supply-scan 1.0.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/LICENSE +21 -0
- package/README.md +148 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1017 -0
- package/dist/index.js.map +1 -0
- package/package.json +64 -0
- package/rules/axios-2026.json +31 -0
- package/rules/chalk-debug-2025.json +40 -0
- package/rules/coa-rc-2021.json +25 -0
- package/rules/colors-faker-2022.json +20 -0
- package/rules/eslint-scope-2018.json +21 -0
- package/rules/event-stream-2018.json +22 -0
- package/rules/glassworm-2026.json +25 -0
- package/rules/lottie-player-2024.json +19 -0
- package/rules/node-ipc-2022.json +28 -0
- package/rules/shai-hulud-2025.json +25 -0
- package/rules/solana-web3-2024.json +20 -0
- package/rules/ua-parser-js-2021.json +24 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1017 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { fileURLToPath } from "url";
|
|
5
|
+
import { dirname, join as join4 } from "path";
|
|
6
|
+
|
|
7
|
+
// src/utils.ts
|
|
8
|
+
import { readFileSync, readdirSync, existsSync, statSync } from "fs";
|
|
9
|
+
import { join, resolve } from "path";
|
|
10
|
+
import { platform, homedir } from "os";
|
|
11
|
+
import { execSync } from "child_process";
|
|
12
|
+
import { createInterface } from "readline";
|
|
13
|
+
function getOS() {
|
|
14
|
+
return platform();
|
|
15
|
+
}
|
|
16
|
+
function readJSON(path) {
|
|
17
|
+
try {
|
|
18
|
+
const content = readFileSync(path, "utf-8");
|
|
19
|
+
return JSON.parse(content);
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function getPkgVersion(pkgJsonPath) {
|
|
25
|
+
const pkg = readJSON(pkgJsonPath);
|
|
26
|
+
return pkg?.version ?? null;
|
|
27
|
+
}
|
|
28
|
+
function isVersionCompromised(version, badVersions) {
|
|
29
|
+
if (!version) return false;
|
|
30
|
+
return badVersions.includes(version);
|
|
31
|
+
}
|
|
32
|
+
function expandPath(p) {
|
|
33
|
+
let result2 = p;
|
|
34
|
+
if (result2.startsWith("~")) {
|
|
35
|
+
result2 = join(homedir(), result2.slice(1));
|
|
36
|
+
}
|
|
37
|
+
if (result2.includes("%PROGRAMDATA%")) {
|
|
38
|
+
result2 = result2.replace("%PROGRAMDATA%", process.env.PROGRAMDATA || "C:\\ProgramData");
|
|
39
|
+
}
|
|
40
|
+
if (result2.includes("%TEMP%")) {
|
|
41
|
+
result2 = result2.replace("%TEMP%", process.env.TEMP || "/tmp");
|
|
42
|
+
}
|
|
43
|
+
if (result2.includes("%USERPROFILE%")) {
|
|
44
|
+
result2 = result2.replace("%USERPROFILE%", homedir());
|
|
45
|
+
}
|
|
46
|
+
return result2;
|
|
47
|
+
}
|
|
48
|
+
function findProjects(baseDirs, maxDepth = 8) {
|
|
49
|
+
const projects = /* @__PURE__ */ new Set();
|
|
50
|
+
function walk(dir, depth) {
|
|
51
|
+
if (depth > maxDepth) return;
|
|
52
|
+
try {
|
|
53
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
54
|
+
for (const entry of entries) {
|
|
55
|
+
if (!entry.isDirectory()) {
|
|
56
|
+
if (entry.name === "package.json" && depth > 0) {
|
|
57
|
+
projects.add(dir);
|
|
58
|
+
}
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
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;
|
|
71
|
+
walk(join(dir, entry.name), depth + 1);
|
|
72
|
+
}
|
|
73
|
+
} catch {
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
for (const base of baseDirs) {
|
|
77
|
+
if (!existsSync(base)) continue;
|
|
78
|
+
if (existsSync(join(base, "package.json"))) {
|
|
79
|
+
projects.add(base);
|
|
80
|
+
}
|
|
81
|
+
walk(base, 0);
|
|
82
|
+
}
|
|
83
|
+
return Array.from(projects);
|
|
84
|
+
}
|
|
85
|
+
function getCommonProjectDirs() {
|
|
86
|
+
const home = homedir();
|
|
87
|
+
const candidates = [
|
|
88
|
+
"Desktop",
|
|
89
|
+
"Documents",
|
|
90
|
+
"Projects",
|
|
91
|
+
"projects",
|
|
92
|
+
"Developer",
|
|
93
|
+
"dev",
|
|
94
|
+
"code",
|
|
95
|
+
"Code",
|
|
96
|
+
"Sites",
|
|
97
|
+
"sites",
|
|
98
|
+
"www",
|
|
99
|
+
"Work",
|
|
100
|
+
"work",
|
|
101
|
+
"workspace",
|
|
102
|
+
"Workspace",
|
|
103
|
+
"repos",
|
|
104
|
+
"Repos",
|
|
105
|
+
"src",
|
|
106
|
+
"github",
|
|
107
|
+
"GitHub"
|
|
108
|
+
];
|
|
109
|
+
return candidates.map((d) => join(home, d)).filter((d) => existsSync(d));
|
|
110
|
+
}
|
|
111
|
+
function loadRules(rulesDir) {
|
|
112
|
+
const rules = [];
|
|
113
|
+
try {
|
|
114
|
+
const files = readdirSync(rulesDir).filter((f) => f.endsWith(".json"));
|
|
115
|
+
for (const file of files) {
|
|
116
|
+
const rule = readJSON(join(rulesDir, file));
|
|
117
|
+
if (rule && rule.id && rule.packages) {
|
|
118
|
+
rules.push(rule);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
} catch {
|
|
122
|
+
}
|
|
123
|
+
rules.sort((a, b) => b.date.localeCompare(a.date));
|
|
124
|
+
return rules;
|
|
125
|
+
}
|
|
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
|
+
}
|
|
182
|
+
function prompt(question) {
|
|
183
|
+
const rl = createInterface({
|
|
184
|
+
input: process.stdin,
|
|
185
|
+
output: process.stdout
|
|
186
|
+
});
|
|
187
|
+
return new Promise((resolve2) => {
|
|
188
|
+
rl.question(question, (answer) => {
|
|
189
|
+
rl.close();
|
|
190
|
+
resolve2(answer.trim());
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
}
|
|
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
|
+
|
|
209
|
+
// src/ui.ts
|
|
210
|
+
var ESC = "\x1B";
|
|
211
|
+
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`
|
|
224
|
+
};
|
|
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();
|
|
260
|
+
}
|
|
261
|
+
function divider() {
|
|
262
|
+
console.log(`${c.dim}${"\u2500".repeat(60)}${c.reset}`);
|
|
263
|
+
}
|
|
264
|
+
function sectionHeader(num, total, title, icon) {
|
|
265
|
+
console.log("");
|
|
266
|
+
console.log(`${c.bgBlue}${c.white}${c.bold} ${icon} CHECK ${num}/${total} \u2502 ${title} ${c.reset}`);
|
|
267
|
+
divider();
|
|
268
|
+
}
|
|
269
|
+
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}`);
|
|
283
|
+
}
|
|
284
|
+
function resultDetail(msg) {
|
|
285
|
+
console.log(` ${c.red}\u2192 ${msg}${c.reset}`);
|
|
286
|
+
}
|
|
287
|
+
var SPIN_FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
|
|
288
|
+
var spinnerTimer = null;
|
|
289
|
+
var spinnerFrame = 0;
|
|
290
|
+
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++;
|
|
296
|
+
}, 80);
|
|
297
|
+
}
|
|
298
|
+
function spinnerStop() {
|
|
299
|
+
if (spinnerTimer) {
|
|
300
|
+
clearInterval(spinnerTimer);
|
|
301
|
+
spinnerTimer = null;
|
|
302
|
+
process.stdout.write("\r\x1B[K");
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
var severityColors = {
|
|
306
|
+
critical: c.red,
|
|
307
|
+
high: c.yellow,
|
|
308
|
+
medium: c.cyan,
|
|
309
|
+
low: c.dim
|
|
310
|
+
};
|
|
311
|
+
function printRuleList(rules) {
|
|
312
|
+
console.log("");
|
|
313
|
+
console.log(` ${c.white}Available attack rules (${rules.length}):${c.reset}`);
|
|
314
|
+
console.log("");
|
|
315
|
+
for (const rule of rules) {
|
|
316
|
+
const sc = severityColors[rule.severity] || c.dim;
|
|
317
|
+
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}`);
|
|
322
|
+
}
|
|
323
|
+
console.log("");
|
|
324
|
+
}
|
|
325
|
+
function printSummary(summary) {
|
|
326
|
+
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}`);
|
|
330
|
+
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}`);
|
|
337
|
+
console.log("");
|
|
338
|
+
divider();
|
|
339
|
+
if (summary.failed > 0) {
|
|
340
|
+
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("");
|
|
345
|
+
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("");
|
|
352
|
+
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
|
+
console.log("");
|
|
358
|
+
}
|
|
359
|
+
const failedByRule = /* @__PURE__ */ new Map();
|
|
360
|
+
for (const r of summary.results.filter((r2) => r2.type === "fail")) {
|
|
361
|
+
const arr = failedByRule.get(r.rule) || [];
|
|
362
|
+
arr.push(r);
|
|
363
|
+
failedByRule.set(r.rule, arr);
|
|
364
|
+
}
|
|
365
|
+
for (const [rule, results] of failedByRule) {
|
|
366
|
+
console.log(` ${c.red}${c.bold}${rule}:${c.reset}`);
|
|
367
|
+
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}`);
|
|
370
|
+
}
|
|
371
|
+
console.log("");
|
|
372
|
+
}
|
|
373
|
+
} else if (summary.warnings > 0) {
|
|
374
|
+
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("");
|
|
379
|
+
} else {
|
|
380
|
+
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}`);
|
|
384
|
+
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`);
|
|
387
|
+
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("");
|
|
391
|
+
}
|
|
392
|
+
divider();
|
|
393
|
+
console.log("");
|
|
394
|
+
console.log(` ${c.dim}Scan completed at ${(/* @__PURE__ */ new Date()).toISOString()}${c.reset}`);
|
|
395
|
+
console.log(` ${c.dim}https://github.com/AsyrafHussin/supply-scan${c.reset}`);
|
|
396
|
+
console.log("");
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// src/checks/packages.ts
|
|
400
|
+
import { join as join2 } from "path";
|
|
401
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
402
|
+
function checkPackages(rules, projectDirs) {
|
|
403
|
+
const results = [];
|
|
404
|
+
for (const dir of projectDirs) {
|
|
405
|
+
const lockfileCache = readLockfiles(dir);
|
|
406
|
+
for (const rule of rules) {
|
|
407
|
+
for (const [pkg, badVersions] of Object.entries(rule.packages.compromised)) {
|
|
408
|
+
const version = getPkgVersion(join2(dir, "node_modules", pkg, "package.json"));
|
|
409
|
+
if (version && isVersionCompromised(version, badVersions)) {
|
|
410
|
+
results.push({
|
|
411
|
+
type: "fail",
|
|
412
|
+
rule: rule.id,
|
|
413
|
+
check: "packages",
|
|
414
|
+
message: `${pkg}@${version} is COMPROMISED`,
|
|
415
|
+
details: `${rule.name} \u2014 ${dir}`
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
for (const [pkg] of Object.entries(rule.packages.malicious)) {
|
|
420
|
+
const pkgDir = join2(dir, "node_modules", pkg);
|
|
421
|
+
if (dirExists(pkgDir)) {
|
|
422
|
+
const version = getPkgVersion(join2(pkgDir, "package.json"));
|
|
423
|
+
results.push({
|
|
424
|
+
type: "fail",
|
|
425
|
+
rule: rule.id,
|
|
426
|
+
check: "packages",
|
|
427
|
+
message: `Malicious package ${pkg}${version ? `@${version}` : ""} found`,
|
|
428
|
+
details: `${rule.name} \u2014 ${dir}`
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
checkLockfilesForRule(lockfileCache, dir, rule, results);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return results;
|
|
436
|
+
}
|
|
437
|
+
function readLockfiles(dir) {
|
|
438
|
+
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) {
|
|
449
|
+
try {
|
|
450
|
+
const encoding = name === "bun.lockb" ? "latin1" : "utf-8";
|
|
451
|
+
cache.set(name, readFileSync2(join2(dir, name), encoding));
|
|
452
|
+
} catch {
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return cache;
|
|
456
|
+
}
|
|
457
|
+
function checkLockfilesForRule(lockfileCache, dir, rule, results) {
|
|
458
|
+
for (const [lockfile, content] of lockfileCache) {
|
|
459
|
+
for (const pkg of Object.keys(rule.packages.malicious)) {
|
|
460
|
+
if (content.includes(pkg)) {
|
|
461
|
+
results.push({
|
|
462
|
+
type: "fail",
|
|
463
|
+
rule: rule.id,
|
|
464
|
+
check: "lockfile",
|
|
465
|
+
message: `${lockfile} references malicious package "${pkg}"`,
|
|
466
|
+
details: `${rule.name} \u2014 ${dir}`
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
for (const [pkg, versions] of Object.entries(rule.packages.compromised)) {
|
|
471
|
+
for (const ver of versions) {
|
|
472
|
+
const tied = [
|
|
473
|
+
`"${pkg}": "${ver}"`,
|
|
474
|
+
// package-lock.json / bun.lock
|
|
475
|
+
`${pkg}@${ver}`,
|
|
476
|
+
// yarn.lock / pnpm-lock.yaml
|
|
477
|
+
`"${pkg}","${ver}"`
|
|
478
|
+
// bun.lockb binary format
|
|
479
|
+
];
|
|
480
|
+
if (tied.some((p) => content.includes(p))) {
|
|
481
|
+
results.push({
|
|
482
|
+
type: "warn",
|
|
483
|
+
rule: rule.id,
|
|
484
|
+
check: "lockfile",
|
|
485
|
+
message: `${lockfile} may reference ${pkg}@${ver}`,
|
|
486
|
+
details: `${rule.name} \u2014 ${dir}`
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// src/checks/files.ts
|
|
495
|
+
function checkFiles(rules) {
|
|
496
|
+
const results = [];
|
|
497
|
+
const os = getOS();
|
|
498
|
+
for (const rule of rules) {
|
|
499
|
+
const filePaths = rule.ioc.files?.[os];
|
|
500
|
+
if (!filePaths || filePaths.length === 0) continue;
|
|
501
|
+
for (const rawPath of filePaths) {
|
|
502
|
+
const path = expandPath(rawPath);
|
|
503
|
+
if (fileExists(path)) {
|
|
504
|
+
results.push({
|
|
505
|
+
type: "fail",
|
|
506
|
+
rule: rule.id,
|
|
507
|
+
check: "files",
|
|
508
|
+
message: `Malware artifact found: ${path}`,
|
|
509
|
+
details: rule.name
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return results;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// src/checks/network.ts
|
|
518
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
519
|
+
function checkNetwork(rules) {
|
|
520
|
+
const results = [];
|
|
521
|
+
const os = getOS();
|
|
522
|
+
const allIPs = /* @__PURE__ */ new Set();
|
|
523
|
+
const allDomains = /* @__PURE__ */ new Set();
|
|
524
|
+
const ruleByIOC = /* @__PURE__ */ new Map();
|
|
525
|
+
for (const rule of rules) {
|
|
526
|
+
for (const ip of rule.ioc.ips ?? []) {
|
|
527
|
+
allIPs.add(ip);
|
|
528
|
+
ruleByIOC.set(ip, rule.id);
|
|
529
|
+
}
|
|
530
|
+
for (const domain of rule.ioc.domains ?? []) {
|
|
531
|
+
allDomains.add(domain);
|
|
532
|
+
ruleByIOC.set(domain, rule.id);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
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)) {
|
|
539
|
+
results.push({
|
|
540
|
+
type: "fail",
|
|
541
|
+
rule: ruleByIOC.get(ip) || "unknown",
|
|
542
|
+
check: "network",
|
|
543
|
+
message: `Active connection to C2 IP: ${ip}`,
|
|
544
|
+
details: "Disconnect from network immediately!"
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
for (const domain of allDomains) {
|
|
549
|
+
if (connections.includes(domain)) {
|
|
550
|
+
results.push({
|
|
551
|
+
type: "fail",
|
|
552
|
+
rule: ruleByIOC.get(domain) || "unknown",
|
|
553
|
+
check: "network",
|
|
554
|
+
message: `Active connection to C2 domain: ${domain}`,
|
|
555
|
+
details: "Disconnect from network immediately!"
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
checkHostsFile(allIPs, allDomains, ruleByIOC, results);
|
|
560
|
+
return results;
|
|
561
|
+
}
|
|
562
|
+
function getActiveConnections(os) {
|
|
563
|
+
if (os === "darwin" || os === "linux") {
|
|
564
|
+
return runCommand("lsof -i -n -P 2>/dev/null || netstat -an 2>/dev/null");
|
|
565
|
+
} else if (os === "win32") {
|
|
566
|
+
return runCommand("netstat -an");
|
|
567
|
+
}
|
|
568
|
+
return "";
|
|
569
|
+
}
|
|
570
|
+
function checkHostsFile(ips, domains, ruleByIOC, results) {
|
|
571
|
+
try {
|
|
572
|
+
const hosts = readFileSync3("/etc/hosts", "utf-8");
|
|
573
|
+
for (const ip of ips) {
|
|
574
|
+
if (hosts.includes(ip)) {
|
|
575
|
+
results.push({
|
|
576
|
+
type: "warn",
|
|
577
|
+
rule: ruleByIOC.get(ip) || "unknown",
|
|
578
|
+
check: "network",
|
|
579
|
+
message: `C2 IP ${ip} found in /etc/hosts`
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
for (const domain of domains) {
|
|
584
|
+
if (hosts.includes(domain)) {
|
|
585
|
+
results.push({
|
|
586
|
+
type: "warn",
|
|
587
|
+
rule: ruleByIOC.get(domain) || "unknown",
|
|
588
|
+
check: "network",
|
|
589
|
+
message: `C2 domain ${domain} found in /etc/hosts`
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
} catch {
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// src/checks/processes.ts
|
|
598
|
+
function checkProcesses(rules) {
|
|
599
|
+
const results = [];
|
|
600
|
+
const os = getOS();
|
|
601
|
+
const processPatterns = /* @__PURE__ */ new Map();
|
|
602
|
+
for (const rule of rules) {
|
|
603
|
+
for (const proc of rule.ioc.processes ?? []) {
|
|
604
|
+
processPatterns.set(proc, rule.id);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
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)) {
|
|
612
|
+
results.push({
|
|
613
|
+
type: "fail",
|
|
614
|
+
rule: ruleId,
|
|
615
|
+
check: "processes",
|
|
616
|
+
message: `Suspicious process running: ${pattern}`,
|
|
617
|
+
details: "This may indicate an active compromise"
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
if (os === "darwin") {
|
|
622
|
+
checkMacOSPersistence(rules, results);
|
|
623
|
+
}
|
|
624
|
+
return results;
|
|
625
|
+
}
|
|
626
|
+
function getProcessList(os) {
|
|
627
|
+
if (os === "darwin" || os === "linux") {
|
|
628
|
+
return runCommand("ps aux 2>/dev/null");
|
|
629
|
+
} else if (os === "win32") {
|
|
630
|
+
return runCommand("tasklist /v 2>nul");
|
|
631
|
+
}
|
|
632
|
+
return "";
|
|
633
|
+
}
|
|
634
|
+
function checkMacOSPersistence(rules, results) {
|
|
635
|
+
const launchDirs = [
|
|
636
|
+
`${process.env.HOME}/Library/LaunchAgents`,
|
|
637
|
+
"/Library/LaunchAgents",
|
|
638
|
+
"/Library/LaunchDaemons"
|
|
639
|
+
];
|
|
640
|
+
const searchStrings = /* @__PURE__ */ new Map();
|
|
641
|
+
for (const rule of rules) {
|
|
642
|
+
for (const s of rule.ioc.strings ?? []) {
|
|
643
|
+
searchStrings.set(s, rule.id);
|
|
644
|
+
}
|
|
645
|
+
for (const domain of rule.ioc.domains ?? []) {
|
|
646
|
+
searchStrings.set(domain, rule.id);
|
|
647
|
+
}
|
|
648
|
+
for (const ip of rule.ioc.ips ?? []) {
|
|
649
|
+
searchStrings.set(ip, rule.id);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
const existingDirs = launchDirs.filter(dirExists);
|
|
653
|
+
if (existingDirs.length === 0 || searchStrings.size === 0) return;
|
|
654
|
+
const pattern = Array.from(searchStrings.keys()).join("|");
|
|
655
|
+
const hits = runCommand(
|
|
656
|
+
`grep -rlE "${pattern}" ${existingDirs.map((d) => `"${d}"`).join(" ")} 2>/dev/null`
|
|
657
|
+
);
|
|
658
|
+
if (hits) {
|
|
659
|
+
for (const file of hits.split("\n").filter(Boolean)) {
|
|
660
|
+
const content = runCommand(`cat "${file}" 2>/dev/null`);
|
|
661
|
+
let matchedRule = "unknown";
|
|
662
|
+
for (const [s, ruleId] of searchStrings) {
|
|
663
|
+
if (content.includes(s)) {
|
|
664
|
+
matchedRule = ruleId;
|
|
665
|
+
break;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
results.push({
|
|
669
|
+
type: "fail",
|
|
670
|
+
rule: matchedRule,
|
|
671
|
+
check: "processes",
|
|
672
|
+
message: `Suspicious LaunchAgent/Daemon: ${file}`,
|
|
673
|
+
details: "May indicate malware persistence"
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// src/checks/cache.ts
|
|
680
|
+
import { join as join3 } from "path";
|
|
681
|
+
import { homedir as homedir2 } from "os";
|
|
682
|
+
function detectCaches() {
|
|
683
|
+
const home = homedir2();
|
|
684
|
+
const caches = [];
|
|
685
|
+
const npmCache = runCommand("npm config get cache") || join3(home, ".npm");
|
|
686
|
+
if (dirExists(npmCache)) {
|
|
687
|
+
caches.push({ name: "npm", dir: npmCache, cleanCmd: "npm cache clean --force" });
|
|
688
|
+
}
|
|
689
|
+
const pnpmStore = runCommand("pnpm store path 2>/dev/null");
|
|
690
|
+
if (pnpmStore && dirExists(pnpmStore)) {
|
|
691
|
+
caches.push({ name: "pnpm", dir: pnpmStore, cleanCmd: "pnpm store prune" });
|
|
692
|
+
}
|
|
693
|
+
const yarnCache = runCommand("yarn cache dir 2>/dev/null");
|
|
694
|
+
if (yarnCache && dirExists(yarnCache)) {
|
|
695
|
+
caches.push({ name: "yarn", dir: yarnCache, cleanCmd: "yarn cache clean" });
|
|
696
|
+
}
|
|
697
|
+
const yarnBerryCache = join3(process.cwd(), ".yarn", "cache");
|
|
698
|
+
if (dirExists(yarnBerryCache)) {
|
|
699
|
+
caches.push({ name: "yarn-berry", dir: yarnBerryCache, cleanCmd: "yarn cache clean --all" });
|
|
700
|
+
}
|
|
701
|
+
const bunCache = join3(home, ".bun", "install", "cache");
|
|
702
|
+
if (dirExists(bunCache)) {
|
|
703
|
+
caches.push({ name: "bun", dir: bunCache, cleanCmd: "bun pm cache rm" });
|
|
704
|
+
}
|
|
705
|
+
return caches;
|
|
706
|
+
}
|
|
707
|
+
function checkCache(rules) {
|
|
708
|
+
const results = [];
|
|
709
|
+
const caches = detectCaches();
|
|
710
|
+
if (caches.length === 0) return results;
|
|
711
|
+
const maliciousPkgs = /* @__PURE__ */ new Map();
|
|
712
|
+
const compromisedTarballs = /* @__PURE__ */ new Map();
|
|
713
|
+
for (const rule of rules) {
|
|
714
|
+
for (const pkg of Object.keys(rule.packages.malicious)) {
|
|
715
|
+
maliciousPkgs.set(pkg, rule.id);
|
|
716
|
+
}
|
|
717
|
+
for (const [pkg, versions] of Object.entries(rule.packages.compromised)) {
|
|
718
|
+
for (const ver of versions) {
|
|
719
|
+
compromisedTarballs.set(`${pkg}-${ver}.tgz`, rule.id);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
if (maliciousPkgs.size === 0 && compromisedTarballs.size === 0) return results;
|
|
724
|
+
for (const cache of caches) {
|
|
725
|
+
scanCacheDir(cache, maliciousPkgs, compromisedTarballs, results);
|
|
726
|
+
}
|
|
727
|
+
return results;
|
|
728
|
+
}
|
|
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
|
+
}
|
|
749
|
+
}
|
|
750
|
+
}
|
|
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
|
+
}
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// src/scanner.ts
|
|
775
|
+
async function scan(options) {
|
|
776
|
+
const { rules, projectDirs, ci } = options;
|
|
777
|
+
const allResults = [];
|
|
778
|
+
const checks = [
|
|
779
|
+
{
|
|
780
|
+
title: "COMPROMISED PACKAGES",
|
|
781
|
+
icon: "\u{1F4E6}",
|
|
782
|
+
passMessage: `All ${projectDirs.length} projects clean \u2014 no compromised packages`,
|
|
783
|
+
run: () => checkPackages(rules, projectDirs)
|
|
784
|
+
},
|
|
785
|
+
{
|
|
786
|
+
title: "MALWARE FILES",
|
|
787
|
+
icon: "\u{1F480}",
|
|
788
|
+
passMessage: "No malware artifacts found on disk",
|
|
789
|
+
run: () => checkFiles(rules)
|
|
790
|
+
},
|
|
791
|
+
{
|
|
792
|
+
title: "NETWORK CONNECTIONS",
|
|
793
|
+
icon: "\u{1F310}",
|
|
794
|
+
passMessage: "No active C2 connections detected",
|
|
795
|
+
run: () => checkNetwork(rules)
|
|
796
|
+
},
|
|
797
|
+
{
|
|
798
|
+
title: "SUSPICIOUS PROCESSES",
|
|
799
|
+
icon: "\u2699\uFE0F ",
|
|
800
|
+
passMessage: "No suspicious processes detected",
|
|
801
|
+
run: () => checkProcesses(rules)
|
|
802
|
+
},
|
|
803
|
+
{
|
|
804
|
+
title: "PACKAGE MANAGER CACHES",
|
|
805
|
+
icon: "\u{1F4C1}",
|
|
806
|
+
passMessage: "All package manager caches are clean",
|
|
807
|
+
run: () => checkCache(rules)
|
|
808
|
+
}
|
|
809
|
+
];
|
|
810
|
+
const totalChecks = checks.length;
|
|
811
|
+
for (let i = 0; i < checks.length; i++) {
|
|
812
|
+
const check = checks[i];
|
|
813
|
+
if (!ci) sectionHeader(i + 1, totalChecks, check.title, check.icon);
|
|
814
|
+
if (!ci) spinnerStart(`Running ${check.title.toLowerCase()} check...`);
|
|
815
|
+
const results = check.run();
|
|
816
|
+
allResults.push(...results);
|
|
817
|
+
if (!ci) spinnerStop();
|
|
818
|
+
if (!ci) displayResults(results, check.passMessage);
|
|
819
|
+
}
|
|
820
|
+
return buildSummary(allResults, projectDirs.length, rules.length, totalChecks);
|
|
821
|
+
}
|
|
822
|
+
function displayResults(results, passMessage) {
|
|
823
|
+
if (results.filter((r) => r.type === "fail" || r.type === "warn").length === 0) {
|
|
824
|
+
result("pass", passMessage);
|
|
825
|
+
}
|
|
826
|
+
for (const r of results) {
|
|
827
|
+
result(r.type, r.message);
|
|
828
|
+
if (r.details) resultDetail(r.details);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
function buildSummary(results, projectsScanned, rulesChecked, totalChecks) {
|
|
832
|
+
const compromisedProjects = /* @__PURE__ */ new Set();
|
|
833
|
+
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]);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
const failCount = results.filter((r) => r.type === "fail").length;
|
|
840
|
+
const warnCount = results.filter((r) => r.type === "warn").length;
|
|
841
|
+
return {
|
|
842
|
+
projectsScanned,
|
|
843
|
+
rulesChecked,
|
|
844
|
+
totalChecks: results.length || totalChecks,
|
|
845
|
+
passed: results.length === 0 ? totalChecks : results.length - failCount - warnCount,
|
|
846
|
+
failed: failCount,
|
|
847
|
+
warnings: warnCount,
|
|
848
|
+
results,
|
|
849
|
+
compromisedProjects: Array.from(compromisedProjects)
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// src/index.ts
|
|
854
|
+
var __filename = fileURLToPath(import.meta.url);
|
|
855
|
+
var __dirname = dirname(__filename);
|
|
856
|
+
var RULES_DIR = join4(__dirname, "..", "rules");
|
|
857
|
+
var VERSION = "1.0.0";
|
|
858
|
+
var HELP_TEXT = `
|
|
859
|
+
${c.white}supply-scan${c.reset} \u2014 Universal npm supply chain attack scanner
|
|
860
|
+
|
|
861
|
+
${c.yellow}USAGE${c.reset}
|
|
862
|
+
npx supply-scan Interactive mode (default)
|
|
863
|
+
npx supply-scan --all Scan all attacks, skip prompts
|
|
864
|
+
npx supply-scan --rule axios-2026 Scan specific attack(s)
|
|
865
|
+
npx supply-scan --list List all available rules
|
|
866
|
+
npx supply-scan --ci CI mode (non-interactive)
|
|
867
|
+
|
|
868
|
+
${c.yellow}OPTIONS${c.reset}
|
|
869
|
+
-a, --all Scan all attacks (skip rule selection)
|
|
870
|
+
-r, --rule <id> Scan specific rule (repeatable)
|
|
871
|
+
-p, --path <dir> Scan specific directory
|
|
872
|
+
-l, --list List all available rules
|
|
873
|
+
--ci CI mode (non-interactive, exit codes only)
|
|
874
|
+
-h, --help Show this help
|
|
875
|
+
-v, --version Show version
|
|
876
|
+
|
|
877
|
+
${c.yellow}EXIT CODES${c.reset}
|
|
878
|
+
0 All clear
|
|
879
|
+
1 Compromise detected
|
|
880
|
+
2 Warnings found
|
|
881
|
+
|
|
882
|
+
${c.yellow}EXAMPLES${c.reset}
|
|
883
|
+
npx supply-scan --path ~/projects/my-app
|
|
884
|
+
npx supply-scan --rule axios-2026 --rule node-ipc-2022
|
|
885
|
+
npx supply-scan --ci --all
|
|
886
|
+
`;
|
|
887
|
+
async function run(argv) {
|
|
888
|
+
const opts = parseArgs(argv);
|
|
889
|
+
if (opts.version) {
|
|
890
|
+
console.log(VERSION);
|
|
891
|
+
process.exit(0);
|
|
892
|
+
}
|
|
893
|
+
if (opts.help) {
|
|
894
|
+
console.log(HELP_TEXT);
|
|
895
|
+
process.exit(0);
|
|
896
|
+
}
|
|
897
|
+
const allRules = loadRules(RULES_DIR);
|
|
898
|
+
if (allRules.length === 0) {
|
|
899
|
+
console.error(`${c.red}No rules found in ${RULES_DIR}${c.reset}`);
|
|
900
|
+
process.exit(1);
|
|
901
|
+
}
|
|
902
|
+
if (opts.list) {
|
|
903
|
+
banner(allRules.length);
|
|
904
|
+
printRuleList(allRules);
|
|
905
|
+
process.exit(0);
|
|
906
|
+
}
|
|
907
|
+
let selectedRules;
|
|
908
|
+
if (opts.rules.length > 0) {
|
|
909
|
+
selectedRules = allRules.filter((r) => opts.rules.includes(r.id));
|
|
910
|
+
if (selectedRules.length === 0) {
|
|
911
|
+
console.error(`${c.red}No matching rules found. Use --list to see available rules.${c.reset}`);
|
|
912
|
+
process.exit(1);
|
|
913
|
+
}
|
|
914
|
+
} else if (opts.all || opts.ci) {
|
|
915
|
+
selectedRules = allRules;
|
|
916
|
+
} else {
|
|
917
|
+
banner(allRules.length);
|
|
918
|
+
selectedRules = await interactiveRuleSelection(allRules);
|
|
919
|
+
}
|
|
920
|
+
if (!opts.ci && (opts.all || opts.rules.length > 0)) {
|
|
921
|
+
banner(selectedRules.length);
|
|
922
|
+
}
|
|
923
|
+
let projectDirs;
|
|
924
|
+
if (opts.path) {
|
|
925
|
+
if (!opts.ci) spinnerStart("Searching for npm projects...");
|
|
926
|
+
projectDirs = findProjects([opts.path]);
|
|
927
|
+
if (!opts.ci) spinnerStop();
|
|
928
|
+
} 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();
|
|
932
|
+
} else {
|
|
933
|
+
projectDirs = await interactivePathSelection();
|
|
934
|
+
}
|
|
935
|
+
if (!opts.ci) {
|
|
936
|
+
console.log(`
|
|
937
|
+
${c.dim}Scanning ${projectDirs.length} projects with ${selectedRules.length} rules...${c.reset}`);
|
|
938
|
+
}
|
|
939
|
+
const summary = await scan({
|
|
940
|
+
rules: selectedRules,
|
|
941
|
+
projectDirs,
|
|
942
|
+
ci: opts.ci
|
|
943
|
+
});
|
|
944
|
+
if (!opts.ci) {
|
|
945
|
+
printSummary(summary);
|
|
946
|
+
}
|
|
947
|
+
if (summary.failed > 0) {
|
|
948
|
+
process.exit(1);
|
|
949
|
+
} else if (summary.warnings > 0) {
|
|
950
|
+
process.exit(2);
|
|
951
|
+
} else {
|
|
952
|
+
if (opts.ci) {
|
|
953
|
+
console.log("OK");
|
|
954
|
+
}
|
|
955
|
+
process.exit(0);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
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}`);
|
|
967
|
+
}
|
|
968
|
+
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]);
|
|
975
|
+
return selected.length > 0 ? selected : rules;
|
|
976
|
+
}
|
|
977
|
+
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}`);
|
|
986
|
+
let baseDirs;
|
|
987
|
+
switch (choice) {
|
|
988
|
+
case "2":
|
|
989
|
+
baseDirs = getCommonProjectDirs();
|
|
990
|
+
if (baseDirs.length === 0) {
|
|
991
|
+
console.log(` ${c.dim}No common directories found, scanning current directory...${c.reset}`);
|
|
992
|
+
baseDirs = [process.cwd()];
|
|
993
|
+
}
|
|
994
|
+
break;
|
|
995
|
+
case "3": {
|
|
996
|
+
const customPath = await prompt(` ${c.cyan}Enter path: ${c.reset}`);
|
|
997
|
+
baseDirs = [customPath.replace(/^~/, process.env.HOME || "")];
|
|
998
|
+
break;
|
|
999
|
+
}
|
|
1000
|
+
default:
|
|
1001
|
+
baseDirs = [process.cwd()];
|
|
1002
|
+
}
|
|
1003
|
+
console.log("");
|
|
1004
|
+
spinnerStart("Searching for npm projects...");
|
|
1005
|
+
const projects = findProjects(baseDirs);
|
|
1006
|
+
spinnerStop();
|
|
1007
|
+
console.log(` ${c.dim}Found ${projects.length} npm projects${c.reset}`);
|
|
1008
|
+
return projects;
|
|
1009
|
+
}
|
|
1010
|
+
var isCLI = process.argv[1]?.includes("supply-scan") || process.argv[1]?.includes("dist/index");
|
|
1011
|
+
if (isCLI) {
|
|
1012
|
+
run(process.argv.slice(2));
|
|
1013
|
+
}
|
|
1014
|
+
export {
|
|
1015
|
+
run
|
|
1016
|
+
};
|
|
1017
|
+
//# sourceMappingURL=index.js.map
|