[Security 8/9] Supply-chain hygiene: CVE patches, audit ratchet, Dependabot

✅ merged · #251 · open-legal-products/mike ← amal66/mike · opened 2mo ago by amal66 · merged 7d ago by willchen96 · +6,756-9,056 across 8 files · ↗ on GitHub

From the PR description

[Security 8/9] Supply-chain hygiene - CVE patches, an audit ratchet, and Dependabot

Part of the split of #227 into single-topic PRs. Index: tracking comment on #227.

TL;DR

Clear every known advisory inside already-declared ranges (no manifest edits, no CVE overrides - the earlier scoped tmp/form-data entries became redundant and are gone), add a blocking CI audit gate for every directory that owns a lockfile - all four: root, backend, frontend, and word-addin - with an explicit, documented GHSA allowlist, and add Dependabot so the fix for a newly-published CVE arrives automatically as a bot PR. The allowlist holds exactly two entries today, both dev-only tooling in word-addin with no in-range fix anywhere, each carrying a REMOVE WHEN exit condition. Lockfiles are regenerated, never hand-merged.

Risk to user data

Severity: medium (systemic). Most of the code we ship is dependencies, and they go bad two ways: honest packages with disclosed CVEs, and outright compromised packages. Both run with our server's privileges - a vulnerable or malicious dependency in the request path can read documents, leak secrets, or execute code. Measured on main @ 6a62d01a today (2026-08-26), the advisories this PR clears include four Next.js highs (middleware/proxy bypass, SSRF in Server Actions, SSRF in rewrites, DoS), an ip-address SSRF/trust-boundary bypass reachable through express-rate-limit, tmp path traversal, form-data CRLF injection, postcss arbitrary file read, and multi-entry sets in @xmldom/xmldom, undici, brace-expansion, js-yaml, nanoid, linkify-it, fast-uri, sharp, and ws - 12 distinct high advisories in backend, 27 in frontend, and 13 in word-addin (full lists under "Base-case replication" below).

Flows affected

  • Build / dependency resolution (root, backend, frontend, and word-addin - every directory with its own package-lock.json).
  • CI (.github/workflows/security.yml) - new blocking gate for all four trees, actions SHA-pinned per the #246 posture.
  • Frontend app code: none. The stricter linter a floating eslint-plugin-react-hooks would pull in is deliberately pinned back for now (details below).
  • Word add-in app code: none. Its lockfile moves; word-addin/package.json is untouched, and its own CI job (typecheck + Playwright on chromium and webkit) runs on this PR for the first time because it is path-filtered on word-addin/**.

Attack precedent

  • event-stream (2018) - a popular package taken over and injected with wallet-stealing code. (writeup)
  • XZ Utils backdoor (2024) - a multi-year social-engineering supply-chain attack. (overview)
  • Vulnerable/compromised components is OWASP A06:2021.

Possible fixes, and what we chose

Option Verdict
npm audit fix --force Rejected - swaps in different major versions untested (here it would downgrade next to v9). Never in CI.
One-time npm audit fix and move on Clears today's advisories but the state rots the moment a new CVE lands. Necessary but not sufficient.
Global overrides forcing versions outside a consumer's declared range Rejected after review (thanks @willchen96) - an earlier revision forced sharp 0.35.x and postcss 8.5.x under a Next that declared sharp ^0.34.5 and pinned postcss 8.4.31 exactly. That ships combinations upstream never tested to make a scanner green. Overrides are only used where the forced version is inside the consumer's declared range (tmp, form-data).
Report-only (continue-on-error) frontend gate Rejected - it tolerates every future advisory silently, not just the known one.
Scoped in-range overrides + semver-safe audit fix + a blocking gate with a documented GHSA allowlist + Dependabot Chosen. Advisories with no in-range fix anywhere get an allowlist entry carrying the reason, the actual exposure, and an explicit REMOVE WHEN exit condition - printed by the gate on every run so they stay visible. Everything else blocks.
flowchart LR
    CVE["new advisory published<br/>(GitHub Advisory DB)"] --> Gate["CI audit gate goes red<br/>scripts/audit-gate.mjs"]
    CVE --> Bot["Dependabot opens a<br/>version-bump PR"]
    Bot --> CI["full CI runs on the bot PR:<br/>audit + tests + lint"]
    CI --> Merge["human reviews & merges"]
    Merge --> Green["gate green again<br/>(and allowlist entries retire)"]

Why the gate is a ratchet: scripts/audit-gate.mjs re-audits straight from each lockfile on every push (seconds, no install), so a newly published advisory fails the build even with zero code change - that fired three times during this PR's own lifetime: the July brace-expansion wave (absorbed in-range, its allowlist entry retired), the early-August ip-address/undici wave (absorbed in-range), and the mid-August nanoid wave (GHSA-28wg-ghj8-5hjv, GHSA-2v37-7h3g-55p8 - absorbed in-range: docx declares nanoid ^5.1.3, postcss declares ^3.3.16, so the patched 5.1.16/3.3.18 land without leaving any consumer's tested range). The gate fails closed: if npm audit itself errors (registry outage), the job fails rather than reporting a false green.

The register, and why it is not empty. scripts/audit-allowlist.json holds exactly two entries, both in word-addin and both dev-only:

GHSA package why it's accepted REMOVE WHEN
GHSA-xcpc-8h2w-3j85 adm-zip 0.5.12 pulled in only by office-addin-debugging / office-addin-manifest - the sideloading chain a developer runs locally. Never bundled by webpack, never in the task pane a user loads. the office-addin-* chain ships adm-zip >=0.6.0 inside the declared ^5.0.12 range
GHSA-ph9p-34f9-6g65 tmp 0.0.33 reachable only via @inquirer/editorexternal-editor, i.e. the interactive prompts of @microsoft/teamsapp-cli under the same devDependency. The prefixes are literals inside that CLI, not anything this repo or a document supplies. external-editor resolves tmp >0.2.5 (or the chain drops external-editor) inside that range

Neither has an in-range fix: npm's only offer for either is a downgrade to office-addin-debugging 4.6.7, outside the declared ^5.0.12 range - exactly the out-of-range-override mistake this PR already rejected once for sharp. So they are accepted, in writing, with an expiry - and the gate prints both, with their reasons, on every word-addin run.

The expiry rule is not decorative: it has already retired four entries by their own stated exit condition - brace-expansion when the fix propagated in-range, sharp when Next 16.3.0 began declaring sharp ^0.35.3, both postcss entries when Next 16.3.0 moved its exact pin to postcss 8.5.23, and undici (GHSA-4cwx-7wf7-3272, dev-only via miniflare's exact undici 7.28.0 pin) when wrangler shipped miniflare with undici ≥7.29.0. Each removal is its own commit quoting the condition that came true - so the register's history reads as a list of risks accepted and later actually discharged, which is the property a risk register is for. An earlier revision of this description bragged that the register had reached []; extending the gate to word-addin put two honest entries back, and a true register beats a flattering one.

The linter the floating floor pulls in - deliberately pinned back: a from-scratch regeneration moves eslint-plugin-react-hooks in-range from 7.0.1 to 7.1.1, whose React-compiler rules flag 30 errors in files main is actively rewriting - and CI lints as a blocking gate. An earlier revision of this PR adopted the rules and restructured everything they flagged, but after the 2026-08-24 rebase that adoption both failed two of main's EditCard tests and collided with main's in-flight DocTable rewrite. So frontend/package.json now carries a single documented override, eslint-plugin-react-hooks: ~7.0.1 (main's own resolved version, patches allowed), with a REMOVE WHEN pointing at a dedicated adoption PR - the restructuring work is preserved at branch tip 475577d6 if wanted. Rule adoption is a frontend refactor, not dependency hygiene, and doesn't belong in this PR's blast radius.

Lockfile discipline: lockfiles are regenerated from scratch (delete, then npm install) on the current base - never hand-edited or hand-merged. From-scratch matters, not just no-hand-merging: re-running npm install --package-lock-only over an existing lockfile preserves every previously-resolved pin. Re-measured on main @ 6a62d01a on 2026-08-26, the incremental command leaves 27 high advisories standing in frontend that a from-scratch regeneration clears to 0 with zero manifest edits - because every fix had already propagated into ranges the manifests declare. Step 4 of the base-case replication below is that exact experiment, five commands long.

...and the one tree where from-scratch is the wrong tool. It is a default, not a law, and word-addin is the counterexample. word-addin/package.json pins @tiptap/starter-kit at exactly 3.29.2, but starter-kit 3.29.2 declares its own sub-extensions with carets (^3.29.2). A full re-resolve floats those siblings to 3.30.5, which drags a nested @tiptap/core@3.30.5 in beside the top-level @tiptap/core@3.29.2 - two unrelated copies of the same type universe. npm run typecheck then fails with "Two different types with this name exist, but they are unrelated" plus five ChainedCommands errors in WorkflowPromptEditor.tsx. Both paths were measured on this tip: from-scratch reaches 2 residual advisories and a broken typecheck; npm audit fix --package-lock-only (in-range only, never --force) reaches the same 2 residuals with the tiptap tree byte-identical to what CI has been testing all along. So word-addin gets the surgical fix, and the rule it actually obeys - the one that matters - is unchanged: the lockfile stays derivable from the manifest, which npm ci is what proves.


Base-case replication - watch the advisories stand on main

npm audit reads the lockfile, not an installed tree, so none of this needs npm install or a node_modules/ directory - just network access to the registry's advisory endpoint. It takes seconds per tree.

⚠️ Advisory databases move on their own, so every number below is a dated measurement, not a constant: main @ 6a62d01a, measured 2026-08-26 (UTC). Re-run it next month and the counts will differ - and that drift is precisely the property this PR is built to survive.

1. Confirm nothing guards dependencies on main today.

git checkout main                                          # 6a62d01a when measured
ls scripts/                                                # build-workflows.js  e2e-local-stack.sh
ls .github/workflows/security.yml .github/dependabot.yml    # both: No such file or directory

There is no audit step anywhere in main's CI. That is the real base case: the advisories below are not merely unfixed, they are unreported - no check is red, so nobody is told.

2. Audit each directory that owns a lockfile. There are four; find . -name package-lock.json -not -path '*/node_modules/*' is how you enumerate them, and it is the check the workflow's matrix comment now names.

npm audit --audit-level=high                  # repo root
(cd backend    && npm audit --audit-level=high)
(cd frontend   && npm audit --audit-level=high)
(cd word-addin && npm audit --audit-level=high)
tree npm's summary line exit distinct high advisory ids
root found 0 vulnerabilities 0 0
backend 17 vulnerabilities (1 low, 10 moderate, 6 high) 1 12
frontend 26 vulnerabilities (3 low, 10 moderate, 13 high) 1 27
word-addin 27 vulnerabilities (5 low, 8 moderate, 14 high) 1 13

The last two columns count different things, and knowing which is which is how you read the gate's output later: npm's summary counts vulnerable packages, the gate counts distinct GHSA ids. One package can carry several advisories (main's frontend tree has four separate undici highs and three separate brace-expansion highs), and one advisory can hit several packages - so "13 high" and "27 advisories" are both true of the same tree.

3. Point this PR's gate at main's lockfiles to see what CI would say. Check this branch out somewhere, then run its script with the cwd set to a main tree:

cd /path/to/main-checkout/backend && node /path/to/this-branch/scripts/audit-gate.mjs; echo $?
12 high/critical advisories are not allowlisted:
  high: GHSA-2v35-w6hq-6mfw - xmldom: Uncontrolled recursion in XML serialization leads to DoS
  high: GHSA-mwp4-54f8-5fhr - ip-address: Address4 decodes leading-zero octets as decimal while
        resolvers decode them as octal, allowing SSRF and trust-boundary bypass
  high: GHSA-ph9p-34f9-6g65 - tmp has Path Traversal via unsanitized prefix/postfix ...
  ... 9 more
1

Same run in frontend lists 27 and exits 1, in word-addin 13 and exits 1; the root exits 0.

backend - 12: GHSA-2v35-w6hq-6mfw, GHSA-f6ww-3ggp-fr8h, GHSA-j759-j44w-7fr8, GHSA-x6wf-f3px-wcqx (@xmldom/xmldom ×4) · GHSA-4c8g-83qw-93j6, GHSA-7p8r-x3mc-p8w7, GHSA-v2hh-gcrm-f6hx (fast-uri ×3) · GHSA-5wm8-gmm8-39j9 (fast-xml-builder) · GHSA-mwp4-54f8-5fhr (ip-address, SSRF/trust-boundary bypass) · GHSA-28wg-ghj8-5hjv, GHSA-2v37-7h3g-55p8 (nanoid ×2) · GHSA-ph9p-34f9-6g65 (tmp, path traversal).

frontend - 27: GHSA-6gpp-xcg3-4w24, GHSA-89xv-2m56-2m9x, GHSA-m99w-x7hq-7vfj, GHSA-p9j2-gv94-2wf4 (Next.js ×4 - middleware/proxy bypass, SSRF in Server Actions, SSRF in rewrites, DoS) · GHSA-4cwx-7wf7-3272, GHSA-hm92-r4w5-c3mj, GHSA-vmh5-mc38-953g, GHSA-vxpw-j846-p89q (undici ×4) · GHSA-3jxr-9vmj-r5cp, GHSA-mh99-v99m-4gvg, GHSA-rgw5-rvv9-x895 (brace-expansion ×3) · GHSA-2v35-w6hq-6mfw, GHSA-f6ww-3ggp-fr8h, GHSA-j759-j44w-7fr8, GHSA-x6wf-f3px-wcqx (@xmldom/xmldom ×4) · GHSA-6g55-p6wh-862q, GHSA-r28c-9q8g-f849 (postcss ×2 - arbitrary file read via attacker-controlled sourceMappingURL) · GHSA-52cp-r559-cp3m, GHSA-5p4m-2wfm-xmqj (js-yaml ×2) · GHSA-22p9-wv53-3rq4, GHSA-v245-v573-v5vm (linkify-it ×2) · GHSA-28wg-ghj8-5hjv, GHSA-2v37-7h3g-55p8 (nanoid ×2) · GHSA-f88m-g3jw-g9cj (sharp/libvips) · GHSA-hmw2-7cc7-3qxx (form-data, CRLF injection) · GHSA-ph9p-34f9-6g65 (tmp) · GHSA-96hv-2xvq-fx4p (ws).

word-addin - 13: GHSA-xcpc-8h2w-3j85 (adm-zip) · GHSA-3jxr-9vmj-r5cp, GHSA-mh99-v99m-4gvg, GHSA-rgw5-rvv9-x895 (brace-expansion ×3) · GHSA-4c8g-83qw-93j6, GHSA-7p8r-x3mc-p8w7, GHSA-v2hh-gcrm-f6hx (fast-uri ×3) · GHSA-8r6m-32jq-jx6q (fast-xml-parser) · GHSA-5p4m-2wfm-xmqj (js-yaml) · GHSA-28wg-ghj8-5hjv, GHSA-2v37-7h3g-55p8 (nanoid ×2) · GHSA-r28c-9q8g-f849 (postcss) · GHSA-ph9p-34f9-6g65 (tmp).

Four of those frontend highs are in Next.js itself - SSRF and middleware-bypass in the framework serving every request. That is the shape of the risk, not a scanner formality.

4. See why they stand - the one experiment that explains this whole PR. They are not standing because no fix exists. Every one of them has a fix already inside a range some consumer declares; what pins them is that a lockfile records resolutions from whenever it was last written, and the usual "regenerate" command preserves those pins. Measured on main @ 6a62d01a, 2026-08-26:

cd /path/to/main-checkout/frontend
npm install --package-lock-only          # (a) incremental - keeps every existing pin
npm audit --audit-level=high             #     → 27 high advisories, still standing

rm package-lock.json                     # (b) from scratch - forces a full re-resolve
npm install --package-lock-only
npm audit --audit-level=high             #     → 0 high advisories
git status --porcelain package.json      #     → empty: not one manifest edit

Same command, same manifests, opposite result. (a) is what a normal rebase or npm install does, which is why 27 highs can survive a "we regenerated the lockfile" claim. (b) is the discipline this PR is built on, and it is what clears them.


PR replication - verify the fix on this branch

1. Same four audits, on this branch.

git fetch origin && git checkout olp-pr/sec-supply-chain   # b1e90a87
npm audit --audit-level=high
(cd backend    && npm audit --audit-level=high)
(cd frontend   && npm audit --audit-level=high)
(cd word-addin && npm audit --audit-level=high)
tree summary line exit distinct high/critical
root found 0 vulnerabilities 0 0
backend 2 moderate severity vulnerabilities 0 0 (was 12)
frontend 4 moderate severity vulnerabilities 0 0 (was 27)
word-addin 19 vulnerabilities (4 low, 7 moderate, 8 high) 1 2, both allowlisted (was 13)

52 → 2 distinct high/critical, and the 2 that remain are the documented dev-only pair above. backend/package.json and word-addin/package.json are untouched; frontend/package.json gains one entry, and it is a lint-tool pin, not a CVE override (see "the linter the floating floor pulls in" above). No advisory here was fixed by telling the resolver something untrue.

Note the word-addin row: plain npm audit --audit-level=high still exits 1, because it counts vulnerable packages and cannot express "known, unfixable in-range, tracked". That gap is the entire reason audit-gate.mjs exists - step 2.

2. Run the gate the way CI runs it - once per lockfile directory (.github/workflows/security.yml matrixes exactly these four):

node "$PWD/scripts/audit-gate.mjs"                    # root
(cd backend    && node ../scripts/audit-gate.mjs)
(cd frontend   && node ../scripts/audit-gate.mjs)
(cd word-addin && node ../scripts/audit-gate.mjs)

All four exit 0. The first three print:

note: allowlist entry GHSA-xcpc-8h2w-3j85 not reported in this workspace - remove it once no workspace reports it
note: allowlist entry GHSA-ph9p-34f9-6g65 not reported in this workspace - remove it once no workspace reports it
audit gate passed (0 high/critical advisories, all allowlisted)

and word-addin prints both accepted risks in full, with their reasons, before passing:

ALLOWLISTED high: GHSA-xcpc-8h2w-3j85 - adm-zip: Crafted ZIP file triggers 4GB memory allocation
  reason: adm-zip 0.5.12 memory-exhaustion on a crafted ZIP, pulled into word-addin only by
  office-addin-debugging/office-addin-manifest (devDependencies used to sideload the add-in on a
  developer's machine). ... REMOVE WHEN: the office-addin-* chain ships adm-zip >=0.6.0 ...
ALLOWLISTED high: GHSA-ph9p-34f9-6g65 - tmp has Path Traversal via unsanitized prefix/postfix ...
  reason: ... reachable in word-addin only through @inquirer/editor -> external-editor ...
audit gate passed (2 high/critical advisories, all allowlisted)

3. Read the allowlist status - it is printed, not hidden. The gate reports allowlist state inline on every run, in three places (scripts/audit-gate.mjs):

  • ALLOWLISTED <severity>: <GHSA> - <title> plus an indented reason: line, for each advisory that is both present in this tree and allowlisted;
  • note: allowlist entry <GHSA> not reported in this workspace - remove it once no workspace reports it, for each entry that no longer applies here - the staleness nag that stops the register rotting;
  • the closing summary line above.

That output is the point of the design: a green gate with a non-empty allowlist prints every accepted risk, with its reason, into the CI log where a reviewer trips over it. The three note: lines in the other trees are the same mechanism pointed the other way - they will nag on every run until the word-addin entries retire, which is exactly when nobody remembers them any more.

4. Prove the gate blocks - a gate that never fires is not a gate. Two directions:

# (a) real advisories → red. This is base-case step 3: run this branch's gate
#     against main's lockfiles → 12 blocking (backend) / 27 (frontend) /
#     13 (word-addin, of which 11 are cleared in-range here), exit 1.

# (b) fails CLOSED when npm audit itself fails (registry outage), rather than
#     reporting a false green:
(cd backend && npm_config_registry=http://127.0.0.1:9/ node ../scripts/audit-gate.mjs); echo $?
npm audit itself failed - refusing to pass the gate:
{ "message": "request to http://127.0.0.1:9/-/npm/v1/security/audits/quick failed,
   reason: connect ECONNREFUSED 127.0.0.1:9", "error": { ... } }
1

5. Check the two mechanisms that keep it true after merge, which no local run can show: .github/workflows/security.yml runs the gate on every push and PR (so a newly published advisory reddens CI with zero code change), and .github/dependabot.yml opens the bump PR that clears it. The second half of that loop needs the repo setting called out below.

Testing evidence

Everything above was re-measured against main @ 6a62d01a and this branch's head b1e90a87 on 2026-08-26 (UTC).

  • CI on the previous head 4211d056 (same tree apart from the word-addin commit): all 13 checks green, including dependency-audit (root), (backend), (frontend).
  • All four gates re-run locally against today's advisory database: root / backend / frontend at 0 high advisories, word-addin at 2, both allowlisted. Each exits 0.
  • Backend: 725 tests passed, 25 skipped (60 files) + build.
  • Frontend: 590 tests passed (95 files), lint 0 errors, coverage ratchet pass, and a full production next build on the upgraded tree (Next 16.3.x with the exact sharp/postcss versions it declares).
  • Word add-in, on the fixed lockfile: npm ci clean (which is what proves the lockfile is still derivable from the untouched manifest), npm run typecheck clean for both the app and e2e tsconfigs, production build:e2e bundle builds, and the full Playwright suite is 332 passed across chromium and webkit. Its CI job runs on this PR for the first time - word-addin.yml is path-filtered on word-addin/**, and until this commit the branch touched nothing there.
  • The production builds are the load-bearing evidence, for the reason spelled out in §10 of the primer: audits and lockfiles are static facts; only a build tests the tree as an integrated artifact.

What's in this PR

  • backend/ and frontend/ package-lock.json - regenerated from scratch on current main. (The repo-root lockfile needed no change - it already reports zero advisories - but it still gets its own gate and Dependabot config, because root tooling ships to developers too.)
  • word-addin/package-lock.json - advisories cleared in-range with npm audit fix --package-lock-only (13 high → 2), not from scratch, for the @tiptap duplication reason documented under "Lockfile discipline". Manifest untouched.
  • frontend/package.json - one entry: the documented eslint-plugin-react-hooks: ~7.0.1 pin, whose REMOVE WHEN lives in its commit message (fae42703) since JSON has no comments. No CVE overrides remain in any manifest - the earlier scoped tmp/form-data entries became redundant on regeneration and are gone; backend/package.json and word-addin/package.json are untouched.
  • scripts/audit-gate.mjs + scripts/audit-allowlist.json - the blocking gate and its documented exceptions (two dev-only word-addin entries, each with a REMOVE WHEN).
  • .github/workflows/security.yml - a new workflow; main has none. Runs the gate for root, backend, frontend, and word-addin, actions pinned to commit SHAs, permissions: contents: read.
  • .github/dependabot.yml - grouped CVE-fix PRs + weekly dev-dep bumps for all four npm trees and GitHub Actions.

One repo setting must be flipped after merge (config files can't enable it): Settings → Advanced Security → "Dependabot security updates" → Enable (free for public repos). Without it this file still delivers weekly bumps, but the within-hours CVE-fix PRs are the point.

📚 Primer: package handling from first principles

1. The dependency tree: you ship code you didn't choose

When frontend/package.json says "next": "^16.2.6", we chose Next. But Next has its own package.json declaring its dependencies (postcss, sharp, ...), and those have theirs. The app ships this entire tree - roughly 1,500 packages of which we picked about 60. The ones we named are direct dependencies; everything they drag in are transitive dependencies. The asymmetry that drives this whole PR: we control our own declarations, but Next's declarations are Next's - written and tested by the Next team, frozen into each Next release.

2. Semver: what a version range promises

A version 8.4.31 is major.minor.patch. The semantic-versioning convention: patch = bug fixes only, minor = backward-compatible additions, major = may break you. Ranges express trust in that convention:

Declaration Meaning
"postcss": "8.4.31" exact pin - this version, nothing else
"postcss": "^8.4.31" caret - anything >=8.4.31 <9.0.0
"sharp": "^0.34.5" caret on a 0.x version - see below

The 0.x special rule. Below 1.0.0, semver says the API is unstable and anything may break, so by convention the minor position plays the role the major position normally plays. npm's caret encodes this with one rule: allow changes that don't touch the leftmost non-zero digit. So ^1.2.3 allows up to <2.0.0, but ^0.34.5 allows only >=0.34.5 <0.35.0 - sharp 0.34.5 → 0.35.3 looks like a tiny step, but it is a major upgrade in disguise (sharp 0.35 ships documented breaking changes). This single fact is why the earlier revision of this PR was wrong to force sharp 0.35 under a Next that declared ^0.34.5. (Next 16.3.0 has since moved its own declaration to sharp ^0.35.3 - the upgrade arrived the legitimate way, tested upstream, and it is what retired the sharp allowlist entry.)

3. package.json vs package-lock.json: the question and the answer

package.json declares ranges - many versions would satisfy it. package-lock.json records one concrete resolution: exactly which version of every package in the whole tree, with a hash of each, so every developer and CI run installs an identical tree. The lockfile must always be derivable from the manifests - which is why this PR regenerates lockfiles with npm install --package-lock-only and never hand-edits them (hand-merging a lockfile can silently corrupt the tree while looking fine in a diff).

4. Why a package pins exactly

Next 16.2.x declared "postcss": "8.4.31" - no range at all. That is the Next team saying: we compiled, tested, and shipped our CSS pipeline against this precise version; no substitutions. A declaration like this is a tested-compatibility statement. Nothing mechanically stops you from violating it - but everything downstream of violating it is your problem, not upstream's. (16.3.0 moved the pin to 8.5.23, which is how both postcss allowlist entries retired.)

5. Overrides: one tool, two opposite uses

npm's overrides field tells the resolver: "wherever anything in the tree asks for package X, hand it version Y instead - regardless of what was asked for."

  • Legitimate use (this PR's tmp and form-data entries): a patched version exists inside the range consumers already declared. Some link in the release chain is just slow to re-lock. The override accelerates delivery of a combination upstream already tests. Runtime risk ≈ zero.
  • Misuse (this PR's earlier sharp/postcss overrides, since removed): forcing a version outside a consumer's declared range. That fabricates a combination upstream never tested - for sharp, a native C image library (libvips bindings) running under next/image in production. It makes the scanner green by increasing runtime risk, which inverts the point of the scanner.

Same JSON key, opposite relationship to upstream's testing. The difference is not degree but kind.

6. Advisories and npm audit: set intersection, not code analysis

When a vulnerability is disclosed it gets a GHSA id in the GitHub Advisory Database, recording the package, the vulnerable version range, and a severity. npm audit intersects our lockfile with that database: "does any resolved version fall inside any advisory's vulnerable range?" No code runs, no install needed - which is why the gate costs seconds and why it can go red with zero code change: the database moved, not our tree. (It fired three times during this PR's own lifetime: the July brace-expansion wave, the early-August ip-address/undici wave, and the mid-August nanoid wave.)

This also explains the perverse incentive the earlier revision fell into: audit only compares versions. Force sharp to 0.35.3 and the intersection is empty - green - even though what actually happened is that runtime risk went up.

7. Why overrides or allowlists at all: the three honest decisions

A blocking gate forces every advisory to a decision, and there are only three honest ones: fix the version, accept the risk, or stop shipping. Nobody chooses the third, so the real machinery is:

  • Fix the version when the polite path can't do it yet → an in-range override (the fix exists; the release chain hasn't propagated it).
  • Accept the risk when no acceptable fix exists anywhere → an allowlist entry (as was true for sharp and postcss until Next 16.3.0, and for undici-under-miniflare until wrangler shipped the bump - all four since retired - and as is true today for adm-zip and tmp under office-addin-debugging, whose only offered "fix" is a downgrade out of the declared range).

Everything else people do - npm audit fix --force, report-only jobs, deleting the audit step, out-of-range overrides - is one of those two decisions made invisibly, where nobody can review, challenge, or undo it.

8. Why blocking + allowlist beats report-only

Scenario report-only job blocking + allowlist
Known advisory with no fix anywhere (e.g. adm-zip and tmp under office-addin-debugging) tolerated tolerated - via one documented, expiring entry each
Brand-new critical published tomorrow silently tolerated fails the build

Report-only doesn't tolerate one advisory; it tolerates every future advisory, silently. A gate that never fires is not a gate. The allowlist keeps the gate armed and makes each exception narrow (one GHSA id), visible (printed with its reason on every run, nagged when stale), and expiring (every entry carries a REMOVE WHEN condition naming the upstream event that retires it). Changing accepted risk requires a reviewed commit to scripts/audit-allowlist.json - a version-controlled risk register.

9. Vulnerable ≠ exploitable: reading the allowlist reasons

Each entry states the actual exposure, because a vulnerable version only matters if the vulnerable path is reachable. The two live entries are the worked examples: adm-zip and tmp are both reached only through office-addin-debugging, the CLI a developer runs to sideload the add-in - devDependencies that webpack never bundles, so nothing a user or a document supplies ever reaches them. The retired entries argued the same way: the undici flaw lived only under miniflare, wrangler's local dev simulator; the postcss entries noted that Next's pinned copy only processed this repo's own CSS at build time. This reasoning is what a reviewer should challenge in an entry - that's why it has to be written down rather than implied by a silent continue-on-error.

10. Why the production build is the load-bearing evidence

Declarations, lockfiles, and audit results are all static facts. The only thing that tests the tree as an integrated artifact is building it: next build compiles every route, type-checks, and exercises the CSS pipeline and image tooling against the real resolved versions. In review, an untested claim and a false claim are indistinguishable - so the claim "what Next declares actually works in this app" is backed by a green production build in CI, not asserted.

One-sentence summary: we stopped lying to the resolver about what versions Next can handle, told the truth in a documented, expiring risk register instead, and then proved the truthful configuration actually builds. Every mechanism here - semver ranges, lockfiles, audit, allowlist, build - is a different layer of making claims checkable by someone else.

Reading

npm audit docs · GitHub Advisory Database · OWASP A06:2021


Revision note: an earlier version of this description said the frontend gate was report-only, and an earlier revision of the branch replaced that with global sharp/postcss overrides without updating this text. Both are gone: the gate blocks everywhere, out-of-range overrides were removed in favor of the documented allowlist. The 2026-08-12 re-review round then rebased onto current main, absorbed the nanoid wave in-range, retired the sharp and both postcss allowlist entries (their exit conditions came true with Next 16.3.0), extended the gate and Dependabot to the repo-root lockfile, and adopted the eslint-plugin-react-hooks 7.1 rules the upgraded floor pulled in. That last item was later reversed - the adoption collided with main's in-flight rewrites, so it was split out to its own PR and the plugin is pinned at ~7.0.1 instead. See the dated notes below for the state that is actually on this tip.

Rebase note - 2026-08-24

Rebased onto main 54681b55; every point from the standing review is addressed on this tip:

  • All CI green, including the frontend job end-to-end (coverage, lint, production build) and the audit gates.
  • No sharp override remains (no global overrides at all). The regenerated lockfile resolves Next 16.3.2 - inside the manifest's declared ^16.2.6 - and Next 16.3.x itself declares sharp ^0.35.3, so sharp 0.35.3 is within Next's supported range rather than forced past it. The green production build on this tip demonstrates the pairing.
  • The description above has been updated to match the implementation exactly: blocking audit gates in every gated tree, no CVE overrides (all became redundant on regeneration), lint adoption split out. (Tree count and allowlist contents were superseded by the 2026-08-26 round below.)

Description update - 2026-08-25

Added the two replication sections this description was missing (base case on main, verification on this branch), with every count re-measured rather than carried over. Also corrected three claims that the 2026-08-24 rebase had left stale - backend/package.json is untouched and no CVE overrides remain in either manifest, and there are no frontend/src/** changes on this branch (the lint adoption was split out).

Rebase + fourth-tree round - 2026-08-26

Rebased onto main 6a62d01a (merge-base confirmed), and every count in this description re-measured against that base today rather than carried over.

The substantive change this round: the gate was missing a lockfile. 5c620e93 claimed the matrix "enumerates every directory that owns a lockfile: root, backend, frontend", and the workflow comment repeated it - but word-addin/ has owned its own package-lock.json since b8bd5b0c (2026-07-25), and nothing was watching it. This branch's own gate, pointed at that lockfile, reported 13 unallowlisted high advisories and exit 1. That is the exact failure this PR exists to prevent, reproduced inside the PR.

Fixed in 0a5282f6/b1e90a87: word-addin joins the gate matrix and Dependabot; its advisories are cleared in-range (13 → 2, no manifest edit) rather than by from-scratch regeneration, because a full re-resolve there duplicates @tiptap/core and breaks the typecheck - measured both ways, documented under "Lockfile discipline"; and the two dev-only residuals get documented allowlist entries with REMOVE WHEN conditions. Verified: all four gates exit 0, and the add-in's own CI job (typecheck, production bundle, 332 Playwright tests on chromium + webkit) passes on the new lockfile.

Consequence worth stating plainly: the allowlist is no longer empty. An earlier revision of this description celebrated reaching []; extending coverage to a tree that was never checked put two honest entries back. The register is supposed to say what we actually accept.

Our analysis

Add supply-chain audit ratchet and Dependabot — read the full analysis →

Think the analysis missed something the PR description covers?

Capture this PR into my fork

Download a Markdown prompt that tells Claude how to port every commit in this PR into your working tree. Run it via claude -p < capture-pull-251.md from inside the repo you want the changes in.

⬇ Download capture-pull-251.md