dw-kit 1.3.4 → 1.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,742 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { banner, log, info, warn, ok, err } from '../lib/ui.mjs';
5
+ import chalk from 'chalk';
6
+ import {
7
+ loadSnapshot,
8
+ snapshotInfo,
9
+ syncSnapshotForProject,
10
+ isStale,
11
+ isSchemaCompatible,
12
+ fetchOsvByName,
13
+ } from '../lib/sc-sync.mjs';
14
+ import {
15
+ scanProject,
16
+ severityRank,
17
+ worstSeverity,
18
+ parsePackageJson,
19
+ parsePackageLockfile,
20
+ findLockfile,
21
+ findPackageJson,
22
+ matchPackageByName,
23
+ matchNamespaceFixture,
24
+ } from '../lib/sc-scanner.mjs';
25
+ import { logEvent } from '../lib/telemetry.mjs';
26
+ import { fetchPackageMetadata, extractSignals, fetchWeeklyDownloads } from '../lib/npm-registry.mjs';
27
+ import { diffLockfilePackages, scoreSignals, loadHeuristicConfig, formatHeuristicHit } from '../lib/sc-heuristic.mjs';
28
+
29
+ const TOOLKIT_ROOT = resolve(fileURLToPath(import.meta.url), '..', '..', '..');
30
+ const NAMESPACE_FIXTURE_REL = '.dw/security/ioc-namespaces.json';
31
+
32
+ export async function securityScanCommand(opts) {
33
+ const rootDir = process.cwd();
34
+ const mode = pickMode(opts);
35
+
36
+ if (mode === 'pre-install') {
37
+ return runPreInstallMode(rootDir, opts);
38
+ }
39
+
40
+ // Pillar 3 standalone path — hook fires with --heuristic-only for fast
41
+ // diff-only scan on lockfile edit. No OSV/fixture noise.
42
+ if (opts.heuristicOnly) {
43
+ return runHeuristicOnlyMode(rootDir, opts);
44
+ }
45
+
46
+ if (opts.json) {
47
+ return runJsonMode(rootDir, mode, opts);
48
+ }
49
+
50
+ banner('dw-kit Supply-Chain Scan');
51
+
52
+ if (mode === 'update-db' || opts.updateDb) {
53
+ info('Fetching fresh advisory snapshot from OSV.dev...');
54
+ try {
55
+ const start = Date.now();
56
+ const res = await syncSnapshotForProject(rootDir);
57
+ const elapsed = Date.now() - start;
58
+ const partialNote = res.partial ? ` (PARTIAL — ${res.chunks.failed}/${res.chunks.total} chunks failed)` : '';
59
+ ok(`Snapshot updated — ${res.advisoryCount} advisories for ${res.packageCount} packages (${elapsed}ms)${partialNote}`);
60
+ if (res.partial) {
61
+ warn(`Snapshot incomplete: chunks ${res.chunks.failed_indices.join(',')} failed after retries. Advisories may be missing; retry --update-db when network is healthy.`);
62
+ }
63
+ logEvent({
64
+ event: 'sc_guard',
65
+ action: 'sync',
66
+ source: 'osv',
67
+ advisories: res.advisoryCount,
68
+ packages: res.packageCount,
69
+ latency_ms: elapsed,
70
+ partial: !!res.partial,
71
+ chunks_total: res.chunks?.total ?? 1,
72
+ chunks_failed: res.chunks?.failed ?? 0,
73
+ }, rootDir);
74
+ } catch (e) {
75
+ err(`Sync failed: ${e.message}`);
76
+ if (e.code === 'NO_LOCKFILE') warn('Run `npm install` first to create package-lock.json.');
77
+ if (e.code === 'SYNC_ALL_CHUNKS_FAILED') warn('All OSV batches failed — likely a network or rate-limit issue. Re-run later.');
78
+ process.exit(2);
79
+ }
80
+ if (mode === 'update-db') {
81
+ log('');
82
+ info('Snapshot ready. Run `dw security-scan` to scan project.');
83
+ return;
84
+ }
85
+ log('');
86
+ }
87
+
88
+ // UX: lazy auto-refresh — if snapshot is missing OR stale, fetch fresh
89
+ // automatically (matches `npm audit` zero-friction UX). Skipped on --offline
90
+ // or --quick (explicit user request to stay offline).
91
+ let info_ = snapshotInfo(rootDir);
92
+ const shouldAutoRefresh = !opts.offline && !opts.quick && mode !== 'update-db' && (!info_.exists || info_.stale);
93
+ if (shouldAutoRefresh) {
94
+ const reason = !info_.exists ? 'no snapshot yet' : `snapshot ${info_.age_days.toFixed(1)}d old (>7d)`;
95
+ info(`Auto-syncing OSV snapshot (${reason})...`);
96
+ try {
97
+ const start = Date.now();
98
+ const res = await syncSnapshotForProject(rootDir);
99
+ const elapsed = Date.now() - start;
100
+ const partialNote = res.partial ? ` (PARTIAL ${res.chunks.failed}/${res.chunks.total})` : '';
101
+ ok(`Auto-sync done — ${res.advisoryCount} advisories for ${res.packageCount} packages (${elapsed}ms)${partialNote}`);
102
+ logEvent({
103
+ event: 'sc_guard', action: 'sync', source: 'osv',
104
+ advisories: res.advisoryCount, packages: res.packageCount, latency_ms: elapsed,
105
+ partial: !!res.partial, sub_mode: 'auto-refresh',
106
+ }, rootDir);
107
+ log('');
108
+ info_ = snapshotInfo(rootDir);
109
+ } catch (e) {
110
+ // Auto-refresh failure is NOT fatal — fall back to stale snapshot if available,
111
+ // or to pillars 2+3 only. Honest message to user.
112
+ warn(`Auto-sync failed: ${e.message}. Proceeding with available signals.`);
113
+ if (e.code === 'NO_LOCKFILE') {
114
+ // Caller-level no-lockfile handler kicks in below
115
+ }
116
+ }
117
+ }
118
+
119
+ if (!info_.exists) {
120
+ // Pillar 1 unavailable. Try to fall back to pre-install if no lockfile.
121
+ const pkgPath = findPackageJson(rootDir);
122
+ if (pkgPath) {
123
+ warn('No advisory snapshot AND no lockfile — falling back to pre-install scan (pillar 2 fixture + OSV name-only).');
124
+ log('');
125
+ return runPreInstallMode(rootDir, opts);
126
+ }
127
+ err('No advisory snapshot found and no package.json in current directory.');
128
+ log('Tip: run `dw scan --update-db` from a Node project, or use `dw scan` with --offline to skip pillar 1.');
129
+ process.exit(1);
130
+ }
131
+
132
+ const ageDays = info_.age_days;
133
+ const ageLabel = ageDays === Infinity ? 'unknown' : `${ageDays.toFixed(1)} days old`;
134
+ log(chalk.bold('Snapshot'));
135
+ log(` Source : ${info_.source} (${info_.ecosystem})`);
136
+ log(` Fetched : ${info_.fetched_at || '?'} (${ageLabel})`);
137
+ log(` Advisories : ${info_.advisory_count} Packages scanned previously: ${info_.package_count}`);
138
+ if (!info_.schema_compatible) {
139
+ err(` Schema mismatch — expected 1.0, got ${info_.schema_version || 'unknown'}`);
140
+ log(' Run `dw security-scan --update-db` to refresh.');
141
+ process.exit(2);
142
+ }
143
+ if (info_.stale) {
144
+ warn(` Snapshot is stale (>7 days). Run \`dw security-scan --update-db\` to refresh.`);
145
+ }
146
+ if (info_.partial) {
147
+ warn(` Snapshot is PARTIAL (${info_.chunks?.failed}/${info_.chunks?.total} chunks failed last sync). Results may be incomplete — re-run --update-db.`);
148
+ }
149
+
150
+ log('');
151
+ log(chalk.bold('Scanning project lockfile...'));
152
+ const snap = loadSnapshot(rootDir);
153
+ const start = Date.now();
154
+ const result = scanProject(rootDir, snap);
155
+
156
+ if (result.error === 'no_lockfile') {
157
+ // UX: auto-fallback to pre-install mode if package.json exists.
158
+ // User reported v1.3.5 blocked instead of degrading gracefully.
159
+ const pkgPath = findPackageJson(rootDir);
160
+ if (pkgPath) {
161
+ log('');
162
+ info('No lockfile yet (npm install not run) — switching to pre-install scan against package.json.');
163
+ log('');
164
+ return runPreInstallMode(rootDir, opts);
165
+ }
166
+ err('No lockfile and no package.json — this directory is not a Node project.');
167
+ process.exit(1);
168
+ }
169
+ if (result.error === 'no_snapshot') {
170
+ err('Snapshot data unavailable.');
171
+ process.exit(1);
172
+ }
173
+
174
+ // Pillar 2 (ADR-0006): fixture-driven catches in default scan path.
175
+ // Distinguishes 'source: fixture' from 'source: osv' for sunset-review integrity.
176
+ const fixtureBundle = loadNamespaceFixture(rootDir);
177
+ let fixtureHits = [];
178
+ if (fixtureBundle.fixture) {
179
+ try {
180
+ const lockPath = findLockfile(rootDir);
181
+ const lockPackages = lockPath ? parsePackageLockfile(lockPath) : new Map();
182
+ fixtureHits = matchNamespaceFixture(lockPackages, fixtureBundle.fixture);
183
+ } catch {
184
+ // fixture failure must not break OSV scan
185
+ }
186
+ }
187
+ const elapsed = Date.now() - start;
188
+
189
+ log(` Lockfile : ${result.lockfile}`);
190
+ log(` Packages scanned : ${result.packages_scanned}`);
191
+ if (fixtureBundle.fixture) {
192
+ const activeCount = (fixtureBundle.fixture.namespaces || []).filter(
193
+ (n) => !n.active_until || new Date(n.active_until) > new Date(),
194
+ ).length;
195
+ log(` Fixture : ${activeCount} active IoC pattern(s) (${fixtureBundle.path})`);
196
+ }
197
+ log(` Elapsed : ${elapsed}ms`);
198
+ log('');
199
+
200
+ // OSV matches first (existing display)
201
+ if (result.matches.length > 0) {
202
+ log(chalk.bold(`OSV advisory matches (${result.matches.length})`));
203
+ const sorted = result.matches.slice().sort((a, b) => severityRank(b.severity) - severityRank(a.severity));
204
+ for (const m of sorted) {
205
+ const tag = severityTag(m.severity);
206
+ log(` ${tag} ${m.package}@${m.version}`);
207
+ if (m.summary) log(chalk.dim(` ${m.summary}`));
208
+ log(chalk.dim(` advisory: ${m.advisory_id}`));
209
+ if (m.fix_versions.length) log(chalk.dim(` fix: ${m.fix_versions.join(', ')}`));
210
+ }
211
+ log('');
212
+ }
213
+
214
+ // Pillar 2: fixture hits (new section, prefixed [NS-IOC])
215
+ if (fixtureHits.length > 0) {
216
+ log(chalk.bold.red(`⚠️ ACTIVE INCIDENT IoC matches (${fixtureHits.length}) — curated fixture`));
217
+ const sortedHits = fixtureHits.slice().sort((a, b) => severityRank(b.severity) - severityRank(a.severity));
218
+ for (const h of sortedHits) {
219
+ const tag = severityTag(h.severity);
220
+ log(` ${tag} [NS-IOC] ${h.package}${h.version ? '@' + h.version : ''} matches "${h.namespace_pattern}"`);
221
+ if (h.reason) log(chalk.dim(` ${h.reason}`));
222
+ if (h.guidance) log(chalk.yellow(` guidance: ${h.guidance}`));
223
+ if (h.advisory_url) log(chalk.dim(` advisory: ${h.advisory_url}`));
224
+ if (h.version_check && h.version_check !== 'in-range') {
225
+ log(chalk.dim(` version_check: ${h.version_check}`));
226
+ }
227
+ }
228
+ log('');
229
+ }
230
+
231
+ // Pillar 3 (ADR-0006): NEW-package heuristic on diff. Auto-skip if offline
232
+ // or --no-heuristic. Runs AFTER pillars 1+2 so blocking signals still surface
233
+ // even if heuristic times out / network fails.
234
+ let heuristicHits = [];
235
+ if (!opts.offline && opts.heuristic !== false) {
236
+ try {
237
+ heuristicHits = await runHeuristicOnDiff(rootDir, { quiet: false });
238
+ } catch (e) {
239
+ warn(`Heuristic check failed: ${e.message} (non-blocking)`);
240
+ }
241
+ }
242
+
243
+ // Combined severity for exit code (heuristic blocks at score ≥80)
244
+ const osvBlockCount = result.matches.filter((m) => severityRank(m.severity) >= severityRank('high')).length;
245
+ const fixtureBlockCount = fixtureHits.filter((h) => severityRank(h.severity) >= severityRank('high')).length;
246
+ const heuristicBlockCount = heuristicHits.filter((h) => h.score >= 80).length;
247
+ const blockCount = osvBlockCount + fixtureBlockCount + heuristicBlockCount;
248
+ const exitCode = blockCount > 0 ? 2 : 1;
249
+ const worst = worstSeverity([...result.matches, ...fixtureHits]);
250
+
251
+ // Per-pillar telemetry — sunset metric needs to distinguish
252
+ if (result.matches.length > 0) {
253
+ logEvent(
254
+ {
255
+ event: 'sc_guard',
256
+ action: osvBlockCount > 0 ? 'block' : 'allow',
257
+ source: 'osv',
258
+ matches: result.matches.length,
259
+ block_count: osvBlockCount,
260
+ worst_severity: worstSeverity(result.matches),
261
+ packages: result.packages_scanned,
262
+ latency_ms: elapsed,
263
+ snapshot_age_days: ageDays,
264
+ partial_snapshot: info_.partial === true,
265
+ },
266
+ rootDir,
267
+ );
268
+ }
269
+ if (fixtureHits.length > 0) {
270
+ logEvent(
271
+ {
272
+ event: 'sc_guard',
273
+ action: fixtureBlockCount > 0 ? 'block' : 'allow',
274
+ source: 'fixture',
275
+ matches: fixtureHits.length,
276
+ block_count: fixtureBlockCount,
277
+ worst_severity: worstSeverity(fixtureHits),
278
+ packages: result.packages_scanned,
279
+ latency_ms: elapsed,
280
+ fixture_path: fixtureBundle.path,
281
+ },
282
+ rootDir,
283
+ );
284
+ }
285
+
286
+ if (heuristicHits.length > 0) {
287
+ logEvent({
288
+ event: 'sc_guard',
289
+ action: heuristicBlockCount > 0 ? 'block' : 'allow',
290
+ source: 'heuristic',
291
+ matches: heuristicHits.length,
292
+ block_count: heuristicBlockCount,
293
+ packages: result.packages_scanned,
294
+ latency_ms: elapsed,
295
+ }, rootDir);
296
+ }
297
+
298
+ const totalAllMatches = result.matches.length + fixtureHits.length + heuristicHits.length;
299
+ if (totalAllMatches === 0) {
300
+ ok('No advisory matches — clean (all 3 pillars).');
301
+ logEvent({
302
+ event: 'sc_guard',
303
+ action: 'scan_run',
304
+ source: 'osv+fixture+heuristic',
305
+ matches: 0,
306
+ packages: result.packages_scanned,
307
+ outcome: 'clean',
308
+ latency_ms: elapsed,
309
+ partial_snapshot: info_.partial === true,
310
+ }, rootDir);
311
+ log('');
312
+ advisoryFooter();
313
+ process.exit(0);
314
+ }
315
+ if (blockCount > 0) {
316
+ err(`${blockCount} HIGH-risk signal(s) — review before merging lockfile changes. (Worst: ${worst}${heuristicBlockCount > 0 ? `, +${heuristicBlockCount} heuristic` : ''})`);
317
+ } else {
318
+ warn(`${totalAllMatches} signal(s) — review recommended.`);
319
+ }
320
+ log('');
321
+ advisoryFooter();
322
+ process.exit(exitCode);
323
+ }
324
+
325
+ async function runHeuristicOnDiff(rootDir, { quiet = false } = {}) {
326
+ const config = loadHeuristicConfig(rootDir);
327
+ const diff = diffLockfilePackages(rootDir);
328
+
329
+ // Skip the cold-start path silently in the default scan flow — checking
330
+ // 1000+ packages on first install would slam npm registry. Cold start is
331
+ // only useful when explicitly invoked via --heuristic-only.
332
+ const candidates = diff.filter((p) => p.change === 'added' || p.change === 'bumped');
333
+ if (candidates.length === 0) {
334
+ if (!quiet) log(chalk.dim('Heuristic (pillar 3): no NEW/bumped packages since HEAD — nothing to probe.'));
335
+ return [];
336
+ }
337
+
338
+ if (!quiet) log(chalk.bold(`Heuristic (pillar 3) — probing ${candidates.length} NEW/bumped package(s)`));
339
+
340
+ const hits = [];
341
+ for (const pkg of candidates) {
342
+ let metadata;
343
+ try {
344
+ metadata = await fetchPackageMetadata(pkg.name, rootDir);
345
+ } catch (e) {
346
+ if (!quiet) log(chalk.dim(` · ${pkg.name}: metadata fetch failed (${e.message}) — skip`));
347
+ continue;
348
+ }
349
+ if (!metadata) {
350
+ if (!quiet) log(chalk.dim(` · ${pkg.name}: not found on registry — skip`));
351
+ continue;
352
+ }
353
+ const signals = extractSignals(metadata, pkg.version);
354
+ const downloads = await fetchWeeklyDownloads(pkg.name, rootDir).catch(() => null);
355
+ const scoring = scoreSignals(signals, downloads, config, { change: pkg.change, from: pkg.from });
356
+ if (scoring.score >= config.risk_threshold) {
357
+ hits.push({
358
+ package: pkg.name,
359
+ version: pkg.version,
360
+ change: pkg.change,
361
+ score: scoring.score,
362
+ reasons: scoring.reasons,
363
+ });
364
+ if (!quiet) {
365
+ const formatted = formatHeuristicHit(pkg.name, pkg.version, pkg.change, scoring);
366
+ const color = scoring.score >= 80 ? chalk.red : chalk.yellow;
367
+ log(color(formatted));
368
+ }
369
+ }
370
+ }
371
+ if (!quiet && hits.length === 0) {
372
+ log(chalk.dim(` · ${candidates.length} package(s) below risk_threshold=${config.risk_threshold} — no flags`));
373
+ }
374
+ return hits;
375
+ }
376
+
377
+ async function runHeuristicOnlyMode(rootDir, opts) {
378
+ const useJson = !!opts.json;
379
+ const hits = await runHeuristicOnDiff(rootDir, { quiet: useJson });
380
+ const blockCount = hits.filter((h) => h.score >= 80).length;
381
+ const exitCode = blockCount > 0 ? 2 : hits.length > 0 ? 1 : 0;
382
+ logEvent({
383
+ event: 'sc_guard',
384
+ action: blockCount > 0 ? 'block' : hits.length > 0 ? 'allow' : 'scan_run',
385
+ source: 'heuristic',
386
+ sub_mode: 'heuristic-only',
387
+ matches: hits.length,
388
+ block_count: blockCount,
389
+ }, rootDir);
390
+ if (useJson) {
391
+ process.stdout.write(JSON.stringify({ mode: 'heuristic-only', hits, exit_code: exitCode }) + '\n');
392
+ }
393
+ process.exit(exitCode);
394
+ }
395
+
396
+ function pickMode(opts) {
397
+ if (opts.preInstall) return 'pre-install';
398
+ if (opts.updateDb) return 'update-db';
399
+ return 'scan';
400
+ }
401
+
402
+ function loadNamespaceFixture(rootDir) {
403
+ // Prefer project-local fixture, fall back to toolkit-bundled
404
+ const candidates = [
405
+ join(rootDir, NAMESPACE_FIXTURE_REL),
406
+ join(TOOLKIT_ROOT, NAMESPACE_FIXTURE_REL),
407
+ ];
408
+ for (const p of candidates) {
409
+ if (existsSync(p)) {
410
+ try {
411
+ return { fixture: JSON.parse(readFileSync(p, 'utf-8')), path: p };
412
+ } catch {
413
+ // skip malformed
414
+ }
415
+ }
416
+ }
417
+ return { fixture: null, path: null };
418
+ }
419
+
420
+ async function runPreInstallMode(rootDir, opts) {
421
+ const useJson = !!opts.json;
422
+ const out = { mode: 'pre-install', ok: true, network_ok: false, packages: 0, osv_hits: [], namespace_hits: [] };
423
+
424
+ const pkgPath = findPackageJson(rootDir);
425
+ if (!pkgPath) {
426
+ if (useJson) {
427
+ out.ok = false;
428
+ out.error = { code: 'NO_PACKAGE_JSON', message: 'No package.json in current directory' };
429
+ process.stdout.write(JSON.stringify(out) + '\n');
430
+ process.exit(1);
431
+ }
432
+ err('No package.json in current directory.');
433
+ process.exit(1);
434
+ }
435
+
436
+ let packages;
437
+ try {
438
+ packages = parsePackageJson(pkgPath);
439
+ } catch (e) {
440
+ if (useJson) {
441
+ out.ok = false;
442
+ out.error = { code: 'PARSE_FAILED', message: e.message };
443
+ process.stdout.write(JSON.stringify(out) + '\n');
444
+ process.exit(1);
445
+ }
446
+ err(`Failed to parse package.json: ${e.message}`);
447
+ process.exit(1);
448
+ }
449
+
450
+ out.packages = packages.size;
451
+
452
+ if (!useJson) {
453
+ banner('dw-kit Pre-Install Scan');
454
+ log(chalk.bold('package.json'));
455
+ log(` Path : ${pkgPath}`);
456
+ log(` Declared : ${packages.size} packages (across deps/devDeps/peerDeps/optionalDeps)`);
457
+ log('');
458
+ }
459
+
460
+ // 1. Namespace fixture (offline, fast)
461
+ const fixtureBundle = loadNamespaceFixture(rootDir);
462
+ if (fixtureBundle.fixture) {
463
+ const hits = matchNamespaceFixture(packages, fixtureBundle.fixture);
464
+ out.namespace_hits = hits;
465
+ out.fixture_path = fixtureBundle.path;
466
+ if (!useJson) {
467
+ log(chalk.bold('Namespace IoC fixture'));
468
+ log(` Source : ${fixtureBundle.path}`);
469
+ log(` Entries : ${(fixtureBundle.fixture.namespaces || []).length} active namespace pattern(s)`);
470
+ if (hits.length === 0) {
471
+ ok(' No matches against active namespace patterns');
472
+ } else {
473
+ log('');
474
+ for (const h of hits) {
475
+ log(` ${severityTag(h.severity)} ${h.package} matches pattern "${h.namespace_pattern}"`);
476
+ if (h.reason) log(chalk.dim(` ${h.reason}`));
477
+ if (h.guidance) log(chalk.yellow(` guidance: ${h.guidance}`));
478
+ if (h.advisory_url) log(chalk.dim(` advisory: ${h.advisory_url}`));
479
+ }
480
+ }
481
+ log('');
482
+ }
483
+ }
484
+
485
+ // 2. OSV.dev name-only queries (network, optional)
486
+ if (!opts.offline) {
487
+ if (!useJson) log(chalk.bold('OSV.dev name-only query (per declared package)'));
488
+ const osvErrors = [];
489
+ let queried = 0;
490
+ for (const [name] of packages) {
491
+ try {
492
+ const result = await fetchOsvByName(name, 'npm', { timeoutMs: 5000 });
493
+ queried++;
494
+ const vulns = (result && result.vulns) || [];
495
+ if (vulns.length > 0) {
496
+ const hits = matchPackageByName(name, vulns);
497
+ for (const h of hits) {
498
+ out.osv_hits.push({ package: name, declared_range: packages.get(name), ...h });
499
+ }
500
+ }
501
+ } catch (e) {
502
+ osvErrors.push({ package: name, error: e.message });
503
+ }
504
+ }
505
+ out.network_ok = queried > 0;
506
+ out.osv_queried = queried;
507
+ out.osv_errors = osvErrors;
508
+
509
+ if (!useJson) {
510
+ log(` Queried : ${queried}/${packages.size} packages (${osvErrors.length} errors)`);
511
+ if (out.osv_hits.length === 0) {
512
+ ok(' No active advisories for declared packages');
513
+ } else {
514
+ const grouped = groupBy(out.osv_hits, 'package');
515
+ log('');
516
+ for (const [pkg, hits] of Object.entries(grouped)) {
517
+ const worst = hits.reduce(
518
+ (acc, h) => (severityRank(h.severity) > severityRank(acc) ? h.severity : acc),
519
+ 'unknown',
520
+ );
521
+ log(` ${severityTag(worst)} ${pkg}@${packages.get(pkg)} — ${hits.length} active advisory(s)`);
522
+ for (const h of hits.slice(0, 3)) {
523
+ log(chalk.dim(` ${h.advisory_id}: ${h.summary || '(no summary)'}`));
524
+ if (h.fix_versions && h.fix_versions.length) {
525
+ log(chalk.dim(` fix: ${h.fix_versions.slice(0, 3).join(', ')}`));
526
+ }
527
+ }
528
+ if (hits.length > 3) log(chalk.dim(` ... and ${hits.length - 3} more`));
529
+ }
530
+ }
531
+ log('');
532
+ }
533
+ } else if (!useJson) {
534
+ log(chalk.dim(' OSV.dev query: skipped (--offline)'));
535
+ log('');
536
+ }
537
+
538
+ // Summarize
539
+ const namespaceCrit = out.namespace_hits.length;
540
+ const osvHigh = out.osv_hits.filter((h) => severityRank(h.severity) >= severityRank('high')).length;
541
+ const exitCode = namespaceCrit > 0 ? 2 : (osvHigh > 0 ? 2 : (out.osv_hits.length > 0 ? 1 : 0));
542
+
543
+ // Distinguish what triggered the block — fixture catches must NOT be
544
+ // counted as OSV catches for the 2026-08-12 sunset review (see ADR-0005).
545
+ const blockSource = exitCode >= 2
546
+ ? (namespaceCrit > 0 && osvHigh > 0 ? 'fixture+osv' : namespaceCrit > 0 ? 'fixture' : 'osv')
547
+ : (out.osv_hits.length > 0 ? 'osv' : 'none');
548
+
549
+ logEvent(
550
+ {
551
+ event: 'sc_guard',
552
+ action: exitCode >= 2 ? 'block' : (exitCode === 1 ? 'allow' : 'scan_run'),
553
+ source: 'pre-install-mixed',
554
+ block_source: blockSource,
555
+ sub_mode: 'pre-install',
556
+ packages: packages.size,
557
+ namespace_hits: namespaceCrit,
558
+ osv_hits: out.osv_hits.length,
559
+ osv_high: osvHigh,
560
+ network_ok: out.network_ok,
561
+ },
562
+ rootDir,
563
+ );
564
+
565
+ if (useJson) {
566
+ out.summary = { namespace_hits: namespaceCrit, osv_hits: out.osv_hits.length, osv_high: osvHigh, exit_code: exitCode };
567
+ process.stdout.write(JSON.stringify(out) + '\n');
568
+ process.exit(exitCode);
569
+ }
570
+
571
+ log(chalk.bold('Summary'));
572
+ if (exitCode === 0) {
573
+ ok(`Clean — ${packages.size} packages scanned, no matches`);
574
+ } else if (exitCode === 1) {
575
+ warn(`${out.osv_hits.length} low/medium advisor(y/ies) found — review recommended`);
576
+ } else {
577
+ err(`${namespaceCrit} namespace match(es) + ${osvHigh} HIGH+ advisory(s) — rotate credentials if already installed`);
578
+ }
579
+ log('');
580
+ advisoryFooter();
581
+ process.exit(exitCode);
582
+ }
583
+
584
+ function groupBy(arr, key) {
585
+ const out = {};
586
+ for (const item of arr) {
587
+ const k = item[key];
588
+ if (!out[k]) out[k] = [];
589
+ out[k].push(item);
590
+ }
591
+ return out;
592
+ }
593
+
594
+ function severityTag(label) {
595
+ switch (label) {
596
+ case 'critical':
597
+ return chalk.red.bold('[CRITICAL]'.padEnd(12));
598
+ case 'high':
599
+ return chalk.red('[HIGH] '.padEnd(12));
600
+ case 'medium':
601
+ return chalk.yellow('[MEDIUM] '.padEnd(12));
602
+ case 'low':
603
+ return chalk.blue('[LOW] '.padEnd(12));
604
+ default:
605
+ return chalk.dim('[?] '.padEnd(12));
606
+ }
607
+ }
608
+
609
+ function advisoryFooter() {
610
+ info('ADVISORY OUTPUT — NOT a decision rule.');
611
+ log(' Threshold matrix in v14-evaluation-protocol.md is authoritative.');
612
+ log(' Use this scan as evidence-supplement only.');
613
+ log(' Public sunset commitment: 2026-08-12 (see ADR-0005).');
614
+ }
615
+
616
+ async function runJsonMode(rootDir, mode, opts) {
617
+ let out = { mode, ok: true };
618
+
619
+ if (mode === 'update-db' || opts.updateDb) {
620
+ try {
621
+ const start = Date.now();
622
+ const res = await syncSnapshotForProject(rootDir);
623
+ out.sync = {
624
+ advisory_count: res.advisoryCount,
625
+ package_count: res.packageCount,
626
+ latency_ms: Date.now() - start,
627
+ partial: !!res.partial,
628
+ chunks: res.chunks,
629
+ };
630
+ logEvent({
631
+ event: 'sc_guard',
632
+ action: 'sync',
633
+ source: 'osv',
634
+ advisories: res.advisoryCount,
635
+ packages: res.packageCount,
636
+ partial: !!res.partial,
637
+ chunks_total: res.chunks?.total ?? 1,
638
+ chunks_failed: res.chunks?.failed ?? 0,
639
+ }, rootDir);
640
+ } catch (e) {
641
+ out.ok = false;
642
+ out.error = { code: e.code || 'SYNC_FAILED', message: e.message };
643
+ process.stdout.write(JSON.stringify(out) + '\n');
644
+ process.exit(2);
645
+ }
646
+ if (mode === 'update-db') {
647
+ process.stdout.write(JSON.stringify(out) + '\n');
648
+ return;
649
+ }
650
+ }
651
+
652
+ const info_ = snapshotInfo(rootDir);
653
+ out.snapshot = info_;
654
+ if (!info_.exists) {
655
+ out.ok = false;
656
+ out.error = { code: 'NO_SNAPSHOT', message: 'Run `dw security-scan --update-db`' };
657
+ process.stdout.write(JSON.stringify(out) + '\n');
658
+ process.exit(1);
659
+ }
660
+
661
+ const snap = loadSnapshot(rootDir);
662
+ const start = Date.now();
663
+ const result = scanProject(rootDir, snap);
664
+
665
+ if (result.error) {
666
+ out.ok = false;
667
+ out.error = { code: result.error.toUpperCase(), message: result.error };
668
+ out.scan = { ...result, elapsed_ms: Date.now() - start };
669
+ process.stdout.write(JSON.stringify(out) + '\n');
670
+ process.exit(1);
671
+ }
672
+
673
+ // Pillar 2 (ADR-0006): fixture-driven catches in JSON mode too.
674
+ const fixtureBundle = loadNamespaceFixture(rootDir);
675
+ let fixtureHits = [];
676
+ if (fixtureBundle.fixture) {
677
+ try {
678
+ const lockPath = findLockfile(rootDir);
679
+ const lockPackages = lockPath ? parsePackageLockfile(lockPath) : new Map();
680
+ fixtureHits = matchNamespaceFixture(lockPackages, fixtureBundle.fixture);
681
+ } catch {
682
+ // ignore fixture failure in JSON mode
683
+ }
684
+ }
685
+ out.scan = { ...result, fixture_hits: fixtureHits, elapsed_ms: Date.now() - start };
686
+
687
+ const osvBlockCount = result.matches.filter((m) => severityRank(m.severity) >= severityRank('high')).length;
688
+ const fixtureBlockCount = fixtureHits.filter((h) => severityRank(h.severity) >= severityRank('high')).length;
689
+ const blockCount = osvBlockCount + fixtureBlockCount;
690
+ const totalMatches = result.matches.length + fixtureHits.length;
691
+ const worst = worstSeverity([...result.matches, ...fixtureHits]);
692
+ out.summary = {
693
+ matches: totalMatches,
694
+ osv_matches: result.matches.length,
695
+ fixture_hits: fixtureHits.length,
696
+ block_count: blockCount,
697
+ osv_block_count: osvBlockCount,
698
+ fixture_block_count: fixtureBlockCount,
699
+ worst_severity: worst,
700
+ };
701
+
702
+ if (result.matches.length > 0) {
703
+ logEvent({
704
+ event: 'sc_guard',
705
+ action: osvBlockCount > 0 ? 'block' : 'allow',
706
+ source: 'osv',
707
+ matches: result.matches.length,
708
+ block_count: osvBlockCount,
709
+ worst_severity: worstSeverity(result.matches),
710
+ packages: result.packages_scanned,
711
+ snapshot_age_days: info_.age_days,
712
+ partial_snapshot: info_.partial === true,
713
+ }, rootDir);
714
+ }
715
+ if (fixtureHits.length > 0) {
716
+ logEvent({
717
+ event: 'sc_guard',
718
+ action: fixtureBlockCount > 0 ? 'block' : 'allow',
719
+ source: 'fixture',
720
+ matches: fixtureHits.length,
721
+ block_count: fixtureBlockCount,
722
+ worst_severity: worstSeverity(fixtureHits),
723
+ packages: result.packages_scanned,
724
+ fixture_path: fixtureBundle.path,
725
+ }, rootDir);
726
+ }
727
+ if (totalMatches === 0) {
728
+ logEvent({
729
+ event: 'sc_guard',
730
+ action: 'scan_run',
731
+ source: 'osv+fixture',
732
+ matches: 0,
733
+ packages: result.packages_scanned,
734
+ outcome: 'clean',
735
+ snapshot_age_days: info_.age_days,
736
+ partial_snapshot: info_.partial === true,
737
+ }, rootDir);
738
+ }
739
+
740
+ process.stdout.write(JSON.stringify(out) + '\n');
741
+ process.exit(blockCount > 0 ? 2 : totalMatches > 0 ? 1 : 0);
742
+ }