muaddib-scanner 2.11.79 → 2.11.81

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/bin/muaddib.js CHANGED
@@ -21,6 +21,15 @@ if (process.argv[2] === 'evaluate') {
21
21
  }
22
22
  }
23
23
 
24
+ // Load /opt/muaddib/.env (project root) if present, BEFORE anything reads env, so
25
+ // one-shot CLI invocations (notably `report --now` / `report --resend`) see
26
+ // MUADDIB_WEBHOOK_URL even when not launched by the systemd unit that sets
27
+ // EnvironmentFile. Real environment variables are never overwritten. Optional file.
28
+ try {
29
+ const { loadDotEnv } = require('../src/env-loader.js');
30
+ loadDotEnv(require('path').join(__dirname, '..', '.env'));
31
+ } catch { /* non-fatal: .env is optional */ }
32
+
24
33
  const { run } = require('../src/index.js');
25
34
  const { updateIOCs } = require('../src/ioc/updater.js');
26
35
  const { watch } = require('../src/watch.js');
@@ -721,6 +730,26 @@ if (command === 'version' || command === '--version' || command === '-v') {
721
730
  console.error('[ERROR]', err.message);
722
731
  process.exit(1);
723
732
  });
733
+ } else if (options.includes('--resend')) {
734
+ // Redeliver an already-persisted daily report (default: the latest) to the
735
+ // webhook — for when the original send failed (e.g. a DNS blip). No stats
736
+ // reconstruction; the exact persisted embed is re-sent.
737
+ const { resendReport } = require('../src/monitor.js');
738
+ const dateArg = options.find(o => !o.startsWith('--')) || null;
739
+ resendReport(dateArg).then(result => {
740
+ const color = process.stdout.isTTY;
741
+ if (result.sent) {
742
+ const check = color ? '\x1b[32m✓\x1b[0m' : '✓';
743
+ console.log(`\n ${check} ${result.message}\n`);
744
+ } else {
745
+ const warn = color ? '\x1b[33m!\x1b[0m' : '!';
746
+ console.log(`\n ${warn} ${result.message}\n`);
747
+ }
748
+ process.exit(result.sent ? 0 : 1);
749
+ }).catch(err => {
750
+ console.error('[ERROR]', err.message);
751
+ process.exit(1);
752
+ });
724
753
  } else if (options.includes('--status')) {
725
754
  const { getReportStatus } = require('../src/monitor.js');
726
755
  const status = getReportStatus();
@@ -731,7 +760,7 @@ if (command === 'version' || command === '--version' || command === '-v') {
731
760
  console.log('');
732
761
  process.exit(0);
733
762
  } else {
734
- console.log('Usage: muaddib report --now | --status');
763
+ console.log('Usage: muaddib report --now | --resend [YYYY-MM-DD] | --status');
735
764
  process.exit(1);
736
765
  }
737
766
  } else if (command === 'relabel') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.11.79",
3
+ "version": "2.11.81",
4
4
  "description": "Supply-chain threat detection & response for npm & PyPI/Python",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "target": "node_modules",
3
- "timestamp": "2026-06-10T12:04:24.243Z",
3
+ "timestamp": "2026-06-10T12:42:10.126Z",
4
4
  "threats": [
5
5
  {
6
6
  "type": "string_mutation_obfuscation",
@@ -0,0 +1,57 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+
5
+ // Minimal .env loader — zero dependency (the project forbids new runtime deps, so
6
+ // no dotenv). Parses KEY=VALUE lines, ignores blanks / # comments / `export `
7
+ // prefixes, strips one pair of surrounding quotes, and by default NEVER overwrites
8
+ // a variable already present in process.env (the real environment / systemd
9
+ // EnvironmentFile always wins over the on-disk file).
10
+
11
+ function parseDotEnv(content) {
12
+ const out = {};
13
+ for (const rawLine of String(content).split(/\r?\n/)) {
14
+ const line = rawLine.trim();
15
+ if (!line || line.startsWith('#')) continue;
16
+ const eq = line.indexOf('=');
17
+ if (eq <= 0) continue;
18
+ let key = line.slice(0, eq).trim();
19
+ if (key.startsWith('export ')) key = key.slice(7).trim();
20
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; // skip malformed keys
21
+ let val = line.slice(eq + 1).trim();
22
+ if ((val.startsWith('"') && val.endsWith('"')) ||
23
+ (val.startsWith("'") && val.endsWith("'"))) {
24
+ val = val.slice(1, -1);
25
+ }
26
+ out[key] = val;
27
+ }
28
+ return out;
29
+ }
30
+
31
+ /**
32
+ * Load a .env file into process.env. Missing / unreadable file is a silent no-op
33
+ * (the file is optional). Returns { loaded, keys } where keys are the names that
34
+ * were actually applied.
35
+ * @param {string} filePath
36
+ * @param {{override?: boolean}} [opts] override=true replaces existing vars (default false)
37
+ */
38
+ function loadDotEnv(filePath, opts = {}) {
39
+ const override = opts.override === true;
40
+ let content;
41
+ try {
42
+ content = fs.readFileSync(filePath, 'utf8');
43
+ } catch {
44
+ return { loaded: false, keys: [] };
45
+ }
46
+ const parsed = parseDotEnv(content);
47
+ const applied = [];
48
+ for (const [k, v] of Object.entries(parsed)) {
49
+ if (override || process.env[k] === undefined) {
50
+ process.env[k] = v;
51
+ applied.push(k);
52
+ }
53
+ }
54
+ return { loaded: true, keys: applied };
55
+ }
56
+
57
+ module.exports = { parseDotEnv, loadDotEnv };
@@ -70,20 +70,15 @@ async function sendWebhook(url, results, options = {}) {
70
70
  const urlObj = new URL(url);
71
71
  let resolvedAddress;
72
72
  try {
73
- const [ipv4Addresses, ipv6Addresses] = await Promise.all([
74
- dns.promises.resolve4(urlObj.hostname).catch(() => []),
75
- dns.promises.resolve6(urlObj.hostname).catch(() => [])
76
- ]);
77
- const allAddresses = [...ipv4Addresses, ...ipv6Addresses];
78
- if (allAddresses.length === 0) {
79
- throw new Error(`Webhook blocked: no DNS records found for ${urlObj.hostname}`);
80
- }
73
+ // Retries transient resolver failures (the 2026-06-10 DNS blip). The SSRF
74
+ // private-IP check below runs on the resolved addresses and is NOT retried.
75
+ const { ipv4, all: allAddresses } = await resolveHostWithRetry(urlObj.hostname);
81
76
  for (const address of allAddresses) {
82
77
  if (PRIVATE_IP_PATTERNS.some(pattern => pattern.test(address))) {
83
78
  throw new Error(`Webhook blocked: hostname ${urlObj.hostname} resolves to private IP ${address}`);
84
79
  }
85
80
  }
86
- resolvedAddress = ipv4Addresses[0] || null;
81
+ resolvedAddress = ipv4[0] || null;
87
82
  } catch (e) {
88
83
  if (e.message.startsWith('Webhook blocked')) throw e;
89
84
  throw new Error(`Webhook blocked: DNS resolution failed for ${urlObj.hostname}`, { cause: e });
@@ -365,6 +360,17 @@ const MAX_RESPONSE_SIZE = 1024 * 1024; // 1MB
365
360
  const MAX_RETRIES = 3;
366
361
  const BACKOFF_BASE_MS = 1000; // 1s, 2s, 4s exponential backoff
367
362
  const RETRYABLE_STATUS_CODES = new Set([429, 500, 502, 503, 504]);
363
+ // Transient socket/DNS errors worth retrying — a flaky resolver or reset connection
364
+ // is exactly the 2026-06-10 "no DNS records for discord.com" failure mode. NEVER
365
+ // includes SSRF blocks (private IP / disallowed domain) — those are hard rejects.
366
+ const RETRYABLE_ERROR_CODES = new Set([
367
+ 'ECONNRESET', 'ETIMEDOUT', 'ENETUNREACH', 'EHOSTUNREACH',
368
+ 'EAI_AGAIN', 'ENOTFOUND', 'ECONNREFUSED', 'EPIPE'
369
+ ]);
370
+ // DNS resolution retry (separate from HTTP retry — a name that won't resolve never
371
+ // reaches the request). 3 retries → 4 attempts, backoff 0.5s/1s/2s ≈ 3.5s worst case.
372
+ const DNS_MAX_RETRIES = 3;
373
+ const DNS_BACKOFF_BASE_MS = 500;
368
374
 
369
375
  // Rate limiting: max 1 webhook per second (Discord limit is 30/min)
370
376
  const RATE_LIMIT_MS = 1000;
@@ -383,6 +389,36 @@ function sleepMs(ms) {
383
389
  return new Promise(resolve => setTimeout(resolve, ms));
384
390
  }
385
391
 
392
+ /**
393
+ * Resolve a hostname to IPv4+IPv6, retrying on transient DNS failures (empty
394
+ * answer / EAI_AGAIN). Returns { ipv4, ipv6, all } on success. Throws after the
395
+ * retry budget is exhausted. Does NOT perform the SSRF private-IP check — the
396
+ * caller does that on the returned addresses so a hard block is never retried.
397
+ */
398
+ async function resolveHostWithRetry(hostname, opts = {}) {
399
+ const maxRetries = opts.maxRetries != null ? opts.maxRetries : DNS_MAX_RETRIES;
400
+ const backoffBase = opts.backoffMs != null ? opts.backoffMs : DNS_BACKOFF_BASE_MS;
401
+ let lastErr;
402
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
403
+ let ipv4 = [], ipv6 = [];
404
+ try {
405
+ [ipv4, ipv6] = await Promise.all([
406
+ dns.promises.resolve4(hostname).catch(() => []),
407
+ dns.promises.resolve6(hostname).catch(() => [])
408
+ ]);
409
+ } catch (e) { lastErr = e; }
410
+ const all = [...ipv4, ...ipv6];
411
+ if (all.length > 0) return { ipv4, ipv6, all };
412
+ lastErr = new Error(`Webhook blocked: no DNS records found for ${hostname}`);
413
+ if (attempt < maxRetries) {
414
+ const backoff = backoffBase * Math.pow(2, attempt);
415
+ console.error(`[WEBHOOK] DNS for ${hostname} returned no records, retrying in ${backoff}ms (attempt ${attempt + 1}/${maxRetries})...`);
416
+ await sleepMs(backoff);
417
+ }
418
+ }
419
+ throw lastErr;
420
+ }
421
+
386
422
  function sendOnce(url, payload, resolvedAddress) {
387
423
  return new Promise((resolve, reject) => {
388
424
  const urlObj = new URL(url);
@@ -449,7 +485,13 @@ async function send(url, payload, resolvedAddress) {
449
485
  return result;
450
486
  } catch (err) {
451
487
  lastError = err;
452
- const isRetryable = err.statusCode && RETRYABLE_STATUS_CODES.has(err.statusCode);
488
+ // Retry on retryable HTTP status, transient socket errors, OR a request
489
+ // timeout (no statusCode/code, identified by message). A 1MB-overflow or
490
+ // any other non-transient error falls through and throws immediately.
491
+ const isRetryable =
492
+ (err.statusCode && RETRYABLE_STATUS_CODES.has(err.statusCode)) ||
493
+ (err.code && RETRYABLE_ERROR_CODES.has(err.code)) ||
494
+ /timeout/i.test(err.message || '');
453
495
  if (!isRetryable || attempt >= MAX_RETRIES) {
454
496
  throw err;
455
497
  }
@@ -461,7 +503,8 @@ async function send(url, payload, resolvedAddress) {
461
503
  } else {
462
504
  backoffMs = BACKOFF_BASE_MS * Math.pow(2, attempt);
463
505
  }
464
- console.error(`[WEBHOOK] HTTP ${err.statusCode}, retrying in ${backoffMs}ms (attempt ${attempt + 1}/${MAX_RETRIES})...`);
506
+ const reason = err.statusCode ? `HTTP ${err.statusCode}` : (err.code || err.message);
507
+ console.error(`[WEBHOOK] ${reason}, retrying in ${backoffMs}ms (attempt ${attempt + 1}/${MAX_RETRIES})...`);
465
508
  await sleepMs(backoffMs);
466
509
  }
467
510
  }
@@ -470,5 +513,7 @@ async function send(url, payload, resolvedAddress) {
470
513
 
471
514
  module.exports = {
472
515
  sendWebhook, validateWebhookUrl, formatDiscord, formatSlack, formatGeneric,
473
- MAX_RETRIES, BACKOFF_BASE_MS, RETRYABLE_STATUS_CODES, RATE_LIMIT_MS
516
+ resolveHostWithRetry,
517
+ MAX_RETRIES, BACKOFF_BASE_MS, RETRYABLE_STATUS_CODES, RATE_LIMIT_MS,
518
+ RETRYABLE_ERROR_CODES, DNS_MAX_RETRIES, DNS_BACKOFF_BASE_MS
474
519
  };
@@ -8,7 +8,7 @@ const { banner } = require('../utils.js');
8
8
  const { setVerboseMode, isSandboxEnabled, isCanaryEnabled, isLlmDetectiveEnabled, getLlmDetectiveMode, DOWNLOADS_CACHE_TTL } = require('./classify.js');
9
9
  const { loadState, saveState, loadDailyStats, saveDailyStats, purgeTarballCache, isDailyReportDue, atomicWriteFileSync, saveNpmSeq, ALERTS_FILE, runStateMigrations, loadRecentlyScanned, saveRecentlyScanned } = require('./state.js');
10
10
  const { isTemporalEnabled, isTemporalAstEnabled, isTemporalPublishEnabled, isTemporalMaintainerEnabled } = require('./temporal.js');
11
- const { pendingGrouped, flushScopeGroup, sendDailyReport, alertedPackageRules, ALERTED_PACKAGES_MAX: MAX_ALERTED_PACKAGES } = require('./webhook.js');
11
+ const { pendingGrouped, flushScopeGroup, sendDailyReport, redeliverPendingReportOnBoot, alertedPackageRules, ALERTED_PACKAGES_MAX: MAX_ALERTED_PACKAGES } = require('./webhook.js');
12
12
  const { poll, getPollBackoffMs } = require('./ingestion.js');
13
13
  const { ensureWorkers, drainWorkers, getTargetConcurrency, setTargetConcurrency, getActiveWorkers, terminateAllWorkers } = require('./queue.js');
14
14
  const { computeTarget, ADJUST_INTERVAL_MS, BASE_CONCURRENCY } = require('./adaptive-concurrency.js');
@@ -964,6 +964,11 @@ async function startMonitor(options, stats, dailyAlerts, recentlyScanned, downlo
964
964
  // Best-effort and fire-and-forget; never blocks the daemon.
965
965
  startGhsaPoller(stats);
966
966
 
967
+ // AUDIT 3: if the last daily report failed to deliver (e.g. a DNS blip at 08:00),
968
+ // it sits on disk with delivered=false. Redeliver it once now. Fire-and-forget —
969
+ // never blocks startup, never throws (legacy reports without the flag are skipped).
970
+ redeliverPendingReportOnBoot().catch(() => { /* logged inside; non-fatal */ });
971
+
967
972
  // ─── Initial poll ───
968
973
  // Fills the queue with pending packages. Processing starts in the main loop
969
974
  // via ensureWorkers (non-blocking) — NOT await processQueue (blocking).
@@ -1115,6 +1115,12 @@ function computeLedgerRollup(sinceTs, opts = {}) {
1115
1115
  const scannedKeys = new Set();
1116
1116
  const droppedKeys = new Set();
1117
1117
  let exactVanished = true;
1118
+ // Distinct package NAMES (version-collapsed) for honest coverage. A package is
1119
+ // "covered" if at least one of its versions reached a real scan (non-dropped).
1120
+ // Bounded: names are only added while underCap, so |names| ≤ |keys| ≤ MAX_ROLLUP_KEYS.
1121
+ // Exactness mirrors exactVanished (false iff the cap was hit mid-window).
1122
+ const allNames = new Set();
1123
+ const scannedNames = new Set();
1118
1124
 
1119
1125
  _iterateJsonlSync(file, (e) => {
1120
1126
  if (!e || !e.name) return;
@@ -1140,11 +1146,11 @@ function computeLedgerRollup(sinceTs, opts = {}) {
1140
1146
  const underCap = exactVanished && (scannedKeys.size + droppedKeys.size) < MAX_ROLLUP_KEYS;
1141
1147
  if (outcome === 'dropped') {
1142
1148
  dropped++; ecoNode.dropped++;
1143
- if (underCap) droppedKeys.add(key); else exactVanished = false;
1149
+ if (underCap) { droppedKeys.add(key); allNames.add(e.name); } else exactVanished = false;
1144
1150
  } else {
1145
1151
  scanned++; ecoNode.scanned++;
1146
1152
  if (outcome === 'suspect' || outcome === 'confirmed') { alerted++; ecoNode.alerted++; }
1147
- if (underCap) scannedKeys.add(key); else exactVanished = false;
1153
+ if (underCap) { scannedKeys.add(key); allNames.add(e.name); scannedNames.add(e.name); } else exactVanished = false;
1148
1154
  }
1149
1155
  });
1150
1156
 
@@ -1164,6 +1170,13 @@ function computeLedgerRollup(sinceTs, opts = {}) {
1164
1170
  alerted,
1165
1171
  // NOT a TPR — see the HONEST METRIC NOTE above. null when nothing was scanned.
1166
1172
  alertRate: scanned > 0 ? alerted / scanned : null,
1173
+ // Honest, version-collapsed coverage: distinct package names seen vs scanned.
1174
+ // Bounded ≤100% by construction (scannedNames ⊆ allNames). Unlike the raw
1175
+ // event-count coverage in the embed, this is immune to version-spam inflation
1176
+ // (e.g. a package publishing thousands of versions counts once).
1177
+ distinctPackages: allNames.size,
1178
+ distinctScanned: scannedNames.size,
1179
+ distinctCoverage: allNames.size > 0 ? scannedNames.size / allNames.size : null,
1167
1180
  byOutcome,
1168
1181
  byEcosystem
1169
1182
  };
@@ -455,6 +455,10 @@ function persistDailyReport(reportPayload, rawMetrics) {
455
455
  const data = {
456
456
  date: today,
457
457
  timestamp: new Date().toISOString(),
458
+ // delivered=false until the webhook confirms — markReportDelivered() flips it
459
+ // after a successful send. The boot redelivery path keys off this (AUDIT 3).
460
+ delivered: false,
461
+ deliveredAt: null,
458
462
  embed: reportPayload,
459
463
  metrics: rawMetrics
460
464
  };
@@ -465,6 +469,92 @@ function persistDailyReport(reportPayload, rawMetrics) {
465
469
  }
466
470
  }
467
471
 
472
+ // --- AUDIT 3: persisted-report redelivery (resend + boot recovery) ---
473
+
474
+ /** List persisted daily-report dates (YYYY-MM-DD), sorted ascending. */
475
+ function listPersistedReportDates() {
476
+ try {
477
+ return fs.readdirSync(DAILY_REPORTS_LOG_DIR)
478
+ .filter(f => /^\d{4}-\d{2}-\d{2}\.json$/.test(f))
479
+ .map(f => f.slice(0, -5))
480
+ .sort();
481
+ } catch { return []; }
482
+ }
483
+
484
+ /**
485
+ * Load a persisted daily report by date (default: latest). Returns
486
+ * { date, filePath, data } or null if none / unreadable.
487
+ */
488
+ function loadPersistedReport(date) {
489
+ let d = date;
490
+ if (!d) {
491
+ const all = listPersistedReportDates();
492
+ if (all.length === 0) return null;
493
+ d = all[all.length - 1];
494
+ }
495
+ const filePath = path.join(DAILY_REPORTS_LOG_DIR, `${d}.json`);
496
+ try {
497
+ return { date: d, filePath, data: JSON.parse(fs.readFileSync(filePath, 'utf8')) };
498
+ } catch { return null; }
499
+ }
500
+
501
+ /** Mark a persisted report file as delivered (idempotent, best-effort). */
502
+ function markReportDelivered(filePath, data) {
503
+ try {
504
+ data.delivered = true;
505
+ data.deliveredAt = new Date().toISOString();
506
+ atomicWriteFileSync(filePath, JSON.stringify(data, null, 2));
507
+ } catch (err) {
508
+ console.error(`[MONITOR] Failed to mark report delivered: ${err.message}`);
509
+ }
510
+ }
511
+
512
+ /**
513
+ * Resend a persisted daily report's EXACT embed to the webhook — faithful
514
+ * redelivery, no stats reconstruction. Used by `report --resend [date]` and the
515
+ * boot redelivery path. Returns { sent, message, date }.
516
+ */
517
+ async function resendDailyReport(date) {
518
+ const url = getWebhookUrl();
519
+ if (!url) return { sent: false, message: 'MUADDIB_WEBHOOK_URL not configured' };
520
+ const report = loadPersistedReport(date);
521
+ if (!report) {
522
+ return { sent: false, message: date ? `No persisted report for ${date}` : 'No persisted reports found' };
523
+ }
524
+ const payload = report.data && report.data.embed;
525
+ if (!payload) return { sent: false, message: `Report ${report.date} has no embed payload`, date: report.date };
526
+ try {
527
+ await sendWebhook(url, payload, { rawPayload: true });
528
+ } catch (err) {
529
+ return { sent: false, message: `Webhook failed: ${err.message}`, date: report.date };
530
+ }
531
+ markReportDelivered(report.filePath, report.data);
532
+ return { sent: true, message: `Daily report ${report.date} resent`, date: report.date };
533
+ }
534
+
535
+ /**
536
+ * Boot redelivery: if the most recent persisted report was never confirmed
537
+ * delivered (last webhook failed — e.g. the 2026-06-10 DNS blip) AND a webhook URL
538
+ * is configured, attempt exactly one resend. Best-effort; never throws. Reports
539
+ * with delivered === undefined (written before this feature) are treated as
540
+ * delivered, so upgrading never spams historical reports.
541
+ */
542
+ async function redeliverPendingReportOnBoot() {
543
+ try {
544
+ const report = loadPersistedReport(null); // latest
545
+ if (!report) return { attempted: false, reason: 'no_reports' };
546
+ if (report.data.delivered !== false) return { attempted: false, reason: 'already_delivered_or_legacy' };
547
+ if (!getWebhookUrl()) return { attempted: false, reason: 'no_webhook_url' };
548
+ console.log(`[MONITOR] Last daily report (${report.date}) was not delivered — attempting boot redelivery...`);
549
+ const res = await resendDailyReport(report.date);
550
+ console.log(`[MONITOR] Boot redelivery of ${report.date}: ${res.sent ? 'sent' : 'failed — ' + res.message}`);
551
+ return { attempted: true, sent: res.sent, date: report.date };
552
+ } catch (err) {
553
+ console.error(`[MONITOR] Boot redelivery error (non-fatal): ${err.message}`);
554
+ return { attempted: false, reason: 'error' };
555
+ }
556
+ }
557
+
468
558
  function computeAlertPriority(result, sandboxResult) {
469
559
  const threats = (result && result.threats) || [];
470
560
  const score = (result && result.summary) ? (result.summary.riskScore || 0) : 0;
@@ -1027,25 +1117,41 @@ function buildDailyReportEmbed(stats, dailyAlerts, ledgerRollup) {
1027
1117
  // Avg scan time from in-memory stats
1028
1118
  const avg = stats.scanned > 0 ? (stats.totalTimeMs / stats.scanned / 1000).toFixed(1) : '0.0';
1029
1119
 
1030
- // --- Coverage estimation ---
1031
- // Numerator: unique (ecosystem, name, version) tuples that reached a scan
1032
- // attempt (post-dedup). Denominator: raw publish events seen on either
1033
- // changes stream BEFORE per-package filtering, plus npm catch-up gaps and
1034
- // PyPI publish events that survived per-(name,version) dedup. This stays
1035
- // bounded near 100% — old "scanned/changesStreamPackages" was racing PyPI
1036
- // scans and ATO burst extras against an npm-only denominator.
1120
+ // --- Phase 0b: per-scan ledger rollup (resolved early so Coverage can use it) ---
1121
+ // Caller may pass a precomputed rollup (sendDailyReport does, to persist the same
1122
+ // numbers it displays); undefined compute here; explicit null → omit the section.
1123
+ const ledger = ledgerRollup !== undefined ? ledgerRollup : safeLedgerRollup();
1124
+
1125
+ // --- Coverage ---
1126
+ // HEADLINE: honest, version-collapsed coverage from the scan-ledger — distinct
1127
+ // package NAMES actually scanned vs distinct names seen (scanned + dropped) in
1128
+ // the window. Bounded ≤100% by construction and immune to version-spam (a
1129
+ // package publishing thousands of versions counts once). The raw publish-event
1130
+ // ratio is kept as a SECONDARY line for continuity but is no longer the headline:
1131
+ // it races re-scans / PyPI / burst extras against an npm-only event denominator
1132
+ // and routinely exceeds 100% (see AUDIT 4 — daily-reports-analysis.md).
1037
1133
  const attempted = stats.uniqueScanAttempts || 0;
1038
1134
  const npmPub = stats.npmPublishEventsSeen || 0;
1039
1135
  const pypiPub = stats.pypiChangelogPackages || 0;
1040
1136
  const published = npmPub + pypiPub;
1041
- const coverageRatio = published > 0 ? (attempted / published * 100).toFixed(0) : '0';
1042
1137
  const catchupSkipped = (stats.npmCatchupSkippedSeqs || 0) + (stats.pypiCatchupSkippedEvents || 0);
1043
1138
  const opsSuffix = catchupSkipped > 0
1044
1139
  ? `\nOps: ${stats.scanned} | Catch-up skip: ${catchupSkipped}`
1045
1140
  : `\nOps: ${stats.scanned}`;
1046
- const coverageText = published > 0
1047
- ? `${attempted}/${published} (${coverageRatio}%)${opsSuffix}`
1048
- : `${attempted} attempted${opsSuffix}`;
1141
+ let coverageText;
1142
+ if (ledger && ledger.distinctPackages > 0 && ledger.distinctCoverage != null) {
1143
+ const pct = (ledger.distinctCoverage * 100).toFixed(0);
1144
+ const approx = ledger.exactVanished === false ? '~' : '';
1145
+ coverageText = `${ledger.distinctScanned}/${ledger.distinctPackages} pkgs (${approx}${pct}%)`;
1146
+ if (published > 0) coverageText += `\nRaw events: ${attempted}/${published}`;
1147
+ coverageText += opsSuffix;
1148
+ } else if (published > 0) {
1149
+ // Fallback: ledger unavailable (first boot / empty ledger) → legacy event ratio.
1150
+ const coverageRatio = (attempted / published * 100).toFixed(0);
1151
+ coverageText = `${attempted}/${published} (${coverageRatio}%)${opsSuffix}`;
1152
+ } else {
1153
+ coverageText = `${attempted} attempted${opsSuffix}`;
1154
+ }
1049
1155
 
1050
1156
  // --- Timeouts ---
1051
1157
  const staticTimeouts = (stats.errorsByType && stats.errorsByType.static_timeout) || 0;
@@ -1108,9 +1214,7 @@ function buildDailyReportEmbed(stats, dailyAlerts, ledgerRollup) {
1108
1214
  const healthText = `Up ${uptimeH}h${uptimeM}m | Heap ${heapMB}MB${jsonlInfo}`;
1109
1215
 
1110
1216
  // --- Phase 0b: per-scan ledger rollup (operational coverage) ---
1111
- // Caller may pass a precomputed rollup (sendDailyReport does, to persist the same
1112
- // numbers it displays); undefined → compute here; explicit null → omit the section.
1113
- const ledger = ledgerRollup !== undefined ? ledgerRollup : safeLedgerRollup();
1217
+ // `ledger` was resolved above (Coverage uses it). explicit null omit the section.
1114
1218
  const ledgerField = formatLedgerField(ledger);
1115
1219
 
1116
1220
  const now = new Date();
@@ -1207,6 +1311,12 @@ async function sendDailyReport(stats, dailyAlerts, recentlyScanned, downloadsCac
1207
1311
  deferredProcessed: stats.deferredProcessed || 0,
1208
1312
  deferredExpired: stats.deferredExpired || 0,
1209
1313
  changesStreamPackages: stats.changesStreamPackages || 0,
1314
+ // Honest version-collapsed coverage (AUDIT 4): top-level mirror of the
1315
+ // ledger fields so trend analysis can read them without descending into
1316
+ // metrics.ledger. null when the ledger window was empty.
1317
+ distinctPackages: ledgerRollup ? (ledgerRollup.distinctPackages ?? null) : null,
1318
+ distinctScanned: ledgerRollup ? (ledgerRollup.distinctScanned ?? null) : null,
1319
+ distinctCoverage: ledgerRollup ? (ledgerRollup.distinctCoverage ?? null) : null,
1210
1320
  restartsToday: stats.restartsToday || 0,
1211
1321
  temporalLoadShed: stats.temporalLoadShed || 0,
1212
1322
  queueHardDrops: stats.queueHardDrops || 0,
@@ -1278,8 +1388,13 @@ async function sendDailyReport(stats, dailyAlerts, recentlyScanned, downloadsCac
1278
1388
  try {
1279
1389
  await sendWebhook(url, payload, { rawPayload: true });
1280
1390
  console.log('[MONITOR] Daily report sent');
1391
+ // Confirm delivery on the just-persisted file so boot redelivery won't resend it.
1392
+ const persisted = loadPersistedReport(today);
1393
+ if (persisted) markReportDelivered(persisted.filePath, persisted.data);
1281
1394
  } catch (err) {
1282
- console.error(`[MONITOR] Daily report webhook failed: ${err.message}`);
1395
+ // Webhook failed (DNS/network/5xx after retries). The report stays on disk with
1396
+ // delivered=false → it will be redelivered on the next daemon boot (AUDIT 3).
1397
+ console.error(`[MONITOR] Daily report webhook failed: ${err.message} — left undelivered for boot redelivery`);
1283
1398
  }
1284
1399
  } else {
1285
1400
  console.log('[MONITOR] Daily report persisted locally (no webhook URL configured)');
@@ -1467,6 +1582,11 @@ module.exports = {
1467
1582
  triageRisk,
1468
1583
  persistAlert,
1469
1584
  persistDailyReport,
1585
+ listPersistedReportDates,
1586
+ loadPersistedReport,
1587
+ markReportDelivered,
1588
+ resendDailyReport,
1589
+ redeliverPendingReportOnBoot,
1470
1590
  computeAlertPriority,
1471
1591
  buildAlertData,
1472
1592
  trySendWebhook,