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/system.ts
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* System IOC checks for supply chain compromise indicators.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { execSync } from "node:child_process";
|
|
6
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { CAMPAIGNS, type Campaign } from "./db.ts";
|
|
10
|
+
|
|
11
|
+
export interface SystemCheckResult {
|
|
12
|
+
check: string;
|
|
13
|
+
status: "infected" | "suspicious" | "clean" | "error";
|
|
14
|
+
severity: "critical" | "high" | "medium" | "low";
|
|
15
|
+
detail: string;
|
|
16
|
+
campaign: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface AggregatedIOCs {
|
|
20
|
+
domains: Set<string>;
|
|
21
|
+
ips: Set<string>;
|
|
22
|
+
files: Set<string>;
|
|
23
|
+
services: Set<string>;
|
|
24
|
+
npmTokenDescriptions: Set<string>;
|
|
25
|
+
persistencePaths: Set<string>;
|
|
26
|
+
campaigns: Campaign[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function aggregateIOCs(): AggregatedIOCs {
|
|
30
|
+
const result: AggregatedIOCs = {
|
|
31
|
+
domains: new Set(),
|
|
32
|
+
ips: new Set(),
|
|
33
|
+
files: new Set(),
|
|
34
|
+
services: new Set(),
|
|
35
|
+
npmTokenDescriptions: new Set(),
|
|
36
|
+
persistencePaths: new Set(),
|
|
37
|
+
campaigns: CAMPAIGNS,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
for (const campaign of CAMPAIGNS) {
|
|
41
|
+
for (const d of campaign.iocs.domains) result.domains.add(d);
|
|
42
|
+
for (const ip of campaign.iocs.ips) result.ips.add(ip);
|
|
43
|
+
for (const f of campaign.iocs.files) result.files.add(f);
|
|
44
|
+
for (const s of campaign.iocs.services) result.services.add(s);
|
|
45
|
+
for (const t of campaign.iocs.npmTokenDescriptions) result.npmTokenDescriptions.add(t);
|
|
46
|
+
for (const p of campaign.iocs.persistencePaths) result.persistencePaths.add(p);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function execSafe(command: string, timeoutMs = 5000): string | null {
|
|
53
|
+
try {
|
|
54
|
+
const buf = execSync(command, { timeout: timeoutMs, encoding: "utf-8" });
|
|
55
|
+
return buf;
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function runSystemChecks(_searchRoots: string[]): SystemCheckResult[] {
|
|
62
|
+
const results: SystemCheckResult[] = [];
|
|
63
|
+
const iocs = aggregateIOCs();
|
|
64
|
+
const campaignNames = iocs.campaigns.map((c) => c.name);
|
|
65
|
+
const campaignName = campaignNames.join(", ") || "unknown";
|
|
66
|
+
|
|
67
|
+
// 1. Process scan
|
|
68
|
+
const psOutput = execSafe("ps aux");
|
|
69
|
+
if (psOutput !== null) {
|
|
70
|
+
const foundServices: string[] = [];
|
|
71
|
+
for (const svc of iocs.services) {
|
|
72
|
+
if (psOutput.includes(svc)) {
|
|
73
|
+
foundServices.push(svc);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (foundServices.length > 0) {
|
|
77
|
+
results.push({
|
|
78
|
+
check: "process-scan",
|
|
79
|
+
status: "infected",
|
|
80
|
+
severity: "critical",
|
|
81
|
+
detail: `Suspicious processes found: ${foundServices.join(", ")}`,
|
|
82
|
+
campaign: campaignName,
|
|
83
|
+
});
|
|
84
|
+
} else {
|
|
85
|
+
results.push({
|
|
86
|
+
check: "process-scan",
|
|
87
|
+
status: "clean",
|
|
88
|
+
severity: "low",
|
|
89
|
+
detail: "No suspicious processes detected",
|
|
90
|
+
campaign: campaignName,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
} else {
|
|
94
|
+
results.push({
|
|
95
|
+
check: "process-scan",
|
|
96
|
+
status: "error",
|
|
97
|
+
severity: "medium",
|
|
98
|
+
detail: "Could not enumerate running processes",
|
|
99
|
+
campaign: campaignName,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 2. Systemd user services
|
|
104
|
+
const systemdDir = join(homedir(), ".config", "systemd", "user");
|
|
105
|
+
if (existsSync(systemdDir)) {
|
|
106
|
+
try {
|
|
107
|
+
const files = readdirSync(systemdDir);
|
|
108
|
+
const suspicious: string[] = [];
|
|
109
|
+
for (const file of files) {
|
|
110
|
+
if (!file.endsWith(".service")) continue;
|
|
111
|
+
const content = readFileSync(join(systemdDir, file), "utf-8");
|
|
112
|
+
for (const svc of iocs.services) {
|
|
113
|
+
if (content.includes(svc)) {
|
|
114
|
+
suspicious.push(file);
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
for (const f of iocs.files) {
|
|
119
|
+
if (content.includes(f)) {
|
|
120
|
+
if (!suspicious.includes(file)) suspicious.push(file);
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (suspicious.length > 0) {
|
|
126
|
+
results.push({
|
|
127
|
+
check: "systemd-user-services",
|
|
128
|
+
status: "suspicious",
|
|
129
|
+
severity: "high",
|
|
130
|
+
detail: `Suspicious user services: ${suspicious.join(", ")}`,
|
|
131
|
+
campaign: campaignName,
|
|
132
|
+
});
|
|
133
|
+
} else {
|
|
134
|
+
results.push({
|
|
135
|
+
check: "systemd-user-services",
|
|
136
|
+
status: "clean",
|
|
137
|
+
severity: "low",
|
|
138
|
+
detail: "No suspicious user systemd services detected",
|
|
139
|
+
campaign: campaignName,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
} catch {
|
|
143
|
+
results.push({
|
|
144
|
+
check: "systemd-user-services",
|
|
145
|
+
status: "error",
|
|
146
|
+
severity: "medium",
|
|
147
|
+
detail: "Could not read user systemd services",
|
|
148
|
+
campaign: campaignName,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
} else {
|
|
152
|
+
results.push({
|
|
153
|
+
check: "systemd-user-services",
|
|
154
|
+
status: "clean",
|
|
155
|
+
severity: "low",
|
|
156
|
+
detail: "No user systemd services directory found",
|
|
157
|
+
campaign: campaignName,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// 3. Crontab
|
|
162
|
+
const crontabOutput = execSafe("crontab -l");
|
|
163
|
+
if (crontabOutput !== null) {
|
|
164
|
+
const hits: string[] = [];
|
|
165
|
+
for (const domain of iocs.domains) {
|
|
166
|
+
if (crontabOutput.includes(domain)) hits.push(domain);
|
|
167
|
+
}
|
|
168
|
+
for (const svc of iocs.services) {
|
|
169
|
+
if (crontabOutput.includes(svc)) hits.push(svc);
|
|
170
|
+
}
|
|
171
|
+
for (const f of iocs.files) {
|
|
172
|
+
if (crontabOutput.includes(f)) hits.push(f);
|
|
173
|
+
}
|
|
174
|
+
if (hits.length > 0) {
|
|
175
|
+
results.push({
|
|
176
|
+
check: "crontab",
|
|
177
|
+
status: "suspicious",
|
|
178
|
+
severity: "high",
|
|
179
|
+
detail: `IOC strings in crontab: ${[...new Set(hits)].join(", ")}`,
|
|
180
|
+
campaign: campaignName,
|
|
181
|
+
});
|
|
182
|
+
} else {
|
|
183
|
+
results.push({
|
|
184
|
+
check: "crontab",
|
|
185
|
+
status: "clean",
|
|
186
|
+
severity: "low",
|
|
187
|
+
detail: "No suspicious entries in user crontab",
|
|
188
|
+
campaign: campaignName,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
} else {
|
|
192
|
+
results.push({
|
|
193
|
+
check: "crontab",
|
|
194
|
+
status: "error",
|
|
195
|
+
severity: "medium",
|
|
196
|
+
detail: "Could not read user crontab",
|
|
197
|
+
campaign: campaignName,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 4. Persistence hooks (Claude Code, VS Code settings)
|
|
202
|
+
const persistenceHits: string[] = [];
|
|
203
|
+
const claudeSettings = join(homedir(), ".claude", "settings.json");
|
|
204
|
+
const claudeLocal = join(homedir(), ".claude", "settings.local.json");
|
|
205
|
+
const vscodeSettings = join(homedir(), ".config", "Code", "User", "settings.json");
|
|
206
|
+
|
|
207
|
+
for (const path of [claudeSettings, claudeLocal, vscodeSettings]) {
|
|
208
|
+
if (!existsSync(path)) continue;
|
|
209
|
+
try {
|
|
210
|
+
const content = readFileSync(path, "utf-8");
|
|
211
|
+
for (const domain of iocs.domains) {
|
|
212
|
+
if (content.includes(domain)) persistenceHits.push(`${path} (domain: ${domain})`);
|
|
213
|
+
}
|
|
214
|
+
for (const f of iocs.files) {
|
|
215
|
+
if (content.includes(f)) persistenceHits.push(`${path} (file: ${f})`);
|
|
216
|
+
}
|
|
217
|
+
} catch {
|
|
218
|
+
// ignore read errors
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (persistenceHits.length > 0) {
|
|
223
|
+
results.push({
|
|
224
|
+
check: "persistence-hooks",
|
|
225
|
+
status: "infected",
|
|
226
|
+
severity: "critical",
|
|
227
|
+
detail: `Persistence indicators in settings: ${persistenceHits.join("; ")}`,
|
|
228
|
+
campaign: campaignName,
|
|
229
|
+
});
|
|
230
|
+
} else {
|
|
231
|
+
results.push({
|
|
232
|
+
check: "persistence-hooks",
|
|
233
|
+
status: "clean",
|
|
234
|
+
severity: "low",
|
|
235
|
+
detail: "No persistence hooks detected in editor settings",
|
|
236
|
+
campaign: campaignName,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// 5. npm tokens
|
|
241
|
+
const npmTokenOutput = execSafe("npm token list --json", 10000);
|
|
242
|
+
if (npmTokenOutput !== null) {
|
|
243
|
+
try {
|
|
244
|
+
const tokens = JSON.parse(npmTokenOutput) as Array<Record<string, unknown>>;
|
|
245
|
+
const suspiciousTokens: string[] = [];
|
|
246
|
+
for (const token of tokens) {
|
|
247
|
+
const desc = String(token.description ?? token.token ?? "");
|
|
248
|
+
for (const maliciousDesc of iocs.npmTokenDescriptions) {
|
|
249
|
+
if (desc.includes(maliciousDesc)) {
|
|
250
|
+
suspiciousTokens.push(desc);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (suspiciousTokens.length > 0) {
|
|
255
|
+
results.push({
|
|
256
|
+
check: "npm-tokens",
|
|
257
|
+
status: "infected",
|
|
258
|
+
severity: "critical",
|
|
259
|
+
detail: `Suspicious npm token descriptions: ${suspiciousTokens.join(", ")}`,
|
|
260
|
+
campaign: campaignName,
|
|
261
|
+
});
|
|
262
|
+
} else {
|
|
263
|
+
results.push({
|
|
264
|
+
check: "npm-tokens",
|
|
265
|
+
status: "clean",
|
|
266
|
+
severity: "low",
|
|
267
|
+
detail: "No suspicious npm tokens found",
|
|
268
|
+
campaign: campaignName,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
} catch {
|
|
272
|
+
results.push({
|
|
273
|
+
check: "npm-tokens",
|
|
274
|
+
status: "error",
|
|
275
|
+
severity: "medium",
|
|
276
|
+
detail: "Could not parse npm token list output",
|
|
277
|
+
campaign: campaignName,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
} else {
|
|
281
|
+
results.push({
|
|
282
|
+
check: "npm-tokens",
|
|
283
|
+
status: "error",
|
|
284
|
+
severity: "medium",
|
|
285
|
+
detail: "Could not run npm token list",
|
|
286
|
+
campaign: campaignName,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// 6. Malicious files
|
|
291
|
+
const commonPaths = [
|
|
292
|
+
homedir(),
|
|
293
|
+
join(homedir(), ".config"),
|
|
294
|
+
join(homedir(), ".local"),
|
|
295
|
+
"/tmp",
|
|
296
|
+
"/var/tmp",
|
|
297
|
+
process.cwd(),
|
|
298
|
+
];
|
|
299
|
+
const foundFiles: string[] = [];
|
|
300
|
+
for (const iocFile of iocs.files) {
|
|
301
|
+
// Check absolute paths directly
|
|
302
|
+
if (iocFile.startsWith("/") && existsSync(iocFile)) {
|
|
303
|
+
foundFiles.push(iocFile);
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
// Check in common locations
|
|
307
|
+
for (const base of commonPaths) {
|
|
308
|
+
const full = join(base, iocFile);
|
|
309
|
+
if (existsSync(full)) {
|
|
310
|
+
foundFiles.push(full);
|
|
311
|
+
break;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
if (foundFiles.length > 0) {
|
|
316
|
+
results.push({
|
|
317
|
+
check: "malicious-files",
|
|
318
|
+
status: "infected",
|
|
319
|
+
severity: "critical",
|
|
320
|
+
detail: `Known malicious files found: ${foundFiles.join(", ")}`,
|
|
321
|
+
campaign: campaignName,
|
|
322
|
+
});
|
|
323
|
+
} else {
|
|
324
|
+
results.push({
|
|
325
|
+
check: "malicious-files",
|
|
326
|
+
status: "clean",
|
|
327
|
+
severity: "low",
|
|
328
|
+
detail: "No known malicious files detected in common locations",
|
|
329
|
+
campaign: campaignName,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// 7. Network connections
|
|
334
|
+
const ssOutput = execSafe("ss -tn");
|
|
335
|
+
const networkHits: string[] = [];
|
|
336
|
+
if (ssOutput !== null) {
|
|
337
|
+
for (const ip of iocs.ips) {
|
|
338
|
+
if (ssOutput.includes(ip)) networkHits.push(ip);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const hostsFile = "/etc/hosts";
|
|
343
|
+
if (existsSync(hostsFile)) {
|
|
344
|
+
try {
|
|
345
|
+
const hostsContent = readFileSync(hostsFile, "utf-8");
|
|
346
|
+
for (const domain of iocs.domains) {
|
|
347
|
+
if (hostsContent.includes(domain)) networkHits.push(`hosts:${domain}`);
|
|
348
|
+
}
|
|
349
|
+
} catch {
|
|
350
|
+
// ignore
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (networkHits.length > 0) {
|
|
355
|
+
results.push({
|
|
356
|
+
check: "network-connections",
|
|
357
|
+
status: "suspicious",
|
|
358
|
+
severity: "high",
|
|
359
|
+
detail: `Network IOC hits: ${networkHits.join(", ")}`,
|
|
360
|
+
campaign: campaignName,
|
|
361
|
+
});
|
|
362
|
+
} else {
|
|
363
|
+
results.push({
|
|
364
|
+
check: "network-connections",
|
|
365
|
+
status: "clean",
|
|
366
|
+
severity: "low",
|
|
367
|
+
detail: "No suspicious network connections or hosts entries",
|
|
368
|
+
campaign: campaignName,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return results;
|
|
373
|
+
}
|