SBOM lifecycle, exploitability triage and CRA/NIS2 evidence, automated.
An automated pipeline that keeps a current software bill of materials for every product you ship, watches for new vulnerabilities in those components, works out which of them are actually dangerous for your deployment, finds the secure version, routes it to the developers who can merge it, and keeps the evidence an EU regulator will ask for.
Built entirely from established open source — OWASP Dependency-Track, Syft,
cdxgen, OSV, CISA KEV, FIRST EPSS, Sigstore cosign, Renovate — with a purpose-built
worker (sbomctl) doing the part none of them do on their own: deciding what a
finding means in context, and proving that the decision was made.
The short version of why this exists. A scanner will hand you four thousand findings. A team will triage a hundred. The other 3,900 sit in a dashboard everyone learns to ignore — and when an auditor asks what you knew and when, a dashboard cannot answer. This closes both gaps: it decides what actually matters using confirmed exploitation rather than severity alone, and it keeps an append-only record of every decision and the evidence behind it.
┌──────────────────────────────────┐
your repos ──┐ │ NVD · GitHub Advisories · OSS │
your images ─┤ │ Index — mirrored continuously │
│ └───────────────┬──────────────────┘
▼ ▼
┌───────────────┐ CycloneDX 1.6 ┌──────────────────────────┐
│ syft / cdxgen │ ────────────────▶│ OWASP Dependency-Track │
└───────┬───────┘ signed │ component inventory + │
│ │ continuous re-matching │
│ └─────────────┬────────────┘
│ conformance check │ findings
│ (BSI TR-03183-2) ▼
│ ┌──────────────────────────┐
│ │ sbomctl │
│ │ ┌────────────────────┐ │
│ │ │ EPSS · CISA KEV · │ │
│ │ │ OSV reachability · │ │
│ │ │ deployment context │ │
│ │ └─────────┬──────────┘ │
│ │ ▼ │
│ │ policy.yml decision │
│ │ act_now / schedule / │
│ │ track / not_affected │
│ └──┬────────┬───────────┬──┘
│ │ │ │
▼ ▼ ▼ ▼
┌───────────────┐ ┌────────────────┐ ┌──────┐ ┌────────────┐
│ signed SBOMs │ │ OpenVEX + │ │ PRs │ │ tickets · │
│ + evidence │ │ CycloneDX VEX │ │ via │ │ chat · │
│ pack │ │ (back into DT) │ │Renov.│ │ digest │
└───────────────┘ └────────────────┘ └──────┘ └────────────┘
make evidence produces a dated, self-contained pack — every SBOM with its
digest, every decision with the rule and evidence behind it, and a control
mapping that states its own gaps.
Syft scans container images and source trees; cdxgen handles Maven/Gradle where resolving the real dependency tree needs the build tool itself. Output is CycloneDX 1.6 — the minimum version BSI TR-03183-2 v2.1.0 accepts.
Every document is then:
- enriched with the fields a scanner cannot know (SBOM creator, contact, CRA classification, direct/transitive marking, SHA-512 where the artefact is on disk),
- scored field by field against BSI TR-03183-2 and against the CRA Annex I Part II(1) minimum, and told plainly what is missing,
- signed with cosign, and
- uploaded to Dependency-Track, where it stays.
Regenerated daily by the scheduler, and on every CI build.
A project with both a source tree and a published image gets two SBOMs. They genuinely contain different things — build-time dependencies versus the OS packages that actually ship — and treating one as covering the other is how base image CVEs go unnoticed for months.
This is what Dependency-Track is for, and why the platform is server-based rather
than CI-only. It mirrors the NVD, the GitHub Advisory Database and OSS Index, and
re-evaluates every SBOM it holds whenever those update. A CVE published today
is matched against a release you shipped last year, with no rebuild and no
rescan. sbomctl pulls the resulting findings every four hours.
CI-only scanning cannot do this. It tells you about the code you are building right now, and says nothing about the versions already in your customers' hands — which is precisely the population CRA Article 14 is about.
The part that decides whether the whole thing is useful or just another red-number dashboard.
Four signals, combined by an ordered, readable policy in
config/policy.yml:
| Signal | Source | What it tells you |
|---|---|---|
| Confirmed exploitation | CISA KEV | Someone is using this against real systems, right now |
| Predicted exploitation | FIRST EPSS | Probability of exploitation in the next 30 days |
| Reachability | osv-scanner call analysis | Whether your code ever calls the vulnerable function |
| Exposure | your projects.yml |
Internet-facing? Personal data? Production? On the EU market? |
Severity is used, but last. CVSS describes a vulnerability's worst case in the abstract; it does not know whether anyone is exploiting it or whether your service is reachable. A CVSS 10.0 with an EPSS of 0.0002 in an internal batch job is a scheduling problem. Triaging by severity alone produces a queue nobody can work through, and the genuinely urgent items sit in it undistinguished.
Two rules run through the design:
Unknown is never treated as safe. Every condition that would de-escalate a finding fails when the underlying data is missing.
epss_below: 0.01does not match a CVE with no EPSS score.reachable: falsedoes not match a component that was never analysed. A vulnerability is only ever downgraded on positive evidence.
The policy is data, not code. A security officer can read
policy.ymltop to bottom and say what the system will do with any finding. Changing the risk appetite is a config change with a diff, not a patch to a scoring formula nobody can audit. Every decision records the id of the rule that produced it.
Decisions become OpenVEX statements for customers and CycloneDX VEX pushed
back into Dependency-Track, so the public statement and the internal dashboard
cannot disagree. A not_affected without a valid justification is refused rather
than emitted — an unjustified suppression is worse than no VEX at all, because
downstream scanners accept it and hide a real vulnerability.
OSV supplies the fixed version per advisory per ecosystem. The planner then does three things a raw finding list does not:
- Groups by component, not by CVE. Six advisories against one library is one
npm install, not six tickets. - Picks the smallest sufficient bump. The lowest version that closes every CVE in the group, not the newest release. A patch bump gets merged; a major bump gets deferred, and a deferred fix is an open vulnerability.
- Separates the unfixable. An advisory with no upstream fix gets a decision and a compensating control, not a ticket telling someone to upgrade to nothing.
Delivery, all four channels you asked for:
- Renovate (self-hosted) opens the pull requests, with the config generated from live triage output so priority reflects your actual risk. Urgent + patch-level is automerged; a major bump never is, however urgent.
- One ticket per component upgrade, with the exact command, the bump type, the deadline, the advisories closed, and the reasoning — updated in place on later runs rather than duplicated.
- Slack / Teams for
act_nowonly, and only on change. A channel that fires for every medium-severity transitive dependency is a muted channel. - Email digest, daily, with what is overdue and what has no fix.
The full mapping is in docs/compliance-mapping.md,
generated from a machine-readable
controls.yml. Short answer to
"does SBOM fall under CRA and NIS2":
Cyber Resilience Act — Regulation (EU) 2024/2847. Yes, explicitly. Annex I Part II(1) requires manufacturers to "identify and document vulnerabilities and components contained in products with digital elements, including by drawing up a software bill of materials in a commonly used and machine-readable format covering at the very least the top-level dependencies." The SBOM is a named obligation, not an inferred best practice. Part II(2) requires remediation "without delay", Part II(4) disclosure of fixed vulnerabilities, Part II(5) a coordinated vulnerability disclosure policy, and Part II(7) secure update distribution.
NIS2 — Directive (EU) 2022/2555. Indirectly but firmly. Article 21(2)(d) requires supply chain security measures; 21(2)(e) requires "security in network and information systems acquisition, development and maintenance, including vulnerability handling and disclosure"; 21(3) requires taking account of "the vulnerabilities specific to each direct supplier". An SBOM is the technical evidence for all three. For the entity types in its scope, Commission Implementing Regulation (EU) 2024/2690 makes it concrete: Annex §5 supply chain, §6 secure development and vulnerability handling, §12 asset inventory.
BSI TR-03183-2 v2.1.0 (Germany, 2025-08-20) is not law, but it is the technical baseline a German evaluator will reach for when judging whether an SBOM is adequate. It requires CycloneDX ≥ 1.6 or SPDX ≥ 3.0.1 and a specific field set. The platform generates to it and scores against it.
The dates that matter. CRA entered force 10 December 2024. Article 14 reporting obligations apply from 11 September 2026 — actively exploited vulnerabilities and severe incidents, reported through the ENISA Single Reporting Platform on a 24-hour early warning / 72-hour notification / 14-day final report clock. Full application, including the Annex I essential requirements, is 11 December 2027.
The platform treats that as an engineering problem, not a footnote: a CISA KEV
listing against a component in a product marked placed_on_market: true raises a
cra_reportable flag, and sbomctl compliance cra-report prints the awareness
timestamp and the resulting deadlines. It does not file the report — that is
a legal decision with consequences, and automating it would be reckless.
A compliance mapping that omits its gaps is how a team convinces itself it is
compliant when it is not. Every control in controls.yml is marked automated,
supports, or process, and every partial control names its gap. The main ones:
- A CVD policy is an organisational commitment.
docs/vulnerability-disclosure-policy.mdis a skeleton to adopt, publish, and staff with people who answer the inbox. No tool supplies that. - KEV is a proxy for "actively exploited", not the definition. Exploitation your own telemetry sees, or that a supplier reports, is equally a CRA Article 14 trigger and will not appear in KEV.
- Reachability analysis covers Go and Rust. For every other ecosystem osv-scanner cannot do call-graph analysis, and this platform reports unknown rather than not reachable. That is deliberate; the alternative manufactures VEX statements that are not defensible.
- Component supplier and SHA-512 coverage will not reach 100% on npm or PyPI, because upstream does not publish it. The conformance report shows the real number rather than rounding it up.
- CRA Annex I Part II(3) means security testing of the product — SAST, DAST, penetration testing. This platform covers the dependency dimension only.
- Ten-year documentation retention (CRA Art. 31 / Annex VII) is a backup and storage-lifecycle commitment. The platform writes the artefacts; keeping them for a decade, off-host and restorable, is an operations decision.
- Scope determinations are legal questions. Whether a given product is a "product with digital elements", and whether you are an essential or important entity under NIS2, belongs with counsel. This is engineering evidence, not legal advice.
Requirements: Docker with Compose v2, about 6 GB of RAM for Dependency-Track, and around 20 GB of disk once the NVD mirror settles.
Get an NVD API key first. It is free and takes a minute, and it raises Dependency-Track's mirror rate limit from 5 to 50 requests per 30 seconds. Without it the initial mirror takes hours instead of minutes, and that is the long pole in the whole setup.
make init # writes .env with a generated Postgres password
# put your NVD_API_KEY in .env now
make up # starts Postgres + Dependency-Track
# first boot takes 3-10 minutes while it mirrors the NVDOn Windows, make does not exist. Use the PowerShell equivalent instead —
same verbs, no WSL2 distro needed, Docker Desktop is enough:
.\sbom.ps1 init
.\sbom.ps1 up
.\sbom.ps1 bootstrap
.\sbom.ps1 help # lists every commandAny sbomctl command not shorthanded is passed straight through, so
.\sbom.ps1 triage explain CVE-2021-44228 works too.
Then create an API key — Dependency-Track at http://localhost:8080, log in with
admin/admin (it forces a change), go to Administration → Access Management →
Teams → Automation → API Keys, and put it in .env as DTRACK_API_KEY. Grant
that team BOM_UPLOAD, VIEW_PORTFOLIO, VIEW_VULNERABILITY,
VULNERABILITY_ANALYSIS, PROJECT_CREATION_UPLOAD. Full walkthrough in
docs/runbook.md.
make bootstrap # validate config, check connectivity, first SBOMs, first triage
make scheduler # daily SBOMs, 4-hourly triage, daily digest, weekly evidenceconfig/projects.yml ships pointed at four real public
repositories — including this one, because a supply-chain security tool is
software in your estate like any other. So make bootstrap does something real
out of the box rather than scanning a fictional example.
config/projects.example.yml is the annotated
reference covering every option, including the CRA fields that only apply to a
product placed on the EU market.
The context block is not paperwork. It is what turns a generic CVSS score into
a decision about your system:
context:
internet_facing: true # is it reachable from outside?
processes_personal_data: true # GDPR Art. 33 exposure shortens the window
environment: production
cra_class: important_class_i # CRA Annex III classification
placed_on_market: true # ← this one triggers Art. 14 reporting
supported_until: "2029-12-31"Get placed_on_market and cra_class right above all: together they decide
whether an actively exploited vulnerability starts a 24-hour reporting clock.
Marking an internal tool as on-market sends your team to a regulator over
something outside the regulation's scope; marking a shipped product as internal
misses a legal obligation.
For a real deployment, copy your config to config/projects.local.yml — it is
gitignored — and point SBOMCTL_CONFIG at it, so which of your services are
internet-facing and which handle personal data does not end up on GitHub.
make status # open findings, what is urgent, what is overdue
make plan # the pending upgrade list, with exact commands
make cra-report # anything carrying an Article 14(2) reporting obligation
make evidence # dated, self-contained evidence pack under out/evidence/
make validate # score every SBOM against BSI TR-03183-2 and the CRA minimumEverything is also available directly:
docker compose run --rm sbomctl triage explain CVE-2021-44228
docker compose run --rm sbomctl compliance status --framework cra
docker compose run --rm sbomctl doctormake evidence produces a dated directory and zip that can be handed to an
auditor, a notified body, or BSI market surveillance without further preparation:
| File | What it evidences |
|---|---|
report.html |
The control mapping and current numbers, self-contained, opens in any browser |
control_mapping.json |
Which CRA/NIS2/BSI requirement each capability serves — with its gaps |
sbom_inventory.json |
Every SBOM generated, with digests and timestamps |
sbom_conformance.json |
Field-by-field BSI TR-03183-2 scoring |
triage_decisions.json |
The current decision on every open finding, with rule and evidence |
cra_reportable.json |
Article 14(2) obligations with 24h/72h clocks and awareness timestamps |
sla_breaches.json |
What is past its remediation deadline |
supplier_inventory.json |
Third-party components by upstream, for the NIS2 Art. 21(3) view |
notifications.json |
Every message sent to developers, successful and failed |
metrics.json |
Median time to remediate, breach rate, SBOM freshness — NIS2 Art. 21(2)(f) |
audit_ledger.sqlite |
The complete append-only history, including superseded decisions |
artifacts/ |
The SBOM and VEX documents themselves, with cosign signatures |
manifest.json |
SHA-256 of every file above; sign it with cosign to make the pack tamper-evident |
The SQLite ledger is the backbone. Dependency-Track knows the current state; what an auditor asks is the history — when did you first learn of CVE-X, what did you decide, on what evidence, who was told, and when was it fixed. Decisions are append-only, so a changed judgement is a new row and the reasoning trail stays intact.
docker-compose.yml Dependency-Track + Postgres + worker + scheduler
docker-compose.renovate.yml optional self-hosted Renovate overlay
versions.env every upstream pin, with `make check-pins`
config/
projects.yml what is monitored, and its deployment context
projects.example.yml the annotated reference for every option
policy.yml the triage decision rules — read this one
sbomctl/ the worker
src/sbomctl/
sbom/ generate · enrich · validate · sign
dtrack/ Dependency-Track client
intel/ EPSS · KEV · OSV · reachability
triage/ the decision engine, and VEX output
remediate/ fixed versions, upgrade plans, Renovate config
notify/ tickets · chat · email
compliance/ control mapping · metrics · evidence pack
store.py the append-only audit ledger
pipeline.py the stage sequence
tests/ 116 tests, no network required
.github/workflows/ this repo's own CI, and its own SBOM
ci/ templates to copy into the repos you monitor
docs/ architecture · runbook · compliance mapping · CVD policy
make dev-install
make test # 116 tests, all offline
make lintThe test suite runs without network access or a Dependency-Track instance, on purpose: a security pipeline whose tests need the internet is a pipeline whose tests get skipped.
CI on every push runs the suite on Python 3.11/3.12/3.13, lints, builds the
worker image and checks each bundled binary actually executes, renders both
Compose files, and fails if docs/compliance-mapping.md has drifted from
controls.yml.
Some tests are worth reading before the code, because they pin the design decisions rather than the implementation:
| Test | What it protects |
|---|---|
test_missing_epss_does_not_match_epss_below |
Unknown data never de-escalates a finding |
test_unknown_reachability_is_not_treated_as_unreachable |
"We cannot tell" is not "not affected" |
test_kev_in_internal_tool_is_urgent_but_not_reportable |
CRA Art. 14 attaches to products on the market, not to everything |
test_invalid_statements_are_dropped_not_emitted |
A malformed VEX statement never reaches the document |
test_partial_controls_state_their_gaps |
A control marked supports must name what it does not do |
test_decision_changed_drives_notification_idempotence |
An unchanged decision does not re-notify |
docs/architecture.md— why it is arranged this way, and what happens when each dependency failsdocs/runbook.md— setup, daily operation, tuning the policy, troubleshooting, and what to do when something urgent landsdocs/compliance-mapping.md— the full control mapping, generated fromcontrols.ymldocs/vulnerability-disclosure-policy.md— a CVD policy skeleton, for the one obligation no tool discharges
Apache-2.0. The upstream tools keep their own licences — Dependency-Track, Syft, cdxgen, osv-scanner and cosign are all Apache-2.0; Renovate is AGPL-3.0 and is run here as an unmodified upstream container via an optional Compose overlay, never linked into this codebase.
