muaddib-scanner 2.2.11 → 2.2.14
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.fr.md +14 -17
- package/README.md +14 -23
- package/datasets/adversarial/indirect-eval-bypass/index.js +27 -0
- package/datasets/adversarial/indirect-eval-bypass/package.json +5 -0
- package/datasets/adversarial/mjs-extension-bypass/package.json +6 -0
- package/datasets/adversarial/mjs-extension-bypass/stealer.mjs +39 -0
- package/datasets/adversarial/muaddib-ignore-bypass/index.js +47 -0
- package/datasets/adversarial/muaddib-ignore-bypass/package.json +5 -0
- package/package.json +3 -2
- package/src/commands/evaluate.js +61 -7
- package/src/index.js +19 -111
- package/src/ioc/bootstrap.js +5 -4
- package/src/response/playbooks.js +10 -0
- package/src/rules/index.js +26 -0
- package/src/scanner/ast.js +103 -57
- package/src/scanner/dataflow.js +32 -61
- package/src/scanner/deobfuscate.js +52 -18
- package/src/scanner/entropy.js +6 -24
- package/src/scanner/github-actions.js +2 -1
- package/src/scanner/hash.js +1 -1
- package/src/scanner/module-graph.js +3 -3
- package/src/scanner/npm-registry.js +4 -3
- package/src/scanner/obfuscation.js +4 -19
- package/src/scanner/shell.js +3 -13
- package/src/shared/analyze-helper.js +49 -0
- package/src/shared/constants.js +5 -1
- package/src/temporal-ast-diff.js +8 -18
- package/src/utils.js +61 -2
|
@@ -328,6 +328,16 @@ const PLAYBOOKS = {
|
|
|
328
328
|
'CRITIQUE: eval() ou Function() recoit un argument decode en base64 (atob/Buffer.from). ' +
|
|
329
329
|
'Technique de staged payload: le code malveillant est encode puis decode et execute dynamiquement. ' +
|
|
330
330
|
'Isoler la machine. Decoder le payload manuellement pour analyser le code execute. Supprimer le package.',
|
|
331
|
+
|
|
332
|
+
crypto_decipher:
|
|
333
|
+
'crypto.createDecipher/createDecipheriv detecte. Dechiffrement de payload embarque a runtime. ' +
|
|
334
|
+
'Pattern canonique de l\'attaque event-stream/flatmap-stream. Extraire et decoder le payload manuellement ' +
|
|
335
|
+
'pour analyser le code execute. Verifier la source des donnees chiffrees.',
|
|
336
|
+
|
|
337
|
+
module_compile:
|
|
338
|
+
'CRITIQUE: module._compile() detecte. Cette API Node.js interne execute du code arbitraire ' +
|
|
339
|
+
'a partir d\'une chaine dans le contexte d\'un module. Utilisee dans flatmap-stream pour executer ' +
|
|
340
|
+
'un payload dechiffre sans ecrire sur disque. Isoler immediatement. Analyser la source de la chaine compilee.',
|
|
331
341
|
};
|
|
332
342
|
|
|
333
343
|
function getPlaybook(threatType) {
|
package/src/rules/index.js
CHANGED
|
@@ -649,6 +649,32 @@ const RULES = {
|
|
|
649
649
|
mitre: 'T1565.001'
|
|
650
650
|
},
|
|
651
651
|
|
|
652
|
+
crypto_decipher: {
|
|
653
|
+
id: 'MUADDIB-AST-022',
|
|
654
|
+
name: 'Encrypted Payload Decryption',
|
|
655
|
+
severity: 'HIGH',
|
|
656
|
+
confidence: 'high',
|
|
657
|
+
description: 'crypto.createDecipher/createDecipheriv detecte. Dechiffrement runtime de payload embarque. Pattern canonique de flatmap-stream/event-stream.',
|
|
658
|
+
references: [
|
|
659
|
+
'https://snyk.io/blog/malicious-code-found-in-npm-package-event-stream/',
|
|
660
|
+
'https://attack.mitre.org/techniques/T1140/'
|
|
661
|
+
],
|
|
662
|
+
mitre: 'T1140'
|
|
663
|
+
},
|
|
664
|
+
|
|
665
|
+
module_compile: {
|
|
666
|
+
id: 'MUADDIB-AST-023',
|
|
667
|
+
name: 'Module Compile Execution',
|
|
668
|
+
severity: 'CRITICAL',
|
|
669
|
+
confidence: 'high',
|
|
670
|
+
description: 'module._compile() detecte. Execution de code arbitraire a partir d\'une chaine dans le contexte module. Technique cle de flatmap-stream.',
|
|
671
|
+
references: [
|
|
672
|
+
'https://blog.npmjs.org/post/180565383195/details-about-the-event-stream-incident',
|
|
673
|
+
'https://attack.mitre.org/techniques/T1059/007/'
|
|
674
|
+
],
|
|
675
|
+
mitre: 'T1059'
|
|
676
|
+
},
|
|
677
|
+
|
|
652
678
|
ai_agent_abuse: {
|
|
653
679
|
id: 'MUADDIB-AST-013',
|
|
654
680
|
name: 'AI Agent Weaponization',
|
package/src/scanner/ast.js
CHANGED
|
@@ -2,9 +2,9 @@ const fs = require('fs');
|
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const acorn = require('acorn');
|
|
4
4
|
const walk = require('acorn-walk');
|
|
5
|
-
const {
|
|
6
|
-
|
|
7
|
-
const
|
|
5
|
+
const { getCallName } = require('../utils.js');
|
|
6
|
+
const { ACORN_OPTIONS } = require('../shared/constants.js');
|
|
7
|
+
const { analyzeWithDeobfuscation } = require('../shared/analyze-helper.js');
|
|
8
8
|
|
|
9
9
|
const EXCLUDED_FILES = [
|
|
10
10
|
'src/scanner/ast.js',
|
|
@@ -99,55 +99,10 @@ const SANDBOX_INDICATORS = [
|
|
|
99
99
|
];
|
|
100
100
|
|
|
101
101
|
async function analyzeAST(targetPath, options = {}) {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const relativePath = path.relative(targetPath, file).replace(/\\/g, '/');
|
|
107
|
-
|
|
108
|
-
if (EXCLUDED_FILES.includes(relativePath)) {
|
|
109
|
-
continue;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// Ignore files in dev folders
|
|
113
|
-
if (isDevFile(relativePath)) {
|
|
114
|
-
continue;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
try {
|
|
118
|
-
const stat = fs.statSync(file);
|
|
119
|
-
if (stat.size > MAX_FILE_SIZE) continue;
|
|
120
|
-
} catch { continue; }
|
|
121
|
-
|
|
122
|
-
let content;
|
|
123
|
-
try {
|
|
124
|
-
content = fs.readFileSync(file, 'utf8');
|
|
125
|
-
} catch {
|
|
126
|
-
continue;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// Analyze original code first (preserves obfuscation-detection rules)
|
|
130
|
-
const fileThreats = analyzeFile(content, file, targetPath);
|
|
131
|
-
threats.push(...fileThreats);
|
|
132
|
-
|
|
133
|
-
// Also analyze deobfuscated code for additional findings hidden by obfuscation
|
|
134
|
-
if (typeof options.deobfuscate === 'function') {
|
|
135
|
-
try {
|
|
136
|
-
const result = options.deobfuscate(content);
|
|
137
|
-
if (result.transforms.length > 0) {
|
|
138
|
-
const deobThreats = analyzeFile(result.code, file, targetPath);
|
|
139
|
-
const existingKeys = new Set(fileThreats.map(t => `${t.type}::${t.message}`));
|
|
140
|
-
for (const dt of deobThreats) {
|
|
141
|
-
if (!existingKeys.has(`${dt.type}::${dt.message}`)) {
|
|
142
|
-
threats.push(dt);
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
} catch { /* deobfuscation failed — skip */ }
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
return threats;
|
|
102
|
+
return analyzeWithDeobfuscation(targetPath, analyzeFile, {
|
|
103
|
+
deobfuscate: options.deobfuscate,
|
|
104
|
+
excludedFiles: EXCLUDED_FILES
|
|
105
|
+
});
|
|
151
106
|
}
|
|
152
107
|
|
|
153
108
|
|
|
@@ -215,11 +170,7 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
215
170
|
let ast;
|
|
216
171
|
|
|
217
172
|
try {
|
|
218
|
-
ast = acorn.parse(content,
|
|
219
|
-
ecmaVersion: 2024,
|
|
220
|
-
sourceType: 'module',
|
|
221
|
-
allowHashBang: true
|
|
222
|
-
});
|
|
173
|
+
ast = acorn.parse(content, ACORN_OPTIONS);
|
|
223
174
|
} catch {
|
|
224
175
|
// AST parse failed — apply regex fallback for known dangerous patterns
|
|
225
176
|
|
|
@@ -254,6 +205,8 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
254
205
|
const workflowPathVars = new Set();
|
|
255
206
|
// Track variables assigned temp/executable file paths
|
|
256
207
|
const execPathVars = new Map();
|
|
208
|
+
// Track variables that alias globalThis/global (e.g. const g = globalThis)
|
|
209
|
+
const globalThisAliases = new Set();
|
|
257
210
|
|
|
258
211
|
/**
|
|
259
212
|
* Extract string value from a node if it's a Literal or TemplateLiteral with no expressions.
|
|
@@ -308,6 +261,13 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
308
261
|
execPathVars.set(node.id.name, strVal);
|
|
309
262
|
}
|
|
310
263
|
|
|
264
|
+
// Track variables that alias globalThis or global (e.g. const g = globalThis)
|
|
265
|
+
if (node.init?.type === 'Identifier' &&
|
|
266
|
+
(node.init.name === 'globalThis' || node.init.name === 'global' ||
|
|
267
|
+
node.init.name === 'window' || node.init.name === 'self')) {
|
|
268
|
+
globalThisAliases.add(node.id.name);
|
|
269
|
+
}
|
|
270
|
+
|
|
311
271
|
// Track variables assigned from path.join containing .github/workflows
|
|
312
272
|
if (node.init?.type === 'CallExpression' && node.init.callee?.type === 'MemberExpression') {
|
|
313
273
|
const obj = node.init.callee.object;
|
|
@@ -719,6 +679,92 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
719
679
|
});
|
|
720
680
|
}
|
|
721
681
|
}
|
|
682
|
+
|
|
683
|
+
// Detect indirect eval/Function via computed property: obj['eval'](code), g['Function'](code)
|
|
684
|
+
// Bypasses getCallName() which only reads .property.name (Identifier), not Literal values
|
|
685
|
+
if (node.callee.type === 'MemberExpression' && node.callee.computed) {
|
|
686
|
+
const prop = node.callee.property;
|
|
687
|
+
if (prop.type === 'Literal' && typeof prop.value === 'string') {
|
|
688
|
+
if (prop.value === 'eval') {
|
|
689
|
+
hasEvalInFile = true;
|
|
690
|
+
threats.push({
|
|
691
|
+
type: 'dangerous_call_eval',
|
|
692
|
+
severity: 'HIGH',
|
|
693
|
+
message: 'Indirect eval via computed property access (obj["eval"]) — evasion technique.',
|
|
694
|
+
file: path.relative(basePath, filePath)
|
|
695
|
+
});
|
|
696
|
+
} else if (prop.value === 'Function') {
|
|
697
|
+
threats.push({
|
|
698
|
+
type: 'dangerous_call_function',
|
|
699
|
+
severity: 'MEDIUM',
|
|
700
|
+
message: 'Indirect Function via computed property access (obj["Function"]) — evasion technique.',
|
|
701
|
+
file: path.relative(basePath, filePath)
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
// Detect computed call on globalThis/global alias with variable property: g[k]()
|
|
706
|
+
// where g = globalThis and k is a variable (not a literal) — dynamic global dispatch
|
|
707
|
+
const obj = node.callee.object;
|
|
708
|
+
if (prop.type === 'Identifier' && obj?.type === 'Identifier' &&
|
|
709
|
+
(globalThisAliases.has(obj.name) || obj.name === 'globalThis' || obj.name === 'global')) {
|
|
710
|
+
hasEvalInFile = true;
|
|
711
|
+
threats.push({
|
|
712
|
+
type: 'dangerous_call_eval',
|
|
713
|
+
severity: 'HIGH',
|
|
714
|
+
message: `Dynamic global dispatch via computed property (${obj.name}[${prop.name}]) — likely indirect eval evasion.`,
|
|
715
|
+
file: path.relative(basePath, filePath)
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Detect indirect eval/Function via sequence expression: (0, eval)(code), (0, Function)(code)
|
|
721
|
+
// Comma operator returns last expression, so (0, eval) === eval but avoids Identifier callee
|
|
722
|
+
if (node.callee.type === 'SequenceExpression') {
|
|
723
|
+
const exprs = node.callee.expressions;
|
|
724
|
+
const last = exprs[exprs.length - 1];
|
|
725
|
+
if (last && last.type === 'Identifier') {
|
|
726
|
+
if (last.name === 'eval') {
|
|
727
|
+
hasEvalInFile = true;
|
|
728
|
+
threats.push({
|
|
729
|
+
type: 'dangerous_call_eval',
|
|
730
|
+
severity: 'HIGH',
|
|
731
|
+
message: 'Indirect eval via sequence expression ((0, eval)) — evasion technique.',
|
|
732
|
+
file: path.relative(basePath, filePath)
|
|
733
|
+
});
|
|
734
|
+
} else if (last.name === 'Function') {
|
|
735
|
+
threats.push({
|
|
736
|
+
type: 'dangerous_call_function',
|
|
737
|
+
severity: 'MEDIUM',
|
|
738
|
+
message: 'Indirect Function via sequence expression ((0, Function)) — evasion technique.',
|
|
739
|
+
file: path.relative(basePath, filePath)
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// Detect crypto.createDecipher/createDecipheriv — encrypted payload pattern (flatmap-stream)
|
|
746
|
+
// Also detect module._compile — in-memory code execution
|
|
747
|
+
if (node.callee.type === 'MemberExpression') {
|
|
748
|
+
const prop = node.callee.property;
|
|
749
|
+
const propName = prop.type === 'Identifier' ? prop.name :
|
|
750
|
+
(prop.type === 'Literal' ? prop.value : null);
|
|
751
|
+
if (propName === 'createDecipher' || propName === 'createDecipheriv') {
|
|
752
|
+
threats.push({
|
|
753
|
+
type: 'crypto_decipher',
|
|
754
|
+
severity: 'HIGH',
|
|
755
|
+
message: `${propName}() detected — runtime decryption of embedded payload (event-stream/flatmap-stream pattern).`,
|
|
756
|
+
file: path.relative(basePath, filePath)
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
if (propName === '_compile') {
|
|
760
|
+
threats.push({
|
|
761
|
+
type: 'module_compile',
|
|
762
|
+
severity: 'CRITICAL',
|
|
763
|
+
message: 'module._compile() detected — executes arbitrary code from string in module context (flatmap-stream pattern).',
|
|
764
|
+
file: path.relative(basePath, filePath)
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
}
|
|
722
768
|
},
|
|
723
769
|
|
|
724
770
|
ImportExpression(node) {
|
package/src/scanner/dataflow.js
CHANGED
|
@@ -2,61 +2,14 @@ const fs = require('fs');
|
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const acorn = require('acorn');
|
|
4
4
|
const walk = require('acorn-walk');
|
|
5
|
-
const {
|
|
6
|
-
|
|
7
|
-
const
|
|
5
|
+
const { getCallName } = require('../utils.js');
|
|
6
|
+
const { ACORN_OPTIONS } = require('../shared/constants.js');
|
|
7
|
+
const { analyzeWithDeobfuscation } = require('../shared/analyze-helper.js');
|
|
8
8
|
|
|
9
9
|
async function analyzeDataFlow(targetPath, options = {}) {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
for (const file of files) {
|
|
14
|
-
const relativePath = path.relative(targetPath, file).replace(/\\/g, '/');
|
|
15
|
-
|
|
16
|
-
if (isDevFile(relativePath)) {
|
|
17
|
-
continue;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
try {
|
|
21
|
-
const stat = fs.statSync(file);
|
|
22
|
-
if (stat.size > MAX_FILE_SIZE) continue;
|
|
23
|
-
} catch { continue; }
|
|
24
|
-
|
|
25
|
-
let content;
|
|
26
|
-
try {
|
|
27
|
-
content = fs.readFileSync(file, 'utf8');
|
|
28
|
-
} catch {
|
|
29
|
-
continue;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// Respect // muaddib-ignore directive in first 5 lines (like eslint-disable)
|
|
33
|
-
const headerLines = content.slice(0, 1024).split('\n').slice(0, 5);
|
|
34
|
-
if (headerLines.some(line => line.includes('muaddib-ignore'))) {
|
|
35
|
-
continue;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// Analyze original code first (preserves obfuscation-detection rules)
|
|
39
|
-
const fileThreats = analyzeFile(content, file, targetPath);
|
|
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
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
return threats;
|
|
10
|
+
return analyzeWithDeobfuscation(targetPath, analyzeFile, {
|
|
11
|
+
deobfuscate: options.deobfuscate
|
|
12
|
+
});
|
|
60
13
|
}
|
|
61
14
|
|
|
62
15
|
function analyzeFile(content, filePath, basePath) {
|
|
@@ -64,12 +17,7 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
64
17
|
let ast;
|
|
65
18
|
|
|
66
19
|
try {
|
|
67
|
-
ast = acorn.parse(content, {
|
|
68
|
-
ecmaVersion: 2024,
|
|
69
|
-
sourceType: 'module',
|
|
70
|
-
allowHashBang: true,
|
|
71
|
-
locations: true
|
|
72
|
-
});
|
|
20
|
+
ast = acorn.parse(content, { ...ACORN_OPTIONS, locations: true });
|
|
73
21
|
} catch {
|
|
74
22
|
return threats;
|
|
75
23
|
}
|
|
@@ -95,7 +43,10 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
95
43
|
const prop = node.init.callee.property;
|
|
96
44
|
if (obj?.type === 'Identifier' && obj.name === 'path' &&
|
|
97
45
|
prop?.type === 'Identifier' && (prop.name === 'join' || prop.name === 'resolve')) {
|
|
98
|
-
if (node.init.arguments.some(a =>
|
|
46
|
+
if (node.init.arguments.some(a =>
|
|
47
|
+
(a.type === 'Identifier' && sensitivePathVars.has(a.name)) ||
|
|
48
|
+
(a.type === 'MemberExpression' && a.object?.type === 'Identifier' && sensitivePathVars.has(a.object.name))
|
|
49
|
+
)) {
|
|
99
50
|
sensitivePathVars.add(node.id.name);
|
|
100
51
|
}
|
|
101
52
|
}
|
|
@@ -241,6 +192,17 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
241
192
|
});
|
|
242
193
|
}
|
|
243
194
|
}
|
|
195
|
+
|
|
196
|
+
// Detect property access to secret key material
|
|
197
|
+
const propName = node.property?.type === 'Identifier' ? node.property.name :
|
|
198
|
+
(node.property?.type === 'Literal' ? node.property.value : null);
|
|
199
|
+
if (propName && ['secretKey', '_secretKey', 'privateKey', '_privateKey', 'mnemonic', 'seedPhrase'].includes(propName)) {
|
|
200
|
+
sources.push({
|
|
201
|
+
type: 'credential_read',
|
|
202
|
+
name: propName,
|
|
203
|
+
line: node.loc?.start?.line
|
|
204
|
+
});
|
|
205
|
+
}
|
|
244
206
|
}
|
|
245
207
|
});
|
|
246
208
|
|
|
@@ -300,7 +262,8 @@ const SENSITIVE_PATH_PATTERNS = [
|
|
|
300
262
|
'.ethereum', '.electrum', '.config/solana', '.exodus',
|
|
301
263
|
'.atomic', '.metamask', '.ledger-live', '.trezor',
|
|
302
264
|
'.bitcoin', '.monero', '.gnupg',
|
|
303
|
-
'_cacache', '.cache/yarn', '.cache/pip'
|
|
265
|
+
'_cacache', '.cache/yarn', '.cache/pip',
|
|
266
|
+
'discord', 'leveldb'
|
|
304
267
|
];
|
|
305
268
|
|
|
306
269
|
function isSensitivePath(val) {
|
|
@@ -327,6 +290,9 @@ function containsSensitiveLiteral(node) {
|
|
|
327
290
|
if (node.type === 'CallExpression' && node.arguments) {
|
|
328
291
|
return node.arguments.some(a => containsSensitiveLiteral(a));
|
|
329
292
|
}
|
|
293
|
+
if (node.type === 'ObjectExpression' && node.properties) {
|
|
294
|
+
return node.properties.some(p => p.value && containsSensitiveLiteral(p.value));
|
|
295
|
+
}
|
|
330
296
|
return false;
|
|
331
297
|
}
|
|
332
298
|
|
|
@@ -346,6 +312,11 @@ function isCredentialPath(arg, sensitivePathVars) {
|
|
|
346
312
|
if (arg.type === 'Identifier' && sensitivePathVars && sensitivePathVars.has(arg.name)) {
|
|
347
313
|
return true;
|
|
348
314
|
}
|
|
315
|
+
// Handle property access on tracked objects: _0x.a where _0x is tracked as sensitive
|
|
316
|
+
if (arg.type === 'MemberExpression' && arg.object?.type === 'Identifier' &&
|
|
317
|
+
sensitivePathVars && sensitivePathVars.has(arg.object.name)) {
|
|
318
|
+
return true;
|
|
319
|
+
}
|
|
349
320
|
// Handle path.join(dir, '.npmrc') or path.join(sshDir, 'id_rsa') where sshDir is tracked
|
|
350
321
|
if (arg.type === 'CallExpression' && arg.callee.type === 'MemberExpression') {
|
|
351
322
|
const obj = arg.callee.object;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const acorn = require('acorn');
|
|
4
4
|
const walk = require('acorn-walk');
|
|
5
|
+
const { ACORN_OPTIONS } = require('../shared/constants.js');
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Lightweight static deobfuscation pre-processor.
|
|
@@ -16,12 +17,7 @@ function deobfuscate(sourceCode) {
|
|
|
16
17
|
// Parse AST — if parsing fails, return source unchanged (fail-safe)
|
|
17
18
|
let ast;
|
|
18
19
|
try {
|
|
19
|
-
ast = acorn.parse(sourceCode, {
|
|
20
|
-
ecmaVersion: 2024,
|
|
21
|
-
sourceType: 'module',
|
|
22
|
-
allowHashBang: true,
|
|
23
|
-
ranges: true
|
|
24
|
-
});
|
|
20
|
+
ast = acorn.parse(sourceCode, { ...ACORN_OPTIONS, ranges: true });
|
|
25
21
|
} catch {
|
|
26
22
|
return { code: sourceCode, transforms };
|
|
27
23
|
}
|
|
@@ -97,6 +93,26 @@ function deobfuscate(sourceCode) {
|
|
|
97
93
|
return;
|
|
98
94
|
}
|
|
99
95
|
|
|
96
|
+
// Buffer.from('...', 'hex').toString() → decoded string
|
|
97
|
+
if (isBufferHexToString(node)) {
|
|
98
|
+
const hexStr = extractBufferHexArg(node);
|
|
99
|
+
if (hexStr === null) return;
|
|
100
|
+
try {
|
|
101
|
+
const decoded = Buffer.from(hexStr, 'hex').toString();
|
|
102
|
+
if (!isPrintable(decoded)) return;
|
|
103
|
+
const before = sourceCode.slice(node.start, node.end);
|
|
104
|
+
const after = quoteString(decoded);
|
|
105
|
+
replacements.push({
|
|
106
|
+
start: node.start,
|
|
107
|
+
end: node.end,
|
|
108
|
+
value: after,
|
|
109
|
+
type: 'hex',
|
|
110
|
+
before
|
|
111
|
+
});
|
|
112
|
+
} catch { /* decode failure — skip */ }
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
100
116
|
// atob('...') → decoded string
|
|
101
117
|
if (isAtobCall(node)) {
|
|
102
118
|
const b64str = node.arguments[0]?.value;
|
|
@@ -177,12 +193,7 @@ function propagateConsts(sourceCode) {
|
|
|
177
193
|
const transforms = [];
|
|
178
194
|
let ast;
|
|
179
195
|
try {
|
|
180
|
-
ast = acorn.parse(sourceCode, {
|
|
181
|
-
ecmaVersion: 2024,
|
|
182
|
-
sourceType: 'module',
|
|
183
|
-
allowHashBang: true,
|
|
184
|
-
ranges: true
|
|
185
|
-
});
|
|
196
|
+
ast = acorn.parse(sourceCode, { ...ACORN_OPTIONS, ranges: true });
|
|
186
197
|
} catch {
|
|
187
198
|
return { code: sourceCode, transforms };
|
|
188
199
|
}
|
|
@@ -296,12 +307,7 @@ function foldConcatsOnly(sourceCode) {
|
|
|
296
307
|
const transforms = [];
|
|
297
308
|
let ast;
|
|
298
309
|
try {
|
|
299
|
-
ast = acorn.parse(sourceCode, {
|
|
300
|
-
ecmaVersion: 2024,
|
|
301
|
-
sourceType: 'module',
|
|
302
|
-
allowHashBang: true,
|
|
303
|
-
ranges: true
|
|
304
|
-
});
|
|
310
|
+
ast = acorn.parse(sourceCode, { ...ACORN_OPTIONS, ranges: true });
|
|
305
311
|
} catch {
|
|
306
312
|
return { code: sourceCode, transforms };
|
|
307
313
|
}
|
|
@@ -430,6 +436,34 @@ function extractBufferBase64Arg(node) {
|
|
|
430
436
|
return inner.arguments[0].value;
|
|
431
437
|
}
|
|
432
438
|
|
|
439
|
+
/**
|
|
440
|
+
* Check if node is Buffer.from('...', 'hex').toString()
|
|
441
|
+
*/
|
|
442
|
+
function isBufferHexToString(node) {
|
|
443
|
+
if (node.type !== 'CallExpression') return false;
|
|
444
|
+
const callee = node.callee;
|
|
445
|
+
if (callee.type !== 'MemberExpression') return false;
|
|
446
|
+
if (callee.property?.type !== 'Identifier' || callee.property.name !== 'toString') return false;
|
|
447
|
+
const inner = callee.object;
|
|
448
|
+
if (inner?.type !== 'CallExpression') return false;
|
|
449
|
+
const innerCallee = inner.callee;
|
|
450
|
+
if (innerCallee?.type !== 'MemberExpression') return false;
|
|
451
|
+
if (innerCallee.object?.type !== 'Identifier' || innerCallee.object.name !== 'Buffer') return false;
|
|
452
|
+
if (innerCallee.property?.type !== 'Identifier' || innerCallee.property.name !== 'from') return false;
|
|
453
|
+
if (inner.arguments.length < 2) return false;
|
|
454
|
+
if (inner.arguments[1]?.type !== 'Literal' || inner.arguments[1].value !== 'hex') return false;
|
|
455
|
+
if (inner.arguments[0]?.type !== 'Literal' || typeof inner.arguments[0].value !== 'string') return false;
|
|
456
|
+
return true;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Extract the hex string argument from Buffer.from(str, 'hex').toString()
|
|
461
|
+
*/
|
|
462
|
+
function extractBufferHexArg(node) {
|
|
463
|
+
const inner = node.callee.object;
|
|
464
|
+
return inner.arguments[0].value;
|
|
465
|
+
}
|
|
466
|
+
|
|
433
467
|
/**
|
|
434
468
|
* Check if node is atob('...')
|
|
435
469
|
*/
|
package/src/scanner/entropy.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
|
-
const { findFiles } = require('../utils.js');
|
|
3
|
+
const { findFiles, forEachSafeFile } = require('../utils.js');
|
|
4
4
|
|
|
5
5
|
const ENTROPY_EXCLUDED_DIRS = ['.git', '.muaddib-cache', '__compiled__', '__tests__', '__test__', 'dist', 'build'];
|
|
6
|
-
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
|
7
6
|
|
|
8
7
|
// File patterns to skip (compiled/minified/bundled)
|
|
9
8
|
const SKIP_FILE_PATTERNS = ['.min.js', '.bundle.js', '.prod.js'];
|
|
@@ -203,29 +202,12 @@ function detectObfuscationPatterns(content, relativePath) {
|
|
|
203
202
|
function scanEntropy(targetPath, options = {}) {
|
|
204
203
|
const threats = [];
|
|
205
204
|
const stringThreshold = options.entropyThreshold || STRING_ENTROPY_MEDIUM;
|
|
206
|
-
const files = findFiles(targetPath, { extensions: ['.js'], excludedDirs: ENTROPY_EXCLUDED_DIRS });
|
|
207
|
-
|
|
208
|
-
for (const file of files) {
|
|
209
|
-
// Skip files matching compiled/minified patterns
|
|
210
|
-
if (shouldSkipFile(file)) continue;
|
|
211
|
-
|
|
212
|
-
// Size guard
|
|
213
|
-
try {
|
|
214
|
-
const stat = fs.statSync(file);
|
|
215
|
-
if (stat.size > MAX_FILE_SIZE) continue;
|
|
216
|
-
} catch {
|
|
217
|
-
continue;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
let content;
|
|
221
|
-
try {
|
|
222
|
-
content = fs.readFileSync(file, 'utf8');
|
|
223
|
-
} catch {
|
|
224
|
-
continue;
|
|
225
|
-
}
|
|
205
|
+
const files = findFiles(targetPath, { extensions: ['.js', '.mjs', '.cjs'], excludedDirs: ENTROPY_EXCLUDED_DIRS });
|
|
226
206
|
|
|
207
|
+
const safeFiles = files.filter(f => !shouldSkipFile(f));
|
|
208
|
+
forEachSafeFile(safeFiles, (file, content) => {
|
|
227
209
|
// Skip files containing source maps (legitimate compiled output)
|
|
228
|
-
if (hasSourceMap(content))
|
|
210
|
+
if (hasSourceMap(content)) return;
|
|
229
211
|
|
|
230
212
|
const relativePath = path.relative(targetPath, file);
|
|
231
213
|
|
|
@@ -252,7 +234,7 @@ function scanEntropy(targetPath, options = {}) {
|
|
|
252
234
|
});
|
|
253
235
|
}
|
|
254
236
|
}
|
|
255
|
-
}
|
|
237
|
+
});
|
|
256
238
|
|
|
257
239
|
return threats;
|
|
258
240
|
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
3
|
|
|
4
|
+
const { MAX_FILE_SIZE } = require('../shared/constants.js');
|
|
5
|
+
|
|
4
6
|
const YAML_EXTENSIONS = ['.yml', '.yaml'];
|
|
5
|
-
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
|
6
7
|
const MAX_DEPTH = 10;
|
|
7
8
|
|
|
8
9
|
function scanGitHubActions(targetPath) {
|
package/src/scanner/hash.js
CHANGED
|
@@ -3,11 +3,11 @@ const path = require('path');
|
|
|
3
3
|
const nodeCrypto = require('crypto');
|
|
4
4
|
const { loadCachedIOCs } = require('../ioc/updater.js');
|
|
5
5
|
const { findFiles } = require('../utils.js');
|
|
6
|
+
const { MAX_FILE_SIZE } = require('../shared/constants.js');
|
|
6
7
|
|
|
7
8
|
// Hash cache: filePath -> { hash, mtime }
|
|
8
9
|
const hashCache = new Map();
|
|
9
10
|
const MAX_CACHE_SIZE = 10000;
|
|
10
|
-
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
|
11
11
|
|
|
12
12
|
async function scanHashes(targetPath) {
|
|
13
13
|
const threats = [];
|
|
@@ -2,13 +2,13 @@ const fs = require('fs');
|
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const acorn = require('acorn');
|
|
4
4
|
const { findFiles } = require('../utils');
|
|
5
|
+
const { ACORN_OPTIONS: BASE_ACORN_OPTIONS } = require('../shared/constants.js');
|
|
5
6
|
|
|
6
7
|
// --- Sensitive source patterns ---
|
|
7
8
|
const SENSITIVE_MODULES = new Set(['fs', 'child_process', 'dns', 'os']);
|
|
8
9
|
|
|
9
10
|
const ACORN_OPTIONS = {
|
|
10
|
-
|
|
11
|
-
sourceType: 'module',
|
|
11
|
+
...BASE_ACORN_OPTIONS,
|
|
12
12
|
allowReturnOutsideFunction: true,
|
|
13
13
|
allowImportExportEverywhere: true,
|
|
14
14
|
};
|
|
@@ -32,7 +32,7 @@ const SINK_INSTANCE_METHODS = new Set(['connect', 'write', 'send']);
|
|
|
32
32
|
function buildModuleGraph(packagePath) {
|
|
33
33
|
const graph = {};
|
|
34
34
|
const files = findFiles(packagePath, {
|
|
35
|
-
extensions: ['.js'],
|
|
35
|
+
extensions: ['.js', '.mjs', '.cjs'],
|
|
36
36
|
excludedDirs: ['node_modules', '.git'],
|
|
37
37
|
});
|
|
38
38
|
for (const absFile of files) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const { NPM_PACKAGE_REGEX } = require('../shared/constants.js');
|
|
2
|
+
const { debugLog } = require('../utils.js');
|
|
2
3
|
|
|
3
4
|
const REGISTRY_URL = 'https://registry.npmjs.org';
|
|
4
5
|
const DOWNLOADS_URL = 'https://api.npmjs.org/downloads/point/last-week';
|
|
@@ -44,13 +45,13 @@ async function fetchWithRetry(url) {
|
|
|
44
45
|
// 404 = package doesn't exist
|
|
45
46
|
if (response.status === 404) {
|
|
46
47
|
// Drain response body to free resources
|
|
47
|
-
try { await response.text(); } catch {}
|
|
48
|
+
try { await response.text(); } catch (e) { debugLog('response drain failed:', e.message); }
|
|
48
49
|
return null;
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
// 429 = rate limit, respect Retry-After header (capped at 30s)
|
|
52
53
|
if (response.status === 429) {
|
|
53
|
-
try { await response.text(); } catch {}
|
|
54
|
+
try { await response.text(); } catch (e) { debugLog('response drain failed:', e.message); }
|
|
54
55
|
const retryAfter = parseInt(response.headers.get('retry-after'), 10);
|
|
55
56
|
const delay = Math.min(retryAfter && retryAfter > 0 ? retryAfter * 1000 : 2000, 30000);
|
|
56
57
|
await new Promise(r => setTimeout(r, delay));
|
|
@@ -59,7 +60,7 @@ async function fetchWithRetry(url) {
|
|
|
59
60
|
|
|
60
61
|
if (!response.ok) {
|
|
61
62
|
// Drain response body on errors
|
|
62
|
-
try { await response.text(); } catch {}
|
|
63
|
+
try { await response.text(); } catch (e) { debugLog('response drain failed:', e.message); }
|
|
63
64
|
return null;
|
|
64
65
|
}
|
|
65
66
|
|
|
@@ -1,30 +1,15 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
|
-
const { findFiles } = require('../utils.js');
|
|
3
|
+
const { findFiles, forEachSafeFile } = require('../utils.js');
|
|
4
4
|
|
|
5
5
|
// node_modules NOT excluded: detect obfuscated code in dependencies
|
|
6
6
|
const OBF_EXCLUDED_DIRS = ['.git', '.muaddib-cache'];
|
|
7
|
-
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
|
8
7
|
|
|
9
8
|
function detectObfuscation(targetPath) {
|
|
10
9
|
const threats = [];
|
|
11
|
-
const files = findFiles(targetPath, { extensions: ['.js'], excludedDirs: OBF_EXCLUDED_DIRS });
|
|
12
|
-
|
|
13
|
-
for (const file of files) {
|
|
14
|
-
// Skip files exceeding MAX_FILE_SIZE to avoid memory issues
|
|
15
|
-
try {
|
|
16
|
-
const stat = fs.statSync(file);
|
|
17
|
-
if (stat.size > MAX_FILE_SIZE) continue;
|
|
18
|
-
} catch {
|
|
19
|
-
continue;
|
|
20
|
-
}
|
|
10
|
+
const files = findFiles(targetPath, { extensions: ['.js', '.mjs', '.cjs'], excludedDirs: OBF_EXCLUDED_DIRS });
|
|
21
11
|
|
|
22
|
-
|
|
23
|
-
try {
|
|
24
|
-
content = fs.readFileSync(file, 'utf8');
|
|
25
|
-
} catch {
|
|
26
|
-
continue; // Skip unreadable files
|
|
27
|
-
}
|
|
12
|
+
forEachSafeFile(files, (file, content) => {
|
|
28
13
|
const relativePath = path.relative(targetPath, file);
|
|
29
14
|
|
|
30
15
|
const signals = [];
|
|
@@ -96,7 +81,7 @@ function detectObfuscation(targetPath) {
|
|
|
96
81
|
file: relativePath
|
|
97
82
|
});
|
|
98
83
|
}
|
|
99
|
-
}
|
|
84
|
+
});
|
|
100
85
|
|
|
101
86
|
return threats;
|
|
102
87
|
}
|