muaddib-scanner 2.10.43 → 2.10.44

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 CHANGED
@@ -30,7 +30,7 @@
30
30
 
31
31
  npm and PyPI supply-chain attacks are exploding. Shai-Hulud compromised 25K+ repos in 2025. Existing tools detect threats but don't help you respond.
32
32
 
33
- MUAD'DIB combines **14 parallel scanners** (195 detection rules), a **deobfuscation engine**, **inter-module dataflow analysis**, **compound scoring**, **ML classifiers** (XGBoost), and Docker sandbox to detect known threats and suspicious behavioral patterns in npm and PyPI packages.
33
+ MUAD'DIB combines **14 parallel scanners** (200 detection rules), a **deobfuscation engine**, **inter-module dataflow analysis**, **compound scoring**, **ML classifiers** (XGBoost), and gVisor/Docker sandbox to detect known threats and suspicious behavioral patterns in npm and PyPI packages.
34
34
 
35
35
  ---
36
36
 
@@ -195,7 +195,7 @@ muaddib replay # Ground truth validation (46/49 TPR)
195
195
  | GitHub Actions | Shai-Hulud backdoor detection |
196
196
  | Hash Scanner | Known malicious file hashes |
197
197
 
198
- ### 195 detection rules
198
+ ### 200 detection rules
199
199
 
200
200
  All rules are mapped to MITRE ATT&CK techniques. See [SECURITY.md](SECURITY.md#detection-rules-v21021) for the complete rules reference.
201
201
 
@@ -271,7 +271,7 @@ With pre-commit framework:
271
271
  ```yaml
272
272
  repos:
273
273
  - repo: https://github.com/DNSZLSK/muad-dib
274
- rev: v2.10.31
274
+ rev: v2.10.43
275
275
  hooks:
276
276
  - id: muaddib-scan
277
277
  ```
@@ -288,7 +288,7 @@ repos:
288
288
  | **FPR** (Benign random) | **7.5%** (15/200) | 200 random npm packages, stratified sampling |
289
289
  | **ADR** (Adversarial + Holdout) | **94.0%** (101/107) | 67 adversarial + 40 holdout (107 available on disk), global threshold=20 |
290
290
 
291
- **2868 tests** across 62 files. **195 rules** (190 RULES + 5 PARANOID).
291
+ **3034 tests** across 65 files. **200 rules** (195 RULES + 5 PARANOID).
292
292
 
293
293
  > **Methodology caveats:**
294
294
  > - TPR measured on 49 Node.js attack samples (3 browser-only excluded from 51 total)
@@ -329,7 +329,7 @@ npm test
329
329
 
330
330
  ### Testing
331
331
 
332
- - **2868 tests** across 62 modular test files
332
+ - **3034 tests** across 65 modular test files
333
333
  - **56 fuzz tests** - Malformed inputs, ReDoS, unicode, binary
334
334
  - **Datadog 17K benchmark** - 14,587 confirmed malware samples (in-scope)
335
335
  - **Ground truth validation** - 51 real-world attacks (93.9% TPR)
@@ -351,7 +351,7 @@ npm test
351
351
  - [Evaluation Methodology](docs/EVALUATION_METHODOLOGY.md) - Experimental protocol, holdout scores
352
352
  - [Threat Model](docs/threat-model.md) - What MUAD'DIB detects and doesn't detect
353
353
  - [Adversarial Evaluation](ADVERSARIAL.md) - Red team samples and ADR results
354
- - [Security Policy](SECURITY.md) - Detection rules reference (195 rules)
354
+ - [Security Policy](SECURITY.md) - Detection rules reference (200 rules)
355
355
  - [Security Audit](docs/SECURITY_AUDIT.md) - Bypass validation report
356
356
  - [FP Analysis](docs/EVALUATION.md) - Historical false positive analysis
357
357
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.10.43",
3
+ "version": "2.10.44",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -4,7 +4,7 @@ const path = require('path');
4
4
  const os = require('os');
5
5
  const { isDockerAvailable, SANDBOX_CONCURRENCY_MAX } = require('../sandbox/index.js');
6
6
  const { setVerboseMode, isSandboxEnabled, isCanaryEnabled, isLlmDetectiveEnabled, getLlmDetectiveMode } = require('./classify.js');
7
- const { loadState, saveState, loadDailyStats, saveDailyStats, purgeTarballCache, getParisHour } = require('./state.js');
7
+ const { loadState, saveState, loadDailyStats, saveDailyStats, purgeTarballCache, getParisHour, atomicWriteFileSync } = require('./state.js');
8
8
  const { isTemporalEnabled, isTemporalAstEnabled, isTemporalPublishEnabled, isTemporalMaintainerEnabled } = require('./temporal.js');
9
9
  const { pendingGrouped, flushScopeGroup, sendDailyReport, DAILY_REPORT_HOUR } = require('./webhook.js');
10
10
  const { poll } = require('./ingestion.js');
@@ -14,11 +14,88 @@ const { startHealthcheck } = require('./healthcheck.js');
14
14
  const POLL_INTERVAL = 60_000;
15
15
  const PROCESS_LOOP_INTERVAL = 2_000; // Queue check interval when empty
16
16
  const QUEUE_WARNING_THRESHOLD = 5_000; // Warn if queue depth exceeds this
17
+ const QUEUE_PERSIST_INTERVAL = 60_000; // Persist queue to disk every 60s
18
+ const QUEUE_STATE_FILE = path.join(__dirname, '..', '..', 'data', 'queue-state.json');
19
+ const QUEUE_STATE_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24h expiry
20
+ const MAX_QUEUE_PERSIST_SIZE = 100_000; // Don't persist if queue > 100K items
17
21
 
18
22
  function sleep(ms) {
19
23
  return new Promise((resolve) => setTimeout(resolve, ms));
20
24
  }
21
25
 
26
+ /**
27
+ * Persist scanQueue to disk so it survives restarts.
28
+ * Uses atomicWriteFileSync (write-to-tmp + rename) for crash safety.
29
+ * Skips if queue is empty or exceeds MAX_QUEUE_PERSIST_SIZE.
30
+ */
31
+ function persistQueue(scanQueue, state) {
32
+ try {
33
+ if (scanQueue.length === 0) {
34
+ // Empty queue — remove stale file if it exists
35
+ try { fs.unlinkSync(QUEUE_STATE_FILE); } catch {}
36
+ return;
37
+ }
38
+ if (scanQueue.length > MAX_QUEUE_PERSIST_SIZE) {
39
+ console.log(`[MONITOR] WARNING: queue too large to persist (${scanQueue.length} > ${MAX_QUEUE_PERSIST_SIZE})`);
40
+ return;
41
+ }
42
+ const payload = JSON.stringify({
43
+ savedAt: new Date().toISOString(),
44
+ lastSeq: state.npmLastSeq || null,
45
+ count: scanQueue.length,
46
+ items: scanQueue
47
+ });
48
+ atomicWriteFileSync(QUEUE_STATE_FILE, payload);
49
+ } catch (err) {
50
+ console.error('[MONITOR] Failed to persist queue:', err.message);
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Restore scanQueue from disk on boot. Items are appended to the (empty) scanQueue.
56
+ * File is deleted after successful restore to prevent double-restore.
57
+ * Skips if file is missing, corrupt, or older than 24h.
58
+ */
59
+ function restoreQueue(scanQueue) {
60
+ try {
61
+ if (!fs.existsSync(QUEUE_STATE_FILE)) return 0;
62
+ const raw = fs.readFileSync(QUEUE_STATE_FILE, 'utf8');
63
+ const data = JSON.parse(raw);
64
+
65
+ // Validate structure
66
+ if (!data || !Array.isArray(data.items) || !data.savedAt) {
67
+ console.log('[MONITOR] Queue state file invalid — ignoring');
68
+ try { fs.unlinkSync(QUEUE_STATE_FILE); } catch {}
69
+ return 0;
70
+ }
71
+
72
+ // Check age — discard if > 24h
73
+ const ageMs = Date.now() - new Date(data.savedAt).getTime();
74
+ if (ageMs > QUEUE_STATE_MAX_AGE_MS) {
75
+ console.log(`[MONITOR] Queue state expired (${Math.round(ageMs / 3600000)}h old) — ignoring`);
76
+ try { fs.unlinkSync(QUEUE_STATE_FILE); } catch {}
77
+ return 0;
78
+ }
79
+
80
+ // Restore items
81
+ const count = data.items.length;
82
+ if (count === 0) {
83
+ try { fs.unlinkSync(QUEUE_STATE_FILE); } catch {}
84
+ return 0;
85
+ }
86
+ scanQueue.push(...data.items);
87
+ console.log(`[MONITOR] Restored ${count} packages from queue state (saved at ${data.savedAt})`);
88
+
89
+ // Delete after successful restore
90
+ try { fs.unlinkSync(QUEUE_STATE_FILE); } catch {}
91
+ return count;
92
+ } catch (err) {
93
+ console.log(`[MONITOR] WARNING: could not restore queue state: ${err.message}`);
94
+ try { fs.unlinkSync(QUEUE_STATE_FILE); } catch {}
95
+ return 0;
96
+ }
97
+ }
98
+
22
99
  function cleanupOrphanTmpDirs() {
23
100
  const tmpBase = path.join(os.tmpdir(), 'muaddib-monitor');
24
101
  try {
@@ -176,7 +253,14 @@ async function startMonitor(options, stats, dailyAlerts, recentlyScanned, downlo
176
253
  console.log(`[MONITOR] Polling every ${POLL_INTERVAL / 1000}s (decoupled from processing). Ctrl+C to stop.\n`);
177
254
 
178
255
  let running = true;
179
- let pollIntervalHandle = null; // Decoupled poll timer — set after initial poll
256
+ let pollIntervalHandle = null; // Decoupled poll timer — set after initial poll
257
+ let queuePersistHandle = null; // Queue persistence timer
258
+
259
+ // Restore queue from previous run (if file exists and is < 24h old)
260
+ const restoredCount = restoreQueue(scanQueue);
261
+ if (restoredCount > 0) {
262
+ console.log(`[MONITOR] ${restoredCount} packages pre-loaded from previous session`);
263
+ }
180
264
 
181
265
  // Graceful shutdown handler (shared by SIGINT and SIGTERM)
182
266
  // Daily report is NEVER sent on shutdown — it only fires at 08:00 Paris time.
@@ -188,6 +272,12 @@ async function startMonitor(options, stats, dailyAlerts, recentlyScanned, downlo
188
272
  clearInterval(pollIntervalHandle);
189
273
  pollIntervalHandle = null;
190
274
  }
275
+ if (queuePersistHandle) {
276
+ clearInterval(queuePersistHandle);
277
+ queuePersistHandle = null;
278
+ }
279
+ // Persist remaining queue items so they survive the restart
280
+ persistQueue(scanQueue, state);
191
281
  healthcheck.stop();
192
282
  // Flush all pending scope groups before exit
193
283
  for (const [scope, group] of pendingGrouped) {
@@ -232,6 +322,15 @@ async function startMonitor(options, stats, dailyAlerts, recentlyScanned, downlo
232
322
  }
233
323
  }, POLL_INTERVAL);
234
324
 
325
+ // ─── Queue persistence ───
326
+ // Snapshot queue to disk every 60s so items survive restarts/crashes.
327
+ // Without this, the decoupled poll advances the CouchDB seq but queued
328
+ // items are lost on restart — they won't be re-polled.
329
+ queuePersistHandle = setInterval(() => {
330
+ if (!running) return;
331
+ persistQueue(scanQueue, state);
332
+ }, QUEUE_PERSIST_INTERVAL);
333
+
235
334
  // ─── Continuous processing loop ───
236
335
  // Consumes scanQueue independently of polling. Workers inside processQueue
237
336
  // check scanQueue.length > 0 after each item, so items added by a concurrent
@@ -264,7 +363,13 @@ module.exports = {
264
363
  reportStats,
265
364
  isDailyReportDue,
266
365
  sleep,
366
+ persistQueue,
367
+ restoreQueue,
267
368
  POLL_INTERVAL,
268
369
  PROCESS_LOOP_INTERVAL,
269
- QUEUE_WARNING_THRESHOLD
370
+ QUEUE_WARNING_THRESHOLD,
371
+ QUEUE_PERSIST_INTERVAL,
372
+ QUEUE_STATE_FILE,
373
+ QUEUE_STATE_MAX_AGE_MS,
374
+ MAX_QUEUE_PERSIST_SIZE
270
375
  };