@blamejs/exceptd-skills 0.13.16 → 0.13.17

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.
@@ -0,0 +1,218 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * lib/cve-regression-watcher.js — NEW-CTRL-074 detection method.
5
+ *
6
+ * The MiniPlasma class. A researcher republishes a working PoC against a
7
+ * historical CVE (CVE-2020-17103, originally fixed December 2020) and
8
+ * demonstrates the fix has been silently reverted or never landed. The
9
+ * vendor does not issue a new CVE — the original ID is treated as
10
+ * authoritative. NVD / KEV / OSV feeds will never surface this as a
11
+ * current threat. Vendor advisory feeds (RHSA, USN, ZDI) won't either.
12
+ * Vendor security blogs won't unless Microsoft Security Blog elects to
13
+ * post about it (which, for a re-regression of their own fix, is a
14
+ * commercial-incentive misalignment).
15
+ *
16
+ * What this catches: a poller diff (from lib/source-advisories.js) whose
17
+ * extracted CVE-IDs include historical IDs (year <= currentYear - 2) that
18
+ * may be silently regressed against current shipping product. The
19
+ * detection method is signal-correlation, not source-extension:
20
+ *
21
+ * 1. Pull the union of cve_ids extracted from poller diffs in a given run.
22
+ * 2. Filter to historical IDs (CVE-YYYY-NNN where YYYY <= currentYear - 2).
23
+ * 3. For each historical hit, check whether the catalog has a parallel
24
+ * "*-REREGRESSION-<year>" key whose `aliases[]` references it OR a
25
+ * `discovery_attribution_note` that names a researcher republishing
26
+ * the historical PoC.
27
+ * 4. Surface the unmatched historical hits as candidate regressions —
28
+ * operators triage and decide whether to (a) create a *-REREGRESSION-
29
+ * catalog entry (the MiniPlasma path) or (b) annotate the existing
30
+ * historical entry with a re-regression-confirmed flag.
31
+ *
32
+ * Output shape:
33
+ * {
34
+ * candidates: [
35
+ * {
36
+ * historical_cve: 'CVE-2020-17103',
37
+ * surfaced_by: ['bleepingcomputer-security', 'thehackernews'],
38
+ * first_seen_titles: ['MiniPlasma — Windows ...', ...],
39
+ * existing_regression_key: 'CVE-2020-17103-REREGRESSION-2026' | null,
40
+ * action: 'annotate' | 'create-regression-entry' | 'already-covered',
41
+ * }
42
+ * ],
43
+ * historical_id_threshold_year: 2024,
44
+ * evaluated_diffs: N,
45
+ * }
46
+ *
47
+ * Design notes:
48
+ *
49
+ * - The threshold (year <= currentYear - 2) is deliberately permissive.
50
+ * A re-regression of a 2024 CVE is still a regression. The threshold
51
+ * keeps the watcher from firing on every fresh CVE poller diff.
52
+ * - The watcher is REPORT-ONLY. It does not mutate the catalog. Operators
53
+ * route candidates through `exceptd refresh --advisory <CVE-ID> --apply`
54
+ * or via manual triage of a new *-REREGRESSION-<year> entry. This
55
+ * matches ADVISORIES_SOURCE's conservative-by-default contract.
56
+ * - The CVE_RE matcher (lib/source-advisories.js) already handles the
57
+ * historical-CVE extraction step — the watcher consumes diffs, not raw
58
+ * feed bodies. This keeps the watcher decoupled from feed-fetch logic
59
+ * so it works in fixture mode, cache mode, and live mode identically.
60
+ */
61
+
62
+ const path = require('path');
63
+ const fs = require('fs');
64
+
65
+ const CVE_ID_RE = /^CVE-((?:19|20)\d{2})-\d{4,7}$/;
66
+ const TODAY = new Date().toISOString().slice(0, 10);
67
+
68
+ /**
69
+ * Extract the year from a CVE-YYYY-NNN identifier. Returns null for non-CVE
70
+ * IDs (e.g. MAL-*, GHSA-*, the HANDLE:* shape from the github-events parser).
71
+ */
72
+ function cveYear(id) {
73
+ if (typeof id !== 'string') return null;
74
+ const m = id.match(CVE_ID_RE);
75
+ return m ? Number(m[1]) : null;
76
+ }
77
+
78
+ /**
79
+ * Find any catalog entry whose `aliases[]` includes the historical CVE-ID
80
+ * OR whose key derives from it via the *-REREGRESSION-<year> convention.
81
+ *
82
+ * Returns the key (e.g. 'CVE-2020-17103-REREGRESSION-2026') or null.
83
+ */
84
+ function findRegressionEntry(catalog, historicalId) {
85
+ for (const key of Object.keys(catalog)) {
86
+ if (key === '_meta') continue;
87
+ if (key === historicalId) return key;
88
+ if (key.startsWith(`${historicalId}-REREGRESSION-`)) return key;
89
+ const entry = catalog[key];
90
+ if (entry && Array.isArray(entry.aliases) && entry.aliases.includes(historicalId)) {
91
+ return key;
92
+ }
93
+ }
94
+ return null;
95
+ }
96
+
97
+ /**
98
+ * Walk a list of poller diffs (the shape lib/source-advisories.js produces)
99
+ * and surface historical-CVE-ID references that are NOT yet covered by a
100
+ * regression entry in the catalog.
101
+ *
102
+ * @param {Array<{id: string, source?: string, title?: string, sources?: string[]}>} diffs
103
+ * @param {Object} catalog — data/cve-catalog.json shape, _meta key tolerated
104
+ * @param {Object} opts — { now?: Date, threshold_years_ago?: number }
105
+ * @returns {Object} report — { candidates, historical_id_threshold_year, evaluated_diffs }
106
+ */
107
+ function findRegressionCandidates(diffs, catalog, opts) {
108
+ const now = (opts && opts.now) || new Date();
109
+ const yearsAgo = (opts && typeof opts.threshold_years_ago === 'number') ? opts.threshold_years_ago : 2;
110
+ const thresholdYear = now.getUTCFullYear() - yearsAgo;
111
+ const evaluated = Array.isArray(diffs) ? diffs.length : 0;
112
+
113
+ // Group historical-CVE refs by id so multi-feed surfacing collapses.
114
+ const byHistoricalId = new Map();
115
+ for (const d of (diffs || [])) {
116
+ if (!d || typeof d.id !== 'string') continue;
117
+ const year = cveYear(d.id);
118
+ if (year === null) continue;
119
+ if (year > thresholdYear) continue;
120
+ if (!byHistoricalId.has(d.id)) byHistoricalId.set(d.id, { sources: new Set(), titles: [] });
121
+ const slot = byHistoricalId.get(d.id);
122
+ if (Array.isArray(d.sources)) {
123
+ for (const s of d.sources) slot.sources.add(s);
124
+ } else if (typeof d.source === 'string') {
125
+ slot.sources.add(d.source);
126
+ }
127
+ if (typeof d.title === 'string' && d.title) slot.titles.push(d.title);
128
+ }
129
+
130
+ const candidates = [];
131
+ for (const [id, slot] of byHistoricalId.entries()) {
132
+ const existing = findRegressionEntry(catalog, id);
133
+ let action;
134
+ if (existing) {
135
+ // The historical CVE is the catalog key itself — annotate it.
136
+ if (existing === id) action = 'annotate';
137
+ // Already covered by a *-REREGRESSION-<year> entry — nothing to do.
138
+ else action = 'already-covered';
139
+ } else {
140
+ action = 'create-regression-entry';
141
+ }
142
+ candidates.push({
143
+ historical_cve: id,
144
+ surfaced_by: Array.from(slot.sources).sort(),
145
+ first_seen_titles: slot.titles.slice(0, 5),
146
+ existing_regression_key: existing,
147
+ action,
148
+ });
149
+ }
150
+
151
+ candidates.sort((a, b) => a.historical_cve.localeCompare(b.historical_cve));
152
+
153
+ return {
154
+ candidates,
155
+ historical_id_threshold_year: thresholdYear,
156
+ evaluated_diffs: evaluated,
157
+ generated_at: TODAY,
158
+ control_ref: 'NEW-CTRL-074',
159
+ };
160
+ }
161
+
162
+ /**
163
+ * Source-style wrapper so the watcher plugs into the refresh pipeline as a
164
+ * peer of ADVISORIES_SOURCE. fetchDiff() consumes the prior
165
+ * ADVISORIES_SOURCE.fetchDiff() result via ctx.advisoriesObservations
166
+ * (preferred — the full extracted-CVE list including IDs already in
167
+ * catalog, so the annotate verdict can fire on the MiniPlasma class) and
168
+ * falls back to ctx.advisoriesDiffs when the advisories source ran on a
169
+ * pre-v0.13.17 build that did not emit observations.
170
+ *
171
+ * Chaining is explicit — the watcher does not poll feeds itself.
172
+ * lib/refresh-external.js#loadCtx wires the prior advisories run output
173
+ * onto ctx.advisoriesObservations + ctx.advisoriesDiffs so order matters:
174
+ * advisories must run before cve-regression-watcher in a multi-source
175
+ * invocation.
176
+ */
177
+ const REGRESSION_WATCHER_SOURCE = {
178
+ name: 'cve-regression-watcher',
179
+ description: 'NEW-CTRL-074 detection method — surfaces poller-diff historical-CVE references that may indicate silent vendor regression (the MiniPlasma class). Report-only; depends on ADVISORIES_SOURCE observations as input.',
180
+ applies_to: 'data/cve-catalog.json',
181
+ async fetchDiff(ctx) {
182
+ // Prefer observations (v0.13.17+ ADVISORIES_SOURCE return shape) over
183
+ // diffs[] (pre-v0.13.17). observations[] preserves in-catalog CVE
184
+ // refs which the annotate verdict requires.
185
+ const input = (ctx && ctx.advisoriesObservations) || (ctx && ctx.advisoriesDiffs) || [];
186
+ const catalog = (ctx && ctx.cveCatalog) || {};
187
+ const report = findRegressionCandidates(input, catalog, {});
188
+ return {
189
+ status: 'ok',
190
+ diffs: report.candidates,
191
+ errors: 0,
192
+ summary: `cve-regression-watcher: evaluated ${report.evaluated_diffs} poller observations against threshold ${report.historical_id_threshold_year}; ${report.candidates.length} candidate(s) surfaced`,
193
+ _meta: {
194
+ control_ref: report.control_ref,
195
+ threshold_year: report.historical_id_threshold_year,
196
+ generated_at: report.generated_at,
197
+ input_field_used: (ctx && ctx.advisoriesObservations) ? 'advisoriesObservations' : 'advisoriesDiffs',
198
+ },
199
+ };
200
+ },
201
+ // Report-only.
202
+ applyDiff(_ctx, _diffs) {
203
+ return {
204
+ updated: 0,
205
+ added: 0,
206
+ drift_updated: 0,
207
+ errors: [],
208
+ note: 'REGRESSION_WATCHER_SOURCE is report-only. Candidates are operator-triage input — create a *-REREGRESSION-<year> catalog entry or annotate the historical entry manually.',
209
+ };
210
+ },
211
+ };
212
+
213
+ module.exports = {
214
+ REGRESSION_WATCHER_SOURCE,
215
+ findRegressionCandidates,
216
+ findRegressionEntry,
217
+ cveYear,
218
+ };
@@ -643,6 +643,16 @@ const OSV_SOURCE = {
643
643
  // (ssh-keysign-pwn) where the existing NVD-based pollers lagged by 3+ days.
644
644
  const { ADVISORIES_SOURCE } = require('./source-advisories');
645
645
 
646
+ // v0.13.17: REGRESSION_WATCHER_SOURCE is NEW-CTRL-074. Implements the
647
+ // detection method that surfaces poller-diff historical-CVE references as
648
+ // candidate silent-regression cases (the MiniPlasma class — a 2026 PoC
649
+ // drop that re-broke CVE-2020-17103 without any new ID being assigned).
650
+ // Report-only; consumes diffs + extracted-CVE-id list from a prior
651
+ // advisories run (loadCtx populates ctx.advisoriesDiffs +
652
+ // ctx.advisoriesExtractedCveIds when advisories runs alongside the
653
+ // watcher, or operators can chain explicitly via the source registry).
654
+ const { REGRESSION_WATCHER_SOURCE } = require('./cve-regression-watcher');
655
+
646
656
  const ALL_SOURCES = {
647
657
  kev: KEV_SOURCE,
648
658
  epss: EPSS_SOURCE,
@@ -652,6 +662,7 @@ const ALL_SOURCES = {
652
662
  ghsa: GHSA_SOURCE,
653
663
  osv: OSV_SOURCE,
654
664
  advisories: ADVISORIES_SOURCE,
665
+ 'cve-regression-watcher': REGRESSION_WATCHER_SOURCE,
655
666
  };
656
667
 
657
668
  // --- Cache-mode helpers ------------------------------------------------
@@ -140,6 +140,40 @@ const FEEDS = [
140
140
  kind: 'rss',
141
141
  description: 'Embrace the Red (Johann Rehberger) — AI-tool prompt-injection + agentic-AI research. Anchored CVE-2025-53773 (Copilot YOLO mode) and the agentic-IDE host-execution class.',
142
142
  },
143
+ // v0.13.17 additions — closes the "researcher GitHub-release drop +
144
+ // tech-press writeup" intake gap surfaced by the four Nightmare-Eclipse
145
+ // catalog entries (MiniPlasma / YellowKey / GreenPlasma / UnDefend).
146
+ // The 12-feed set polled advisory venues + vendor research blogs but
147
+ // had no coverage for either (a) named researcher handles whose prior
148
+ // drops are already in the catalog (BlueHammer / CVE-2026-33825 was in,
149
+ // four sibling drops were not) nor (b) the canonical tech-press
150
+ // venues that surface "researcher dropped PoC on GitHub, no advisory
151
+ // yet" events. The bleepingcomputer-security + thehackernews feeds
152
+ // close (b); the nightmare-eclipse-github feed closes (a) as the
153
+ // anchor handle for the v0.13.17 backfill. NEW-CTRL-073 generalises
154
+ // the handle-tracker control — additional handles get registered as
155
+ // their drops land in the catalog. See also NEW-CTRL-074 (CVE-
156
+ // regression-watcher) in lib/cve-regression-watcher.js for the
157
+ // complementary detection method that catches re-broken historical
158
+ // CVEs without depending on a new ID being assigned.
159
+ {
160
+ name: 'bleepingcomputer-security',
161
+ url: 'https://www.bleepingcomputer.com/feed/',
162
+ kind: 'rss',
163
+ description: 'BleepingComputer security feed — canonical tech-press venue for "researcher dropped PoC on GitHub, no advisory yet" events (BlueHammer / RedSun / UnDefend / MiniPlasma / YellowKey / GreenPlasma anchor cluster). CVE-ID-bearing items extracted via the standard CVE_RE; non-CVE researcher drops surface as named-handle items consumed by NEW-CTRL-073 handle tracker. v0.13.17.',
164
+ },
165
+ {
166
+ name: 'thehackernews',
167
+ url: 'https://feeds.feedburner.com/TheHackersNews',
168
+ kind: 'rss',
169
+ description: 'The Hacker News RSS — canonical tech-press venue for PoC drops + zero-day weaponization writeups. Anchored MiniPlasma 2026-05-14 writeup which the 12-feed set missed. CVE-ID extraction same as bleepingcomputer-security. v0.13.17.',
170
+ },
171
+ {
172
+ name: 'nightmare-eclipse-github',
173
+ url: 'https://api.github.com/users/Nightmare-Eclipse/events/public',
174
+ kind: 'github-events',
175
+ description: 'GitHub public-events feed for the Nightmare-Eclipse / Chaotic Eclipse researcher handle. Anchored the BlueHammer (CVE-2026-33825) + MiniPlasma cluster — the handle is the canonical signal source for unpatched Windows LPE / BitLocker / Defender drops since April 2026. NEW-CTRL-073 handle-tracker class; additional handles registered as their drops land in the catalog. v0.13.17.',
176
+ },
143
177
  ];
144
178
 
145
179
  // Permissive CVE-ID matcher. The official format is CVE-YYYY-NNNN+ but
@@ -215,6 +249,57 @@ function parseCsafIndex(text) {
215
249
  });
216
250
  }
217
251
 
252
+ /**
253
+ * GitHub public-events parser — NEW-CTRL-073 researcher-handle tracker.
254
+ *
255
+ * Input is the JSON body of `https://api.github.com/users/<handle>/events/public`
256
+ * (array of event objects). Output flattens each event into a standard
257
+ * { title, link, published, body, researcher_handle } shape that
258
+ * extractCveIds() can scan and the diff-deduplicator can route. Events of
259
+ * interest: PushEvent (release commits), ReleaseEvent (tagged drops),
260
+ * CreateEvent (tag / branch creation often signals an upcoming drop),
261
+ * PublicEvent (a private repo flipped public — frequent drop pattern).
262
+ *
263
+ * The diff carries `researcher_handle` so callers + tests can assert the
264
+ * handle-tracker fired even when no CVE ID is present in the commit text
265
+ * (the MiniPlasma class — drop names a researcher-coined alias rather
266
+ * than a CVE ID until MITRE / vendor assigns).
267
+ */
268
+ function parseGitHubEvents(body, feed) {
269
+ let arr;
270
+ try {
271
+ arr = JSON.parse(body);
272
+ } catch (e) {
273
+ return [];
274
+ }
275
+ if (!Array.isArray(arr)) return [];
276
+ const handle = (feed.url.match(/users\/([^/]+)\/events/) || [])[1] || null;
277
+ const out = [];
278
+ const wanted = new Set(['PushEvent', 'ReleaseEvent', 'CreateEvent', 'PublicEvent']);
279
+ for (const ev of arr) {
280
+ if (!ev || typeof ev !== 'object') continue;
281
+ if (!wanted.has(ev.type)) continue;
282
+ const repo = (ev.repo && ev.repo.name) || '';
283
+ const payload = ev.payload || {};
284
+ let label = '';
285
+ if (ev.type === 'ReleaseEvent') label = `release ${(payload.release && payload.release.tag_name) || ''} — ${(payload.release && payload.release.name) || ''}`;
286
+ else if (ev.type === 'PushEvent') label = `push ${payload.ref || ''} ${((payload.commits || []).map(c => c.message).join(' || '))}`;
287
+ else if (ev.type === 'CreateEvent') label = `create ${payload.ref_type || ''} ${payload.ref || ''} — ${payload.description || ''}`;
288
+ else if (ev.type === 'PublicEvent') label = 'repository made public';
289
+ const title = `[${ev.type}] ${repo} — ${label}`.slice(0, 240);
290
+ out.push({
291
+ title,
292
+ link: `https://github.com/${repo}`,
293
+ published: ev.created_at || '',
294
+ body: label,
295
+ researcher_handle: handle,
296
+ event_type: ev.type,
297
+ repo_name: repo,
298
+ });
299
+ }
300
+ return out;
301
+ }
302
+
218
303
  /**
219
304
  * Fetch a feed body. In fixture / cache modes, read from disk.
220
305
  */
@@ -223,7 +308,7 @@ async function fetchFeed(feed, ctx) {
223
308
  return { ok: true, body: ctx.fixtures.advisories[feed.name] };
224
309
  }
225
310
  if (ctx.cacheDir) {
226
- const ext = feed.kind === 'csaf-index' ? '.txt' : '.xml';
311
+ const ext = feed.kind === 'csaf-index' ? '.txt' : feed.kind === 'github-events' ? '.json' : '.xml';
227
312
  const p = path.join(ctx.cacheDir, 'advisories', `${feed.name}${ext}`);
228
313
  if (!fs.existsSync(p)) return { ok: false, error: `cache miss: ${p}` };
229
314
  return { ok: true, body: fs.readFileSync(p, 'utf8') };
@@ -253,27 +338,61 @@ async function checkFeed(feed, ctx) {
253
338
  items = parseCsafIndex(res.body);
254
339
  // Flatten cves_from_filename onto cve_ids field uniformly.
255
340
  items = items.map((it) => ({ ...it, cve_ids: it.cves_from_filename || [] }));
341
+ } else if (feed.kind === 'github-events') {
342
+ items = parseGitHubEvents(res.body, feed);
343
+ items = items.map((it) => ({ ...it, cve_ids: extractCveIds(`${it.title} ${it.body} ${it.link}`) }));
256
344
  } else {
257
345
  items = parseRssAtom(res.body);
258
346
  items = items.map((it) => ({ ...it, cve_ids: extractCveIds(`${it.title} ${it.body} ${it.link}`) }));
259
347
  }
260
348
  const diffs = [];
349
+ // v0.13.17: surface EVERY extracted CVE-ID — including IDs already in
350
+ // catalog — for NEW-CTRL-074 (cve-regression-watcher) consumption. The
351
+ // diffs[] array remains filtered to NOT-IN-CATALOG so existing callers
352
+ // see no behavior change, but a parallel observations[] carries the
353
+ // full set with the in_catalog flag preserved. The regression watcher
354
+ // routes historical-CVE references (e.g. CVE-2020-17103) through this
355
+ // path so its action: "annotate" verdict can fire even when the
356
+ // historical entry IS a catalog key.
357
+ const observations = [];
261
358
  for (const it of items) {
359
+ // Standard CVE-ID-bearing diff (every feed kind).
262
360
  for (const cveId of it.cve_ids) {
263
361
  const inCatalog = !!ctx.cveCatalog[cveId];
362
+ const baseRecord = {
363
+ id: cveId,
364
+ source: feed.name,
365
+ advisory_url: it.link || feed.url,
366
+ disclosed_at: it.published || null,
367
+ title: it.title.slice(0, 200),
368
+ in_catalog: inCatalog,
369
+ };
370
+ observations.push(baseRecord);
264
371
  if (!inCatalog) {
265
- diffs.push({
266
- id: cveId,
267
- source: feed.name,
268
- advisory_url: it.link || feed.url,
269
- disclosed_at: it.published || null,
270
- title: it.title.slice(0, 200),
271
- in_catalog: false,
272
- });
372
+ diffs.push({ ...baseRecord });
273
373
  }
274
374
  }
375
+ // NEW-CTRL-073 — researcher-handle diff. Github-events surfaces drop
376
+ // activity even when no CVE ID is present yet (the MiniPlasma class:
377
+ // researcher publishes alias name, vendor advisory + CVE assignment
378
+ // arrive days / weeks later). The diff carries handle + event_type
379
+ // so downstream triage can prioritise without waiting for an ID.
380
+ if (feed.kind === 'github-events' && it.cve_ids.length === 0 && (it.event_type === 'ReleaseEvent' || it.event_type === 'PublicEvent')) {
381
+ diffs.push({
382
+ id: `HANDLE:${it.researcher_handle || feed.name}:${it.repo_name || 'unknown'}@${(it.published || '').slice(0, 10)}`,
383
+ source: feed.name,
384
+ advisory_url: it.link || feed.url,
385
+ disclosed_at: it.published || null,
386
+ title: it.title.slice(0, 200),
387
+ in_catalog: false,
388
+ researcher_handle: it.researcher_handle || null,
389
+ repo_name: it.repo_name || null,
390
+ event_type: it.event_type,
391
+ triage_class: 'researcher-handle-drop',
392
+ });
393
+ }
275
394
  }
276
- return { diffs, errors: 0, status: 'ok' };
395
+ return { diffs, observations, errors: 0, status: 'ok' };
277
396
  }
278
397
 
279
398
  /**
@@ -286,9 +405,11 @@ const ADVISORIES_SOURCE = {
286
405
  async fetchDiff(ctx) {
287
406
  const results = await Promise.all(FEEDS.map((feed) => checkFeed(feed, ctx)));
288
407
  const allDiffs = [];
408
+ const allObservations = [];
289
409
  let unreachable = 0;
290
410
  for (const r of results) {
291
411
  allDiffs.push(...r.diffs);
412
+ if (Array.isArray(r.observations)) allObservations.push(...r.observations);
292
413
  if (r.status === 'unreachable') unreachable++;
293
414
  }
294
415
  // Deduplicate by CVE-ID across feeds — multiple advisories for the
@@ -309,14 +430,43 @@ const ADVISORIES_SOURCE = {
309
430
  delete d.advisory_url;
310
431
  return d;
311
432
  });
433
+ // v0.13.17: observations are the FULL per-feed extraction list,
434
+ // including in-catalog CVE-IDs. NEW-CTRL-074 cve-regression-watcher
435
+ // consumes this so an "annotate" verdict can fire on historical IDs
436
+ // (CVE-2020-17103) that are already keys in the catalog — diffs[]
437
+ // alone would have filtered them out and broken the watcher's
438
+ // annotate path. Dedupe + sources[] follow the diffs[] pattern so
439
+ // downstream consumers see one record per CVE with all observing
440
+ // feed names listed.
441
+ const obsByCve = new Map();
442
+ for (const o of allObservations) {
443
+ // The github-events handle-drop diffs use a HANDLE:* id shape and
444
+ // are not CVE observations — skip them.
445
+ if (!/^CVE-/.test(o.id)) continue;
446
+ if (!obsByCve.has(o.id)) {
447
+ obsByCve.set(o.id, {
448
+ id: o.id,
449
+ sources: [o.source],
450
+ advisory_urls: [o.advisory_url],
451
+ first_title: o.title,
452
+ in_catalog: o.in_catalog,
453
+ });
454
+ } else {
455
+ const existing = obsByCve.get(o.id);
456
+ if (!existing.sources.includes(o.source)) existing.sources.push(o.source);
457
+ if (!existing.advisory_urls.includes(o.advisory_url)) existing.advisory_urls.push(o.advisory_url);
458
+ }
459
+ }
460
+ const observations = Array.from(obsByCve.values());
312
461
  const status =
313
462
  unreachable === 0 ? 'ok' :
314
463
  unreachable === FEEDS.length ? 'unreachable' : 'partial';
315
464
  return {
316
465
  status,
317
466
  diffs,
467
+ observations,
318
468
  errors: unreachable,
319
- summary: `${FEEDS.length - unreachable}/${FEEDS.length} feeds reachable; ${diffs.length} new CVE references found across primary advisory sources`,
469
+ summary: `${FEEDS.length - unreachable}/${FEEDS.length} feeds reachable; ${diffs.length} new CVE references found, ${observations.length} total CVE observations across primary advisory sources`,
320
470
  };
321
471
  },
322
472
  // Report-only: no applyDiff. Operators route promising CVE IDs through
@@ -339,4 +489,5 @@ module.exports = {
339
489
  extractCveIds,
340
490
  parseRssAtom,
341
491
  parseCsafIndex,
492
+ parseGitHubEvents,
342
493
  };