#!/usr/bin/env bash # ============================================================================= # L0 STATIC CHECK — no two oauth2-proxy instances can share a browser cookie. # See chainsaw-test.yaml's header for WHY this exists, what it measured, and the argument # for assertion (4). In one paragraph: # # Every oauth2-proxy here holds its OWN cookie-secret (seven distinct values on prod, # 2026-09-30), so two instances sharing a cookie NAME on a shared DOMAIN cannot read each # other's session — they OVERWRITE it. The loser bounces to Keycloak, is redirected straight # back by the live SSO session, and overwrites the winner in turn. The symptom is "I signed # into Keycloak but I'm still not signed in", it only shows up after a second gated app is # visited, and nothing in the cluster reports an error. A missing --cookie-name IS the bug, # so the next forward-auth app inherits it by doing nothing. # # 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 oauth2-proxy instance 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 there is nothing to audit at all. # Matches the IMAGE every instance runs and the CHART the shared one installs; if NEITHER # appears anywhere, the forward-auth pattern has left this repo and the check has no subject. # # ANCHORED ON `image:` DELIBERATELY. 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` — namespace/service:port, not a registry path). # That file configures the ext_authz listener that CALLS the proxy; it runs no proxy and holds # no cookie flags, so counting it made the grep/parse cross-check below report a phantom # unaudited instance. if ! grep -rlE 'image:[[:space:]]*[^[:space:]]*oauth2-proxy|chart:[[:space:]]*oauth2-proxy' \ "${ROOT}/apps" "${ROOT}/platform" "${ROOT}/catalog" >/dev/null 2>&1; then echo "check SKIPPED: no oauth2-proxy image or chart found under apps/, platform/ or catalog/ — nothing to audit" >&2 exit 3 fi cd "${ROOT}" "${PYTHON}" - "${ROOT}" <<'PY' """Static audit: no two oauth2-proxy instances can share a browser cookie. 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 HelmRelease's flags live in a values map, and a line-based reader of either is precisely the approximate parser that silently stops matching — the failure class this check exists to prevent. """ from __future__ import annotations 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) def read(p: Path) -> str: try: return p.read_text() except (OSError, UnicodeDecodeError): return "" # oauth2-proxy's OWN built-in defaults (pkg/apis/options/cookie.go): the session cookie is # `_oauth2_proxy` and the CSRF cookie is that name with `_csrf` appended. An instance that # declares nothing is NOT unconfigured — it has claimed this exact name, which is why the # inventory below treats the default as a name like any other rather than as an absence. DEFAULT_COOKIE_NAME = "_oauth2_proxy" CSRF_SUFFIX = "_csrf" # ============================================================================================= # REGISTER — SHARED_DEFAULT_COOKIE_OWNER. Exactly one entry, and it must match a real instance. # # The one instance permitted to hold DEFAULT_COOKIE_NAME, and the one permitted a zone-wide # cookie-domain. Both exemptions belong to the SAME object for the SAME reason, so they are one # register rather than two: the shared forward-auth proxy is not a per-app sidecar, it is the # single session that every forward-auth-gated surface on the platform rides on. Renaming its # cookie signs all of them out simultaneously, and host-scoping it would break the cross-host # forward-auth that is its entire job. # # Shrink-only, and exact in BOTH directions: an entry that matches no instance FAILS as stale # (the proxy was renamed or removed and the exemption is now a hole), and an instance holding # the default WITHOUT an entry FAILS as an unregistered heir. Adding an entry is a review event # on a sensitive path. # # Keyed by ":". # ============================================================================================= SHARED_DEFAULT_COOKIE_OWNER: dict[str, str] = { "platform/oauth2-proxy/helmrelease.yaml:oauth2-proxy": "The SHARED forward-auth proxy (ADR-0014, platform/oauth2-proxy/README.md). It is the " "single authenticator behind every forward-auth-gated surface that is NOT fronted by " "its own sidecar, so its cookie is the platform session: renaming it signs every one " "of them out at once, and that is why the 2026-09-29 crafty fix changed only the " "sidecar side. Its cookie-domain is zone-wide for the same structural reason — " "cross-host forward-auth is what it is for.", } def is_test_fixture(p: Path) -> bool: """conformance/tests/**/{golden,fixtures}/** is assertion DATA, not deployed manifests. Auditing a golden would be auditing a copy of something already audited through apps/. Same exclusion the sibling l0-static checks make, and the same "reference, not declaration" distinction ci/touched-requirements.sh draws for fixture paths. """ 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: a sidecar sits under spec.template.spec.containers in a Deployment, but the same container shape appears under a CronJob's jobTemplate, a DaemonSet, or a 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 flag_from_args(args: list, flag: str): """Value of `--flag=value` or `--flag value` in an argv list, else None.""" pref = "--" + flag for i, a in enumerate(args): if not isinstance(a, str): continue if a.startswith(pref + "="): return a.split("=", 1)[1] if a == pref and i + 1 < len(args) and isinstance(args[i + 1], str): return args[i + 1] return None def host_of(url): """Host part of an https://host/path URL, else None.""" if not isinstance(url, str): return None m = re.match(r"^https?://([^/]+)", url) return m.group(1) if m else None # ============================================================ (0) INVENTORY # instances: list of dicts {rel, ident, kind, declared_name, cookie_domain, redirect_host} instances: list[dict] = [] unparseable: list[str] = [] # Files the grep gate says contain an instance, so the parse can be cross-checked against it. grep_files: set[str] = set() 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 audited 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. Same fix as egress-paths-selected-by-rules. .claude/ is gitignored. if any(part.startswith(".") for part in relparts) or is_test_fixture(p) or ".sops." in p.name: continue txt = read(p) if not txt: continue rel = str(p.relative_to(ROOT)) # Same anchoring as check.sh's skip gate: `image:` (or a chart name), never a bare path — # see the comment there for the Envoy `serviceName: oauth2-proxy/oauth2-proxy:80` near-miss. if re.search(r"image:\s*\S*oauth2-proxy|chart:\s*oauth2-proxy", txt): grep_files.add(rel) elif "oauth2-proxy" not in txt: continue try: docs = [d for d in yaml.safe_load_all(txt) if isinstance(d, dict)] except yaml.YAMLError as exc: unparseable.append(f"{rel} ({exc})") continue for d in docs: # --- shape 1: a container running the oauth2-proxy image ------------------------- 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 args = [a for a in (list(c.get("args") or []) + list(c.get("command") or []))] env = {e.get("name"): e.get("value") for e in (c.get("env") or []) if isinstance(e, dict) and "value" in e} owner = (d.get("metadata") or {}).get("name") or "?" instances.append({ "rel": rel, "ident": f"{owner}/{name or 'oauth2-proxy'}", "kind": "container", "declared_name": flag_from_args(args, "cookie-name") or env.get("OAUTH2_PROXY_COOKIE_NAME"), "cookie_domain": flag_from_args(args, "cookie-domain") or env.get("OAUTH2_PROXY_COOKIE_DOMAIN"), "redirect_host": host_of(flag_from_args(args, "redirect-url") or env.get("OAUTH2_PROXY_REDIRECT_URL")), }) # --- shape 2: the shared proxy's HelmRelease values ------------------------------ if d.get("kind") != "HelmRelease": continue spec = d.get("spec") or {} chart = (((spec.get("chart") or {}).get("spec") or {}).get("chart") or "") if "oauth2-proxy" not in str(chart): continue values = spec.get("values") or {} extra = values.get("extraArgs") or {} if isinstance(extra, list): # the chart also accepts a bare argv list extra = {a.lstrip("-").split("=", 1)[0]: a.split("=", 1)[1] for a in extra if isinstance(a, str) and "=" in a} cfg = values.get("config") or {} get = lambda k: extra.get(k, cfg.get(k)) # noqa: E731 - two-source lookup, used 3x instances.append({ "rel": rel, "ident": str((d.get("metadata") or {}).get("name") or "oauth2-proxy"), "kind": "helmrelease", "declared_name": get("cookie-name"), "cookie_domain": get("cookie-domain"), "redirect_host": host_of(get("redirect-url")), }) for u in unparseable: check(False, f"{u} is not parseable YAML — every oauth2-proxy instance in it went unaudited") for ins in instances: ins["key"] = f"{ins['rel']}:{ins['ident']}" # THE CORE MODELLING DECISION: an omitted --cookie-name is not "unset", it is a CLAIM on # oauth2-proxy's built-in default. Resolving it here is what lets assertion (1) below be a # plain distinctness test with no special case, and it is exactly the step whose absence # made the defect invisible to every reviewer who read these manifests. ins["effective_name"] = ins["declared_name"] or DEFAULT_COOKIE_NAME # Anti-vacuous. The floor is deliberately low (a real removal should not turn this check red), # so the load-bearing guard is the grep/parse CROSS-CHECK beneath it: every file the cheap # text scan says holds an instance must yield at least one PARSED instance. A parser that # quietly stopped matching would keep the floor and fail this. check(len(instances) >= 5, f"only {len(instances)} oauth2-proxy instance(s) parsed out of the tree. This repo " f"authors more than that (five rendered sidecars, crafty's, and the shared proxy), so " f"the inventory has stopped seeing instances and every assertion below would pass " f"vacuously while a collision went unaudited") parsed_files = {ins["rel"] for ins in instances} for rel in sorted(grep_files - parsed_files): check(False, f"{rel} contains an oauth2-proxy image or chart reference but NO instance was parsed " f"out of it. Either it uses a shape iter_containers()/the HelmRelease reader does not " f"recognise, or the file stopped parsing. Its cookie configuration is UNAUDITED, which " f"is the same state the whole fleet was in before this check existed") # ============================================================ (1) DISTINCT EFFECTIVE NAMES by_name: dict[str, list[dict]] = {} for ins in instances: by_name.setdefault(ins["effective_name"], []).append(ins) for nm, group in sorted(by_name.items()): if len(group) < 2: continue who = ", ".join(sorted(g["key"] for g in group)) default_note = ( " Every one of them OMITS --cookie-name, so they are all claiming oauth2-proxy's " "built-in default. Omitting the flag is not neutral: it IS this collision." if nm == DEFAULT_COOKIE_NAME else "") check(False, f"{len(group)} oauth2-proxy instances share the cookie name '{nm}': {who}.{default_note}\n" f" WHY THIS IS A BUG AND NOT A STYLE RULE: these are INDEPENDENT instances with " f"DIFFERENT cookie-secrets (measured on prod 2026-09-30: seven distinct values), so " f"neither can DECRYPT the other's cookie — it can only OVERWRITE it. A browser keys a " f"cookie by (name, domain, path), so signing into one app destroys the session of every " f"other app sharing that key. The loser logs 'No valid authentication in request', " f"bounces to Keycloak, is redirected straight back because the SSO session is still " f"live, and overwrites the winner in turn. The user sees 'I signed into Keycloak but " f"I'm still not signed in' and goes looking in Keycloak, where nothing is wrong.\n" f" FIX: give each per-app instance its own --cookie-name. For a service rendered " f"from catalog/, do NOT hand-edit the golden — catalog/render.py derives the flag from " f"the service name; re-render instead (ci/render-golden.sh's header has the canonical " f"command). Exactly one instance may hold '{DEFAULT_COOKIE_NAME}', and it must be the " f"one registered in SHARED_DEFAULT_COOKIE_OWNER.") # ============================================================ (2) SESSION vs CSRF DERIVATION # oauth2-proxy names the CSRF cookie _csrf. So a SESSION name that happens to equal # another instance's name + "_csrf" collides on the CSRF cookie while assertion (1) sees two # perfectly distinct strings. This is the near-miss the obvious fix invites: naming the shared # proxy's neighbour `_oauth2_proxy_csrf` reads as "clearly different" and is not. effective = {ins["effective_name"] for ins in instances} for ins in sorted(instances, key=lambda i: i["key"]): base = ins["effective_name"] if not base.endswith(CSRF_SUFFIX): continue victim = base[: -len(CSRF_SUFFIX)] check(victim not in effective, f"{ins['key']} uses the SESSION cookie name '{base}', which is exactly the CSRF cookie " f"oauth2-proxy derives for the instance named '{victim}' (it appends '{CSRF_SUFFIX}'). " f"The two session names look distinct and the CSRF cookies collide, so sign-in breaks " f"with a state-mismatch instead of a session loss — the same bug wearing a different " f"error message. Pick a name that is not another instance's name + '{CSRF_SUFFIX}'") # ============================================================ (3) THE DEFAULT HAS ONE OWNER default_holders = sorted(ins["key"] for ins in instances if ins["effective_name"] == DEFAULT_COOKIE_NAME) registered = set(SHARED_DEFAULT_COOKIE_OWNER) for key in sorted(registered - set(ins["key"] for ins in instances)): check(False, f"SHARED_DEFAULT_COOKIE_OWNER registers '{key}', but no such oauth2-proxy instance " f"exists in the tree. The register is SHRINK-ONLY and exact: a stale entry is a " f"standing exemption for an object that is gone, so the next instance to inherit the " f"default name would be silently excused. Remove the entry, or restore the instance") for key in default_holders: check(key in registered, f"{key} holds oauth2-proxy's DEFAULT cookie name '{DEFAULT_COOKIE_NAME}' but is NOT " f"registered in SHARED_DEFAULT_COOKIE_OWNER. Exactly ONE instance may hold the default, " f"and it is the SHARED forward-auth proxy — whose cookie is the platform-wide session " f"every forward-auth-gated surface rides on, so renaming IT signs them all out at once. " f"Every other instance must declare its own --cookie-name. If this instance really is " f"the new shared proxy, register it here with the reason; if it is a per-app sidecar, " f"give it a unique name") check(len(default_holders) <= 1, f"{len(default_holders)} instances hold the default cookie name: {', '.join(default_holders)}. " f"The default is a NAME, not an absence — it can have exactly one owner") # ============================================================ (4) HOST-SCOPED COOKIE DOMAIN # See the chainsaw-test header for the argument (distinct names alone already fix the eviction; # host-scoping is asserted anyway because a zone-wide cookie is TRANSMITTED to every host in the # zone, and because it makes a future name collision contained instead of platform-wide). for ins in sorted(instances, key=lambda i: i["key"]): if ins["key"] in registered: continue # the shared proxy: zone-wide by design, registered dom, rhost = ins["cookie_domain"], ins["redirect_host"] check(dom is not None, f"{ins['key']} declares no --cookie-domain. oauth2-proxy then scopes the cookie to the " f"request host, which happens to be correct — but it is correct BY ACCIDENT and says " f"nothing to the next reader. Declare it explicitly as this app's own host") if dom is None: continue check(not dom.startswith("."), f"{ins['key']} sets --cookie-domain={dom}. A leading dot makes the cookie ZONE-WIDE: " f"the browser sends this app's session token to every other host under that domain, " f"including hosts behind no proxy at all, and it is what turns any future cookie-name " f"collision into a platform-wide outage instead of a contained one. Scope it to this " f"app's own host. Only the registered shared forward-auth proxy may be zone-wide, " f"because cross-host forward-auth is its job") if rhost: check(dom == rhost, f"{ins['key']} sets --cookie-domain={dom} but its --redirect-url host is {rhost}. " f"The cookie is then set for a domain the sign-in flow never returns to (or for a " f"wider one than this app owns), so the session either does not stick or leaks " f"beyond the app. Both flags describe the SAME app: make them agree") # ============================================================ (5) THE RENDERER DERIVES # Five of these instances are GENERATED. Auditing only the goldens would let someone "fix" the # tree by hand and leave the generator emitting the defect for the next service onboarded — and # the next re-render would then silently revert the hand fix. render_py = ROOT / "catalog" / "render.py" check(render_py.is_file(), "catalog/render.py is missing — the generator of five of these instances 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; assertions (5) below cannot run, and the generator is unaudited") if fn: cookie_name_lines = [ln for ln in fn.splitlines() if "--cookie-name" in ln] check(bool(cookie_name_lines), "catalog/render.py's _oauth2_sidecar_container() emits NO --cookie-name at all, so " "every service it renders claims oauth2-proxy's default and collides with every " "other. This is the defect itself, back in the one place that reintroduces it " "fleet-wide in a single edit") check(any("s['name']" in ln or 's["name"]' in ln for ln in cookie_name_lines), "catalog/render.py emits --cookie-name without DERIVING it from the " "ServiceDefinition's own name. A constant collides with every sibling by " "construction; a per-service table leaves the NEXT app onboarded defaulting into " "the collision, which is how twelve ServiceMonitors acquired one identical wrong " "port name in a single edit. Derive it from s['name']") cookie_dom_lines = [ln for ln in fn.splitlines() if "--cookie-domain" in ln] check(any("{host}" in ln for ln in cookie_dom_lines), "catalog/render.py's sidecar --cookie-domain is not derived from the route host. A " "zone-wide literal (`.{ZONE}`) broadcasts every app's session cookie across the " "whole zone — see assertion (4)") check(not any(re.search(r"--cookie-name=_oauth2_proxy_[a-z0-9]", ln) for ln in cookie_name_lines), "catalog/render.py hardcodes a literal service name into --cookie-name. Whatever " "service that is, every OTHER service now renders the same constant and they all " "collide. Derive the name instead") # ============================================================ (6) PROVE THE SHIPPED RENDERER # Once the tree is compliant, (1)-(4) have nothing left to catch and their passing is evidence # of nothing. This runs the SHIPPED renderer as a SUBPROCESS on two synthetic ServiceDefinitions # and asserts the property directly, so the guard stays falsifiable. def _sd(name: str, port: int) -> str: 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": {"mode": "forward-auth", "groups": ["platform-admins"]}, }, sort_keys=False) def _sidecar_args(rendered: str) -> list: for doc in yaml.safe_load_all(rendered): 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(): fixtures = {"cookiefixa": 8111, "cookiefixb": 8112} rendered_args: dict[str, list] = {} with tempfile.TemporaryDirectory() as td: for svc, port in fixtures.items(): f = Path(td) / f"{svc}.yaml" f.write_text(_sd(svc, port)) proc = subprocess.run([sys.executable, str(render_py), str(f)], capture_output=True, text=True) check(proc.returncode == 0, f"the shipped catalog/render.py failed on a synthetic forward-auth fixture " f"({svc}): rc={proc.returncode} {proc.stderr.strip()[:200]}") if proc.returncode == 0: rendered_args[svc] = _sidecar_args(proc.stdout) names, domains = {}, {} for svc, args in rendered_args.items(): names[svc] = flag_from_args(args, "cookie-name") domains[svc] = flag_from_args(args, "cookie-domain") check(bool(args), f"the shipped renderer produced no oauth2-proxy sidecar for synthetic forward-auth " f"fixture {svc} — the sidecar is how a forward-auth app is gated at all (R-IDP-02)") check(names[svc] not in (None, DEFAULT_COOKIE_NAME), f"the shipped renderer gave synthetic fixture {svc} the cookie name " f"{names[svc]!r}. A newly-onboarded forward-auth app would therefore default " f"straight into the fleet collision — which is exactly how five services acquired " f"this defect without anyone making a decision") check(domains[svc] == f"{svc}.home.keithjackson.dev", f"the shipped renderer scoped synthetic fixture {svc}'s cookie to " f"{domains[svc]!r} rather than to its own host — a newly-onboarded app would " f"broadcast its session cookie across the whole zone") check(any(isinstance(a, str) and a.startswith("--whitelist-domain=.") for a in args), f"synthetic fixture {svc} lost its ZONE-WIDE --whitelist-domain. That flag governs " f"permitted post-login REDIRECT targets, not cookie scope; narrowing it with the " f"cookie domain breaks the sign-in bounce") if len(rendered_args) == 2: a, b = sorted(rendered_args) check(names[a] != names[b], f"the shipped renderer gave two DIFFERENT synthetic services the SAME cookie name " f"({names[a]!r}). Whatever the committed goldens currently look like, the generator " f"will collide every forward-auth app it renders from now on") check(domains[a] != domains[b], f"the shipped renderer gave two different synthetic services the same cookie domain " f"({domains[a]!r}) — the cookies would share a scope even with distinct names") # ============================================================ VERDICT inv = " | ".join(f"{i['key']} -> {i['effective_name']} @ {i['cookie_domain'] or ''}" for i in sorted(instances, key=lambda x: x["key"])) print(f"oauth2-proxy instances audited ({len(instances)}): {inv}") if FAILURES: print(f"\nCHECK FAIL (oauth2-proxy-cookie-names-distinct): {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-cookie-names-distinct — {CHECKS} assertions (R-IDP-02, R-IDP-06)") PY