Vulnerability Management Standard
This standard is the policy for the outcomes of pitlab’s vulnerability loop: how completely the estate must be scanned, how quickly a finding must be triaged, how long a fixable vulnerability may stay open, and who may decide not to fix one. It exists because the mechanisms were all built before the policy was written — Trivy scans, Dependency-Track holds the findings, vulnscan triages and remediates, ADR-0302 governs risk acceptance — but nothing stated a number any of them could breach. That is why nine critical-tier patch bumps queued for 15–29 days without formally breaching anything, and why a broken scan hid 13 CRITICAL findings for nine days with no rule to violate. The mechanics of the loop live in the vulnerability-management overview; this standard governs what the loop must achieve, and cites the mechanisms rather than restating them.
Scan coverage means one Dependency-Track project per production host and per running image, with every exclusion named
Section titled “Scan coverage means one Dependency-Track project per production host and per running image, with every exclusion named”Every host in the Ansible inventory and every container image running on docker01 or pve01 MUST be represented by exactly one Dependency-Track project fed by a Trivy CycloneDX SBOM. As of 2026-08-06 that is 75 active projects. Coverage is asserted against the live estate, not against a hand-maintained list: a workload that exists in production and has no DT project is a coverage gap, not an oversight, and is tracked as an Issue until closed.
“Running” means executed by the estate, not currently in docker ps. A build-time image (the docs toolchain, a validator container) and a run-to-completion image (a weekly render, a batch indexer) exist for seconds or minutes and are invisible to any snapshot of running containers — so a clause read literally excludes exactly the images nobody is watching. scan_trivy_docker.yml’s extra_images list is the mechanism for this class and it is not optional: an image the estate executes and nobody scans is a coverage gap regardless of whether a docker ps at scan time would have found it. Measured 2026-08-06, the pitlab-docs MkDocs build image carried 94 unmonitored findings (45 Alpine OS, 49 Python) solely because it is never running when the sweep looks (ADR-0324, Issue #2186).
Where a build-time image’s tag is already pinned in a committed pipeline or playbook, the scan target is derived from that pin, never copied. A second hardcoded copy drifts the first time the pin is bumped, and the scan then reports cleanly on a version the estate no longer builds with — a coverage lie, which is worse than the gap it replaced. Deriving fails closed: if the pin cannot be resolved, the scan fails rather than silently dropping the image.
An exclusion is legitimate only when it is named, reasoned, and dated in the scan-coverage exclusions table, and states what detection replaces the scan. Two inventory hosts carry no Dependency-Track project today — hass and ubuntu25 — and both are recorded there. pit-mini was a third when this standard landed, excluded silently by a !macos play-pattern token; applying this clause found it, and it is now scanned by its own surface (ADR-0327, Issue #2178) rather than excluded. An exclusion that exists only as a !group in an Ansible hosts: line is not an exclusion, it is an unrecorded gap.
Every committed dependency pin has an update proposer, because a scanner alone does not see what it does not carry
Section titled “Every committed dependency pin has an update proposer, because a scanner alone does not see what it does not carry”Scanning and update automation are not redundant layers of the same control — they detect different things, and for a build-time toolchain the update bot is the primary detector. Trivy reports what its database knows about the packages it finds; Renovate reports that upstream shipped a newer version at all, regardless of whether anyone has filed an advisory. Where the advisory is missing, only the second one fires.
That is not hypothetical. GHSA-xvg9-69gf-fjrf (DOM XSS in Material for MkDocs, affecting 7.2.0–9.7.6) shipped on the public docs.pitbun.com for ~3 weeks. Measured on 2026-08-06, trivy 0.70.0 scanning the affected image finds mkdocs-material as an installed package and reports no finding against it — while reporting GHSA-only advisories for other pip packages in the same scan. Full SBOM coverage would have stayed green for the entire window; a Renovate PR proposing 9.7.7 would not have.
Therefore: every committed pin of a third-party dependency — including build-time image tags and in-pipeline pip install pins — must be inside an update bot’s view. A repository absent from the runner’s repositories list, or a pin in a file no manager matches, is a governance gap of the same class as a missing Dependency-Track project, and is tracked as an Issue until closed. A pin nobody proposes a bump for is not a review checkpoint; it is a freeze.
A dependency delivered to a browser is a managed dependency, pinned and SBOM’d like any other
Section titled “A dependency delivered to a browser is a managed dependency, pinned and SBOM’d like any other”Host packages are covered by patchmon and the overnight patch window; container images are covered by Renovate pins, Trivy and Dependency-Track. JavaScript served to a browser is neither, and until Epic #2260 the estate had no workload of that class. A vendored library sitting in a bind-mounted directory that a stock nginx serves is invisible to every scanner the estate runs: no Trivy layer contains it, no dpkg database lists it, and no SBOM describes it. A known CVE in it would be served to every viewer indefinitely with nothing red anywhere.
Any dependency delivered to a browser therefore carries the same four obligations as every other dependency class:
- Declared and pinned in a
package.jsonbeside the stack that serves it, with a committed lockfile, at an exact version — never a range. - The vendored asset is committed alongside the lockfile, and the two move in the same commit. A lockfile that disagrees with the served file makes the SBOM describe bytes the browser never loads.
- Renovate-managed through the built-in npm manager, on the same blast-radius governance as image and pip bumps.
- SBOM’d into its own Dependency-Track project via
cyclonedx-npm, production dependencies only, so a CVE surfaces in the weekly/vulnscanloop like any other finding.
Loading the dependency from a public CDN — with or without a Subresource Integrity hash — does not satisfy this clause for internal tooling: it makes an internal tool depend on an external host at runtime, and an SRI bump is a manual edit nothing watches.
Reference implementation: the servicemap-frontend project
(ADR-0360).
The whole estate is rescanned weekly, and an SBOM older than eight days is a coverage failure
Section titled “The whole estate is rescanned weekly, and an SBOM older than eight days is a coverage failure”The scan cadence is weekly. The vulnscan-weekly control01 cron (Monday 09:00 AEST) is the single owner: it runs all five scan surfaces blocking — scan_trivy.yml (fleet host rootfs), scan_trivy_arm.yml (mesh01), scan_trivy_macos.yml (pit-mini’s Homebrew prefix), scan_trivy_docker.yml (docker01 images) and scan_trivy_pve01.yml (pve01 compose images) — before the triage agent is invoked. The standalone Trivy crons were retired at go-live precisely so there is one owner and one cadence.
A project’s SBOM MUST therefore never be older than 8 days — one weekly cycle plus a day’s grace, the same ceiling as the vulnscan.run.age dead-man that watches the runner. Eight days is the coverage number; the runner heartbeat is not a substitute for it, because a perfectly healthy weekly run can sit above a project that silently stopped refreshing.
A stale SBOM and a clean one are numerically identical, so silence counts as evidence only once freshness is proven
Section titled “A stale SBOM and a clean one are numerically identical, so silence counts as evidence only once freshness is proven”Zero new findings because everything is patched and zero new findings because the scan is broken produce the same output. This is Prime Directive 12’s absence of errors is not proof of function applied to scanning, and it has already cost the estate: the docker-zfdash project’s scan failed on every run for nine days and nobody knew, because the only durable symptom was an ageing timestamp. When it was fixed, 176 findings appeared at once — 13 CRITICAL and 56 HIGH — all of which had been invisible the whole time.
The rule that follows is structural: per-project freshness is the precondition for reading any finding count as good news. A delta computed over a project whose SBOM is stale is not a clean week, it is an unread one.
An SBOM missing a whole ecosystem is indistinguishable from a complete one, so coverage must be asserted on composition, not row count
Section titled “An SBOM missing a whole ecosystem is indistinguishable from a complete one, so coverage must be asserted on composition, not row count”The freshness clause above closes the stale case. It does not close the incomplete case, and that case is worse: a stale SBOM at least ages into visibility, while an SBOM that is silently missing an entire package ecosystem refreshes on time, uploads cleanly, import-verifies, and holds its freshness ceiling forever. Project presence, project freshness and a healthy row count are all satisfiable by an SBOM that measures a fraction of the host.
The estate has now paid for this. From go-live until 2026-08-17, six hosts carried zero Go-binary components — pve01 (the hypervisor), xt035, plex01, xv035, xt010, xt020 — and the remaining eight were missing Alloy and zabbix_agent2 while looking healthy on the strength of unrelated Go binaries they happened to also carry. pve01 presented 1,016 components, none of them Go, which clears any plausible row-count floor. Trivy’s SystemFileFilteringPostHandler had removed every dpkg-owned language component, which for a vendor deb shipping a static Go binary means the estate held no advisory source for it at all (ADR-0424, incident #2437).
So a scan surface MUST assert something only a working analyzer can satisfy, not merely that output was produced:
- The assertion is on composition — the presence of components of an ecosystem the target is known to contain — never on total count alone. A row-count floor is a liveness check (FC-04
proxy-not-artifact), and clears comfortably while a whole ecosystem is missing. - The expected ecosystem is derived from a property true of every target in the pattern, so a zero is unambiguously a broken scan rather than a clean host. Every fleet host runs
/usr/bin/alloyand/or/usr/sbin/zabbix_agent2, both Go, both mandated by Pillars 1 and 2 — sogobinary > 0is a fact about the estate’s own standards, not a borrowed threshold. - A scan that fails the assertion fails the host. It does not upload a partial SBOM and warn, because a warning next to a successful import is how eleven months passed.
Remediation SLA clocks start at a finding’s first Dependency-Track attribution, not at triage
Section titled “Remediation SLA clocks start at a finding’s first Dependency-Track attribution, not at triage”Every deadline in this standard is measured from the finding’s attribution.attributedOn in Dependency-Track — the moment the estate first had the evidence — never from when someone got round to looking at it. Measuring from triage would make the clock resettable by not triaging, which is the exact failure mode the SLA exists to prevent.
Remediation SLA by severity — in-bounds fixes are bounded in days, attended fixes are bounded by a decision
Section titled “Remediation SLA by severity — in-bounds fixes are bounded in days, attended fixes are bounded by a decision”An in-bounds fix is one the loop may apply unattended under ADR-0101: a same-major pinned-tag patch/minor bump, a Python/npm security floor pin, or a targeted OS package upgrade. Everything else is attended — a major bump, a version migration, or any fix needing behavioural testing — and its SLA binds the decision, not the engineering, because the work may legitimately need a maintenance window.
| Severity | In-bounds fix — applied, verified, closed | Attended fix — decision recorded (schedule / mitigate / accept) |
|---|---|---|
| CRITICAL | 7 days (the run that discovers it; 14 days if deferred by the blast-radius cap) | groomed Epic raised in the discovering run; decision within 14 days |
| HIGH | 14 days (two weekly cycles) | decision within 30 days |
| MEDIUM | 30 days | decision within 90 days |
| LOW / UNASSIGNED | next natural bump — no dated SLA | none |
“Closed” means the prove-it gate passed: a targeted re-scan confirmed the specific finding cleared in Dependency-Track and the target’s health did not regress. A deploy is never a fix.
A security fix waiting in an open dependency PR is bound by the same SLA as the finding it clears
Section titled “A security fix waiting in an open dependency PR is bound by the same SLA as the finding it clears”A fix sitting in an unmerged Renovate PR is an unfixed vulnerability with extra steps. Two rules bound it:
- Critical-tier
patchanddigestbumps auto-merge on green CI and are expected to land within 72 hours of the PR opening. This is ADR-0301: a review gate that the vulnscan loop is separately authorised to bypass is latency, not control. The safety argument is the gate pair — the pre-deploy smoke gate on crash-loop and the service test plan on functional regression. - A critical-tier
minorormajorPR carrying a security fix MUST be merged, or explicitly deferred with a recorded reason, within 14 days. This is the clause that would have caught the 15–29 day queue of nine PRs carrying 70 net-new CRITICAL/HIGH findings (Issue #2070). An indefiniteneeds-changelog-reviewlabel is a deferral with no decision behind it.
A deferral is recorded as the Azure DevOps pull-request label deferred-until-YYYY-MM-DD, with the reason in a PR comment. The label is the machine-readable half of “explicitly deferred with a recorded reason”: a decision was taken, and here is when it runs out. It expires — past that date the PR is in breach again — so “defer indefinitely” is not reachable, and a malformed date is ignored rather than honoured. Same shape as a risk acceptance with an expiry (ADR-0302).
The machine check enforces a deliberate superset: EVERY critical-tier PR, not only those carrying a security fix. Whether a given upstream release carries a security fix is not machine-determinable on this platform — Renovate’s vulnerabilityAlerts needs advisory data that Azure DevOps does not supply. The honest options were to enforce nothing or to enforce the decidable superset, and the superset stands on its own: a critical-tier image bump deserves a decision inside 14 days whether or not an advisory happens to be attached, and the cost of a “not yet” is one dated label. This is stated here rather than left as a check quietly stricter than the standard it enforces (ADR-0364).
An SLA breach is itself a finding — it converts to an escalation or a risk acceptance, never rolls on
Section titled “An SLA breach is itself a finding — it converts to an escalation or a risk acceptance, never rolls on”When a deadline passes, the position changes state: the finding MUST become either an escalated Issue with a named owner and a date, or a row on the Vulnerability Risk Acceptance Register under the acceptance rules below. Silently carrying a breached finding into the next weekly delta is not permitted — that is precisely how a 29-day queue formed while every individual run reported normally.
vulnscan owns triage by default, and every finding it touches is annotated in the same run
Section titled “vulnscan owns triage by default, and every finding it touches is annotated in the same run”Triage ownership is not ambient. The vulnscan loop is the standing default owner of every finding in Dependency-Track: it is a standing-tier autonomous actuator under the Autonomous Remediation Authority Standard, and its annotation sweep is mandatory in both dryrun and live mode — annotation is triage, not mutation. Every finding it acts on — fixed, escalated, accepted or dismissed — leaves a Dependency-Track analysis state and a comment carrying the reasoning, so the next run does not re-derive it from scratch.
Ownership may be reassigned to the owner for a specific cluster (an attended migration, a risk acceptance), but it is never unassigned: a finding with no owner defaults back to the loop.
A finding left at analysis state NONE beyond one scan cycle is a triage breach
Section titled “A finding left at analysis state NONE beyond one scan cycle is a triage breach”Un-annotated is untriaged. No finding may sit at Dependency-Track analysis state NONE for more than 7 days — one scan cycle — and an un-annotated CRITICAL or HIGH past that point is an alertable breach, not a backlog item. The rule is deliberately about annotation, not about being fixed: recording “this is real but carried” is triage; recording nothing is a hole in the record where the next run’s reasoning should have been.
The estate met this clause on 2026-08-06 and it is now measured. A live read across all 75 projects found 2,857 findings at NONE, of which 291 CRITICAL/HIGH were older than 7 days — 54 CRITICAL and 237 HIGH, the oldest 17.9 days — a standing backlog rather than one week’s delta, which nothing measured. All 2,857 were swept to IN_TRIAGE with a per-class fix-route comment, and dt_sla_conformance_check.py now emits dt_sla_untriaged_overdue_count daily so the next non-converging weekly run is caught in hours rather than weeks (Issue #2176, ADR-0328).
A CRITICAL or HIGH at IN_TRIAGE for more than 90 days must convert to a fix, an acceptance, or NOT_AFFECTED
Section titled “A CRITICAL or HIGH at IN_TRIAGE for more than 90 days must convert to a fix, an acceptance, or NOT_AFFECTED”IN_TRIAGE is a transit state, not a parking space. After 90 days a CRITICAL or HIGH annotated IN_TRIAGE MUST be converted to one of three terminal positions: remediated, risk-accepted with an expiry and a watch, or annotated NOT_AFFECTED with a justification. Carrying it indefinitely re-creates the pre-register problem — a standing cluster nobody re-argues — one annotation layer deeper.
The 90-day clock runs from the annotation, not the finding — read from the earliest analysis comment, so re-commenting cannot reset it. The bulk of the 9,030 IN_TRIAGE annotations carried into 2026-08-06 date from the pre-go-live sweep of 2026-07-19, so the first conversions under this clause fall due from around 2026-10-17; dt_sla_in_triage_expired_count is correctly zero until then, and fires when they do.
A triage decision must survive a version bump of the same subject, or the SLA clock measures annotation churn rather than risk
Section titled “A triage decision must survive a version bump of the same subject, or the SLA clock measures annotation churn rather than risk”A finding’s analysis is a decision about a (component, version, vulnerability) subject, and it MUST outlive any change to the container that happens to hold it. Retiring, replacing or re-creating a Dependency-Track project MUST carry every analysis decision forward onto its replacement first, keyed so that only a genuinely unchanged finding inherits: same component name, same component version, same vulnerability. A component that moved version is a different subject and is re-triaged from scratch. An existing decision on the incoming project is never overwritten — carry-forward fills gaps, it does not impose the old view on a fresh one.
A retirement whose carry-forward could not be proven MUST NOT proceed. Keeping a superseded project is loud and reversible next run (it ages into the 8-day SBOM-freshness breach above); losing the triage is silent and permanent.
This clause exists because the estate was destroying its own triage on a schedule. Dependency-Track scopes an analysis to a (project, component, vulnerability) UUID triple, and a pinned-tag bump mints a new project UUID — so the supersession tidy that retired the outgoing project discarded every annotation attached to it, and unchanged components re-presented as untriaged. Measured 2026-08-17: the previous run had verified zero findings at NONE, and one week later 123 already-triaged findings were untriaged again, mapping one-for-one onto five of the seven projects the tidy retired (gotenberg 61, netbox 42, recipes 16, grafana 2, loki 2). Two consequences make it a standards matter rather than a bug: the more diligently the estate patches, the more analysis it loses, and a NOT_AFFECTED backed by real reachability evidence silently reverts to untriaged, so a correctly-excluded finding reappears as a CRITICAL and is re-litigated. The NONE-beyond-one-cycle clause above then fires for a reason unrelated to risk. Fixed in scan_trivy_docker.yml and governed by ADR-0418 (Issue #2469).
Only the owner may accept a vulnerability risk; the loop proposes, it never self-accepts
Section titled “Only the owner may accept a vulnerability risk; the loop proposes, it never self-accepts”Risk acceptance is a human decision and sits outside every autonomous envelope. The vulnscan loop may present a fully-argued acceptance — the four conditions, the compensating controls read out of committed config, a proposed review_by, and the watch entry it would register — but it MUST NOT add a register row or set an expiry on its own authority. This is the out-of-bounds escalation clause of the Autonomous Remediation Authority Standard applied to a decision rather than a change: the loop does the legwork, the owner accepts the risk.
The four conditions an acceptance must satisfy are ADR-0302’s and are not restated here: no fix path we control, compensating controls verifiable in committed code, a review_by date, and a registered upstream watch. All four, or it is still open work.
A risk acceptance expires — 90 days for CRITICAL/HIGH, 180 days for MEDIUM/LOW
Section titled “A risk acceptance expires — 90 days for CRITICAL/HIGH, 180 days for MEDIUM/LOW”An acceptance is a decision with a shelf life. The maximum review_by horizon is 90 days for a CRITICAL or HIGH acceptance and 180 days for MEDIUM/LOW, measured from the date it is accepted. The current gotenberg/chromium acceptance sets the pattern: accepted 2026-08-03, review_by 2026-11-03 — 92 days.
Renewal is not automatic and is not a date change. Re-affirming requires re-reading every compensating control against the live config and confirming it still holds; an acceptance re-dated without that re-check has rotted. If the upstream watch fires fix_available first, the acceptance is over — but a new release is not automatically the fix, so the finding must be proven cleared by a targeted re-scan before the row is removed.
Suppressing a finding in Dependency-Track is not accepting it, and is never permitted as a way to clear a delta
Section titled “Suppressing a finding in Dependency-Track is not accepting it, and is never permitted as a way to clear a delta”An accepted risk stays visible. Findings under an acceptance keep their DT annotation and keep appearing in the weekly delta — the count is the standing reminder of what is being carried, and only the re-derivation is removed by the register. Suppression is forbidden as a triage action: it makes the carried risk invisible, which is the exact failure the register exists to prevent.
A finding’s lifecycle has four terminal states and every path out is dated
Section titled “A finding’s lifecycle has four terminal states and every path out is dated”This standard governs outcomes; the mechanisms it governs are defined elsewhere and cited, never restated
Section titled “This standard governs outcomes; the mechanisms it governs are defined elsewhere and cited, never restated”| What | Where it is defined | What this standard adds |
|---|---|---|
| Scan surfaces, Dependency-Track topology, recovery procedures | vulnerability-management overview | the coverage and cadence obligations those surfaces must satisfy |
| The agentic loop — run modes, phases, manual invocation | vulnscan runbook | that the loop is the default triage owner, with a deadline |
| What the loop may change unattended | ADR-0101 | the SLA the in-bounds classes must be applied within |
| The generalised bounded-autonomy contract | Autonomous Remediation Authority Standard (ADR-0112) | that risk acceptance is outside every autonomous envelope |
| Dependency-PR merge policy by tier and update type | ADR-0301 | a deadline on the PRs that do not auto-merge |
| Risk-acceptance conditions and the register mechanism | ADR-0302 + the Vulnerability Risk Acceptance Register | who may accept, and the maximum acceptance duration |
| SBOM import reliability and the freshness watchdog | ADR-0179 | the 8-day freshness ceiling as a policy number |
| Notification class for findings and reports | Notification Channel Policy | — (applied as-is) |
Vulnerability-management conformance checklist
Section titled “Vulnerability-management conformance checklist”- Every production host and every image the estate executes — running, scheduled, or build-time — has exactly one Dependency-Track project; every exclusion is named, reasoned and dated in the overview hub.
- A build-time image’s scan target is derived from its committed pin, fails closed when unresolvable, and is never a second hardcoded copy.
- Every committed third-party pin — image tags and in-pipeline package pins included — is inside an update bot’s view; no repository carrying pins sits outside the runner’s
repositorieslist. - Every in-scope target is rescanned at least weekly by the single owning cadence, and every SBOM upload is import-verified.
- No Dependency-Track project’s SBOM is older than 8 days; per-project freshness is proven before any finding count is read as good news.
- Every scan surface asserts SBOM composition — the presence of an ecosystem the target is known to contain — and fails the target on a zero, rather than uploading a partial SBOM that imports cleanly.
- Every SLA clock is measured from the finding’s first Dependency-Track attribution, never from triage.
- In-bounds fixes meet the per-severity remediation SLA (CRITICAL 7d, HIGH 14d, MEDIUM 30d); attended fixes have a recorded decision inside their decision SLA.
- A security fix in an open dependency PR is merged (critical-tier patch/digest, ≤72h on green CI) or explicitly deferred with a recorded reason (minor/major, ≤14d).
- Every breached SLA has converted to an escalation with an owner and a date, or to a risk-acceptance row — none has silently rolled into the next delta.
- Every finding the loop touches carries a Dependency-Track analysis state and a comment with the reasoning.
- No finding sits at analysis state
NONEbeyond one scan cycle (7 days); no CRITICAL/HIGH is un-annotated past that point. - No CRITICAL/HIGH has been at
IN_TRIAGEfor more than 90 days without converting to a fix, an acceptance, orNOT_AFFECTED. - Every project retirement carried its analysis decisions forward first, keyed on unchanged component+version+vulnerability; no retirement proceeded on an unproven carry-forward.
- Every risk acceptance was made by the owner (never self-accepted by the loop), satisfies all four ADR-0302 conditions, and expires within 90 days (CRITICAL/HIGH) or 180 days (MEDIUM/LOW).
- No finding has been suppressed in Dependency-Track to clear a delta.
Known gaps — where the estate does not yet meet this standard
Section titled “Known gaps — where the estate does not yet meet this standard”These are stated rather than quietly excluded, so the standard is honest about its own conformance. Three of the four gaps it landed with were closed on 2026-08-06 (Issues #2176 and #2178): pit-mini is scanned by its own surface rather than silently excluded, the 2,857-finding untriaged backlog was swept and is now measured daily, and the three SLA clauses below are machine rather than review.
One gap is carried and named: the remediation-SLA backlog itself. dt_sla_remediation_overdue_count reports 817 CRITICAL and 3,230 HIGH findings past their per-severity SLA (oldest 73.8 days) as of 2026-08-08, rising ~110/day as findings age past their deadline. Every one carries a triage annotation and a fix route, but under this standard a breached SLA must convert to an escalation with an owner and a date, or to a risk-acceptance row — and that conversion programme has not been done. It is now carried the way this standard says a breach you are not remediating today should be carried: as a dated, owner-accepted risk with an expiry (ADR-0302) rather than an open-ended one. The commitment is 2026-12-01, written into the DTRemediationBacklogOverCeiling rule itself so it arms on that date with no further edit (ADR-0361); DTRemediationBacklogStepChange guards the window against a structural jump meanwhile. Closing it is Issue #2200.
| Clause | Current state | Tracked as |
|---|---|---|
| The remediation SLA is met, not merely measured | 768 CRITICAL / 3,041 HIGH findings sit past their per-severity SLA (oldest 71.6 days, live 2026-08-06). Measured daily and alerted on growth, but the conversion of each breach to an escalation or an acceptance has not been done | Issue #2200 |
| SBOM composition is asserted for every ecosystem, not just Go | The composition gate added by ADR-0424 asserts gobinary > 0, because that is the one ecosystem every host in the pattern provably carries. A host that silently lost its Python or Node components — the same handler removes those too — would still pass. Stated rather than assumed equivalent: a per-ecosystem expectation would need a per-host declaration of what each host contains, which is a register to design, not a threshold to pick | Snag 49 on the standing register (Epic #2259, snag list #2323) |
| A triage decision survives a version bump — on the host plane | Covered on the image plane by the scan_trivy_docker.yml carry-forward (ADR-0418). The host-side tidy in tasks/dt_sbom_upload_verify_tidy.yml retires superseded OS-version projects and has the identical defect on a much rarer trigger (a host’s OS version string changing). Named rather than assumed equivalent, and deliberately not fixed with a second unproven implementation | Issue #2469 |
CLOSED 2026-08-08. dep_pr_age_check.py measures it daily and DependencyPRDeadlineBreached alerts on it; replaying every Renovate PR across the three repos shows the gauge peaked at 8 during the Issue #2070 queue, is green on 38 of 54 days, and reads 0 today (ADR-0364) | Issue #2201 (Done) |
Enforcement
Section titled “Enforcement”Coverage, freshness, acceptance-expiry, the three SLA/triage-deadline clauses and now the open-dependency-PR age clause are machine. All four were declared review when this standard landed, because an assertion satisfiable without the feature is not a test (Prime Directive 12) and no gate existed; Issue #2176 built dt_sla_conformance_check.py for the first three and Issue #2201 built dep_pr_age_check.py for the fourth. No mechanisable review row remains. What is still review is genuinely un-mechanised — who may accept a risk is a judgement, not a computation — plus the pin-coverage row, whose promotion path is a repo-scanning validator (Issue #2196), and the browser-asset lockfile-to-served-asset parity row. Note the distinction the SLA rows draw: the SLA is now measured, which is not the same as met — the standing breach backlog is a named Known gap above (Issue #2200).
A word on where each of the two 2026-08-08 promotions sits. The PR-age check measures the queue, not whether the queue is complete: a pin outside every update bot’s view raises no PR, so nothing ages and it reports clean. That is the separate pin-coverage gap (ADR-0324) and the two must not be read as covering each other. The DT project-identity row is machine in the static sense: validate_dt_project_namespaces.py proves a declared namespace is unique and actually used by every derivation, not that the declared namespace names the host genuinely scanned.
| Obligation | Class | Layer | Mechanism | Dead-man |
|---|---|---|---|---|
Every image the estate EXECUTES carries a Dependency-Track project, including build-time and run-to-completion images that docker ps never sees; a build-time image’s scan target is derived from its committed pin and fails closed when unresolvable | machine | scheduled | scan_trivy_docker.yml’s extra_images covers the non-running class (vectormap-render, pitlab/mermaid-validate, and the pitlab-docs MkDocs build image, whose tag is resolved at scan time from pitlab-docs/pipelines/docs.yml by a controller-side lookup('file') and asserted non-empty before the sweep proceeds) | vulnscan.run.age |
| Every committed third-party pin — build-time image tags and in-pipeline package pins included — sits inside an update bot’s view, so an upstream release is proposed even when no advisory exists for it | review | — | the estate-wide pin sweep recorded in ADR-0324 (2026-08-06: no unwatched third-party container pin remains), plus the renovate.json repositories list reviewed whenever a repo is added. No repo-scanning validator exists yet, and the sweep found Terraform provider constraints in tf-cloudflare, tf-backblaze and tf-tailscale outside any update automation — Issue #2196 is the promotion path for both | n/a |
| Every dependency delivered to a browser is pinned in a committed lockfile, vendored alongside it, Renovate-managed and SBOM’d into its own Dependency-Track project | review | — | the servicemap-frontend project’s SBOM freshness is covered by the estate-wide 8-day ceiling row below (a stale frontend BOM reds like any other), and Renovate’s npm manager raises bumps automatically. What is NOT yet mechanised is lockfile-to-served-asset parity — a commit that bumps the lockfile without re-vendoring the asset, or vice versa, would leave the SBOM describing bytes the browser never loads. Deliberately unbuilt while the estate has one instance; the promotion path is a parity gate comparing the served asset’s version banner against the lockfile, as ADR-0360 records | vulnscan.run.age |
| Every production host and running container image carries exactly one Dependency-Track project, rescanned at least weekly, with every upload import-verified | machine | scheduled | the four scan surfaces scan_trivy.yml, scan_trivy_arm.yml, scan_trivy_docker.yml, scan_trivy_pve01.yml run blocking by the weekly vulnscan-weekly cron deployed by sync_control01.yml; each upload asserts lastBomImport advanced (ADR-0179) | vulnscan.run.age |
| No Dependency-Track project’s SBOM is older than the 8-day freshness ceiling — a stale project is a coverage failure, not a quiet week | machine | scheduled | dt_sbom_freshness_check.py (daily) emits dt_sbom_stale_project_count, which fires the DTSBOMProjectsStale Alertmanager rule | dtrack.sbom.freshness.age |
| Every host rootfs scan asserts SBOM composition — an ecosystem the target is known to contain must be present — and fails the host rather than uploading a partial SBOM | machine | deploy | tasks/trivy_rootfs_scan_upload.yml asserts gobinary > 0 after the system-file re-filter and before the upload chain, so a host whose language analysis silently collapsed reds its own scan (ADR-0424). Derived from a property true of every host in the pattern — all 14 run alloy and/or zabbix_agent2 — so a zero is never a clean host. Go only: an equivalent silent loss of the Python or Node ecosystems on a host that carries them would still pass, named in Known gaps rather than assumed away | vulnscan.run.age |
A risk acceptance carries named controls verifiable in committed code, a review_by expiry, and a registered upstream watch; the premise becoming false pages rather than being discovered | machine | scheduled | upstream_release_watch.py reconciles every register row in upstream_release_watch.yml against the live pin and upstream releases — pages fix_available when upstream ships and review_overdue when an acceptance outlives its expiry | upstream.release.watch.age |
A critical-tier patch or digest dependency bump merges on green CI rather than queueing behind a review it never receives | machine | deploy | Renovate critical-tier patch/digest auto-merge (ADR-0301), admitted only by a green run of the affected stack’s category deploy pipeline (docker-stacks-media.yml, docker-stacks-ai.yml, docker-stacks-content.yml, docker-stacks-infra.yml, docker-stacks-observability.yml) — pre-deploy smoke gate plus service test plan | n/a |
| Remediation SLAs by severity are measured, with the clock taken from the finding’s first Dependency-Track attribution rather than from triage | machine | scheduled | dt_sla_conformance_check.py (daily 13:40) emits dt_sla_remediation_overdue_count{severity} and dt_sla_oldest_overdue_days{severity} — open non-terminal findings past CRITICAL 7 d / HIGH 14 d / MEDIUM 30 d — which fires DTRemediationBacklogOverCeiling above 250 from 2026-12-01, and DTRemediationBacklogStepChange on +2,000/7 d meanwhile. A dated ceiling, not a >0 threshold and not a burn-rate: the standing backlog (Issue #2200) is carried as a risk acceptance with an expiry, and because the count rises ~110/day from ageing alone, a slope-based rule is permanently red and therefore not a control (ADR-0361) | dtrack.sla.conformance.age |
Every finding is annotated with a Dependency-Track analysis state and a comment within one scan cycle; none sits at NONE beyond 7 days | machine | scheduled | dt_sla_conformance_check.py (daily 13:40) emits dt_sla_untriaged_overdue_count{severity} — findings with no analysis state past 7 days — which fires the DTUntriagedFindingsOverdue Alertmanager rule on CRITICAL/HIGH > 0. The vulnscan run’s own annotation sweep (Phase 6, applied in dryrun as well as live) remains the actuator; this is the check that a non-converging sweep can no longer hide behind a green runner heartbeat | dtrack.sla.conformance.age |
A CRITICAL or HIGH at IN_TRIAGE for more than 90 days converts to a remediation, a risk acceptance, or NOT_AFFECTED | machine | scheduled | dt_sla_conformance_check.py (daily 13:40) emits dt_sla_in_triage_expired_count{severity}, reading the clock from each finding’s earliest analysis comment so a re-comment cannot reset it, which fires the DTInTriageExpired Alertmanager rule on > 0. Correctly zero until around 2026-10-17, when the 2026-07-19 sweep’s annotations fall due | dtrack.sla.conformance.age |
| Only the owner may accept a vulnerability risk; the loop proposes with full argument but never self-accepts, and never suppresses a finding to clear a delta | review | — | /code-review of the vulnscan skill’s escalation path against the Autonomous Remediation Authority conformance checklist; the register row is authored by the owner | n/a |
A critical-tier minor or major PR carrying a security fix is merged or explicitly deferred with a recorded reason within 14 days — and a critical-tier patch/digest lands within 72 h | machine | scheduled | dep_pr_age_check.py (daily 14:10) reads every OPEN Renovate PR across the three Renovate-managed repos via the Azure DevOps API and emits dep_pr_overdue_count{tier,update_class}, which fires DependencyPRDeadlineBreached on the critical tier > 0. Dependency PRs are matched on the refs/heads/renovate/ branch prefix, not the author (Renovate uses Arron’s PAT, so every one of its PRs reads createdBy: Arron Pitman); tier comes from renovate.json’s critical-tier matchPackageNames, not from labels, which are absent on a whole class of Renovate PRs. Cleared by merging, abandoning, or adding the dated ADO label deferred-until-YYYY-MM-DD, which expires back into breach (ADR-0364) | dep.pr.age |
| A Dependency-Track project identity is unique per deployed instance, not per image name — the image plane is namespaced by the host that owns the deployment | machine | scheduled | dt_project_namespace in scan_trivy_docker.yml names every image-plane project <namespace>-<shortname> (docker = docker01, pve01 = the delegate scan), read by all four name derivations; tidy and orphan reconcile carry an explicit namespace boundary. validate_dt_project_namespaces.py (ansible CI) fails a delegating play that leaves the namespace at the default, two surfaces sharing one, or any derivation reverting to a hard-coded literal. Without it, two hosts running one image aliased onto one project and the weekly fleet tidy deleted the other host’s — and a deleted project reports no findings, so the gap read as clean (ADR-0363) | vulnscan.run.age |
| A triage decision survives a version bump of the same subject — a project retirement carries its analysis decisions forward first, and never proceeds on an unproven carry-forward | machine | scheduled | scan_trivy_docker.yml’s supersession tidy carries every analysis (state, justification, response and the original reasoning) from the outgoing project onto its replacement before deleting, keyed on unchanged component name + component version + vulnerability id, never overwriting an existing decision; a carry-forward that cannot be completed keeps the superseded project instead of deleting it, so the failure surfaces as an 8-day SBOM-freshness breach rather than as silent data loss. The detective control is the existing dt_sla_untriaged_overdue_count — a recurrence re-presents as a step change in findings at NONE, which is how the original defect was caught (ADR-0418). The host-side OS-version tidy in tasks/dt_sbom_upload_verify_tidy.yml is not yet covered — named as a known gap rather than assumed equivalent | vulnscan.run.age |
| Severity is read as real exploitability in our deployment posture, not as the raw CVSS base score | advisory | — | justification: per-component exploitability judgement against live config has no reliable machine signal; the compensating control is the recorded NOT_AFFECTED justification on each finding dismissed this way | n/a |