muaddib-scanner 2.10.40 → 2.10.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/integrations/canary-tokens.js +53 -0
- package/src/sandbox/gvisor-parser.js +348 -0
- package/src/sandbox/index.js +87 -4
- package/iocs/builtin.yaml +0 -239
- package/iocs/hashes.yaml +0 -214
- package/iocs/packages.yaml +0 -481
- package/scripts/analyze-score0.js +0 -190
- package/scripts/archive-cleanup.sh +0 -7
- package/scripts/audit-archive.sh +0 -45
- package/scripts/benchmark.js +0 -326
- package/scripts/cleanup-fp-labels.js +0 -81
- package/scripts/ossf-benchmark.js +0 -560
- package/scripts/sample-npm-random.js +0 -339
- package/src/ioc/data/.ossf-tree-sha +0 -1
package/package.json
CHANGED
|
@@ -10,6 +10,46 @@ function generateBase32(length) {
|
|
|
10
10
|
return Array.from(bytes).map(b => chars[b % 32]).join('');
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
// Minimum consecutive chars from an encoded variant to match in a DNS domain.
|
|
14
|
+
// 8 chars avoids FP on short legitimate hex subdomains (e.g. commit SHAs, CDN hashes).
|
|
15
|
+
const MIN_ENCODED_MATCH = 8;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Generate hex/base64/base64url encoded variants of a token value.
|
|
19
|
+
* Attackers encode tokens before DNS exfiltration to evade raw string matching.
|
|
20
|
+
* @param {string} value - Raw token value
|
|
21
|
+
* @returns {Array<{encoding: string, encoded: string}>}
|
|
22
|
+
*/
|
|
23
|
+
function generateEncodedVariants(value) {
|
|
24
|
+
const buf = Buffer.from(value);
|
|
25
|
+
return [
|
|
26
|
+
{ encoding: 'hex', encoded: buf.toString('hex') },
|
|
27
|
+
{ encoding: 'base64', encoded: buf.toString('base64') },
|
|
28
|
+
{ encoding: 'base64url', encoded: buf.toString('base64url') }
|
|
29
|
+
];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Check if a DNS domain contains an encoded token variant.
|
|
34
|
+
* Strips dots to reassemble data chunked across DNS labels (RFC 1035: max 63 chars/label).
|
|
35
|
+
* @param {string} domain - Full DNS query domain
|
|
36
|
+
* @param {Array<{encoding: string, encoded: string}>} variants
|
|
37
|
+
* @returns {{encoding: string, match: string}|null}
|
|
38
|
+
*/
|
|
39
|
+
function findEncodedInDomain(domain, variants) {
|
|
40
|
+
const stripped = domain.replace(/\./g, '');
|
|
41
|
+
for (const { encoding, encoded } of variants) {
|
|
42
|
+
if (encoded.length < MIN_ENCODED_MATCH) continue;
|
|
43
|
+
for (let i = 0; i <= encoded.length - MIN_ENCODED_MATCH; i++) {
|
|
44
|
+
const chunk = encoded.substring(i, i + MIN_ENCODED_MATCH);
|
|
45
|
+
if (stripped.includes(chunk)) {
|
|
46
|
+
return { encoding, match: chunk };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
13
53
|
/**
|
|
14
54
|
* Canary token generators.
|
|
15
55
|
* Each generator produces a format-valid token that matches the real service format.
|
|
@@ -173,6 +213,7 @@ function detectCanaryExfiltration(networkLogs, tokens) {
|
|
|
173
213
|
for (const domain of (networkLogs.dns_queries || [])) {
|
|
174
214
|
if (!domain) continue;
|
|
175
215
|
for (const [tokenName, tokenValue] of tokenEntries) {
|
|
216
|
+
// Raw value match
|
|
176
217
|
if (domain.includes(tokenValue)) {
|
|
177
218
|
exfiltrations.push({
|
|
178
219
|
token: tokenName,
|
|
@@ -180,6 +221,18 @@ function detectCanaryExfiltration(networkLogs, tokens) {
|
|
|
180
221
|
foundIn: `DNS query: ${domain}`,
|
|
181
222
|
severity: 'CRITICAL'
|
|
182
223
|
});
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
// Encoded variant match (hex, base64, base64url — catches DNS label chunking)
|
|
227
|
+
const variants = generateEncodedVariants(tokenValue);
|
|
228
|
+
const encodedMatch = findEncodedInDomain(domain, variants);
|
|
229
|
+
if (encodedMatch) {
|
|
230
|
+
exfiltrations.push({
|
|
231
|
+
token: tokenName,
|
|
232
|
+
value: tokenValue,
|
|
233
|
+
foundIn: `DNS query (${encodedMatch.encoding}-encoded): ${domain}`,
|
|
234
|
+
severity: 'CRITICAL'
|
|
235
|
+
});
|
|
183
236
|
}
|
|
184
237
|
}
|
|
185
238
|
}
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
// ══════════════════════════════════════════════════════════════
|
|
7
|
+
// gVisor strace log parser
|
|
8
|
+
//
|
|
9
|
+
// gVisor's --strace flag logs syscalls at the kernel level in its
|
|
10
|
+
// debug log files. This parser extracts security-relevant data
|
|
11
|
+
// (file access, network connections, process execution) and returns
|
|
12
|
+
// the SAME structure as sandbox-runner.sh's strace/tcpdump parsing,
|
|
13
|
+
// so the downstream scoreFindings() analyzer needs no changes.
|
|
14
|
+
//
|
|
15
|
+
// gVisor strace format:
|
|
16
|
+
// D0331 12:34:56.789012 1 strace.go:587] [ PID] process E syscall(args) = ret (dur)
|
|
17
|
+
//
|
|
18
|
+
// Or bare (without Go log prefix):
|
|
19
|
+
// [ PID] process E syscall(args) = ret (dur)
|
|
20
|
+
// ══════════════════════════════════════════════════════════════
|
|
21
|
+
|
|
22
|
+
const SENSITIVE_PATTERN = /\.npmrc|\.ssh\/|\.aws\/|\.env(?:$|[^a-zA-Z])|\/etc\/passwd|\/etc\/shadow|\.gitconfig|\.bash_history/;
|
|
23
|
+
|
|
24
|
+
// Processes that are sandbox infrastructure — not spawned by the package
|
|
25
|
+
const SAFE_PROCESSES = new Set(['node', 'npm', 'npx', 'sh', 'git']);
|
|
26
|
+
|
|
27
|
+
// ── Line-level parsers ──
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Parse a single gVisor strace line.
|
|
31
|
+
* Handles both Go-log-prefixed and bare formats.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} line - Raw log line
|
|
34
|
+
* @returns {object|null} Parsed syscall info or null if not a strace line
|
|
35
|
+
*/
|
|
36
|
+
function parseStraceLine(line) {
|
|
37
|
+
// Strip Go log prefix if present: everything up to `] ` before the `[PID]` block
|
|
38
|
+
const bracketIdx = line.indexOf('] [');
|
|
39
|
+
const content = bracketIdx >= 0 ? line.substring(bracketIdx + 2).trim() : line.trim();
|
|
40
|
+
|
|
41
|
+
// Match: [PID] process E/X syscall(args) = return (duration)
|
|
42
|
+
const match = content.match(
|
|
43
|
+
/^\[\s*(\d+)\]\s+(\S+)\s+[EX]\s+(\w+)\((.+)\)\s*=\s*(.+?)(?:\s+\([\d.]+[µm]?s\))?$/
|
|
44
|
+
);
|
|
45
|
+
if (!match) return null;
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
pid: parseInt(match[1], 10),
|
|
49
|
+
process: match[2],
|
|
50
|
+
syscall: match[3],
|
|
51
|
+
args: match[4],
|
|
52
|
+
returnValue: match[5].trim()
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Extract file path and flags from openat/open args.
|
|
58
|
+
* gVisor format: AT_FDCWD, "/path", O_RDONLY|O_CLOEXEC
|
|
59
|
+
*
|
|
60
|
+
* @param {string} args - Syscall arguments string
|
|
61
|
+
* @returns {object|null} { path, flags } or null
|
|
62
|
+
*/
|
|
63
|
+
function extractOpenatPath(args) {
|
|
64
|
+
// Comma-separated: AT_FDCWD, "/path/to/file", O_RDONLY|O_CLOEXEC, 0o0
|
|
65
|
+
const match = args.match(/"([^"]+)"[\s,]+([A-Z_|]+)/);
|
|
66
|
+
if (match) return { path: match[1], flags: match[2] };
|
|
67
|
+
|
|
68
|
+
// Space-separated (some gVisor versions): AT_FDCWD "/path" O_RDONLY
|
|
69
|
+
const matchSpace = args.match(/"([^"]+)"\s+([A-Z_|]+)/);
|
|
70
|
+
if (matchSpace) return { path: matchSpace[1], flags: matchSpace[2] };
|
|
71
|
+
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Extract IP and port from connect() args.
|
|
77
|
+
* gVisor format: {Family: AF_INET, Addr: 1.2.3.4, Port: 443}
|
|
78
|
+
*
|
|
79
|
+
* @param {string} args - Syscall arguments string
|
|
80
|
+
* @returns {object|null} { ip, port } or null
|
|
81
|
+
*/
|
|
82
|
+
function extractConnectInfo(args) {
|
|
83
|
+
const match = args.match(/\{Family:\s*AF_INET,\s*Addr:\s*([\d.]+),\s*Port:\s*(\d+)\}/);
|
|
84
|
+
if (!match) return null;
|
|
85
|
+
return { ip: match[1], port: parseInt(match[2], 10) };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Extract command path from execve() args.
|
|
90
|
+
* gVisor format: execve("/usr/bin/curl", ["curl", ...], ...)
|
|
91
|
+
*
|
|
92
|
+
* @param {string} args - Syscall arguments string
|
|
93
|
+
* @returns {string|null} Command path or null
|
|
94
|
+
*/
|
|
95
|
+
function extractExecveCommand(args) {
|
|
96
|
+
const match = args.match(/^"([^"]+)"/);
|
|
97
|
+
return match ? match[1] : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Main parser ──
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Parse gVisor strace log content and extract security-relevant findings.
|
|
104
|
+
* Returns the SAME data structure as sandbox-runner.sh's strace parsing
|
|
105
|
+
* so scoreFindings() works identically.
|
|
106
|
+
*
|
|
107
|
+
* @param {string} content - Raw gVisor strace log content
|
|
108
|
+
* @returns {object} { sensitive_files: {read, written}, network: {http_connections}, processes: {spawned} }
|
|
109
|
+
*/
|
|
110
|
+
function parseGvisorStrace(content) {
|
|
111
|
+
const sensitiveReads = new Set();
|
|
112
|
+
const sensitiveWrites = new Set();
|
|
113
|
+
const connections = new Map(); // dedup by ip:port
|
|
114
|
+
const processes = new Map(); // dedup by pid:command
|
|
115
|
+
|
|
116
|
+
const lines = content.split('\n');
|
|
117
|
+
|
|
118
|
+
for (const line of lines) {
|
|
119
|
+
const parsed = parseStraceLine(line);
|
|
120
|
+
if (!parsed) continue;
|
|
121
|
+
|
|
122
|
+
// Only process successful syscalls (return >= 0)
|
|
123
|
+
if (parsed.returnValue.startsWith('-')) continue;
|
|
124
|
+
|
|
125
|
+
switch (parsed.syscall) {
|
|
126
|
+
case 'openat':
|
|
127
|
+
case 'open': {
|
|
128
|
+
const info = extractOpenatPath(parsed.args);
|
|
129
|
+
if (!info || !SENSITIVE_PATTERN.test(info.path)) break;
|
|
130
|
+
|
|
131
|
+
if (/O_WRONLY|O_RDWR|O_CREAT/.test(info.flags)) {
|
|
132
|
+
sensitiveWrites.add(info.path);
|
|
133
|
+
} else if (/O_RDONLY/.test(info.flags)) {
|
|
134
|
+
sensitiveReads.add(info.path);
|
|
135
|
+
}
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
case 'connect': {
|
|
140
|
+
const conn = extractConnectInfo(parsed.args);
|
|
141
|
+
if (!conn) break;
|
|
142
|
+
if (conn.ip.startsWith('127.')) break; // skip loopback
|
|
143
|
+
if (conn.port === 65535) break; // skip probe port
|
|
144
|
+
const key = `${conn.ip}:${conn.port}`;
|
|
145
|
+
if (!connections.has(key)) {
|
|
146
|
+
connections.set(key, { host: conn.ip, port: conn.port, protocol: 'TCP' });
|
|
147
|
+
}
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
case 'execve': {
|
|
152
|
+
const cmd = extractExecveCommand(parsed.args);
|
|
153
|
+
if (!cmd) break;
|
|
154
|
+
const basename = path.basename(cmd);
|
|
155
|
+
if (SAFE_PROCESSES.has(basename)) break;
|
|
156
|
+
const key = `${parsed.pid}:${cmd}`;
|
|
157
|
+
if (!processes.has(key)) {
|
|
158
|
+
processes.set(key, { command: cmd, pid: parsed.pid });
|
|
159
|
+
}
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
sensitive_files: {
|
|
167
|
+
read: [...sensitiveReads],
|
|
168
|
+
written: [...sensitiveWrites]
|
|
169
|
+
},
|
|
170
|
+
network: {
|
|
171
|
+
http_connections: [...connections.values()]
|
|
172
|
+
},
|
|
173
|
+
processes: {
|
|
174
|
+
spawned: [...processes.values()]
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── Log discovery ──
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Find gVisor log files for a specific container.
|
|
183
|
+
* Searches the debug-log directory using multiple strategies:
|
|
184
|
+
* 1. %ID% template → directory named after full container ID
|
|
185
|
+
* 2. Truncated ID (12 chars) directory
|
|
186
|
+
* 3. Files in logDir containing the container ID
|
|
187
|
+
*
|
|
188
|
+
* @param {string} containerId - Docker container ID (full 64-char or truncated)
|
|
189
|
+
* @param {string} logDir - gVisor debug-log base directory (default: /tmp/runsc)
|
|
190
|
+
* @returns {string[]} Matching log file paths
|
|
191
|
+
*/
|
|
192
|
+
function findGvisorLogs(containerId, logDir) {
|
|
193
|
+
logDir = logDir || '/tmp/runsc';
|
|
194
|
+
const logFiles = [];
|
|
195
|
+
|
|
196
|
+
if (!fs.existsSync(logDir)) return logFiles;
|
|
197
|
+
|
|
198
|
+
const shortId = containerId.substring(0, 12);
|
|
199
|
+
|
|
200
|
+
// Strategy 1: directory named after full container ID (%ID% template)
|
|
201
|
+
const fullDir = path.join(logDir, containerId);
|
|
202
|
+
if (fs.existsSync(fullDir) && fs.statSync(fullDir).isDirectory()) {
|
|
203
|
+
return collectLogFiles(fullDir);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Strategy 2: directory named after truncated ID
|
|
207
|
+
const shortDir = path.join(logDir, shortId);
|
|
208
|
+
if (fs.existsSync(shortDir) && fs.statSync(shortDir).isDirectory()) {
|
|
209
|
+
return collectLogFiles(shortDir);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Strategy 3: flat files containing the container ID in name
|
|
213
|
+
try {
|
|
214
|
+
const files = fs.readdirSync(logDir);
|
|
215
|
+
for (const file of files) {
|
|
216
|
+
if ((file.includes(containerId) || file.includes(shortId)) &&
|
|
217
|
+
(file.endsWith('.log') || file.includes('boot'))) {
|
|
218
|
+
logFiles.push(path.join(logDir, file));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
} catch { /* directory not readable */ }
|
|
222
|
+
|
|
223
|
+
return logFiles;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function collectLogFiles(dir) {
|
|
227
|
+
const files = [];
|
|
228
|
+
try {
|
|
229
|
+
for (const file of fs.readdirSync(dir)) {
|
|
230
|
+
if (file.endsWith('.log') || file.includes('boot')) {
|
|
231
|
+
files.push(path.join(dir, file));
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
} catch { /* directory not readable */ }
|
|
235
|
+
return files;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ── Aggregated parser (main entry point) ──
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Parse gVisor log file and return report-compatible structure.
|
|
242
|
+
* This is the main export matching the spec:
|
|
243
|
+
* parseGvisorLog(logPath) → same format as parseStraceOutput()
|
|
244
|
+
*
|
|
245
|
+
* @param {string} logPath - Path to a gVisor strace log file
|
|
246
|
+
* @returns {object} Report supplement with sensitive_files, network, processes
|
|
247
|
+
*/
|
|
248
|
+
function parseGvisorLog(logPath) {
|
|
249
|
+
try {
|
|
250
|
+
const content = fs.readFileSync(logPath, 'utf8');
|
|
251
|
+
return parseGvisorStrace(content);
|
|
252
|
+
} catch {
|
|
253
|
+
return {
|
|
254
|
+
sensitive_files: { read: [], written: [] },
|
|
255
|
+
network: { http_connections: [] },
|
|
256
|
+
processes: { spawned: [] }
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Parse all gVisor logs for a container and return aggregated report supplement.
|
|
263
|
+
*
|
|
264
|
+
* @param {string} containerId - Docker container ID
|
|
265
|
+
* @param {string} logDir - gVisor debug-log base directory
|
|
266
|
+
* @returns {object} Aggregated report supplement
|
|
267
|
+
*/
|
|
268
|
+
function parseGvisorLogs(containerId, logDir) {
|
|
269
|
+
const emptyResult = {
|
|
270
|
+
sensitive_files: { read: [], written: [] },
|
|
271
|
+
network: { http_connections: [] },
|
|
272
|
+
processes: { spawned: [] }
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const logFiles = findGvisorLogs(containerId, logDir);
|
|
276
|
+
if (logFiles.length === 0) return emptyResult;
|
|
277
|
+
|
|
278
|
+
// Aggregate across all log files (boot, gofer, etc.)
|
|
279
|
+
const allReads = new Set();
|
|
280
|
+
const allWrites = new Set();
|
|
281
|
+
const allConnections = new Map();
|
|
282
|
+
const allProcesses = new Map();
|
|
283
|
+
|
|
284
|
+
for (const logFile of logFiles) {
|
|
285
|
+
const result = parseGvisorLog(logFile);
|
|
286
|
+
|
|
287
|
+
for (const f of result.sensitive_files.read) allReads.add(f);
|
|
288
|
+
for (const f of result.sensitive_files.written) allWrites.add(f);
|
|
289
|
+
for (const c of result.network.http_connections) {
|
|
290
|
+
const key = `${c.host}:${c.port}`;
|
|
291
|
+
if (!allConnections.has(key)) allConnections.set(key, c);
|
|
292
|
+
}
|
|
293
|
+
for (const p of result.processes.spawned) {
|
|
294
|
+
const key = `${p.pid}:${p.command}`;
|
|
295
|
+
if (!allProcesses.has(key)) allProcesses.set(key, p);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
sensitive_files: { read: [...allReads], written: [...allWrites] },
|
|
301
|
+
network: { http_connections: [...allConnections.values()] },
|
|
302
|
+
processes: { spawned: [...allProcesses.values()] }
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Clean up gVisor log files for a container after analysis.
|
|
308
|
+
* Prevents disk fill from accumulated logs across sandbox runs.
|
|
309
|
+
*
|
|
310
|
+
* @param {string} containerId - Docker container ID
|
|
311
|
+
* @param {string} logDir - gVisor debug-log base directory
|
|
312
|
+
*/
|
|
313
|
+
function cleanupGvisorLogs(containerId, logDir) {
|
|
314
|
+
logDir = logDir || '/tmp/runsc';
|
|
315
|
+
const shortId = containerId.substring(0, 12);
|
|
316
|
+
|
|
317
|
+
try {
|
|
318
|
+
// Try container-specific directory first
|
|
319
|
+
for (const dirName of [containerId, shortId]) {
|
|
320
|
+
const dir = path.join(logDir, dirName);
|
|
321
|
+
if (fs.existsSync(dir) && fs.statSync(dir).isDirectory()) {
|
|
322
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Fall back to individual files
|
|
328
|
+
const files = fs.readdirSync(logDir);
|
|
329
|
+
for (const file of files) {
|
|
330
|
+
if (file.includes(containerId) || file.includes(shortId)) {
|
|
331
|
+
fs.unlinkSync(path.join(logDir, file));
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
} catch { /* cleanup is best-effort */ }
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
module.exports = {
|
|
338
|
+
parseGvisorLog,
|
|
339
|
+
parseGvisorLogs,
|
|
340
|
+
parseGvisorStrace,
|
|
341
|
+
findGvisorLogs,
|
|
342
|
+
cleanupGvisorLogs,
|
|
343
|
+
// Exported for unit tests
|
|
344
|
+
parseStraceLine,
|
|
345
|
+
extractOpenatPath,
|
|
346
|
+
extractConnectInfo,
|
|
347
|
+
extractExecveCommand
|
|
348
|
+
};
|
package/src/sandbox/index.js
CHANGED
|
@@ -16,6 +16,7 @@ const {
|
|
|
16
16
|
const { NPM_PACKAGE_REGEX } = require('../shared/constants.js');
|
|
17
17
|
const { analyzePreloadLog } = require('./analyzer.js');
|
|
18
18
|
const { classifyDomain } = require('./network-allowlist.js');
|
|
19
|
+
const { parseGvisorLogs, cleanupGvisorLogs } = require('./gvisor-parser.js');
|
|
19
20
|
|
|
20
21
|
const DOCKER_IMAGE = 'muaddib-sandbox';
|
|
21
22
|
const CONTAINER_TIMEOUT = 120000; // 120 seconds
|
|
@@ -135,6 +136,17 @@ function imageExists() {
|
|
|
135
136
|
}
|
|
136
137
|
}
|
|
137
138
|
|
|
139
|
+
// ── gVisor availability check ──
|
|
140
|
+
|
|
141
|
+
function isGvisorAvailable() {
|
|
142
|
+
try {
|
|
143
|
+
const info = execSync('docker info', { encoding: 'utf8', stdio: 'pipe', timeout: 10000 });
|
|
144
|
+
return /\brunsc\b/.test(info);
|
|
145
|
+
} catch {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
138
150
|
// ── Build image (with cache) ──
|
|
139
151
|
|
|
140
152
|
async function buildSandboxImage() {
|
|
@@ -186,6 +198,7 @@ async function runSingleSandbox(packageName, options = {}) {
|
|
|
186
198
|
const mode = strict ? 'strict' : 'permissive';
|
|
187
199
|
const timeOffset = options.timeOffset || 0;
|
|
188
200
|
const runTimeout = options.runTimeout || CONTAINER_TIMEOUT;
|
|
201
|
+
const gvisorMode = options.gvisor || false;
|
|
189
202
|
|
|
190
203
|
return new Promise((resolve) => {
|
|
191
204
|
let stdout = '';
|
|
@@ -209,6 +222,12 @@ async function runSingleSandbox(packageName, options = {}) {
|
|
|
209
222
|
'--cap-drop=ALL'
|
|
210
223
|
];
|
|
211
224
|
|
|
225
|
+
// gVisor runtime: use runsc instead of default runc
|
|
226
|
+
if (gvisorMode) {
|
|
227
|
+
dockerArgs.push('--runtime=runsc');
|
|
228
|
+
dockerArgs.push('-e', 'MUADDIB_GVISOR=1');
|
|
229
|
+
}
|
|
230
|
+
|
|
212
231
|
// Inject canary tokens as environment variables
|
|
213
232
|
if (canaryTokens) {
|
|
214
233
|
for (const [key, value] of Object.entries(canaryTokens)) {
|
|
@@ -239,11 +258,14 @@ async function runSingleSandbox(packageName, options = {}) {
|
|
|
239
258
|
}
|
|
240
259
|
|
|
241
260
|
// Both modes need NET_RAW for tcpdump (runs as root in entrypoint).
|
|
261
|
+
// gVisor mode: no tcpdump needed — gVisor captures via --strace/--log-packets.
|
|
242
262
|
// Strict mode also needs NET_ADMIN for iptables network blocking.
|
|
243
263
|
// SYS_PTRACE is not needed: strace traces its own child (npm install via su).
|
|
244
264
|
// SETUID + SETGID required for su (privilege drop to sandboxuser).
|
|
245
265
|
// CHOWN required for chown in sandbox-runner.sh.
|
|
246
|
-
|
|
266
|
+
if (!gvisorMode) {
|
|
267
|
+
dockerArgs.push('--cap-add=NET_RAW');
|
|
268
|
+
}
|
|
247
269
|
dockerArgs.push('--cap-add=SETUID');
|
|
248
270
|
dockerArgs.push('--cap-add=SETGID');
|
|
249
271
|
dockerArgs.push('--cap-add=CHOWN');
|
|
@@ -270,6 +292,7 @@ async function runSingleSandbox(packageName, options = {}) {
|
|
|
270
292
|
dockerArgs.push(mode);
|
|
271
293
|
|
|
272
294
|
const proc = spawn('docker', dockerArgs);
|
|
295
|
+
let gvisorContainerId = null;
|
|
273
296
|
|
|
274
297
|
// Timeout: kill container
|
|
275
298
|
const timer = setTimeout(() => {
|
|
@@ -294,6 +317,16 @@ async function runSingleSandbox(packageName, options = {}) {
|
|
|
294
317
|
|
|
295
318
|
proc.stderr.on('data', (data) => {
|
|
296
319
|
stderr += data.toString();
|
|
320
|
+
|
|
321
|
+
// Capture container ID for gVisor log retrieval (once, while container is running)
|
|
322
|
+
if (gvisorMode && !gvisorContainerId) {
|
|
323
|
+
try {
|
|
324
|
+
gvisorContainerId = execFileSync('docker', ['inspect', '--format={{.Id}}', containerName], {
|
|
325
|
+
encoding: 'utf8', stdio: 'pipe', timeout: 5000
|
|
326
|
+
}).trim();
|
|
327
|
+
} catch { /* container not yet ready, will retry on next data event */ }
|
|
328
|
+
}
|
|
329
|
+
|
|
297
330
|
// Forward sandbox progress logs (sanitize ANSI escape sequences)
|
|
298
331
|
const text = data.toString().replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
|
|
299
332
|
for (const line of text.split(/\r?\n/)) {
|
|
@@ -366,6 +399,43 @@ async function runSingleSandbox(packageName, options = {}) {
|
|
|
366
399
|
return;
|
|
367
400
|
}
|
|
368
401
|
|
|
402
|
+
// In gVisor mode, merge kernel-level strace data from gVisor debug logs.
|
|
403
|
+
// sandbox-runner.sh skips strace/tcpdump in gVisor mode, so file access,
|
|
404
|
+
// connections, and process data come from gVisor's kernel-level tracing.
|
|
405
|
+
if (gvisorMode && gvisorContainerId) {
|
|
406
|
+
const gvisorLogDir = process.env.MUADDIB_GVISOR_LOG_DIR || '/tmp/runsc';
|
|
407
|
+
const gvisorData = parseGvisorLogs(gvisorContainerId, gvisorLogDir);
|
|
408
|
+
|
|
409
|
+
// Merge gVisor findings into report without duplicating
|
|
410
|
+
if (!report.sensitive_files) report.sensitive_files = { read: [], written: [] };
|
|
411
|
+
if (!report.network) report.network = {};
|
|
412
|
+
if (!report.processes) report.processes = { spawned: [] };
|
|
413
|
+
|
|
414
|
+
const existingReads = new Set(report.sensitive_files.read || []);
|
|
415
|
+
for (const f of gvisorData.sensitive_files.read) {
|
|
416
|
+
if (!existingReads.has(f)) report.sensitive_files.read.push(f);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const existingWrites = new Set(report.sensitive_files.written || []);
|
|
420
|
+
for (const f of gvisorData.sensitive_files.written) {
|
|
421
|
+
if (!existingWrites.has(f)) report.sensitive_files.written.push(f);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const existingConns = new Set((report.network.http_connections || []).map(c => `${c.host}:${c.port}`));
|
|
425
|
+
if (!report.network.http_connections) report.network.http_connections = [];
|
|
426
|
+
for (const c of gvisorData.network.http_connections) {
|
|
427
|
+
if (!existingConns.has(`${c.host}:${c.port}`)) report.network.http_connections.push(c);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const existingProcs = new Set((report.processes.spawned || []).map(p => p.command));
|
|
431
|
+
for (const p of gvisorData.processes.spawned) {
|
|
432
|
+
if (!existingProcs.has(p.command)) report.processes.spawned.push(p);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Cleanup gVisor logs to prevent disk fill
|
|
436
|
+
cleanupGvisorLogs(gvisorContainerId, gvisorLogDir);
|
|
437
|
+
}
|
|
438
|
+
|
|
369
439
|
const { score, findings } = scoreFindings(report);
|
|
370
440
|
|
|
371
441
|
// Analyze preload log for behavioral findings
|
|
@@ -475,6 +545,17 @@ async function runSandbox(packageName, options = {}) {
|
|
|
475
545
|
return cleanResult;
|
|
476
546
|
}
|
|
477
547
|
|
|
548
|
+
// Detect sandbox runtime (gVisor or default Docker/runc)
|
|
549
|
+
let useGvisor = process.env.MUADDIB_SANDBOX_RUNTIME === 'gvisor';
|
|
550
|
+
if (useGvisor) {
|
|
551
|
+
if (isGvisorAvailable()) {
|
|
552
|
+
console.log('[SANDBOX] Runtime: gvisor (runsc)');
|
|
553
|
+
} else {
|
|
554
|
+
console.log('[SANDBOX] Runtime: gvisor requested but runsc not configured in Docker. Falling back to Docker standard.');
|
|
555
|
+
useGvisor = false;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
478
559
|
// Generate canary tokens for this sandbox session
|
|
479
560
|
let canaryTokens = null;
|
|
480
561
|
if (canaryEnabled) {
|
|
@@ -492,7 +573,8 @@ async function runSandbox(packageName, options = {}) {
|
|
|
492
573
|
await acquireSandboxSlot();
|
|
493
574
|
|
|
494
575
|
try {
|
|
495
|
-
|
|
576
|
+
const runtimeLabel = useGvisor ? 'gvisor' : 'docker';
|
|
577
|
+
console.log(`[SANDBOX] Analyzing "${displayName}" in isolated container (mode: ${mode}, runtime: ${runtimeLabel}${canaryEnabled ? ', canary: on' : ''}${local ? ', local' : ''}, runs: ${TIME_OFFSETS.length}, slots: ${_sandboxSemaphore.active}/${SANDBOX_CONCURRENCY_MAX})...`);
|
|
496
578
|
|
|
497
579
|
const allRuns = [];
|
|
498
580
|
let bestResult = cleanResult;
|
|
@@ -508,7 +590,8 @@ async function runSandbox(packageName, options = {}) {
|
|
|
508
590
|
localAbsPath,
|
|
509
591
|
displayName,
|
|
510
592
|
timeOffset: offset,
|
|
511
|
-
runTimeout: SINGLE_RUN_TIMEOUT
|
|
593
|
+
runTimeout: SINGLE_RUN_TIMEOUT,
|
|
594
|
+
gvisor: useGvisor
|
|
512
595
|
});
|
|
513
596
|
|
|
514
597
|
allRuns.push({
|
|
@@ -894,4 +977,4 @@ function displayResults(result) {
|
|
|
894
977
|
}
|
|
895
978
|
}
|
|
896
979
|
|
|
897
|
-
module.exports = { buildSandboxImage, runSandbox, runSingleSandbox, scoreFindings, generateNetworkReport, EXFIL_PATTERNS, SAFE_DOMAINS, getSeverity, displayResults, isDockerAvailable, imageExists, STATIC_CANARY_TOKENS, detectStaticCanaryExfiltration, analyzePreloadLog, TIME_OFFSETS, SAFE_SANDBOX_CMDS, SANDBOX_CONCURRENCY_MAX, acquireSandboxSlot, releaseSandboxSlot, resetSandboxLimiter, getSandboxSemaphore };
|
|
980
|
+
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, releaseSandboxSlot, resetSandboxLimiter, getSandboxSemaphore };
|