diff --git a/scripts/kb/check_whitelist.txt b/scripts/kb/check_whitelist.txt new file mode 100644 index 0000000..07d6841 --- /dev/null +++ b/scripts/kb/check_whitelist.txt @@ -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: +# +# wycisza KAŻDE trafienie zawierające +# <ścieżka strony>| 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. diff --git a/scripts/kb/gen_pages.py b/scripts/kb/gen_pages.py index 72d1a32..4355950 100644 --- a/scripts/kb/gen_pages.py +++ b/scripts/kb/gen_pages.py @@ -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"(?=4 grup — trzy grupy to zwykle godzina 10:15:30. +_IPV6_RE = re.compile( + r"(? 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: `` albo `<ścieżka strony>|`. + + 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