Files
shopdb-flask/docs/DEPENDENCY-SECURITY.md
cproudlock b8fc2e9e33 Upgrade the eight shipped dependencies carrying advisories
scan_sbom.py reported 50 advisory matches against shipped components, 16 of them
at or above HIGH. This clears all of them: the scan now reports 0 shipped
findings and the gate passes.

  pyjwt             2.12.1  -> 2.13.0
  idna              3.13    -> 3.19
  axios             1.13.2  -> 1.20.0   (29 advisories)
  postcss           8.5.6   -> 8.5.26
  dompurify         3.4.11  -> 3.4.14
  nanoid            3.3.11  -> 3.3.18
  follow-redirects  1.15.11 -> 1.16.0
  form-data         4.0.5   -> 4.0.6

Every one is an in-major bump inside a range the manifests already allowed, so
this is a lockfile refresh rather than a migration. The Python side was compiled
with --upgrade-package for each name so nothing else moved; the diff is two
version lines and their hashes.

nanoid is worth recording: it is transitive under postcss at ^3.3.11, and its
advisories are fixed on both the 3.x and 5.x branches. 3.3.18 is the fix that is
actually reachable.

The declared floors for axios and dompurify were raised to the versions in the
lockfile. They were ^1.6.0 and ^3.4.11, which still nominally admitted the
vulnerable range - npm resolves to the highest match so an install was never
going to pick one, but the manifest should state the minimum safe version rather
than rely on that.

axios 1.20 adds optional proxy-agent support, which pulls https-proxy-agent and
agent-base into the tree. Both are Node-only and neither reaches the browser
bundle; the build was checked for them. The main chunk grew 256kB to 271kB
(85.7kB to 90.5kB gzipped) from the rest of the axios delta.

The PyJWT entry in the allowlist was removed rather than kept. It stopped
matching the moment the upgrade landed, and an allowlist that accumulates
entries for advisories that no longer apply is one nobody rereads. The reasoning
is preserved as the worked example in docs/DEPENDENCY-SECURITY.md.

Verified: full suite 2161 passed, frontend 273 passed, build clean,
npm audit --omit=dev reports 0 vulnerabilities.
2026-08-29 17:42:35 -04:00

4.6 KiB

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

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 file has held exactly one entry so far, and how it ended is the point.

A PyJWT advisory rated HIGH - algorithm confusion, forged HS256 tokens - needs a verifier configured with both symmetric and asymmetric algorithms AND a raw JSON JWK passed as the key. This application configures neither: there is no JWT_ALGORITHM or JWT_DECODE_ALGORITHMS anywhere in the tree, so flask-jwt-extended runs HS256-only against a string secret. That was written down, with invalidatedby recording what would make it untrue - SSO issuing tokens verified against a JWK.

Then PyJWT was upgraded, the advisory stopped matching, and the entry was deleted. The allowlist is empty again, which is the state to aim for.

invalidatedby is the field 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.