muaddib-scanner 2.2.4 → 2.2.6

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.
Files changed (63) hide show
  1. package/bin/muaddib.js +11 -1
  2. package/datasets/holdout-v4/atob-eval/index.js +2 -0
  3. package/datasets/holdout-v4/atob-eval/package.json +5 -0
  4. package/datasets/holdout-v4/base64-require/index.js +3 -0
  5. package/datasets/holdout-v4/base64-require/package.json +5 -0
  6. package/datasets/holdout-v4/charcode-fetch/index.js +3 -0
  7. package/datasets/holdout-v4/charcode-fetch/package.json +5 -0
  8. package/datasets/holdout-v4/charcode-spread-homedir/index.js +5 -0
  9. package/datasets/holdout-v4/charcode-spread-homedir/package.json +5 -0
  10. package/datasets/holdout-v4/concat-env-steal/index.js +4 -0
  11. package/datasets/holdout-v4/concat-env-steal/package.json +5 -0
  12. package/datasets/holdout-v4/double-decode-exfil/index.js +4 -0
  13. package/datasets/holdout-v4/double-decode-exfil/package.json +5 -0
  14. package/datasets/holdout-v4/hex-array-exec/index.js +3 -0
  15. package/datasets/holdout-v4/hex-array-exec/package.json +5 -0
  16. package/datasets/holdout-v4/mixed-obfuscation-stealer/index.js +10 -0
  17. package/datasets/holdout-v4/mixed-obfuscation-stealer/package.json +5 -0
  18. package/datasets/holdout-v4/nested-base64-concat/index.js +4 -0
  19. package/datasets/holdout-v4/nested-base64-concat/package.json +5 -0
  20. package/datasets/holdout-v4/template-literal-hide/index.js +3 -0
  21. package/datasets/holdout-v4/template-literal-hide/package.json +5 -0
  22. package/datasets/holdout-v5/callback-exfil/main.js +8 -0
  23. package/datasets/holdout-v5/callback-exfil/package.json +5 -0
  24. package/datasets/holdout-v5/callback-exfil/reader.js +10 -0
  25. package/datasets/holdout-v5/class-method-exfil/collector.js +10 -0
  26. package/datasets/holdout-v5/class-method-exfil/main.js +7 -0
  27. package/datasets/holdout-v5/class-method-exfil/package.json +5 -0
  28. package/datasets/holdout-v5/conditional-split/detector.js +2 -0
  29. package/datasets/holdout-v5/conditional-split/package.json +5 -0
  30. package/datasets/holdout-v5/conditional-split/stealer.js +16 -0
  31. package/datasets/holdout-v5/event-emitter-flow/listener.js +12 -0
  32. package/datasets/holdout-v5/event-emitter-flow/package.json +5 -0
  33. package/datasets/holdout-v5/event-emitter-flow/scanner.js +11 -0
  34. package/datasets/holdout-v5/mixed-inline-split/index.js +6 -0
  35. package/datasets/holdout-v5/mixed-inline-split/package.json +5 -0
  36. package/datasets/holdout-v5/mixed-inline-split/reader.js +3 -0
  37. package/datasets/holdout-v5/mixed-inline-split/sender.js +6 -0
  38. package/datasets/holdout-v5/named-export-steal/main.js +6 -0
  39. package/datasets/holdout-v5/named-export-steal/package.json +5 -0
  40. package/datasets/holdout-v5/named-export-steal/utils.js +1 -0
  41. package/datasets/holdout-v5/reexport-chain/a.js +2 -0
  42. package/datasets/holdout-v5/reexport-chain/b.js +1 -0
  43. package/datasets/holdout-v5/reexport-chain/c.js +11 -0
  44. package/datasets/holdout-v5/reexport-chain/package.json +5 -0
  45. package/datasets/holdout-v5/split-env-exfil/env.js +2 -0
  46. package/datasets/holdout-v5/split-env-exfil/exfil.js +5 -0
  47. package/datasets/holdout-v5/split-env-exfil/package.json +5 -0
  48. package/datasets/holdout-v5/split-npmrc-steal/index.js +2 -0
  49. package/datasets/holdout-v5/split-npmrc-steal/package.json +5 -0
  50. package/datasets/holdout-v5/split-npmrc-steal/reader.js +8 -0
  51. package/datasets/holdout-v5/split-npmrc-steal/sender.js +17 -0
  52. package/datasets/holdout-v5/three-hop-chain/package.json +5 -0
  53. package/datasets/holdout-v5/three-hop-chain/reader.js +8 -0
  54. package/datasets/holdout-v5/three-hop-chain/sender.js +11 -0
  55. package/datasets/holdout-v5/three-hop-chain/transform.js +3 -0
  56. package/package.json +1 -1
  57. package/src/index.js +26 -3
  58. package/src/response/playbooks.js +10 -0
  59. package/src/rules/index.js +26 -0
  60. package/src/scanner/ast.js +107 -24
  61. package/src/scanner/dataflow.js +18 -1
  62. package/src/scanner/deobfuscate.js +557 -0
  63. package/src/scanner/module-graph.js +883 -0
@@ -92,22 +92,22 @@ const SANDBOX_INDICATORS = [
92
92
  '/proc/self/cgroup'
93
93
  ];
94
94
 
95
- async function analyzeAST(targetPath) {
95
+ async function analyzeAST(targetPath, options = {}) {
96
96
  const threats = [];
97
97
  const files = findJsFiles(targetPath);
98
98
 
99
99
  for (const file of files) {
100
100
  const relativePath = path.relative(targetPath, file).replace(/\\/g, '/');
101
-
101
+
102
102
  if (EXCLUDED_FILES.includes(relativePath)) {
103
103
  continue;
104
104
  }
105
-
105
+
106
106
  // Ignore files in dev folders
107
107
  if (isDevFile(relativePath)) {
108
108
  continue;
109
109
  }
110
-
110
+
111
111
  try {
112
112
  const stat = fs.statSync(file);
113
113
  if (stat.size > MAX_FILE_SIZE) continue;
@@ -119,8 +119,26 @@ async function analyzeAST(targetPath) {
119
119
  } catch {
120
120
  continue;
121
121
  }
122
+
123
+ // Analyze original code first (preserves obfuscation-detection rules)
122
124
  const fileThreats = analyzeFile(content, file, targetPath);
123
125
  threats.push(...fileThreats);
126
+
127
+ // Also analyze deobfuscated code for additional findings hidden by obfuscation
128
+ if (typeof options.deobfuscate === 'function') {
129
+ try {
130
+ const result = options.deobfuscate(content);
131
+ if (result.transforms.length > 0) {
132
+ const deobThreats = analyzeFile(result.code, file, targetPath);
133
+ const existingKeys = new Set(fileThreats.map(t => `${t.type}::${t.message}`));
134
+ for (const dt of deobThreats) {
135
+ if (!existingKeys.has(`${dt.type}::${dt.message}`)) {
136
+ threats.push(dt);
137
+ }
138
+ }
139
+ }
140
+ } catch { /* deobfuscation failed — skip */ }
141
+ }
124
142
  }
125
143
 
126
144
  return threats;
@@ -137,6 +155,34 @@ function hasOnlyStringLiteralArgs(node) {
137
155
  return node.arguments.every(arg => arg.type === 'Literal' && typeof arg.value === 'string');
138
156
  }
139
157
 
158
+ /**
159
+ * Returns true if a node is a decode call: atob(str) or Buffer.from(str,'base64').toString()
160
+ * Used to detect staged eval/Function decode patterns.
161
+ */
162
+ function hasDecodeArg(node) {
163
+ if (!node || typeof node !== 'object') return false;
164
+ // atob('...')
165
+ if (node.type === 'CallExpression' &&
166
+ node.callee?.type === 'Identifier' && node.callee.name === 'atob') {
167
+ return true;
168
+ }
169
+ // Buffer.from('...', 'base64').toString()
170
+ if (node.type === 'CallExpression' &&
171
+ node.callee?.type === 'MemberExpression' &&
172
+ node.callee.property?.name === 'toString') {
173
+ const inner = node.callee.object;
174
+ if (inner?.type === 'CallExpression' &&
175
+ inner.callee?.type === 'MemberExpression' &&
176
+ inner.callee.object?.name === 'Buffer' &&
177
+ inner.callee.property?.name === 'from' &&
178
+ inner.arguments?.length >= 2 &&
179
+ inner.arguments[1]?.value === 'base64') {
180
+ return true;
181
+ }
182
+ }
183
+ return false;
184
+ }
185
+
140
186
  /**
141
187
  * Checks if an AST subtree contains decode patterns (base64, atob, fromCharCode).
142
188
  */
@@ -391,6 +437,23 @@ function analyzeFile(content, filePath, basePath) {
391
437
  }
392
438
  }
393
439
 
440
+ // Detect chained: require(non-literal).exec(...) — direct dynamic require + exec
441
+ if ((execName || memberExec) && node.callee.type === 'MemberExpression' &&
442
+ node.callee.object?.type === 'CallExpression') {
443
+ const innerCall = node.callee.object;
444
+ const innerName = getCallName(innerCall);
445
+ if (innerName === 'require' && innerCall.arguments.length > 0 &&
446
+ innerCall.arguments[0]?.type !== 'Literal') {
447
+ const method = execName || memberExec;
448
+ threats.push({
449
+ type: 'dynamic_require_exec',
450
+ severity: 'CRITICAL',
451
+ message: `${method}() chained on dynamic require() — obfuscated module + command execution.`,
452
+ file: path.relative(basePath, filePath)
453
+ });
454
+ }
455
+ }
456
+
394
457
  // Detect sandbox/container evasion: fs.accessSync('/.dockerenv'), fs.existsSync('/.dockerenv'), etc.
395
458
  if (node.callee.type === 'MemberExpression' && node.callee.property?.type === 'Identifier') {
396
459
  const fsMethod = node.callee.property.name;
@@ -608,27 +671,47 @@ function analyzeFile(content, filePath, basePath) {
608
671
 
609
672
  if (callName === 'eval') {
610
673
  hasEvalInFile = true;
611
- const isConstant = hasOnlyStringLiteralArgs(node);
612
- threats.push({
613
- type: 'dangerous_call_eval',
614
- severity: isConstant ? 'LOW' : 'HIGH',
615
- message: isConstant
616
- ? 'eval() with constant string literal (low risk, globalThis polyfill pattern).'
617
- : 'Dangerous call "eval" with dynamic expression detected.',
618
- file: path.relative(basePath, filePath)
619
- });
674
+ // Detect staged eval decode: eval(atob(...)) or eval(Buffer.from(...).toString())
675
+ if (node.arguments.length === 1 && hasDecodeArg(node.arguments[0])) {
676
+ threats.push({
677
+ type: 'staged_eval_decode',
678
+ severity: 'CRITICAL',
679
+ message: 'eval() with decode argument (atob/Buffer.from base64) staged payload execution.',
680
+ file: path.relative(basePath, filePath)
681
+ });
682
+ } else {
683
+ const isConstant = hasOnlyStringLiteralArgs(node);
684
+ threats.push({
685
+ type: 'dangerous_call_eval',
686
+ severity: isConstant ? 'LOW' : 'HIGH',
687
+ message: isConstant
688
+ ? 'eval() with constant string literal (low risk, globalThis polyfill pattern).'
689
+ : 'Dangerous call "eval" with dynamic expression detected.',
690
+ file: path.relative(basePath, filePath)
691
+ });
692
+ }
620
693
  } else if (callName === 'Function') {
621
- const isConstant = hasOnlyStringLiteralArgs(node);
622
- // Function() creates a new scope (unlike eval), so dynamic usage is MEDIUM not HIGH.
623
- // Common in template engines (lodash, handlebars) and globalThis polyfills.
624
- threats.push({
625
- type: 'dangerous_call_function',
626
- severity: isConstant ? 'LOW' : 'MEDIUM',
627
- message: isConstant
628
- ? 'Function() with constant string literal (low risk, globalThis polyfill pattern).'
629
- : 'Function() with dynamic expression (template/factory pattern).',
630
- file: path.relative(basePath, filePath)
631
- });
694
+ // Detect staged Function decode: new Function(atob(...))
695
+ if (node.arguments.length >= 1 && hasDecodeArg(node.arguments[node.arguments.length - 1])) {
696
+ threats.push({
697
+ type: 'staged_eval_decode',
698
+ severity: 'CRITICAL',
699
+ message: 'Function() with decode argument (atob/Buffer.from base64) — staged payload execution.',
700
+ file: path.relative(basePath, filePath)
701
+ });
702
+ } else {
703
+ const isConstant = hasOnlyStringLiteralArgs(node);
704
+ // Function() creates a new scope (unlike eval), so dynamic usage is MEDIUM not HIGH.
705
+ // Common in template engines (lodash, handlebars) and globalThis polyfills.
706
+ threats.push({
707
+ type: 'dangerous_call_function',
708
+ severity: isConstant ? 'LOW' : 'MEDIUM',
709
+ message: isConstant
710
+ ? 'Function() with constant string literal (low risk, globalThis polyfill pattern).'
711
+ : 'Function() with dynamic expression (template/factory pattern).',
712
+ file: path.relative(basePath, filePath)
713
+ });
714
+ }
632
715
  }
633
716
  },
634
717
 
@@ -6,7 +6,7 @@ const { isDevFile, findJsFiles, getCallName } = require('../utils.js');
6
6
 
7
7
  const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
8
8
 
9
- async function analyzeDataFlow(targetPath) {
9
+ async function analyzeDataFlow(targetPath, options = {}) {
10
10
  const threats = [];
11
11
  const files = findJsFiles(targetPath);
12
12
 
@@ -35,8 +35,25 @@ async function analyzeDataFlow(targetPath) {
35
35
  continue;
36
36
  }
37
37
 
38
+ // Analyze original code first (preserves obfuscation-detection rules)
38
39
  const fileThreats = analyzeFile(content, file, targetPath);
39
40
  threats.push(...fileThreats);
41
+
42
+ // Also analyze deobfuscated code for additional findings hidden by obfuscation
43
+ if (typeof options.deobfuscate === 'function') {
44
+ try {
45
+ const result = options.deobfuscate(content);
46
+ if (result.transforms.length > 0) {
47
+ const deobThreats = analyzeFile(result.code, file, targetPath);
48
+ const existingKeys = new Set(fileThreats.map(t => `${t.type}::${t.message}`));
49
+ for (const dt of deobThreats) {
50
+ if (!existingKeys.has(`${dt.type}::${dt.message}`)) {
51
+ threats.push(dt);
52
+ }
53
+ }
54
+ }
55
+ } catch { /* deobfuscation failed — skip */ }
56
+ }
40
57
  }
41
58
 
42
59
  return threats;