muaddib-scanner 2.4.7 → 2.4.9

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/.dockerignore ADDED
@@ -0,0 +1,7 @@
1
+ node_modules
2
+ .git
3
+ datasets
4
+ tests
5
+ metrics
6
+ .muaddib-cache
7
+ *.md
package/bin/muaddib.js CHANGED
@@ -5,7 +5,7 @@ const { updateIOCs } = require('../src/ioc/updater.js');
5
5
  const { watch } = require('../src/watch.js');
6
6
  const { runScraper } = require('../src/ioc/scraper.js');
7
7
  const { safeInstall } = require('../src/safe-install.js');
8
- const { buildSandboxImage, runSandbox, generateNetworkReport } = require('../src/sandbox.js');
8
+ const { buildSandboxImage, runSandbox, generateNetworkReport } = require('../src/sandbox/index.js');
9
9
  const { diff, showRefs } = require('../src/diff.js');
10
10
  const { initHooks, removeHooks } = require('../src/hooks-init.js');
11
11
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.4.7",
3
+ "version": "2.4.9",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -188,6 +188,32 @@ const PLAYBOOKS = {
188
188
  sandbox_timeout:
189
189
  'CRITIQUE: Le container sandbox a depasse le timeout. Possible boucle infinie ou consommation de ressources.',
190
190
 
191
+ sandbox_timer_delay_suspicious:
192
+ 'Timer avec delai > 1h detecte. Possible time-bomb: le malware attend avant de s\'activer pour eviter les sandbox. ' +
193
+ 'Verifier le code pour des setTimeout/setInterval avec des delais inhabituels.',
194
+
195
+ sandbox_timer_delay_critical:
196
+ 'CRITIQUE: Timer avec delai > 24h detecte. Fort indicateur de time-bomb malware. ' +
197
+ 'Le package retarde volontairement l\'execution du payload pour echapper a l\'analyse sandbox. ' +
198
+ 'NE PAS installer. Analyser le code pour identifier le payload retarde.',
199
+
200
+ sandbox_preload_sensitive_read:
201
+ 'Lecture de fichiers sensibles detectee via monkey-patching runtime (.npmrc, .ssh, .aws, .env). ' +
202
+ 'Le package accede a des credentials pendant l\'installation. Regenerer les secrets exposes.',
203
+
204
+ sandbox_network_after_sensitive_read:
205
+ 'CRITIQUE: Activite reseau detectee apres lecture de fichiers sensibles. ' +
206
+ 'Fort indicateur d\'exfiltration de credentials. Isoler la machine, supprimer le package, ' +
207
+ 'regenerer TOUS les secrets. Auditer les connexions reseau recentes.',
208
+
209
+ sandbox_exec_suspicious:
210
+ 'Execution de commandes dangereuses detectee via monkey-patching runtime (curl, wget, bash, sh, powershell). ' +
211
+ 'Verifier les commandes executees. Si le package n\'a pas de raison legitime d\'executer ces commandes, supprimer.',
212
+
213
+ sandbox_env_token_access:
214
+ 'Acces a des variables d\'environnement sensibles detecte via monkey-patching runtime (TOKEN, SECRET, KEY, PASSWORD). ' +
215
+ 'Verifier si le package a une raison legitime d\'acceder a ces variables. Revoquer les credentials si necessaire.',
216
+
191
217
  high_entropy_string:
192
218
  'Chaine a haute entropie detectee. Verifier si c\'est du base64, hex, ou un payload chiffre. Analyser le contexte d\'utilisation.',
193
219
  js_obfuscation_pattern:
@@ -892,6 +892,62 @@ const RULES = {
892
892
  mitre: 'T1499'
893
893
  },
894
894
 
895
+ // Sandbox preload detections (time-bomb and behavioral analysis)
896
+ sandbox_timer_delay_suspicious: {
897
+ id: 'MUADDIB-SANDBOX-009',
898
+ name: 'Sandbox: Suspicious Timer Delay',
899
+ severity: 'MEDIUM',
900
+ confidence: 'medium',
901
+ description: 'Package uses setTimeout/setInterval with delay > 1 hour. Possible time-bomb to evade sandbox analysis.',
902
+ references: ['https://attack.mitre.org/techniques/T1497/003/'],
903
+ mitre: 'T1497.003'
904
+ },
905
+ sandbox_timer_delay_critical: {
906
+ id: 'MUADDIB-SANDBOX-010',
907
+ name: 'Sandbox: Critical Timer Delay (Time-Bomb)',
908
+ severity: 'CRITICAL',
909
+ confidence: 'high',
910
+ description: 'Package uses setTimeout/setInterval with delay > 24 hours. Strong indicator of time-bomb malware designed to evade sandbox analysis.',
911
+ references: ['https://attack.mitre.org/techniques/T1497/003/'],
912
+ mitre: 'T1497.003'
913
+ },
914
+ sandbox_preload_sensitive_read: {
915
+ id: 'MUADDIB-SANDBOX-011',
916
+ name: 'Sandbox: Preload Sensitive File Read',
917
+ severity: 'HIGH',
918
+ confidence: 'high',
919
+ description: 'Package reads sensitive credential files (.npmrc, .ssh, .aws, .env) detected via runtime monkey-patching.',
920
+ references: ['https://attack.mitre.org/techniques/T1552/001/'],
921
+ mitre: 'T1552.001'
922
+ },
923
+ sandbox_network_after_sensitive_read: {
924
+ id: 'MUADDIB-SANDBOX-012',
925
+ name: 'Sandbox: Network After Sensitive Read',
926
+ severity: 'CRITICAL',
927
+ confidence: 'high',
928
+ description: 'Package makes network requests after reading sensitive files. Strong indicator of credential exfiltration.',
929
+ references: ['https://attack.mitre.org/techniques/T1041/'],
930
+ mitre: 'T1041'
931
+ },
932
+ sandbox_exec_suspicious: {
933
+ id: 'MUADDIB-SANDBOX-013',
934
+ name: 'Sandbox: Suspicious Command Execution',
935
+ severity: 'HIGH',
936
+ confidence: 'high',
937
+ description: 'Package executes dangerous commands (curl, wget, bash, sh, powershell) detected via runtime monkey-patching.',
938
+ references: ['https://attack.mitre.org/techniques/T1059/'],
939
+ mitre: 'T1059'
940
+ },
941
+ sandbox_env_token_access: {
942
+ id: 'MUADDIB-SANDBOX-014',
943
+ name: 'Sandbox: Sensitive Env Var Access',
944
+ severity: 'MEDIUM',
945
+ confidence: 'medium',
946
+ description: 'Package accesses sensitive environment variables (TOKEN, SECRET, KEY, PASSWORD) detected via runtime monkey-patching.',
947
+ references: ['https://attack.mitre.org/techniques/T1552/001/'],
948
+ mitre: 'T1552.001'
949
+ },
950
+
895
951
  // Entropy detections
896
952
  high_entropy_string: {
897
953
  id: 'MUADDIB-ENTROPY-001',
@@ -0,0 +1,182 @@
1
+ /**
2
+ * MUAD'DIB Sandbox Preload Log Analyzer
3
+ *
4
+ * Parses [PRELOAD] log lines produced by docker/preload.js and generates
5
+ * scored findings for behavioral analysis. Six detection rules:
6
+ *
7
+ * 1. sandbox_timer_delay_suspicious — timer delay > 1h (MEDIUM, +15)
8
+ * 2. sandbox_timer_delay_critical — timer delay > 24h (CRITICAL, +30, supersedes #1)
9
+ * 3. sandbox_preload_sensitive_read — sensitive file read (HIGH, +20)
10
+ * 4. sandbox_network_after_sensitive_read — network call after sensitive read (CRITICAL, +40)
11
+ * 5. sandbox_exec_suspicious — dangerous command execution (HIGH, +25)
12
+ * 6. sandbox_env_token_access — sensitive env var access (MEDIUM, +10)
13
+ */
14
+
15
+ const ONE_HOUR_MS = 3600000;
16
+ const TWENTY_FOUR_HOURS_MS = 24 * ONE_HOUR_MS;
17
+
18
+ /**
19
+ * Parse [PRELOAD] log content and produce scored findings.
20
+ *
21
+ * @param {string} logContent - Raw preload log content
22
+ * @returns {{ score: number, findings: Array<{type: string, severity: string, detail: string, evidence: string}> }}
23
+ */
24
+ function analyzePreloadLog(logContent) {
25
+ const findings = [];
26
+ let score = 0;
27
+
28
+ if (!logContent || typeof logContent !== 'string') {
29
+ return { score: 0, findings: [] };
30
+ }
31
+
32
+ const lines = logContent.split('\n').filter(l => l.includes('[PRELOAD]'));
33
+
34
+ // Categorize lines
35
+ const timerLines = [];
36
+ const fsReadLines = [];
37
+ const fsWriteLines = [];
38
+ const networkLines = [];
39
+ const execLines = [];
40
+ const envLines = [];
41
+
42
+ for (const line of lines) {
43
+ if (line.includes('TIMER:')) {
44
+ timerLines.push(line);
45
+ } else if (line.includes('FS_READ:')) {
46
+ fsReadLines.push(line);
47
+ } else if (line.includes('FS_WRITE:')) {
48
+ fsWriteLines.push(line);
49
+ } else if (line.includes('NETWORK:')) {
50
+ networkLines.push(line);
51
+ } else if (line.includes('EXEC:')) {
52
+ execLines.push(line);
53
+ } else if (line.includes('ENV_ACCESS:')) {
54
+ envLines.push(line);
55
+ }
56
+ }
57
+
58
+ // ── Rule 1/2: Timer delay detection ──
59
+ // Parse delay values from timer lines
60
+ const delayRe = /delay=(\d+)ms/;
61
+ let hasCriticalTimer = false;
62
+ let hasSuspiciousTimer = false;
63
+
64
+ for (const line of timerLines) {
65
+ const match = line.match(delayRe);
66
+ if (!match) continue;
67
+ const delay = parseInt(match[1], 10);
68
+
69
+ if (delay > TWENTY_FOUR_HOURS_MS) {
70
+ // Critical supersedes suspicious for this specific timer
71
+ if (!hasCriticalTimer) {
72
+ hasCriticalTimer = true;
73
+ score += 30;
74
+ findings.push({
75
+ type: 'sandbox_timer_delay_critical',
76
+ severity: 'CRITICAL',
77
+ detail: `Timer delay > 24h detected: ${delay}ms (${(delay / 3600000).toFixed(1)}h) — likely time-bomb`,
78
+ evidence: line.trim()
79
+ });
80
+ }
81
+ } else if (delay > ONE_HOUR_MS) {
82
+ if (!hasSuspiciousTimer && !hasCriticalTimer) {
83
+ hasSuspiciousTimer = true;
84
+ score += 15;
85
+ findings.push({
86
+ type: 'sandbox_timer_delay_suspicious',
87
+ severity: 'MEDIUM',
88
+ detail: `Timer delay > 1h detected: ${delay}ms (${(delay / 3600000).toFixed(1)}h) — possible time-bomb`,
89
+ evidence: line.trim()
90
+ });
91
+ }
92
+ }
93
+ }
94
+
95
+ // If we found a critical timer, remove any suspicious timer finding (supersede)
96
+ if (hasCriticalTimer && hasSuspiciousTimer) {
97
+ const suspIdx = findings.findIndex(f => f.type === 'sandbox_timer_delay_suspicious');
98
+ if (suspIdx !== -1) {
99
+ score -= 15; // Remove the suspicious score since critical supersedes
100
+ findings.splice(suspIdx, 1);
101
+ }
102
+ }
103
+
104
+ // ── Rule 3: Sensitive file read ──
105
+ const hasSensitiveRead = fsReadLines.some(l => l.includes('SENSITIVE'));
106
+ if (hasSensitiveRead) {
107
+ const sensitiveFiles = fsReadLines
108
+ .filter(l => l.includes('SENSITIVE'))
109
+ .map(l => {
110
+ const m = l.match(/SENSITIVE\s+(.+?)(?:\s+\(t\+|$)/);
111
+ return m ? m[1].trim() : 'unknown';
112
+ });
113
+
114
+ score += 20;
115
+ findings.push({
116
+ type: 'sandbox_preload_sensitive_read',
117
+ severity: 'HIGH',
118
+ detail: `Sensitive file read detected via preload: ${sensitiveFiles.join(', ')}`,
119
+ evidence: sensitiveFiles.join(', ')
120
+ });
121
+ }
122
+
123
+ // ── Rule 4: Network after sensitive read (compound) ──
124
+ if (hasSensitiveRead && networkLines.length > 0) {
125
+ // Check that a network event occurs AFTER a sensitive read
126
+ // Lines are logged sequentially via appendFileSync, so order = temporal order
127
+ const firstSensitiveReadIdx = lines.findIndex(l => l.includes('FS_READ:') && l.includes('SENSITIVE'));
128
+ const lastNetworkIdx = lines.length - 1 - [...lines].reverse().findIndex(l => l.includes('NETWORK:'));
129
+
130
+ if (firstSensitiveReadIdx !== -1 && lastNetworkIdx > firstSensitiveReadIdx) {
131
+ const networkEvidence = networkLines[0].trim();
132
+ score += 40;
133
+ findings.push({
134
+ type: 'sandbox_network_after_sensitive_read',
135
+ severity: 'CRITICAL',
136
+ detail: 'Network activity detected after sensitive file read — possible exfiltration',
137
+ evidence: networkEvidence
138
+ });
139
+ }
140
+ }
141
+
142
+ // ── Rule 5: Suspicious exec ──
143
+ const dangerousExecLines = execLines.filter(l => l.includes('DANGEROUS'));
144
+ if (dangerousExecLines.length > 0) {
145
+ const cmds = dangerousExecLines.map(l => {
146
+ const m = l.match(/(?:exec|execSync|spawn|spawnSync|execFile|execFileSync):\s*(.+?)(?:\s+\(t\+|$)/);
147
+ return m ? m[1].trim() : 'unknown';
148
+ });
149
+
150
+ score += 25;
151
+ findings.push({
152
+ type: 'sandbox_exec_suspicious',
153
+ severity: 'HIGH',
154
+ detail: `Dangerous command execution detected: ${cmds.join('; ')}`,
155
+ evidence: cmds.join('; ')
156
+ });
157
+ }
158
+
159
+ // ── Rule 6: Env token access ──
160
+ if (envLines.length > 0) {
161
+ const vars = envLines.map(l => {
162
+ const m = l.match(/ENV_ACCESS:\s*(\S+)/);
163
+ return m ? m[1] : 'unknown';
164
+ });
165
+ const unique = [...new Set(vars)];
166
+
167
+ score += 10;
168
+ findings.push({
169
+ type: 'sandbox_env_token_access',
170
+ severity: 'MEDIUM',
171
+ detail: `Sensitive env var access detected: ${unique.join(', ')}`,
172
+ evidence: unique.join(', ')
173
+ });
174
+ }
175
+
176
+ return {
177
+ score: Math.min(100, score),
178
+ findings
179
+ };
180
+ }
181
+
182
+ module.exports = { analyzePreloadLog };
@@ -0,0 +1,746 @@
1
+ const { execSync, execFileSync, spawn } = require('child_process');
2
+ const crypto = require('crypto');
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const {
6
+ generateCanaryTokens,
7
+ createCanaryEnvFile,
8
+ createCanaryNpmrc,
9
+ detectCanaryExfiltration,
10
+ detectCanaryInOutput
11
+ } = require('../canary-tokens.js');
12
+
13
+ const { NPM_PACKAGE_REGEX } = require('../shared/constants.js');
14
+ const { analyzePreloadLog } = require('./analyzer.js');
15
+
16
+ const DOCKER_IMAGE = 'muaddib-sandbox';
17
+ const CONTAINER_TIMEOUT = 120000; // 120 seconds
18
+ const SINGLE_RUN_TIMEOUT = 60000; // 60 seconds per run in multi-run mode
19
+
20
+ // Time offsets for multi-run sandbox execution (ms)
21
+ const TIME_OFFSETS = [
22
+ { offset: 0, label: 'immediate' },
23
+ { offset: 259200000, label: '72h offset' }, // 72 hours
24
+ { offset: 604800000, label: '7d offset' } // 7 days
25
+ ];
26
+
27
+ // Domains excluded from network findings (false positives)
28
+ const SAFE_DOMAINS = [
29
+ 'registry.npmjs.org',
30
+ 'github.com',
31
+ 'objects.githubusercontent.com',
32
+ 'api.github.com',
33
+ 'raw.githubusercontent.com',
34
+ 'codeload.github.com',
35
+ 'npmjs.com',
36
+ 'npmjs.org',
37
+ 'yarnpkg.com',
38
+ 'googleapis.com',
39
+ 'cloudflare.com'
40
+ ];
41
+
42
+ // IPs/ports excluded from connection findings (false positives)
43
+ const SAFE_IPS = ['127.0.0.1'];
44
+ const PROBE_PORTS = [65535]; // Node.js internal connectivity checks
45
+
46
+ // Commands that are always suspicious in a sandbox
47
+ const DANGEROUS_CMDS = ['curl', 'wget', 'nc', 'netcat', 'python', 'python3', 'bash', 'sh'];
48
+
49
+ // Static canary tokens injected by sandbox-runner.sh (fallback honeypots).
50
+ // These are searched in the sandbox report as a complement to the dynamic
51
+ // tokens from canary-tokens.js (which use random suffixes per session).
52
+ const STATIC_CANARY_TOKENS = {
53
+ GITHUB_TOKEN: 'MUADDIB_CANARY_GITHUB_f8k3t0k3n',
54
+ NPM_TOKEN: 'MUADDIB_CANARY_NPM_s3cr3tt0k3n',
55
+ AWS_ACCESS_KEY_ID: 'MUADDIB_CANARY_AKIAIOSFODNN7EXAMPLE',
56
+ AWS_SECRET_ACCESS_KEY: 'MUADDIB_CANARY_wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
57
+ SLACK_WEBHOOK_URL: 'MUADDIB_CANARY_SLACK',
58
+ DISCORD_WEBHOOK_URL: 'MUADDIB_CANARY_DISCORD'
59
+ };
60
+
61
+ // Patterns indicating data exfiltration in HTTP bodies
62
+ const EXFIL_PATTERNS = [
63
+ { pattern: /\bNPM_TOKEN\b/i, label: 'npm token', severity: 'CRITICAL' },
64
+ { pattern: /\bGITHUB_TOKEN\b/i, label: 'GitHub token', severity: 'CRITICAL' },
65
+ { pattern: /\bAWS_SECRET/i, label: 'AWS credentials', severity: 'CRITICAL' },
66
+ { pattern: /npmrc/i, label: '.npmrc content', severity: 'CRITICAL' },
67
+ { pattern: /\bssh-rsa\b|\bssh-ed25519\b/i, label: 'SSH key', severity: 'CRITICAL' },
68
+ { pattern: /BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY/, label: 'private key', severity: 'CRITICAL' },
69
+ { pattern: /\bpassword\b/i, label: 'password', severity: 'CRITICAL' },
70
+ { pattern: /\btoken\b/i, label: 'token', severity: 'CRITICAL' },
71
+ { pattern: /\/etc\/passwd/, label: 'passwd file', severity: 'HIGH' },
72
+ { pattern: /\.env\b/, label: '.env content', severity: 'HIGH' }
73
+ ];
74
+
75
+ // ── Docker availability checks ──
76
+
77
+ function isDockerAvailable() {
78
+ try {
79
+ execSync('docker info', { stdio: 'pipe', timeout: 10000 });
80
+ return true;
81
+ } catch {
82
+ return false;
83
+ }
84
+ }
85
+
86
+ function imageExists() {
87
+ try {
88
+ execFileSync('docker', ['image', 'inspect', DOCKER_IMAGE], { stdio: 'pipe', timeout: 10000 });
89
+ return true;
90
+ } catch {
91
+ return false;
92
+ }
93
+ }
94
+
95
+ // ── Build image (with cache) ──
96
+
97
+ async function buildSandboxImage() {
98
+ if (!isDockerAvailable()) {
99
+ console.log('[SANDBOX] Docker is not installed or not running. Skipping sandbox analysis.');
100
+ return false;
101
+ }
102
+
103
+ if (imageExists()) {
104
+ console.log('[SANDBOX] Using cached Docker image.');
105
+ return true;
106
+ }
107
+
108
+ console.log('[SANDBOX] Building Docker image...');
109
+
110
+ return new Promise((resolve) => {
111
+ const dockerfilePath = path.join(__dirname, '..', 'docker').replace(/\\/g, '/');
112
+ const proc = spawn('docker', ['build', '-t', DOCKER_IMAGE, dockerfilePath], {
113
+ stdio: 'inherit'
114
+ });
115
+
116
+ proc.on('close', (code) => {
117
+ if (code === 0) {
118
+ console.log('[SANDBOX] Image built successfully.');
119
+ resolve(true);
120
+ } else {
121
+ console.log('[SANDBOX] Docker build failed.');
122
+ resolve(false);
123
+ }
124
+ });
125
+
126
+ proc.on('error', () => {
127
+ console.log('[SANDBOX] Docker error during build.');
128
+ resolve(false);
129
+ });
130
+ });
131
+ }
132
+
133
+ // ── Run single sandbox execution ──
134
+
135
+ async function runSingleSandbox(packageName, options = {}) {
136
+ const cleanResult = { score: 0, severity: 'CLEAN', findings: [], raw_report: null, suspicious: false };
137
+
138
+ const strict = options.strict || false;
139
+ const canaryTokens = options.canaryTokens || null;
140
+ const local = options.local || false;
141
+ const localAbsPath = options.localAbsPath || null;
142
+ const displayName = options.displayName || packageName;
143
+ const mode = strict ? 'strict' : 'permissive';
144
+ const timeOffset = options.timeOffset || 0;
145
+ const runTimeout = options.runTimeout || CONTAINER_TIMEOUT;
146
+
147
+ return new Promise((resolve) => {
148
+ let stdout = '';
149
+ let stderr = '';
150
+ let timedOut = false;
151
+ const containerName = `muaddib-sandbox-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
152
+
153
+ const dockerArgs = [
154
+ 'run',
155
+ '--rm',
156
+ `--name=${containerName}`,
157
+ '--network=bridge',
158
+ '--memory=512m',
159
+ '--cpus=1',
160
+ '--pids-limit=100',
161
+ '--cap-drop=ALL'
162
+ ];
163
+
164
+ // Inject canary tokens as environment variables
165
+ if (canaryTokens) {
166
+ for (const [key, value] of Object.entries(canaryTokens)) {
167
+ dockerArgs.push('-e', `${key}=${value}`);
168
+ }
169
+ // Also inject canary file contents as env vars for the entrypoint to write
170
+ dockerArgs.push('-e', `CANARY_ENV_CONTENT=${createCanaryEnvFile(canaryTokens).replace(/\r?\n/g, '\\n')}`);
171
+ dockerArgs.push('-e', `CANARY_NPMRC_CONTENT=${createCanaryNpmrc(canaryTokens).replace(/\r?\n/g, '\\n')}`);
172
+ }
173
+
174
+ // Inject time offset and preload for monkey-patching
175
+ dockerArgs.push('-e', `MUADDIB_TIME_OFFSET_MS=${timeOffset}`);
176
+ dockerArgs.push('-e', 'NODE_OPTIONS=--require /opt/preload.js');
177
+
178
+ // Both modes need NET_RAW for tcpdump (runs as root in entrypoint).
179
+ // Strict mode also needs NET_ADMIN for iptables network blocking.
180
+ // SYS_PTRACE is not needed: strace traces its own child (npm install via su).
181
+ dockerArgs.push('--cap-add=NET_RAW');
182
+ if (strict) {
183
+ dockerArgs.push('--cap-add=NET_ADMIN');
184
+ }
185
+
186
+ dockerArgs.push('--tmpfs', '/tmp:rw,nosuid,size=64m');
187
+ dockerArgs.push('--tmpfs', '/sandbox/install:rw,nosuid,size=256m');
188
+ dockerArgs.push('--tmpfs', '/home/sandboxuser:rw,noexec,nosuid,size=16m');
189
+ dockerArgs.push('--read-only');
190
+
191
+ dockerArgs.push('--security-opt', 'no-new-privileges');
192
+
193
+ if (local && localAbsPath) {
194
+ dockerArgs.push('-v', `${localAbsPath}:/sandbox/local-pkg:ro`);
195
+ }
196
+
197
+ dockerArgs.push(DOCKER_IMAGE);
198
+ dockerArgs.push(local ? '/sandbox/local-pkg' : packageName);
199
+ dockerArgs.push(mode);
200
+
201
+ const proc = spawn('docker', dockerArgs);
202
+
203
+ // Timeout: kill container
204
+ const timer = setTimeout(() => {
205
+ timedOut = true;
206
+ console.log(`[SANDBOX] Timeout (${runTimeout / 1000}s). Killing container...`);
207
+ try {
208
+ execFileSync('docker', ['kill', containerName], { stdio: 'pipe', timeout: 5000 });
209
+ } catch {
210
+ proc.kill('SIGKILL');
211
+ }
212
+ }, runTimeout);
213
+
214
+ proc.stdout.on('data', (data) => {
215
+ stdout += data.toString();
216
+ });
217
+
218
+ proc.stderr.on('data', (data) => {
219
+ stderr += data.toString();
220
+ // Forward sandbox progress logs (sanitize ANSI escape sequences)
221
+ const text = data.toString().replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
222
+ for (const line of text.split(/\r?\n/)) {
223
+ if (line.includes('[SANDBOX]')) {
224
+ console.log(line.trim());
225
+ }
226
+ }
227
+ });
228
+
229
+ proc.on('close', () => {
230
+ clearTimeout(timer);
231
+
232
+ if (timedOut) {
233
+ const result = {
234
+ score: 100,
235
+ severity: 'CRITICAL',
236
+ findings: [{
237
+ type: 'timeout',
238
+ severity: 'CRITICAL',
239
+ detail: `Container exceeded ${runTimeout / 1000}s timeout`,
240
+ evidence: `Killed after ${runTimeout}ms`
241
+ }],
242
+ raw_report: null,
243
+ suspicious: true
244
+ };
245
+ resolve(result);
246
+ return;
247
+ }
248
+
249
+ // Parse JSON from container stdout using delimiter
250
+ let report;
251
+ try {
252
+ const REPORT_DELIMITER = '---MUADDIB-REPORT-START---';
253
+ const delimIdx = stdout.indexOf(REPORT_DELIMITER);
254
+ let jsonStr;
255
+ if (delimIdx !== -1) {
256
+ // Reliable: use delimiter to skip any package output before the report
257
+ jsonStr = stdout.substring(delimIdx + REPORT_DELIMITER.length).trim();
258
+ } else {
259
+ // Fallback: find first '{' (backward compat with older images)
260
+ const jsonStart = stdout.indexOf('{');
261
+ const jsonEnd = stdout.lastIndexOf('}');
262
+ if (jsonStart === -1 || jsonEnd === -1) {
263
+ throw new Error('No JSON found in output');
264
+ }
265
+ jsonStr = stdout.substring(jsonStart, jsonEnd + 1);
266
+ }
267
+ report = JSON.parse(jsonStr);
268
+ if (local && report) {
269
+ report.package = displayName;
270
+ }
271
+ } catch (e) {
272
+ console.log('[SANDBOX] Failed to parse container output:', e.message);
273
+ resolve(cleanResult);
274
+ return;
275
+ }
276
+
277
+ const { score, findings } = scoreFindings(report);
278
+
279
+ // Analyze preload log for behavioral findings
280
+ if (report.preload_log) {
281
+ const preloadResult = analyzePreloadLog(report.preload_log);
282
+ for (const finding of preloadResult.findings) {
283
+ findings.push(finding);
284
+ }
285
+ // Add preload score (capped at 100 with the rest)
286
+ const combinedScore = Math.min(100, score + preloadResult.score);
287
+ // We'll use combinedScore below instead of score
288
+ report._preloadScore = preloadResult.score;
289
+ }
290
+
291
+ // Canary token exfiltration detection (dynamic tokens)
292
+ if (canaryTokens) {
293
+ const networkExfil = detectCanaryExfiltration(report.network || {}, canaryTokens);
294
+ const outputExfil = detectCanaryInOutput(stdout, stderr, canaryTokens);
295
+
296
+ for (const exfil of [...networkExfil.exfiltrations, ...outputExfil.exfiltrations]) {
297
+ findings.push({
298
+ type: 'canary_exfiltration',
299
+ severity: 'CRITICAL',
300
+ detail: `Package attempted to exfiltrate ${exfil.token} (${exfil.foundIn})`,
301
+ evidence: exfil.value
302
+ });
303
+ }
304
+ }
305
+
306
+ // Static canary token detection (fallback for shell-injected tokens)
307
+ const staticExfil = detectStaticCanaryExfiltration(report);
308
+ for (const { token, value } of staticExfil) {
309
+ const alreadyDetected = findings.some(f =>
310
+ f.type === 'canary_exfiltration' && f.detail && f.detail.includes(token)
311
+ );
312
+ if (!alreadyDetected) {
313
+ findings.push({
314
+ type: 'canary_exfiltration',
315
+ severity: 'CRITICAL',
316
+ detail: `Canary token exfiltration detected: ${token}`,
317
+ evidence: value
318
+ });
319
+ }
320
+ }
321
+
322
+ const preloadScore = report._preloadScore || 0;
323
+ const finalScore = Math.min(100, findings.reduce((s, f) => {
324
+ if (f.type === 'canary_exfiltration') return s + 50;
325
+ return s;
326
+ }, score + preloadScore));
327
+ const severity = getSeverity(finalScore);
328
+ const result = { score: finalScore, severity, findings, raw_report: report, suspicious: finalScore > 0 };
329
+
330
+ resolve(result);
331
+ });
332
+
333
+ proc.on('error', (err) => {
334
+ clearTimeout(timer);
335
+ if (err.code === 'ENOENT') {
336
+ console.log('[SANDBOX] Docker not found. Please install Docker.');
337
+ } else {
338
+ console.log(`[SANDBOX] Error: ${err.message}`);
339
+ }
340
+ resolve(cleanResult);
341
+ });
342
+ });
343
+ }
344
+
345
+ // ── Multi-run sandbox orchestrator ──
346
+
347
+ async function runSandbox(packageName, options = {}) {
348
+ const cleanResult = { score: 0, severity: 'CLEAN', findings: [], raw_report: null, suspicious: false };
349
+
350
+ const strict = options.strict || false;
351
+ const canaryEnabled = options.canary !== false; // enabled by default
352
+ const local = options.local || false;
353
+
354
+ // Validate inputs before checking Docker availability
355
+ let localAbsPath = null;
356
+ let displayName = packageName;
357
+
358
+ if (local) {
359
+ localAbsPath = path.resolve(packageName);
360
+ if (!fs.existsSync(localAbsPath)) {
361
+ console.log('[SANDBOX] Local path does not exist: ' + localAbsPath);
362
+ return cleanResult;
363
+ }
364
+ // Read package name for display
365
+ const pkgJsonPath = path.join(localAbsPath, 'package.json');
366
+ if (fs.existsSync(pkgJsonPath)) {
367
+ try {
368
+ const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
369
+ displayName = pkg.name || path.basename(localAbsPath);
370
+ } catch { displayName = path.basename(localAbsPath); }
371
+ } else {
372
+ displayName = path.basename(localAbsPath);
373
+ }
374
+ } else {
375
+ if (!NPM_PACKAGE_REGEX.test(packageName)) {
376
+ console.log('[SANDBOX] Invalid package name: ' + packageName);
377
+ return cleanResult;
378
+ }
379
+ }
380
+
381
+ if (!isDockerAvailable()) {
382
+ console.log('[SANDBOX] Docker is not installed or not running. Skipping.');
383
+ return cleanResult;
384
+ }
385
+
386
+ // Generate canary tokens for this sandbox session
387
+ let canaryTokens = null;
388
+ if (canaryEnabled) {
389
+ const canary = generateCanaryTokens();
390
+ canaryTokens = canary.tokens;
391
+ }
392
+
393
+ const mode = strict ? 'strict' : 'permissive';
394
+ console.log(`[SANDBOX] Analyzing "${displayName}" in isolated container (mode: ${mode}${canaryEnabled ? ', canary: on' : ''}${local ? ', local' : ''}, runs: ${TIME_OFFSETS.length})...`);
395
+
396
+ const allRuns = [];
397
+ let bestResult = cleanResult;
398
+
399
+ for (let i = 0; i < TIME_OFFSETS.length; i++) {
400
+ const { offset, label } = TIME_OFFSETS[i];
401
+ console.log(`[SANDBOX] Run ${i + 1}/${TIME_OFFSETS.length} (${label})...`);
402
+
403
+ const runResult = await runSingleSandbox(packageName, {
404
+ strict,
405
+ canaryTokens,
406
+ local,
407
+ localAbsPath,
408
+ displayName,
409
+ timeOffset: offset,
410
+ runTimeout: SINGLE_RUN_TIMEOUT
411
+ });
412
+
413
+ allRuns.push({
414
+ run: i + 1,
415
+ label,
416
+ timeOffset: offset,
417
+ score: runResult.score,
418
+ severity: runResult.severity,
419
+ findingCount: runResult.findings.length
420
+ });
421
+
422
+ // Keep the result with the highest score
423
+ if (runResult.score > bestResult.score) {
424
+ bestResult = runResult;
425
+ }
426
+
427
+ // Early exit: CRITICAL found, skip remaining runs
428
+ if (runResult.score >= 80) {
429
+ console.log(`[SANDBOX] Critical score (${runResult.score}) detected in run ${i + 1}. Skipping remaining runs.`);
430
+ break;
431
+ }
432
+ }
433
+
434
+ // Attach multi-run metadata
435
+ bestResult.all_runs = allRuns;
436
+
437
+ displayResults(bestResult);
438
+ return bestResult;
439
+ }
440
+
441
+ // ── Static canary detection ──
442
+
443
+ /**
444
+ * Detect static canary token exfiltration in a sandbox report.
445
+ * Searches HTTP bodies, DNS queries, HTTP request URLs, TLS domains,
446
+ * filesystem changes, process commands, and install output.
447
+ * @param {object} report - Parsed sandbox report JSON
448
+ * @returns {Array<{token: string, value: string}>} Exfiltrated tokens
449
+ */
450
+ function detectStaticCanaryExfiltration(report) {
451
+ const exfiltrated = [];
452
+ if (!report) return exfiltrated;
453
+
454
+ const searchable = [];
455
+
456
+ // Network data
457
+ for (const body of (report.network?.http_bodies || [])) if (body) searchable.push(body);
458
+ for (const domain of (report.network?.dns_queries || [])) if (domain) searchable.push(domain);
459
+ for (const req of (report.network?.http_requests || [])) {
460
+ searchable.push(`${req.method || ''} ${req.host || ''}${req.path || ''}`);
461
+ }
462
+ for (const tls of (report.network?.tls_connections || [])) if (tls.domain) searchable.push(tls.domain);
463
+
464
+ // Filesystem + processes
465
+ for (const file of (report.filesystem?.created || [])) if (file) searchable.push(file);
466
+ for (const proc of (report.processes?.spawned || [])) if (proc.command) searchable.push(proc.command);
467
+
468
+ // Install + entrypoint output
469
+ if (report.install_output) searchable.push(report.install_output);
470
+ if (report.entrypoint_output) searchable.push(report.entrypoint_output);
471
+
472
+ const allOutput = searchable.join('\n');
473
+
474
+ for (const [tokenName, tokenValue] of Object.entries(STATIC_CANARY_TOKENS)) {
475
+ if (allOutput.includes(tokenValue)) {
476
+ exfiltrated.push({ token: tokenName, value: tokenValue });
477
+ }
478
+ }
479
+
480
+ return exfiltrated;
481
+ }
482
+
483
+ // ── Scoring engine ──
484
+
485
+ function scoreFindings(report) {
486
+ let score = 0;
487
+ const findings = [];
488
+
489
+ // 1. Sensitive file reads
490
+ for (const file of (report.sensitive_files?.read || [])) {
491
+ if (/\.npmrc/.test(file) || /\.ssh/.test(file) || /\.aws/.test(file)) {
492
+ score += 40;
493
+ findings.push({ type: 'sensitive_file_read', severity: 'CRITICAL', detail: `Read credential file: ${file}`, evidence: file });
494
+ } else if (/\/etc\/passwd/.test(file) || /\/etc\/shadow/.test(file)) {
495
+ score += 25;
496
+ findings.push({ type: 'sensitive_file_read', severity: 'HIGH', detail: `Read system file: ${file}`, evidence: file });
497
+ } else if (/\.env/.test(file) || /\.gitconfig/.test(file) || /\.bash_history/.test(file)) {
498
+ score += 15;
499
+ findings.push({ type: 'sensitive_file_read', severity: 'MEDIUM', detail: `Read config file: ${file}`, evidence: file });
500
+ }
501
+ }
502
+
503
+ // 2. Sensitive file writes (from strace)
504
+ for (const file of (report.sensitive_files?.written || [])) {
505
+ if (/\.npmrc/.test(file) || /\.ssh/.test(file) || /\.aws/.test(file)) {
506
+ score += 40;
507
+ findings.push({ type: 'sensitive_file_write', severity: 'CRITICAL', detail: `Write to credential file: ${file}`, evidence: file });
508
+ } else if (/\/etc\/passwd/.test(file) || /\/etc\/shadow/.test(file)) {
509
+ score += 25;
510
+ findings.push({ type: 'sensitive_file_write', severity: 'HIGH', detail: `Write to system file: ${file}`, evidence: file });
511
+ } else {
512
+ score += 15;
513
+ findings.push({ type: 'sensitive_file_write', severity: 'MEDIUM', detail: `Write to sensitive file: ${file}`, evidence: file });
514
+ }
515
+ }
516
+
517
+ // 3. Filesystem changes — files created in suspicious locations
518
+ for (const file of (report.filesystem?.created || [])) {
519
+ if (/^\/usr\/bin\//.test(file) || /crontab/.test(file) || /\/cron\.d\//.test(file)) {
520
+ score += 50;
521
+ findings.push({ type: 'suspicious_filesystem', severity: 'CRITICAL', detail: `File created in system path: ${file}`, evidence: file });
522
+ } else if (/^\/tmp\//.test(file)) {
523
+ score += 30;
524
+ findings.push({ type: 'suspicious_filesystem', severity: 'HIGH', detail: `File created in /tmp: ${file}`, evidence: file });
525
+ }
526
+ }
527
+
528
+ // 4a. DNS queries (exclude safe domains)
529
+ for (const domain of (report.network?.dns_queries || [])) {
530
+ if (isSafeDomain(domain)) continue;
531
+ score += 20;
532
+ findings.push({ type: 'suspicious_dns', severity: 'HIGH', detail: `DNS query to non-registry domain: ${domain}`, evidence: domain });
533
+ }
534
+
535
+ // 4b. DNS resolutions — extra detail
536
+ for (const res of (report.network?.dns_resolutions || [])) {
537
+ if (isSafeDomain(res.domain)) continue;
538
+ // Already scored in 4a via dns_queries, but flag the resolution for reporting
539
+ findings.push({ type: 'dns_resolution', severity: 'INFO', detail: `${res.domain} → ${res.ip}`, evidence: `${res.domain}:${res.ip}` });
540
+ }
541
+
542
+ // 5a. TCP connections (exclude safe hosts, probe ports, localhost)
543
+ for (const conn of (report.network?.http_connections || [])) {
544
+ if (isSafeHost(conn.host)) continue;
545
+ if (SAFE_IPS.includes(conn.host)) continue;
546
+ if (PROBE_PORTS.includes(conn.port)) continue;
547
+ score += 25;
548
+ findings.push({ type: 'suspicious_connection', severity: 'HIGH', detail: `TCP connection to ${conn.host}:${conn.port}`, evidence: `${conn.host}:${conn.port}` });
549
+ }
550
+
551
+ // 5b. TLS connections — non-safe domains
552
+ for (const tls of (report.network?.tls_connections || [])) {
553
+ if (isSafeDomain(tls.domain)) continue;
554
+ score += 20;
555
+ findings.push({ type: 'suspicious_tls', severity: 'HIGH', detail: `TLS connection to ${tls.domain} (${tls.ip}:${tls.port})`, evidence: tls.domain });
556
+ }
557
+
558
+ // 5c. HTTP exfiltration detection — scan body snippets for sensitive data
559
+ for (const body of (report.network?.http_bodies || [])) {
560
+ for (const pat of EXFIL_PATTERNS) {
561
+ if (pat.pattern.test(body)) {
562
+ score += 50;
563
+ findings.push({
564
+ type: 'data_exfiltration',
565
+ severity: pat.severity,
566
+ detail: `HTTP body contains ${pat.label}`,
567
+ evidence: body.substring(0, 200)
568
+ });
569
+ break; // One match per body is enough
570
+ }
571
+ }
572
+ }
573
+
574
+ // 5d. HTTP requests to non-safe hosts
575
+ for (const req of (report.network?.http_requests || [])) {
576
+ if (isSafeDomain(req.host)) continue;
577
+ score += 20;
578
+ findings.push({ type: 'suspicious_http_request', severity: 'HIGH', detail: `${req.method} ${req.host}${req.path}`, evidence: `${req.method} ${req.host}${req.path}` });
579
+ }
580
+
581
+ // 5e. Blocked connections (strict mode)
582
+ for (const blocked of (report.network?.blocked_connections || [])) {
583
+ score += 30;
584
+ findings.push({ type: 'blocked_connection', severity: 'HIGH', detail: `Blocked outbound to ${blocked.ip}:${blocked.port}`, evidence: `${blocked.ip}:${blocked.port}` });
585
+ }
586
+
587
+ // 6. Suspicious processes
588
+ for (const p of (report.processes?.spawned || [])) {
589
+ const cmd = p.command || '';
590
+ const basename = path.basename(cmd);
591
+ if (DANGEROUS_CMDS.some(d => basename === d)) {
592
+ score += 40;
593
+ findings.push({ type: 'suspicious_process', severity: 'CRITICAL', detail: `Dangerous command spawned: ${cmd}`, evidence: cmd });
594
+ } else if (cmd) {
595
+ score += 15;
596
+ findings.push({ type: 'unknown_process', severity: 'MEDIUM', detail: `Unknown process spawned: ${cmd}`, evidence: cmd });
597
+ }
598
+ }
599
+
600
+ score = Math.min(100, score);
601
+ return { score, findings };
602
+ }
603
+
604
+ // ── Network report (detailed, colored) ──
605
+
606
+ function generateNetworkReport(report) {
607
+ const lines = [];
608
+ const RED = '\x1b[31m';
609
+ const YELLOW = '\x1b[33m';
610
+ const GREEN = '\x1b[32m';
611
+ const CYAN = '\x1b[36m';
612
+ const MAGENTA = '\x1b[35m';
613
+ const BOLD = '\x1b[1m';
614
+ const DIM = '\x1b[2m';
615
+ const RESET = '\x1b[0m';
616
+
617
+ lines.push('');
618
+ lines.push(`${BOLD}${MAGENTA}╔══════════════════════════════════════════════════╗${RESET}`);
619
+ lines.push(`${BOLD}${MAGENTA}║ MUAD'DIB — Sandbox Network Report ║${RESET}`);
620
+ lines.push(`${BOLD}${MAGENTA}╚══════════════════════════════════════════════════╝${RESET}`);
621
+ lines.push('');
622
+ lines.push(` Package: ${BOLD}${report.package}${RESET}`);
623
+ lines.push(` Mode: ${report.mode === 'strict' ? RED + 'STRICT' : GREEN + 'permissive'}${RESET}`);
624
+ lines.push(` Time: ${report.timestamp}`);
625
+ lines.push(` Duration: ${report.duration_ms}ms`);
626
+
627
+ // DNS Resolutions
628
+ const dnsRes = report.network?.dns_resolutions || [];
629
+ lines.push('');
630
+ lines.push(`${BOLD}${CYAN}── DNS Resolutions (${dnsRes.length}) ──${RESET}`);
631
+ if (dnsRes.length === 0) {
632
+ lines.push(` ${DIM}No DNS resolutions captured${RESET}`);
633
+ } else {
634
+ for (const r of dnsRes) {
635
+ const safe = isSafeDomain(r.domain);
636
+ const icon = safe ? GREEN + '[OK]' : YELLOW + '[!!]';
637
+ lines.push(` ${icon}${RESET} ${r.domain} → ${r.ip}`);
638
+ }
639
+ }
640
+
641
+ // HTTP Requests
642
+ const httpReqs = report.network?.http_requests || [];
643
+ lines.push('');
644
+ lines.push(`${BOLD}${CYAN}── HTTP Requests (${httpReqs.length}) ──${RESET}`);
645
+ if (httpReqs.length === 0) {
646
+ lines.push(` ${DIM}No HTTP requests captured${RESET}`);
647
+ } else {
648
+ for (const req of httpReqs) {
649
+ const safe = isSafeDomain(req.host);
650
+ const icon = safe ? GREEN + '[OK]' : RED + '[!!]';
651
+ lines.push(` ${icon}${RESET} ${req.method} ${req.host}${req.path}`);
652
+ }
653
+ }
654
+
655
+ // TLS Connections
656
+ const tlsConns = report.network?.tls_connections || [];
657
+ lines.push('');
658
+ lines.push(`${BOLD}${CYAN}── TLS Connections (${tlsConns.length}) ──${RESET}`);
659
+ if (tlsConns.length === 0) {
660
+ lines.push(` ${DIM}No TLS connections captured${RESET}`);
661
+ } else {
662
+ for (const tls of tlsConns) {
663
+ const safe = isSafeDomain(tls.domain);
664
+ const icon = safe ? GREEN + '[OK]' : YELLOW + '[!!]';
665
+ lines.push(` ${icon}${RESET} ${tls.domain} (${tls.ip}:${tls.port})`);
666
+ }
667
+ }
668
+
669
+ // Blocked Connections (strict mode)
670
+ const blocked = report.network?.blocked_connections || [];
671
+ if (blocked.length > 0) {
672
+ lines.push('');
673
+ lines.push(`${BOLD}${RED}── Blocked Connections (${blocked.length}) ──${RESET}`);
674
+ for (const b of blocked) {
675
+ lines.push(` ${RED}[BLOCKED]${RESET} ${b.ip}:${b.port}`);
676
+ }
677
+ }
678
+
679
+ // Data Exfiltration Alerts
680
+ const bodies = report.network?.http_bodies || [];
681
+ const exfilAlerts = [];
682
+ for (const body of bodies) {
683
+ for (const pat of EXFIL_PATTERNS) {
684
+ if (pat.pattern.test(body)) {
685
+ exfilAlerts.push({ label: pat.label, severity: pat.severity, snippet: body.substring(0, 100) });
686
+ break;
687
+ }
688
+ }
689
+ }
690
+ if (exfilAlerts.length > 0) {
691
+ lines.push('');
692
+ lines.push(`${BOLD}${RED}── Data Exfiltration Alerts (${exfilAlerts.length}) ──${RESET}`);
693
+ for (const alert of exfilAlerts) {
694
+ lines.push(` ${RED}[${alert.severity}]${RESET} ${alert.label} detected in HTTP body`);
695
+ lines.push(` ${DIM}${alert.snippet}...${RESET}`);
696
+ }
697
+ }
698
+
699
+ // Raw TCP connections
700
+ const conns = report.network?.http_connections || [];
701
+ if (conns.length > 0) {
702
+ lines.push('');
703
+ lines.push(`${BOLD}${CYAN}── Raw TCP Connections (${conns.length}) ──${RESET}`);
704
+ for (const c of conns) {
705
+ const safe = isSafeHost(c.host);
706
+ const icon = safe ? GREEN + '[OK]' : YELLOW + '[!!]';
707
+ lines.push(` ${icon}${RESET} ${c.host}:${c.port} (${c.protocol})`);
708
+ }
709
+ }
710
+
711
+ lines.push('');
712
+ return lines.join('\n');
713
+ }
714
+
715
+ // ── Helpers ──
716
+
717
+ function isSafeDomain(domain) {
718
+ return SAFE_DOMAINS.some(safe => domain === safe || domain.endsWith('.' + safe));
719
+ }
720
+
721
+ function isSafeHost(host) {
722
+ return SAFE_DOMAINS.some(safe => host === safe || host.endsWith('.' + safe));
723
+ }
724
+
725
+ function getSeverity(score) {
726
+ if (score === 0) return 'CLEAN';
727
+ if (score <= 20) return 'LOW';
728
+ if (score <= 50) return 'MEDIUM';
729
+ if (score <= 80) return 'HIGH';
730
+ return 'CRITICAL';
731
+ }
732
+
733
+ function displayResults(result) {
734
+ console.log(`\n[SANDBOX] Score: ${result.score}/100 — ${result.severity}`);
735
+ if (result.findings.length === 0) {
736
+ console.log('[SANDBOX] No suspicious behavior detected.');
737
+ } else {
738
+ const actionable = result.findings.filter(f => f.severity !== 'INFO');
739
+ console.log(`[SANDBOX] ${actionable.length} finding(s):`);
740
+ for (const f of actionable) {
741
+ console.log(` [${f.severity}] ${f.type}: ${f.detail}`);
742
+ }
743
+ }
744
+ }
745
+
746
+ module.exports = { buildSandboxImage, runSandbox, runSingleSandbox, scoreFindings, generateNetworkReport, EXFIL_PATTERNS, SAFE_DOMAINS, getSeverity, displayResults, isDockerAvailable, imageExists, STATIC_CANARY_TOKENS, detectStaticCanaryExfiltration, analyzePreloadLog, TIME_OFFSETS };