The rollup took the highest fix version across a component's advisories. That is wrong whenever an advisory is patched on more than one release branch. nanoid is the case that exposed it. Both advisories against it are fixed on two branches - 3.3.16 and 5.1.16, 3.3.18 and 5.1.6 - and the rollup recommended 5.1.16 with a MAJOR warning. But nanoid is transitive, pulled in by postcss at `^3.3.11`, a range no 5.x release can satisfy. The recommendation was not merely a bigger job than necessary, it was unreachable: the actual target is 3.3.18, a patch inside the existing range. Per advisory the target is now the lowest published fix above the version in use; per component it is the highest of those, since one upgrade has to satisfy every advisory against it. Fixes on older branches are skipped rather than offered as a downgrade - axios advisories list 0.30.x fixes that would have been a downgrade from 1.13.2. This also gives the MAJOR label meaning. It now fires only when no fix exists on the current branch, rather than whenever some branch happened to be higher.
93 lines
4.1 KiB
Markdown
93 lines
4.1 KiB
Markdown
# 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 smallest upgrade that
|
|
clears every advisory against that component, and how many it clears.
|
|
|
|
"Smallest" is doing real work there. An advisory patched on several release
|
|
branches lists a fix per branch - nanoid's is fixed in both 3.3.18 and 5.1.16 -
|
|
and taking the highest recommends a major upgrade when a patch on the current
|
|
branch would do. Where the package is transitive that major may not even be
|
|
reachable: nanoid arrives under postcss, pinned `^3.3.11`, a range 5.x cannot
|
|
satisfy. So the target is the lowest published fix above the version in use.
|
|
Fixes on older branches are ignored rather than offered as a downgrade.
|
|
|
|
A target on a different major version is still labelled `MAJOR`, but now that
|
|
label means something: no fix exists on the current branch, so the upgrade is
|
|
genuinely a different job - it can be ESM-only, or drop an API.
|