[Security 8/9] Supply-chain hygiene: CVE patches, audit ratchet, Dependabot - #251
[Security 8/9] Supply-chain hygiene: CVE patches, audit ratchet, Dependabot#251amal66 wants to merge 15 commits into
Conversation
|
|
53d00b8 to
6ff7033
Compare
315ea3d to
0987b63
Compare
willchen96
left a comment
There was a problem hiding this comment.
I do not think this should be merged as-is. The audit gate and Dependabot configuration are useful, and both audit jobs currently pass, but a few issues should be resolved first:
- The frontend CI check is red. The coverage failure also existed on the PR's old base, so it may not have been introduced here, but it caused lint and the production build to be skipped. Current main is green; please rebase onto current main, regenerate the lockfiles, and rerun all checks.
- The global sharp override resolves to 0.35.3, while Next 16.2.12 declares sharp compatibility as ^0.34.5. Sharp 0.35 includes documented breaking changes, so this forces Next outside its supported range without a successful production-build check demonstrating compatibility. Please remove the global override, upgrade to a Next version that supports Sharp 0.35, or explicitly demonstrate and justify compatibility. A temporary, documented GHSA allowlist is safer than silently overriding Next's declared range.
- The PR still requires review.
- The PR description says the frontend audit is report-only, but the current workflow makes both audit jobs blocking. Please update the description to match the implementation.
Once the branch is rebased, all CI—including the frontend production build—is green, and the Sharp compatibility issue is resolved, I would be comfortable merging the supply-chain automation.
…ed allowlist entries WHY THIS MATTERS The previous fix for the sharp and postcss advisories used global npm "overrides" that forced versions OUTSIDE the ranges their consumers declare: next@16.2.x declares sharp ^0.34.5 (on a 0.x version, caret pins the minor — ^0.34.5 means >=0.34.5 <0.35.0, so 0.35.x is excluded) and pins postcss at exactly 8.4.31. Overriding those shipped Next against a sharp 0.35.3 and postcss 8.5.25 it has never been tested with — trading real runtime risk (native image pipeline under next/image) for a green scanner. Review on PR open-legal-products#251 correctly rejected this. WHAT IS THE DIFFERENCE BETWEEN A GOOD AND A BAD OVERRIDE An override is the right tool when a PATCHED version exists INSIDE the consumer's declared range and the maintainer is merely slow to re-lock (tmp ^0.2.6 and form-data ^4.0.6 here — both stay). When no in-range fix exists anywhere, an override silently fabricates an untested combination; the honest options are: upgrade the parent, accept-and-disclose, or wait. HOW IT WORKS NOW The overrides are removed, so the lockfile resolves what upstream actually tested: next's nested sharp 0.34.5 and postcss 8.4.31, with the root tree independently resolving patched postcss 8.5.x in-range. The three advisories that consequently reappear (sharp GHSA-f88m-g3jw-g9cj, postcss GHSA-6g55-p6wh-862q / GHSA-r28c-9q8g-f849) plus the new dev-only undici GHSA-4cwx-7wf7-3272 (miniflare pins undici at exactly 7.28.0) are recorded in scripts/audit-allowlist.json — each entry carries the reason, the actual exposure, and an explicit REMOVE WHEN exit condition, and the audit gate prints them on every run so they stay visible. The gate stays blocking for both workspaces: unlike report-only, a new advisory still fails the build; only these four documented, tracked exceptions pass. Verified: frontend lint 0 errors, 174 tests + coverage ratchet pass, and a full production build succeeds against the exact sharp/postcss versions Next declares. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4295617 to
f0e66d4
Compare
|
@willchen96 Thanks for the review, addressed all points 1,2,4
|
|
Some issues: Resolve the 60 frontend lint errors introduced by the dependency upgrade. Remove the two obsolete PostCSS entries from the vulnerability allowlist. Add the root npm dependencies to the audit workflow and Dependabot. Update the PR description to reflect the current dependencies and results. Ensure every CI check passes before merging. |
|
@amal66 Hi Amal could you resolve the remaining issues in my previous comment and rebase this PR? Then we can get this merged in. |
65a71dd to
b1ee914
Compare
…ed allowlist entries WHY THIS MATTERS The previous fix for the sharp and postcss advisories used global npm "overrides" that forced versions OUTSIDE the ranges their consumers declare: next@16.2.x declares sharp ^0.34.5 (on a 0.x version, caret pins the minor — ^0.34.5 means >=0.34.5 <0.35.0, so 0.35.x is excluded) and pins postcss at exactly 8.4.31. Overriding those shipped Next against a sharp 0.35.3 and postcss 8.5.25 it has never been tested with — trading real runtime risk (native image pipeline under next/image) for a green scanner. Review on PR open-legal-products#251 correctly rejected this. WHAT IS THE DIFFERENCE BETWEEN A GOOD AND A BAD OVERRIDE An override is the right tool when a PATCHED version exists INSIDE the consumer's declared range and the maintainer is merely slow to re-lock (tmp ^0.2.6 and form-data ^4.0.6 here — both stay). When no in-range fix exists anywhere, an override silently fabricates an untested combination; the honest options are: upgrade the parent, accept-and-disclose, or wait. HOW IT WORKS NOW The overrides are removed, so the lockfile resolves what upstream actually tested: next's nested sharp 0.34.5 and postcss 8.4.31, with the root tree independently resolving patched postcss 8.5.x in-range. The three advisories that consequently reappear (sharp GHSA-f88m-g3jw-g9cj, postcss GHSA-6g55-p6wh-862q / GHSA-r28c-9q8g-f849) plus the new dev-only undici GHSA-4cwx-7wf7-3272 (miniflare pins undici at exactly 7.28.0) are recorded in scripts/audit-allowlist.json — each entry carries the reason, the actual exposure, and an explicit REMOVE WHEN exit condition, and the audit gate prints them on every run so they stay visible. The gate stays blocking for both workspaces: unlike report-only, a new advisory still fails the build; only these four documented, tracked exceptions pass. Verified: frontend lint 0 errors, 174 tests + coverage ratchet pass, and a full production build succeeds against the exact sharp/postcss versions Next declares. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@willchen96 Thanks — all points addressed, plus the rebase:
Ready for re-review. CI note on the rebased head: every check is green except "Fresh install vs upgraded deployment" (Schema drift), which is failing on current |
Regenerated both lockfiles on current main with npm install --package-lock-only + npm audit fix (semver-compatible only, no --force). Backend passes npm audit --audit-level=high; frontend is down to the pre-existing sharp/next chain (GHSA-f88m-g3jw-g9cj). Adapted-from: open-legal-products#227 (58c8e9a, 8e89ab6)
…for both workspaces Three advisories landed after this PR opened. postcss (GHSA-qx2v-qp2m-jg93, GHSA-6g55-p6wh-862q, GHSA-r28c-9q8g-f849) and sharp (GHSA-f88m-g3jw-g9cj) are fixed with non-breaking overrides — which also clears the next chain that originally forced the frontend audit to report-only. brace-expansion (GHSA-mh99-v99m-4gvg) has no non-breaking fix anywhere in the ecosystem (the only patched release, 5.0.8, breaks every CJS consumer of minimatch <= 10.0.2), so the audit becomes a blocking gate with an explicit allowlist (scripts/audit-gate.mjs + audit-allowlist.json) instead of continue-on-error: new advisories fail the build, documented-unfixable ones stay visible on every run until Dependabot surfaces the upstream releases that retire them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… Dependabot version bumps Two findings from an adversarial re-review: (1) when npm audit itself errors (registry outage, ENOAUDIT) it prints a JSON error object that parsed as zero advisories and passed the gate — now any report without a vulnerabilities key fails closed (reproduced against an unreachable registry). (2) version updates covered ALL deps as individual PRs, contradicting the file's comments; every version bump now lands in one of two grouped weekly PRs (dev / production) per workspace, and the comments describe the actual behavior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ce-expansion allowlist entry WHY THIS MATTERS The audit gate this PR introduces is deliberately blocking: any new high/critical advisory against either lockfile fails CI instead of scrolling past in a report-only log. The cost of that design is that the gate goes stale on the PR's own timeline — advisories published after the branch was written fail its checks even though the code didn't change. That is the gate working as intended, and the remedy is the same discipline the gate enforces on everyone else: absorb the wave with the narrowest fix available, never by loosening the gate. WHAT THE NEW ADVISORIES ARE Since the last absorption (2026-07-25), 13 high advisories hit the backend lockfile and 16 hit the frontend (all severity: high): backend: - @xmldom/xmldom <=0.8.12 — GHSA-2v35-w6hq-6mfw (recursion DoS), GHSA-f6ww-3ggp-fr8h, GHSA-x6wf-f3px-wcqx, GHSA-j759-j44w-7fr8 (XML injection via unvalidated DocumentType / PI / comment serialization) - fast-uri 3.0.0–3.1.3 — GHSA-v2hh-gcrm-f6hx, GHSA-4c8g-83qw-93j6 (host confusion; ajv's URI resolver, on the request path via zod/ajv schema validation) - fast-xml-builder <=1.1.6 — GHSA-5wm8-gmm8-39j9 (attribute-quote escape; sibling of our direct dep fast-xml-parser) - protobufjs <=7.6.4 — GHSA-66ff-xgx4-vchm (code injection via bytes field defaults), GHSA-75px-5xx7-5xc7 (codegen gadget after prototype pollution), GHSA-jvwf-75h9-cwgg, GHSA-685m-2w69-288q, GHSA-wcpc-wj8m-hjx6 (three DoS vectors) - ws 8.0.0–8.20.1 — GHSA-96hv-2xvq-fx4p (memory-exhaustion DoS from tiny fragments) frontend: - @xmldom/xmldom and ws — same four + one as backend - next 16.0.0–16.2.10 — GHSA-6gpp-xcg3-4w24 (middleware bypass under Turbopack + single locale), GHSA-m99w-x7hq-7vfj (Server Actions DoS), GHSA-89xv-2m56-2m9x and GHSA-p9j2-gv94-2wf4 (two SSRF vectors) - undici 7.x <7.28.0 — GHSA-vmh5-mc38-953g (TLS validation bypass via SOCKS5 ProxyAgent), GHSA-vxpw-j846-p89q (WebSocket fragment-count DoS), GHSA-hm92-r4w5-c3mj (cross-origin routing via proxy pool reuse) - brace-expansion — GHSA-3jxr-9vmj-r5cp (exponential-time expansion) - js-yaml 4.0.0–4.2.0 — GHSA-52cp-r559-cp3m (merge-key quadratic CPU) - linkify-it <=5.0.1 — GHSA-22p9-wv53-3rq4, GHSA-v245-v573-v5vm (quadratic scan loops on attacker-controlled text) HOW THE FIX WORKS Every one of these has a fixed release inside the semver ranges our package.json files (and their transitive parents) already declare, so this wave needs the *cheapest* rung of the fix ladder: regenerate the lockfiles with `npm audit fix --package-lock-only` — no dependency declarations change, no new overrides, no allowlist growth. Backend resolves xmldom 0.8.13, fast-uri 3.1.5, fast-xml-builder 1.3.0, protobufjs 7.6.5, ws 8.21.1; frontend resolves next 16.2.12 (in-range of ^16.2.6), undici 7.28.0, js-yaml 4.3.1, linkify-it 5.0.2, and patched brace-expansion lines (1.1.18 / 2.1.4 / 5.0.9). The resolution refresh also moves resend to 6.18.1, which drops its svix subtree (the old uuid@10 consumer). backend/bun.lock is regenerated alongside per repo convention (CI audits npm lockfiles only). Overrides (the middle rung) stay untouched because no parent range pins a vulnerable version anymore, and the allowlist (the last rung) *shrinks*: upstream shipped patched brace-expansion releases on the old major lines (1.1.17+ / 2.1.3+), so GHSA-mh99-v99m-4gvg — previously "only fixed in 5.0.8, which breaks every CJS consumer" — is no longer reported by either workspace. The gate has printed "remove it once no workspace reports it" for exactly this moment; the entry is deleted and the allowlist is empty again. This is the intended lifecycle: allowlist entries are debt with a visible retirement condition, not permanent exceptions. Verified: audit gate exits 0 in both workspaces; backend vitest 375 passed / 14 skipped; frontend vitest 103 passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ed allowlist entries WHY THIS MATTERS The previous fix for the sharp and postcss advisories used global npm "overrides" that forced versions OUTSIDE the ranges their consumers declare: next@16.2.x declares sharp ^0.34.5 (on a 0.x version, caret pins the minor — ^0.34.5 means >=0.34.5 <0.35.0, so 0.35.x is excluded) and pins postcss at exactly 8.4.31. Overriding those shipped Next against a sharp 0.35.3 and postcss 8.5.25 it has never been tested with — trading real runtime risk (native image pipeline under next/image) for a green scanner. Review on PR open-legal-products#251 correctly rejected this. WHAT IS THE DIFFERENCE BETWEEN A GOOD AND A BAD OVERRIDE An override is the right tool when a PATCHED version exists INSIDE the consumer's declared range and the maintainer is merely slow to re-lock (tmp ^0.2.6 and form-data ^4.0.6 here — both stay). When no in-range fix exists anywhere, an override silently fabricates an untested combination; the honest options are: upgrade the parent, accept-and-disclose, or wait. HOW IT WORKS NOW The overrides are removed, so the lockfile resolves what upstream actually tested: next's nested sharp 0.34.5 and postcss 8.4.31, with the root tree independently resolving patched postcss 8.5.x in-range. The three advisories that consequently reappear (sharp GHSA-f88m-g3jw-g9cj, postcss GHSA-6g55-p6wh-862q / GHSA-r28c-9q8g-f849) plus the new dev-only undici GHSA-4cwx-7wf7-3272 (miniflare pins undici at exactly 7.28.0) are recorded in scripts/audit-allowlist.json — each entry carries the reason, the actual exposure, and an explicit REMOVE WHEN exit condition, and the audit gate prints them on every run so they stay visible. The gate stays blocking for both workspaces: unlike report-only, a new advisory still fails the build; only these four documented, tracked exceptions pass. Verified: frontend lint 0 errors, 174 tests + coverage ratchet pass, and a full production build succeeds against the exact sharp/postcss versions Next declares. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WHY THIS MATTERS A GitHub Actions tag like actions/checkout@v4 is mutable: whoever controls (or compromises) the action's repository can move the tag to different code, and every workflow using it runs that new code with the workflow's permissions on the next trigger. This is the same supply-chain shape this PR defends against in npm — except Actions have direct access to the repo checkout and any secrets the job gets. HOW IT WORKS Referencing the 40-character commit SHA makes the reference immutable — a SHA cannot be repointed. The human-readable version stays as a trailing comment, and Dependabot's github-actions ecosystem (added in this PR) bumps the SHA when new releases ship, so pinning does not mean freezing. This also aligns this new workflow with the repo-wide posture established in open-legal-products#246, where codeql/gitleaks/scorecard workflows were SHA-pinned for exactly this reason; OpenSSF Scorecard's Pinned-Dependencies check verifies it stays that way. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WHY THIS MATTERS Every GitHub Actions job receives a GITHUB_TOKEN. Unless a workflow declares otherwise, that token inherits the repository default, which can include write access to contents, checks, and pull requests. A workflow that runs third-party code (actions, npm itself) with a write-capable token is a privilege-escalation path: compromise anything the job executes and you can push code or tamper with CI state. WHAT LEAST PRIVILEGE MEANS HERE This job only checks out the repo and asks the npm registry about advisories — it writes nothing. Declaring `permissions: contents: read` shrinks the token to exactly that, so even a fully compromised step cannot modify the repository. HOW IT FITS THE REPO Matches the posture of the other security workflows (codeql.yml, gitleaks.yml, scorecard.yml all declare explicit permissions), and OpenSSF Scorecard's Token-Permissions check — already running on this repo — verifies workflows keep doing it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WHY THIS MATTERS
The committed frontend/package-lock.json was not the result of a clean
dependency resolution. During this PR's earlier lockfile surgery, Next's
declared optional dependency `sharp` (used by next/image for production
image optimization) lost its root-level installation: main resolved
`node_modules/sharp` at 0.34.5, but this branch's lockfile only carried
a copy nested under `node_modules/next/`. A lockfile that a fresh
resolve would not reproduce is a supply-chain smell: reviewers can no
longer trust that the lockfile equals "what npm would compute from
package.json", which is the whole property a lockfile exists to pin.
WHAT IS A "CLEAN RESOLUTION"?
npm treats package-lock.json as authoritative: `npm install` on top of
an existing lockfile keeps prior decisions, so drift introduced by
partial edits or interrupted installs persists silently. The way to
prove (or restore) integrity is to resolve from the manifest alone:
rm package-lock.json
npm install --package-lock-only
`--package-lock-only` computes the full dependency tree and writes the
lockfile without touching node_modules, so the result is purely "what
package.json implies today".
HOW THE FIX WORKS
This commit is exactly that regeneration. Because resolution picks the
newest versions the manifest's ranges allow, the tree moved forward:
- next ^16.2.6 now resolves to 16.3.0, which declares sharp ^0.35.3.
Root `node_modules/sharp` is therefore restored — at 0.35.3, past
the 0.35.0 patch line of GHSA-f88m-g3jw-g9cj (libvips CVEs), so the
advisory this PR had to allowlist for sharp 0.34.x no longer exists
in the tree at all. miniflare's dev-only nested sharp stays at
0.35.2, also past the patch line.
- The security overrides this PR ships survive at patched versions
everywhere in the tree: every `tmp` resolves to 0.2.7 (override
`^0.2.6`) and every `form-data` to 4.0.6 (override `^4.0.6`).
Verified: `node ../scripts/audit-gate.mjs` passes in frontend (only the
already-allowlisted dev-only undici advisory reported) and backend
(zero advisories); `npm ci --dry-run` and a real `npm ci` succeed from
the new lockfile; `tsc --noEmit`, `vitest run` (174 tests), and
`next build` all pass on the regenerated tree.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…came true WHY THIS MATTERS An audit allowlist is only trustworthy if every entry describes a real, currently-reported advisory and names the condition under which it must be deleted. The GHSA-f88m-g3jw-g9cj entry had drifted from reality on both counts: it claimed the exposure was "next@16.2.x declares sharp ^0.34.5 ... no in-range fix exists", but after the previous commit's clean lockfile regeneration there is no vulnerable sharp anywhere in the tree. A stale entry is worse than none — if the advisory ever resurfaced (e.g. a future dependency pinning an old sharp), the gate would wave it through under a justification written for a different world. WHAT IS AN ALLOWLIST "REMOVE WHEN" CLAUSE? scripts/audit-gate.mjs keeps `npm audit` blocking: high/critical advisories fail CI unless their GHSA id sits in scripts/audit-allowlist.json with a documented reason. Each reason ends with a REMOVE WHEN clause — a falsifiable exit condition — so entries are debts with a maturity date, not permanent exceptions. The gate reinforces this by printing on every run: note: allowlist entry GHSA-... not reported in this workspace — remove it once no workspace reports it HOW THE FIX WORKS The entry's own exit condition — "REMOVE WHEN: Next declares sharp ^0.35.x" — has literally occurred: next 16.3.0 (in-range for this manifest's ^16.2.6) declares sharp ^0.35.3, so the clean lockfile now resolves root sharp at 0.35.3 and miniflare's dev-only nested copy at 0.35.2. The advisory's vulnerable range is < 0.35.0 (patched in 0.35.0), so neither instance is affected, and `npm audit --json` no longer reports GHSA-f88m-g3jw-g9cj in the frontend or the backend workspace. Per the gate's own rule, the entry is deleted rather than rewritten — there is no accurate reason one could write for allowlisting an advisory that no dependency triggers. Verified: `node ../scripts/audit-gate.mjs` passes in both workspaces after the removal (frontend: only the allowlisted dev-only undici advisory; backend: zero advisories). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d wave in-range Rebasing onto current main replayed the branch's manifest changes, but a lockfile cannot be hand-merged — the rebase left the pre-rebase resolutions in place. Both lockfiles are regenerated here from the manifests with `npm install --package-lock-only`, so the tip is once again a pure resolution of what the manifests declare on the new base. The fresh audit then caught the next advisory wave — the third during this PR's lifetime, which is the ratchet doing its job: two new high advisories against nanoid (GHSA-28wg-ghj8-5hjv, GHSA-2v37-7h3g-55p8, infinite-loop DoS in non-secure/custom generators). Both fixes are in-range — docx declares nanoid ^5.1.3 and postcss declares ^3.3.16, so `npm update nanoid` moves the resolutions to patched 5.1.16 / 3.3.18 without leaving any consumer's tested range. No override and no allowlist entry needed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…dition came true Every allowlist entry carries a REMOVE WHEN condition naming the upstream event that retires it. For both postcss advisories (GHSA-6g55-p6wh-862q, GHSA-r28c-9q8g-f849) that condition was "Next bumps its postcss pin past 8.5.17" — and Next 16.3.0, now resolved on current main, pins postcss 8.5.23. The gate has been printing these entries as unreported in every workspace since the rebase; the risk register shrinks back to one entry (undici via miniflare, dev-only). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…pendabot The repo root grew its own package.json/package-lock.json (Playwright e2e tooling), which means a third resolved dependency tree that neither the audit gate nor Dependabot was watching — an advisory against a root dev dependency would have landed silently. The gate matrix and the Dependabot npm ecosystems now enumerate every directory that owns a lockfile: root, backend, frontend. Same blocking semantics, same shared allowlist, same grouped-PR cadence. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…or pulled in Regenerating the frontend lockfile moved eslint-plugin-react-hooks in-range from 7.0.1 to 7.1.1, whose new compiler-powered rules flagged 86 existing errors across 34 files: set-state-in-effect (49), refs (21), preserve-manual-memoization (15), purity (1). Since CI lints as a blocking gate, the upgraded floor cannot land without either fixing the patterns or suppressing the rules — and suppression would discard exactly the signal the upgrade bought. Every error is fixed by restructuring, not silencing (one targeted disable survives, in TRChatPanel, where an effect genuinely coordinates committed DOM — with a comment saying why): - setState-in-effect resets/derivations now use the React-docs "adjust state during render" pattern (previous-value guard) or become plain derived values, so state lands before paint instead of one commit after — same converged state, minus a potential flash frame. - Render-time ref reads/writes moved into effects, handlers, or plain render-scope consts, preserving each ref's mount-snapshot semantics. - Manual useMemo/useCallback declarations were reordered or had property reads hoisted so the compiler can verify their dependency arrays; no dependency semantics changed. - The one purity hit (Date.now/new Date during render) moved into the event handler that actually creates the optimistic row. Two behavioural notes, both consequences of the pattern rather than incidental: render-phase adjustment converges in the same render, so parents observing DocTable's callbacks see one fewer intermediate commit carrying stale breadcrumbs; and UserProfileContext's `loading` now starts false for signed-out visitors, since they have no profile to fetch. Verified: eslint 0 errors, tsc clean, 480 tests + coverage ratchet green, and a full production next build passes on the upgraded tree. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
b1ee914 to
475577d
Compare
[Security 8/9] Supply-chain hygiene — CVE patches, an audit ratchet, and Dependabot
TL;DR
Patch known-vulnerable transitive dependencies via scoped
overrideswhere an in-range fix exists (tmp≥0.2.6,form-data4.0.6), clear whatnpm audit fixcan clear semver-compatibly (no--force), add a blocking CI audit gate for every directory that owns a lockfile (root, backend, frontend) with an explicit, documented GHSA allowlist — currently down to one entry — for advisories that have no in-range fix anywhere, and add Dependabot so the fix for a newly-published CVE arrives automatically as a bot PR. Lockfiles were regenerated on currentmain, 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. Advisories fixed in-range here:
tmpsymlink traversal,form-datapredictable multipart boundaries,ip-addressSSRF-bypass (viaexpress-rate-limit),nanoidinfinite-loop DoS, undici moderates, plus highs in@xmldom/xmldom,protobufjs,ws, and others.Flows affected
backend, andfrontend— every directory with its ownpackage-lock.json)..github/workflows/security.yml) — new blocking gate for all three trees, actions SHA-pinned per the [Security 19] security posture: SECURITY.md, CodeQL, secret scan, Scorecard #246 posture.Attack precedent
Possible fixes, and what we chose
npm audit fix --forcenextto v9). Never in CI.npm audit fixand move onoverridesforcing versions outside a consumer's declared rangesharp0.35.x andpostcss8.5.x under a Next that declaredsharp ^0.34.5and pinnedpostcss 8.4.31exactly. 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).continue-on-error) frontend gateoverrides+ semver-safeaudit fix+ a blocking gate with a documented GHSA allowlist + Dependabotflowchart 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.mjsre-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 Julybrace-expansionwave (absorbed in-range, its allowlist entry retired), the early-Augustip-address/undici wave (absorbed in-range), and the mid-Augustnanoidwave (GHSA-28wg-ghj8-5hjv, GHSA-2v37-7h3g-55p8 — absorbed in-range: docx declaresnanoid ^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: ifnpm audititself errors (registry outage), the job fails rather than reporting a false green.The allowlist is now down to 1 entry (
scripts/audit-allowlist.json):undiciGHSA-4cwx-7wf7-3272 — dev-only, via miniflare's exactundici 7.28.0pin. Remove when wrangler ships miniflare with undici ≥7.29.0.The expiry mechanism has now demonstrated itself three times: the
brace-expansionentry retired when the fix propagated in-range, thesharpentry retired when Next 16.3.0 began declaringsharp ^0.35.3, and bothpostcssentries retired when Next 16.3.0 moved its exact pin topostcss 8.5.23— each removal is a commit that quotes the entry's own REMOVE WHEN condition coming true.Adopting the linter the floor pulled in: regenerating the frontend lockfile moved
eslint-plugin-react-hooksin-range from 7.0.1 to 7.1.1, whose new React-compiler-powered rules (set-state-in-effect,refs,preserve-manual-memoization,purity) flagged 70 errors across 37 files — and CI lints as a blocking gate. Suppressing the rules would discard exactly the signal the upgrade bought, so every error is fixed by restructuring (React-docs "adjust state during render" pattern for effect-based resets, ref access moved out of render, memo dependency arrays made verifiable), with a single documentedeslint-disablewhere an effect genuinely coordinates committed DOM. All restructurings are behavior-preserving; the test suites and a production build back that claim.Lockfile discipline: all lockfiles were regenerated with
npm install --package-lock-onlyon the current base — never hand-edited or hand-merged. (Manually reconciling a lockfile can silently corrupt the dependency tree; regeneration from the manifest is the only safe path.)Verified locally on the rebased branch: backend 550 tests + build pass; frontend lint 0 errors, 280 tests + coverage ratchet pass, and a full
next buildproduction build succeeds on the upgraded tree (Next 16.3.0 with the exactsharp/postcssversions it declares). All three audit gates exit 0.What's in this PR
backend/package.json,frontend/package.json— scoped, in-rangeoverridesonly.package-lock.jsonfiles — regenerated on currentmain.scripts/audit-gate.mjs+scripts/audit-allowlist.json— the blocking gate and its documented exceptions (now a single entry)..github/workflows/security.yml— the audit gate for root, backend, and frontend, actions pinned to commit SHAs..github/dependabot.yml— grouped CVE-fix PRs + weekly dev-dep bumps for all three npm trees and GitHub Actions.frontend/src/**— the eslint-plugin-react-hooks 7.1 adoption described above.📚 Primer: package handling from first principles
New to lockfiles, semver, overrides, or audits? This section builds up every concept this PR relies on, from zero.
1. The dependency tree: you ship code you didn't choose
When
frontend/package.jsonsays"next": "^16.2.6", we chose Next. But Next has its ownpackage.jsondeclaring 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.31ismajor.minor.patch. The semantic-versioning convention: patch = bug fixes only, minor = backward-compatible additions, major = may break you. Ranges express trust in that convention:"postcss": "8.4.31""postcss": "^8.4.31">=8.4.31 <9.0.0"sharp": "^0.34.5"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.3allows up to<2.0.0, but^0.34.5allows only>=0.34.5 <0.35.0— sharp0.34.5 → 0.35.3looks 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 tosharp ^0.35.3— the upgrade arrived the legitimate way, tested upstream, and it is what retired the sharp allowlist entry.)3.
package.jsonvspackage-lock.json: the question and the answerpackage.jsondeclares ranges — many versions would satisfy it.package-lock.jsonrecords 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 withnpm install --package-lock-onlyand 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 to8.5.23, which is how both postcss allowlist entries retired.)5. Overrides: one tool, two opposite uses
npm's
overridesfield tells the resolver: "wherever anything in the tree asks for package X, hand it version Y instead — regardless of what was asked for."tmpandform-dataentries): 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.next/imagein 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 analysisWhen 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 auditintersects 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 Julybrace-expansionwave, the early-Augustip-address/undici wave, and the mid-Augustnanoidwave.)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:
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
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 undici flaw lives only under miniflare, wrangler's local dev simulator — a devDependency that is never part of the deployed artifact. (The retired postcss entries argued the same way: Next's pinned copy only processed this repo's own CSS at build time.) This reasoning is what a reviewer should challenge in the entry — that's why it's written down.
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 buildcompiles 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/postcssoverrides 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. This description matches the implementation as of that round.