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 +30 -1
- package/package.json +1 -1
- package/{self-scan-v2.11.80.json → self-scan-v2.11.82.json} +1 -1
- package/src/env-loader.js +57 -0
- package/src/integrations/webhook.js +57 -12
- package/src/ml/feature-extractor.js +70 -0
- package/src/monitor/daemon.js +6 -1
- package/src/monitor/webhook.js +101 -1
- package/src/scoring.js +8 -0
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
|
};
|
|
@@ -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,
|
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/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;
|
|
@@ -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
|
-
|
|
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 });
|