From 15ff64ee002adeb0c4b5b2a572471cde69705535 Mon Sep 17 00:00:00 2001 From: Greg Anderson Date: Tue, 4 Aug 2026 04:30:33 -0600 Subject: [PATCH 01/16] docs(risk acceptance): document the Risk Acceptances 2.0 lifecycle A risk acceptance has had one state: as soon as it existed, its findings were accepted. That describes an organization where one person decides. It does not describe one where somebody requests an exception, somebody else approves it, and an auditor later asks who agreed to what and why. Documents the beta feature that adds that shape: the six states and which moves are allowed between them, why an active acceptance is expired rather than rejected, the split between the permission to request and the permission to decide, what happens to findings while a request is undecided, and the durable per-finding record that outlives the membership. Also documents the two endpoints -- state and transition -- including when a reason is required and what the refusals mean. Written against the behaviour as built, so it describes the flag being off as the default and unchanged. Co-Authored-By: Claude Opus 5 --- .../PRO__risk_acceptance.md | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) diff --git a/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md b/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md index 051a85651a5..7c239456f2b 100644 --- a/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md +++ b/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md @@ -179,6 +179,143 @@ Expiring and reinstating are also available directly, so you do not have to driv Both accept an optional `reason`, which is recorded as a note on the Risk Acceptance along with who performed the action. Both require the same permission as editing the Risk Acceptance. +## Risk Acceptances 2.0 (Beta) + +By default, a Risk Acceptance has one state: as soon as it exists, the Findings it covers are +accepted. That works when one person decides. It does not describe an organization where somebody +*requests* an exception, somebody else *approves* it, and an auditor later asks who agreed to what +and why. + +**Risk Acceptances 2.0** adds that shape. Enable **Risk Acceptances 2.0** on the Feature Flags +settings page. It is off by default, and while it is off nothing changes: every Risk Acceptance +behaves exactly as described above. + +### The lifecycle + +A Risk Acceptance moves through these states: + +| State | What it means | Are the Findings suppressed? | +| --- | --- | --- | +| **Proposed** | Somebody has asked for the exception. Nobody has answered. | No | +| **Under Review** | A reviewer has picked it up. | No | +| **Approved** | The decision is yes, but it has not been switched on yet. | No | +| **Rejected** | The decision is no. | No | +| **Active** | The acceptance is in force. | Yes | +| **Expired** | It ran out, or somebody ended it. | No | + +Only **Active** suppresses anything. A Finding in a Proposed, Under Review or Approved Risk +Acceptance stays Active and keeps counting — because it *is* still active, and nobody has agreed to +accept it yet. + +The moves allowed between states: + +``` +Proposed ──→ Under Review ──→ Approved ──→ Active ──→ Expired + │ │ │ │ + └──────────────┴──────────────┴──→ Rejected └──→ Active (reinstated) + │ + └──→ Proposed (reworked and resubmitted) +``` + +Two shapes worth calling out: + +- **An Active acceptance cannot be Rejected.** Once it is in force, the way to end it is to expire + it, which puts its Findings back the way expiry always has. "Rejected" means the request was + declined before it ever suppressed anything, and using it for both would make the record untrue. +- **A Rejected acceptance can go back to Proposed.** A declined request can be reworked and + resubmitted instead of recreated, which would throw away its history. + +Every move is recorded: which state it left, which it entered, who made it, when, and why. That +sequence is the approval chain, and it is never edited or deleted. + +### Who can do what + +Requesting an exception and deciding one are separate permissions, so the person who asks is not +automatically the person who agrees. + +| Move | Permission required | Minimum role on the Asset | +| --- | --- | --- | +| Propose, submit for review, resubmit | Risk Acceptance Edit | Writer | +| Approve, Reject | **Risk Acceptance Approve** | Maintainer | +| Activate an approved acceptance | **Risk Acceptance Approve** | Maintainer | +| Expire | Risk Acceptance Edit | Writer | + +Activating is an approval because it is the moment suppression starts. Reinstating an expired +acceptance is the same move, and needs the same permission. + +### Requested exceptions in your metrics + +The problem this solves: a team asks for an exception and waits — on a change board, on a vendor, on +somebody outside DefectDojo entirely. Until now the only options were to accept the Finding before +anyone agreed (which hides it, and is not true), or to leave it counting as unaddressed work (which +is what customers describe as their Active numbers being polluted). + +With Risk Acceptances 2.0 enabled, a Finding in an undecided Risk Acceptance is reported in its own +status band, **Exception Requested**, instead of **Active**: + +- The Finding is not hidden and not suppressed. Its own status is still Active. +- Charts and status splits show **Exception Requested** as its own slice, so the total still adds up + and you can see how much work is sitting in a queue waiting on a decision. +- Filter Findings on `has_pending_exception` to build that queue. +- Filter Risk Acceptances on `workflow_state=proposed&workflow_state=under_review` to see the + requests waiting for a reviewer. + +When the acceptance is activated, its Findings become Risk Accepted as normal. When it is rejected, +they simply stay Active. + +### The record of what was accepted + +DefectDojo has always tracked which Findings a Risk Acceptance covers as a live membership list, and +Findings leave that list for ordinary reasons: the acceptance expires, or a re-import stops seeing +the Finding. Both are correct, and both used to remove the only evidence that the Finding was ever +accepted. + +With Risk Acceptances 2.0 enabled, DefectDojo Pro keeps a durable record per Finding per Risk +Acceptance. When a Finding leaves the acceptance, its record is **closed, not deleted**, and it +records: + +- whether it was **removed** (a person's decision), **expired** (the clock), or **superseded** (a + scan stopped reporting the Finding), +- who added it and who closed it, and when the acceptance took effect and stopped, +- a per-Finding justification, separate from the acceptance's overall recommendation — auditors ask + about individual Findings, and the answer is rarely the same for every Finding in a batch. + +Records are not backfilled. A Risk Acceptance created before you enabled the feature can be seeded +from the Findings it still covers, but Findings whose membership was already severed cannot be +recovered — that history was not kept. + +### API + +``` +GET /api/v2/risk_acceptance/{id}/state/ +POST /api/v2/risk_acceptance/{id}/transition/ +``` + +`state/` reports where a Risk Acceptance is and which moves are available from there, so a client +can offer the actions that exist rather than guessing: + +```json +{"workflow_state": "under_review", "allowed_next_states": ["approved", "proposed", "rejected"]} +``` + +`transition/` makes a move: + +```json +{"to_state": "rejected", "reason": "a fix is scheduled for the next release"} +``` + +- `reason` is **required** when moving to `rejected` or `expired`. Both are refusals of risk somebody + accepted, and "why" is the whole content of the record. +- `400` if the move is not allowed from the current state — the response says which move was refused. +- `403` if you may not make that particular move, for example a Writer trying to approve. +- Moving to `active` accepts the Findings, exactly as accepting them any other way does. + +Both endpoints return `403` with an explanatory message while the feature flag is off. + +`workflow_state` is also readable on the Risk Acceptance object itself, and is read-only there: the +state changes only through `transition/`, so that every change is validated and recorded. Risk +Acceptances created before the feature existed report `active`, which is what they are. + ## Risk Acceptance Best Practices While it is possible to affect Findings within Full Risk Acceptance objects using Simple Risk Acceptance workflows (and vice versa), it is generally preferable to default to either process exclusively rather than having both enabled at once. From 33f235a31fd10a2f862999443a3d71517a68cc65 Mon Sep 17 00:00:00 2001 From: Greg Anderson Date: Tue, 4 Aug 2026 04:48:09 -0600 Subject: [PATCH 02/16] docs(risk acceptance): document requesting an exception and reading the history Four endpoints the first pass did not cover, because they did not exist yet: the one that asks for an exception without accepting anything, and the three that read a history the live membership cannot answer from. The request endpoint's existence needs explaining rather than just listing -- it is there because creating a Risk Acceptance the ordinary way accepts its Findings immediately, and an Active one deliberately cannot move back to Proposed, so create-then-transition is not a route to a request. Also states the visibility rule on the Finding-side history: Risk Acceptance visibility is a separate grant, so reading a Finding does not show you the exceptions raised against it. Co-Authored-By: Claude Opus 5 --- .../PRO__risk_acceptance.md | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md b/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md index 7c239456f2b..bc4de8372aa 100644 --- a/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md +++ b/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md @@ -316,6 +316,54 @@ Both endpoints return `403` with an explanatory message while the feature flag i state changes only through `transition/`, so that every change is validated and recorded. Risk Acceptances created before the feature existed report `active`, which is what they are. +#### Asking for an exception + +``` +POST /api/v2/risk_acceptance/request/ +``` + +```json +{ + "findings": [1234, 1235], + "name": "Waiting on the Q4 platform upgrade", + "reason": "the fix requires a platform version we upgrade to in Q4", + "justification": "compensating control in the gateway", + "expiration_date": "2027-01-15T00:00:00Z" +} +``` + +Creates a Risk Acceptance that starts in **Proposed** and **does not accept its Findings**. This has +its own endpoint because creating a Risk Acceptance the ordinary way accepts its Findings +immediately — which is the one thing a request must not do — and an Active Risk Acceptance +deliberately cannot move back to Proposed, so there is no transition that would undo it. + +`reason` is required: a request with no argument cannot be reviewed. `justification` is optional and +is recorded against every Finding in the request, where it can be edited per Finding afterwards. + +Requesting needs the same permission as editing a Risk Acceptance, plus permission to view every +Finding named. Deciding the request needs **Risk Acceptance Approve**, which is what keeps the +request and the decision two different acts. + +#### Reading the history + +``` +GET /api/v2/risk_acceptance/{id}/transitions/ +GET /api/v2/risk_acceptance/{id}/finding_records/ +GET /api/v2/findings/{id}/acceptance_history/ +``` + +- `transitions/` is the approval chain, oldest first: which state each move left and entered, who + made it, when, and why. +- `finding_records/` is what the Risk Acceptance has covered — **including Findings that have since + left it**, which the accepted-findings list cannot show. +- `acceptance_history/` is the same records from the Finding's side: every Risk Acceptance this + Finding has been part of. This is the endpoint that answers "was this ever accepted, and by whom" + about a Finding whose membership was severed months ago. + +`acceptance_history/` only lists Risk Acceptances you may see in your own right. Risk Acceptance +visibility is a separate grant from Finding visibility, so being able to read the Finding does not +show you the exceptions raised against it. + ## Risk Acceptance Best Practices While it is possible to affect Findings within Full Risk Acceptance objects using Simple Risk Acceptance workflows (and vice versa), it is generally preferable to default to either process exclusively rather than having both enabled at once. From 43ad3e4a3bce240cc008a5c766d77be9b73413ac Mon Sep 17 00:00:00 2001 From: Greg Anderson Date: Tue, 4 Aug 2026 11:12:14 -0600 Subject: [PATCH 03/16] docs(risk acceptance): document self-approval and where the UI surfaces this Three additions for behaviour that now exists: the requester cannot approve or activate their own request (and the setting that relaxes it for a one-person approval chain), the Exception Requested badge on Finding rows, and where to find the request action and the Finding's Acceptance History tab. Says why rejecting your own request is still allowed -- it is a withdrawal, and needs nobody's agreement -- because that asymmetry looks like an oversight otherwise. Co-Authored-By: Claude Opus 5 --- .../findings_workflows/PRO__risk_acceptance.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md b/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md index bc4de8372aa..3d060bdfe40 100644 --- a/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md +++ b/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md @@ -243,6 +243,15 @@ automatically the person who agrees. Activating is an approval because it is the moment suppression starts. Reinstating an expired acceptance is the same move, and needs the same permission. +**The requester cannot be the approver.** Holding the approve permission is not the same as being +a second pair of eyes, so whoever requested a Risk Acceptance cannot approve or activate it +themselves — the API returns `403` and says so. Rejecting your own request is still allowed; that +is a withdrawal, and needs nobody's agreement. + +An administrator can turn this off with **Block Self-Approval of Risk Acceptances** in System +Settings (on by default). Turning it off is for a team small enough that one person is the whole +approval chain, where the alternative is that nobody uses the workflow at all. + ### Requested exceptions in your metrics The problem this solves: a team asks for an exception and waits — on a change board, on a vendor, on @@ -256,6 +265,9 @@ status band, **Exception Requested**, instead of **Active**: - The Finding is not hidden and not suppressed. Its own status is still Active. - Charts and status splits show **Exception Requested** as its own slice, so the total still adds up and you can see how much work is sitting in a queue waiting on a decision. +- Findings waiting on a decision carry an **Exception Requested** badge in Finding tables, + alongside their real status — the Finding is still Active, and the badge says somebody is + waiting. - Filter Findings on `has_pending_exception` to build that queue. - Filter Risk Acceptances on `workflow_state=proposed&workflow_state=under_review` to see the requests waiting for a reviewer. @@ -318,6 +330,9 @@ Acceptances created before the feature existed report `active`, which is what th #### Asking for an exception +From the UI: **Request Security Exception** on a Finding's action menu, or on the Risk Acceptance +menu after selecting several Findings in a table — bulk is the normal case. + ``` POST /api/v2/risk_acceptance/request/ ``` @@ -358,7 +373,8 @@ GET /api/v2/findings/{id}/acceptance_history/ left it**, which the accepted-findings list cannot show. - `acceptance_history/` is the same records from the Finding's side: every Risk Acceptance this Finding has been part of. This is the endpoint that answers "was this ever accepted, and by whom" - about a Finding whose membership was severed months ago. + about a Finding whose membership was severed months ago. The Finding page shows it as an + **Acceptance History** tab. `acceptance_history/` only lists Risk Acceptances you may see in your own right. Risk Acceptance visibility is a separate grant from Finding visibility, so being able to read the Finding does not From b3d1c6da86433b1d080ed2585cafd572ab18b28d Mon Sep 17 00:00:00 2001 From: Greg Anderson Date: Tue, 4 Aug 2026 11:32:20 -0600 Subject: [PATCH 04/16] docs(risk acceptance): document standing acceptances The feature that answers "we accepted this base-image CVE once, stop asking us again on every asset and rescan" -- which adding Findings by hand cannot, because the Findings arrive with the next scan. Documents the criteria shape, every matchable attribute, and the three rules that make it safe to leave on: active-only, in-scope-only, and refusing criteria that have a scope but nothing to match (which would mean "every Finding in this product"). Says explicitly that an acceptance with no criteria behaves exactly as before, because that covers every acceptance an upgrading install already has. Co-Authored-By: Claude Opus 5 --- .../PRO__risk_acceptance.md | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md b/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md index 3d060bdfe40..61408fe7888 100644 --- a/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md +++ b/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md @@ -296,6 +296,56 @@ Records are not backfilled. A Risk Acceptance created before you enabled the fea from the Findings it still covers, but Findings whose membership was already severed cannot be recovered — that history was not kept. +### Standing acceptances + +The ask this answers: *we accepted this base-image CVE once — stop asking us again on every asset +and every rescan.* Adding Findings by hand cannot, because the Findings that need accepting do not +exist yet; they arrive with the next scan. + +So a Risk Acceptance can carry **criteria** describing the Findings it covers. A new Finding that +matches is added to the acceptance as it is imported, exactly as if somebody had added it, and its +record says the criteria covered it rather than naming a person. + +```json +{ + "acceptance_criteria": { + "scope": { "type": "product", "ids": [12] }, + "vulnerability_ids": ["CVE-2021-44228"], + "component_name": "*log4j-core*", + "component_version": "2.14.*" + } +} +``` + +Matchable attributes — all optional, and **all of the ones present must match**: + +| Key | Matches on | +| --- | --- | +| `vulnerability_ids` | any of the Finding's vulnerability IDs (or its `cve`), case-insensitive | +| `cwes` | the Finding's CWE | +| `severities` | the Finding's severity, case-insensitive | +| `component_name`, `component_version` | glob patterns — the same component is named differently by different scanners, and versions come in ranges | +| `title_pattern` | a glob against the title | + +`scope` is **required** and is what stops an acceptance reaching Findings its author has no business +accepting. `type` is `engagement`, `product`, `product_type` or `global`; everything except `global` +needs `ids`. + +Three rules keep this safe to leave switched on: + +- **Only while the acceptance is Active.** Expiry and rejection stop it — there is no second + lifetime to manage, and a lapsed decision cannot keep suppressing new Findings. This is what the + lifecycle in front of it is for. +- **Only inside its scope.** A Finding outside the scope never matches, however well its + vulnerability lines up. +- **Criteria with nothing to match are refused.** A scope with no matchable attribute would mean + "every Finding in this product", so the API returns `400` rather than accepting it. An acceptance + with *no* criteria at all is fine and covers exactly what was added to it — which is every Risk + Acceptance that existed before this feature. + +Applying criteria never fails an import: if it cannot run, the Finding is imported unaccepted and +the next reimport tries again. + ### API ``` From 1608212afacddd1e7764fcd51eb130a6f82d00be Mon Sep 17 00:00:00 2001 From: Greg Anderson Date: Tue, 4 Aug 2026 15:05:36 -0600 Subject: [PATCH 05/16] docs(risk acceptance): document deriving a scope, and the criteria editor Two additions: `ids` can be left empty and are filled in from the Findings the acceptance already covers -- which cannot widen anything, since that is where it is already accepting -- and where to edit criteria in the UI. Says what the editor asks for (breadth, not ids) because that is the difference between a one-click scope and hunting through a list of thousands of assets. Co-Authored-By: Claude Opus 5 --- .../findings_workflows/PRO__risk_acceptance.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md b/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md index 61408fe7888..0bb596ea041 100644 --- a/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md +++ b/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md @@ -328,8 +328,16 @@ Matchable attributes — all optional, and **all of the ones present must match* | `title_pattern` | a glob against the title | `scope` is **required** and is what stops an acceptance reaching Findings its author has no business -accepting. `type` is `engagement`, `product`, `product_type` or `global`; everything except `global` -needs `ids`. +accepting. `type` is `engagement`, `product`, `product_type` or `global`. + +You can leave `ids` empty and DefectDojo fills them in from the Findings the Risk Acceptance already +covers — so a client can say "this asset" without hunting for an id. That cannot widen anything: the +ids come from where the acceptance is already accepting. If there is nothing to derive from, the +criteria are refused rather than quietly broadened. + +**From the UI:** *Edit Standing Criteria* on the Risk Acceptance menu. It asks how far the acceptance +should reach — this engagement, this asset, this asset's type, or everywhere — rather than asking you +to pick ids, and refuses to save until both a scope and at least one matchable attribute are set. Three rules keep this safe to leave switched on: From a882a0968994f5abe339ad9b2d547a8dcb640402 Mon Sep 17 00:00:00 2001 From: Greg Anderson Date: Tue, 4 Aug 2026 15:11:58 -0600 Subject: [PATCH 06/16] docs(risk acceptance): reinstating needs an approver by every route Both routes, because there are two and only documenting one would make the other look like a loophole: the reinstate endpoint, and changing the expiration date on an expired Risk Acceptance, which reinstates it as a side effect. Says what is still an ordinary edit, so the rule does not read as "expired Risk Acceptances are read-only". Co-Authored-By: Claude Opus 5 --- .../findings_workflows/PRO__risk_acceptance.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md b/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md index 0bb596ea041..ada7313b0ac 100644 --- a/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md +++ b/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md @@ -243,6 +243,12 @@ automatically the person who agrees. Activating is an approval because it is the moment suppression starts. Reinstating an expired acceptance is the same move, and needs the same permission. +**Reinstating needs an approver, by every route.** Open source treats reinstating an expired Risk +Acceptance as an edit — which is right when there is no review to respect. With Risk Acceptances 2.0 +enabled there is one, so `POST .../reinstate/` and changing the expiration date on an expired Risk +Acceptance (which reinstates it as a side effect) both require **Risk Acceptance Approve**. Editing +any other field on an expired Risk Acceptance is still an ordinary edit. + **The requester cannot be the approver.** Holding the approve permission is not the same as being a second pair of eyes, so whoever requested a Risk Acceptance cannot approve or activate it themselves — the API returns `403` and says so. Rejecting your own request is still allowed; that From 9a77ff1e79fb63fc1ad9338f8ceeeee989c48fc8 Mon Sep 17 00:00:00 2001 From: Greg Anderson Date: Tue, 4 Aug 2026 17:04:41 -0600 Subject: [PATCH 07/16] docs(risk acceptance): document the Rules Engine conditions Five condition paths a rule can use once both features are on, with what each one says rather than just its name -- the useful ones are not obvious from the field names alone. Calls out two things a rule author would otherwise discover by accident: days_to_expiry goes negative once the date has passed (so "expired 3 days ago" is expressible, and "expiring within 7 days" does not silently also match everything long overdue), and the fields describe the earliest acceptance when a Finding is in several. Co-Authored-By: Claude Opus 5 --- .../PRO__risk_acceptance.md | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md b/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md index ada7313b0ac..169d996869d 100644 --- a/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md +++ b/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md @@ -360,6 +360,30 @@ Three rules keep this safe to leave switched on: Applying criteria never fails an import: if it cannot run, the Finding is imported unaccepted and the next reimport tries again. +### Rules Engine 2.0 conditions + +With both features enabled, a rule can condition on what an acceptance is doing, not just on the +`Risk Accepted` flag: + +| Condition path | Type | Says | +| --- | --- | --- | +| `finding.has_pending_exception` | boolean | Somebody has asked for this Finding to be accepted and nobody has answered | +| `finding.risk_acceptance_state` | select | The state of the Risk Acceptance covering it (a picker, drawn from the lifecycle's own states) | +| `finding.risk_acceptance_expiration_date` | string | When that acceptance expires, ISO-8601 | +| `finding.risk_acceptance_days_to_expiry` | number | Days until it expires — **negative once the date has passed**, so "expired 3 days ago" is expressible | +| `finding.risk_acceptance_is_global` | boolean | Whether that acceptance spans more than one Asset | + +What this makes possible, for example: notify an owner when an acceptance covering their Findings is +within a week of expiring (`risk_acceptance_days_to_expiry <= 7`), or chase requests that have sat +unanswered (`has_pending_exception == true`). + +All of these read empty while **Risk Acceptances 2.0** is off — `false` for the boolean and `null` +for the rest — so a rule written against them matches nothing rather than acting on a lifecycle the +install does not use. + +Where a Finding is covered by more than one Risk Acceptance, these describe the earliest one it was +accepted under. + ### API ``` From 99c1f28647ba9c5bde8549baec23bec02fa86281 Mon Sep 17 00:00:00 2001 From: Greg Anderson Date: Wed, 5 Aug 2026 15:11:07 -0600 Subject: [PATCH 08/16] docs(risk acceptance): document the review notifications and webhook payload Two events, who each one goes to, and why activation/expiry/reinstatement are deliberately silent -- that last one reads as an omission otherwise. Shows the webhook body, because the point of the payload is that a subscriber gets the state change rather than a sentence to parse. Says plainly that notifications are reinforcement: the state change is visible on the Risk Acceptance regardless, and a failed send does not undo a decision. Co-Authored-By: Claude Opus 5 --- .../PRO__risk_acceptance.md | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md b/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md index 169d996869d..cb131760e80 100644 --- a/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md +++ b/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md @@ -360,6 +360,40 @@ Three rules keep this safe to leave switched on: Applying criteria never fails an import: if it cannot run, the Finding is imported unaccepted and the next reimport tries again. +### Notifications and webhooks + +Two events fire on the review, and they go to different people because they answer different +questions: + +| Event | Fires when | Goes to | +| --- | --- | --- | +| **Risk Acceptance Requested** | somebody submits a Risk Acceptance for review | the named reviewers, or everybody subscribed to the event if none are named | +| **Risk Acceptance Decided** | it is approved or rejected | whoever requested it | + +Both are ordinary notification events, so each user picks their own channels — alert, mail, Slack, +Teams or **webhook** — under Notification Settings. + +Activation, expiry and reinstatement are deliberately silent here. They are not somebody waiting on +somebody else, expiry already has its own long-standing notification, and mailing about every state +change is how people learn to ignore the ones that matter. + +The webhook body carries the state change itself, so a subscriber does not have to parse a sentence: + +```yaml +risk_acceptance: + id: 42 + name: "Accepted pending the Q4 platform upgrade" + from_state: "under_review" + to_state: "approved" + actor: "someone" + reason: "the compensating control is adequate" + url_ui: "https://your-instance/ui/risk_acceptance/42" +``` + +Notifications are reinforcement, not the mechanism: every state change is already visible on the +Risk Acceptance, in its Approvals tab and in the review queue, whether or not a message is sent or +ever arrives. A send that fails is logged and does not undo the decision. + ### Rules Engine 2.0 conditions With both features enabled, a rule can condition on what an acceptance is doing, not just on the From 31daaa932bb7ac6910661c0e6df477ed19f44247 Mon Sep 17 00:00:00 2001 From: Greg Anderson Date: Wed, 5 Aug 2026 22:05:40 -0600 Subject: [PATCH 09/16] docs(risk acceptance): extending an expired acceptance, and finding what needs attention Two behaviours a reader cannot guess. An expired Risk Acceptance no longer goes straight back into force: the expiration date exists because somebody wanted the risk looked at again, so extending it is another review. The page says what the 409 means, that the older reinstate route refuses for the same reason rather than being a way around it, and which setting turns it off. The two new list filters get a section because they answer questions people already ask this data: what lands on somebody's desk this week, and what have we accepted on this host -- the latter being what gets asked when a host is decommissioned or turns up in an audit. Also records the boundary that keeps the two systems honest: the setting governs what a person may ask for through the API, while open source stays the authority on whether an acceptance has expired. Co-Authored-By: Claude Opus 5 --- .../PRO__risk_acceptance.md | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md b/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md index cb131760e80..5bae77c9d8c 100644 --- a/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md +++ b/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md @@ -258,6 +258,41 @@ An administrator can turn this off with **Block Self-Approval of Risk Acceptance Settings (on by default). Turning it off is for a team small enough that one person is the whole approval chain, where the alternative is that nobody uses the workflow at all. +### Extending an expired acceptance + +An expiration date exists because somebody wanted the risk looked at again on that date. So by +default an expired Risk Acceptance cannot simply be switched back on — it has to go back through +review: + +1. Move it to **proposed** (Risk Acceptance Edit). This is the "please extend this" step. +2. It is approved or rejected as any other request is (Risk Acceptance Approve). +3. Approving and activating it puts the suppression back. + +Trying to move an expired Risk Acceptance straight to `active` returns `409` with a message saying +it has to be reviewed again — including through `POST .../reinstate/`, so the older route is not a +way around the review. `409` rather than `403` because the caller may hold every permission there +is; what is wrong is the state, and asking for a review is a move they can make. + +An administrator can turn this off with **Require Review to Extend an Expired Risk Acceptance** in +System Settings (on by default), for teams whose expiry dates are a reminder rather than a gate. + +This governs what a person may ask for through the API. If expiry is cleared by any other path, +the lifecycle still follows it — open source remains the authority on whether a Risk Acceptance has +expired, and Pro records the consequence rather than arguing with it. + +### Finding what needs attention + +Two list filters on `/api/v2/risk_acceptance/` answer the questions people actually ask of this +data: + +| Filter | Answers | +| --- | --- | +| `expiring_within_days=7` | What lands on somebody's desk this week. Bounded at both ends — already-expired Risk Acceptances are excluded, because those are a different queue (`workflow_state=expired`) | +| `finding_host=api.example.com` | What have we accepted on this host — the question asked when a host is decommissioned, handed to another team, or turns up in an audit. Matches part of a host, and matches the host of the Finding's Location (or its Endpoint, on installs that have not moved to Locations) | + +Combine them with `workflow_state` to build a review queue: `?workflow_state=proposed&workflow_state=under_review` +is everything nobody has decided yet. + ### Requested exceptions in your metrics The problem this solves: a team asks for an exception and waits — on a change board, on a vendor, on From 1f51caf9863f7d55dbcc8b6a055c88db6691036d Mon Sep 17 00:00:00 2001 From: Greg Anderson Date: Mon, 10 Aug 2026 21:38:39 -0600 Subject: [PATCH 10/16] docs(risk acceptance): describe the review thread on the Approvals tab Says what the thread is (decisions and comments in one time-ordered conversation), why it is one list rather than two panels, and the two things a reader would otherwise have to discover: the comments are ordinary Risk Acceptance notes, so older ones are already there and new ones show up everywhere else those notes do, and line breaks survive. Co-Authored-By: Claude Opus 5 --- .../findings_workflows/PRO__risk_acceptance.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md b/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md index 5bae77c9d8c..92379b8e39e 100644 --- a/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md +++ b/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md @@ -293,6 +293,21 @@ data: Combine them with `workflow_state` to build a review queue: `?workflow_state=proposed&workflow_state=under_review` is everything nobody has decided yet. +### The review thread + +The **Approvals** tab shows the decisions and the discussion as one conversation, oldest first: +every state change with the reason recorded against it, interleaved with comments people have +added. + +They are two different records — a reason belongs to the move that carried it, a comment belongs to +nobody's decision — but a reviewer reads one thread. Kept apart, "why was this approved?" is +answered by a reason in one place and the argument that produced it in another, with nothing saying +which came first. + +Comments are ordinary Risk Acceptance notes, so anything written before this existed is already in +the thread, and anything written here is visible to every other view of those notes. Line breaks are +preserved. + ### Requested exceptions in your metrics The problem this solves: a team asks for an exception and waits — on a change board, on a vendor, on From 65e448a1f6ff86f2de561903751486a5bb060b7f Mon Sep 17 00:00:00 2001 From: Greg Anderson Date: Mon, 10 Aug 2026 21:48:34 -0600 Subject: [PATCH 11/16] docs(risk acceptance): document the severity limit on the Accept Risk rule action The behaviour a reader cannot guess: over the limit the Finding is not accepted and not dropped either -- it becomes a Risk Acceptance awaiting review, still active and still counted. Includes the two asymmetries (an unrecognisable severity is treated as over the limit, an unrecognisable limit is ignored) and what happens with the feature flag off. Co-Authored-By: Claude Opus 5 --- .../PRO__risk_acceptance.md | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md b/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md index 92379b8e39e..2d91010ef53 100644 --- a/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md +++ b/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md @@ -468,6 +468,29 @@ install does not use. Where a Finding is covered by more than one Risk Acceptance, these describe the earliest one it was accepted under. +#### How severe a Finding a rule may accept + +The **Accept Risk** action takes a limit — *Accept Without Review Up To*. Anything more severe than +that is **not** accepted by the rule. + +Those Findings are not dropped. The rule matched them for a reason, so they are put into a Risk +Acceptance awaiting review, named for the rule that asked and carrying why. They stay **active and +counted** the whole time, exactly like any other requested exception, and a person decides. + +| Setting | What the rule does with a Critical | +| --- | --- | +| No limit (default) | Accepts it | +| Accept up to High | Leaves it active and raises a Risk Acceptance for review | + +Two details worth knowing. A severity the rule cannot recognise counts as *over* the limit — if it +cannot be ranked it cannot be called safe. But a *limit* that cannot be recognised is ignored rather +than blocking everything, because a rule that silently stops working is harder to notice than one +that keeps going. + +With **Risk Acceptances 2.0** off there is no review state to put them in, so they are simply left +alone. They are never accepted anyway; that is the one thing the limit exists to prevent. A rule +preview creates nothing, as with every other action. + ### API ``` From 517446d99c62860ebe2ee07d7118db8a4e992aa1 Mon Sep 17 00:00:00 2001 From: Greg Anderson Date: Mon, 10 Aug 2026 22:19:40 -0600 Subject: [PATCH 12/16] docs(risk acceptance): document the exception-backlog metrics endpoint Explains what each number means and, more importantly, which question it answers -- particularly the two that are easy to confuse: `overdue` is past its date and still suppressing findings, which is not the same set as `expired`, and `average_days_to_decision` counts only decided requests so that filing a new one cannot improve the figure. Co-Authored-By: Claude Opus 5 --- .../PRO__risk_acceptance.md | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md b/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md index 2d91010ef53..fe655538088 100644 --- a/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md +++ b/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md @@ -308,6 +308,31 @@ Comments are ordinary Risk Acceptance notes, so anything written before this exi the thread, and anything written here is visible to every other view of those notes. Line breaks are preserved. +### Is the backlog being managed? + +``` +GET /api/v2/risk_acceptance/exception_metrics/?expiring_within_days=30 +``` + +A count of Risk Acceptances does not answer the question an auditor asks. Fifty active exceptions +that are all dated and reviewed is a healthy program; three that lapsed a year ago is not. This +reports the numbers that distinguish them, for the Risk Acceptances **you can see**: + +| Field | Means | +| --- | --- | +| `active`, `expired`, `rejected` | Where things currently stand. Risk Acceptances predating the feature count as active, which is what they are | +| `awaiting_decision` | Requests nobody has answered — proposed and under review together, because "waiting" is one queue however it is labelled | +| `oldest_waiting_days` | How long the longest-waiting request has waited, measured from the move that asked | +| `average_days_to_decision` | How long requests typically wait for an answer. Only decided requests count; including open ones would make the number fall every time somebody files a request | +| `expiring_soon` | Expiring within `expiring_within_days` (default 30) | +| `overdue` | **Past its date and still suppressing findings.** Not the same as `expired` — this is the set nobody has dealt with | +| `average_age_days` | How long the exceptions in force have been in force | +| `never_expires` | Active Risk Acceptances with no expiration date at all — the shape of "we accepted this and forgot" | + +`expiring_within_days` must be a whole number; a value that is not is refused rather than ignored, +because a figure that quietly answers a different question than the one asked is worse than an +error. + ### Requested exceptions in your metrics The problem this solves: a team asks for an exception and waits — on a change board, on a vendor, on From 77caea4e1cd95e5b6e3009600aa7c5fd58b7c8aa Mon Sep 17 00:00:00 2001 From: Greg Anderson Date: Tue, 11 Aug 2026 16:07:02 -0600 Subject: [PATCH 13/16] docs(risk acceptance): document the approval chain in reports Four new Risk Acceptance report columns, and the two things a reader would otherwise misread: they are empty for acceptances made before the workflow existed (deliberately, rather than falling back to a typed field), and moves that are not decisions are ignored so the column names whoever agreed rather than whoever last touched the record. Co-Authored-By: Claude Opus 5 --- .../findings_workflows/PRO__risk_acceptance.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md b/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md index fe655538088..16ffba9f6bf 100644 --- a/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md +++ b/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md @@ -308,6 +308,24 @@ Comments are ordinary Risk Acceptance notes, so anything written before this exi the thread, and anything written here is visible to every other view of those notes. Line breaks are preserved. +### In reports + +Risk Acceptance report blocks can print the approval chain, not just the decision: + +| Column | Shows | +| --- | --- | +| **Workflow State** | Where the Risk Acceptance is in its lifecycle. Also available as a sort field | +| **Approved By** | Who made the decision — the person who approved or rejected it | +| **Approved On** | When they made it | +| **Decision Reason** | What they said at the time (detail blocks only) | + +These read from the review history, so they are filled for Risk Acceptances decided through the +workflow and **empty for ones accepted before it existed**. That is deliberate: falling back to the +`Accepted By` field would present something somebody typed as though it were a recorded approval. + +Moves that are not decisions — submitting, activating, expiring — are ignored, so the column names +whoever agreed rather than whoever last touched the record. + ### Is the backlog being managed? ``` From 5e111cd596cc8b75c741eac625095eac1a74e516 Mon Sep 17 00:00:00 2001 From: Greg Anderson Date: Tue, 11 Aug 2026 16:08:04 -0600 Subject: [PATCH 14/16] docs(rules engine 2.0): risk acceptance automation in the rules docs The RA page described these from the risk acceptance side, but a rule author looks in the rules docs. Adds the acceptance condition paths to Referring to Finding data, and the severity limit to the Accept Risk node reference, each cross-linking to the Risk Acceptances 2.0 page. Says the two things that are not guessable: over the limit a Finding is neither accepted nor dropped but sent for review, and with the feature off the condition fields read empty so a rule matches nothing rather than acting on a lifecycle the install does not use. Co-Authored-By: Claude Opus 5 --- .../rules_engine_2/building_rules.md | 23 +++++++++++++++++++ .../rules_engine_2/node_reference.md | 18 +++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/docs/content/automation/rules_engine_2/building_rules.md b/docs/content/automation/rules_engine_2/building_rules.md index 4ebfbcc6a46..cd5c78efe14 100644 --- a/docs/content/automation/rules_engine_2/building_rules.md +++ b/docs/content/automation/rules_engine_2/building_rules.md @@ -98,6 +98,29 @@ ctx.rule_name A path that does not resolve produces no value rather than an error. +### Conditioning on an exception + +With [Risk Acceptances 2.0](../../triage_findings/findings_workflows/pro__risk_acceptance/) enabled, +a rule can condition on what an acceptance is *doing*, not just on the `Risk Accepted` flag: + +``` +finding.has_pending_exception somebody asked, nobody has answered +finding.risk_acceptance_state proposed / under_review / approved / rejected / active / expired +finding.risk_acceptance_expiration_date +finding.risk_acceptance_days_to_expiry negative once the date has passed +finding.risk_acceptance_is_global +``` + +What that makes possible, for example: chase requests nobody has answered +(`has_pending_exception eq true`), or warn an owner a week before an exception lapses +(`risk_acceptance_days_to_expiry lte 7`). Because days-to-expiry goes negative rather than stopping +at zero, "expired three days ago" is expressible too. + +With **Risk Acceptances 2.0** off these read empty — `false` for the boolean, nothing for the rest — +so a rule written against them matches nothing rather than acting on a lifecycle the install does +not use. Where a Finding is covered by more than one Risk Acceptance, they describe the earliest one +it was accepted under. + ### Available fields Each item carries a fixed set of Finding fields. This list is a contract, so it changes only deliberately. diff --git a/docs/content/automation/rules_engine_2/node_reference.md b/docs/content/automation/rules_engine_2/node_reference.md index 047a71a89ff..b2cf127f69a 100644 --- a/docs/content/automation/rules_engine_2/node_reference.md +++ b/docs/content/automation/rules_engine_2/node_reference.md @@ -174,6 +174,24 @@ Simple risk accepts the Finding, or adds it to a risk acceptance record. | **How** | `simple` | `simple` sets simple risk acceptance on the Finding. `acceptance` adds it to a risk acceptance record. | | **Accepted** | on | Shown for `simple`. Turn off to un-accept the risk. | | **Risk Acceptance** | none | Shown for `acceptance`. Which risk acceptance to add these Findings to. | +| **Accept Without Review Up To** | No limit | The most severe Finding this rule may accept on its own. Anything more severe is **not** accepted. | + +#### Limiting what a rule may accept on its own + +A rule that can accept risk can accept a Critical, and by default nothing says otherwise. *Accept +Without Review Up To* draws that line. + +Findings over the limit are not dropped — the rule matched them for a reason. With +[Risk Acceptances 2.0](../../triage_findings/findings_workflows/pro__risk_acceptance/) enabled they +are put into a Risk Acceptance **awaiting review**, named for the rule that asked and carrying why, +so a person decides. They stay active and counted the whole time. With that feature off there is no +review state to use, so they are simply left alone — never accepted, which is the point of the +limit. A rule preview creates nothing, as with every other action. + +Two behaviours worth knowing: a severity the rule cannot recognise counts as *over* the limit (if it +cannot be ranked it cannot be called safe), while a *limit* that cannot be recognised is ignored +rather than blocking everything, because a rule that silently stops working is harder to notice than +one that keeps going. ### Set Mitigation Policy From 1ffb8130dbce2c31f1df3c722aa3c0b3ce67dc0b Mon Sep 17 00:00:00 2001 From: Greg Anderson Date: Tue, 11 Aug 2026 17:43:37 -0600 Subject: [PATCH 15/16] docs(risk acceptance): how to build a read-only auditor A custom role holding Risk Acceptance View and nothing else, rather than any change to the built-in roles. Says what such a role can read, that every write is refused, that visibility stays scoped to the auditor's own Assets, and that a plain Reader is unaffected -- acceptance visibility is a separate grant from Finding visibility on purpose. Co-Authored-By: Claude Opus 5 --- .../PRO__risk_acceptance.md | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md b/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md index 16ffba9f6bf..7603c65973f 100644 --- a/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md +++ b/docs/content/triage_findings/findings_workflows/PRO__risk_acceptance.md @@ -258,6 +258,26 @@ An administrator can turn this off with **Block Self-Approval of Risk Acceptance Settings (on by default). Turning it off is for a team small enough that one person is the whole approval chain, where the alternative is that nobody uses the workflow at all. +### A read-only auditor + +An ERM or audit reader needs to see what has been accepted and who agreed, and needs to change +nothing. Give them a **custom role holding `Risk Acceptance View` and nothing else**, then add them +as a member of the Assets they audit. + +With that role they can: + +- list and open Risk Acceptances, including the reasoning +- read the lifecycle state, the transition history and the finding records +- read the backlog metrics + +and they cannot create, edit, delete, or decide anything — every write answers `403`. + +Two things worth knowing. Visibility is scoped like every other read, so an auditor added to one +Asset sees that Asset's exceptions and no others. And **no built-in role changes**: a plain Reader +still cannot see Risk Acceptances, because acceptance visibility is deliberately a separate grant +from Finding visibility — seeing a Finding does not entitle you to the reasoning behind its +exception. + ### Extending an expired acceptance An expiration date exists because somebody wanted the risk looked at again on that date. So by From e08d0ae007b742e0bd57ec81cd16487daac8f4ee Mon Sep 17 00:00:00 2001 From: Greg Anderson Date: Wed, 12 Aug 2026 00:05:17 -0600 Subject: [PATCH 16/16] docs(rules engine 2.0): absolute links to the risk acceptance page The relative form resolved one directory short from /automation/rules_engine_2// and the link checker failed the build. Every other cross-reference in the tree is absolute; match it. --- docs/content/automation/rules_engine_2/building_rules.md | 2 +- docs/content/automation/rules_engine_2/node_reference.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/content/automation/rules_engine_2/building_rules.md b/docs/content/automation/rules_engine_2/building_rules.md index cd5c78efe14..cc3a1285f65 100644 --- a/docs/content/automation/rules_engine_2/building_rules.md +++ b/docs/content/automation/rules_engine_2/building_rules.md @@ -100,7 +100,7 @@ A path that does not resolve produces no value rather than an error. ### Conditioning on an exception -With [Risk Acceptances 2.0](../../triage_findings/findings_workflows/pro__risk_acceptance/) enabled, +With [Risk Acceptances 2.0](/triage_findings/findings_workflows/pro__risk_acceptance/) enabled, a rule can condition on what an acceptance is *doing*, not just on the `Risk Accepted` flag: ``` diff --git a/docs/content/automation/rules_engine_2/node_reference.md b/docs/content/automation/rules_engine_2/node_reference.md index b2cf127f69a..d490e93add4 100644 --- a/docs/content/automation/rules_engine_2/node_reference.md +++ b/docs/content/automation/rules_engine_2/node_reference.md @@ -182,7 +182,7 @@ A rule that can accept risk can accept a Critical, and by default nothing says o Without Review Up To* draws that line. Findings over the limit are not dropped — the rule matched them for a reason. With -[Risk Acceptances 2.0](../../triage_findings/findings_workflows/pro__risk_acceptance/) enabled they +[Risk Acceptances 2.0](/triage_findings/findings_workflows/pro__risk_acceptance/) enabled they are put into a Risk Acceptance **awaiting review**, named for the rule that asked and carrying why, so a person decides. They stay active and counted the whole time. With that feature off there is no review state to use, so they are simply left alone — never accepted, which is the point of the