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`).
|
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
|
Tryb `--check` nie generuje niczego — skanuje JUŻ WYGENEROWANY katalog wyjściowy
|
||||||
w poszukiwaniu wycieków (adresy IP, porty, ścieżki hosta, tokeny). Świadome
|
w poszukiwaniu wycieków (adresy IP, porty, ścieżki hosta, tokeny). Świadome
|
||||||
wyjątki trzymamy w scripts/kb/check_whitelist.txt. Trafienie = exit 1.
|
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.
|
frontmatter dokładnie tak samo.
|
||||||
|
|
||||||
Uruchomienie:
|
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]
|
python3 scripts/kb/gen_pages.py --check [--out KATALOG] [--whitelist PLIK]
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -43,11 +50,13 @@ from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import html
|
import html
|
||||||
|
import os
|
||||||
import posixpath
|
import posixpath
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import urllib.parse
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
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 ------------------------------------------------------------
|
# --- model ------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -242,6 +288,9 @@ def render_inline(text: str) -> str:
|
||||||
attrs = ""
|
attrs = ""
|
||||||
if href.startswith(("http://", "https://", "mailto:")):
|
if href.startswith(("http://", "https://", "mailto:")):
|
||||||
attrs = ' target="_blank" rel="noopener" class="ext"'
|
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>'
|
return f'<a href="{html.escape(href, quote=True)}"{attrs}>{label}</a>'
|
||||||
|
|
||||||
text = _LINK_RE.sub(link, text)
|
text = _LINK_RE.sub(link, text)
|
||||||
|
|
@ -457,7 +506,8 @@ def page_shell(
|
||||||
) -> str:
|
) -> str:
|
||||||
up = "../" * depth
|
up = "../" * depth
|
||||||
if nav is None:
|
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 ""
|
nav_html = f"<nav>{nav}</nav>\n" if nav else ""
|
||||||
return f"""<!doctype html>
|
return f"""<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
@ -546,7 +596,7 @@ def render_index(docs: list[Doc], base_url: str, stamp: str) -> str:
|
||||||
for doc in items:
|
for doc in items:
|
||||||
lead = summary(doc)
|
lead = summary(doc)
|
||||||
cards.append(
|
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"{html.escape(doc.title)}</a>"
|
||||||
f'<span class="chip">{html.escape(doc.doc_type)}</span>'
|
f'<span class="chip">{html.escape(doc.doc_type)}</span>'
|
||||||
+ (f"<p>{html.escape(lead)}</p>" if lead else "")
|
+ (f"<p>{html.escape(lead)}</p>" if lead else "")
|
||||||
|
|
@ -589,7 +639,8 @@ def git_commit() -> str:
|
||||||
return "unknown"
|
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()
|
docs = load_docs()
|
||||||
public = [d for d in docs if d.public]
|
public = [d for d in docs if d.public]
|
||||||
published = {d.id: d.page for d in 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"Źródło: {KB_DIR.relative_to(REPO_ROOT)}/**/*.md")
|
||||||
print(f"Wyjście: {out_dir}")
|
print(f"Wyjście: {out_dir}")
|
||||||
print(f"BASE_URL: {base_url}")
|
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(
|
print(
|
||||||
f"Dokumenty: {len(docs)} razem, {len(public)} public, "
|
f"Dokumenty: {len(docs)} razem, {len(public)} public, "
|
||||||
f"{len(docs) - len(public)} pominiętych (private / brak frontmattera)"
|
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`.
|
# tylko `::` z adresów IPv6; te i tak raportuje wzorzec `ip-v6`.
|
||||||
_PORT_RE = re.compile(r"(?<!:):(\d{4,5})(?!\d)")
|
_PORT_RE = re.compile(r"(?<!:):(\d{4,5})(?!\d)")
|
||||||
_PATH_RE = re.compile(r"/(?:home|opt)/[\w.\-/]*")
|
_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])")
|
_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+/=-])")
|
_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]]:
|
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 = _ipv4_hits(text) + _ipv6_hits(text) + _port_hits(text)
|
||||||
hits += [("path-host", m.group(0)) for m in _PATH_RE.finditer(text)]
|
hits += [("path-host", m.group(0)) for m in _PATH_RE.finditer(text)]
|
||||||
hits += _token_hits(text)
|
hits += _token_hits(text)
|
||||||
|
|
@ -830,6 +896,15 @@ def main() -> int:
|
||||||
default=DEFAULT_OUT,
|
default=DEFAULT_OUT,
|
||||||
help="katalog wyjściowy (domyślnie build/kb-site)",
|
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(
|
parser.add_argument(
|
||||||
"--check",
|
"--check",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
|
|
@ -846,7 +921,10 @@ def main() -> int:
|
||||||
if args.check:
|
if args.check:
|
||||||
return check(args.out.resolve(), args.whitelist)
|
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
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue