muaddib-scanner 2.2.28 → 2.3.1

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/bin/muaddib.js CHANGED
@@ -33,6 +33,7 @@ let temporalFullMode = false;
33
33
  let breakdownMode = false;
34
34
  let noDeobfuscate = false;
35
35
  let noModuleGraph = false;
36
+ let noReachability = false;
36
37
  let feedLimit = null;
37
38
  let feedSeverity = null;
38
39
  let feedSince = null;
@@ -122,6 +123,8 @@ for (let i = 0; i < options.length; i++) {
122
123
  noDeobfuscate = true;
123
124
  } else if (options[i] === '--no-module-graph') {
124
125
  noModuleGraph = true;
126
+ } else if (options[i] === '--no-reachability') {
127
+ noReachability = true;
125
128
  } else if (options[i] === '--temporal') {
126
129
  temporalMode = true;
127
130
  } else if (options[i] === '--limit') {
@@ -415,6 +418,7 @@ const helpText = `
415
418
  --no-canary Disable honey token injection in sandbox
416
419
  --no-deobfuscate Disable deobfuscation pre-processing
417
420
  --no-module-graph Disable cross-file dataflow analysis
421
+ --no-reachability Disable entry-point reachability analysis
418
422
  --exclude [dir] Exclude directory from scan (repeatable)
419
423
  --limit [n] Limit feed entries (default: 50)
420
424
  --severity [level] Filter by severity (CRITICAL|HIGH|MEDIUM|LOW)
@@ -461,7 +465,8 @@ if (command === 'version' || command === '--version' || command === '-v') {
461
465
  entropyThreshold: entropyThreshold,
462
466
  breakdown: breakdownMode,
463
467
  noDeobfuscate: noDeobfuscate,
464
- noModuleGraph: noModuleGraph
468
+ noModuleGraph: noModuleGraph,
469
+ noReachability: noReachability
465
470
  }).then(exitCode => {
466
471
  process.exit(exitCode);
467
472
  }).catch(err => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.2.28",
3
+ "version": "2.3.1",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -44,9 +44,9 @@
44
44
  "node": ">=18.0.0"
45
45
  },
46
46
  "dependencies": {
47
- "@inquirer/prompts": "8.2.1",
48
- "acorn": "8.15.0",
49
- "acorn-walk": "8.3.4",
47
+ "@inquirer/prompts": "8.3.0",
48
+ "acorn": "8.16.0",
49
+ "acorn-walk": "8.3.5",
50
50
  "adm-zip": "0.5.16",
51
51
  "chalk": "5.6.2",
52
52
  "js-yaml": "4.1.1",
@@ -54,8 +54,8 @@
54
54
  },
55
55
  "devDependencies": {
56
56
  "@eslint/js": "10.0.1",
57
- "eslint": "10.0.0",
58
- "eslint-plugin-security": "^3.0.1",
57
+ "eslint": "10.0.2",
58
+ "eslint-plugin-security": "^4.0.0",
59
59
  "globals": "17.3.0"
60
60
  }
61
61
  }
package/src/index.js CHANGED
@@ -19,6 +19,7 @@ const { scanEntropy } = require('./scanner/entropy.js');
19
19
  const { scanAIConfig } = require('./scanner/ai-config.js');
20
20
  const { deobfuscate } = require('./scanner/deobfuscate.js');
21
21
  const { buildModuleGraph, annotateTaintedExports, detectCrossFileFlows } = require('./scanner/module-graph.js');
22
+ const { computeReachableFiles } = require('./scanner/reachability.js');
22
23
  const { runTemporalAnalyses } = require('./temporal-runner.js');
23
24
  const { formatOutput } = require('./output-formatter.js');
24
25
  const { setExtraExcludes, getExtraExcludes, Spinner, listInstalledPackages, clearFileListCache } = require('./utils.js');
@@ -333,9 +334,22 @@ async function run(targetPath, options = {}) {
333
334
  }
334
335
  }
335
336
 
337
+ // Reachability analysis: determine which files are reachable from entry points
338
+ let reachableFiles = null;
339
+ if (!options.noReachability) {
340
+ try {
341
+ const reachability = computeReachableFiles(targetPath);
342
+ if (!reachability.skipped) {
343
+ reachableFiles = reachability.reachableFiles;
344
+ }
345
+ } catch {
346
+ // Graceful fallback — treat all files as reachable
347
+ }
348
+ }
349
+
336
350
  // FP reduction: legitimate frameworks produce high volumes of certain threat types.
337
351
  // A malware package typically has 1-3 occurrences, not dozens.
338
- applyFPReductions(deduped);
352
+ applyFPReductions(deduped, reachableFiles);
339
353
 
340
354
  // Enrich each threat with rules
341
355
  const enrichedThreats = deduped.map(t => {
package/src/monitor.js CHANGED
@@ -1640,9 +1640,9 @@ function getTemporalMaxSeverity(temporalResult, astResult, publishResult, mainta
1640
1640
  if (astResult && astResult.suspicious && astResult.findings) {
1641
1641
  allFindings.push(...astResult.findings);
1642
1642
  }
1643
- if (publishResult && publishResult.suspicious && publishResult.anomalies) {
1644
- allFindings.push(...publishResult.anomalies);
1645
- }
1643
+ // publishResult deliberately excluded publish anomalies alone (nightly builds,
1644
+ // burst releases) should not trigger temporal preservation. They are handled
1645
+ // separately by isPublishAnomalyOnly().
1646
1646
  if (maintainerResult && maintainerResult.suspicious && maintainerResult.findings) {
1647
1647
  allFindings.push(...maintainerResult.findings);
1648
1648
  }
@@ -808,7 +808,7 @@ function handleCallExpression(node, ctx) {
808
808
  });
809
809
  }
810
810
  // Module._compile counts as temp file exec for write-execute-delete pattern
811
- ctx.hasTempFileExec = ctx.hasTempFileExec || ctx.hasTmpdirInContent;
811
+ ctx.hasTempFileExec = ctx.hasTempFileExec || ctx.hasDevShmInContent;
812
812
  }
813
813
 
814
814
  // SANDWORM_MODE: Track writeFileSync/writeFile to temp paths
@@ -816,13 +816,13 @@ function handleCallExpression(node, ctx) {
816
816
  const arg = node.arguments && node.arguments[0];
817
817
  if (arg) {
818
818
  const strVal = extractStringValue(arg);
819
- if (strVal && (/\/dev\/shm\b/.test(strVal) || /\btmp\b/i.test(strVal) || /\btemp\b/i.test(strVal))) {
819
+ if (strVal && /\/dev\/shm\b/.test(strVal)) {
820
820
  ctx.hasTempFileWrite = true;
821
821
  }
822
- // Variable reference to tmpdir/temp path
822
+ // Variable reference to /dev/shm path
823
823
  if (!strVal && (arg.type === 'Identifier' || arg.type === 'CallExpression' || arg.type === 'MemberExpression')) {
824
- // Dynamic path — check if file content involves tmpdir patterns
825
- ctx.hasTempFileWrite = ctx.hasTempFileWrite || ctx.hasTmpdirInContent;
824
+ // Dynamic path — check if file content involves /dev/shm
825
+ ctx.hasTempFileWrite = ctx.hasTempFileWrite || ctx.hasDevShmInContent;
826
826
  }
827
827
  }
828
828
  }
@@ -837,10 +837,10 @@ function handleCallExpression(node, ctx) {
837
837
  if (callName === 'require' && node.arguments.length > 0) {
838
838
  const arg = node.arguments[0];
839
839
  const strVal = extractStringValue(arg);
840
- if (strVal && (/\/dev\/shm\b/.test(strVal) || /\btmp\b/i.test(strVal) || /\btemp\b/i.test(strVal))) {
840
+ if (strVal && /\/dev\/shm\b/.test(strVal)) {
841
841
  ctx.hasTempFileExec = true;
842
- } else if (!strVal && ctx.hasTmpdirInContent) {
843
- // Variable argument in a file that references tmpdir paths
842
+ } else if (!strVal && ctx.hasDevShmInContent) {
843
+ // Variable argument in a file that references /dev/shm
844
844
  ctx.hasTempFileExec = true;
845
845
  }
846
846
  }
@@ -860,16 +860,7 @@ function handleCallExpression(node, ctx) {
860
860
  }
861
861
  }
862
862
 
863
- // SANDWORM_MODE R8: Detect dns.resolve/resolve4/resolveTxt calls (flag for co-occurrence)
864
- if (node.callee.type === 'MemberExpression' && node.callee.property?.type === 'Identifier') {
865
- const dnsPropName = node.callee.property.name;
866
- if (['resolve', 'resolve4', 'resolveTxt', 'resolveCname'].includes(dnsPropName)) {
867
- // Set hasDnsLoop if file has dns require + base64 encoding (co-occurrence checked in postWalk)
868
- if (ctx.hasDnsRequire && ctx.hasBase64Encode) {
869
- ctx.hasDnsLoop = true;
870
- }
871
- }
872
- }
863
+ // SANDWORM_MODE R8: dns.resolve detection moved to walk.ancestor() in ast.js (FIX 5)
873
864
  }
874
865
 
875
866
  function handleImportExpression(node, ctx) {
@@ -1086,9 +1077,11 @@ function handleMemberExpression(node, ctx) {
1086
1077
  function handlePostWalk(ctx) {
1087
1078
  // SANDWORM_MODE: zlib inflate + base64 decode + eval/Function/Module._compile = obfuscated payload
1088
1079
  if (ctx.hasZlibInflate && ctx.hasBase64Decode && ctx.hasDynamicExec) {
1080
+ // FIX 4: dist/build files get LOW severity (bundlers legitimately use zlib+base64+eval)
1081
+ const isDistFile = /^(dist|build)[/\\]/i.test(ctx.relFile) || /\.bundle\.js$/i.test(ctx.relFile);
1089
1082
  ctx.threats.push({
1090
1083
  type: 'zlib_inflate_eval',
1091
- severity: 'CRITICAL',
1084
+ severity: isDistFile ? 'LOW' : 'CRITICAL',
1092
1085
  message: 'Obfuscated payload: zlib inflate + base64 decode + dynamic execution. No legitimate package uses this pattern.',
1093
1086
  file: ctx.relFile
1094
1087
  });
@@ -1105,7 +1098,7 @@ function handlePostWalk(ctx) {
1105
1098
  }
1106
1099
 
1107
1100
  // SANDWORM_MODE R7: env harvesting = Object.entries/keys/values(process.env) + sensitive pattern in file
1108
- if (ctx.hasEnvEnumeration && ctx.hasEnvHarvestPattern) {
1101
+ if (ctx.hasEnvEnumeration && ctx.hasEnvHarvestPattern && ctx.hasNetworkCallInFile) {
1109
1102
  ctx.threats.push({
1110
1103
  type: 'env_harvesting_dynamic',
1111
1104
  severity: 'HIGH',
@@ -84,10 +84,11 @@ function analyzeFile(content, filePath, basePath) {
84
84
  hasTempFileWrite: false,
85
85
  hasTempFileExec: false,
86
86
  hasFileDelete: false,
87
- hasTmpdirInContent: /\btmpdir\b|\/dev\/shm\b|\/tmp\b/i.test(content),
87
+ hasDevShmInContent: /\/dev\/shm\b/.test(content),
88
88
  // SANDWORM_MODE P2: env harvesting co-occurrence
89
89
  hasEnvEnumeration: false, // Object.entries/keys/values(process.env)
90
90
  hasEnvHarvestPattern: /\b(KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL|NPM|AWS|SSH|WEBHOOK)\b/.test(content),
91
+ hasNetworkCallInFile: /\b(fetch|https?\.request|https?\.get|dns\.resolve)\b/.test(content),
91
92
  // SANDWORM_MODE P2: DNS exfiltration co-occurrence
92
93
  hasDnsRequire: /\brequire\s*\(\s*['"]dns['"]\s*\)/.test(content) || /\bdns\s*\.\s*resolve/.test(content),
93
94
  hasBase64Encode: /\.toString\s*\(\s*['"]base64(url)?['"]\s*\)/.test(content),
@@ -106,6 +107,35 @@ function analyzeFile(content, filePath, basePath) {
106
107
  MemberExpression(node) { handleMemberExpression(node, ctx); }
107
108
  });
108
109
 
110
+ // FIX 5: DNS chunk exfiltration — verify dns.resolve* is inside a loop body
111
+ if (ctx.hasDnsRequire && ctx.hasBase64Encode && !ctx.hasDnsLoop) {
112
+ walk.ancestor(ast, {
113
+ CallExpression(node, _state, ancestors) {
114
+ if (ctx.hasDnsLoop) return;
115
+ if (node.callee.type === 'MemberExpression' && node.callee.property?.type === 'Identifier') {
116
+ const name = node.callee.property.name;
117
+ if (['resolve', 'resolve4', 'resolveTxt', 'resolveCname'].includes(name)) {
118
+ for (const anc of ancestors) {
119
+ if (['ForStatement', 'WhileStatement', 'ForOfStatement',
120
+ 'ForInStatement', 'DoWhileStatement'].includes(anc.type)) {
121
+ ctx.hasDnsLoop = true;
122
+ return;
123
+ }
124
+ // forEach/map callback = implicit loop
125
+ if (anc.type === 'CallExpression' && anc.callee?.type === 'MemberExpression') {
126
+ const m = anc.callee.property?.name;
127
+ if (['forEach', 'map', 'reduce', 'filter'].includes(m)) {
128
+ ctx.hasDnsLoop = true;
129
+ return;
130
+ }
131
+ }
132
+ }
133
+ }
134
+ }
135
+ }
136
+ });
137
+ }
138
+
109
139
  handlePostWalk(ctx);
110
140
 
111
141
  return threats;