muaddib-scanner 2.10.2 → 2.10.3
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/package.json +1 -1
- package/src/canary-tokens.js +52 -0
- package/src/index.js +29 -0
- package/src/response/playbooks.js +5 -0
- package/src/rules/index.js +12 -0
- package/src/sandbox/index.js +11 -0
- package/src/scanner/ast-detectors.js +136 -8
- package/src/scanner/ast.js +3 -1
package/bin/muaddib.js
CHANGED
|
@@ -34,6 +34,7 @@ let noDeobfuscate = false;
|
|
|
34
34
|
let noModuleGraph = false;
|
|
35
35
|
let noReachability = false;
|
|
36
36
|
let configPath = null;
|
|
37
|
+
let autoSandbox = false;
|
|
37
38
|
let feedLimit = null;
|
|
38
39
|
let feedSeverity = null;
|
|
39
40
|
let feedSince = null;
|
|
@@ -137,6 +138,8 @@ for (let i = 0; i < options.length; i++) {
|
|
|
137
138
|
}
|
|
138
139
|
configPath = cfgPath;
|
|
139
140
|
i++;
|
|
141
|
+
} else if (options[i] === '--auto-sandbox') {
|
|
142
|
+
autoSandbox = true;
|
|
140
143
|
} else if (options[i] === '--temporal') {
|
|
141
144
|
temporalMode = true;
|
|
142
145
|
} else if (options[i] === '--limit') {
|
|
@@ -429,6 +432,7 @@ const helpText = `
|
|
|
429
432
|
--temporal-publish Detect publish frequency anomalies (bursts, dormant spikes)
|
|
430
433
|
--temporal-maintainer Detect maintainer changes (new maintainer, account takeover)
|
|
431
434
|
--temporal-full All temporal analyses (lifecycle + AST + publish + maintainer)
|
|
435
|
+
--auto-sandbox Auto-trigger sandbox when static scan score >= 20 (requires Docker)
|
|
432
436
|
--no-canary Disable honey token injection in sandbox
|
|
433
437
|
--no-deobfuscate Disable deobfuscation pre-processing
|
|
434
438
|
--no-module-graph Disable cross-file dataflow analysis
|
|
@@ -482,7 +486,8 @@ if (command === 'version' || command === '--version' || command === '-v') {
|
|
|
482
486
|
noDeobfuscate: noDeobfuscate,
|
|
483
487
|
noModuleGraph: noModuleGraph,
|
|
484
488
|
noReachability: noReachability,
|
|
485
|
-
configPath: configPath
|
|
489
|
+
configPath: configPath,
|
|
490
|
+
autoSandbox: autoSandbox
|
|
486
491
|
}).then(exitCode => {
|
|
487
492
|
process.exit(exitCode);
|
|
488
493
|
}).catch(err => {
|
package/package.json
CHANGED
package/src/canary-tokens.js
CHANGED
|
@@ -71,6 +71,55 @@ function createCanaryNpmrc(tokens) {
|
|
|
71
71
|
return `//registry.npmjs.org/:_authToken=${tokens.NPM_AUTH_TOKEN}\n`;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Generate fake AWS credentials file content.
|
|
76
|
+
* Format matches ~/.aws/credentials (INI format, format-valid key IDs).
|
|
77
|
+
* @param {Record<string, string>} tokens - The token map from generateCanaryTokens()
|
|
78
|
+
* @returns {string} AWS credentials file content
|
|
79
|
+
*/
|
|
80
|
+
function createCanaryAwsCredentials(tokens) {
|
|
81
|
+
return [
|
|
82
|
+
'[default]',
|
|
83
|
+
`aws_access_key_id = ${tokens.AWS_ACCESS_KEY_ID}`,
|
|
84
|
+
`aws_secret_access_key = ${tokens.AWS_SECRET_ACCESS_KEY}`,
|
|
85
|
+
'region = us-east-1',
|
|
86
|
+
''
|
|
87
|
+
].join('\n');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Generate a fake SSH private key (Ed25519 format).
|
|
92
|
+
* The key is structurally valid PEM but cryptographically meaningless.
|
|
93
|
+
* Malware that reads ~/.ssh/id_rsa or id_ed25519 will exfiltrate this.
|
|
94
|
+
* @returns {string} Fake SSH private key content
|
|
95
|
+
*/
|
|
96
|
+
function createCanarySshKey() {
|
|
97
|
+
const fakeKeyData = crypto.randomBytes(64).toString('base64');
|
|
98
|
+
return [
|
|
99
|
+
'-----BEGIN OPENSSH PRIVATE KEY-----',
|
|
100
|
+
fakeKeyData.substring(0, 70),
|
|
101
|
+
fakeKeyData.substring(0, 70),
|
|
102
|
+
'-----END OPENSSH PRIVATE KEY-----',
|
|
103
|
+
''
|
|
104
|
+
].join('\n');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Generate a fake .gitconfig with user identity.
|
|
109
|
+
* Malware fingerprinting the developer will exfiltrate this.
|
|
110
|
+
* @returns {string} Fake .gitconfig content
|
|
111
|
+
*/
|
|
112
|
+
function createCanaryGitconfig() {
|
|
113
|
+
return [
|
|
114
|
+
'[user]',
|
|
115
|
+
'\tname = John Developer',
|
|
116
|
+
'\temail = john.dev@company-internal.example.com',
|
|
117
|
+
'[credential]',
|
|
118
|
+
'\thelper = store',
|
|
119
|
+
''
|
|
120
|
+
].join('\n');
|
|
121
|
+
}
|
|
122
|
+
|
|
74
123
|
/**
|
|
75
124
|
* Search for canary tokens in network logs from sandbox.
|
|
76
125
|
* Network log structure matches sandbox.js report.network:
|
|
@@ -199,6 +248,9 @@ module.exports = {
|
|
|
199
248
|
generateCanaryTokens,
|
|
200
249
|
createCanaryEnvFile,
|
|
201
250
|
createCanaryNpmrc,
|
|
251
|
+
createCanaryAwsCredentials,
|
|
252
|
+
createCanarySshKey,
|
|
253
|
+
createCanaryGitconfig,
|
|
202
254
|
detectCanaryExfiltration,
|
|
203
255
|
detectCanaryInOutput
|
|
204
256
|
};
|
package/src/index.js
CHANGED
|
@@ -523,6 +523,35 @@ async function run(targetPath, options = {}) {
|
|
|
523
523
|
threats.push(...temporalThreats);
|
|
524
524
|
}
|
|
525
525
|
|
|
526
|
+
// Auto-sandbox: trigger sandbox analysis when static scan detects threats.
|
|
527
|
+
// Preliminary score estimate: count CRITICAL/HIGH threats as a quick heuristic.
|
|
528
|
+
// Only when --auto-sandbox flag is set, no explicit sandboxResult, and Docker available.
|
|
529
|
+
if (options.autoSandbox && !options.sandboxResult) {
|
|
530
|
+
const critCount = threats.filter(t => t.severity === 'CRITICAL').length;
|
|
531
|
+
const highCount = threats.filter(t => t.severity === 'HIGH').length;
|
|
532
|
+
const prelimScore = Math.min(100, critCount * 25 + highCount * 10);
|
|
533
|
+
if (prelimScore >= 20) {
|
|
534
|
+
try {
|
|
535
|
+
const { isDockerAvailable, buildSandboxImage, runSandbox } = require('./sandbox/index.js');
|
|
536
|
+
if (isDockerAvailable()) {
|
|
537
|
+
console.log(`\n[AUTO-SANDBOX] Preliminary score ~${prelimScore} >= 20 — triggering sandbox analysis...`);
|
|
538
|
+
const built = await buildSandboxImage();
|
|
539
|
+
if (built) {
|
|
540
|
+
const sbResult = await runSandbox(targetPath, { local: true, strict: false });
|
|
541
|
+
if (sbResult && Array.isArray(sbResult.findings)) {
|
|
542
|
+
options.sandboxResult = sbResult;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
} else {
|
|
546
|
+
debugLog('[AUTO-SANDBOX] Docker not available — skipping sandbox');
|
|
547
|
+
}
|
|
548
|
+
} catch (e) {
|
|
549
|
+
debugLog('[AUTO-SANDBOX] Error:', e && e.message);
|
|
550
|
+
// Graceful fallback — sandbox is best-effort
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
526
555
|
// Sandbox integration
|
|
527
556
|
let sandboxData = null;
|
|
528
557
|
if (options.sandboxResult && Array.isArray(options.sandboxResult.findings)) {
|
|
@@ -585,6 +585,11 @@ const PLAYBOOKS = {
|
|
|
585
585
|
'Le malware est cache derriere une indirection: package.json → node setup.js → payload malveillant. ' +
|
|
586
586
|
'NE PAS installer. Supprimer le package immediatement. Auditer le fichier reference.',
|
|
587
587
|
|
|
588
|
+
uncaught_exception_exfil:
|
|
589
|
+
'CRITIQUE: Exfiltration silencieuse via process.on("uncaughtException"). ' +
|
|
590
|
+
'Le handler intercepte les erreurs pour envoyer les credentials (process.env) a un serveur externe. ' +
|
|
591
|
+
'Regenerer immediatement tous les secrets. Supprimer le package. Auditer les connexions sortantes.',
|
|
592
|
+
|
|
588
593
|
websocket_credential_exfil:
|
|
589
594
|
'CRITIQUE: Exfiltration de credentials via canal non-HTTP (WebSocket, MQTT, Socket.io). ' +
|
|
590
595
|
'Les proxies HTTP ne detectent pas ce trafic. Regenerer immediatement tous les secrets exposes. ' +
|
package/src/rules/index.js
CHANGED
|
@@ -1719,6 +1719,18 @@ const RULES = {
|
|
|
1719
1719
|
],
|
|
1720
1720
|
mitre: 'T1041'
|
|
1721
1721
|
},
|
|
1722
|
+
uncaught_exception_exfil: {
|
|
1723
|
+
id: 'MUADDIB-COMPOUND-008',
|
|
1724
|
+
name: 'Uncaught Exception Handler Credential Exfiltration',
|
|
1725
|
+
severity: 'CRITICAL',
|
|
1726
|
+
confidence: 'high',
|
|
1727
|
+
description: 'process.on("uncaughtException") combine avec acces aux variables d\'environnement sensibles et appel reseau. Technique d\'exfiltration silencieuse: le handler intercepte les erreurs pour envoyer les credentials a un serveur externe sans interruption du processus.',
|
|
1728
|
+
references: [
|
|
1729
|
+
'https://attack.mitre.org/techniques/T1041/',
|
|
1730
|
+
'https://nodejs.org/api/process.html#event-uncaughtexception'
|
|
1731
|
+
],
|
|
1732
|
+
mitre: 'T1041'
|
|
1733
|
+
},
|
|
1722
1734
|
suspicious_module_sink: {
|
|
1723
1735
|
id: 'MUADDIB-FLOW-005',
|
|
1724
1736
|
name: 'Non-HTTP Network Module Sink',
|
package/src/sandbox/index.js
CHANGED
|
@@ -6,6 +6,9 @@ const {
|
|
|
6
6
|
generateCanaryTokens,
|
|
7
7
|
createCanaryEnvFile,
|
|
8
8
|
createCanaryNpmrc,
|
|
9
|
+
createCanaryAwsCredentials,
|
|
10
|
+
createCanarySshKey,
|
|
11
|
+
createCanaryGitconfig,
|
|
9
12
|
detectCanaryExfiltration,
|
|
10
13
|
detectCanaryInOutput
|
|
11
14
|
} = require('../canary-tokens.js');
|
|
@@ -154,10 +157,15 @@ async function runSingleSandbox(packageName, options = {}) {
|
|
|
154
157
|
let timedOut = false;
|
|
155
158
|
const containerName = `npm-audit-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
|
|
156
159
|
|
|
160
|
+
// Realistic hostname to evade sandbox detection (T1497.001)
|
|
161
|
+
// Default Docker hostname is a 12-char hex hash — easily fingerprinted.
|
|
162
|
+
const fakeHostname = `dev-laptop-${crypto.randomBytes(2).toString('hex')}`;
|
|
163
|
+
|
|
157
164
|
const dockerArgs = [
|
|
158
165
|
'run',
|
|
159
166
|
'--rm',
|
|
160
167
|
`--name=${containerName}`,
|
|
168
|
+
`--hostname=${fakeHostname}`,
|
|
161
169
|
'--network=bridge',
|
|
162
170
|
'--memory=512m',
|
|
163
171
|
'--cpus=1',
|
|
@@ -173,6 +181,9 @@ async function runSingleSandbox(packageName, options = {}) {
|
|
|
173
181
|
// Also inject canary file contents as env vars for the entrypoint to write
|
|
174
182
|
dockerArgs.push('-e', `CANARY_ENV_CONTENT=${createCanaryEnvFile(canaryTokens).replace(/\r?\n/g, '\\n')}`);
|
|
175
183
|
dockerArgs.push('-e', `CANARY_NPMRC_CONTENT=${createCanaryNpmrc(canaryTokens).replace(/\r?\n/g, '\\n')}`);
|
|
184
|
+
dockerArgs.push('-e', `CANARY_AWS_CONTENT=${createCanaryAwsCredentials(canaryTokens).replace(/\r?\n/g, '\\n')}`);
|
|
185
|
+
dockerArgs.push('-e', `CANARY_SSH_KEY=${createCanarySshKey().replace(/\r?\n/g, '\\n')}`);
|
|
186
|
+
dockerArgs.push('-e', `CANARY_GITCONFIG=${createCanaryGitconfig().replace(/\r?\n/g, '\\n')}`);
|
|
176
187
|
}
|
|
177
188
|
|
|
178
189
|
// Inject time offset (preload.js deferred to entry point in sandbox-runner.sh)
|
|
@@ -537,7 +537,16 @@ function handleVariableDeclarator(node, ctx) {
|
|
|
537
537
|
innerCall.arguments?.length >= 1) {
|
|
538
538
|
const arg = innerCall.arguments[0];
|
|
539
539
|
if (arg.type === 'FunctionExpression' && (arg.async || arg.generator)) {
|
|
540
|
+
const kind = arg.async ? 'AsyncFunction' : 'GeneratorFunction';
|
|
540
541
|
ctx.evalAliases.set(node.id.name, 'Function');
|
|
542
|
+
// Emit CRITICAL at declaration — extracting constructor via prototype chain is never benign
|
|
543
|
+
ctx.hasDynamicExec = true;
|
|
544
|
+
ctx.threats.push({
|
|
545
|
+
type: 'dangerous_constructor',
|
|
546
|
+
severity: 'CRITICAL',
|
|
547
|
+
message: `${kind} constructor extracted via Object.getPrototypeOf() into "${node.id.name}" — prototype chain code execution evasion.`,
|
|
548
|
+
file: ctx.relFile
|
|
549
|
+
});
|
|
541
550
|
}
|
|
542
551
|
}
|
|
543
552
|
}
|
|
@@ -664,6 +673,21 @@ function handleVariableDeclarator(node, ctx) {
|
|
|
664
673
|
}
|
|
665
674
|
}
|
|
666
675
|
|
|
676
|
+
// Audit v3 bypass fix: Array destructuring eval alias: const [fn] = [eval]
|
|
677
|
+
if (node.id?.type === 'ArrayPattern' && node.init?.type === 'ArrayExpression') {
|
|
678
|
+
const elements = node.init.elements;
|
|
679
|
+
const patterns = node.id.elements;
|
|
680
|
+
for (let i = 0; i < Math.min(elements?.length || 0, patterns?.length || 0); i++) {
|
|
681
|
+
const el = elements[i];
|
|
682
|
+
const pat = patterns[i];
|
|
683
|
+
if (el?.type === 'Identifier' && pat?.type === 'Identifier') {
|
|
684
|
+
if (el.name === 'eval' || el.name === 'Function') {
|
|
685
|
+
ctx.evalAliases.set(pat.name, el.name);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
667
691
|
// Audit v3 B3: Destructuring of require('module') → track _load as direct function alias
|
|
668
692
|
if (node.id?.type === 'ObjectPattern' &&
|
|
669
693
|
node.init?.type === 'CallExpression') {
|
|
@@ -1398,10 +1422,21 @@ function handleCallExpression(node, ctx) {
|
|
|
1398
1422
|
} else {
|
|
1399
1423
|
ctx.hasEvalInFile = true;
|
|
1400
1424
|
ctx.hasDynamicExec = true;
|
|
1425
|
+
// Audit v3: elevate to CRITICAL when argument contains dangerous API calls
|
|
1426
|
+
let aliasSev = 'HIGH';
|
|
1427
|
+
let aliasMsg = `Indirect ${aliased} via alias "${node.callee.name}" — eval wrapper evasion.`;
|
|
1428
|
+
if (node.arguments.length >= 1) {
|
|
1429
|
+
const aliasArg = node.arguments[0];
|
|
1430
|
+
if (aliasArg?.type === 'Literal' && typeof aliasArg.value === 'string' &&
|
|
1431
|
+
/\b(require|import|exec|execSync|spawn|child_process|process\.env)\b/.test(aliasArg.value)) {
|
|
1432
|
+
aliasSev = 'CRITICAL';
|
|
1433
|
+
aliasMsg = `Indirect ${aliased} via alias "${node.callee.name}" with dangerous payload: "${aliasArg.value.substring(0, 80)}" — eval evasion + code execution.`;
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1401
1436
|
ctx.threats.push({
|
|
1402
1437
|
type: aliased === 'eval' ? 'dangerous_call_eval' : 'dangerous_call_function',
|
|
1403
|
-
severity:
|
|
1404
|
-
message:
|
|
1438
|
+
severity: aliasSev,
|
|
1439
|
+
message: aliasMsg,
|
|
1405
1440
|
file: ctx.relFile
|
|
1406
1441
|
});
|
|
1407
1442
|
}
|
|
@@ -1579,10 +1614,19 @@ function handleCallExpression(node, ctx) {
|
|
|
1579
1614
|
firstArg.type === 'TemplateLiteral') {
|
|
1580
1615
|
ctx.hasEvalInFile = true;
|
|
1581
1616
|
ctx.hasDynamicExec = true;
|
|
1617
|
+
// Audit v3: elevate to CRITICAL when string contains dangerous API calls
|
|
1618
|
+
let timerSeverity = 'HIGH';
|
|
1619
|
+
let timerMsg = `${callName}() with string argument — eval equivalent, executes the string as code.`;
|
|
1620
|
+
if (firstArg.type === 'Literal' && typeof firstArg.value === 'string') {
|
|
1621
|
+
if (/\b(require|import|exec|execSync|spawn|child_process|\.readFile|\.writeFile|process\.env|\.homedir)\b/.test(firstArg.value)) {
|
|
1622
|
+
timerSeverity = 'CRITICAL';
|
|
1623
|
+
timerMsg = `${callName}() with dangerous API in string: "${firstArg.value.substring(0, 80)}" — eval equivalent code execution.`;
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1582
1626
|
ctx.threats.push({
|
|
1583
1627
|
type: 'dangerous_call_eval',
|
|
1584
|
-
severity:
|
|
1585
|
-
message:
|
|
1628
|
+
severity: timerSeverity,
|
|
1629
|
+
message: timerMsg,
|
|
1586
1630
|
file: ctx.relFile
|
|
1587
1631
|
});
|
|
1588
1632
|
}
|
|
@@ -1929,6 +1973,24 @@ function handleCallExpression(node, ctx) {
|
|
|
1929
1973
|
}
|
|
1930
1974
|
}
|
|
1931
1975
|
}
|
|
1976
|
+
|
|
1977
|
+
// Audit v3 bypass fix: .constructor.constructor('code')() — double constructor chain
|
|
1978
|
+
// Any expression.constructor.constructor is traversing to Function constructor
|
|
1979
|
+
if (obj?.type === 'MemberExpression') {
|
|
1980
|
+
const innerPropName = obj.computed
|
|
1981
|
+
? (obj.property?.type === 'Literal' ? String(obj.property.value) : null)
|
|
1982
|
+
: (obj.property?.type === 'Identifier' ? obj.property.name : null);
|
|
1983
|
+
if (innerPropName === 'constructor') {
|
|
1984
|
+
ctx.hasEvalInFile = true;
|
|
1985
|
+
ctx.hasDynamicExec = true;
|
|
1986
|
+
ctx.threats.push({
|
|
1987
|
+
type: 'dangerous_constructor',
|
|
1988
|
+
severity: 'CRITICAL',
|
|
1989
|
+
message: 'Constructor chain traversal: .constructor.constructor() — accesses Function constructor via prototype chain.',
|
|
1990
|
+
file: ctx.relFile
|
|
1991
|
+
});
|
|
1992
|
+
}
|
|
1993
|
+
}
|
|
1932
1994
|
}
|
|
1933
1995
|
|
|
1934
1996
|
// SANDWORM_MODE: Track writeFileSync/writeFile to temp paths
|
|
@@ -1980,17 +2042,45 @@ function handleCallExpression(node, ctx) {
|
|
|
1980
2042
|
}
|
|
1981
2043
|
}
|
|
1982
2044
|
|
|
2045
|
+
// Audit v3: JSON.stringify(process.env) — bulk env serialization = env enumeration
|
|
2046
|
+
if (node.callee.type === 'MemberExpression' &&
|
|
2047
|
+
node.callee.object?.type === 'Identifier' && node.callee.object.name === 'JSON' &&
|
|
2048
|
+
node.callee.property?.type === 'Identifier' && node.callee.property.name === 'stringify' &&
|
|
2049
|
+
node.arguments.length >= 1) {
|
|
2050
|
+
const strArg = node.arguments[0];
|
|
2051
|
+
if (strArg.type === 'MemberExpression' &&
|
|
2052
|
+
strArg.object?.type === 'Identifier' && strArg.object.name === 'process' &&
|
|
2053
|
+
strArg.property?.type === 'Identifier' && strArg.property.name === 'env') {
|
|
2054
|
+
ctx.hasEnvEnumeration = true;
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
|
|
1983
2058
|
// Batch 1: vm.* code execution — vm.runInThisContext, vm.runInNewContext, vm.compileFunction, vm.Script
|
|
1984
2059
|
if (node.callee.type === 'MemberExpression' && node.callee.property?.type === 'Identifier') {
|
|
1985
2060
|
const vmMethod = node.callee.property.name;
|
|
1986
2061
|
if (['runInThisContext', 'runInNewContext', 'compileFunction'].includes(vmMethod)) {
|
|
1987
|
-
//
|
|
2062
|
+
// Audit v3: elevate to CRITICAL when argument contains dangerous API calls
|
|
2063
|
+
let vmSeverity = 'HIGH';
|
|
2064
|
+
let vmMsg = `vm.${vmMethod}() — dynamic code execution via Node.js vm module bypasses eval detection.`;
|
|
2065
|
+
const vmArg = node.arguments?.[0];
|
|
2066
|
+
let vmContent = '';
|
|
2067
|
+
if (vmArg?.type === 'Literal' && typeof vmArg.value === 'string') {
|
|
2068
|
+
vmContent = vmArg.value;
|
|
2069
|
+
} else if (vmArg?.type === 'Identifier' && ctx.stringVarValues?.has(vmArg.name)) {
|
|
2070
|
+
vmContent = ctx.stringVarValues.get(vmArg.name);
|
|
2071
|
+
}
|
|
2072
|
+
if (/\b(require|import|exec|execSync|spawn|child_process|process\.env)\b/.test(vmContent)) {
|
|
2073
|
+
vmSeverity = 'CRITICAL';
|
|
2074
|
+
ctx.hasDynamicExec = true;
|
|
2075
|
+
vmMsg = `vm.${vmMethod}() with dangerous API in code: "${vmContent.substring(0, 80)}" — vm module code execution bypass.`;
|
|
2076
|
+
}
|
|
2077
|
+
// NOTE: Do NOT set ctx.hasDynamicExec for generic vm.* calls — legitimately used by bundlers
|
|
1988
2078
|
// (webpack, jest, etc.) and must not trigger compound detections (zlib_inflate_eval,
|
|
1989
2079
|
// fetch_decrypt_exec) which were designed for eval/Function patterns.
|
|
1990
2080
|
ctx.threats.push({
|
|
1991
2081
|
type: 'vm_code_execution',
|
|
1992
|
-
severity:
|
|
1993
|
-
message:
|
|
2082
|
+
severity: vmSeverity,
|
|
2083
|
+
message: vmMsg,
|
|
1994
2084
|
file: ctx.relFile
|
|
1995
2085
|
});
|
|
1996
2086
|
}
|
|
@@ -2053,6 +2143,19 @@ function handleCallExpression(node, ctx) {
|
|
|
2053
2143
|
}
|
|
2054
2144
|
}
|
|
2055
2145
|
|
|
2146
|
+
// Audit v3 bypass fix: process.on('uncaughtException'/'unhandledRejection', handler)
|
|
2147
|
+
// Error handler hijacking for silent credential exfiltration
|
|
2148
|
+
if (node.callee?.type === 'MemberExpression' &&
|
|
2149
|
+
node.callee.object?.type === 'Identifier' && node.callee.object.name === 'process' &&
|
|
2150
|
+
node.callee.property?.type === 'Identifier' && node.callee.property.name === 'on' &&
|
|
2151
|
+
node.arguments.length >= 2) {
|
|
2152
|
+
const eventArg = node.arguments[0];
|
|
2153
|
+
if (eventArg?.type === 'Literal' &&
|
|
2154
|
+
(eventArg.value === 'uncaughtException' || eventArg.value === 'unhandledRejection')) {
|
|
2155
|
+
ctx.hasUncaughtExceptionHandler = true;
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2056
2159
|
// SANDWORM_MODE R8: dns.resolve detection moved to walk.ancestor() in ast.js (FIX 5)
|
|
2057
2160
|
}
|
|
2058
2161
|
|
|
@@ -2064,9 +2167,11 @@ function handleImportExpression(node, ctx) {
|
|
|
2064
2167
|
// Batch 2: strip node: prefix so import('node:child_process') normalizes
|
|
2065
2168
|
const modName = src.value.startsWith('node:') ? src.value.slice(5) : src.value;
|
|
2066
2169
|
if (dangerousModules.includes(modName)) {
|
|
2170
|
+
// Audit v3: dynamic import of code execution modules → CRITICAL (evasion technique)
|
|
2171
|
+
const CRITICAL_IMPORTS = ['child_process', 'net', 'dns', 'worker_threads'];
|
|
2067
2172
|
ctx.threats.push({
|
|
2068
2173
|
type: 'dynamic_import',
|
|
2069
|
-
severity: 'HIGH',
|
|
2174
|
+
severity: CRITICAL_IMPORTS.includes(modName) ? 'CRITICAL' : 'HIGH',
|
|
2070
2175
|
message: `Dynamic import() of dangerous module "${src.value}".`,
|
|
2071
2176
|
file: ctx.relFile
|
|
2072
2177
|
});
|
|
@@ -2815,6 +2920,29 @@ function handlePostWalk(ctx) {
|
|
|
2815
2920
|
});
|
|
2816
2921
|
}
|
|
2817
2922
|
|
|
2923
|
+
// Audit v3 bypass fix: uncaughtException + env access + network = silent exfiltration
|
|
2924
|
+
// Pattern: process.on('uncaughtException', handler) that reads env vars and sends to network.
|
|
2925
|
+
// Never legitimate — error handlers don't need to send credentials to external servers.
|
|
2926
|
+
if (ctx.hasUncaughtExceptionHandler && hasSensitiveEnvInFile && ctx.hasNetworkCallInFile) {
|
|
2927
|
+
ctx.threats.push({
|
|
2928
|
+
type: 'uncaught_exception_exfil',
|
|
2929
|
+
severity: 'CRITICAL',
|
|
2930
|
+
message: 'process.on("uncaughtException") + sensitive env access + network — silent credential exfiltration via error handler hijacking.',
|
|
2931
|
+
file: ctx.relFile
|
|
2932
|
+
});
|
|
2933
|
+
}
|
|
2934
|
+
// Also: uncaughtException + env enumeration (Object.entries/keys(process.env)) + network
|
|
2935
|
+
if (ctx.hasUncaughtExceptionHandler && ctx.hasEnvEnumeration && ctx.hasNetworkCallInFile) {
|
|
2936
|
+
if (!ctx.threats.some(t => t.type === 'uncaught_exception_exfil' && t.file === ctx.relFile)) {
|
|
2937
|
+
ctx.threats.push({
|
|
2938
|
+
type: 'uncaught_exception_exfil',
|
|
2939
|
+
severity: 'CRITICAL',
|
|
2940
|
+
message: 'process.on("uncaughtException") + bulk env enumeration + network — silent credential exfiltration via error handler hijacking.',
|
|
2941
|
+
file: ctx.relFile
|
|
2942
|
+
});
|
|
2943
|
+
}
|
|
2944
|
+
}
|
|
2945
|
+
|
|
2818
2946
|
// GlassWorm: Unicode variation selector decoder = .codePointAt + variation selector constants
|
|
2819
2947
|
// CRITICAL if combined with eval/exec (GlassWorm always uses dynamic execution),
|
|
2820
2948
|
// MEDIUM otherwise (.codePointAt + 0xFE00 is legitimate Unicode processing in fonts/text libs)
|
package/src/scanner/ast.js
CHANGED
|
@@ -178,7 +178,9 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
178
178
|
hasVariationSelectorConst: false,
|
|
179
179
|
// GlassWorm: blockchain C2 resolution (Solana import + C2 method + dynamic exec)
|
|
180
180
|
hasSolanaImport: false,
|
|
181
|
-
hasSolanaC2Method: false
|
|
181
|
+
hasSolanaC2Method: false,
|
|
182
|
+
// Audit v3: uncaughtException/unhandledRejection handler for error hijacking detection
|
|
183
|
+
hasUncaughtExceptionHandler: false
|
|
182
184
|
};
|
|
183
185
|
|
|
184
186
|
// Compute fetchOnlySafeDomains: check if ALL URLs in file point to known registries
|