From 02df0a8a15ee6ac1aa2fd7aa8d9e78bd70af427e Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Tue, 29 Sep 2026 03:13:08 +0200 Subject: [PATCH] feat(10.1-04): add the fail-closed Phase 10.1 gate scripts/check-phase10.1.sh: --self-test, --go, --security, --postgres, --spa, --openapi, --dist, --hygiene, --evidence and --all. - phase101_detect refuses failed, skipped, zero-test, non-JSON and build-failed runs and required tests that did not pass - hygiene_101 refuses HTML-string parsers in admin/src, network, cookie or storage access in application plugin asset JS, and script, style or inline handler markup in application partial templates; each rule is proven by its own self-test plant - --evidence requires a review row and, for every high threat, a named test and a removal check row --- scripts/check-phase10.1.sh | 441 +++++++++++++++++++++++++++++++++++++ 1 file changed, 441 insertions(+) create mode 100755 scripts/check-phase10.1.sh diff --git a/scripts/check-phase10.1.sh b/scripts/check-phase10.1.sh new file mode 100755 index 0000000..0ec4a65 --- /dev/null +++ b/scripts/check-phase10.1.sh @@ -0,0 +1,441 @@ +#!/usr/bin/env bash +# Phase 10.1 fail-closed gate (runtime admin extension point, ADMIN-07). +# Every stage exits non-zero on a failing command, a go test run that fails, +# skips or matches zero tests, a named test that did not pass, OpenAPI or +# dist drift, a hygiene violation or an evidence gap. --self-test proves each +# detector and each Phase 10.1 hygiene rule fails closed. +# +# Allow-list: none. The fonoteka.go parity failures TestMigrateSeedsCanonicalGenres +# and TestSchemaMatchesPHPSnapshot, accepted until then, pass since fonoteka.go +# 21c0f12 (fix(09): update parity expectations for the backend admin schema), +# and the detector refuses an allow-listed failure that passes. KNOWN_APP_FAILURES +# stays as the one place to name a future known failure, with a reason. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +APP="$(cd "$ROOT/../fonoteka.go" && pwd)" +PHASE_DIR="$ROOT/.planning/phases/10.1-runtime-admin-extension-point" +REVIEW="$PHASE_DIR/10.1-SECURITY-REVIEW.md" +VALIDATION="$PHASE_DIR/10.1-VALIDATION.md" +APP_PLUGINS=(./plugins/golem15/fonoteka/... ./plugins/golem15/user/...) +KNOWN_APP_FAILURES="" + +usage() { + cat >&2 <<'EOF' +usage: + check-phase10.1.sh --self-test + check-phase10.1.sh --go + check-phase10.1.sh --security + check-phase10.1.sh --postgres + check-phase10.1.sh --spa + check-phase10.1.sh --openapi + check-phase10.1.sh --dist + check-phase10.1.sh --hygiene + check-phase10.1.sh --evidence + check-phase10.1.sh --all +EOF + exit 2 +} + +# phase101_detect reads go test -json. Exit 1 fail, 2 skip, 3 zero tests, +# 4 non-JSON, 5 a required test did not pass, 6 an allow-listed failure now +# passes. PHASE101_REQUIRE lists test names that must pass; PHASE101_ALLOW +# lists "package:Test" failures that are accepted (and must still fail). +phase101_detect() { + python3 - "$1" <<'PY' +import json, os, sys +path = sys.argv[1] +allow = set(os.environ.get("PHASE101_ALLOW", "").split()) +require = set(os.environ.get("PHASE101_REQUIRE", "").split()) +passed = set() +failed_tests = {} +failed_pkgs = [] +build_failed = False +with open(path, encoding="utf-8", errors="replace") as fh: + for raw in fh: + line = raw.strip() + if not line.startswith("{"): + continue + try: + ev = json.loads(line) + except json.JSONDecodeError: + print("refuse: non-json test output", file=sys.stderr) + sys.exit(4) + action = ev.get("Action") + test = ev.get("Test") or "" + pkg = ev.get("Package") or "" + if action == "build-fail": + build_failed = True + if action == "skip" and test: + print(f"refuse: skipped {pkg} {test}", file=sys.stderr) + sys.exit(2) + if action == "fail": + if ev.get("FailedBuild"): + build_failed = True + if test: + failed_tests.setdefault(pkg, []).append(test) + else: + failed_pkgs.append(pkg) + if action == "pass" and test: + passed.add(test) + top = test.split("/", 1)[0] + if f"{pkg}:{top}" in allow and "/" not in test: + print(f"refuse: allow-listed failure {pkg} {test} now passes; remove it from the gate", file=sys.stderr) + sys.exit(6) +if build_failed: + print("refuse: build failed", file=sys.stderr) + sys.exit(1) +accepted = [] +for pkg, tests in failed_tests.items(): + for test in tests: + top = test.split("/", 1)[0] + if f"{pkg}:{top}" in allow: + accepted.append(f"{pkg} {test}") + continue + print(f"refuse: failed {pkg} {test}", file=sys.stderr) + sys.exit(1) +for pkg in failed_pkgs: + if not failed_tests.get(pkg): + print(f"refuse: failed {pkg or 'unknown package'}", file=sys.stderr) + sys.exit(1) +for item in sorted(set(accepted)): + print(f"known pre-existing failure (deferred-items.md): {item}", file=sys.stderr) +missing = sorted(name for name in require if name not in passed) +if missing: + print("refuse: required tests did not pass: " + ", ".join(missing), file=sys.stderr) + sys.exit(5) +if not passed: + print("refuse: zero tests", file=sys.stderr) + sys.exit(3) +PY +} + +# phase101_go DIR PKGS... [-run REGEX] runs go test -json through the +# detector. The go test exit status is trusted only when no failure was +# allow-listed. +phase101_go() { + local dir="$1" + shift + local log err + log="$(mktemp)" + err="$(mktemp)" + set +e + (cd "$dir" && go test -json -count=1 "$@") >"$log" 2>"$err" + local rc=$? + set -e + local dc=0 + phase101_detect "$log" || dc=$? + if [[ "$dc" -ne 0 || ("$rc" -ne 0 && -z "${PHASE101_ALLOW:-}") ]]; then + cat "$err" >&2 || true + tail -n 40 "$log" >&2 || true + rm -f "$log" "$err" + echo "refuse: go test $* in $dir (test=$rc detect=$dc)" >&2 + exit 1 + fi + rm -f "$log" "$err" +} + +# phase101_tests DIR PKG TEST... requires every named test to run and pass. +phase101_tests() { + local dir="$1" pkg="$2" + shift 2 + local names="$*" + local regex="^($(tr ' ' '|' <<<"$names"))\$" + PHASE101_REQUIRE="$names" phase101_go "$dir" "$pkg" -run "$regex" +} + +expect_detect() { + local name="$1" want="$2" payload="$3" + local log dc=0 + log="$(mktemp)" + printf '%s\n' "$payload" >"$log" + phase101_detect "$log" 2>/dev/null || dc=$? + rm -f "$log" + if [[ "$dc" -ne "$want" ]]; then + echo "refuse: self-test $name: detector exit $dc, want $want" >&2 + exit 1 + fi +} + +# The hygiene_101 refusal reasons; the self-test checks each plant is refused +# for its own reason only. +REASON_PARSER="HTML-string parser in admin/src" +REASON_ASSET="network, cookie or storage access in application plugin asset JS" +REASON_PARTIAL="script or event-handler markup in an application partial template" + +# hygiene_101 TREE APPTREE: the Phase 10.1 extension rules (D-04, D-05, D-17; +# T-10.1-08, T-10.1-10, T-10.1-14, T-10.1-20). The Phase 10 rules run first +# through check-phase10.sh --hygiene. +hygiene_101() { + local tree="$1" app="$2" bad=0 hits + fail101() { + echo "refuse: hygiene: $*" >&2 + bad=1 + } + # The SPA never parses a string as markup: partial nodes are built with h(). + hits="$(cd "$tree" && grep -rnE 'setHTML(Unsafe)?\b|createContextualFragment|DOMParser|srcdoc|document\.write' admin/src 2>/dev/null || true)" + [[ -z "$hits" ]] || fail101 "$REASON_PARSER: $hits" + # Plugin elements signal through summer-action; the SPA owns HTTP and the + # session cookie is HttpOnly (D-05, D-08). + local js=() + if [[ -d "$app/plugins" ]]; then + mapfile -t js < <(find "$app/plugins" -mindepth 3 -path '*/assets/*' -type f \( -name '*.js' -o -name '*.mjs' \) | sort) + fi + if [[ "${#js[@]}" -gt 0 ]]; then + hits="$(grep -nHE '\bfetch[[:space:]]*\(|XMLHttpRequest|document\.cookie|localStorage|sessionStorage|indexedDB|sendBeacon|\bWebSocket\b|\bEventSource\b' "${js[@]}" || true)" + [[ -z "$hits" ]] || fail101 "$REASON_ASSET: $hits" + fi + # Partial templates carry markup only; behaviour lives in plugin JS. + local partials=() + if [[ -d "$app/plugins" ]]; then + mapfile -t partials < <(find "$app/plugins" -mindepth 3 -path '*/controllers/*' -type f -name '_*.htm' | sort) + fi + if [[ "${#partials[@]}" -gt 0 ]]; then + hits="$(grep -nHiE '&2 + exit 1 + } + done + + # hygiene_101 passes on scratch copies of admin/src and the application + # plugins, then refuses each plant for its own reason and no other. + local scratch app + scratch="$(mktemp -d)" + trap 'rm -rf "$scratch"' RETURN + mkdir -p "$scratch/fw/admin" "$scratch/app" + cp -R "$ROOT/admin/src" "$scratch/fw/admin/src" + cp -R "$APP/plugins" "$scratch/app/plugins" + app="$scratch/app" + (hygiene_101 "$scratch/fw" "$app") >/dev/null 2>&1 || { + echo "refuse: self-test hygiene_101 rejected the clean scratch copy" >&2 + exit 1 + } + local plant want out file others reason + for plant in parser dom-write network cookie storage script style handler; do + rm -rf "$scratch/fw/admin/src/__plant" "$app/plugins/acme" + mkdir -p "$scratch/fw/admin/src/__plant" "$app/plugins/acme/demo/assets/js" "$app/plugins/acme/demo/controllers/gadgets" + case "$plant" in + parser) + printf "export const parse = (s: string) => new DOMParser().parseFromString(s, 'text/html')\n" >"$scratch/fw/admin/src/__plant/plant.ts" + want="$REASON_PARSER" + ;; + dom-write) + printf 'export const write = (s: string) => document.write(s)\n' >"$scratch/fw/admin/src/__plant/plant.ts" + want="$REASON_PARSER" + ;; + network) + printf "class Plant extends HTMLElement { connectedCallback() { fetch('/backend/api/v1/x') } }\n" >"$app/plugins/acme/demo/assets/js/plant.js" + want="$REASON_ASSET" + ;; + cookie) + printf 'const token = document.cookie\n' >"$app/plugins/acme/demo/assets/js/plant.js" + want="$REASON_ASSET" + ;; + storage) + printf "localStorage.setItem('k', 'v')\n" >"$app/plugins/acme/demo/assets/js/plant.js" + want="$REASON_ASSET" + ;; + script) + printf '
\n\n' >"$app/plugins/acme/demo/controllers/gadgets/_plant.htm" + want="$REASON_PARTIAL" + ;; + style) + printf '

{{ .Data.Name }}

\n' >"$app/plugins/acme/demo/controllers/gadgets/_plant.htm" + want="$REASON_PARTIAL" + ;; + handler) + printf 'x\n' >"$app/plugins/acme/demo/controllers/gadgets/_plant.htm" + want="$REASON_PARTIAL" + ;; + esac + if out="$( (hygiene_101 "$scratch/fw" "$app") 2>&1)"; then + echo "refuse: self-test hygiene_101 accepted a planted $plant" >&2 + exit 1 + fi + if ! grep -qF "$want" <<<"$out"; then + echo "refuse: self-test hygiene_101 rejected the $plant plant without naming its rule: $out" >&2 + exit 1 + fi + for reason in "$REASON_PARSER" "$REASON_ASSET" "$REASON_PARTIAL"; do + if [[ "$reason" != "$want" ]] && grep -qF "$reason" <<<"$out"; then + echo "refuse: self-test hygiene_101 rejected the $plant plant for another rule: $out" >&2 + exit 1 + fi + done + done + # Markup that only resembles a handler or style attribute is not refused. + rm -rf "$scratch/fw/admin/src/__plant" "$app/plugins/acme" + mkdir -p "$app/plugins/acme/demo/controllers/gadgets" + printf '

{{ trans "acme.demo::lang.button" }}

\n' >"$app/plugins/acme/demo/controllers/gadgets/_clean.htm" + (hygiene_101 "$scratch/fw" "$app") >/dev/null 2>&1 || { + echo "refuse: self-test hygiene_101 rejected clean partial markup" >&2 + exit 1 + } + echo "phase10.1 self-test passed" +} + +run_go() { + (cd "$ROOT" && go vet ./...) + phase101_go "$ROOT" ./... + (cd "$APP" && go vet ./... "${APP_PLUGINS[@]}") + PHASE101_ALLOW="$KNOWN_APP_FAILURES" phase101_go "$APP" ./... "${APP_PLUGINS[@]}" + echo "phase10.1 go passed" +} + +run_security() { + phase101_tests "$ROOT" ./modules/cabana TestPhase10CSRF TestPhase09PermissionMatrix TestPhase101Assets \ + TestPhase101PartialSanitizer TestPhase101Actions TestPhase101FormExtensionSchema TestPhase101PartialSchema TestPhase101Toolbar + PHASE101_REQUIRE="TestPhase101BoardwalkExports" phase101_go "$ROOT" ./modules/boardwalk + phase101_tests "$APP" ./plugins/golem15/fonoteka TestPhase09SecurityRoutes + echo "phase10.1 security passed" +} + +run_postgres() { + phase101_tests "$ROOT" ./modules/cabana TestPhase10OpenAPIConformance TestPhase101Actions + phase101_tests "$APP" ./plugins/golem15/fonoteka TestPhase101AlbumsExtension TestPhase101AlbumsSmoke TestPhase10Controllers \ + TestPhase10ControllerCopy TestAlbumsAdminForm TestAlbumsAdminList TestPhase10AssembledAcceptance + echo "phase10.1 postgres passed" +} + +run_spa() { + npm --prefix "$ROOT/admin" ci --no-audit --no-fund + npm --prefix "$ROOT/admin" run typecheck + local log + log="$(mktemp)" + set +e + npm --prefix "$ROOT/admin" test >"$log" 2>&1 + local rc=$? + set -e + if [[ "$rc" -ne 0 ]] || grep -qE 'No test files found|Unhandled (Errors|Rejection)|FAIL ' "$log"; then + tail -n 60 "$log" >&2 + rm -f "$log" + echo "refuse: admin Vitest run failed (exit $rc)" >&2 + exit 1 + fi + grep -E 'Test Files|Tests ' "$log" || true + rm -f "$log" + echo "phase10.1 spa passed" +} + +run_openapi() { + "$ROOT/scripts/check-admin-openapi.sh" --check + phase101_tests "$ROOT" ./modules/cabana TestPhase10OpenAPIConformance TestPhase09ContractInventory + echo "phase10.1 openapi passed" +} + +run_dist() { + "$ROOT/scripts/check-admin-dist.sh" + echo "phase10.1 dist passed" +} + +run_hygiene() { + "$ROOT/scripts/check-phase10.sh" --hygiene + hygiene_101 "$ROOT" "$APP" + echo "phase10.1 hygiene passed" +} + +run_evidence() { + [[ -f "$REVIEW" && -f "$VALIDATION" ]] || { + echo "refuse: security review or validation file is missing" >&2 + exit 1 + } + python3 - "$REVIEW" "$VALIDATION" <<'PY' +import pathlib, re, sys +review = pathlib.Path(sys.argv[1]).read_text() +validation = pathlib.Path(sys.argv[2]).read_text() +required = [f"T-10.1-{i:02d}" for i in range(1, 23)] + ["T-10.1-SC"] +lines = review.splitlines() +removal = [line for line in lines if line.startswith("| RC-")] +for item in required: + rows = [line for line in lines if line.startswith("| " + item + " ")] + if len(rows) != 1: + print(f"refuse: review has {len(rows)} threat rows for {item}, want 1", file=sys.stderr) + sys.exit(1) + row = rows[0] + cells = [cell.strip().lower() for cell in row.strip("|").split("|")] + high = "high" in cells + mitigated = "mitigate" in cells + if high and mitigated: + if not re.search(r"Test[A-Z][A-Za-z0-9]+|check-phase[\d.]+\.sh|check-admin-|tests/", row): + print(f"refuse: high threat {item} names no failing-when-broken test or gate stage", file=sys.stderr) + sys.exit(1) + if not any(re.search(re.escape(item) + r"(?![0-9A-Za-z])", line) for line in removal): + print(f"refuse: high threat {item} has no removal check row", file=sys.stderr) + sys.exit(1) +if not re.search(r"^nyquist_compliant: true$", validation, re.M): + print("refuse: validation is not nyquist_compliant", file=sys.stderr) + sys.exit(1) +for line in validation.splitlines(): + if line.startswith("|") and "pending" in line.lower(): + print("refuse: validation row still pending: " + line, file=sys.stderr) + sys.exit(1) +if "ADMIN-07" not in validation: + print("refuse: validation does not name ADMIN-07", file=sys.stderr) + sys.exit(1) +print("phase10.1 evidence files passed") +PY + run_security + run_postgres + run_openapi + echo "phase10.1 evidence passed" +} + +case "${1:-}" in +--self-test) run_self_test ;; +--go) run_go ;; +--security) run_security ;; +--postgres) run_postgres ;; +--spa) run_spa ;; +--openapi) run_openapi ;; +--dist) run_dist ;; +--hygiene) run_hygiene ;; +--evidence) run_evidence ;; +--all) + run_self_test + run_go + run_security + run_postgres + run_spa + run_openapi + run_dist + run_hygiene + run_evidence + echo "phase10.1 all passed" + ;; +*) usage ;; +esac