homelab-codex-ws/scripts/kb/gen_pages.py
oskar f5c6f3b651 fix(kb-site): token bramki w kazdym linku wewnetrznym generatora
Wystawka KB stoi za bramka NPM na ?key=<token>. Linki generowane przez
gen_pages.py (index -> dokument, dokument <-> dokument, powrot do indexu)
tokenu nie nosily, wiec kazde klikniecie ze strony wpadalo w 403 —
dzialal wylacznie recznie sklejony URL do indexu.

Token podaje sie przy generacji: --access-token TOKEN albo zmienna
ACCESS_TOKEN. Nie ma go w repo w zadnej formie — to parametr runtime,
nie stala w kodzie. Bez tokenu generacja dziala jak dotad, z golymi
linkami (tryb lokalnego podgladu); wyjscie jest wtedy bajt w bajt takie
samo jak przed zmiana.

with_token() doklada ?key=... przed ewentualna kotwica i uzywa & gdy URL
ma juz wlasne query params (dzis nie ma — obrona na zapas). Token jedzie
przez urllib.parse.quote. Kotwice (#sekcja) i linki zewnetrzne zostaja
nietkniete. Wartosc nigdy nie leci na stdout — build() loguje tylko
TAK/NIE, bo logi z generacji bywaja wklejane.

--check: prawdziwy token (32+ hex) wygladal dla skanera dokladnie jak
wyciek `token-hex`. scan_line() wycina teraz wartosc `key=` WYLACZNIE
wewnatrz atrybutu href — ten sam token w tresci strony, po innym
parametrze niz key, albo poza href nadal jest raportowany jako wyciek.

Test: 11 stron public + index; z --access-token TEST123 wszystkie 12
linkow spisu, link doc->doc (agent-operating-procedures ->
action-approval-model) i kazdy powrot "All documents" niosa ?key=TEST123;
canonical swiadomie bez tokenu (metadana, nie nawigacja). Bez tokenu
diff vs HEAD pusty poza znacznikiem czasu. gen_pages --check exit 0 dla
generacji bez tokenu, z TEST123 i z realistycznym tokenem 64-hex;
check_okf.py exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 20:29:27 +02:00

933 lines
34 KiB
Python

#!/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`
<katalog>/<nazwa>.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=<token>`, 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 "
"<code>visibility: public</code> 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=<token>` 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"(?<!\*)\*(?!\s)([^*]+?)(?<!\s)\*(?!\*)")
_ITALIC_UNDER_RE = re.compile(r"(?<![\w\\])_(?!\s)([^_]+?)(?<!\s)_(?![\w])")
_HEADING_RE = re.compile(r"^(#{1,6})\s+(.*)$")
_HR_RE = re.compile(r"^(-{3,}|\*{3,}|_{3,})$")
_LIST_RE = re.compile(r"^\s*([-*+]|\d+[.)])\s+(.*)$")
_TASK_RE = re.compile(r"^\[([ xX])\]\s+(.*)$")
_TABLE_SEP_RE = re.compile(r"^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$")
def _linkable(href: str) -> 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'<a href="{html.escape(href, quote=True)}"{attrs}>{label}</a>'
text = _LINK_RE.sub(link, text)
text = _BOLD_RE.sub(r"<strong>\1</strong>", text)
text = _ITALIC_STAR_RE.sub(r"<em>\1</em>", text)
text = _ITALIC_UNDER_RE.sub(r"<em>\1</em>", text)
for i, code in enumerate(codes):
text = text.replace(f"\x00c{i}\x00", f"<code>{html.escape(code, quote=False)}</code>")
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"<pre><code>{html.escape(chr(10).join(code), quote=False)}</code></pre>")
continue
heading = _HEADING_RE.match(stripped)
if heading:
level = len(heading.group(1))
out.append(f"<h{level}>{render_inline(heading.group(2))}</h{level}>")
i += 1
continue
if _HR_RE.match(stripped):
out.append("<hr>")
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'<th style="text-align:{align[n] if n < len(align) else "left"}">'
f"{render_inline(c)}</th>"
for n, c in enumerate(header)
)
body = "".join(
"<tr>"
+ "".join(
f'<td style="text-align:{align[n] if n < len(align) else "left"}">'
f"{render_inline(c)}</td>"
for n, c in enumerate(row)
)
+ "</tr>"
for row in rows
)
out.append(
f'<div class="table-wrap"><table><thead><tr>{head}</tr></thead>'
f"<tbody>{body}</tbody></table></div>"
)
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"<blockquote>{render_blocks(chr(10).join(quoted))}</blockquote>")
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'<li class="task"><input type="checkbox" disabled{checked}> '
f"{render_inline(task.group(2))}</li>"
)
else:
rendered.append(f"<li>{render_inline(raw)}</li>")
tag = "ol" if ordered else "ul"
out.append(f"<{tag}>{''.join(rendered)}</{tag}>")
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"<p>{render_inline(' '.join(paragraph))}</p>")
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'<a href="{back}">← All documents</a>'
nav_html = f"<nav>{nav}</nav>\n" if nav else ""
return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>{html.escape(title)}{html.escape(SITE_NAME)}</title>
<link rel="canonical" href="{html.escape(canonical, quote=True)}">
<style>{CSS}</style>
</head>
<body>
{nav_html}{content}
<footer>{stamp}</footer>
</body>
</html>
"""
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'<span class="chip">{html.escape(doc.doc_type)}</span>']
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 '<p class="meta">' + " · ".join(parts) + "</p>"
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 <h1> strony zamiast dokładać drugi
# tytuł nad nim (dokument może zaczynać się od `#` albo od `###`).
match = re.match(r"\s*<h[1-6]>(.*?)</h[1-6]>", rendered, re.S)
if match:
headline = match.group(1)
rendered = rendered[match.end() :]
else:
headline = html.escape(doc.title)
content = f"<h1>{headline}</h1>\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'<li><a href="{html.escape(with_token(doc.page), quote=True)}">'
f"{html.escape(doc.title)}</a>"
f'<span class="chip">{html.escape(doc.doc_type)}</span>'
+ (f"<p>{html.escape(lead)}</p>" if lead else "")
+ "</li>"
)
heading = labels.get(doc_type, doc_type)
sections.append(
f"<h2>{html.escape(heading)} <span class=\"chip\">{len(items)}</span></h2>\n"
f'<ul class="cards">{"".join(cards)}</ul>'
)
content = (
f"<h1>{html.escape(SITE_NAME)}</h1>\n"
f'<p class="lead">{SITE_LEAD}</p>\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 <code>{html.escape(git_commit())}</code> "
f"· <code>scripts/kb/gen_pages.py</code> — 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"(?<![\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.\-/]*")
_HREF_TOKEN_RE = re.compile(
rf'(href="[^"]*[?&]{re.escape(ACCESS_TOKEN_PARAM)}=)[^"&#]*'
)
_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]]:
# 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: `<fragment>` albo `<ścieżka strony>|<fragment>`.
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())