muaddib-scanner 2.11.140 → 2.11.141
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
|
@@ -38,6 +38,51 @@ 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 on Node 24 / V8 13.6: terminate returns ~6ms
|
|
57
|
+
// mid-backtrack) — TO CONFIRM on the VPS Node version: interruptible regexps have been in
|
|
58
|
+
// V8 since ~Node 16, but this was only tested on Node 24. On that basis a layer-1 bypass
|
|
59
|
+
// is bounded to <=45s and the worker is killed.
|
|
60
|
+
// The CLI runs the pipeline inline (src/index.js) and has no such backstop — one-shot,
|
|
61
|
+
// Ctrl-C-able, lower severity.
|
|
62
|
+
//
|
|
63
|
+
// A refused pattern only leaves the chain "unresolved" (still flagged by the depth>=4
|
|
64
|
+
// fallback) — never a false negative by crash.
|
|
65
|
+
// -----------------------------------------------------------------------------
|
|
66
|
+
const MAX_REPLACE_REGEX_LEN = 200; // reject absurdly long attacker regex sources
|
|
67
|
+
const MAX_REPLACE_SUBJECT_LEN = 4000; // caps LINEAR/polynomial blowup on huge subjects (not exponential)
|
|
68
|
+
|
|
69
|
+
// BLOCKLIST (non-exhaustive) of common catastrophic-backtracking regex shapes. Allows any
|
|
70
|
+
// pattern that does not match a known-bad shape — the hard guarantee is layer 2 above, not
|
|
71
|
+
// this heuristic. Its OWN patterns are linear (negated char classes only), so it introduces
|
|
72
|
+
// no ReDoS of its own.
|
|
73
|
+
function isCatastrophicReplaceRegex(src) {
|
|
74
|
+
if (typeof src !== 'string') return true;
|
|
75
|
+
// Quantifier applied to a group whose body already contains a quantifier:
|
|
76
|
+
// (a+)+ (a*)* (a+)* ([ab]+)+ (\w+)*
|
|
77
|
+
if (/\([^()]*[+*][^()]*\)\s*[*+]/.test(src)) return true;
|
|
78
|
+
// Quantified group whose body contains a {n,m} repetition: (\d{1,3})+
|
|
79
|
+
if (/\([^()]*\{\d+(?:,\d*)?\}[^()]*\)\s*[*+]/.test(src)) return true;
|
|
80
|
+
// Quantified group containing alternation (possible overlap): (a|a)* (ab|a)+
|
|
81
|
+
if (/\([^()]*\|[^()]*\)\s*[*+]/.test(src)) return true;
|
|
82
|
+
// Unbounded {n,} repetition applied to a group: (...)+{2,}
|
|
83
|
+
if (/\)[*+?]?\{\d+,\}/.test(src)) return true;
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
41
86
|
const {
|
|
42
87
|
extractStringValue,
|
|
43
88
|
calculateShannonEntropy,
|
|
@@ -1919,14 +1964,20 @@ function handleCallExpression(node, ctx) {
|
|
|
1919
1964
|
let replacement = null;
|
|
1920
1965
|
// Extract regex or string pattern
|
|
1921
1966
|
if (args[0].type === 'Literal' && args[0].regex) {
|
|
1922
|
-
|
|
1967
|
+
// SECURITY (E3): the pattern is attacker-controlled. Refuse catastrophic
|
|
1968
|
+
// shapes / over-long sources so a ReDoS can't wedge this synchronous scanner.
|
|
1969
|
+
if (args[0].regex.pattern.length <= MAX_REPLACE_REGEX_LEN &&
|
|
1970
|
+
!isCatastrophicReplaceRegex(args[0].regex.pattern)) {
|
|
1971
|
+
try { pattern = new RegExp(args[0].regex.pattern, args[0].regex.flags); } catch { /* skip */ }
|
|
1972
|
+
}
|
|
1923
1973
|
} else if (args[0].type === 'Literal' && typeof args[0].value === 'string') {
|
|
1924
1974
|
pattern = args[0].value;
|
|
1925
1975
|
}
|
|
1926
1976
|
if (args[1].type === 'Literal' && typeof args[1].value === 'string') {
|
|
1927
1977
|
replacement = args[1].value;
|
|
1928
1978
|
}
|
|
1929
|
-
|
|
1979
|
+
// SECURITY (E3): also refuse to run any pattern against an oversized subject.
|
|
1980
|
+
if (pattern !== null && replacement !== null && resolved.length <= MAX_REPLACE_SUBJECT_LEN) {
|
|
1930
1981
|
resolved = resolved.replace(pattern, replacement);
|
|
1931
1982
|
} else {
|
|
1932
1983
|
resolved = null;
|
|
@@ -2101,4 +2152,4 @@ function handleCallExpression(node, ctx) {
|
|
|
2101
2152
|
|
|
2102
2153
|
// _shadowClassifyMcpWrite is shared with handle-post-walk.js (the Wave-4
|
|
2103
2154
|
// keyword-co-occurrence emitter — the third mcp_config_injection site).
|
|
2104
|
-
module.exports = { handleCallExpression, _shadowClassifyMcpWrite };
|
|
2155
|
+
module.exports = { handleCallExpression, _shadowClassifyMcpWrite, _isCatastrophicReplaceRegex: isCatastrophicReplaceRegex };
|
package/src/shared/constants.js
CHANGED
|
@@ -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 };
|
package/src/shared/download.js
CHANGED
|
@@ -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,
|