muaddib-scanner 2.10.29 → 2.10.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,7 +30,7 @@
30
30
 
31
31
  npm and PyPI supply-chain attacks are exploding. Shai-Hulud compromised 25K+ repos in 2025. Existing tools detect threats but don't help you respond.
32
32
 
33
- MUAD'DIB combines **14 parallel scanners** (176 detection rules), a **deobfuscation engine**, **inter-module dataflow analysis**, **compound scoring**, **ML classifiers** (XGBoost), and Docker sandbox to detect known threats and suspicious behavioral patterns in npm and PyPI packages.
33
+ MUAD'DIB combines **14 parallel scanners** (195 detection rules), a **deobfuscation engine**, **inter-module dataflow analysis**, **compound scoring**, **ML classifiers** (XGBoost), and Docker sandbox to detect known threats and suspicious behavioral patterns in npm and PyPI packages.
34
34
 
35
35
  ---
36
36
 
@@ -195,7 +195,7 @@ muaddib replay # Ground truth validation (46/49 TPR)
195
195
  | GitHub Actions | Shai-Hulud backdoor detection |
196
196
  | Hash Scanner | Known malicious file hashes |
197
197
 
198
- ### 176 detection rules
198
+ ### 195 detection rules
199
199
 
200
200
  All rules are mapped to MITRE ATT&CK techniques. See [SECURITY.md](SECURITY.md#detection-rules-v21021) for the complete rules reference.
201
201
 
@@ -271,7 +271,7 @@ With pre-commit framework:
271
271
  ```yaml
272
272
  repos:
273
273
  - repo: https://github.com/DNSZLSK/muad-dib
274
- rev: v2.10.21
274
+ rev: v2.10.31
275
275
  hooks:
276
276
  - id: muaddib-scan
277
277
  ```
@@ -288,7 +288,7 @@ repos:
288
288
  | **FPR** (Benign random) | **7.5%** (15/200) | 200 random npm packages, stratified sampling |
289
289
  | **ADR** (Adversarial + Holdout) | **94.0%** (101/107) | 67 adversarial + 40 holdout (107 available on disk), global threshold=20 |
290
290
 
291
- **2793 tests** across 57 files. **176 rules** (171 RULES + 5 PARANOID).
291
+ **2868 tests** across 62 files. **195 rules** (190 RULES + 5 PARANOID).
292
292
 
293
293
  > **Methodology caveats:**
294
294
  > - TPR measured on 49 Node.js attack samples (3 browser-only excluded from 51 total)
@@ -329,7 +329,7 @@ npm test
329
329
 
330
330
  ### Testing
331
331
 
332
- - **2793 tests** across 57 modular test files
332
+ - **2868 tests** across 62 modular test files
333
333
  - **56 fuzz tests** - Malformed inputs, ReDoS, unicode, binary
334
334
  - **Datadog 17K benchmark** - 14,587 confirmed malware samples (in-scope)
335
335
  - **Ground truth validation** - 51 real-world attacks (93.9% TPR)
@@ -351,7 +351,7 @@ npm test
351
351
  - [Evaluation Methodology](docs/EVALUATION_METHODOLOGY.md) - Experimental protocol, holdout scores
352
352
  - [Threat Model](docs/threat-model.md) - What MUAD'DIB detects and doesn't detect
353
353
  - [Adversarial Evaluation](ADVERSARIAL.md) - Red team samples and ADR results
354
- - [Security Policy](SECURITY.md) - Detection rules reference (176 rules)
354
+ - [Security Policy](SECURITY.md) - Detection rules reference (195 rules)
355
355
  - [Security Audit](docs/SECURITY_AUDIT.md) - Bypass validation report
356
356
  - [FP Analysis](docs/EVALUATION.md) - Historical false positive analysis
357
357
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.10.29",
3
+ "version": "2.10.31",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/sbom.json ADDED
Binary file
@@ -5,7 +5,7 @@ const path = require('path');
5
5
  // Read version from package.json for pre-commit config
6
6
  const PKG_VERSION = (() => {
7
7
  try {
8
- return 'v' + JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).version;
8
+ return 'v' + JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8')).version;
9
9
  } catch {
10
10
  return 'v1.0.0';
11
11
  }
@@ -107,7 +107,7 @@ async function interactiveMenu() {
107
107
  message: 'Webhook URL:'
108
108
  });
109
109
  }
110
- const { startDaemon } = require('../src/daemon.js');
110
+ const { startDaemon } = require('../daemon.js');
111
111
  startDaemon({ webhook });
112
112
  }
113
113
 
@@ -146,14 +146,14 @@ async function interactiveMenu() {
146
146
  }
147
147
 
148
148
  if (action === 'feed') {
149
- const { getFeed } = require('../src/threat-feed.js');
149
+ const { getFeed } = require('../threat-feed.js');
150
150
  const result = getFeed();
151
151
  console.log(JSON.stringify(result, null, 2));
152
152
  process.exit(0);
153
153
  }
154
154
 
155
155
  if (action === 'serve') {
156
- const { startServer } = require('../src/serve.js');
156
+ const { startServer } = require('../serve.js');
157
157
  startServer({ port: 3000 });
158
158
  // Server runs indefinitely
159
159
  }
package/src/index.js CHANGED
@@ -6,29 +6,30 @@ const { process: processThreats } = require('./pipeline/processor.js');
6
6
  const { output } = require('./pipeline/outputter.js');
7
7
 
8
8
  async function run(targetPath, options = {}) {
9
- // Phase 1: Initialization (validate, IOCs, config, Python detection)
10
- const { pythonDeps, configApplied, configResult, warnings } = await initialize(targetPath, options);
9
+ try {
10
+ // Phase 1: Initialization (validate, IOCs, config, Python detection)
11
+ const { pythonDeps, configApplied, configResult, warnings } = await initialize(targetPath, options);
11
12
 
12
- // Phase 2: Execute all scanners
13
- const { threats, scannerErrors } = await execute(targetPath, options, pythonDeps, warnings);
13
+ // Phase 2: Execute all scanners
14
+ const { threats, scannerErrors } = await execute(targetPath, options, pythonDeps, warnings);
14
15
 
15
- // Phase 3: Process threats (sandbox, dedup, compounds, FP reduction, intent, scoring)
16
- const processed = await processThreats(threats, targetPath, options, pythonDeps, warnings, scannerErrors);
17
- const { result } = processed;
16
+ // Phase 3: Process threats (sandbox, dedup, compounds, FP reduction, intent, scoring)
17
+ const processed = await processThreats(threats, targetPath, options, pythonDeps, warnings, scannerErrors);
18
+ const { result } = processed;
18
19
 
19
- // _capture mode: return result directly without printing (used by diff.js)
20
- if (options._capture) {
21
- resetAll();
22
- return result;
23
- }
24
-
25
- // Phase 4: Output (CLI formatting, webhook, exit code)
26
- const exitCode = await output(result, options, processed);
20
+ // _capture mode: return result directly without printing (used by diff.js)
21
+ if (options._capture) {
22
+ return result;
23
+ }
27
24
 
28
- // Clear all per-scan mutable state
29
- resetAll();
25
+ // Phase 4: Output (CLI formatting, webhook, exit code)
26
+ const exitCode = await output(result, options, processed);
30
27
 
31
- return exitCode;
28
+ return exitCode;
29
+ } finally {
30
+ // Clear all per-scan mutable state — even on exception
31
+ resetAll();
32
+ }
32
33
  }
33
34
 
34
35
  module.exports = { run, isPackageLevelThreat, computeGroupScore };
@@ -1059,7 +1059,7 @@ async function sendReportNow(stats) {
1059
1059
  pypiLastPackage: stateRaw.pypiLastPackage || ''
1060
1060
  };
1061
1061
  stats.lastDailyReportDate = today;
1062
- saveState(state);
1062
+ saveState(state, stats);
1063
1063
  saveLastDailyReportDate(today);
1064
1064
 
1065
1065
  return { sent: true, message: 'Daily report sent' };
@@ -4,7 +4,7 @@ const { RULES } = require('../rules/index.js');
4
4
 
5
5
  const pkgVersion = (() => {
6
6
  try {
7
- return JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).version;
7
+ return JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8')).version;
8
8
  } catch {
9
9
  return '0.0.0';
10
10
  }
@@ -511,6 +511,13 @@ const PLAYBOOKS = {
511
511
  'CRITIQUE: Un Proxy JavaScript avec trap set/get/apply est combine avec un appel reseau. ' +
512
512
  'Technique d\'interception: le Proxy capture toutes les ecritures de proprietes (credentials, tokens, config) ' +
513
513
  'et les exfiltre via HTTPS/fetch/dgram. Supprimer le package. Auditer tous les modules qui importent ce package.',
514
+ proxy_globalthis_intercept:
515
+ 'CRITIQUE: new Proxy(globalThis/global) intercepte tous les acces au scope global. ' +
516
+ 'L\'attaquant peut hooker eval, Function, require de maniere transparente via le handler Proxy. ' +
517
+ 'Supprimer le package immediatement.',
518
+ reflect_bind_code_execution:
519
+ 'CRITIQUE: Reflect.apply() avec methode prototype (bind/call/apply) et thisArg=Function/eval. ' +
520
+ 'Evasion de 2nd niveau contournant la detection Reflect.apply(eval). Supprimer le package.',
514
521
  detached_credential_exfil:
515
522
  'CRITIQUE: Process detache avec acces aux credentials et exfiltration reseau. ' +
516
523
  'Technique DPRK/Lazarus: le process fils survit au parent (detached:true, unref()) et exfiltre des secrets en arriere-plan. ' +
@@ -2142,6 +2142,24 @@ const RULES = {
2142
2142
  ],
2143
2143
  mitre: 'T1082'
2144
2144
  },
2145
+ proxy_globalthis_intercept: {
2146
+ id: 'MUADDIB-AST-083',
2147
+ name: 'Proxy GlobalThis Interception',
2148
+ severity: 'CRITICAL',
2149
+ confidence: 'high',
2150
+ description: 'new Proxy(globalThis/global/window/self) — intercepts all global scope access, enabling transparent hooking of eval/Function/require.',
2151
+ references: ['https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy'],
2152
+ mitre: 'T1574'
2153
+ },
2154
+ reflect_bind_code_execution: {
2155
+ id: 'MUADDIB-AST-084',
2156
+ name: 'Reflect.apply Prototype Method Code Execution',
2157
+ severity: 'CRITICAL',
2158
+ confidence: 'high',
2159
+ description: 'Reflect.apply(Function.prototype.bind/call/apply, Function, [...]) — indirect code execution via Reflect with prototype method as target.',
2160
+ references: ['https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Reflect/apply'],
2161
+ mitre: 'T1059'
2162
+ },
2145
2163
  lifecycle_missing_script: {
2146
2164
  id: 'MUADDIB-PKG-017',
2147
2165
  name: 'Phantom Lifecycle Script',
@@ -115,7 +115,7 @@ async function buildSandboxImage() {
115
115
  console.log('[SANDBOX] Building Docker image...');
116
116
 
117
117
  return new Promise((resolve) => {
118
- const dockerfilePath = path.join(__dirname, '..', 'docker').replace(/\\/g, '/');
118
+ const dockerfilePath = path.join(__dirname, '..', '..', 'docker').replace(/\\/g, '/');
119
119
  const proc = spawn('docker', ['build', '-t', DOCKER_IMAGE, dockerfilePath], {
120
120
  stdio: 'inherit'
121
121
  });
@@ -1496,6 +1496,26 @@ function handleCallExpression(node, ctx) {
1496
1496
  file: ctx.relFile
1497
1497
  });
1498
1498
  }
1499
+ // Bypass fix: Reflect.apply(Function.prototype.bind/call/apply, Function, [...])
1500
+ if (target.type === 'MemberExpression') {
1501
+ const methodProp = target.property;
1502
+ const methodName = methodProp?.type === 'Identifier' ? methodProp.name :
1503
+ (methodProp?.type === 'Literal' ? String(methodProp.value) : null);
1504
+ if (methodName === 'bind' || methodName === 'call' || methodName === 'apply') {
1505
+ const thisArg = node.arguments[1];
1506
+ if (thisArg?.type === 'Identifier' &&
1507
+ (thisArg.name === 'Function' || thisArg.name === 'eval' ||
1508
+ ctx.evalAliases?.has(thisArg.name))) {
1509
+ ctx.hasDynamicExec = true;
1510
+ ctx.threats.push({
1511
+ type: 'reflect_bind_code_execution',
1512
+ severity: 'CRITICAL',
1513
+ message: `Reflect.apply(*.${methodName}, ${thisArg.name}, [...]) — indirect ${thisArg.name} invocation via prototype method, bypasses Reflect.apply(${thisArg.name}) detection.`,
1514
+ file: ctx.relFile
1515
+ });
1516
+ }
1517
+ }
1518
+ }
1499
1519
  // B1: Reflect.apply(require, null, ['child_process']) — bypasses require() call detection
1500
1520
  if (target.type === 'Identifier' && target.name === 'require') {
1501
1521
  const argsArray = node.arguments[2];
@@ -58,6 +58,18 @@ function handleNewExpression(node, ctx) {
58
58
  file: ctx.relFile
59
59
  });
60
60
  }
61
+ // Detect new Proxy(globalThis/global/window/self, handler) — intercepts all global access
62
+ if (target.type === 'Identifier' &&
63
+ (target.name === 'globalThis' || target.name === 'global' ||
64
+ target.name === 'window' || target.name === 'self' ||
65
+ ctx.globalThisAliases.has(target.name))) {
66
+ ctx.threats.push({
67
+ type: 'proxy_globalthis_intercept',
68
+ severity: 'CRITICAL',
69
+ message: `new Proxy(${target.name}, handler) — intercepts all global object access. Attacker can hook eval/Function/require transparently.`,
70
+ file: ctx.relFile
71
+ });
72
+ }
61
73
  // Detect new Proxy(obj, handler) where handler has set/get traps — data interception
62
74
  // Real-world technique: export a Proxy that intercepts all property sets/gets to exfiltrate
63
75
  // data flowing through the module. Combined with network (hasNetworkInFile) → credential theft.
@@ -58,6 +58,19 @@ function handleVariableDeclarator(node, ctx) {
58
58
  ctx.globalThisAliases.add(node.id.name);
59
59
  }
60
60
 
61
+ // Track Proxy(globalThis) as globalThis alias for downstream detection
62
+ if (node.init?.type === 'NewExpression' &&
63
+ node.init.callee?.type === 'Identifier' && node.init.callee.name === 'Proxy' &&
64
+ node.init.arguments?.length >= 2) {
65
+ const proxyTarget = node.init.arguments[0];
66
+ if (proxyTarget?.type === 'Identifier' &&
67
+ (proxyTarget.name === 'globalThis' || proxyTarget.name === 'global' ||
68
+ proxyTarget.name === 'window' || proxyTarget.name === 'self' ||
69
+ ctx.globalThisAliases.has(proxyTarget.name))) {
70
+ ctx.globalThisAliases.add(node.id.name);
71
+ }
72
+ }
73
+
61
74
  // B1: const E = eval; const F = Function;
62
75
  if (node.init?.type === 'Identifier' &&
63
76
  (node.init.name === 'eval' || node.init.name === 'Function')) {
package/src/sandbox.js DELETED
@@ -1,663 +0,0 @@
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
-
15
- const DOCKER_IMAGE = 'muaddib-sandbox';
16
- const CONTAINER_TIMEOUT = 120000; // 120 seconds
17
-
18
- // Domains excluded from network findings (false positives)
19
- const SAFE_DOMAINS = [
20
- 'registry.npmjs.org',
21
- 'github.com',
22
- 'objects.githubusercontent.com',
23
- 'api.github.com',
24
- 'raw.githubusercontent.com',
25
- 'codeload.github.com',
26
- 'npmjs.com',
27
- 'npmjs.org',
28
- 'yarnpkg.com',
29
- 'googleapis.com',
30
- 'cloudflare.com'
31
- ];
32
-
33
- // IPs/ports excluded from connection findings (false positives)
34
- const SAFE_IPS = ['127.0.0.1'];
35
- const PROBE_PORTS = [65535]; // Node.js internal connectivity checks
36
-
37
- // Commands that are always suspicious in a sandbox
38
- const DANGEROUS_CMDS = ['curl', 'wget', 'nc', 'netcat', 'python', 'python3', 'bash', 'sh'];
39
-
40
- // Static canary tokens injected by sandbox-runner.sh (fallback honeypots).
41
- // These are searched in the sandbox report as a complement to the dynamic
42
- // tokens from canary-tokens.js (which use random suffixes per session).
43
- const STATIC_CANARY_TOKENS = {
44
- GITHUB_TOKEN: 'ghp_R8kLmN2pQ4vW7xY9aB3cD5eF6gH8jK0mN2pQ4vW',
45
- NPM_TOKEN: 'npm_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8',
46
- AWS_ACCESS_KEY_ID: 'AKIAIOSFODNN7EXAMPLE',
47
- AWS_SECRET_ACCESS_KEY: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
48
- SLACK_WEBHOOK_URL: 'https://hooks.example.com/services/TCANARY/BCANARY/canary-slack-token',
49
- DISCORD_WEBHOOK_URL: 'https://discord.com/api/webhooks/000000000000000000/abcdefghijklmnopqrstuvwxyz'
50
- };
51
-
52
- // Patterns indicating data exfiltration in HTTP bodies
53
- const EXFIL_PATTERNS = [
54
- { pattern: /\bNPM_TOKEN\b/i, label: 'npm token', severity: 'CRITICAL' },
55
- { pattern: /\bGITHUB_TOKEN\b/i, label: 'GitHub token', severity: 'CRITICAL' },
56
- { pattern: /\bAWS_SECRET/i, label: 'AWS credentials', severity: 'CRITICAL' },
57
- { pattern: /npmrc/i, label: '.npmrc content', severity: 'CRITICAL' },
58
- { pattern: /\bssh-rsa\b|\bssh-ed25519\b/i, label: 'SSH key', severity: 'CRITICAL' },
59
- { pattern: /BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY/, label: 'private key', severity: 'CRITICAL' },
60
- { pattern: /\bpassword\b/i, label: 'password', severity: 'CRITICAL' },
61
- { pattern: /\btoken\b/i, label: 'token', severity: 'CRITICAL' },
62
- { pattern: /\/etc\/passwd/, label: 'passwd file', severity: 'HIGH' },
63
- { pattern: /\.env\b/, label: '.env content', severity: 'HIGH' }
64
- ];
65
-
66
- // ── Docker availability checks ──
67
-
68
- function isDockerAvailable() {
69
- try {
70
- execSync('docker info', { stdio: 'pipe', timeout: 10000 });
71
- return true;
72
- } catch {
73
- return false;
74
- }
75
- }
76
-
77
- function imageExists() {
78
- try {
79
- execFileSync('docker', ['image', 'inspect', DOCKER_IMAGE], { stdio: 'pipe', timeout: 10000 });
80
- return true;
81
- } catch {
82
- return false;
83
- }
84
- }
85
-
86
- // ── Build image (with cache) ──
87
-
88
- async function buildSandboxImage() {
89
- if (!isDockerAvailable()) {
90
- console.log('[SANDBOX] Docker is not installed or not running. Skipping sandbox analysis.');
91
- return false;
92
- }
93
-
94
- if (imageExists()) {
95
- console.log('[SANDBOX] Using cached Docker image.');
96
- return true;
97
- }
98
-
99
- console.log('[SANDBOX] Building Docker image...');
100
-
101
- return new Promise((resolve) => {
102
- const dockerfilePath = path.join(__dirname, '..', 'docker').replace(/\\/g, '/');
103
- const proc = spawn('docker', ['build', '-t', DOCKER_IMAGE, dockerfilePath], {
104
- stdio: 'inherit'
105
- });
106
-
107
- proc.on('close', (code) => {
108
- if (code === 0) {
109
- console.log('[SANDBOX] Image built successfully.');
110
- resolve(true);
111
- } else {
112
- console.log('[SANDBOX] Docker build failed.');
113
- resolve(false);
114
- }
115
- });
116
-
117
- proc.on('error', () => {
118
- console.log('[SANDBOX] Docker error during build.');
119
- resolve(false);
120
- });
121
- });
122
- }
123
-
124
- // ── Run sandbox analysis ──
125
-
126
- async function runSandbox(packageName, options = {}) {
127
- const cleanResult = { score: 0, severity: 'CLEAN', findings: [], raw_report: null, suspicious: false };
128
-
129
- const strict = options.strict || false;
130
- const canaryEnabled = options.canary !== false; // enabled by default
131
- const local = options.local || false;
132
- const mode = strict ? 'strict' : 'permissive';
133
-
134
- // Validate inputs before checking Docker availability
135
- let localAbsPath = null;
136
- let displayName = packageName;
137
-
138
- if (local) {
139
- localAbsPath = path.resolve(packageName);
140
- if (!fs.existsSync(localAbsPath)) {
141
- console.log('[SANDBOX] Local path does not exist: ' + localAbsPath);
142
- return cleanResult;
143
- }
144
- // Read package name for display
145
- const pkgJsonPath = path.join(localAbsPath, 'package.json');
146
- if (fs.existsSync(pkgJsonPath)) {
147
- try {
148
- const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
149
- displayName = pkg.name || path.basename(localAbsPath);
150
- } catch { displayName = path.basename(localAbsPath); }
151
- } else {
152
- displayName = path.basename(localAbsPath);
153
- }
154
- } else {
155
- if (!NPM_PACKAGE_REGEX.test(packageName)) {
156
- console.log('[SANDBOX] Invalid package name: ' + packageName);
157
- return cleanResult;
158
- }
159
- }
160
-
161
- if (!isDockerAvailable()) {
162
- console.log('[SANDBOX] Docker is not installed or not running. Skipping.');
163
- return cleanResult;
164
- }
165
-
166
- // Generate canary tokens for this sandbox session
167
- let canaryTokens = null;
168
- if (canaryEnabled) {
169
- const canary = generateCanaryTokens();
170
- canaryTokens = canary.tokens;
171
- }
172
-
173
- console.log(`[SANDBOX] Analyzing "${displayName}" in isolated container (mode: ${mode}${canaryEnabled ? ', canary: on' : ''}${local ? ', local' : ''})...`);
174
-
175
- return new Promise((resolve) => {
176
- let stdout = '';
177
- let stderr = '';
178
- let timedOut = false;
179
- const containerName = `muaddib-sandbox-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
180
-
181
- const dockerArgs = [
182
- 'run',
183
- '--rm',
184
- `--name=${containerName}`,
185
- '--network=bridge',
186
- '--memory=512m',
187
- '--cpus=1',
188
- '--pids-limit=100',
189
- '--cap-drop=ALL'
190
- ];
191
-
192
- // Inject canary tokens as environment variables
193
- if (canaryTokens) {
194
- for (const [key, value] of Object.entries(canaryTokens)) {
195
- dockerArgs.push('-e', `${key}=${value}`);
196
- }
197
- // Also inject canary file contents as env vars for the entrypoint to write
198
- dockerArgs.push('-e', `CANARY_ENV_CONTENT=${createCanaryEnvFile(canaryTokens).replace(/\r?\n/g, '\\n')}`);
199
- dockerArgs.push('-e', `CANARY_NPMRC_CONTENT=${createCanaryNpmrc(canaryTokens).replace(/\r?\n/g, '\\n')}`);
200
- }
201
-
202
- // Both modes need NET_RAW for tcpdump (runs as root in entrypoint).
203
- // Strict mode also needs NET_ADMIN for iptables network blocking.
204
- // SYS_PTRACE is not needed: strace traces its own child (npm install via su).
205
- dockerArgs.push('--cap-add=NET_RAW');
206
- if (strict) {
207
- dockerArgs.push('--cap-add=NET_ADMIN');
208
- }
209
-
210
- dockerArgs.push('--tmpfs', '/tmp:rw,nosuid,size=64m');
211
- dockerArgs.push('--tmpfs', '/sandbox/install:rw,nosuid,size=256m');
212
- dockerArgs.push('--tmpfs', '/home/sandboxuser:rw,noexec,nosuid,size=16m');
213
- dockerArgs.push('--read-only');
214
-
215
- dockerArgs.push('--security-opt', 'no-new-privileges');
216
-
217
- if (local) {
218
- dockerArgs.push('-v', `${localAbsPath}:/sandbox/local-pkg:ro`);
219
- }
220
-
221
- dockerArgs.push(DOCKER_IMAGE);
222
- dockerArgs.push(local ? '/sandbox/local-pkg' : packageName);
223
- dockerArgs.push(mode);
224
-
225
- const proc = spawn('docker', dockerArgs);
226
-
227
- // Timeout: kill container after 120s
228
- const timer = setTimeout(() => {
229
- timedOut = true;
230
- console.log('[SANDBOX] Timeout (120s). Killing container...');
231
- try {
232
- execFileSync('docker', ['kill', containerName], { stdio: 'pipe', timeout: 5000 });
233
- } catch {
234
- proc.kill('SIGKILL');
235
- }
236
- }, CONTAINER_TIMEOUT);
237
-
238
- proc.stdout.on('data', (data) => {
239
- stdout += data.toString();
240
- });
241
-
242
- proc.stderr.on('data', (data) => {
243
- stderr += data.toString();
244
- // Forward sandbox progress logs (sanitize ANSI escape sequences)
245
- const text = data.toString().replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
246
- for (const line of text.split(/\r?\n/)) {
247
- if (line.includes('[SANDBOX]')) {
248
- console.log(line.trim());
249
- }
250
- }
251
- });
252
-
253
- proc.on('close', () => {
254
- clearTimeout(timer);
255
-
256
- if (timedOut) {
257
- const result = {
258
- score: 100,
259
- severity: 'CRITICAL',
260
- findings: [{
261
- type: 'timeout',
262
- severity: 'CRITICAL',
263
- detail: 'Container exceeded 120s timeout',
264
- evidence: `Killed after ${CONTAINER_TIMEOUT}ms`
265
- }],
266
- raw_report: null,
267
- suspicious: true
268
- };
269
- displayResults(result);
270
- resolve(result);
271
- return;
272
- }
273
-
274
- // Parse JSON from container stdout using delimiter
275
- let report;
276
- try {
277
- const REPORT_DELIMITER = '---MUADDIB-REPORT-START---';
278
- const delimIdx = stdout.indexOf(REPORT_DELIMITER);
279
- let jsonStr;
280
- if (delimIdx !== -1) {
281
- // Reliable: use delimiter to skip any package output before the report
282
- jsonStr = stdout.substring(delimIdx + REPORT_DELIMITER.length).trim();
283
- } else {
284
- // Fallback: find first '{' (backward compat with older images)
285
- const jsonStart = stdout.indexOf('{');
286
- const jsonEnd = stdout.lastIndexOf('}');
287
- if (jsonStart === -1 || jsonEnd === -1) {
288
- throw new Error('No JSON found in output');
289
- }
290
- jsonStr = stdout.substring(jsonStart, jsonEnd + 1);
291
- }
292
- report = JSON.parse(jsonStr);
293
- if (local && report) {
294
- report.package = displayName;
295
- }
296
- } catch (e) {
297
- console.log('[SANDBOX] Failed to parse container output:', e.message);
298
- resolve(cleanResult);
299
- return;
300
- }
301
-
302
- const { score, findings } = scoreFindings(report);
303
-
304
- // Canary token exfiltration detection (dynamic tokens)
305
- if (canaryTokens) {
306
- const networkExfil = detectCanaryExfiltration(report.network || {}, canaryTokens);
307
- const outputExfil = detectCanaryInOutput(stdout, stderr, canaryTokens);
308
-
309
- for (const exfil of [...networkExfil.exfiltrations, ...outputExfil.exfiltrations]) {
310
- findings.push({
311
- type: 'canary_exfiltration',
312
- severity: 'CRITICAL',
313
- detail: `Package attempted to exfiltrate ${exfil.token} (${exfil.foundIn})`,
314
- evidence: exfil.value
315
- });
316
- }
317
- }
318
-
319
- // Static canary token detection (fallback for shell-injected tokens)
320
- const staticExfil = detectStaticCanaryExfiltration(report);
321
- for (const { token, value } of staticExfil) {
322
- const alreadyDetected = findings.some(f =>
323
- f.type === 'canary_exfiltration' && f.detail && f.detail.includes(token)
324
- );
325
- if (!alreadyDetected) {
326
- findings.push({
327
- type: 'canary_exfiltration',
328
- severity: 'CRITICAL',
329
- detail: `Canary token exfiltration detected: ${token}`,
330
- evidence: value
331
- });
332
- }
333
- }
334
-
335
- const finalScore = Math.min(100, findings.reduce((s, f) => {
336
- if (f.type === 'canary_exfiltration') return s + 50;
337
- return s;
338
- }, score));
339
- const severity = getSeverity(finalScore);
340
- const result = { score: finalScore, severity, findings, raw_report: report, suspicious: finalScore > 0 };
341
-
342
- displayResults(result);
343
- resolve(result);
344
- });
345
-
346
- proc.on('error', (err) => {
347
- clearTimeout(timer);
348
- if (err.code === 'ENOENT') {
349
- console.log('[SANDBOX] Docker not found. Please install Docker.');
350
- } else {
351
- console.log(`[SANDBOX] Error: ${err.message}`);
352
- }
353
- resolve(cleanResult);
354
- });
355
- });
356
- }
357
-
358
- // ── Static canary detection ──
359
-
360
- /**
361
- * Detect static canary token exfiltration in a sandbox report.
362
- * Searches HTTP bodies, DNS queries, HTTP request URLs, TLS domains,
363
- * filesystem changes, process commands, and install output.
364
- * @param {object} report - Parsed sandbox report JSON
365
- * @returns {Array<{token: string, value: string}>} Exfiltrated tokens
366
- */
367
- function detectStaticCanaryExfiltration(report) {
368
- const exfiltrated = [];
369
- if (!report) return exfiltrated;
370
-
371
- const searchable = [];
372
-
373
- // Network data
374
- for (const body of (report.network?.http_bodies || [])) if (body) searchable.push(body);
375
- for (const domain of (report.network?.dns_queries || [])) if (domain) searchable.push(domain);
376
- for (const req of (report.network?.http_requests || [])) {
377
- searchable.push(`${req.method || ''} ${req.host || ''}${req.path || ''}`);
378
- }
379
- for (const tls of (report.network?.tls_connections || [])) if (tls.domain) searchable.push(tls.domain);
380
-
381
- // Filesystem + processes
382
- for (const file of (report.filesystem?.created || [])) if (file) searchable.push(file);
383
- for (const proc of (report.processes?.spawned || [])) if (proc.command) searchable.push(proc.command);
384
-
385
- // Install + entrypoint output
386
- if (report.install_output) searchable.push(report.install_output);
387
- if (report.entrypoint_output) searchable.push(report.entrypoint_output);
388
-
389
- const allOutput = searchable.join('\n');
390
-
391
- for (const [tokenName, tokenValue] of Object.entries(STATIC_CANARY_TOKENS)) {
392
- if (allOutput.includes(tokenValue)) {
393
- exfiltrated.push({ token: tokenName, value: tokenValue });
394
- }
395
- }
396
-
397
- return exfiltrated;
398
- }
399
-
400
- // ── Scoring engine ──
401
-
402
- function scoreFindings(report) {
403
- let score = 0;
404
- const findings = [];
405
-
406
- // 1. Sensitive file reads
407
- for (const file of (report.sensitive_files?.read || [])) {
408
- if (/\.npmrc/.test(file) || /\.ssh/.test(file) || /\.aws/.test(file)) {
409
- score += 40;
410
- findings.push({ type: 'sensitive_file_read', severity: 'CRITICAL', detail: `Read credential file: ${file}`, evidence: file });
411
- } else if (/\/etc\/passwd/.test(file) || /\/etc\/shadow/.test(file)) {
412
- score += 25;
413
- findings.push({ type: 'sensitive_file_read', severity: 'HIGH', detail: `Read system file: ${file}`, evidence: file });
414
- } else if (/\.env/.test(file) || /\.gitconfig/.test(file) || /\.bash_history/.test(file)) {
415
- score += 15;
416
- findings.push({ type: 'sensitive_file_read', severity: 'MEDIUM', detail: `Read config file: ${file}`, evidence: file });
417
- }
418
- }
419
-
420
- // 2. Sensitive file writes (from strace)
421
- for (const file of (report.sensitive_files?.written || [])) {
422
- if (/\.npmrc/.test(file) || /\.ssh/.test(file) || /\.aws/.test(file)) {
423
- score += 40;
424
- findings.push({ type: 'sensitive_file_write', severity: 'CRITICAL', detail: `Write to credential file: ${file}`, evidence: file });
425
- } else if (/\/etc\/passwd/.test(file) || /\/etc\/shadow/.test(file)) {
426
- score += 25;
427
- findings.push({ type: 'sensitive_file_write', severity: 'HIGH', detail: `Write to system file: ${file}`, evidence: file });
428
- } else {
429
- score += 15;
430
- findings.push({ type: 'sensitive_file_write', severity: 'MEDIUM', detail: `Write to sensitive file: ${file}`, evidence: file });
431
- }
432
- }
433
-
434
- // 3. Filesystem changes — files created in suspicious locations
435
- for (const file of (report.filesystem?.created || [])) {
436
- if (/^\/usr\/bin\//.test(file) || /crontab/.test(file) || /\/cron\.d\//.test(file)) {
437
- score += 50;
438
- findings.push({ type: 'suspicious_filesystem', severity: 'CRITICAL', detail: `File created in system path: ${file}`, evidence: file });
439
- } else if (/^\/tmp\//.test(file)) {
440
- score += 30;
441
- findings.push({ type: 'suspicious_filesystem', severity: 'HIGH', detail: `File created in /tmp: ${file}`, evidence: file });
442
- }
443
- }
444
-
445
- // 4a. DNS queries (exclude safe domains)
446
- for (const domain of (report.network?.dns_queries || [])) {
447
- if (isSafeDomain(domain)) continue;
448
- score += 20;
449
- findings.push({ type: 'suspicious_dns', severity: 'HIGH', detail: `DNS query to non-registry domain: ${domain}`, evidence: domain });
450
- }
451
-
452
- // 4b. DNS resolutions — extra detail
453
- for (const res of (report.network?.dns_resolutions || [])) {
454
- if (isSafeDomain(res.domain)) continue;
455
- // Already scored in 4a via dns_queries, but flag the resolution for reporting
456
- findings.push({ type: 'dns_resolution', severity: 'INFO', detail: `${res.domain} → ${res.ip}`, evidence: `${res.domain}:${res.ip}` });
457
- }
458
-
459
- // 5a. TCP connections (exclude safe hosts, probe ports, localhost)
460
- for (const conn of (report.network?.http_connections || [])) {
461
- if (isSafeHost(conn.host)) continue;
462
- if (SAFE_IPS.includes(conn.host)) continue;
463
- if (PROBE_PORTS.includes(conn.port)) continue;
464
- score += 25;
465
- findings.push({ type: 'suspicious_connection', severity: 'HIGH', detail: `TCP connection to ${conn.host}:${conn.port}`, evidence: `${conn.host}:${conn.port}` });
466
- }
467
-
468
- // 5b. TLS connections — non-safe domains
469
- for (const tls of (report.network?.tls_connections || [])) {
470
- if (isSafeDomain(tls.domain)) continue;
471
- score += 20;
472
- findings.push({ type: 'suspicious_tls', severity: 'HIGH', detail: `TLS connection to ${tls.domain} (${tls.ip}:${tls.port})`, evidence: tls.domain });
473
- }
474
-
475
- // 5c. HTTP exfiltration detection — scan body snippets for sensitive data
476
- for (const body of (report.network?.http_bodies || [])) {
477
- for (const pat of EXFIL_PATTERNS) {
478
- if (pat.pattern.test(body)) {
479
- score += 50;
480
- findings.push({
481
- type: 'data_exfiltration',
482
- severity: pat.severity,
483
- detail: `HTTP body contains ${pat.label}`,
484
- evidence: body.substring(0, 200)
485
- });
486
- break; // One match per body is enough
487
- }
488
- }
489
- }
490
-
491
- // 5d. HTTP requests to non-safe hosts
492
- for (const req of (report.network?.http_requests || [])) {
493
- if (isSafeDomain(req.host)) continue;
494
- score += 20;
495
- findings.push({ type: 'suspicious_http_request', severity: 'HIGH', detail: `${req.method} ${req.host}${req.path}`, evidence: `${req.method} ${req.host}${req.path}` });
496
- }
497
-
498
- // 5e. Blocked connections (strict mode)
499
- for (const blocked of (report.network?.blocked_connections || [])) {
500
- score += 30;
501
- findings.push({ type: 'blocked_connection', severity: 'HIGH', detail: `Blocked outbound to ${blocked.ip}:${blocked.port}`, evidence: `${blocked.ip}:${blocked.port}` });
502
- }
503
-
504
- // 6. Suspicious processes
505
- for (const p of (report.processes?.spawned || [])) {
506
- const cmd = p.command || '';
507
- const basename = path.basename(cmd);
508
- if (DANGEROUS_CMDS.some(d => basename === d)) {
509
- score += 40;
510
- findings.push({ type: 'suspicious_process', severity: 'CRITICAL', detail: `Dangerous command spawned: ${cmd}`, evidence: cmd });
511
- } else if (cmd) {
512
- score += 15;
513
- findings.push({ type: 'unknown_process', severity: 'MEDIUM', detail: `Unknown process spawned: ${cmd}`, evidence: cmd });
514
- }
515
- }
516
-
517
- score = Math.min(100, score);
518
- return { score, findings };
519
- }
520
-
521
- // ── Network report (detailed, colored) ──
522
-
523
- function generateNetworkReport(report) {
524
- const lines = [];
525
- const RED = '\x1b[31m';
526
- const YELLOW = '\x1b[33m';
527
- const GREEN = '\x1b[32m';
528
- const CYAN = '\x1b[36m';
529
- const MAGENTA = '\x1b[35m';
530
- const BOLD = '\x1b[1m';
531
- const DIM = '\x1b[2m';
532
- const RESET = '\x1b[0m';
533
-
534
- lines.push('');
535
- lines.push(`${BOLD}${MAGENTA}╔══════════════════════════════════════════════════╗${RESET}`);
536
- lines.push(`${BOLD}${MAGENTA}║ MUAD'DIB — Sandbox Network Report ║${RESET}`);
537
- lines.push(`${BOLD}${MAGENTA}╚══════════════════════════════════════════════════╝${RESET}`);
538
- lines.push('');
539
- lines.push(` Package: ${BOLD}${report.package}${RESET}`);
540
- lines.push(` Mode: ${report.mode === 'strict' ? RED + 'STRICT' : GREEN + 'permissive'}${RESET}`);
541
- lines.push(` Time: ${report.timestamp}`);
542
- lines.push(` Duration: ${report.duration_ms}ms`);
543
-
544
- // DNS Resolutions
545
- const dnsRes = report.network?.dns_resolutions || [];
546
- lines.push('');
547
- lines.push(`${BOLD}${CYAN}── DNS Resolutions (${dnsRes.length}) ──${RESET}`);
548
- if (dnsRes.length === 0) {
549
- lines.push(` ${DIM}No DNS resolutions captured${RESET}`);
550
- } else {
551
- for (const r of dnsRes) {
552
- const safe = isSafeDomain(r.domain);
553
- const icon = safe ? GREEN + '[OK]' : YELLOW + '[!!]';
554
- lines.push(` ${icon}${RESET} ${r.domain} → ${r.ip}`);
555
- }
556
- }
557
-
558
- // HTTP Requests
559
- const httpReqs = report.network?.http_requests || [];
560
- lines.push('');
561
- lines.push(`${BOLD}${CYAN}── HTTP Requests (${httpReqs.length}) ──${RESET}`);
562
- if (httpReqs.length === 0) {
563
- lines.push(` ${DIM}No HTTP requests captured${RESET}`);
564
- } else {
565
- for (const req of httpReqs) {
566
- const safe = isSafeDomain(req.host);
567
- const icon = safe ? GREEN + '[OK]' : RED + '[!!]';
568
- lines.push(` ${icon}${RESET} ${req.method} ${req.host}${req.path}`);
569
- }
570
- }
571
-
572
- // TLS Connections
573
- const tlsConns = report.network?.tls_connections || [];
574
- lines.push('');
575
- lines.push(`${BOLD}${CYAN}── TLS Connections (${tlsConns.length}) ──${RESET}`);
576
- if (tlsConns.length === 0) {
577
- lines.push(` ${DIM}No TLS connections captured${RESET}`);
578
- } else {
579
- for (const tls of tlsConns) {
580
- const safe = isSafeDomain(tls.domain);
581
- const icon = safe ? GREEN + '[OK]' : YELLOW + '[!!]';
582
- lines.push(` ${icon}${RESET} ${tls.domain} (${tls.ip}:${tls.port})`);
583
- }
584
- }
585
-
586
- // Blocked Connections (strict mode)
587
- const blocked = report.network?.blocked_connections || [];
588
- if (blocked.length > 0) {
589
- lines.push('');
590
- lines.push(`${BOLD}${RED}── Blocked Connections (${blocked.length}) ──${RESET}`);
591
- for (const b of blocked) {
592
- lines.push(` ${RED}[BLOCKED]${RESET} ${b.ip}:${b.port}`);
593
- }
594
- }
595
-
596
- // Data Exfiltration Alerts
597
- const bodies = report.network?.http_bodies || [];
598
- const exfilAlerts = [];
599
- for (const body of bodies) {
600
- for (const pat of EXFIL_PATTERNS) {
601
- if (pat.pattern.test(body)) {
602
- exfilAlerts.push({ label: pat.label, severity: pat.severity, snippet: body.substring(0, 100) });
603
- break;
604
- }
605
- }
606
- }
607
- if (exfilAlerts.length > 0) {
608
- lines.push('');
609
- lines.push(`${BOLD}${RED}── Data Exfiltration Alerts (${exfilAlerts.length}) ──${RESET}`);
610
- for (const alert of exfilAlerts) {
611
- lines.push(` ${RED}[${alert.severity}]${RESET} ${alert.label} detected in HTTP body`);
612
- lines.push(` ${DIM}${alert.snippet}...${RESET}`);
613
- }
614
- }
615
-
616
- // Raw TCP connections
617
- const conns = report.network?.http_connections || [];
618
- if (conns.length > 0) {
619
- lines.push('');
620
- lines.push(`${BOLD}${CYAN}── Raw TCP Connections (${conns.length}) ──${RESET}`);
621
- for (const c of conns) {
622
- const safe = isSafeHost(c.host);
623
- const icon = safe ? GREEN + '[OK]' : YELLOW + '[!!]';
624
- lines.push(` ${icon}${RESET} ${c.host}:${c.port} (${c.protocol})`);
625
- }
626
- }
627
-
628
- lines.push('');
629
- return lines.join('\n');
630
- }
631
-
632
- // ── Helpers ──
633
-
634
- function isSafeDomain(domain) {
635
- return SAFE_DOMAINS.some(safe => domain === safe || domain.endsWith('.' + safe));
636
- }
637
-
638
- function isSafeHost(host) {
639
- return SAFE_DOMAINS.some(safe => host === safe || host.endsWith('.' + safe));
640
- }
641
-
642
- function getSeverity(score) {
643
- if (score === 0) return 'CLEAN';
644
- if (score <= 20) return 'LOW';
645
- if (score <= 50) return 'MEDIUM';
646
- if (score <= 80) return 'HIGH';
647
- return 'CRITICAL';
648
- }
649
-
650
- function displayResults(result) {
651
- console.log(`\n[SANDBOX] Score: ${result.score}/100 — ${result.severity}`);
652
- if (result.findings.length === 0) {
653
- console.log('[SANDBOX] No suspicious behavior detected.');
654
- } else {
655
- const actionable = result.findings.filter(f => f.severity !== 'INFO');
656
- console.log(`[SANDBOX] ${actionable.length} finding(s):`);
657
- for (const f of actionable) {
658
- console.log(` [${f.severity}] ${f.type}: ${f.detail}`);
659
- }
660
- }
661
- }
662
-
663
- module.exports = { buildSandboxImage, runSandbox, scoreFindings, generateNetworkReport, EXFIL_PATTERNS, SAFE_DOMAINS, getSeverity, displayResults, isDockerAvailable, imageExists, STATIC_CANARY_TOKENS, detectStaticCanaryExfiltration };