homelab-codex-ws/scripts/kb/gen_pages.py

933 lines
34 KiB
Python
Raw Permalink Normal View History

#!/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`).
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
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:
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
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
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
import os
import posixpath
import re
import shutil
import subprocess
import sys
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
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"),
]
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
# --- 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"'
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
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:
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
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(
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
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"
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
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}")
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
# 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.\-/]*")
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
_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]]:
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
# 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)",
)
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
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)
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
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())