muaddib-scanner 2.6.3 → 2.6.5

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 CHANGED
@@ -284,9 +284,14 @@ repos:
284
284
  | **Wild TPR** (Datadog 17K) | **88.2%** raw / **~100%** adjusted | 17,922 real malware. 2,077 out-of-scope (phishing, binaries, corrected) |
285
285
  | **TPR** (Ground Truth) | **93.9%** (46/49) | 51 real attacks. 3 out-of-scope: browser-only |
286
286
  | **FPR** (Benign) | **12.1%** (64/529) | 529 npm packages, real source via `npm pack` |
287
- | **ADR** (Adversarial + Holdout) | **94.8%** (73/77) | 53 adversarial + 40 holdout (77 available on disk) |
287
+ | **ADR** (Adversarial + Holdout) | **92.2%** (71/77) | 53 adversarial + 40 holdout (77 available on disk), global threshold=20 |
288
288
 
289
- **1940 tests** across 44 files, 86% code coverage. **129 rules** (124 RULES + 5 PARANOID).
289
+ **1974 tests** across 44 files, 86% code coverage. **129 rules** (124 RULES + 5 PARANOID).
290
+
291
+ > **Methodology caveats:**
292
+ > - TPR measured on 49 Node.js attack samples (3 browser-only excluded from 51 total)
293
+ > - FPR measured on 529 curated popular npm packages (not a random sample)
294
+ > - ADR measured with global threshold (score >= 20) as of v2.6.5
290
295
 
291
296
  See [Evaluation Methodology](docs/EVALUATION_METHODOLOGY.md) for the full experimental protocol, holdout history, and Datadog benchmark details.
292
297
 
@@ -322,7 +327,7 @@ npm test
322
327
 
323
328
  ### Testing
324
329
 
325
- - **1940 tests** across 44 modular test files - 86% code coverage
330
+ - **1974 tests** across 44 modular test files - 86% code coverage
326
331
  - **56 fuzz tests** - Malformed inputs, ReDoS, unicode, binary
327
332
  - **Datadog 17K benchmark** - 17,922 real malware samples
328
333
  - **Ground truth validation** - 51 real-world attacks (93.9% TPR)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.6.3",
3
+ "version": "2.6.5",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -48,8 +48,7 @@
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
- "muaddib-scanner": "^2.6.2"
51
+ "js-yaml": "4.1.1"
53
52
  },
54
53
  "overrides": {
55
54
  "loadash": "0.0.0-security"
package/src/index.js CHANGED
@@ -18,7 +18,7 @@ const fs = require('fs');
18
18
  const path = require('path');
19
19
  const { scanGitHubActions } = require('./scanner/github-actions.js');
20
20
  const { detectPythonProject, normalizePythonName } = require('./scanner/python.js');
21
- const { loadCachedIOCs } = require('./ioc/updater.js');
21
+ const { loadCachedIOCs, checkIOCStaleness } = require('./ioc/updater.js');
22
22
  const { ensureIOCs } = require('./ioc/bootstrap.js');
23
23
  const { scanEntropy } = require('./scanner/entropy.js');
24
24
  const { scanAIConfig } = require('./scanner/ai-config.js');
@@ -92,18 +92,32 @@ function scanParanoid(targetPath) {
92
92
 
93
93
  const found = new Set(); // deduplicate: one finding per rule per file
94
94
 
95
+ // v2.6.5: Track aliases of eval, Function, require for bypass detection
96
+ // e.g., const e = eval; e(code) — or — const F = Function; new F(code)
97
+ const ALIAS_TARGETS = new Set(['eval', 'Function', 'require']);
98
+ const aliases = new Map(); // aliasName → originalName
99
+
95
100
  walk.simple(ast, {
101
+ VariableDeclarator(node) {
102
+ // const e = eval / const F = Function / const r = require
103
+ if (node.id?.type === 'Identifier' && node.init?.type === 'Identifier' &&
104
+ ALIAS_TARGETS.has(node.init.name)) {
105
+ aliases.set(node.id.name, node.init.name);
106
+ }
107
+ },
96
108
  CallExpression(node) {
97
109
  // Direct calls: eval(), exec(), fetch(), etc.
98
110
  if (node.callee.type === 'Identifier') {
99
- const name = node.callee.name;
111
+ // Resolve alias to original name if applicable
112
+ const name = aliases.get(node.callee.name) || node.callee.name;
100
113
  for (const [ruleKey, detector] of Object.entries(PARANOID_AST_DETECTORS)) {
101
114
  if (detector.callNames && detector.callNames.has(name) && !found.has(ruleKey)) {
102
115
  found.add(ruleKey);
103
116
  const rule = PARANOID_RULES[ruleKey];
104
117
  threats.push({
105
118
  type: rule.id, severity: rule.severity.toUpperCase(),
106
- message: `${rule.message}: "${name}"`, file: relFile, mitre: rule.mitre
119
+ message: `${rule.message}: "${node.callee.name}"${aliases.has(node.callee.name) ? ` (alias of ${name})` : ''}`,
120
+ file: relFile, mitre: rule.mitre
107
121
  });
108
122
  }
109
123
  }
@@ -130,14 +144,16 @@ function scanParanoid(targetPath) {
130
144
  },
131
145
  NewExpression(node) {
132
146
  if (node.callee.type === 'Identifier') {
133
- const name = node.callee.name;
147
+ // Resolve alias: const F = Function; new F(code)
148
+ const name = aliases.get(node.callee.name) || node.callee.name;
134
149
  for (const [ruleKey, detector] of Object.entries(PARANOID_AST_DETECTORS)) {
135
150
  if (detector.newNames && detector.newNames.has(name) && !found.has(ruleKey)) {
136
151
  found.add(ruleKey);
137
152
  const rule = PARANOID_RULES[ruleKey];
138
153
  threats.push({
139
154
  type: rule.id, severity: rule.severity.toUpperCase(),
140
- message: `${rule.message}: "new ${name}"`, file: relFile, mitre: rule.mitre
155
+ message: `${rule.message}: "new ${node.callee.name}"${aliases.has(node.callee.name) ? ` (alias of ${name})` : ''}`,
156
+ file: relFile, mitre: rule.mitre
141
157
  });
142
158
  }
143
159
  }
@@ -333,6 +349,9 @@ async function run(targetPath, options = {}) {
333
349
  // Ensure IOCs are downloaded (first run only, graceful failure)
334
350
  await ensureIOCs();
335
351
 
352
+ // Check IOC freshness — warn if database is older than 30 days
353
+ const iocStalenessWarning = checkIOCStaleness(30);
354
+
336
355
  // Apply --exclude dirs for this scan
337
356
  if (options.exclude && options.exclude.length > 0) {
338
357
  setExtraExcludes(options.exclude, targetPath);
@@ -359,10 +378,16 @@ async function run(targetPath, options = {}) {
359
378
  // Wrapped in yieldThen to unblock spinner animation
360
379
  // Bounded: 5s timeout to prevent DoS on large/adversarial packages
361
380
  const MODULE_GRAPH_TIMEOUT_MS = 5000;
381
+ const warnings = [];
382
+ if (iocStalenessWarning) warnings.push(iocStalenessWarning);
362
383
  let crossFileFlows = [];
363
384
  if (!options.noModuleGraph) {
364
385
  const moduleGraphWork = async () => {
365
386
  const graph = await yieldThen(() => buildModuleGraph(targetPath));
387
+ if (Object.keys(graph).length === 0) {
388
+ // buildModuleGraph returns empty when MAX_GRAPH_NODES exceeded
389
+ warnings.push('Module graph skipped: package exceeds 100 files limit');
390
+ }
366
391
  const tainted = await yieldThen(() => annotateTaintedExports(graph, targetPath));
367
392
  const sinkAnnotations = await yieldThen(() => annotateSinkExports(graph, targetPath));
368
393
  crossFileFlows = await yieldThen(() => detectCrossFileFlows(graph, tainted, sinkAnnotations, targetPath));
@@ -381,6 +406,9 @@ async function run(targetPath, options = {}) {
381
406
  } catch (e) {
382
407
  // Graceful fallback — module graph is best-effort
383
408
  debugLog('[MODULE-GRAPH] Error:', e && e.message);
409
+ if (e && e.message === 'Module graph timeout') {
410
+ warnings.push(`Module graph analysis timed out (${MODULE_GRAPH_TIMEOUT_MS / 1000}s) — cross-file flows may be incomplete`);
411
+ }
384
412
  }
385
413
  }
386
414
 
@@ -593,6 +621,10 @@ async function run(targetPath, options = {}) {
593
621
  threats: pythonThreats.length + pypiTyposquatThreats.length
594
622
  } : null;
595
623
 
624
+ // Track deobfuscation failures
625
+ // (deobfuscate returns {deobfuscatedThreats, failures} but failures aren't surfaced)
626
+ // We detect this via scannerErrors for now
627
+
596
628
  const result = {
597
629
  target: targetPath,
598
630
  timestamp: new Date().toISOString(),
@@ -614,6 +646,7 @@ async function run(targetPath, options = {}) {
614
646
  breakdown
615
647
  },
616
648
  sandbox: sandboxData,
649
+ warnings: warnings.length > 0 ? warnings : undefined,
617
650
  scannerErrors: scannerErrors.length > 0 ? scannerErrors : undefined
618
651
  };
619
652
 
@@ -24,11 +24,14 @@ const SOURCE_TYPES = {
24
24
  credential_regex_harvest: 'credential_read', // regex patterns for tokens/passwords
25
25
  llm_api_key_harvest: 'credential_read', // OPENAI_API_KEY, ANTHROPIC_API_KEY
26
26
  credential_cli_steal: 'credential_read', // gh auth token, gcloud auth
27
- // env_access EXCLUDEDstandard config (process.env.PORT, AWS_REGION, NODE_ENV)
27
+ // env_access: conditionally classified see classifySource()
28
28
  // suspicious_dataflow EXCLUDED — already compound detection
29
29
  // cross_file_dataflow EXCLUDED — already scored CRITICAL by module-graph
30
30
  };
31
31
 
32
+ // Sensitive env var patterns — env_access referencing these is credential theft, not config
33
+ const SENSITIVE_ENV_PATTERNS = /TOKEN|KEY|SECRET|PASSWORD|CREDENTIAL|API_KEY|AUTH/i;
34
+
32
35
  // ============================================
33
36
  // SINK CLASSIFICATION (from existing threats only)
34
37
  // ============================================
@@ -105,9 +108,17 @@ const CROSS_FILE_MULTIPLIER = 0.5;
105
108
  function classifySource(threat) {
106
109
  if (SOURCE_TYPES[threat.type]) return SOURCE_TYPES[threat.type];
107
110
 
111
+ // env_access: only classify as credential_read if accessing sensitive vars
112
+ // Standard config (NODE_ENV, PORT, DEBUG) → null (no pairing)
113
+ if (threat.type === 'env_access') {
114
+ if (threat.message && SENSITIVE_ENV_PATTERNS.test(threat.message)) {
115
+ return 'credential_read';
116
+ }
117
+ return null;
118
+ }
119
+
108
120
  // Explicitly excluded types
109
121
  if (threat.type === 'suspicious_dataflow') return null;
110
- if (threat.type === 'env_access') return null;
111
122
  if (threat.type === 'cross_file_dataflow') return null;
112
123
 
113
124
  // Message-based: only for threats referencing sensitive file paths
@@ -10,6 +10,38 @@ const HOME_IOC_FILE = path.join(os.homedir(), '.muaddib', 'data', 'iocs.json');
10
10
  const STATIC_IOCS_FILE = path.join(__dirname, '../../data/static-iocs.json');
11
11
  const { generateCompactIOCs } = require('./updater.js');
12
12
  const { Spinner } = require('../utils.js');
13
+ const { NPM_PACKAGE_REGEX } = require('../shared/constants.js');
14
+
15
+ // Version format validation (semver-like + wildcard)
16
+ const VERSION_RE = /^(\*|0|[1-9]\d*(\.\d+){0,2}(-[\w.]+)?(\+[\w.]+)?)$/;
17
+
18
+ /**
19
+ * Validate an IOC package entry before insertion.
20
+ * Returns true if valid, false if should be skipped.
21
+ */
22
+ function validateIOCEntry(pkgName, version, ecosystem) {
23
+ if (!pkgName || typeof pkgName !== 'string') return false;
24
+ // npm: validate with NPM_PACKAGE_REGEX
25
+ if (ecosystem === 'npm' || !ecosystem) {
26
+ if (!NPM_PACKAGE_REGEX.test(pkgName)) {
27
+ console.warn(`[WARN] Invalid ${ecosystem || 'npm'} package name skipped: ${pkgName}`);
28
+ return false;
29
+ }
30
+ }
31
+ // PyPI: basic check — no path traversal, no slashes
32
+ if (ecosystem === 'pypi') {
33
+ if (/[/\\]|\.\./.test(pkgName)) {
34
+ console.warn(`[WARN] Invalid PyPI package name skipped: ${pkgName}`);
35
+ return false;
36
+ }
37
+ }
38
+ // Version validation
39
+ if (version && !VERSION_RE.test(version)) {
40
+ console.warn(`[WARN] Invalid version skipped: ${version} for ${pkgName}`);
41
+ return false;
42
+ }
43
+ return true;
44
+ }
13
45
 
14
46
  // Allowed domains for redirections (SSRF security)
15
47
  const ALLOWED_REDIRECT_DOMAINS = [
@@ -1110,10 +1142,15 @@ async function runScraper() {
1110
1142
  dedupMap.set(key, pkg);
1111
1143
  }
1112
1144
 
1113
- // Merge new IOCs with smart replacement
1145
+ // Merge new IOCs with smart replacement (with input validation)
1114
1146
  let addedPackages = 0;
1115
1147
  let upgradedPackages = 0;
1148
+ let skippedInvalid = 0;
1116
1149
  for (const pkg of allPackages) {
1150
+ if (!validateIOCEntry(pkg.name, pkg.version, 'npm')) {
1151
+ skippedInvalid++;
1152
+ continue;
1153
+ }
1117
1154
  const key = pkg.name + '@' + pkg.version;
1118
1155
  if (!dedupMap.has(key)) {
1119
1156
  dedupMap.set(key, pkg);
@@ -1148,6 +1185,10 @@ async function runScraper() {
1148
1185
  }
1149
1186
  let addedPyPIPackages = 0;
1150
1187
  for (const pkg of pypiPackages) {
1188
+ if (!validateIOCEntry(pkg.name, pkg.version, 'pypi')) {
1189
+ skippedInvalid++;
1190
+ continue;
1191
+ }
1151
1192
  const key = pkg.name + '@' + pkg.version;
1152
1193
  if (!pypiDedupMap.has(key)) {
1153
1194
  pypiDedupMap.set(key, pkg);
@@ -1308,6 +1349,7 @@ module.exports = {
1308
1349
  // Pure utility functions (exported for testing)
1309
1350
  parseCSVLine, parseCSV, extractVersions, parseOSVEntry,
1310
1351
  createFreshness, isAllowedRedirect, loadStaticIOCs,
1352
+ validateIOCEntry,
1311
1353
  CONFIDENCE_ORDER, ALLOWED_REDIRECT_DOMAINS
1312
1354
  };
1313
1355
 
@@ -463,6 +463,34 @@ function invalidateCache() {
463
463
  cachedIOCsTime = 0;
464
464
  }
465
465
 
466
+ /**
467
+ * Check IOC freshness based on cached file mtime.
468
+ * Returns a warning string if IOCs are older than maxAgeDays, null otherwise.
469
+ * @param {number} maxAgeDays - Maximum acceptable age in days (default: 30)
470
+ * @returns {string|null} Warning message or null
471
+ */
472
+ function checkIOCStaleness(maxAgeDays = 30) {
473
+ const filesToCheck = [CACHE_IOC_FILE, LOCAL_IOC_FILE, LOCAL_COMPACT_FILE];
474
+ let newestMtime = 0;
475
+
476
+ for (const f of filesToCheck) {
477
+ try {
478
+ const stat = fs.statSync(f);
479
+ if (stat.mtimeMs > newestMtime) newestMtime = stat.mtimeMs;
480
+ } catch {
481
+ // File doesn't exist — skip
482
+ }
483
+ }
484
+
485
+ if (newestMtime === 0) return null; // No IOC files found — bootstrap will handle
486
+
487
+ const ageDays = (Date.now() - newestMtime) / (1000 * 60 * 60 * 24);
488
+ if (ageDays > maxAgeDays) {
489
+ return `IOC database is ${Math.floor(ageDays)} days old (threshold: ${maxAgeDays}d). Run "muaddib update" for latest threat data.`;
490
+ }
491
+ return null;
492
+ }
493
+
466
494
  // ============================================
467
495
  // IOC INTEGRITY: HMAC-SHA256 signing/verification
468
496
  // ============================================
@@ -510,4 +538,4 @@ function verifyIOCHMAC(data, hmac) {
510
538
  }
511
539
  }
512
540
 
513
- module.exports = { updateIOCs, loadCachedIOCs, invalidateCache, generateCompactIOCs, expandCompactIOCs, mergeIOCs, createOptimizedIOCs, generateIOCHMAC, verifyIOCHMAC, NEVER_WILDCARD };
541
+ module.exports = { updateIOCs, loadCachedIOCs, invalidateCache, generateCompactIOCs, expandCompactIOCs, mergeIOCs, createOptimizedIOCs, generateIOCHMAC, verifyIOCHMAC, checkIOCStaleness, NEVER_WILDCARD };
@@ -110,6 +110,32 @@ function buildTaintMap(ast) {
110
110
  }
111
111
  }
112
112
 
113
+ // B8 fix: const fn = tools.read — resolve object property alias to tainted method
114
+ if (node.id.type === 'Identifier' && init.type === 'MemberExpression' &&
115
+ init.object?.type === 'Identifier' && init.property?.type === 'Identifier') {
116
+ const aliasKey = `${init.object.name}.${init.property.name}`;
117
+ const aliasTaint = taintMap.get(aliasKey);
118
+ if (aliasTaint && TRACKED_MODULES.has(aliasTaint.source)) {
119
+ taintMap.set(node.id.name, aliasTaint);
120
+ }
121
+ }
122
+
123
+ // B9 fix: const [x] = [fs.readFileSync(...)] — array destructuring taint
124
+ if (node.id.type === 'ArrayPattern' && init.type === 'ArrayExpression') {
125
+ for (let i = 0; i < node.id.elements.length && i < init.elements.length; i++) {
126
+ const elem = node.id.elements[i];
127
+ const val = init.elements[i];
128
+ if (!elem || elem.type !== 'Identifier' || !val) continue;
129
+ if (val.type === 'CallExpression' && val.callee?.type === 'MemberExpression' &&
130
+ val.callee.object?.type === 'Identifier' && val.callee.property?.type === 'Identifier') {
131
+ const parentTaint = taintMap.get(val.callee.object.name);
132
+ if (parentTaint && TRACKED_MODULES.has(parentTaint.source)) {
133
+ taintMap.set(elem.name, { source: parentTaint.source, detail: `${parentTaint.source}.${val.callee.property.name}` });
134
+ }
135
+ }
136
+ }
137
+ }
138
+
113
139
  // B5 fix: const tools = { read: fs.readFileSync, home: os.homedir }
114
140
  // Track object properties that reference tainted module methods as tainted aliases
115
141
  if (node.id.type === 'Identifier' && init.type === 'ObjectExpression') {
@@ -193,6 +219,18 @@ function analyzeFile(content, filePath, basePath) {
193
219
  },
194
220
 
195
221
  VariableDeclarator(node) {
222
+ // B9: Array destructuring taint propagation: const [data] = [fs.readFileSync('.npmrc')]
223
+ if (node.id?.type === 'ArrayPattern' && node.init?.type === 'ArrayExpression') {
224
+ for (let i = 0; i < node.id.elements.length && i < node.init.elements.length; i++) {
225
+ const elem = node.id.elements[i];
226
+ const val = node.init.elements[i];
227
+ if (!elem || elem.type !== 'Identifier' || !val) continue;
228
+ if (containsSensitiveLiteral(val)) {
229
+ sensitivePathVars.add(elem.name);
230
+ }
231
+ }
232
+ }
233
+
196
234
  if (node.id?.type === 'Identifier' && node.init) {
197
235
  let initNode = node.init;
198
236
  if (initNode.type === 'AwaitExpression') initNode = initNode.argument;
@@ -5,9 +5,10 @@ const { findFiles, EXCLUDED_DIRS, debugLog } = require('../utils');
5
5
  const { ACORN_OPTIONS: BASE_ACORN_OPTIONS, safeParse } = require('../shared/constants.js');
6
6
 
7
7
  // --- Bounded path limits ---
8
- const MAX_GRAPH_NODES = 50; // Max files in dependency graph
9
- const MAX_GRAPH_EDGES = 200; // Max total import edges
8
+ const MAX_GRAPH_NODES = 100; // Max files in dependency graph (covers ~86% of npm packages)
9
+ const MAX_GRAPH_EDGES = 400; // Max total import edges
10
10
  const MAX_FLOWS = 20; // Max cross-file flow findings per package
11
+ const MAX_TAINT_DEPTH = 50; // Max AST recursion depth (DoS guard)
11
12
 
12
13
  // --- Sensitive source patterns ---
13
14
  const SENSITIVE_MODULES = new Set(['fs', 'child_process', 'dns', 'os', 'dgram']);
@@ -103,7 +104,9 @@ function tryResolveConcatRequire(node, depth) {
103
104
  return null;
104
105
  }
105
106
 
106
- function walkForRequires(node, fileDir, packagePath, imports) {
107
+ function walkForRequires(node, fileDir, packagePath, imports, depth) {
108
+ if (depth === undefined) depth = 0;
109
+ if (depth > MAX_TAINT_DEPTH) return;
107
110
  if (!node || typeof node !== 'object') return;
108
111
  if (
109
112
  node.type === 'CallExpression' &&
@@ -130,11 +133,11 @@ function walkForRequires(node, fileDir, packagePath, imports) {
130
133
  if (Array.isArray(child)) {
131
134
  for (const item of child) {
132
135
  if (item && typeof item === 'object' && item.type) {
133
- walkForRequires(item, fileDir, packagePath, imports);
136
+ walkForRequires(item, fileDir, packagePath, imports, depth + 1);
134
137
  }
135
138
  }
136
139
  } else if (child && typeof child === 'object' && child.type) {
137
- walkForRequires(child, fileDir, packagePath, imports);
140
+ walkForRequires(child, fileDir, packagePath, imports, depth + 1);
138
141
  }
139
142
  }
140
143
  }
@@ -1462,7 +1465,9 @@ function parseFile(filePath) {
1462
1465
  return safeParse(content, { allowReturnOutsideFunction: true, allowImportExportEverywhere: true });
1463
1466
  }
1464
1467
 
1465
- function walkAST(node, visitor) {
1468
+ function walkAST(node, visitor, depth) {
1469
+ if (depth === undefined) depth = 0;
1470
+ if (depth > MAX_TAINT_DEPTH) return;
1466
1471
  if (!node || typeof node !== 'object') return;
1467
1472
  if (node.type) visitor(node);
1468
1473
  for (const key of Object.keys(node)) {
@@ -1470,10 +1475,10 @@ function walkAST(node, visitor) {
1470
1475
  const child = node[key];
1471
1476
  if (Array.isArray(child)) {
1472
1477
  for (const item of child) {
1473
- if (item && typeof item === 'object' && item.type) walkAST(item, visitor);
1478
+ if (item && typeof item === 'object' && item.type) walkAST(item, visitor, depth + 1);
1474
1479
  }
1475
1480
  } else if (child && typeof child === 'object' && child.type) {
1476
- walkAST(child, visitor);
1481
+ walkAST(child, visitor, depth + 1);
1477
1482
  }
1478
1483
  }
1479
1484
  }
@@ -1536,10 +1541,12 @@ function getFunctionBody(node) {
1536
1541
  return null;
1537
1542
  }
1538
1543
 
1539
- function getMemberChain(node) {
1544
+ function getMemberChain(node, depth) {
1545
+ if (depth === undefined) depth = 0;
1546
+ if (depth > MAX_TAINT_DEPTH) return '';
1540
1547
  if (node.type === 'Identifier') return node.name;
1541
1548
  if (node.type === 'MemberExpression') {
1542
- const obj = getMemberChain(node.object);
1549
+ const obj = getMemberChain(node.object, depth + 1);
1543
1550
  const prop = node.property.name || node.property.value || '';
1544
1551
  return `${obj}.${prop}`;
1545
1552
  }
@@ -2084,5 +2091,5 @@ module.exports = {
2084
2091
  annotateSinkExports, detectCallbackCrossFileFlows, detectEventEmitterFlows,
2085
2092
  resolveLocal, extractLocalImports, parseFile, isLocalImport, toRel, isFileExists,
2086
2093
  tryResolveConcatRequire,
2087
- MAX_GRAPH_NODES, MAX_GRAPH_EDGES, MAX_FLOWS
2094
+ MAX_GRAPH_NODES, MAX_GRAPH_EDGES, MAX_FLOWS, MAX_TAINT_DEPTH
2088
2095
  };
package/src/scoring.js CHANGED
@@ -232,17 +232,13 @@ function applyFPReductions(threats, reachableFiles, packageName) {
232
232
  const rule = FP_COUNT_THRESHOLDS[t.type];
233
233
  if (rule && typeCounts[t.type] > rule.maxCount && (!rule.from || t.severity === rule.from)) {
234
234
  const typeRatio = typeCounts[t.type] / totalThreats;
235
- // suspicious_dataflow: full bypass of percentage guard. Packages with >3 suspicious_dataflow
236
- // findings are always legitimate SDKs (SMTP, monitoring, analytics). Real malware has 1-2
237
- // targeted source→sink pairs. The count >3 threshold is sufficient protection.
238
- // P7: removed 80% ratio cap it caused ~30k FP hits in production on SDK packages
239
- // where dataflow was the dominant finding type (e.g. @darajs/core, addio-admin-sdk).
240
- // vm_code_execution: full bypass — packages with only vm.Script calls (cassandra-driver,
241
- // webpack, jest) are legitimate. Real malware using vm always has other signals
242
- // (network, fs, obfuscation). The >3 count threshold is sufficient protection.
235
+ // suspicious_dataflow: bypass percentage guard when count exceeds threshold.
236
+ // Packages with >3 suspicious_dataflow findings are always legitimate SDKs.
237
+ // But a single suspicious_dataflow at 50% ratio should NOT be downgraded.
238
+ // vm_code_execution: same logicbypass only when count exceeds threshold.
243
239
  if (typeRatio < 0.4 ||
244
- t.type === 'suspicious_dataflow' ||
245
- t.type === 'vm_code_execution') {
240
+ (t.type === 'suspicious_dataflow' && typeCounts[t.type] > rule.maxCount) ||
241
+ (t.type === 'vm_code_execution' && typeCounts[t.type] > rule.maxCount)) {
246
242
  t.severity = rule.to;
247
243
  }
248
244
  }
@@ -41,13 +41,27 @@ function normalizeHostname(hostname) {
41
41
  return ipv4Part;
42
42
  }
43
43
  }
44
- // Convert decimal IP notation: 2130706433 → 127.0.0.1
45
- if (/^\d+$/.test(hostname)) {
46
- const num = parseInt(hostname, 10);
44
+ // Convert integer IP notation (decimal or hex): 2130706433 or 0x7f000001 → 127.0.0.1
45
+ if (/^(0x[\da-f]+|\d+)$/i.test(hostname)) {
46
+ const num = hostname.startsWith('0x') ? parseInt(hostname, 16) : parseInt(hostname, 10);
47
47
  if (num > 0 && num < 4294967296) {
48
48
  return [(num >>> 24) & 255, (num >>> 16) & 255, (num >>> 8) & 255, num & 255].join('.');
49
49
  }
50
50
  }
51
+ // Convert dotted IP with octal/hex octets: 0177.0.0.01 or 0x7f.0.0.1 → 127.0.0.1
52
+ if (/^[\da-fox.]+$/i.test(hostname)) {
53
+ const parts = hostname.split('.');
54
+ if (parts.length === 4) {
55
+ const octets = parts.map(p => {
56
+ if (/^0x[\da-f]+$/i.test(p)) return parseInt(p, 16);
57
+ if (/^0\d+$/.test(p)) return parseInt(p, 8);
58
+ return parseInt(p, 10);
59
+ });
60
+ if (octets.every(o => !isNaN(o) && o >= 0 && o <= 255)) {
61
+ return octets.join('.');
62
+ }
63
+ }
64
+ }
51
65
  return hostname;
52
66
  }
53
67
 
@@ -121,12 +135,18 @@ async function safeDnsResolve(hostname) {
121
135
  * @param {number} [timeoutMs] - Download timeout in ms (default: DOWNLOAD_TIMEOUT)
122
136
  * @returns {Promise<number>} Number of bytes downloaded
123
137
  */
138
+ const MAX_REDIRECTS = 5;
139
+
124
140
  function downloadToFile(url, destPath, timeoutMs = DOWNLOAD_TIMEOUT) {
125
141
  // DNS rebinding protection: validate hostname before connecting
126
142
  const parsedUrl = new URL(url);
127
143
  return safeDnsResolve(parsedUrl.hostname).then(() => {
128
144
  return new Promise((resolve, reject) => {
129
- const doRequest = (requestUrl) => {
145
+ const doRequest = (requestUrl, redirectCount) => {
146
+ if (redirectCount === undefined) redirectCount = 0;
147
+ if (redirectCount >= MAX_REDIRECTS) {
148
+ return reject(new Error(`Too many redirects (${MAX_REDIRECTS}) for ${url}`));
149
+ }
130
150
  const req = https.get(requestUrl, { timeout: timeoutMs }, (res) => {
131
151
  if (res.statusCode === 301 || res.statusCode === 302) {
132
152
  res.resume();
@@ -138,7 +158,7 @@ function downloadToFile(url, destPath, timeoutMs = DOWNLOAD_TIMEOUT) {
138
158
  if (!check.allowed) {
139
159
  return reject(new Error(check.error));
140
160
  }
141
- return doRequest(absoluteLocation);
161
+ return doRequest(absoluteLocation, redirectCount + 1);
142
162
  }
143
163
  if (res.statusCode < 200 || res.statusCode >= 300) {
144
164
  res.resume();
@@ -246,5 +266,6 @@ module.exports = {
246
266
  isPrivateIP,
247
267
  safeDnsResolve,
248
268
  ALLOWED_DOWNLOAD_DOMAINS,
249
- PRIVATE_IP_PATTERNS
269
+ PRIVATE_IP_PATTERNS,
270
+ MAX_REDIRECTS
250
271
  };