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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muaddib-scanner",
3
- "version": "2.11.70",
3
+ "version": "2.11.71",
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-07T15:33:14.219Z",
3
+ "timestamp": "2026-06-07T16:09:51.503Z",
4
4
  "threats": [
5
5
  {
6
6
  "type": "string_mutation_obfuscation",
@@ -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
+ };
@@ -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 {