muaddib-scanner 2.11.80 → 2.11.82

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.80",
3
+ "version": "2.11.82",
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:26:12.478Z",
3
+ "timestamp": "2026-06-10T12:51:04.328Z",
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
  };
@@ -751,6 +751,75 @@ function mcpServerEnvAccess(result, meta) {
751
751
  return true;
752
752
  }
753
753
 
754
+ // ============================================================================
755
+ // Feature 15 — mcp_server_benign_lifecycle (AUDIT 2, 2026-06)
756
+ // ============================================================================
757
+ //
758
+ // Like F9 (mcpServerEnvAccess) but TOLERATES a benign install lifecycle. F9
759
+ // vetoes on ANY preinstall/install/postinstall (its C3), which makes it
760
+ // inoperative for the ~77% of legitimate MCP installers that ship a build/setup
761
+ // hook (`husky install`, `node build.js`, `tsc`). Those packages stack
762
+ // mcp_config_injection (CRIT) + suspicious_dataflow (CRIT, env→first-party POST)
763
+ // + env_access (HIGH) + lifecycle_script (MEDIUM) and score ~150 on `muaddib
764
+ // scan` — the recurring @recapp/mcp-style false positives in the daily report.
765
+ //
766
+ // F15 instead allows a lifecycle that is only flagged as a plain MEDIUM/LOW
767
+ // `lifecycle_script`, and vetoes the moment the lifecycle does anything
768
+ // malicious. Ground-truth safety (verified by replay before/after):
769
+ // GT-060 mcp-config-inject → vetoed by lifecycle_file_exec (malicious postinstall)
770
+ // GT-088 defi-threat-scanner → vetoed by HARD exfil (suspicious_domain) + cred files
771
+ // GT-066 ai-agent-exploit → never emits mcp_config_injection (C2 excludes it)
772
+ // GT-097 / GT-099 → HARD exfil / not an mcp_config_injection JS package
773
+ // Same cap (30 = MEDIUM) and identity/provider-key machinery as F9.
774
+ const F15_LIFECYCLE_MALICE_TYPES = new Set([
775
+ 'lifecycle_file_exec', // postinstall executes a file containing HIGH/CRIT threats
776
+ 'lifecycle_dataflow', // install-time credential read + network send (compound)
777
+ 'lifecycle_shell_pipe', // curl | sh during install
778
+ 'lifecycle_missing_script', // phantom install script (payload injected later)
779
+ 'intent_credential_exfil', // multi-file credential→network intent
780
+ 'intent_command_exfil',
781
+ 'detached_credential_exfil',
782
+ 'staged_payload'
783
+ ]);
784
+
785
+ function mcpServerBenignLifecycle(result, meta) {
786
+ // C1 — MCP identity (same as F9)
787
+ if (!_f9HasMcpIdentity(meta)) return false;
788
+ const threats = (result && result.threats) || [];
789
+ if (threats.length === 0) return false;
790
+ // C2 — mcp_config_injection present (proves real MCP work, not just a name claim)
791
+ if (!threats.some(t => t.type === 'mcp_config_injection')) return false;
792
+ // C3' (relaxed) — a lifecycle MAY exist, but it must be benign: no malicious
793
+ // lifecycle compound, and a plain lifecycle_script (if any) must not itself be
794
+ // HIGH/CRITICAL (a benign husky/build hook is MEDIUM/LOW).
795
+ for (const t of threats) {
796
+ if (F15_LIFECYCLE_MALICE_TYPES.has(t.type)) return false;
797
+ if (t.type === 'lifecycle_script' && (t.severity === 'HIGH' || t.severity === 'CRITICAL')) return false;
798
+ }
799
+ // C4 — env_access / credential threats cite ONLY known provider keys or infra
800
+ // vars; never credential file paths (same machinery as F9).
801
+ for (const t of threats) {
802
+ if (t.type !== 'env_access' && t.type !== 'credential_regex_harvest' &&
803
+ t.type !== 'env_charcode_reconstruction') continue;
804
+ const msg = String(t.message || '');
805
+ if (F9_CREDENTIAL_FILE_RE.test(msg)) return false;
806
+ const candidates = msg.match(/\b[A-Z][A-Z0-9_]{2,}\b/g);
807
+ if (!candidates) continue;
808
+ for (const v of candidates) {
809
+ if (KNOWN_PROVIDER_KEYS_LITERAL.has(v)) continue;
810
+ if (PROVIDER_KEY_SUFFIX_RE.test(v)) continue;
811
+ if (F9_INFRA_KEYS.has(v)) continue;
812
+ return false;
813
+ }
814
+ }
815
+ // C5 — no HARD third-party exfil capability (SOFT suspicious_dataflow to a
816
+ // first-party endpoint is intrinsic to MCP installers — see F9/F14)
817
+ for (const t of threats) {
818
+ if (HARD_EXFIL_TYPES.has(t.type)) return false;
819
+ }
820
+ return true;
821
+ }
822
+
754
823
  // ============================================================================
755
824
  // Feature 10 — vendor_cli_sdk (v2.11.23, audit week3 cluster, 96 FP)
756
825
  // ============================================================================
@@ -1426,6 +1495,7 @@ module.exports = {
1426
1495
  placeholderAntiDepConfusion,
1427
1496
  installScriptNoNetworkEgress,
1428
1497
  mcpServerEnvAccess,
1498
+ mcpServerBenignLifecycle,
1429
1499
  vendorCliSdk,
1430
1500
  aiAgentBot,
1431
1501
  vendorMinifiedBundle,
@@ -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).
@@ -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;
@@ -1298,8 +1388,13 @@ async function sendDailyReport(stats, dailyAlerts, recentlyScanned, downloadsCac
1298
1388
  try {
1299
1389
  await sendWebhook(url, payload, { rawPayload: true });
1300
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);
1301
1394
  } catch (err) {
1302
- 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`);
1303
1398
  }
1304
1399
  } else {
1305
1400
  console.log('[MONITOR] Daily report persisted locally (no webhook URL configured)');
@@ -1487,6 +1582,11 @@ module.exports = {
1487
1582
  triageRisk,
1488
1583
  persistAlert,
1489
1584
  persistDailyReport,
1585
+ listPersistedReportDates,
1586
+ loadPersistedReport,
1587
+ markReportDelivered,
1588
+ resendDailyReport,
1589
+ redeliverPendingReportOnBoot,
1490
1590
  computeAlertPriority,
1491
1591
  buildAlertData,
1492
1592
  trySendWebhook,
package/src/scoring.js CHANGED
@@ -1506,6 +1506,7 @@ const {
1506
1506
  obfuscationWithoutVector,
1507
1507
  placeholderAntiDepConfusion,
1508
1508
  mcpServerEnvAccess,
1509
+ mcpServerBenignLifecycle,
1509
1510
  vendorCliSdk,
1510
1511
  aiAgentBot,
1511
1512
  vendorMinifiedBundle,
@@ -1559,6 +1560,13 @@ function applyContextualFPCaps(result, pkgMeta) {
1559
1560
  if (mcpServerEnvAccess(result, meta)) {
1560
1561
  applied.push({ feature: 'mcp_server_env_access', cap: 30 });
1561
1562
  }
1563
+ // F15: legit MCP installer/server WITH a benign install lifecycle (AUDIT 2) →
1564
+ // MAX 30. Extends F9 to the ~77% of MCP installers that ship a build/setup hook
1565
+ // (husky install, node build.js). Vetoes on malicious lifecycle (lifecycle_file_exec
1566
+ // etc.), HARD exfil, or credential-file access — so GT MCP malware stays uncapped.
1567
+ if (mcpServerBenignLifecycle(result, meta)) {
1568
+ applied.push({ feature: 'mcp_server_benign_lifecycle', cap: 30 });
1569
+ }
1562
1570
  // F2: binary installer from GitHub Releases → MAX 35
1563
1571
  if (installUrlGithubReleases(result)) {
1564
1572
  applied.push({ feature: 'install_url_github_releases', cap: 35 });