diff --git a/findings/RISK-F-0002-ops-warden-sign-ungated.md b/findings/RISK-F-0002-ops-warden-sign-ungated.md new file mode 100644 index 0000000..b814a58 --- /dev/null +++ b/findings/RISK-F-0002-ops-warden-sign-ungated.md @@ -0,0 +1,122 @@ +--- +id: RISK-F-0002 +type: finding +title: "ops-warden signs SSH certificates with no authorization decision, and its unblock is now unsafe" +status: open +reported_by: ops-warden +reported_via: ops-warden +routed_by: ops-warden +date_reported: "2026-08-18" +system: ops-warden +environment: production +fix_owner: ops-warden +fix_tracking: WARDEN-WP-0007 (gate shipped, disabled) / FLEX-WP-0007 (runtime deploy) +related: [RISK-F-0001] +# risk-nexus's to set, not the reporter's (INTENT, "What it does not own"). +severity: unset +disclosure: unset +escalation: unset +--- + +# RISK-F-0002 — the SSH signing gate is off, and turning it on is now the more dangerous move + +## What is true + +`ops-warden` issues short-lived SSH certificates for `adm`/`agt`/`atm` actors +against production OpenBao. It ships a pre-sign authorization gate that asks +`flex-auth` whether a given actor may sign. **That gate is disabled in +production and has been since it shipped.** + +Verifiable in this estate's own files: + +- `ops-warden/examples/warden.production.example.yaml:22` — `policy.enabled: false` +- `ops-warden/src/warden/policy.py:33` — `if not cfg.enabled: return None` + +Every production `warden sign` therefore proceeds with no authorization +decision. What still constrains it: the actor must exist in `inventory.yaml`, +TTL is enforced per actor type (`adm` 48h, `agt` 24h, `atm` 8h), the caller must +hold a scoped `VAULT_TOKEN`, and every issuance is logged. What does not +constrain it: any per-request judgement about whether this actor should be +getting this certificate right now. Possession of the signing token is the +whole authorization model. + +This is a known, deliberate state, not a discovery. It is filed because +"deliberate and known" is exactly the condition that stops being tracked once +the person who decided it stops looking — and because the reason it is still +true has just changed. + +## Why this is filed now rather than in June + +The gate's stated blocker has always been `FLEX-WP-0007`: `flex-auth` is not +deployed at a reachable URL, so enabling a `fail_closed: true` gate would break +all signing. That is a availability blocker, and it is boring. + +`RISK-F-0001` changes the shape of it. The production config points the gate at: + +``` +flex_auth_url: http://flex-auth.flex-auth.svc.cluster.local:8080 +``` + +That is precisely the ClusterIP surface `RISK-F-0001` reports as authenticating +no caller. So the sequencing is no longer "enable it once flex-auth is up". It +is: + +> **Enabling the gate before `RISK-F-0001` is fixed would make things worse, not +> better.** + +Today, an unauthorized signing attempt is unauthorized and unrecorded as such. +With the gate on and the oracle forgeable, an attacker who can reach the +ClusterIP can obtain a genuine `allow` and the signature log will carry a +`policy_decision_id` attesting that the issuance was authorized. The gate would +convert an absent control into a **false attestation** — and the audit trail, +which currently makes no claim, would start making one that is wrong. + +An authorization check that can be forged is worse than no authorization check, +because only one of the two lies in the record afterwards. + +## What ops-warden is doing about it + +The mechanism recommendation for `RISK-F-0001` was answered on 2026-08-17 +(`ops-warden/wiki/NetKingdomSecurityMap.md`, "Service-to-service caller +authentication"): projected, audience-scoped Kubernetes ServiceAccount tokens +verified by TokenReview, with the caller's asserted `system` bound to the +authenticated ServiceAccount. `ops-warden` implements the calling side and has +committed to a warn-only rollout on `flex-auth`'s schedule. + +The ordering constraint is recorded there and restated here because it is a +risk statement, not an architecture one: + +``` +flex-auth warn-only -> ops-warden gate presents its SA token -> logs clean + -> flex-auth fail-closed -> ops-warden policy.enabled: true +``` + +`policy.enabled` must not flip anywhere while `/v1/check` answers +unauthenticated callers. Nothing else is asked of `flex-auth` by this record. + +## What this repo is asked to decide + +1. **Severity.** Note the two states are not equally bad and the register should + probably say which it is scoring: the gate being *off* (a missing control, + honestly represented) versus the gate being *turned on prematurely* (a + present control that lies). The second is the one worth a severity. +2. **Whether this is one finding or a dependency on `RISK-F-0001`.** It is + filed separately because the fix owner differs and because the "off" state + has its own standing regardless of how `RISK-F-0001` resolves. If this repo + would rather carry it as a consequence of `RISK-F-0001` than as a peer, + that is a reasonable call and `ops-warden` will not re-file it. +3. **Escalation.** `ops-warden` does not think this needs the operator: it is + known, owned, and its dangerous failure mode is a *sequencing* error that is + now written down in both repos. Recorded so the judgement is this repo's and + not assumed. + +## The general point, offered for the register's design + +`RISK-F-0001` observed that four live defects were found by repos reading their +own code against a ladder, none by monitoring. This finding is a fifth, found +the same way — by reading an answer we had just given and noticing it changed +the risk of a decision we had already made and filed away as merely blocked. + +The estate's habit of recording a blocker once and not revisiting it is the +thing to watch. A blocker is a claim about the world at a date. `RISK-F-0001` +invalidated this one in a day, and nothing would have re-checked it.