muaddib-scanner 2.2.19 → 2.2.22

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/src/sandbox.js CHANGED
@@ -1,620 +1,631 @@
1
- const { execSync, spawn } = require('child_process');
2
- const path = require('path');
3
- const {
4
- generateCanaryTokens,
5
- createCanaryEnvFile,
6
- createCanaryNpmrc,
7
- detectCanaryExfiltration,
8
- detectCanaryInOutput
9
- } = require('./canary-tokens.js');
10
-
11
- const { NPM_PACKAGE_REGEX } = require('./shared/constants.js');
12
-
13
- const DOCKER_IMAGE = 'muaddib-sandbox';
14
- const CONTAINER_TIMEOUT = 120000; // 120 seconds
15
-
16
- // Domains excluded from network findings (false positives)
17
- const SAFE_DOMAINS = [
18
- 'registry.npmjs.org',
19
- 'github.com',
20
- 'objects.githubusercontent.com',
21
- 'api.github.com',
22
- 'raw.githubusercontent.com',
23
- 'codeload.github.com',
24
- 'npmjs.com',
25
- 'npmjs.org',
26
- 'yarnpkg.com',
27
- 'googleapis.com',
28
- 'cloudflare.com'
29
- ];
30
-
31
- // IPs/ports excluded from connection findings (false positives)
32
- const SAFE_IPS = ['127.0.0.1'];
33
- const PROBE_PORTS = [65535]; // Node.js internal connectivity checks
34
-
35
- // Commands that are always suspicious in a sandbox
36
- const DANGEROUS_CMDS = ['curl', 'wget', 'nc', 'netcat', 'python', 'python3', 'bash', 'sh'];
37
-
38
- // Static canary tokens injected by sandbox-runner.sh (fallback honeypots).
39
- // These are searched in the sandbox report as a complement to the dynamic
40
- // tokens from canary-tokens.js (which use random suffixes per session).
41
- const STATIC_CANARY_TOKENS = {
42
- GITHUB_TOKEN: 'MUADDIB_CANARY_GITHUB_f8k3t0k3n',
43
- NPM_TOKEN: 'MUADDIB_CANARY_NPM_s3cr3tt0k3n',
44
- AWS_ACCESS_KEY_ID: 'MUADDIB_CANARY_AKIAIOSFODNN7EXAMPLE',
45
- AWS_SECRET_ACCESS_KEY: 'MUADDIB_CANARY_wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
46
- SLACK_WEBHOOK_URL: 'MUADDIB_CANARY_SLACK',
47
- DISCORD_WEBHOOK_URL: 'MUADDIB_CANARY_DISCORD'
48
- };
49
-
50
- // Patterns indicating data exfiltration in HTTP bodies
51
- const EXFIL_PATTERNS = [
52
- { pattern: /\bNPM_TOKEN\b/i, label: 'npm token', severity: 'CRITICAL' },
53
- { pattern: /\bGITHUB_TOKEN\b/i, label: 'GitHub token', severity: 'CRITICAL' },
54
- { pattern: /\bAWS_SECRET/i, label: 'AWS credentials', severity: 'CRITICAL' },
55
- { pattern: /npmrc/i, label: '.npmrc content', severity: 'CRITICAL' },
56
- { pattern: /\bssh-rsa\b|\bssh-ed25519\b/i, label: 'SSH key', severity: 'CRITICAL' },
57
- { pattern: /BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY/, label: 'private key', severity: 'CRITICAL' },
58
- { pattern: /\bpassword\b/i, label: 'password', severity: 'CRITICAL' },
59
- { pattern: /\btoken\b/i, label: 'token', severity: 'CRITICAL' },
60
- { pattern: /\/etc\/passwd/, label: 'passwd file', severity: 'HIGH' },
61
- { pattern: /\.env\b/, label: '.env content', severity: 'HIGH' }
62
- ];
63
-
64
- // ── Docker availability checks ──
65
-
66
- function isDockerAvailable() {
67
- try {
68
- execSync('docker info', { stdio: 'pipe', timeout: 10000 });
69
- return true;
70
- } catch {
71
- return false;
72
- }
73
- }
74
-
75
- function imageExists() {
76
- try {
77
- execSync(`docker image inspect ${DOCKER_IMAGE}`, { stdio: 'pipe', timeout: 10000 });
78
- return true;
79
- } catch {
80
- return false;
81
- }
82
- }
83
-
84
- // ── Build image (with cache) ──
85
-
86
- async function buildSandboxImage() {
87
- if (!isDockerAvailable()) {
88
- console.log('[SANDBOX] Docker is not installed or not running. Skipping sandbox analysis.');
89
- return false;
90
- }
91
-
92
- if (imageExists()) {
93
- console.log('[SANDBOX] Using cached Docker image.');
94
- return true;
95
- }
96
-
97
- console.log('[SANDBOX] Building Docker image...');
98
-
99
- return new Promise((resolve) => {
100
- const dockerfilePath = path.join(__dirname, '..', 'docker');
101
- const proc = spawn('docker', ['build', '-t', DOCKER_IMAGE, dockerfilePath], {
102
- stdio: 'inherit'
103
- });
104
-
105
- proc.on('close', (code) => {
106
- if (code === 0) {
107
- console.log('[SANDBOX] Image built successfully.');
108
- resolve(true);
109
- } else {
110
- console.log('[SANDBOX] Docker build failed.');
111
- resolve(false);
112
- }
113
- });
114
-
115
- proc.on('error', () => {
116
- console.log('[SANDBOX] Docker error during build.');
117
- resolve(false);
118
- });
119
- });
120
- }
121
-
122
- // ── Run sandbox analysis ──
123
-
124
- async function runSandbox(packageName, options = {}) {
125
- const cleanResult = { score: 0, severity: 'CLEAN', findings: [], raw_report: null, suspicious: false };
126
-
127
- if (!isDockerAvailable()) {
128
- console.log('[SANDBOX] Docker is not installed or not running. Skipping.');
129
- return cleanResult;
130
- }
131
-
132
- const strict = options.strict || false;
133
- const canaryEnabled = options.canary !== false; // enabled by default
134
- const mode = strict ? 'strict' : 'permissive';
135
-
136
- // Validate package name before passing to container
137
- if (!NPM_PACKAGE_REGEX.test(packageName)) {
138
- console.log('[SANDBOX] Invalid package name: ' + packageName);
139
- return cleanResult;
140
- }
141
-
142
- // Generate canary tokens for this sandbox session
143
- let canaryTokens = null;
144
- if (canaryEnabled) {
145
- const canary = generateCanaryTokens();
146
- canaryTokens = canary.tokens;
147
- }
148
-
149
- console.log(`[SANDBOX] Analyzing "${packageName}" in isolated container (mode: ${mode}${canaryEnabled ? ', canary: on' : ''})...`);
150
-
151
- return new Promise((resolve) => {
152
- let stdout = '';
153
- let stderr = '';
154
- let timedOut = false;
155
- const containerName = `muaddib-sandbox-${Date.now()}`;
156
-
157
- const dockerArgs = [
158
- 'run',
159
- '--rm',
160
- `--name=${containerName}`,
161
- '--network=bridge',
162
- '--memory=512m',
163
- '--cpus=1',
164
- '--pids-limit=100',
165
- '--cap-drop=ALL'
166
- ];
167
-
168
- // Inject canary tokens as environment variables
169
- if (canaryTokens) {
170
- for (const [key, value] of Object.entries(canaryTokens)) {
171
- dockerArgs.push('-e', `${key}=${value}`);
172
- }
173
- // Also inject canary file contents as env vars for the entrypoint to write
174
- dockerArgs.push('-e', `CANARY_ENV_CONTENT=${createCanaryEnvFile(canaryTokens).replace(/\n/g, '\\n')}`);
175
- dockerArgs.push('-e', `CANARY_NPMRC_CONTENT=${createCanaryNpmrc(canaryTokens).replace(/\n/g, '\\n')}`);
176
- }
177
-
178
- // Strict mode needs strace (SYS_PTRACE), packet capture (NET_RAW), and iptables (NET_ADMIN)
179
- if (strict) {
180
- dockerArgs.push('--cap-add=SYS_PTRACE');
181
- dockerArgs.push('--cap-add=NET_RAW');
182
- dockerArgs.push('--cap-add=NET_ADMIN');
183
- }
184
-
185
- dockerArgs.push('--tmpfs', '/tmp:rw,nosuid,size=64m');
186
- dockerArgs.push('--tmpfs', '/sandbox/install:rw,nosuid,size=256m');
187
- dockerArgs.push('--tmpfs', '/home/sandboxuser:rw,noexec,nosuid,size=16m');
188
- dockerArgs.push('--read-only');
189
-
190
- dockerArgs.push('--security-opt', 'no-new-privileges');
191
- dockerArgs.push(DOCKER_IMAGE);
192
- dockerArgs.push(packageName);
193
- dockerArgs.push(mode);
194
-
195
- const proc = spawn('docker', dockerArgs);
196
-
197
- // Timeout: kill container after 120s
198
- const timer = setTimeout(() => {
199
- timedOut = true;
200
- console.log('[SANDBOX] Timeout (120s). Killing container...');
201
- try {
202
- execSync(`docker kill ${containerName}`, { stdio: 'pipe', timeout: 5000 });
203
- } catch {
204
- proc.kill('SIGKILL');
205
- }
206
- }, CONTAINER_TIMEOUT);
207
-
208
- proc.stdout.on('data', (data) => {
209
- stdout += data.toString();
210
- });
211
-
212
- proc.stderr.on('data', (data) => {
213
- stderr += data.toString();
214
- // Forward sandbox progress logs (sanitize ANSI escape sequences)
215
- const text = data.toString().replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
216
- for (const line of text.split('\n')) {
217
- if (line.includes('[SANDBOX]')) {
218
- console.log(line.trim());
219
- }
220
- }
221
- });
222
-
223
- proc.on('close', () => {
224
- clearTimeout(timer);
225
-
226
- if (timedOut) {
227
- const result = {
228
- score: 100,
229
- severity: 'CRITICAL',
230
- findings: [{
231
- type: 'timeout',
232
- severity: 'CRITICAL',
233
- detail: 'Container exceeded 120s timeout',
234
- evidence: `Killed after ${CONTAINER_TIMEOUT}ms`
235
- }],
236
- raw_report: null,
237
- suspicious: true
238
- };
239
- displayResults(result);
240
- resolve(result);
241
- return;
242
- }
243
-
244
- // Parse JSON from container stdout
245
- let report;
246
- try {
247
- // Extract JSON from stdout (may contain log lines before the JSON)
248
- const jsonStart = stdout.indexOf('{');
249
- const jsonEnd = stdout.lastIndexOf('}');
250
- if (jsonStart === -1 || jsonEnd === -1) {
251
- throw new Error('No JSON found in output');
252
- }
253
- report = JSON.parse(stdout.substring(jsonStart, jsonEnd + 1));
254
- } catch (e) {
255
- console.log('[SANDBOX] Failed to parse container output:', e.message);
256
- resolve(cleanResult);
257
- return;
258
- }
259
-
260
- const { score, findings } = scoreFindings(report);
261
-
262
- // Canary token exfiltration detection (dynamic tokens)
263
- if (canaryTokens) {
264
- const networkExfil = detectCanaryExfiltration(report.network || {}, canaryTokens);
265
- const outputExfil = detectCanaryInOutput(stdout, stderr, canaryTokens);
266
-
267
- for (const exfil of [...networkExfil.exfiltrations, ...outputExfil.exfiltrations]) {
268
- findings.push({
269
- type: 'canary_exfiltration',
270
- severity: 'CRITICAL',
271
- detail: `Package attempted to exfiltrate ${exfil.token} (${exfil.foundIn})`,
272
- evidence: exfil.value
273
- });
274
- }
275
- }
276
-
277
- // Static canary token detection (fallback for shell-injected tokens)
278
- const staticExfil = detectStaticCanaryExfiltration(report);
279
- for (const { token, value } of staticExfil) {
280
- const alreadyDetected = findings.some(f =>
281
- f.type === 'canary_exfiltration' && f.detail && f.detail.includes(token)
282
- );
283
- if (!alreadyDetected) {
284
- findings.push({
285
- type: 'canary_exfiltration',
286
- severity: 'CRITICAL',
287
- detail: `Canary token exfiltration detected: ${token}`,
288
- evidence: value
289
- });
290
- }
291
- }
292
-
293
- const finalScore = Math.min(100, findings.reduce((s, f) => {
294
- if (f.type === 'canary_exfiltration') return s + 50;
295
- return s;
296
- }, score));
297
- const severity = getSeverity(finalScore);
298
- const result = { score: finalScore, severity, findings, raw_report: report, suspicious: finalScore > 0 };
299
-
300
- displayResults(result);
301
- resolve(result);
302
- });
303
-
304
- proc.on('error', (err) => {
305
- clearTimeout(timer);
306
- if (err.code === 'ENOENT') {
307
- console.log('[SANDBOX] Docker not found. Please install Docker.');
308
- } else {
309
- console.log(`[SANDBOX] Error: ${err.message}`);
310
- }
311
- resolve(cleanResult);
312
- });
313
- });
314
- }
315
-
316
- // ── Static canary detection ──
317
-
318
- /**
319
- * Detect static canary token exfiltration in a sandbox report.
320
- * Searches HTTP bodies, DNS queries, HTTP request URLs, TLS domains,
321
- * filesystem changes, process commands, and install output.
322
- * @param {object} report - Parsed sandbox report JSON
323
- * @returns {Array<{token: string, value: string}>} Exfiltrated tokens
324
- */
325
- function detectStaticCanaryExfiltration(report) {
326
- const exfiltrated = [];
327
- if (!report) return exfiltrated;
328
-
329
- const searchable = [];
330
-
331
- // Network data
332
- for (const body of (report.network?.http_bodies || [])) if (body) searchable.push(body);
333
- for (const domain of (report.network?.dns_queries || [])) if (domain) searchable.push(domain);
334
- for (const req of (report.network?.http_requests || [])) {
335
- searchable.push(`${req.method || ''} ${req.host || ''}${req.path || ''}`);
336
- }
337
- for (const tls of (report.network?.tls_connections || [])) if (tls.domain) searchable.push(tls.domain);
338
-
339
- // Filesystem + processes
340
- for (const file of (report.filesystem?.created || [])) if (file) searchable.push(file);
341
- for (const proc of (report.processes?.spawned || [])) if (proc.command) searchable.push(proc.command);
342
-
343
- // Install output
344
- if (report.install_output) searchable.push(report.install_output);
345
-
346
- const allOutput = searchable.join('\n');
347
-
348
- for (const [tokenName, tokenValue] of Object.entries(STATIC_CANARY_TOKENS)) {
349
- if (allOutput.includes(tokenValue)) {
350
- exfiltrated.push({ token: tokenName, value: tokenValue });
351
- }
352
- }
353
-
354
- return exfiltrated;
355
- }
356
-
357
- // ── Scoring engine ──
358
-
359
- function scoreFindings(report) {
360
- let score = 0;
361
- const findings = [];
362
-
363
- // 1. Sensitive file reads
364
- for (const file of (report.sensitive_files?.read || [])) {
365
- if (/\.npmrc/.test(file) || /\.ssh/.test(file) || /\.aws/.test(file)) {
366
- score += 40;
367
- findings.push({ type: 'sensitive_file_read', severity: 'CRITICAL', detail: `Read credential file: ${file}`, evidence: file });
368
- } else if (/\/etc\/passwd/.test(file) || /\/etc\/shadow/.test(file)) {
369
- score += 25;
370
- findings.push({ type: 'sensitive_file_read', severity: 'HIGH', detail: `Read system file: ${file}`, evidence: file });
371
- } else if (/\.env/.test(file) || /\.gitconfig/.test(file) || /\.bash_history/.test(file)) {
372
- score += 15;
373
- findings.push({ type: 'sensitive_file_read', severity: 'MEDIUM', detail: `Read config file: ${file}`, evidence: file });
374
- }
375
- }
376
-
377
- // 2. Sensitive file writes (from strace)
378
- for (const file of (report.sensitive_files?.written || [])) {
379
- if (/\.npmrc/.test(file) || /\.ssh/.test(file) || /\.aws/.test(file)) {
380
- score += 40;
381
- findings.push({ type: 'sensitive_file_write', severity: 'CRITICAL', detail: `Write to credential file: ${file}`, evidence: file });
382
- } else if (/\/etc\/passwd/.test(file) || /\/etc\/shadow/.test(file)) {
383
- score += 25;
384
- findings.push({ type: 'sensitive_file_write', severity: 'HIGH', detail: `Write to system file: ${file}`, evidence: file });
385
- } else {
386
- score += 15;
387
- findings.push({ type: 'sensitive_file_write', severity: 'MEDIUM', detail: `Write to sensitive file: ${file}`, evidence: file });
388
- }
389
- }
390
-
391
- // 3. Filesystem changes — files created in suspicious locations
392
- for (const file of (report.filesystem?.created || [])) {
393
- if (/^\/usr\/bin\//.test(file) || /crontab/.test(file) || /\/cron\.d\//.test(file)) {
394
- score += 50;
395
- findings.push({ type: 'suspicious_filesystem', severity: 'CRITICAL', detail: `File created in system path: ${file}`, evidence: file });
396
- } else if (/^\/tmp\//.test(file)) {
397
- score += 30;
398
- findings.push({ type: 'suspicious_filesystem', severity: 'HIGH', detail: `File created in /tmp: ${file}`, evidence: file });
399
- }
400
- }
401
-
402
- // 4a. DNS queries (exclude safe domains)
403
- for (const domain of (report.network?.dns_queries || [])) {
404
- if (isSafeDomain(domain)) continue;
405
- score += 20;
406
- findings.push({ type: 'suspicious_dns', severity: 'HIGH', detail: `DNS query to non-registry domain: ${domain}`, evidence: domain });
407
- }
408
-
409
- // 4b. DNS resolutions extra detail
410
- for (const res of (report.network?.dns_resolutions || [])) {
411
- if (isSafeDomain(res.domain)) continue;
412
- // Already scored in 4a via dns_queries, but flag the resolution for reporting
413
- findings.push({ type: 'dns_resolution', severity: 'INFO', detail: `${res.domain} ${res.ip}`, evidence: `${res.domain}:${res.ip}` });
414
- }
415
-
416
- // 5a. TCP connections (exclude safe hosts, probe ports, localhost)
417
- for (const conn of (report.network?.http_connections || [])) {
418
- if (isSafeHost(conn.host)) continue;
419
- if (SAFE_IPS.includes(conn.host)) continue;
420
- if (PROBE_PORTS.includes(conn.port)) continue;
421
- score += 25;
422
- findings.push({ type: 'suspicious_connection', severity: 'HIGH', detail: `TCP connection to ${conn.host}:${conn.port}`, evidence: `${conn.host}:${conn.port}` });
423
- }
424
-
425
- // 5b. TLS connections — non-safe domains
426
- for (const tls of (report.network?.tls_connections || [])) {
427
- if (isSafeDomain(tls.domain)) continue;
428
- score += 20;
429
- findings.push({ type: 'suspicious_tls', severity: 'HIGH', detail: `TLS connection to ${tls.domain} (${tls.ip}:${tls.port})`, evidence: tls.domain });
430
- }
431
-
432
- // 5c. HTTP exfiltration detection — scan body snippets for sensitive data
433
- for (const body of (report.network?.http_bodies || [])) {
434
- for (const pat of EXFIL_PATTERNS) {
435
- if (pat.pattern.test(body)) {
436
- score += 50;
437
- findings.push({
438
- type: 'data_exfiltration',
439
- severity: pat.severity,
440
- detail: `HTTP body contains ${pat.label}`,
441
- evidence: body.substring(0, 200)
442
- });
443
- break; // One match per body is enough
444
- }
445
- }
446
- }
447
-
448
- // 5d. HTTP requests to non-safe hosts
449
- for (const req of (report.network?.http_requests || [])) {
450
- if (isSafeDomain(req.host)) continue;
451
- score += 20;
452
- findings.push({ type: 'suspicious_http_request', severity: 'HIGH', detail: `${req.method} ${req.host}${req.path}`, evidence: `${req.method} ${req.host}${req.path}` });
453
- }
454
-
455
- // 5e. Blocked connections (strict mode)
456
- for (const blocked of (report.network?.blocked_connections || [])) {
457
- score += 30;
458
- findings.push({ type: 'blocked_connection', severity: 'HIGH', detail: `Blocked outbound to ${blocked.ip}:${blocked.port}`, evidence: `${blocked.ip}:${blocked.port}` });
459
- }
460
-
461
- // 6. Suspicious processes
462
- for (const p of (report.processes?.spawned || [])) {
463
- const cmd = p.command || '';
464
- const basename = cmd.split('/').pop();
465
- if (DANGEROUS_CMDS.some(d => basename === d)) {
466
- score += 40;
467
- findings.push({ type: 'suspicious_process', severity: 'CRITICAL', detail: `Dangerous command spawned: ${cmd}`, evidence: cmd });
468
- } else if (cmd) {
469
- score += 15;
470
- findings.push({ type: 'unknown_process', severity: 'MEDIUM', detail: `Unknown process spawned: ${cmd}`, evidence: cmd });
471
- }
472
- }
473
-
474
- score = Math.min(100, score);
475
- return { score, findings };
476
- }
477
-
478
- // ── Network report (detailed, colored) ──
479
-
480
- function generateNetworkReport(report) {
481
- const lines = [];
482
- const RED = '\x1b[31m';
483
- const YELLOW = '\x1b[33m';
484
- const GREEN = '\x1b[32m';
485
- const CYAN = '\x1b[36m';
486
- const MAGENTA = '\x1b[35m';
487
- const BOLD = '\x1b[1m';
488
- const DIM = '\x1b[2m';
489
- const RESET = '\x1b[0m';
490
-
491
- lines.push('');
492
- lines.push(`${BOLD}${MAGENTA}╔══════════════════════════════════════════════════╗${RESET}`);
493
- lines.push(`${BOLD}${MAGENTA}║ MUAD'DIB Sandbox Network Report ║${RESET}`);
494
- lines.push(`${BOLD}${MAGENTA}╚══════════════════════════════════════════════════╝${RESET}`);
495
- lines.push('');
496
- lines.push(` Package: ${BOLD}${report.package}${RESET}`);
497
- lines.push(` Mode: ${report.mode === 'strict' ? RED + 'STRICT' : GREEN + 'permissive'}${RESET}`);
498
- lines.push(` Time: ${report.timestamp}`);
499
- lines.push(` Duration: ${report.duration_ms}ms`);
500
-
501
- // DNS Resolutions
502
- const dnsRes = report.network?.dns_resolutions || [];
503
- lines.push('');
504
- lines.push(`${BOLD}${CYAN}── DNS Resolutions (${dnsRes.length}) ──${RESET}`);
505
- if (dnsRes.length === 0) {
506
- lines.push(` ${DIM}No DNS resolutions captured${RESET}`);
507
- } else {
508
- for (const r of dnsRes) {
509
- const safe = isSafeDomain(r.domain);
510
- const icon = safe ? GREEN + '[OK]' : YELLOW + '[!!]';
511
- lines.push(` ${icon}${RESET} ${r.domain} → ${r.ip}`);
512
- }
513
- }
514
-
515
- // HTTP Requests
516
- const httpReqs = report.network?.http_requests || [];
517
- lines.push('');
518
- lines.push(`${BOLD}${CYAN}── HTTP Requests (${httpReqs.length}) ──${RESET}`);
519
- if (httpReqs.length === 0) {
520
- lines.push(` ${DIM}No HTTP requests captured${RESET}`);
521
- } else {
522
- for (const req of httpReqs) {
523
- const safe = isSafeDomain(req.host);
524
- const icon = safe ? GREEN + '[OK]' : RED + '[!!]';
525
- lines.push(` ${icon}${RESET} ${req.method} ${req.host}${req.path}`);
526
- }
527
- }
528
-
529
- // TLS Connections
530
- const tlsConns = report.network?.tls_connections || [];
531
- lines.push('');
532
- lines.push(`${BOLD}${CYAN}── TLS Connections (${tlsConns.length}) ──${RESET}`);
533
- if (tlsConns.length === 0) {
534
- lines.push(` ${DIM}No TLS connections captured${RESET}`);
535
- } else {
536
- for (const tls of tlsConns) {
537
- const safe = isSafeDomain(tls.domain);
538
- const icon = safe ? GREEN + '[OK]' : YELLOW + '[!!]';
539
- lines.push(` ${icon}${RESET} ${tls.domain} (${tls.ip}:${tls.port})`);
540
- }
541
- }
542
-
543
- // Blocked Connections (strict mode)
544
- const blocked = report.network?.blocked_connections || [];
545
- if (blocked.length > 0) {
546
- lines.push('');
547
- lines.push(`${BOLD}${RED}── Blocked Connections (${blocked.length}) ──${RESET}`);
548
- for (const b of blocked) {
549
- lines.push(` ${RED}[BLOCKED]${RESET} ${b.ip}:${b.port}`);
550
- }
551
- }
552
-
553
- // Data Exfiltration Alerts
554
- const bodies = report.network?.http_bodies || [];
555
- const exfilAlerts = [];
556
- for (const body of bodies) {
557
- for (const pat of EXFIL_PATTERNS) {
558
- if (pat.pattern.test(body)) {
559
- exfilAlerts.push({ label: pat.label, severity: pat.severity, snippet: body.substring(0, 100) });
560
- break;
561
- }
562
- }
563
- }
564
- if (exfilAlerts.length > 0) {
565
- lines.push('');
566
- lines.push(`${BOLD}${RED}── Data Exfiltration Alerts (${exfilAlerts.length}) ──${RESET}`);
567
- for (const alert of exfilAlerts) {
568
- lines.push(` ${RED}[${alert.severity}]${RESET} ${alert.label} detected in HTTP body`);
569
- lines.push(` ${DIM}${alert.snippet}...${RESET}`);
570
- }
571
- }
572
-
573
- // Raw TCP connections
574
- const conns = report.network?.http_connections || [];
575
- if (conns.length > 0) {
576
- lines.push('');
577
- lines.push(`${BOLD}${CYAN}── Raw TCP Connections (${conns.length}) ──${RESET}`);
578
- for (const c of conns) {
579
- const safe = isSafeHost(c.host);
580
- const icon = safe ? GREEN + '[OK]' : YELLOW + '[!!]';
581
- lines.push(` ${icon}${RESET} ${c.host}:${c.port} (${c.protocol})`);
582
- }
583
- }
584
-
585
- lines.push('');
586
- return lines.join('\n');
587
- }
588
-
589
- // ── Helpers ──
590
-
591
- function isSafeDomain(domain) {
592
- return SAFE_DOMAINS.some(safe => domain === safe || domain.endsWith('.' + safe));
593
- }
594
-
595
- function isSafeHost(host) {
596
- return SAFE_DOMAINS.some(safe => host === safe || host.endsWith('.' + safe));
597
- }
598
-
599
- function getSeverity(score) {
600
- if (score === 0) return 'CLEAN';
601
- if (score <= 20) return 'LOW';
602
- if (score <= 50) return 'MEDIUM';
603
- if (score <= 80) return 'HIGH';
604
- return 'CRITICAL';
605
- }
606
-
607
- function displayResults(result) {
608
- console.log(`\n[SANDBOX] Score: ${result.score}/100 — ${result.severity}`);
609
- if (result.findings.length === 0) {
610
- console.log('[SANDBOX] No suspicious behavior detected.');
611
- } else {
612
- const actionable = result.findings.filter(f => f.severity !== 'INFO');
613
- console.log(`[SANDBOX] ${actionable.length} finding(s):`);
614
- for (const f of actionable) {
615
- console.log(` [${f.severity}] ${f.type}: ${f.detail}`);
616
- }
617
- }
618
- }
619
-
620
- module.exports = { buildSandboxImage, runSandbox, scoreFindings, generateNetworkReport, EXFIL_PATTERNS, SAFE_DOMAINS, getSeverity, displayResults, isDockerAvailable, imageExists, STATIC_CANARY_TOKENS, detectStaticCanaryExfiltration };
1
+ const { execSync, execFileSync, spawn } = require('child_process');
2
+ const crypto = require('crypto');
3
+ const path = require('path');
4
+ const {
5
+ generateCanaryTokens,
6
+ createCanaryEnvFile,
7
+ createCanaryNpmrc,
8
+ detectCanaryExfiltration,
9
+ detectCanaryInOutput
10
+ } = require('./canary-tokens.js');
11
+
12
+ const { NPM_PACKAGE_REGEX } = require('./shared/constants.js');
13
+
14
+ const DOCKER_IMAGE = 'muaddib-sandbox';
15
+ const CONTAINER_TIMEOUT = 120000; // 120 seconds
16
+
17
+ // Domains excluded from network findings (false positives)
18
+ const SAFE_DOMAINS = [
19
+ 'registry.npmjs.org',
20
+ 'github.com',
21
+ 'objects.githubusercontent.com',
22
+ 'api.github.com',
23
+ 'raw.githubusercontent.com',
24
+ 'codeload.github.com',
25
+ 'npmjs.com',
26
+ 'npmjs.org',
27
+ 'yarnpkg.com',
28
+ 'googleapis.com',
29
+ 'cloudflare.com'
30
+ ];
31
+
32
+ // IPs/ports excluded from connection findings (false positives)
33
+ const SAFE_IPS = ['127.0.0.1'];
34
+ const PROBE_PORTS = [65535]; // Node.js internal connectivity checks
35
+
36
+ // Commands that are always suspicious in a sandbox
37
+ const DANGEROUS_CMDS = ['curl', 'wget', 'nc', 'netcat', 'python', 'python3', 'bash', 'sh'];
38
+
39
+ // Static canary tokens injected by sandbox-runner.sh (fallback honeypots).
40
+ // These are searched in the sandbox report as a complement to the dynamic
41
+ // tokens from canary-tokens.js (which use random suffixes per session).
42
+ const STATIC_CANARY_TOKENS = {
43
+ GITHUB_TOKEN: 'MUADDIB_CANARY_GITHUB_f8k3t0k3n',
44
+ NPM_TOKEN: 'MUADDIB_CANARY_NPM_s3cr3tt0k3n',
45
+ AWS_ACCESS_KEY_ID: 'MUADDIB_CANARY_AKIAIOSFODNN7EXAMPLE',
46
+ AWS_SECRET_ACCESS_KEY: 'MUADDIB_CANARY_wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
47
+ SLACK_WEBHOOK_URL: 'MUADDIB_CANARY_SLACK',
48
+ DISCORD_WEBHOOK_URL: 'MUADDIB_CANARY_DISCORD'
49
+ };
50
+
51
+ // Patterns indicating data exfiltration in HTTP bodies
52
+ const EXFIL_PATTERNS = [
53
+ { pattern: /\bNPM_TOKEN\b/i, label: 'npm token', severity: 'CRITICAL' },
54
+ { pattern: /\bGITHUB_TOKEN\b/i, label: 'GitHub token', severity: 'CRITICAL' },
55
+ { pattern: /\bAWS_SECRET/i, label: 'AWS credentials', severity: 'CRITICAL' },
56
+ { pattern: /npmrc/i, label: '.npmrc content', severity: 'CRITICAL' },
57
+ { pattern: /\bssh-rsa\b|\bssh-ed25519\b/i, label: 'SSH key', severity: 'CRITICAL' },
58
+ { pattern: /BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY/, label: 'private key', severity: 'CRITICAL' },
59
+ { pattern: /\bpassword\b/i, label: 'password', severity: 'CRITICAL' },
60
+ { pattern: /\btoken\b/i, label: 'token', severity: 'CRITICAL' },
61
+ { pattern: /\/etc\/passwd/, label: 'passwd file', severity: 'HIGH' },
62
+ { pattern: /\.env\b/, label: '.env content', severity: 'HIGH' }
63
+ ];
64
+
65
+ // ── Docker availability checks ──
66
+
67
+ function isDockerAvailable() {
68
+ try {
69
+ execSync('docker info', { stdio: 'pipe', timeout: 10000 });
70
+ return true;
71
+ } catch {
72
+ return false;
73
+ }
74
+ }
75
+
76
+ function imageExists() {
77
+ try {
78
+ execFileSync('docker', ['image', 'inspect', DOCKER_IMAGE], { stdio: 'pipe', timeout: 10000 });
79
+ return true;
80
+ } catch {
81
+ return false;
82
+ }
83
+ }
84
+
85
+ // ── Build image (with cache) ──
86
+
87
+ async function buildSandboxImage() {
88
+ if (!isDockerAvailable()) {
89
+ console.log('[SANDBOX] Docker is not installed or not running. Skipping sandbox analysis.');
90
+ return false;
91
+ }
92
+
93
+ if (imageExists()) {
94
+ console.log('[SANDBOX] Using cached Docker image.');
95
+ return true;
96
+ }
97
+
98
+ console.log('[SANDBOX] Building Docker image...');
99
+
100
+ return new Promise((resolve) => {
101
+ const dockerfilePath = path.join(__dirname, '..', 'docker').replace(/\\/g, '/');
102
+ const proc = spawn('docker', ['build', '-t', DOCKER_IMAGE, dockerfilePath], {
103
+ stdio: 'inherit'
104
+ });
105
+
106
+ proc.on('close', (code) => {
107
+ if (code === 0) {
108
+ console.log('[SANDBOX] Image built successfully.');
109
+ resolve(true);
110
+ } else {
111
+ console.log('[SANDBOX] Docker build failed.');
112
+ resolve(false);
113
+ }
114
+ });
115
+
116
+ proc.on('error', () => {
117
+ console.log('[SANDBOX] Docker error during build.');
118
+ resolve(false);
119
+ });
120
+ });
121
+ }
122
+
123
+ // ── Run sandbox analysis ──
124
+
125
+ async function runSandbox(packageName, options = {}) {
126
+ const cleanResult = { score: 0, severity: 'CLEAN', findings: [], raw_report: null, suspicious: false };
127
+
128
+ if (!isDockerAvailable()) {
129
+ console.log('[SANDBOX] Docker is not installed or not running. Skipping.');
130
+ return cleanResult;
131
+ }
132
+
133
+ const strict = options.strict || false;
134
+ const canaryEnabled = options.canary !== false; // enabled by default
135
+ const mode = strict ? 'strict' : 'permissive';
136
+
137
+ // Validate package name before passing to container
138
+ if (!NPM_PACKAGE_REGEX.test(packageName)) {
139
+ console.log('[SANDBOX] Invalid package name: ' + packageName);
140
+ return cleanResult;
141
+ }
142
+
143
+ // Generate canary tokens for this sandbox session
144
+ let canaryTokens = null;
145
+ if (canaryEnabled) {
146
+ const canary = generateCanaryTokens();
147
+ canaryTokens = canary.tokens;
148
+ }
149
+
150
+ console.log(`[SANDBOX] Analyzing "${packageName}" in isolated container (mode: ${mode}${canaryEnabled ? ', canary: on' : ''})...`);
151
+
152
+ return new Promise((resolve) => {
153
+ let stdout = '';
154
+ let stderr = '';
155
+ let timedOut = false;
156
+ const containerName = `muaddib-sandbox-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
157
+
158
+ const dockerArgs = [
159
+ 'run',
160
+ '--rm',
161
+ `--name=${containerName}`,
162
+ '--network=bridge',
163
+ '--memory=512m',
164
+ '--cpus=1',
165
+ '--pids-limit=100',
166
+ '--cap-drop=ALL'
167
+ ];
168
+
169
+ // Inject canary tokens as environment variables
170
+ if (canaryTokens) {
171
+ for (const [key, value] of Object.entries(canaryTokens)) {
172
+ dockerArgs.push('-e', `${key}=${value}`);
173
+ }
174
+ // Also inject canary file contents as env vars for the entrypoint to write
175
+ dockerArgs.push('-e', `CANARY_ENV_CONTENT=${createCanaryEnvFile(canaryTokens).replace(/\r?\n/g, '\\n')}`);
176
+ dockerArgs.push('-e', `CANARY_NPMRC_CONTENT=${createCanaryNpmrc(canaryTokens).replace(/\r?\n/g, '\\n')}`);
177
+ }
178
+
179
+ // Both modes need NET_RAW for tcpdump (runs as root in entrypoint).
180
+ // Strict mode also needs NET_ADMIN for iptables network blocking.
181
+ // SYS_PTRACE is not needed: strace traces its own child (npm install via su).
182
+ dockerArgs.push('--cap-add=NET_RAW');
183
+ if (strict) {
184
+ dockerArgs.push('--cap-add=NET_ADMIN');
185
+ }
186
+
187
+ dockerArgs.push('--tmpfs', '/tmp:rw,nosuid,size=64m');
188
+ dockerArgs.push('--tmpfs', '/sandbox/install:rw,nosuid,size=256m');
189
+ dockerArgs.push('--tmpfs', '/home/sandboxuser:rw,noexec,nosuid,size=16m');
190
+ dockerArgs.push('--read-only');
191
+
192
+ dockerArgs.push('--security-opt', 'no-new-privileges');
193
+ dockerArgs.push(DOCKER_IMAGE);
194
+ dockerArgs.push(packageName);
195
+ dockerArgs.push(mode);
196
+
197
+ const proc = spawn('docker', dockerArgs);
198
+
199
+ // Timeout: kill container after 120s
200
+ const timer = setTimeout(() => {
201
+ timedOut = true;
202
+ console.log('[SANDBOX] Timeout (120s). Killing container...');
203
+ try {
204
+ execFileSync('docker', ['kill', containerName], { stdio: 'pipe', timeout: 5000 });
205
+ } catch {
206
+ proc.kill('SIGKILL');
207
+ }
208
+ }, CONTAINER_TIMEOUT);
209
+
210
+ proc.stdout.on('data', (data) => {
211
+ stdout += data.toString();
212
+ });
213
+
214
+ proc.stderr.on('data', (data) => {
215
+ stderr += data.toString();
216
+ // Forward sandbox progress logs (sanitize ANSI escape sequences)
217
+ const text = data.toString().replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
218
+ for (const line of text.split(/\r?\n/)) {
219
+ if (line.includes('[SANDBOX]')) {
220
+ console.log(line.trim());
221
+ }
222
+ }
223
+ });
224
+
225
+ proc.on('close', () => {
226
+ clearTimeout(timer);
227
+
228
+ if (timedOut) {
229
+ const result = {
230
+ score: 100,
231
+ severity: 'CRITICAL',
232
+ findings: [{
233
+ type: 'timeout',
234
+ severity: 'CRITICAL',
235
+ detail: 'Container exceeded 120s timeout',
236
+ evidence: `Killed after ${CONTAINER_TIMEOUT}ms`
237
+ }],
238
+ raw_report: null,
239
+ suspicious: true
240
+ };
241
+ displayResults(result);
242
+ resolve(result);
243
+ return;
244
+ }
245
+
246
+ // Parse JSON from container stdout using delimiter
247
+ let report;
248
+ try {
249
+ const REPORT_DELIMITER = '---MUADDIB-REPORT-START---';
250
+ const delimIdx = stdout.indexOf(REPORT_DELIMITER);
251
+ let jsonStr;
252
+ if (delimIdx !== -1) {
253
+ // Reliable: use delimiter to skip any package output before the report
254
+ jsonStr = stdout.substring(delimIdx + REPORT_DELIMITER.length).trim();
255
+ } else {
256
+ // Fallback: find first '{' (backward compat with older images)
257
+ const jsonStart = stdout.indexOf('{');
258
+ const jsonEnd = stdout.lastIndexOf('}');
259
+ if (jsonStart === -1 || jsonEnd === -1) {
260
+ throw new Error('No JSON found in output');
261
+ }
262
+ jsonStr = stdout.substring(jsonStart, jsonEnd + 1);
263
+ }
264
+ report = JSON.parse(jsonStr);
265
+ } catch (e) {
266
+ console.log('[SANDBOX] Failed to parse container output:', e.message);
267
+ resolve(cleanResult);
268
+ return;
269
+ }
270
+
271
+ const { score, findings } = scoreFindings(report);
272
+
273
+ // Canary token exfiltration detection (dynamic tokens)
274
+ if (canaryTokens) {
275
+ const networkExfil = detectCanaryExfiltration(report.network || {}, canaryTokens);
276
+ const outputExfil = detectCanaryInOutput(stdout, stderr, canaryTokens);
277
+
278
+ for (const exfil of [...networkExfil.exfiltrations, ...outputExfil.exfiltrations]) {
279
+ findings.push({
280
+ type: 'canary_exfiltration',
281
+ severity: 'CRITICAL',
282
+ detail: `Package attempted to exfiltrate ${exfil.token} (${exfil.foundIn})`,
283
+ evidence: exfil.value
284
+ });
285
+ }
286
+ }
287
+
288
+ // Static canary token detection (fallback for shell-injected tokens)
289
+ const staticExfil = detectStaticCanaryExfiltration(report);
290
+ for (const { token, value } of staticExfil) {
291
+ const alreadyDetected = findings.some(f =>
292
+ f.type === 'canary_exfiltration' && f.detail && f.detail.includes(token)
293
+ );
294
+ if (!alreadyDetected) {
295
+ findings.push({
296
+ type: 'canary_exfiltration',
297
+ severity: 'CRITICAL',
298
+ detail: `Canary token exfiltration detected: ${token}`,
299
+ evidence: value
300
+ });
301
+ }
302
+ }
303
+
304
+ const finalScore = Math.min(100, findings.reduce((s, f) => {
305
+ if (f.type === 'canary_exfiltration') return s + 50;
306
+ return s;
307
+ }, score));
308
+ const severity = getSeverity(finalScore);
309
+ const result = { score: finalScore, severity, findings, raw_report: report, suspicious: finalScore > 0 };
310
+
311
+ displayResults(result);
312
+ resolve(result);
313
+ });
314
+
315
+ proc.on('error', (err) => {
316
+ clearTimeout(timer);
317
+ if (err.code === 'ENOENT') {
318
+ console.log('[SANDBOX] Docker not found. Please install Docker.');
319
+ } else {
320
+ console.log(`[SANDBOX] Error: ${err.message}`);
321
+ }
322
+ resolve(cleanResult);
323
+ });
324
+ });
325
+ }
326
+
327
+ // ── Static canary detection ──
328
+
329
+ /**
330
+ * Detect static canary token exfiltration in a sandbox report.
331
+ * Searches HTTP bodies, DNS queries, HTTP request URLs, TLS domains,
332
+ * filesystem changes, process commands, and install output.
333
+ * @param {object} report - Parsed sandbox report JSON
334
+ * @returns {Array<{token: string, value: string}>} Exfiltrated tokens
335
+ */
336
+ function detectStaticCanaryExfiltration(report) {
337
+ const exfiltrated = [];
338
+ if (!report) return exfiltrated;
339
+
340
+ const searchable = [];
341
+
342
+ // Network data
343
+ for (const body of (report.network?.http_bodies || [])) if (body) searchable.push(body);
344
+ for (const domain of (report.network?.dns_queries || [])) if (domain) searchable.push(domain);
345
+ for (const req of (report.network?.http_requests || [])) {
346
+ searchable.push(`${req.method || ''} ${req.host || ''}${req.path || ''}`);
347
+ }
348
+ for (const tls of (report.network?.tls_connections || [])) if (tls.domain) searchable.push(tls.domain);
349
+
350
+ // Filesystem + processes
351
+ for (const file of (report.filesystem?.created || [])) if (file) searchable.push(file);
352
+ for (const proc of (report.processes?.spawned || [])) if (proc.command) searchable.push(proc.command);
353
+
354
+ // Install output
355
+ if (report.install_output) searchable.push(report.install_output);
356
+
357
+ const allOutput = searchable.join('\n');
358
+
359
+ for (const [tokenName, tokenValue] of Object.entries(STATIC_CANARY_TOKENS)) {
360
+ if (allOutput.includes(tokenValue)) {
361
+ exfiltrated.push({ token: tokenName, value: tokenValue });
362
+ }
363
+ }
364
+
365
+ return exfiltrated;
366
+ }
367
+
368
+ // ── Scoring engine ──
369
+
370
+ function scoreFindings(report) {
371
+ let score = 0;
372
+ const findings = [];
373
+
374
+ // 1. Sensitive file reads
375
+ for (const file of (report.sensitive_files?.read || [])) {
376
+ if (/\.npmrc/.test(file) || /\.ssh/.test(file) || /\.aws/.test(file)) {
377
+ score += 40;
378
+ findings.push({ type: 'sensitive_file_read', severity: 'CRITICAL', detail: `Read credential file: ${file}`, evidence: file });
379
+ } else if (/\/etc\/passwd/.test(file) || /\/etc\/shadow/.test(file)) {
380
+ score += 25;
381
+ findings.push({ type: 'sensitive_file_read', severity: 'HIGH', detail: `Read system file: ${file}`, evidence: file });
382
+ } else if (/\.env/.test(file) || /\.gitconfig/.test(file) || /\.bash_history/.test(file)) {
383
+ score += 15;
384
+ findings.push({ type: 'sensitive_file_read', severity: 'MEDIUM', detail: `Read config file: ${file}`, evidence: file });
385
+ }
386
+ }
387
+
388
+ // 2. Sensitive file writes (from strace)
389
+ for (const file of (report.sensitive_files?.written || [])) {
390
+ if (/\.npmrc/.test(file) || /\.ssh/.test(file) || /\.aws/.test(file)) {
391
+ score += 40;
392
+ findings.push({ type: 'sensitive_file_write', severity: 'CRITICAL', detail: `Write to credential file: ${file}`, evidence: file });
393
+ } else if (/\/etc\/passwd/.test(file) || /\/etc\/shadow/.test(file)) {
394
+ score += 25;
395
+ findings.push({ type: 'sensitive_file_write', severity: 'HIGH', detail: `Write to system file: ${file}`, evidence: file });
396
+ } else {
397
+ score += 15;
398
+ findings.push({ type: 'sensitive_file_write', severity: 'MEDIUM', detail: `Write to sensitive file: ${file}`, evidence: file });
399
+ }
400
+ }
401
+
402
+ // 3. Filesystem changes files created in suspicious locations
403
+ for (const file of (report.filesystem?.created || [])) {
404
+ if (/^\/usr\/bin\//.test(file) || /crontab/.test(file) || /\/cron\.d\//.test(file)) {
405
+ score += 50;
406
+ findings.push({ type: 'suspicious_filesystem', severity: 'CRITICAL', detail: `File created in system path: ${file}`, evidence: file });
407
+ } else if (/^\/tmp\//.test(file)) {
408
+ score += 30;
409
+ findings.push({ type: 'suspicious_filesystem', severity: 'HIGH', detail: `File created in /tmp: ${file}`, evidence: file });
410
+ }
411
+ }
412
+
413
+ // 4a. DNS queries (exclude safe domains)
414
+ for (const domain of (report.network?.dns_queries || [])) {
415
+ if (isSafeDomain(domain)) continue;
416
+ score += 20;
417
+ findings.push({ type: 'suspicious_dns', severity: 'HIGH', detail: `DNS query to non-registry domain: ${domain}`, evidence: domain });
418
+ }
419
+
420
+ // 4b. DNS resolutions — extra detail
421
+ for (const res of (report.network?.dns_resolutions || [])) {
422
+ if (isSafeDomain(res.domain)) continue;
423
+ // Already scored in 4a via dns_queries, but flag the resolution for reporting
424
+ findings.push({ type: 'dns_resolution', severity: 'INFO', detail: `${res.domain} → ${res.ip}`, evidence: `${res.domain}:${res.ip}` });
425
+ }
426
+
427
+ // 5a. TCP connections (exclude safe hosts, probe ports, localhost)
428
+ for (const conn of (report.network?.http_connections || [])) {
429
+ if (isSafeHost(conn.host)) continue;
430
+ if (SAFE_IPS.includes(conn.host)) continue;
431
+ if (PROBE_PORTS.includes(conn.port)) continue;
432
+ score += 25;
433
+ findings.push({ type: 'suspicious_connection', severity: 'HIGH', detail: `TCP connection to ${conn.host}:${conn.port}`, evidence: `${conn.host}:${conn.port}` });
434
+ }
435
+
436
+ // 5b. TLS connections — non-safe domains
437
+ for (const tls of (report.network?.tls_connections || [])) {
438
+ if (isSafeDomain(tls.domain)) continue;
439
+ score += 20;
440
+ findings.push({ type: 'suspicious_tls', severity: 'HIGH', detail: `TLS connection to ${tls.domain} (${tls.ip}:${tls.port})`, evidence: tls.domain });
441
+ }
442
+
443
+ // 5c. HTTP exfiltration detection — scan body snippets for sensitive data
444
+ for (const body of (report.network?.http_bodies || [])) {
445
+ for (const pat of EXFIL_PATTERNS) {
446
+ if (pat.pattern.test(body)) {
447
+ score += 50;
448
+ findings.push({
449
+ type: 'data_exfiltration',
450
+ severity: pat.severity,
451
+ detail: `HTTP body contains ${pat.label}`,
452
+ evidence: body.substring(0, 200)
453
+ });
454
+ break; // One match per body is enough
455
+ }
456
+ }
457
+ }
458
+
459
+ // 5d. HTTP requests to non-safe hosts
460
+ for (const req of (report.network?.http_requests || [])) {
461
+ if (isSafeDomain(req.host)) continue;
462
+ score += 20;
463
+ findings.push({ type: 'suspicious_http_request', severity: 'HIGH', detail: `${req.method} ${req.host}${req.path}`, evidence: `${req.method} ${req.host}${req.path}` });
464
+ }
465
+
466
+ // 5e. Blocked connections (strict mode)
467
+ for (const blocked of (report.network?.blocked_connections || [])) {
468
+ score += 30;
469
+ findings.push({ type: 'blocked_connection', severity: 'HIGH', detail: `Blocked outbound to ${blocked.ip}:${blocked.port}`, evidence: `${blocked.ip}:${blocked.port}` });
470
+ }
471
+
472
+ // 6. Suspicious processes
473
+ for (const p of (report.processes?.spawned || [])) {
474
+ const cmd = p.command || '';
475
+ const basename = path.basename(cmd);
476
+ if (DANGEROUS_CMDS.some(d => basename === d)) {
477
+ score += 40;
478
+ findings.push({ type: 'suspicious_process', severity: 'CRITICAL', detail: `Dangerous command spawned: ${cmd}`, evidence: cmd });
479
+ } else if (cmd) {
480
+ score += 15;
481
+ findings.push({ type: 'unknown_process', severity: 'MEDIUM', detail: `Unknown process spawned: ${cmd}`, evidence: cmd });
482
+ }
483
+ }
484
+
485
+ score = Math.min(100, score);
486
+ return { score, findings };
487
+ }
488
+
489
+ // ── Network report (detailed, colored) ──
490
+
491
+ function generateNetworkReport(report) {
492
+ const lines = [];
493
+ const RED = '\x1b[31m';
494
+ const YELLOW = '\x1b[33m';
495
+ const GREEN = '\x1b[32m';
496
+ const CYAN = '\x1b[36m';
497
+ const MAGENTA = '\x1b[35m';
498
+ const BOLD = '\x1b[1m';
499
+ const DIM = '\x1b[2m';
500
+ const RESET = '\x1b[0m';
501
+
502
+ lines.push('');
503
+ lines.push(`${BOLD}${MAGENTA}╔══════════════════════════════════════════════════╗${RESET}`);
504
+ lines.push(`${BOLD}${MAGENTA}║ MUAD'DIB Sandbox Network Report ║${RESET}`);
505
+ lines.push(`${BOLD}${MAGENTA}╚══════════════════════════════════════════════════╝${RESET}`);
506
+ lines.push('');
507
+ lines.push(` Package: ${BOLD}${report.package}${RESET}`);
508
+ lines.push(` Mode: ${report.mode === 'strict' ? RED + 'STRICT' : GREEN + 'permissive'}${RESET}`);
509
+ lines.push(` Time: ${report.timestamp}`);
510
+ lines.push(` Duration: ${report.duration_ms}ms`);
511
+
512
+ // DNS Resolutions
513
+ const dnsRes = report.network?.dns_resolutions || [];
514
+ lines.push('');
515
+ lines.push(`${BOLD}${CYAN}── DNS Resolutions (${dnsRes.length}) ──${RESET}`);
516
+ if (dnsRes.length === 0) {
517
+ lines.push(` ${DIM}No DNS resolutions captured${RESET}`);
518
+ } else {
519
+ for (const r of dnsRes) {
520
+ const safe = isSafeDomain(r.domain);
521
+ const icon = safe ? GREEN + '[OK]' : YELLOW + '[!!]';
522
+ lines.push(` ${icon}${RESET} ${r.domain} ${r.ip}`);
523
+ }
524
+ }
525
+
526
+ // HTTP Requests
527
+ const httpReqs = report.network?.http_requests || [];
528
+ lines.push('');
529
+ lines.push(`${BOLD}${CYAN}── HTTP Requests (${httpReqs.length}) ──${RESET}`);
530
+ if (httpReqs.length === 0) {
531
+ lines.push(` ${DIM}No HTTP requests captured${RESET}`);
532
+ } else {
533
+ for (const req of httpReqs) {
534
+ const safe = isSafeDomain(req.host);
535
+ const icon = safe ? GREEN + '[OK]' : RED + '[!!]';
536
+ lines.push(` ${icon}${RESET} ${req.method} ${req.host}${req.path}`);
537
+ }
538
+ }
539
+
540
+ // TLS Connections
541
+ const tlsConns = report.network?.tls_connections || [];
542
+ lines.push('');
543
+ lines.push(`${BOLD}${CYAN}── TLS Connections (${tlsConns.length}) ──${RESET}`);
544
+ if (tlsConns.length === 0) {
545
+ lines.push(` ${DIM}No TLS connections captured${RESET}`);
546
+ } else {
547
+ for (const tls of tlsConns) {
548
+ const safe = isSafeDomain(tls.domain);
549
+ const icon = safe ? GREEN + '[OK]' : YELLOW + '[!!]';
550
+ lines.push(` ${icon}${RESET} ${tls.domain} (${tls.ip}:${tls.port})`);
551
+ }
552
+ }
553
+
554
+ // Blocked Connections (strict mode)
555
+ const blocked = report.network?.blocked_connections || [];
556
+ if (blocked.length > 0) {
557
+ lines.push('');
558
+ lines.push(`${BOLD}${RED}── Blocked Connections (${blocked.length}) ──${RESET}`);
559
+ for (const b of blocked) {
560
+ lines.push(` ${RED}[BLOCKED]${RESET} ${b.ip}:${b.port}`);
561
+ }
562
+ }
563
+
564
+ // Data Exfiltration Alerts
565
+ const bodies = report.network?.http_bodies || [];
566
+ const exfilAlerts = [];
567
+ for (const body of bodies) {
568
+ for (const pat of EXFIL_PATTERNS) {
569
+ if (pat.pattern.test(body)) {
570
+ exfilAlerts.push({ label: pat.label, severity: pat.severity, snippet: body.substring(0, 100) });
571
+ break;
572
+ }
573
+ }
574
+ }
575
+ if (exfilAlerts.length > 0) {
576
+ lines.push('');
577
+ lines.push(`${BOLD}${RED}── Data Exfiltration Alerts (${exfilAlerts.length}) ──${RESET}`);
578
+ for (const alert of exfilAlerts) {
579
+ lines.push(` ${RED}[${alert.severity}]${RESET} ${alert.label} detected in HTTP body`);
580
+ lines.push(` ${DIM}${alert.snippet}...${RESET}`);
581
+ }
582
+ }
583
+
584
+ // Raw TCP connections
585
+ const conns = report.network?.http_connections || [];
586
+ if (conns.length > 0) {
587
+ lines.push('');
588
+ lines.push(`${BOLD}${CYAN}── Raw TCP Connections (${conns.length}) ──${RESET}`);
589
+ for (const c of conns) {
590
+ const safe = isSafeHost(c.host);
591
+ const icon = safe ? GREEN + '[OK]' : YELLOW + '[!!]';
592
+ lines.push(` ${icon}${RESET} ${c.host}:${c.port} (${c.protocol})`);
593
+ }
594
+ }
595
+
596
+ lines.push('');
597
+ return lines.join('\n');
598
+ }
599
+
600
+ // ── Helpers ──
601
+
602
+ function isSafeDomain(domain) {
603
+ return SAFE_DOMAINS.some(safe => domain === safe || domain.endsWith('.' + safe));
604
+ }
605
+
606
+ function isSafeHost(host) {
607
+ return SAFE_DOMAINS.some(safe => host === safe || host.endsWith('.' + safe));
608
+ }
609
+
610
+ function getSeverity(score) {
611
+ if (score === 0) return 'CLEAN';
612
+ if (score <= 20) return 'LOW';
613
+ if (score <= 50) return 'MEDIUM';
614
+ if (score <= 80) return 'HIGH';
615
+ return 'CRITICAL';
616
+ }
617
+
618
+ function displayResults(result) {
619
+ console.log(`\n[SANDBOX] Score: ${result.score}/100 — ${result.severity}`);
620
+ if (result.findings.length === 0) {
621
+ console.log('[SANDBOX] No suspicious behavior detected.');
622
+ } else {
623
+ const actionable = result.findings.filter(f => f.severity !== 'INFO');
624
+ console.log(`[SANDBOX] ${actionable.length} finding(s):`);
625
+ for (const f of actionable) {
626
+ console.log(` [${f.severity}] ${f.type}: ${f.detail}`);
627
+ }
628
+ }
629
+ }
630
+
631
+ module.exports = { buildSandboxImage, runSandbox, scoreFindings, generateNetworkReport, EXFIL_PATTERNS, SAFE_DOMAINS, getSeverity, displayResults, isDockerAvailable, imageExists, STATIC_CANARY_TOKENS, detectStaticCanaryExfiltration };