dw-kit 1.3.5 → 1.4.0

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.
@@ -16,11 +16,15 @@ import {
16
16
  severityRank,
17
17
  worstSeverity,
18
18
  parsePackageJson,
19
+ parsePackageLockfile,
20
+ findLockfile,
19
21
  findPackageJson,
20
22
  matchPackageByName,
21
23
  matchNamespaceFixture,
22
24
  } from '../lib/sc-scanner.mjs';
23
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';
24
28
 
25
29
  const TOOLKIT_ROOT = resolve(fileURLToPath(import.meta.url), '..', '..', '..');
26
30
  const NAMESPACE_FIXTURE_REL = '.dw/security/ioc-namespaces.json';
@@ -33,6 +37,12 @@ export async function securityScanCommand(opts) {
33
37
  return runPreInstallMode(rootDir, opts);
34
38
  }
35
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
+
36
46
  if (opts.json) {
37
47
  return runJsonMode(rootDir, mode, opts);
38
48
  }
@@ -45,11 +55,26 @@ export async function securityScanCommand(opts) {
45
55
  const start = Date.now();
46
56
  const res = await syncSnapshotForProject(rootDir);
47
57
  const elapsed = Date.now() - start;
48
- ok(`Snapshot updated — ${res.advisoryCount} advisories for ${res.packageCount} packages (${elapsed}ms)`);
49
- logEvent({ event: 'sc_guard', action: 'sync', advisories: res.advisoryCount, packages: res.packageCount, latency_ms: elapsed }, rootDir);
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);
50
74
  } catch (e) {
51
75
  err(`Sync failed: ${e.message}`);
52
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.');
53
78
  process.exit(2);
54
79
  }
55
80
  if (mode === 'update-db') {
@@ -60,11 +85,47 @@ export async function securityScanCommand(opts) {
60
85
  log('');
61
86
  }
62
87
 
63
- const info_ = snapshotInfo(rootDir);
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
+
64
119
  if (!info_.exists) {
65
- warn('No advisory snapshot found.');
66
- log('Run `dw security-scan --update-db` to fetch from OSV.dev.');
67
- log('Quick mode requires an existing snapshot.');
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.');
68
129
  process.exit(1);
69
130
  }
70
131
 
@@ -82,16 +143,27 @@ export async function securityScanCommand(opts) {
82
143
  if (info_.stale) {
83
144
  warn(` Snapshot is stale (>7 days). Run \`dw security-scan --update-db\` to refresh.`);
84
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
+ }
85
149
 
86
150
  log('');
87
151
  log(chalk.bold('Scanning project lockfile...'));
88
152
  const snap = loadSnapshot(rootDir);
89
153
  const start = Date.now();
90
154
  const result = scanProject(rootDir, snap);
91
- const elapsed = Date.now() - start;
92
155
 
93
156
  if (result.error === 'no_lockfile') {
94
- err('No lockfile found (expected package-lock.json).');
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.');
95
167
  process.exit(1);
96
168
  }
97
169
  if (result.error === 'no_snapshot') {
@@ -99,58 +171,228 @@ export async function securityScanCommand(opts) {
99
171
  process.exit(1);
100
172
  }
101
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
+
102
189
  log(` Lockfile : ${result.lockfile}`);
103
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
+ }
104
197
  log(` Elapsed : ${elapsed}ms`);
105
198
  log('');
106
199
 
107
- if (result.matches.length === 0) {
108
- ok('No advisory matches clean.');
109
- logEvent({ event: 'sc_guard', action: 'scan_run', matches: 0, packages: result.packages_scanned, outcome: 'clean', latency_ms: elapsed }, rootDir);
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
+ }
110
211
  log('');
111
- advisoryFooter();
112
- return;
113
212
  }
114
213
 
115
- log(chalk.bold(`Matches (${result.matches.length})`));
116
- const sorted = result.matches.slice().sort((a, b) => severityRank(b.severity) - severityRank(a.severity));
117
- for (const m of sorted) {
118
- const tag = severityTag(m.severity);
119
- log(` ${tag} ${m.package}@${m.version}`);
120
- if (m.summary) log(chalk.dim(` ${m.summary}`));
121
- log(chalk.dim(` advisory: ${m.advisory_id}`));
122
- if (m.fix_versions.length) log(chalk.dim(` fix: ${m.fix_versions.join(', ')}`));
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('');
123
229
  }
124
- log('');
125
230
 
126
- const worst = worstSeverity(result.matches);
127
- const blockCount = result.matches.filter((m) => severityRank(m.severity) >= severityRank('high')).length;
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;
128
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
+ }
129
285
 
130
- logEvent(
131
- {
286
+ if (heuristicHits.length > 0) {
287
+ logEvent({
132
288
  event: 'sc_guard',
133
- action: blockCount > 0 ? 'block' : 'allow',
134
- matches: result.matches.length,
135
- block_count: blockCount,
136
- worst_severity: worst,
289
+ action: heuristicBlockCount > 0 ? 'block' : 'allow',
290
+ source: 'heuristic',
291
+ matches: heuristicHits.length,
292
+ block_count: heuristicBlockCount,
137
293
  packages: result.packages_scanned,
138
294
  latency_ms: elapsed,
139
- snapshot_age_days: ageDays,
140
- },
141
- rootDir,
142
- );
295
+ }, rootDir);
296
+ }
143
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
+ }
144
315
  if (blockCount > 0) {
145
- err(`${blockCount} HIGH+ severity match(es) — review before merging lockfile changes.`);
316
+ err(`${blockCount} HIGH-risk signal(s) — review before merging lockfile changes. (Worst: ${worst}${heuristicBlockCount > 0 ? `, +${heuristicBlockCount} heuristic` : ''})`);
146
317
  } else {
147
- warn(`${result.matches.length} low/medium match(es) — review recommended.`);
318
+ warn(`${totalAllMatches} signal(s) — review recommended.`);
148
319
  }
149
320
  log('');
150
321
  advisoryFooter();
151
322
  process.exit(exitCode);
152
323
  }
153
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
+
154
396
  function pickMode(opts) {
155
397
  if (opts.preInstall) return 'pre-install';
156
398
  if (opts.updateDb) return 'update-db';
@@ -298,10 +540,18 @@ async function runPreInstallMode(rootDir, opts) {
298
540
  const osvHigh = out.osv_hits.filter((h) => severityRank(h.severity) >= severityRank('high')).length;
299
541
  const exitCode = namespaceCrit > 0 ? 2 : (osvHigh > 0 ? 2 : (out.osv_hits.length > 0 ? 1 : 0));
300
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
+
301
549
  logEvent(
302
550
  {
303
551
  event: 'sc_guard',
304
552
  action: exitCode >= 2 ? 'block' : (exitCode === 1 ? 'allow' : 'scan_run'),
553
+ source: 'pre-install-mixed',
554
+ block_source: blockSource,
305
555
  sub_mode: 'pre-install',
306
556
  packages: packages.size,
307
557
  namespace_hits: namespaceCrit,
@@ -370,8 +620,23 @@ async function runJsonMode(rootDir, mode, opts) {
370
620
  try {
371
621
  const start = Date.now();
372
622
  const res = await syncSnapshotForProject(rootDir);
373
- out.sync = { advisory_count: res.advisoryCount, package_count: res.packageCount, latency_ms: Date.now() - start };
374
- logEvent({ event: 'sc_guard', action: 'sync', advisories: res.advisoryCount, packages: res.packageCount }, 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);
375
640
  } catch (e) {
376
641
  out.ok = false;
377
642
  out.error = { code: e.code || 'SYNC_FAILED', message: e.message };
@@ -396,32 +661,82 @@ async function runJsonMode(rootDir, mode, opts) {
396
661
  const snap = loadSnapshot(rootDir);
397
662
  const start = Date.now();
398
663
  const result = scanProject(rootDir, snap);
399
- out.scan = { ...result, elapsed_ms: Date.now() - start };
400
664
 
401
665
  if (result.error) {
402
666
  out.ok = false;
403
667
  out.error = { code: result.error.toUpperCase(), message: result.error };
668
+ out.scan = { ...result, elapsed_ms: Date.now() - start };
404
669
  process.stdout.write(JSON.stringify(out) + '\n');
405
670
  process.exit(1);
406
671
  }
407
672
 
408
- const worst = worstSeverity(result.matches);
409
- const blockCount = result.matches.filter((m) => severityRank(m.severity) >= severityRank('high')).length;
410
- out.summary = { matches: result.matches.length, block_count: blockCount, worst_severity: worst };
411
-
412
- logEvent(
413
- {
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({
414
704
  event: 'sc_guard',
415
- action: blockCount > 0 ? 'block' : (result.matches.length > 0 ? 'allow' : 'scan_run'),
705
+ action: osvBlockCount > 0 ? 'block' : 'allow',
706
+ source: 'osv',
416
707
  matches: result.matches.length,
417
- block_count: blockCount,
418
- worst_severity: worst,
708
+ block_count: osvBlockCount,
709
+ worst_severity: worstSeverity(result.matches),
419
710
  packages: result.packages_scanned,
420
711
  snapshot_age_days: info_.age_days,
421
- },
422
- rootDir,
423
- );
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
+ }
424
739
 
425
740
  process.stdout.write(JSON.stringify(out) + '\n');
426
- process.exit(blockCount > 0 ? 2 : result.matches.length > 0 ? 1 : 0);
741
+ process.exit(blockCount > 0 ? 2 : totalMatches > 0 ? 1 : 0);
427
742
  }