feat(kb): gen_pages.py --check — skan wygenerowanego HTML pod katem wyciekow

Tryb --check nie generuje niczego: skanuje build/kb-site/**/*.html linia po
linii i przerywa z exit 1 na pierwszym zestawie trafien. Wzorce:

  ip-rfc1918    192.168./10./172.16-31.
  ip-tailscale  100.64-127.
  ip-public-v4  reszta poprawnych adresow v4 (loopback, link-local, multicast,
                broadcast i pule dokumentacyjne RFC 5737 sa neutralne)
  ip-v6         adresy z "::" albo >=4 grupami (3 grupy to zwykle godzina)
  port          :NNNN w zakresie 1024-65535
  path-host     /home/... i /opt/...
  token-hex     ciagi hex >=32 znakow
  token-b64     ciagi base64-podobne >=40 znakow mieszajace cyfry i litery
                (sciezki absolutne odsiane — raportuje je path-host)

Raport: plik:linia [wzorzec] trafienie, na koncu licznik per wzorzec.

scripts/kb/check_whitelist.txt — swiadome wyjatki, na start PUSTY (same
komentarze z opisem formatu). Wpis to `<fragment>` albo
`<sciezka strony>|<fragment>`; fragment dopasowuje sie jako podciag trafienia,
wiec jeden wpis `/opt/homelab` wycisza wszystkie warianty.

Uruchomione lokalnie na 11 wygenerowanych stronach: 22 trafienia, wszystkie
path-host (/opt/homelab/... w dokumentach public), zero IP, portow i tokenow.
Whitelist zostaje pusta — decyzja co z tymi sciezkami zrobic nalezy do
operatora (patrz kb/runbooks/kb-site-deploy.md).
This commit is contained in:
oskar 2026-08-04 17:57:37 +02:00
parent 65093815a8
commit 9591c8b688
2 changed files with 214 additions and 0 deletions

View file

@ -0,0 +1,23 @@
# Świadome wyjątki dla `python3 scripts/kb/gen_pages.py --check`.
#
# --check skanuje WYGENEROWANY HTML (build/kb-site/) i przerywa z exit 1, gdy
# znajdzie adres IP, port, ścieżkę hosta (/home/, /opt/) albo coś, co wygląda
# na token. Ten plik wycisza pojedyncze, świadomie zaakceptowane trafienia.
#
# Format — jeden wpis na linię, `#` zaczyna komentarz:
#
# <fragment> wycisza KAŻDE trafienie zawierające <fragment>
# <ścieżka strony>|<fragment> to samo, ale tylko w tej jednej stronie
#
# Ścieżka strony jest względna wobec katalogu wyjściowego, np.
# `subsystems/observer.html`. Fragment dopasowuje się jako podciąg trafienia,
# więc wpis `/opt/homelab` wycisza wszystkie warianty `/opt/homelab/...`.
#
# Przykłady (zakomentowane — plik startuje PUSTY, każdy wyjątek ma być decyzją):
#
# /opt/homelab # umowna ścieżka runtime, nie sekret
# subsystems/observer.html|:8080 # port w przykładzie z dokumentacji
#
# Zasada: najpierw popraw źródło w kb/ (dokument `visibility: public` nie
# powinien zawierać adresów ani ścieżek konkretnego hosta). Whitelist jest
# ostatecznością dla rzeczy, które naprawdę mają zostać na stronie.

View file

@ -25,6 +25,10 @@ Wyjście: build/kb-site/
Stopka każdej strony: data generacji + krótki hash commita (`git rev-parse --short HEAD`).
Tryb `--check` nie generuje niczego skanuje JUŻ WYGENEROWANY katalog wyjściowy
w poszukiwaniu wycieków (adresy IP, porty, ścieżki hosta, tokeny). Świadome
wyjątki trzymamy w scripts/kb/check_whitelist.txt. Trafienie = exit 1.
Tylko biblioteka standardowa (jak scripts/npm/npm_api.py) skrypt uruchamiany
doraźnie z SATURN/SOLARIA, bez własnego obrazu i bez `pip install`. Parser
frontmattera jest współdzielony z check_okf.py, żeby obie ścieżki widziały
@ -32,6 +36,7 @@ frontmatter dokładnie tak samo.
Uruchomienie:
python3 scripts/kb/gen_pages.py [--base-url URL] [--out KATALOG]
python3 scripts/kb/gen_pages.py --check [--out KATALOG] [--whitelist PLIK]
"""
from __future__ import annotations
@ -56,6 +61,7 @@ REPO_ROOT = Path(__file__).resolve().parents[2]
KB_DIR = REPO_ROOT / "kb"
DEFAULT_OUT = REPO_ROOT / "build" / "kb-site"
DEFAULT_BASE_URL = "https://kb.okit.pl"
DEFAULT_WHITELIST = Path(__file__).resolve().parent / "check_whitelist.txt"
SITE_NAME = "homelab-codex — knowledge base"
SITE_LEAD = (
@ -626,9 +632,180 @@ def build(out_dir: Path, base_url: str) -> list[Doc]:
print(f" {doc.page} ← kb/{doc.id}.md")
print()
print(f" index.html ({len(public)} pozycji)")
print()
print("Kontrola wycieków: python3 scripts/kb/gen_pages.py --check")
return public
# --- tryb --check: skan wygenerowanego HTML ---------------------------
_OCTET = r"(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)"
_IPV4_RE = re.compile(rf"(?<![\w.]){_OCTET}(?:\.{_OCTET}){{3}}(?![\w.])")
_PRIVATE_IPV4_RE = re.compile(
r"^(?:192\.168\.|10\.|172\.(?:1[6-9]|2\d|3[01])\.)"
)
_TAILSCALE_IPV4_RE = re.compile(
r"^100\.(?:6[4-9]|[7-9]\d|1[01]\d|12[0-7])\."
)
# Adresy, które nie są wyciekiem: loopback, link-local, multicast/broadcast,
# „to sieć", maski i pule dokumentacyjne z RFC 5737.
_NEUTRAL_IPV4_RE = re.compile(
r"^(?:127\.|169\.254\.|0\.|22[4-9]\.|2[3-5]\d\.|255\.|"
r"192\.0\.2\.|198\.51\.100\.|203\.0\.113\.)"
)
# IPv6: wymagamy albo "::", albo >=4 grup — trzy grupy to zwykle godzina 10:15:30.
_IPV6_RE = re.compile(
r"(?<![\w:])(?:"
r"(?:[0-9a-fA-F]{1,4}:){3,7}[0-9a-fA-F]{1,4}"
r"|(?:[0-9a-fA-F]{1,4}:){1,7}:(?:[0-9a-fA-F]{1,4}(?::[0-9a-fA-F]{1,4}){0,6})?"
r"|::(?:[0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4}"
r")(?![\w:])"
)
_NEUTRAL_IPV6 = {"::", "::1"}
# Port leci zaraz po hoście albo adresie (`localhost:8123`, `192.168.31.5:8240`),
# więc przed dwukropkiem stoi znak słowa — żadnego lookbehindu na \w. Odsiewamy
# tylko `::` z adresów IPv6; te i tak raportuje wzorzec `ip-v6`.
_PORT_RE = re.compile(r"(?<!:):(\d{4,5})(?!\d)")
_PATH_RE = re.compile(r"/(?:home|opt)/[\w.\-/]*")
_HEX_TOKEN_RE = re.compile(r"(?<![\w])[0-9a-fA-F]{32,}(?![\w])")
_B64_TOKEN_RE = re.compile(r"(?<![\w+/=-])[A-Za-z0-9+/_-]{40,}={0,2}(?![\w+/=-])")
def _ipv4_hits(text: str) -> list[tuple[str, str]]:
hits = []
for match in _IPV4_RE.finditer(text):
value = match.group(0)
if _PRIVATE_IPV4_RE.match(value):
hits.append(("ip-rfc1918", value))
elif _TAILSCALE_IPV4_RE.match(value):
hits.append(("ip-tailscale", value))
elif _NEUTRAL_IPV4_RE.match(value):
continue
else:
hits.append(("ip-public-v4", value))
return hits
def _ipv6_hits(text: str) -> list[tuple[str, str]]:
hits = []
for match in _IPV6_RE.finditer(text):
value = match.group(0)
if value in _NEUTRAL_IPV6:
continue
hits.append(("ip-v6", value))
return hits
def _port_hits(text: str) -> list[tuple[str, str]]:
hits = []
for match in _PORT_RE.finditer(text):
port = int(match.group(1))
if 1024 <= port <= 65535:
hits.append(("port", match.group(0)))
return hits
def _token_hits(text: str) -> list[tuple[str, str]]:
hits = []
for match in _HEX_TOKEN_RE.finditer(text):
hits.append(("token-hex", match.group(0)))
for match in _B64_TOKEN_RE.finditer(text):
value = match.group(0)
# Ścieżka absolutna nie jest tokenem — alfabet base64 zawiera "/", więc
# długie `/opt/homelab/events/...` łapało się tu jako fałszywy alarm.
# Ścieżki hosta i tak raportuje osobny wzorzec `path-host`.
if value.startswith("/"):
continue
# Bez cyfry i bez litery to nie jest sekret, tylko długie słowo albo
# ciąg myślników — sekrety mieszają jedno z drugim.
if any(c.isdigit() for c in value) and any(c.isalpha() for c in value):
hits.append(("token-b64", value))
return hits
def scan_line(text: str) -> list[tuple[str, str]]:
hits = _ipv4_hits(text) + _ipv6_hits(text) + _port_hits(text)
hits += [("path-host", m.group(0)) for m in _PATH_RE.finditer(text)]
hits += _token_hits(text)
return hits
def load_whitelist(path: Path) -> list[tuple[str | None, str]]:
"""Wpisy: `<fragment>` albo `<ścieżka strony>|<fragment>`.
Fragment jest dopasowywany jako podciąg trafienia, więc jeden wpis
`/opt/homelab` wycisza wszystkie warianty `/opt/homelab/...`.
"""
if not path.is_file():
return []
entries: list[tuple[str | None, str]] = []
for raw in path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
if "|" in line:
scope, _, fragment = line.partition("|")
entries.append((scope.strip(), fragment.strip()))
else:
entries.append((None, line))
return entries
def whitelisted(entries: list[tuple[str | None, str]], rel: str, value: str) -> bool:
return any(
fragment and fragment in value and (scope is None or scope == rel)
for scope, fragment in entries
)
def check(out_dir: Path, whitelist_path: Path) -> int:
if not out_dir.is_dir():
print(f"BRAK katalogu {out_dir} — najpierw wygeneruj strony (bez --check).")
return 1
entries = load_whitelist(whitelist_path)
files = sorted(out_dir.rglob("*.html"))
print(f"Skan: {out_dir}")
print(f"Whitelist: {whitelist_path} ({len(entries)} wpis(ów))")
print(f"Plików: {len(files)}")
print()
findings: list[tuple[str, int, str, str]] = []
suppressed = 0
for path in files:
rel = path.relative_to(out_dir).as_posix()
for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
for pattern, value in scan_line(line):
if whitelisted(entries, rel, value):
suppressed += 1
continue
findings.append((rel, lineno, pattern, value))
if not findings:
print(f"CZYSTO — 0 trafień ({suppressed} wyciszonych whitelistą).")
return 0
by_pattern: dict[str, int] = {}
for rel, lineno, pattern, value in findings:
by_pattern[pattern] = by_pattern.get(pattern, 0) + 1
print(f"{rel}:{lineno} [{pattern}] {value}")
print()
print(f"WYCIEK — {len(findings)} trafień ({suppressed} wyciszonych whitelistą):")
for pattern, count in sorted(by_pattern.items(), key=lambda kv: -kv[1]):
print(f" {pattern}: {count}")
print()
print(
"Napraw źródło w kb/ (usuń adres/ścieżkę/token z dokumentu public) albo "
f"dopisz świadomy wyjątek do {whitelist_path.relative_to(REPO_ROOT)}."
)
return 1
# --- CLI --------------------------------------------------------------
@ -647,8 +824,22 @@ def main() -> int:
default=DEFAULT_OUT,
help="katalog wyjściowy (domyślnie build/kb-site)",
)
parser.add_argument(
"--check",
action="store_true",
help="nie generuj — przeskanuj wygenerowany HTML pod kątem wycieków",
)
parser.add_argument(
"--whitelist",
type=Path,
default=DEFAULT_WHITELIST,
help="plik świadomych wyjątków dla --check",
)
args = parser.parse_args()
if args.check:
return check(args.out.resolve(), args.whitelist)
build(args.out.resolve(), args.base_url)
return 0