shai-scan 0.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/LICENSE +21 -0
- package/README.md +253 -0
- package/package.json +36 -0
- package/src/cli.ts +412 -0
- package/src/db.ts +317 -0
- package/src/lockfile.ts +189 -0
- package/src/scanner.ts +154 -0
- package/src/system.ts +373 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* CLI entry point for shai-scan.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { writeFileSync } from "node:fs";
|
|
7
|
+
import { resolve } from "node:path";
|
|
8
|
+
import { CAMPAIGNS } from "./db.ts";
|
|
9
|
+
import { type LockfileFinding, type ScanOptions, type ScanResult, scan } from "./scanner.ts";
|
|
10
|
+
|
|
11
|
+
const VERSION = "0.1.0";
|
|
12
|
+
|
|
13
|
+
// ── ANSI colors ────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
interface Colors {
|
|
16
|
+
reset: string;
|
|
17
|
+
bold: string;
|
|
18
|
+
red: string;
|
|
19
|
+
yellow: string;
|
|
20
|
+
green: string;
|
|
21
|
+
cyan: string;
|
|
22
|
+
gray: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getColors(enabled: boolean): Colors {
|
|
26
|
+
if (!enabled) {
|
|
27
|
+
return { reset: "", bold: "", red: "", yellow: "", green: "", cyan: "", gray: "" };
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
reset: "\x1b[0m",
|
|
31
|
+
bold: "\x1b[1m",
|
|
32
|
+
red: "\x1b[31m",
|
|
33
|
+
yellow: "\x1b[33m",
|
|
34
|
+
green: "\x1b[32m",
|
|
35
|
+
cyan: "\x1b[36m",
|
|
36
|
+
gray: "\x1b[90m",
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ── Arg parsing ────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
interface ParsedArgs {
|
|
43
|
+
rootPath: string;
|
|
44
|
+
json: boolean;
|
|
45
|
+
sarif: boolean;
|
|
46
|
+
sarifFile: string | null;
|
|
47
|
+
text: boolean;
|
|
48
|
+
severity: string | null;
|
|
49
|
+
lockfilesOnly: boolean;
|
|
50
|
+
systemOnly: boolean;
|
|
51
|
+
noColor: boolean;
|
|
52
|
+
help: boolean;
|
|
53
|
+
version: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function parseArgs(argv: string[]): ParsedArgs {
|
|
57
|
+
const args: ParsedArgs = {
|
|
58
|
+
rootPath: process.cwd(),
|
|
59
|
+
json: false,
|
|
60
|
+
sarif: false,
|
|
61
|
+
sarifFile: null,
|
|
62
|
+
text: false,
|
|
63
|
+
severity: null,
|
|
64
|
+
lockfilesOnly: false,
|
|
65
|
+
systemOnly: false,
|
|
66
|
+
noColor: false,
|
|
67
|
+
help: false,
|
|
68
|
+
version: false,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
let positionalSet = false;
|
|
72
|
+
|
|
73
|
+
for (let i = 0; i < argv.length; i++) {
|
|
74
|
+
const arg = argv[i];
|
|
75
|
+
|
|
76
|
+
switch (arg) {
|
|
77
|
+
case "--json":
|
|
78
|
+
args.json = true;
|
|
79
|
+
break;
|
|
80
|
+
case "--sarif":
|
|
81
|
+
args.sarif = true;
|
|
82
|
+
break;
|
|
83
|
+
case "--sarif-file":
|
|
84
|
+
args.sarifFile = argv[++i] ?? null;
|
|
85
|
+
break;
|
|
86
|
+
case "--text":
|
|
87
|
+
args.text = true;
|
|
88
|
+
break;
|
|
89
|
+
case "--severity":
|
|
90
|
+
args.severity = argv[++i] ?? null;
|
|
91
|
+
break;
|
|
92
|
+
case "--lockfiles-only":
|
|
93
|
+
args.lockfilesOnly = true;
|
|
94
|
+
break;
|
|
95
|
+
case "--system-only":
|
|
96
|
+
args.systemOnly = true;
|
|
97
|
+
break;
|
|
98
|
+
case "--no-color":
|
|
99
|
+
args.noColor = true;
|
|
100
|
+
break;
|
|
101
|
+
case "-h":
|
|
102
|
+
case "--help":
|
|
103
|
+
args.help = true;
|
|
104
|
+
break;
|
|
105
|
+
case "-V":
|
|
106
|
+
case "--version":
|
|
107
|
+
args.version = true;
|
|
108
|
+
break;
|
|
109
|
+
default:
|
|
110
|
+
if (!arg.startsWith("-") && !positionalSet) {
|
|
111
|
+
args.rootPath = resolve(arg);
|
|
112
|
+
positionalSet = true;
|
|
113
|
+
}
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return args;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function showHelp(): void {
|
|
122
|
+
console.log(`shai-scan v${VERSION} — Supply chain compromise scanner`);
|
|
123
|
+
console.log("");
|
|
124
|
+
console.log("Usage: shai-scan [path] [options]");
|
|
125
|
+
console.log("");
|
|
126
|
+
console.log("Options:");
|
|
127
|
+
console.log(" --json Output results as JSON");
|
|
128
|
+
console.log(" --sarif Output results as SARIF");
|
|
129
|
+
console.log(" --sarif-file <path> Write SARIF to file");
|
|
130
|
+
console.log(" --text Output human-readable text (default)");
|
|
131
|
+
console.log(" --severity <level> Minimum severity: critical, high, medium, low");
|
|
132
|
+
console.log(" --lockfiles-only Skip system checks");
|
|
133
|
+
console.log(" --system-only Skip lockfile scanning");
|
|
134
|
+
console.log(" --no-color Disable ANSI colors");
|
|
135
|
+
console.log(" -h, --help Show this help");
|
|
136
|
+
console.log(" -V, --version Show version");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function showVersion(): void {
|
|
140
|
+
console.log(VERSION);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── Output formatters ──────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
function formatText(result: ScanResult, rootPath: string, colors: Colors): string {
|
|
146
|
+
const lines: string[] = [];
|
|
147
|
+
|
|
148
|
+
lines.push(`${colors.bold}shai-scan${colors.reset} — Supply Chain Compromise Scanner`);
|
|
149
|
+
lines.push(`${colors.gray}Root:${colors.reset} ${rootPath}`);
|
|
150
|
+
lines.push("");
|
|
151
|
+
|
|
152
|
+
// Lockfile findings
|
|
153
|
+
if (result.lockfileFindings.length > 0) {
|
|
154
|
+
lines.push(`${colors.bold}Lockfile Findings:${colors.reset}`);
|
|
155
|
+
|
|
156
|
+
const byLockfile = new Map<string, LockfileFinding[]>();
|
|
157
|
+
for (const f of result.lockfileFindings) {
|
|
158
|
+
const arr = byLockfile.get(f.lockfile) ?? [];
|
|
159
|
+
arr.push(f);
|
|
160
|
+
byLockfile.set(f.lockfile, arr);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
for (const [lockfile, findings] of byLockfile) {
|
|
164
|
+
lines.push(` ${colors.cyan}${lockfile}${colors.reset}`);
|
|
165
|
+
for (const f of findings) {
|
|
166
|
+
const sevColor = f.severity === "critical" ? colors.red : f.severity === "high" ? colors.yellow : colors.gray;
|
|
167
|
+
lines.push(
|
|
168
|
+
` ${sevColor}[${f.severity.toUpperCase()}]${colors.reset} ${f.packageName}@${f.version} — ${f.campaign.name}`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
lines.push("");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// System findings
|
|
176
|
+
const notableSystem = result.systemFindings.filter(
|
|
177
|
+
(f) => f.status === "infected" || f.status === "suspicious" || f.status === "error",
|
|
178
|
+
);
|
|
179
|
+
if (notableSystem.length > 0) {
|
|
180
|
+
lines.push(`${colors.bold}System Findings:${colors.reset}`);
|
|
181
|
+
for (const f of notableSystem) {
|
|
182
|
+
const sevColor = f.severity === "critical" ? colors.red : f.severity === "high" ? colors.yellow : colors.gray;
|
|
183
|
+
const statusColor =
|
|
184
|
+
f.status === "infected" ? colors.red : f.status === "suspicious" ? colors.yellow : colors.gray;
|
|
185
|
+
lines.push(
|
|
186
|
+
` ${statusColor}[${f.status.toUpperCase()}]${colors.reset} ${sevColor}[${f.severity.toUpperCase()}]${colors.reset} ${f.check}`,
|
|
187
|
+
);
|
|
188
|
+
lines.push(` ${f.detail}`);
|
|
189
|
+
}
|
|
190
|
+
lines.push("");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Summary
|
|
194
|
+
const infectedCount = result.lockfileFindings.length;
|
|
195
|
+
const sysIssues = result.systemFindings.filter((f) => f.status === "infected" || f.status === "suspicious").length;
|
|
196
|
+
|
|
197
|
+
lines.push(`${colors.bold}Summary:${colors.reset}`);
|
|
198
|
+
lines.push(` Lockfiles scanned: ${result.lockfilesScanned}`);
|
|
199
|
+
lines.push(` Compromised packages: ${infectedCount}`);
|
|
200
|
+
lines.push(` System issues: ${sysIssues}`);
|
|
201
|
+
if (result.errors.length > 0) {
|
|
202
|
+
lines.push(` Errors: ${result.errors.length}`);
|
|
203
|
+
for (const err of result.errors) {
|
|
204
|
+
lines.push(` ${colors.gray}${err}${colors.reset}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (infectedCount === 0 && sysIssues === 0) {
|
|
209
|
+
lines.push("");
|
|
210
|
+
lines.push(`${colors.green}No compromises detected.${colors.reset}`);
|
|
211
|
+
} else {
|
|
212
|
+
lines.push("");
|
|
213
|
+
lines.push(`${colors.red}COMPROMISE DETECTED — immediate action recommended.${colors.reset}`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return lines.join("\n");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function formatJson(result: ScanResult, rootPath: string): string {
|
|
220
|
+
const infectedCount = result.lockfileFindings.length;
|
|
221
|
+
const sysIssues = result.systemFindings.filter((f) => f.status === "infected" || f.status === "suspicious").length;
|
|
222
|
+
|
|
223
|
+
const payload = {
|
|
224
|
+
version: VERSION,
|
|
225
|
+
timestamp: new Date().toISOString(),
|
|
226
|
+
rootPath,
|
|
227
|
+
findings: {
|
|
228
|
+
lockfile: result.lockfileFindings.map((f) => ({
|
|
229
|
+
lockfile: f.lockfile,
|
|
230
|
+
packageName: f.packageName,
|
|
231
|
+
version: f.version,
|
|
232
|
+
severity: f.severity,
|
|
233
|
+
campaign: {
|
|
234
|
+
id: f.campaign.id,
|
|
235
|
+
name: f.campaign.name,
|
|
236
|
+
cve: f.campaign.cve,
|
|
237
|
+
ghsa: f.campaign.ghsa,
|
|
238
|
+
},
|
|
239
|
+
})),
|
|
240
|
+
system: result.systemFindings.filter(
|
|
241
|
+
(f) => f.status === "infected" || f.status === "suspicious" || f.status === "error",
|
|
242
|
+
),
|
|
243
|
+
},
|
|
244
|
+
summary: {
|
|
245
|
+
lockfilesScanned: result.lockfilesScanned,
|
|
246
|
+
compromisedPackages: infectedCount,
|
|
247
|
+
systemIssues: sysIssues,
|
|
248
|
+
errors: result.errors.length,
|
|
249
|
+
},
|
|
250
|
+
exitCode: infectedCount > 0 || sysIssues > 0 ? 1 : 0,
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
return JSON.stringify(payload, null, 2);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function formatSarif(result: ScanResult, rootPath: string): string {
|
|
257
|
+
const rules: Array<Record<string, unknown>> = [];
|
|
258
|
+
const results: Array<Record<string, unknown>> = [];
|
|
259
|
+
|
|
260
|
+
for (const campaign of CAMPAIGNS) {
|
|
261
|
+
rules.push({
|
|
262
|
+
id: campaign.id,
|
|
263
|
+
name: campaign.name,
|
|
264
|
+
shortDescription: { text: campaign.description.slice(0, 200) },
|
|
265
|
+
fullDescription: { text: campaign.description },
|
|
266
|
+
defaultConfiguration: {
|
|
267
|
+
level: campaign.severity === "critical" ? "error" : campaign.severity === "high" ? "error" : "warning",
|
|
268
|
+
},
|
|
269
|
+
properties: {
|
|
270
|
+
severity: campaign.severity,
|
|
271
|
+
cve: campaign.cve,
|
|
272
|
+
ghsa: campaign.ghsa,
|
|
273
|
+
referenceUrls: campaign.referenceUrls,
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
for (const finding of result.lockfileFindings) {
|
|
279
|
+
results.push({
|
|
280
|
+
ruleId: finding.campaign.id,
|
|
281
|
+
level: finding.severity === "critical" || finding.severity === "high" ? "error" : "warning",
|
|
282
|
+
message: {
|
|
283
|
+
text: `Compromised package ${finding.packageName}@${finding.version} found in ${finding.lockfile}`,
|
|
284
|
+
},
|
|
285
|
+
locations: [
|
|
286
|
+
{
|
|
287
|
+
physicalLocation: {
|
|
288
|
+
artifactLocation: {
|
|
289
|
+
uri: finding.lockfile,
|
|
290
|
+
uriBaseId: "%SRCROOT%",
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
],
|
|
295
|
+
properties: {
|
|
296
|
+
packageName: finding.packageName,
|
|
297
|
+
version: finding.version,
|
|
298
|
+
campaign: finding.campaign.name,
|
|
299
|
+
severity: finding.severity,
|
|
300
|
+
},
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
for (const sys of result.systemFindings) {
|
|
305
|
+
if (sys.status !== "infected" && sys.status !== "suspicious") continue;
|
|
306
|
+
results.push({
|
|
307
|
+
ruleId: sys.campaign.split(", ")[0] ?? "unknown",
|
|
308
|
+
level: sys.severity === "critical" || sys.severity === "high" ? "error" : "warning",
|
|
309
|
+
message: { text: `${sys.check}: ${sys.detail}` },
|
|
310
|
+
properties: {
|
|
311
|
+
check: sys.check,
|
|
312
|
+
status: sys.status,
|
|
313
|
+
severity: sys.severity,
|
|
314
|
+
campaign: sys.campaign,
|
|
315
|
+
},
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const sarif = {
|
|
320
|
+
$schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
|
|
321
|
+
version: "2.1.0",
|
|
322
|
+
runs: [
|
|
323
|
+
{
|
|
324
|
+
tool: {
|
|
325
|
+
driver: {
|
|
326
|
+
name: "shai-scan",
|
|
327
|
+
version: VERSION,
|
|
328
|
+
informationUri: "https://github.com/shai-scan/shai-scan",
|
|
329
|
+
rules,
|
|
330
|
+
},
|
|
331
|
+
},
|
|
332
|
+
results,
|
|
333
|
+
invocations: [
|
|
334
|
+
{
|
|
335
|
+
executionSuccessful: result.errors.length === 0,
|
|
336
|
+
},
|
|
337
|
+
],
|
|
338
|
+
originalUriBaseIds: {
|
|
339
|
+
"%SRCROOT%": {
|
|
340
|
+
uri: `file://${rootPath}/`,
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
],
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
return JSON.stringify(sarif, null, 2);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ── Main ───────────────────────────────────────────────────────────────────
|
|
351
|
+
|
|
352
|
+
async function main(): Promise<void> {
|
|
353
|
+
const args = parseArgs(process.argv.slice(2));
|
|
354
|
+
|
|
355
|
+
if (args.help) {
|
|
356
|
+
showHelp();
|
|
357
|
+
process.exit(0);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (args.version) {
|
|
361
|
+
showVersion();
|
|
362
|
+
process.exit(0);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const useColor = !args.noColor && !process.env.NO_COLOR;
|
|
366
|
+
const colors = getColors(useColor);
|
|
367
|
+
|
|
368
|
+
const options: ScanOptions = {
|
|
369
|
+
lockfilesOnly: args.lockfilesOnly,
|
|
370
|
+
systemOnly: args.systemOnly,
|
|
371
|
+
minSeverity: args.severity ?? undefined,
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
let result: ScanResult;
|
|
375
|
+
try {
|
|
376
|
+
result = await scan(args.rootPath, options);
|
|
377
|
+
} catch (err) {
|
|
378
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
379
|
+
console.error(`${colors.red}Fatal error:${colors.reset} ${msg}`);
|
|
380
|
+
process.exit(2);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Determine output format
|
|
384
|
+
let output = "";
|
|
385
|
+
if (args.json) {
|
|
386
|
+
output = formatJson(result, args.rootPath);
|
|
387
|
+
} else if (args.sarif || args.sarifFile) {
|
|
388
|
+
output = formatSarif(result, args.rootPath);
|
|
389
|
+
} else {
|
|
390
|
+
output = formatText(result, args.rootPath, colors);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
console.log(output);
|
|
394
|
+
|
|
395
|
+
if (args.sarifFile) {
|
|
396
|
+
try {
|
|
397
|
+
writeFileSync(args.sarifFile, output, "utf-8");
|
|
398
|
+
} catch (err) {
|
|
399
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
400
|
+
console.error(`${colors.red}Failed to write SARIF file:${colors.reset} ${msg}`);
|
|
401
|
+
process.exit(2);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const hasFindings =
|
|
406
|
+
result.lockfileFindings.length > 0 ||
|
|
407
|
+
result.systemFindings.some((f) => f.status === "infected" || f.status === "suspicious");
|
|
408
|
+
|
|
409
|
+
process.exit(hasFindings ? 1 : 0);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
main();
|