diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e00b9c8d9..efb92b9a1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -201,15 +201,50 @@ jobs: if path and not info.get('dev') and not info.get('devOptional'): prod.add(path.split('node_modules/')[-1]) vulns = data.get('vulnerabilities', {}) + # Documented advisory exceptions: high/critical findings whose only offered + # 'fix' is a semver-major change and which do not apply to how Bambuddy ships. + # Keyed by GHSA id; RE-REVIEW ON EVERY react-router BUMP. + # GHSA-qwww-vcr4-c8h2 - React Router RSC-mode CSRF. Bambuddy is a Vite SPA + # using BrowserRouter with no RSC runtime (@react-router/server is NOT + # installed), so the vulnerable code path is unreachable. No non-major fix + # exists (7.18.1 is the most-patched 7.x - it clears 14 other advisories that + # older 7.x carry - and the RSC fix landed only in the 8.3.0 major). react-router + # /-dom are pinned to 7.18.1 in package.json. If a non-major fix ships, this stops + # being exempt (major-only guard below) and the gate fails until we take it. + ALLOWLIST = {'GHSA-qwww-vcr4-c8h2'} + def advisory_ids(name, seen=None): + seen = seen if seen is not None else set() + if name in seen: + return set() + seen.add(name) + ids = set() + for item in vulns.get(name, {}).get('via', []): + if isinstance(item, dict): + url = item.get('url', '') + if '/advisories/' in url: + ids.add(url.rsplit('/', 1)[-1]) + elif isinstance(item, str): + ids |= advisory_ids(item, seen) + return ids + def fix_is_major(v): + fa = v.get('fixAvailable') + return isinstance(fa, dict) and fa.get('isSemVerMajor') + def exempt(name, v): + ids = advisory_ids(name) + return bool(ids) and ids <= ALLOWLIST and fix_is_major(v) fixable = {n: v for n, v in vulns.items() - if n in prod and v.get('severity') in ('high', 'critical') and v.get('fixAvailable')} + if n in prod and v.get('severity') in ('high', 'critical') + and v.get('fixAvailable') and not exempt(n, v)} skipped = len(vulns) - len({n: v for n, v in vulns.items() if n in prod}) if fixable: for name, v in fixable.items(): print(f'FIXABLE {v[\"severity\"].upper()}: {name}') sys.exit(1) total = sum(1 for n, v in vulns.items() if n in prod and v.get('severity') in ('high', 'critical')) + exempted = sorted(n for n, v in vulns.items() if n in prod and exempt(n, v)) print(f'npm audit: {total} high/critical (0 fixable), {len(vulns)} total ({skipped} npm-internal filtered)') + if exempted: + print('exempted (documented, unreachable): ' + ', '.join(exempted)) " frontend-typecheck: diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 9d7236a80..0a330a1c0 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -308,13 +308,46 @@ jobs: } } const vulns = results.vulnerabilities || {}; + // Documented advisory exceptions (keyed by GHSA id) - see ci.yml for the + // full rationale and the matching hard gate. GHSA-qwww-vcr4-c8h2: React + // Router RSC-mode CSRF, not reachable from Bambuddy's BrowserRouter SPA + // (@react-router/server not installed); react-router/-dom pinned to 7.18.1 + // (the most-patched 7.x), no non-major fix exists. Auto-surfaces again if a + // non-major fix ships. + const ALLOWLIST = new Set(['GHSA-qwww-vcr4-c8h2']); + function advisoryIds(name, seen) { + seen = seen || new Set(); + if (seen.has(name)) return new Set(); + seen.add(name); + const ids = new Set(); + for (const item of (vulns[name] || {}).via || []) { + if (item && typeof item === 'object') { + const url = item.url || ''; + if (url.includes('/advisories/')) ids.add(url.split('/').pop()); + } else if (typeof item === 'string') { + for (const id of advisoryIds(item, seen)) ids.add(id); + } + } + return ids; + } + function fixIsMajor(info) { + const fa = info.fixAvailable; + return fa && typeof fa === 'object' && fa.isSemVerMajor; + } + function exempt(name, info) { + const ids = advisoryIds(name); + return ids.size > 0 && [...ids].every(id => ALLOWLIST.has(id)) && fixIsMajor(info); + } const filtered = {}; + const flagged = {}; for (const [name, info] of Object.entries(vulns)) { - if (prodDeps.has(name)) filtered[name] = info; + if (!prodDeps.has(name)) continue; + filtered[name] = info; + if (!exempt(name, info)) flagged[name] = info; } results.vulnerabilities = filtered; fs.writeFileSync('npm-audit-results.json', JSON.stringify(results, null, 2)); - const count = Object.keys(filtered).length; + const count = Object.keys(flagged).length; console.log(count > 0 ? count + ' production vulnerabilities found' : 'No production vulnerabilities (filtered ' + Object.keys(vulns).length + ' npm-internal entries)'); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 360a106d0..c949c7e1e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -33,7 +33,7 @@ "react-dom": "^19.2.0", "react-i18next": "^16.3.5", "react-markdown": "^9.1.0", - "react-router-dom": "^7.16.0", + "react-router-dom": "7.18.1", "react-simple-keyboard": "^3.8.164", "recharts": "^3.5.1", "remark-gfm": "^4.0.1", @@ -3173,15 +3173,15 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "dev": true, "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/browserslist": { @@ -6481,9 +6481,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "dev": true, "funding": [ { @@ -6701,9 +6701,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "dev": true, "funding": [ { @@ -6720,7 +6720,7 @@ } ], "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, diff --git a/frontend/package.json b/frontend/package.json index 920f62e74..9f40df21d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -40,7 +40,7 @@ "react-dom": "^19.2.0", "react-i18next": "^16.3.5", "react-markdown": "^9.1.0", - "react-router-dom": "^7.16.0", + "react-router-dom": "7.18.1", "react-simple-keyboard": "^3.8.164", "recharts": "^3.5.1", "remark-gfm": "^4.0.1", @@ -48,8 +48,9 @@ }, "overrides": { "minimatch": "^10.2.1", - "brace-expansion": "^5.0.7", - "js-yaml": "^4.3.0" + "brace-expansion": "^5.0.8", + "js-yaml": "^4.3.0", + "react-router": "7.18.1" }, "devDependencies": { "@eslint/js": "^9.39.1",