diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..34473a0 --- /dev/null +++ b/Makefile @@ -0,0 +1,16 @@ +PY := python3 +TOOLS := tools + +.PHONY: help register check + +help: + @echo "make register - rebuild REGISTER.md from findings/" + @echo "make check - verify the index is current, then report what is going quiet" + +register: + @$(PY) $(TOOLS)/register_index.py + +check: + @$(PY) $(TOOLS)/register_index.py --check + @echo + @$(PY) $(TOOLS)/register_check.py diff --git a/REGISTER.md b/REGISTER.md new file mode 100644 index 0000000..fc14f38 --- /dev/null +++ b/REGISTER.md @@ -0,0 +1,37 @@ +# Register + +Generated by `tools/register_index.py` from `findings/`. Do not edit by hand. Last built 2026-08-19. + +3 open of 3 findings; 0 notes below the floor. + +## Findings + +| ID | Finding | System | Severity | Disclosure | Escalation | Fix owner | Status | Review by | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| [RISK-F-0003](findings/RISK-F-0003-ops-warden-read-boundary-ungraded-lanes.md) | ops-warden agent read-boundary does not fire on ungraded catalog lanes | ops-warden | **high** | embargoed | none | ops-warden | open | 2026-09-18 | +| [RISK-F-0002](findings/RISK-F-0002-ops-warden-sign-ungated.md) | ops-warden signs SSH certificates with no authorization decision, and its unblock is now unsafe | ops-warden | medium | embargoed | **required** (t6, pending-operator) | ops-warden | open | 2026-11-17 | +| [RISK-F-0001](findings/RISK-F-0001-flex-auth-unauthenticated-check.md) | flex-auth /v1/check authenticates no caller | flex-auth | **critical** | embargoed | **required** (t1, pending-operator) | flex-auth | open | 2026-08-26 | + +## Constraints + +Hazards created by acting in the wrong order. Each binds another finding's remediation. + +| From | Binds | Severity | Constraint | +| --- | --- | --- | --- | +| RISK-F-0002 | RISK-F-0001 | **high** | policy.enabled must not be turned on while flex-auth /v1/check answers unauthenticated callers — the gate would sign a false attestation | + +## Embargoes + +Held from publication with a stated condition. A hold with no moving condition is a stall. + +| Finding | Since | Lifts when | Re-decided | +| --- | --- | --- | --- | +| RISK-F-0003 | 2026-08-19 | the five exec_capable lanes graded under WARDEN-WP-0032-T05 | 2026-09-18 | +| RISK-F-0002 | 2026-08-19 | FLEX-WP-0015-T02 shipped and ops-warden policy.enabled true in production | 2026-11-17 | +| RISK-F-0001 | 2026-08-19 | FLEX-WP-0015-T02 ships to production | 2026-08-26 | + +## How to read this + +Severity is `docs/method/severity.md`; disclosure `docs/method/disclosure.md`; +escalation `docs/method/escalation.md`; review dates `docs/method/review.md`. +A constraint may be graded higher than the finding that carries it — read both. diff --git a/docs/method/disclosure.md b/docs/method/disclosure.md new file mode 100644 index 0000000..33bcc29 --- /dev/null +++ b/docs/method/disclosure.md @@ -0,0 +1,116 @@ +--- +id: RISK-METHOD-DISCLOSURE +type: method +title: "Disclosure: publish now, hold, or restrict" +status: adopted +owner: risk-nexus +adopted: "2026-08-19" +workplan: RISK-WP-0001-T02 +review_interval: 180d +--- + +# Disclosure + +Whether and when a finding is published. `policy-nexus` is the surface; this +document decides what it is handed. + +## What disclosure is not + +**A finding file in this repo is not a publication.** This repo is a private +checkout on a private forge. Holding a finding means not routing it to +`policy-nexus`; it does not mean hiding it from the estate, from the owning +repo, or from the operator. Every state below is fully visible internally. + +## The states + +| State | Meaning | Entry condition | +| --- | --- | --- | +| `public` | Published through `policy-nexus` now. | The finding is fixed, or reading it gives no one an advantage they do not already have. | +| `embargoed` | Held, with a stated condition that lifts it. | Live, unfixed, and the text would help someone reach the defect faster than they could without it. | +| `restricted` | Held with no expected lift. | Publication would remain harmful after the fix — third-party material, a named person, or a credential-shaped detail that survives remediation. | + +There is no fourth state and no `unset` after grading. A finding whose +disclosure has not been decided is an ungraded finding. + +## Embargo is a record, not a silence + +`INTENT.md` requires the record that a delay was deliberate rather than a +document quietly going missing. An `embargoed` finding therefore carries: + +```yaml +disclosure: embargoed +embargo_condition: "FLEX-WP-0015-T02 ships to production" +embargo_since: "2026-08-19" +embargo_review: "2026-08-26" +``` + +`embargo_condition` must be an event someone can observe, not a mood. "Until +it is safer" is not a condition. `embargo_review` follows the finding's +severity interval from `docs/method/review.md`; when it passes, the embargo is +re-decided, not extended by default. + +An embargo that has outlived two consecutive reviews without its condition +moving is itself a finding — the remediation has stalled, and the hold is now +doing the work the fix was supposed to do. + +## The build-mode deferral, re-taken + +`INTENT.md` recorded controlled disclosure as deferred to production, reasoning +that build mode has no users to expose. `RISK-F-0001` arrived and tested it: a +live authorization bypass in the service every other service trusts, with +publish-or-forget as the only available choice. + +**The deferral is narrowed, not kept and not abandoned.** + +What was right about it: build mode does have no consumers to protect, and +building an embargo *mechanism* — timed release, staged notification, +coordinated disclosure with third parties — before there is anyone to +coordinate with would be machinery for its own sake. + +What was wrong about it: it conflated the mechanism with the decision. The +argument for publishing in build mode is that there are no users to expose. +That argument says nothing about attackers, and `RISK-F-0001` is exactly the +class where the two come apart — the finding names an unauthenticated +decision surface and the service that carries it. Publishing that while it is +live helps precisely one kind of reader. + +So the ruling is: + +1. **Build-mode default stays publish.** Architecture, method, fixed findings, + and findings whose exposure is already bounded go out. The estate publishing + what it knows is wrong remains the norm and does not need a case made for it + each time. +2. **Live-and-reachable is the exception, and it exists now.** A finding that + is unfixed *and* whose text shortens the path to the defect is `embargoed` + until the fix lands. That is the missing middle `INTENT.md` said did not + exist. It costs one front-matter field and a line in `REGISTER.md`. +3. **The mechanism stays deferred.** No timed release, no coordinated + disclosure protocol, no notification tiers. Those wait for real users, as + originally reasoned. What is not deferred is the *decision*, because + `RISK-F-0001` demonstrated the decision is needed before the machinery is. + +This is a decision of this repo, taken 2026-08-19 with `RISK-F-0001`, +`RISK-F-0002` and `RISK-F-0003` in hand rather than hypothetically. It is +revisable, and the production transition is the scheduled moment to revisit it. + +## Test for "shortens the path" + +Ask: does the finding tell a reader something that materially reduces the work +of reaching the defect, beyond what reading the repo would give them? + +- A file path and line number in a private repo — no, that is already there. +- "This surface authenticates nobody, here is its cluster address" — yes. +- "These five named lanes vend real secret values without the boundary firing" + — yes. +- "This system had no backups configured" — no, once backups exist; yes, while + they do not, because it names when destruction is unrecoverable. + +When the answer is genuinely unclear, embargo and re-decide at the review. The +cost of a wrong embargo is a delayed publication; the cost of a wrong publish +is not recoverable. + +## Publication happens elsewhere + +A `public` finding is handed to `policy-nexus` under its publication contract +and gets a permanent address there. This repo never serves it and never +edits it after handover; corrections go through the same route as the original. diff --git a/docs/method/escalation.md b/docs/method/escalation.md new file mode 100644 index 0000000..c261c79 --- /dev/null +++ b/docs/method/escalation.md @@ -0,0 +1,148 @@ +--- +id: RISK-METHOD-ESCALATION +type: method +title: "Escalation: what reaches the operator personally" +status: proposed +owner: the-custodian +drafted_by: risk-nexus +drafted: "2026-08-19" +workplan: RISK-WP-0001-T03 +review_interval: 90d +--- + +# Escalation + +`INTENT.md` gives `the-custodian` the duty of deciding what must reach the +operator personally rather than sitting in a register, and says the rule is not +yet written. This is the rule, drafted by `risk-nexus`. + +**Status: `proposed`.** The custodian adopts it. It is not in force until the +front-matter says `adopted`, and an unadopted rule is worse than an unwritten +one because it looks like coverage. + +Getting this wrong in either direction is a failure: escalating everything +makes the operator the queue, escalating nothing makes the register a place +where serious things go quiet. + +## What escalation is + +A message to the operator personally — inbox message to `the-custodian`, and +said out loud in whatever session is running — containing exactly four things: + +1. the finding id and one sentence of what is true; +2. the decision being asked for; +3. what happens if no answer comes; +4. by when. + +It is **not** a copy of the finding, not a status report, and not a request for +the operator to do the work. If the operator's answer would not change +anything, it was not an escalation. + +The operator is expected to rule, or to defer explicitly with a date. Silence +is not resolution: an unanswered escalation past its date goes back into the +register as an open item on this repo, visible in `REGISTER.md`, and is raised +again once. It is never quietly dropped, and it is never repeated weekly until +someone answers to make it stop. + +## The triggers + +Each of the five candidates from `INTENT.md` is adopted, dropped, or bounded. +A trigger without a threshold is not a rule. + +### 1. Real tenant data — adopted, bounded + +Escalate when a finding exposes real data belonging to a real counterparty, or +**the decision that governs access to it**, while live and unfixed. + +The bound matters: `INTENT.md` asks whether governing the access counts as +exposing the data. It does. An authorization oracle that can be forged is not +one step removed from the data; it is the step. Test data and empty tenants do +not trigger it — the word is *real*. + +### 2. Legal or regulatory obligation — adopted, unbounded + +Escalate when a finding creates or reveals an obligation with an outside +counterparty: a notification duty, a retention or erasure requirement, a +contractual commitment, an in-scope determination under a regime. + +No threshold. The estate cannot decide unilaterally that an obligation is +small, and this repo is explicitly not the place where legal advice is given. + +### 3. Spend — adopted, bounded + +Escalate when remediation needs money that is not already committed: +**recurring above €50/month, or one-off above €500.** + +Below those, the owning repo decides and records it. The numbers are a +starting proposal from this repo; the operator owns them and should overwrite +them on adoption if they are wrong. + +### 4. Ownership disagreement — adopted, bounded by one exchange + +Escalate when two repos disagree about who owns a fix **and one routing +exchange has already failed to settle it.** + +Disagreement itself is normal and is the register's job to route. What needs +the operator is a *stuck* disagreement, because only the operator can assign +work across repos that will not take it. One exchange, then escalate — not +three, and not zero. + +### 5. Stalled remediation — adopted, bounded by interval + +Escalate when a finding's fix has not moved for **twice its review interval** +(`docs/method/review.md`): 14 days for `critical`, 60 for `high`, 180 for +`medium`. `low` findings do not escalate on staleness; they age out through +review. + +"Not moved" means the fix owner's tracking record has not changed state and no +new evidence has arrived. A deliberate, stated deferral by the owner is +movement. Silence is not. + +### 6. Ordering hazard across repos — added + +Escalate once when remediating finding A in the wrong order relative to +finding B would create a **false attestation** — a control that starts +claiming a check happened when it did not. + +This trigger is not in `INTENT.md`'s list. It is added because `RISK-F-0002` +produced the case: enabling the `ops-warden` signing gate against a forgeable +`flex-auth` would convert an absent control into a signed audit record +asserting an authorization that was never made. Ordering hazards are cheap to +avoid before the fact and expensive to detect afterwards, because the evidence +is what lies. + +The escalation is a single acknowledgement of the ordering, not a standing +supervision request. Once the operator has the constraint, the register carries +it. + +## What does not escalate + +Stated so the rule bites in both directions. + +- **Known, owned, and moving.** However bad. A `critical` finding with an + active fix and a responsive owner is register work. +- **Severity alone.** There is deliberately no "escalate everything + `critical`" trigger. Severity drives review cadence, not the operator's + attention. `RISK-F-0002` argued its own case for not escalating and was + right on triggers 1-5; it reaches the operator only through trigger 6, and + only once. +- **The reporter asking for it.** A repo can flag that it thinks something + needs the operator. That is input, not a decision. This repo rules. +- **Anything this repo can decide itself.** Severity, disclosure, filing + shape, review dates. Asking the operator to confirm a judgement that is this + repo's to make is how the operator becomes the queue. + +## Testing the rule against the register + +The draft was tested against all three open findings before it was proposed; +the rulings are in `docs/rulings/2026-08-19-first-grading.md`. + +Summary: `RISK-F-0001` escalates on trigger 1. `RISK-F-0002` does not escalate +on triggers 1-5 — agreeing with the reporter's own reading — but the +`RISK-F-0001`/`RISK-F-0002` ordering constraint escalates once on trigger 6, +which is the disagreement the rule was worth writing to surface. `RISK-F-0003` +does not escalate; it is known, owned, tracked, and moving. + +One escalation, covering two findings, out of three findings. That ratio is +the rule doing its job. If it ever reaches "most findings", the rule is wrong, +not the register. diff --git a/docs/method/review.md b/docs/method/review.md new file mode 100644 index 0000000..75ff08a --- /dev/null +++ b/docs/method/review.md @@ -0,0 +1,109 @@ +--- +id: RISK-METHOD-REVIEW +type: method +title: "Review and expiry: what happens when nobody looks" +status: adopted +owner: risk-nexus +adopted: "2026-08-19" +workplan: RISK-WP-0001-T04 +review_interval: 180d +--- + +# Review and expiry + +`INTENT.md`: a finding that has sat untouched past its review date is itself a +finding. Silence is not resolution. This document makes that operable. + +## Intervals + +Set from the headline severity at grading time, and reset at each review. + +| Severity | Interval | Stale (2×, escalation trigger 5) | +| --- | --- | --- | +| `critical` | 7 days | 14 days | +| `high` | 30 days | 60 days | +| `medium` | 90 days | 180 days | +| `low` | 180 days | — (ages out through review) | + +A finding graded `critical` is being looked at weekly or it is not being +managed. If weekly is unbearable, the honest response is to re-grade it, not +to let the date slide. + +## What a review is + +Four questions, answered in writing on the finding. It takes minutes; it is not +an investigation. + +1. **Is the grade still right?** Re-read impact and likelihood against what has + changed. New facts move the grade in both directions. +2. **Is the blocker still true?** This is the one `RISK-F-0002` bought with + evidence: *a blocker is a claim about the world at a date*. Its own stated + blocker — "`flex-auth` is not deployed yet" — was invalidated within a day + by `RISK-F-0001`, and nothing would have re-checked it. Every review + re-checks every stated blocker. +3. **Has the fix moved?** Read the owner's tracking record, not our memory of + it. Confirm the record still exists and still refers to this defect. +4. **Is the disclosure state still right?** An embargo whose condition has + been met is published; an embargo whose condition has not moved in two + reviews is a stall. + +The finding gets a dated line under `## Reviews`, `last_reviewed` is updated, +and `review_by` is pushed by one interval. A review that changes nothing still +writes the line — "checked, nothing moved" is the evidence that the silence was +observed rather than accidental. + +## When a review is missed + +Overdue is not a status change on the finding. It is a fact about *this repo*, +and it surfaces in three places: + +- `make check` reports it (`tools/register_check.py`), always, unconditionally. +- `REGISTER.md` shows the finding as overdue with the day count. +- At **twice** the interval, escalation trigger 5 fires for `critical`, + `high` and `medium`. That is the point at which the register's own silence + reaches the operator. + +The register does not auto-escalate severity for lateness, and it does not +auto-close anything. Both would be the register lying about its own state to +make a number look better. + +## The production re-score + +Every finding carries `severity_at_production` alongside `severity` +(`docs/method/severity.md`). Where the two differ, the finding is flagged +`production_rescore: true`. + +On the day any part of the estate declares production readiness, every flagged +finding is re-scored before that declaration completes. This is not a review +date — it is an event, and it fires regardless of where the review dates +happen to sit. + +Until then, `make check` lists the flagged findings so the size of that +obligation is visible rather than discovered on the day. + +## Front-matter this adds + +```yaml +last_reviewed: "2026-08-19" +review_by: "2026-08-26" +production_rescore: true +``` + +`review_by` is what the nag reads. It is a date, never a duration, so that +nothing has to recompute an interval to know whether a finding is late. + +## Closing a finding + +A finding leaves `open` for exactly one of: + +- `fixed` — the owner's record shows the defect gone, and this repo has read + something concrete rather than been told. Publication follows if the + disclosure state was `embargoed`. +- `accepted` — the estate is deliberately carrying it. Requires who accepted + it, why, and a re-review date. `accepted` is not `closed`; it keeps a review + interval forever. +- `withdrawn` — the finding was wrong, or the defect never existed. Say which. + +There is no `stale`, no `wontfix` and no silent expiry. A finding that nobody +will fix and nobody will accept stays `open` and keeps arriving in the nag, +because that is the true state. diff --git a/docs/method/severity.md b/docs/method/severity.md new file mode 100644 index 0000000..0199e52 --- /dev/null +++ b/docs/method/severity.md @@ -0,0 +1,169 @@ +--- +id: RISK-METHOD-SEVERITY +type: method +title: "Severity: how bad, how likely, and what is not a risk at all" +status: adopted +owner: risk-nexus +adopted: "2026-08-19" +workplan: RISK-WP-0001-T01 +review_interval: 180d +--- + +# Severity + +This is `risk-nexus`'s judgement instrument. It is not canon, it is not a +standard, and it binds nobody else. It exists so that two findings graded a +month apart are graded the same way, and so that a grade can be argued with. + +It was written against three real findings (`RISK-F-0001`, `RISK-F-0002`, +`RISK-F-0003`) and it must keep grading those three sensibly or it is wrong. + +## The two axes + +### Impact — what happens if it goes wrong once + +| Band | Name | Test | +| --- | --- | --- | +| `I1` | negligible | Confined to one component. No data leaves it, no record is falsified, no recovery is lost. | +| `I2` | limited | One system's data or availability. Recoverable. Confined to a single tenant, actor or lane. | +| `I3` | serious | Crosses a boundary — tenant, system, or trust — or removes recoverability for one system. | +| `I4` | severe | Crosses the estate. What is compromised here propagates to everything that trusts it, or the data loss is unbounded. | + +Impact is scored at **one occurrence**, not at the worst imaginable campaign. +"An attacker who already owns the cluster could do this too" is not an impact +argument. + +### Likelihood — how far anyone has to reach + +| Band | Name | Test | +| --- | --- | --- | +| `L1` | remote | Requires access nobody currently holds and no ordinary process grants. | +| `L2` | possible | Requires a foothold the estate does grant somewhere — an in-cluster workload, an agent session, a scoped token. | +| `L3` | likely | Reachable from inside the normal working set with no additional step. | +| `L4` | present | No barrier at all, or it is already happening. | + +Likelihood is about **reach**, not about intent or about whether anyone has +bothered. `risk-nexus` does not model attackers; it models what the system +permits. + +Where the reporter has not established exposure, the finding says so and the +grade uses the band the *stated* facts support — not the worst case, and not +zero. `RISK-F-0001` explicitly declines to assume a default-deny NetworkPolicy +exists; the grade must decline with it, and the unverified fact becomes a +review item rather than a silent assumption in either direction. + +## The grid + +| | `L1` | `L2` | `L3` | `L4` | +| --- | --- | --- | --- | --- | +| `I4` | medium | high | **critical** | **critical** | +| `I3` | low | medium | high | **critical** | +| `I2` | low | low | medium | high | +| `I1` | note | low | low | medium | + +Four severities: `low`, `medium`, `high`, `critical`. `note` is not a +severity; see the floor. + +## The fidelity modifier + +**A control that lies is one impact band worse than the same control absent.** + +Apply `+1` impact band (capped at `I4`) when the failure mode produces a +*false record* rather than *no record*: an attestation that a check passed +when nothing checked, an audit line asserting an authorization that was never +made, a green signal derived from an unreachable test. + +The reasoning is `RISK-F-0002`'s and the register adopts it: an absent control +is a gap you can find by looking; a lying control is a gap that survives +looking, because the evidence you would look at is the thing that is wrong. +Only one of the two states misleads the person investigating afterwards. + +The modifier applies to the state being scored. A finding that describes both +states — control absent today, control lying if switched on in the wrong order — +gets **two scores and one of them is the register's headline**; see +"Which state is scored". + +## Which state is scored + +The headline `severity` is the state of the world **today**. A hazard that +would be created by a *future* action is not the headline, because a register +that scores hypotheticals stops describing the estate. + +The hazard is not lost. It is recorded on the finding as a named +**constraint** with its own grade, and it attaches to whatever action would +trigger it — usually another finding's remediation. `RISK-F-0002` is the +worked example: the gate being off is today (headline), the gate being +switched on while the oracle is forgeable is a constraint on `RISK-F-0001`'s +fix, graded separately and higher. + +If the constraint's grade is higher than the headline, the finding says so in +its ruling. A reader must not be able to come away with the low number and +miss the high one. + +## Build mode + +Every finding is graded twice: + +- `severity` — today, in build mode, with today's likelihood. +- `severity_at_production` — the same impact, with likelihood re-read for a + system carrying real users and real tenant data. + +Build mode is a legitimate likelihood input. "No consumer holds real data +here yet" genuinely lowers reach. It is *not* an impact input: what the defect +would do on the day it is exercised does not change because the calendar has +not reached production. + +Where the two grades differ, the production transition is a mandatory +re-score. `docs/method/review.md` binds the review date to it, so the re-score +is a scheduled event and not somebody's memory. + +## The floor + +`INTENT.md`: if a finding would not change anyone's decision, it is a note, +not a risk. Concretely, a register entry requires **both**: + +1. **An owner who could act.** Some repo, or the operator, can do something + about it. No actor, no entry. +2. **A decision that changes.** Recording it alters what someone does, when + they do it, or what they must not do first. + +Fails either test → it is a note in `notes/`, not a finding in `findings/`. +Notes are not graded, not reviewed, and not published. They exist so that +"we saw it" survives without inflating the register. + +An `I1`/`L1` cell is `note` in the grid for the same reason: something that +is both negligible and unreachable is a thing we know, not a risk we carry. + +Two things the floor does **not** exclude: + +- **Known and deliberate.** `RISK-F-0002` is a decision somebody made on + purpose. It still passes the floor, because it changes what may be switched + on and in what order. Deliberate is not the same as tracked. +- **Omission-shaped.** `RISK-F-0003` is a default that silently produces + ungoverned lanes. The individual lane is small; the default is not. + +## Provenance is a grading input + +All four defects known to this register were found by repos reading their own +code against a ladder, within days of each other. None was found by monitoring. + +Where a finding's provenance is "we happened to look", the register does not +get to assume that similar defects would have been caught. That raises +likelihood for the class, not for the instance, and it belongs in the ruling's +reasoning rather than in a modifier — the register grades what is filed, and +notes when the filing was luck. + +## Recording a grade + +The finding's front-matter carries: + +```yaml +severity: critical # headline, today +severity_at_production: critical +impact: I4 # band, before modifiers +likelihood: L3 +fidelity_modifier: false # true if +1 applied, with the reason in the ruling +``` + +and the ruling section states impact, likelihood, any modifier, and the one +sentence that would have to become false for the grade to change. diff --git a/docs/rulings/2026-08-19-first-grading.md b/docs/rulings/2026-08-19-first-grading.md new file mode 100644 index 0000000..0210897 --- /dev/null +++ b/docs/rulings/2026-08-19-first-grading.md @@ -0,0 +1,225 @@ +--- +id: RISK-RULING-2026-08-19 +type: ruling +title: "First grading: RISK-F-0001, RISK-F-0002, RISK-F-0003" +status: recorded +owner: risk-nexus +date: "2026-08-19" +workplan: RISK-WP-0001-T06 +--- + +# First grading — 2026-08-19 + +The first application of `docs/method/`. Three findings, graded together +because the instruments were written for them and a scale that has never been +used is a draft. + +Where a grading changed what an owning repo should do, it went back as a +message. No owning repo's files were edited. + +## RISK-F-0001 — flex-auth /v1/check authenticates no caller + +| Field | Value | +| --- | --- | +| Impact | `I4` | +| Likelihood | `L3` | +| Fidelity modifier | no | +| **Severity** | **critical** | +| At production | critical | +| Disclosure | embargoed | +| Escalation | yes — trigger 1 | +| Review by | 2026-08-26 | + +**Impact `I4`.** `flex-auth` is the estate's authorization oracle. A forged +allow does not stay in `flex-auth`; it is the answer other services act on, +and `tenant-engine` has separately stated that direct authority over its rows +means privilege escalation across NetKingdom rather than tampering confined to +one store. That is the definition of `I4` — what is compromised here +propagates to everything that trusts it. + +**Likelihood `L3`, not `L4`.** Any workload with cluster reach can assert any +subject and any tenant. That is the normal working set with no additional +step. It is not `L4` only because reach is inside the cluster rather than open. + +The register follows the reporter in **not** assuming a default-deny +NetworkPolicy exists, and equally in not assuming its absence proves wider +exposure. `flex-auth` did not state it either way. This is the single fact +that would most change the grade, so it is the first question at review — see +"Asks" below. + +**No fidelity modifier.** The endpoint answers honestly about a caller it +never checked; it makes no attestation of its own. The false-record hazard +belongs to `RISK-F-0002`'s constraint, where a *consumer* of this endpoint +would start signing records that assert an authorization was made. + +**Production.** Unchanged. Impact is already estate-wide and likelihood does +not depend on there being users. + +**Disclosure: embargoed** until `FLEX-WP-0015-T02` ships to production. This +is the finding that forced the deferral to be re-taken +(`docs/method/disclosure.md`): it names an unauthenticated decision surface +and the service carrying it, which shortens the path for exactly one kind of +reader. Publish on fix. + +**Escalation: yes, trigger 1.** `INTENT.md` asked whether governing access to +tenant data counts as exposing it. Ruled: it does. An authorization oracle +that can be forged is not one step removed from the data, it is the step. + +**Nothing further is asked of `flex-auth`.** They own it, classify it as their +only urgent follow-up, and are shipping through staged promotion. + +## RISK-F-0002 — ops-warden signs SSH certificates with no authorization decision + +| Field | Value | +| --- | --- | +| Impact | `I3` | +| Likelihood | `L2` | +| Fidelity modifier | no (headline) | +| **Severity** | **medium** | +| Constraint severity | **high** | +| At production | medium | +| Disclosure | embargoed | +| Escalation | via trigger 6 only, jointly with RISK-F-0001 | +| Review by | 2026-11-17 | + +`RISK-F-0002` asked this repo three questions. All three are answered here. + +### 1. Severity — which state is scored + +The headline scores **today**: the gate is off, and that is a missing control +honestly represented. `I3` — SSH certificates into production hosts cross a +trust boundary. `L2` — the caller must already hold a scoped `VAULT_TOKEN` and +the actor must exist in `inventory.yaml`; TTLs are enforced and every issuance +is logged. Possession of the signing token is the whole authorization model, +which is thin, but it is not nothing and it is not `L3`. + +**The reporter's argument is accepted in full**: the two states are not equally +bad, and the second is the one worth a severity. It is recorded as a +constraint, not as the headline, because the register describes the estate as +it is and the second state does not exist yet. + +> **Constraint (severity `high`).** Enabling `policy.enabled` while +> `flex-auth`'s `/v1/check` answers unauthenticated callers converts an absent +> control into a false attestation: an attacker with ClusterIP reach obtains a +> genuine `allow`, and the signature log carries a `policy_decision_id` +> asserting the issuance was authorized. `I3 + fidelity → I4`, `L2` → `high`. + +The constraint attaches to `RISK-F-0001`'s remediation, and the ordering is +recorded on both findings: + +``` +flex-auth warn-only -> ops-warden gate presents its SA token -> logs clean + -> flex-auth fail-closed -> ops-warden policy.enabled: true +``` + +The headline being lower than the constraint is exactly the case +`docs/method/severity.md` was written to handle. A reader must not come away +with "medium" and miss the high one. + +### 2. Peer or consequence — **peer** + +Filed as its own finding, as `ops-warden` filed it. Two reasons, both from +`INTENT.md`'s routing rule: the fix owner differs, and the "off" state has +standing regardless of how `RISK-F-0001` resolves. If `RISK-F-0001` were +withdrawn tomorrow, `ops-warden` would still be signing certificates with no +per-request judgement. + +What is *not* independent is the ordering, and that is carried as a constraint +on both rather than by collapsing one finding into the other. + +### 3. Escalation — the register disagrees with the reporter, narrowly + +`ops-warden` judged this does not need the operator: known, owned, and its +dangerous failure mode written down in both repos. On triggers 1-5 the +register agrees — no real tenant data, no obligation, no spend, no ownership +dispute, no stall. + +But the reasoning "it is written down in both repos" is the argument the +register cannot accept on its own, because `RISK-F-0002` is itself the +evidence against it: its own blocker was written down, filed away, and +invalidated in a day with nothing re-checking it. Written down is not the same +as held. + +So **trigger 6 fires once** — an ordering hazard whose wrong sequence creates +a false attestation. One acknowledgement from the operator that the ordering +exists, then the register carries it. Not a standing supervision request. + +This is the disagreement the escalation rule was worth writing to surface, and +it is the reason trigger 6 exists at all. + +**Disclosure: embargoed** until the ordering completes — `FLEX-WP-0015-T02` +shipped *and* `policy.enabled: true` in production. Publishing "production SSH +signing performs no authorization decision" while true shortens the path. + +## RISK-F-0003 — agent read-boundary does not fire on ungraded catalog lanes + +| Field | Value | +| --- | --- | +| Impact | `I4` | +| Likelihood | `L2` | +| Fidelity modifier | no (see below) | +| **Severity** | **high** | +| At production | high | +| Disclosure | embargoed | +| Escalation | no | +| Review by | 2026-09-18 | + +**Impact `I4`, driven by one lane.** Four of the five `exec_capable` ungraded +lanes are `I3`-shaped — a publish token, an OIDC login, an ingestion key, a +hub write token, each bad within its own system. `openbao-api-key` is not: +it vends access to the store the estate's other secrets live in, and a +disclosure there propagates past every boundary the others respect. The +finding is graded on its worst lane. + +**Likelihood `L2`.** Requires an agent session with `WARDEN_AGENT_ID` set — +a foothold the estate does grant. `ops-warden` states the exposed population +is not established and should not be assumed zero or large, and the register +holds that line: `L2` is what the stated facts support. The unverified OpenBao +`agent-high-risk-boundary` coverage would lower it if confirmed, and is a +review question rather than an assumption. + +**No fidelity modifier, deliberately, and it was close.** `ADR-0004` reads as +a categorical rule while the implementation is an opt-in list, so the estate's +*documentation* asserts a boundary that does not exist for 14 lanes. That is +documentation drift, and the modifier in `docs/method/severity.md` is scoped +to attestations a system produces at runtime. If any compliance evidence +anywhere cites `ADR-0004` as implemented, that is a false attestation and a +separate finding — this register has not looked, and says so rather than +assuming. + +**Escalation: no.** Known, owned, tracked (`WARDEN-WP-0032-T05` for the five +lanes, `WARDEN-WP-0032-T06` for the structural fix), and moving. No trigger +fires. Severity alone deliberately does not escalate. + +**The operator's maturity-context direction is noted, not re-decided.** The +finding records that an absent grade should resolve from maturity context — +tolerable in early or experimental contexts, `high` or `critical` in +production ones — and that the durable form waits on the zone model +(`ZONE-WP-0001-T03`). The register's only remark is the one the finding +already makes and this repo confirms from its own registration work today: +`.repo-classification.yaml` `category` cannot carry that signal. +`railiance-platform` runs production OpenBao and owns three of the five lanes +while classified `tooling`, and this repo has just classified itself with the +same vocabulary. The maturity ladder needs its own field. + +**Disclosure: embargoed** until the five lanes are graded under +`WARDEN-WP-0032-T05`. It names five live lanes that vend real values without +the boundary firing. + +## What this grading asks of other repos + +Sent as messages, not as edits. + +| Repo | Ask | +| --- | --- | +| `flex-auth` | Confirm whether a default-deny NetworkPolicy fronts the `/v1/check` Service. It is the single fact that most changes a `critical` grade, and only they can answer it. | +| `ops-warden` | Confirm the OpenBao `agent-high-risk-boundary` policy's coverage of the five `exec_capable` lanes. | +| both | The ordering constraint is recorded and carried by this register. Neither repo needs to re-derive it. | + +## What this grading proved about the instruments + +The scale earned two of its features on first use: the fidelity modifier +(needed by `RISK-F-0002`, and correctly *not* applied to `RISK-F-0003`), and +the headline-versus-constraint split (needed the moment a finding described +two states). The floor was not exercised here — all three findings clear it +easily — and gets its real test in `RISK-WP-0001-T07`. diff --git a/findings/README.md b/findings/README.md new file mode 100644 index 0000000..1c2cf67 --- /dev/null +++ b/findings/README.md @@ -0,0 +1,46 @@ +# Filing a finding + +One file per finding: `findings/RISK-F-NNNN-.md`, YAML front-matter, +then prose. Next id is one past the highest here. + +## What the reporter fills in + +```yaml +id: RISK-F-0004 +type: finding +title: "one line, what is true — not what should be done" +status: open # open | fixed | accepted | withdrawn +reported_by: # who found it +reported_via: # who routed it here, if different +date_reported: "YYYY-MM-DD" +system: # the system the defect is in +environment: production # production | build | both +fix_owner: # who owns the fix — never risk-nexus +fix_tracking: +related: [RISK-F-0001] # optional +``` + +## What risk-nexus fills in — leave these out + +`severity`, `severity_at_production`, `impact`, `likelihood`, +`fidelity_modifier`, `production_rescore`, `disclosure`, `embargo_*`, +`escalation*`, `constraint*`, `last_reviewed`, `review_by`, `graded_by`, +`ruling`. + +Setting them yourself is not an error to be corrected — it is a boundary this +repo would rather keep. The reporter says what is true; this repo says how bad +it is and who hears about it (`INTENT.md`). Leaving them out, or writing +`unset`, both work; the nag reports either way until they are graded. + +## What makes a good finding here + +- **State exposure only as far as you can support it.** "Not established" is a + complete answer and grades better than a guess. `RISK-F-0001` declining to + assume a NetworkPolicy is the model. +- **Say how it was found.** Provenance is a grading input. +- **Suggest a direction if you have one, marked as a suggestion.** The fix is + yours; the grade is ours. +- **A note is fine.** If it would not change anyone's decision, it belongs in + `notes/` — see the floor in `docs/method/severity.md`. + +After filing: `make check`. Then this repo grades it. diff --git a/findings/RISK-F-0001-flex-auth-unauthenticated-check.md b/findings/RISK-F-0001-flex-auth-unauthenticated-check.md index 3fa88fd..00fa9cd 100644 --- a/findings/RISK-F-0001-flex-auth-unauthenticated-check.md +++ b/findings/RISK-F-0001-flex-auth-unauthenticated-check.md @@ -11,12 +11,24 @@ system: flex-auth environment: production fix_owner: flex-auth fix_tracking: FLEX-WP-0015-T02 -# The three fields below are risk-nexus's, not the reporter's. Left unset -# deliberately: the reporter says what is true, this repo says how bad it is -# and who hears about it (INTENT, "What it does not own"). -severity: unset -disclosure: unset -escalation: unset +# Graded by risk-nexus 2026-08-19 — docs/rulings/2026-08-19-first-grading.md +severity: critical +severity_at_production: critical +impact: I4 +likelihood: L3 +fidelity_modifier: false +production_rescore: false +disclosure: embargoed +embargo_condition: "FLEX-WP-0015-T02 ships to production" +embargo_since: "2026-08-19" +embargo_review: "2026-08-26" +escalation: required +escalation_trigger: 1 +escalation_status: pending-operator +last_reviewed: "2026-08-19" +review_by: "2026-08-26" +graded_by: risk-nexus +ruling: RISK-RULING-2026-08-19 --- # RISK-F-0001 — flex-auth authenticates no caller on the decision surface @@ -101,3 +113,43 @@ All four were found the same way, by repos reading their own code against a ladder, within a day of each other. That is a fact about the estate's observability worth carrying into triage: **four live defects, none found by monitoring.** + +## Register ruling — 2026-08-19 + +`critical` (`I4` × `L3`, no fidelity modifier), embargoed until +`FLEX-WP-0015-T02` ships, **escalated** to the operator on trigger 1. + +The question this finding put — whether governing access to tenant data counts +as exposing it — is answered **yes**. An authorization oracle that can be +forged is not one step removed from the data; it is the step. + +Impact is `I4` because a forged allow does not stay here: it is the answer +other services act on, and `tenant-engine` has stated what direct authority +over its rows would mean. Likelihood is `L3` — the normal working set, inside +the cluster, no additional step — and the register follows the reporter in +neither assuming a default-deny NetworkPolicy nor assuming its absence. + +No fidelity modifier: this endpoint answers honestly about a caller it never +checked. The false-record hazard lives in `RISK-F-0002`'s constraint, where a +consumer of this endpoint would begin signing records asserting an +authorization that was never made. **That constraint binds this finding's +remediation**: `ops-warden`'s `policy.enabled` must not be turned on until +`/v1/check` authenticates its callers, and the ordering is + +``` +flex-auth warn-only -> ops-warden gate presents its SA token -> logs clean + -> flex-auth fail-closed -> ops-warden policy.enabled: true +``` + +Nothing further is asked of `flex-auth` beyond what they have committed to, +except one fact only they can supply: **is there a default-deny NetworkPolicy +in front of the Service?** It is the single fact that would most change this +grade, and it is the first question at review. + +Reasoning: `docs/rulings/2026-08-19-first-grading.md`. + +## Reviews + +- **2026-08-19** — graded. Next review 2026-08-26 (`critical` → 7 days). + Open at review: the NetworkPolicy question; whether `FLEX-WP-0015-T02` has + moved; whether the embargo still holds. diff --git a/findings/RISK-F-0002-ops-warden-sign-ungated.md b/findings/RISK-F-0002-ops-warden-sign-ungated.md index b814a58..9ad6bc3 100644 --- a/findings/RISK-F-0002-ops-warden-sign-ungated.md +++ b/findings/RISK-F-0002-ops-warden-sign-ungated.md @@ -12,10 +12,27 @@ 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 +# Graded by risk-nexus 2026-08-19 — docs/rulings/2026-08-19-first-grading.md +severity: medium +severity_at_production: medium +impact: I3 +likelihood: L2 +fidelity_modifier: false +production_rescore: false +constraint_on: RISK-F-0001 +constraint_severity: high +constraint: "policy.enabled must not be turned on while flex-auth /v1/check answers unauthenticated callers — the gate would sign a false attestation" +disclosure: embargoed +embargo_condition: "FLEX-WP-0015-T02 shipped and ops-warden policy.enabled true in production" +embargo_since: "2026-08-19" +embargo_review: "2026-11-17" +escalation: required +escalation_trigger: 6 +escalation_status: pending-operator +last_reviewed: "2026-08-19" +review_by: "2026-11-17" +graded_by: risk-nexus +ruling: RISK-RULING-2026-08-19 --- # RISK-F-0002 — the SSH signing gate is off, and turning it on is now the more dangerous move @@ -120,3 +137,59 @@ 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. + +## Register ruling — 2026-08-19 + +All three questions this finding put are answered. + +**1. Severity — `medium` today, with a `high` constraint.** The headline +scores the state of the world now: the gate is off, a missing control honestly +represented. `I3` (SSH certificates into production hosts cross a trust +boundary) × `L2` (scoped `VAULT_TOKEN`, actor in `inventory.yaml`, TTLs, every +issuance logged — thin, but not nothing). + +The argument that the two states are not equally bad is **accepted in full**, +and it is now written into the scale as the fidelity modifier: a control that +lies is one impact band worse than the same control absent +(`docs/method/severity.md`). It is recorded as a constraint rather than the +headline because the register describes the estate as it is, and the dangerous +state does not exist yet: + +> **Constraint, severity `high`.** Enabling `policy.enabled` while `/v1/check` +> answers unauthenticated callers converts an absent control into a false +> attestation — a genuine `allow` obtained by anyone with ClusterIP reach, and +> a `policy_decision_id` in the signature log asserting the issuance was +> authorized. `I3 + fidelity → I4`, `L2` → `high`. + +The constraint is attached to `RISK-F-0001`'s remediation and recorded on both +findings. A reader must not take away `medium` and miss it. + +**2. Peer, not consequence.** Filed as `ops-warden` filed it. The fix owner +differs and the "off" state has standing regardless of how `RISK-F-0001` +resolves — if that finding were withdrawn tomorrow, production signing would +still carry no per-request judgement. What is not independent is the ordering, +and that travels as a constraint rather than by collapsing the two records. + +**3. Escalation — the register disagrees, narrowly.** On triggers 1-5 it +agrees with `ops-warden`: no real tenant data, no obligation, no spend, no +ownership dispute, no stall. But "it is written down in both repos" is the one +argument the register cannot accept here, because this finding is itself the +evidence against it: its own blocker was written down, filed, and invalidated +in a day with nothing re-checking it. + +So trigger 6 — ordering hazard producing a false attestation — fires **once**. +One acknowledgement that the operator holds the ordering, then the register +carries it. Not a standing supervision request. That trigger exists in +`docs/method/escalation.md` because of this finding. + +**The general point is adopted.** "A blocker is a claim about the world at a +date" is now question 2 of every review in `docs/method/review.md`, and this +finding is cited there as the case that bought it. + +Reasoning: `docs/rulings/2026-08-19-first-grading.md`. + +## Reviews + +- **2026-08-19** — graded. Next review 2026-11-17 (`medium` → 90 days). + Open at review: has `FLEX-WP-0007` or `WARDEN-WP-0007` moved; is the stated + blocker still true; does the ordering constraint still hold. diff --git a/findings/RISK-F-0003-ops-warden-read-boundary-ungraded-lanes.md b/findings/RISK-F-0003-ops-warden-read-boundary-ungraded-lanes.md index 2123314..266067d 100644 --- a/findings/RISK-F-0003-ops-warden-read-boundary-ungraded-lanes.md +++ b/findings/RISK-F-0003-ops-warden-read-boundary-ungraded-lanes.md @@ -11,10 +11,22 @@ system: ops-warden environment: production fix_owner: ops-warden fix_tracking: WARDEN-WP-0032-T05 -# risk-nexus's fields, deliberately left unset by the reporter. -severity: unset -disclosure: unset -escalation: unset +# Graded by risk-nexus 2026-08-19 — docs/rulings/2026-08-19-first-grading.md +severity: high +severity_at_production: high +impact: I4 +likelihood: L2 +fidelity_modifier: false +production_rescore: false +disclosure: embargoed +embargo_condition: "the five exec_capable lanes graded under WARDEN-WP-0032-T05" +embargo_since: "2026-08-19" +embargo_review: "2026-09-18" +escalation: none +last_reviewed: "2026-08-19" +review_by: "2026-09-18" +graded_by: risk-nexus +ruling: RISK-RULING-2026-08-19 --- # RISK-F-0003 — the agent read-boundary has a fourteen-lane blind spot @@ -122,3 +134,50 @@ OpenBao and owns three of the five lanes above, is `category: tooling`. - `wiki/playbooks/agent-read-boundary.md` - `RISK-F-0002` — ops-warden sign ungated (same system, different control) - `zone-engine` `ZONE-WP-0001-T02` — the work that surfaced this + +## Register ruling — 2026-08-19 + +`high` (`I4` × `L2`), embargoed until the five `exec_capable` lanes are graded +under `WARDEN-WP-0032-T05`, no escalation. + +**Graded on its worst lane.** Four of the five are `I3`-shaped — a publish +token, an OIDC login, an ingestion key, a hub write token, each bad inside its +own system. `openbao-api-key` is not: it reaches the store the estate's other +secrets live in, and a disclosure there propagates past every boundary the +others respect. + +**Likelihood `L2`** — an agent session with `WARDEN_AGENT_ID` is a foothold the +estate does grant. The register holds the line this finding drew: the exposed +population is not established, so it is neither assumed zero nor assumed large, +and the unverified OpenBao `agent-high-risk-boundary` coverage is a review +question rather than an assumption in either direction. Confirming that +coverage for these five paths would lower the grade. + +**No fidelity modifier, and it was close.** `ADR-0004` reads categorical while +the implementation is opt-in, so the documentation asserts a boundary that does +not exist for 14 lanes. The modifier is scoped to attestations a system +produces at runtime, and documentation drift is not that. But if any compliance +evidence anywhere cites `ADR-0004` as implemented, that *is* a false +attestation and a separate finding. This register has not looked, and says so +rather than assuming. + +**No escalation.** Known, owned, tracked (`WARDEN-WP-0032-T05` for the five +lanes, `WARDEN-WP-0032-T06` for the structural fix), and moving. Severity alone +deliberately does not reach the operator. + +**On the maturity-context direction**, which is the operator's and not +re-decided here: the note that `.repo-classification.yaml` `category` cannot +carry the signal is confirmed from this repo's own registration work today. +`railiance-platform` runs production OpenBao and owns three of the five lanes +while classified `tooling`; `risk-nexus` classified itself with the same +vocabulary an hour ago. The maturity ladder needs its own field, and that is +`ZONE-WP-0001-T03`'s to build. + +Reasoning: `docs/rulings/2026-08-19-first-grading.md`. + +## Reviews + +- **2026-08-19** — graded. Next review 2026-09-18 (`high` → 30 days). + Open at review: OpenBao policy coverage of the five paths; whether + `WARDEN-WP-0032-T05` has landed; whether any compliance evidence cites + `ADR-0004` as implemented. diff --git a/tools/__pycache__/register_lib.cpython-312.pyc b/tools/__pycache__/register_lib.cpython-312.pyc new file mode 100644 index 0000000..131e5dd Binary files /dev/null and b/tools/__pycache__/register_lib.cpython-312.pyc differ diff --git a/tools/register_check.py b/tools/register_check.py new file mode 100644 index 0000000..0509e81 --- /dev/null +++ b/tools/register_check.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Report what the register is not saying out loud. + +RISK-WP-0001-T08. Report only: it writes nothing, changes no field, +escalates nothing on its own, and blocks nothing. A human or the custodian +acts on the output. Exit code is 0 unless a finding cannot be read. +""" +from __future__ import annotations + +import datetime as dt + +import register_lib as lib + +TODAY = dt.date.today() + + +def date(value): + if isinstance(value, dt.date): + return value + if isinstance(value, str) and value not in ("", "unset"): + return dt.date.fromisoformat(value) + return None + + +def main() -> int: + fs = [f for f in lib.findings() if f.get("status") == "open"] + lines: list[str] = [] + + def section(title: str, rows: list[str], quiet: str) -> None: + lines.append(f"{title}:") + if rows: + lines.extend(f" {r}" for r in rows) + else: + lines.append(f" {quiet}") + lines.append("") + + ungraded = [ + f"{f['id']} — {', '.join(k for k in lib.GRADED_FIELDS if str(f.get(k, 'unset')) == 'unset')}" + for f in fs + if any(str(f.get(k, "unset")) == "unset" for k in lib.GRADED_FIELDS) + ] + section("Ungraded", ungraded, "none — every open finding carries a grade") + + overdue = [] + for f in fs: + due = date(f.get("review_by")) + if due and TODAY > due: + overdue.append(f"{f['id']} — review was due {due} ({(TODAY - due).days}d ago)") + section("Overdue review", overdue, "none") + + stale = [] + for f in fs: + seen = date(f.get("last_reviewed")) + interval = lib.REVIEW_INTERVAL_DAYS.get(f.get("severity")) + if not seen or not interval or f.get("severity") == "low": + continue + limit = seen + dt.timedelta(days=interval * lib.STALE_MULTIPLIER) + if TODAY > limit: + stale.append( + f"{f['id']} ({f.get('severity')}) — untouched since {seen}; " + f"escalation trigger 5 fires (limit was {limit})" + ) + section("Stalled — escalation trigger 5", stale, "none") + + embargo = [] + for f in fs: + if f.get("disclosure") != "embargoed": + continue + due = date(f.get("embargo_review")) + if due and TODAY > due: + embargo.append( + f"{f['id']} — embargo not re-decided since {due}; lifts when: {f.get('embargo_condition')}" + ) + section("Embargo overdue for re-decision", embargo, "none") + + esc = [ + f"{f['id']} — trigger {f.get('escalation_trigger')}, {f.get('escalation_status')}" + for f in fs + if f.get("escalation") == "required" and f.get("escalation_status") != "answered" + ] + section("Escalations awaiting the operator", esc, "none") + + rescore = [ + f"{f['id']} — {f.get('severity')} now, {f.get('severity_at_production')} at production" + for f in fs + if f.get("production_rescore") + ] + section("Owed at the production transition", rescore, "none — no finding is graded lower for build mode") + + print(f"Register check — {TODAY}\n{len(fs)} open finding(s)\n") + print("\n".join(lines).rstrip()) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/register_index.py b/tools/register_index.py new file mode 100644 index 0000000..11895e4 --- /dev/null +++ b/tools/register_index.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""Generate REGISTER.md from the finding front-matter. + +RISK-WP-0001-T05. The index is generated because a hand-maintained one +drifts and then lies. Edit the findings, re-run `make register`. +""" +from __future__ import annotations + +import datetime as dt +import sys + +import register_lib as lib + +TODAY = dt.date.today() + + +def date(value) -> dt.date | None: + if isinstance(value, dt.date): + return value + if isinstance(value, str) and value not in ("", "unset"): + return dt.date.fromisoformat(value) + return None + + +def review_cell(f: dict) -> str: + due = date(f.get("review_by")) + if due is None: + return "—" + late = (TODAY - due).days + if late > 0: + return f"**{due} (overdue {late}d)**" + return str(due) + + +def sev_cell(f: dict) -> str: + sev = f.get("severity", "unset") + return f"**{sev}**" if sev in ("critical", "high") else sev + + +def esc_cell(f: dict) -> str: + esc = f.get("escalation", "unset") + if esc in ("none", "unset"): + return esc + trigger = f.get("escalation_trigger") + status = f.get("escalation_status", "") + tail = f" (t{trigger}{', ' + status if status else ''})" if trigger else "" + return f"**{esc}**{tail}" + + +def render() -> str: + fs = lib.findings() + ns = lib.notes() + open_fs = [f for f in fs if f.get("status") == "open"] + + out = [ + "# Register", + "", + f"Generated by `tools/register_index.py` from `findings/`. Do not edit by hand. Last built {TODAY}.", + "", + f"{len(open_fs)} open of {len(fs)} findings; {len(ns)} notes below the floor.", + "", + "## Findings", + "", + "| ID | Finding | System | Severity | Disclosure | Escalation | Fix owner | Status | Review by |", + "| --- | --- | --- | --- | --- | --- | --- | --- | --- |", + ] + for f in fs: + out.append( + "| [{id}]({path}) | {title} | {system} | {sev} | {disc} | {esc} | {owner} | {status} | {review} |".format( + id=f["id"], + path=f["_path"].relative_to(lib.REPO), + title=str(f.get("title", "")).strip('"'), + system=f.get("system", "—"), + sev=sev_cell(f), + disc=f.get("disclosure", "unset"), + esc=esc_cell(f), + owner=f.get("fix_owner", "—"), + status=f.get("status", "—"), + review=review_cell(f), + ) + ) + + constraints = [f for f in fs if f.get("constraint")] + if constraints: + out += ["", "## Constraints", "", + "Hazards created by acting in the wrong order. Each binds another finding's remediation.", + "", "| From | Binds | Severity | Constraint |", "| --- | --- | --- | --- |"] + for f in constraints: + out.append("| {id} | {on} | **{sev}** | {text} |".format( + id=f["id"], on=f.get("constraint_on", "—"), + sev=f.get("constraint_severity", "—"), text=f["constraint"])) + + embargoed = [f for f in fs if f.get("disclosure") == "embargoed"] + if embargoed: + out += ["", "## Embargoes", "", + "Held from publication with a stated condition. A hold with no moving condition is a stall.", + "", "| Finding | Since | Lifts when | Re-decided |", "| --- | --- | --- | --- |"] + for f in embargoed: + out.append("| {id} | {since} | {cond} | {rev} |".format( + id=f["id"], since=f.get("embargo_since", "—"), + cond=f.get("embargo_condition", "—"), rev=f.get("embargo_review", "—"))) + + if ns: + out += ["", "## Notes (below the floor)", "", + "Seen, deliberately not findings. Not graded, not reviewed, not published.", + "", "| ID | Note | Why below the floor |", "| --- | --- | --- |"] + for n in ns: + out.append("| [{id}]({path}) | {title} | {why} |".format( + id=n["id"], path=n["_path"].relative_to(lib.REPO), + title=str(n.get("title", "")).strip('"'), + why=str(n.get("floor_reason", "—")).strip('"'))) + + out += ["", "## How to read this", "", + "Severity is `docs/method/severity.md`; disclosure `docs/method/disclosure.md`;", + "escalation `docs/method/escalation.md`; review dates `docs/method/review.md`.", + "A constraint may be graded higher than the finding that carries it — read both.", + ""] + return "\n".join(out) + + +if __name__ == "__main__": + text = render() + target = lib.REPO / "REGISTER.md" + if "--check" in sys.argv: + current = target.read_text(encoding="utf-8") if target.exists() else "" + if current != text: + print("REGISTER.md is stale — run `make register`") + raise SystemExit(1) + print("REGISTER.md is current") + else: + target.write_text(text, encoding="utf-8") + print(f"wrote {target.relative_to(lib.REPO)}") diff --git a/tools/register_lib.py b/tools/register_lib.py new file mode 100644 index 0000000..e17b641 --- /dev/null +++ b/tools/register_lib.py @@ -0,0 +1,42 @@ +"""Shared front-matter reading for the register tools. + +The finding files are the source of truth. Nothing here writes to them. +""" +from __future__ import annotations + +import pathlib +import yaml + +REPO = pathlib.Path(__file__).resolve().parent.parent +FINDINGS = REPO / "findings" +NOTES = REPO / "notes" + +SEVERITIES = ["critical", "high", "medium", "low"] +REVIEW_INTERVAL_DAYS = {"critical": 7, "high": 30, "medium": 90, "low": 180} +STALE_MULTIPLIER = 2 +GRADED_FIELDS = ["severity", "disclosure", "escalation", "review_by", "last_reviewed"] + + +def load(path: pathlib.Path) -> dict: + text = path.read_text(encoding="utf-8") + if not text.startswith("---\n"): + raise ValueError(f"{path.name}: no front-matter") + _, fm, _body = text.split("---\n", 2) + data = yaml.safe_load(fm) or {} + data["_path"] = path + return data + + +def findings() -> list[dict]: + items = [load(p) for p in sorted(FINDINGS.glob("RISK-F-*.md"))] + return sorted(items, key=lambda f: f["id"], reverse=True) + + +def notes() -> list[dict]: + if not NOTES.exists(): + return [] + return sorted((load(p) for p in NOTES.glob("RISK-N-*.md")), key=lambda n: n["id"], reverse=True) + + +def sev_rank(sev: str) -> int: + return SEVERITIES.index(sev) if sev in SEVERITIES else len(SEVERITIES)