Add vulnerability scanning on top of the SBOM
The SBOM was generated and installed onto every server, and nothing read it. "Are we exposed to this advisory" was still a person going and looking, which meant it happened when somebody remembered to. scripts/scan_sbom.py queries OSV.dev by purl - no account, no API key, no agent, no vulnerability database to maintain on a box that cannot reach the internet. The SBOM is already the input OSV wants, so this is a lookup against what shipped rather than a second scan of the tree that could disagree with it. Three decisions worth recording: Shipped and build-only findings are both reported, but only shipped ones can fail a build. Two thirds of the components here are build tooling the SBOM already marks scope 'excluded', and the highest-severity match in the whole tree today is a CRITICAL against the test runner. A gate that fails a release over that trains everybody to bypass it, and then it catches nothing. Severity is taken from the advisory, never derived. Where there is only a CVSS vector the report says UNKNOWN, which is shown and never gates. A guessed score is worse than an honest gap because somebody would act on it. The allowlist is a record rather than a mute button: each entry carries the reason, the accepter, the date, and what would invalidate the reasoning. The first entry is a worked example - a PyJWT algorithm-confusion advisory that needs both mixed algorithm families and a raw JSON JWK as the key, and this application configures neither. The report ends with a per-component upgrade rollup, since that is the part somebody acts on. A target on a different major version is labelled rather than listed beside patch bumps as though the two cost the same. Current state: 8 shipped components have fixes available, none of them exploitable through a path this application opens today.
This commit is contained in:
85
docs/DEPENDENCY-SECURITY.md
Normal file
85
docs/DEPENDENCY-SECURITY.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# Dependency security
|
||||
|
||||
Two scripts, one question each.
|
||||
|
||||
| question | script | output |
|
||||
|---|---|---|
|
||||
| what are we running | `scripts/generate_sbom.py` | `sbom.cdx.json`, CycloneDX 1.6, shipped with every release |
|
||||
| are we exposed | `scripts/scan_sbom.py` | a report, and an exit code |
|
||||
|
||||
The SBOM was already generated and installed onto every server. Nothing read
|
||||
it, so "are we exposed to this advisory" was still a person going and looking.
|
||||
The scanner closes that.
|
||||
|
||||
## Running it
|
||||
|
||||
```bash
|
||||
venv/bin/python scripts/scan_sbom.py # report, exit 0
|
||||
venv/bin/python scripts/scan_sbom.py --gate # exit 1 on a shipped finding
|
||||
venv/bin/python scripts/scan_sbom.py --json out.json # machine-readable too
|
||||
```
|
||||
|
||||
With no `--sbom`, it generates one first, so there is never a stale input.
|
||||
|
||||
It needs outbound HTTPS to `api.osv.dev`. **Run it where the repository lives,
|
||||
not on a customer's server** - a deployed instance is frequently air-gapped and
|
||||
has nothing to query. That is also why the SBOM ships as a file: it is the
|
||||
artefact a security team scans on their own terms.
|
||||
|
||||
## Why OSV
|
||||
|
||||
It takes a purl and returns the advisories that apply. No account, no API key,
|
||||
no agent installed anywhere, no vulnerability database to keep current. The
|
||||
SBOM is already the input it wants, so this is a lookup against what shipped
|
||||
rather than a second scan of the tree that could disagree with it.
|
||||
|
||||
## Shipped and build-only are not the same finding
|
||||
|
||||
Two thirds of the components in the SBOM are build tooling - bundlers, test
|
||||
runners, their transitive dependencies. They never reach a server, and the SBOM
|
||||
already marks them `scope: excluded`.
|
||||
|
||||
Both are reported. **Only shipped components can fail a build.**
|
||||
|
||||
This is deliberate. At the time of writing, the highest-severity match in the
|
||||
whole tree is a CRITICAL against the test runner, which is not installed
|
||||
anywhere a release reaches. A gate that fails on that teaches everybody to pass
|
||||
`--no-verify` within a month, and then it catches nothing at all.
|
||||
|
||||
## Severity is taken, not computed
|
||||
|
||||
Where an advisory records a plain word - GHSA gives LOW, MODERATE, HIGH,
|
||||
CRITICAL - that word is used. Where there is only a CVSS vector, the report says
|
||||
UNKNOWN rather than parsing the vector into a score it might get wrong.
|
||||
|
||||
UNKNOWN is shown and never gates. A guessed score is worse than an honest gap,
|
||||
because somebody would act on it.
|
||||
|
||||
## The allowlist is a record, not a mute button
|
||||
|
||||
`scripts/vulnerability-allowlist.json` holds advisories that were reviewed and
|
||||
accepted. An entry carries the advisory id, the reason, who accepted it, the
|
||||
date, and what would invalidate the reasoning.
|
||||
|
||||
A good reason names the precondition that is absent. The first entry is the
|
||||
worked example: a PyJWT algorithm-confusion advisory that needs a verifier
|
||||
configured with both symmetric and asymmetric algorithms AND a raw JSON JWK
|
||||
passed as the key. Neither is true here, and `invalidatedby` records the change
|
||||
that would make it true.
|
||||
|
||||
That last field is the one that earns its keep. A reason stops being true when
|
||||
the code that made it true changes, and nobody re-reads an allowlist looking for
|
||||
that. Write down what would break it.
|
||||
|
||||
Upgrading is almost always cheaper than defending a decision to skip it. Do not
|
||||
add an entry to make a build pass.
|
||||
|
||||
## What the report tells you to do
|
||||
|
||||
The report ends with a per-component upgrade rollup: the highest fix version
|
||||
across that component's advisories, and how many it clears. Upgrading once to
|
||||
the top of the list clears the ones below it.
|
||||
|
||||
A target on a different major version is labelled `MAJOR`. It is a different
|
||||
job from a patch bump - it can be ESM-only, or drop an API - and it is marked
|
||||
rather than listed as though the two cost the same.
|
||||
Reference in New Issue
Block a user