#!/usr/bin/env bash # ============================================================================= # L0 STATIC CHECK — `--api-route` is PER APP, ANCHORED, and DECLARED. # See chainsaw-test.yaml's header for WHY this exists and what it measured. In one paragraph: # # oauth2-proxy answers an unauthenticated request with a 302 to Keycloak. A browser XHR follows # that redirect invisibly, the live SSO session satisfies it silently, and the call resolves 200 # carrying the app's HTML where its JavaScript expected JSON — and a POST following a 302 is # re-issued as a GET with its BODY DROPPED. `--api-route=` makes matching paths answer # 401 instead. The flag is right for jackett (`/api/`) and radarr (`/signalr/`, `/api/`) and # WRONG for pihole, which serves no `/api/` at all — so it cannot be a renderer constant, and # the point of this check is that it never becomes one again. # # The breadth direction is the dangerous one and it is NOT symmetric with the bug. A missing # `--api-route` is a silent 302; a TOO BROAD one answers a plain PAGE LOAD with 401, so a # logged-out operator gets a bare error page and is never offered a sign-in — the service looks # permanently broken. That is a worse outcome than the defect, which is why the anchoring rule # is asserted here as well as in the schema. # # Exit 0 = all assertions pass. Exit 1 = a failure. Exit 3 (honest SKIP, which the runner turns # into rc 3 = UNWIRED, never green, because this test declares the whole-repo scope) only if the # tree contains no forward-auth oauth2-proxy sidecar at all — genuinely nothing to audit. # ============================================================================= set -euo pipefail DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT="$(cd "${DIR}/../../../.." && pwd)" # repo root PYTHON="${PYTHON:-python3}" # Honest SKIP (never green for a scope-declaring test) if the forward-auth pattern has left the # repo. Anchored on `image:` for the same reason as the sibling cookie check: a bare # `oauth2-proxy/oauth2-proxy:` also matches platform/oauth2-proxy/forward-auth-cec.yaml's Envoy # cluster reference (`serviceName: oauth2-proxy/oauth2-proxy:80`), which runs no proxy. if ! grep -rlE 'image:[[:space:]]*[^[:space:]]*oauth2-proxy' \ "${ROOT}/apps" "${ROOT}/platform" >/dev/null 2>&1; then echo "check SKIPPED: no oauth2-proxy container image found under apps/ or platform/ — the forward-auth sidecar pattern is gone and there is nothing to audit" >&2 exit 3 fi cd "${ROOT}" "${PYTHON}" - "${ROOT}" <<'PY' """Static audit: --api-route is per-app, anchored, and declared in the ServiceDefinition. PyYAML (the same dependency as catalog/render.py and the sibling l0-static checks) rather than line-based parsing: a container's args live in a nested structure, and a line-based reader of one is precisely the approximate parser that silently stops matching. """ from __future__ import annotations import json import re import subprocess import sys import tempfile from pathlib import Path try: import yaml except ImportError: # pragma: no cover print("check FAIL: PyYAML is required (same dependency as catalog/render.py)", file=sys.stderr) raise SystemExit(1) ROOT = Path(sys.argv[1]) sys.dont_write_bytecode = True # keep __pycache__ out of the tracked tree FAILURES: list[str] = [] CHECKS = 0 def check(cond, msg): global CHECKS CHECKS += 1 if not cond: FAILURES.append(msg) # THE BREADTH RATCHET, stated once and asserted against three independent surfaces below (the # rendered tree, the authored catalog, the shipped renderer). Each entry must be anchored at # `^/` and continue with LITERAL path characters. This rejects: # `^/` and `^/.*` — match every page load, so the UI answers 401 and never signs in # `/api/` — unanchored; oauth2-proxy would match it mid-path # `.*` — everything # `^/api/|^/` — alternation smuggles breadth past a prefix-only reading # Alternation is REFUSED rather than supported because `--api-route` is repeatable: one entry # per route costs nothing and each entry stays readable. Kept byte-identical to # catalog/render.py's OAUTH2_API_ROUTE_PATTERN and to the schema's items.pattern; assertion (5) # below fails if they drift apart. ANCHORED = re.compile(r"^\^/[A-Za-z0-9._~/-]+(\.\*|\$)?$") SCHEMA_PATTERN = r"^\^/[A-Za-z0-9._~/-]+(\.\*|\$)?$" def read(p: Path) -> str: try: return p.read_text() except (OSError, UnicodeDecodeError): return "" def is_test_fixture(p: Path) -> bool: """conformance/tests/**/{golden,fixtures}/** is assertion DATA, not deployed manifests.""" parts = p.parts return "conformance" in parts and ("golden" in parts or "fixtures" in parts) def iter_containers(node): """Yield every container mapping under any `containers`/`initContainers` list, at any depth. Depth-agnostic on purpose: the same container shape appears under a Deployment, a DaemonSet, a CronJob's jobTemplate or a bare Pod, and a reader that hardcoded one path would silently stop seeing an instance the day one moved. """ if isinstance(node, dict): for key, val in node.items(): if key in ("containers", "initContainers") and isinstance(val, list): for c in val: if isinstance(c, dict): yield c else: yield from iter_containers(val) elif isinstance(node, list): for item in node: yield from iter_containers(item) def api_routes_of(args: list) -> list: """Every `--api-route=` / `--api-route ` value, in order. The flag is REPEATABLE, so this must collect ALL of them — a single-value reader would audit the first and wave the rest through, which is the shape of reader bug this file's header warns about.""" out = [] for i, a in enumerate(args): if not isinstance(a, str): continue if a.startswith("--api-route="): out.append(a.split("=", 1)[1]) elif a == "--api-route" and i + 1 < len(args) and isinstance(args[i + 1], str): out.append(args[i + 1]) return out # ============================================================ (0) INVENTORY: THE RENDERED TREE # Every oauth2-proxy container this repo DEPLOYS, keyed by file + owner, with its api-routes. rendered: dict[str, list] = {} for p in sorted(ROOT.rglob("*.yaml")): relparts = p.relative_to(ROOT).parent.parts # NESTED WORKTREES: skip dot-DIRECTORIES (.git, .claude) while still matching dot-FILES such # as .sops.yaml. .claude/worktrees/agent-*/ holds full repo copies for isolated agent runs, # so a bare rglob audits up to 18 duplicates of every file — which both invents FAILs and, # worse, can satisfy a "something declares X" check from a copy rather than from real source. if any(part.startswith(".") for part in relparts) or is_test_fixture(p) or ".sops." in p.name: continue txt = read(p) if "oauth2-proxy" not in txt: continue rel = str(p.relative_to(ROOT)) try: docs = [d for d in yaml.safe_load_all(txt) if isinstance(d, dict)] except yaml.YAMLError as exc: check(False, f"{rel} is not parseable YAML ({exc}) — any oauth2-proxy instance in it " f"went unaudited, so a too-broad --api-route could be hiding there") continue for d in docs: for c in iter_containers(d): image = str(c.get("image") or "") name = str(c.get("name") or "") if "oauth2-proxy" not in image and name != "oauth2-proxy": continue owner = (d.get("metadata") or {}).get("name") or "?" args = list(c.get("args") or []) + list(c.get("command") or []) rendered[f"{rel}:{owner}/{name or 'oauth2-proxy'}"] = api_routes_of(args) check(len(rendered) >= 5, f"only {len(rendered)} oauth2-proxy container(s) parsed out of the tree. This repo " f"deploys more than that (five rendered sidecars plus crafty's hand-authored one), so the " f"inventory has stopped seeing instances and every assertion below would pass vacuously " f"while an unanchored --api-route went unaudited") # ============================================================ (1) EVERY ROUTE IS ANCHORED for key, routes in sorted(rendered.items()): for r in routes: check(bool(ANCHORED.match(r)), f"{key} declares --api-route={r!r}, which is not an anchored path regex.\n" f" WHY THIS IS WORSE THAN THE BUG IT FIXES: --api-route makes matching paths " f"answer 401 instead of 302ing to Keycloak. A regex that also matches a path a " f"BROWSER NAVIGATES to — `^/`, `^/.*`, `.*`, or an alternation that reaches the " f"UI root — answers a plain PAGE LOAD with 401. A logged-out operator then sees a " f"bare error page and is NEVER OFFERED A SIGN-IN: the service looks permanently " f"broken rather than merely unauthenticated, and no reload can recover it.\n" f" FIX: anchor it at '^/' followed by literal path characters " f"(e.g. '^/api/', '^/signalr/', '^/api/v3/.*'), one entry per route — the flag is " f"repeatable, so alternation is never needed. For a service rendered from catalog/, " f"do NOT hand-edit the golden: declare auth.apiRoutes and re-render " f"(ci/render-golden.sh's header has the canonical command)") # ============================================================ (2) DERIVED FROM THE CATALOG # The five rendered sidecars are OUTPUTS. Auditing only them would let someone "fix" a golden by # hand (reverted by the next render) or make the renderer emit a CONSTANT and regenerate every # golden to match — which render-golden cannot see, because render and golden would agree. # So: every onboarded forward-auth service's rendered route list must EQUAL its own # ServiceDefinition's auth.apiRoutes, in order. catalog_dir = ROOT / "catalog" declared: dict[str, list] = {} forward_auth: list[str] = [] for sd_path in sorted(catalog_dir.glob("*.yaml")): try: sd = yaml.safe_load(read(sd_path)) except yaml.YAMLError as exc: check(False, f"catalog/{sd_path.name} is not parseable YAML ({exc})") continue if not isinstance(sd, dict) or not sd.get("name"): continue auth = sd.get("auth") or {} routes = auth.get("apiRoutes") or [] declared[sd["name"]] = list(routes) if auth.get("mode") == "forward-auth" and (sd.get("ingressRoutes") or []): forward_auth.append(sd["name"]) for r in routes: check(bool(ANCHORED.match(str(r))), f"catalog/{sd_path.name}: auth.apiRoutes entry {r!r} is not anchored at '^/' " f"followed by literal path characters. See assertion (1) for why breadth here is " f"the dangerous direction. The schema rejects this too (items.pattern); this " f"assertion exists because the schema stage can be SKIPPED and this one cannot") if routes: check(auth.get("mode") == "forward-auth", f"catalog/{sd_path.name} declares auth.apiRoutes with auth.mode=" f"{auth.get('mode')!r}. --api-route renders onto the per-app oauth2-proxy sidecar, " f"and no sidecar is rendered for oidc/native/none — so this is a SILENT NO-OP: the " f"operator believes the app's XHRs now answer 401 and nothing changed") matched = 0 for name in sorted(forward_auth): golden = next((p for p in sorted(ROOT.glob(f"apps/wave-*/{name}.yaml"))), None) if golden is None: continue # catalog service not onboarded to apps/ yet — no golden key = next((k for k in rendered if k.startswith(str(golden.relative_to(ROOT)) + ":")), None) check(key is not None, f"apps/{golden.name} is the golden for forward-auth service {name!r} but no " f"oauth2-proxy container was parsed out of it. Its --api-route configuration is " f"UNAUDITED") if key is None: continue matched += 1 check(rendered[key] == declared.get(name, []), f"{name}: the rendered sidecar declares --api-route {rendered[key]!r} but " f"catalog/{name}.yaml declares auth.apiRoutes {declared.get(name, [])!r}.\n" f" These must be IDENTICAL and in order. A mismatch means one of two things, " f"both bad: a golden was hand-edited (the next re-render silently reverts it), or the " f"renderer is emitting --api-route from somewhere other than this service's own " f"ServiceDefinition — i.e. a constant or a per-service table. A constant is the defect " f"this field exists to prevent: it applies ONE app's path shape to all five, and " f"pihole serves no /api/ at all, so it would answer pihole's page loads with 401. " f"render-golden cannot catch that, because render and golden would agree with each " f"other while both being wrong") check(matched >= 4, f"only {matched} onboarded forward-auth service(s) were cross-checked against their " f"ServiceDefinition. This repo onboards five, so the catalog<->golden pairing has stopped " f"resolving and assertion (2) is passing vacuously") # ============================================================ (3) BOTH CASES EXIST # Once the tree is consistent, (2) is satisfied by any pairing — including one where NOTHING # declares a route (vacuous) or one where EVERYTHING does (so the "renders nothing" path is # never exercised). Both halves are asserted to be live, because the per-app field only earns # its complexity if both outcomes actually occur in this tree. with_routes = [n for n in forward_auth if declared.get(n)] without_routes = [n for n in forward_auth if not declared.get(n)] check(bool(with_routes), "no onboarded forward-auth service declares auth.apiRoutes, so assertion (2) cannot " "distinguish a working renderer from one that emits the flag nowhere. If the flag is " "genuinely not needed anywhere, delete the field rather than leaving an unexercised seam") check(bool(without_routes), "EVERY onboarded forward-auth service declares auth.apiRoutes, so nothing in this tree " "exercises the 'declares none => renders no flag' path. That path is the whole reason the " "field is per-app: pihole serves no /api/, and a renderer-wide default would answer its " "page loads with 401") for name in sorted(without_routes): golden = next((p for p in sorted(ROOT.glob(f"apps/wave-*/{name}.yaml"))), None) if golden is None: continue key = next((k for k in rendered if k.startswith(str(golden.relative_to(ROOT)) + ":")), None) if key is None: continue check(rendered[key] == [], f"{name} declares NO auth.apiRoutes but its rendered sidecar carries " f"--api-route {rendered[key]!r}. An app that declares nothing must render NOTHING") # ============================================================ (4) THE RENDERER DERIVES render_py = ROOT / "catalog" / "render.py" check(render_py.is_file(), "catalog/render.py is missing — the generator of five of these sidecars cannot be audited") if render_py.is_file(): src = read(render_py) m = re.search(r"def _oauth2_sidecar_container\(.*?\n(?=\n*def |\n*# =)", src, re.S) fn = m.group(0) if m else "" check(bool(fn), "could not locate _oauth2_sidecar_container() in catalog/render.py. It was renamed or " "removed, so assertion (4) cannot run and the generator is unaudited") if fn: check("--api-route=" in fn, "catalog/render.py's _oauth2_sidecar_container() emits no --api-route at all, so " "every forward-auth app's XHRs are answered with a 302 the browser follows " "invisibly — measured as 33x302-vs-2x401 on jackett and 232x302 on radarr, with " "POST bodies dropped. auth.apiRoutes would then be a field with no consumer") check(not re.search(r'--api-route=\^/[A-Za-z0-9]', fn), "catalog/render.py hardcodes a literal path regex into --api-route. Whatever path " "that is, EVERY forward-auth app now renders it: one app's path shape applied to " "five. pihole serves no /api/, so a constant answers its page loads with 401 and " "the service looks permanently broken to a logged-out operator. Render the routes " "declared in each service's own auth.apiRoutes instead") check("apiRoutes" in fn, "catalog/render.py's _oauth2_sidecar_container() does not read auth.apiRoutes, so " "the per-app declaration has no effect on what is rendered") # ============================================================ (5) ONE PATTERN, THREE SURFACES # The ratchet is written in three places (this check, the renderer, the schema) because each # guards a surface the others cannot. If they drift apart, the loosest one wins silently. if render_py.is_file(): # The renderer writes the pattern as a raw string literal, so the source text contains it # verbatim. A substring test is deliberate: it also catches a rename of the constant. check(SCHEMA_PATTERN in read(render_py), f"catalog/render.py no longer contains the anchoring pattern {SCHEMA_PATTERN!r} that " f"this check and the schema enforce. Three surfaces guard --api-route breadth (the " f"authoring surface via the schema, the render via the renderer, the tree via this " f"check); if they drift apart the loosest wins and the ratchet is decorative") # The schema's pattern is read from the PARSED JSON, not from the file text: in the file its # backslashes are JSON-escaped, so a substring test against the raw bytes would compare the # wrong string and pass or fail for the wrong reason. schema_file = ROOT / "schemas" / "servicedefinition.schema.json" schema_pat = None if schema_file.is_file(): try: schema_pat = (((((json.loads(read(schema_file)).get("properties") or {}) .get("auth") or {}).get("properties") or {}) .get("apiRoutes") or {}).get("items") or {}).get("pattern") except (ValueError, AttributeError) as exc: check(False, f"schemas/servicedefinition.schema.json could not be read as JSON ({exc}) — " f"the schema stage that rejects a too-broad --api-route cannot be audited") check(schema_pat == SCHEMA_PATTERN, f"schemas/servicedefinition.schema.json constrains auth.apiRoutes items with " f"{schema_pat!r}, not {SCHEMA_PATTERN!r}. The schema is the only surface that rejects a " f"too-broad regex WHERE A HUMAN TYPES IT (ci/l0.sh validates catalog/*.yaml on every gate " f"run); if it is absent or looser than the renderer's, the operator finds out from a 401 " f"on a page load") # ============================================================ (6) PROVE THE SHIPPED RENDERER # Once the tree is compliant, (1)-(3) have nothing left to catch and their passing is evidence # of nothing. This runs the SHIPPED renderer as a SUBPROCESS on synthetic ServiceDefinitions and # asserts the behaviour directly, in BOTH directions, so the guard stays falsifiable. def _sd(name: str, port: int, routes) -> str: auth = {"mode": "forward-auth", "groups": ["platform-admins"]} if routes is not None: auth["apiRoutes"] = routes return yaml.safe_dump({ "name": name, "kind": "container", "source": {"image": "example/app:1"}, "exposure": "internal", "ingressRoutes": [{"host": f"{name}.home.keithjackson.dev", "port": port}], "auth": auth, }, sort_keys=False) def _render(name: str, port: int, routes): with tempfile.TemporaryDirectory() as td: f = Path(td) / f"{name}.yaml" f.write_text(_sd(name, port, routes)) return subprocess.run([sys.executable, str(render_py), str(f)], capture_output=True, text=True) def _sidecar_args(rendered_yaml: str) -> list: for doc in yaml.safe_load_all(rendered_yaml): if not isinstance(doc, dict) or doc.get("kind") != "Deployment": continue for c in iter_containers(doc): if c.get("name") == "oauth2-proxy": return list(c.get("args") or []) return [] if render_py.is_file(): # (6a) DECLARED ROUTES RENDER, IN ORDER, AND NOTHING ELSE DOES. proc = _render("apirtdecl", 8121, ["^/api/", "^/signalr/"]) check(proc.returncode == 0, f"the shipped catalog/render.py failed on a synthetic forward-auth fixture declaring " f"two apiRoutes: rc={proc.returncode} {proc.stderr.strip()[:300]}") if proc.returncode == 0: got = api_routes_of(_sidecar_args(proc.stdout)) check(got == ["^/api/", "^/signalr/"], f"the shipped renderer turned auth.apiRoutes ['^/api/', '^/signalr/'] into " f"--api-route {got!r}. It must emit one repeatable --api-route per entry, in " f"declared order. Note the flag is SINGULAR: --api-routes does not exist in " f"oauth2-proxy v7.8.1 and the proxy REFUSES TO START with it, so a plural would " f"crashloop every sidecar it rendered") # (6b) AN APP THAT DECLARES NOTHING RENDERS NOTHING. The pihole case, proven on the # generator rather than inferred from one committed golden. for label, routes in (("absent", None), ("empty", [])): proc = _render(f"apirtnone{label}", 8122, routes) check(proc.returncode == 0, f"the shipped renderer failed on a synthetic forward-auth fixture with apiRoutes " f"{label}: rc={proc.returncode} {proc.stderr.strip()[:300]}") if proc.returncode == 0: got = api_routes_of(_sidecar_args(proc.stdout)) check(got == [], f"the shipped renderer emitted --api-route {got!r} for a synthetic service " f"whose auth.apiRoutes is {label}. A newly-onboarded forward-auth app must " f"render NO --api-route: pihole serves no /api/, so a default would answer " f"its page loads with 401 and the service would look permanently broken to a " f"logged-out operator — a worse failure than the 302 bug the flag fixes") # (6c) THE RENDERER REFUSES BREADTH. Asserted on the generator because render-golden runs # on every gate invocation while the schema stage can be skipped. for bad in ("^/", "^/.*", ".*", "/api/", "^/api/|^/"): proc = _render("apirtbad", 8123, [bad]) check(proc.returncode != 0, f"the shipped renderer ACCEPTED auth.apiRoutes {bad!r} and rendered a sidecar. " f"That regex matches paths a browser NAVIGATES to, so a plain page load answers " f"401 and the operator is never offered a sign-in. The renderer must refuse it, " f"so a breadth mistake fails render-golden even on a run where the schema stage " f"did not execute") # ============================================================ VERDICT inv = " | ".join(f"{k} -> {v or '(none)'}" for k, v in sorted(rendered.items())) print(f"oauth2-proxy sidecars audited ({len(rendered)}): {inv}") if FAILURES: print(f"\nCHECK FAIL (oauth2-proxy-api-routes-per-app): {len(FAILURES)} failed, " f"{CHECKS - len(FAILURES)} passed", file=sys.stderr) for f in FAILURES: print(f" FAIL {f}", file=sys.stderr) raise SystemExit(1) print(f"check OK: oauth2-proxy-api-routes-per-app — {CHECKS} assertions (R-IDP-02)") PY