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>
This commit is contained in:
oskar 2026-08-05 20:29:27 +02:00
parent 04251b5bfb
commit f5c6f3b651

View file

@ -25,6 +25,13 @@ Wyjście: build/kb-site/
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.
@ -35,7 +42,7 @@ 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]
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]
"""
@ -43,11 +50,13 @@ 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
@ -87,6 +96,43 @@ TYPE_ORDER = [
]
# --- 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 ------------------------------------------------------------
@ -242,6 +288,9 @@ def render_inline(text: str) -> str:
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)
@ -457,7 +506,8 @@ def page_shell(
) -> str:
up = "../" * depth
if nav is None:
nav = f'<a href="{up}index.html">← All documents</a>'
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">
@ -546,7 +596,7 @@ def render_index(docs: list[Doc], base_url: str, stamp: str) -> str:
for doc in items:
lead = summary(doc)
cards.append(
f'<li><a href="{html.escape(doc.page, quote=True)}">'
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 "")
@ -589,7 +639,8 @@ def git_commit() -> str:
return "unknown"
def build(out_dir: Path, base_url: str) -> list[Doc]:
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}
@ -618,6 +669,11 @@ def build(out_dir: Path, base_url: str) -> list[Doc]:
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)"
@ -671,6 +727,9 @@ _NEUTRAL_IPV6 = {"::", "::1"}
# 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+/=-])")
@ -728,6 +787,13 @@ def _token_hits(text: str) -> list[tuple[str, str]]:
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)
@ -830,6 +896,15 @@ def main() -> int:
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",
@ -846,7 +921,10 @@ def main() -> int:
if args.check:
return check(args.out.resolve(), args.whitelist)
build(args.out.resolve(), args.base_url)
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