muaddib-scanner 2.10.31 → 2.10.32

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.10.31",
3
+ "version": "2.10.32",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -55,7 +55,7 @@
55
55
  },
56
56
  "devDependencies": {
57
57
  "@eslint/js": "10.0.1",
58
- "eslint": "10.0.3",
58
+ "eslint": "10.1.0",
59
59
  "eslint-plugin-security": "^4.0.0",
60
60
  "globals": "17.4.0"
61
61
  }
@@ -1,7 +1,8 @@
1
+ const { execFileSync } = require('child_process');
1
2
  const fs = require('fs');
2
3
  const path = require('path');
3
4
  const os = require('os');
4
- const { isDockerAvailable } = require('../sandbox/index.js');
5
+ const { isDockerAvailable, SANDBOX_CONCURRENCY_MAX } = require('../sandbox/index.js');
5
6
  const { setVerboseMode, isSandboxEnabled, isCanaryEnabled } = require('./classify.js');
6
7
  const { loadState, saveState, loadDailyStats, saveDailyStats, purgeTarballCache, getParisHour } = require('./state.js');
7
8
  const { isTemporalEnabled, isTemporalAstEnabled, isTemporalPublishEnabled, isTemporalMaintainerEnabled } = require('./temporal.js');
@@ -33,6 +34,26 @@ function cleanupOrphanTmpDirs() {
33
34
  } catch {}
34
35
  }
35
36
 
37
+ function cleanupOrphanContainers() {
38
+ try {
39
+ // List running containers with the sandbox name prefix (npm-audit-*)
40
+ const output = execFileSync('docker', ['ps', '-q', '--filter', 'name=npm-audit-'], {
41
+ stdio: ['pipe', 'pipe', 'pipe'],
42
+ timeout: 10000
43
+ }).toString().trim();
44
+ if (!output) return;
45
+ const ids = output.split(/\s+/).filter(Boolean);
46
+ for (const id of ids) {
47
+ try {
48
+ execFileSync('docker', ['rm', '-f', id], { stdio: 'pipe', timeout: 10000 });
49
+ } catch {}
50
+ }
51
+ console.log(`[MONITOR] Cleaned up ${ids.length} orphan sandbox container(s)`);
52
+ } catch {
53
+ // Docker not available or command failed — skip silently
54
+ }
55
+ }
56
+
36
57
  function reportStats(stats) {
37
58
  const avg = stats.scanned > 0 ? (stats.totalTimeMs / stats.scanned / 1000).toFixed(1) : '0.0';
38
59
  const { t1, t1a, t1b, t2, t3 } = stats.suspectByTier;
@@ -67,6 +88,8 @@ async function startMonitor(options, stats, dailyAlerts, recentlyScanned, downlo
67
88
 
68
89
  // Cleanup temp dirs from previous runs (SIGTERM/crash may leave orphans)
69
90
  cleanupOrphanTmpDirs();
91
+ // Kill orphan sandbox containers from previous crash (npm-audit-* prefix)
92
+ cleanupOrphanContainers();
70
93
  // Layer 3: Purge expired cached tarballs on startup
71
94
  purgeTarballCache();
72
95
 
@@ -137,6 +160,7 @@ async function startMonitor(options, stats, dailyAlerts, recentlyScanned, downlo
137
160
  console.log(`[MONITOR] State loaded — npm last: ${state.npmLastPackage || 'none'}, pypi last: ${state.pypiLastPackage || 'none'}, npm seq: ${state.npmLastSeq || 'none'}`);
138
161
  console.log('[MONITOR] npm changes stream enabled (replicate.npmjs.com) with RSS fallback');
139
162
  console.log(`[MONITOR] Scan concurrency: ${SCAN_CONCURRENCY} (MUADDIB_SCAN_CONCURRENCY to override)`);
163
+ console.log(`[MONITOR] Sandbox concurrency: ${SANDBOX_CONCURRENCY_MAX} (MUADDIB_SANDBOX_CONCURRENCY to override)`);
140
164
  console.log(`[MONITOR] Polling every ${POLL_INTERVAL / 1000}s. Ctrl+C to stop.\n`);
141
165
 
142
166
  let running = true;
@@ -193,6 +217,7 @@ async function startMonitor(options, stats, dailyAlerts, recentlyScanned, downlo
193
217
  module.exports = {
194
218
  startMonitor,
195
219
  cleanupOrphanTmpDirs,
220
+ cleanupOrphanContainers,
196
221
  reportStats,
197
222
  isDailyReportDue,
198
223
  sleep,
@@ -20,6 +20,41 @@ const DOCKER_IMAGE = 'muaddib-sandbox';
20
20
  const CONTAINER_TIMEOUT = 120000; // 120 seconds
21
21
  const SINGLE_RUN_TIMEOUT = 60000; // 60 seconds per run in multi-run mode
22
22
 
23
+ // ── Sandbox concurrency limiter ──
24
+ // Prevents Docker container saturation under load (16 workers × 3 runs = 48 containers).
25
+ // Pattern: same semaphore as src/shared/http-limiter.js.
26
+ const SANDBOX_CONCURRENCY_MAX = Math.max(1, parseInt(process.env.MUADDIB_SANDBOX_CONCURRENCY, 10) || 3);
27
+
28
+ const _sandboxSemaphore = { active: 0, queue: [] };
29
+
30
+ function acquireSandboxSlot() {
31
+ if (_sandboxSemaphore.active < SANDBOX_CONCURRENCY_MAX) {
32
+ _sandboxSemaphore.active++;
33
+ return Promise.resolve();
34
+ }
35
+ return new Promise(resolve => {
36
+ _sandboxSemaphore.queue.push(resolve);
37
+ });
38
+ }
39
+
40
+ function releaseSandboxSlot() {
41
+ if (_sandboxSemaphore.queue.length > 0) {
42
+ const next = _sandboxSemaphore.queue.shift();
43
+ next(); // Transfers slot to next waiter (active count stays the same)
44
+ } else {
45
+ _sandboxSemaphore.active--;
46
+ }
47
+ }
48
+
49
+ function resetSandboxLimiter() {
50
+ _sandboxSemaphore.active = 0;
51
+ _sandboxSemaphore.queue.length = 0;
52
+ }
53
+
54
+ function getSandboxSemaphore() {
55
+ return _sandboxSemaphore;
56
+ }
57
+
23
58
  // Time offsets for multi-run sandbox execution (ms)
24
59
  const TIME_OFFSETS = [
25
60
  { offset: 0, label: 'immediate' },
@@ -238,11 +273,17 @@ async function runSingleSandbox(packageName, options = {}) {
238
273
  // Timeout: kill container
239
274
  const timer = setTimeout(() => {
240
275
  timedOut = true;
241
- console.log(`[SANDBOX] Timeout (${runTimeout / 1000}s). Killing container...`);
276
+ console.log(`[SANDBOX] Timeout (${runTimeout / 1000}s). Killing container ${containerName}...`);
242
277
  try {
243
278
  execFileSync('docker', ['kill', containerName], { stdio: 'pipe', timeout: 5000 });
244
279
  } catch {
245
- proc.kill('SIGKILL');
280
+ // docker kill failed (container in intermediate state) — force remove
281
+ try {
282
+ execFileSync('docker', ['rm', '-f', containerName], { stdio: 'pipe', timeout: 5000 });
283
+ } catch {
284
+ // Last resort: kill the docker client process (container may survive as orphan)
285
+ proc.kill('SIGKILL');
286
+ }
246
287
  }
247
288
  }, runTimeout);
248
289
 
@@ -441,69 +482,81 @@ async function runSandbox(packageName, options = {}) {
441
482
  }
442
483
 
443
484
  const mode = strict ? 'strict' : 'permissive';
444
- console.log(`[SANDBOX] Analyzing "${displayName}" in isolated container (mode: ${mode}${canaryEnabled ? ', canary: on' : ''}${local ? ', local' : ''}, runs: ${TIME_OFFSETS.length})...`);
445
-
446
- const allRuns = [];
447
- let bestResult = cleanResult;
448
-
449
- for (let i = 0; i < TIME_OFFSETS.length; i++) {
450
- const { offset, label } = TIME_OFFSETS[i];
451
- console.log(`[SANDBOX] Run ${i + 1}/${TIME_OFFSETS.length} (${label})...`);
452
-
453
- const runResult = await runSingleSandbox(packageName, {
454
- strict,
455
- canaryTokens,
456
- local,
457
- localAbsPath,
458
- displayName,
459
- timeOffset: offset,
460
- runTimeout: SINGLE_RUN_TIMEOUT
461
- });
462
485
 
463
- allRuns.push({
464
- run: i + 1,
465
- label,
466
- timeOffset: offset,
467
- score: runResult.score,
468
- severity: runResult.severity,
469
- findingCount: runResult.findings.length
470
- });
486
+ // Acquire sandbox slot — blocks if SANDBOX_CONCURRENCY_MAX containers already running
487
+ const queueLen = _sandboxSemaphore.queue.length;
488
+ if (queueLen > 0) {
489
+ console.log(`[SANDBOX] Waiting for sandbox slot (${_sandboxSemaphore.active}/${SANDBOX_CONCURRENCY_MAX} active, ${queueLen} queued)...`);
490
+ }
491
+ await acquireSandboxSlot();
471
492
 
472
- // Keep the result with the highest score
473
- if (runResult.score > bestResult.score) {
474
- bestResult = runResult;
493
+ try {
494
+ console.log(`[SANDBOX] Analyzing "${displayName}" in isolated container (mode: ${mode}${canaryEnabled ? ', canary: on' : ''}${local ? ', local' : ''}, runs: ${TIME_OFFSETS.length}, slots: ${_sandboxSemaphore.active}/${SANDBOX_CONCURRENCY_MAX})...`);
495
+
496
+ const allRuns = [];
497
+ let bestResult = cleanResult;
498
+
499
+ for (let i = 0; i < TIME_OFFSETS.length; i++) {
500
+ const { offset, label } = TIME_OFFSETS[i];
501
+ console.log(`[SANDBOX] Run ${i + 1}/${TIME_OFFSETS.length} (${label})...`);
502
+
503
+ const runResult = await runSingleSandbox(packageName, {
504
+ strict,
505
+ canaryTokens,
506
+ local,
507
+ localAbsPath,
508
+ displayName,
509
+ timeOffset: offset,
510
+ runTimeout: SINGLE_RUN_TIMEOUT
511
+ });
512
+
513
+ allRuns.push({
514
+ run: i + 1,
515
+ label,
516
+ timeOffset: offset,
517
+ score: runResult.score,
518
+ severity: runResult.severity,
519
+ findingCount: runResult.findings.length
520
+ });
521
+
522
+ // Keep the result with the highest score
523
+ if (runResult.score > bestResult.score) {
524
+ bestResult = runResult;
525
+ }
526
+
527
+ // Early exit: CRITICAL found, skip remaining runs
528
+ if (runResult.score >= 80) {
529
+ console.log(`[SANDBOX] Critical score (${runResult.score}) detected in run ${i + 1}. Skipping remaining runs.`);
530
+ break;
531
+ }
475
532
  }
476
533
 
477
- // Early exit: CRITICAL found, skip remaining runs
478
- if (runResult.score >= 80) {
479
- console.log(`[SANDBOX] Critical score (${runResult.score}) detected in run ${i + 1}. Skipping remaining runs.`);
480
- break;
534
+ // If all runs were inconclusive (timeout), propagate inconclusive status
535
+ // instead of returning CLEAN (which would cause false FP relabeling)
536
+ if (bestResult.score === 0 && allRuns.length > 0 && allRuns.every(r => r.score === -1)) {
537
+ bestResult = {
538
+ score: -1,
539
+ severity: 'INCONCLUSIVE',
540
+ findings: [{
541
+ type: 'timeout',
542
+ severity: 'MEDIUM',
543
+ detail: `All ${allRuns.length} runs exceeded timeout — package too large or slow install`,
544
+ evidence: `All ${allRuns.length} runs timed out`
545
+ }],
546
+ raw_report: null,
547
+ suspicious: false,
548
+ inconclusive: true
549
+ };
481
550
  }
482
- }
483
551
 
484
- // If all runs were inconclusive (timeout), propagate inconclusive status
485
- // instead of returning CLEAN (which would cause false FP relabeling)
486
- if (bestResult.score === 0 && allRuns.length > 0 && allRuns.every(r => r.score === -1)) {
487
- bestResult = {
488
- score: -1,
489
- severity: 'INCONCLUSIVE',
490
- findings: [{
491
- type: 'timeout',
492
- severity: 'MEDIUM',
493
- detail: `All ${allRuns.length} runs exceeded timeout — package too large or slow install`,
494
- evidence: `All ${allRuns.length} runs timed out`
495
- }],
496
- raw_report: null,
497
- suspicious: false,
498
- inconclusive: true
499
- };
500
- }
501
-
502
- // Attach multi-run metadata
503
- bestResult.all_runs = allRuns;
504
-
505
- displayResults(bestResult);
506
- return bestResult;
552
+ // Attach multi-run metadata
553
+ bestResult.all_runs = allRuns;
554
+
555
+ displayResults(bestResult);
556
+ return bestResult;
557
+ } finally {
558
+ releaseSandboxSlot();
559
+ }
507
560
  }
508
561
 
509
562
  // ── Static canary detection ──
@@ -812,4 +865,4 @@ function displayResults(result) {
812
865
  }
813
866
  }
814
867
 
815
- module.exports = { buildSandboxImage, runSandbox, runSingleSandbox, scoreFindings, generateNetworkReport, EXFIL_PATTERNS, SAFE_DOMAINS, getSeverity, displayResults, isDockerAvailable, imageExists, STATIC_CANARY_TOKENS, detectStaticCanaryExfiltration, analyzePreloadLog, TIME_OFFSETS, SAFE_SANDBOX_CMDS };
868
+ module.exports = { buildSandboxImage, runSandbox, runSingleSandbox, scoreFindings, generateNetworkReport, EXFIL_PATTERNS, SAFE_DOMAINS, getSeverity, displayResults, isDockerAvailable, imageExists, STATIC_CANARY_TOKENS, detectStaticCanaryExfiltration, analyzePreloadLog, TIME_OFFSETS, SAFE_SANDBOX_CMDS, SANDBOX_CONCURRENCY_MAX, acquireSandboxSlot, releaseSandboxSlot, resetSandboxLimiter, getSandboxSemaphore };