muaddib-scanner 2.11.70 → 2.11.71
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/package.json
CHANGED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IOC feed-health alarm (Phase 2c, part 1).
|
|
3
|
+
*
|
|
4
|
+
* The audit that started the coverage plan traced the 24.5% operational-coverage
|
|
5
|
+
* collapse to a SILENT ops failure: the OSM feed went dark (returned 0) for weeks, so
|
|
6
|
+
* the IOC store went stale and nothing alarmed. This module closes that blind spot.
|
|
7
|
+
*
|
|
8
|
+
* After every IOC refresh, `checkFeedHealth` compares each feed's returned count against
|
|
9
|
+
* a persisted per-feed baseline. When a feed that has previously shown a healthy count
|
|
10
|
+
* suddenly returns 0, it raises a ONE-SHOT alarm (console + webhook) on the healthy→dark
|
|
11
|
+
* transition — not every cycle — and a recovery notice on dark→alive. Best-effort: a
|
|
12
|
+
* feed-health failure must NEVER break the IOC refresh.
|
|
13
|
+
*
|
|
14
|
+
* The decision core (`evaluateFeedHealth`) is a pure function (counts + prev state →
|
|
15
|
+
* alarms/recoveries/next state) so it is fully unit-testable without I/O or network.
|
|
16
|
+
*/
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
|
|
22
|
+
const FEED_HEALTH_FILE = process.env.MUADDIB_FEED_HEALTH_FILE ||
|
|
23
|
+
path.join(__dirname, '..', '..', 'data', 'feed-health.json');
|
|
24
|
+
|
|
25
|
+
// A feed must have shown at least this many IOCs at least once before a later zero counts
|
|
26
|
+
// as "went dark". Below this a feed is too small/volatile to alarm on (FP guard). Real
|
|
27
|
+
// feeds (GenSecAI/DataDog/OSV/OSM) return hundreds–thousands, so 5 is a safe floor.
|
|
28
|
+
const MIN_HEALTHY_BASELINE = (() => {
|
|
29
|
+
const n = parseInt(process.env.MUADDIB_FEED_HEALTH_MIN, 10);
|
|
30
|
+
return Number.isFinite(n) && n > 0 ? n : 5;
|
|
31
|
+
})();
|
|
32
|
+
|
|
33
|
+
function loadFeedHealth(file = FEED_HEALTH_FILE) {
|
|
34
|
+
try {
|
|
35
|
+
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
36
|
+
return (data && typeof data === 'object' && data.feeds && typeof data.feeds === 'object') ? data.feeds : {};
|
|
37
|
+
} catch {
|
|
38
|
+
return {};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function saveFeedHealth(state, file = FEED_HEALTH_FILE) {
|
|
43
|
+
try {
|
|
44
|
+
const dir = path.dirname(file);
|
|
45
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
46
|
+
const tmp = file + '.tmp';
|
|
47
|
+
fs.writeFileSync(tmp, JSON.stringify({ updatedAt: new Date().toISOString(), feeds: state }, null, 2));
|
|
48
|
+
fs.renameSync(tmp, file);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
// Best-effort: a read-only / full disk must not break the refresh.
|
|
51
|
+
if (err && ['EROFS', 'EACCES', 'EPERM', 'ENOSPC'].includes(err.code)) return;
|
|
52
|
+
console.warn('[FEED-HEALTH] Failed to persist state: ' + err.message);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* PURE decision core. Given current per-feed counts and the previous state, compute:
|
|
58
|
+
* - alarms: feeds with a healthy baseline that returned 0 this cycle (healthy→dark edge)
|
|
59
|
+
* - recoveries: dark feeds that returned data again (dark→alive edge)
|
|
60
|
+
* - nextState: updated per-feed { lastHealthy, lastHealthyAt, dark }
|
|
61
|
+
* Feeds present in prevState but absent from `counts` keep their baseline (carry-forward).
|
|
62
|
+
*
|
|
63
|
+
* @param {Object<string,number>} counts
|
|
64
|
+
* @param {Object<string,{lastHealthy:number,lastHealthyAt:?string,dark:boolean}>} prevState
|
|
65
|
+
* @param {string} nowIso
|
|
66
|
+
*/
|
|
67
|
+
function evaluateFeedHealth(counts, prevState, nowIso) {
|
|
68
|
+
const alarms = [];
|
|
69
|
+
const recoveries = [];
|
|
70
|
+
const nextState = {};
|
|
71
|
+
|
|
72
|
+
for (const feed of Object.keys(counts || {})) {
|
|
73
|
+
const cur = Number(counts[feed]) || 0;
|
|
74
|
+
const prev = (prevState && prevState[feed]) || { lastHealthy: 0, lastHealthyAt: null, dark: false };
|
|
75
|
+
const entry = { lastHealthy: prev.lastHealthy || 0, lastHealthyAt: prev.lastHealthyAt || null, dark: !!prev.dark };
|
|
76
|
+
|
|
77
|
+
if (cur >= MIN_HEALTHY_BASELINE) {
|
|
78
|
+
entry.lastHealthy = cur;
|
|
79
|
+
entry.lastHealthyAt = nowIso;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (cur === 0) {
|
|
83
|
+
if ((prev.lastHealthy || 0) >= MIN_HEALTHY_BASELINE && !prev.dark) {
|
|
84
|
+
alarms.push({ feed, lastHealthy: prev.lastHealthy, lastHealthyAt: prev.lastHealthyAt || null });
|
|
85
|
+
}
|
|
86
|
+
entry.dark = true;
|
|
87
|
+
} else {
|
|
88
|
+
if (prev.dark) recoveries.push({ feed, count: cur });
|
|
89
|
+
entry.dark = false;
|
|
90
|
+
}
|
|
91
|
+
nextState[feed] = entry;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Carry forward feeds not reported this cycle so their baseline is not lost.
|
|
95
|
+
for (const feed of Object.keys(prevState || {})) {
|
|
96
|
+
if (!(feed in nextState)) nextState[feed] = prevState[feed];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { alarms, recoveries, nextState };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function buildFeedHealthAlarmEmbed(alarms, recoveries) {
|
|
103
|
+
const fields = [];
|
|
104
|
+
for (const a of alarms) {
|
|
105
|
+
fields.push({
|
|
106
|
+
name: `🔴 ${a.feed} returned 0`,
|
|
107
|
+
value: `Last healthy: ${a.lastHealthy} IOC(s)${a.lastHealthyAt ? ` (${a.lastHealthyAt})` : ''}. ` +
|
|
108
|
+
'Feed likely down / token expired / endpoint moved — IOC store is going stale. Investigate now.',
|
|
109
|
+
inline: false
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
for (const r of (recoveries || [])) {
|
|
113
|
+
fields.push({ name: `🟢 ${r.feed} recovered`, value: `Now returning ${r.count} IOC(s).`, inline: false });
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
embeds: [{
|
|
117
|
+
title: '⚠️ MUAD\'DIB IOC Feed Health',
|
|
118
|
+
color: alarms.length ? 0xe74c3c : 0x2ecc71,
|
|
119
|
+
fields,
|
|
120
|
+
footer: { text: 'MUAD\'DIB IOC feed-health monitor' },
|
|
121
|
+
timestamp: new Date().toISOString()
|
|
122
|
+
}]
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function _defaultDispatch(payload) {
|
|
127
|
+
const url = process.env.MUADDIB_WEBHOOK_URL;
|
|
128
|
+
if (!url) return; // no webhook configured — the console alarm already fired
|
|
129
|
+
try {
|
|
130
|
+
const { sendWebhook } = require('../webhook.js');
|
|
131
|
+
await sendWebhook(url, payload, { rawPayload: true });
|
|
132
|
+
} catch (err) {
|
|
133
|
+
console.warn('[FEED-HEALTH] webhook dispatch failed: ' + err.message);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Load → evaluate → persist → dispatch. Best-effort: NEVER throws (a feed-health failure
|
|
139
|
+
* must not break the IOC refresh). Returns { alarms, recoveries }.
|
|
140
|
+
*
|
|
141
|
+
* @param {Object<string,number>} counts - per-feed IOC counts from this refresh
|
|
142
|
+
* @param {Object} [opts]
|
|
143
|
+
* @param {function(object):Promise} [opts.dispatch] - injectable webhook sender (tests)
|
|
144
|
+
* @param {string} [opts.file] - state file override (tests)
|
|
145
|
+
*/
|
|
146
|
+
async function checkFeedHealth(counts, opts = {}) {
|
|
147
|
+
try {
|
|
148
|
+
const file = opts.file || FEED_HEALTH_FILE;
|
|
149
|
+
const prev = loadFeedHealth(file);
|
|
150
|
+
const { alarms, recoveries, nextState } = evaluateFeedHealth(counts, prev, new Date().toISOString());
|
|
151
|
+
saveFeedHealth(nextState, file);
|
|
152
|
+
|
|
153
|
+
for (const a of alarms) {
|
|
154
|
+
console.warn(`[FEED-HEALTH] ALARM: feed "${a.feed}" returned 0 (last healthy ${a.lastHealthy}). Stale IOCs degrade coverage — check the source.`);
|
|
155
|
+
}
|
|
156
|
+
for (const r of recoveries) {
|
|
157
|
+
console.log(`[FEED-HEALTH] RECOVERED: feed "${r.feed}" now returns ${r.count}.`);
|
|
158
|
+
}
|
|
159
|
+
if (alarms.length || recoveries.length) {
|
|
160
|
+
const dispatch = opts.dispatch || _defaultDispatch;
|
|
161
|
+
await dispatch(buildFeedHealthAlarmEmbed(alarms, recoveries));
|
|
162
|
+
}
|
|
163
|
+
return { alarms, recoveries };
|
|
164
|
+
} catch (err) {
|
|
165
|
+
console.warn('[FEED-HEALTH] check failed (non-fatal): ' + err.message);
|
|
166
|
+
return { alarms: [], recoveries: [] };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
module.exports = {
|
|
171
|
+
evaluateFeedHealth,
|
|
172
|
+
checkFeedHealth,
|
|
173
|
+
loadFeedHealth,
|
|
174
|
+
saveFeedHealth,
|
|
175
|
+
buildFeedHealthAlarmEmbed,
|
|
176
|
+
FEED_HEALTH_FILE,
|
|
177
|
+
MIN_HEALTHY_BASELINE
|
|
178
|
+
};
|
package/src/ioc/updater.js
CHANGED
|
@@ -62,6 +62,28 @@ async function updateIOCs() {
|
|
|
62
62
|
mergeIOCs(baseIOCs, githubIOCs);
|
|
63
63
|
console.log(' +' + shaiHulud.packages.length + ' GenSecAI, +' + datadog.packages.length + ' DataDog, +' + osvApi.length + ' OSV API, +' + osmResult.packages.length + ' OSM npm, +' + (osmResult.pypi_packages || []).length + ' OSM PyPI');
|
|
64
64
|
|
|
65
|
+
// Phase 2c (feed health): a feed that previously returned data but now returns 0 is the
|
|
66
|
+
// silent failure mode that froze the OSM feed and collapsed coverage. Raise a one-shot
|
|
67
|
+
// alarm on the healthy→dark transition. Best-effort — never throws, never blocks the refresh.
|
|
68
|
+
// Only feeds that were actually ATTEMPTED are health-checked: OSM is token-gated, so without
|
|
69
|
+
// OSM_API_TOKEN it is SKIPPED (not down) — counting it would raise a false "OSM went dark"
|
|
70
|
+
// alarm in any no-token context (e.g. an ad-hoc `muaddib update`) against the monitor-seeded
|
|
71
|
+
// baseline. OSV-API is public and volatile; its small counts rarely cross MIN_HEALTHY_BASELINE,
|
|
72
|
+
// so the engine naturally never establishes an alarm-able baseline for it.
|
|
73
|
+
try {
|
|
74
|
+
const feedCounts = {
|
|
75
|
+
'GenSecAI': shaiHulud.packages.length,
|
|
76
|
+
'DataDog': datadog.packages.length,
|
|
77
|
+
'OSV-API': osvApi.length
|
|
78
|
+
};
|
|
79
|
+
if (process.env.OSM_API_TOKEN) {
|
|
80
|
+
feedCounts['OSM'] = osmResult.packages.length + (osmResult.pypi_packages || []).length;
|
|
81
|
+
}
|
|
82
|
+
await require('./feed-health.js').checkFeedHealth(feedCounts);
|
|
83
|
+
} catch (e) {
|
|
84
|
+
console.warn('[FEED-HEALTH] skipped: ' + e.message);
|
|
85
|
+
}
|
|
86
|
+
|
|
65
87
|
// Step 3b: Load existing cache IOCs (from bootstrap download or previous update)
|
|
66
88
|
if (fs.existsSync(CACHE_IOC_FILE)) {
|
|
67
89
|
try {
|