supply-chain-attack 0.1.1 → 0.1.7

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/lib/cli.js ADDED
@@ -0,0 +1,601 @@
1
+ 'use strict';
2
+
3
+ const readline = require('node:readline/promises');
4
+ const { advisories, SNAPSHOT_DATE, flattenAdvisories } = require('./advisories');
5
+ const { scanMachine } = require('./scanner');
6
+
7
+ const APP_NAME = 'supply-chain-attack';
8
+ const COPY = {
9
+ noFindingTitle: 'Summary',
10
+ noFindingBody: 'No known malicious package versions were found in the scanned package-manager state. This is not a complete malware assessment.',
11
+ findingsTitle: 'Matched packages',
12
+ promptPrefix: 'options',
13
+ findingVerdict: 'Potential supply-chain exposure detected',
14
+ cleanVerdict: 'No known bad packages found',
15
+ educationTitle: 'Learn: how to read this scan',
16
+ noMatchExplanation: 'No known malicious package versions matched the embedded advisory snapshot. This means the scanned package-manager state did not contain the specific versions currently known to this tool.',
17
+ noGuarantee: 'It does not prove this machine is malware-free. Supply-chain attacks evolve quickly, and local package-manager state can be incomplete.',
18
+ iocWarning: 'These file names and contents resemble persistence or credential-exfiltration indicators from recent supply-chain campaigns. Inspect them before deleting so you understand what changed.',
19
+ nextActionsTitle: 'Recommended next actions',
20
+ };
21
+ const DEFAULT_COLOR = process.env.NO_COLOR ? false : process.stdout.isTTY !== false;
22
+ const SPINNER_FRAMES = ['-', '\\', '|', '/'];
23
+ const SPINNER_MESSAGES = [
24
+ 'scanning local package-manager state',
25
+ 'checking package-manager caches',
26
+ 'checking global installs',
27
+ 'checking for poisoned CLIs',
28
+ 'checking known risky package versions',
29
+ 'checking suspicious local files',
30
+ ];
31
+
32
+ const SEVERITY_RANK = {
33
+ critical: 5,
34
+ high: 4,
35
+ medium: 3,
36
+ moderate: 3,
37
+ low: 2,
38
+ info: 1,
39
+ unknown: 0,
40
+ };
41
+
42
+ function parseArgs(argv) {
43
+ const args = {
44
+ json: false,
45
+ failOn: 'findings',
46
+ listAdvisories: false,
47
+ color: DEFAULT_COLOR,
48
+ interactive: true,
49
+ };
50
+
51
+ for (let index = 0; index < argv.length; index += 1) {
52
+ const arg = argv[index];
53
+ if (arg === '--help' || arg === '-h') args.help = true;
54
+ else if (arg === '--json') args.json = true;
55
+ else if (arg === '--no-color') args.color = false;
56
+ else if (arg === '--color') args.color = true;
57
+ else if (arg === '--no-interactive') args.interactive = false;
58
+ else if (arg === '--interactive') args.interactive = true;
59
+ else if (arg === '--list-advisories') args.listAdvisories = true;
60
+ else if (arg === '--fail-on') args.failOn = argv[++index] || 'findings';
61
+ else if (arg.startsWith('--fail-on=')) args.failOn = arg.slice('--fail-on='.length);
62
+ else if (arg.startsWith('-')) throw new Error(`unknown option: ${arg}`);
63
+ else throw new Error(`path arguments are not supported; ${APP_NAME} always scans this machine`);
64
+ }
65
+
66
+ if (!['findings', 'none'].includes(args.failOn)) {
67
+ throw new Error('--fail-on must be "findings" or "none"');
68
+ }
69
+ return args;
70
+ }
71
+
72
+ async function main(argv) {
73
+ const args = parseArgs(argv);
74
+ if (args.help) {
75
+ process.stdout.write(helpText());
76
+ return;
77
+ }
78
+
79
+ if (args.listAdvisories) {
80
+ const rows = flattenAdvisories();
81
+ const payload = {
82
+ snapshotDate: SNAPSHOT_DATE,
83
+ advisoryCount: advisories.length,
84
+ artifactCount: rows.length,
85
+ advisories,
86
+ };
87
+ process.stdout.write(args.json ? `${JSON.stringify(payload, null, 2)}\n` : formatAdvisoryList(payload, args));
88
+ return;
89
+ }
90
+
91
+ const spinner = createSpinner(args);
92
+ spinner.start();
93
+ let result;
94
+ try {
95
+ result = await scanMachine();
96
+ spinner.stop();
97
+ } catch (error) {
98
+ spinner.stop();
99
+ throw error;
100
+ }
101
+
102
+ process.stdout.write(args.json ? `${JSON.stringify(result, null, 2)}\n` : formatResult(result, args));
103
+ if (!args.json) await runInteractive(result, args);
104
+
105
+ const hasFindings = result.findings.length > 0 || result.iocs.length > 0;
106
+ if (args.failOn === 'findings' && hasFindings) {
107
+ process.exitCode = 1;
108
+ }
109
+ }
110
+
111
+ function formatResult(result, options = {}) {
112
+ const color = createColor(options.color);
113
+ const hasFindings = hasAnyFindings(result);
114
+ const lines = [];
115
+
116
+ lines.push(formatVerdictHeader(result, options));
117
+ lines.push('');
118
+
119
+ if (!hasFindings) {
120
+ lines.push(color.bold(COPY.noFindingTitle));
121
+ lines.push(COPY.noFindingBody);
122
+ lines.push('');
123
+ lines.push(`${color.dim('scan')} ${result.locations.length} store(s), ${result.packages.length} package/version pair(s), snapshot ${result.snapshotDate}`);
124
+ return `${lines.join('\n')}\n`;
125
+ }
126
+
127
+ lines.push(color.red(color.bold(COPY.findingsTitle)));
128
+ for (const finding of result.findings.slice(0, 8)) {
129
+ const locations = finding.locations.length ? formatCompactList(finding.locations, 3) : 'unknown location';
130
+ lines.push(`- ${color.dim(finding.ecosystem)} ${color.yellow(color.bold(`${finding.name}@${finding.version}`))} ${color.dim('(')}${color.cyan(locations)}${color.dim(')')}`);
131
+ }
132
+ if (result.findings.length > 8) lines.push(`- ...and ${pluralize(result.findings.length - 8, 'more package hit')}. Run with --json for raw evidence.`);
133
+
134
+ if (result.iocs.length) {
135
+ lines.push('');
136
+ lines.push(color.bold('Suspicious local files'));
137
+ for (const ioc of result.iocs.slice(0, 5)) {
138
+ lines.push(`- ${color.red('IOC')} ${color.bold(ioc.path)} — ${ioc.reason}`);
139
+ }
140
+ if (result.iocs.length > 5) lines.push(`- ...and ${pluralize(result.iocs.length - 5, 'more suspicious file')}.`);
141
+ }
142
+
143
+ lines.push('');
144
+ lines.push(`${color.dim('scan')} ${result.locations.length} store(s), ${result.packages.length} package/version pair(s), snapshot ${result.snapshotDate}`);
145
+ if (!interactiveCanRun(options)) {
146
+ lines.push(`${color.dim('tip')} run in a real terminal for the interactive menu, or use --json for raw paths.`);
147
+ }
148
+
149
+ return `${lines.join('\n')}\n`;
150
+ }
151
+
152
+ async function runInteractive(result, options = {}) {
153
+ if (!interactiveCanRun(options)) return;
154
+
155
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
156
+ try {
157
+ process.stdout.write('\n');
158
+ while (true) {
159
+ const answer = (await rl.question(formatPrompt(options))).trim().toLowerCase();
160
+ if (!answer || answer === 'q' || answer === 'quit' || answer === 'exit') {
161
+ process.stdout.write('Done. Stay safe.\n');
162
+ break;
163
+ }
164
+
165
+ if (answer === '1' || answer === 'l' || answer === 'e' || answer.includes('learn') || answer.includes('educate') || answer.includes('explain')) {
166
+ process.stdout.write(`\n${formatEducation(result, options)}\n`);
167
+ continue;
168
+ }
169
+
170
+ if (answer === '2' || answer === 'a' || answer.includes('action') || answer.includes('next')) {
171
+ process.stdout.write(`\n${formatNextActions(result, options)}\n`);
172
+ continue;
173
+ }
174
+
175
+ process.stdout.write('Try l, a, or q.\n');
176
+ }
177
+ } finally {
178
+ rl.close();
179
+ }
180
+ }
181
+
182
+ function formatPrompt(options = {}) {
183
+ const color = createColor(options.color);
184
+ return [
185
+ color.dim(COPY.promptPrefix),
186
+ `${color.cyan('l')} learn`,
187
+ `${color.cyan('a')} actions`,
188
+ `${color.dim('q quit')}`,
189
+ color.bold('› '),
190
+ ].join(' ');
191
+ }
192
+
193
+ function formatVerdictHeader(result, options = {}) {
194
+ const color = createColor(options.color);
195
+ const hasFindings = hasAnyFindings(result);
196
+ const findingCount = result.findings.length;
197
+ const iocCount = result.iocs.length;
198
+ const countLabel = hasFindings
199
+ ? `${pluralize(findingCount, 'package hit')}${iocCount ? ` + ${pluralize(iocCount, 'suspicious file')}` : ''}`
200
+ : '0 known bad hits';
201
+
202
+ const verdict = hasFindings
203
+ ? `${color.dim('Verdict:')} ${color.red(color.bold(COPY.findingVerdict))} — ${color.yellow(color.bold(countLabel))}`
204
+ : `${color.dim('Verdict:')} ${color.green(color.bold(COPY.cleanVerdict))} — ${color.green(countLabel)}`;
205
+
206
+ return verdict;
207
+ }
208
+
209
+ function formatEducation(result, options = {}) {
210
+ const color = createColor(options.color);
211
+ const lines = [color.bold(COPY.educationTitle)];
212
+
213
+ if (!hasAnyFindings(result)) {
214
+ lines.push(COPY.noMatchExplanation);
215
+ lines.push(COPY.noGuarantee);
216
+ lines.push('How to read this: a clean result lowers concern for the specific campaigns in this snapshot, but it does not audit every dependency, shell history entry, CI run, browser extension, or secret on the machine.');
217
+ lines.push('The good news: this scan is offline and privacy-safe. Your package list was not uploaded anywhere.');
218
+ return `${lines.join('\n')}\n`;
219
+ }
220
+
221
+ lines.push('This is not a normal vulnerability finding. Supply-chain malware is dangerous because install scripts, package CLIs, and postinstall hooks can run before your app imports anything. The important question is: was the package merely cached, or did attacker-controlled code likely run in a place with secrets?');
222
+
223
+ const groups = groupFindingsByCampaign(result.findings);
224
+ for (const group of groups) {
225
+ const packages = formatCompactList(group.findings.map((finding) => `${finding.name}@${finding.version}`), 7);
226
+ const locations = formatCompactList(group.findings.flatMap((finding) => finding.locations || []), 5) || 'unknown location';
227
+ const receipts = formatCompactList(group.advisories.map((advisory) => advisory.source), 3);
228
+ const summaries = formatCompactList(group.advisories.map((advisory) => advisory.summary), 2);
229
+
230
+ lines.push('');
231
+ lines.push(color.bold(`${group.name} attack`));
232
+ if (summaries) lines.push(`What was reported: ${summaries}`);
233
+ lines.push('Attack chain:');
234
+ for (const step of attackExplanation(group)) lines.push(`- ${step}`);
235
+ lines.push(`Evidence on this machine: ${packages} showed up in ${locations}.`);
236
+ lines.push(`Risk read: ${riskSentence(group.findings)}`);
237
+ lines.push(`How to interpret the location: ${locationMeaning(group.findings)}`);
238
+ lines.push('Why this matters: developer machines and CI usually contain npm/GitHub/cloud/deploy/AI-provider credentials. Malware in a devtool can turn one bad install into stolen tokens, poisoned packages, or compromised deployments.');
239
+ if (receipts) lines.push(`Receipts: ${receipts}`);
240
+ }
241
+
242
+ if (result.iocs.length) {
243
+ lines.push('');
244
+ lines.push(`${color.red('IOC')} ${color.bold('Suspicious local-file indicators')}`);
245
+ lines.push(COPY.iocWarning);
246
+ }
247
+
248
+ return `${lines.join('\n')}\n`;
249
+ }
250
+
251
+ function formatNextActions(result, options = {}) {
252
+ const color = createColor(options.color);
253
+ const lines = [color.bold(COPY.nextActionsTitle)];
254
+
255
+ lines.push('');
256
+ lines.push('```text');
257
+ lines.push(...buildAgentPrompt(result));
258
+ lines.push('```');
259
+
260
+ return `${lines.join('\n')}\n`;
261
+ }
262
+
263
+ function buildAgentPrompt(result) {
264
+ const lines = [];
265
+
266
+ if (!hasAnyFindings(result)) {
267
+ lines.push(`I ran \`npx ${APP_NAME}\` on my dev machine. It found 0 known malware-linked package hits in ${result.packages.length} package/version pairs.`);
268
+ lines.push('');
269
+ lines.push('Please help me keep this project safer:');
270
+ lines.push(`1. Add or document a lightweight supply-chain check: \`npx ${APP_NAME} --fail-on findings\`.`);
271
+ lines.push('2. Verify lockfiles are committed and package-manager versions are pinned where practical.');
272
+ lines.push('3. Warn me before adding one-off npx/global CLI installs.');
273
+ lines.push('4. Do not print, store, or ask for secrets.');
274
+ return lines;
275
+ }
276
+
277
+ const labels = new Set(result.findings.flatMap((finding) => finding.locations || []));
278
+ const riskyExecution = Array.from(labels).some((label) => /global|_npx|node_modules|python|pipx/i.test(label));
279
+ const commands = cleanupCommands(labels);
280
+
281
+ lines.push(`I ran \`npx ${APP_NAME}\` on my dev machine and it flagged possible supply-chain malware exposure.`);
282
+ lines.push('');
283
+ lines.push('Scan summary:');
284
+ lines.push(`- Worst severity: ${worstSeverity(result)}`);
285
+ lines.push(`- Campaigns: ${campaignSummary(result, 5)}`);
286
+ lines.push(`- Risk read: ${riskyExecution ? 'global/npx/CLI-style hit; package code may have installed or run' : 'cache/store hit; package was fetched or stored, execution not proven'}`);
287
+ lines.push('');
288
+ lines.push('Flagged packages/locations:');
289
+ for (const finding of result.findings.slice(0, 12)) {
290
+ const locations = formatCompactList(finding.locations || [], 4) || 'unknown location';
291
+ lines.push(`- ${finding.ecosystem} ${finding.name}@${finding.version} (${campaignName(finding.advisory)}; ${locations})`);
292
+ }
293
+ if (result.findings.length > 12) lines.push(`- ...plus ${pluralize(result.findings.length - 12, 'more package hit')}`);
294
+
295
+ if (result.iocs.length) {
296
+ lines.push('');
297
+ lines.push('Suspicious local files:');
298
+ for (const ioc of result.iocs.slice(0, 8)) lines.push(`- ${ioc.path}: ${ioc.reason}`);
299
+ if (result.iocs.length > 8) lines.push(`- ...plus ${pluralize(result.iocs.length - 8, 'more suspicious file')}`);
300
+ }
301
+
302
+ lines.push('');
303
+ lines.push('Please help me clean this safely:');
304
+ lines.push('1. Search this repo/workspace lockfiles, manifests, and install scripts for the exact packages/versions above.');
305
+ lines.push('2. If any are present in the project, remove/upgrade them and reinstall from a clean lockfile.');
306
+ lines.push('3. Run or ask me to run these cleanup commands:');
307
+ for (const command of commands) lines.push(` ${command}`);
308
+ lines.push(`4. Re-run: npx ${APP_NAME}`);
309
+ lines.push(`5. If raw evidence paths are needed, ask me to run: npx ${APP_NAME} --json`);
310
+ if (riskyExecution) {
311
+ lines.push('6. Help me rotate GitHub, npm, cloud, AI-provider, CI/CD, registry, and deploy tokens that may have lived on this machine. Do not print secrets.');
312
+ } else {
313
+ lines.push('6. Treat cache/store hits as evidence of presence, not proof of execution, but still inspect active projects before dismissing.');
314
+ }
315
+ lines.push('');
316
+ lines.push('Rules: explain each change before destructive cleanup, do not delete unfamiliar files blindly, and never echo/exfiltrate/store secrets.');
317
+
318
+ return lines;
319
+ }
320
+
321
+ function cleanupCommands(locationLabels) {
322
+ const labels = Array.from(locationLabels).join(' ').toLowerCase();
323
+ const commands = [];
324
+ if (/npm/.test(labels)) {
325
+ commands.push('npm cache clean --force');
326
+ if (/_npx/.test(labels)) commands.push('rm -rf ~/.npm/_npx');
327
+ }
328
+ if (/pnpm/.test(labels)) commands.push('pnpm store prune');
329
+ if (/yarn/.test(labels)) commands.push('yarn cache clean');
330
+ if (/bun/.test(labels)) commands.push('bun pm cache rm');
331
+ if (/python|pipx/.test(labels)) commands.push('python -m pip cache purge # if supported by your pip');
332
+ if (!commands.length) commands.push('# clear the listed cache/store for the package manager that pinged');
333
+ return Array.from(new Set(commands));
334
+ }
335
+
336
+ function formatAdvisoryList(payload, options = {}) {
337
+ const color = createColor(options.color);
338
+ const lines = [
339
+ color.bold(`${APP_NAME} advisory snapshot`),
340
+ `${payload.snapshotDate} - ${payload.advisoryCount} advisories, ${payload.artifactCount} package/version artifacts`,
341
+ '',
342
+ ];
343
+ for (const advisory of payload.advisories) {
344
+ const artifactCount = advisory.packages.reduce((count, item) => count + item.versions.length, 0);
345
+ lines.push(`${color.yellow(advisory.id)} ${advisory.title}`);
346
+ lines.push(` ${advisory.ecosystem} ${severityColor(advisory.severity, color)(advisory.severity)} ${artifactCount} artifacts`);
347
+ lines.push(` ${color.dim(advisory.source)}`);
348
+ }
349
+ return `${lines.join('\n')}\n`;
350
+ }
351
+
352
+ function formatCompactList(values, limit = 5) {
353
+ const list = Array.from(new Set(values.filter(Boolean)));
354
+ if (list.length <= limit) return list.join(', ');
355
+ return `${list.slice(0, limit).join(', ')}, +${list.length - limit} more`;
356
+ }
357
+
358
+ function pluralize(count, singular) {
359
+ return `${count} ${singular}${count === 1 ? '' : 's'}`;
360
+ }
361
+
362
+ function severityLabel(severity) {
363
+ return String(severity || 'unknown').toUpperCase();
364
+ }
365
+
366
+ function severityColor(severity, color) {
367
+ const label = String(severity || '').toLowerCase();
368
+ if (label.includes('critical') || label.includes('high')) return color.red;
369
+ if (label.includes('medium') || label.includes('moderate')) return color.yellow;
370
+ return color.dim;
371
+ }
372
+
373
+ function createColor(enabled = DEFAULT_COLOR) {
374
+ const wrap = (code, value) => enabled ? `\u001b[${code}m${value}\u001b[0m` : String(value);
375
+ return {
376
+ bold: (value) => wrap('1', value),
377
+ dim: (value) => wrap('2', value),
378
+ green: (value) => wrap('32', value),
379
+ red: (value) => wrap('31', value),
380
+ yellow: (value) => wrap('33', value),
381
+ cyan: (value) => wrap('36', value),
382
+ magenta: (value) => wrap('35', value),
383
+ };
384
+ }
385
+
386
+ function createSpinner(options) {
387
+ const enabled = !options.json && process.stderr.isTTY && !process.env.CI;
388
+ if (!enabled) {
389
+ return {
390
+ start() {},
391
+ stop() {},
392
+ };
393
+ }
394
+
395
+ const color = createColor(options.color);
396
+ let frameIndex = 0;
397
+ let messageIndex = 0;
398
+ let timer = null;
399
+
400
+ function render() {
401
+ const frame = SPINNER_FRAMES[frameIndex % SPINNER_FRAMES.length];
402
+ const message = SPINNER_MESSAGES[messageIndex % SPINNER_MESSAGES.length];
403
+ process.stderr.write(`\r${color.yellow(frame)} ${message}...`);
404
+ frameIndex += 1;
405
+ if (frameIndex % 10 === 0) messageIndex += 1;
406
+ }
407
+
408
+ return {
409
+ start() {
410
+ render();
411
+ timer = setInterval(render, 100);
412
+ if (typeof timer.unref === 'function') timer.unref();
413
+ },
414
+ stop() {
415
+ if (timer) clearInterval(timer);
416
+ timer = null;
417
+ process.stderr.write('\r\x1b[2K\n');
418
+ },
419
+ };
420
+ }
421
+
422
+ function helpText() {
423
+ return `Usage: ${APP_NAME}
424
+
425
+ Scan this machine's package-manager state for packages and binaries tied to known supply-chain attacks or AI security incidents.
426
+
427
+ It always scans local machine locations such as npm global installs, npm cache/_npx, pnpm global/store, yarn global/cache, bun global/cache, and Python user/pipx environments when present.
428
+
429
+ Optional:
430
+ ${APP_NAME} --json
431
+ ${APP_NAME} --list-advisories
432
+ ${APP_NAME} --no-interactive
433
+
434
+ Text output shows a compact verdict. In a real terminal, an interactive menu can teach what the attack means or print a copy/paste cleanup prompt.
435
+
436
+ Set NO_COLOR=1 to disable colors.
437
+ `;
438
+ }
439
+
440
+ function interactiveCanRun(options = {}) {
441
+ return Boolean(options.interactive && !options.json && process.stdin.isTTY && process.stdout.isTTY && !process.env.CI);
442
+ }
443
+
444
+ function hasAnyFindings(result) {
445
+ return Boolean((result.findings && result.findings.length) || (result.iocs && result.iocs.length));
446
+ }
447
+
448
+ function worstSeverity(result) {
449
+ let worst = result.iocs && result.iocs.length ? 'critical' : 'unknown';
450
+ for (const finding of result.findings || []) {
451
+ const severity = String(finding.advisory && finding.advisory.severity || 'unknown').toLowerCase();
452
+ if ((SEVERITY_RANK[severity] || 0) > (SEVERITY_RANK[worst] || 0)) worst = severity;
453
+ }
454
+ return severityLabel(worst);
455
+ }
456
+
457
+ function campaignSummary(result, limit = 3) {
458
+ const campaigns = (result.findings || []).map((finding) => campaignName(finding.advisory));
459
+ if (result.iocs && result.iocs.length) campaigns.push('local-file IOC');
460
+ return formatCompactList(campaigns, limit) || 'unknown';
461
+ }
462
+
463
+ function campaignName(advisory = {}) {
464
+ const text = `${advisory.title || ''} ${advisory.id || ''}`.toLowerCase();
465
+ if (text.includes('mini shai-hulud')) return 'Mini Shai-Hulud';
466
+ if (text.includes('canisterworm')) return 'CanisterWorm';
467
+ if (text.includes('canistersprawl')) return 'CanisterSprawl';
468
+ if (text.includes('axios')) return 'Axios/plain-crypto-js';
469
+ if (text.includes('rspack')) return 'Rspack compromise';
470
+ if (text.includes('nx') || text.includes('s1ngularity')) return 'Nx s1ngularity';
471
+ return advisory.id || advisory.title || 'unknown campaign';
472
+ }
473
+
474
+ function attackExplanation(group) {
475
+ const name = group.name || '';
476
+ const lower = name.toLowerCase();
477
+
478
+ if (lower.includes('mini shai-hulud')) {
479
+ return [
480
+ 'Attackers obtained publishing access for packages developers already trusted, then released poisoned versions under legitimate package names.',
481
+ 'During install or CLI execution, the malicious package gets the same local access as your package manager: repo files, shell environment, home-directory config, and registry credentials.',
482
+ 'The payload class is credential theft: npm tokens, GitHub tokens, cloud keys, CI/CD secrets, deploy tokens, and AI-provider keys are the high-value targets.',
483
+ 'Stolen tokens are then useful for a second wave: publishing more poisoned packages, reading private repos, modifying CI, or deploying attacker-controlled code.',
484
+ 'Some waves also leave behind helper files or persistence-style artifacts, which is why local-file IOCs are treated separately from package hits.',
485
+ ];
486
+ }
487
+
488
+ if (lower.includes('canisterworm')) {
489
+ return [
490
+ 'Attackers targeted AI/devtool packages because developers run them from terminals, agents, and automation with broad permissions.',
491
+ 'The malicious code searches the machine or CI environment for credentials, tokens, project metadata, and registry access.',
492
+ 'The worm behavior comes from reuse of stolen access: a compromised token can publish more malware or reach additional repos and packages.',
493
+ 'Treat this as both a machine incident and a project supply-chain incident; removing one package is not enough if secrets were exposed.',
494
+ ];
495
+ }
496
+
497
+ if (lower.includes('canistersprawl')) {
498
+ return [
499
+ 'Attackers published fake packages with names that resemble real AI, MCP, cloud, or devtool SDKs.',
500
+ 'The trick is dependency confusion by branding: the name looks plausible enough that a developer, agent, or script installs it.',
501
+ 'After installation, the package can read environment variables, config files, registry credentials, SSH/Git settings, or cloud tokens available to the current user.',
502
+ 'The fix is to remove the fake package, verify the exact official package name from the vendor, then reinstall from a clean lockfile if needed.',
503
+ ];
504
+ }
505
+
506
+ if (lower.includes('axios')) {
507
+ return [
508
+ 'A trusted package release path was compromised and a malicious dependency entered through an otherwise familiar package chain.',
509
+ 'Install-time code can run before your application imports anything, so “we never called it” is not a safe dismissal.',
510
+ 'The valuable target is the install environment: developer laptops and CI often contain registry tokens, GitHub tokens, cloud keys, and deployment credentials.',
511
+ 'This is why lockfile review matters: a safe-looking top-level dependency can smuggle an attacker-controlled transitive package.',
512
+ ];
513
+ }
514
+
515
+ if (lower.includes('rspack')) {
516
+ return [
517
+ 'Attackers shipped malicious Rspack releases into the npm ecosystem under names developers expect to use for builds.',
518
+ 'Build tools are high impact because they run in source repos and CI, exactly where source code and deployment credentials are available.',
519
+ 'The dangerous path is install-time script execution, npx/global CLI execution, or CI build execution with attacker code in the dependency tree.',
520
+ 'If the hit came from npx or a global install, treat it as likely execution until you prove otherwise from shell history, CI logs, and project lockfiles.',
521
+ ];
522
+ }
523
+
524
+ if (lower.includes('nx')) {
525
+ return [
526
+ 'Attackers published malicious Nx-related versions into a widely used developer-tooling supply chain.',
527
+ 'Developer tools are high value because they run inside repos, terminals, and CI with broad filesystem and environment access.',
528
+ 'The payload class is credential theft and reconnaissance: grab tokens/config, identify projects, then use access to reach more repos, packages, or pipelines.',
529
+ 'If Nx was installed globally, via npx, or in CI, assume the blast radius may include more than one repo until token usage and CI history are reviewed.',
530
+ ];
531
+ }
532
+
533
+ return [
534
+ 'A package version on this machine matches a known malicious or supply-chain advisory in the embedded snapshot.',
535
+ 'The risky part is not “your app imported a vulnerable function”; package installation, postinstall hooks, or CLI execution can run attacker code first.',
536
+ 'That attacker code may read environment variables, local config files, tokens, lockfiles, source files, and project metadata.',
537
+ 'Use the exact package names, versions, and locations below to decide whether this was only fetched into a cache or likely executed in a project/CLI/CI context.',
538
+ ];
539
+ }
540
+
541
+ function riskSentence(findings) {
542
+ const locations = findings.flatMap((finding) => finding.locations || []);
543
+ const labels = locations.join(' ').toLowerCase();
544
+ const hasBins = findings.some((finding) => finding.binaries && finding.binaries.length);
545
+
546
+ if (/_npx|global|pipx/.test(labels) || hasBins) {
547
+ return 'Hot. global/npx/CLI hits mean package code may have installed or run on this machine.';
548
+ }
549
+ if (/cache|store/.test(labels)) {
550
+ return 'Medium-hot. cache/store hits prove the package was fetched or stored here, not necessarily executed.';
551
+ }
552
+ return 'Unknown-hot. The scanner found a matching package/version, but execution risk depends on how it got there.';
553
+ }
554
+
555
+ function locationMeaning(findings) {
556
+ const locations = findings.flatMap((finding) => finding.locations || []);
557
+ const labels = locations.join(' ').toLowerCase();
558
+ const hasBins = findings.some((finding) => finding.binaries && finding.binaries.length);
559
+ const meanings = [];
560
+
561
+ if (/cache|store/.test(labels)) meanings.push('cache/store = package artifact was present locally; it may have been downloaded as part of install resolution, but this alone does not prove execution');
562
+ if (/_npx/.test(labels)) meanings.push('npx cache = a one-off CLI install path; treat as likely executed or intended to execute');
563
+ if (/global|pipx/.test(labels) || hasBins) meanings.push('global/pipx/binary = command-line tooling was installed; assume it could have run with your user permissions');
564
+ if (/node_modules|python user site/.test(labels)) meanings.push('project/user environment = inspect lockfiles, install logs, and recent shell/CI history for actual use');
565
+
566
+ return meanings.length ? meanings.join('; ') : 'the scanner can identify the package/version, but you need local context to determine whether it executed';
567
+ }
568
+
569
+ function groupFindingsByCampaign(findings) {
570
+ const groups = new Map();
571
+ for (const finding of findings || []) {
572
+ const advisory = finding.advisory || {};
573
+ const key = campaignName(advisory);
574
+ if (!groups.has(key)) groups.set(key, { name: key, advisory, advisories: [], findings: [] });
575
+ const group = groups.get(key);
576
+ group.findings.push(finding);
577
+ if (!group.advisories.some((item) => item.id === advisory.id)) group.advisories.push(advisory);
578
+ if (severityRank(advisory.severity) > severityRank(group.advisory.severity)) group.advisory = advisory;
579
+ }
580
+ return Array.from(groups.values()).sort((a, b) => severityRank(groupSeverity(b)) - severityRank(groupSeverity(a)));
581
+ }
582
+
583
+ function groupSeverity(group) {
584
+ let worst = 'unknown';
585
+ for (const advisory of group.advisories || []) {
586
+ if (severityRank(advisory.severity) > severityRank(worst)) worst = String(advisory.severity || 'unknown').toLowerCase();
587
+ }
588
+ return worst;
589
+ }
590
+
591
+ function severityRank(severity) {
592
+ return SEVERITY_RANK[String(severity || 'unknown').toLowerCase()] || 0;
593
+ }
594
+
595
+ module.exports = {
596
+ main,
597
+ parseArgs,
598
+ formatResult,
599
+ formatEducation,
600
+ formatNextActions,
601
+ };