dw-kit 1.3.5 → 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.
- package/.claude/hooks/supply-chain-scan.sh +16 -14
- package/.dw/security/advisory-snapshot.json +157 -0
- package/.dw/security/ioc-namespaces.json +20 -8
- package/CLAUDE.md +1 -1
- package/README.md +14 -2
- package/package.json +1 -1
- package/src/cli.mjs +5 -2
- package/src/commands/init.mjs +45 -1
- package/src/commands/security-scan.mjs +367 -52
- package/src/lib/gitignore.mjs +5 -1
- package/src/lib/npm-registry.mjs +159 -0
- package/src/lib/sc-heuristic.mjs +263 -0
- package/src/lib/sc-scanner.mjs +60 -11
- package/src/lib/sc-sync.mjs +98 -8
- package/src/lib/telemetry.mjs +20 -0
|
@@ -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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
127
|
-
|
|
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
|
-
|
|
131
|
-
{
|
|
286
|
+
if (heuristicHits.length > 0) {
|
|
287
|
+
logEvent({
|
|
132
288
|
event: 'sc_guard',
|
|
133
|
-
action:
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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
|
|
316
|
+
err(`${blockCount} HIGH-risk signal(s) — review before merging lockfile changes. (Worst: ${worst}${heuristicBlockCount > 0 ? `, +${heuristicBlockCount} heuristic` : ''})`);
|
|
146
317
|
} else {
|
|
147
|
-
warn(`${
|
|
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 = {
|
|
374
|
-
|
|
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
|
-
|
|
409
|
-
const
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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:
|
|
705
|
+
action: osvBlockCount > 0 ? 'block' : 'allow',
|
|
706
|
+
source: 'osv',
|
|
416
707
|
matches: result.matches.length,
|
|
417
|
-
block_count:
|
|
418
|
-
worst_severity:
|
|
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 :
|
|
741
|
+
process.exit(blockCount > 0 ? 2 : totalMatches > 0 ? 1 : 0);
|
|
427
742
|
}
|
package/src/lib/gitignore.mjs
CHANGED
|
@@ -53,7 +53,11 @@ function applyManagedBlock(content, blockLines) {
|
|
|
53
53
|
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
|
|
54
54
|
const before = content.slice(0, startIdx);
|
|
55
55
|
const after = content.slice(endIdx + MARKER_END.length);
|
|
56
|
-
|
|
56
|
+
// Collapse multiple consecutive blanks AND normalize trailing whitespace
|
|
57
|
+
// so re-running the helper is byte-identical (required by smoke idempotency test).
|
|
58
|
+
return (before + block + after)
|
|
59
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
60
|
+
.replace(/\n+$/, '\n');
|
|
57
61
|
}
|
|
58
62
|
|
|
59
63
|
// Markers not present — append block
|