#!/usr/bin/env python3 """Generator publicznej warstwy bazy wiedzy z dokumentów OKF v0.1. Wzorzec: ~/narty-2027/saalbach-kb/gen_pages.py — renderer markdown na samej bibliotece standardowej plus prosty, czytelny szablon HTML. Tutaj dochodzi jedna reguła nadrzędna nad wszystkim innym: PUBLIKUJEMY WYŁĄCZNIE DOKUMENTY Z `visibility: public`. Kwalifikacja jest **fail-closed** — dokument trafia na stronę tylko wtedy, gdy da się jednoznacznie stwierdzić, że jest publiczny. Brak frontmattera, niepoprawny YAML, brak pola `visibility`, pusta albo nierozpoznana wartość = PRIVATE. Każdy inny wariant (np. „domyślnie public, gdy nic nie napisano") oznaczałby, że przeoczony frontmatter wypycha wewnętrzny dokument do internetu. Ta sama reguła obowiązuje linki: odnośnik do dokumentu, który nie został opublikowany, NIE jest renderowany jako link — zostaje sam tekst etykiety z dopiskiem `[private]`. Dzięki temu strona publiczna nigdy nie wskazuje ścieżek prywatnych dokumentów ani nie generuje martwych 404. Wejście: kb/**/*.md Wyjście: build/kb-site/ index.html spis stron pogrupowany per `type` /.html strona na dokument (lustro drzewa kb/) Stopka każdej strony: data generacji + krótki hash commita (`git rev-parse --short HEAD`). Wystawka stoi za bramką NPM na `?key=`, więc każdy link wewnętrzny musi nieść ten token — inaczej kliknięcie ze spisu wpada w 403 i działa wyłącznie ręcznie sklejony URL. Token podaje się przy generacji (`--access-token` albo zmienna `ACCESS_TOKEN`) i NIGDY nie trafia do repo: to parametr runtime, nie stała w kodzie. Bez tokenu strony generują się jak dotąd, z gołymi linkami — to jest tryb lokalnego podglądu, nie błąd. 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 frontmatter dokładnie tak samo. Uruchomienie: python3 scripts/kb/gen_pages.py [--base-url URL] [--out KATALOG] [--access-token TOKEN] python3 scripts/kb/gen_pages.py --check [--out KATALOG] [--whitelist PLIK] """ from __future__ import annotations import argparse import html import os import posixpath import re import shutil import subprocess import sys import urllib.parse from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path # Ten sam parser frontmattera co walidator OKF — check_okf.py leży w tym samym # katalogu, więc import działa niezależnie od katalogu uruchomienia. sys.path.insert(0, str(Path(__file__).resolve().parent)) from check_okf import parse_yaml, split_frontmatter # noqa: E402 REPO_ROOT = Path(__file__).resolve().parents[2] KB_DIR = REPO_ROOT / "kb" DEFAULT_OUT = REPO_ROOT / "build" / "kb-site" DEFAULT_BASE_URL = "https://kb-e2a24af3.okit.pl" DEFAULT_WHITELIST = Path(__file__).resolve().parent / "check_whitelist.txt" SITE_NAME = "homelab-codex — knowledge base" SITE_LEAD = ( "Public slice of the homelab knowledge base. Only documents marked " "visibility: public are published here; everything else stays " "in the private repository." ) PUBLIC = "public" # Nagłówki grup na stronie spisu. Kolejność listy = kolejność sekcji; typy spoza # listy trafiają na koniec, alfabetycznie, pod własną nazwą. TYPE_ORDER = [ ("subsystem", "Subsystems"), ("service", "Services"), ("node", "Nodes"), ("runbook", "Runbooks"), ("decision", "Decisions"), ("incident", "Incidents"), ("phase", "Phases"), ("audit", "Audits"), ("session-log", "Session logs"), ] # --- token dostępu ---------------------------------------------------- ACCESS_TOKEN_ENV = "ACCESS_TOKEN" ACCESS_TOKEN_PARAM = "key" # Token bramki NPM. Ustawiany raz na starcie build(), czytany przez with_token(). # Modułowy, bo wewnętrzne href-y powstają w trzech miejscach na dnie rekurencji # renderera (render_inline → render_blocks → render_blocks …); przewlekanie go # parametrem przez cały renderer zaśmieciłoby każdą sygnaturę po drodze. # Wartość NIGDY nie jest zapisywana w repo — pochodzi z --access-token/ACCESS_TOKEN. _ACCESS_TOKEN = "" def set_access_token(token: str) -> None: global _ACCESS_TOKEN _ACCESS_TOKEN = token or "" def with_token(href: str) -> str: """Dopisuje `?key=` do wewnętrznego odnośnika. Bez ustawionego tokenu zwraca href bez zmian — generacja lokalna do podglądu ma dawać dokładnie to co dotąd. Fragment (`#sekcja`) zostaje na końcu, bo query string idzie PRZED kotwicą. Gdyby URL kiedyś niósł własne parametry, doklejamy `&` zamiast `?` — dziś nie niesie, ale to jeden warunek. """ if not _ACCESS_TOKEN: return href base, hash_sep, fragment = href.partition("#") if not base: # Czysta kotwica w obrębie strony — nie ma czego bramkować. return href sep = "&" if "?" in base else "?" token = urllib.parse.quote(_ACCESS_TOKEN, safe="") return f"{base}{sep}{ACCESS_TOKEN_PARAM}={token}{hash_sep}{fragment}" # --- model ------------------------------------------------------------ @dataclass class Doc: id: str # ścieżka względem kb/ bez rozszerzenia, np. "subsystems/observer" path: Path frontmatter: dict body: str public: bool @property def page(self) -> str: """Ścieżka strony względem katalogu wyjściowego.""" return f"{self.id}.html" @property def doc_type(self) -> str: return str(self.frontmatter.get("type") or "").strip() or "?" @property def title(self) -> str: heading = first_heading(self.body) return heading or self.id.rsplit("/", 1)[-1].replace("-", " ") # Nie każdy dokument zaczyna się od `#` — np. kb/subsystems/action-approval-model.md # otwiera się `###`. Tytułem strony jest pierwszy nagłówek DOWOLNEGO poziomu. _ANY_HEADING_RE = re.compile(r"^#{1,6}\s+(.+)$", re.M) def first_heading(body: str) -> str | None: match = _ANY_HEADING_RE.search(body) return match.group(1).strip() if match else None def is_public(frontmatter: dict | None) -> bool: """Fail-closed: publiczny jest wyłącznie dokument z jawnym `visibility: public`.""" if not isinstance(frontmatter, dict): return False return str(frontmatter.get("visibility", "")).strip() == PUBLIC def load_docs() -> list[Doc]: docs: list[Doc] = [] for path in sorted(KB_DIR.rglob("*.md")): if ".git" in path.parts: continue text = path.read_text(encoding="utf-8") frontmatter: dict = {} body = text try: block, rest = split_frontmatter(text) if block is not None: # Niepoprawny YAML => frontmatter nieznany => dokument prywatny. frontmatter = parse_yaml(block) body = rest except ValueError: frontmatter = {} body = text docs.append( Doc( id=path.relative_to(KB_DIR).with_suffix("").as_posix(), path=path, frontmatter=frontmatter, body=body.lstrip("\n"), public=is_public(frontmatter), ) ) return docs # --- linki ------------------------------------------------------------ # Etykieta linku bywa złamana na dwie linie (np. kb/decisions/ai-cluster-legacy.md), # więc klasa [^\]] musi łapać też znak nowej linii — domyślnie łapie. _MD_LINK_RE = re.compile(r"\[([^\]]*)\]\(([^)\s]+)((?:#[^)\s]*)?)\)") _EXTERNAL_PREFIXES = ("http://", "https://", "mailto:", "#") def rewrite_links(doc: Doc, published: dict[str, str]) -> str: """Linki wewnątrz-repowe → strony; wszystko nieopublikowane → zwykły tekst. `published` mapuje id dokumentu (ścieżka względem kb/ bez rozszerzenia) na ścieżkę wygenerowanej strony. Cel spoza tej mapy jest z definicji niepubliczny (private albo w ogóle nie jest dokumentem KB) i traci link. """ here = posixpath.dirname(doc.page) def repl(match: re.Match) -> str: label, target, fragment = match.group(1), match.group(2), match.group(3) if target.startswith(_EXTERNAL_PREFIXES): return match.group(0) if not target.endswith(".md"): # Odnośnik do pliku repo (compose, yaml, skrypt) — na stronie # publicznej nie ma czego pokazać, więc zostaje sama etykieta. return label if target.startswith("/"): rel = target.lstrip("/") rel = rel[3:] if rel.startswith("kb/") else rel else: rel = posixpath.normpath( posixpath.join(posixpath.dirname(doc.id), target) ) doc_id = rel[:-3] page = published.get(doc_id) if page is None: return f"{label} [private]" return f"[{label}]({posixpath.relpath(page, here or '.')}{fragment})" return _MD_LINK_RE.sub(repl, doc.body) # --- renderer markdown (biblioteka standardowa) ------------------------ _CODE_RE = re.compile(r"`([^`]+)`") _LINK_RE = re.compile(r"\[([^\]]*)\]\(([^)\s]+)\)") _BOLD_RE = re.compile(r"\*\*(.+?)\*\*", re.S) _ITALIC_STAR_RE = re.compile(r"(? bool: """Druga bramka fail-closed: linkujemy tylko adresy zewnętrzne, kotwice i wygenerowane strony. Cokolwiek innego przeciekło przez rewrite_links() (ścieżka repo, .md bez odpowiednika) zostaje tekstem, nie martwym linkiem.""" return href.startswith(_EXTERNAL_PREFIXES) or href.split("#", 1)[0].endswith(".html") def render_inline(text: str) -> str: codes: list[str] = [] def stash(match: re.Match) -> str: codes.append(match.group(1)) return f"\x00c{len(codes) - 1}\x00" text = _CODE_RE.sub(stash, text) text = html.escape(text, quote=False) def link(match: re.Match) -> str: label, href = match.group(1), match.group(2) if not _linkable(href): return label attrs = "" if href.startswith(("http://", "https://", "mailto:")): attrs = ' target="_blank" rel="noopener" class="ext"' else: # Wewnętrzny (strona .html albo kotwica) — musi nieść token bramki. href = with_token(href) return f'{label}' text = _LINK_RE.sub(link, text) text = _BOLD_RE.sub(r"\1", text) text = _ITALIC_STAR_RE.sub(r"\1", text) text = _ITALIC_UNDER_RE.sub(r"\1", text) for i, code in enumerate(codes): text = text.replace(f"\x00c{i}\x00", f"{html.escape(code, quote=False)}") return text def _alignments(sep: str) -> list[str]: out = [] for cell in _cells(sep): left, right = cell.startswith(":"), cell.endswith(":") out.append("center" if left and right else "right" if right else "left") return out def _cells(row: str) -> list[str]: return [c.strip() for c in row.strip().strip("|").split("|")] def _starts_block(line: str) -> bool: s = line.strip() return ( not s or bool(_HEADING_RE.match(s)) or bool(_HR_RE.match(s)) or s.startswith(("```", ">")) or bool(_LIST_RE.match(line)) ) def render_blocks(text: str) -> str: lines = text.split("\n") out: list[str] = [] i = 0 while i < len(lines): line = lines[i] stripped = line.strip() if not stripped: i += 1 continue if stripped.startswith("```"): i += 1 code: list[str] = [] while i < len(lines) and not lines[i].strip().startswith("```"): code.append(lines[i]) i += 1 i += 1 out.append(f"
{html.escape(chr(10).join(code), quote=False)}
") continue heading = _HEADING_RE.match(stripped) if heading: level = len(heading.group(1)) out.append(f"{render_inline(heading.group(2))}") i += 1 continue if _HR_RE.match(stripped): out.append("
") i += 1 continue if "|" in stripped and i + 1 < len(lines) and _TABLE_SEP_RE.match(lines[i + 1]): header = _cells(stripped) align = _alignments(lines[i + 1]) i += 2 rows = [] while i < len(lines) and "|" in lines[i] and lines[i].strip(): rows.append(_cells(lines[i])) i += 1 head = "".join( f'' f"{render_inline(c)}" for n, c in enumerate(header) ) body = "".join( "" + "".join( f'' f"{render_inline(c)}" for n, c in enumerate(row) ) + "" for row in rows ) out.append( f'
{head}' f"{body}
" ) continue if stripped.startswith(">"): quoted = [] while i < len(lines) and lines[i].strip().startswith(">"): quoted.append(lines[i].strip()[1:].lstrip()) i += 1 out.append(f"
{render_blocks(chr(10).join(quoted))}
") continue item = _LIST_RE.match(line) if item: ordered = item.group(1)[0].isdigit() items: list[str] = [] while i < len(lines): current = lines[i] match = _LIST_RE.match(current) if match: items.append(match.group(2)) i += 1 elif current.startswith((" ", "\t")) and current.strip() and items: items[-1] += " " + current.strip() i += 1 else: break rendered = [] for raw in items: task = _TASK_RE.match(raw) if task: checked = " checked" if task.group(1).lower() == "x" else "" rendered.append( f'
  • ' f"{render_inline(task.group(2))}
  • " ) else: rendered.append(f"
  • {render_inline(raw)}
  • ") tag = "ol" if ordered else "ul" out.append(f"<{tag}>{''.join(rendered)}") continue paragraph = [stripped] i += 1 while i < len(lines) and not _starts_block(lines[i]): if "|" in lines[i] and i + 1 < len(lines) and _TABLE_SEP_RE.match(lines[i + 1]): break paragraph.append(lines[i].strip()) i += 1 out.append(f"

    {render_inline(' '.join(paragraph))}

    ") return "\n".join(out) # --- szablon ---------------------------------------------------------- CSS = """ :root { color-scheme: light dark; --fg:#0f172a; --muted:#64748b; --bg:#ffffff; --line:#e2e8f0; --accent:#0d9488; --chip:#f1f5f9; --quote:#f8fafc; } @media (prefers-color-scheme: dark) { :root { --fg:#e2e8f0; --muted:#94a3b8; --bg:#0f172a; --line:#334155; --accent:#2dd4bf; --chip:#1e293b; --quote:#1e293b; } } * { box-sizing: border-box; } body { margin: 0 auto; padding: 1.25rem 1rem 4rem; max-width: 46rem; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; font-size: 17px; line-height: 1.55; color: var(--fg); background: var(--bg); -webkit-text-size-adjust: 100%; } nav { font-size: .85rem; margin-bottom: 1.25rem; } nav a { color: var(--muted); } h1 { font-size: 1.55rem; line-height: 1.25; margin: 0 0 .35rem; } h2 { font-size: 1.2rem; margin: 2rem 0 .5rem; padding-top: .5rem; border-top: 1px solid var(--line); } h3 { font-size: 1.02rem; margin: 1.4rem 0 .4rem; } h4, h5, h6 { font-size: .95rem; margin: 1.1rem 0 .3rem; } p, ul, ol { margin: .6rem 0; } ul, ol { padding-left: 1.3rem; } li { margin: .25rem 0; } li.task { list-style: none; margin-left: -1.3rem; } li.task input { margin-right: .45rem; } a { color: var(--accent); } a.ext::after { content: " \\2197"; font-size: .8em; color: var(--muted); } code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .88em; background: var(--chip); padding: .1em .35em; border-radius: 4px; } pre { background: var(--chip); padding: .75rem; border-radius: 6px; overflow-x: auto; } pre code { background: none; padding: 0; } blockquote { margin: .9rem 0; padding: .6rem .9rem; background: var(--quote); border-left: 3px solid var(--accent); border-radius: 0 6px 6px 0; } blockquote p { margin: .2rem 0; } hr { border: 0; border-top: 1px solid var(--line); margin: 1.5rem 0; } .table-wrap { overflow-x: auto; margin: .8rem 0; -webkit-overflow-scrolling: touch; } table { border-collapse: collapse; width: 100%; font-size: .92rem; } th, td { border: 1px solid var(--line); padding: .4rem .55rem; vertical-align: top; } th { background: var(--chip); font-weight: 600; } .chip { display: inline-block; background: var(--chip); color: var(--muted); font-size: .72rem; text-transform: uppercase; letter-spacing: .04em; padding: .15rem .5rem; border-radius: 999px; } .lead { color: var(--muted); margin: .35rem 0 1.25rem; } .meta { color: var(--muted); font-size: .82rem; margin: .2rem 0 1.5rem; } .cards { list-style: none; padding: 0; margin: 0; } .cards li { border: 1px solid var(--line); border-radius: 8px; padding: .8rem .9rem; margin: .6rem 0; } .cards a { font-weight: 600; text-decoration: none; } .cards p { margin: .3rem 0 0; font-size: .9rem; color: var(--muted); } .cards .chip { margin-left: .35rem; } footer { margin-top: 3rem; padding-top: 1rem; border-top: 1px solid var(--line); font-size: .78rem; color: var(--muted); } """ def page_shell( title: str, depth: int, content: str, *, canonical: str, stamp: str, nav: str | None = None, ) -> str: up = "../" * depth if nav is None: back = html.escape(with_token(f"{up}index.html"), quote=True) nav = f'← All documents' nav_html = f"\n" if nav else "" return f""" {html.escape(title)} — {html.escape(SITE_NAME)} {nav_html}{content}
    {stamp}
    """ def meta_line(doc: Doc) -> str: """Krótki pasek metadanych. Świadomie BEZ pola `links` — wpisy potrafią wskazywać dokumenty prywatne, a sama ścieżka to już wyciek nazwy.""" parts = [f'{html.escape(doc.doc_type)}'] status = str(doc.frontmatter.get("status") or "").strip() updated = str(doc.frontmatter.get("updated") or "").strip() if status: parts.append(html.escape(status)) if updated: parts.append("updated " + html.escape(updated)) return '

    ' + " · ".join(parts) + "

    " def render_page(doc: Doc, published: dict[str, str], base_url: str, stamp: str) -> str: rendered = render_blocks(rewrite_links(doc, published)) # Pierwszy nagłówek treści awansujemy na

    strony zamiast dokładać drugi # tytuł nad nim (dokument może zaczynać się od `#` albo od `###`). match = re.match(r"\s*(.*?)", rendered, re.S) if match: headline = match.group(1) rendered = rendered[match.end() :] else: headline = html.escape(doc.title) content = f"

    {headline}

    \n{meta_line(doc)}\n{rendered}" canonical = f"{base_url.rstrip('/')}/{doc.page}" return page_shell(doc.title, doc.id.count("/"), content, canonical=canonical, stamp=stamp) _INLINE_MARKUP_RE = re.compile(r"[*`_]|\[private\]") def summary(doc: Doc, limit: int = 190) -> str: """Pierwszy akapit treści (bez nagłówka) jako zajawka na spisie.""" body = _ANY_HEADING_RE.sub("", doc.body, count=1) paragraph = "" for chunk in body.split("\n\n"): text = " ".join(line.strip() for line in chunk.strip().splitlines()) if not text or text.startswith(("#", "|", ">", "```", "-", "*")): continue paragraph = text break if not paragraph: return "" paragraph = _MD_LINK_RE.sub(r"\1", paragraph) paragraph = _INLINE_MARKUP_RE.sub("", paragraph) if len(paragraph) > limit: paragraph = paragraph[:limit].rsplit(" ", 1)[0] + "…" return paragraph def render_index(docs: list[Doc], base_url: str, stamp: str) -> str: known = [t for t, _ in TYPE_ORDER] labels = dict(TYPE_ORDER) by_type: dict[str, list[Doc]] = {} for doc in docs: by_type.setdefault(doc.doc_type, []).append(doc) order = [t for t in known if t in by_type] order += sorted(t for t in by_type if t not in known) sections = [] for doc_type in order: items = sorted(by_type[doc_type], key=lambda d: d.title.lower()) cards = [] for doc in items: lead = summary(doc) cards.append( f'
  • ' f"{html.escape(doc.title)}" f'{html.escape(doc.doc_type)}' + (f"

    {html.escape(lead)}

    " if lead else "") + "
  • " ) heading = labels.get(doc_type, doc_type) sections.append( f"

    {html.escape(heading)} {len(items)}

    \n" f'
      {"".join(cards)}
    ' ) content = ( f"

    {html.escape(SITE_NAME)}

    \n" f'

    {SITE_LEAD}

    \n' + "\n".join(sections) ) return page_shell( "Index", 0, content, canonical=base_url.rstrip("/") + "/", stamp=stamp, nav="", ) # --- budowa ----------------------------------------------------------- def git_commit() -> str: try: out = subprocess.run( ["git", "rev-parse", "--short", "HEAD"], cwd=REPO_ROOT, capture_output=True, text=True, check=True, ) return out.stdout.strip() or "unknown" except (OSError, subprocess.CalledProcessError): return "unknown" def build(out_dir: Path, base_url: str, access_token: str = "") -> list[Doc]: set_access_token(access_token) docs = load_docs() public = [d for d in docs if d.public] published = {d.id: d.page for d in public} generated = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") stamp = ( f"Generated {generated} · commit {html.escape(git_commit())} " f"· scripts/kb/gen_pages.py — do not edit the HTML, " f"edit the source document and regenerate." ) if out_dir.exists(): shutil.rmtree(out_dir) out_dir.mkdir(parents=True) for doc in public: target = out_dir / doc.page target.parent.mkdir(parents=True, exist_ok=True) target.write_text(render_page(doc, published, base_url, stamp), encoding="utf-8") (out_dir / "index.html").write_text( render_index(public, base_url, stamp), encoding="utf-8" ) print(f"Repo: {REPO_ROOT}") print(f"Źródło: {KB_DIR.relative_to(REPO_ROOT)}/**/*.md") print(f"Wyjście: {out_dir}") print(f"BASE_URL: {base_url}") # Sam token nigdy nie leci na stdout — logi z generacji bywają wklejane. print( f"Token: {'TAK' if _ACCESS_TOKEN else 'NIE'} " f"(linki wewnętrzne {'z' if _ACCESS_TOKEN else 'bez'} ?{ACCESS_TOKEN_PARAM}=…)" ) print( f"Dokumenty: {len(docs)} razem, {len(public)} public, " f"{len(docs) - len(public)} pominiętych (private / brak frontmattera)" ) by_type: dict[str, list[Doc]] = {} for doc in public: by_type.setdefault(doc.doc_type, []).append(doc) print() for doc_type in sorted(by_type): print(f" {doc_type} ({len(by_type[doc_type])}):") for doc in sorted(by_type[doc_type], key=lambda d: d.id): 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]]: # Token bramki w linkach wewnętrznych to nie wyciek, tylko cały sens tych # linków — a wygląda dokładnie jak sekret, na który poluje `token-hex`/ # `token-b64`. Wycinamy WYŁĄCZNIE wartość `key=` wewnątrz atrybutu href; # reszta linii (i każde inne `key=` w treści dokumentu) leci do skanera # normalnie, żeby ta furtka nie zaczęła wyciszać prawdziwych sekretów. text = _HREF_TOKEN_RE.sub(r"\1", text) 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/...`. `#` zaczyna komentarz w dowolnym miejscu linii — wyjątek bez uzasadnienia obok siebie szybko staje się wyjątkiem, którego nikt już nie rozumie. Żaden ze skanowanych wzorców (adresy, porty, ścieżki, tokeny) nie zawiera `#`, więc obcięcie ogona jest bezpieczne. """ if not path.is_file(): return [] entries: list[tuple[str | None, str]] = [] for raw in path.read_text(encoding="utf-8").splitlines(): line = raw.split("#", 1)[0].strip() if not line: 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 -------------------------------------------------------------- def main() -> int: parser = argparse.ArgumentParser( description="Generator publicznej warstwy KB z dokumentów OKF." ) parser.add_argument( "--base-url", default=DEFAULT_BASE_URL, help=f"publiczny adres wystawki (domyślnie {DEFAULT_BASE_URL})", ) parser.add_argument( "--out", type=Path, default=DEFAULT_OUT, help="katalog wyjściowy (domyślnie build/kb-site)", ) parser.add_argument( "--access-token", default=None, help=( f"token bramki NPM dopisywany jako ?{ACCESS_TOKEN_PARAM}=… do każdego " f"linku wewnętrznego; domyślnie ze zmiennej {ACCESS_TOKEN_ENV}. " "Bez tokenu linki zostają gołe (podgląd lokalny)." ), ) 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) token = args.access_token if token is None: token = os.environ.get(ACCESS_TOKEN_ENV, "") build(args.out.resolve(), args.base_url, token.strip()) return 0 if __name__ == "__main__": raise SystemExit(main())