@blamejs/exceptd-skills 0.10.1 → 0.10.3
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 +51 -0
- package/CHANGELOG.md +72 -0
- package/bin/exceptd.js +468 -37
- package/data/_indexes/_meta.json +2 -2
- package/data/playbooks/crypto-codebase.json +1387 -0
- package/data/playbooks/kernel.json +1 -1
- package/data/playbooks/library-author.json +1792 -0
- package/lib/framework-gap.js +17 -1
- package/lib/playbook-runner.js +146 -11
- package/lib/prefetch.js +9 -1
- package/manifest-snapshot.json +1 -1
- package/manifest.json +39 -39
- package/orchestrator/index.js +98 -8
- package/package.json +2 -1
- package/sbom.cdx.json +6 -6
- package/sources/README.md +170 -0
- package/sources/validators/atlas-validator.js +158 -0
- package/sources/validators/cve-validator.js +277 -0
- package/sources/validators/index.js +86 -0
- package/sources/validators/rfc-validator.js +165 -0
- package/sources/validators/version-pin-validator.js +144 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* cve-validator.js — Cross-check local CVE catalog entries against NVD, CISA KEV, and EPSS.
|
|
5
|
+
*
|
|
6
|
+
* Zero npm dependencies. Node 24 stdlib only.
|
|
7
|
+
*
|
|
8
|
+
* Exported:
|
|
9
|
+
* validateCve(cveId, localCatalogEntry) -> Promise<ValidationResult>
|
|
10
|
+
* getKevCache() -> Map (KEV map by CVE ID, lazy-loaded)
|
|
11
|
+
* resetKevCache() -> void (testing helper)
|
|
12
|
+
*
|
|
13
|
+
* ValidationResult shape:
|
|
14
|
+
* {
|
|
15
|
+
* cve_id: 'CVE-YYYY-NNNNN',
|
|
16
|
+
* status: 'match' | 'drift' | 'unreachable' | 'missing',
|
|
17
|
+
* discrepancies: [ { field, local, fetched, severity } ],
|
|
18
|
+
* fetched: {
|
|
19
|
+
* cvss_score, cvss_vector, in_kev, kev_date,
|
|
20
|
+
* epss: { score, percentile, date } | null,
|
|
21
|
+
* sources: { nvd, kev, epss }
|
|
22
|
+
* },
|
|
23
|
+
* local: {
|
|
24
|
+
* cvss_score, cvss_vector, cisa_kev, cisa_kev_date,
|
|
25
|
+
* epss_score, epss_percentile, epss_date
|
|
26
|
+
* },
|
|
27
|
+
* drift: { local_epss, fetched_epss } | null // populated when EPSS drift is present
|
|
28
|
+
* }
|
|
29
|
+
*
|
|
30
|
+
* Network resilience:
|
|
31
|
+
* - Every fetch has a 10s AbortController timeout.
|
|
32
|
+
* - Network/parse errors return { status: 'unreachable', ... } — never throw.
|
|
33
|
+
* - The KEV feed is fetched once per process and cached in module-level memory.
|
|
34
|
+
* - The EPSS API is queried per-CVE (FIRST recommends per-CVE lookups for fresh data).
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
const NVD_API = 'https://services.nvd.nist.gov/rest/json/cves/2.0?cveId=';
|
|
38
|
+
const KEV_FEED = 'https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json';
|
|
39
|
+
const EPSS_API = 'https://api.first.org/data/v1/epss?cve=';
|
|
40
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
41
|
+
const EPSS_DRIFT_THRESHOLD = 0.05; // |Δscore| or |Δpercentile| > 0.05 flags drift
|
|
42
|
+
const USER_AGENT = 'exceptd-security/cve-validator (+https://exceptd.com)';
|
|
43
|
+
|
|
44
|
+
let kevCachePromise = null; // Promise<Map<cveId, kevEntry>> | null
|
|
45
|
+
let kevCacheError = null; // { code, message } if last attempt failed (per process)
|
|
46
|
+
|
|
47
|
+
async function timedFetch(url) {
|
|
48
|
+
const controller = new AbortController();
|
|
49
|
+
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
50
|
+
try {
|
|
51
|
+
const res = await fetch(url, {
|
|
52
|
+
signal: controller.signal,
|
|
53
|
+
headers: { 'User-Agent': USER_AGENT, Accept: 'application/json' },
|
|
54
|
+
});
|
|
55
|
+
if (!res.ok) {
|
|
56
|
+
return { ok: false, error: `HTTP ${res.status}`, status: res.status };
|
|
57
|
+
}
|
|
58
|
+
const json = await res.json();
|
|
59
|
+
return { ok: true, json };
|
|
60
|
+
} catch (err) {
|
|
61
|
+
const code = err.name === 'AbortError' ? 'timeout' : (err.code || 'network_error');
|
|
62
|
+
return { ok: false, error: `${code}: ${err.message}`, code };
|
|
63
|
+
} finally {
|
|
64
|
+
clearTimeout(timer);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function loadKevCache() {
|
|
69
|
+
if (kevCachePromise) return kevCachePromise;
|
|
70
|
+
kevCachePromise = (async () => {
|
|
71
|
+
const result = await timedFetch(KEV_FEED);
|
|
72
|
+
if (!result.ok) {
|
|
73
|
+
kevCacheError = { code: 'kev_unreachable', message: result.error };
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
const map = new Map();
|
|
77
|
+
const items = Array.isArray(result.json?.vulnerabilities) ? result.json.vulnerabilities : [];
|
|
78
|
+
for (const v of items) {
|
|
79
|
+
if (v && v.cveID) map.set(v.cveID, v);
|
|
80
|
+
}
|
|
81
|
+
return map;
|
|
82
|
+
})();
|
|
83
|
+
return kevCachePromise;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function getKevCache() {
|
|
87
|
+
return kevCachePromise;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function resetKevCache() {
|
|
91
|
+
kevCachePromise = null;
|
|
92
|
+
kevCacheError = null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function extractNvdCvss(nvdJson) {
|
|
96
|
+
// NVD response: vulnerabilities[0].cve.metrics.cvssMetricV31[0].cvssData
|
|
97
|
+
const vuln = nvdJson?.vulnerabilities?.[0]?.cve;
|
|
98
|
+
if (!vuln) return null;
|
|
99
|
+
const metrics = vuln.metrics || {};
|
|
100
|
+
const ordered = [
|
|
101
|
+
...(metrics.cvssMetricV31 || []),
|
|
102
|
+
...(metrics.cvssMetricV30 || []),
|
|
103
|
+
...(metrics.cvssMetricV2 || []),
|
|
104
|
+
];
|
|
105
|
+
// Prefer Primary type if present
|
|
106
|
+
const primary = ordered.find(m => m.type === 'Primary') || ordered[0];
|
|
107
|
+
if (!primary?.cvssData) return null;
|
|
108
|
+
return {
|
|
109
|
+
score: typeof primary.cvssData.baseScore === 'number' ? primary.cvssData.baseScore : null,
|
|
110
|
+
vector: primary.cvssData.vectorString || null,
|
|
111
|
+
source: primary.source || null,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function pushDiscrepancy(list, field, local, fetched, severity = 'warning') {
|
|
116
|
+
list.push({ field, local, fetched, severity });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function extractEpss(epssJson, cveId) {
|
|
120
|
+
// FIRST EPSS response shape: { status: "OK", data: [ { cve, epss, percentile, date } ] }
|
|
121
|
+
const data = Array.isArray(epssJson?.data) ? epssJson.data : [];
|
|
122
|
+
if (data.length === 0) return null;
|
|
123
|
+
const row = data.find(r => r?.cve === cveId) || data[0];
|
|
124
|
+
if (!row) return null;
|
|
125
|
+
// EPSS returns strings; coerce defensively.
|
|
126
|
+
const score = row.epss !== undefined && row.epss !== null ? Number(row.epss) : null;
|
|
127
|
+
const percentile = row.percentile !== undefined && row.percentile !== null ? Number(row.percentile) : null;
|
|
128
|
+
return {
|
|
129
|
+
score: Number.isFinite(score) ? score : null,
|
|
130
|
+
percentile: Number.isFinite(percentile) ? percentile : null,
|
|
131
|
+
date: typeof row.date === 'string' ? row.date : null,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function validateCve(cveId, localEntry) {
|
|
136
|
+
if (!cveId || typeof cveId !== 'string') {
|
|
137
|
+
throw new TypeError('validateCve: cveId must be a string');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const local = {
|
|
141
|
+
cvss_score: localEntry?.cvss_score ?? null,
|
|
142
|
+
cvss_vector: localEntry?.cvss_vector ?? null,
|
|
143
|
+
cisa_kev: localEntry?.cisa_kev ?? null,
|
|
144
|
+
cisa_kev_date: localEntry?.cisa_kev_date ?? null,
|
|
145
|
+
epss_score: localEntry?.epss_score ?? null,
|
|
146
|
+
epss_percentile: localEntry?.epss_percentile ?? null,
|
|
147
|
+
epss_date: localEntry?.epss_date ?? null,
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const fetched = {
|
|
151
|
+
cvss_score: null,
|
|
152
|
+
cvss_vector: null,
|
|
153
|
+
in_kev: null,
|
|
154
|
+
kev_date: null,
|
|
155
|
+
epss: null,
|
|
156
|
+
sources: { nvd: null, kev: null, epss: null },
|
|
157
|
+
};
|
|
158
|
+
const discrepancies = [];
|
|
159
|
+
let drift = null;
|
|
160
|
+
|
|
161
|
+
// Run NVD + KEV + EPSS in parallel.
|
|
162
|
+
const [nvdResult, kevMap, epssResult] = await Promise.all([
|
|
163
|
+
timedFetch(NVD_API + encodeURIComponent(cveId)),
|
|
164
|
+
loadKevCache(),
|
|
165
|
+
timedFetch(EPSS_API + encodeURIComponent(cveId)),
|
|
166
|
+
]);
|
|
167
|
+
|
|
168
|
+
// --- NVD branch ---
|
|
169
|
+
let nvdReachable = false;
|
|
170
|
+
let cveFoundInNvd = false;
|
|
171
|
+
if (nvdResult.ok) {
|
|
172
|
+
nvdReachable = true;
|
|
173
|
+
const totalResults = nvdResult.json?.totalResults ?? nvdResult.json?.vulnerabilities?.length ?? 0;
|
|
174
|
+
if (totalResults === 0) {
|
|
175
|
+
fetched.sources.nvd = { reachable: true, found: false };
|
|
176
|
+
} else {
|
|
177
|
+
cveFoundInNvd = true;
|
|
178
|
+
const cvss = extractNvdCvss(nvdResult.json);
|
|
179
|
+
if (cvss) {
|
|
180
|
+
fetched.cvss_score = cvss.score;
|
|
181
|
+
fetched.cvss_vector = cvss.vector;
|
|
182
|
+
fetched.sources.nvd = { reachable: true, found: true, source: cvss.source };
|
|
183
|
+
} else {
|
|
184
|
+
fetched.sources.nvd = { reachable: true, found: true, source: null, note: 'no cvss metrics in NVD response' };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
} else {
|
|
188
|
+
fetched.sources.nvd = { reachable: false, error: nvdResult.error };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// --- KEV branch ---
|
|
192
|
+
if (kevMap === null) {
|
|
193
|
+
fetched.sources.kev = { reachable: false, error: kevCacheError?.message || 'unknown' };
|
|
194
|
+
} else {
|
|
195
|
+
const hit = kevMap.get(cveId);
|
|
196
|
+
fetched.in_kev = !!hit;
|
|
197
|
+
fetched.kev_date = hit?.dateAdded || null;
|
|
198
|
+
fetched.sources.kev = { reachable: true, total_entries: kevMap.size };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// --- EPSS branch ---
|
|
202
|
+
let epssReachable = false;
|
|
203
|
+
if (epssResult.ok) {
|
|
204
|
+
epssReachable = true;
|
|
205
|
+
const epss = extractEpss(epssResult.json, cveId);
|
|
206
|
+
if (epss) {
|
|
207
|
+
fetched.epss = epss;
|
|
208
|
+
fetched.sources.epss = { reachable: true, found: true, date: epss.date };
|
|
209
|
+
} else {
|
|
210
|
+
fetched.sources.epss = { reachable: true, found: false };
|
|
211
|
+
}
|
|
212
|
+
} else {
|
|
213
|
+
fetched.sources.epss = { reachable: false, error: epssResult.error };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// --- Status decision ---
|
|
217
|
+
// Only declare 'unreachable' if every source failed. EPSS being down alone
|
|
218
|
+
// should not block NVD/KEV drift detection.
|
|
219
|
+
if (!nvdReachable && (kevMap === null) && !epssReachable) {
|
|
220
|
+
return { cve_id: cveId, status: 'unreachable', discrepancies, fetched, local, drift };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (nvdReachable && !cveFoundInNvd) {
|
|
224
|
+
return { cve_id: cveId, status: 'missing', discrepancies, fetched, local, drift };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// --- Compare CVSS (only if NVD reachable & has data) ---
|
|
228
|
+
if (cveFoundInNvd && fetched.cvss_score !== null && local.cvss_score !== null) {
|
|
229
|
+
if (Math.abs(fetched.cvss_score - local.cvss_score) > 0.05) {
|
|
230
|
+
pushDiscrepancy(discrepancies, 'cvss_score', local.cvss_score, fetched.cvss_score, 'high');
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (cveFoundInNvd && fetched.cvss_vector && local.cvss_vector) {
|
|
234
|
+
if (fetched.cvss_vector !== local.cvss_vector) {
|
|
235
|
+
pushDiscrepancy(discrepancies, 'cvss_vector', local.cvss_vector, fetched.cvss_vector, 'medium');
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// --- Compare KEV (only if KEV reachable) ---
|
|
240
|
+
if (kevMap !== null) {
|
|
241
|
+
if (typeof local.cisa_kev === 'boolean' && local.cisa_kev !== fetched.in_kev) {
|
|
242
|
+
pushDiscrepancy(discrepancies, 'cisa_kev', local.cisa_kev, fetched.in_kev, 'high');
|
|
243
|
+
}
|
|
244
|
+
if (local.cisa_kev_date && fetched.kev_date && local.cisa_kev_date !== fetched.kev_date) {
|
|
245
|
+
pushDiscrepancy(discrepancies, 'cisa_kev_date', local.cisa_kev_date, fetched.kev_date, 'low');
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// --- Compare EPSS (only if EPSS reachable + both sides have data) ---
|
|
250
|
+
if (epssReachable && fetched.epss) {
|
|
251
|
+
const fScore = fetched.epss.score;
|
|
252
|
+
const fPct = fetched.epss.percentile;
|
|
253
|
+
const lScore = typeof local.epss_score === 'number' ? local.epss_score : null;
|
|
254
|
+
const lPct = typeof local.epss_percentile === 'number' ? local.epss_percentile : null;
|
|
255
|
+
|
|
256
|
+
let epssDrift = false;
|
|
257
|
+
if (lScore !== null && fScore !== null && Math.abs(fScore - lScore) > EPSS_DRIFT_THRESHOLD) {
|
|
258
|
+
pushDiscrepancy(discrepancies, 'epss_score', lScore, fScore, 'medium');
|
|
259
|
+
epssDrift = true;
|
|
260
|
+
}
|
|
261
|
+
if (lPct !== null && fPct !== null && Math.abs(fPct - lPct) > EPSS_DRIFT_THRESHOLD) {
|
|
262
|
+
pushDiscrepancy(discrepancies, 'epss_percentile', lPct, fPct, 'medium');
|
|
263
|
+
epssDrift = true;
|
|
264
|
+
}
|
|
265
|
+
if (epssDrift) {
|
|
266
|
+
drift = {
|
|
267
|
+
local_epss: { score: lScore, percentile: lPct, date: local.epss_date },
|
|
268
|
+
fetched_epss: { score: fScore, percentile: fPct, date: fetched.epss.date },
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const status = discrepancies.length === 0 ? 'match' : 'drift';
|
|
274
|
+
return { cve_id: cveId, status, discrepancies, fetched, local, drift };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
module.exports = { validateCve, getKevCache, resetKevCache };
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* sources/validators/index.js — barrel export.
|
|
5
|
+
*
|
|
6
|
+
* Re-exports:
|
|
7
|
+
* - validateCve(cveId, localEntry) — NVD + CISA KEV cross-check (per CVE)
|
|
8
|
+
* - validateAtlasVersion() — Confirm pinned ATLAS version matches upstream
|
|
9
|
+
* - validateAllCves(catalog, opts?) — Aggregate CVE validation across the local catalog
|
|
10
|
+
*
|
|
11
|
+
* Aggregate report shape:
|
|
12
|
+
* {
|
|
13
|
+
* generated_at: ISO timestamp,
|
|
14
|
+
* total: number,
|
|
15
|
+
* by_status: { match, drift, unreachable, missing },
|
|
16
|
+
* drift_count: number,
|
|
17
|
+
* results: ValidationResult[] // see cve-validator.js
|
|
18
|
+
* }
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const { validateCve, getKevCache, resetKevCache } = require('./cve-validator');
|
|
22
|
+
const { validateAtlasVersion } = require('./atlas-validator');
|
|
23
|
+
const { validateRfc, validateAllRfcs } = require('./rfc-validator');
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {object} catalog - parsed data/cve-catalog.json (the whole object incl. _meta)
|
|
27
|
+
* @param {object} [opts]
|
|
28
|
+
* @param {number} [opts.concurrency=4] - parallel NVD lookups (NVD allows 5 rps anonymously)
|
|
29
|
+
* @returns {Promise<object>} aggregate report
|
|
30
|
+
*/
|
|
31
|
+
async function validateAllCves(catalog, opts = {}) {
|
|
32
|
+
const concurrency = Math.max(1, Math.min(8, opts.concurrency || 4));
|
|
33
|
+
if (!catalog || typeof catalog !== 'object') {
|
|
34
|
+
throw new TypeError('validateAllCves: catalog must be an object');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const ids = Object.keys(catalog).filter(k => /^CVE-\d{4}-\d{4,7}$/.test(k));
|
|
38
|
+
const results = [];
|
|
39
|
+
const by_status = { match: 0, drift: 0, unreachable: 0, missing: 0 };
|
|
40
|
+
|
|
41
|
+
// Simple windowed concurrency — no extra deps.
|
|
42
|
+
let cursor = 0;
|
|
43
|
+
async function worker() {
|
|
44
|
+
while (cursor < ids.length) {
|
|
45
|
+
const idx = cursor++;
|
|
46
|
+
const id = ids[idx];
|
|
47
|
+
try {
|
|
48
|
+
const res = await validateCve(id, catalog[id]);
|
|
49
|
+
results[idx] = res;
|
|
50
|
+
by_status[res.status] = (by_status[res.status] || 0) + 1;
|
|
51
|
+
} catch (err) {
|
|
52
|
+
// Defensive: validateCve already swallows network errors; this is a logic error.
|
|
53
|
+
results[idx] = {
|
|
54
|
+
cve_id: id,
|
|
55
|
+
status: 'unreachable',
|
|
56
|
+
discrepancies: [],
|
|
57
|
+
fetched: { sources: { nvd: null, kev: null } },
|
|
58
|
+
local: catalog[id] || null,
|
|
59
|
+
error: err.message,
|
|
60
|
+
};
|
|
61
|
+
by_status.unreachable++;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const workers = Array.from({ length: Math.min(concurrency, ids.length) }, () => worker());
|
|
67
|
+
await Promise.all(workers);
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
generated_at: new Date().toISOString(),
|
|
71
|
+
total: ids.length,
|
|
72
|
+
by_status,
|
|
73
|
+
drift_count: by_status.drift,
|
|
74
|
+
results,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = {
|
|
79
|
+
validateCve,
|
|
80
|
+
validateAtlasVersion,
|
|
81
|
+
validateAllCves,
|
|
82
|
+
validateRfc,
|
|
83
|
+
validateAllRfcs,
|
|
84
|
+
getKevCache,
|
|
85
|
+
resetKevCache,
|
|
86
|
+
};
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* sources/validators/rfc-validator.js
|
|
4
|
+
*
|
|
5
|
+
* Cross-checks a single entry from data/rfc-references.json against the
|
|
6
|
+
* IETF Datatracker. Mirrors the shape of cve-validator.js:
|
|
7
|
+
*
|
|
8
|
+
* validateRfc(id, entry) → { id, status, discrepancies, fetched, local }
|
|
9
|
+
*
|
|
10
|
+
* status is one of:
|
|
11
|
+
* match — Datatracker view agrees with the local entry on status,
|
|
12
|
+
* errata count, and replaces/replaced-by relationships.
|
|
13
|
+
* drift — at least one of those fields disagrees.
|
|
14
|
+
* missing — Datatracker does not have this RFC / draft.
|
|
15
|
+
* unreachable — network failed or the request timed out.
|
|
16
|
+
*
|
|
17
|
+
* Zero external dependencies. Node 24 stdlib (fetch + AbortController).
|
|
18
|
+
*
|
|
19
|
+
* The Datatracker API is generous about rate limits for read-only queries,
|
|
20
|
+
* but every fetch wraps an AbortController with a 10-second timeout so an
|
|
21
|
+
* airgapped CI runner never hangs.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const TIMEOUT_MS = 10_000;
|
|
25
|
+
|
|
26
|
+
const DATATRACKER_RFC_BASE = 'https://datatracker.ietf.org/api/v1/doc/document/';
|
|
27
|
+
const DATATRACKER_DRAFT_BASE = 'https://datatracker.ietf.org/api/v1/doc/document/';
|
|
28
|
+
|
|
29
|
+
async function fetchWithTimeout(url, opts = {}) {
|
|
30
|
+
const ac = new AbortController();
|
|
31
|
+
const t = setTimeout(() => ac.abort(), TIMEOUT_MS);
|
|
32
|
+
try {
|
|
33
|
+
const res = await fetch(url, { ...opts, signal: ac.signal });
|
|
34
|
+
return res;
|
|
35
|
+
} finally {
|
|
36
|
+
clearTimeout(t);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function rfcNumberFromKey(id) {
|
|
41
|
+
// Catalog keys look like "RFC-8446". The Datatracker doc name is "rfc8446".
|
|
42
|
+
const m = id.match(/^RFC-(\d+)$/);
|
|
43
|
+
return m ? `rfc${m[1]}` : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function draftSlugFromKey(id) {
|
|
47
|
+
// Catalog keys look like "DRAFT-IETF-TLS-ECDHE-MLKEM". The Datatracker doc
|
|
48
|
+
// name is "draft-ietf-tls-ecdhe-mlkem" — lowercased, hyphenated.
|
|
49
|
+
const m = id.match(/^DRAFT-(.+)$/);
|
|
50
|
+
return m ? `draft-${m[1].toLowerCase()}` : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function fetchRfcDocument(name) {
|
|
54
|
+
// Datatracker API: /api/v1/doc/document/?name=rfc8446 returns a list with
|
|
55
|
+
// one entry. The fields we care about are `std_level` (status), the
|
|
56
|
+
// related "replaces" and "replaced-by" relationships, and the abstract.
|
|
57
|
+
try {
|
|
58
|
+
const url = `${DATATRACKER_RFC_BASE}?name=${encodeURIComponent(name)}&format=json`;
|
|
59
|
+
const res = await fetchWithTimeout(url, { headers: { 'Accept': 'application/json' } });
|
|
60
|
+
if (!res.ok) {
|
|
61
|
+
if (res.status === 404) return { status: 'missing' };
|
|
62
|
+
return { status: 'unreachable', reason: `HTTP ${res.status}` };
|
|
63
|
+
}
|
|
64
|
+
const body = await res.json();
|
|
65
|
+
const obj = body.objects && body.objects[0];
|
|
66
|
+
if (!obj) return { status: 'missing' };
|
|
67
|
+
return { status: 'found', obj };
|
|
68
|
+
} catch (err) {
|
|
69
|
+
return { status: 'unreachable', reason: err.message };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function compareEntry(local, upstream) {
|
|
74
|
+
const discrepancies = [];
|
|
75
|
+
|
|
76
|
+
// Datatracker exposes the standards-track level as `std_level`. Map between
|
|
77
|
+
// its short codes and the longer human strings the catalog uses.
|
|
78
|
+
const DATATRACKER_TO_LOCAL = {
|
|
79
|
+
'std': 'Internet Standard',
|
|
80
|
+
'ps': 'Proposed Standard',
|
|
81
|
+
'ds': 'Draft Standard',
|
|
82
|
+
'bcp': 'Best Current Practice',
|
|
83
|
+
'inf': 'Informational',
|
|
84
|
+
'exp': 'Experimental',
|
|
85
|
+
'his': 'Historic',
|
|
86
|
+
'unkn': 'Unknown',
|
|
87
|
+
};
|
|
88
|
+
const upstreamStatusCode = upstream.obj && upstream.obj.std_level;
|
|
89
|
+
const upstreamStatusHuman = upstreamStatusCode && DATATRACKER_TO_LOCAL[upstreamStatusCode];
|
|
90
|
+
if (local.status && upstreamStatusHuman && local.status !== upstreamStatusHuman) {
|
|
91
|
+
discrepancies.push(
|
|
92
|
+
`status drift: local "${local.status}" vs Datatracker "${upstreamStatusHuman}"`
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Datatracker doesn't expose errata count directly on this endpoint; we
|
|
97
|
+
// capture it as informational only and don't fail on it. A future
|
|
98
|
+
// enhancement could hit https://www.rfc-editor.org/errata/<rfcN>.json
|
|
99
|
+
// for the canonical count.
|
|
100
|
+
|
|
101
|
+
// If the upstream record indicates obsoletion (replaced-by populated), the
|
|
102
|
+
// local entry must reflect it.
|
|
103
|
+
// Datatracker exposes related docs through a separate `related_documents`
|
|
104
|
+
// endpoint — keep this simple for now and just surface the abstract URL so
|
|
105
|
+
// the operator can verify by hand. Anything more requires a second fetch.
|
|
106
|
+
|
|
107
|
+
return discrepancies;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function validateRfc(id, entry) {
|
|
111
|
+
let docName;
|
|
112
|
+
if (id.startsWith('RFC-')) docName = rfcNumberFromKey(id);
|
|
113
|
+
else if (id.startsWith('DRAFT-')) docName = draftSlugFromKey(id);
|
|
114
|
+
else {
|
|
115
|
+
return {
|
|
116
|
+
id,
|
|
117
|
+
status: 'drift',
|
|
118
|
+
discrepancies: [`unrecognized catalog key shape: ${id}`],
|
|
119
|
+
local: entry,
|
|
120
|
+
fetched: null,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!docName) {
|
|
125
|
+
return {
|
|
126
|
+
id,
|
|
127
|
+
status: 'drift',
|
|
128
|
+
discrepancies: [`could not derive Datatracker doc name from ${id}`],
|
|
129
|
+
local: entry,
|
|
130
|
+
fetched: null,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const fetched = await fetchRfcDocument(docName);
|
|
135
|
+
if (fetched.status === 'unreachable') {
|
|
136
|
+
return { id, status: 'unreachable', reason: fetched.reason, local: entry, fetched: null };
|
|
137
|
+
}
|
|
138
|
+
if (fetched.status === 'missing') {
|
|
139
|
+
return { id, status: 'missing', local: entry, fetched: null };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const discrepancies = compareEntry(entry, fetched);
|
|
143
|
+
return {
|
|
144
|
+
id,
|
|
145
|
+
status: discrepancies.length === 0 ? 'match' : 'drift',
|
|
146
|
+
discrepancies,
|
|
147
|
+
local: entry,
|
|
148
|
+
fetched: fetched.obj,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function validateAllRfcs(refs, { concurrency = 4 } = {}) {
|
|
153
|
+
const ids = Object.keys(refs).filter(k => !k.startsWith('_'));
|
|
154
|
+
const results = [];
|
|
155
|
+
for (let i = 0; i < ids.length; i += concurrency) {
|
|
156
|
+
const batch = ids.slice(i, i + concurrency);
|
|
157
|
+
const batchResults = await Promise.all(
|
|
158
|
+
batch.map(id => validateRfc(id, refs[id]))
|
|
159
|
+
);
|
|
160
|
+
results.push(...batchResults);
|
|
161
|
+
}
|
|
162
|
+
return results;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
module.exports = { validateRfc, validateAllRfcs };
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* sources/validators/version-pin-validator.js
|
|
4
|
+
*
|
|
5
|
+
* Checks pinned upstream catalog versions against their canonical release
|
|
6
|
+
* channels:
|
|
7
|
+
*
|
|
8
|
+
* - MITRE ATLAS: GitHub releases (mitre-atlas/atlas-data)
|
|
9
|
+
* - MITRE ATT&CK: GitHub releases (mitre-attack/attack-stix-data)
|
|
10
|
+
* - MITRE D3FEND: GitHub releases (mitre-d3fend/d3fend-data)
|
|
11
|
+
* - MITRE CWE: GitHub releases (mitre/cwe)
|
|
12
|
+
*
|
|
13
|
+
* Each check returns:
|
|
14
|
+
* { pin_name, local_version, latest_version, drift: bool, source_url, error? }
|
|
15
|
+
*
|
|
16
|
+
* Network resilience: 10s AbortController timeout per call. A failure is
|
|
17
|
+
* `{ error: ... }` — never throws. Version-pin drift is REPORT-ONLY: the
|
|
18
|
+
* upgrade requires audit per AGENTS.md Hard Rule #12, so refresh-external
|
|
19
|
+
* surfaces these as separate findings (GitHub issue, not an auto-apply PR).
|
|
20
|
+
*
|
|
21
|
+
* Zero npm deps. Node 24 stdlib only.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const TIMEOUT_MS = 10_000;
|
|
25
|
+
|
|
26
|
+
const PINS = [
|
|
27
|
+
{
|
|
28
|
+
pin_name: "atlas_version",
|
|
29
|
+
repo: "mitre-atlas/atlas-data",
|
|
30
|
+
local_path_hint: "manifest.json — atlas_version",
|
|
31
|
+
strip_v_prefix: true,
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
pin_name: "attack_version",
|
|
35
|
+
repo: "mitre-attack/attack-stix-data",
|
|
36
|
+
local_path_hint: "manifest.json — attack_version",
|
|
37
|
+
strip_v_prefix: true,
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
pin_name: "d3fend_version",
|
|
41
|
+
repo: "d3fend/d3fend-data",
|
|
42
|
+
local_path_hint: "data/d3fend-catalog.json _meta.version",
|
|
43
|
+
strip_v_prefix: true,
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
pin_name: "cwe_version",
|
|
47
|
+
repo: "mitre/cwe",
|
|
48
|
+
local_path_hint: "data/cwe-catalog.json _meta.version",
|
|
49
|
+
strip_v_prefix: true,
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
async function fetchWithTimeout(url, opts = {}) {
|
|
54
|
+
const ac = new AbortController();
|
|
55
|
+
const t = setTimeout(() => ac.abort(), TIMEOUT_MS);
|
|
56
|
+
try {
|
|
57
|
+
return await fetch(url, { ...opts, signal: ac.signal });
|
|
58
|
+
} finally {
|
|
59
|
+
clearTimeout(t);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function latestGithubRelease(repo) {
|
|
64
|
+
// Use the un-authenticated /releases endpoint and pick the most recent
|
|
65
|
+
// non-draft, non-prerelease entry. Limits: 60 anonymous requests/hour, more
|
|
66
|
+
// than enough for a weekly refresh job.
|
|
67
|
+
const url = `https://api.github.com/repos/${repo}/releases?per_page=5`;
|
|
68
|
+
try {
|
|
69
|
+
const res = await fetchWithTimeout(url, {
|
|
70
|
+
headers: { Accept: "application/vnd.github+json", "User-Agent": "exceptd-security/version-pin-validator" },
|
|
71
|
+
});
|
|
72
|
+
if (!res.ok) {
|
|
73
|
+
return { error: `HTTP ${res.status}`, source_url: url };
|
|
74
|
+
}
|
|
75
|
+
const arr = await res.json();
|
|
76
|
+
if (!Array.isArray(arr)) return { error: "unexpected payload", source_url: url };
|
|
77
|
+
const stable = arr.find((r) => !r.draft && !r.prerelease);
|
|
78
|
+
if (!stable) return { error: "no stable release found", source_url: url };
|
|
79
|
+
return { tag: stable.tag_name, name: stable.name, published_at: stable.published_at, source_url: stable.html_url };
|
|
80
|
+
} catch (err) {
|
|
81
|
+
return { error: err.message || "fetch failed", source_url: url };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function normalize(version, stripV) {
|
|
86
|
+
if (version == null) return null;
|
|
87
|
+
let v = String(version).trim();
|
|
88
|
+
if (stripV && v.startsWith("v")) v = v.slice(1);
|
|
89
|
+
return v;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Resolve the local pinned version for a given pin name.
|
|
94
|
+
* @param {string} pin_name
|
|
95
|
+
* @param {object} ctx - { manifest, cweCatalog, d3fendCatalog }
|
|
96
|
+
*/
|
|
97
|
+
function resolveLocalVersion(pin_name, ctx) {
|
|
98
|
+
switch (pin_name) {
|
|
99
|
+
case "atlas_version":
|
|
100
|
+
return ctx.manifest.atlas_version;
|
|
101
|
+
case "attack_version":
|
|
102
|
+
return ctx.manifest.attack_version;
|
|
103
|
+
case "cwe_version":
|
|
104
|
+
return ctx.cweCatalog?._meta?.version || ctx.cweCatalog?._meta?.cwe_version || null;
|
|
105
|
+
case "d3fend_version":
|
|
106
|
+
return ctx.d3fendCatalog?._meta?.version || ctx.d3fendCatalog?._meta?.d3fend_version || null;
|
|
107
|
+
default:
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function checkAllPins(ctx) {
|
|
113
|
+
const out = [];
|
|
114
|
+
for (const pin of PINS) {
|
|
115
|
+
const local = normalize(resolveLocalVersion(pin.pin_name, ctx), pin.strip_v_prefix);
|
|
116
|
+
const release = await latestGithubRelease(pin.repo);
|
|
117
|
+
if (release.error) {
|
|
118
|
+
out.push({
|
|
119
|
+
pin_name: pin.pin_name,
|
|
120
|
+
local_version: local,
|
|
121
|
+
latest_version: null,
|
|
122
|
+
drift: null,
|
|
123
|
+
unreachable: true,
|
|
124
|
+
error: release.error,
|
|
125
|
+
source_url: release.source_url,
|
|
126
|
+
});
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const latest = normalize(release.tag, pin.strip_v_prefix);
|
|
130
|
+
out.push({
|
|
131
|
+
pin_name: pin.pin_name,
|
|
132
|
+
local_version: local,
|
|
133
|
+
latest_version: latest,
|
|
134
|
+
latest_release_name: release.name,
|
|
135
|
+
latest_published_at: release.published_at,
|
|
136
|
+
drift: local != null && latest != null && local !== latest,
|
|
137
|
+
source_url: release.source_url,
|
|
138
|
+
local_path_hint: pin.local_path_hint,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = { checkAllPins, PINS };
|