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:
parent
04251b5bfb
commit
f5c6f3b651
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue