muaddib-scanner 2.11.140 → 2.11.142

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.11.140",
3
+ "version": "2.11.142",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "target": "node_modules",
3
- "timestamp": "2026-07-01T20:47:08.448Z",
3
+ "timestamp": "2026-07-01T21:20:16.682Z",
4
4
  "threats": [
5
5
  {
6
6
  "type": "string_mutation_obfuscation",
@@ -38,6 +38,50 @@ const {
38
38
  ETHEREUM_PACKAGES,
39
39
  SOLANA_PACKAGES
40
40
  } = require('./constants.js');
41
+
42
+ // -----------------------------------------------------------------------------
43
+ // SECURITY (E3 — audit 2026-07): ReDoS mitigation for static `.replace()`-chain resolution.
44
+ // The resolver (Blue Team v8, below) statically re-applies `.replace(/regex/, str)` calls
45
+ // taken from the SCANNED package to reconstruct obfuscated strings. On the CLI path this
46
+ // runs synchronously on the main thread, so executing an attacker-authored regex that
47
+ // catastrophically backtracks would wedge it. Note: the subject-length cap does NOT bound
48
+ // this — exponential backtracking blows up on ~30 chars regardless of cap.
49
+ //
50
+ // Defense in depth (this is layer 1 of 2):
51
+ // Layer 1 (here): a BLOCKLIST of common catastrophic shapes + length caps, so the
52
+ // frequent cases fast-fail without running the regex. It is NOT exhaustive — a crafted
53
+ // pattern (e.g. nested groups like `((a+)x)+`) can slip past it.
54
+ // Layer 2 (hard bound): the monitor runs every scan in a Worker with a 45s
55
+ // STATIC_SCAN_TIMEOUT_MS -> worker.terminate() (src/monitor/queue.js). V8 TerminateExecution
56
+ // interrupts even a running regex — verified empirically that terminate() returns
57
+ // mid-backtrack on BOTH the dev box (Node 24 / V8 13.6: ~6ms) and the production VPS
58
+ // (Node 20.20.0: ~8ms). So a layer-1 bypass is bounded to <=45s and the worker is killed.
59
+ // The CLI runs the pipeline inline (src/index.js) and has no such backstop — one-shot,
60
+ // Ctrl-C-able, lower severity.
61
+ //
62
+ // A refused pattern only leaves the chain "unresolved" (still flagged by the depth>=4
63
+ // fallback) — never a false negative by crash.
64
+ // -----------------------------------------------------------------------------
65
+ const MAX_REPLACE_REGEX_LEN = 200; // reject absurdly long attacker regex sources
66
+ const MAX_REPLACE_SUBJECT_LEN = 4000; // caps LINEAR/polynomial blowup on huge subjects (not exponential)
67
+
68
+ // BLOCKLIST (non-exhaustive) of common catastrophic-backtracking regex shapes. Allows any
69
+ // pattern that does not match a known-bad shape — the hard guarantee is layer 2 above, not
70
+ // this heuristic. Its OWN patterns are linear (negated char classes only), so it introduces
71
+ // no ReDoS of its own.
72
+ function isCatastrophicReplaceRegex(src) {
73
+ if (typeof src !== 'string') return true;
74
+ // Quantifier applied to a group whose body already contains a quantifier:
75
+ // (a+)+ (a*)* (a+)* ([ab]+)+ (\w+)*
76
+ if (/\([^()]*[+*][^()]*\)\s*[*+]/.test(src)) return true;
77
+ // Quantified group whose body contains a {n,m} repetition: (\d{1,3})+
78
+ if (/\([^()]*\{\d+(?:,\d*)?\}[^()]*\)\s*[*+]/.test(src)) return true;
79
+ // Quantified group containing alternation (possible overlap): (a|a)* (ab|a)+
80
+ if (/\([^()]*\|[^()]*\)\s*[*+]/.test(src)) return true;
81
+ // Unbounded {n,} repetition applied to a group: (...)+{2,}
82
+ if (/\)[*+?]?\{\d+,\}/.test(src)) return true;
83
+ return false;
84
+ }
41
85
  const {
42
86
  extractStringValue,
43
87
  calculateShannonEntropy,
@@ -1919,14 +1963,20 @@ function handleCallExpression(node, ctx) {
1919
1963
  let replacement = null;
1920
1964
  // Extract regex or string pattern
1921
1965
  if (args[0].type === 'Literal' && args[0].regex) {
1922
- try { pattern = new RegExp(args[0].regex.pattern, args[0].regex.flags); } catch { /* skip */ }
1966
+ // SECURITY (E3): the pattern is attacker-controlled. Refuse catastrophic
1967
+ // shapes / over-long sources so a ReDoS can't wedge this synchronous scanner.
1968
+ if (args[0].regex.pattern.length <= MAX_REPLACE_REGEX_LEN &&
1969
+ !isCatastrophicReplaceRegex(args[0].regex.pattern)) {
1970
+ try { pattern = new RegExp(args[0].regex.pattern, args[0].regex.flags); } catch { /* skip */ }
1971
+ }
1923
1972
  } else if (args[0].type === 'Literal' && typeof args[0].value === 'string') {
1924
1973
  pattern = args[0].value;
1925
1974
  }
1926
1975
  if (args[1].type === 'Literal' && typeof args[1].value === 'string') {
1927
1976
  replacement = args[1].value;
1928
1977
  }
1929
- if (pattern !== null && replacement !== null) {
1978
+ // SECURITY (E3): also refuse to run any pattern against an oversized subject.
1979
+ if (pattern !== null && replacement !== null && resolved.length <= MAX_REPLACE_SUBJECT_LEN) {
1930
1980
  resolved = resolved.replace(pattern, replacement);
1931
1981
  } else {
1932
1982
  resolved = null;
@@ -2101,4 +2151,4 @@ function handleCallExpression(node, ctx) {
2101
2151
 
2102
2152
  // _shadowClassifyMcpWrite is shared with handle-post-walk.js (the Wave-4
2103
2153
  // keyword-co-occurrence emitter — the third mcp_config_injection site).
2104
- module.exports = { handleCallExpression, _shadowClassifyMcpWrite };
2154
+ module.exports = { handleCallExpression, _shadowClassifyMcpWrite, _isCatastrophicReplaceRegex: isCatastrophicReplaceRegex };
@@ -85,6 +85,10 @@ const NPM_PACKAGE_REGEX = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*
85
85
  // Download/extraction limits
86
86
  const MAX_TARBALL_SIZE = 50 * 1024 * 1024; // 50MB
87
87
  const DOWNLOAD_TIMEOUT = 30_000; // 30 seconds
88
+ // Decompressed-size cap for tar.gz extraction (M4). PARTIAL tar-bomb mitigation only —
89
+ // bypassable by a bomb forged to a multiple of 4GB + (<1GB), because the gzip ISIZE trailer
90
+ // this is checked against is mod 2^32. A hard cap needs streaming decompression (separate tech debt).
91
+ const MAX_EXTRACTED_SIZE = 1024 * 1024 * 1024; // 1GB
88
92
 
89
93
  // Shared scanner constants
90
94
  const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB — skip files larger than this to avoid memory issues
@@ -146,4 +150,4 @@ function clearASTCache() {
146
150
  _astCache.clear();
147
151
  }
148
152
 
149
- module.exports = { REHABILITATED_PACKAGES, NPM_PACKAGE_REGEX, MAX_TARBALL_SIZE, DOWNLOAD_TIMEOUT, MAX_FILE_SIZE, ACORN_OPTIONS, safeParse, clearASTCache, getMaxFileSize, setMaxFileSize, resetMaxFileSize };
153
+ module.exports = { REHABILITATED_PACKAGES, NPM_PACKAGE_REGEX, MAX_TARBALL_SIZE, DOWNLOAD_TIMEOUT, MAX_EXTRACTED_SIZE, MAX_FILE_SIZE, ACORN_OPTIONS, safeParse, clearASTCache, getMaxFileSize, setMaxFileSize, resetMaxFileSize };
@@ -3,7 +3,7 @@ const fs = require('fs');
3
3
  const path = require('path');
4
4
  const { execFileSync } = require('child_process');
5
5
  const AdmZip = require('adm-zip');
6
- const { MAX_TARBALL_SIZE, DOWNLOAD_TIMEOUT } = require('./constants.js');
6
+ const { MAX_TARBALL_SIZE, DOWNLOAD_TIMEOUT, MAX_EXTRACTED_SIZE } = require('./constants.js');
7
7
  const { registryAuthHeaders } = require('./registry-auth.js');
8
8
 
9
9
  // Allowed redirect domains for tarball downloads (SSRF protection)
@@ -241,6 +241,34 @@ function detectArchiveFormat(archivePath) {
241
241
  return 'unknown';
242
242
  }
243
243
 
244
+ // SECURITY (M4 — audit 2026-07): PARTIAL tar-bomb mitigation.
245
+ // Reads the gzip ISIZE trailer (last 4 bytes = uncompressed size mod 2^32) in O(1) memory,
246
+ // synchronously, so _extractTarGzImpl can reject a declared-oversize archive BEFORE spawning
247
+ // `tar` (rejecting pre-spawn matters: worker.terminate() can orphan a running tar child that
248
+ // keeps writing to disk). LIMITATION — this is a PARTIAL mitigation, NOT a hard cap: because
249
+ // ISIZE is mod 2^32, a bomb forged to a multiple of 4GB + (<1GB) wraps under the cap and evades
250
+ // this hint; that narrow residual falls back to the 60s tar timeout. A complete cap requires
251
+ // streaming decompression, which would make extraction async and ripple to all sync callers —
252
+ // tracked as separate tech debt, deliberately not done here.
253
+ function readGzipUncompressedHint(tgzPath) {
254
+ let fd;
255
+ try {
256
+ fd = fs.openSync(tgzPath, 'r');
257
+ const { size } = fs.fstatSync(fd);
258
+ if (size < 18) return null; // too small to be a valid gzip member
259
+ const magic = Buffer.alloc(2);
260
+ fs.readSync(fd, magic, 0, 2, 0);
261
+ if (magic[0] !== 0x1f || magic[1] !== 0x8b) return null; // not gzip — let tar error naturally
262
+ const isize = Buffer.alloc(4);
263
+ fs.readSync(fd, isize, 0, 4, size - 4);
264
+ return isize.readUInt32LE(0);
265
+ } catch {
266
+ return null; // unreadable hint — never block extraction on a hint failure
267
+ } finally {
268
+ if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* ignore */ } }
269
+ }
270
+ }
271
+
244
272
  /**
245
273
  * Extract a tar.gz tarball with the system `tar` binary. Used for npm
246
274
  * tarballs and PyPI sdists. Internal implementation — call extractArchive
@@ -248,6 +276,14 @@ function detectArchiveFormat(archivePath) {
248
276
  * scanner/temporal-ast-diff.js callsite.
249
277
  */
250
278
  function _extractTarGzImpl(tgzPath, destDir) {
279
+ // SECURITY (M4): reject a declared-oversize gzip before `tar` ever runs. PARTIAL mitigation
280
+ // (bypassable by a 4GB-multiple + <1GB forged bomb) — see readGzipUncompressedHint.
281
+ const uncompressedHint = readGzipUncompressedHint(tgzPath);
282
+ if (uncompressedHint !== null && uncompressedHint > MAX_EXTRACTED_SIZE) {
283
+ throw new Error(
284
+ `Tar extract refused: declared uncompressed size ${uncompressedHint} exceeds ${MAX_EXTRACTED_SIZE} (possible decompression bomb)`
285
+ );
286
+ }
251
287
  // Use cwd + relative paths so C: never appears in tar arguments
252
288
  // (GNU tar treats C: as remote host, bsdtar doesn't support --force-local)
253
289
  const tgzDir = path.dirname(path.resolve(tgzPath));
@@ -418,6 +454,7 @@ module.exports = {
418
454
  extractArchive,
419
455
  extractArchiveOffThread,
420
456
  detectArchiveFormat,
457
+ _readGzipUncompressedHint: readGzipUncompressedHint,
421
458
  sanitizePackageName,
422
459
  isAllowedDownloadRedirect,
423
460
  normalizeHostname,