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 +30 -1
- package/package.json +1 -1
- package/{self-scan-v2.11.79.json → self-scan-v2.11.81.json} +1 -1
- package/src/env-loader.js +57 -0
- package/src/integrations/webhook.js +57 -12
- package/src/monitor/daemon.js +6 -1
- package/src/monitor/state.js +15 -2
- package/src/monitor/webhook.js +135 -15
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
|
@@ -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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|
package/src/monitor/daemon.js
CHANGED
|
@@ -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).
|
package/src/monitor/state.js
CHANGED
|
@@ -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
|
};
|
package/src/monitor/webhook.js
CHANGED
|
@@ -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
|
|
1031
|
-
//
|
|
1032
|
-
//
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
//
|
|
1036
|
-
//
|
|
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
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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,
|