muaddib-scanner 1.6.8 → 1.6.10

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.fr.md CHANGED
@@ -202,10 +202,12 @@ muaddib sandbox <nom-package>
202
202
  muaddib sandbox <nom-package> --strict
203
203
  ```
204
204
 
205
- Analyse un package dans un container Docker isolé. Capture :
206
- - Connexions réseau (détecte exfiltration vers hosts suspects)
207
- - Accès fichiers (détecte vol credentials : .npmrc, .ssh, .aws, .env)
208
- - Spawn de processus (détecte reverse shells, abus curl/wget)
205
+ Analyse un package dans un container Docker isolé avec monitoring multi-couches :
206
+ - **Traçage système** (strace) : accès fichiers, spawn de processus, monitoring syscalls
207
+ - **Capture réseau** (tcpdump) : résolutions DNS avec IPs résolues, requêtes HTTP (méthode, host, path, body), détection TLS SNI
208
+ - **Diff filesystem** : snapshot avant/après install, détecte les fichiers créés dans des emplacements suspects
209
+ - **Détection exfiltration de données** : 16 patterns sensibles (tokens, credentials, clés SSH, clés privées, .env)
210
+ - **Moteur de scoring** : score de risque 0-100 basé sur la sévérité des comportements
209
211
 
210
212
  Utilisez `--strict` pour bloquer tout trafic réseau sortant non essentiel via iptables.
211
213
 
package/README.md CHANGED
@@ -202,10 +202,12 @@ muaddib sandbox <package-name>
202
202
  muaddib sandbox <package-name> --strict
203
203
  ```
204
204
 
205
- Analyzes a package in an isolated Docker container. Captures:
206
- - Network connections (detects exfiltration to suspicious hosts)
207
- - File access (detects credential theft: .npmrc, .ssh, .aws, .env)
208
- - Process spawns (detects reverse shells, curl/wget abuse)
205
+ Analyzes a package in an isolated Docker container with multi-layer monitoring:
206
+ - **System tracing** (strace): file access, process spawns, syscall monitoring
207
+ - **Network capture** (tcpdump): DNS resolutions with resolved IPs, HTTP requests (method, host, path, body), TLS SNI detection
208
+ - **Filesystem diff**: snapshot before/after install, detects files created in suspicious locations
209
+ - **Data exfiltration detection**: 16 sensitive patterns (tokens, credentials, SSH keys, private keys, .env)
210
+ - **Scoring engine**: 0-100 risk score based on behavioral severity
209
211
 
210
212
  Use `--strict` to block all non-essential outbound network traffic via iptables.
211
213
 
package/bin/muaddib.js CHANGED
@@ -1,5 +1,5 @@
1
- #!/usr/bin/env node
2
- const { execSync } = require('child_process');
1
+ #!/usr/bin/env node
2
+ const { exec } = require('child_process');
3
3
  const { run } = require('../src/index.js');
4
4
  const { updateIOCs } = require('../src/ioc/updater.js');
5
5
  const { watch } = require('../src/watch.js');
@@ -23,15 +23,28 @@ let explainMode = false;
23
23
  let failLevel = 'high';
24
24
  let webhookUrl = null;
25
25
  let paranoidMode = false;
26
+ let excludeDirs = [];
26
27
 
27
28
  for (let i = 0; i < options.length; i++) {
28
29
  if (options[i] === '--json') {
29
30
  jsonOutput = true;
30
31
  } else if (options[i] === '--html') {
31
- htmlOutput = options[i + 1] || 'muaddib-report.html';
32
+ const htmlPath = options[i + 1] || 'muaddib-report.html';
33
+ // CLI-001: Block path traversal
34
+ if (htmlPath.includes('..')) {
35
+ console.error('[ERROR] --html path must not contain path traversal (..)');
36
+ process.exit(1);
37
+ }
38
+ htmlOutput = htmlPath;
32
39
  i++;
33
40
  } else if (options[i] === '--sarif') {
34
- sarifOutput = options[i + 1] || 'muaddib-results.sarif';
41
+ const sarifPath = options[i + 1] || 'muaddib-results.sarif';
42
+ // CLI-001: Block path traversal
43
+ if (sarifPath.includes('..')) {
44
+ console.error('[ERROR] --sarif path must not contain path traversal (..)');
45
+ process.exit(1);
46
+ }
47
+ sarifOutput = sarifPath;
35
48
  i++;
36
49
  } else if (options[i] === '--explain') {
37
50
  explainMode = true;
@@ -39,8 +52,32 @@ for (let i = 0; i < options.length; i++) {
39
52
  failLevel = options[i + 1] || 'high';
40
53
  i++;
41
54
  } else if (options[i] === '--webhook') {
42
- webhookUrl = options[i + 1];
55
+ const rawUrl = options[i + 1];
56
+ // CLI-002: Validate webhook URL (HTTPS only, no private IPs)
57
+ if (rawUrl) {
58
+ try {
59
+ const parsed = new URL(rawUrl);
60
+ if (parsed.protocol !== 'https:') {
61
+ console.error('[ERROR] --webhook URL must use HTTPS');
62
+ process.exit(1);
63
+ }
64
+ const host = parsed.hostname.toLowerCase();
65
+ if (/^(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|0\.|169\.254\.|localhost$|::1$)/.test(host)) {
66
+ console.error('[ERROR] --webhook URL must not point to a private/local address');
67
+ process.exit(1);
68
+ }
69
+ } catch {
70
+ console.error('[ERROR] --webhook URL is invalid');
71
+ process.exit(1);
72
+ }
73
+ }
74
+ webhookUrl = rawUrl;
43
75
  i++;
76
+ } else if (options[i] === '--exclude') {
77
+ if (options[i + 1] && !options[i + 1].startsWith('-')) {
78
+ excludeDirs.push(options[i + 1]);
79
+ i++;
80
+ }
44
81
  } else if (options[i] === '--paranoid') {
45
82
  paranoidMode = true;
46
83
  } else if (options[i] === '--strict') {
@@ -50,17 +87,20 @@ for (let i = 0; i < options.length; i++) {
50
87
  }
51
88
  }
52
89
 
53
- // Version check (non-blocking, skip for machine-readable output)
90
+ // Version check (truly non-blocking, skip for machine-readable output)
54
91
  if (!jsonOutput && !sarifOutput) {
55
92
  try {
56
93
  const currentVersion = require('../package.json').version;
57
- const latest = execSync('npm view muaddib-scanner version', { timeout: 5000 }).toString().trim();
58
- if (latest !== currentVersion) {
59
- console.log(`\n[UPDATE] New version available: ${currentVersion} -> ${latest}`);
60
- console.log(` Run: npm install -g muaddib-scanner@latest\n`);
61
- }
94
+ exec('npm view muaddib-scanner version', { timeout: 5000 }, (err, stdout) => {
95
+ if (err) return; // No network or npm unavailable
96
+ const latest = (stdout || '').toString().trim();
97
+ if (latest && latest !== currentVersion) {
98
+ console.log(`\n[UPDATE] New version available: ${currentVersion} -> ${latest}`);
99
+ console.log(` Run: npm install -g muaddib-scanner@latest\n`);
100
+ }
101
+ });
62
102
  } catch {
63
- // No network or npm unavailable, skip silently
103
+ // Skip silently
64
104
  }
65
105
  }
66
106
 
@@ -273,6 +313,7 @@ const helpText = `
273
313
  --fail-on [level] Fail level (critical|high|medium|low)
274
314
  --webhook [url] Discord/Slack webhook
275
315
  --paranoid Ultra-strict mode
316
+ --exclude [dir] Exclude directory from scan (repeatable)
276
317
  --save-dev, -D Install as dev dependency
277
318
  -g, --global Install globally
278
319
  --force Force install despite threats
@@ -300,9 +341,13 @@ if (command === 'version' || command === '--version' || command === '-v') {
300
341
  explain: explainMode,
301
342
  failLevel: failLevel,
302
343
  webhook: webhookUrl,
303
- paranoid: paranoidMode
344
+ paranoid: paranoidMode,
345
+ exclude: excludeDirs
304
346
  }).then(exitCode => {
305
347
  process.exit(exitCode);
348
+ }).catch(err => {
349
+ console.error('[ERROR]', err.message);
350
+ process.exit(1);
306
351
  });
307
352
  } else if (command === 'watch') {
308
353
  watch(target);
@@ -436,7 +481,7 @@ if (command === 'version' || command === '--version' || command === '-v') {
436
481
  console.log(helpText);
437
482
  process.exit(0);
438
483
  } else {
439
- console.log(`Unknown command: ${command}`);
484
+ console.log(`Unknown command: ${String(command).replace(/[\x00-\x1f\x7f-\x9f]/g, '')}`);
440
485
  console.log('Type "muaddib help" to see available commands.');
441
486
  process.exit(1);
442
487
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "1.6.8",
3
+ "version": "1.6.10",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -52,7 +52,7 @@
52
52
  },
53
53
  "devDependencies": {
54
54
  "@eslint/js": "9.39.2",
55
- "eslint": "9.39.2",
55
+ "eslint": "10.0.0",
56
56
  "eslint-plugin-security": "^3.0.1",
57
57
  "globals": "17.3.0"
58
58
  }
package/src/daemon.js CHANGED
@@ -3,11 +3,9 @@ const path = require('path');
3
3
  const { run } = require('./index.js');
4
4
 
5
5
  let webhookUrl = null;
6
- let isRunning = false;
7
6
 
8
7
  async function startDaemon(options = {}) {
9
8
  webhookUrl = options.webhook || null;
10
- isRunning = true;
11
9
 
12
10
  console.log(`
13
11
  ╔════════════════════════════════════════════╗
@@ -33,9 +31,8 @@ async function startDaemon(options = {}) {
33
31
 
34
32
  // Keep process alive until SIGINT
35
33
  await new Promise((resolve) => {
36
- process.on('SIGINT', () => {
34
+ process.once('SIGINT', () => {
37
35
  console.log('\n[DAEMON] Arret...');
38
- isRunning = false;
39
36
  cleanup();
40
37
  resolve();
41
38
  });
@@ -70,6 +67,10 @@ function watchDirectory(dir) {
70
67
  }
71
68
 
72
69
  // Surveille la creation de node_modules
70
+ if (process.platform === 'linux') {
71
+ console.log('[DAEMON] Note: recursive fs.watch may not work on Linux');
72
+ }
73
+
73
74
  const dirWatcher = fs.watch(dir, (eventType, filename) => {
74
75
  if (filename === 'node_modules' && eventType === 'rename') {
75
76
  const nmPath = path.join(dir, 'node_modules');
@@ -83,6 +84,9 @@ function watchDirectory(dir) {
83
84
  triggerScan(dir);
84
85
  }
85
86
  });
87
+ dirWatcher.on('error', (err) => {
88
+ console.log(`[DAEMON] Watcher error on ${dir}: ${err.message}`);
89
+ });
86
90
  watchers.push(dirWatcher);
87
91
 
88
92
  return watchers;
@@ -96,7 +100,7 @@ function watchFile(filePath, projectDir) {
96
100
  return null; // File deleted between existsSync and statSync
97
101
  }
98
102
 
99
- return fs.watch(filePath, (eventType) => {
103
+ const watcher = fs.watch(filePath, (eventType) => {
100
104
  if (eventType === 'change') {
101
105
  try {
102
106
  const currentMtime = fs.statSync(filePath).mtime.getTime();
@@ -110,36 +114,52 @@ function watchFile(filePath, projectDir) {
110
114
  }
111
115
  }
112
116
  });
117
+ watcher.on('error', (err) => {
118
+ console.log(`[DAEMON] Watcher error on ${filePath}: ${err.message}`);
119
+ });
120
+ return watcher;
113
121
  }
114
122
 
115
123
  function watchNodeModules(nodeModulesPath, projectDir) {
116
- return fs.watch(nodeModulesPath, { recursive: true }, (eventType, filename) => {
124
+ const watcher = fs.watch(nodeModulesPath, { recursive: true }, (eventType, filename) => {
117
125
  if (filename && filename.includes('package.json')) {
118
126
  console.log(`[DAEMON] Nouveau package detecte: ${filename}`);
119
127
  triggerScan(projectDir);
120
128
  }
121
129
  });
130
+ watcher.on('error', (err) => {
131
+ console.log(`[DAEMON] Watcher error on ${nodeModulesPath}: ${err.message}`);
132
+ });
133
+ return watcher;
122
134
  }
123
135
 
124
- let scanTimeout = null;
125
- let lastScanTime = 0;
136
+ // Per-directory scan state to prevent cross-directory scan suppression
137
+ const scanState = new Map();
138
+
139
+ function getScanState(dir) {
140
+ if (!scanState.has(dir)) {
141
+ scanState.set(dir, { timeout: null, lastScanTime: 0 });
142
+ }
143
+ return scanState.get(dir);
144
+ }
126
145
 
127
146
  function triggerScan(dir) {
128
147
  const now = Date.now();
129
-
148
+ const state = getScanState(dir);
149
+
130
150
  // Debounce: attend 3 secondes avant de scanner
131
- if (scanTimeout) {
132
- clearTimeout(scanTimeout);
151
+ if (state.timeout) {
152
+ clearTimeout(state.timeout);
133
153
  }
134
154
 
135
155
  // Evite les scans trop frequents (minimum 10 secondes entre chaque)
136
- if (now - lastScanTime < 10000) {
137
- scanTimeout = setTimeout(() => triggerScan(dir), 10000 - (now - lastScanTime));
156
+ if (now - state.lastScanTime < 10000) {
157
+ state.timeout = setTimeout(() => triggerScan(dir), 10000 - (now - state.lastScanTime));
138
158
  return;
139
159
  }
140
160
 
141
- scanTimeout = setTimeout(async () => {
142
- lastScanTime = Date.now();
161
+ state.timeout = setTimeout(async () => {
162
+ state.lastScanTime = Date.now();
143
163
  console.log(`\n[DAEMON] ========== SCAN AUTOMATIQUE ==========`);
144
164
  console.log(`[DAEMON] Cible: ${dir}`);
145
165
  console.log(`[DAEMON] Heure: ${new Date().toLocaleTimeString()}\n`);
package/src/diff.js CHANGED
@@ -1,24 +1,24 @@
1
- const { execSync } = require('child_process');
1
+ const { execFileSync } = require('child_process');
2
2
  const { run } = require('./index.js');
3
3
  const path = require('path');
4
4
  const fs = require('fs');
5
5
  const os = require('os');
6
6
 
7
7
  // Only allow safe characters in git refs (prevents command injection)
8
- const SAFE_REF_REGEX = /^[a-zA-Z0-9._\-/~^@{}:]+$/;
8
+ const SAFE_REF_REGEX = /^[a-zA-Z0-9._\-/~^@{}]+$/;
9
9
 
10
10
  /**
11
11
  * Get the list of commits/tags for comparison suggestions
12
12
  */
13
13
  function getRecentRefs(targetPath, limit = 10) {
14
14
  try {
15
- const tags = execSync('git tag --sort=-creatordate', {
15
+ const tags = execFileSync('git', ['tag', '--sort=-creatordate'], {
16
16
  cwd: targetPath,
17
17
  encoding: 'utf8',
18
18
  stdio: ['pipe', 'pipe', 'pipe']
19
19
  }).trim().split('\n').filter(Boolean).slice(0, 5);
20
20
 
21
- const commits = execSync(`git log --oneline -${limit}`, {
21
+ const commits = execFileSync('git', ['log', '--oneline', `-${Number(limit) || 10}`], {
22
22
  cwd: targetPath,
23
23
  encoding: 'utf8',
24
24
  stdio: ['pipe', 'pipe', 'pipe']
@@ -35,7 +35,7 @@ function getRecentRefs(targetPath, limit = 10) {
35
35
  */
36
36
  function isGitRepo(targetPath) {
37
37
  try {
38
- execSync('git rev-parse --git-dir', {
38
+ execFileSync('git', ['rev-parse', '--git-dir'], {
39
39
  cwd: targetPath,
40
40
  stdio: ['pipe', 'pipe', 'pipe']
41
41
  });
@@ -50,7 +50,7 @@ function isGitRepo(targetPath) {
50
50
  */
51
51
  function getCurrentCommit(targetPath) {
52
52
  try {
53
- return execSync('git rev-parse HEAD', {
53
+ return execFileSync('git', ['rev-parse', 'HEAD'], {
54
54
  cwd: targetPath,
55
55
  encoding: 'utf8',
56
56
  stdio: ['pipe', 'pipe', 'pipe']
@@ -68,7 +68,7 @@ function resolveRef(targetPath, ref) {
68
68
  return null;
69
69
  }
70
70
  try {
71
- return execSync(`git rev-parse ${ref}`, {
71
+ return execFileSync('git', ['rev-parse', ref], {
72
72
  cwd: targetPath,
73
73
  encoding: 'utf8',
74
74
  stdio: ['pipe', 'pipe', 'pipe']
@@ -83,7 +83,7 @@ function resolveRef(targetPath, ref) {
83
83
  */
84
84
  function hasUncommittedChanges(targetPath) {
85
85
  try {
86
- const status = execSync('git status --porcelain', {
86
+ const status = execFileSync('git', ['status', '--porcelain'], {
87
87
  cwd: targetPath,
88
88
  encoding: 'utf8',
89
89
  stdio: ['pipe', 'pipe', 'pipe']
@@ -106,13 +106,13 @@ function createTempCopyAtCommit(targetPath, commitHash) {
106
106
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'muaddib-diff-'));
107
107
 
108
108
  try {
109
- // Clone the repo to temp directory (use -- to separate paths from options)
110
- execSync(`git clone --quiet -- "${targetPath}" "${tempDir}"`, {
109
+ // Clone the repo to temp directory (use execFileSync to prevent injection)
110
+ execFileSync('git', ['clone', '--quiet', '--', targetPath, tempDir], {
111
111
  stdio: ['pipe', 'pipe', 'pipe']
112
112
  });
113
113
 
114
114
  // Checkout the specific commit
115
- execSync(`git checkout --quiet ${commitHash}`, {
115
+ execFileSync('git', ['checkout', '--quiet', commitHash], {
116
116
  cwd: tempDir,
117
117
  stdio: ['pipe', 'pipe', 'pipe']
118
118
  });
@@ -122,7 +122,7 @@ function createTempCopyAtCommit(targetPath, commitHash) {
122
122
  const packageJsonPath = path.join(tempDir, 'package.json');
123
123
  if (fs.existsSync(packageJsonPath)) {
124
124
  try {
125
- execSync('npm install --quiet --no-audit --no-fund --ignore-scripts', {
125
+ execFileSync('npm', ['install', '--quiet', '--no-audit', '--no-fund', '--ignore-scripts'], {
126
126
  cwd: tempDir,
127
127
  stdio: ['pipe', 'pipe', 'pipe'],
128
128
  timeout: 60000
@@ -204,21 +204,11 @@ function compareThreats(oldThreats, newThreats) {
204
204
  * Run scan and capture results (without console output)
205
205
  */
206
206
  async function runSilentScan(targetPath, options = {}) {
207
- const originalLog = console.log;
208
- const logs = [];
209
- try {
210
- console.log = (...args) => logs.push(args.join(' '));
211
- await run(targetPath, { ...options, json: true });
212
- } finally {
213
- console.log = originalLog;
214
- }
215
-
216
- const jsonOutput = logs.join('\n');
217
- try {
218
- return JSON.parse(jsonOutput);
219
- } catch {
220
- return { threats: [], summary: { total: 0 } };
207
+ const result = await run(targetPath, { ...options, _capture: true });
208
+ if (result && typeof result === 'object' && result.threats) {
209
+ return result;
221
210
  }
211
+ return { threats: [], summary: { total: 0 } };
222
212
  }
223
213
 
224
214
  /**
package/src/hooks-init.js CHANGED
@@ -1,4 +1,4 @@
1
- const { execSync } = require('child_process');
1
+ const { execFileSync } = require('child_process');
2
2
  const fs = require('fs');
3
3
  const path = require('path');
4
4
 
@@ -29,10 +29,16 @@ function detectHookSystem(targetPath) {
29
29
  /**
30
30
  * Initialize hooks for a project
31
31
  */
32
+ const VALID_MODES = ['scan', 'diff'];
33
+ const HOOK_COMMANDS = {
34
+ scan: 'npx muaddib scan . --fail-on high',
35
+ diff: 'npx muaddib diff HEAD --fail-on high'
36
+ };
37
+
32
38
  async function initHooks(targetPath, options = {}) {
33
39
  const resolvedPath = path.resolve(targetPath);
34
40
  const hookType = options.type || 'auto';
35
- const mode = options.mode || 'scan'; // 'scan' or 'diff'
41
+ const mode = VALID_MODES.includes(options.mode) ? options.mode : 'scan';
36
42
 
37
43
  console.log('\n[MUADDIB] Initializing git hooks...\n');
38
44
 
@@ -92,9 +98,10 @@ async function initHusky(targetPath, mode) {
92
98
  if (!fs.existsSync(huskyDir)) {
93
99
  console.log('[INFO] Husky not detected. Installing...');
94
100
  try {
95
- execSync('npx husky install', {
101
+ execFileSync('npx', ['husky', 'install'], {
96
102
  cwd: targetPath,
97
- stdio: 'inherit'
103
+ stdio: 'inherit',
104
+ shell: false
98
105
  });
99
106
  } catch {
100
107
  throw new Error('Failed to install husky. Run: npm install -D husky && npx husky install');
@@ -103,9 +110,7 @@ async function initHusky(targetPath, mode) {
103
110
 
104
111
  // Create pre-commit hook
105
112
  const preCommitPath = path.join(huskyDir, 'pre-commit');
106
- const command = mode === 'diff'
107
- ? 'npx muaddib diff HEAD --fail-on high'
108
- : 'npx muaddib scan . --fail-on high';
113
+ const command = HOOK_COMMANDS[mode];
109
114
 
110
115
  const hookContent = `#!/usr/bin/env sh
111
116
  . "$(dirname -- "$0")/_/husky.sh"
@@ -169,9 +174,7 @@ async function initGitHook(targetPath, mode) {
169
174
  }
170
175
 
171
176
  const preCommitPath = path.join(gitHooksDir, 'pre-commit');
172
- const command = mode === 'diff'
173
- ? 'npx muaddib diff HEAD --fail-on high'
174
- : 'npx muaddib scan . --fail-on high';
177
+ const command = HOOK_COMMANDS[mode];
175
178
 
176
179
  const hookContent = `#!/bin/sh
177
180
  # MUAD'DIB pre-commit hook
@@ -193,11 +196,23 @@ fi
193
196
  exit 0
194
197
  `;
195
198
 
196
- // Backup existing hook
199
+ // Backup existing hook (limit to 3 backups)
197
200
  if (fs.existsSync(preCommitPath)) {
198
201
  const backup = `${preCommitPath}.backup.${Date.now()}`;
199
202
  fs.copyFileSync(preCommitPath, backup);
200
203
  console.log(`[INFO] Backed up existing hook to ${backup}`);
204
+
205
+ // Cleanup old backups, keep only 3 most recent
206
+ try {
207
+ const hooksDir = path.dirname(preCommitPath);
208
+ const backups = fs.readdirSync(hooksDir)
209
+ .filter(f => f.startsWith('pre-commit.backup.'))
210
+ .sort()
211
+ .reverse();
212
+ for (const old of backups.slice(3)) {
213
+ fs.unlinkSync(path.join(hooksDir, old));
214
+ }
215
+ } catch { /* ignore cleanup errors */ }
201
216
  }
202
217
 
203
218
  fs.writeFileSync(preCommitPath, hookContent, { mode: 0o755 });
package/src/index.js CHANGED
@@ -16,6 +16,7 @@ const path = require('path');
16
16
  const { scanGitHubActions } = require('./scanner/github-actions.js');
17
17
  const { detectPythonProject, normalizePythonName } = require('./scanner/python.js');
18
18
  const { loadCachedIOCs } = require('./ioc/updater.js');
19
+ const { setExtraExcludes, getExtraExcludes } = require('./utils.js');
19
20
 
20
21
  // ============================================
21
22
  // SCORING CONSTANTS
@@ -52,12 +53,16 @@ const RISK_THRESHOLDS = {
52
53
  // Maximum score (capped)
53
54
  const MAX_RISK_SCORE = 100;
54
55
 
56
+ const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
57
+
55
58
  // Paranoid mode scanner
56
59
  function scanParanoid(targetPath) {
57
60
  const threats = [];
58
61
 
59
62
  function scanFile(filePath) {
60
63
  try {
64
+ const stat = fs.statSync(filePath);
65
+ if (stat.size > MAX_FILE_SIZE) return;
61
66
  const content = fs.readFileSync(filePath, 'utf8');
62
67
 
63
68
  // Ignore URLs (they often contain patterns like .git)
@@ -81,8 +86,9 @@ function scanParanoid(targetPath) {
81
86
  }
82
87
  }
83
88
 
84
- function walkDir(dir) {
85
- const excluded = ['node_modules', '.git', 'test', 'tests', 'src', 'vscode-extension', '.muaddib-cache', 'data', 'iocs', 'docker'];
89
+ function walkDir(dir, depth) {
90
+ if (depth > 50) return; // Max depth guard (IDX-06)
91
+ const excluded = ['node_modules', '.git', '.muaddib-cache', ...getExtraExcludes()];
86
92
  try {
87
93
  const files = fs.readdirSync(dir);
88
94
  for (const file of files) {
@@ -94,7 +100,7 @@ function scanParanoid(targetPath) {
94
100
 
95
101
  if (stat.isDirectory()) {
96
102
  if (!excluded.includes(file)) {
97
- walkDir(fullPath);
103
+ walkDir(fullPath, depth + 1);
98
104
  }
99
105
  } else if (file.endsWith('.js') || file.endsWith('.json') || file.endsWith('.sh')) {
100
106
  scanFile(fullPath);
@@ -105,7 +111,7 @@ function scanParanoid(targetPath) {
105
111
  }
106
112
  }
107
113
 
108
- walkDir(targetPath);
114
+ walkDir(targetPath, 0);
109
115
  return threats;
110
116
  }
111
117
 
@@ -187,6 +193,11 @@ function checkPyPITyposquatting(deps, targetPath) {
187
193
  }
188
194
 
189
195
  async function run(targetPath, options = {}) {
196
+ // Apply --exclude dirs for this scan
197
+ if (options.exclude && options.exclude.length > 0) {
198
+ setExtraExcludes(options.exclude);
199
+ }
200
+
190
201
  // Detect Python project (synchronous, fast file reads)
191
202
  const pythonDeps = detectPythonProject(targetPath);
192
203
 
@@ -240,7 +251,7 @@ async function run(targetPath, options = {}) {
240
251
 
241
252
  // Sandbox integration
242
253
  let sandboxData = null;
243
- if (options.sandboxResult && options.sandboxResult.findings) {
254
+ if (options.sandboxResult && Array.isArray(options.sandboxResult.findings)) {
244
255
  const sr = options.sandboxResult;
245
256
  const pkg = sr.raw_report?.package || 'unknown';
246
257
  sandboxData = {
@@ -331,6 +342,12 @@ async function run(targetPath, options = {}) {
331
342
  sandbox: sandboxData
332
343
  };
333
344
 
345
+ // _capture mode: return result directly without printing (used by diff.js)
346
+ if (options._capture) {
347
+ setExtraExcludes([]);
348
+ return result;
349
+ }
350
+
334
351
  // JSON output
335
352
  if (options.json) {
336
353
  console.log(JSON.stringify(result, null, 2));
@@ -451,7 +468,7 @@ async function run(targetPath, options = {}) {
451
468
  }
452
469
 
453
470
  // Send webhook if configured
454
- if (options.webhook && threats.length > 0) {
471
+ if (options.webhook && enrichedThreats.length > 0) {
455
472
  try {
456
473
  await sendWebhook(options.webhook, result);
457
474
  console.log(`[OK] Alert sent to webhook`);
@@ -472,7 +489,10 @@ async function run(targetPath, options = {}) {
472
489
  const levelsToCheck = severityLevels[failLevel] || severityLevels.high;
473
490
  const failingThreats = deduped.filter(t => levelsToCheck.includes(t.severity));
474
491
 
475
- return failingThreats.length;
492
+ // Clear runtime excludes
493
+ setExtraExcludes([]);
494
+
495
+ return Math.min(failingThreats.length, 125);
476
496
  }
477
497
 
478
498
  module.exports = { run };