Files
Dragan Spiridonov 6dcb919aee feat(deps): dependency-security system + fix grpc-js HIGH (ADR-115)
Closes the gap that let @grpc/grpc-js@1.14.3 (CVE-2026-48068/48069, HIGH)
sit undetected: every existing dependency check only fires on dep-file
changes, so a CVE disclosed against a frozen pin was invisible.

Adds:
- scripts/dependency-audit.mjs    in-repo + consumer-POV audit, severity gate
- scripts/dependency-update.mjs   security-first in-range updater with a guard
                                  that refuses to drop optional @ruvector
                                  platform deps (the Dependabot lockfile bug)
- dependency-audit.yml            daily consumer-POV scan -> tracking issue
- dependency-update.yml           weekly in-range security PR (gh CLI)
- docs/guides/dependency-security-policy.md + ADR-115
- tests/unit/scripts/dependency-security.test.ts (11 hermetic tests)
- npm scripts: deps:audit[:consumer], deps:update[:all]

Also bumps @grpc/grpc-js 1.14.3 -> 1.14.4 in-range (HIGH 1 -> 0); optional
platform deps preserved (90 -> 90).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 12:18:21 +00:00

165 lines
7.6 KiB
JavaScript

#!/usr/bin/env node
// =============================================================================
// Dependency Update (ADR-115) — controlled, in-range lockfile refresh.
//
// Why a custom updater instead of Dependabot/Renovate:
// This repo declares 15 optionalDependencies — the @ruvector/* native
// binaries (per-platform), hnswlib-node, rvlite. Dependabot regenerates the
// lockfile on the runner's single platform and PRUNES the optional entries
// for every OTHER platform. The pruned lock then fails `npm ci` with EUSAGE
// on those platforms (this broke a real install). So the off-the-shelf bot is
// actively wrong for us. (See: dependabot-lockfile-drops-optional-deps note.)
//
// Two modes (both in-range only — never --force, never a major bump):
// (default) Security-targeted: `npm audit fix --package-lock-only`. Touches
// ONLY packages with a known advisory, applying the in-range fix
// (the @grpc/grpc-js 1.14.3 -> 1.14.4 case). Small, reviewable,
// security-relevant diffs — safe to automate into a PR.
// --all Lockfile maintenance: `npm update --package-lock-only`. Refreshes
// every dependency to its newest in-range version. Much broader
// churn (loose-range transitives can still cross majors), so this
// is opt-in for a human-reviewed periodic sweep, not the bot.
//
// What this does:
// 1. Snapshot the pre-update lockfile + the set of optional platform entries.
// 2. Run the chosen update command (in-range only).
// 3. GUARD: assert no optional platform dep was dropped. If any vanished, the
// lockfile is corrupt for cross-platform `npm ci` — restore from git and
// fail loudly rather than open a lock-breaking PR.
// 4. Diff versions + run an in-repo audit before/after, and report the delta.
//
// Writes reports/dependency-update.{json,md}. Exits 0 whether or not there were
// changes (the workflow opens a PR only when package-lock.json actually moved);
// exits 1 only on the optional-dep guard tripping.
// =============================================================================
import { execFileSync } from 'node:child_process';
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { runRepoAudit } from './dependency-audit.mjs';
const ROOT = resolve(process.cwd());
const LOCK = join(ROOT, 'package-lock.json');
function run(cmd, args) {
try { return { stdout: execFileSync(cmd, args, { cwd: ROOT, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }), status: 0 }; }
catch (e) { return { stdout: (e.stdout ?? '').toString(), status: e.status ?? 1, stderr: (e.stderr ?? '').toString() }; }
}
/** Map of `node_modules/...` path -> resolved version, from a lockfile string. */
export function lockVersions(lockText) {
const lock = JSON.parse(lockText);
const out = new Map();
for (const [path, node] of Object.entries(lock.packages ?? {})) {
if (path && node?.version) out.set(path, node.version);
}
return out;
}
/** The optional platform deps that Dependabot prunes — our guard set. */
function optionalGuardSet() {
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'));
return new Set(Object.keys(pkg.optionalDependencies ?? {}));
}
/** Which guard-set packages have a lockfile entry (any node_modules/... path). */
export function presentOptionals(versions, guard) {
const present = new Set();
for (const path of versions.keys()) {
for (const name of guard) {
if (path === `node_modules/${name}` || path.endsWith(`/node_modules/${name}`)) present.add(name);
}
}
return present;
}
function summariseAudit(a) {
const t = a.totals ?? {};
return { critical: t.critical ?? 0, high: t.high ?? 0, moderate: t.moderate ?? 0, low: t.low ?? 0 };
}
function main() {
const all = process.argv.includes('--all');
const mode = all ? 'maintenance' : 'security';
const guard = optionalGuardSet();
const beforeText = readFileSync(LOCK, 'utf8');
const beforeVersions = lockVersions(beforeText);
const beforeOptionals = presentOptionals(beforeVersions, guard);
const auditBefore = summariseAudit(runRepoAudit());
// Security default fixes only vulnerable packages in-range; --all is a full
// in-range lockfile refresh. Neither uses --force (no major bumps).
const cmd = all ? ['update', '--package-lock-only'] : ['audit', 'fix', '--package-lock-only'];
console.log(`[deps:update] mode=${mode} — running \`npm ${cmd.join(' ')}\`...`);
const upd = run('npm', cmd);
if (upd.status !== 0) console.error('[deps:update] npm reported:', upd.stderr?.trim());
const afterText = readFileSync(LOCK, 'utf8');
const afterVersions = lockVersions(afterText);
const afterOptionals = presentOptionals(afterVersions, guard);
// GUARD: any optional platform dep dropped => cross-platform `npm ci` breakage.
const dropped = [...beforeOptionals].filter((n) => !afterOptionals.has(n));
if (dropped.length) {
console.error(`[deps:update] GUARD TRIPPED — optional platform deps pruned: ${dropped.join(', ')}`);
console.error('[deps:update] restoring package-lock.json from git; not proposing a lock-breaking update.');
run('git', ['checkout', '--', 'package-lock.json']);
process.exit(1);
}
// Diff resolved versions (dedupe by package name + version pair).
const changes = [];
const seen = new Set();
for (const [path, after] of afterVersions) {
const before = beforeVersions.get(path);
if (before && before !== after) {
const name = path.split('node_modules/').pop();
const key = `${name}@${before}->${after}`;
if (!seen.has(key)) { seen.add(key); changes.push({ name, from: before, to: after }); }
}
}
changes.sort((a, b) => a.name.localeCompare(b.name));
const auditAfter = summariseAudit(runRepoAudit());
const changed = beforeText !== afterText;
const report = {
generatedAt: new Date().toISOString(),
mode,
changed,
optionalDepsPreserved: true,
optionalDepsTracked: [...guard].length,
changes,
audit: { before: auditBefore, after: auditAfter },
};
mkdirSync(join(ROOT, 'reports'), { recursive: true });
writeFileSync(join(ROOT, 'reports', 'dependency-update.json'), JSON.stringify(report, null, 2));
const md = [];
md.push(`# Automated in-range dependency update (${mode})`);
md.push('');
md.push(changed
? `Updated **${changes.length}** package(s) within existing semver ranges (\`${mode}\` mode).`
: `No in-range ${mode} updates available — lockfile unchanged.`);
md.push('');
md.push(`Audit (in-repo): high ${auditBefore.high} → ${auditAfter.high}, ` +
`critical ${auditBefore.critical} → ${auditAfter.critical}, moderate ${auditBefore.moderate} → ${auditAfter.moderate}`);
md.push(`Optional platform deps preserved: ✅ (${[...guard].length} tracked, 0 dropped)`);
if (changes.length) {
md.push('');
md.push('| Package | From | To |');
md.push('|---|---|---|');
for (const c of changes) md.push(`| \`${c.name}\` | ${c.from} | ${c.to} |`);
}
md.push('');
md.push('> In-range only (no major bumps). Generated by `scripts/dependency-update.mjs` per ADR-115.');
writeFileSync(join(ROOT, 'reports', 'dependency-update.md'), md.join('\n') + '\n');
console.log(`[deps:update] ${changed ? `${changes.length} package(s) updated` : 'no changes'}; ` +
`high ${auditBefore.high}->${auditAfter.high}, critical ${auditBefore.critical}->${auditAfter.critical}; ` +
`optional deps preserved (${[...guard].length} tracked).`);
}
// Run as CLI; stay importable (without shelling out to npm) for unit tests.
if (import.meta.url === `file://${process.argv[1]}`) main();