muaddib-scanner 2.11.174 → 2.11.176

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
@@ -214,7 +214,7 @@ muaddib replay # Ground truth validation (90/94 TPR@3, v2.11
214
214
 
215
215
  ### <!--stat:rulesTotal-->277<!--/stat:rulesTotal--> detection rules
216
216
 
217
- All rules (<!--stat:rulesCore-->272<!--/stat:rulesCore--> RULES + <!--stat:rulesParanoid-->5<!--/stat:rulesParanoid--> PARANOID) are mapped to MITRE ATT&CK techniques. See [SECURITY.md](SECURITY.md#detection-rules-v211139) for the complete rules reference.
217
+ All rules (<!--stat:rulesCore-->272<!--/stat:rulesCore--> RULES + <!--stat:rulesParanoid-->5<!--/stat:rulesParanoid--> PARANOID) are mapped to MITRE ATT&CK techniques. See [SECURITY.md](SECURITY.md#detection-rules) for the complete rules reference.
218
218
 
219
219
  ### Detected campaigns
220
220
 
@@ -345,7 +345,7 @@ npm test
345
345
 
346
346
  ### Testing
347
347
 
348
- - **<!--stat:tests-->4561<!--/stat:tests--> tests** across <!--stat:testFiles-->152<!--/stat:testFiles--> modular test files
348
+ - **<!--stat:tests-->4534<!--/stat:tests--> tests** across <!--stat:testFiles-->152<!--/stat:testFiles--> modular test files
349
349
  - **56 fuzz tests** - Malformed inputs, ReDoS, unicode, binary
350
350
  - **Datadog 17K benchmark** - 14,587 confirmed malware samples (in-scope)
351
351
  - **Ground truth validation** - 96 real-world attacks (95.74% TPR@3, 88.30% TPR@20 — v2.11.48 full measure on 94 in-scope)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.11.174",
3
+ "version": "2.11.176",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -14,6 +14,7 @@
14
14
  "scan": "node bin/muaddib.js scan .",
15
15
  "update": "node bin/muaddib.js update",
16
16
  "lint": "eslint src bin --ext .js",
17
+ "ci:local": "node scripts/check-deps-typosquats.js && node scripts/check-no-tracked-node-modules.js && npm audit --audit-level=high && npm run docs:stats:check && npm test",
17
18
  "docs:stats": "node scripts/collect-stats.js && node scripts/sync-stats.js",
18
19
  "docs:stats:tests": "node scripts/collect-stats.js --with-tests && node scripts/sync-stats.js",
19
20
  "docs:stats:check": "node scripts/collect-stats.js --check && node scripts/sync-stats.js --check",
@@ -54,8 +55,8 @@
54
55
  "@inquirer/prompts": "8.5.2",
55
56
  "acorn": "8.17.0",
56
57
  "acorn-walk": "8.3.5",
57
- "adm-zip": "0.5.18",
58
- "js-yaml": "5.1.0",
58
+ "adm-zip": "0.6.0",
59
+ "js-yaml": "5.2.1",
59
60
  "web-tree-sitter": "^0.26.9"
60
61
  },
61
62
  "devDependencies": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "target": "node_modules",
3
- "timestamp": "2026-07-17T09:25:49.894Z",
3
+ "timestamp": "2026-07-22T15:08:41.143Z",
4
4
  "threats": [
5
5
  {
6
6
  "type": "string_mutation_obfuscation",
@@ -54,6 +54,32 @@ const HOOK_COMMANDS = {
54
54
  diff: 'npx muaddib diff HEAD --fail-on high'
55
55
  };
56
56
 
57
+ /**
58
+ * Back up an existing pre-commit hook before overwriting it, keeping the 3 most recent
59
+ * timestamped backups. Shared by the husky and native-git hook writers so neither silently
60
+ * destroys a user's existing hook (lint-staged, tests, etc.).
61
+ * @param {string} hookPath - Absolute path to the pre-commit hook to be written
62
+ */
63
+ function backupExistingHook(hookPath) {
64
+ if (!fs.existsSync(hookPath)) return;
65
+ const backup = `${hookPath}.backup.${Date.now()}`;
66
+ fs.copyFileSync(hookPath, backup);
67
+ console.log(`[INFO] Backed up existing hook to ${backup}`);
68
+
69
+ // Cleanup old backups, keep only 3 most recent
70
+ try {
71
+ const hooksDir = path.dirname(hookPath);
72
+ const base = path.basename(hookPath);
73
+ const backups = fs.readdirSync(hooksDir)
74
+ .filter(f => f.startsWith(base + '.backup.'))
75
+ .sort()
76
+ .reverse();
77
+ for (const old of backups.slice(3)) {
78
+ fs.unlinkSync(path.join(hooksDir, old));
79
+ }
80
+ } catch { /* ignore cleanup errors */ }
81
+ }
82
+
57
83
  async function initHooks(targetPath, options = {}) {
58
84
  const resolvedPath = path.resolve(targetPath);
59
85
  const hookType = options.type || 'auto';
@@ -138,6 +164,9 @@ echo "[MUADDIB] Running security check..."
138
164
  ${command}
139
165
  `;
140
166
 
167
+ // Back up any existing hook before overwriting (same policy as initGitHook): a project may
168
+ // already have a husky pre-commit (lint-staged, tests…) — clobbering it silently loses config.
169
+ backupExistingHook(preCommitPath);
141
170
  fs.writeFileSync(preCommitPath, hookContent, { mode: 0o755 });
142
171
  if (process.platform !== 'win32') {
143
172
  fs.chmodSync(preCommitPath, 0o755);
@@ -219,23 +248,7 @@ exit 0
219
248
  `;
220
249
 
221
250
  // Backup existing hook (limit to 3 backups)
222
- if (fs.existsSync(preCommitPath)) {
223
- const backup = `${preCommitPath}.backup.${Date.now()}`;
224
- fs.copyFileSync(preCommitPath, backup);
225
- console.log(`[INFO] Backed up existing hook to ${backup}`);
226
-
227
- // Cleanup old backups, keep only 3 most recent
228
- try {
229
- const hooksDir = path.dirname(preCommitPath);
230
- const backups = fs.readdirSync(hooksDir)
231
- .filter(f => f.startsWith('pre-commit.backup.'))
232
- .sort()
233
- .reverse();
234
- for (const old of backups.slice(3)) {
235
- fs.unlinkSync(path.join(hooksDir, old));
236
- }
237
- } catch { /* ignore cleanup errors */ }
238
- }
251
+ backupExistingHook(preCommitPath);
239
252
 
240
253
  fs.writeFileSync(preCommitPath, hookContent, { mode: 0o755 });
241
254
  if (process.platform !== 'win32') {
@@ -133,31 +133,42 @@ function checkIOCs(pkg, pkgName, pkgVersion) {
133
133
  return malicious || null;
134
134
  }
135
135
 
136
- // Scan a package and its dependencies recursively
137
- async function scanPackageRecursive(pkg, depth = 0, maxDepth = 3) {
138
- const indent = ' '.repeat(depth);
139
-
140
- // Extract name and version of the package
136
+ // Split a package spec into { pkgName, pkgVersion }.
137
+ // Handles scoped packages (@scope/name) and version specs (@scope/name@spec or name@spec).
138
+ // Transitive deps arrive as `name@^1.2.3` (range specs from dependency maps): the spec must
139
+ // be split off the name even when it is not an exact version, otherwise pkgName keeps the
140
+ // full spec and the IOC map lookup (keyed on bare names) never matches — wildcard IOCs
141
+ // included. Range/tag specs leave pkgVersion null so only wildcard IOCs match (SFI-003).
142
+ function parsePackageSpec(pkg) {
141
143
  let pkgName = pkg;
142
144
  let pkgVersion = null;
143
-
144
- // Handle scoped packages (@scope/name) and versions (@scope/name@version or name@version)
145
145
  if (pkg.startsWith('@')) {
146
146
  // Scoped package
147
147
  const parts = pkg.slice(1).split('@');
148
- if (parts.length >= 2 && parts[parts.length - 1].match(/^\d/)) {
149
- pkgVersion = parts.pop();
148
+ if (parts.length >= 2) {
149
+ const spec = parts.pop();
150
150
  pkgName = '@' + parts.join('@');
151
+ if (spec.match(/^\d/)) pkgVersion = spec;
151
152
  }
152
153
  } else {
153
154
  const parts = pkg.split('@');
154
- if (parts.length >= 2 && parts[parts.length - 1].match(/^\d/)) {
155
- pkgVersion = parts.pop();
155
+ if (parts.length >= 2) {
156
+ const spec = parts.pop();
156
157
  pkgName = parts.join('@');
158
+ if (spec.match(/^\d/)) pkgVersion = spec;
157
159
  }
158
160
  }
159
-
160
-
161
+ return { pkgName, pkgVersion };
162
+ }
163
+
164
+ // Scan a package and its dependencies recursively
165
+ async function scanPackageRecursive(pkg, depth = 0, maxDepth = 3) {
166
+ const indent = ' '.repeat(depth);
167
+
168
+ // Extract name and version of the package
169
+ const { pkgName, pkgVersion } = parsePackageSpec(pkg);
170
+
171
+
161
172
  // Avoid infinite loops
162
173
  if (scannedPackages.has(pkgName)) {
163
174
  return { safe: true };
@@ -309,4 +320,4 @@ async function safeInstall(packages, options = {}) {
309
320
  return { blocked: false };
310
321
  }
311
322
 
312
- module.exports = { safeInstall, scanPackageRecursive, REHABILITATED_PACKAGES, checkRehabilitated, isValidPackageName, checkIOCs };
323
+ module.exports = { safeInstall, scanPackageRecursive, parsePackageSpec, REHABILITATED_PACKAGES, checkRehabilitated, isValidPackageName, checkIOCs };
@@ -51,6 +51,13 @@ const DOWNLOAD_TIMEOUT = 60_000;
51
51
  // Max redirects to follow
52
52
  const MAX_REDIRECTS = 5;
53
53
 
54
+ // Decompression bomb guard: cap the DECOMPRESSED byte count streamed to disk. A compromised
55
+ // release asset (exactly this project's threat model — maintainer account takeover) could
56
+ // otherwise fill the disk: this was the only unbounded decompressor in the repo (download.js
57
+ // has the M4 gzip hint, scraper.js has MAX_ENTRY/TOTAL_UNCOMPRESSED). Env-tunable like the
58
+ // scraper caps; default leaves ample headroom over the ~112-223MB iocs.json.
59
+ const MAX_DECOMPRESSED_SIZE = parseInt(process.env.MUADDIB_BOOTSTRAP_MAX_DECOMPRESSED || '', 10) || 1024 * 1024 * 1024; // 1 GB
60
+
54
61
  // Allowed redirect domains (SSRF protection)
55
62
  const ALLOWED_REDIRECT_DOMAINS = [
56
63
  'github.com',
@@ -115,6 +122,19 @@ function downloadAndDecompress(url, destPath) {
115
122
  const gunzip = zlib.createGunzip();
116
123
  const fileStream = fs.createWriteStream(tmpPath);
117
124
 
125
+ // Decompression bomb guard: count decompressed bytes, abort past the cap.
126
+ let decompressedBytes = 0;
127
+ gunzip.on('data', (chunk) => {
128
+ decompressedBytes += chunk.length;
129
+ if (decompressedBytes > MAX_DECOMPRESSED_SIZE) {
130
+ res.destroy();
131
+ gunzip.destroy();
132
+ fileStream.destroy();
133
+ try { fs.unlinkSync(tmpPath); } catch (e) { debugLog('cleanup failed:', e.message); }
134
+ reject(new Error('Decompressed size exceeds cap (' + MAX_DECOMPRESSED_SIZE + ' bytes) — aborting'));
135
+ }
136
+ });
137
+
118
138
  gunzip.on('error', (err) => {
119
139
  fileStream.destroy();
120
140
  try { fs.unlinkSync(tmpPath); } catch (e) { debugLog('cleanup failed:', e.message); }
@@ -733,6 +733,7 @@ async function scrapeOSSFMaliciousPackages(knownIds) {
733
733
  fetchSpinner.start('Fetching OSSF entries... 0/' + toFetch.length);
734
734
  }
735
735
 
736
+ let failedFetches = 0;
736
737
  for (let i = 0; i < toFetch.length; i += BATCH_SIZE) {
737
738
  const batch = toFetch.slice(i, i + BATCH_SIZE);
738
739
  const results = await Promise.all(batch.map(function(entry) {
@@ -741,7 +742,7 @@ async function scrapeOSSFMaliciousPackages(knownIds) {
741
742
  }));
742
743
 
743
744
  for (const result of results) {
744
- if (!result || result.status !== 200 || !result.data) continue;
745
+ if (!result || result.status !== 200 || !result.data) { failedFetches++; continue; }
745
746
  const parsed = parseOSVEntry(result.data, 'ossf-malicious');
746
747
  for (const p of parsed) packages.push(p);
747
748
  }
@@ -760,8 +761,16 @@ async function scrapeOSSFMaliciousPackages(knownIds) {
760
761
  fetchSpinner.succeed('Fetched OSSF entries: ' + packages.length + ' packages');
761
762
  }
762
763
 
763
- // Save tree SHA for next incremental run
764
- try { fs.writeFileSync(shaFile, treeSha); } catch {}
764
+ // Save tree SHA for next incremental run — only if every entry was fetched.
765
+ // Persisting the SHA after partial fetches (e.g. GitHub rate-limiting the raw batches)
766
+ // marks the failed entries as "done": subsequent runs skip everything until the next
767
+ // OSSF commit, silently losing IOCs. On partial failure, keep the old SHA so the next
768
+ // run retries the delta.
769
+ if (failedFetches === 0) {
770
+ try { fs.writeFileSync(shaFile, treeSha); } catch {}
771
+ } else {
772
+ console.log('[SCRAPER] ' + failedFetches + ' OSSF fetches failed — tree SHA not persisted, next run will retry');
773
+ }
765
774
  } catch (e) {
766
775
  console.log('[SCRAPER] Error: ' + e.message);
767
776
  }
@@ -1284,11 +1293,21 @@ async function runScraper() {
1284
1293
  'npm-removed'
1285
1294
  ];
1286
1295
 
1287
- // Save enriched (full) IOCs — atomic write via .tmp + rename
1296
+ // Save enriched (full) IOCs — atomic write via .tmp + rename.
1297
+ // Stringify ONCE: the pretty-printed DB is a giant string (~2x the 112MB+ file); a second
1298
+ // stringify for the home copy used to double the peak on top of the zip buffers already in
1299
+ // flight. The HMAC is computed here from the same string, then the string is released and
1300
+ // the home copy reuses the file on disk (copyFileSync — no second serialization).
1288
1301
  const saveSpinner = new Spinner();
1289
1302
  saveSpinner.start('Saving IOCs...');
1290
1303
  const tmpIOCFile = IOC_FILE + '.tmp';
1291
- fs.writeFileSync(tmpIOCFile, JSON.stringify(existingIOCs, null, 2));
1304
+ let iocHmac;
1305
+ {
1306
+ const iocJsonData = JSON.stringify(existingIOCs, null, 2);
1307
+ fs.writeFileSync(tmpIOCFile, iocJsonData);
1308
+ const { generateIOCHMAC } = require('./updater.js');
1309
+ iocHmac = generateIOCHMAC(iocJsonData);
1310
+ }
1292
1311
  fs.renameSync(tmpIOCFile, IOC_FILE);
1293
1312
 
1294
1313
  // Save compact IOCs (lightweight, shipped in npm) — atomic write
@@ -1311,12 +1330,11 @@ async function runScraper() {
1311
1330
  }
1312
1331
  try {
1313
1332
  const tmpHomeFile = HOME_IOC_FILE + '.tmp';
1314
- const homeJsonData = JSON.stringify(existingIOCs, null, 2);
1315
- fs.writeFileSync(tmpHomeFile, homeJsonData);
1316
- // Write HMAC before rename for consistency with updater.js
1317
- const { generateIOCHMAC } = require('./updater.js');
1318
- const homeHmac = generateIOCHMAC(homeJsonData);
1319
- fs.writeFileSync(HOME_IOC_FILE + '.hmac', homeHmac);
1333
+ // Byte-identical copy of the file already written locally — no second stringify peak.
1334
+ fs.copyFileSync(IOC_FILE, tmpHomeFile);
1335
+ // Write HMAC before rename for consistency with updater.js (computed once above from
1336
+ // the same serialized string the local file was written from).
1337
+ fs.writeFileSync(HOME_IOC_FILE + '.hmac', iocHmac);
1320
1338
  fs.renameSync(tmpHomeFile, HOME_IOC_FILE);
1321
1339
  // Mark HMAC as initialized
1322
1340
  const hmacMarker = path.join(homeDir, '.hmac-initialized');
@@ -46,7 +46,10 @@ const LLM_CONCURRENCY_MAX = 2; // max simultaneous API calls
46
46
  const LLM_DAILY_LIMIT_DEFAULT = 100;
47
47
  const MAX_SINGLE_FILE_BYTES = 512 * 1024; // skip individual files > 512KB
48
48
 
49
- // Extensions to collect from packages
49
+ // Extensions to collect from packages for LLM context (NOT a detection scanner — this feeds
50
+ // the monitor's opt-in LLM investigation). `.json` is intentionally included (manifest/config
51
+ // context for the model); `.jsx/.tsx` are out of scope here by design. Deliberately distinct
52
+ // from the scanner source-extension lists — do not sync.
50
53
  const SOURCE_EXTENSIONS = ['.js', '.mjs', '.cjs', '.ts', '.json', '.py'];
51
54
 
52
55
  // ── Semaphore (pattern: src/shared/http-limiter.js) ──
@@ -31,6 +31,11 @@ const os = require('os');
31
31
  const { Worker } = require('worker_threads');
32
32
  const { runSandbox, tryAcquireSandboxSlot } = require('../sandbox/index.js');
33
33
  const { sendWebhook } = require('../webhook.js');
34
+ // queue.js has its own destructured sendWebhook reference (IOC-fallback + temporal
35
+ // alert sends), distinct from monitor/webhook.js. Route those sends through a
36
+ // mutable seam so tests can intercept them, same as ingestion._deps — otherwise
37
+ // the destructured ref is captured at load and a test stub never fires.
38
+ const _deps = { sendWebhook };
34
39
  const { downloadToFile, extractArchive, sanitizePackageName } = require('../shared/download.js');
35
40
  const { extractInPool } = require('../shared/extract-pool.js');
36
41
  const { MAX_TARBALL_SIZE, getMaxFileSize } = require('../shared/constants.js');
@@ -145,6 +150,11 @@ function registerWorkerMessageHandler(type, fn) {
145
150
  }
146
151
  _workerMessageHandlers.set(type, fn);
147
152
  }
153
+ function unregisterWorkerMessageHandler(type) {
154
+ // Inverse of registerWorkerMessageHandler — lets tests (and future governors)
155
+ // remove their handler instead of leaking it into the process-global registry.
156
+ return _workerMessageHandlers.delete(type);
157
+ }
148
158
 
149
159
  // ─── Network-brain glue (governors phase A) ───
150
160
  // Workers proxy token acquisition and 429 signals to the main thread so the
@@ -263,6 +273,46 @@ function computeSandboxScoreThreshold(envValue) {
263
273
  }
264
274
  const SANDBOX_SCORE_THRESHOLD = computeSandboxScoreThreshold(process.env.MUADDIB_SANDBOX_SCORE_THRESHOLD);
265
275
 
276
+ // Tier-based sandbox gate, extracted pure so tests exercise THIS function and
277
+ // not a hand-maintained replica (audit 2026-07: sandbox-gate.test.js asserted
278
+ // on a local copy that nothing kept in lockstep with the inline condition).
279
+ // T1a: mandatory. T1b: score-gated. T2: score-gated AND queue must be short.
280
+ // Availability preconditions (isSandboxEnabled, sandboxAvailable, large-package
281
+ // skip) remain at the call site — they are environment, not tier policy.
282
+ function computeSandboxGate(tier, riskScore, queueLen, threshold) {
283
+ return tier === '1a' ||
284
+ (tier === '1b' && riskScore >= threshold) ||
285
+ (tier === 2 && riskScore >= threshold && queueLen < 50);
286
+ }
287
+
288
+ // Post-sandbox label classifier, extracted pure from processQueueItem for the
289
+ // same reason (the RELABEL tests — including the @cloudbase/cloudbase-mcp
290
+ // regression — re-implemented this formula inline and protected nothing).
291
+ // Returns the stat/label decision; the caller performs the side effects.
292
+ // 'inconclusive_timeout' sandbox timed out — keep original label
293
+ // 'relabel_blocked_hc' sandbox clean BUT high-confidence malice types
294
+ // 'relabel_blocked_static' sandbox clean BUT dormant or static score >= 70
295
+ // 'unconfirmed' sandbox clean, low static → relabel to unconfirmed
296
+ // 'confirmed' sandbox found live behavior → confirm
297
+ // 'inconclusive_install_error' sandbox score > 0 with 0 findings (install error)
298
+ // 'suspect' no usable sandbox verdict — keep suspect
299
+ // null static-clean package: nothing to classify
300
+ function classifySandboxOutcome({ staticClean, sandboxResult, hasHCThreats, isDormant, staticScore }) {
301
+ if (staticClean) return null;
302
+ if (sandboxResult && sandboxResult.inconclusive) return 'inconclusive_timeout';
303
+ if (sandboxResult && sandboxResult.score === 0) {
304
+ if (hasHCThreats) return 'relabel_blocked_hc';
305
+ if (isDormant || staticScore >= 70) return 'relabel_blocked_static';
306
+ return 'unconfirmed';
307
+ }
308
+ if (sandboxResult && sandboxResult.score > 0) {
309
+ return (sandboxResult.findings && sandboxResult.findings.length > 0)
310
+ ? 'confirmed'
311
+ : 'inconclusive_install_error';
312
+ }
313
+ return 'suspect';
314
+ }
315
+
266
316
  // --- Sandbox waste-cut (v2.11.6x): skip sandbox time that yields no new verdict ---
267
317
  // Two skip paths, both detection-safe, applied BEFORE the tier sandbox decision:
268
318
  // (1) memory match — re-sandboxing a package whose static result is equivalent to a
@@ -1286,11 +1336,8 @@ async function scanPackage(name, version, ecosystem, tarballUrl, registryMeta, s
1286
1336
  // findings in 4 months of prod data (axon-enterprise was at 52).
1287
1337
  // T2: conditional sandbox — same score gate AND queue < 50.
1288
1338
  let sandboxResult = null;
1289
- const shouldSandbox = !skipSandboxLargePackage && isSandboxEnabled() && sandboxAvailable && (
1290
- tier === '1a' ||
1291
- (tier === '1b' && riskScore >= SANDBOX_SCORE_THRESHOLD) ||
1292
- (tier === 2 && riskScore >= SANDBOX_SCORE_THRESHOLD && scanQueue.length < 50)
1293
- );
1339
+ const shouldSandbox = !skipSandboxLargePackage && isSandboxEnabled() && sandboxAvailable &&
1340
+ computeSandboxGate(tier, riskScore, scanQueue.length, SANDBOX_SCORE_THRESHOLD);
1294
1341
 
1295
1342
  // Waste-cut: skip the sandbox (run AND defer) when re-running it yields no new
1296
1343
  // verdict — a memory match the webhook would suppress anyway (dominant cost:
@@ -1383,7 +1430,7 @@ async function scanPackage(name, version, ecosystem, tarballUrl, registryMeta, s
1383
1430
  }));
1384
1431
  const payload = buildCanaryExfiltrationWebhookEmbed(name, version, exfiltrations);
1385
1432
  try {
1386
- await sendWebhook(url, payload, { rawPayload: true });
1433
+ await _deps.sendWebhook(url, payload, { rawPayload: true });
1387
1434
  console.log(`[MONITOR] Canary exfiltration webhook sent for ${name}@${version}`);
1388
1435
  if (previousRules) {
1389
1436
  previousRules.add(canaryRuleId);
@@ -1767,7 +1814,7 @@ async function processQueueItem(item, stats, dailyAlerts, recentlyScanned, downl
1767
1814
  timestamp: new Date().toISOString()
1768
1815
  }]
1769
1816
  };
1770
- await sendWebhook(url, payload, { rawPayload: true });
1817
+ await _deps.sendWebhook(url, payload, { rawPayload: true });
1771
1818
  }
1772
1819
  } catch (webhookErr) {
1773
1820
  console.error(`[MONITOR] IOC fallback webhook failed: ${webhookErr.message}`);
@@ -2241,41 +2288,47 @@ async function resolveTarballAndScan(item, stats, dailyAlerts, recentlyScanned,
2241
2288
  const sandboxResult = scanResult && scanResult.sandboxResult;
2242
2289
  const staticClean = scanResult && scanResult.staticClean;
2243
2290
 
2244
- // FP rate tracking + ML label refinement
2291
+ // FP rate tracking + ML label refinement — the decision itself lives in
2292
+ // classifySandboxOutcome (pure, exported); this block performs the side effects.
2245
2293
  if (scanResult) {
2246
- if (!staticClean) {
2247
- if (sandboxResult && sandboxResult.inconclusive) {
2294
+ const outcome = classifySandboxOutcome({
2295
+ staticClean,
2296
+ sandboxResult,
2297
+ hasHCThreats: scanResult.hasHCThreats || false,
2298
+ isDormant: scanResult.isDormant || false,
2299
+ staticScore: scanResult.staticScore || 0
2300
+ });
2301
+ switch (outcome) {
2302
+ case 'inconclusive_timeout':
2248
2303
  // Sandbox timeout: cannot conclude — do NOT relabel (neither fp nor confirmed)
2249
2304
  updateScanStats('sandbox_inconclusive');
2250
2305
  console.log(`[MONITOR] SANDBOX INCONCLUSIVE (timeout): ${item.name} — keeping original label`);
2251
- } else if (sandboxResult && sandboxResult.score === 0) {
2252
- const hasHC = scanResult.hasHCThreats || false;
2253
- const isDormant = scanResult.isDormant || false;
2254
- const staticScore = scanResult.staticScore || 0;
2255
-
2256
- if (hasHC) {
2257
- updateScanStats('sandbox_inconclusive');
2258
- console.log(`[MONITOR] RELABEL BLOCKED (HC threats): ${item.name} — sandbox clean but has high-confidence malice types, keeping suspect label`);
2259
- } else if (isDormant || staticScore >= 70) {
2260
- updateScanStats('sandbox_inconclusive');
2261
- console.log(`[MONITOR] RELABEL BLOCKED (high static): ${item.name} — static score=${staticScore}, keeping suspect label`);
2262
- } else {
2263
- updateScanStats('sandbox_unconfirmed');
2264
- relabelRecords(item.name, 'unconfirmed');
2265
- }
2266
- } else if (sandboxResult && sandboxResult.score > 0) {
2267
- const hasSandboxFindings = sandboxResult.findings && sandboxResult.findings.length > 0;
2268
- if (hasSandboxFindings) {
2269
- updateScanStats('confirmed');
2270
- relabelRecords(item.name, 'confirmed', sandboxResult.findings.length);
2271
- } else {
2272
- // Sandbox score > 0 but no detailed findings = install error
2273
- updateScanStats('sandbox_inconclusive');
2274
- console.log(`[MONITOR] SANDBOX INCONCLUSIVE: ${item.name} score=${sandboxResult.score} but 0 findings — probable install error`);
2275
- }
2276
- } else {
2306
+ break;
2307
+ case 'relabel_blocked_hc':
2308
+ updateScanStats('sandbox_inconclusive');
2309
+ console.log(`[MONITOR] RELABEL BLOCKED (HC threats): ${item.name} sandbox clean but has high-confidence malice types, keeping suspect label`);
2310
+ break;
2311
+ case 'relabel_blocked_static':
2312
+ updateScanStats('sandbox_inconclusive');
2313
+ console.log(`[MONITOR] RELABEL BLOCKED (high static): ${item.name} — static score=${scanResult.staticScore || 0}, keeping suspect label`);
2314
+ break;
2315
+ case 'unconfirmed':
2316
+ updateScanStats('sandbox_unconfirmed');
2317
+ relabelRecords(item.name, 'unconfirmed');
2318
+ break;
2319
+ case 'confirmed':
2320
+ updateScanStats('confirmed');
2321
+ relabelRecords(item.name, 'confirmed', sandboxResult.findings.length);
2322
+ break;
2323
+ case 'inconclusive_install_error':
2324
+ // Sandbox score > 0 but no detailed findings = install error
2325
+ updateScanStats('sandbox_inconclusive');
2326
+ console.log(`[MONITOR] SANDBOX INCONCLUSIVE: ${item.name} score=${sandboxResult.score} but 0 findings — probable install error`);
2327
+ break;
2328
+ case 'suspect':
2277
2329
  updateScanStats('suspect');
2278
- }
2330
+ break;
2331
+ // null: static-clean package — nothing to classify
2279
2332
  }
2280
2333
  }
2281
2334
 
@@ -2363,7 +2416,11 @@ module.exports = {
2363
2416
  classifyNativeShard,
2364
2417
  shouldSkipSandbox,
2365
2418
  runScanInWorker,
2419
+ _deps,
2366
2420
  registerWorkerMessageHandler,
2421
+ unregisterWorkerMessageHandler,
2422
+ computeSandboxGate,
2423
+ classifySandboxOutcome,
2367
2424
  getInFlightItems,
2368
2425
  computeInterruptDisposition,
2369
2426
  capWorkersForDegradation,
@@ -29,6 +29,14 @@ const path = require('path');
29
29
 
30
30
  const { sendWebhook } = require('../webhook.js');
31
31
  const { sendIngest, isIngestConfigured } = require('../integrations/api-ingest.js');
32
+
33
+ // Outbound-delivery seam. Destructuring `sendWebhook` above captures the
34
+ // reference at load time, so a test that patches `require('../webhook.js').exports`
35
+ // afterwards never intercepts anything (audit 2026-07: four monitor tests were
36
+ // doing real HTTPS POSTs through a "stubbed" sendWebhook). All call sites below
37
+ // go through `_deps.*` so tests can swap the delivery function directly —
38
+ // same pattern as ingestion._deps.
39
+ const _deps = { sendWebhook, sendIngest };
32
40
  const {
33
41
  atomicWriteFileSync,
34
42
  ALERTS_LOG_DIR,
@@ -231,7 +239,7 @@ function buildIOCPreAlertEmbed(name, version, ecosystem = 'npm') {
231
239
  async function sendIOCPreAlert(name, version, ecosystem = 'npm') {
232
240
  const url = getWebhookUrl();
233
241
  if (!url) return;
234
- await sendWebhook(url, buildIOCPreAlertEmbed(name, version, ecosystem), { rawPayload: true });
242
+ await _deps.sendWebhook(url, buildIOCPreAlertEmbed(name, version, ecosystem), { rawPayload: true });
235
243
  }
236
244
 
237
245
  /**
@@ -271,7 +279,7 @@ function buildCampaignPreAlertEmbed(name, campaign, ecosystem = 'npm') {
271
279
  async function sendCampaignPreAlert(name, campaign, ecosystem = 'npm') {
272
280
  const url = getWebhookUrl();
273
281
  if (!url) return;
274
- await sendWebhook(url, buildCampaignPreAlertEmbed(name, campaign, ecosystem), { rawPayload: true });
282
+ await _deps.sendWebhook(url, buildCampaignPreAlertEmbed(name, campaign, ecosystem), { rawPayload: true });
275
283
  }
276
284
 
277
285
  /**
@@ -327,7 +335,7 @@ async function sendBurstPreAlert(name, count, ecosystem = 'npm') {
327
335
  if (!burstPreAlertWebhookEnabled()) return;
328
336
  const url = getWebhookUrl();
329
337
  if (!url) return;
330
- await sendWebhook(url, buildBurstPreAlertEmbed(name, count, ecosystem), { rawPayload: true });
338
+ await _deps.sendWebhook(url, buildBurstPreAlertEmbed(name, count, ecosystem), { rawPayload: true });
331
339
  }
332
340
 
333
341
  /**
@@ -575,7 +583,7 @@ async function resendDailyReport(date) {
575
583
  const payload = report.data && report.data.embed;
576
584
  if (!payload) return { sent: false, message: `Report ${report.date} has no embed payload`, date: report.date };
577
585
  try {
578
- await sendWebhook(url, payload, { rawPayload: true });
586
+ await _deps.sendWebhook(url, payload, { rawPayload: true });
579
587
  } catch (err) {
580
588
  return { sent: false, message: `Webhook failed: ${err.message}`, date: report.date };
581
589
  }
@@ -724,7 +732,7 @@ async function trySendWebhook(name, version, ecosystem, result, sandboxResult, m
724
732
  // independent of Discord dedup. The API's ON CONFLICT DO UPDATE absorbs duplicates;
725
733
  // dedup downstream is a Discord noise filter, not a data filter.
726
734
  if (isIngestConfigured()) {
727
- sendIngest(name, version, result).catch(() => {});
735
+ _deps.sendIngest(name, version, result).catch(() => {});
728
736
  }
729
737
 
730
738
  // Persist periodically (throttled to every 10 scans to avoid disk I/O overhead)
@@ -775,7 +783,7 @@ async function trySendWebhook(name, version, ecosystem, result, sandboxResult, m
775
783
  const url = getWebhookUrl();
776
784
  const webhookData = buildAlertData(name, version, ecosystem, result, sandboxResult, llmResult);
777
785
  try {
778
- await sendWebhook(url, webhookData);
786
+ await _deps.sendWebhook(url, webhookData);
779
787
  console.log(`[MONITOR] Webhook sent for ${name}@${version}`);
780
788
  } catch (err) {
781
789
  console.error(`[MONITOR] Webhook failed for ${name}@${version}: ${err.message}`);
@@ -848,7 +856,7 @@ async function flushScopeGroup(scope) {
848
856
  };
849
857
  const webhookData = buildAlertData(pkg.name, pkg.version, group.ecosystem, result, pkg.sandboxResult, pkg.llmResult);
850
858
  try {
851
- await sendWebhook(url, webhookData);
859
+ await _deps.sendWebhook(url, webhookData);
852
860
  console.log(`[MONITOR] Webhook sent for ${pkg.name}@${pkg.version} (scope group flush, single)`);
853
861
  } catch (err) {
854
862
  console.error(`[MONITOR] Webhook failed for ${pkg.name}@${pkg.version}: ${err.message}`);
@@ -892,7 +900,7 @@ async function flushScopeGroup(scope) {
892
900
  };
893
901
 
894
902
  try {
895
- await sendWebhook(url, payload, { rawPayload: true });
903
+ await _deps.sendWebhook(url, payload, { rawPayload: true });
896
904
  console.log(`[MONITOR] Grouped webhook sent for ${scope} (${group.packages.length} packages, max=${group.maxScore})`);
897
905
  } catch (err) {
898
906
  console.error(`[MONITOR] Grouped webhook failed for ${scope}: ${err.message}`);
@@ -1601,7 +1609,7 @@ async function sendDailyReport(stats, dailyAlerts, recentlyScanned, downloadsCac
1601
1609
  const url = getWebhookUrl();
1602
1610
  if (url) {
1603
1611
  try {
1604
- await sendWebhook(url, payload, { rawPayload: true });
1612
+ await _deps.sendWebhook(url, payload, { rawPayload: true });
1605
1613
  console.log('[MONITOR] Daily report sent');
1606
1614
  // Confirm delivery on the just-persisted file so boot redelivery won't resend it.
1607
1615
  const persisted = loadPersistedReport(today);
@@ -1704,7 +1712,7 @@ async function sendReportNow(stats) {
1704
1712
  }
1705
1713
 
1706
1714
  try {
1707
- await sendWebhook(url, payload, { rawPayload: true });
1715
+ await _deps.sendWebhook(url, payload, { rawPayload: true });
1708
1716
  } catch (err) {
1709
1717
  return { sent: false, message: `Webhook failed: ${err.message}` };
1710
1718
  }
@@ -1767,6 +1775,9 @@ function getReportStatus() {
1767
1775
  }
1768
1776
 
1769
1777
  module.exports = {
1778
+ // Test seam — swap _deps.sendWebhook/_deps.sendIngest to intercept outbound delivery
1779
+ _deps,
1780
+
1770
1781
  // Mutable state
1771
1782
  alertedPackageRules,
1772
1783
  SCOPE_GROUP_WINDOW_MS,
@@ -133,6 +133,50 @@ function checkPyPITyposquatting(deps, targetPath) {
133
133
  * @param {string[]} warnings - Warnings array (mutated: may push module graph warnings)
134
134
  * @returns {Promise<{threats: Array, scannerErrors: Array}>}
135
135
  */
136
+
137
+ const SCANNER_NAMES = [
138
+ 'scanPackageJson', 'scanShellScripts', 'analyzeAST', 'detectObfuscation',
139
+ 'scanDependencies', 'scanHashes', 'analyzeDataFlow', 'scanTyposquatting',
140
+ 'scanGitHubActions', 'matchPythonIOCs', 'checkPyPITyposquatting',
141
+ 'scanEntropy', 'scanAIConfig', 'scanIocStrings', 'scanAntiForensic',
142
+ 'scanStubPackage', 'scanMonorepo', 'scanTrustedDepDiff', 'scanPythonSource',
143
+ 'scanPythonAST', 'scanAntiScannerInjection', 'scanBinarySource'
144
+ ];
145
+
146
+ // Stage 2 quick_scan subset (monitor-only, set via options.scanMode='quick'
147
+ // by queue.js when MUADDIB_TRIAGE_MODE=enforce). The subset keeps the heavy
148
+ // detectors that anchor TPR on the 96-sample GT (analyzeAST covers 70/96,
149
+ // analyzeDataFlow covers 31/96 — non-negotiable), the cheap high-signal
150
+ // lifecycle/IOC scanners, and the Python detectors (PyPI samples need them;
151
+ // npm exit immediately on a depth-1 readdir, so the cost is negligible).
152
+ // Excluded: scanAntiForensic (45s timeout, never the unique trigger on GT),
153
+ // scanHashes (cheap but GT samples are rebuilt — hashes drift), scanAIConfig,
154
+ // scanStubPackage, scanMonorepo, scanTrustedDepDiff (opt-in registry diff),
155
+ // checkPyPITyposquatting (subsumed by scanTyposquatting for npm; PyPI
156
+ // typosquats already get full via triage signals). CLI mode and shadow mode
157
+ // never set scanMode so the default branch runs all 20 scanners — fully
158
+ // backwards-compatible.
159
+ // Module-level + exported so tests assert THIS set, not a copy (audit 2026-07:
160
+ // triage-gt.test.js compared two hand-maintained replicas of it).
161
+ const QUICK_SCAN_ALLOWLIST = new Set([
162
+ 'scanPackageJson',
163
+ 'scanShellScripts',
164
+ 'analyzeAST',
165
+ 'detectObfuscation',
166
+ 'scanDependencies',
167
+ 'analyzeDataFlow',
168
+ 'scanTyposquatting',
169
+ 'scanGitHubActions',
170
+ 'matchPythonIOCs',
171
+ 'scanEntropy',
172
+ 'scanIocStrings',
173
+ 'scanPythonSource',
174
+ 'scanPythonAST',
175
+ 'scanAIConfig',
176
+ 'scanAntiScannerInjection',
177
+ 'scanBinarySource'
178
+ ]);
179
+
136
180
  async function execute(targetPath, options, pythonDeps, warnings) {
137
181
  // Show spinner during scan (TTY only; piped/CI output keeps static message)
138
182
  const useTTYSpinner = !options._capture && process.stdout.isTTY;
@@ -248,46 +292,6 @@ async function execute(targetPath, options, pythonDeps, warnings) {
248
292
  });
249
293
  }
250
294
 
251
- const SCANNER_NAMES = [
252
- 'scanPackageJson', 'scanShellScripts', 'analyzeAST', 'detectObfuscation',
253
- 'scanDependencies', 'scanHashes', 'analyzeDataFlow', 'scanTyposquatting',
254
- 'scanGitHubActions', 'matchPythonIOCs', 'checkPyPITyposquatting',
255
- 'scanEntropy', 'scanAIConfig', 'scanIocStrings', 'scanAntiForensic',
256
- 'scanStubPackage', 'scanMonorepo', 'scanTrustedDepDiff', 'scanPythonSource',
257
- 'scanPythonAST', 'scanAntiScannerInjection', 'scanBinarySource'
258
- ];
259
-
260
- // Stage 2 quick_scan subset (monitor-only, set via options.scanMode='quick'
261
- // by queue.js when MUADDIB_TRIAGE_MODE=enforce). The subset keeps the heavy
262
- // detectors that anchor TPR on the 96-sample GT (analyzeAST covers 70/96,
263
- // analyzeDataFlow covers 31/96 — non-negotiable), the cheap high-signal
264
- // lifecycle/IOC scanners, and the Python detectors (PyPI samples need them;
265
- // npm exit immediately on a depth-1 readdir, so the cost is negligible).
266
- // Excluded: scanAntiForensic (45s timeout, never the unique trigger on GT),
267
- // scanHashes (cheap but GT samples are rebuilt — hashes drift), scanAIConfig,
268
- // scanStubPackage, scanMonorepo, scanTrustedDepDiff (opt-in registry diff),
269
- // checkPyPITyposquatting (subsumed by scanTyposquatting for npm; PyPI
270
- // typosquats already get full via triage signals). CLI mode and shadow mode
271
- // never set scanMode so the default branch runs all 20 scanners — fully
272
- // backwards-compatible.
273
- const QUICK_SCAN_ALLOWLIST = new Set([
274
- 'scanPackageJson',
275
- 'scanShellScripts',
276
- 'analyzeAST',
277
- 'detectObfuscation',
278
- 'scanDependencies',
279
- 'analyzeDataFlow',
280
- 'scanTyposquatting',
281
- 'scanGitHubActions',
282
- 'matchPythonIOCs',
283
- 'scanEntropy',
284
- 'scanIocStrings',
285
- 'scanPythonSource',
286
- 'scanPythonAST',
287
- 'scanAIConfig',
288
- 'scanAntiScannerInjection',
289
- 'scanBinarySource'
290
- ]);
291
295
  const isQuick = options.scanMode === 'quick';
292
296
  function ifEnabled(name, fn) {
293
297
  if (isQuick && !QUICK_SCAN_ALLOWLIST.has(name)) return Promise.resolve([]);
@@ -488,4 +492,4 @@ async function execute(targetPath, options, pythonDeps, warnings) {
488
492
  }
489
493
  }
490
494
 
491
- module.exports = { execute, matchPythonIOCs, checkPyPITyposquatting };
495
+ module.exports = { execute, matchPythonIOCs, checkPyPITyposquatting, QUICK_SCAN_ALLOWLIST, SCANNER_NAMES };
@@ -21,6 +21,7 @@
21
21
 
22
22
  const http = require('http');
23
23
  const url = require('url');
24
+ const crypto = require('crypto');
24
25
  const { getFeed } = require('../threat-feed.js');
25
26
  // Monitor-feed routes : optional out-of-tree dependency. Lazy-load so the
26
27
  // /feed and /health routes still work when the file is absent. Missing-file
@@ -68,6 +69,19 @@ const SECURITY_HEADERS = {
68
69
  const RATE_LIMIT_MAX = 60;
69
70
  const RATE_LIMIT_WINDOW_MS = 60_000;
70
71
  const rateLimitMap = new Map();
72
+ // Sweep the map when it grows past this many keys, evicting entries whose window has fully
73
+ // expired. Bind is localhost-only so this is bounded in practice, but without a sweep the Map
74
+ // grows unbounded if the server is ever bound wider (one dead key per distinct client IP).
75
+ const RATE_LIMIT_MAX_KEYS = 10_000;
76
+
77
+ function sweepRateLimitMap(now) {
78
+ const windowStart = now - RATE_LIMIT_WINDOW_MS;
79
+ for (const [ip, timestamps] of rateLimitMap) {
80
+ if (timestamps.length === 0 || timestamps[timestamps.length - 1] < windowStart) {
81
+ rateLimitMap.delete(ip);
82
+ }
83
+ }
84
+ }
71
85
 
72
86
  function sendJson(res, statusCode, data) {
73
87
  res.writeHead(statusCode, SECURITY_HEADERS);
@@ -92,7 +106,12 @@ function checkAuth(req) {
92
106
  if (parts.length !== 2 || parts[0] !== 'Bearer') {
93
107
  return { ok: false, error: 'Invalid Authorization format. Use: Bearer <token>' };
94
108
  }
95
- if (parts[1] !== token) {
109
+ // Constant-time comparison to avoid a timing oracle on the token. Compare fixed-length
110
+ // SHA-256 digests so timingSafeEqual never sees mismatched buffer lengths (which would
111
+ // throw and leak length via the exception path).
112
+ const a = crypto.createHash('sha256').update(parts[1]).digest();
113
+ const b = crypto.createHash('sha256').update(token).digest();
114
+ if (!crypto.timingSafeEqual(a, b)) {
96
115
  return { ok: false, error: 'Invalid token' };
97
116
  }
98
117
  return { ok: true };
@@ -105,6 +124,10 @@ function checkAuth(req) {
105
124
  */
106
125
  function checkRateLimit(ip) {
107
126
  const now = Date.now();
127
+ // Opportunistic bounded sweep: only when the map is oversized, so the common path stays O(1).
128
+ if (rateLimitMap.size > RATE_LIMIT_MAX_KEYS) {
129
+ sweepRateLimitMap(now);
130
+ }
108
131
  if (!rateLimitMap.has(ip)) {
109
132
  rateLimitMap.set(ip, [now]);
110
133
  return { ok: true, remaining: RATE_LIMIT_MAX - 1 };
@@ -165,7 +188,10 @@ function startServer(options = {}) {
165
188
  try {
166
189
  sendJson(res, 200, buildMonitorDaily());
167
190
  } catch (err) {
168
- sendJson(res, 500, { error: 'monitor_daily_failed', message: err.message });
191
+ // Don't leak err.message to the client — it can expose internal filesystem paths.
192
+ // Log server-side, return a generic error.
193
+ console.error('[SERVE] /monitor/daily failed:', err.message);
194
+ sendJson(res, 500, { error: 'monitor_daily_failed' });
169
195
  }
170
196
  } else if (pathname === '/monitor/window') {
171
197
  const range = (parsed.query && parsed.query.range) ? String(parsed.query.range) : '7d';
@@ -176,13 +202,15 @@ function startServer(options = {}) {
176
202
  try {
177
203
  sendJson(res, 200, buildMonitorWindow(range));
178
204
  } catch (err) {
179
- sendJson(res, 500, { error: 'monitor_window_failed', message: err.message });
205
+ console.error('[SERVE] /monitor/window failed:', err.message);
206
+ sendJson(res, 500, { error: 'monitor_window_failed' });
180
207
  }
181
208
  } else if (pathname === '/monitor/stats') {
182
209
  try {
183
210
  sendJson(res, 200, buildMonitorAll());
184
211
  } catch (err) {
185
- sendJson(res, 500, { error: 'monitor_stats_failed', message: err.message });
212
+ console.error('[SERVE] /monitor/stats failed:', err.message);
213
+ sendJson(res, 500, { error: 'monitor_stats_failed' });
186
214
  }
187
215
  } else if (pathname === '/health') {
188
216
  sendJson(res, 200, { status: 'ok', version: pkg.version });
@@ -385,6 +385,10 @@ function analyzeAIConfigFile(content, relPath, invisibleCount) {
385
385
  threats.push({
386
386
  type: 'ai_config_injection_critical',
387
387
  severity: 'CRITICAL',
388
+ // Structural marker (like GHA-006): the compound escalation shares its type
389
+ // with single-pattern criticals — tests and consumers must not have to
390
+ // sniff the message wording to tell them apart.
391
+ compound: true,
388
392
  message: `AI config compound attack: shell commands + ${hasExfiltration ? 'exfiltration' : 'credential access'} in ${relPath} — ToxicSkills/Clinejection pattern.`,
389
393
  file: relPath
390
394
  });
@@ -46,6 +46,10 @@ const { countInvisibleUnicode, stripInvisibleUnicode } = require('../shared/unic
46
46
  * the self-scan canary (`npm run scan .`) must stay at 0 antiscanner_* findings.
47
47
  */
48
48
 
49
+ // Intentional deviations from utils.EXCLUDED_DIRS / the common source-extension lists:
50
+ // - `.py` is included: TrapDoor/eth-security-auditor PyPI campaigns target Python reviewers.
51
+ // - dist/build/out are NOT excluded: the Hades prompt-injection sits atop a bundle, so the
52
+ // bundled output in dist/ must be scanned. Do not align on the common lists.
49
53
  const SCAN_EXTENSIONS = ['.js', '.cjs', '.mjs', '.ts', '.tsx', '.jsx', '.py'];
50
54
  const EXCLUDED_DIRS = ['node_modules', '.git', '.muaddib-cache'];
51
55
 
@@ -63,25 +63,9 @@ function extractStringValue(node) {
63
63
  return null;
64
64
  }
65
65
 
66
- /**
67
- * Audit v3 B2: Shannon entropy calculation for split-entropy detection.
68
- * Replicates the algorithm from entropy.js for inline use in AST scanner.
69
- */
70
- function calculateShannonEntropy(str) {
71
- if (!str || str.length === 0) return 0;
72
- const freq = Object.create(null);
73
- for (let i = 0; i < str.length; i++) {
74
- const ch = str[i];
75
- freq[ch] = (freq[ch] || 0) + 1;
76
- }
77
- let entropy = 0;
78
- const len = str.length;
79
- for (const ch in freq) {
80
- const p = freq[ch] / len;
81
- entropy -= p * Math.log2(p);
82
- }
83
- return entropy;
84
- }
66
+ // Audit v3 B2: Shannon entropy for split-entropy detection — single implementation in
67
+ // src/shared/entropy.js (re-exported below for handle-call-expression.js).
68
+ const { calculateShannonEntropy } = require('../../shared/entropy.js');
85
69
 
86
70
  /**
87
71
  * Audit v3 B2: Count the number of leaf string operands in a BinaryExpression chain.
@@ -33,6 +33,7 @@ const {
33
33
  handlePostWalk
34
34
  } = require('./ast-detectors');
35
35
  const { detectAnalyzerHoneytoken, detectEnvMarkerEnumeration, countExitGuards, hasHostRecon } = require('./ast-detectors/anti-evasion.js');
36
+ const { SAFE_FETCH_DOMAINS } = require('./ast-detectors/constants.js');
36
37
 
37
38
  // Check if credential keywords appear INSIDE regex literals or new RegExp() patterns.
38
39
  // Only true when the keyword is part of the regex pattern itself, not just a string elsewhere in the file.
@@ -349,12 +350,6 @@ function analyzeFile(content, filePath, basePath) {
349
350
  // Compute fetchOnlySafeDomains: check if ALL URLs in file point to known registries
350
351
  if (ctx.hasRemoteFetch) {
351
352
  const urlMatches = content.match(/https?:\/\/[^\s'"`)]+/g) || [];
352
- const SAFE_FETCH_DOMAINS = [
353
- 'registry.npmjs.org', 'npmjs.com',
354
- 'github.com', 'objects.githubusercontent.com', 'raw.githubusercontent.com',
355
- 'nodejs.org', 'yarnpkg.com',
356
- 'pypi.org', 'files.pythonhosted.org'
357
- ];
358
353
  if (urlMatches.length > 0 && urlMatches.every(u => {
359
354
  try {
360
355
  const hostname = new URL(u).hostname;
@@ -238,6 +238,11 @@ function getPackageVersion(pkgPath) {
238
238
  module.exports = {
239
239
  scanDependencies,
240
240
  checkRehabilitatedPackage,
241
+ // Pure fs helpers, exported so tests assert their actual behaviour (scoped
242
+ // name resolution, hidden/non-dir skipping, version fallback) instead of
243
+ // only Array.isArray on scanDependencies' output.
244
+ listPackages,
245
+ getPackageVersion,
241
246
  TRUSTED_PACKAGES,
242
247
  SAFE_FILES
243
248
  };
@@ -78,31 +78,9 @@ function isWhitelistedString(str, filePath) {
78
78
  return false;
79
79
  }
80
80
 
81
- /**
82
- * Calculate Shannon entropy of a string.
83
- * @param {string} str - Input string
84
- * @returns {number} Entropy in bits (0-8)
85
- */
86
- function calculateShannonEntropy(str) {
87
- if (!str || str.length === 0) return 0;
88
-
89
- const freq = {};
90
- for (let i = 0; i < str.length; i++) {
91
- const ch = str[i];
92
- freq[ch] = (freq[ch] || 0) + 1;
93
- }
94
-
95
- const len = str.length;
96
- let entropy = 0;
97
- for (const ch in freq) {
98
- const p = freq[ch] / len;
99
- if (p > 0) {
100
- entropy -= p * Math.log2(p);
101
- }
102
- }
103
-
104
- return entropy;
105
- }
81
+ // Shannon entropy: single implementation in src/shared/entropy.js (re-exported below —
82
+ // tests and callers import it from this module).
83
+ const { calculateShannonEntropy } = require('../shared/entropy.js');
106
84
 
107
85
  /**
108
86
  * Extract string literals from JS source code via regex.
@@ -219,6 +197,12 @@ function detectObfuscationPatterns(content, relativePath) {
219
197
  return threats;
220
198
  }
221
199
 
200
+ // Bounded resources: cap high_entropy_string emissions per file. A dense ≤10MB file could
201
+ // otherwise emit thousands of threat objects (distinct messages survive dedup). Must stay
202
+ // well above the FP_COUNT_THRESHOLDS maxCount=5 for high_entropy_string (scoring.js) so the
203
+ // anti-flood downgrade still triggers; scoring decay saturates long before 50.
204
+ const MAX_ENTROPY_THREATS_PER_FILE = 50;
205
+
222
206
  /**
223
207
  * Scan JavaScript files for high-entropy strings and JS obfuscation patterns.
224
208
  * @param {string} targetPath - Directory to scan
@@ -244,7 +228,9 @@ function scanEntropy(targetPath, options = {}) {
244
228
 
245
229
  // String-level entropy check (MUADDIB-ENTROPY-001)
246
230
  const strings = extractStringLiterals(content);
231
+ let entropyThreatCount = 0;
247
232
  for (const str of strings) {
233
+ if (entropyThreatCount >= MAX_ENTROPY_THREATS_PER_FILE) break;
248
234
  if (str.length < MIN_STRING_LENGTH) continue;
249
235
 
250
236
  // B12: Windowed analysis for strings > MAX_STRING_LENGTH
@@ -261,6 +247,7 @@ function scanEntropy(targetPath, options = {}) {
261
247
  message: `High entropy window in long string (${str.length} chars, offset ${i}) — possible padded payload`,
262
248
  file: relativePath
263
249
  });
250
+ entropyThreatCount++;
264
251
  break;
265
252
  }
266
253
  }
@@ -280,6 +267,7 @@ function scanEntropy(targetPath, options = {}) {
280
267
  message: `High entropy string (${strEntropy.toFixed(2)} bits, ${str.length} chars) — possible base64/hex/encrypted payload`,
281
268
  file: relativePath
282
269
  });
270
+ entropyThreatCount++;
283
271
  }
284
272
  }
285
273
 
@@ -213,6 +213,10 @@ function scanParanoid(targetPath) {
213
213
  }
214
214
  }
215
215
 
216
+ // Paranoid mode is opt-in maximal coverage: this walker deliberately does NOT use
217
+ // utils.EXCLUDED_DIRS — it scans dist/build/out (bundled payloads) and .json/.sh in addition
218
+ // to JS. Do not "align" it on findFiles/EXCLUDED_DIRS: that would drop coverage this mode
219
+ // exists to provide. Symlink-safe (lstat skip) + depth guard 50 (IDX-06).
216
220
  function walkDir(dir, depth) {
217
221
  if (depth > 50) return; // Max depth guard (IDX-06)
218
222
  const excluded = ['node_modules', '.git', '.muaddib-cache', ...getExtraExcludes()];
@@ -19,6 +19,7 @@
19
19
 
20
20
  const fs = require('fs');
21
21
  const path = require('path');
22
+ const { getMaxFileSize } = require('../shared/constants.js');
22
23
 
23
24
  // ============================================
24
25
  // REQUIREMENTS.TXT PARSER
@@ -41,7 +42,17 @@ function parseRequirementsTxt(filePath, visited, projectRoot) {
41
42
  if (visited.has(resolved)) return [];
42
43
  visited.add(resolved);
43
44
 
44
- const content = fs.readFileSync(filePath, 'utf8');
45
+ // Guarded read (aligned on parseSetupPy/parsePyprojectToml): attacker-controlled input —
46
+ // a `-r <dir>` include resolves to a directory (EISDIR) and would otherwise abort the
47
+ // whole scan (denial-of-detection); oversized files are skipped like everywhere else.
48
+ let content;
49
+ try {
50
+ const stat = fs.statSync(filePath);
51
+ if (!stat.isFile() || stat.size > getMaxFileSize()) return [];
52
+ content = fs.readFileSync(filePath, 'utf8');
53
+ } catch {
54
+ return [];
55
+ }
45
56
  const lines = content.split(/\r?\n/);
46
57
  const deps = [];
47
58
  const relFile = filePath;
@@ -56,13 +67,13 @@ function parseRequirementsTxt(filePath, visited, projectRoot) {
56
67
  const includeMatch = line.match(/^(?:-r|--requirement)\s+(.+)$/);
57
68
  if (includeMatch) {
58
69
  const includePath = path.resolve(path.dirname(filePath), includeMatch[1].trim());
59
- // Path traversal guard: ensure included file stays within the project root
60
- // Use case-insensitive comparison on Windows (PY-01)
61
- // PY-001: Derive rootDir once at top-level, pass it down for all recursive calls
70
+ // Path traversal guard: ensure included file stays within the project root.
71
+ // PY-001: Derive rootDir once at top-level, pass it down for all recursive calls.
72
+ // path.relative enforces a separator boundary (a raw startsWith prefix check let
73
+ // /scan/pkg-evil pass as "within" /scan/pkg) and handles per-OS case/separator rules.
62
74
  const rootDir = projectRoot || path.resolve(path.dirname(filePath));
63
- const isWithin = process.platform === 'win32'
64
- ? includePath.toLowerCase().startsWith(rootDir.toLowerCase())
65
- : includePath.startsWith(rootDir);
75
+ const rel = path.relative(rootDir, includePath);
76
+ const isWithin = rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
66
77
  if (!isWithin) continue;
67
78
  const included = parseRequirementsTxt(includePath, visited, rootDir);
68
79
  deps.push(...included);
@@ -46,7 +46,10 @@ const crypto = require('crypto');
46
46
 
47
47
  const { HIGH_CONFIDENCE_MALICE_TYPES } = require('../monitor/classify.js');
48
48
 
49
- const CACHE_DIR = path.join(process.cwd(), '.muaddib-cache', 'version-deltas');
49
+ // Env override so tests write to a mkdtemp instead of littering the
50
+ // production cache (unset in prod → unchanged behavior).
51
+ const CACHE_DIR = process.env.MUADDIB_DELTA_CACHE_DIR
52
+ || path.join(process.cwd(), '.muaddib-cache', 'version-deltas');
50
53
  const CACHE_TTL_MS = 90 * 24 * 60 * 60 * 1000;
51
54
  const CACHE_MAX_ENTRIES = 50000;
52
55
  const MIN_PRIOR_VERSIONS_FOR_DECAY = 3;
@@ -0,0 +1,45 @@
1
+ /*
2
+ * MUAD'DIB — Supply-chain threat detection for npm & PyPI
3
+ * Copyright (C) 2026 DNSZLSK
4
+ *
5
+ * This program is free software: you can redistribute it and/or modify
6
+ * it under the terms of the GNU Affero General Public License version 3,
7
+ * as published by the Free Software Foundation.
8
+ *
9
+ * This program is distributed in the hope that it will be useful,
10
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ * GNU Affero General Public License for more details.
13
+ *
14
+ * You should have received a copy of the GNU Affero General Public License
15
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+ *
17
+ * SPDX-License-Identifier: AGPL-3.0-only
18
+ */
19
+
20
+ // Single source of truth for Shannon entropy — consumed by the entropy scanner
21
+ // (src/scanner/entropy.js) and the AST split-entropy detector
22
+ // (src/scanner/ast-detectors/helpers.js). Leaf module: keep it dependency-free.
23
+
24
+ /**
25
+ * Calculate Shannon entropy of a string.
26
+ * @param {string} str - Input string
27
+ * @returns {number} Entropy in bits (0-8)
28
+ */
29
+ function calculateShannonEntropy(str) {
30
+ if (!str || str.length === 0) return 0;
31
+ const freq = Object.create(null);
32
+ for (let i = 0; i < str.length; i++) {
33
+ const ch = str[i];
34
+ freq[ch] = (freq[ch] || 0) + 1;
35
+ }
36
+ let entropy = 0;
37
+ const len = str.length;
38
+ for (const ch in freq) {
39
+ const p = freq[ch] / len;
40
+ entropy -= p * Math.log2(p);
41
+ }
42
+ return entropy;
43
+ }
44
+
45
+ module.exports = { calculateShannonEntropy };
@@ -49,8 +49,11 @@ let _token = null;
49
49
  let _source = null;
50
50
 
51
51
  function _fromNpmrc() {
52
- const files = [
53
- process.env.MUADDIB_NPMRC,
52
+ // An explicit MUADDIB_NPMRC wins EXCLUSIVELY — no fallback to cwd/HOME/VPS
53
+ // paths. Explicit config falling through to ambient files was surprising in
54
+ // prod and made tests non-hermetic (a token in the runner's cwd .npmrc
55
+ // leaked into "no token" assertions).
56
+ const files = process.env.MUADDIB_NPMRC ? [process.env.MUADDIB_NPMRC] : [
54
57
  path.join(process.cwd(), '.npmrc'),
55
58
  process.env.HOME ? path.join(process.env.HOME, '.npmrc') : null,
56
59
  '/home/muaddib/.npmrc',
package/src/utils.js CHANGED
@@ -25,7 +25,14 @@ const { getMaxFileSize, clearASTCache } = require('./shared/constants.js');
25
25
  * Directories excluded from scanning.
26
26
  * Skips dependency/VCS/cache dirs and bundled output (dist/build/out).
27
27
  * Bundled output is minified, huge, and produces FPs without security value.
28
- * Obfuscation scanner uses its own OBF_EXCLUDED_DIRS to intentionally scan these.
28
+ *
29
+ * Per-scanner deviations are intentional (do NOT unify blindly — each is FPR-gated):
30
+ * - obfuscation.js OBF_EXCLUDED_DIRS: also excludes dist/build/out but does NOT exclude
31
+ * node_modules (it deliberately scans dependencies for obfuscation).
32
+ * - binary-source.js / shell.js / anti-scanner-injection.js: deliberately DO scan dist/
33
+ * (bundled payloads — jscrambler dist/intro.js gap, revshells in bundles, Hades directive
34
+ * atop a bundle).
35
+ * - paranoid.js: opt-in maximal coverage, scans dist/build/out and .json/.sh.
29
36
  */
30
37
  const EXCLUDED_DIRS = ['node_modules', '.git', '.muaddib-cache', 'dist', 'build', 'out', 'output'];
31
38
 
@@ -251,6 +258,12 @@ function clearFileListCache() {
251
258
  _overflowFiles = [];
252
259
  }
253
260
 
261
+ // Test-only introspection: the _FILE_CONTENT_CACHE_MAX bound (Bounded resources
262
+ // contract) is unobservable from outside, so its eviction was untestable.
263
+ function _getFileContentCacheSize() {
264
+ return _fileContentCache.size;
265
+ }
266
+
254
267
  function wasFilesCapped() {
255
268
  return _filesCapped;
256
269
  }
@@ -475,6 +488,7 @@ module.exports = {
475
488
  findFiles,
476
489
  findJsFiles,
477
490
  clearFileListCache,
491
+ _getFileContentCacheSize,
478
492
  wasFilesCapped,
479
493
  getOverflowFiles,
480
494
  escapeHtml,
package/stats.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "_comment": "GENERATED by scripts/collect-stats.js — do not edit by hand. Run `npm run docs:stats`.",
3
- "version": "2.11.174",
3
+ "version": "2.11.176",
4
4
  "scanners": 22,
5
5
  "rulesTotal": 277,
6
6
  "rulesCore": 272,
@@ -12,5 +12,5 @@
12
12
  "moduleGraph": 9,
13
13
  "pythonAstDetectors": 6,
14
14
  "testFiles": 152,
15
- "tests": 4561
15
+ "tests": 4534
16
16
  }