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:
parent
65093815a8
commit
9591c8b688
23
scripts/kb/check_whitelist.txt
Normal file
23
scripts/kb/check_whitelist.txt
Normal 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.
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue