muaddib-scanner 2.5.3 → 2.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.5.3",
3
+ "version": "2.5.5",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/index.js CHANGED
@@ -27,7 +27,7 @@ const { buildModuleGraph, annotateTaintedExports, detectCrossFileFlows } = requi
27
27
  const { computeReachableFiles } = require('./scanner/reachability.js');
28
28
  const { runTemporalAnalyses } = require('./temporal-runner.js');
29
29
  const { formatOutput } = require('./output-formatter.js');
30
- const { setExtraExcludes, getExtraExcludes, Spinner, listInstalledPackages, clearFileListCache } = require('./utils.js');
30
+ const { setExtraExcludes, getExtraExcludes, Spinner, listInstalledPackages, clearFileListCache, debugLog } = require('./utils.js');
31
31
  const { SEVERITY_WEIGHTS, RISK_THRESHOLDS, MAX_RISK_SCORE, isPackageLevelThreat, computeGroupScore, applyFPReductions, calculateRiskScore } = require('./scoring.js');
32
32
 
33
33
  const { MAX_FILE_SIZE } = require('./shared/constants.js');
@@ -218,8 +218,9 @@ async function run(targetPath, options = {}) {
218
218
  const graph = await yieldThen(() => buildModuleGraph(targetPath));
219
219
  const tainted = await yieldThen(() => annotateTaintedExports(graph, targetPath));
220
220
  crossFileFlows = await yieldThen(() => detectCrossFileFlows(graph, tainted, targetPath));
221
- } catch {
221
+ } catch (e) {
222
222
  // Graceful fallback — module graph is best-effort
223
+ debugLog('[MODULE-GRAPH] Error:', e && e.message);
223
224
  }
224
225
  }
225
226
 
@@ -2,7 +2,7 @@
2
2
  * MUAD'DIB Sandbox Preload Log Analyzer
3
3
  *
4
4
  * Parses [PRELOAD] log lines produced by docker/preload.js and generates
5
- * scored findings for behavioral analysis. Six detection rules:
5
+ * scored findings for behavioral analysis. Seven detection rules:
6
6
  *
7
7
  * 1. sandbox_timer_delay_suspicious — timer delay > 1h (MEDIUM, +15)
8
8
  * 2. sandbox_timer_delay_critical — timer delay > 24h (CRITICAL, +30, supersedes #1)
@@ -10,6 +10,7 @@
10
10
  * 4. sandbox_network_after_sensitive_read — network call after sensitive read (CRITICAL, +40)
11
11
  * 5. sandbox_exec_suspicious — dangerous command execution (HIGH, +25)
12
12
  * 6. sandbox_env_token_access — sensitive env var access (MEDIUM, +10)
13
+ * 7. sandbox_native_addon_load — native .node addon loaded (MEDIUM, +15)
13
14
  */
14
15
 
15
16
  const ONE_HOUR_MS = 3600000;
@@ -21,6 +22,24 @@ const TWENTY_FOUR_HOURS_MS = 24 * ONE_HOUR_MS;
21
22
  * @param {string} logContent - Raw preload log content
22
23
  * @returns {{ score: number, findings: Array<{type: string, severity: string, detail: string, evidence: string}> }}
23
24
  */
25
+ /**
26
+ * Validate that a log line has the expected [PRELOAD] CATEGORY: format.
27
+ * Rejects lines that don't match the expected structure to prevent
28
+ * log injection attacks where malware injects fake preload log lines.
29
+ */
30
+ const VALID_CATEGORIES = new Set([
31
+ 'INIT', 'TIME', 'TIMER', 'NETWORK', 'FS_READ', 'FS_WRITE',
32
+ 'EXEC', 'ENV_ACCESS', 'NATIVE_ADDON', 'WORKER'
33
+ ]);
34
+
35
+ function isValidPreloadLine(line) {
36
+ if (!line || !line.includes('[PRELOAD]')) return false;
37
+ // Must match format: [PRELOAD] CATEGORY: ... (t+NNNms)
38
+ const match = line.match(/^\[PRELOAD\]\s+(\w+):/);
39
+ if (!match) return false;
40
+ return VALID_CATEGORIES.has(match[1]);
41
+ }
42
+
24
43
  function analyzePreloadLog(logContent) {
25
44
  const findings = [];
26
45
  let score = 0;
@@ -29,7 +48,7 @@ function analyzePreloadLog(logContent) {
29
48
  return { score: 0, findings: [] };
30
49
  }
31
50
 
32
- const lines = logContent.split('\n').filter(l => l.includes('[PRELOAD]'));
51
+ const lines = logContent.split('\n').filter(l => isValidPreloadLine(l));
33
52
 
34
53
  // Categorize lines
35
54
  const timerLines = [];
@@ -38,6 +57,7 @@ function analyzePreloadLog(logContent) {
38
57
  const networkLines = [];
39
58
  const execLines = [];
40
59
  const envLines = [];
60
+ const nativeAddonLines = [];
41
61
 
42
62
  for (const line of lines) {
43
63
  if (line.includes('TIMER:')) {
@@ -52,6 +72,8 @@ function analyzePreloadLog(logContent) {
52
72
  execLines.push(line);
53
73
  } else if (line.includes('ENV_ACCESS:')) {
54
74
  envLines.push(line);
75
+ } else if (line.includes('NATIVE_ADDON:')) {
76
+ nativeAddonLines.push(line);
55
77
  }
56
78
  }
57
79
 
@@ -173,10 +195,28 @@ function analyzePreloadLog(logContent) {
173
195
  });
174
196
  }
175
197
 
198
+ // ── Rule 7: Native addon loading ──
199
+ // Native addons (.node files) can bypass all JS monkey-patches via syscalls.
200
+ // Flag their loading so analysts know time-based evasion may be undetected.
201
+ if (nativeAddonLines.length > 0) {
202
+ const addons = nativeAddonLines.map(l => {
203
+ const m = l.match(/process\.dlopen:\s*(.+?)(?:\s+\(t\+|$)/);
204
+ return m ? m[1].trim() : 'unknown';
205
+ });
206
+
207
+ score += 15;
208
+ findings.push({
209
+ type: 'sandbox_native_addon_load',
210
+ severity: 'MEDIUM',
211
+ detail: `Native addon loaded (${addons.length}): time-based evasion via syscalls possible`,
212
+ evidence: addons.join(', ')
213
+ });
214
+ }
215
+
176
216
  return {
177
217
  score: Math.min(100, score),
178
218
  findings
179
219
  };
180
220
  }
181
221
 
182
- module.exports = { analyzePreloadLog };
222
+ module.exports = { analyzePreloadLog, isValidPreloadLine };
@@ -30,7 +30,7 @@ const SAFE_ENV_VARS = [
30
30
  ];
31
31
 
32
32
  // Env var prefixes that are safe (npm metadata, locale settings)
33
- const SAFE_ENV_PREFIXES = ['npm_config_', 'npm_lifecycle_', 'npm_package_', 'lc_'];
33
+ const SAFE_ENV_PREFIXES = ['npm_config_', 'npm_lifecycle_', 'npm_package_', 'lc_', 'muaddib_'];
34
34
 
35
35
  // Env var keywords to detect sensitive environment access (separate from SENSITIVE_STRINGS)
36
36
  const ENV_SENSITIVE_KEYWORDS = [
@@ -862,6 +862,81 @@ function handleCallExpression(node, ctx) {
862
862
  }
863
863
  }
864
864
 
865
+ // Detect eval.call(null, code) / eval.apply(null, [code]) / Function.call/apply
866
+ if (node.callee.type === 'MemberExpression' && !node.callee.computed &&
867
+ node.callee.property?.type === 'Identifier' &&
868
+ (node.callee.property.name === 'call' || node.callee.property.name === 'apply')) {
869
+ const obj = node.callee.object;
870
+ if (obj?.type === 'Identifier' && (obj.name === 'eval' || obj.name === 'Function')) {
871
+ ctx.hasEvalInFile = true;
872
+ ctx.hasDynamicExec = true;
873
+ ctx.threats.push({
874
+ type: obj.name === 'eval' ? 'dangerous_call_eval' : 'dangerous_call_function',
875
+ severity: 'HIGH',
876
+ message: `${obj.name}.${node.callee.property.name}() — indirect execution via call/apply evasion technique.`,
877
+ file: ctx.relFile
878
+ });
879
+ }
880
+ }
881
+
882
+ // Detect array access pattern: [require][0]('child_process') or [eval][0](code)
883
+ if (node.callee.type === 'MemberExpression' && node.callee.computed &&
884
+ node.callee.object?.type === 'ArrayExpression' &&
885
+ node.callee.property?.type === 'Literal' && typeof node.callee.property.value === 'number') {
886
+ const elements = node.callee.object.elements;
887
+ for (const el of elements) {
888
+ if (el?.type === 'Identifier') {
889
+ if (el.name === 'eval') {
890
+ ctx.hasEvalInFile = true;
891
+ ctx.hasDynamicExec = true;
892
+ ctx.threats.push({
893
+ type: 'dangerous_call_eval',
894
+ severity: 'HIGH',
895
+ message: '[eval][0]() — array access evasion technique for indirect eval execution.',
896
+ file: ctx.relFile
897
+ });
898
+ } else if (el.name === 'require') {
899
+ ctx.threats.push({
900
+ type: 'dynamic_require',
901
+ severity: 'HIGH',
902
+ message: '[require][0]() — array access evasion technique for indirect require.',
903
+ file: ctx.relFile
904
+ });
905
+ } else if (el.name === 'Function') {
906
+ ctx.hasDynamicExec = true;
907
+ ctx.threats.push({
908
+ type: 'dangerous_call_function',
909
+ severity: 'MEDIUM',
910
+ message: '[Function][0]() — array access evasion technique for indirect Function construction.',
911
+ file: ctx.relFile
912
+ });
913
+ }
914
+ }
915
+ }
916
+ }
917
+
918
+ // Detect new Proxy(require, handler) — proxy wrapping require to intercept module loading
919
+ if (node.callee.type === 'Identifier' && node.callee.name !== 'Proxy') {
920
+ // handled below in handleNewExpression
921
+ }
922
+
923
+ // Detect template literals in exec/execSync: execSync(`${cmd}`)
924
+ if ((execName || memberExec) && node.arguments.length > 0) {
925
+ const arg = node.arguments[0];
926
+ if (arg.type === 'TemplateLiteral' && arg.expressions.length > 0) {
927
+ // Template literal with dynamic expressions in exec — bypass for string matching
928
+ const staticParts = arg.quasis.map(q => q.value.raw).join('');
929
+ if (DANGEROUS_CMD_PATTERNS.some(p => p.test(staticParts))) {
930
+ ctx.threats.push({
931
+ type: 'dangerous_exec',
932
+ severity: 'CRITICAL',
933
+ message: `Dangerous command in template literal exec(): "${staticParts.substring(0, 80)}" — template literal evasion.`,
934
+ file: ctx.relFile
935
+ });
936
+ }
937
+ }
938
+ }
939
+
865
940
  // Detect indirect eval/Function via computed property
866
941
  if (node.callee.type === 'MemberExpression' && node.callee.computed) {
867
942
  const prop = node.callee.property;
@@ -1058,6 +1133,15 @@ function handleNewExpression(node, ctx) {
1058
1133
  file: ctx.relFile
1059
1134
  });
1060
1135
  }
1136
+ // Detect new Proxy(require, handler) — intercept module loading
1137
+ if (target.type === 'Identifier' && target.name === 'require') {
1138
+ ctx.threats.push({
1139
+ type: 'dynamic_require',
1140
+ severity: 'HIGH',
1141
+ message: 'new Proxy(require) — proxy wrapping require to intercept/redirect module loading.',
1142
+ file: ctx.relFile
1143
+ });
1144
+ }
1061
1145
  }
1062
1146
  }
1063
1147
 
@@ -1119,6 +1203,54 @@ function handleLiteral(node, ctx) {
1119
1203
  }
1120
1204
 
1121
1205
  function handleAssignmentExpression(node, ctx) {
1206
+ // Detect object property indirection: obj.exec = require('child_process').exec
1207
+ // or obj.fn = eval — stashing dangerous functions in object properties
1208
+ if (node.left?.type === 'MemberExpression' && node.right) {
1209
+ const propName = node.left.property?.type === 'Identifier' ? node.left.property.name :
1210
+ (node.left.property?.type === 'Literal' ? String(node.left.property.value) : null);
1211
+
1212
+ if (propName) {
1213
+ // Assigning require('child_process') or its methods to an object property
1214
+ if (node.right.type === 'CallExpression' && getCallName(node.right) === 'require' &&
1215
+ node.right.arguments.length > 0 && node.right.arguments[0]?.type === 'Literal') {
1216
+ const mod = node.right.arguments[0].value;
1217
+ if (mod === 'child_process' || mod === 'fs' || mod === 'net' || mod === 'dns') {
1218
+ ctx.threats.push({
1219
+ type: 'dynamic_require',
1220
+ severity: 'HIGH',
1221
+ message: `Object property indirection: ${propName} = require('${mod}') — hiding dangerous module in object property.`,
1222
+ file: ctx.relFile
1223
+ });
1224
+ }
1225
+ }
1226
+ // Assigning require('child_process').exec to an object property
1227
+ if (node.right.type === 'MemberExpression' && node.right.object?.type === 'CallExpression' &&
1228
+ getCallName(node.right.object) === 'require' &&
1229
+ node.right.object.arguments.length > 0 && node.right.object.arguments[0]?.type === 'Literal' &&
1230
+ node.right.object.arguments[0].value === 'child_process') {
1231
+ const method = node.right.property?.type === 'Identifier' ? node.right.property.name : null;
1232
+ if (method && ['exec', 'execSync', 'spawn', 'execFile'].includes(method)) {
1233
+ ctx.threats.push({
1234
+ type: 'dangerous_exec',
1235
+ severity: 'HIGH',
1236
+ message: `Object property indirection: ${propName} = require('child_process').${method} — hiding exec in object property.`,
1237
+ file: ctx.relFile
1238
+ });
1239
+ }
1240
+ }
1241
+ // Assigning eval or Function to an object property
1242
+ if (node.right.type === 'Identifier' && (node.right.name === 'eval' || node.right.name === 'Function')) {
1243
+ ctx.hasDynamicExec = true;
1244
+ ctx.threats.push({
1245
+ type: node.right.name === 'eval' ? 'dangerous_call_eval' : 'dangerous_call_function',
1246
+ severity: 'HIGH',
1247
+ message: `Object property indirection: ${propName} = ${node.right.name} — stashing dangerous function in object property.`,
1248
+ file: ctx.relFile
1249
+ });
1250
+ }
1251
+ }
1252
+ }
1253
+
1122
1254
  if (node.left?.type === 'MemberExpression') {
1123
1255
  const left = node.left;
1124
1256
 
@@ -1375,6 +1507,33 @@ function handlePostWalk(ctx) {
1375
1507
  }
1376
1508
  }
1377
1509
 
1510
+ function handleWithStatement(node, ctx) {
1511
+ // with(require('child_process')) exec(cmd) — scope injection evasion
1512
+ // The with() statement makes all properties of the object available as local variables.
1513
+ // When used with require(), it allows calling exec(), spawn() etc. without explicit reference.
1514
+ if (node.object?.type === 'CallExpression' && getCallName(node.object) === 'require') {
1515
+ const arg = node.object.arguments[0];
1516
+ const modName = arg?.type === 'Literal' ? arg.value : null;
1517
+ const dangerousModules = ['child_process', 'fs', 'http', 'https', 'net', 'dns'];
1518
+ if (modName && dangerousModules.includes(modName)) {
1519
+ ctx.hasDynamicExec = true;
1520
+ ctx.threats.push({
1521
+ type: 'dangerous_exec',
1522
+ severity: 'CRITICAL',
1523
+ message: `with(require('${modName}')) — scope injection evasion: all module methods available as local variables.`,
1524
+ file: ctx.relFile
1525
+ });
1526
+ } else if (!modName) {
1527
+ ctx.threats.push({
1528
+ type: 'dynamic_require',
1529
+ severity: 'HIGH',
1530
+ message: 'with(require(...)) — scope injection with dynamic module. Evasion technique.',
1531
+ file: ctx.relFile
1532
+ });
1533
+ }
1534
+ }
1535
+ }
1536
+
1378
1537
  module.exports = {
1379
1538
  handleVariableDeclarator,
1380
1539
  handleCallExpression,
@@ -1383,5 +1542,6 @@ module.exports = {
1383
1542
  handleLiteral,
1384
1543
  handleAssignmentExpression,
1385
1544
  handleMemberExpression,
1545
+ handleWithStatement,
1386
1546
  handlePostWalk
1387
1547
  };
@@ -11,6 +11,7 @@ const {
11
11
  handleLiteral,
12
12
  handleAssignmentExpression,
13
13
  handleMemberExpression,
14
+ handleWithStatement,
14
15
  handlePostWalk
15
16
  } = require('./ast-detectors.js');
16
17
 
@@ -121,7 +122,8 @@ function analyzeFile(content, filePath, basePath) {
121
122
  NewExpression(node) { handleNewExpression(node, ctx); },
122
123
  Literal(node) { handleLiteral(node, ctx); },
123
124
  AssignmentExpression(node) { handleAssignmentExpression(node, ctx); },
124
- MemberExpression(node) { handleMemberExpression(node, ctx); }
125
+ MemberExpression(node) { handleMemberExpression(node, ctx); },
126
+ WithStatement(node) { handleWithStatement(node, ctx); }
125
127
  });
126
128
 
127
129
  // FIX 5: DNS chunk exfiltration — verify dns.resolve* is inside a loop body
@@ -140,7 +140,26 @@ function analyzeFile(content, filePath, basePath) {
140
140
  // Track exec calls whose result is captured (for command_output source detection)
141
141
  const execResultNodes = new Set();
142
142
 
143
+ // Fix #22: EventEmitter tracking — detect tainted emit → on patterns
144
+ const eventHandlers = new Map(); // eventName → { hasNetworkSink: boolean }
145
+ const emitTaintedEvents = new Set(); // event names emitted with tainted data
146
+
147
+ // Fix #23: Function param tainting — track function declarations
148
+ const functionDefs = new Map(); // functionName → { params: [paramNames] }
149
+
143
150
  walk.simple(ast, {
151
+ FunctionDeclaration(node) {
152
+ // Fix #23: Track function declarations for param tainting
153
+ if (node.id && node.id.name && node.params) {
154
+ const paramNames = node.params
155
+ .filter(p => p.type === 'Identifier')
156
+ .map(p => p.name);
157
+ if (paramNames.length > 0) {
158
+ functionDefs.set(node.id.name, { params: paramNames });
159
+ }
160
+ }
161
+ },
162
+
144
163
  VariableDeclarator(node) {
145
164
  if (node.id?.type === 'Identifier' && node.init) {
146
165
  if (containsSensitiveLiteral(node.init)) {
@@ -373,6 +392,62 @@ function analyzeFile(content, filePath, basePath) {
373
392
  }
374
393
  }
375
394
 
395
+ // Fix #22: EventEmitter tracking
396
+ if (node.callee.type === 'MemberExpression' && node.callee.property?.type === 'Identifier') {
397
+ const methodName = node.callee.property.name;
398
+
399
+ // Track .on('eventName', handler) — check if handler has network sink
400
+ if (methodName === 'on' && node.arguments.length >= 2) {
401
+ const eventArg = node.arguments[0];
402
+ if (eventArg.type === 'Literal' && typeof eventArg.value === 'string') {
403
+ const handler = node.arguments[1];
404
+ // Check if the handler body contains network sinks
405
+ let handlerHasSink = false;
406
+ if (handler.type === 'FunctionExpression' || handler.type === 'ArrowFunctionExpression') {
407
+ const bodyStr = content.slice(handler.start, handler.end);
408
+ handlerHasSink = /\b(request|fetch|https?\.get|https?\.request|dns\.resolve)\b/.test(bodyStr);
409
+ }
410
+ eventHandlers.set(eventArg.value, { hasNetworkSink: handlerHasSink });
411
+ }
412
+ }
413
+
414
+ // Track .emit('eventName', taintedData) — check if emitted data is tainted
415
+ if (methodName === 'emit' && node.arguments.length >= 2) {
416
+ const eventArg = node.arguments[0];
417
+ if (eventArg.type === 'Literal' && typeof eventArg.value === 'string') {
418
+ const dataArg = node.arguments[1];
419
+ if (dataArg.type === 'Identifier' && sensitivePathVars.has(dataArg.name)) {
420
+ emitTaintedEvents.add(eventArg.value);
421
+ }
422
+ // Also check taintMap
423
+ if (dataArg.type === 'Identifier') {
424
+ const taint = taintMap.get(dataArg.name);
425
+ if (taint && (taint.source === 'process.env' || MODULE_SOURCE_METHODS[taint.source])) {
426
+ emitTaintedEvents.add(eventArg.value);
427
+ }
428
+ }
429
+ }
430
+ }
431
+ }
432
+
433
+ // Fix #23: Function param tainting — propagate taint through function calls
434
+ if (node.callee.type === 'Identifier' && functionDefs.has(node.callee.name)) {
435
+ const funcDef = functionDefs.get(node.callee.name);
436
+ for (let i = 0; i < node.arguments.length && i < funcDef.params.length; i++) {
437
+ const arg = node.arguments[i];
438
+ if (arg.type === 'Identifier') {
439
+ // Check if argument is tainted
440
+ const argTaint = taintMap.get(arg.name);
441
+ if (argTaint && (argTaint.source === 'process.env' || MODULE_SOURCE_METHODS[argTaint.source])) {
442
+ sensitivePathVars.add(funcDef.params[i]);
443
+ }
444
+ if (sensitivePathVars.has(arg.name)) {
445
+ sensitivePathVars.add(funcDef.params[i]);
446
+ }
447
+ }
448
+ }
449
+ }
450
+
376
451
  // Exec callback: exec('cmd', (err, stdout) => {...}) — output will be used
377
452
  if (!execResultNodes.has(node) && node.arguments.length >= 2) {
378
453
  const lastArg = node.arguments[node.arguments.length - 1];
@@ -471,6 +546,25 @@ function analyzeFile(content, filePath, basePath) {
471
546
  }
472
547
  });
473
548
 
549
+ // Fix #22: EventEmitter compound detection
550
+ for (const eventName of emitTaintedEvents) {
551
+ const handler = eventHandlers.get(eventName);
552
+ if (handler && handler.hasNetworkSink) {
553
+ sources.push({
554
+ type: 'credential_read',
555
+ name: `EventEmitter.emit('${eventName}')`,
556
+ line: 0,
557
+ taint_tracked: true
558
+ });
559
+ sinks.push({
560
+ type: 'network_send',
561
+ name: `EventEmitter.on('${eventName}') handler`,
562
+ line: 0,
563
+ taint_tracked: true
564
+ });
565
+ }
566
+ }
567
+
474
568
  // Check if any source or sink was resolved via taint tracking
475
569
  const hasTaintTracked = sources.some(s => s.taint_tracked) || sinks.some(s => s.taint_tracked);
476
570
 
@@ -613,9 +707,13 @@ const SYSTEM_IDENTITY_ENVS = new Set([
613
707
  'USERPROFILE', 'COMPUTERNAME', 'WHOAMI'
614
708
  ]);
615
709
 
710
+ // Env var prefixes for tool-internal configuration (not external credentials)
711
+ const SAFE_ENV_PREFIXES = ['MUADDIB_', 'npm_config_', 'npm_lifecycle_', 'npm_package_'];
712
+
616
713
  function isSensitiveEnv(name) {
617
714
  const upper = name.toUpperCase();
618
715
  if (SYSTEM_IDENTITY_ENVS.has(upper)) return true;
716
+ if (SAFE_ENV_PREFIXES.some(p => upper.startsWith(p))) return false;
619
717
  const sensitive = ['TOKEN', 'SECRET', 'KEY', 'PASSWORD', 'CREDENTIAL', 'AUTH', 'NPM', 'AWS', 'AZURE', 'GCP'];
620
718
  return sensitive.some(s => upper.includes(s));
621
719
  }
@@ -581,4 +581,71 @@ function isPrintable(str) {
581
581
  return (controlCount / str.length) < 0.2;
582
582
  }
583
583
 
584
- module.exports = { deobfuscate };
584
+ /**
585
+ * Detect control flow flattening obfuscation pattern.
586
+ * Pattern: while(true/1) { switch(var) { case N: ...; var = M; break; ... } }
587
+ * Returns true if the pattern is detected.
588
+ * @param {string} sourceCode — raw JS source
589
+ * @returns {boolean}
590
+ */
591
+ function detectControlFlowFlattening(sourceCode) {
592
+ const ast = safeParse(sourceCode, { ranges: true });
593
+ if (!ast) return false;
594
+
595
+ let found = false;
596
+ walk.simple(ast, {
597
+ WhileStatement(node) {
598
+ if (found) return;
599
+ // Check for while(true) or while(1)
600
+ const test = node.test;
601
+ const isInfinite = (test.type === 'Literal' && (test.value === true || test.value === 1))
602
+ || (test.type === 'Identifier' && test.name === 'true');
603
+ if (!isInfinite) return;
604
+
605
+ // Body should contain a SwitchStatement
606
+ const body = node.body;
607
+ let switchNode = null;
608
+ if (body.type === 'SwitchStatement') {
609
+ switchNode = body;
610
+ } else if (body.type === 'BlockStatement' && body.body) {
611
+ switchNode = body.body.find(s => s.type === 'SwitchStatement');
612
+ }
613
+ if (!switchNode || !switchNode.cases) return;
614
+
615
+ // Need at least 3 cases for CFF pattern
616
+ if (switchNode.cases.length < 3) return;
617
+
618
+ // Check for state variable reassignment in at least 2 cases
619
+ const discriminant = switchNode.discriminant;
620
+ if (!discriminant) return;
621
+ let stateVarName = null;
622
+ if (discriminant.type === 'Identifier') {
623
+ stateVarName = discriminant.name;
624
+ } else if (discriminant.type === 'MemberExpression' && discriminant.property?.type === 'Identifier') {
625
+ stateVarName = discriminant.property.name;
626
+ }
627
+ if (!stateVarName) return;
628
+
629
+ // Count cases that reassign the state variable
630
+ let reassignCount = 0;
631
+ for (const c of switchNode.cases) {
632
+ if (!c.consequent) continue;
633
+ const caseSource = sourceCode.slice(c.start, c.end);
634
+ // Look for stateVar = <number> pattern
635
+ const reassignRe = new RegExp('\\b' + stateVarName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s*=\\s*\\d+');
636
+ if (reassignRe.test(caseSource)) {
637
+ reassignCount++;
638
+ }
639
+ }
640
+
641
+ // CFF pattern: at least 2 cases reassign the state variable
642
+ if (reassignCount >= 2) {
643
+ found = true;
644
+ }
645
+ }
646
+ });
647
+
648
+ return found;
649
+ }
650
+
651
+ module.exports = { deobfuscate, detectControlFlowFlattening };
@@ -63,18 +63,43 @@ function extractLocalImports(filePath, packagePath) {
63
63
  return [...new Set(imports)];
64
64
  }
65
65
 
66
+ /**
67
+ * Try to resolve string concatenation in require arguments.
68
+ * require('./a' + '/b') → './a/b'
69
+ * @param {Object} node - BinaryExpression AST node
70
+ * @returns {string|null} Resolved string or null
71
+ */
72
+ function tryResolveConcatRequire(node, depth) {
73
+ if (depth === undefined) depth = 0;
74
+ if (depth > 20) return null;
75
+ if (node.type === 'Literal' && typeof node.value === 'string') return node.value;
76
+ if (node.type === 'BinaryExpression' && node.operator === '+') {
77
+ const left = tryResolveConcatRequire(node.left, depth + 1);
78
+ if (left === null) return null;
79
+ const right = tryResolveConcatRequire(node.right, depth + 1);
80
+ if (right === null) return null;
81
+ return left + right;
82
+ }
83
+ return null;
84
+ }
85
+
66
86
  function walkForRequires(node, fileDir, packagePath, imports) {
67
87
  if (!node || typeof node !== 'object') return;
68
88
  if (
69
89
  node.type === 'CallExpression' &&
70
90
  node.callee && node.callee.type === 'Identifier' &&
71
91
  node.callee.name === 'require' &&
72
- node.arguments.length === 1 &&
73
- node.arguments[0].type === 'Literal' &&
74
- typeof node.arguments[0].value === 'string'
92
+ node.arguments.length === 1
75
93
  ) {
76
- const spec = node.arguments[0].value;
77
- if (isLocalImport(spec)) {
94
+ const arg = node.arguments[0];
95
+ let spec = null;
96
+ if (arg.type === 'Literal' && typeof arg.value === 'string') {
97
+ spec = arg.value;
98
+ } else if (arg.type === 'BinaryExpression') {
99
+ // Fix #25: Resolve simple string concatenation in require args
100
+ spec = tryResolveConcatRequire(arg);
101
+ }
102
+ if (spec && isLocalImport(spec)) {
78
103
  const resolved = resolveLocal(fileDir, spec, packagePath);
79
104
  if (resolved) imports.push(resolved);
80
105
  }
@@ -420,7 +445,7 @@ function expandTaintThroughReexports(graph, taintedExports, packagePath) {
420
445
  expanded[f] = { ...taintedExports[f] };
421
446
  }
422
447
 
423
- for (let level = 0; level < 2; level++) {
448
+ for (let level = 0; level < 4; level++) {
424
449
  let changed = false;
425
450
  for (const relFile of Object.keys(graph)) {
426
451
  const absFile = path.resolve(packagePath, relFile);
@@ -878,5 +903,6 @@ function toRel(abs, packagePath) {
878
903
 
879
904
  module.exports = {
880
905
  buildModuleGraph, annotateTaintedExports, detectCrossFileFlows,
881
- resolveLocal, extractLocalImports, parseFile, isLocalImport, toRel, isFileExists
906
+ resolveLocal, extractLocalImports, parseFile, isLocalImport, toRel, isFileExists,
907
+ tryResolveConcatRequire
882
908
  };
package/src/scoring.js CHANGED
@@ -283,13 +283,25 @@ function calculateRiskScore(deduped) {
283
283
  // 4. Compute package-level score (typosquat, lifecycle, dependency IOC, etc.)
284
284
  const packageScore = computeGroupScore(packageLevelThreats);
285
285
 
286
- // 5. Final score = max file score + package-level score, capped at 100
287
- const riskScore = Math.min(MAX_RISK_SCORE, maxFileScore + packageScore);
286
+ // 5. Cross-file bonus: aggregate signal from non-max files
287
+ // A package with 3 files each scoring 20 is more suspicious than 1 file scoring 20.
288
+ // Add 25% of each non-max file's score as a bonus, capped at 25.
289
+ const sortedScores = Object.values(fileScores).sort((a, b) => b - a);
290
+ let crossFileBonus = 0;
291
+ if (sortedScores.length > 1) {
292
+ for (let i = 1; i < sortedScores.length; i++) {
293
+ crossFileBonus += Math.ceil(sortedScores[i] * 0.25);
294
+ }
295
+ crossFileBonus = Math.min(crossFileBonus, 25);
296
+ }
297
+
298
+ // 6. Final score = max file score + cross-file bonus + package-level score, capped at 100
299
+ const riskScore = Math.min(MAX_RISK_SCORE, maxFileScore + crossFileBonus + packageScore);
288
300
 
289
- // 6. Old global score for comparison (sum of ALL findings)
301
+ // 7. Old global score for comparison (sum of ALL findings)
290
302
  const globalRiskScore = computeGroupScore(deduped);
291
303
 
292
- // 7. Severity counts (global, for summary display)
304
+ // 8. Severity counts (global, for summary display)
293
305
  const criticalCount = deduped.filter(t => t.severity === 'CRITICAL').length;
294
306
  const highCount = deduped.filter(t => t.severity === 'HIGH').length;
295
307
  const mediumCount = deduped.filter(t => t.severity === 'MEDIUM').length;
@@ -303,7 +315,7 @@ function calculateRiskScore(deduped) {
303
315
 
304
316
  return {
305
317
  riskScore, riskLevel, globalRiskScore,
306
- maxFileScore, packageScore, mostSuspiciousFile, fileScores,
318
+ maxFileScore, crossFileBonus, packageScore, mostSuspiciousFile, fileScores,
307
319
  criticalCount, highCount, mediumCount, lowCount
308
320
  };
309
321
  }
@@ -82,6 +82,38 @@ function isAllowedDownloadRedirect(redirectUrl) {
82
82
  }
83
83
  }
84
84
 
85
+ /**
86
+ * Check if an IP address is private/internal.
87
+ */
88
+ function isPrivateIP(ip) {
89
+ const normalized = normalizeHostname(ip);
90
+ return PRIVATE_IP_PATTERNS.some(p => p.test(normalized));
91
+ }
92
+
93
+ /**
94
+ * Resolve hostname to IP and validate it's not a private address.
95
+ * Prevents DNS rebinding attacks where a domain initially resolves to
96
+ * a public IP but later rebinds to a private IP.
97
+ */
98
+ async function safeDnsResolve(hostname) {
99
+ // Skip for IP addresses (already validated in isAllowedDownloadRedirect)
100
+ if (/^(\d{1,3}\.){3}\d{1,3}$/.test(hostname)) {
101
+ if (isPrivateIP(hostname)) throw new Error(`DNS rebinding blocked: ${hostname} is private`);
102
+ return hostname;
103
+ }
104
+ const dns = require('dns');
105
+ const addresses = await dns.promises.resolve4(hostname);
106
+ if (!addresses || addresses.length === 0) {
107
+ throw new Error(`DNS resolution failed for ${hostname}`);
108
+ }
109
+ for (const addr of addresses) {
110
+ if (isPrivateIP(addr)) {
111
+ throw new Error(`DNS rebinding blocked: ${hostname} resolved to private IP ${addr}`);
112
+ }
113
+ }
114
+ return addresses[0];
115
+ }
116
+
85
117
  /**
86
118
  * Download a file from HTTPS URL to disk, with SSRF-safe redirect handling.
87
119
  * @param {string} url - Source URL (must be HTTPS)
@@ -90,60 +122,64 @@ function isAllowedDownloadRedirect(redirectUrl) {
90
122
  * @returns {Promise<number>} Number of bytes downloaded
91
123
  */
92
124
  function downloadToFile(url, destPath, timeoutMs = DOWNLOAD_TIMEOUT) {
93
- return new Promise((resolve, reject) => {
94
- const doRequest = (requestUrl) => {
95
- const req = https.get(requestUrl, { timeout: timeoutMs }, (res) => {
96
- if (res.statusCode === 301 || res.statusCode === 302) {
97
- res.resume();
98
- const location = res.headers.location;
99
- if (!location) return reject(new Error(`Redirect without Location for ${requestUrl}`));
100
- // Resolve relative redirects against the request URL
101
- const absoluteLocation = new URL(location, requestUrl).href;
102
- const check = isAllowedDownloadRedirect(absoluteLocation);
103
- if (!check.allowed) {
104
- return reject(new Error(check.error));
125
+ // DNS rebinding protection: validate hostname before connecting
126
+ const parsedUrl = new URL(url);
127
+ return safeDnsResolve(parsedUrl.hostname).then(() => {
128
+ return new Promise((resolve, reject) => {
129
+ const doRequest = (requestUrl) => {
130
+ const req = https.get(requestUrl, { timeout: timeoutMs }, (res) => {
131
+ if (res.statusCode === 301 || res.statusCode === 302) {
132
+ res.resume();
133
+ const location = res.headers.location;
134
+ if (!location) return reject(new Error(`Redirect without Location for ${requestUrl}`));
135
+ // Resolve relative redirects against the request URL
136
+ const absoluteLocation = new URL(location, requestUrl).href;
137
+ const check = isAllowedDownloadRedirect(absoluteLocation);
138
+ if (!check.allowed) {
139
+ return reject(new Error(check.error));
140
+ }
141
+ return doRequest(absoluteLocation);
142
+ }
143
+ if (res.statusCode < 200 || res.statusCode >= 300) {
144
+ res.resume();
145
+ return reject(new Error(`HTTP ${res.statusCode} for ${requestUrl}`));
105
146
  }
106
- return doRequest(absoluteLocation);
107
- }
108
- if (res.statusCode < 200 || res.statusCode >= 300) {
109
- res.resume();
110
- return reject(new Error(`HTTP ${res.statusCode} for ${requestUrl}`));
111
- }
112
- const contentLength = parseInt(res.headers['content-length'], 10);
113
- if (contentLength && contentLength > MAX_TARBALL_SIZE) {
114
- res.resume();
115
- return reject(new Error(`Package too large: ${contentLength} bytes (max ${MAX_TARBALL_SIZE})`));
116
- }
117
- const fileStream = fs.createWriteStream(destPath);
118
- let downloadedBytes = 0;
119
- res.on('data', (chunk) => {
120
- downloadedBytes += chunk.length;
121
- if (downloadedBytes > MAX_TARBALL_SIZE) {
122
- res.destroy();
147
+ const contentLength = parseInt(res.headers['content-length'], 10);
148
+ if (contentLength && contentLength > MAX_TARBALL_SIZE) {
149
+ res.resume();
150
+ return reject(new Error(`Package too large: ${contentLength} bytes (max ${MAX_TARBALL_SIZE})`));
151
+ }
152
+ const fileStream = fs.createWriteStream(destPath);
153
+ let downloadedBytes = 0;
154
+ res.on('data', (chunk) => {
155
+ downloadedBytes += chunk.length;
156
+ if (downloadedBytes > MAX_TARBALL_SIZE) {
157
+ res.destroy();
158
+ fileStream.destroy();
159
+ try { fs.unlinkSync(destPath); } catch {}
160
+ reject(new Error(`Package too large: ${downloadedBytes}+ bytes (max ${MAX_TARBALL_SIZE})`));
161
+ }
162
+ });
163
+ res.pipe(fileStream);
164
+ fileStream.on('finish', () => resolve(downloadedBytes));
165
+ fileStream.on('error', (err) => {
166
+ try { fs.unlinkSync(destPath); } catch {}
167
+ reject(err);
168
+ });
169
+ res.on('error', (err) => {
123
170
  fileStream.destroy();
124
171
  try { fs.unlinkSync(destPath); } catch {}
125
- reject(new Error(`Package too large: ${downloadedBytes}+ bytes (max ${MAX_TARBALL_SIZE})`));
126
- }
127
- });
128
- res.pipe(fileStream);
129
- fileStream.on('finish', () => resolve(downloadedBytes));
130
- fileStream.on('error', (err) => {
131
- try { fs.unlinkSync(destPath); } catch {}
132
- reject(err);
172
+ reject(err);
173
+ });
133
174
  });
134
- res.on('error', (err) => {
135
- fileStream.destroy();
136
- try { fs.unlinkSync(destPath); } catch {}
137
- reject(err);
175
+ req.on('error', reject);
176
+ req.on('timeout', () => {
177
+ req.destroy();
178
+ reject(new Error(`Timeout downloading ${requestUrl}`));
138
179
  });
139
- });
140
- req.on('error', reject);
141
- req.on('timeout', () => {
142
- req.destroy();
143
- reject(new Error(`Timeout downloading ${requestUrl}`));
144
- });
145
- };
146
- doRequest(url);
180
+ };
181
+ doRequest(url);
182
+ });
147
183
  });
148
184
  }
149
185
 
@@ -204,6 +240,8 @@ module.exports = {
204
240
  sanitizePackageName,
205
241
  isAllowedDownloadRedirect,
206
242
  normalizeHostname,
243
+ isPrivateIP,
244
+ safeDnsResolve,
207
245
  ALLOWED_DOWNLOAD_DOMAINS,
208
246
  PRIVATE_IP_PATTERNS
209
247
  };