muaddib-scanner 2.5.15 → 2.5.16
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/package.json +1 -1
- package/src/index.js +5 -1
- package/src/response/playbooks.js +25 -0
- package/src/rules/index.js +72 -0
- package/src/scanner/ast-detectors.js +173 -9
- package/src/scanner/ast.js +32 -1
- package/src/scanner/dataflow.js +10 -12
- package/src/scanner/entropy.js +1 -1
- package/src/scanner/module-graph.js +291 -1
- package/src/scoring.js +32 -7
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -23,7 +23,7 @@ const { ensureIOCs } = require('./ioc/bootstrap.js');
|
|
|
23
23
|
const { scanEntropy } = require('./scanner/entropy.js');
|
|
24
24
|
const { scanAIConfig } = require('./scanner/ai-config.js');
|
|
25
25
|
const { deobfuscate } = require('./scanner/deobfuscate.js');
|
|
26
|
-
const { buildModuleGraph, annotateTaintedExports, detectCrossFileFlows } = require('./scanner/module-graph.js');
|
|
26
|
+
const { buildModuleGraph, annotateTaintedExports, detectCrossFileFlows, annotateSinkExports, detectCallbackCrossFileFlows } = require('./scanner/module-graph.js');
|
|
27
27
|
const { computeReachableFiles } = require('./scanner/reachability.js');
|
|
28
28
|
const { runTemporalAnalyses } = require('./temporal-runner.js');
|
|
29
29
|
const { formatOutput } = require('./output-formatter.js');
|
|
@@ -362,6 +362,10 @@ async function run(targetPath, options = {}) {
|
|
|
362
362
|
const graph = await yieldThen(() => buildModuleGraph(targetPath));
|
|
363
363
|
const tainted = await yieldThen(() => annotateTaintedExports(graph, targetPath));
|
|
364
364
|
crossFileFlows = await yieldThen(() => detectCrossFileFlows(graph, tainted, targetPath));
|
|
365
|
+
// Callback-based cross-file flow detection
|
|
366
|
+
const sinkAnnotations = await yieldThen(() => annotateSinkExports(graph, targetPath));
|
|
367
|
+
const callbackFlows = await yieldThen(() => detectCallbackCrossFileFlows(graph, tainted, sinkAnnotations, targetPath));
|
|
368
|
+
crossFileFlows = crossFileFlows.concat(callbackFlows);
|
|
365
369
|
} catch (e) {
|
|
366
370
|
// Graceful fallback — module graph is best-effort
|
|
367
371
|
debugLog('[MODULE-GRAPH] Error:', e && e.message);
|
|
@@ -461,6 +461,31 @@ const PLAYBOOKS = {
|
|
|
461
461
|
fragmented_high_entropy_cluster:
|
|
462
462
|
'Cluster de chaines courtes a haute entropie detecte. Possible fragmentation de payload pour eviter la detection. ' +
|
|
463
463
|
'Reconstituer les fragments et analyser le contenu combine. Verifier si les chaines sont concatenees ou reassemblees a l\'execution.',
|
|
464
|
+
|
|
465
|
+
wasm_host_sink:
|
|
466
|
+
'CRITIQUE: Module WebAssembly charge avec des imports host contenant des sinks reseau. Le flux de controle est cache dans le binaire WASM, ' +
|
|
467
|
+
'rendant l\'analyse statique impossible. Le WASM peut lire des fichiers sensibles et exfiltrer via les callbacks host. ' +
|
|
468
|
+
'Supprimer le package immediatement. Analyser le fichier WASM avec wasm2wat pour comprendre le flux. Regenerer tous les secrets.',
|
|
469
|
+
credential_regex_harvest:
|
|
470
|
+
'Code contient des regex de detection de credentials (Bearer, password, token, API key) combine avec un appel reseau. ' +
|
|
471
|
+
'Technique de harvesting: scanne les donnees en transit (streams HTTP, fichiers) pour extraire des secrets et les exfiltrer. ' +
|
|
472
|
+
'Supprimer le package. Auditer le trafic reseau sortant.',
|
|
473
|
+
builtin_override_exfil:
|
|
474
|
+
'Code remplace une methode built-in (console.log/warn/error, Object.defineProperty) et contient un appel reseau. ' +
|
|
475
|
+
'Technique de monkey-patching: intercepte les donnees passant par les APIs natives pour les exfiltrer. ' +
|
|
476
|
+
'Supprimer le package. Verifier si d\'autres methodes natives ont ete modifiees.',
|
|
477
|
+
stream_credential_intercept:
|
|
478
|
+
'Classe stream (Transform/Duplex/Writable) avec regex de credentials et appel reseau. ' +
|
|
479
|
+
'Technique de wiretap: le stream intercepte les donnees en transit, scanne pour des secrets (Bearer, password, token) ' +
|
|
480
|
+
'et les exfiltre via un appel reseau. Supprimer le package.',
|
|
481
|
+
remote_code_load:
|
|
482
|
+
'CRITIQUE: Fetch reseau + eval/new Function() dans le meme fichier. ' +
|
|
483
|
+
'Technique multi-stage: le package telecharge un payload depuis un serveur distant (SVG, HTML, JSON) puis l\'execute. ' +
|
|
484
|
+
'Supprimer le package. Bloquer le domaine C2 au niveau firewall.',
|
|
485
|
+
proxy_data_intercept:
|
|
486
|
+
'CRITIQUE: Un Proxy JavaScript avec trap set/get/apply est combine avec un appel reseau. ' +
|
|
487
|
+
'Technique d\'interception: le Proxy capture toutes les ecritures de proprietes (credentials, tokens, config) ' +
|
|
488
|
+
'et les exfiltre via HTTPS/fetch/dgram. Supprimer le package. Auditer tous les modules qui importent ce package.',
|
|
464
489
|
};
|
|
465
490
|
|
|
466
491
|
function getPlaybook(threatType) {
|
package/src/rules/index.js
CHANGED
|
@@ -1285,6 +1285,78 @@ const RULES = {
|
|
|
1285
1285
|
],
|
|
1286
1286
|
mitre: 'T1059'
|
|
1287
1287
|
},
|
|
1288
|
+
wasm_host_sink: {
|
|
1289
|
+
id: 'MUADDIB-AST-042',
|
|
1290
|
+
name: 'WASM Host Import Sink',
|
|
1291
|
+
severity: 'CRITICAL',
|
|
1292
|
+
confidence: 'high',
|
|
1293
|
+
description: 'Module WebAssembly charge avec des callbacks host contenant des sinks reseau (fetch/http.request). Le WASM peut invoquer ces callbacks pour exfiltrer des donnees tout en cachant le flux de controle. Aucun package npm legitime ne combine WASM + callbacks reseau host.',
|
|
1294
|
+
references: [
|
|
1295
|
+
'https://attack.mitre.org/techniques/T1059/',
|
|
1296
|
+
'https://attack.mitre.org/techniques/T1027/'
|
|
1297
|
+
],
|
|
1298
|
+
mitre: 'T1059'
|
|
1299
|
+
},
|
|
1300
|
+
credential_regex_harvest: {
|
|
1301
|
+
id: 'MUADDIB-AST-041',
|
|
1302
|
+
name: 'Credential Regex Harvesting',
|
|
1303
|
+
severity: 'HIGH',
|
|
1304
|
+
confidence: 'high',
|
|
1305
|
+
description: 'Regex de detection de credentials (token/password/secret/Bearer) combine avec un appel reseau. Technique de harvesting: le code scanne les donnees de flux (streams, requetes) a la recherche de credentials et les exfiltre.',
|
|
1306
|
+
references: [
|
|
1307
|
+
'https://attack.mitre.org/techniques/T1552/',
|
|
1308
|
+
'https://attack.mitre.org/techniques/T1041/'
|
|
1309
|
+
],
|
|
1310
|
+
mitre: 'T1552'
|
|
1311
|
+
},
|
|
1312
|
+
builtin_override_exfil: {
|
|
1313
|
+
id: 'MUADDIB-AST-044',
|
|
1314
|
+
name: 'Built-in Method Override Exfiltration',
|
|
1315
|
+
severity: 'HIGH',
|
|
1316
|
+
confidence: 'high',
|
|
1317
|
+
description: 'Override de methode built-in (console.log/warn/error, Object.defineProperty) combine avec un appel reseau. Technique de monkey-patching: le code remplace une API native pour intercepter les donnees en transit et les exfiltrer.',
|
|
1318
|
+
references: [
|
|
1319
|
+
'https://attack.mitre.org/techniques/T1557/',
|
|
1320
|
+
'https://attack.mitre.org/techniques/T1041/'
|
|
1321
|
+
],
|
|
1322
|
+
mitre: 'T1557'
|
|
1323
|
+
},
|
|
1324
|
+
stream_credential_intercept: {
|
|
1325
|
+
id: 'MUADDIB-AST-045',
|
|
1326
|
+
name: 'Stream Credential Interception',
|
|
1327
|
+
severity: 'HIGH',
|
|
1328
|
+
confidence: 'high',
|
|
1329
|
+
description: 'Classe stream (Transform/Duplex/Writable) avec regex de credentials et appel reseau. Technique de wiretap: le stream intercepte les donnees en transit, scanne pour des credentials (Bearer, password, token) et les exfiltre.',
|
|
1330
|
+
references: [
|
|
1331
|
+
'https://attack.mitre.org/techniques/T1557/',
|
|
1332
|
+
'https://attack.mitre.org/techniques/T1552/'
|
|
1333
|
+
],
|
|
1334
|
+
mitre: 'T1557'
|
|
1335
|
+
},
|
|
1336
|
+
remote_code_load: {
|
|
1337
|
+
id: 'MUADDIB-AST-040',
|
|
1338
|
+
name: 'Remote Code Loading',
|
|
1339
|
+
severity: 'CRITICAL',
|
|
1340
|
+
confidence: 'high',
|
|
1341
|
+
description: 'Fetch reseau + eval/Function dans le meme fichier. Technique multi-stage: le code telecharge un payload distant (SVG, HTML, JSON) et l\'execute dynamiquement. Aucun package npm legitime ne combine fetch + eval/Function.',
|
|
1342
|
+
references: [
|
|
1343
|
+
'https://attack.mitre.org/techniques/T1105/',
|
|
1344
|
+
'https://attack.mitre.org/techniques/T1059/'
|
|
1345
|
+
],
|
|
1346
|
+
mitre: 'T1105'
|
|
1347
|
+
},
|
|
1348
|
+
proxy_data_intercept: {
|
|
1349
|
+
id: 'MUADDIB-AST-043',
|
|
1350
|
+
name: 'Proxy Data Interception',
|
|
1351
|
+
severity: 'CRITICAL',
|
|
1352
|
+
confidence: 'high',
|
|
1353
|
+
description: 'Proxy trap (set/get/apply) combine avec un appel reseau dans le meme fichier. Technique d\'interception de donnees: le Proxy capture toutes les ecritures/lectures de proprietes et les exfiltre via le reseau. Utilise pour voler des credentials passees via module.exports.',
|
|
1354
|
+
references: [
|
|
1355
|
+
'https://attack.mitre.org/techniques/T1557/',
|
|
1356
|
+
'https://attack.mitre.org/techniques/T1041/'
|
|
1357
|
+
],
|
|
1358
|
+
mitre: 'T1557'
|
|
1359
|
+
},
|
|
1288
1360
|
};
|
|
1289
1361
|
|
|
1290
1362
|
function getRule(type) {
|
|
@@ -26,17 +26,56 @@ const SAFE_ENV_VARS = [
|
|
|
26
26
|
'LANG', 'TERM', 'CI', 'DEBUG', 'VERBOSE', 'LOG_LEVEL',
|
|
27
27
|
'SHELL', 'USER', 'LOGNAME', 'EDITOR', 'TZ',
|
|
28
28
|
'NODE_DEBUG', 'NODE_PATH', 'NODE_OPTIONS',
|
|
29
|
-
'DISPLAY', 'COLORTERM', 'FORCE_COLOR', 'NO_COLOR', 'TERM_PROGRAM'
|
|
29
|
+
'DISPLAY', 'COLORTERM', 'FORCE_COLOR', 'NO_COLOR', 'TERM_PROGRAM',
|
|
30
|
+
// CI environment metadata (non-sensitive)
|
|
31
|
+
'GITHUB_REPOSITORY', 'GITHUB_SHA', 'GITHUB_REF', 'GITHUB_WORKSPACE',
|
|
32
|
+
'GITHUB_RUN_ID', 'GITHUB_RUN_NUMBER', 'GITHUB_ACTOR', 'GITHUB_EVENT_NAME',
|
|
33
|
+
'GITHUB_WORKFLOW', 'GITHUB_ACTION', 'GITHUB_JOB', 'GITHUB_SERVER_URL',
|
|
34
|
+
'GITLAB_CI', 'TRAVIS', 'CIRCLECI', 'JENKINS_URL',
|
|
35
|
+
// Build tool config
|
|
36
|
+
'NODE_TLS_REJECT_UNAUTHORIZED', 'BABEL_ENV', 'WEBPACK_MODE'
|
|
30
37
|
];
|
|
31
38
|
|
|
32
|
-
// Env var prefixes that are safe (npm metadata, locale settings)
|
|
33
|
-
const SAFE_ENV_PREFIXES = [
|
|
39
|
+
// Env var prefixes that are safe (npm metadata, locale settings, framework public vars)
|
|
40
|
+
const SAFE_ENV_PREFIXES = [
|
|
41
|
+
'npm_config_', 'npm_lifecycle_', 'npm_package_', 'lc_', 'muaddib_',
|
|
42
|
+
'next_public_', 'vite_', 'react_app_'
|
|
43
|
+
];
|
|
34
44
|
|
|
35
45
|
// Env var keywords to detect sensitive environment access (separate from SENSITIVE_STRINGS)
|
|
36
46
|
const ENV_SENSITIVE_KEYWORDS = [
|
|
37
47
|
'TOKEN', 'SECRET', 'KEY', 'PASSWORD', 'CREDENTIAL', 'AUTH'
|
|
38
48
|
];
|
|
39
49
|
|
|
50
|
+
// Non-sensitive qualifiers: when a keyword is preceded by one of these in the env var name,
|
|
51
|
+
// it is config metadata, not a real secret (e.g., PUBLIC_KEY, CACHE_KEY, SORT_KEY)
|
|
52
|
+
const ENV_NON_SENSITIVE_QUALIFIERS = new Set([
|
|
53
|
+
'PUBLIC', 'CACHE', 'PRIMARY', 'FOREIGN', 'SORT', 'PARTITION', 'INDEX', 'ENCRYPTION'
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Check if an env var name contains a sensitive keyword as a full _-delimited segment,
|
|
58
|
+
* not preceded by a non-sensitive qualifier.
|
|
59
|
+
* e.g., NPM_TOKEN → TOKEN is full segment → true
|
|
60
|
+
* PUBLIC_KEY → KEY preceded by PUBLIC → false
|
|
61
|
+
* CACHE_KEY → KEY preceded by CACHE → false
|
|
62
|
+
* GITHUB_TOKEN → TOKEN is full segment, preceded by GITHUB (not a qualifier) → true
|
|
63
|
+
*/
|
|
64
|
+
function isEnvSensitive(envVar) {
|
|
65
|
+
const upper = envVar.toUpperCase();
|
|
66
|
+
const segments = upper.split('_');
|
|
67
|
+
for (let i = 0; i < segments.length; i++) {
|
|
68
|
+
if (ENV_SENSITIVE_KEYWORDS.includes(segments[i])) {
|
|
69
|
+
// Check if preceded by a non-sensitive qualifier
|
|
70
|
+
if (i > 0 && ENV_NON_SENSITIVE_QUALIFIERS.has(segments[i - 1])) {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
|
|
40
79
|
// AI agent dangerous flags — disable security controls (s1ngularity/Nx, Aug 2025)
|
|
41
80
|
const AI_AGENT_DANGEROUS_FLAGS = [
|
|
42
81
|
'--dangerously-skip-permissions',
|
|
@@ -85,10 +124,11 @@ const HOOKABLE_NATIVES = [
|
|
|
85
124
|
];
|
|
86
125
|
|
|
87
126
|
// Node.js core module classes targeted for prototype hooking
|
|
88
|
-
const NODE_HOOKABLE_MODULES = ['http', 'https', 'net', 'tls', 'stream'];
|
|
127
|
+
const NODE_HOOKABLE_MODULES = ['http', 'https', 'net', 'tls', 'stream', 'events', 'dgram'];
|
|
89
128
|
const NODE_HOOKABLE_CLASSES = [
|
|
90
129
|
'IncomingMessage', 'ServerResponse', 'ClientRequest',
|
|
91
|
-
'OutgoingMessage', 'Socket', 'Server', 'Agent'
|
|
130
|
+
'OutgoingMessage', 'Socket', 'Server', 'Agent',
|
|
131
|
+
'EventEmitter'
|
|
92
132
|
];
|
|
93
133
|
|
|
94
134
|
// AI/MCP config paths targeted for config injection (SANDWORM_MODE)
|
|
@@ -423,7 +463,7 @@ function handleVariableDeclarator(node, ctx) {
|
|
|
423
463
|
if (SAFE_ENV_VARS.includes(envVar)) continue;
|
|
424
464
|
const envLower = envVar.toLowerCase();
|
|
425
465
|
if (SAFE_ENV_PREFIXES.some(p => envLower.startsWith(p))) continue;
|
|
426
|
-
if (
|
|
466
|
+
if (isEnvSensitive(envVar)) {
|
|
427
467
|
ctx.threats.push({
|
|
428
468
|
type: 'env_access',
|
|
429
469
|
severity: 'HIGH',
|
|
@@ -538,7 +578,7 @@ function handleCallExpression(node, ctx) {
|
|
|
538
578
|
}
|
|
539
579
|
}
|
|
540
580
|
if (!resolved) {
|
|
541
|
-
ctx.threats.push({ type: 'dynamic_require', severity: '
|
|
581
|
+
ctx.threats.push({ type: 'dynamic_require', severity: 'MEDIUM',
|
|
542
582
|
message: 'Dynamic require() with member expression argument (object property obfuscation).',
|
|
543
583
|
file: ctx.relFile });
|
|
544
584
|
}
|
|
@@ -985,9 +1025,9 @@ function handleCallExpression(node, ctx) {
|
|
|
985
1025
|
|
|
986
1026
|
if (callName === 'eval') {
|
|
987
1027
|
ctx.hasEvalInFile = true;
|
|
988
|
-
ctx.hasDynamicExec = true;
|
|
989
1028
|
// Detect staged eval decode
|
|
990
1029
|
if (node.arguments.length === 1 && hasDecodeArg(node.arguments[0])) {
|
|
1030
|
+
ctx.hasDynamicExec = true;
|
|
991
1031
|
ctx.threats.push({
|
|
992
1032
|
type: 'staged_eval_decode',
|
|
993
1033
|
severity: 'CRITICAL',
|
|
@@ -1007,9 +1047,15 @@ function handleCallExpression(node, ctx) {
|
|
|
1007
1047
|
if (/\b(require|import|exec|execSync|spawn|child_process|\.readFile|\.writeFile|process\.env|\.homedir)\b/.test(val)) {
|
|
1008
1048
|
severity = 'HIGH';
|
|
1009
1049
|
message = `eval() with dangerous API in string literal: "${val.substring(0, 100)}"`;
|
|
1050
|
+
ctx.hasDynamicExec = true;
|
|
1010
1051
|
}
|
|
1011
1052
|
}
|
|
1012
1053
|
|
|
1054
|
+
// Only set hasDynamicExec for non-constant (dynamic) eval
|
|
1055
|
+
if (!isConstant) {
|
|
1056
|
+
ctx.hasDynamicExec = true;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1013
1059
|
ctx.threats.push({
|
|
1014
1060
|
type: 'dangerous_call_eval',
|
|
1015
1061
|
severity,
|
|
@@ -1039,6 +1085,25 @@ function handleCallExpression(node, ctx) {
|
|
|
1039
1085
|
}
|
|
1040
1086
|
}
|
|
1041
1087
|
|
|
1088
|
+
// setTimeout/setInterval with string argument = eval equivalent
|
|
1089
|
+
// setTimeout("require('child_process').exec('whoami')", 100) executes the string as code
|
|
1090
|
+
// Only string Literal and TemplateLiteral are eval-equivalent; Identifier/MemberExpression
|
|
1091
|
+
// are function references (callbacks), not code strings.
|
|
1092
|
+
if ((callName === 'setTimeout' || callName === 'setInterval') && node.arguments.length >= 1) {
|
|
1093
|
+
const firstArg = node.arguments[0];
|
|
1094
|
+
if ((firstArg.type === 'Literal' && typeof firstArg.value === 'string') ||
|
|
1095
|
+
firstArg.type === 'TemplateLiteral') {
|
|
1096
|
+
ctx.hasEvalInFile = true;
|
|
1097
|
+
ctx.hasDynamicExec = true;
|
|
1098
|
+
ctx.threats.push({
|
|
1099
|
+
type: 'dangerous_call_eval',
|
|
1100
|
+
severity: 'HIGH',
|
|
1101
|
+
message: `${callName}() with string argument — eval equivalent, executes the string as code.`,
|
|
1102
|
+
file: ctx.relFile
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1042
1107
|
// Detect eval.call(null, code) / eval.apply(null, [code]) / Function.call/apply
|
|
1043
1108
|
if (node.callee.type === 'MemberExpression' && !node.callee.computed &&
|
|
1044
1109
|
node.callee.property?.type === 'Identifier' &&
|
|
@@ -1447,6 +1512,20 @@ function handleNewExpression(node, ctx) {
|
|
|
1447
1512
|
file: ctx.relFile
|
|
1448
1513
|
});
|
|
1449
1514
|
}
|
|
1515
|
+
// Detect new Proxy(obj, handler) where handler has set/get traps — data interception
|
|
1516
|
+
// Real-world technique: export a Proxy that intercepts all property sets/gets to exfiltrate
|
|
1517
|
+
// data flowing through the module. Combined with network (hasNetworkInFile) → credential theft.
|
|
1518
|
+
if (!target.type?.includes('MemberExpression') || target.property?.name !== 'env') {
|
|
1519
|
+
const handler = node.arguments[1];
|
|
1520
|
+
if (handler?.type === 'ObjectExpression') {
|
|
1521
|
+
const hasTrap = handler.properties?.some(p =>
|
|
1522
|
+
p.key?.type === 'Identifier' && ['set', 'get', 'apply', 'construct'].includes(p.key.name)
|
|
1523
|
+
);
|
|
1524
|
+
if (hasTrap) {
|
|
1525
|
+
ctx.hasProxyTrap = true;
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1450
1529
|
}
|
|
1451
1530
|
|
|
1452
1531
|
// Batch 2: new Worker(code, { eval: true }) — worker_threads code execution
|
|
@@ -1630,6 +1709,19 @@ function handleAssignmentExpression(node, ctx) {
|
|
|
1630
1709
|
}
|
|
1631
1710
|
}
|
|
1632
1711
|
|
|
1712
|
+
// JSON.stringify = ... or JSON.parse = ... — global API hooking
|
|
1713
|
+
// Real-world technique: override JSON.stringify to intercept all serialization and exfiltrate data
|
|
1714
|
+
if (left.object?.type === 'Identifier' && left.object.name === 'JSON' &&
|
|
1715
|
+
left.property?.type === 'Identifier' &&
|
|
1716
|
+
['stringify', 'parse'].includes(left.property.name)) {
|
|
1717
|
+
ctx.threats.push({
|
|
1718
|
+
type: 'prototype_hook',
|
|
1719
|
+
severity: 'HIGH',
|
|
1720
|
+
message: `JSON.${left.property.name} overridden — global API hooking to intercept all JSON serialization/deserialization.`,
|
|
1721
|
+
file: ctx.relFile
|
|
1722
|
+
});
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1633
1725
|
// XMLHttpRequest.prototype.send = ... or Response.prototype.json = ...
|
|
1634
1726
|
if (left.object?.type === 'MemberExpression' &&
|
|
1635
1727
|
left.object.property?.type === 'Identifier' &&
|
|
@@ -1723,7 +1815,7 @@ function handleMemberExpression(node, ctx) {
|
|
|
1723
1815
|
if (SAFE_ENV_PREFIXES.some(p => envLower.startsWith(p))) {
|
|
1724
1816
|
return;
|
|
1725
1817
|
}
|
|
1726
|
-
if (
|
|
1818
|
+
if (isEnvSensitive(envVar)) {
|
|
1727
1819
|
ctx.threats.push({
|
|
1728
1820
|
type: 'env_access',
|
|
1729
1821
|
severity: 'HIGH',
|
|
@@ -1828,6 +1920,17 @@ function handlePostWalk(ctx) {
|
|
|
1828
1920
|
});
|
|
1829
1921
|
}
|
|
1830
1922
|
|
|
1923
|
+
// Remote code loading: fetch + eval/Function in same file = multi-stage payload
|
|
1924
|
+
// Distinct from fetch_decrypt_exec which also requires crypto. This catches SVG/HTML payload extraction.
|
|
1925
|
+
if (ctx.hasRemoteFetch && ctx.hasDynamicExec && !ctx.hasCryptoDecipher) {
|
|
1926
|
+
ctx.threats.push({
|
|
1927
|
+
type: 'remote_code_load',
|
|
1928
|
+
severity: 'CRITICAL',
|
|
1929
|
+
message: 'Remote code loading: network fetch + dynamic eval/Function in same file — multi-stage payload execution.',
|
|
1930
|
+
file: ctx.relFile
|
|
1931
|
+
});
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1831
1934
|
// Wave 4: Remote fetch + crypto decrypt + dynamic eval = steganographic payload chain
|
|
1832
1935
|
if (ctx.hasRemoteFetch && ctx.hasCryptoDecipher && ctx.hasDynamicExec) {
|
|
1833
1936
|
ctx.threats.push({
|
|
@@ -1861,6 +1964,67 @@ function handlePostWalk(ctx) {
|
|
|
1861
1964
|
});
|
|
1862
1965
|
}
|
|
1863
1966
|
|
|
1967
|
+
// WASM payload detection: WebAssembly.compile/instantiate + readFileSync/https in same file
|
|
1968
|
+
// WASM host import objects can contain callback functions that read credentials and exfiltrate.
|
|
1969
|
+
// This pattern is never legitimate in npm packages — WASM should use pure computation, not host I/O.
|
|
1970
|
+
if (ctx.hasWasmLoad && ctx.hasNetworkCallInFile) {
|
|
1971
|
+
ctx.threats.push({
|
|
1972
|
+
type: 'wasm_host_sink',
|
|
1973
|
+
severity: 'CRITICAL',
|
|
1974
|
+
message: 'WebAssembly module with network-capable host imports. WASM can invoke host callbacks to exfiltrate data while hiding control flow.',
|
|
1975
|
+
file: ctx.relFile
|
|
1976
|
+
});
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
// Credential regex harvesting: credential-matching regex + network call in same file
|
|
1980
|
+
// Real-world pattern: Transform/stream that scans data for tokens/passwords and exfiltrates
|
|
1981
|
+
if (ctx.hasCredentialRegex && ctx.hasNetworkCallInFile) {
|
|
1982
|
+
ctx.threats.push({
|
|
1983
|
+
type: 'credential_regex_harvest',
|
|
1984
|
+
severity: 'HIGH',
|
|
1985
|
+
message: 'Credential regex patterns (token/password/secret/Bearer) + network call in same file — stream data credential harvesting.',
|
|
1986
|
+
file: ctx.relFile
|
|
1987
|
+
});
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
// Built-in method override + network: console.X = function or Object.defineProperty = function
|
|
1991
|
+
// combined with network calls. Monkey-patching built-in APIs for data interception.
|
|
1992
|
+
if (ctx.hasBuiltinOverride && ctx.hasNetworkCallInFile) {
|
|
1993
|
+
ctx.threats.push({
|
|
1994
|
+
type: 'builtin_override_exfil',
|
|
1995
|
+
severity: 'HIGH',
|
|
1996
|
+
message: 'Built-in method override (console/Object.defineProperty) + network call — runtime API hijacking for data interception and exfiltration.',
|
|
1997
|
+
file: ctx.relFile
|
|
1998
|
+
});
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
// Stream credential interception: Transform/Duplex/Writable stream + credential regex + network
|
|
2002
|
+
// Wiretap pattern: intercepts data in transit, scans for credentials, exfiltrates matches.
|
|
2003
|
+
if (ctx.hasStreamInterceptor && ctx.hasCredentialRegex && ctx.hasNetworkCallInFile) {
|
|
2004
|
+
ctx.threats.push({
|
|
2005
|
+
type: 'stream_credential_intercept',
|
|
2006
|
+
severity: 'HIGH',
|
|
2007
|
+
message: 'Stream class (Transform/Duplex/Writable) with credential regex scanning + network call — data-in-transit credential wiretap.',
|
|
2008
|
+
file: ctx.relFile
|
|
2009
|
+
});
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
// Proxy data interception: new Proxy(obj, { set/get }) + network in same file
|
|
2013
|
+
// Real-world pattern: export a Proxy that exfiltrates all property assignments via network
|
|
2014
|
+
// CRITICAL only when credential signals co-occur (env_access, suspicious_dataflow),
|
|
2015
|
+
// otherwise HIGH — bare Proxy + fetch is insufficient evidence.
|
|
2016
|
+
if (ctx.hasProxyTrap && ctx.hasNetworkCallInFile) {
|
|
2017
|
+
const hasCredentialSignal = ctx.threats.some(t =>
|
|
2018
|
+
t.type === 'env_access' || t.type === 'suspicious_dataflow'
|
|
2019
|
+
);
|
|
2020
|
+
ctx.threats.push({
|
|
2021
|
+
type: 'proxy_data_intercept',
|
|
2022
|
+
severity: hasCredentialSignal ? 'CRITICAL' : 'HIGH',
|
|
2023
|
+
message: 'Proxy trap (set/get/apply) with network call in same file — data interception and exfiltration via Proxy handler.',
|
|
2024
|
+
file: ctx.relFile
|
|
2025
|
+
});
|
|
2026
|
+
}
|
|
2027
|
+
|
|
1864
2028
|
// Wave 4: MCP content keywords in file with writeFileSync = MCP injection signal
|
|
1865
2029
|
if (ctx.hasMcpContentKeywords && !ctx.threats.some(t => t.type === 'mcp_config_injection')) {
|
|
1866
2030
|
ctx.threats.push({
|
package/src/scanner/ast.js
CHANGED
|
@@ -15,6 +15,24 @@ const {
|
|
|
15
15
|
handlePostWalk
|
|
16
16
|
} = require('./ast-detectors.js');
|
|
17
17
|
|
|
18
|
+
// Check if credential keywords appear INSIDE regex literals or new RegExp() patterns.
|
|
19
|
+
// Only true when the keyword is part of the regex pattern itself, not just a string elsewhere in the file.
|
|
20
|
+
const CREDENTIAL_REGEX_KEYWORDS = /bearer|password|secret|token|credential|api.?key/i;
|
|
21
|
+
function hasCredentialInsideRegex(content) {
|
|
22
|
+
// Check regex literals: /...pattern.../flags
|
|
23
|
+
const regexLiteralRe = /\/(?!\*)(?:[^/\\]|\\.)+\/[gimsuy]*/g;
|
|
24
|
+
let m;
|
|
25
|
+
while ((m = regexLiteralRe.exec(content)) !== null) {
|
|
26
|
+
if (CREDENTIAL_REGEX_KEYWORDS.test(m[0])) return true;
|
|
27
|
+
}
|
|
28
|
+
// Check new RegExp('pattern') — keyword must be in the string argument
|
|
29
|
+
const newRegExpRe = /new\s+RegExp\s*\(\s*(['"`])((?:[^\\]|\\.)*?)\1/g;
|
|
30
|
+
while ((m = newRegExpRe.exec(content)) !== null) {
|
|
31
|
+
if (CREDENTIAL_REGEX_KEYWORDS.test(m[2])) return true;
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
|
|
18
36
|
const EXCLUDED_FILES = [
|
|
19
37
|
'src/scanner/ast.js',
|
|
20
38
|
'src/scanner/shell.js',
|
|
@@ -93,6 +111,15 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
93
111
|
hasEnvEnumeration: false, // Object.entries/keys/values(process.env)
|
|
94
112
|
hasEnvHarvestPattern: /\b(KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL|NPM|AWS|SSH|WEBHOOK)\b/.test(content),
|
|
95
113
|
hasNetworkCallInFile: /\b(fetch|https?\.request|https?\.get|dns\.resolve)\b/.test(content),
|
|
114
|
+
// Credential regex harvesting: regex literals or new RegExp() whose PATTERN contains credential keywords
|
|
115
|
+
// Must check that the keyword is inside the regex, not just anywhere in the file
|
|
116
|
+
hasCredentialRegex: hasCredentialInsideRegex(content),
|
|
117
|
+
// Built-in method override: console.X = function or Object.defineProperty = function
|
|
118
|
+
hasBuiltinOverride: /\bconsole\s*\.\s*\w+\s*=\s*function/.test(content) ||
|
|
119
|
+
/\bconsole\s*\[\s*\w+\s*\]\s*=\s*function/.test(content) ||
|
|
120
|
+
/\bObject\s*\.\s*defineProperty\s*=\s*function/.test(content),
|
|
121
|
+
// Stream interceptor: class extending Transform/Duplex/Writable (data wiretap pattern)
|
|
122
|
+
hasStreamInterceptor: /\bextends\s+(Transform|Duplex|Writable)\b/.test(content),
|
|
96
123
|
// SANDWORM_MODE P2: DNS exfiltration co-occurrence
|
|
97
124
|
hasDnsRequire: /\brequire\s*\(\s*['"]dns['"]\s*\)/.test(content) || /\bdns\s*\.\s*resolve/.test(content),
|
|
98
125
|
hasBase64Encode: /\.toString\s*\(\s*['"]base64(url)?['"]\s*\)/.test(content),
|
|
@@ -123,7 +150,11 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
123
150
|
hasModuleImport: /require\s*\(\s*['"]module['"]\s*\)/.test(content) || /module\.constructor/.test(content),
|
|
124
151
|
hasMcpContentKeywords: (/\bmcpServers\b/.test(content) || /\bmcp\.json\b/.test(content) || /\bclaude_desktop_config\b/.test(content)) &&
|
|
125
152
|
/\bwriteFileSync\b|\bwriteFile\s*\(/.test(content) &&
|
|
126
|
-
(/\.claude[/\\]/.test(content) || /\.cursor[/\\]/.test(content) || /\.vscode[/\\]/.test(content) || /\.windsurf[/\\]/.test(content) || /\.codeium[/\\]/.test(content) || /\.continue[/\\]/.test(content) || /claude_desktop_config/.test(content) || /\bmcp\.json\b/.test(content))
|
|
153
|
+
(/\.claude[/\\]/.test(content) || /\.cursor[/\\]/.test(content) || /\.vscode[/\\]/.test(content) || /\.windsurf[/\\]/.test(content) || /\.codeium[/\\]/.test(content) || /\.continue[/\\]/.test(content) || /claude_desktop_config/.test(content) || /\bmcp\.json\b/.test(content)),
|
|
154
|
+
// WASM payload detection: WebAssembly.compile/instantiate with host import sinks
|
|
155
|
+
hasWasmLoad: /\bWebAssembly\s*\.\s*(compile|instantiate|compileStreaming|instantiateStreaming)\b/.test(content),
|
|
156
|
+
hasWasmHostSink: false, // set in handleCallExpression when WASM import object contains network/fs sinks
|
|
157
|
+
hasProxyTrap: false // set in handleNewExpression when Proxy has set/get/apply trap
|
|
127
158
|
};
|
|
128
159
|
|
|
129
160
|
// Compute fetchOnlySafeDomains: check if ALL URLs in file point to known registries
|
package/src/scanner/dataflow.js
CHANGED
|
@@ -9,9 +9,11 @@ const { analyzeWithDeobfuscation } = require('../shared/analyze-helper.js');
|
|
|
9
9
|
// Module classification maps for intra-file taint tracking
|
|
10
10
|
const MODULE_SOURCE_METHODS = {
|
|
11
11
|
os: {
|
|
12
|
-
homedir: 'fingerprint_read',
|
|
12
|
+
homedir: 'fingerprint_read',
|
|
13
13
|
networkInterfaces: 'fingerprint_read', userInfo: 'fingerprint_read',
|
|
14
|
-
platform: 'telemetry_read', arch: 'telemetry_read'
|
|
14
|
+
hostname: 'telemetry_read', platform: 'telemetry_read', arch: 'telemetry_read',
|
|
15
|
+
type: 'telemetry_read', release: 'telemetry_read',
|
|
16
|
+
cpus: 'telemetry_read', totalmem: 'telemetry_read', freemem: 'telemetry_read'
|
|
15
17
|
},
|
|
16
18
|
fs: {
|
|
17
19
|
readFileSync: 'credential_read', readFile: 'credential_read',
|
|
@@ -356,21 +358,17 @@ function analyzeFile(content, filePath, basePath) {
|
|
|
356
358
|
}
|
|
357
359
|
}
|
|
358
360
|
|
|
359
|
-
// os
|
|
360
|
-
//
|
|
361
|
+
// os.* methods classified via MODULE_SOURCE_METHODS for consistent categorization
|
|
362
|
+
// fingerprint_read: homedir, networkInterfaces, userInfo (real exfil targets)
|
|
363
|
+
// telemetry_read: hostname, platform, arch, type, release, cpus, totalmem, freemem
|
|
361
364
|
if (node.callee.type === 'MemberExpression') {
|
|
362
365
|
const obj = node.callee.object;
|
|
363
366
|
const prop = node.callee.property;
|
|
364
367
|
if (obj?.type === 'Identifier' && obj.name === 'os' && prop?.type === 'Identifier') {
|
|
365
|
-
|
|
368
|
+
const osClassification = MODULE_SOURCE_METHODS.os?.[prop.name];
|
|
369
|
+
if (osClassification) {
|
|
366
370
|
sources.push({
|
|
367
|
-
type:
|
|
368
|
-
name: `os.${prop.name}`,
|
|
369
|
-
line: node.loc?.start?.line
|
|
370
|
-
});
|
|
371
|
-
} else if (['platform', 'arch'].includes(prop.name)) {
|
|
372
|
-
sources.push({
|
|
373
|
-
type: 'telemetry_read',
|
|
371
|
+
type: osClassification,
|
|
374
372
|
name: `os.${prop.name}`,
|
|
375
373
|
line: node.loc?.start?.line
|
|
376
374
|
});
|
package/src/scanner/entropy.js
CHANGED
|
@@ -266,7 +266,7 @@ function scanEntropy(targetPath, options = {}) {
|
|
|
266
266
|
}
|
|
267
267
|
|
|
268
268
|
// B11: Fragment cluster — many short high-entropy strings = payload fragmentation
|
|
269
|
-
const FRAG_MIN = 8, FRAG_MAX = 49, FRAG_COUNT =
|
|
269
|
+
const FRAG_MIN = 8, FRAG_MAX = 49, FRAG_COUNT = 10, FRAG_ENTROPY = 5.0;
|
|
270
270
|
const frags = strings.filter(s =>
|
|
271
271
|
s.length >= FRAG_MIN && s.length <= FRAG_MAX &&
|
|
272
272
|
!SOURCE_MAP_REGEX.test(s) && !SHA256_HEX_REGEX.test(s) && !MD5_HEX_REGEX.test(s) &&
|
|
@@ -5,7 +5,7 @@ const { findFiles, EXCLUDED_DIRS } = require('../utils');
|
|
|
5
5
|
const { ACORN_OPTIONS: BASE_ACORN_OPTIONS, safeParse } = require('../shared/constants.js');
|
|
6
6
|
|
|
7
7
|
// --- Sensitive source patterns ---
|
|
8
|
-
const SENSITIVE_MODULES = new Set(['fs', 'child_process', 'dns', 'os']);
|
|
8
|
+
const SENSITIVE_MODULES = new Set(['fs', 'child_process', 'dns', 'os', 'dgram']);
|
|
9
9
|
|
|
10
10
|
const ACORN_OPTIONS = {
|
|
11
11
|
...BASE_ACORN_OPTIONS,
|
|
@@ -151,10 +151,15 @@ function analyzeExports(filePath) {
|
|
|
151
151
|
|
|
152
152
|
// Track class declarations: class Foo { ... }
|
|
153
153
|
const classDefs = {};
|
|
154
|
+
// Track function declarations: function foo() { ... }
|
|
155
|
+
const funcDefs = {};
|
|
154
156
|
walkAST(ast, (node) => {
|
|
155
157
|
if (node.type === 'ClassDeclaration' && node.id && node.id.name) {
|
|
156
158
|
classDefs[node.id.name] = node;
|
|
157
159
|
}
|
|
160
|
+
if (node.type === 'FunctionDeclaration' && node.id && node.id.name) {
|
|
161
|
+
funcDefs[node.id.name] = node;
|
|
162
|
+
}
|
|
158
163
|
});
|
|
159
164
|
|
|
160
165
|
// First pass: collect require assignments, ES imports, and tainted variable assignments
|
|
@@ -309,6 +314,16 @@ function analyzeExports(filePath) {
|
|
|
309
314
|
} else if (prop.value.type === 'Identifier' && taintedVars[prop.value.name]) {
|
|
310
315
|
const t = taintedVars[prop.value.name];
|
|
311
316
|
exports[propName] = { tainted: true, source: t.source, detail: t.detail };
|
|
317
|
+
} else if (prop.value.type === 'Identifier' && funcDefs[prop.value.name]) {
|
|
318
|
+
// Shorthand property referencing a FunctionDeclaration: { readConfig }
|
|
319
|
+
const fnNode = funcDefs[prop.value.name];
|
|
320
|
+
const fnBody = fnNode.body && fnNode.body.type === 'BlockStatement' ? fnNode.body.body : null;
|
|
321
|
+
if (fnBody) {
|
|
322
|
+
const bodyTaint = scanBodyForTaint(fnBody, moduleVars, taintedVars);
|
|
323
|
+
if (bodyTaint) {
|
|
324
|
+
exports[propName] = { tainted: true, source: bodyTaint.source, detail: bodyTaint.detail };
|
|
325
|
+
}
|
|
326
|
+
}
|
|
312
327
|
}
|
|
313
328
|
}
|
|
314
329
|
}
|
|
@@ -1081,8 +1096,283 @@ function toRel(abs, packagePath) {
|
|
|
1081
1096
|
return path.relative(packagePath, abs).replace(/\\/g, '/');
|
|
1082
1097
|
}
|
|
1083
1098
|
|
|
1099
|
+
// =============================================================================
|
|
1100
|
+
// STEP 4 — Sink export annotation (for callback-based cross-file detection)
|
|
1101
|
+
// =============================================================================
|
|
1102
|
+
|
|
1103
|
+
/**
|
|
1104
|
+
* Annotate exports that contain network/exec sinks in their function body.
|
|
1105
|
+
* This is the inverse of annotateTaintedExports — finds "where data goes out".
|
|
1106
|
+
* Used to detect callback-based cross-file exfiltration:
|
|
1107
|
+
* reader.js exports readConfig() (tainted source)
|
|
1108
|
+
* sender.js exports sendData() (sink export)
|
|
1109
|
+
* index.js connects them via callback: readConfig((data) => sendData(data))
|
|
1110
|
+
*/
|
|
1111
|
+
function annotateSinkExports(graph, packagePath) {
|
|
1112
|
+
const result = {};
|
|
1113
|
+
for (const relFile of Object.keys(graph)) {
|
|
1114
|
+
const absFile = path.resolve(packagePath, relFile);
|
|
1115
|
+
result[relFile] = analyzeSinkExports(absFile);
|
|
1116
|
+
}
|
|
1117
|
+
return result;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
function analyzeSinkExports(filePath) {
|
|
1121
|
+
const ast = parseFile(filePath);
|
|
1122
|
+
if (!ast) return {};
|
|
1123
|
+
|
|
1124
|
+
const sinkExports = {};
|
|
1125
|
+
|
|
1126
|
+
// Track function declarations for shorthand property resolution
|
|
1127
|
+
const localFuncDefs = {};
|
|
1128
|
+
walkAST(ast, (node) => {
|
|
1129
|
+
if (node.type === 'FunctionDeclaration' && node.id && node.id.name) {
|
|
1130
|
+
localFuncDefs[node.id.name] = node;
|
|
1131
|
+
}
|
|
1132
|
+
});
|
|
1133
|
+
|
|
1134
|
+
// Collect require assignments for sink module detection
|
|
1135
|
+
const sinkModuleVars = {};
|
|
1136
|
+
walkAST(ast, (node) => {
|
|
1137
|
+
if (node.type === 'VariableDeclaration') {
|
|
1138
|
+
for (const decl of node.declarations) {
|
|
1139
|
+
if (!decl.init || !decl.id || decl.id.type !== 'Identifier') continue;
|
|
1140
|
+
if (isRequireCall(decl.init)) {
|
|
1141
|
+
const mod = decl.init.arguments[0].value;
|
|
1142
|
+
if (mod === 'http' || mod === 'https' || mod === 'net' || mod === 'dgram') {
|
|
1143
|
+
sinkModuleVars[decl.id.name] = mod;
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
if (node.type === 'ImportDeclaration' && node.source && typeof node.source.value === 'string') {
|
|
1149
|
+
const mod = node.source.value;
|
|
1150
|
+
if (mod === 'http' || mod === 'https' || mod === 'net' || mod === 'dgram') {
|
|
1151
|
+
for (const spec of node.specifiers) {
|
|
1152
|
+
sinkModuleVars[spec.local.name] = mod;
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
});
|
|
1157
|
+
|
|
1158
|
+
function bodyHasSink(body) {
|
|
1159
|
+
let found = null;
|
|
1160
|
+
walkAST({ type: 'Program', body }, (node) => {
|
|
1161
|
+
if (found) return;
|
|
1162
|
+
if (node.type === 'CallExpression') {
|
|
1163
|
+
// fetch(), eval()
|
|
1164
|
+
if (node.callee.type === 'Identifier' && SINK_CALLEE_NAMES.has(node.callee.name)) {
|
|
1165
|
+
found = node.callee.name + '()';
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
// https.request(), http.get()
|
|
1169
|
+
if (node.callee.type === 'MemberExpression') {
|
|
1170
|
+
const chain = getMemberChain(node.callee);
|
|
1171
|
+
if (SINK_MEMBER_METHODS.has(chain)) {
|
|
1172
|
+
found = chain + '()';
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
// Variable-based: const h = require('https'); h.request()
|
|
1176
|
+
if (node.callee.object.type === 'Identifier' && sinkModuleVars[node.callee.object.name]) {
|
|
1177
|
+
const method = node.callee.property.name || node.callee.property.value;
|
|
1178
|
+
if (method === 'request' || method === 'get') {
|
|
1179
|
+
found = sinkModuleVars[node.callee.object.name] + '.' + method + '()';
|
|
1180
|
+
return;
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
// .write(), .send(), .connect()
|
|
1184
|
+
const method = node.callee.property.name || node.callee.property.value;
|
|
1185
|
+
if (SINK_INSTANCE_METHODS.has(method)) {
|
|
1186
|
+
found = method + '()';
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
});
|
|
1192
|
+
return found;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
// Check module.exports = { fn: function() { ... sink ... } }
|
|
1196
|
+
walkAST(ast, (node) => {
|
|
1197
|
+
if (isModuleExportsAssign(node)) {
|
|
1198
|
+
const value = node.expression.right;
|
|
1199
|
+
const exportName = getExportName(node.expression.left);
|
|
1200
|
+
|
|
1201
|
+
if (value.type === 'ObjectExpression' && exportName === 'default') {
|
|
1202
|
+
for (const prop of value.properties) {
|
|
1203
|
+
if (!prop.key) continue;
|
|
1204
|
+
const propName = prop.key.name || prop.key.value || 'unknown';
|
|
1205
|
+
let funcBody = getFunctionBody(prop.value);
|
|
1206
|
+
// Shorthand property referencing a FunctionDeclaration: { reportData }
|
|
1207
|
+
if (!funcBody && prop.value.type === 'Identifier' && localFuncDefs[prop.value.name]) {
|
|
1208
|
+
const fnNode = localFuncDefs[prop.value.name];
|
|
1209
|
+
funcBody = fnNode.body && fnNode.body.type === 'BlockStatement' ? fnNode.body.body : null;
|
|
1210
|
+
}
|
|
1211
|
+
if (funcBody) {
|
|
1212
|
+
const sink = bodyHasSink(funcBody);
|
|
1213
|
+
if (sink) {
|
|
1214
|
+
sinkExports[propName] = { hasSink: true, sink };
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
} else {
|
|
1219
|
+
const funcBody = getFunctionBody(value);
|
|
1220
|
+
if (funcBody) {
|
|
1221
|
+
const sink = bodyHasSink(funcBody);
|
|
1222
|
+
if (sink) {
|
|
1223
|
+
sinkExports[exportName] = { hasSink: true, sink };
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
// export function foo() { ... sink ... }
|
|
1230
|
+
if (node.type === 'ExportNamedDeclaration' && node.declaration) {
|
|
1231
|
+
const decl = node.declaration;
|
|
1232
|
+
if (decl.type === 'FunctionDeclaration' && decl.id) {
|
|
1233
|
+
const funcBody = decl.body && decl.body.type === 'BlockStatement' ? decl.body.body : null;
|
|
1234
|
+
if (funcBody) {
|
|
1235
|
+
const sink = bodyHasSink(funcBody);
|
|
1236
|
+
if (sink) {
|
|
1237
|
+
sinkExports[decl.id.name] = { hasSink: true, sink };
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
});
|
|
1243
|
+
|
|
1244
|
+
return sinkExports;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
/**
|
|
1248
|
+
* Detect callback-based cross-file flows.
|
|
1249
|
+
* Pattern: file imports tainted source fn + sink fn, connects them via callback.
|
|
1250
|
+
* Example: readConfig((err, data) => { sendData(data); })
|
|
1251
|
+
* Also: const data = readConfig(); sendData(data);
|
|
1252
|
+
*/
|
|
1253
|
+
function detectCallbackCrossFileFlows(graph, taintedExports, sinkExports, packagePath) {
|
|
1254
|
+
const expandedTaint = expandTaintThroughReexports(graph, taintedExports, packagePath);
|
|
1255
|
+
const flows = [];
|
|
1256
|
+
|
|
1257
|
+
for (const relFile of Object.keys(graph)) {
|
|
1258
|
+
const absFile = path.resolve(packagePath, relFile);
|
|
1259
|
+
const ast = parseFile(absFile);
|
|
1260
|
+
if (!ast) continue;
|
|
1261
|
+
|
|
1262
|
+
const fileDir = path.dirname(absFile);
|
|
1263
|
+
|
|
1264
|
+
// Collect imported tainted source functions and imported sink functions
|
|
1265
|
+
const importedSources = {}; // varName → { sourceFile, source, detail }
|
|
1266
|
+
const importedSinks = {}; // varName → { sinkFile, sink }
|
|
1267
|
+
|
|
1268
|
+
walkAST(ast, (node) => {
|
|
1269
|
+
if (node.type !== 'VariableDeclaration') return;
|
|
1270
|
+
for (const decl of node.declarations) {
|
|
1271
|
+
if (!decl.init || !decl.id) continue;
|
|
1272
|
+
|
|
1273
|
+
// const { readConfig } = require('./reader')
|
|
1274
|
+
if (isRequireCall(decl.init) && isLocalImport(decl.init.arguments[0].value)) {
|
|
1275
|
+
const spec = decl.init.arguments[0].value;
|
|
1276
|
+
const resolved = resolveLocal(fileDir, spec, packagePath);
|
|
1277
|
+
if (!resolved) continue;
|
|
1278
|
+
|
|
1279
|
+
if (decl.id.type === 'ObjectPattern') {
|
|
1280
|
+
for (const prop of decl.id.properties) {
|
|
1281
|
+
const key = prop.key && (prop.key.name || prop.key.value);
|
|
1282
|
+
const localName = prop.value && prop.value.name;
|
|
1283
|
+
if (!key || !localName) continue;
|
|
1284
|
+
|
|
1285
|
+
// Check if this is a tainted source export
|
|
1286
|
+
if (expandedTaint[resolved] && expandedTaint[resolved][key] && expandedTaint[resolved][key].tainted) {
|
|
1287
|
+
const t = expandedTaint[resolved][key];
|
|
1288
|
+
importedSources[localName] = {
|
|
1289
|
+
sourceFile: t.sourceFile || resolved,
|
|
1290
|
+
source: t.source,
|
|
1291
|
+
detail: t.detail || ''
|
|
1292
|
+
};
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
// Check if this is a sink export
|
|
1296
|
+
if (sinkExports[resolved] && sinkExports[resolved][key] && sinkExports[resolved][key].hasSink) {
|
|
1297
|
+
importedSinks[localName] = {
|
|
1298
|
+
sinkFile: resolved,
|
|
1299
|
+
sink: sinkExports[resolved][key].sink
|
|
1300
|
+
};
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
if (decl.id.type === 'Identifier') {
|
|
1306
|
+
// Whole module import: const reader = require('./reader')
|
|
1307
|
+
// Check default taint
|
|
1308
|
+
if (expandedTaint[resolved] && expandedTaint[resolved]['default'] && expandedTaint[resolved]['default'].tainted) {
|
|
1309
|
+
const t = expandedTaint[resolved]['default'];
|
|
1310
|
+
importedSources[decl.id.name] = {
|
|
1311
|
+
sourceFile: t.sourceFile || resolved,
|
|
1312
|
+
source: t.source,
|
|
1313
|
+
detail: t.detail || ''
|
|
1314
|
+
};
|
|
1315
|
+
}
|
|
1316
|
+
// Check default sink
|
|
1317
|
+
if (sinkExports[resolved] && sinkExports[resolved]['default'] && sinkExports[resolved]['default'].hasSink) {
|
|
1318
|
+
importedSinks[decl.id.name] = {
|
|
1319
|
+
sinkFile: resolved,
|
|
1320
|
+
sink: sinkExports[resolved]['default'].sink
|
|
1321
|
+
};
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
});
|
|
1327
|
+
|
|
1328
|
+
// If we have both imported sources and sinks, check for callback connections
|
|
1329
|
+
if (Object.keys(importedSources).length === 0 || Object.keys(importedSinks).length === 0) continue;
|
|
1330
|
+
|
|
1331
|
+
// Pattern 1: sourceFn(function(err, data) { sinkFn(data); })
|
|
1332
|
+
// Pattern 2: const result = sourceFn(); sinkFn(result);
|
|
1333
|
+
walkAST(ast, (node) => {
|
|
1334
|
+
if (node.type !== 'CallExpression') return;
|
|
1335
|
+
|
|
1336
|
+
// Check if the call is to an imported source
|
|
1337
|
+
const calleeName = node.callee.type === 'Identifier' ? node.callee.name : null;
|
|
1338
|
+
if (!calleeName || !importedSources[calleeName]) return;
|
|
1339
|
+
|
|
1340
|
+
// Check if any argument is a callback that calls an imported sink
|
|
1341
|
+
for (const arg of node.arguments) {
|
|
1342
|
+
if (arg.type === 'FunctionExpression' || arg.type === 'ArrowFunctionExpression') {
|
|
1343
|
+
const body = arg.body.type === 'BlockStatement' ? arg.body.body : [arg.body];
|
|
1344
|
+
walkAST({ type: 'Program', body }, (inner) => {
|
|
1345
|
+
if (inner.type !== 'CallExpression') return;
|
|
1346
|
+
const innerCallee = inner.callee.type === 'Identifier' ? inner.callee.name : null;
|
|
1347
|
+
if (innerCallee && importedSinks[innerCallee]) {
|
|
1348
|
+
const src = importedSources[calleeName];
|
|
1349
|
+
const snk = importedSinks[innerCallee];
|
|
1350
|
+
// Avoid duplicates
|
|
1351
|
+
const key = `${src.sourceFile}→${relFile}→${snk.sinkFile}`;
|
|
1352
|
+
if (!flows.some(f => `${f.sourceFile}→${f.sinkFile}→${snk.sinkFile}` === key)) {
|
|
1353
|
+
flows.push({
|
|
1354
|
+
severity: 'CRITICAL',
|
|
1355
|
+
type: 'cross_file_dataflow',
|
|
1356
|
+
sourceFile: src.sourceFile,
|
|
1357
|
+
source: `${src.source}${src.detail ? '(' + src.detail + ')' : ''}`,
|
|
1358
|
+
sinkFile: relFile,
|
|
1359
|
+
sink: snk.sink,
|
|
1360
|
+
description: `Credential read in ${src.sourceFile} passed via callback to network sink (${snk.sink}) imported from ${snk.sinkFile} in ${relFile}`,
|
|
1361
|
+
});
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
});
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
});
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
return flows;
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1084
1373
|
module.exports = {
|
|
1085
1374
|
buildModuleGraph, annotateTaintedExports, detectCrossFileFlows,
|
|
1375
|
+
annotateSinkExports, detectCallbackCrossFileFlows,
|
|
1086
1376
|
resolveLocal, extractLocalImports, parseFile, isLocalImport, toRel, isFileExists,
|
|
1087
1377
|
tryResolveConcatRequire
|
|
1088
1378
|
};
|
package/src/scoring.js
CHANGED
|
@@ -118,7 +118,9 @@ const FP_COUNT_THRESHOLDS = {
|
|
|
118
118
|
// P4: hash algorithms contain bit manipulation that triggers obfuscation heuristics
|
|
119
119
|
js_obfuscation_pattern: { maxCount: 1, from: 'HIGH', to: 'LOW' },
|
|
120
120
|
// P4: bundled credential_tampering from minified alias resolution (jspdf, lerna)
|
|
121
|
-
credential_tampering: { maxCount: 5, to: 'LOW' }
|
|
121
|
+
credential_tampering: { maxCount: 5, to: 'LOW' },
|
|
122
|
+
// B1 FP reduction: bundled code aliases eval/Function (sinon, storybook, vitest)
|
|
123
|
+
dangerous_call_eval: { maxCount: 3, from: 'MEDIUM', to: 'LOW' }
|
|
122
124
|
};
|
|
123
125
|
|
|
124
126
|
// Types exempt from dist/ downgrade — IOC matches, lifecycle scripts, and
|
|
@@ -133,12 +135,25 @@ const DIST_EXEMPT_TYPES = new Set([
|
|
|
133
135
|
'download_exec_binary', // download + chmod + exec (binary dropper)
|
|
134
136
|
'cross_file_dataflow', // credential read → network exfil across files
|
|
135
137
|
'staged_eval_decode', // eval(atob(...)) (explicit payload staging)
|
|
136
|
-
'reverse_shell'
|
|
138
|
+
'reverse_shell', // net.Socket + connect + pipe (always malicious)
|
|
139
|
+
'remote_code_load', // fetch + eval/Function (multi-stage payload)
|
|
140
|
+
'proxy_data_intercept' // Proxy trap + network (data interception)
|
|
137
141
|
]);
|
|
138
142
|
|
|
139
143
|
// Regex matching dist/build/minified/bundled file paths
|
|
140
144
|
const DIST_FILE_RE = /(?:^|[/\\])(?:dist|build)[/\\]|\.min\.js$|\.bundle\.js$/i;
|
|
141
145
|
|
|
146
|
+
// Bundler artifact types: get two-notch downgrade in dist/ files (CRITICAL→MEDIUM, HIGH→LOW).
|
|
147
|
+
// These are individual pattern signals that bundlers routinely produce (eval for globalThis,
|
|
148
|
+
// dynamic require for code-splitting, minification obfuscation, etc.)
|
|
149
|
+
const DIST_BUNDLER_ARTIFACT_TYPES = new Set([
|
|
150
|
+
'dangerous_call_eval', 'dangerous_call_function',
|
|
151
|
+
'dynamic_require', 'dynamic_import',
|
|
152
|
+
'obfuscation_detected', 'high_entropy_string', 'possible_obfuscation',
|
|
153
|
+
'js_obfuscation_pattern', 'vm_code_execution',
|
|
154
|
+
'module_compile', 'module_compile_dynamic'
|
|
155
|
+
]);
|
|
156
|
+
|
|
142
157
|
// Types exempt from reachability downgrade — IOC matches, lifecycle, and package-level types.
|
|
143
158
|
// NOTE: Uses the base IOC/lifecycle exempt set, NOT full DIST_EXEMPT_TYPES.
|
|
144
159
|
// Compound detections (zlib_inflate_eval, staged_eval_decode, etc.) should still be
|
|
@@ -244,13 +259,23 @@ function applyFPReductions(threats, reachableFiles, packageName) {
|
|
|
244
259
|
}
|
|
245
260
|
}
|
|
246
261
|
|
|
247
|
-
// Dist/build/minified files:
|
|
248
|
-
// Reduced from two-notch (audit fix): 2-notch made dist/ attacks invisible (CRITICAL→MEDIUM=3pts).
|
|
262
|
+
// Dist/build/minified files: severity downgrade for bundler output.
|
|
249
263
|
// Compound detections are exempt (DIST_EXEMPT_TYPES).
|
|
264
|
+
// Bundler artifact types (eval, dynamic_require, obfuscation) get two-notch downgrade
|
|
265
|
+
// (CRITICAL→MEDIUM, HIGH→LOW) since bundlers routinely produce these patterns.
|
|
266
|
+
// Other non-exempt types keep one-notch downgrade.
|
|
250
267
|
if (t.file && !DIST_EXEMPT_TYPES.has(t.type) && DIST_FILE_RE.test(t.file)) {
|
|
251
|
-
if (t.
|
|
252
|
-
|
|
253
|
-
|
|
268
|
+
if (DIST_BUNDLER_ARTIFACT_TYPES.has(t.type)) {
|
|
269
|
+
// Two-notch downgrade for bundler artifacts
|
|
270
|
+
if (t.severity === 'CRITICAL') t.severity = 'MEDIUM';
|
|
271
|
+
else if (t.severity === 'HIGH') t.severity = 'LOW';
|
|
272
|
+
else if (t.severity === 'MEDIUM') t.severity = 'LOW';
|
|
273
|
+
} else {
|
|
274
|
+
// One-notch downgrade for other non-exempt types
|
|
275
|
+
if (t.severity === 'CRITICAL') t.severity = 'HIGH';
|
|
276
|
+
else if (t.severity === 'HIGH') t.severity = 'MEDIUM';
|
|
277
|
+
else if (t.severity === 'MEDIUM') t.severity = 'LOW';
|
|
278
|
+
}
|
|
254
279
|
}
|
|
255
280
|
|
|
256
281
|
// Reachability: findings in files not reachable from entry points → LOW
|