diff --git a/apps/frontend/package-lock.json b/apps/frontend/package-lock.json index ee717da..ccf4078 100644 --- a/apps/frontend/package-lock.json +++ b/apps/frontend/package-lock.json @@ -18,7 +18,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-dropzone": "^19.1.1", - "react-router-dom": "^7.18.1", + "react-router-dom": "^7.18.2", "sonner": "^2.0.7", "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7", @@ -4684,9 +4684,9 @@ } }, "node_modules/react-router": { - "version": "7.18.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz", - "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", + "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -4706,12 +4706,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.18.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz", - "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz", + "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==", "license": "MIT", "dependencies": { - "react-router": "7.18.1" + "react-router": "7.18.2" }, "engines": { "node": ">=20.0.0" diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 7f1ff46..353d27e 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -24,7 +24,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-dropzone": "^19.1.1", - "react-router-dom": "^7.18.1", + "react-router-dom": "^7.18.2", "sonner": "^2.0.7", "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7", diff --git a/scripts/dependency-audit-policy.json b/scripts/dependency-audit-policy.json index f77ab86..850c2c2 100644 --- a/scripts/dependency-audit-policy.json +++ b/scripts/dependency-audit-policy.json @@ -7,31 +7,5 @@ "functionality becomes reachable in our own source. Removing the vulnerable dependency", "also requires removing its entry: a stale acknowledgement fails the build too." ], - "acknowledged": [ - { - "id": "GHSA-qwww-vcr4-c8h2", - "package": "react-router", - "severity": "high", - "acceptedVersion": "7.18.1", - "patchedVersion": "8.3.0", - "advisoryUrl": "https://github.com/advisories/GHSA-qwww-vcr4-c8h2", - "reason": "GitHub lists React Router 8.3.0 as patched (affected >=7.12.0 <8.3.0). PARTHA temporarily remains on 7.18.1 because the patched major version has not yet been validated against the current React 18 prototype: react-router@8.3.0 declares peerDependencies react>=19.2.7 and react-dom>=19.2.7, and react-router-dom has no 8.x release at all (latest 7.18.1), so adopting it means a React 19 migration plus moving all 33 react-router-dom import sites. The affected behavior is restricted to unstable RSC APIs, which PARTHA does not import or enable; PARTHA is a client-only Vite SPA mounting createBrowserRouter with no server-side React Router runtime. This acceptance expires on 2026-10-01 and does not claim that no patched version exists.", - "reachability": { - "description": "Fails if our own source starts using an unstable or server-side React Router surface, which is the only way the advisory becomes reachable.", - "roots": ["apps/frontend/src"], - "forbiddenPatterns": [ - "unstable_", - "createStaticHandler", - "createStaticRouter", - "StaticRouterProvider", - "deserializeErrors", - "react-router/server", - "@react-router/node", - "@react-router/express", - "@react-router/serve" - ] - }, - "reviewBy": "2026-10-01" - } - ] + "acknowledged": [] } diff --git a/scripts/dependency-audit.test.mjs b/scripts/dependency-audit.test.mjs index 4566094..a4b8434 100644 --- a/scripts/dependency-audit.test.mjs +++ b/scripts/dependency-audit.test.mjs @@ -4,6 +4,15 @@ * The gate's value is entirely in when it *refuses* to stay quiet. These cover * each condition under which an acknowledgement must stop being accepted. * + * The behavioural tests deliberately use *synthetic* entries rather than + * binding to whichever exception happens to be shipped today. An earlier + * version of this file used the live react-router entry as its fixture, so + * retiring that exception — the correct action once the advisory was patched + * in 7.18.2 — broke five tests at once. A gate that fails when you fix the + * vulnerability it guards is worse than no gate: it teaches you to keep the + * exception. Structural expectations about the shipped policy live in their + * own tests below and hold for any entry, including none. + * * Run with: node --test scripts/ */ @@ -21,8 +30,18 @@ const policy = JSON.parse(readFileSync(join(repoRoot, 'scripts', 'dependency-aud const FUTURE = '2099-01-01'; const TODAY = '2026-07-25'; -/** The real react-router entry, so the tests bind to the shipped policy. */ -const reactRouterEntry = policy.acknowledged.find((entry) => entry.id === 'GHSA-qwww-vcr4-c8h2'); +/** + * A synthetic acknowledgement. `react-router` is a real lockfile entry, so the + * version-drift checks exercise the real lookup, but no assertion depends on + * which version is pinned today. + */ +const syntheticEntry = { + id: 'GHSA-test-0000-0000', + package: 'react-router', + severity: 'high', + reason: 'synthetic fixture for the policy-gate tests', + reviewBy: FUTURE, +}; function advisoryFor(entry, overrides = {}) { return { @@ -35,48 +54,36 @@ function advisoryFor(entry, overrides = {}) { }; } -test('the shipped react-router acknowledgement is currently valid', () => { - assert.ok(reactRouterEntry, 'expected a GHSA-qwww-vcr4-c8h2 acknowledgement'); - const problems = validateAcknowledgement(reactRouterEntry, advisoryFor(reactRouterEntry), TODAY); +test('a current acknowledgement with no drift is accepted', () => { + const problems = validateAcknowledgement(syntheticEntry, advisoryFor(syntheticEntry), TODAY); assert.deepEqual(problems, []); }); -test('the react-router acknowledgement records the real patched version', () => { - // The earlier version of this policy claimed no patched release existed. - // GitHub lists 8.3.0 as patched, so the policy must say so and must not - // repeat npm's "No fix available" as if it meant the same thing. - assert.equal(reactRouterEntry.patchedVersion, '8.3.0'); - assert.match(reactRouterEntry.reason, /8\.3\.0 as patched/); - assert.doesNotMatch(reactRouterEntry.reason, /no fixed release exists/i); - assert.match(reactRouterEntry.reason, /does not claim that no patched version exists/i); -}); - test('an expired acknowledgement fails', () => { - const entry = { ...reactRouterEntry, reviewBy: '2020-01-01' }; + const entry = { ...syntheticEntry, reviewBy: '2020-01-01' }; const problems = validateAcknowledgement(entry, advisoryFor(entry), TODAY); assert.equal(problems.length, 1); assert.match(problems[0], /expired on 2020-01-01/); }); test('an acknowledgement fails when the advisory no longer names the accepted package', () => { - const entry = { ...reactRouterEntry, reviewBy: FUTURE }; - const advisory = advisoryFor(entry, { packages: new Set(['some-other-package']) }); - const problems = validateAcknowledgement(entry, advisory, TODAY); + const advisory = advisoryFor(syntheticEntry, { packages: new Set(['some-other-package']) }); + const problems = validateAcknowledgement(syntheticEntry, advisory, TODAY); assert.equal(problems.length, 1); assert.match(problems[0], /but the advisory now affects some-other-package/); }); test('an acknowledgement fails when the accepted package version changes', () => { - const entry = { ...reactRouterEntry, reviewBy: FUTURE, acceptedVersion: '7.0.0' }; + // A version the lockfile will never pin, so this stays true across bumps. + const entry = { ...syntheticEntry, acceptedVersion: '0.0.0-not-a-real-release' }; const problems = validateAcknowledgement(entry, advisoryFor(entry), TODAY); assert.equal(problems.length, 1); - assert.match(problems[0], /accepted for react-router@7\.0\.0 but the lockfile now pins/); + assert.match(problems[0], /accepted for react-router@0\.0\.0-not-a-real-release but the lockfile now pins/); }); test('an acknowledgement fails when the accepted package leaves the lockfile', () => { const entry = { - ...reactRouterEntry, - reviewBy: FUTURE, + ...syntheticEntry, package: 'package-that-is-not-installed', acceptedVersion: '1.0.0', }; @@ -85,12 +92,10 @@ test('an acknowledgement fails when the accepted package leaves the lockfile', ( }); test('an acknowledgement fails when the affected surface becomes reachable', () => { - // The react-router acceptance rests on PARTHA never touching unstable RSC or - // server APIs. Point the guard at a root that does contain one of its own - // forbidden patterns to prove the check actually fires. + // Point the guard at a root that does contain one of its own forbidden + // patterns to prove the check actually fires. const entry = { - ...reactRouterEntry, - reviewBy: FUTURE, + ...syntheticEntry, reachability: { roots: ['scripts'], forbiddenPatterns: ['createStaticHandler'], @@ -102,15 +107,54 @@ test('an acknowledgement fails when the affected surface becomes reachable', () assert.match(problems[0], /createStaticHandler/); }); -test('the real reachability guard finds nothing in the frontend source today', () => { - const problems = validateAcknowledgement( - { ...reactRouterEntry, reviewBy: FUTURE }, - advisoryFor(reactRouterEntry), - TODAY, - ); +test('the frontend source uses no unstable or server-side React Router surface', () => { + // Kept independent of any shipped acknowledgement: PARTHA is a client-only + // Vite SPA, and the RSC/server React Router surface is where this class of + // advisory lives. This asserts that property directly, so it keeps holding + // after an exception is retired. + const entry = { + ...syntheticEntry, + reachability: { + roots: ['apps/frontend/src'], + forbiddenPatterns: [ + 'unstable_', + 'createStaticHandler', + 'createStaticRouter', + 'StaticRouterProvider', + 'deserializeErrors', + 'react-router/server', + '@react-router/node', + '@react-router/express', + '@react-router/serve', + ], + }, + }; + const problems = validateAcknowledgement(entry, advisoryFor(entry), TODAY); assert.deepEqual(problems, [], 'frontend source must not use unstable or server React Router APIs'); }); +test('every shipped acknowledgement is well-formed and still valid today', () => { + // Holds for an empty policy, which is the healthy steady state: an + // acknowledgement should exist only while a real advisory is unpatched. + const today = new Date().toISOString().slice(0, 10); + for (const entry of policy.acknowledged || []) { + assert.ok(entry.id, 'each acknowledgement needs an advisory id'); + assert.ok(entry.package, `${entry.id} needs a package`); + assert.ok(entry.reason, `${entry.id} needs a reviewable reason`); + assert.ok(entry.reviewBy, `${entry.id} needs a reviewBy date`); + assert.doesNotMatch( + entry.reason, + /no fixed release exists/i, + `${entry.id} must not repeat npm's "No fix available" as if it meant no patch exists`, + ); + assert.deepEqual( + validateAcknowledgement(entry, advisoryFor(entry), today), + [], + `${entry.id} is no longer valid; re-review or remove it`, + ); + } +}); + test('advisories() groups npm audit output by advisory id', () => { const report = { vulnerabilities: {