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.
- package/bin/muaddib.js +11 -1
- package/datasets/holdout-v4/atob-eval/index.js +2 -0
- package/datasets/holdout-v4/atob-eval/package.json +5 -0
- package/datasets/holdout-v4/base64-require/index.js +3 -0
- package/datasets/holdout-v4/base64-require/package.json +5 -0
- package/datasets/holdout-v4/charcode-fetch/index.js +3 -0
- package/datasets/holdout-v4/charcode-fetch/package.json +5 -0
- package/datasets/holdout-v4/charcode-spread-homedir/index.js +5 -0
- package/datasets/holdout-v4/charcode-spread-homedir/package.json +5 -0
- package/datasets/holdout-v4/concat-env-steal/index.js +4 -0
- package/datasets/holdout-v4/concat-env-steal/package.json +5 -0
- package/datasets/holdout-v4/double-decode-exfil/index.js +4 -0
- package/datasets/holdout-v4/double-decode-exfil/package.json +5 -0
- package/datasets/holdout-v4/hex-array-exec/index.js +3 -0
- package/datasets/holdout-v4/hex-array-exec/package.json +5 -0
- package/datasets/holdout-v4/mixed-obfuscation-stealer/index.js +10 -0
- package/datasets/holdout-v4/mixed-obfuscation-stealer/package.json +5 -0
- package/datasets/holdout-v4/nested-base64-concat/index.js +4 -0
- package/datasets/holdout-v4/nested-base64-concat/package.json +5 -0
- package/datasets/holdout-v4/template-literal-hide/index.js +3 -0
- package/datasets/holdout-v4/template-literal-hide/package.json +5 -0
- package/datasets/holdout-v5/callback-exfil/main.js +8 -0
- package/datasets/holdout-v5/callback-exfil/package.json +5 -0
- package/datasets/holdout-v5/callback-exfil/reader.js +10 -0
- package/datasets/holdout-v5/class-method-exfil/collector.js +10 -0
- package/datasets/holdout-v5/class-method-exfil/main.js +7 -0
- package/datasets/holdout-v5/class-method-exfil/package.json +5 -0
- package/datasets/holdout-v5/conditional-split/detector.js +2 -0
- package/datasets/holdout-v5/conditional-split/package.json +5 -0
- package/datasets/holdout-v5/conditional-split/stealer.js +16 -0
- package/datasets/holdout-v5/event-emitter-flow/listener.js +12 -0
- package/datasets/holdout-v5/event-emitter-flow/package.json +5 -0
- package/datasets/holdout-v5/event-emitter-flow/scanner.js +11 -0
- package/datasets/holdout-v5/mixed-inline-split/index.js +6 -0
- package/datasets/holdout-v5/mixed-inline-split/package.json +5 -0
- package/datasets/holdout-v5/mixed-inline-split/reader.js +3 -0
- package/datasets/holdout-v5/mixed-inline-split/sender.js +6 -0
- package/datasets/holdout-v5/named-export-steal/main.js +6 -0
- package/datasets/holdout-v5/named-export-steal/package.json +5 -0
- package/datasets/holdout-v5/named-export-steal/utils.js +1 -0
- package/datasets/holdout-v5/reexport-chain/a.js +2 -0
- package/datasets/holdout-v5/reexport-chain/b.js +1 -0
- package/datasets/holdout-v5/reexport-chain/c.js +11 -0
- package/datasets/holdout-v5/reexport-chain/package.json +5 -0
- package/datasets/holdout-v5/split-env-exfil/env.js +2 -0
- package/datasets/holdout-v5/split-env-exfil/exfil.js +5 -0
- package/datasets/holdout-v5/split-env-exfil/package.json +5 -0
- package/datasets/holdout-v5/split-npmrc-steal/index.js +2 -0
- package/datasets/holdout-v5/split-npmrc-steal/package.json +5 -0
- package/datasets/holdout-v5/split-npmrc-steal/reader.js +8 -0
- package/datasets/holdout-v5/split-npmrc-steal/sender.js +17 -0
- package/datasets/holdout-v5/three-hop-chain/package.json +5 -0
- package/datasets/holdout-v5/three-hop-chain/reader.js +8 -0
- package/datasets/holdout-v5/three-hop-chain/sender.js +11 -0
- package/datasets/holdout-v5/three-hop-chain/transform.js +3 -0
- package/package.json +1 -1
- package/src/index.js +26 -3
- package/src/response/playbooks.js +10 -0
- package/src/rules/index.js +26 -0
- package/src/scanner/ast.js +107 -24
- package/src/scanner/dataflow.js +18 -1
- package/src/scanner/deobfuscate.js +557 -0
- package/src/scanner/module-graph.js +883 -0
package/src/scanner/ast.js
CHANGED
|
@@ -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
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
:
|
|
618
|
-
|
|
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
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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
|
|
package/src/scanner/dataflow.js
CHANGED
|
@@ -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;
|