supply-scan 1.0.2 → 1.1.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.
package/dist/index.js CHANGED
@@ -2,14 +2,71 @@
2
2
 
3
3
  // src/index.ts
4
4
  import { fileURLToPath } from "url";
5
- import { dirname, join as join4 } from "path";
5
+ import { dirname, join as join5 } from "path";
6
+
7
+ // src/args.ts
8
+ import { resolve } from "path";
9
+ function parseArgs(argv) {
10
+ const opts = {
11
+ rules: [],
12
+ all: false,
13
+ list: false,
14
+ path: null,
15
+ ci: false,
16
+ help: false,
17
+ version: false
18
+ };
19
+ for (let i = 0; i < argv.length; i++) {
20
+ const arg = argv[i];
21
+ switch (arg) {
22
+ case "--all":
23
+ case "-a":
24
+ opts.all = true;
25
+ break;
26
+ case "--list":
27
+ case "-l":
28
+ opts.list = true;
29
+ break;
30
+ case "--ci":
31
+ opts.ci = true;
32
+ opts.all = true;
33
+ break;
34
+ case "--help":
35
+ case "-h":
36
+ opts.help = true;
37
+ break;
38
+ case "--version":
39
+ case "-v":
40
+ opts.version = true;
41
+ break;
42
+ case "--rule":
43
+ case "-r":
44
+ if (i + 1 < argv.length) {
45
+ opts.rules.push(argv[++i]);
46
+ }
47
+ break;
48
+ case "--path":
49
+ case "-p":
50
+ if (i + 1 < argv.length) {
51
+ opts.path = resolve(argv[++i]);
52
+ }
53
+ break;
54
+ }
55
+ }
56
+ return opts;
57
+ }
58
+
59
+ // src/rules.ts
60
+ import { readdirSync as readdirSync2 } from "fs";
61
+ import { join as join2 } from "path";
6
62
 
7
63
  // src/utils.ts
8
64
  import { readFileSync, readdirSync, existsSync, statSync } from "fs";
9
- import { join, resolve } from "path";
65
+ import { join } from "path";
10
66
  import { platform, homedir } from "os";
11
- import { execSync } from "child_process";
12
- import { createInterface } from "readline";
67
+ function escapeRegex(s) {
68
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
69
+ }
13
70
  function getOS() {
14
71
  return platform();
15
72
  }
@@ -45,6 +102,15 @@ function expandPath(p) {
45
102
  }
46
103
  return result2;
47
104
  }
105
+ var SKIP_DIRS = /* @__PURE__ */ new Set([
106
+ "node_modules",
107
+ ".git",
108
+ "bower_components",
109
+ ".cache",
110
+ ".Trash",
111
+ "Library",
112
+ ".npm"
113
+ ]);
48
114
  function findProjects(baseDirs, maxDepth = 8) {
49
115
  const projects = /* @__PURE__ */ new Set();
50
116
  function walk(dir, depth) {
@@ -58,16 +124,7 @@ function findProjects(baseDirs, maxDepth = 8) {
58
124
  }
59
125
  continue;
60
126
  }
61
- const skip = [
62
- "node_modules",
63
- ".git",
64
- "bower_components",
65
- ".cache",
66
- ".Trash",
67
- "Library",
68
- ".npm"
69
- ];
70
- if (skip.includes(entry.name)) continue;
127
+ if (SKIP_DIRS.has(entry.name)) continue;
71
128
  walk(join(dir, entry.name), depth + 1);
72
129
  }
73
130
  } catch {
@@ -108,14 +165,54 @@ function getCommonProjectDirs() {
108
165
  ];
109
166
  return candidates.map((d) => join(home, d)).filter((d) => existsSync(d));
110
167
  }
168
+ function fileExists(path) {
169
+ try {
170
+ return statSync(path).isFile();
171
+ } catch {
172
+ return false;
173
+ }
174
+ }
175
+ function dirExists(path) {
176
+ try {
177
+ return statSync(path).isDirectory();
178
+ } catch {
179
+ return false;
180
+ }
181
+ }
182
+
183
+ // src/rules.ts
184
+ function decodeB64(s) {
185
+ return Buffer.from(s, "base64").toString("utf-8");
186
+ }
187
+ function decodeArray(arr) {
188
+ return arr?.map(decodeB64);
189
+ }
190
+ function decodeRecord(rec) {
191
+ if (!rec) return void 0;
192
+ const result2 = {};
193
+ for (const [key, vals] of Object.entries(rec)) {
194
+ result2[key] = vals.map(decodeB64);
195
+ }
196
+ return result2;
197
+ }
198
+ function decodeRule(rule) {
199
+ if (!rule.encoded || !rule.ioc) return rule;
200
+ const ioc = rule.ioc;
201
+ ioc.domains = decodeArray(ioc.domains);
202
+ ioc.ips = decodeArray(ioc.ips);
203
+ ioc.processes = decodeArray(ioc.processes);
204
+ ioc.strings = decodeArray(ioc.strings);
205
+ ioc.files = decodeRecord(ioc.files);
206
+ return rule;
207
+ }
111
208
  function loadRules(rulesDir) {
112
209
  const rules = [];
113
210
  try {
114
- const files = readdirSync(rulesDir).filter((f) => f.endsWith(".json"));
211
+ const files = readdirSync2(rulesDir).filter((f) => f.endsWith(".json"));
115
212
  for (const file of files) {
116
- const rule = readJSON(join(rulesDir, file));
213
+ const rule = readJSON(join2(rulesDir, file));
117
214
  if (rule && rule.id && rule.packages) {
118
- rules.push(rule);
215
+ rules.push(decodeRule(rule));
119
216
  }
120
217
  }
121
218
  } catch {
@@ -123,62 +220,9 @@ function loadRules(rulesDir) {
123
220
  rules.sort((a, b) => b.date.localeCompare(a.date));
124
221
  return rules;
125
222
  }
126
- function parseArgs(argv) {
127
- const opts = {
128
- rules: [],
129
- all: false,
130
- list: false,
131
- path: null,
132
- ci: false,
133
- help: false,
134
- version: false
135
- };
136
- for (let i = 0; i < argv.length; i++) {
137
- const arg = argv[i];
138
- switch (arg) {
139
- case "--all":
140
- case "-a":
141
- opts.all = true;
142
- break;
143
- case "--list":
144
- case "-l":
145
- opts.list = true;
146
- break;
147
- case "--ci":
148
- opts.ci = true;
149
- opts.all = true;
150
- break;
151
- case "--help":
152
- case "-h":
153
- opts.help = true;
154
- break;
155
- case "--version":
156
- case "-v":
157
- opts.version = true;
158
- break;
159
- case "--rule":
160
- case "-r":
161
- if (i + 1 < argv.length) {
162
- opts.rules.push(argv[++i]);
163
- }
164
- break;
165
- case "--path":
166
- case "-p":
167
- if (i + 1 < argv.length) {
168
- opts.path = resolve(argv[++i]);
169
- }
170
- break;
171
- }
172
- }
173
- return opts;
174
- }
175
- function runCommand(cmd) {
176
- try {
177
- return execSync(cmd, { encoding: "utf-8", timeout: 1e4, stdio: ["pipe", "pipe", "pipe"] }).trim();
178
- } catch {
179
- return "";
180
- }
181
- }
223
+
224
+ // src/prompt.ts
225
+ import { createInterface } from "readline";
182
226
  function prompt(question) {
183
227
  const rl = createInterface({
184
228
  input: process.stdin,
@@ -191,221 +235,378 @@ function prompt(question) {
191
235
  });
192
236
  });
193
237
  }
194
- function fileExists(path) {
195
- try {
196
- return statSync(path).isFile();
197
- } catch {
198
- return false;
199
- }
200
- }
201
- function dirExists(path) {
202
- try {
203
- return statSync(path).isDirectory();
204
- } catch {
205
- return false;
206
- }
207
- }
208
238
 
209
239
  // src/ui.ts
240
+ var isTTY = !!process.stdout.isTTY;
241
+ var noColor = !!process.env.NO_COLOR || process.env.TERM === "dumb";
242
+ var hasTruecolor = isTTY && !noColor && (process.env.COLORTERM === "truecolor" || process.env.COLORTERM === "24bit" || !!process.env.WT_SESSION || process.env.TERM_PROGRAM === "vscode" || process.env.TERM_PROGRAM === "iTerm.app");
243
+ var hasColor = isTTY && !noColor;
210
244
  var ESC = "\x1B";
245
+ function fg(r, g, b) {
246
+ if (hasTruecolor) return `${ESC}[38;2;${r};${g};${b}m`;
247
+ if (hasColor) return `${ESC}[36m`;
248
+ return "";
249
+ }
250
+ function bg(r, g, b) {
251
+ if (hasTruecolor) return `${ESC}[48;2;${r};${g};${b}m`;
252
+ if (hasColor) return `${ESC}[44m`;
253
+ return "";
254
+ }
211
255
  var c = {
212
- red: `${ESC}[1;31m`,
213
- green: `${ESC}[1;32m`,
214
- yellow: `${ESC}[1;33m`,
215
- cyan: `${ESC}[1;36m`,
216
- white: `${ESC}[1;37m`,
217
- dim: `${ESC}[2m`,
218
- bold: `${ESC}[1m`,
219
- reset: `${ESC}[0m`,
220
- bgRed: `${ESC}[41m`,
221
- bgGreen: `${ESC}[42m`,
222
- bgYellow: `${ESC}[43m`,
223
- bgBlue: `${ESC}[44m`
256
+ red: hasColor ? `${ESC}[1;31m` : "",
257
+ green: hasColor ? `${ESC}[1;32m` : "",
258
+ yellow: hasColor ? `${ESC}[1;33m` : "",
259
+ cyan: hasColor ? `${ESC}[1;36m` : "",
260
+ white: hasColor ? `${ESC}[1;37m` : "",
261
+ dim: hasColor ? `${ESC}[2m` : "",
262
+ bold: hasColor ? `${ESC}[1m` : "",
263
+ reset: hasColor ? `${ESC}[0m` : "",
264
+ bgRed: hasColor ? `${ESC}[41m` : "",
265
+ bgGreen: hasColor ? `${ESC}[42m` : "",
266
+ bgYellow: hasColor ? `${ESC}[43m` : "",
267
+ bgBlue: hasColor ? `${ESC}[44m` : "",
268
+ reverse: hasColor ? `${ESC}[7m` : ""
224
269
  };
225
- var sym = {
226
- pass: "\u2705",
227
- fail: "\u{1F6A8}",
228
- warn: "\u26A0\uFE0F ",
229
- info: "\u{1F4A1}",
230
- scan: "\u{1F50D}",
231
- shield: "\u{1F6E1}\uFE0F ",
232
- skull: "\u{1F480}",
233
- clean: "\u2728",
234
- gear: "\u2699\uFE0F ",
235
- pkg: "\u{1F4E6}",
236
- lock: "\u{1F512}",
237
- globe: "\u{1F310}",
238
- folder: "\u{1F4C1}"
239
- };
240
- function banner(ruleCount) {
241
- const w = process.stdout.write.bind(process.stdout);
242
- w("\n");
243
- w(`${c.cyan} ___ _ _ ___ ___ _ _ _ ___ ___ _ _ _ ${c.reset}
244
- `);
245
- w(`${c.cyan} / __| | | | _ \\ _ \\ | | | | | / __|/ __| /_\\ | \\| |${c.reset}
246
- `);
247
- w(`${c.cyan} \\__ \\ |_| | _/ _/ |_| |_| |_| (__| (__ / _ \\| .\` |${c.reset}
248
- `);
249
- w(`${c.cyan} |___/\\___/|_| |_| \\__, |_____|\\___|\\___|_/ \\_\\_|\\_|${c.reset}
250
- `);
251
- w(`${c.cyan} |___/ ${c.dim}v1.0.0${c.reset}
252
- `);
253
- w("\n");
254
- w(` ${c.white}Universal npm Supply Chain Attack Scanner${c.reset}
255
- `);
256
- w(` ${c.dim}Detects ${ruleCount} known attacks | Zero dependencies${c.reset}
257
- `);
258
- w("\n");
259
- divider();
270
+ function termW() {
271
+ return process.stdout.columns || 80;
272
+ }
273
+ var W = () => Math.min(termW(), 70);
274
+ function line(char = "\u2500") {
275
+ return `${c.dim}${char.repeat(W())}${c.reset}`;
260
276
  }
261
- function divider() {
262
- console.log(`${c.dim}${"\u2500".repeat(60)}${c.reset}`);
277
+ function severityBadge(sev) {
278
+ const label = sev.toUpperCase();
279
+ if (!hasColor) return `[${label}]`;
280
+ const colors = {
281
+ critical: [220, 38, 38],
282
+ high: [234, 179, 8],
283
+ medium: [59, 130, 246],
284
+ low: [107, 114, 128]
285
+ };
286
+ const col = colors[sev] || [107, 114, 128];
287
+ return `${bg(col[0], col[1], col[2])}${c.white} ${label} ${c.reset}`;
288
+ }
289
+ function badge(type) {
290
+ if (!hasColor) return `[${type.toUpperCase()}]`;
291
+ const badges = {
292
+ pass: `${bg(34, 197, 94)}${c.white} PASS ${c.reset}`,
293
+ fail: `${bg(239, 68, 68)}${c.white} FAIL ${c.reset}`,
294
+ warn: `${bg(234, 179, 8)}${ESC}[30m WARN ${c.reset}`,
295
+ info: `${bg(107, 114, 128)}${c.white} INFO ${c.reset}`
296
+ };
297
+ return badges[type];
298
+ }
299
+ function banner(ruleCount, version = "0.0.0") {
300
+ const art = [
301
+ " \u250C\u2500\u2510\u252C \u252C\u250C\u2500\u2510\u250C\u2500\u2510\u252C \u252C \u252C \u250C\u2500\u2510\u250C\u2500\u2510\u250C\u2500\u2510\u250C\u2510\u250C",
302
+ " \u2514\u2500\u2510\u2502 \u2502\u251C\u2500\u2518\u251C\u2500\u2518\u2502 \u2514\u252C\u2518 \u2514\u2500\u2510\u2502 \u251C\u2500\u2524\u2502\u2502\u2502",
303
+ " \u2514\u2500\u2518\u2514\u2500\u2518\u2534 \u2534 \u2534\u2500\u2518 \u2534 \u2514\u2500\u2518\u2514\u2500\u2518\u2534 \u2534\u2518\u2514\u2518"
304
+ ];
305
+ const vBadge = `${bg(59, 130, 246)}${c.white} v${version} ${c.reset}`;
306
+ console.log("");
307
+ for (let i = 0; i < art.length; i++) {
308
+ const t = art.length > 1 ? i / (art.length - 1) : 0;
309
+ const r = Math.round(0 + 120 * t);
310
+ const g = Math.round(200 - 120 * t);
311
+ const b = Math.round(255);
312
+ console.log(`${fg(r, g, b)}${art[i]}${c.reset}`);
313
+ }
314
+ console.log("");
315
+ console.log(` ${c.white}Universal npm Supply Chain Attack Scanner${c.reset} ${vBadge}`);
316
+ console.log(` ${c.dim}Detects ${ruleCount} known attacks \xB7 Zero dependencies${c.reset}`);
317
+ console.log("");
318
+ console.log(line());
263
319
  }
264
320
  function sectionHeader(num, total, title, icon) {
321
+ const w = W();
322
+ const step = `${bg(59, 130, 246)}${c.white} ${num}/${total} ${c.reset}`;
323
+ const text = ` ${icon} ${step} ${c.white}${c.bold}${title}${c.reset} `;
324
+ const textLen = 4 + 4 + title.length + 4;
325
+ const fill = Math.max(w - textLen - 2, 0);
265
326
  console.log("");
266
- console.log(`${c.bgBlue}${c.white}${c.bold} ${icon} CHECK ${num}/${total} \u2502 ${title} ${c.reset}`);
267
- divider();
327
+ console.log(`${c.dim}\u2500\u2500${c.reset}${text}${c.dim}${"\u2500".repeat(fill)}${c.reset}`);
268
328
  }
269
329
  function result(type, msg) {
270
- const icons = {
271
- pass: sym.pass,
272
- fail: sym.fail,
273
- warn: sym.warn,
274
- info: sym.info
275
- };
276
- const colors = {
277
- pass: c.green,
278
- fail: `${c.red}${c.bold}`,
279
- warn: c.yellow,
280
- info: c.dim
281
- };
282
- console.log(` ${icons[type]} ${colors[type]}${msg}${c.reset}`);
330
+ const color = type === "fail" ? c.red : type === "warn" ? c.yellow : type === "pass" ? c.green : c.dim;
331
+ console.log(` ${badge(type)} ${color}${msg}${c.reset}`);
283
332
  }
284
333
  function resultDetail(msg) {
285
- console.log(` ${c.red}\u2192 ${msg}${c.reset}`);
334
+ console.log(` ${c.dim}\u21B3${c.reset} ${c.red}${msg}${c.reset}`);
335
+ }
336
+ function hideCursor() {
337
+ if (isTTY) process.stdout.write(`${ESC}[?25l`);
286
338
  }
287
- var SPIN_FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
288
- var spinnerTimer = null;
289
- var spinnerFrame = 0;
339
+ function showCursor() {
340
+ if (isTTY) process.stdout.write(`${ESC}[?25h`);
341
+ }
342
+ function teardownStdin(listener) {
343
+ if (process.stdin.isTTY) process.stdin.setRawMode(false);
344
+ process.stdin.pause();
345
+ process.stdin.removeListener("data", listener);
346
+ showCursor();
347
+ }
348
+ var FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
349
+ var SPIN_COLORS = FRAMES.map((_, i) => {
350
+ const r = Math.round(80 + 40 * Math.sin(i * 0.6));
351
+ const g = Math.round(180 + 20 * Math.cos(i * 0.6));
352
+ return fg(r, g, 255);
353
+ });
354
+ var spinTimer = null;
355
+ var spinIdx = 0;
290
356
  function spinnerStart(msg) {
291
- spinnerFrame = 0;
292
- spinnerTimer = setInterval(() => {
293
- const frame = SPIN_FRAMES[spinnerFrame % SPIN_FRAMES.length];
294
- process.stdout.write(`\r ${c.cyan}${frame}${c.reset} ${c.dim}${msg}${c.reset}`);
295
- spinnerFrame++;
357
+ spinIdx = 0;
358
+ hideCursor();
359
+ spinTimer = setInterval(() => {
360
+ const i = spinIdx % FRAMES.length;
361
+ process.stdout.write(`\r${ESC}[2K ${SPIN_COLORS[i]}${FRAMES[i]}${c.reset} ${c.dim}${msg}${c.reset}`);
362
+ spinIdx++;
296
363
  }, 80);
297
364
  }
298
365
  function spinnerStop() {
299
- if (spinnerTimer) {
300
- clearInterval(spinnerTimer);
301
- spinnerTimer = null;
302
- process.stdout.write("\r\x1B[K");
366
+ if (spinTimer) {
367
+ clearInterval(spinTimer);
368
+ spinTimer = null;
369
+ process.stdout.write(`\r${ESC}[2K`);
370
+ showCursor();
303
371
  }
304
372
  }
305
- var severityColors = {
306
- critical: c.red,
307
- high: c.yellow,
308
- medium: c.cyan,
309
- low: c.dim
310
- };
373
+ function info(msg) {
374
+ console.log(` ${c.dim}${msg}${c.reset}`);
375
+ }
376
+ function success(msg) {
377
+ console.log(` ${c.green}\u2713${c.reset} ${msg}`);
378
+ }
311
379
  function printRuleList(rules) {
312
380
  console.log("");
313
- console.log(` ${c.white}Available attack rules (${rules.length}):${c.reset}`);
381
+ console.log(` ${c.white}${c.bold}Available rules (${rules.length}):${c.reset}`);
314
382
  console.log("");
315
383
  for (const rule of rules) {
316
- const sc = severityColors[rule.severity] || c.dim;
317
384
  const pkgCount = Object.keys(rule.packages.compromised).length + Object.keys(rule.packages.malicious).length;
318
- console.log(
319
- ` ${c.dim}\u2022${c.reset} ${c.white}${rule.id.padEnd(25)}${c.reset} ${sc}${rule.severity.padEnd(10)}${c.reset} ${c.dim}${rule.date}${c.reset} ${c.dim}(${pkgCount} pkgs)${c.reset}`
320
- );
321
- console.log(` ${c.dim}${rule.description}${c.reset}`);
385
+ console.log(` ${severityBadge(rule.severity)} ${c.white}${rule.id}${c.reset} ${c.dim}\xB7 ${rule.date} \xB7 ${pkgCount} pkgs${c.reset}`);
386
+ console.log(` ${c.dim}${rule.description}${c.reset}`);
322
387
  }
323
388
  console.log("");
324
389
  }
325
390
  function printSummary(summary) {
326
391
  console.log("");
327
- console.log(`${c.cyan}${"\u2550".repeat(60)}${c.reset}`);
328
- console.log(` ${sym.shield} ${c.white}${c.bold}SCAN COMPLETE${c.reset}`);
329
- console.log(`${c.cyan}${"\u2550".repeat(60)}${c.reset}`);
392
+ console.log(line("\u2550"));
393
+ console.log("");
394
+ console.log(` ${c.dim}Projects scanned${c.reset} ${c.white}${c.bold}${summary.projectsScanned}${c.reset}`);
395
+ console.log(` ${c.dim}Rules checked${c.reset} ${c.white}${c.bold}${summary.rulesChecked}${c.reset}`);
330
396
  console.log("");
331
- console.log(` ${c.dim}Projects scanned:${c.reset} ${c.white}${summary.projectsScanned}${c.reset}`);
332
- console.log(` ${c.dim}Rules checked:${c.reset} ${c.white}${summary.rulesChecked}${c.reset}`);
333
- console.log(` ${c.dim}Total checks:${c.reset} ${c.white}${summary.totalChecks}${c.reset}`);
334
- console.log(` ${c.green}Passed:${c.reset} ${c.green}${summary.passed}${c.reset}`);
335
- console.log(` ${c.yellow}Warnings:${c.reset} ${c.yellow}${summary.warnings}${c.reset}`);
336
- console.log(` ${c.red}Failed:${c.reset} ${c.red}${summary.failed}${c.reset}`);
397
+ console.log(` ${badge("pass")} ${c.green}${summary.passed}${c.reset} ${badge("warn")} ${c.yellow}${summary.warnings}${c.reset} ${badge("fail")} ${c.red}${summary.failed}${c.reset}`);
337
398
  console.log("");
338
- divider();
339
399
  if (summary.failed > 0) {
400
+ console.log(` ${bg(220, 38, 38)}${c.white}${c.bold} \u26D4 COMPROMISE DETECTED \u26D4 ${c.reset}`);
340
401
  console.log("");
341
- console.log(` ${c.bgRed}${c.white}${c.bold} \u26D4 COMPROMISE DETECTED \u26D4 ${c.reset}`);
342
- console.log("");
343
- console.log(` ${c.red}${c.bold}Immediate Actions Required:${c.reset}`);
344
- console.log("");
402
+ console.log(` ${c.red}${c.bold}Immediate Actions:${c.reset}`);
345
403
  console.log(` ${c.red}1.${c.reset} Disconnect from the network`);
346
- console.log(` ${c.red}2.${c.reset} Rotate ALL credentials, tokens, API keys, SSH keys`);
347
- console.log(` ${c.red}3.${c.reset} Remove compromised packages: ${c.white}npm install <pkg>@latest${c.reset}`);
348
- console.log(` ${c.red}4.${c.reset} Clean npm cache: ${c.white}npm cache clean --force${c.reset}`);
349
- console.log(` ${c.red}5.${c.reset} Review system for backdoors and persistence mechanisms`);
350
- console.log(` ${c.red}6.${c.reset} Consider full system wipe if RAT/worm was executed`);
351
- console.log("");
404
+ console.log(` ${c.red}2.${c.reset} Rotate ALL credentials, tokens, API keys`);
405
+ console.log(` ${c.red}3.${c.reset} ${c.white}npm install <pkg>@latest${c.reset} for compromised packages`);
406
+ console.log(` ${c.red}4.${c.reset} ${c.white}npm cache clean --force${c.reset}`);
407
+ console.log(` ${c.red}5.${c.reset} Review system for backdoors and persistence`);
352
408
  if (summary.compromisedProjects.length > 0) {
353
- console.log(` ${c.red}${c.bold}Compromised Projects:${c.reset}`);
354
- for (const proj of summary.compromisedProjects) {
355
- console.log(` ${c.red}${sym.skull} ${proj}${c.reset}`);
356
- }
357
409
  console.log("");
410
+ console.log(` ${c.red}${c.bold}Compromised:${c.reset}`);
411
+ for (const p of summary.compromisedProjects) {
412
+ console.log(` ${c.red} \u2192 ${p}${c.reset}`);
413
+ }
358
414
  }
359
- const failedByRule = /* @__PURE__ */ new Map();
415
+ const byRule = /* @__PURE__ */ new Map();
360
416
  for (const r of summary.results.filter((r2) => r2.type === "fail")) {
361
- const arr = failedByRule.get(r.rule) || [];
417
+ const arr = byRule.get(r.rule) || [];
362
418
  arr.push(r);
363
- failedByRule.set(r.rule, arr);
419
+ byRule.set(r.rule, arr);
364
420
  }
365
- for (const [rule, results] of failedByRule) {
421
+ for (const [rule, results] of byRule) {
422
+ console.log("");
366
423
  console.log(` ${c.red}${c.bold}${rule}:${c.reset}`);
367
424
  for (const r of results) {
368
- console.log(` ${c.red}\u2192 ${r.message}${c.reset}`);
369
- if (r.details) console.log(` ${c.dim}${r.details}${c.reset}`);
425
+ console.log(` ${c.red}\u2192 ${r.message}${c.reset}`);
426
+ if (r.details) console.log(` ${c.dim}${r.details}${c.reset}`);
370
427
  }
371
- console.log("");
372
428
  }
373
429
  } else if (summary.warnings > 0) {
430
+ console.log(` ${bg(234, 179, 8)}${ESC}[30m${c.bold} \u26A0 WARNINGS \u2014 REVIEW RECOMMENDED \u26A0 ${c.reset}`);
374
431
  console.log("");
375
- console.log(` ${c.bgYellow}${c.white}${c.bold} \u26A0 WARNINGS FOUND \u2014 REVIEW RECOMMENDED \u26A0 ${c.reset}`);
376
- console.log("");
377
- console.log(` ${c.yellow}Some items need manual review. Check the warnings above.${c.reset}`);
378
- console.log("");
432
+ console.log(` ${c.yellow}Some items need manual review.${c.reset}`);
379
433
  } else {
434
+ console.log(` ${bg(34, 197, 94)}${c.white}${c.bold} \u2713 ALL CLEAR ${c.reset}`);
380
435
  console.log("");
381
- console.log(` ${c.bgGreen}${c.white}${c.bold} ${sym.clean} ALL CLEAR \u2014 NO COMPROMISE DETECTED ${sym.clean} ${c.reset}`);
382
- console.log("");
383
- console.log(` ${c.green}Your system appears clean from all known supply chain attacks.${c.reset}`);
436
+ console.log(` ${c.green}No supply chain compromises detected.${c.reset}`);
384
437
  console.log("");
385
- console.log(` ${c.dim}Prevention tips:${c.reset}`);
386
- console.log(` ${c.cyan}\u203A${c.reset} Use lockfiles and verify package integrity`);
438
+ console.log(` ${c.dim}Tips:${c.reset}`);
439
+ console.log(` ${c.cyan}\u203A${c.reset} Use lockfiles and verify integrity`);
387
440
  console.log(` ${c.cyan}\u203A${c.reset} Pin dependencies to exact versions`);
388
- console.log(` ${c.cyan}\u203A${c.reset} Enable npm audit in your CI/CD pipeline`);
389
- console.log(` ${c.cyan}\u203A${c.reset} Monitor for SLSA provenance on critical packages`);
390
- console.log("");
441
+ console.log(` ${c.cyan}\u203A${c.reset} Enable npm audit in CI/CD`);
442
+ console.log(` ${c.cyan}\u203A${c.reset} Monitor SLSA provenance on critical packages`);
391
443
  }
392
- divider();
393
444
  console.log("");
394
- console.log(` ${c.dim}Scan completed at ${(/* @__PURE__ */ new Date()).toISOString()}${c.reset}`);
445
+ console.log(line("\u2550"));
446
+ console.log(` ${c.dim}${(/* @__PURE__ */ new Date()).toISOString()}${c.reset}`);
395
447
  console.log(` ${c.dim}https://github.com/AsyrafHussin/supply-scan${c.reset}`);
396
448
  console.log("");
397
449
  }
450
+ async function interactiveMultiSelect(opts) {
451
+ if (!process.stdin.isTTY) return opts.items.map((i) => i.value);
452
+ const items = opts.items;
453
+ const hasAll = !!opts.allOption;
454
+ const totalRows = hasAll ? items.length + 1 : items.length;
455
+ let cursor = 0;
456
+ const selected = /* @__PURE__ */ new Set();
457
+ let allSelected = true;
458
+ const headerLines = 3;
459
+ const totalLines = headerLines + totalRows;
460
+ function render(initial = false) {
461
+ if (!initial) process.stdout.write(`${ESC}[${totalLines}A`);
462
+ process.stdout.write(`${ESC}[2K
463
+ `);
464
+ process.stdout.write(`${ESC}[2K ${c.white}${c.bold}${opts.message}${c.reset}
465
+ `);
466
+ process.stdout.write(`${ESC}[2K ${c.dim}\u2191\u2193 navigate \xB7 space toggle \xB7 enter confirm${c.reset}
467
+ `);
468
+ if (hasAll) {
469
+ const active = cursor === 0;
470
+ const icon = allSelected ? `${c.green}\u25C9${c.reset}` : `${c.dim}\u25CB${c.reset}`;
471
+ const ptr = active ? `${c.cyan}\u276F${c.reset}` : " ";
472
+ const lbl = active ? `${c.green}${c.bold}${opts.allOption.label}${c.reset}` : `${c.dim}${opts.allOption.label}${c.reset}`;
473
+ process.stdout.write(`${ESC}[2K ${ptr} ${icon} ${lbl}
474
+ `);
475
+ }
476
+ for (let i = 0; i < items.length; i++) {
477
+ const idx = hasAll ? i + 1 : i;
478
+ const active = cursor === idx;
479
+ const sel = allSelected || selected.has(i);
480
+ const icon = sel ? `${c.green}\u25C9${c.reset}` : `${c.dim}\u25CB${c.reset}`;
481
+ const ptr = active ? `${c.cyan}\u276F${c.reset}` : " ";
482
+ const hint = items[i].hint ? ` ${c.dim}${items[i].hint}${c.reset}` : "";
483
+ const lbl = active ? `${c.white}${items[i].label}${c.reset}` : `${c.dim}${items[i].label}${c.reset}`;
484
+ process.stdout.write(`${ESC}[2K ${ptr} ${icon} ${lbl}${hint}
485
+ `);
486
+ }
487
+ }
488
+ return new Promise((resolve2) => {
489
+ hideCursor();
490
+ render(true);
491
+ process.stdin.setRawMode(true);
492
+ process.stdin.resume();
493
+ const maxCursor = hasAll ? items.length : items.length - 1;
494
+ const onData = (data) => {
495
+ const key = data.toString();
496
+ if (key === "\x1B[A") {
497
+ cursor = cursor <= 0 ? maxCursor : cursor - 1;
498
+ render();
499
+ } else if (key === "\x1B[B") {
500
+ cursor = cursor >= maxCursor ? 0 : cursor + 1;
501
+ render();
502
+ } else if (key === " ") {
503
+ if (hasAll && cursor === 0) {
504
+ allSelected = !allSelected;
505
+ selected.clear();
506
+ } else {
507
+ const ii = hasAll ? cursor - 1 : cursor;
508
+ allSelected = false;
509
+ if (selected.has(ii)) selected.delete(ii);
510
+ else selected.add(ii);
511
+ if (selected.size === items.length) {
512
+ allSelected = true;
513
+ selected.clear();
514
+ }
515
+ }
516
+ render();
517
+ } else if (key === "\r") {
518
+ teardownStdin(onData);
519
+ if (allSelected || selected.size === 0) {
520
+ resolve2(items.map((i) => i.value));
521
+ } else {
522
+ resolve2([...selected].map((i) => items[i].value));
523
+ }
524
+ } else if (key === "") {
525
+ teardownStdin(onData);
526
+ process.exit(130);
527
+ }
528
+ };
529
+ process.stdin.on("data", onData);
530
+ });
531
+ }
532
+ async function interactiveSingleSelect(opts) {
533
+ if (!process.stdin.isTTY) return opts.items[0].value;
534
+ const items = opts.items;
535
+ let cursor = 0;
536
+ const headerLines = 3;
537
+ const totalLines = headerLines + items.length;
538
+ function render(initial = false) {
539
+ if (!initial) process.stdout.write(`${ESC}[${totalLines}A`);
540
+ process.stdout.write(`${ESC}[2K
541
+ `);
542
+ process.stdout.write(`${ESC}[2K ${c.white}${c.bold}${opts.message}${c.reset}
543
+ `);
544
+ process.stdout.write(`${ESC}[2K ${c.dim}\u2191\u2193 navigate \xB7 enter select${c.reset}
545
+ `);
546
+ for (let i = 0; i < items.length; i++) {
547
+ const active = cursor === i;
548
+ const ptr = active ? `${c.cyan}\u276F${c.reset}` : " ";
549
+ const hint = items[i].hint ? ` ${c.dim}${items[i].hint}${c.reset}` : "";
550
+ const lbl = active ? `${c.white}${c.bold}${items[i].label}${c.reset}` : `${c.dim}${items[i].label}${c.reset}`;
551
+ process.stdout.write(`${ESC}[2K ${ptr} ${lbl}${hint}
552
+ `);
553
+ }
554
+ }
555
+ return new Promise((resolve2) => {
556
+ hideCursor();
557
+ render(true);
558
+ process.stdin.setRawMode(true);
559
+ process.stdin.resume();
560
+ const onData = (data) => {
561
+ const key = data.toString();
562
+ if (key === "\x1B[A") {
563
+ cursor = cursor <= 0 ? items.length - 1 : cursor - 1;
564
+ render();
565
+ } else if (key === "\x1B[B") {
566
+ cursor = cursor >= items.length - 1 ? 0 : cursor + 1;
567
+ render();
568
+ } else if (key === "\r") {
569
+ teardownStdin(onData);
570
+ resolve2(items[cursor].value);
571
+ } else if (key === "") {
572
+ teardownStdin(onData);
573
+ process.exit(130);
574
+ }
575
+ };
576
+ process.stdin.on("data", onData);
577
+ });
578
+ }
398
579
 
399
580
  // src/checks/packages.ts
400
- import { join as join2 } from "path";
401
- import { readFileSync as readFileSync2 } from "fs";
581
+ import { join as join3 } from "path";
582
+ import { readFileSync as readFileSync2, readdirSync as readdirSync3 } from "fs";
583
+ var regexCache = /* @__PURE__ */ new Map();
584
+ function getMaliciousPkgRegex(pkg) {
585
+ let re = regexCache.get(pkg);
586
+ if (!re) {
587
+ re = new RegExp(`["'/]${escapeRegex(pkg)}["'/@:]`);
588
+ regexCache.set(pkg, re);
589
+ }
590
+ return re;
591
+ }
402
592
  function checkPackages(rules, projectDirs) {
403
593
  const results = [];
594
+ for (const rule of rules) {
595
+ for (const pkg of Object.keys(rule.packages.malicious)) {
596
+ getMaliciousPkgRegex(pkg);
597
+ }
598
+ }
404
599
  for (const dir of projectDirs) {
405
600
  const lockfileCache = readLockfiles(dir);
601
+ const versionCache = /* @__PURE__ */ new Map();
406
602
  for (const rule of rules) {
407
603
  for (const [pkg, badVersions] of Object.entries(rule.packages.compromised)) {
408
- const version = getPkgVersion(join2(dir, "node_modules", pkg, "package.json"));
604
+ const pkgJsonPath = join3(dir, "node_modules", pkg, "package.json");
605
+ let version = versionCache.get(pkgJsonPath);
606
+ if (version === void 0) {
607
+ version = getPkgVersion(pkgJsonPath);
608
+ versionCache.set(pkgJsonPath, version);
609
+ }
409
610
  if (version && isVersionCompromised(version, badVersions)) {
410
611
  results.push({
411
612
  type: "fail",
@@ -417,9 +618,14 @@ function checkPackages(rules, projectDirs) {
417
618
  }
418
619
  }
419
620
  for (const [pkg] of Object.entries(rule.packages.malicious)) {
420
- const pkgDir = join2(dir, "node_modules", pkg);
621
+ const pkgDir = join3(dir, "node_modules", pkg);
421
622
  if (dirExists(pkgDir)) {
422
- const version = getPkgVersion(join2(pkgDir, "package.json"));
623
+ const pkgJsonPath = join3(pkgDir, "package.json");
624
+ let version = versionCache.get(pkgJsonPath);
625
+ if (version === void 0) {
626
+ version = getPkgVersion(pkgJsonPath);
627
+ versionCache.set(pkgJsonPath, version);
628
+ }
423
629
  results.push({
424
630
  type: "fail",
425
631
  rule: rule.id,
@@ -434,21 +640,20 @@ function checkPackages(rules, projectDirs) {
434
640
  }
435
641
  return results;
436
642
  }
643
+ var LOCKFILE_NAMES = ["package-lock.json", "yarn.lock", "pnpm-lock.yaml", "bun.lock", "bun.lockb"];
437
644
  function readLockfiles(dir) {
438
645
  const cache = /* @__PURE__ */ new Map();
439
- const lockfiles = [
440
- "package-lock.json",
441
- "yarn.lock",
442
- "pnpm-lock.yaml",
443
- "bun.lock",
444
- // bun text-based lockfile (bun v1.2+)
445
- "bun.lockb"
446
- // bun binary lockfile (older bun)
447
- ];
448
- for (const name of lockfiles) {
646
+ let dirEntries;
647
+ try {
648
+ dirEntries = new Set(readdirSync3(dir));
649
+ } catch {
650
+ return cache;
651
+ }
652
+ for (const name of LOCKFILE_NAMES) {
653
+ if (!dirEntries.has(name)) continue;
449
654
  try {
450
655
  const encoding = name === "bun.lockb" ? "latin1" : "utf-8";
451
- cache.set(name, readFileSync2(join2(dir, name), encoding));
656
+ cache.set(name, readFileSync2(join3(dir, name), encoding));
452
657
  } catch {
453
658
  }
454
659
  }
@@ -457,7 +662,7 @@ function readLockfiles(dir) {
457
662
  function checkLockfilesForRule(lockfileCache, dir, rule, results) {
458
663
  for (const [lockfile, content] of lockfileCache) {
459
664
  for (const pkg of Object.keys(rule.packages.malicious)) {
460
- if (content.includes(pkg)) {
665
+ if (getMaliciousPkgRegex(pkg).test(content)) {
461
666
  results.push({
462
667
  type: "fail",
463
668
  rule: rule.id,
@@ -471,11 +676,8 @@ function checkLockfilesForRule(lockfileCache, dir, rule, results) {
471
676
  for (const ver of versions) {
472
677
  const tied = [
473
678
  `"${pkg}": "${ver}"`,
474
- // package-lock.json / bun.lock
475
679
  `${pkg}@${ver}`,
476
- // yarn.lock / pnpm-lock.yaml
477
680
  `"${pkg}","${ver}"`
478
- // bun.lockb binary format
479
681
  ];
480
682
  if (tied.some((p) => content.includes(p))) {
481
683
  results.push({
@@ -516,47 +718,70 @@ function checkFiles(rules) {
516
718
 
517
719
  // src/checks/network.ts
518
720
  import { readFileSync as readFileSync3 } from "fs";
721
+
722
+ // src/shell.ts
723
+ import { execSync, execFileSync } from "child_process";
724
+ var TIMEOUT = 1e4;
725
+ function runCommand(cmd) {
726
+ try {
727
+ return execSync(cmd, { encoding: "utf-8", timeout: TIMEOUT, stdio: ["pipe", "pipe", "pipe"] }).trim();
728
+ } catch {
729
+ return "";
730
+ }
731
+ }
732
+ function runSafe(cmd, args) {
733
+ try {
734
+ return execFileSync(cmd, args, { encoding: "utf-8", timeout: TIMEOUT, stdio: ["pipe", "pipe", "pipe"] }).trim();
735
+ } catch {
736
+ return "";
737
+ }
738
+ }
739
+
740
+ // src/checks/network.ts
519
741
  function checkNetwork(rules) {
520
742
  const results = [];
521
743
  const os = getOS();
522
- const allIPs = /* @__PURE__ */ new Set();
523
- const allDomains = /* @__PURE__ */ new Set();
524
- const ruleByIOC = /* @__PURE__ */ new Map();
744
+ const rulesByIP = /* @__PURE__ */ new Map();
745
+ const rulesByDomain = /* @__PURE__ */ new Map();
525
746
  for (const rule of rules) {
526
747
  for (const ip of rule.ioc.ips ?? []) {
527
- allIPs.add(ip);
528
- ruleByIOC.set(ip, rule.id);
748
+ const arr = rulesByIP.get(ip) || [];
749
+ arr.push(rule.id);
750
+ rulesByIP.set(ip, arr);
529
751
  }
530
752
  for (const domain of rule.ioc.domains ?? []) {
531
- allDomains.add(domain);
532
- ruleByIOC.set(domain, rule.id);
753
+ const arr = rulesByDomain.get(domain) || [];
754
+ arr.push(rule.id);
755
+ rulesByDomain.set(domain, arr);
533
756
  }
534
757
  }
535
- if (allIPs.size === 0 && allDomains.size === 0) return results;
536
- const connections = getActiveConnections(os);
537
- for (const ip of allIPs) {
538
- if (connections.includes(ip)) {
758
+ if (rulesByIP.size === 0 && rulesByDomain.size === 0) return results;
759
+ const connectionLines = getActiveConnections(os).split("\n");
760
+ for (const [ip, ruleIds] of rulesByIP) {
761
+ const pattern = new RegExp(`(?:^|[\\s:])${escapeRegex(ip)}(?:[\\s:]|$)`);
762
+ if (connectionLines.some((line2) => pattern.test(line2))) {
539
763
  results.push({
540
764
  type: "fail",
541
- rule: ruleByIOC.get(ip) || "unknown",
765
+ rule: ruleIds[0],
542
766
  check: "network",
543
767
  message: `Active connection to C2 IP: ${ip}`,
544
- details: "Disconnect from network immediately!"
768
+ details: `Rules: ${ruleIds.join(", ")} \u2014 Disconnect from network immediately!`
545
769
  });
546
770
  }
547
771
  }
548
- for (const domain of allDomains) {
549
- if (connections.includes(domain)) {
772
+ for (const [domain, ruleIds] of rulesByDomain) {
773
+ const pattern = new RegExp(`(?:^|[\\s:])${escapeRegex(domain)}(?:[\\s:.]|$)`);
774
+ if (connectionLines.some((line2) => pattern.test(line2))) {
550
775
  results.push({
551
776
  type: "fail",
552
- rule: ruleByIOC.get(domain) || "unknown",
777
+ rule: ruleIds[0],
553
778
  check: "network",
554
779
  message: `Active connection to C2 domain: ${domain}`,
555
- details: "Disconnect from network immediately!"
780
+ details: `Rules: ${ruleIds.join(", ")} \u2014 Disconnect from network immediately!`
556
781
  });
557
782
  }
558
783
  }
559
- checkHostsFile(allIPs, allDomains, ruleByIOC, results);
784
+ checkHostsFile(os, rulesByIP, rulesByDomain, results);
560
785
  return results;
561
786
  }
562
787
  function getActiveConnections(os) {
@@ -567,26 +792,29 @@ function getActiveConnections(os) {
567
792
  }
568
793
  return "";
569
794
  }
570
- function checkHostsFile(ips, domains, ruleByIOC, results) {
795
+ function checkHostsFile(os, ips, domains, results) {
796
+ const hostsPath = os === "win32" ? "C:\\Windows\\System32\\drivers\\etc\\hosts" : "/etc/hosts";
571
797
  try {
572
- const hosts = readFileSync3("/etc/hosts", "utf-8");
573
- for (const ip of ips) {
574
- if (hosts.includes(ip)) {
798
+ const hostsLines = readFileSync3(hostsPath, "utf-8").split("\n");
799
+ for (const [ip, ruleIds] of ips) {
800
+ const pattern = new RegExp(`(?:^|\\s)${escapeRegex(ip)}(?:\\s|$)`);
801
+ if (hostsLines.some((line2) => pattern.test(line2))) {
575
802
  results.push({
576
803
  type: "warn",
577
- rule: ruleByIOC.get(ip) || "unknown",
804
+ rule: ruleIds[0],
578
805
  check: "network",
579
- message: `C2 IP ${ip} found in /etc/hosts`
806
+ message: `C2 IP ${ip} found in hosts file`
580
807
  });
581
808
  }
582
809
  }
583
- for (const domain of domains) {
584
- if (hosts.includes(domain)) {
810
+ for (const [domain, ruleIds] of domains) {
811
+ const pattern = new RegExp(`(?:^|\\s)${escapeRegex(domain)}(?:\\s|$)`);
812
+ if (hostsLines.some((line2) => pattern.test(line2))) {
585
813
  results.push({
586
814
  type: "warn",
587
- rule: ruleByIOC.get(domain) || "unknown",
815
+ rule: ruleIds[0],
588
816
  check: "network",
589
- message: `C2 domain ${domain} found in /etc/hosts`
817
+ message: `C2 domain ${domain} found in hosts file`
590
818
  });
591
819
  }
592
820
  }
@@ -598,23 +826,26 @@ function checkHostsFile(ips, domains, ruleByIOC, results) {
598
826
  function checkProcesses(rules) {
599
827
  const results = [];
600
828
  const os = getOS();
601
- const processPatterns = /* @__PURE__ */ new Map();
829
+ const rulesByProcess = /* @__PURE__ */ new Map();
602
830
  for (const rule of rules) {
603
831
  for (const proc of rule.ioc.processes ?? []) {
604
- processPatterns.set(proc, rule.id);
832
+ const arr = rulesByProcess.get(proc) || [];
833
+ arr.push(rule.id);
834
+ rulesByProcess.set(proc, arr);
605
835
  }
606
836
  }
607
- if (processPatterns.size === 0) return results;
608
- const processList = getProcessList(os);
609
- if (!processList) return results;
610
- for (const [pattern, ruleId] of processPatterns) {
611
- if (processList.includes(pattern)) {
837
+ if (rulesByProcess.size === 0) return results;
838
+ const processLines = getProcessList(os).split("\n");
839
+ if (processLines.length === 0) return results;
840
+ for (const [pattern, ruleIds] of rulesByProcess) {
841
+ const regex = new RegExp(`(?:^|[\\s/])${escapeRegex(pattern)}(?:\\s|$)`);
842
+ if (processLines.some((line2) => regex.test(line2))) {
612
843
  results.push({
613
844
  type: "fail",
614
- rule: ruleId,
845
+ rule: ruleIds[0],
615
846
  check: "processes",
616
847
  message: `Suspicious process running: ${pattern}`,
617
- details: "This may indicate an active compromise"
848
+ details: `Rules: ${ruleIds.join(", ")} \u2014 May indicate active compromise`
618
849
  });
619
850
  }
620
851
  }
@@ -652,12 +883,10 @@ function checkMacOSPersistence(rules, results) {
652
883
  const existingDirs = launchDirs.filter(dirExists);
653
884
  if (existingDirs.length === 0 || searchStrings.size === 0) return;
654
885
  const pattern = Array.from(searchStrings.keys()).join("|");
655
- const hits = runCommand(
656
- `grep -rlE "${pattern}" ${existingDirs.map((d) => `"${d}"`).join(" ")} 2>/dev/null`
657
- );
886
+ const hits = runSafe("grep", ["-rlE", pattern, ...existingDirs]);
658
887
  if (hits) {
659
888
  for (const file of hits.split("\n").filter(Boolean)) {
660
- const content = runCommand(`cat "${file}" 2>/dev/null`);
889
+ const content = runSafe("cat", [file]);
661
890
  let matchedRule = "unknown";
662
891
  for (const [s, ruleId] of searchStrings) {
663
892
  if (content.includes(s)) {
@@ -677,12 +906,12 @@ function checkMacOSPersistence(rules, results) {
677
906
  }
678
907
 
679
908
  // src/checks/cache.ts
680
- import { join as join3 } from "path";
909
+ import { join as join4 } from "path";
681
910
  import { homedir as homedir2 } from "os";
682
911
  function detectCaches() {
683
912
  const home = homedir2();
684
913
  const caches = [];
685
- const npmCache = runCommand("npm config get cache") || join3(home, ".npm");
914
+ const npmCache = runCommand("npm config get cache") || join4(home, ".npm");
686
915
  if (dirExists(npmCache)) {
687
916
  caches.push({ name: "npm", dir: npmCache, cleanCmd: "npm cache clean --force" });
688
917
  }
@@ -694,11 +923,11 @@ function detectCaches() {
694
923
  if (yarnCache && dirExists(yarnCache)) {
695
924
  caches.push({ name: "yarn", dir: yarnCache, cleanCmd: "yarn cache clean" });
696
925
  }
697
- const yarnBerryCache = join3(process.cwd(), ".yarn", "cache");
926
+ const yarnBerryCache = join4(process.cwd(), ".yarn", "cache");
698
927
  if (dirExists(yarnBerryCache)) {
699
928
  caches.push({ name: "yarn-berry", dir: yarnBerryCache, cleanCmd: "yarn cache clean --all" });
700
929
  }
701
- const bunCache = join3(home, ".bun", "install", "cache");
930
+ const bunCache = join4(home, ".bun", "install", "cache");
702
931
  if (dirExists(bunCache)) {
703
932
  caches.push({ name: "bun", dir: bunCache, cleanCmd: "bun pm cache rm" });
704
933
  }
@@ -726,48 +955,40 @@ function checkCache(rules) {
726
955
  }
727
956
  return results;
728
957
  }
729
- function scanCacheDir(cache, maliciousPkgs, compromisedTarballs, results) {
730
- if (maliciousPkgs.size > 0) {
731
- const nameArgs = Array.from(maliciousPkgs.keys()).map((n) => `-name "${n}"`).join(" -o ");
732
- const found = runCommand(
733
- `find "${cache.dir}" -maxdepth 4 -type d \\( ${nameArgs} \\) 2>/dev/null`
734
- );
735
- if (found) {
736
- for (const line of found.split("\n").filter(Boolean)) {
737
- const name = line.split("/").pop() || "";
738
- const ruleId = maliciousPkgs.get(name);
739
- if (ruleId) {
740
- results.push({
741
- type: "fail",
742
- rule: ruleId,
743
- check: "cache",
744
- message: `Malicious package "${name}" found in ${cache.name} cache`,
745
- details: `Run: ${cache.cleanCmd}`
746
- });
747
- }
748
- }
958
+ function findInCache(cacheDir, names, type) {
959
+ const hits = /* @__PURE__ */ new Map();
960
+ if (names.size === 0) return hits;
961
+ const nameExprs = Array.from(names.keys()).flatMap(
962
+ (n, i) => i === 0 ? ["-name", n] : ["-o", "-name", n]
963
+ );
964
+ const found = runSafe("find", [cacheDir, "-maxdepth", "4", "-type", type, "(", ...nameExprs, ")"]);
965
+ if (found) {
966
+ for (const line2 of found.split("\n").filter(Boolean)) {
967
+ const name = line2.split("/").pop() || "";
968
+ const ruleId = names.get(name);
969
+ if (ruleId) hits.set(name, ruleId);
749
970
  }
750
971
  }
751
- if (compromisedTarballs.size > 0) {
752
- const nameArgs = Array.from(compromisedTarballs.keys()).map((n) => `-name "${n}"`).join(" -o ");
753
- const found = runCommand(
754
- `find "${cache.dir}" -maxdepth 4 -type f \\( ${nameArgs} \\) 2>/dev/null`
755
- );
756
- if (found) {
757
- for (const line of found.split("\n").filter(Boolean)) {
758
- const name = line.split("/").pop() || "";
759
- const ruleId = compromisedTarballs.get(name);
760
- if (ruleId) {
761
- results.push({
762
- type: "warn",
763
- rule: ruleId,
764
- check: "cache",
765
- message: `Compromised tarball ${name} in ${cache.name} cache`,
766
- details: `Run: ${cache.cleanCmd}`
767
- });
768
- }
769
- }
770
- }
972
+ return hits;
973
+ }
974
+ function scanCacheDir(cache, maliciousPkgs, compromisedTarballs, results) {
975
+ for (const [name, ruleId] of findInCache(cache.dir, maliciousPkgs, "d")) {
976
+ results.push({
977
+ type: "fail",
978
+ rule: ruleId,
979
+ check: "cache",
980
+ message: `Malicious package "${name}" found in ${cache.name} cache`,
981
+ details: `Run: ${cache.cleanCmd}`
982
+ });
983
+ }
984
+ for (const [name, ruleId] of findInCache(cache.dir, compromisedTarballs, "f")) {
985
+ results.push({
986
+ type: "warn",
987
+ rule: ruleId,
988
+ check: "cache",
989
+ message: `Compromised tarball ${name} in ${cache.name} cache`,
990
+ details: `Run: ${cache.cleanCmd}`
991
+ });
771
992
  }
772
993
  }
773
994
 
@@ -830,19 +1051,27 @@ function displayResults(results, passMessage) {
830
1051
  }
831
1052
  function buildSummary(results, projectsScanned, rulesChecked, totalChecks) {
832
1053
  const compromisedProjects = /* @__PURE__ */ new Set();
1054
+ let failCount = 0;
1055
+ let warnCount = 0;
1056
+ let passCount = 0;
833
1057
  for (const r of results) {
834
- if (r.type === "fail" && r.details) {
835
- const match = r.details.match(/— (.+)$/);
836
- if (match) compromisedProjects.add(match[1]);
1058
+ if (r.type === "fail") {
1059
+ failCount++;
1060
+ if (r.details) {
1061
+ const match = r.details.match(/— (.+)$/);
1062
+ if (match) compromisedProjects.add(match[1]);
1063
+ }
1064
+ } else if (r.type === "warn") {
1065
+ warnCount++;
1066
+ } else if (r.type === "pass") {
1067
+ passCount++;
837
1068
  }
838
1069
  }
839
- const failCount = results.filter((r) => r.type === "fail").length;
840
- const warnCount = results.filter((r) => r.type === "warn").length;
841
1070
  return {
842
1071
  projectsScanned,
843
1072
  rulesChecked,
844
1073
  totalChecks: results.length || totalChecks,
845
- passed: results.length === 0 ? totalChecks : results.length - failCount - warnCount,
1074
+ passed: results.length === 0 ? totalChecks : passCount,
846
1075
  failed: failCount,
847
1076
  warnings: warnCount,
848
1077
  results,
@@ -853,8 +1082,9 @@ function buildSummary(results, projectsScanned, rulesChecked, totalChecks) {
853
1082
  // src/index.ts
854
1083
  var __filename = fileURLToPath(import.meta.url);
855
1084
  var __dirname = dirname(__filename);
856
- var RULES_DIR = join4(__dirname, "..", "rules");
857
- var VERSION = "1.0.0";
1085
+ var RULES_DIR = join5(__dirname, "..", "rules");
1086
+ var PKG_JSON_PATH = join5(__dirname, "..", "package.json");
1087
+ var VERSION = readJSON(PKG_JSON_PATH)?.version ?? "0.0.0";
858
1088
  var HELP_TEXT = `
859
1089
  ${c.white}supply-scan${c.reset} \u2014 Universal npm supply chain attack scanner
860
1090
 
@@ -888,53 +1118,49 @@ async function run(argv) {
888
1118
  const opts = parseArgs(argv);
889
1119
  if (opts.version) {
890
1120
  console.log(VERSION);
891
- process.exit(0);
1121
+ return 0;
892
1122
  }
893
1123
  if (opts.help) {
894
1124
  console.log(HELP_TEXT);
895
- process.exit(0);
1125
+ return 0;
896
1126
  }
897
1127
  const allRules = loadRules(RULES_DIR);
898
1128
  if (allRules.length === 0) {
899
1129
  console.error(`${c.red}No rules found in ${RULES_DIR}${c.reset}`);
900
- process.exit(1);
1130
+ return 1;
901
1131
  }
902
1132
  if (opts.list) {
903
- banner(allRules.length);
1133
+ banner(allRules.length, VERSION);
904
1134
  printRuleList(allRules);
905
- process.exit(0);
1135
+ return 0;
906
1136
  }
907
1137
  let selectedRules;
908
1138
  if (opts.rules.length > 0) {
909
1139
  selectedRules = allRules.filter((r) => opts.rules.includes(r.id));
910
1140
  if (selectedRules.length === 0) {
911
1141
  console.error(`${c.red}No matching rules found. Use --list to see available rules.${c.reset}`);
912
- process.exit(1);
1142
+ return 1;
913
1143
  }
914
1144
  } else if (opts.all || opts.ci) {
915
1145
  selectedRules = allRules;
916
1146
  } else {
917
- banner(allRules.length);
1147
+ banner(allRules.length, VERSION);
918
1148
  selectedRules = await interactiveRuleSelection(allRules);
919
1149
  }
920
1150
  if (!opts.ci && (opts.all || opts.rules.length > 0)) {
921
- banner(selectedRules.length);
1151
+ banner(selectedRules.length, VERSION);
922
1152
  }
923
1153
  let projectDirs;
924
1154
  if (opts.path) {
925
- if (!opts.ci) spinnerStart("Searching for npm projects...");
926
- projectDirs = findProjects([opts.path]);
927
- if (!opts.ci) spinnerStop();
1155
+ projectDirs = discoverProjects([opts.path], opts.ci);
928
1156
  } else if (opts.ci || opts.all) {
929
- if (!opts.ci) spinnerStart("Searching for npm projects...");
930
- projectDirs = findProjects([process.cwd()]);
931
- if (!opts.ci) spinnerStop();
1157
+ projectDirs = discoverProjects([process.cwd()], opts.ci);
932
1158
  } else {
933
1159
  projectDirs = await interactivePathSelection();
934
1160
  }
935
1161
  if (!opts.ci) {
936
- console.log(`
937
- ${c.dim}Scanning ${projectDirs.length} projects with ${selectedRules.length} rules...${c.reset}`);
1162
+ console.log("");
1163
+ info(`Scanning ${projectDirs.length} projects with ${selectedRules.length} rules...`);
938
1164
  }
939
1165
  const summary = await scan({
940
1166
  rules: selectedRules,
@@ -945,55 +1171,70 @@ async function run(argv) {
945
1171
  printSummary(summary);
946
1172
  }
947
1173
  if (summary.failed > 0) {
948
- process.exit(1);
1174
+ return 1;
949
1175
  } else if (summary.warnings > 0) {
950
- process.exit(2);
1176
+ return 2;
951
1177
  } else {
952
1178
  if (opts.ci) {
953
1179
  console.log("OK");
954
1180
  }
955
- process.exit(0);
1181
+ return 0;
956
1182
  }
957
1183
  }
958
- async function interactiveRuleSelection(rules) {
959
- console.log("");
960
- console.log(` ${c.white}Select attacks to scan:${c.reset}`);
961
- console.log("");
962
- console.log(` ${c.cyan}0${c.reset}) ${c.green}All attacks (${rules.length} rules)${c.reset}`);
963
- for (let i = 0; i < rules.length; i++) {
964
- const r = rules[i];
965
- const sc = severityColors[r.severity] || c.dim;
966
- console.log(` ${c.cyan}${i + 1}${c.reset}) ${r.name} ${sc}(${r.severity})${c.reset}`);
1184
+ function discoverProjects(dirs, ci) {
1185
+ if (!ci) spinnerStart("Searching for npm projects...");
1186
+ const projects = findProjects(dirs);
1187
+ if (!ci) {
1188
+ spinnerStop();
1189
+ success(`Found ${projects.length} projects`);
967
1190
  }
1191
+ return projects;
1192
+ }
1193
+ async function interactiveRuleSelection(rules) {
968
1194
  console.log("");
969
- const answer = await prompt(` ${c.yellow}Enter numbers separated by commas (default=0): ${c.reset}`);
970
- if (!answer || answer === "0") {
971
- return rules;
972
- }
973
- const indices = answer.split(",").map((s) => parseInt(s.trim(), 10) - 1);
974
- const selected = indices.filter((i) => i >= 0 && i < rules.length).map((i) => rules[i]);
1195
+ const selected = await interactiveMultiSelect({
1196
+ message: "Select attacks to scan:",
1197
+ items: rules.map((r) => ({
1198
+ label: r.name,
1199
+ value: r,
1200
+ hint: `${r.severity} ${r.date}`
1201
+ })),
1202
+ allOption: { label: `All attacks (${rules.length} rules)` }
1203
+ });
975
1204
  return selected.length > 0 ? selected : rules;
976
1205
  }
977
1206
  async function interactivePathSelection() {
978
- console.log("");
979
- console.log(` ${c.white}Where should I scan?${c.reset}`);
980
- console.log("");
981
- console.log(` ${c.cyan}1${c.reset}) Current directory`);
982
- console.log(` ${c.cyan}2${c.reset}) Common project directories`);
983
- console.log(` ${c.cyan}3${c.reset}) Enter a custom path`);
984
- console.log("");
985
- const choice = await prompt(` ${c.yellow}Choose (1/2/3, default=1): ${c.reset}`);
1207
+ const choice = await interactiveSingleSelect({
1208
+ message: "Where should I scan?",
1209
+ items: [
1210
+ { label: "Current directory", value: "cwd", hint: process.cwd() },
1211
+ { label: "Common project directories", value: "common", hint: "Desktop, Projects, dev..." },
1212
+ { label: "Entire home directory", value: "home", hint: "(slower)" },
1213
+ { label: "Enter a custom path", value: "custom" }
1214
+ ]
1215
+ });
986
1216
  let baseDirs;
987
1217
  switch (choice) {
988
- case "2":
1218
+ case "common":
989
1219
  baseDirs = getCommonProjectDirs();
990
1220
  if (baseDirs.length === 0) {
991
- console.log(` ${c.dim}No common directories found, scanning current directory...${c.reset}`);
1221
+ info("No common directories found, scanning current directory...");
992
1222
  baseDirs = [process.cwd()];
1223
+ } else {
1224
+ info(`Scanning ${baseDirs.length} directories:`);
1225
+ for (const d of baseDirs.slice(0, 5)) {
1226
+ console.log(` ${c.dim}${d}${c.reset}`);
1227
+ }
1228
+ if (baseDirs.length > 5) console.log(` ${c.dim}...and ${baseDirs.length - 5} more${c.reset}`);
993
1229
  }
994
1230
  break;
995
- case "3": {
996
- const customPath = await prompt(` ${c.cyan}Enter path: ${c.reset}`);
1231
+ case "home":
1232
+ baseDirs = [process.env.HOME || "/"];
1233
+ info(`Scanning home directory (this may take a while)...`);
1234
+ break;
1235
+ case "custom": {
1236
+ const customPath = await prompt(`
1237
+ ${c.cyan}Enter path:${c.reset} `);
997
1238
  baseDirs = [customPath.replace(/^~/, process.env.HOME || "")];
998
1239
  break;
999
1240
  }
@@ -1004,12 +1245,15 @@ async function interactivePathSelection() {
1004
1245
  spinnerStart("Searching for npm projects...");
1005
1246
  const projects = findProjects(baseDirs);
1006
1247
  spinnerStop();
1007
- console.log(` ${c.dim}Found ${projects.length} npm projects${c.reset}`);
1248
+ success(`Found ${projects.length} npm projects`);
1008
1249
  return projects;
1009
1250
  }
1010
- var isCLI = process.argv[1]?.includes("supply-scan") || process.argv[1]?.includes("dist/index");
1251
+ var isCLI = process.argv[1]?.endsWith("supply-scan") || process.argv[1]?.endsWith("dist/index.js") || process.argv[1]?.includes("/supply-scan/");
1011
1252
  if (isCLI) {
1012
- run(process.argv.slice(2));
1253
+ run(process.argv.slice(2)).then((code) => process.exit(code)).catch((err) => {
1254
+ console.error(`${c.red}Error: ${err instanceof Error ? err.message : err}${c.reset}`);
1255
+ process.exit(1);
1256
+ });
1013
1257
  }
1014
1258
  export {
1015
1259
  run