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 +2 -2
- package/package.json +4 -3
- package/{self-scan-v2.11.174.json → self-scan-v2.11.176.json} +1 -1
- package/src/commands/hooks-init.js +30 -17
- package/src/commands/safe-install.js +25 -14
- package/src/ioc/bootstrap.js +20 -0
- package/src/ioc/scraper.js +29 -11
- package/src/ml/llm-detective.js +4 -1
- package/src/monitor/queue.js +94 -37
- package/src/monitor/webhook.js +21 -10
- package/src/pipeline/executor.js +45 -41
- package/src/runtime/serve.js +32 -4
- package/src/scanner/ai-config.js +4 -0
- package/src/scanner/anti-scanner-injection.js +4 -0
- package/src/scanner/ast-detectors/helpers.js +3 -19
- package/src/scanner/ast.js +1 -6
- package/src/scanner/dependencies.js +5 -0
- package/src/scanner/entropy.js +13 -25
- package/src/scanner/paranoid.js +4 -0
- package/src/scanner/python.js +18 -7
- package/src/scoring/delta-multiplier.js +4 -1
- package/src/shared/entropy.js +45 -0
- package/src/shared/registry-auth.js +5 -2
- package/src/utils.js +15 -1
- package/stats.json +2 -2
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
|
|
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-->
|
|
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.
|
|
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.
|
|
58
|
-
"js-yaml": "5.1
|
|
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": {
|
|
@@ -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
|
-
|
|
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
|
-
//
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
149
|
-
|
|
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
|
|
155
|
-
|
|
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 };
|
package/src/ioc/bootstrap.js
CHANGED
|
@@ -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); }
|
package/src/ioc/scraper.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1315
|
-
fs.
|
|
1316
|
-
// Write HMAC before rename for consistency with updater.js
|
|
1317
|
-
|
|
1318
|
-
|
|
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');
|
package/src/ml/llm-detective.js
CHANGED
|
@@ -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) ──
|
package/src/monitor/queue.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
2247
|
-
|
|
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
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
}
|
|
2272
|
-
|
|
2273
|
-
|
|
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,
|
package/src/monitor/webhook.js
CHANGED
|
@@ -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,
|
package/src/pipeline/executor.js
CHANGED
|
@@ -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 };
|
package/src/runtime/serve.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 });
|
package/src/scanner/ai-config.js
CHANGED
|
@@ -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
|
-
|
|
68
|
-
|
|
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.
|
package/src/scanner/ast.js
CHANGED
|
@@ -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
|
};
|
package/src/scanner/entropy.js
CHANGED
|
@@ -78,31 +78,9 @@ function isWhitelistedString(str, filePath) {
|
|
|
78
78
|
return false;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
package/src/scanner/paranoid.js
CHANGED
|
@@ -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()];
|
package/src/scanner/python.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
61
|
-
//
|
|
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
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
*
|
|
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.
|
|
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":
|
|
15
|
+
"tests": 4534
|
|
16
16
|
}
|