Files
ThothII/scripts/verify-auth-docs.py
2026-09-15 14:37:29 +02:00

95 lines
5.8 KiB
Python

#!/usr/bin/env python3
"""Validate current authentication documentation, not retired rollout transcripts."""
import argparse
import re
from pathlib import Path
AUTH = {
"docs/architecture/authentication.md": ["## Diagnostics and ordering", "HttpOnly", "SameSite=Lax", "authRevision", "401", "403", "direct, non-empty", "ignored silently"],
"docs/install/authentication-local.md": ["tht auth", "--password-file"],
"docs/install/authentication-oidc.md": ["groups", "THT_OIDC_CLIENT_SECRET", "THT_AUTHENTIK_API_TOKEN", "/api/auth/oidc/callback"],
"docs/install/authentik.md": ["groups"],
"docs/install/authentication-upstream.md": ["AUTH_MODE=upstream"],
"docs/testing/authentication-manual-acceptance.md": ["Remember me", "403", "Header identità inventati", "non sostituiscono"],
}
DWH = {
"docs/install/dwh-auth-server.md": ["/var/lib/dwh-auth/", "/run/dwh-auth/verify.sock", "key create", "key revoke", "encrypted", "401", "503"],
"docs/install/dwh-auth-client-enrollment.md": ["rest_api", "postgres_direct", "ssh_tunnel", "API_KEY_FILE", "TLS_CA_FILE", "/rpc/ping", "401", "503"],
"docs/install/dwh-auth-tls.md": ["SAN", "TLS_CA_FILE", "openssl x509 -noout -fingerprint -sha256"],
}
CODES = """auth_ready auth_config_incomplete auth_config_invalid auth_session_store_invalid
local_user_registry_invalid local_admin_missing oidc_secret_missing oidc_discovery_unreachable
oidc_issuer_mismatch oidc_jwks_unreachable oidc_group_catalog_unreachable
oidc_group_catalog_unauthorized oidc_mapped_group_missing oidc_mapped_group_ambiguous
oidc_groups_claim_invalid oidc_device_flow_unavailable""".split()
ADMIN = """session.use session.read_all session.manage_all settings.manage workspace.manage
workspace.secrets.manage database.manage memory.manage evidence.manage pi.manage auth.diagnostics.read""".split()
def verify(root, mode):
requirements = AUTH if mode == "auth" else DWH
sources = {}
for relative, tokens in requirements.items():
path = root / relative
if not path.is_file():
raise ValueError(f"missing {relative}")
source = path.read_text()
sources[relative] = source
for token in tokens:
if token.lower() not in source.lower():
raise ValueError(f"{relative}: missing topic {token}")
for target in re.findall(r"(?<!!)\[[^]]*\]\(([^)#]+)(?:#[^)]+)?\)", source):
if "://" in target or target.startswith("mailto:"):
continue
candidate = (path.parent / target).resolve()
if not candidate.is_file() or root.resolve() not in candidate.parents:
raise ValueError(f"{relative}: broken local link {target}")
corpus = "\n".join(sources.values())
# Negative prose can explain a forbidden command; only executable examples are
# checked for command safety. Credential literals are forbidden everywhere.
code = "\n".join(re.findall(r"```[^\n]*\n([\s\S]*?)```", corpus))
for pattern, label, source in [
(r"\b(?:thothctl|thothii-admin)\b", "obsolete host CLI", corpus),
(r"(?im)^\s*(?:[\"']?password[\"']?)\s*:\s*\S+|\"password\"\s*:\s*\"[^\"]+\"", "plaintext password", code),
(r"thtdwh_v1\.[A-Za-z0-9_-]{16}\.[A-Za-z0-9_-]{43}", "credential literal", corpus),
(r"(?mi)^\s*[\"']?secret_sha256[\"']?\s*[:=]\s*[\"']?[A-Za-z0-9_-]{16,}", "credential digest", corpus),
(r"(?mi)^\s*(?:export\s+)?[A-Z][A-Z0-9_]*(?:API_KEY|SECRET|TOKEN|PASSWORD)\s*=\s*[^\s#]+", "raw environment secret", code),
(r"(?i)--(?:password|api-key|token)(?!-file)\b(?:\s+|=)\S+", "secret argument", code),
(r"(?i)(?:-H|--header)\s+['\"][^'\"]*X-API-Key\s*:", "raw key header", code),
(r"(?i)curl[^\n]*(?:\s-k\b|--insecure)|verify_tls\s*[:=]\s*false|insecure_skip_verify\s*[:=]\s*true", "TLS bypass", code),
(r"(?i)chmod\s+0?[0-7][0-7][4-7]\s+[^\n]*(?:\.key|secret|provision|auth\.yaml|users\.yaml)", "world-readable secret", code),
(r"(?m)^\s*(?:sudo\s+)?nginx\s+-T\b", "raw nginx capture", code),
(r"(?m)^\s*(?:sudo\s+)?(?:diff\b|git\s+diff\b)", "raw diff capture", code),
(r"(?i)docker\s+compose[^\n]*\bdwh-auth\b", "DWH service Compose coupling", code),
(r"auth-canonical[^\n]*:/run/thothii-auth", "canonical auth root mounted into core", code),
(r"(?:useradd|groupadd)[^\n]*10001", "host runtime identity creation", code),
(r"(?im)^\s*(?:sudo\s+)?(?:vi|vim|nano|sed|cp|mv|rm|tee)\b[^\n]*(?:CURRENT|generations)", "direct auth projection edit", code),
]:
if re.search(pattern, source):
raise ValueError(f"forbidden {label}")
if mode == "auth":
architecture = sources["docs/architecture/authentication.md"]
user_row = "| `user` | `session.use` |"
admin_row = "| `admin` | " + ", ".join(f"`{p}`" for p in ADMIN) + " |"
if user_row not in architecture or admin_row not in architecture:
raise ValueError("role-to-permission map is not exact")
section = architecture.split("## Diagnostics and ordering", 1)[1].split("\n## ", 1)[0]
block = re.search(r"```text\n([\s\S]*?)```", section)
if not block or block.group(1).split() != CODES:
raise ValueError("diagnostic code union is not exact")
if re.search(r"(?:extra|unmapped|additional) groups[^.\n]*(?:generate|emit|produce) (?:warnings|alerts)", corpus, re.I):
raise ValueError("misleading noise claim for unmapped groups")
print(f"{mode} documentation contract passed")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("mode", choices=["auth", "dwh"])
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parent.parent)
args = parser.parse_args()
try:
verify(args.root, args.mode)
except (ValueError, OSError) as exc:
parser.exit(1, f"{args.mode} docs: {exc}\n")