@blamejs/exceptd-skills 0.13.15 → 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.
- package/AGENTS.md +5 -2
- package/CHANGELOG.md +42 -0
- package/README.md +3 -3
- package/data/_indexes/_meta.json +9 -9
- package/data/_indexes/activity-feed.json +5 -5
- package/data/_indexes/catalog-summaries.json +5 -5
- package/data/_indexes/chains.json +36194 -5006
- package/data/_indexes/frequency.json +50 -1
- package/data/attack-techniques.json +310 -1
- package/data/cve-catalog.json +26215 -4
- package/data/cwe-catalog.json +1090 -20
- package/data/framework-control-gaps.json +866 -2
- package/data/zeroday-lessons.json +10758 -0
- package/lib/cve-regression-watcher.js +218 -0
- package/lib/refresh-external.js +11 -0
- package/lib/source-advisories.js +162 -11
- package/manifest.json +44 -44
- package/package.json +2 -2
- package/sbom.cdx.json +46 -31
|
@@ -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
|
+
};
|
package/lib/refresh-external.js
CHANGED
|
@@ -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 ------------------------------------------------
|
package/lib/source-advisories.js
CHANGED
|
@@ -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
|
};
|