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. Three inventory hosts carry no Dependency-Track project today — hass, ubuntu25 and pit-mini — and all three are recorded there. pit-mini is the clause’s own worked example in both directions: it was excluded silently by a !macos play-pattern token when this standard landed, applying this clause found it and it was scanned by its own surface from 2026-08-06 (ADR-0327, Issue #2178), and on 2026-09-02 that surface was retired because the findings could not be remediated through any pipeline (ADR-0517). It is excluded again — but declared, reasoned and dated, which is what this clause asks for. Scanning is not the requirement; recording the decision is. 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 GitHub 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 determined by this check — Renovate’s vulnerabilityAlerts was unavailable on Azure DevOps, which supplied no advisory data, and the superset was adopted for that reason. The estate moved to GitHub on 2026-08-30 (ADR-0496), which does supply advisory data; narrowing the clause to advisory-bearing bumps is therefore now possible and is deliberately not taken here, because the superset stands on its own merits. 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. A BOM re-import CAN reset it, and that is a known defect rather than a property of the clause. Dependency-Track keys an analysis on the component’s UUID, which a re-import re-mints, so the analysis record is orphaned and attributedOn starts again from zero (FreeScout incident #3048, ADR-0508). Read dt_sla_in_triage_expired_count with that in mind: a zero is not by itself evidence that nothing has aged past 90 days, because the ageing population is exactly the population a re-import silently rejuvenates.
This standard previously predicted that “the first conversions fall due from around 2026-10-17”, on the basis that the bulk of the 9,030 IN_TRIAGE annotations carried into 2026-08-06 dated from the pre-go-live sweep of 2026-07-19. That prediction is withdrawn as unsound, twice over. It was already contradicted on 2026-08-27, when 16 HIGH findings measured past 90 days — annotations predating 2026-05-29, two months earlier than the sweep the prediction was anchored to. And on 2026-09-02 the same gauge reads 0 for both CRITICAL and HIGH, not because those 16 were converted but because the annotation trail they were computed from was destroyed and rebuilt. Neither number can be used to date the first genuine expiry, and no replacement date is stated here: a predicted date invites the reader to treat a silent gauge as expected, which is precisely the reasoning that let a corrupted measurement pass as a healthy one. The rule is unconditional — any CRITICAL or HIGH at IN_TRIAGE past 90 days is a breach, whenever it appears.
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.
The clause binds every re-creation of a finding’s subject, not only a project retirement. As written in 2026-08-17 it addressed the supersession tidy — a retirement the estate performs deliberately and can therefore carry forward. That framing had a blind spot, and it cost the estate its triage a second time. Dependency-Track re-creates a component with a new UUID on an ordinary BOM re-import into a live project, with no retirement anywhere in the picture: the analysis is orphaned exactly as it is on a retirement, and attribution.attributedOn resets, restarting this standard’s own 7-day triage clock. So the obligation is restated in the general form — any operation that re-creates a project, a component, or a finding’s identity MUST leave every prior analysis decision reachable, and where it cannot, the decision MUST be restored before the next delta is computed.
A delta-based detector does not satisfy this clause and MUST NOT be relied on to. The vulnscan loop fingerprints on project::component::version::vulnId, and a re-import changes none of those four fields — so an orphaned finding is not net-new, is never re-classified, and is never a candidate again in any future run. The loss is permanent and self-concealing: the loop reports a clean delta while the disposition is gone. A standard that says “carry the decision forward” and leaves the only detector structurally blind to the loss has stated an outcome nothing can observe. Conformance therefore requires a control that detects the ORPHANING DIRECTLY — a day-over-day comparison of each finding’s analysis state — not the downstream SLA symptom. Measured 2026-09-01: the untriaged-overdue alert saw 74 of a 1,082-finding orphan pool, because the other 1,008 had not also aged past seven days.
NOT_SET is un-annotated. Dependency-Track reports no-decision in two forms — an absent state and the literal string NOT_SET — and the second is truthy. Any conformance code MUST test membership of {None, "", "NOT_SET"}, never truthiness; a bare falsy test under-reported the CRITICAL/HIGH breach by 12 (62 against a true 74).
Governed by ADR-0508; mechanism, measurements and the restore procedure in the disposition-orphaning runbook section. FreeScout incident #3048.
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.
An acceptance is proposed per CLUSTER, and only after the cluster is classified by fix route
Section titled “An acceptance is proposed per CLUSTER, and only after the cluster is classified by fix route”The clause above fixes who accepts; this one fixes at what granularity the loop asks, because a correctly-argued proposal at the wrong granularity is still unanswerable. Governed by ADR-0478.
Group by component, never by finding. One row carries every instance of one component across every project, so the owner answers once rather than once per finding. Measured 2026-08-27, the standing backlog was 5,352 findings past SLA — a per-finding list is not a decision process.
Derive the clusters by counting live, never from recall. A remembered inventory is a hypothesis
(FC-19 authored-not-observed): a short list and a complete one are indistinguishable from the
inside.
Classify each cluster by fix route BEFORE proposing it, and only the fourth outcome is an acceptance — a distro fix already waiting; removable at source (strictly better than acceptance, which leaves the vulnerable code on the box); a pin bump we control; or genuinely no fix path. Most clusters are not acceptance candidates, so skipping this step puts unnecessary decisions to the owner and can accept a risk that was simply deletable.
A cluster may split across hosts, and splitting it is the point — decided by whether the component is actually in use on each host, never by which disposition is easier.
Cap one sitting at 10–15 rows and offset their review_by dates. Past that ceiling later rows
are rubber-stamped, which is worse than not asking; and two acceptances expiring on the same date
batch into one review where the second is skimmed.
State the coverage limit whenever the proposal is made. Clustering reaches roughly 84% of CRITICALs and materially less below that — the long tail is not convertible this way and is governed by ADR-0361’s dated ceiling instead.
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 |
| Disposition orphaning on BOM re-import, the carry-forward restore and the direct detection gauge | ADR-0508 + the runbook section | that a decision must survive any identity re-creation, and that a delta-based detector does not discharge the obligation |
| 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 operation that re-creates a project, component or finding identity — a BOM re-import included — leaves prior analysis decisions reachable, or the decisions are restored before the next delta is computed.
- Disposition orphaning is detected directly by a day-over-day analysis-state comparison (
dt_disposition_orphaned_count), not inferred from the untriaged-overdue SLA symptom; the watch is armed (dt_disposition_orphan_watch_ready == 1). - Every un-annotated test treats DT’s literal
NOT_SETas untriaged, not merely an absent state. - 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 stopped being a silent exclusion — first by being scanned, and since 2026-09-02 by carrying a declared row in the exclusions table instead (ADR-0517); 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 544 CRITICAL, 2,403 HIGH and 2,420 MEDIUM findings past their per-severity SLA (oldest 91.1 days) as of 2026-08-27, across 317 distinct components. Every one carries a triage annotation and a fix route, and under this standard a breached SLA must convert to an escalation with an owner and a date, or to a risk-acceptance row.
That conversion programme is now part-done, and the split is the point. Measured live 2026-08-27 after the first cluster-level sitting: of the 544 CRITICALs, 95 were accepted at that sitting, 154 had been accepted previously, 36 were remediated, and 138 were routed to remediation Issues — 423 converted, 77.8%. The 121 that remain (22.2%) are not convertible by clustering: the top 25 components reach only 61% of the whole backlog, so the residue is a genuine long tail of one- and two-finding components. Across ALL severities the converted share is materially lower again — roughly a quarter — because the CRITICAL tier clusters and the backlog as a whole does not. 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 | 544 CRITICAL / 2,403 HIGH / 2,420 MEDIUM sit past their per-severity SLA (oldest 91.1 days, live 2026-08-27). Conversion is 77.8% of CRITICALs and materially less below that; the residual 121 CRITICALs are a 313-component long tail that per-cluster acceptance cannot reach and which needs ADR-0361’s dated-ceiling instrument instead | 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. Do not read a zero as expected: a BOM re-import orphans the analysis and resets attributedOn (#3048, ADR-0508), so the gauge can read 0 while findings have genuinely aged past 90 days. DTDispositionOrphaned is the companion control that makes that case visible | 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 four Renovate-managed repos via the GitHub API — the plane, endpoint and repo list all derived from renovate.json rather than declared, so the check and Renovate cannot end up on different planes (ADR-0499) — and emits dep_pr_overdue_count{tier,update_class}, which fires DependencyPRDeadlineBreached on the critical tier > 0. Dependency PRs are matched on the renovate/ branch prefix in head.ref, 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, closing, or adding the dated GitHub label deferred-until-YYYY-MM-DD, which expires back into breach. A zero is published only after every repo is proven to resolve on the plane — a 404, a 401/403 or an unreachable endpoint is fatal and writes no metrics, so the dep.pr.age dead-man fires rather than an unreadable queue being reported as an empty one (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 |