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.
- package/README.md +1 -1
- package/bin/muaddib.js +1 -1
- package/package.json +4 -4
- package/{self-scan-v2.11.52.json → self-scan-v2.11.57.json} +2 -2
- package/src/commands/safe-install.js +0 -1
- package/src/index.js +1 -1
- package/src/integrations/maintainer-change.js +0 -1
- package/src/integrations/webhook.js +1 -3
- package/src/ioc/scraper.js +8 -69
- package/src/ml/classifier.js +3 -2
- package/src/ml/feature-extractor.js +1 -1
- package/src/ml/llm-detective.js +2 -2
- package/src/monitor/daemon.js +65 -15
- package/src/monitor/deferred-sandbox.js +8 -1
- package/src/monitor/ingestion.js +4 -4
- package/src/monitor/queue.js +133 -73
- package/src/monitor/state.js +2 -2
- package/src/monitor/webhook.js +9 -10
- package/src/output/cyclonedx.js +1 -1
- package/src/output/report.js +1 -1
- package/src/output/sarif.js +1 -1
- package/src/pipeline/executor.js +2 -2
- package/src/pipeline/processor.js +2 -2
- package/src/runtime/monitor-feed.js +0 -3
- package/src/sandbox/compound-triggers.js +2 -2
- package/src/sandbox/index.js +219 -104
- package/src/scanner/ai-config.js +60 -5
- package/src/scanner/ast-detectors/constants.js +12 -0
- package/src/scanner/ast-detectors/handle-assignment-expression.js +0 -1
- package/src/scanner/ast-detectors/handle-call-expression.js +88 -7
- package/src/scanner/ast-detectors/handle-variable-declarator.js +2 -2
- package/src/scanner/ast.js +2 -3
- package/src/scanner/dataflow.js +1 -2
- package/src/scanner/deobfuscate.js +1 -2
- package/src/scanner/entropy.js +0 -1
- package/src/scanner/github-actions.js +1 -1
- package/src/scanner/hash.js +1 -1
- package/src/scanner/module-graph/annotate-sinks.js +1 -2
- package/src/scanner/module-graph/annotate-tainted.js +1 -2
- package/src/scanner/module-graph/detect-callback-flows.js +0 -1
- package/src/scanner/module-graph/detect-cross-file.js +1 -1
- package/src/scanner/module-graph/detect-event-flows.js +1 -1
- package/src/scanner/module-graph/parse-utils.js +1 -2
- package/src/scanner/npm-registry.js +1 -4
- package/src/scanner/obfuscation.js +0 -1
- package/src/scanner/package.js +1 -1
- package/src/scanner/python-ast-detectors/handle-setup-call.js +0 -1
- package/src/scanner/reachability.js +1 -1
- package/src/scanner/shell.js +1 -1
- package/src/scanner/temporal-ast-diff.js +1 -2
- package/src/scanner/typosquat.js +3 -3
- package/src/scoring.js +1 -1
- package/src/shared/constants.js +1 -1
- package/src/shared/download.js +1 -0
- package/src/utils.js +1 -1
package/src/sandbox/index.js
CHANGED
|
@@ -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((
|
|
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
|
-
//
|
|
225
|
-
//
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
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
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
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 };
|
package/src/scanner/ai-config.js
CHANGED
|
@@ -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 (`cu
|
|
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
|
-
//
|
|
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 (
|
|
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: .
|
|
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,
|