hulud-party-scanner 1.0.6 → 1.0.8
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/README.md +39 -5
- package/compromised-libs.txt +1090 -798
- package/env-patterns.txt +7 -0
- package/exfil-patterns.txt +4 -0
- package/malicious-commands.txt +6 -0
- package/malicious-filenames.txt +5 -0
- package/malicious-hashes.txt +10 -0
- package/package.json +5 -2
- package/scan.js +393 -169
package/scan.js
CHANGED
|
@@ -6,37 +6,28 @@ const crypto = require('crypto');
|
|
|
6
6
|
const { execSync } = require('child_process');
|
|
7
7
|
|
|
8
8
|
// --- Configuration ---
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
"
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
'trufflehog',
|
|
32
|
-
'trufflehog.exe'
|
|
33
|
-
]);
|
|
34
|
-
const MALICIOUS_COMMAND_PATTERNS = [
|
|
35
|
-
'bun.sh/install', // Catches both curl and powershell variants
|
|
36
|
-
'del /F /Q /S "%USERPROFILE%\\*"',
|
|
37
|
-
'shred -uvz -n 1',
|
|
38
|
-
'cipher /W:%USERPROFILE%'
|
|
39
|
-
];
|
|
9
|
+
const IOC_URLS = {
|
|
10
|
+
COMPROMISED_LIBS: "https://raw.githubusercontent.com/migohe14/hulud-scanner/refs/heads/main/compromised-libs.txt",
|
|
11
|
+
ENV_PATTERNS: "https://raw.githubusercontent.com/migohe14/hulud-scanner/refs/heads/main/env-patterns.txt",
|
|
12
|
+
EXFIL_PATTERNS: "https://raw.githubusercontent.com/migohe14/hulud-scanner/refs/heads/main/exfil-patterns.txt",
|
|
13
|
+
MALICIOUS_COMMANDS: "https://raw.githubusercontent.com/migohe14/hulud-scanner/refs/heads/main/malicious-commands.txt",
|
|
14
|
+
MALICIOUS_FILENAMES: "https://raw.githubusercontent.com/migohe14/hulud-scanner/refs/heads/main/malicious-filenames.txt",
|
|
15
|
+
MALICIOUS_HASHES: "https://raw.githubusercontent.com/migohe14/hulud-scanner/refs/heads/main/malicious-hashes.txt"
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// --- MITRE ATT&CK & Scoring Configuration ---
|
|
19
|
+
const MITRE_ATTACK = {
|
|
20
|
+
"T1064": { name: "Scripting", tactic: "Execution", baseScore: 5 },
|
|
21
|
+
"T1552": { name: "Unsecured Credentials", tactic: "Credential Access", baseScore: 6 },
|
|
22
|
+
"T1082": { name: "System Information Discovery", tactic: "Discovery", baseScore: 3 },
|
|
23
|
+
"T1518": { name: "Software Discovery", tactic: "Discovery", baseScore: 3 },
|
|
24
|
+
"T1053": { name: "Scheduled Task/Job", tactic: "Persistence", baseScore: 8 },
|
|
25
|
+
"T1098": { name: "Account Manipulation", tactic: "Persistence", baseScore: 7 },
|
|
26
|
+
"T1059": { name: "Command and Scripting Interpreter", tactic: "Execution", baseScore: 6 },
|
|
27
|
+
"T1027": { name: "Obfuscated Files or Information", tactic: "Defense Evasion", baseScore: 5 },
|
|
28
|
+
"T1195": { name: "Supply Chain Compromise", tactic: "Initial Access", baseScore: 25 }, // IOC Match
|
|
29
|
+
"T1567": { name: "Exfiltration Over Web Service", tactic: "Exfiltration", baseScore: 9 }
|
|
30
|
+
};
|
|
40
31
|
|
|
41
32
|
// --- Console Colors ---
|
|
42
33
|
const colors = {
|
|
@@ -55,6 +46,20 @@ const log = {
|
|
|
55
46
|
header: (msg) => console.log(`\n${colors.BLUE}${colors.BOLD}--- ${msg} ---${colors.RESET}`),
|
|
56
47
|
};
|
|
57
48
|
|
|
49
|
+
class Finding {
|
|
50
|
+
constructor(technique, description, severity, evidence, file) {
|
|
51
|
+
this.technique = technique;
|
|
52
|
+
this.tactic = MITRE_ATTACK[technique]?.tactic || "Unknown";
|
|
53
|
+
this.name = MITRE_ATTACK[technique]?.name || "Unknown";
|
|
54
|
+
this.description = description;
|
|
55
|
+
this.severity = severity;
|
|
56
|
+
this.evidence = evidence;
|
|
57
|
+
this.file = file;
|
|
58
|
+
const multipliers = { "CRITICAL": 4, "HIGH": 2, "MEDIUM": 1, "LOW": 0.5 };
|
|
59
|
+
this.score = (MITRE_ATTACK[technique]?.baseScore || 1) * (multipliers[severity] || 1);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
58
63
|
/**
|
|
59
64
|
* Checks if a command exists on the system.
|
|
60
65
|
* @param {string} cmd The command to check.
|
|
@@ -70,27 +75,56 @@ function commandExists(cmd) {
|
|
|
70
75
|
}
|
|
71
76
|
|
|
72
77
|
/**
|
|
73
|
-
*
|
|
74
|
-
* @
|
|
78
|
+
* Fetches a list of strings from a URL, ignoring comments and empty lines.
|
|
79
|
+
* @param {string} url The URL to fetch.
|
|
80
|
+
* @returns {Promise<string[]>} A list of strings.
|
|
75
81
|
*/
|
|
76
|
-
async function
|
|
77
|
-
log.info("Downloading compromised packages list...");
|
|
82
|
+
async function fetchRemoteList(url) {
|
|
78
83
|
return new Promise((resolve, reject) => {
|
|
79
|
-
https.get(
|
|
84
|
+
https.get(url, (res) => {
|
|
85
|
+
if (res.statusCode !== 200) {
|
|
86
|
+
reject(new Error(`Failed to fetch ${url}: Status Code ${res.statusCode}`));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
80
89
|
let data = '';
|
|
81
90
|
res.on('data', (chunk) => { data += chunk; });
|
|
82
91
|
res.on('end', () => {
|
|
83
|
-
const
|
|
84
|
-
.split('\n')
|
|
85
|
-
.map(line => line.trim())
|
|
86
|
-
.filter(line => line && !line.startsWith('#')) // Ignore comments and empty lines
|
|
87
|
-
|
|
88
|
-
resolve(packages);
|
|
92
|
+
const lines = data
|
|
93
|
+
.split('\n') // Split into lines
|
|
94
|
+
.map(line => line.trim()) // Trim whitespace
|
|
95
|
+
.filter(line => line && !line.startsWith('#')); // Ignore comments and empty lines
|
|
96
|
+
resolve(lines);
|
|
89
97
|
});
|
|
90
|
-
}).on('error', (err) =>
|
|
91
|
-
|
|
92
|
-
|
|
98
|
+
}).on('error', (err) => reject(err));
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Parses the raw lines of the compromised libs file into a list of "name@version".
|
|
104
|
+
* @param {string[]} lines The raw lines from the file.
|
|
105
|
+
* @returns {string[]} Parsed packages.
|
|
106
|
+
*/
|
|
107
|
+
function parseCompromisedLibs(lines) {
|
|
108
|
+
const allCompromised = [];
|
|
109
|
+
lines.forEach(line => {
|
|
110
|
+
if (line.includes(',=')) {
|
|
111
|
+
// Handles format: "pkg-name,= 1.0.0 || = 2.0.0"
|
|
112
|
+
const [name, versionsPart] = line.split(',=');
|
|
113
|
+
const versions = versionsPart.split('||').map(v => v.replace('=', '').trim());
|
|
114
|
+
versions.forEach(version => {
|
|
115
|
+
if (name && version) allCompromised.push(`${name.trim()}@${version}`);
|
|
116
|
+
});
|
|
117
|
+
} else if (line.includes(':')) {
|
|
118
|
+
// Handles original format: "pkg-name:1.0.0"
|
|
119
|
+
allCompromised.push(line.replace(':', '@'));
|
|
120
|
+
} else if (line.lastIndexOf('@') > 0) {
|
|
121
|
+
// Handles format: "pkg-name@1.0.0" or "@scope/pkg@1.0.0"
|
|
122
|
+
// We check lastIndexOf('@') > 0 to ensure there is a version separator,
|
|
123
|
+
// avoiding cases like just "@scope/pkg" (which implies no specific version).
|
|
124
|
+
allCompromised.push(line);
|
|
125
|
+
}
|
|
93
126
|
});
|
|
127
|
+
return allCompromised;
|
|
94
128
|
}
|
|
95
129
|
|
|
96
130
|
/**
|
|
@@ -210,6 +244,65 @@ function getLocalPackages(lockfilePath) {
|
|
|
210
244
|
return packages;
|
|
211
245
|
}
|
|
212
246
|
|
|
247
|
+
/**
|
|
248
|
+
* Scans the node_modules directory to find all installed packages by reading their package.json files.
|
|
249
|
+
* This is a deep scan to find packages that might not be in the lockfile.
|
|
250
|
+
* It also flags directories whose names match known compromised packages, even without a package.json.
|
|
251
|
+
* @param {string} projectRoot - The root of the project.
|
|
252
|
+
* @param {Set<string>} compromisedNames - A Set of names of known compromised packages.
|
|
253
|
+
* @returns {Set<string>} A Set of local dependencies in "name@version" format.
|
|
254
|
+
*/
|
|
255
|
+
function getPackagesFromNodeModules(projectRoot, compromisedNames) {
|
|
256
|
+
log.info("Performing deep scan of node_modules to find all installed packages...");
|
|
257
|
+
const packages = new Set();
|
|
258
|
+
const nodeModulesPath = path.join(projectRoot, 'node_modules');
|
|
259
|
+
|
|
260
|
+
if (!fs.existsSync(nodeModulesPath)) {
|
|
261
|
+
return packages;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const directories = fs.readdirSync(nodeModulesPath, { withFileTypes: true });
|
|
265
|
+
|
|
266
|
+
for (const dir of directories) {
|
|
267
|
+
const dirPath = path.join(nodeModulesPath, dir.name);
|
|
268
|
+
const isScoped = dir.name.startsWith('@');
|
|
269
|
+
|
|
270
|
+
if (isScoped) { // Scoped package
|
|
271
|
+
if (!fs.existsSync(dirPath)) continue;
|
|
272
|
+
const scopedDirs = fs.readdirSync(dirPath);
|
|
273
|
+
for (const scopedDir of scopedDirs) {
|
|
274
|
+
const fullPackageName = `${dir.name}/${scopedDir}`;
|
|
275
|
+
if (compromisedNames.has(fullPackageName)) {
|
|
276
|
+
packages.add(`${fullPackageName}@ (directory found without package.json)`);
|
|
277
|
+
}
|
|
278
|
+
const pkgJsonPath = path.join(dirPath, scopedDir, 'package.json');
|
|
279
|
+
addPackage(pkgJsonPath, packages);
|
|
280
|
+
}
|
|
281
|
+
} else { // Regular package
|
|
282
|
+
checkDirectory(dir.name, dirPath, packages);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function addPackage(pkgJsonPath, packageSet) {
|
|
287
|
+
if (fs.existsSync(pkgJsonPath)) {
|
|
288
|
+
try {
|
|
289
|
+
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
|
|
290
|
+
if (pkg.name && pkg.version) {
|
|
291
|
+
packageSet.add(`${pkg.name}@${pkg.version}`);
|
|
292
|
+
}
|
|
293
|
+
} catch (e) { /* Ignore parsing errors */ }
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function checkDirectory(name, fullPath, packageSet) {
|
|
298
|
+
if (compromisedNames.has(name)) {
|
|
299
|
+
packageSet.add(`${name}@ (directory found without package.json)`);
|
|
300
|
+
}
|
|
301
|
+
const pkgJsonPath = path.join(fullPath, 'package.json');
|
|
302
|
+
addPackage(pkgJsonPath, packageSet);
|
|
303
|
+
}
|
|
304
|
+
return packages;
|
|
305
|
+
}
|
|
213
306
|
/**
|
|
214
307
|
* Recursively finds all files in a directory, ignoring node_modules, .git, and binary-like extensions.
|
|
215
308
|
* @param {string} directory - The directory to scan.
|
|
@@ -217,7 +310,7 @@ function getLocalPackages(lockfilePath) {
|
|
|
217
310
|
*/
|
|
218
311
|
function getAllFiles(directory) {
|
|
219
312
|
const filesToScan = [];
|
|
220
|
-
const ignoredDirs = new Set(['node_modules', '.git']);
|
|
313
|
+
const ignoredDirs = new Set(['node_modules', '.git', '.angular', '.next', '.nuxt', 'dist', 'build', 'coverage']);
|
|
221
314
|
const ignoredExtensions = new Set(['.md', '.d.ts', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.woff', '.woff2', '.eot', '.ttf', '.ico']);
|
|
222
315
|
|
|
223
316
|
function findFiles(dir) {
|
|
@@ -249,41 +342,31 @@ function getAllFiles(directory) {
|
|
|
249
342
|
* Scans a list of files for multiple types of threats.
|
|
250
343
|
* @param {string[]} allFiles - A list of absolute file paths to scan.
|
|
251
344
|
* @param {string} projectRoot - The root directory of the project for relative paths.
|
|
252
|
-
* @
|
|
345
|
+
* @param {object} iocs - Object containing IOC sets and arrays.
|
|
346
|
+
* @returns {Finding[]} A list of findings.
|
|
253
347
|
*/
|
|
254
|
-
function scanProjectFiles(allFiles, projectRoot) {
|
|
348
|
+
function scanProjectFiles(allFiles, projectRoot, iocs) {
|
|
255
349
|
log.info(`Scanning ${allFiles.length} project files for malicious indicators...`);
|
|
256
350
|
|
|
257
|
-
const findings =
|
|
258
|
-
hashMatches: [],
|
|
259
|
-
namespaceMatches: new Set(),
|
|
260
|
-
hookMatches: [],
|
|
261
|
-
correlatedExfil: [],
|
|
262
|
-
filenameMatches: [],
|
|
263
|
-
commandMatches: [],
|
|
264
|
-
};
|
|
265
|
-
|
|
351
|
+
const findings = [];
|
|
266
352
|
const pkgJsonFiles = allFiles.filter(f => path.basename(f) === 'package.json');
|
|
267
353
|
|
|
268
|
-
// Scan for
|
|
269
|
-
log.info("Checking for
|
|
354
|
+
// Scan for postinstall hooks in package.json files
|
|
355
|
+
log.info("Checking for package.json hooks...");
|
|
270
356
|
for (const file of pkgJsonFiles) {
|
|
271
357
|
try {
|
|
272
358
|
const content = fs.readFileSync(file, 'utf-8');
|
|
273
359
|
const pkg = JSON.parse(content);
|
|
274
360
|
|
|
275
|
-
// Check namespaces
|
|
276
|
-
const allDeps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
277
|
-
for (const depName in allDeps) {
|
|
278
|
-
const namespace = depName.split('/')[0];
|
|
279
|
-
if (COMPROMISED_NAMESPACES.includes(namespace)) {
|
|
280
|
-
findings.namespaceMatches.add(`Warning: Contains packages from compromised namespace: ${namespace} (Found in ${path.relative(projectRoot, file)})`);
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
|
|
284
361
|
// Check for postinstall hooks
|
|
285
|
-
if (pkg.scripts
|
|
286
|
-
|
|
362
|
+
if (pkg.scripts) {
|
|
363
|
+
const hooks = ['preinstall', 'install', 'postinstall', 'prepare'];
|
|
364
|
+
hooks.forEach(hook => {
|
|
365
|
+
if (pkg.scripts[hook]) {
|
|
366
|
+
findings.push(new Finding("T1064", `Lifecycle Hook Detected (${hook})`, "LOW", `Script: ${pkg.scripts[hook]}`, path.relative(projectRoot, file)));
|
|
367
|
+
// Check for suspicious commands inside the hook
|
|
368
|
+
}
|
|
369
|
+
});
|
|
287
370
|
}
|
|
288
371
|
} catch (e) {
|
|
289
372
|
log.warn(`Could not parse ${path.relative(projectRoot, file)}: ${e.message}`);
|
|
@@ -292,9 +375,8 @@ function scanProjectFiles(allFiles, projectRoot) {
|
|
|
292
375
|
|
|
293
376
|
// Scan file contents for hashes and exfiltration patterns
|
|
294
377
|
log.info("Scanning file signatures and for exfiltration patterns...");
|
|
295
|
-
const envRegex = new RegExp(
|
|
296
|
-
const exfilRegex = new RegExp(
|
|
297
|
-
const commandRegex = new RegExp(MALICIOUS_COMMAND_PATTERNS.join('|').replace(/%/g, '%').replace(/\*/g, '\\*'), 'i');
|
|
378
|
+
const envRegex = new RegExp(iocs.envPatterns.join('|'));
|
|
379
|
+
const exfilRegex = new RegExp(iocs.exfilPatterns.join('|'));
|
|
298
380
|
|
|
299
381
|
for (const file of allFiles) {
|
|
300
382
|
try {
|
|
@@ -305,28 +387,44 @@ function scanProjectFiles(allFiles, projectRoot) {
|
|
|
305
387
|
hashSum.update(fileBuffer);
|
|
306
388
|
const hex = hashSum.digest('hex');
|
|
307
389
|
|
|
308
|
-
if (
|
|
309
|
-
findings.
|
|
390
|
+
if (iocs.maliciousHashes.has(hex)) {
|
|
391
|
+
findings.push(new Finding("T1195", "Known Malicious File Hash", "CRITICAL", `Hash: ${hex}`, path.relative(projectRoot, file)));
|
|
310
392
|
}
|
|
311
393
|
|
|
312
394
|
// 2. Check filename
|
|
313
|
-
if (
|
|
314
|
-
findings.
|
|
395
|
+
if (iocs.maliciousFilenames.has(path.basename(file))) {
|
|
396
|
+
findings.push(new Finding("T1195", "Known Malicious Filename", "HIGH", `Filename: ${path.basename(file)}`, path.relative(projectRoot, file)));
|
|
315
397
|
}
|
|
316
398
|
|
|
317
|
-
// 3.
|
|
399
|
+
// 3. Behavioral Analysis (Text files)
|
|
318
400
|
if (file.endsWith('.js') || file.endsWith('.ts') || file.endsWith('.json') || file.endsWith('.sh') || file.endsWith('.yml')) {
|
|
319
401
|
const content = fileBuffer.toString('utf-8');
|
|
320
|
-
|
|
321
|
-
|
|
402
|
+
|
|
403
|
+
// T1552: Unsecured Credentials
|
|
404
|
+
if (/\bprocess\.env\b/.test(content) || envRegex.test(content)) {
|
|
405
|
+
findings.push(new Finding("T1552", "Access to Environment Variables", "LOW", "Pattern: process.env or similar", path.relative(projectRoot, file)));
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// T1518: Software Discovery (CI/Runners)
|
|
409
|
+
if (/\bGITHUB_ACTIONS\b|\bCI\b|\bGITLAB_CI\b/.test(content)) {
|
|
410
|
+
findings.push(new Finding("T1518", "CI Environment Discovery", "LOW", "Pattern: CI/GITHUB_ACTIONS", path.relative(projectRoot, file)));
|
|
411
|
+
}
|
|
322
412
|
|
|
323
|
-
//
|
|
324
|
-
if (
|
|
325
|
-
findings.
|
|
413
|
+
// T1082: System Information Discovery
|
|
414
|
+
if (/\bos\.platform\(\)|\bos\.userInfo\(\)|\bhomedir\(\)/.test(content)) {
|
|
415
|
+
findings.push(new Finding("T1082", "System Information Discovery", "LOW", "Pattern: os.platform/userInfo", path.relative(projectRoot, file)));
|
|
326
416
|
}
|
|
327
417
|
|
|
328
|
-
|
|
329
|
-
|
|
418
|
+
// T1059: Command Execution
|
|
419
|
+
if (/\bchild_process\b|\bexec\s*\(|\bspawn\s*\(|\bexecSync\s*\(/.test(content)) {
|
|
420
|
+
findings.push(new Finding("T1059", "Process Execution", "MEDIUM", "Pattern: child_process/exec", path.relative(projectRoot, file)));
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// T1053: Persistence via Workflows
|
|
424
|
+
if (file.includes('.github/workflows')) {
|
|
425
|
+
if (/run:.*npm publish/.test(content) || /run:.*git push/.test(content)) {
|
|
426
|
+
findings.push(new Finding("T1098", "Suspicious Workflow Action", "HIGH", "Pattern: npm publish/git push in workflow", path.relative(projectRoot, file)));
|
|
427
|
+
}
|
|
330
428
|
}
|
|
331
429
|
}
|
|
332
430
|
} catch (e) {
|
|
@@ -338,12 +436,84 @@ function scanProjectFiles(allFiles, projectRoot) {
|
|
|
338
436
|
return findings;
|
|
339
437
|
}
|
|
340
438
|
|
|
439
|
+
/**
|
|
440
|
+
* Recursively scans the node_modules directory specifically for known malicious filenames.
|
|
441
|
+
* This is a targeted scan for performance reasons.
|
|
442
|
+
* @param {string} nodeModulesPath - The absolute path to the node_modules directory.
|
|
443
|
+
* @param {string} projectRoot - The root directory of the project for relative paths.
|
|
444
|
+
* @param {object} iocs - Object containing IOC sets and arrays.
|
|
445
|
+
* @returns {Finding[]} A list of findings.
|
|
446
|
+
*/
|
|
447
|
+
function scanNodeModulesForFiles(nodeModulesPath, projectRoot, iocs) {
|
|
448
|
+
if (!fs.existsSync(nodeModulesPath)) {
|
|
449
|
+
return [];
|
|
450
|
+
}
|
|
451
|
+
log.info("Scanning node_modules for malicious filenames and suspicious package.json scripts...");
|
|
452
|
+
const findings = [];
|
|
453
|
+
|
|
454
|
+
// Regex for malicious commands (same as in scanProjectFiles)
|
|
455
|
+
const commandRegex = new RegExp(iocs.maliciousCommands.join('|').replace(/%/g, '%').replace(/\*/g, '\\*'), 'i');
|
|
456
|
+
|
|
457
|
+
function findFiles(dir) {
|
|
458
|
+
let entries;
|
|
459
|
+
try {
|
|
460
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
461
|
+
} catch (e) { return; }
|
|
462
|
+
|
|
463
|
+
for (const entry of entries) {
|
|
464
|
+
const fullPath = path.join(dir, entry.name);
|
|
465
|
+
if (entry.isDirectory()) {
|
|
466
|
+
findFiles(fullPath);
|
|
467
|
+
} else if (entry.isFile()) {
|
|
468
|
+
if (iocs.maliciousFilenames.has(entry.name)) {
|
|
469
|
+
findings.push(new Finding("T1195", "Malicious Filename in node_modules", "HIGH", `File: ${entry.name}`, path.relative(projectRoot, fullPath)));
|
|
470
|
+
} else if (entry.name === 'package.json') {
|
|
471
|
+
// Inspect package.json inside node_modules for malicious commands
|
|
472
|
+
try {
|
|
473
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
474
|
+
if (commandRegex.test(content)) {
|
|
475
|
+
findings.push(new Finding("T1195", "Malicious Command in Dependency", "CRITICAL", "Pattern match in package.json", path.relative(projectRoot, fullPath)));
|
|
476
|
+
}
|
|
477
|
+
} catch (e) { /* ignore read errors */ }
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
findFiles(nodeModulesPath);
|
|
484
|
+
return findings;
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Scans the user's home directory for known malicious artifacts.
|
|
488
|
+
* @returns {Finding[]} A list of findings.
|
|
489
|
+
*/
|
|
490
|
+
function scanHomeDirectory() {
|
|
491
|
+
log.info("Scanning user home directory for known artifacts...");
|
|
492
|
+
const homeDir = require('os').homedir();
|
|
493
|
+
const findings = [];
|
|
494
|
+
const truffleCachePath = path.join(homeDir, '.truffler-cache');
|
|
495
|
+
|
|
496
|
+
if (fs.existsSync(truffleCachePath)) {
|
|
497
|
+
findings.push(new Finding("T1552", "Malicious Artifact (Trufflehog Cache)", "HIGH", `Path: ${truffleCachePath}`, "HOME_DIR"));
|
|
498
|
+
// Also check for the specific binaries inside
|
|
499
|
+
const trufflehogPath = path.join(truffleCachePath, 'trufflehog');
|
|
500
|
+
const trufflehogExePath = path.join(truffleCachePath, 'trufflehog.exe');
|
|
501
|
+
if (fs.existsSync(trufflehogPath)) {
|
|
502
|
+
findings.push(new Finding("T1552", "Malicious Binary (Trufflehog)", "HIGH", `Path: ${trufflehogPath}`, "HOME_DIR"));
|
|
503
|
+
}
|
|
504
|
+
if (fs.existsSync(trufflehogExePath)) {
|
|
505
|
+
findings.push(new Finding("T1552", "Malicious Binary (Trufflehog.exe)", "HIGH", `Path: ${trufflehogExePath}`, "HOME_DIR"));
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return findings;
|
|
509
|
+
}
|
|
341
510
|
/**
|
|
342
511
|
* Orchestrates the dependency analysis.
|
|
343
512
|
* @param {string} projectRoot The root of the project.
|
|
344
|
-
* @
|
|
513
|
+
* @param {Set<string>} compromisedPackagesWithVersions - Set of known compromised packages.
|
|
514
|
+
* @returns {Promise<Finding[]>} A list of findings.
|
|
345
515
|
*/
|
|
346
|
-
async function runDependencyAnalysis(projectRoot) {
|
|
516
|
+
async function runDependencyAnalysis(projectRoot, compromisedPackagesWithVersions) {
|
|
347
517
|
log.header("Module 1: Dependency Analysis");
|
|
348
518
|
const pnpmLockFile = path.join(projectRoot, 'pnpm-lock.yaml');
|
|
349
519
|
const yarnLockFile = path.join(projectRoot, 'yarn.lock');
|
|
@@ -352,7 +522,7 @@ async function runDependencyAnalysis(projectRoot) {
|
|
|
352
522
|
|
|
353
523
|
if (!fs.existsSync(pkgFile)) {
|
|
354
524
|
log.warn("No package.json found. Skipping all dependency analysis.");
|
|
355
|
-
return
|
|
525
|
+
return [];
|
|
356
526
|
}
|
|
357
527
|
|
|
358
528
|
let localPackages = new Set();
|
|
@@ -372,22 +542,84 @@ async function runDependencyAnalysis(projectRoot) {
|
|
|
372
542
|
localPackages = parsePackageJson(pkgFile);
|
|
373
543
|
}
|
|
374
544
|
|
|
545
|
+
// Create a set of just the names for directory matching
|
|
546
|
+
const compromisedPackageNames = new Set(Array.from(compromisedPackagesWithVersions).map(pkg => {
|
|
547
|
+
const lastAt = pkg.lastIndexOf('@');
|
|
548
|
+
return lastAt > 0 ? pkg.substring(0, lastAt) : pkg.split('@')[0];
|
|
549
|
+
}));
|
|
550
|
+
|
|
551
|
+
// Perform a deep scan of node_modules to catch packages not in lockfiles
|
|
552
|
+
const directScanPackages = getPackagesFromNodeModules(projectRoot, compromisedPackageNames);
|
|
553
|
+
directScanPackages.forEach(pkg => localPackages.add(pkg));
|
|
554
|
+
|
|
555
|
+
|
|
375
556
|
if (localPackages.size === 0) {
|
|
376
557
|
log.warn("Could not determine local packages. Skipping version check.");
|
|
377
|
-
return
|
|
558
|
+
return [];
|
|
378
559
|
}
|
|
379
560
|
|
|
380
561
|
log.info("Checking for vulnerable versions...");
|
|
381
|
-
const
|
|
382
|
-
const matches = new Set();
|
|
562
|
+
const findings = [];
|
|
383
563
|
for (const localPkg of localPackages) {
|
|
384
|
-
if (
|
|
385
|
-
|
|
564
|
+
if (compromisedPackagesWithVersions.has(localPkg) || localPkg.includes('(directory found without package.json)')) {
|
|
565
|
+
let evidence = `Package: ${localPkg}\n IOC Source: ${IOC_URLS.COMPROMISED_LIBS}`;
|
|
566
|
+
const lastAt = localPkg.lastIndexOf('@');
|
|
567
|
+
if (lastAt > 0) {
|
|
568
|
+
const name = localPkg.substring(0, lastAt);
|
|
569
|
+
const localPath = path.join(projectRoot, 'node_modules', name);
|
|
570
|
+
evidence += `\n Path: ${localPath}`;
|
|
571
|
+
}
|
|
572
|
+
findings.push(new Finding("T1195", "Compromised Package Version", "CRITICAL", evidence, "package.json/lockfile"));
|
|
386
573
|
}
|
|
387
574
|
}
|
|
388
575
|
log.info("Dependency analysis complete.");
|
|
389
|
-
return
|
|
576
|
+
return findings;
|
|
390
577
|
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Calculates the final score and verdict based on findings and correlations.
|
|
581
|
+
* @param {Finding[]} findings
|
|
582
|
+
*/
|
|
583
|
+
function calculateRisk(findings) {
|
|
584
|
+
let totalScore = 0;
|
|
585
|
+
const techniquesDetected = new Set();
|
|
586
|
+
const tacticsDetected = new Set();
|
|
587
|
+
|
|
588
|
+
findings.forEach(f => {
|
|
589
|
+
totalScore += f.score;
|
|
590
|
+
techniquesDetected.add(f.technique);
|
|
591
|
+
tacticsDetected.add(f.tactic);
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
// --- Correlation Logic ---
|
|
595
|
+
const hasScripting = techniquesDetected.has("T1064"); // Lifecycle hooks
|
|
596
|
+
const hasCredAccess = techniquesDetected.has("T1552"); // process.env
|
|
597
|
+
const hasDiscovery = techniquesDetected.has("T1518") || techniquesDetected.has("T1082"); // CI or Sys info
|
|
598
|
+
const hasExecution = techniquesDetected.has("T1059"); // child_process
|
|
599
|
+
|
|
600
|
+
let suspectedFamily = "None";
|
|
601
|
+
let correlationBonus = 0;
|
|
602
|
+
|
|
603
|
+
// Rule: T1064 + T1552 + T1518 -> High probability of Shai Hulud
|
|
604
|
+
if (hasScripting && hasCredAccess && hasDiscovery) {
|
|
605
|
+
correlationBonus += 50;
|
|
606
|
+
suspectedFamily = "Shai-Hulud (High Confidence)";
|
|
607
|
+
findings.push(new Finding("CORRELATION", "Behavioral Pattern Match: Shai-Hulud", "CRITICAL", "Combination of Install Script + Env Access + CI Discovery", "Multiple Sources"));
|
|
608
|
+
} else if (hasScripting && hasExecution) {
|
|
609
|
+
correlationBonus += 20;
|
|
610
|
+
suspectedFamily = "Generic Malware Loader";
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
totalScore += correlationBonus;
|
|
614
|
+
|
|
615
|
+
let verdict = "LOW";
|
|
616
|
+
if (totalScore > 80) verdict = "CRITICAL";
|
|
617
|
+
else if (totalScore > 40) verdict = "HIGH";
|
|
618
|
+
else if (totalScore > 15) verdict = "MEDIUM";
|
|
619
|
+
|
|
620
|
+
return { totalScore, verdict, suspectedFamily, techniques: Array.from(techniquesDetected) };
|
|
621
|
+
}
|
|
622
|
+
|
|
391
623
|
/**
|
|
392
624
|
* Main function to orchestrate the scan.
|
|
393
625
|
*/
|
|
@@ -404,94 +636,86 @@ async function main() {
|
|
|
404
636
|
console.log(`\n${colors.BLUE}${colors.BOLD}--- Shai-Hulud Integrity Scanner (Node.js) ---${colors.RESET}`);
|
|
405
637
|
log.info(`Scanning project at: ${projectRoot}`);
|
|
406
638
|
|
|
639
|
+
// --- Fetch IOCs ---
|
|
640
|
+
log.info("Downloading IOC definitions from remote repositories...");
|
|
641
|
+
const [
|
|
642
|
+
compromisedLibsLines,
|
|
643
|
+
maliciousHashes,
|
|
644
|
+
maliciousFilenames,
|
|
645
|
+
maliciousCommands,
|
|
646
|
+
exfilPatterns,
|
|
647
|
+
envPatterns
|
|
648
|
+
] = await Promise.all([
|
|
649
|
+
fetchRemoteList(IOC_URLS.COMPROMISED_LIBS),
|
|
650
|
+
fetchRemoteList(IOC_URLS.MALICIOUS_HASHES),
|
|
651
|
+
fetchRemoteList(IOC_URLS.MALICIOUS_FILENAMES),
|
|
652
|
+
fetchRemoteList(IOC_URLS.MALICIOUS_COMMANDS),
|
|
653
|
+
fetchRemoteList(IOC_URLS.EXFIL_PATTERNS),
|
|
654
|
+
fetchRemoteList(IOC_URLS.ENV_PATTERNS)
|
|
655
|
+
]);
|
|
656
|
+
|
|
657
|
+
const iocs = {
|
|
658
|
+
maliciousHashes: new Set(maliciousHashes),
|
|
659
|
+
maliciousFilenames: new Set(maliciousFilenames),
|
|
660
|
+
maliciousCommands: maliciousCommands,
|
|
661
|
+
exfilPatterns: exfilPatterns,
|
|
662
|
+
envPatterns: envPatterns,
|
|
663
|
+
compromisedPackages: new Set(parseCompromisedLibs(compromisedLibsLines))
|
|
664
|
+
};
|
|
665
|
+
|
|
666
|
+
log.info(`Loaded IOCs: ${iocs.compromisedPackages.size} compromised pkgs, ${iocs.maliciousHashes.size} hashes, ${iocs.maliciousFilenames.size} filenames.`);
|
|
667
|
+
|
|
407
668
|
// --- Run Analyses ---
|
|
408
|
-
const
|
|
669
|
+
const allFindings = [];
|
|
670
|
+
|
|
671
|
+
const dependencyFindings = await runDependencyAnalysis(projectRoot, iocs.compromisedPackages);
|
|
672
|
+
allFindings.push(...dependencyFindings);
|
|
409
673
|
|
|
410
674
|
log.header("Module 2: Project Structure & Content Analysis");
|
|
411
675
|
const allFiles = getAllFiles(projectRoot);
|
|
412
|
-
const
|
|
413
|
-
|
|
676
|
+
const fileFindings = scanProjectFiles(allFiles, projectRoot, iocs);
|
|
677
|
+
allFindings.push(...fileFindings);
|
|
414
678
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
let issuesFound = false;
|
|
418
|
-
let report = "";
|
|
419
|
-
|
|
420
|
-
if (fileScanFindings.hashMatches.length > 0) {
|
|
421
|
-
issuesFound = true;
|
|
422
|
-
report += `${colors.RED}🚨 CRITICAL RISK: Known Malware Signature Detected${colors.RESET}\n`;
|
|
423
|
-
fileScanFindings.hashMatches.forEach(match => {
|
|
424
|
-
report += ` - File with matching signature: ${colors.YELLOW}${match}${colors.RESET}\n`;
|
|
425
|
-
});
|
|
426
|
-
report += " NOTE: This is a definitive indicator of compromise. Immediate investigation is required.\n\n";
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
if (homeDirFindings.length > 0) {
|
|
430
|
-
issuesFound = true;
|
|
431
|
-
report += `${colors.RED}🚨 HIGH RISK: Malicious Artifacts Found in Home Directory${colors.RESET}\n`;
|
|
432
|
-
homeDirFindings.forEach(match => {
|
|
433
|
-
report += ` - ${colors.YELLOW}${match}${colors.RESET}\n`;
|
|
434
|
-
});
|
|
435
|
-
report += " NOTE: These artifacts are used to store and execute malicious tools.\n\n";
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
if (fileScanFindings.filenameMatches.length > 0) {
|
|
439
|
-
issuesFound = true;
|
|
440
|
-
report += `${colors.RED}🚨 HIGH RISK: Known Malicious Filename Detected${colors.RESET}\n`;
|
|
441
|
-
fileScanFindings.filenameMatches.forEach(match => {
|
|
442
|
-
report += ` - File: ${colors.YELLOW}${match}${colors.RESET}\n`;
|
|
443
|
-
});
|
|
444
|
-
report += " NOTE: These filenames are associated with malicious scripts.\n\n";
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
if (fileScanFindings.correlatedExfil.length > 0) {
|
|
448
|
-
issuesFound = true;
|
|
449
|
-
report += `${colors.RED}🚨 HIGH RISK: Environment Scanning with Exfiltration Detected${colors.RESET}\n`;
|
|
450
|
-
fileScanFindings.correlatedExfil.forEach(match => {
|
|
451
|
-
report += ` - File: ${colors.YELLOW}${match}${colors.RESET}\n`;
|
|
452
|
-
});
|
|
453
|
-
report += " NOTE: These files access secrets AND contain data exfiltration patterns.\n\n";
|
|
454
|
-
}
|
|
679
|
+
const homeDirFindings = scanHomeDirectory();
|
|
680
|
+
allFindings.push(...homeDirFindings);
|
|
455
681
|
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
report += `${colors.RED}🚨 HIGH RISK: Compromised Package Versions Detected${colors.RESET}\n`;
|
|
459
|
-
dependencyMatches.forEach(match => {
|
|
460
|
-
report += ` - Package: ${colors.YELLOW}${match}${colors.RESET}\n`;
|
|
461
|
-
});
|
|
462
|
-
report += " NOTE: These specific package versions are known to be compromised.\n\n";
|
|
463
|
-
}
|
|
682
|
+
const nodeModulesFindings = scanNodeModulesForFiles(path.join(projectRoot, 'node_modules'), projectRoot, iocs);
|
|
683
|
+
allFindings.push(...nodeModulesFindings);
|
|
464
684
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
report += `${colors.YELLOW}⚠️ MEDIUM RISK: Packages from Compromised Namespaces${colors.RESET}\n`;
|
|
468
|
-
fileScanFindings.namespaceMatches.forEach(match => {
|
|
469
|
-
report += ` - ${match}\n`;
|
|
470
|
-
});
|
|
471
|
-
report += " NOTE: Review packages from these organizations carefully.\n\n";
|
|
472
|
-
}
|
|
685
|
+
// --- Scoring & Correlation ---
|
|
686
|
+
const riskAssessment = calculateRisk(allFindings);
|
|
473
687
|
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
688
|
+
// --- Reporting ---
|
|
689
|
+
log.header("Scan Report");
|
|
690
|
+
|
|
691
|
+
// JSON Output support
|
|
692
|
+
if (process.argv.includes('--json')) {
|
|
693
|
+
console.log(JSON.stringify({
|
|
694
|
+
risk: riskAssessment,
|
|
695
|
+
findings: allFindings
|
|
696
|
+
}, null, 2));
|
|
697
|
+
process.exit(riskAssessment.verdict === 'LOW' ? 0 : 1);
|
|
481
698
|
}
|
|
482
699
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
700
|
+
// Human Readable Output
|
|
701
|
+
console.log(`\n${colors.BOLD}Risk Verdict:${colors.RESET} ${riskAssessment.verdict === 'CRITICAL' || riskAssessment.verdict === 'HIGH' ? colors.RED : riskAssessment.verdict === 'MEDIUM' ? colors.YELLOW : colors.GREEN}${riskAssessment.verdict}${colors.RESET}`);
|
|
702
|
+
console.log(`${colors.BOLD}Total Score:${colors.RESET} ${riskAssessment.totalScore}`);
|
|
703
|
+
console.log(`${colors.BOLD}Suspected Family:${colors.RESET} ${riskAssessment.suspectedFamily}`);
|
|
704
|
+
console.log(`${colors.BOLD}MITRE Techniques:${colors.RESET} ${riskAssessment.techniques.join(', ')}\n`);
|
|
705
|
+
|
|
706
|
+
if (allFindings.length > 0) {
|
|
707
|
+
console.log(`${colors.BOLD}Detailed Findings:${colors.RESET}`);
|
|
708
|
+
allFindings.sort((a, b) => b.score - a.score).forEach(f => {
|
|
709
|
+
const color = f.severity === 'CRITICAL' ? colors.RED : f.severity === 'HIGH' ? colors.RED : f.severity === 'MEDIUM' ? colors.YELLOW : colors.BLUE;
|
|
710
|
+
console.log(`[${color}${f.severity}${colors.RESET}] ${colors.BOLD}${f.technique} - ${f.name}${colors.RESET}`);
|
|
711
|
+
console.log(` File: ${f.file}`);
|
|
712
|
+
console.log(` Evidence: ${f.evidence}`);
|
|
713
|
+
console.log(` Description: ${f.description}\n`);
|
|
488
714
|
});
|
|
489
|
-
report += " NOTE: These files contain commands known to be used for malicious purposes.\n\n";
|
|
490
715
|
}
|
|
491
716
|
|
|
492
|
-
if (
|
|
493
|
-
console.
|
|
494
|
-
log.error("Scan complete. Actionable issues were found.");
|
|
717
|
+
if (riskAssessment.verdict !== 'LOW') {
|
|
718
|
+
console.error(`${colors.RED}${colors.BOLD}❌ Scan complete. Potential threats detected.${colors.RESET}`);
|
|
495
719
|
process.exit(2);
|
|
496
720
|
} else {
|
|
497
721
|
log.info(`${colors.GREEN}✅ No actionable project integrity issues found.${colors.RESET}`);
|