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/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 COMPROMISED_LIST_URL = "https://raw.githubusercontent.com/migohe14/hulud-scanner/refs/heads/main/compromised-libs.txt";
10
- const MALICIOUS_HASHES = new Set([
11
- "46faab8ab153fae6e80e7cca38eab363075bb524edd79e42269217a083628f09",
12
- "de0e25a3e6c1e1e5998b306b7141b3dc4c0088da9d7bb47c1c00c91e6e4f85d6",
13
- "81d2a004a1bca6ef87a1caf7d0e0b355ad1764238e40ff6d1b1cb77ad4f595c3",
14
- "83a650ce44b2a9854802a7fb4c202877815274c129af49e6c2d1d5d5d55c501e",
15
- "4b2399646573bb737c4969563303d8ee2e9ddbd1b271f1ca9e35ea78062538db",
16
- "dc67467a39b70d1cd4c1f7f7a459b35058163592f4a9e8fb4dffcbba98ef210c",
17
- "b74caeaa75e077c99f7d44f46daaf9796a3be43ecf24f2a1fd381844669da777",
18
- "86532ed94c5804e1ca32fa67257e1bb9de628e3e48a1f56e67042dc055effb5b",
19
- "aba1fcbd15c6ba6d9b96e34cec287660fff4a31632bf76f2a766c499f55ca1ee",
20
- ]);
21
- const COMPROMISED_NAMESPACES = [
22
- "@crowdstrike", "@art-ws", "@ngx", "@ctrl", "@nativescript-community",
23
- "@ahmedhfarag", "@operato", "@teselagen", "@things-factory", "@hestjs",
24
- "@nstudio", "@basic-ui-components-stc", "@nexe", "@thangved",
25
- "@tnf-dev", "@ui-ux-gang", "@yoobic"
26
- ];
27
- const EXFIL_PATTERNS = ['webhook.site', 'bb8ca5f6-4175-45d2-b042-fc9ebb8170b7', 'exfiltrat'];
28
- const ENV_PATTERNS = ['process\\.env', 'os\\.environ', 'getenv', 'AWS_ACCESS_KEY', 'GITHUB_TOKEN', 'NPM_TOKEN'];
29
- const MALICIOUS_FILENAMES = new Set([
30
- 'bun_environment.js',
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
- * Downloads the list of compromised packages from GitHub.
74
- * @returns {Promise<Set<string>>} A Set of packages in "name@version" format.
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 getCompromisedPackages() {
77
- log.info("Downloading compromised packages list...");
82
+ async function fetchRemoteList(url) {
78
83
  return new Promise((resolve, reject) => {
79
- https.get(COMPROMISED_LIST_URL, (res) => {
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 packages = data
84
- .split('\n')
85
- .map(line => line.trim())
86
- .filter(line => line && !line.startsWith('#')) // Ignore comments and empty lines
87
- .map(line => line.replace(':', '@')); // Change 'pkg:1.0.0' to 'pkg@1.0.0'
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
- reject(new Error(`Failed to download list: ${err.message}`));
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
- * @returns {object} An object containing arrays of different findings.
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 compromised namespaces and postinstall hooks in package.json files
269
- log.info("Checking for compromised namespaces and package.json hooks...");
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 && pkg.scripts.postinstall) {
286
- findings.hookMatches.push(`File: ${path.relative(projectRoot, file)}`);
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(ENV_PATTERNS.join('|'));
296
- const exfilRegex = new RegExp(EXFIL_PATTERNS.join('|'));
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 (MALICIOUS_HASHES.has(hex)) {
309
- findings.hashMatches.push(path.relative(projectRoot, file));
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 (MALICIOUS_FILENAMES.has(path.basename(file))) {
314
- findings.filenameMatches.push(path.relative(projectRoot, file));
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. Check for correlated exfiltration and malicious commands (only for text files)
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
- const hasEnv = envRegex.test(content);
321
- const hasExfil = exfilRegex.test(content);
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
- // Check for malicious commands
324
- if (commandRegex.test(content)) {
325
- findings.commandMatches.push(path.relative(projectRoot, file));
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
- if (hasEnv && hasExfil) {
329
- findings.correlatedExfil.push(path.relative(projectRoot, file));
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
- * @returns {Promise<Set<string>>} A set of matched compromised dependencies.
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 new Set();
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 new Set();
558
+ return [];
378
559
  }
379
560
 
380
561
  log.info("Checking for vulnerable versions...");
381
- const compromisedPackages = new Set(await getCompromisedPackages());
382
- const matches = new Set();
562
+ const findings = [];
383
563
  for (const localPkg of localPackages) {
384
- if (compromisedPackages.has(localPkg)) {
385
- matches.add(localPkg);
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 matches;
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 dependencyMatches = await runDependencyAnalysis(projectRoot);
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 fileScanFindings = scanProjectFiles(allFiles, projectRoot);
413
- const homeDirFindings = scanHomeDirectory();
676
+ const fileFindings = scanProjectFiles(allFiles, projectRoot, iocs);
677
+ allFindings.push(...fileFindings);
414
678
 
415
- // --- Reporting ---
416
- log.header("Scan Report");
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
- if (dependencyMatches.size > 0) {
457
- issuesFound = true;
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
- if (fileScanFindings.namespaceMatches.size > 0) {
466
- issuesFound = true;
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
- if (fileScanFindings.hookMatches.length > 0) {
475
- issuesFound = true;
476
- report += `${colors.YELLOW}⚠️ MEDIUM RISK: Potentially Malicious package.json Hooks${colors.RESET}\n`;
477
- fileScanFindings.hookMatches.forEach(match => {
478
- report += ` - ${match}\n`;
479
- });
480
- report += " NOTE: 'postinstall' scripts can execute arbitrary commands and require review.\n\n";
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
- if (fileScanFindings.commandMatches.length > 0) {
484
- issuesFound = true;
485
- report += `${colors.YELLOW}⚠️ MEDIUM RISK: Suspicious Commands Found in Files${colors.RESET}\n`;
486
- fileScanFindings.commandMatches.forEach(match => {
487
- report += ` - File: ${colors.YELLOW}${match}${colors.RESET}\n`;
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 (issuesFound) {
493
- console.log(report);
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}`);