muaddib-scanner 2.11.52 → 2.11.57

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 (55) hide show
  1. package/README.md +1 -1
  2. package/bin/muaddib.js +1 -1
  3. package/package.json +4 -4
  4. package/{self-scan-v2.11.52.json → self-scan-v2.11.57.json} +2 -2
  5. package/src/commands/safe-install.js +0 -1
  6. package/src/index.js +1 -1
  7. package/src/integrations/maintainer-change.js +0 -1
  8. package/src/integrations/webhook.js +1 -3
  9. package/src/ioc/scraper.js +8 -69
  10. package/src/ml/classifier.js +3 -2
  11. package/src/ml/feature-extractor.js +1 -1
  12. package/src/ml/llm-detective.js +2 -2
  13. package/src/monitor/daemon.js +65 -15
  14. package/src/monitor/deferred-sandbox.js +8 -1
  15. package/src/monitor/ingestion.js +4 -4
  16. package/src/monitor/queue.js +133 -73
  17. package/src/monitor/state.js +2 -2
  18. package/src/monitor/webhook.js +9 -10
  19. package/src/output/cyclonedx.js +1 -1
  20. package/src/output/report.js +1 -1
  21. package/src/output/sarif.js +1 -1
  22. package/src/pipeline/executor.js +2 -2
  23. package/src/pipeline/processor.js +2 -2
  24. package/src/runtime/monitor-feed.js +0 -3
  25. package/src/sandbox/compound-triggers.js +2 -2
  26. package/src/sandbox/index.js +219 -104
  27. package/src/scanner/ai-config.js +60 -5
  28. package/src/scanner/ast-detectors/constants.js +12 -0
  29. package/src/scanner/ast-detectors/handle-assignment-expression.js +0 -1
  30. package/src/scanner/ast-detectors/handle-call-expression.js +88 -7
  31. package/src/scanner/ast-detectors/handle-variable-declarator.js +2 -2
  32. package/src/scanner/ast.js +2 -3
  33. package/src/scanner/dataflow.js +1 -2
  34. package/src/scanner/deobfuscate.js +1 -2
  35. package/src/scanner/entropy.js +0 -1
  36. package/src/scanner/github-actions.js +1 -1
  37. package/src/scanner/hash.js +1 -1
  38. package/src/scanner/module-graph/annotate-sinks.js +1 -2
  39. package/src/scanner/module-graph/annotate-tainted.js +1 -2
  40. package/src/scanner/module-graph/detect-callback-flows.js +0 -1
  41. package/src/scanner/module-graph/detect-cross-file.js +1 -1
  42. package/src/scanner/module-graph/detect-event-flows.js +1 -1
  43. package/src/scanner/module-graph/parse-utils.js +1 -2
  44. package/src/scanner/npm-registry.js +1 -4
  45. package/src/scanner/obfuscation.js +0 -1
  46. package/src/scanner/package.js +1 -1
  47. package/src/scanner/python-ast-detectors/handle-setup-call.js +0 -1
  48. package/src/scanner/reachability.js +1 -1
  49. package/src/scanner/shell.js +1 -1
  50. package/src/scanner/temporal-ast-diff.js +1 -2
  51. package/src/scanner/typosquat.js +3 -3
  52. package/src/scoring.js +1 -1
  53. package/src/shared/constants.js +1 -1
  54. package/src/shared/download.js +1 -0
  55. package/src/utils.js +1 -1
@@ -21,6 +21,7 @@ const { parseGvisorLogs, cleanupGvisorLogs } = require('./gvisor-parser.js');
21
21
  const DOCKER_IMAGE = 'muaddib-sandbox';
22
22
  const CONTAINER_TIMEOUT = 120000; // 120 seconds
23
23
  const SINGLE_RUN_TIMEOUT = 90000; // 90 seconds per run in multi-run mode (gVisor ~30% I/O overhead)
24
+ const WATCHDOG_GRACE_MS = 15000; // grace after a kill before force-resolving INCONCLUSIVE (anti-hang)
24
25
 
25
26
  // ── Sandbox concurrency limiter ──
26
27
  // Prevents Docker container saturation under load (main-path T1a/T1b/T2).
@@ -71,6 +72,47 @@ function getSandboxSemaphore() {
71
72
  return _sandboxSemaphore;
72
73
  }
73
74
 
75
+ // ── Live container registry (OOM leak fix) ──
76
+ // Tracks the docker container name of every in-flight sandbox run. Bounded by
77
+ // SANDBOX_CONCURRENCY_MAX + the deferred slot, so it stays tiny. Lets the daemon's
78
+ // memory circuit breaker force-reap orphaned containers under EMERGENCY pressure —
79
+ // gVisor (runsc) sandboxes can survive a normal `docker kill`, and a wedged one is
80
+ // the dominant off-heap leak. Truncating the in-memory queue does nothing for them.
81
+ const _liveContainers = new Set();
82
+
83
+ /**
84
+ * Force-remove every tracked sandbox container (best-effort). Returns the count
85
+ * a removal was attempted for. Called by daemon.js handleMemoryPressure() at
86
+ * EMERGENCY level.
87
+ */
88
+ function killAllSandboxContainers() {
89
+ const names = Array.from(_liveContainers);
90
+ for (const name of names) {
91
+ try { execFileSync('docker', ['rm', '-f', name], { stdio: 'pipe', timeout: 5000 }); } catch { /* already gone */ }
92
+ _liveContainers.delete(name);
93
+ }
94
+ if (names.length > 0) {
95
+ console.log(`[SANDBOX] killAllSandboxContainers: force-removed ${names.length} orphan container(s)`);
96
+ }
97
+ return names.length;
98
+ }
99
+
100
+ /**
101
+ * Standard INCONCLUSIVE sandbox result (score -1). A timed-out / aborted / killed
102
+ * run MUST map to INCONCLUSIVE — never to CLEAN — so a package that deliberately
103
+ * hangs to evade analysis is not relabelled benign downstream.
104
+ */
105
+ function inconclusiveResult(detail, evidence) {
106
+ return {
107
+ score: -1,
108
+ severity: 'INCONCLUSIVE',
109
+ findings: [{ type: 'timeout', severity: 'MEDIUM', detail, evidence: evidence || detail }],
110
+ raw_report: null,
111
+ suspicious: false,
112
+ inconclusive: true
113
+ };
114
+ }
115
+
74
116
  // Time offsets for multi-run sandbox execution (ms)
75
117
  const TIME_OFFSETS = [
76
118
  { offset: 0, label: 'immediate' },
@@ -200,6 +242,115 @@ async function buildSandboxImage() {
200
242
  });
201
243
  }
202
244
 
245
+ // ── Docker run argument construction ──
246
+
247
+ // Build the `docker run` argument vector for a single sandbox execution. Pure: given the run
248
+ // options it returns a string[] with no side effects (the canary helpers it calls are themselves
249
+ // pure), so the isolation flags, the anti-detection hostname, the canary env injection, the gVisor
250
+ // runtime, and the libfaketime/time-offset wiring are all behaviorally testable without spawning
251
+ // Docker. Callers compute containerName + fakeHostname (the kill-ladder needs the former) and pass
252
+ // them in. KEEP IN SYNC with sandbox-runner.sh (caps, tmpfs, env contract).
253
+ function buildDockerArgs(opts = {}) {
254
+ const {
255
+ strict = false, canaryTokens = null, timeOffset = 0, gvisorMode = false,
256
+ containerName, fakeHostname, local = false, localAbsPath = null, packageName, mode
257
+ } = opts;
258
+
259
+ const dockerArgs = [
260
+ 'run',
261
+ '--rm',
262
+ `--name=${containerName}`,
263
+ `--hostname=${fakeHostname}`,
264
+ '--network=bridge',
265
+ '--memory=512m',
266
+ '--cpus=1',
267
+ '--pids-limit=100',
268
+ '--cap-drop=ALL'
269
+ ];
270
+
271
+ // gVisor runtime: use runsc instead of default runc.
272
+ // Performance: configure --directfs and --overlay2=all:memory in daemon.json:
273
+ // "runsc": { "path": "/usr/bin/runsc", "runtimeArgs": ["--directfs", "--overlay2=all:memory"] }
274
+ // --directfs: bypass gofer process for direct filesystem access (fewer RPCs, faster I/O)
275
+ // --overlay2=all:memory: sandbox writes go to tmpfs instead of host (faster, isolated)
276
+ // These flags require gVisor >= 2023-06-01.
277
+ if (gvisorMode) {
278
+ dockerArgs.push('--runtime=runsc');
279
+ dockerArgs.push('-e', 'MUADDIB_GVISOR=1');
280
+ }
281
+
282
+ // Inject canary tokens as environment variables
283
+ if (canaryTokens) {
284
+ for (const [key, value] of Object.entries(canaryTokens)) {
285
+ dockerArgs.push('-e', `${key}=${value}`);
286
+ }
287
+ // Also inject canary file contents as env vars for the entrypoint to write
288
+ dockerArgs.push('-e', `CANARY_ENV_CONTENT=${createCanaryEnvFile(canaryTokens).replace(/\r?\n/g, '\\n')}`);
289
+ dockerArgs.push('-e', `CANARY_NPMRC_CONTENT=${createCanaryNpmrc(canaryTokens).replace(/\r?\n/g, '\\n')}`);
290
+ dockerArgs.push('-e', `CANARY_AWS_CONTENT=${createCanaryAwsCredentials(canaryTokens).replace(/\r?\n/g, '\\n')}`);
291
+ dockerArgs.push('-e', `CANARY_SSH_KEY=${createCanarySshKey().replace(/\r?\n/g, '\\n')}`);
292
+ dockerArgs.push('-e', `CANARY_GITCONFIG=${createCanaryGitconfig().replace(/\r?\n/g, '\\n')}`);
293
+ }
294
+
295
+ // Inject time offset — libfaketime-aware (v2.10.7)
296
+ // Run 1 (offset=0): no libfaketime, preload.js handles JS-level only
297
+ // Runs 2+ (offset>0): libfaketime handles C-level time shift for ALL processes
298
+ // (Node, Python, bash), preload.js TIME_OFFSET=0 to avoid double acceleration
299
+ const useFaketime = timeOffset > 0;
300
+ dockerArgs.push('-e', `NODE_TIMING_OFFSET=${useFaketime ? 0 : timeOffset}`);
301
+
302
+ if (useFaketime) {
303
+ const hours = Math.floor(timeOffset / 3600000);
304
+ const faketimeStr = hours >= 24
305
+ ? `+${Math.floor(hours / 24)}d x1000`
306
+ : `+${hours}h x1000`;
307
+ dockerArgs.push('-e', `MUADDIB_FAKETIME=${faketimeStr}`);
308
+ dockerArgs.push('-e', 'MUADDIB_FAKETIME_ACTIVE=1');
309
+ }
310
+
311
+ // Both modes need NET_RAW for tcpdump (runs as root in entrypoint).
312
+ // gVisor mode: no tcpdump needed — gVisor captures via --strace/--log-packets.
313
+ // Strict mode also needs NET_ADMIN for iptables network blocking.
314
+ // SYS_PTRACE is not needed: strace traces its own child (npm install via su).
315
+ // SETUID + SETGID required for su (privilege drop to sandboxuser).
316
+ // CHOWN required for chown in sandbox-runner.sh.
317
+ if (!gvisorMode) {
318
+ dockerArgs.push('--cap-add=NET_RAW');
319
+ }
320
+ dockerArgs.push('--cap-add=SETUID');
321
+ dockerArgs.push('--cap-add=SETGID');
322
+ dockerArgs.push('--cap-add=CHOWN');
323
+ if (strict) {
324
+ dockerArgs.push('--cap-add=NET_ADMIN');
325
+ }
326
+
327
+ dockerArgs.push('--tmpfs', '/tmp:rw,nosuid,size=64m');
328
+ dockerArgs.push('--tmpfs', '/sandbox/install:rw,nosuid,size=256m');
329
+ dockerArgs.push('--tmpfs', '/home/sandboxuser:rw,noexec,nosuid,size=16m');
330
+ dockerArgs.push('--read-only');
331
+
332
+ // /proc/uptime evasion (T1497.003) handled by preload.js monkey-patching
333
+ // (process.uptime, Date.now, performance.now, process.hrtime)
334
+
335
+ dockerArgs.push('--security-opt', 'no-new-privileges');
336
+
337
+ if (local && localAbsPath) {
338
+ dockerArgs.push('-v', `${localAbsPath}:/sandbox/local-pkg:ro`);
339
+ }
340
+
341
+ dockerArgs.push(DOCKER_IMAGE);
342
+ dockerArgs.push(local ? '/sandbox/local-pkg' : packageName);
343
+ dockerArgs.push(mode);
344
+
345
+ return dockerArgs;
346
+ }
347
+
348
+ // Realistic hostname to evade sandbox detection (T1497.001). The default Docker hostname is a
349
+ // 12-char hex hash — trivially fingerprinted; we use a dev-laptop-XXXX shape with random hex.
350
+ function generateFakeHostname() {
351
+ return `dev-laptop-${crypto.randomBytes(2).toString('hex')}`;
352
+ }
353
+
203
354
  // ── Run single sandbox execution ──
204
355
 
205
356
  async function runSingleSandbox(packageName, options = {}) {
@@ -214,122 +365,77 @@ async function runSingleSandbox(packageName, options = {}) {
214
365
  const timeOffset = options.timeOffset || 0;
215
366
  const runTimeout = options.runTimeout || CONTAINER_TIMEOUT;
216
367
  const gvisorMode = options.gvisor || false;
368
+ const signal = options.signal || null;
217
369
 
218
- return new Promise((resolve) => {
370
+ return new Promise((_resolve) => {
219
371
  let stdout = '';
220
372
  let stderr = '';
221
373
  let timedOut = false;
374
+ let settled = false;
375
+ let timer = null;
376
+ let forceTimer = null;
222
377
  const containerName = `npm-audit-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
223
378
 
224
- // Realistic hostname to evade sandbox detection (T1497.001)
225
- // Default Docker hostname is a 12-char hex hash easily fingerprinted.
226
- const fakeHostname = `dev-laptop-${crypto.randomBytes(2).toString('hex')}`;
227
-
228
- const dockerArgs = [
229
- 'run',
230
- '--rm',
231
- `--name=${containerName}`,
232
- `--hostname=${fakeHostname}`,
233
- '--network=bridge',
234
- '--memory=512m',
235
- '--cpus=1',
236
- '--pids-limit=100',
237
- '--cap-drop=ALL'
238
- ];
239
-
240
- // gVisor runtime: use runsc instead of default runc.
241
- // Performance: configure --directfs and --overlay2=all:memory in daemon.json:
242
- // "runsc": { "path": "/usr/bin/runsc", "runtimeArgs": ["--directfs", "--overlay2=all:memory"] }
243
- // --directfs: bypass gofer process for direct filesystem access (fewer RPCs, faster I/O)
244
- // --overlay2=all:memory: sandbox writes go to tmpfs instead of host (faster, isolated)
245
- // These flags require gVisor >= 2023-06-01.
246
- if (gvisorMode) {
247
- dockerArgs.push('--runtime=runsc');
248
- dockerArgs.push('-e', 'MUADDIB_GVISOR=1');
249
- }
250
-
251
- // Inject canary tokens as environment variables
252
- if (canaryTokens) {
253
- for (const [key, value] of Object.entries(canaryTokens)) {
254
- dockerArgs.push('-e', `${key}=${value}`);
379
+ // Single-settle wrapper: every resolve(...) below routes through this, so
380
+ // cleanup (timers, abort listener, live-container registry) runs exactly once
381
+ // no matter which path finishes the run.
382
+ const resolve = (result) => {
383
+ if (settled) return;
384
+ settled = true;
385
+ if (timer) clearTimeout(timer);
386
+ if (forceTimer) clearTimeout(forceTimer);
387
+ if (signal) { try { signal.removeEventListener('abort', onAbort); } catch { /* not added */ } }
388
+ _liveContainers.delete(containerName);
389
+ _resolve(result);
390
+ };
391
+
392
+ // `docker kill` alone leaves the container record, and under gVisor the runsc
393
+ // sandbox can survive it — rm -f reaps both. --rm makes this a no-op on clean exit.
394
+ const killContainer = () => {
395
+ try { execFileSync('docker', ['kill', containerName], { stdio: 'pipe', timeout: 5000 }); } catch { /* may be gone */ }
396
+ try { execFileSync('docker', ['rm', '-f', containerName], { stdio: 'pipe', timeout: 5000 }); } catch { /* already removed */ }
397
+ };
398
+
399
+ // Kill + arm a watchdog that force-resolves INCONCLUSIVE if the docker client
400
+ // never emits 'close' after the kill (wedged runsc gofer / stdio held open by a
401
+ // grandchild — the multi-hour hang that leaked slots and off-heap memory).
402
+ const triggerKill = (reason) => {
403
+ if (settled) return;
404
+ timedOut = true;
405
+ console.log(`[SANDBOX] ${reason} — killing container ${containerName}...`);
406
+ try { killContainer(); } catch { /* best-effort */ }
407
+ if (!forceTimer) {
408
+ forceTimer = setTimeout(() => {
409
+ if (settled) return;
410
+ console.error(`[SANDBOX] Container ${containerName} did not exit after kill — force-resolving INCONCLUSIVE`);
411
+ try { proc.kill('SIGKILL'); } catch { /* already dead */ }
412
+ resolve(inconclusiveResult(`Killed (${reason}) but process did not exit within grace`));
413
+ }, WATCHDOG_GRACE_MS);
255
414
  }
256
- // Also inject canary file contents as env vars for the entrypoint to write
257
- dockerArgs.push('-e', `CANARY_ENV_CONTENT=${createCanaryEnvFile(canaryTokens).replace(/\r?\n/g, '\\n')}`);
258
- dockerArgs.push('-e', `CANARY_NPMRC_CONTENT=${createCanaryNpmrc(canaryTokens).replace(/\r?\n/g, '\\n')}`);
259
- dockerArgs.push('-e', `CANARY_AWS_CONTENT=${createCanaryAwsCredentials(canaryTokens).replace(/\r?\n/g, '\\n')}`);
260
- dockerArgs.push('-e', `CANARY_SSH_KEY=${createCanarySshKey().replace(/\r?\n/g, '\\n')}`);
261
- dockerArgs.push('-e', `CANARY_GITCONFIG=${createCanaryGitconfig().replace(/\r?\n/g, '\\n')}`);
262
- }
415
+ };
263
416
 
264
- // Inject time offset libfaketime-aware (v2.10.7)
265
- // Run 1 (offset=0): no libfaketime, preload.js handles JS-level only
266
- // Runs 2+ (offset>0): libfaketime handles C-level time shift for ALL processes
267
- // (Node, Python, bash), preload.js TIME_OFFSET=0 to avoid double acceleration
268
- const useFaketime = timeOffset > 0;
269
- dockerArgs.push('-e', `NODE_TIMING_OFFSET=${useFaketime ? 0 : timeOffset}`);
270
-
271
- if (useFaketime) {
272
- const hours = Math.floor(timeOffset / 3600000);
273
- const faketimeStr = hours >= 24
274
- ? `+${Math.floor(hours / 24)}d x1000`
275
- : `+${hours}h x1000`;
276
- dockerArgs.push('-e', `MUADDIB_FAKETIME=${faketimeStr}`);
277
- dockerArgs.push('-e', 'MUADDIB_FAKETIME_ACTIVE=1');
278
- }
417
+ const onAbort = () => triggerKill('Aborted by signal');
279
418
 
280
- // Both modes need NET_RAW for tcpdump (runs as root in entrypoint).
281
- // gVisor mode: no tcpdump needed — gVisor captures via --strace/--log-packets.
282
- // Strict mode also needs NET_ADMIN for iptables network blocking.
283
- // SYS_PTRACE is not needed: strace traces its own child (npm install via su).
284
- // SETUID + SETGID required for su (privilege drop to sandboxuser).
285
- // CHOWN required for chown in sandbox-runner.sh.
286
- if (!gvisorMode) {
287
- dockerArgs.push('--cap-add=NET_RAW');
288
- }
289
- dockerArgs.push('--cap-add=SETUID');
290
- dockerArgs.push('--cap-add=SETGID');
291
- dockerArgs.push('--cap-add=CHOWN');
292
- if (strict) {
293
- dockerArgs.push('--cap-add=NET_ADMIN');
419
+ // Early abort: signal already tripped before we spawned anything.
420
+ if (signal && signal.aborted) {
421
+ resolve(inconclusiveResult('Aborted before launch'));
422
+ return;
294
423
  }
295
424
 
296
- dockerArgs.push('--tmpfs', '/tmp:rw,nosuid,size=64m');
297
- dockerArgs.push('--tmpfs', '/sandbox/install:rw,nosuid,size=256m');
298
- dockerArgs.push('--tmpfs', '/home/sandboxuser:rw,noexec,nosuid,size=16m');
299
- dockerArgs.push('--read-only');
300
-
301
- // /proc/uptime evasion (T1497.003) handled by preload.js monkey-patching
302
- // (process.uptime, Date.now, performance.now, process.hrtime)
425
+ const fakeHostname = generateFakeHostname();
303
426
 
304
- dockerArgs.push('--security-opt', 'no-new-privileges');
305
-
306
- if (local && localAbsPath) {
307
- dockerArgs.push('-v', `${localAbsPath}:/sandbox/local-pkg:ro`);
308
- }
309
-
310
- dockerArgs.push(DOCKER_IMAGE);
311
- dockerArgs.push(local ? '/sandbox/local-pkg' : packageName);
312
- dockerArgs.push(mode);
427
+ const dockerArgs = buildDockerArgs({
428
+ strict, canaryTokens, timeOffset, gvisorMode,
429
+ containerName, fakeHostname, local, localAbsPath, packageName, mode
430
+ });
313
431
 
314
432
  const proc = spawn('docker', dockerArgs);
433
+ _liveContainers.add(containerName);
434
+ if (signal) signal.addEventListener('abort', onAbort, { once: true });
315
435
  let gvisorContainerId = null;
316
436
 
317
- // Timeout: kill container
318
- const timer = setTimeout(() => {
319
- timedOut = true;
320
- console.log(`[SANDBOX] Timeout (${runTimeout / 1000}s). Killing container ${containerName}...`);
321
- try {
322
- execFileSync('docker', ['kill', containerName], { stdio: 'pipe', timeout: 5000 });
323
- } catch {
324
- // docker kill failed (container in intermediate state) — force remove
325
- try {
326
- execFileSync('docker', ['rm', '-f', containerName], { stdio: 'pipe', timeout: 5000 });
327
- } catch {
328
- // Last resort: kill the docker client process (container may survive as orphan)
329
- proc.kill('SIGKILL');
330
- }
331
- }
332
- }, runTimeout);
437
+ // Timeout: kill container + arm anti-hang watchdog (see triggerKill).
438
+ timer = setTimeout(() => triggerKill(`Timeout (${runTimeout / 1000}s)`), runTimeout);
333
439
 
334
440
  proc.stdout.on('data', (data) => {
335
441
  stdout += data.toString();
@@ -348,6 +454,7 @@ async function runSingleSandbox(packageName, options = {}) {
348
454
  }
349
455
 
350
456
  // Forward sandbox progress logs (sanitize ANSI escape sequences)
457
+ // eslint-disable-next-line no-control-regex -- ESC control byte required to match ANSI sequences
351
458
  const text = data.toString().replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
352
459
  for (const line of text.split(/\r?\n/)) {
353
460
  if (line.includes('[SANDBOX]')) {
@@ -491,8 +598,6 @@ async function runSingleSandbox(packageName, options = {}) {
491
598
  findings.push(finding);
492
599
  }
493
600
  // Add preload score (capped at 100 with the rest)
494
- const combinedScore = Math.min(100, score + preloadResult.score);
495
- // We'll use combinedScore below instead of score
496
601
  report._preloadScore = preloadResult.score;
497
602
  }
498
603
 
@@ -663,6 +768,10 @@ async function runSandbox(packageName, options = {}) {
663
768
  let bestResult = cleanResult;
664
769
 
665
770
  for (let i = 0; i < effectiveRuns; i++) {
771
+ if (options.signal && options.signal.aborted) {
772
+ console.log(`[SANDBOX] Aborted — stopping before run ${i + 1}/${effectiveRuns}`);
773
+ break;
774
+ }
666
775
  const { offset, label } = TIME_OFFSETS[i];
667
776
  console.log(`[SANDBOX] Run ${i + 1}/${TIME_OFFSETS.length} (${label})...`);
668
777
 
@@ -674,7 +783,8 @@ async function runSandbox(packageName, options = {}) {
674
783
  displayName,
675
784
  timeOffset: offset,
676
785
  runTimeout: SINGLE_RUN_TIMEOUT,
677
- gvisor: useGvisor
786
+ gvisor: useGvisor,
787
+ signal: options.signal || null
678
788
  });
679
789
 
680
790
  allRuns.push({
@@ -716,6 +826,11 @@ async function runSandbox(packageName, options = {}) {
716
826
  };
717
827
  }
718
828
 
829
+ // Aborted before any run could conclude → INCONCLUSIVE, never CLEAN.
830
+ if (options.signal && options.signal.aborted && bestResult.score <= 0) {
831
+ bestResult = inconclusiveResult('Sandbox aborted before completing a run');
832
+ }
833
+
719
834
  // Attach multi-run metadata
720
835
  bestResult.all_runs = allRuns;
721
836
 
@@ -1060,4 +1175,4 @@ function displayResults(result) {
1060
1175
  }
1061
1176
  }
1062
1177
 
1063
- module.exports = { buildSandboxImage, runSandbox, runSingleSandbox, scoreFindings, generateNetworkReport, EXFIL_PATTERNS, SAFE_DOMAINS, getSeverity, displayResults, isDockerAvailable, imageExists, isGvisorAvailable, STATIC_CANARY_TOKENS, detectStaticCanaryExfiltration, analyzePreloadLog, TIME_OFFSETS, SAFE_SANDBOX_CMDS, SANDBOX_CONCURRENCY_MAX, acquireSandboxSlot, tryAcquireSandboxSlot, releaseSandboxSlot, resetSandboxLimiter, getSandboxSemaphore };
1178
+ module.exports = { buildSandboxImage, runSandbox, runSingleSandbox, buildDockerArgs, generateFakeHostname, scoreFindings, generateNetworkReport, EXFIL_PATTERNS, SAFE_DOMAINS, getSeverity, displayResults, isDockerAvailable, imageExists, isGvisorAvailable, STATIC_CANARY_TOKENS, detectStaticCanaryExfiltration, analyzePreloadLog, TIME_OFFSETS, SAFE_SANDBOX_CMDS, SANDBOX_CONCURRENCY_MAX, acquireSandboxSlot, tryAcquireSandboxSlot, releaseSandboxSlot, resetSandboxLimiter, getSandboxSemaphore, killAllSandboxContainers };
@@ -43,13 +43,31 @@ const AI_CONFIG_FILES = [
43
43
  // These are distinct from AI_CONFIG_FILES: they contain machine-readable hooks
44
44
  // that execute code on project open, not human-readable prompt injection.
45
45
  // Technique: Shai-Hulud (TeamPCP, May 2026) — .claude/settings.json SessionStart hook.
46
+ // Additional mai 2026 surfaces (Cursor / Windsurf / Continue / root Claude Desktop)
47
+ // added after the TrapDoor + Bitwarden CLI campaigns confirmed cross-agent targeting.
46
48
  const IDE_HOOK_FILES = [
47
49
  '.claude/settings.json',
48
50
  '.claude/settings.local.json',
49
51
  '.vscode/tasks.json',
50
- '.kiro/settings/mcp.json'
52
+ '.kiro/settings/mcp.json',
53
+ '.cursor/mcp.json',
54
+ '.continue/config.json',
55
+ '.windsurf/mcp.json',
56
+ 'mcp.json',
57
+ 'claude_desktop_config.json'
51
58
  ];
52
59
 
60
+ // Paths that follow the standard MCP `mcpServers.{name}.command` schema.
61
+ // A package shipping any of these with a `command` entry is hostile: legitimate
62
+ // npm/PyPI packages never ship per-user MCP configurations.
63
+ const MCP_STANDARD_PATHS = new Set([
64
+ '.kiro/settings/mcp.json',
65
+ '.cursor/mcp.json',
66
+ '.windsurf/mcp.json',
67
+ 'mcp.json',
68
+ 'claude_desktop_config.json'
69
+ ]);
70
+
53
71
  // Dangerous shell command patterns in AI config files
54
72
  const SHELL_COMMAND_PATTERNS = [
55
73
  // Download and execute
@@ -120,7 +138,7 @@ function scanAIConfig(targetPath) {
120
138
 
121
139
  const relPath = configFile;
122
140
  // Normalize invisible Unicode BEFORE running regex patterns.
123
- // Without this, an attacker can split keywords with U+200B (`curl`) to
141
+ // Without this, an attacker can split keywords with U+200B (`cu<ZWSP>rl`) to
124
142
  // evade /curl\s+/ — the exact TrapDoor (mai 2026) .cursorrules vector.
125
143
  const invisibleCount = countInvisibleUnicode(content);
126
144
  const normalized = invisibleCount > 0 ? stripInvisibleUnicode(content) : content;
@@ -209,9 +227,46 @@ function analyzeIDEHookFile(content, relPath) {
209
227
  }
210
228
  }
211
229
 
212
- // .kiro/settings/mcp.json
230
+ // Standard MCP config family:
231
+ // .kiro/settings/mcp.json | .cursor/mcp.json | .windsurf/mcp.json
232
+ // | root mcp.json (Claude Desktop project mode)
233
+ // | root claude_desktop_config.json (Claude Desktop global, hostile if shipped)
213
234
  // Structure: { mcpServers: { name: { command, args } } }
214
- if (relPath.includes('.kiro/') && relPath.endsWith('mcp.json')) {
235
+ if (MCP_STANDARD_PATHS.has(relPath)) {
236
+ const mcpServers = parsed.mcpServers;
237
+ if (mcpServers && typeof mcpServers === 'object') {
238
+ for (const [name, config] of Object.entries(mcpServers)) {
239
+ if (config && typeof config === 'object' && config.command) {
240
+ threats.push({
241
+ type: 'ide_hook_autoexec',
242
+ severity: 'CRITICAL',
243
+ message: `IDE auto-exec hook: ${relPath} server "${name}" executes "${config.command}" on project open`,
244
+ file: relPath
245
+ });
246
+ }
247
+ }
248
+ }
249
+ }
250
+
251
+ // .continue/config.json — Continue.dev schema. Two MCP surfaces:
252
+ // 1. experimental.modelContextProtocolServers[].transport.command (canonical)
253
+ // 2. mcpServers.{name}.command (newer alias)
254
+ if (relPath === '.continue/config.json') {
255
+ const exp = parsed.experimental;
256
+ const mcps = exp && Array.isArray(exp.modelContextProtocolServers)
257
+ ? exp.modelContextProtocolServers
258
+ : [];
259
+ for (const srv of mcps) {
260
+ const cmd = srv && srv.transport && srv.transport.command;
261
+ if (cmd) {
262
+ threats.push({
263
+ type: 'ide_hook_autoexec',
264
+ severity: 'CRITICAL',
265
+ message: `IDE auto-exec hook: .continue/config.json modelContextProtocolServer transport executes "${cmd}" on project open`,
266
+ file: relPath
267
+ });
268
+ }
269
+ }
215
270
  const mcpServers = parsed.mcpServers;
216
271
  if (mcpServers && typeof mcpServers === 'object') {
217
272
  for (const [name, config] of Object.entries(mcpServers)) {
@@ -219,7 +274,7 @@ function analyzeIDEHookFile(content, relPath) {
219
274
  threats.push({
220
275
  type: 'ide_hook_autoexec',
221
276
  severity: 'CRITICAL',
222
- message: `IDE auto-exec hook: .kiro/settings/mcp.json server "${name}" executes "${config.command}" on project open`,
277
+ message: `IDE auto-exec hook: .continue/config.json mcpServers "${name}" executes "${config.command}" on project open`,
223
278
  file: relPath
224
279
  });
225
280
  }
@@ -136,6 +136,17 @@ const SENSITIVE_AI_CONFIG_FILES_UNIQUE = [
136
136
  'claude.md', 'claude_desktop_config.json',
137
137
  'mcp.json',
138
138
  '.cursorrules', '.windsurfrules',
139
+ 'copilot-instructions.md',
140
+ 'agents.md', 'agent.md'
141
+ ];
142
+
143
+ // Agent prompt files that live at any directory level — written by TrapDoor-style
144
+ // post-install hooks to the user's home or cwd. Detected via a "standalone" branch
145
+ // that does NOT require an MCP_CONFIG_PATHS dir prefix (otherwise homedir + .cursorrules
146
+ // slips through because '.cursorrules'.includes('.cursor/') is false).
147
+ const AGENT_PROMPT_FILENAMES = [
148
+ '.cursorrules', '.windsurfrules',
149
+ 'claude.md', 'agents.md', 'agent.md',
139
150
  'copilot-instructions.md'
140
151
  ];
141
152
  const SENSITIVE_AI_CONFIG_FILES_ROOT_ONLY = [
@@ -256,6 +267,7 @@ module.exports = {
256
267
  NODE_HOOKABLE_CLASSES,
257
268
  MCP_CONFIG_PATHS,
258
269
  MCP_CONTENT_PATTERNS,
270
+ AGENT_PROMPT_FILENAMES,
259
271
  SENSITIVE_AI_CONFIG_FILES_UNIQUE,
260
272
  SENSITIVE_AI_CONFIG_FILES_ROOT_ONLY,
261
273
  GIT_HOOKS,
@@ -8,7 +8,6 @@ const {
8
8
  NODE_HOOKABLE_CLASSES
9
9
  } = require('./constants.js');
10
10
  const {
11
- extractStringValue,
12
11
  resolveStringConcat,
13
12
  extractStringValueDeep
14
13
  } = require('./helpers.js');