muaddib-scanner 2.11.160 → 2.11.162
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 +4 -3
- package/{self-scan-v2.11.160.json → self-scan-v2.11.162.json} +1 -1
- package/src/ioc/scraper.js +6 -1
- package/src/ml/jsonl-writer.js +60 -0
- package/src/monitor/daemon.js +59 -45
- package/src/monitor/event-loop-monitor.js +18 -0
- package/stats.json +1 -1
- package/zizmor.yml +18 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "muaddib-scanner",
|
|
3
|
-
"version": "2.11.
|
|
3
|
+
"version": "2.11.162",
|
|
4
4
|
"description": "Supply-chain threat detection & response for npm & PyPI/Python",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"docs:stats": "node scripts/collect-stats.js && node scripts/sync-stats.js",
|
|
18
18
|
"docs:stats:tests": "node scripts/collect-stats.js --with-tests && node scripts/sync-stats.js",
|
|
19
19
|
"docs:stats:check": "node scripts/collect-stats.js --check && node scripts/sync-stats.js --check",
|
|
20
|
+
"version": "npm run docs:stats && git diff --name-only | xargs -r git add",
|
|
20
21
|
"compress-iocs": "node -e \"const fs=require('fs');const zlib=require('zlib');zlib.gzip(fs.readFileSync('src/ioc/data/iocs.json'),(e,b)=>{if(e)throw e;fs.writeFileSync('iocs.json.gz',b);console.log('Compressed: '+b.length+' bytes')})\"",
|
|
21
22
|
"prepublishOnly": "node -e \"if(!process.env.CI){console.error('ERR: Publish via CI workflow (tag v* push). Local publishes are disabled.');process.exit(1)}\""
|
|
22
23
|
},
|
|
@@ -53,13 +54,13 @@
|
|
|
53
54
|
"@inquirer/prompts": "8.5.2",
|
|
54
55
|
"acorn": "8.17.0",
|
|
55
56
|
"acorn-walk": "8.3.5",
|
|
56
|
-
"adm-zip": "0.5.
|
|
57
|
+
"adm-zip": "0.5.18",
|
|
57
58
|
"js-yaml": "5.1.0",
|
|
58
59
|
"web-tree-sitter": "^0.26.9"
|
|
59
60
|
},
|
|
60
61
|
"devDependencies": {
|
|
61
62
|
"@eslint/js": "10.0.1",
|
|
62
|
-
"eslint": "10.
|
|
63
|
+
"eslint": "10.6.0",
|
|
63
64
|
"eslint-plugin-security": "^4.0.0",
|
|
64
65
|
"globals": "17.7.0"
|
|
65
66
|
}
|
package/src/ioc/scraper.js
CHANGED
|
@@ -201,7 +201,12 @@ function parseCSV(csvContent, hasHeader = true) {
|
|
|
201
201
|
}
|
|
202
202
|
|
|
203
203
|
const MAX_REDIRECTS = 5;
|
|
204
|
-
|
|
204
|
+
// Env-tunable (idiom: parseInt(env) || default). Default 512MB — the OSV npm all.zip
|
|
205
|
+
// crossed the old 200MB cap on 2026-07 (measured 200.04MB), which silently dropped the
|
|
206
|
+
// entire OSV npm dump AND emptied knownIds (→ OSSF re-fetched all ~31k entries one-by-one).
|
|
207
|
+
// Bump gives headroom for growth; lower it via MUADDIB_MAX_RESPONSE_MB on RAM-constrained
|
|
208
|
+
// hosts (the whole response is buffered in memory — streaming-to-disk is the long-term fix).
|
|
209
|
+
const MAX_RESPONSE_SIZE = (parseInt(process.env.MUADDIB_MAX_RESPONSE_MB, 10) || 512) * 1024 * 1024;
|
|
205
210
|
const MAX_ENTRY_UNCOMPRESSED = 50 * 1024 * 1024; // 50MB per zip entry
|
|
206
211
|
const MAX_TOTAL_UNCOMPRESSED = 500 * 1024 * 1024; // 500MB total budget per zip
|
|
207
212
|
|
package/src/ml/jsonl-writer.js
CHANGED
|
@@ -37,6 +37,20 @@ const DEFAULT_TRAINING_FILE = path.join(__dirname, '..', '..', 'data', 'ml-train
|
|
|
37
37
|
let TRAINING_FILE = DEFAULT_TRAINING_FILE;
|
|
38
38
|
const MAX_JSONL_SIZE = 100 * 1024 * 1024; // 100MB rotation threshold
|
|
39
39
|
|
|
40
|
+
// Benign down-sampling: 'clean' records are ~88% of the stream and near-identical
|
|
41
|
+
// (the same popular packages re-scanned on every backlog replay / monitor restart).
|
|
42
|
+
// They balloon the file without adding ML signal — negatives are re-derived from
|
|
43
|
+
// GHSA/GT at retrain time, not from these self-labels. Keep 1 in CLEAN_SAMPLE_RATE
|
|
44
|
+
// clean records; every non-clean outcome (suspect/confirmed/unconfirmed/…) is always
|
|
45
|
+
// written. Gating here (the single write chokepoint) rather than in the monitor's
|
|
46
|
+
// recordTrainingSample keeps the separate scan-ledger (coverage/throughput) complete.
|
|
47
|
+
const CLEAN_SAMPLE_RATE = 100; // 1% of 'clean' records are persisted
|
|
48
|
+
let _cleanCounter = 0;
|
|
49
|
+
|
|
50
|
+
// Rotation retention: keep only the MAX_ROTATED_SNAPSHOTS most recent rotated files
|
|
51
|
+
// on disk; prune the rest at each rotation (see pruneOldSnapshots).
|
|
52
|
+
const MAX_ROTATED_SNAPSHOTS = 7;
|
|
53
|
+
|
|
40
54
|
// In-memory line counter. null = needs recompute (cold boot, file rewrite, or
|
|
41
55
|
// path swap). Maintained incrementally by appendRecord and invalidated by
|
|
42
56
|
// relabelRecords and setTrainingFile. Prior to this cache, getStats read the
|
|
@@ -50,6 +64,7 @@ let _cachedLineCount = null;
|
|
|
50
64
|
function setTrainingFile(filePath) {
|
|
51
65
|
TRAINING_FILE = filePath;
|
|
52
66
|
_cachedLineCount = null; // different file → recompute on next getStats
|
|
67
|
+
_cleanCounter = 0; // reset benign-sampling counter for test isolation
|
|
53
68
|
}
|
|
54
69
|
|
|
55
70
|
/**
|
|
@@ -58,6 +73,7 @@ function setTrainingFile(filePath) {
|
|
|
58
73
|
function resetTrainingFile() {
|
|
59
74
|
TRAINING_FILE = DEFAULT_TRAINING_FILE;
|
|
60
75
|
_cachedLineCount = null;
|
|
76
|
+
_cleanCounter = 0; // reset benign-sampling counter for test isolation
|
|
61
77
|
}
|
|
62
78
|
|
|
63
79
|
/**
|
|
@@ -66,6 +82,12 @@ function resetTrainingFile() {
|
|
|
66
82
|
*/
|
|
67
83
|
function appendRecord(record) {
|
|
68
84
|
try {
|
|
85
|
+
// Down-sample benign records (see CLEAN_SAMPLE_RATE). Every non-clean outcome
|
|
86
|
+
// is always persisted; only 1 in CLEAN_SAMPLE_RATE 'clean' records is kept.
|
|
87
|
+
if (record && record.label === 'clean' && (_cleanCounter++ % CLEAN_SAMPLE_RATE) !== 0) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
69
91
|
const dir = path.dirname(TRAINING_FILE);
|
|
70
92
|
if (!fs.existsSync(dir)) {
|
|
71
93
|
fs.mkdirSync(dir, { recursive: true });
|
|
@@ -103,11 +125,49 @@ function maybeRotate() {
|
|
|
103
125
|
fs.renameSync(TRAINING_FILE, rotatedName);
|
|
104
126
|
_cachedLineCount = 0; // fresh file starts empty
|
|
105
127
|
console.log(`[ML] Rotated training file → ${path.basename(rotatedName)} (${(stat.size / 1024 / 1024).toFixed(1)}MB)`);
|
|
128
|
+
pruneOldSnapshots();
|
|
106
129
|
} catch (err) {
|
|
107
130
|
console.error(`[ML] Rotation failed: ${err.message}`);
|
|
108
131
|
}
|
|
109
132
|
}
|
|
110
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Delete rotated snapshots beyond the MAX_ROTATED_SNAPSHOTS most recent.
|
|
136
|
+
* Matches ONLY timestamped rotation files (`<base>-<ISO>.jsonl`, e.g.
|
|
137
|
+
* ml-training-2026-07-08T08-28-49-263Z.jsonl) so the live file and sibling data
|
|
138
|
+
* files (ml-training-datadog.jsonl, ml-training-relabeled.jsonl,
|
|
139
|
+
* ml-corpus-consolidated.jsonl, …) are never touched.
|
|
140
|
+
*/
|
|
141
|
+
function pruneOldSnapshots() {
|
|
142
|
+
try {
|
|
143
|
+
const dir = path.dirname(TRAINING_FILE);
|
|
144
|
+
const base = path.basename(TRAINING_FILE, '.jsonl'); // e.g. 'ml-training'
|
|
145
|
+
const escBase = base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
146
|
+
const rotatedRe = new RegExp(`^${escBase}-\\d{4}-\\d{2}-\\d{2}T[0-9-]+Z\\.jsonl$`);
|
|
147
|
+
|
|
148
|
+
const rotated = fs.readdirSync(dir)
|
|
149
|
+
.filter(f => rotatedRe.test(f))
|
|
150
|
+
.map(f => {
|
|
151
|
+
const full = path.join(dir, f);
|
|
152
|
+
try { return { full, mtime: fs.statSync(full).mtimeMs }; }
|
|
153
|
+
catch { return null; }
|
|
154
|
+
})
|
|
155
|
+
.filter(Boolean)
|
|
156
|
+
.sort((a, b) => b.mtime - a.mtime); // most recent first
|
|
157
|
+
|
|
158
|
+
for (const { full } of rotated.slice(MAX_ROTATED_SNAPSHOTS)) {
|
|
159
|
+
try {
|
|
160
|
+
fs.unlinkSync(full);
|
|
161
|
+
console.log(`[ML] Pruned old snapshot → ${path.basename(full)}`);
|
|
162
|
+
} catch (e) {
|
|
163
|
+
console.warn(`[ML] Prune failed for ${path.basename(full)}: ${e.message}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
} catch (err) {
|
|
167
|
+
console.error(`[ML] Snapshot prune failed: ${err.message}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
111
171
|
/**
|
|
112
172
|
* Read all records from the current JSONL file.
|
|
113
173
|
* Useful for offline analysis and model training.
|
package/src/monitor/daemon.js
CHANGED
|
@@ -32,7 +32,7 @@ const { poll, getPollBackoffMs, SOFT_BACKPRESSURE_THRESHOLD } = require('./inges
|
|
|
32
32
|
const { ensureWorkers, drainWorkers, getTargetConcurrency, setTargetConcurrency, getActiveWorkers, terminateAllWorkers, getInFlightItems, computeInterruptDisposition } = require('./queue.js');
|
|
33
33
|
const { computeTarget, ADJUST_INTERVAL_MS, BASE_CONCURRENCY } = require('./adaptive-concurrency.js');
|
|
34
34
|
const { startHealthcheck } = require('./healthcheck.js');
|
|
35
|
-
const { startLagSampler } = require('./event-loop-monitor.js');
|
|
35
|
+
const { startLagSampler, runInstrumented } = require('./event-loop-monitor.js');
|
|
36
36
|
const { startDeferredWorker, stopDeferredWorker, persistDeferredQueue, restoreDeferredQueue, clearDeferredQueue } = require('./deferred-sandbox.js');
|
|
37
37
|
const { evictFromScanQueueBulk, enqueueScan } = require('./scan-queue.js');
|
|
38
38
|
const { isSpillEnabled, shouldDrain, drainBacklog, getBacklogSize } = require('./spill.js');
|
|
@@ -373,23 +373,28 @@ function cleanupOrphanTmpDirs() {
|
|
|
373
373
|
}
|
|
374
374
|
|
|
375
375
|
function cleanupOrphanContainers() {
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
376
|
+
// Instrumented (2026-07-09): `docker ps` + a sequential `docker rm -f` per orphan
|
|
377
|
+
// (10s timeout EACH, count unbounded) is a synchronous main-thread op. Name it so a
|
|
378
|
+
// concurrent lag stall attributes here instead of op:null (event-loop-monitor.js).
|
|
379
|
+
return runInstrumented('maint:orphan-containers', null, () => {
|
|
380
|
+
try {
|
|
381
|
+
// List running containers with the sandbox name prefix (npm-audit-*)
|
|
382
|
+
const output = execFileSync('docker', ['ps', '-q', '--filter', 'name=npm-audit-'], {
|
|
383
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
384
|
+
timeout: 10000
|
|
385
|
+
}).toString().trim();
|
|
386
|
+
if (!output) return;
|
|
387
|
+
const ids = output.split(/\s+/).filter(Boolean);
|
|
388
|
+
for (const id of ids) {
|
|
389
|
+
try {
|
|
390
|
+
execFileSync('docker', ['rm', '-f', id], { stdio: 'pipe', timeout: 10000 });
|
|
391
|
+
} catch {}
|
|
392
|
+
}
|
|
393
|
+
console.log(`[MONITOR] Cleaned up ${ids.length} orphan sandbox container(s)`);
|
|
394
|
+
} catch {
|
|
395
|
+
// Docker not available or command failed — skip silently
|
|
388
396
|
}
|
|
389
|
-
|
|
390
|
-
} catch {
|
|
391
|
-
// Docker not available or command failed — skip silently
|
|
392
|
-
}
|
|
397
|
+
});
|
|
393
398
|
}
|
|
394
399
|
|
|
395
400
|
/**
|
|
@@ -401,28 +406,34 @@ function cleanupOrphanContainers() {
|
|
|
401
406
|
*/
|
|
402
407
|
function cleanupRunscOrphans(maxAgeMs = 3600_000) {
|
|
403
408
|
const runscDir = process.env.MUADDIB_GVISOR_LOG_DIR || '/tmp/runsc';
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
409
|
+
// Instrumented (2026-07-09): readdir + per-entry statSync + recursive rmSync over
|
|
410
|
+
// /tmp/runsc is a synchronous main-thread op that blocks the poll loop (RSS breaker,
|
|
411
|
+
// governor feed) for as long as it runs — the prime suspect for the op:null
|
|
412
|
+
// multi-minute stalls in loop-stalls.jsonl. Name it (also covers the boot call).
|
|
413
|
+
return runInstrumented('maint:runsc-cleanup', { dir: runscDir }, () => {
|
|
414
|
+
try {
|
|
415
|
+
if (!fs.existsSync(runscDir)) return 0;
|
|
416
|
+
const entries = fs.readdirSync(runscDir);
|
|
417
|
+
const now = Date.now();
|
|
418
|
+
let cleaned = 0;
|
|
419
|
+
for (const entry of entries) {
|
|
420
|
+
const fullPath = path.join(runscDir, entry);
|
|
421
|
+
try {
|
|
422
|
+
const stat = fs.statSync(fullPath);
|
|
423
|
+
if (now - stat.mtimeMs > maxAgeMs) {
|
|
424
|
+
fs.rmSync(fullPath, { recursive: true, force: true });
|
|
425
|
+
cleaned++;
|
|
426
|
+
}
|
|
427
|
+
} catch { /* skip unreadable entries */ }
|
|
428
|
+
}
|
|
429
|
+
if (cleaned > 0) {
|
|
430
|
+
console.log(`[MONITOR] Cleaned up ${cleaned} orphan runsc dir(s) in ${runscDir}`);
|
|
431
|
+
}
|
|
432
|
+
return cleaned;
|
|
433
|
+
} catch {
|
|
434
|
+
return 0;
|
|
421
435
|
}
|
|
422
|
-
|
|
423
|
-
} catch {
|
|
424
|
-
return 0;
|
|
425
|
-
}
|
|
436
|
+
});
|
|
426
437
|
}
|
|
427
438
|
|
|
428
439
|
/**
|
|
@@ -445,9 +456,9 @@ function checkDiskSpace() {
|
|
|
445
456
|
|
|
446
457
|
// Top consumers in /tmp/
|
|
447
458
|
try {
|
|
448
|
-
const tmpDu = execFileSync('du', ['-sh', '--max-depth=1', '/tmp/'], {
|
|
459
|
+
const tmpDu = runInstrumented('maint:disk-du-tmp', null, () => execFileSync('du', ['-sh', '--max-depth=1', '/tmp/'], {
|
|
449
460
|
encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 10000
|
|
450
|
-
});
|
|
461
|
+
}));
|
|
451
462
|
const lines = tmpDu.trim().split('\n')
|
|
452
463
|
.map(l => { const m = l.match(/^([\d.]+[KMGT]?)\s+(.+)/); return m ? { size: m[1], path: m[2] } : null; })
|
|
453
464
|
.filter(Boolean)
|
|
@@ -462,9 +473,9 @@ function checkDiskSpace() {
|
|
|
462
473
|
// Top consumers in data/
|
|
463
474
|
const dataDir = path.join(__dirname, '..', '..', 'data');
|
|
464
475
|
try {
|
|
465
|
-
const dataDu = execFileSync('du', ['-sh', '--max-depth=1', dataDir], {
|
|
476
|
+
const dataDu = runInstrumented('maint:disk-du-data', null, () => execFileSync('du', ['-sh', '--max-depth=1', dataDir], {
|
|
466
477
|
encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 10000
|
|
467
|
-
});
|
|
478
|
+
}));
|
|
468
479
|
const lines = dataDu.trim().split('\n')
|
|
469
480
|
.map(l => { const m = l.match(/^([\d.]+[KMGT]?)\s+(.+)/); return m ? { size: m[1], path: m[2] } : null; })
|
|
470
481
|
.filter(Boolean)
|
|
@@ -1328,10 +1339,13 @@ async function startMonitor(options, stats, dailyAlerts, recentlyScanned, downlo
|
|
|
1328
1339
|
|
|
1329
1340
|
// Hourly stats report + cache purge + runsc cleanup + memory pruning
|
|
1330
1341
|
if (Date.now() - stats.lastReportTime >= 3600_000) {
|
|
1331
|
-
|
|
1332
|
-
|
|
1342
|
+
// Hourly maintenance runs synchronously on the poll loop; instrument each step so
|
|
1343
|
+
// a concurrent lag stall names the culprit (was op:null). cleanupRunscOrphans
|
|
1344
|
+
// self-wraps in its own body (that also covers the boot call at startup).
|
|
1345
|
+
runInstrumented('maint:report-stats', null, () => reportStats(stats));
|
|
1346
|
+
runInstrumented('maint:tarball-purge', null, () => purgeTarballCache());
|
|
1333
1347
|
cleanupRunscOrphans();
|
|
1334
|
-
pruneMemoryCaches(recentlyScanned, downloadsCache, alertedPackageRules);
|
|
1348
|
+
runInstrumented('maint:prune-caches', null, () => pruneMemoryCaches(recentlyScanned, downloadsCache, alertedPackageRules));
|
|
1335
1349
|
}
|
|
1336
1350
|
|
|
1337
1351
|
// Daily webhook report at 08:00 Paris time
|
|
@@ -84,6 +84,23 @@ function endOp(token) {
|
|
|
84
84
|
_current = null;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Run a synchronous, potentially-blocking main-thread op UNDER a breadcrumb so a
|
|
89
|
+
* concurrent lag-sampler stall attributes itself to THIS op instead of reporting
|
|
90
|
+
* `op:null`. Mirrors the sandbox `dockerSync` wrapper (sandbox/index.js). Passes
|
|
91
|
+
* fn's return value through; the breadcrumb is cleared in `finally` even when fn
|
|
92
|
+
* throws (and the error re-thrown). Single-slot model (see file header) — the
|
|
93
|
+
* wrapped fn must NOT itself call beginOp/endOp/runInstrumented (no nesting).
|
|
94
|
+
*/
|
|
95
|
+
function runInstrumented(label, meta, fn) {
|
|
96
|
+
const token = beginOp(label, meta);
|
|
97
|
+
try {
|
|
98
|
+
return fn();
|
|
99
|
+
} finally {
|
|
100
|
+
endOp(token);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
87
104
|
/**
|
|
88
105
|
* The op whose lifetime overlaps [windowStartMs, windowEndMs] — i.e. the op on
|
|
89
106
|
* the thread during the blocked window. Prefers the still-running op, else the
|
|
@@ -191,6 +208,7 @@ function _reset(clockFn) {
|
|
|
191
208
|
module.exports = {
|
|
192
209
|
beginOp,
|
|
193
210
|
endOp,
|
|
211
|
+
runInstrumented,
|
|
194
212
|
opOverlapping,
|
|
195
213
|
configure,
|
|
196
214
|
observeTick,
|
package/stats.json
CHANGED
package/zizmor.yml
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# zizmor configuration — https://docs.zizmor.sh/configuration/
|
|
2
|
+
#
|
|
3
|
+
# Suppress a finding ONLY when it is a confirmed false positive, and keep the
|
|
4
|
+
# rationale on the same line. Schema:
|
|
5
|
+
#
|
|
6
|
+
# rules:
|
|
7
|
+
# <audit-id>: # e.g. template-injection, artipacked, cache-poisoning
|
|
8
|
+
# ignore:
|
|
9
|
+
# - <workflow>.yml # ignore the whole file
|
|
10
|
+
# - <workflow>.yml:LINE # ignore one line
|
|
11
|
+
# - <workflow>.yml:LINE:COL# ignore one exact finding
|
|
12
|
+
#
|
|
13
|
+
# Policy for this repo: prefer FIXING findings over ignoring them. As of the first
|
|
14
|
+
# run every finding is real (template-injection in publish.yml, artipacked on the
|
|
15
|
+
# checkout steps, cache-poisoning, adhoc-packages), so nothing is suppressed here.
|
|
16
|
+
# Add an entry below only after confirming a genuine false positive.
|
|
17
|
+
|
|
18
|
+
rules: {}
|