muaddib-scanner 2.11.159 → 2.11.161

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.159",
3
+ "version": "2.11.161",
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.17",
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.5.0",
63
+ "eslint": "10.6.0",
63
64
  "eslint-plugin-security": "^4.0.0",
64
65
  "globals": "17.7.0"
65
66
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "target": "node_modules",
3
- "timestamp": "2026-07-07T13:26:22.703Z",
3
+ "timestamp": "2026-07-09T09:23:51.701Z",
4
4
  "threats": [
5
5
  {
6
6
  "type": "string_mutation_obfuscation",
@@ -468,26 +468,6 @@
468
468
  "playbook": "CRITIQUE: Assignation a Module._resolveFilename/_compile/_extensions. Detournement du systeme de modules Node.js. Tous les require() sont interceptes. Supprimer le package immediatement. Auditer tous les modules charges apres installation.",
469
469
  "points": 25
470
470
  },
471
- {
472
- "type": "dynamic_require",
473
- "severity": "HIGH",
474
- "message": "Dynamic require() with computed argument (possible decode obfuscation).",
475
- "file": "ajv/scripts/bundle.js",
476
- "count": 2,
477
- "reductions": [],
478
- "originalSeverity": "HIGH",
479
- "confidenceTier": "medium",
480
- "rule_id": "MUADDIB-AST-006",
481
- "rule_name": "Dynamic Require with Concatenation",
482
- "confidence": "high",
483
- "domain": "malware",
484
- "references": [
485
- "https://attack.mitre.org/techniques/T1027/"
486
- ],
487
- "mitre": "T1027",
488
- "playbook": "require() avec concatenation detecte. Technique d'obfuscation pour masquer le module charge. Analyser les variables concatenees.",
489
- "points": 10
490
- },
491
471
  {
492
472
  "type": "env_access",
493
473
  "severity": "MEDIUM",
@@ -2090,9 +2070,9 @@
2090
2070
  ],
2091
2071
  "python": null,
2092
2072
  "summary": {
2093
- "total": 92,
2073
+ "total": 91,
2094
2074
  "critical": 7,
2095
- "high": 10,
2075
+ "high": 9,
2096
2076
  "medium": 44,
2097
2077
  "low": 31,
2098
2078
  "riskScore": 35,
@@ -2106,7 +2086,6 @@
2106
2086
  "web-tree-sitter/web-tree-sitter.cjs": 42,
2107
2087
  "web-tree-sitter/web-tree-sitter.js": 42,
2108
2088
  "ajv/lib/ajv.js": 25,
2109
- "ajv/scripts/bundle.js": 10,
2110
2089
  "debug/src/node.js": 3,
2111
2090
  "eslint/bin/eslint.js": 10,
2112
2091
  "fast-json-stable-stringify/benchmark/index.js": 10,
@@ -2129,7 +2108,6 @@
2129
2108
  "web-tree-sitter/web-tree-sitter.cjs": 165707,
2130
2109
  "web-tree-sitter/web-tree-sitter.js": 153666,
2131
2110
  "ajv/lib/ajv.js": 15837,
2132
- "ajv/scripts/bundle.js": 1795,
2133
2111
  "debug/src/node.js": 4728,
2134
2112
  "eslint/bin/eslint.js": 6028,
2135
2113
  "fast-json-stable-stringify/benchmark/index.js": 740,
@@ -2202,12 +2180,6 @@
2202
2180
  "points": 10,
2203
2181
  "reason": "Binary file reference (.png/.jpg/.wasm/etc.) + eval() in same file — possible steganographic payload execution."
2204
2182
  },
2205
- {
2206
- "rule": "MUADDIB-AST-006",
2207
- "type": "dynamic_require",
2208
- "points": 10,
2209
- "reason": "Dynamic require() with computed argument (possible decode obfuscation)."
2210
- },
2211
2183
  {
2212
2184
  "rule": "MUADDIB-AST-006",
2213
2185
  "type": "dynamic_require",
@@ -2698,7 +2670,7 @@
2698
2670
  "tierCounts": {
2699
2671
  "verified": 0,
2700
2672
  "high": 0,
2701
- "medium": 18,
2673
+ "medium": 17,
2702
2674
  "low": 74
2703
2675
  },
2704
2676
  "perceivedFlagged": 0
@@ -201,7 +201,12 @@ function parseCSV(csvContent, hasHeader = true) {
201
201
  }
202
202
 
203
203
  const MAX_REDIRECTS = 5;
204
- const MAX_RESPONSE_SIZE = 200 * 1024 * 1024; // 200MB
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
 
@@ -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.
@@ -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
- try {
377
- // List running containers with the sandbox name prefix (npm-audit-*)
378
- const output = execFileSync('docker', ['ps', '-q', '--filter', 'name=npm-audit-'], {
379
- stdio: ['pipe', 'pipe', 'pipe'],
380
- timeout: 10000
381
- }).toString().trim();
382
- if (!output) return;
383
- const ids = output.split(/\s+/).filter(Boolean);
384
- for (const id of ids) {
385
- try {
386
- execFileSync('docker', ['rm', '-f', id], { stdio: 'pipe', timeout: 10000 });
387
- } catch {}
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
- console.log(`[MONITOR] Cleaned up ${ids.length} orphan sandbox container(s)`);
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
- try {
405
- if (!fs.existsSync(runscDir)) return 0;
406
- const entries = fs.readdirSync(runscDir);
407
- const now = Date.now();
408
- let cleaned = 0;
409
- for (const entry of entries) {
410
- const fullPath = path.join(runscDir, entry);
411
- try {
412
- const stat = fs.statSync(fullPath);
413
- if (now - stat.mtimeMs > maxAgeMs) {
414
- fs.rmSync(fullPath, { recursive: true, force: true });
415
- cleaned++;
416
- }
417
- } catch { /* skip unreadable entries */ }
418
- }
419
- if (cleaned > 0) {
420
- console.log(`[MONITOR] Cleaned up ${cleaned} orphan runsc dir(s) in ${runscDir}`);
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
- return cleaned;
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
- reportStats(stats);
1332
- purgeTarballCache();
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,
@@ -219,9 +219,18 @@ function handleCallExpression(node, ctx) {
219
219
  file: ctx.relFile
220
220
  });
221
221
  } else if (arg.type === 'CallExpression') {
222
- const argCallName = getCallName(arg);
223
- // Skip safe patterns: require(path.join(...)), require(path.resolve(...))
224
- if (argCallName !== 'path.join' && argCallName !== 'path.resolve') {
222
+ // Skip safe path-builder patterns: require(path.join(...)), require(path.resolve(...)).
223
+ // NB: getCallName(arg) returns the bare method name ('join'/'resolve'), NOT 'path.join', so
224
+ // the previous `argCallName !== 'path.join'` guard never matched a dead check that let the
225
+ // standard require(path.join(...)) idiom raise a HIGH dynamic_require FP. Inspect the
226
+ // MemberExpression object directly instead. Real obfuscation still fires: require(atob(...)),
227
+ // require(decoder.decode(...)), require(Buffer.from(...).toString()) all have object!=='path'.
228
+ const callee = arg.callee;
229
+ const isPathBuilder =
230
+ callee.type === 'MemberExpression' &&
231
+ callee.object && callee.object.name === 'path' &&
232
+ callee.property && (callee.property.name === 'join' || callee.property.name === 'resolve');
233
+ if (!isPathBuilder) {
225
234
  const hasDecode = containsDecodePattern(arg);
226
235
  ctx.threats.push({
227
236
  type: 'dynamic_require',
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.159",
3
+ "version": "2.11.161",
4
4
  "scanners": 21,
5
5
  "rulesTotal": 275,
6
6
  "rulesCore": 270,