muaddib-scanner 2.4.19 → 2.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.4.19",
3
+ "version": "2.5.0",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -48,8 +48,10 @@
48
48
  "acorn": "8.16.0",
49
49
  "acorn-walk": "8.3.5",
50
50
  "adm-zip": "0.5.16",
51
- "js-yaml": "4.1.1",
52
- "loadash": "^1.0.0"
51
+ "js-yaml": "4.1.1"
52
+ },
53
+ "overrides": {
54
+ "loadash": "0.0.0-security"
53
55
  },
54
56
  "devDependencies": {
55
57
  "@eslint/js": "10.0.1",
package/src/index.js CHANGED
@@ -32,6 +32,10 @@ const { SEVERITY_WEIGHTS, RISK_THRESHOLDS, MAX_RISK_SCORE, isPackageLevelThreat,
32
32
 
33
33
  const { MAX_FILE_SIZE } = require('./shared/constants.js');
34
34
 
35
+ // Timeout constants for scan safety
36
+ const SCANNER_TIMEOUT = 15000; // 15s per individual scanner
37
+ const SCAN_TIMEOUT = 60000; // 60s global scan timeout
38
+
35
39
  // Paranoid mode scanner
36
40
  function scanParanoid(targetPath) {
37
41
  const threats = [];
@@ -222,27 +226,38 @@ async function run(targetPath, options = {}) {
222
226
  // Sequential execution of scanners with event loop yields between each.
223
227
  // All scanners (even "async" ones) are effectively synchronous (readFileSync, readdirSync).
224
228
  // Running them via yieldThen ensures the spinner animates between each scanner.
225
- let scanResult;
226
- try {
227
- scanResult = await Promise.all([
228
- yieldThen(() => scanPackageJson(targetPath)),
229
- yieldThen(() => scanShellScripts(targetPath)),
230
- yieldThen(() => analyzeAST(targetPath, { deobfuscate: deobfuscateFn })),
231
- yieldThen(() => detectObfuscation(targetPath)),
232
- yieldThen(() => scanDependencies(targetPath)),
233
- yieldThen(() => scanHashes(targetPath)),
234
- yieldThen(() => analyzeDataFlow(targetPath, { deobfuscate: deobfuscateFn })),
235
- yieldThen(() => scanTyposquatting(targetPath)),
236
- yieldThen(() => scanGitHubActions(targetPath)),
237
- yieldThen(() => matchPythonIOCs(pythonDeps, targetPath)),
238
- yieldThen(() => checkPyPITyposquatting(pythonDeps, targetPath)),
239
- yieldThen(() => scanEntropy(targetPath, { entropyThreshold: options.entropyThreshold || undefined })),
240
- yieldThen(() => scanAIConfig(targetPath))
241
- ]);
242
- } catch (err) {
243
- if (spinner) spinner.fail(`[MUADDIB] Scan failed: ${err.message}`);
244
- throw err;
245
- }
229
+ // Uses Promise.allSettled so one scanner crash doesn't kill the entire scan.
230
+ const SCANNER_NAMES = [
231
+ 'scanPackageJson', 'scanShellScripts', 'analyzeAST', 'detectObfuscation',
232
+ 'scanDependencies', 'scanHashes', 'analyzeDataFlow', 'scanTyposquatting',
233
+ 'scanGitHubActions', 'matchPythonIOCs', 'checkPyPITyposquatting',
234
+ 'scanEntropy', 'scanAIConfig'
235
+ ];
236
+
237
+ const settledResults = await Promise.allSettled([
238
+ yieldThen(() => scanPackageJson(targetPath)),
239
+ yieldThen(() => scanShellScripts(targetPath)),
240
+ yieldThen(() => analyzeAST(targetPath, { deobfuscate: deobfuscateFn })),
241
+ yieldThen(() => detectObfuscation(targetPath)),
242
+ yieldThen(() => scanDependencies(targetPath)),
243
+ yieldThen(() => scanHashes(targetPath)),
244
+ yieldThen(() => analyzeDataFlow(targetPath, { deobfuscate: deobfuscateFn })),
245
+ yieldThen(() => scanTyposquatting(targetPath)),
246
+ yieldThen(() => scanGitHubActions(targetPath)),
247
+ yieldThen(() => matchPythonIOCs(pythonDeps, targetPath)),
248
+ yieldThen(() => checkPyPITyposquatting(pythonDeps, targetPath)),
249
+ yieldThen(() => scanEntropy(targetPath, { entropyThreshold: options.entropyThreshold || undefined })),
250
+ yieldThen(() => scanAIConfig(targetPath))
251
+ ]);
252
+
253
+ // Extract results: use empty array for rejected scanners, log errors
254
+ const scannerErrors = [];
255
+ const scanResult = settledResults.map((r, i) => {
256
+ if (r.status === 'fulfilled') return r.value;
257
+ scannerErrors.push({ scanner: SCANNER_NAMES[i], error: r.reason });
258
+ console.error(`[WARN] Scanner ${SCANNER_NAMES[i]} failed: ${r.reason?.message || r.reason}`);
259
+ return [];
260
+ });
246
261
 
247
262
  const [
248
263
  packageThreats,
@@ -420,7 +435,8 @@ async function run(targetPath, options = {}) {
420
435
  fileScores,
421
436
  breakdown
422
437
  },
423
- sandbox: sandboxData
438
+ sandbox: sandboxData,
439
+ scannerErrors: scannerErrors.length > 0 ? scannerErrors : undefined
424
440
  };
425
441
 
426
442
  // _capture mode: return result directly without printing (used by diff.js)
@@ -1,6 +1,7 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
3
  const os = require('os');
4
+ const crypto = require('crypto');
4
5
 
5
6
  const HOME_DATA_PATH = path.join(os.homedir(), '.muaddib', 'data');
6
7
  const CACHE_IOC_FILE = path.join(HOME_DATA_PATH, 'iocs.json');
@@ -100,9 +101,14 @@ async function updateIOCs() {
100
101
 
101
102
  // Atomic write: write to .tmp then rename (UP-001)
102
103
  const tmpFile = CACHE_IOC_FILE + '.tmp';
103
- fs.writeFileSync(tmpFile, JSON.stringify(baseIOCs));
104
+ const jsonData = JSON.stringify(baseIOCs);
105
+ fs.writeFileSync(tmpFile, jsonData);
104
106
  fs.renameSync(tmpFile, CACHE_IOC_FILE);
105
107
 
108
+ // Write HMAC signature alongside the cache file
109
+ const hmac = generateIOCHMAC(jsonData);
110
+ fs.writeFileSync(CACHE_IOC_FILE + '.hmac', hmac);
111
+
106
112
  const totalNpm = baseIOCs.packages.length;
107
113
  const totalPyPI = (baseIOCs.pypi_packages || []).length;
108
114
  console.log('[4/4] Saved to cache: ' + CACHE_IOC_FILE);
@@ -217,11 +223,22 @@ function loadCachedIOCs() {
217
223
  }
218
224
  }
219
225
 
220
- // Priority 3: Cached IOCs (from previous update)
226
+ // Priority 3: Cached IOCs (from previous update) — verify HMAC integrity
221
227
  if (fs.existsSync(CACHE_IOC_FILE)) {
222
228
  try {
223
- const cachedIOCs = JSON.parse(fs.readFileSync(CACHE_IOC_FILE, 'utf8'));
224
- mergeIOCs(merged, cachedIOCs);
229
+ const cachedData = fs.readFileSync(CACHE_IOC_FILE, 'utf8');
230
+ const hmacFile = CACHE_IOC_FILE + '.hmac';
231
+ if (fs.existsSync(hmacFile)) {
232
+ const storedHmac = fs.readFileSync(hmacFile, 'utf8').trim();
233
+ if (!verifyIOCHMAC(cachedData, storedHmac)) {
234
+ console.log('[WARN] IOC cache HMAC verification failed — possible tampering. Skipping cache.');
235
+ } else {
236
+ mergeIOCs(merged, JSON.parse(cachedData));
237
+ }
238
+ } else {
239
+ // No HMAC file yet (first run or pre-HMAC version) — load but warn
240
+ mergeIOCs(merged, JSON.parse(cachedData));
241
+ }
225
242
  } catch (e) {
226
243
  console.log('[WARN] Failed to load cached IOCs: ' + e.message);
227
244
  }
@@ -421,4 +438,44 @@ function invalidateCache() {
421
438
  cachedIOCsTime = 0;
422
439
  }
423
440
 
424
- module.exports = { updateIOCs, loadCachedIOCs, invalidateCache, generateCompactIOCs, expandCompactIOCs, mergeIOCs, createOptimizedIOCs };
441
+ // ============================================
442
+ // IOC INTEGRITY: HMAC-SHA256 signing/verification
443
+ // ============================================
444
+ // Key is derived from a stable machine-specific seed + hardcoded salt.
445
+ // This protects against local file tampering by unauthorized processes.
446
+ const IOC_HMAC_SALT = 'muaddib-ioc-integrity-v1';
447
+
448
+ function getIOCHMACKey() {
449
+ // Derive key from salt + hostname (machine-specific but stable)
450
+ const seed = IOC_HMAC_SALT + ':' + os.hostname();
451
+ return crypto.createHash('sha256').update(seed).digest();
452
+ }
453
+
454
+ /**
455
+ * Generate HMAC-SHA256 for IOC data string.
456
+ * @param {string} data - JSON string of IOC data
457
+ * @returns {string} Hex-encoded HMAC
458
+ */
459
+ function generateIOCHMAC(data) {
460
+ const key = getIOCHMACKey();
461
+ return crypto.createHmac('sha256', key).update(data).digest('hex');
462
+ }
463
+
464
+ /**
465
+ * Verify HMAC-SHA256 of IOC data.
466
+ * @param {string} data - JSON string of IOC data
467
+ * @param {string} hmac - Expected HMAC hex string
468
+ * @returns {boolean} True if HMAC matches
469
+ */
470
+ function verifyIOCHMAC(data, hmac) {
471
+ if (!hmac || typeof hmac !== 'string') return false;
472
+ const expected = generateIOCHMAC(data);
473
+ // Constant-time comparison to prevent timing attacks
474
+ try {
475
+ return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(hmac, 'hex'));
476
+ } catch {
477
+ return false;
478
+ }
479
+ }
480
+
481
+ module.exports = { updateIOCs, loadCachedIOCs, invalidateCache, generateCompactIOCs, expandCompactIOCs, mergeIOCs, createOptimizedIOCs, generateIOCHMAC, verifyIOCHMAC };
@@ -188,6 +188,10 @@ async function runSingleSandbox(packageName, options = {}) {
188
188
  dockerArgs.push('--tmpfs', '/home/sandboxuser:rw,noexec,nosuid,size=16m');
189
189
  dockerArgs.push('--read-only');
190
190
 
191
+ // Mount fake /proc/uptime to prevent time-based sandbox evasion (T1497.003)
192
+ // Malware reads /proc/uptime to detect sandboxes (low uptime = sandbox)
193
+ dockerArgs.push('--tmpfs', '/proc/uptime:ro,size=4k');
194
+
191
195
  dockerArgs.push('--security-opt', 'no-new-privileges');
192
196
 
193
197
  if (local && localAbsPath) {
@@ -339,15 +339,20 @@ function foldConcatsOnly(sourceCode) {
339
339
  /**
340
340
  * Recursively fold string concat BinaryExpression.
341
341
  * Returns the concatenated string, or null if any part is not a string literal.
342
+ * Depth limit prevents stack overflow DoS on deeply nested expressions.
342
343
  */
343
- function tryFoldConcat(node) {
344
+ const MAX_FOLD_DEPTH = 100;
345
+
346
+ function tryFoldConcat(node, depth) {
347
+ if (depth === undefined) depth = 0;
348
+ if (depth > MAX_FOLD_DEPTH) return null;
344
349
  if (node.type === 'Literal' && typeof node.value === 'string') {
345
350
  return node.value;
346
351
  }
347
352
  if (node.type === 'BinaryExpression' && node.operator === '+') {
348
- const left = tryFoldConcat(node.left);
353
+ const left = tryFoldConcat(node.left, depth + 1);
349
354
  if (left === null) return null;
350
- const right = tryFoldConcat(node.right);
355
+ const right = tryFoldConcat(node.right, depth + 1);
351
356
  if (right === null) return null;
352
357
  return left + right;
353
358
  }
package/src/scoring.js CHANGED
@@ -180,12 +180,19 @@ function applyFPReductions(threats, reachableFiles, packageName) {
180
180
  typeCounts[t.type] = (typeCounts[t.type] || 0) + 1;
181
181
  }
182
182
 
183
+ const totalThreats = threats.length;
184
+
183
185
  for (const t of threats) {
184
186
  // Count-based downgrade: if a threat type appears too many times,
185
- // it's a framework/plugin system, not malware
187
+ // it's a framework/plugin system, not malware.
188
+ // Percentage guard: only downgrade if the type is < 50% of total threats.
189
+ // When a type dominates findings (> 50%), it may be real malware, not framework noise.
186
190
  const rule = FP_COUNT_THRESHOLDS[t.type];
187
191
  if (rule && typeCounts[t.type] > rule.maxCount && (!rule.from || t.severity === rule.from)) {
188
- t.severity = rule.to;
192
+ const typeRatio = typeCounts[t.type] / totalThreats;
193
+ if (typeRatio < 0.5) {
194
+ t.severity = rule.to;
195
+ }
189
196
  }
190
197
 
191
198
  // require_cache_poison: single hit → HIGH (plugin dedup/hot-reload, not malware)
@@ -26,9 +26,35 @@ const PRIVATE_IP_PATTERNS = [
26
26
  /^fe80:/
27
27
  ];
28
28
 
29
+ /**
30
+ * Normalize a hostname by unwrapping IPv6-mapped IPv4 addresses
31
+ * and converting decimal IP notation to dotted notation.
32
+ * @param {string} hostname - Raw hostname from URL
33
+ * @returns {string} Normalized hostname for SSRF validation
34
+ */
35
+ function normalizeHostname(hostname) {
36
+ hostname = hostname.toLowerCase();
37
+ // Unwrap IPv6-mapped IPv4: ::ffff:192.168.1.1 → 192.168.1.1
38
+ if (hostname.startsWith('::ffff:')) {
39
+ const ipv4Part = hostname.slice(7);
40
+ if (/^(\d{1,3}\.){3}\d{1,3}$/.test(ipv4Part)) {
41
+ return ipv4Part;
42
+ }
43
+ }
44
+ // Convert decimal IP notation: 2130706433 → 127.0.0.1
45
+ if (/^\d+$/.test(hostname)) {
46
+ const num = parseInt(hostname, 10);
47
+ if (num > 0 && num < 4294967296) {
48
+ return [(num >>> 24) & 255, (num >>> 16) & 255, (num >>> 8) & 255, num & 255].join('.');
49
+ }
50
+ }
51
+ return hostname;
52
+ }
53
+
29
54
  /**
30
55
  * Validates that a redirect URL is allowed (SSRF protection).
31
56
  * Only HTTPS to whitelisted domains is permitted.
57
+ * Normalizes IPv6-mapped IPv4 and decimal IP notation before validation.
32
58
  * @param {string} redirectUrl - The redirect target URL
33
59
  * @returns {{allowed: boolean, error?: string}}
34
60
  */
@@ -38,10 +64,11 @@ function isAllowedDownloadRedirect(redirectUrl) {
38
64
  if (urlObj.protocol !== 'https:') {
39
65
  return { allowed: false, error: `Redirect blocked: non-HTTPS protocol ${urlObj.protocol}` };
40
66
  }
41
- const hostname = urlObj.hostname.toLowerCase();
42
- // Block private IP addresses
43
- if (PRIVATE_IP_PATTERNS.some(p => p.test(hostname))) {
44
- return { allowed: false, error: `Redirect blocked: private IP ${hostname}` };
67
+ const rawHostname = urlObj.hostname.toLowerCase();
68
+ const hostname = normalizeHostname(rawHostname);
69
+ // Block private IP addresses (check both raw and normalized)
70
+ if (PRIVATE_IP_PATTERNS.some(p => p.test(hostname) || p.test(rawHostname))) {
71
+ return { allowed: false, error: `Redirect blocked: private IP ${rawHostname}` };
45
72
  }
46
73
  const domainAllowed = ALLOWED_DOWNLOAD_DOMAINS.some(domain =>
47
74
  hostname === domain || hostname.endsWith('.' + domain)
@@ -176,6 +203,7 @@ module.exports = {
176
203
  extractTarGz,
177
204
  sanitizePackageName,
178
205
  isAllowedDownloadRedirect,
206
+ normalizeHostname,
179
207
  ALLOWED_DOWNLOAD_DOMAINS,
180
208
  PRIVATE_IP_PATTERNS
181
209
  };