Skip to content

About

SBOM lifecycle, exploitability triage and CRA/NIS2 evidence automation. Decides which CVEs actually matter using CISA KEV, EPSS and reachability - not severity alone.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

sbom-sentinel

CI Self SBOM Python 3.11+ Licence: Apache 2.0 CycloneDX 1.6 OpenVEX

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     │
        └───────────────┘        └────────────────┘ └──────┘ └────────────┘

The output an auditor actually asks for

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.

The evidence report: headline counts, overdue findings, component inventory, and the CRA/NIS2 control mapping


What it does

1. Automatic component lists, kept up to date

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.

2. Automatic warnings about new CVEs

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.

3. Working out whether a vulnerability is actually dangerous

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.01 does not match a CVE with no EPSS score. reachable: false does 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.yml top 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.

4. Finding the secure update and getting it to programmers

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_now only, 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.

5. EU regulatory alignment

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.


Honesty about what this does not do

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.md is 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.

Getting started

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 NVD

On 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 command

Any 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 evidence

Configuring what it watches

config/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.

Day to day

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 minimum

Everything 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 doctor

The evidence pack

make 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.


Repository layout

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

Development

make dev-install
make test          # 116 tests, all offline
make lint

The 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

Further reading

Licence

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.

About

SBOM lifecycle, exploitability triage and CRA/NIS2 evidence automation. Decides which CVEs actually matter using CISA KEV, EPSS and reachability - not severity alone.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages