@blamejs/exceptd-skills 0.14.0 → 0.14.2
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 +3 -1
- package/CHANGELOG.md +16 -0
- package/README.md +31 -0
- package/bin/exceptd.js +31 -1
- package/data/_indexes/_meta.json +3 -3
- package/data/_indexes/activity-feed.json +8 -8
- package/data/_indexes/catalog-summaries.json +2 -2
- package/data/_indexes/frequency.json +1413 -1
- package/data/playbooks/citation-hygiene.json +1 -1
- package/data/rfc-references.json +55757 -146
- package/lib/citation-resolve.js +226 -0
- package/lib/collectors/citation-hygiene.js +81 -1
- package/lib/cve-cli.js +51 -0
- package/lib/flag-suggest.js +1 -1
- package/lib/rfc-cli.js +68 -0
- package/lib/schemas/cve-catalog.schema.json +13 -0
- package/lib/source-ghsa.js +3 -0
- package/lib/source-osv.js +4 -0
- package/lib/validate-package.js +7 -2
- package/manifest.json +44 -44
- package/package.json +2 -2
- package/sbom.cdx.json +84 -39
- package/scripts/refresh-upstream-catalogs.js +12 -2
- package/sources/validators/cve-validator.js +46 -1
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* lib/citation-resolve.js
|
|
5
|
+
*
|
|
6
|
+
* Answers "is this CVE/RFC citation valid?" so an agent gets the answer FROM
|
|
7
|
+
* exceptd instead of researching each citation against NVD / the IETF
|
|
8
|
+
* datatracker by hand. Offline-first:
|
|
9
|
+
*
|
|
10
|
+
* CVE: local catalog -> resolved cache -> (opt-in) one NVD lookup, cached.
|
|
11
|
+
* RFC: local index -> resolved cache -> (opt-in) one datatracker lookup.
|
|
12
|
+
*
|
|
13
|
+
* The resolved cache lives at .cache/upstream/resolved/<kind>/<id>.json with a
|
|
14
|
+
* 7-day TTL. The FIRST agent to resolve an uncatalogued id pays one network
|
|
15
|
+
* call and writes the cache; sibling agents (and later offline runs) read it —
|
|
16
|
+
* turning N agents x M citations of redundant lookups into one lookup per id.
|
|
17
|
+
*
|
|
18
|
+
* Network is opt-out: --air-gap / EXCEPTD_AIR_GAP=1 / { noNetwork:true } make
|
|
19
|
+
* resolution offline-only (catalog + cache), returning status "unknown" with a
|
|
20
|
+
* reason rather than reaching out. Network-resolved records are transient
|
|
21
|
+
* (cache only) and are never written into the signed catalog.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const fs = require("node:fs");
|
|
25
|
+
const path = require("node:path");
|
|
26
|
+
|
|
27
|
+
const PKG_ROOT = path.join(__dirname, "..");
|
|
28
|
+
const CVE_CATALOG = process.env.EXCEPTD_CVE_CATALOG || path.join(PKG_ROOT, "data", "cve-catalog.json");
|
|
29
|
+
const RFC_INDEX = process.env.EXCEPTD_RFC_INDEX || path.join(PKG_ROOT, "data", "rfc-references.json");
|
|
30
|
+
const RESOLVE_CACHE_DIR = process.env.EXCEPTD_RESOLVE_CACHE_DIR || path.join(PKG_ROOT, ".cache", "upstream", "resolved");
|
|
31
|
+
const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // matches the prefetch freshness window
|
|
32
|
+
|
|
33
|
+
const CVE_RE = /^CVE-\d{4}-\d{4,}$/;
|
|
34
|
+
const RFC_RE = /^(?:RFC[-\s]?)?(\d+)$/i;
|
|
35
|
+
|
|
36
|
+
let _cve = null;
|
|
37
|
+
let _rfc = null;
|
|
38
|
+
function cveCatalog() {
|
|
39
|
+
if (!_cve) _cve = JSON.parse(fs.readFileSync(CVE_CATALOG, "utf8"));
|
|
40
|
+
return _cve;
|
|
41
|
+
}
|
|
42
|
+
function rfcIndex() {
|
|
43
|
+
if (!_rfc) _rfc = JSON.parse(fs.readFileSync(RFC_INDEX, "utf8"));
|
|
44
|
+
return _rfc;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// --- resolved-id cache (atomic JSON files, TTL-bounded, best-effort) ---
|
|
48
|
+
function cachePath(kind, id) {
|
|
49
|
+
// Read the env at call time so tests can isolate the cache per-case.
|
|
50
|
+
const dir = process.env.EXCEPTD_RESOLVE_CACHE_DIR || RESOLVE_CACHE_DIR;
|
|
51
|
+
const safe = id.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
52
|
+
return path.join(dir, kind, `${safe}.json`);
|
|
53
|
+
}
|
|
54
|
+
function cacheGet(kind, id) {
|
|
55
|
+
try {
|
|
56
|
+
const p = cachePath(kind, id);
|
|
57
|
+
const st = fs.statSync(p);
|
|
58
|
+
if (Date.now() - st.mtimeMs > CACHE_TTL_MS) return null;
|
|
59
|
+
return JSON.parse(fs.readFileSync(p, "utf8"));
|
|
60
|
+
} catch { return null; }
|
|
61
|
+
}
|
|
62
|
+
function cachePut(kind, id, record) {
|
|
63
|
+
try {
|
|
64
|
+
const p = cachePath(kind, id);
|
|
65
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
66
|
+
const tmp = `${p}.${process.pid}.tmp`;
|
|
67
|
+
fs.writeFileSync(tmp, JSON.stringify(record));
|
|
68
|
+
fs.renameSync(tmp, p); // atomic — concurrent agents can't read a half-written file
|
|
69
|
+
} catch { /* cache is an optimization, never fatal */ }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isAirGap(opts) {
|
|
73
|
+
return !!(opts && opts.airGap) || process.env.EXCEPTD_AIR_GAP === "1";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Resolve a CVE citation. Returns { id, kind:"cve", status, from, ... }.
|
|
78
|
+
* status: published | rejected | disputed | fabricated | nonexistent | unknown
|
|
79
|
+
* from: format | catalog | cache | network | offline | error
|
|
80
|
+
*/
|
|
81
|
+
async function resolveCve(id, opts = {}) {
|
|
82
|
+
const cveId = String(id || "").toUpperCase();
|
|
83
|
+
const base = { id: cveId, kind: "cve" };
|
|
84
|
+
|
|
85
|
+
if (!CVE_RE.test(cveId)) {
|
|
86
|
+
return { ...base, status: "fabricated", from: "format",
|
|
87
|
+
reason: "not the canonical CVE-YYYY-NNNN form — a non-numeric tail is a fabricated identifier" };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 1. curated catalog (offline, authoritative for the ids it covers)
|
|
91
|
+
const entry = cveCatalog()[cveId];
|
|
92
|
+
if (entry && typeof entry === "object") {
|
|
93
|
+
return {
|
|
94
|
+
...base,
|
|
95
|
+
status: entry.status || "published",
|
|
96
|
+
cvss: entry.cvss_score ?? null,
|
|
97
|
+
kev: entry.cisa_kev ?? null,
|
|
98
|
+
product: entry.name || entry.type || null,
|
|
99
|
+
exploitation: entry.active_exploitation ?? null,
|
|
100
|
+
from: "catalog",
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 2. resolved cache (offline, warmed by a prior agent's lookup)
|
|
105
|
+
const cached = cacheGet("cve", cveId);
|
|
106
|
+
if (cached) return { ...cached, from: "cache" };
|
|
107
|
+
|
|
108
|
+
// 3. offline / air-gap: cannot resolve uncatalogued ids without network
|
|
109
|
+
if (isAirGap(opts)) {
|
|
110
|
+
return { ...base, status: "unknown", from: "offline",
|
|
111
|
+
reason: "air-gap: not in local catalog and no cached resolution — verify against NVD when online" };
|
|
112
|
+
}
|
|
113
|
+
if (opts.noNetwork) {
|
|
114
|
+
return { ...base, status: "unknown", from: "offline",
|
|
115
|
+
reason: "not in local catalog and no cached resolution (network disabled)" };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 4. resolve once via NVD, then cache for sibling agents.
|
|
119
|
+
// opts._validateCve is a test seam (inject a fake validator); production uses
|
|
120
|
+
// the real NVD-backed validator.
|
|
121
|
+
let validateCve = opts._validateCve;
|
|
122
|
+
if (!validateCve) {
|
|
123
|
+
try { ({ validateCve } = require("../sources/validators/cve-validator.js")); }
|
|
124
|
+
catch { return { ...base, status: "unknown", from: "error", reason: "cve validator unavailable" }; }
|
|
125
|
+
}
|
|
126
|
+
let v;
|
|
127
|
+
try { v = await validateCve(cveId, {}); }
|
|
128
|
+
catch (e) { return { ...base, status: "unknown", from: "error", reason: e.message }; }
|
|
129
|
+
|
|
130
|
+
if (v.status === "unreachable") {
|
|
131
|
+
return { ...base, status: "unknown", from: "offline", reason: "NVD unreachable — retry online" };
|
|
132
|
+
}
|
|
133
|
+
// NVD is the authority for a CVE's existence and lifecycle. validateCve only
|
|
134
|
+
// returns "unreachable" when EVERY source fails — if NVD is down but KEV/EPSS
|
|
135
|
+
// answer, it returns match/drift with sources.nvd.reachable === false. Do NOT
|
|
136
|
+
// declare "published" on KEV/EPSS alone during an NVD outage; that would
|
|
137
|
+
// falsely validate an unconfirmed (or nonexistent) identifier.
|
|
138
|
+
const nvd = v.fetched && v.fetched.sources && v.fetched.sources.nvd;
|
|
139
|
+
if (!nvd || nvd.reachable !== true) {
|
|
140
|
+
return { ...base, status: "unknown", from: "offline",
|
|
141
|
+
reason: "NVD unreachable — CVE existence/status unconfirmed; retry online" };
|
|
142
|
+
}
|
|
143
|
+
let status;
|
|
144
|
+
if (v.status === "rejected") status = "rejected";
|
|
145
|
+
else if (v.status === "missing" || nvd.found !== true) status = "nonexistent";
|
|
146
|
+
else if ((v.fetched?.cve_tags || []).some(t => /disputed/i.test(t)) || /disputed/i.test(v.fetched?.nvd_vuln_status || "")) status = "disputed";
|
|
147
|
+
else status = "published";
|
|
148
|
+
|
|
149
|
+
const record = {
|
|
150
|
+
id: cveId, kind: "cve", status,
|
|
151
|
+
cvss: v.fetched?.cvss_score ?? null,
|
|
152
|
+
kev: v.fetched?.in_kev ?? null,
|
|
153
|
+
// NVD English description — carries the product/scope a citation must match,
|
|
154
|
+
// so an agent can confirm status=published applies to the right product
|
|
155
|
+
// without a second manual NVD lookup.
|
|
156
|
+
product: v.fetched?.description ?? null,
|
|
157
|
+
nvd_vuln_status: v.fetched?.nvd_vuln_status ?? null,
|
|
158
|
+
cve_tags: v.fetched?.cve_tags || [],
|
|
159
|
+
source: "nvd",
|
|
160
|
+
resolved_at: new Date().toISOString(),
|
|
161
|
+
};
|
|
162
|
+
cachePut("cve", cveId, record);
|
|
163
|
+
return { ...record, from: "network" };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Resolve an RFC citation. Returns { id, kind:"rfc", number, title, rfc_status,
|
|
168
|
+
* found, from, ... }. The local index covers the whole current RFC series, so
|
|
169
|
+
* number->title resolution is fully offline. Obsoleted/historic RFCs are
|
|
170
|
+
* excluded from the index, so a not-found number is either obsoleted or
|
|
171
|
+
* nonexistent; the optional network step disambiguates.
|
|
172
|
+
*/
|
|
173
|
+
async function resolveRfc(id, opts = {}) {
|
|
174
|
+
const raw = String(id || "").trim();
|
|
175
|
+
const m = raw.match(RFC_RE);
|
|
176
|
+
const base = { id: raw, kind: "rfc" };
|
|
177
|
+
if (!m) {
|
|
178
|
+
return { ...base, found: false, status: "unknown", from: "format",
|
|
179
|
+
reason: "not an RFC number — expected `RFC <n>` or a bare number" };
|
|
180
|
+
}
|
|
181
|
+
const num = Number(m[1]);
|
|
182
|
+
const key = `RFC-${num}`;
|
|
183
|
+
|
|
184
|
+
// 1. local index (offline, whole current series)
|
|
185
|
+
const entry = rfcIndex()[key];
|
|
186
|
+
if (entry && typeof entry === "object") {
|
|
187
|
+
return {
|
|
188
|
+
...base, number: num, found: true,
|
|
189
|
+
title: entry.title || null,
|
|
190
|
+
rfc_status: entry.status || null,
|
|
191
|
+
published: entry.published || null,
|
|
192
|
+
obsoleted_by: entry.obsoleted_by || null,
|
|
193
|
+
from: "index",
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// 2. resolved cache
|
|
198
|
+
const cached = cacheGet("rfc", String(num));
|
|
199
|
+
if (cached) return { ...cached, from: "cache" };
|
|
200
|
+
|
|
201
|
+
// 3. offline: report the ambiguity rather than guessing
|
|
202
|
+
if (isAirGap(opts) || opts.noNetwork) {
|
|
203
|
+
return { ...base, number: num, found: false, status: "unknown", from: "offline",
|
|
204
|
+
reason: "not in the local RFC index — likely obsoleted/historic (excluded from the index) or nonexistent; verify at datatracker.ietf.org when online" };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// 4. disambiguate obsoleted vs nonexistent via the datatracker, once + cached
|
|
208
|
+
let validateRfc;
|
|
209
|
+
try { ({ validateRfc } = require("../sources/validators/rfc-validator.js")); }
|
|
210
|
+
catch { return { ...base, number: num, found: false, status: "unknown", from: "error", reason: "rfc validator unavailable" }; }
|
|
211
|
+
let v;
|
|
212
|
+
try { v = await validateRfc(key, {}); }
|
|
213
|
+
catch (e) { return { ...base, number: num, found: false, status: "unknown", from: "error", reason: e.message }; }
|
|
214
|
+
if (v.status === "unreachable") {
|
|
215
|
+
return { ...base, number: num, found: false, status: "unknown", from: "offline", reason: "datatracker unreachable — retry online" };
|
|
216
|
+
}
|
|
217
|
+
const record = v.status === "missing"
|
|
218
|
+
? { id: raw, kind: "rfc", number: num, found: false, status: "nonexistent", source: "datatracker", resolved_at: new Date().toISOString() }
|
|
219
|
+
: { id: raw, kind: "rfc", number: num, found: true, status: "obsoleted-or-historic",
|
|
220
|
+
title: v.fetched?.title || null, source: "datatracker", resolved_at: new Date().toISOString(),
|
|
221
|
+
note: "resolves at the datatracker but is absent from the local index (obsoleted/historic RFCs are excluded)" };
|
|
222
|
+
cachePut("rfc", String(num), record);
|
|
223
|
+
return { ...record, from: "network" };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
module.exports = { resolveCve, resolveRfc };
|
|
@@ -34,6 +34,14 @@ const { codeExcludeSet, isLinkedWorktreeDir } = require("./scan-excludes");
|
|
|
34
34
|
|
|
35
35
|
const COLLECTOR_ID = "citation-hygiene";
|
|
36
36
|
|
|
37
|
+
// Opt-in resolver (`exceptd collect citation-hygiene --resolve`). Lets the
|
|
38
|
+
// collector resolve the citations the offline catalog can't confirm — once,
|
|
39
|
+
// through the shared cache — instead of parking them as inconclusive for an
|
|
40
|
+
// agent to research. Required lazily so a plain collect() never loads it.
|
|
41
|
+
function loadResolver() {
|
|
42
|
+
return require("../citation-resolve.js");
|
|
43
|
+
}
|
|
44
|
+
|
|
37
45
|
const DEFAULT_MAX_DEPTH = 8;
|
|
38
46
|
const EXCLUDES = codeExcludeSet();
|
|
39
47
|
|
|
@@ -445,6 +453,10 @@ function collect({ cwd = process.cwd() } = {}) {
|
|
|
445
453
|
},
|
|
446
454
|
artifacts,
|
|
447
455
|
signal_overrides,
|
|
456
|
+
// The citations the offline catalog could not confirm. `applyResolution`
|
|
457
|
+
// (opt-in --resolve) consumes this to resolve + flip them; on a plain
|
|
458
|
+
// collect it documents what still needs verification.
|
|
459
|
+
needs_verification: needsVerify,
|
|
448
460
|
collector_meta: {
|
|
449
461
|
collector_id: COLLECTOR_ID,
|
|
450
462
|
collector_version: "2026-05-26",
|
|
@@ -462,4 +474,72 @@ function collect({ cwd = process.cwd() } = {}) {
|
|
|
462
474
|
};
|
|
463
475
|
}
|
|
464
476
|
|
|
465
|
-
|
|
477
|
+
/**
|
|
478
|
+
* Resolve the citations a plain collect() left as needs-verification, flipping
|
|
479
|
+
* the parked signals from inconclusive to a real verdict. Opt-in: only invoked
|
|
480
|
+
* for `exceptd collect citation-hygiene --resolve`. Each uncatalogued CVE goes
|
|
481
|
+
* through the shared resolver (catalog -> cache -> one NVD lookup, cached), so a
|
|
482
|
+
* fan-out resolves each id once. Honors air-gap (resolver returns unknown).
|
|
483
|
+
*
|
|
484
|
+
* Mutates a shallow copy of the submission's signal_overrides and records a
|
|
485
|
+
* resolution summary artifact. Returns the updated submission.
|
|
486
|
+
*
|
|
487
|
+
* @param {object} submission the object returned by collect()
|
|
488
|
+
* @param {object} [opts] { airGap?: boolean, _resolveCve?, _resolveRfc? }
|
|
489
|
+
* @returns {Promise<object>}
|
|
490
|
+
*/
|
|
491
|
+
async function applyResolution(submission, opts = {}) {
|
|
492
|
+
if (!submission || typeof submission !== "object") return submission;
|
|
493
|
+
const nv = submission.needs_verification || {};
|
|
494
|
+
const cveList = Array.isArray(nv.cve_not_in_catalog) ? nv.cve_not_in_catalog : [];
|
|
495
|
+
const rfcList = Array.isArray(nv.rfc_not_in_index) ? nv.rfc_not_in_index : [];
|
|
496
|
+
const resolver = (opts._resolveCve && opts._resolveRfc)
|
|
497
|
+
? { resolveCve: opts._resolveCve, resolveRfc: opts._resolveRfc }
|
|
498
|
+
: loadResolver();
|
|
499
|
+
const airGap = !!opts.airGap;
|
|
500
|
+
|
|
501
|
+
const signals = { ...(submission.signal_overrides || {}) };
|
|
502
|
+
const resolved = { cve: [], rfc: [] };
|
|
503
|
+
let cveUnknown = 0;
|
|
504
|
+
let rejectedHit = false;
|
|
505
|
+
let fabricatedHit = false;
|
|
506
|
+
|
|
507
|
+
for (const item of cveList) {
|
|
508
|
+
const id = String(item.citation || "").trim();
|
|
509
|
+
const r = await resolver.resolveCve(id, { airGap });
|
|
510
|
+
resolved.cve.push({ citation: id, file: item.file, status: r.status, from: r.from, product: r.product || null });
|
|
511
|
+
if (r.status === "rejected" || r.status === "disputed") rejectedHit = true;
|
|
512
|
+
else if (r.status === "nonexistent" || r.status === "fabricated") fabricatedHit = true;
|
|
513
|
+
else if (r.status === "unknown") cveUnknown++;
|
|
514
|
+
// published -> resolved-clean (no flip)
|
|
515
|
+
}
|
|
516
|
+
if (rejectedHit) signals["rejected-or-disputed-cve"] = "hit";
|
|
517
|
+
if (fabricatedHit) signals["fabricated-cve-id"] = "hit";
|
|
518
|
+
// The needs-verification signal: a clean miss once every parked CVE was
|
|
519
|
+
// classified, inconclusive while any remain unresolvable (NVD unreachable /
|
|
520
|
+
// air-gap), and absent when there was nothing to verify.
|
|
521
|
+
if (cveList.length > 0) {
|
|
522
|
+
signals["cve-citation-needs-external-verification"] = cveUnknown > 0 ? "inconclusive" : "miss";
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
for (const item of rfcList) {
|
|
526
|
+
const cite = String(item.citation || "");
|
|
527
|
+
const num = (cite.match(/(\d+)/) || [])[1];
|
|
528
|
+
const r = await resolver.resolveRfc(num || cite, { airGap });
|
|
529
|
+
resolved.rfc.push({ citation: cite, file: item.file, status: r.status, found: r.found, from: r.from, title: r.title || null });
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const out = { ...submission, signal_overrides: signals };
|
|
533
|
+
out.artifacts = { ...(submission.artifacts || {}) };
|
|
534
|
+
const fmt = (arr) => arr.length === 0 ? "0" : arr.map(x => `${x.citation}=${x.status}`).join(", ");
|
|
535
|
+
out.artifacts["citation-resolution"] = {
|
|
536
|
+
value: `Resolved ${resolved.cve.length} uncatalogued CVE citation(s): ${fmt(resolved.cve)}. ` +
|
|
537
|
+
`Resolved ${resolved.rfc.length} not-in-index RFC citation(s): ${fmt(resolved.rfc)}.` +
|
|
538
|
+
(airGap ? " (air-gap: network resolution skipped — catalog/cache only.)" : ""),
|
|
539
|
+
captured: true,
|
|
540
|
+
};
|
|
541
|
+
out.resolution = resolved;
|
|
542
|
+
return out;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
module.exports = { playbook_id: COLLECTOR_ID, collect, applyResolution };
|
package/lib/cve-cli.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* lib/cve-cli.js — `exceptd cve <CVE-ID>` resolver.
|
|
6
|
+
*
|
|
7
|
+
* Catalog -> resolved cache -> one NVD lookup (cached). Tells an agent whether
|
|
8
|
+
* a cited CVE is published / rejected / disputed / fabricated / nonexistent
|
|
9
|
+
* without it researching NVD by hand. Network is opt-out (--air-gap /
|
|
10
|
+
* --no-network / EXCEPTD_AIR_GAP=1).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const { resolveCve } = require("./citation-resolve.js");
|
|
14
|
+
|
|
15
|
+
(async () => {
|
|
16
|
+
const argv = process.argv.slice(2);
|
|
17
|
+
const flags = new Set(argv.filter((a) => a.startsWith("--")));
|
|
18
|
+
const id = argv.find((a) => !a.startsWith("--"));
|
|
19
|
+
const pretty = flags.has("--pretty");
|
|
20
|
+
const json = flags.has("--json") || pretty;
|
|
21
|
+
|
|
22
|
+
if (!id) {
|
|
23
|
+
process.stderr.write(
|
|
24
|
+
JSON.stringify({ ok: false, verb: "cve", error: "usage: exceptd cve <CVE-ID> [--json|--pretty] [--air-gap|--no-network]" }) + "\n"
|
|
25
|
+
);
|
|
26
|
+
process.exitCode = 1;
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const r = await resolveCve(id, { airGap: flags.has("--air-gap"), noNetwork: flags.has("--no-network") });
|
|
31
|
+
const body = { ok: true, verb: "cve", ...r };
|
|
32
|
+
|
|
33
|
+
if (json) {
|
|
34
|
+
process.stdout.write(JSON.stringify(body, null, pretty ? 2 : 0) + "\n");
|
|
35
|
+
} else {
|
|
36
|
+
const bits = [];
|
|
37
|
+
bits.push(`${r.id}: ${String(r.status).toUpperCase()}`);
|
|
38
|
+
if (r.cvss != null) bits.push(`CVSS ${r.cvss}`);
|
|
39
|
+
if (r.kev != null) bits.push(`KEV=${r.kev}`);
|
|
40
|
+
if (r.product) bits.push(r.product);
|
|
41
|
+
let line = bits.join(" · ") + ` (${r.from})`;
|
|
42
|
+
if (r.nvd_vuln_status) line += `\n NVD vulnStatus: ${r.nvd_vuln_status}`;
|
|
43
|
+
if (Array.isArray(r.cve_tags) && r.cve_tags.length) line += `\n NVD tags: ${r.cve_tags.join(", ")}`;
|
|
44
|
+
if (r.reason) line += `\n ${r.reason}`;
|
|
45
|
+
process.stdout.write(line + "\n");
|
|
46
|
+
}
|
|
47
|
+
// A citation that won't stand up is a non-zero exit so a CI/script gate trips.
|
|
48
|
+
if (r.status === "rejected" || r.status === "fabricated" || r.status === "nonexistent" || r.status === "withdrawn") {
|
|
49
|
+
process.exitCode = 2;
|
|
50
|
+
}
|
|
51
|
+
})();
|
package/lib/flag-suggest.js
CHANGED
|
@@ -114,7 +114,7 @@ const VERB_FLAG_ALLOWLIST = Object.freeze({
|
|
|
114
114
|
],
|
|
115
115
|
doctor: ['signatures', 'cves', 'rfcs', 'fix', 'registry-check', 'exit-codes', 'shipped-tarball', 'ai-config', 'currency', 'collectors'],
|
|
116
116
|
lint: ['evidence'],
|
|
117
|
-
collect: ['cwd', 'attest-ownership'],
|
|
117
|
+
collect: ['cwd', 'attest-ownership', 'resolve'],
|
|
118
118
|
refresh: [
|
|
119
119
|
'apply', 'dry-run', 'from-cache', 'from-fixture', 'network', 'source',
|
|
120
120
|
'advisory', 'check-advisories', 'force-stale', 'force-stale-acked', 'air-gap', 'swarm',
|
package/lib/rfc-cli.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* lib/rfc-cli.js — `exceptd rfc <number>` resolver.
|
|
6
|
+
*
|
|
7
|
+
* Local index (whole current RFC series, offline) -> resolved cache -> one
|
|
8
|
+
* datatracker lookup to disambiguate obsoleted-vs-nonexistent. Resolves an RFC
|
|
9
|
+
* number to its title + status so an agent can confirm a citation (e.g. "is
|
|
10
|
+
* RFC 9404 the Sieve spec?") without the datatracker. Optional --check
|
|
11
|
+
* "<claimed title>" reports whether the claimed title matches.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const { resolveRfc } = require("./citation-resolve.js");
|
|
15
|
+
|
|
16
|
+
(async () => {
|
|
17
|
+
const argv = process.argv.slice(2);
|
|
18
|
+
const flags = new Set(argv.filter((a) => a.startsWith("--")));
|
|
19
|
+
const positionals = argv.filter((a) => !a.startsWith("--"));
|
|
20
|
+
const id = positionals[0];
|
|
21
|
+
const pretty = flags.has("--pretty");
|
|
22
|
+
const json = flags.has("--json") || pretty;
|
|
23
|
+
|
|
24
|
+
// --check "<claimed title>" : the next non-flag token after the number.
|
|
25
|
+
let claimedTitle = null;
|
|
26
|
+
const checkIdx = argv.indexOf("--check");
|
|
27
|
+
if (checkIdx !== -1 && argv[checkIdx + 1] && !argv[checkIdx + 1].startsWith("--")) {
|
|
28
|
+
claimedTitle = argv[checkIdx + 1];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!id) {
|
|
32
|
+
process.stderr.write(
|
|
33
|
+
JSON.stringify({ ok: false, verb: "rfc", error: "usage: exceptd rfc <number> [--check \"<claimed title>\"] [--json|--pretty] [--air-gap|--no-network]" }) + "\n"
|
|
34
|
+
);
|
|
35
|
+
process.exitCode = 1;
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const r = await resolveRfc(id, { airGap: flags.has("--air-gap"), noNetwork: flags.has("--no-network") });
|
|
40
|
+
|
|
41
|
+
let titleMatch = null;
|
|
42
|
+
if (claimedTitle && r.title) {
|
|
43
|
+
const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
|
44
|
+
const a = norm(claimedTitle), b = norm(r.title);
|
|
45
|
+
titleMatch = a.length > 0 && (b.includes(a) || a.includes(b));
|
|
46
|
+
}
|
|
47
|
+
const body = { ok: true, verb: "rfc", ...r, ...(claimedTitle ? { claimed_title: claimedTitle, title_match: titleMatch } : {}) };
|
|
48
|
+
|
|
49
|
+
if (json) {
|
|
50
|
+
process.stdout.write(JSON.stringify(body, null, pretty ? 2 : 0) + "\n");
|
|
51
|
+
} else {
|
|
52
|
+
let line;
|
|
53
|
+
if (r.found && r.title) {
|
|
54
|
+
line = `RFC ${r.number}: ${r.title}`;
|
|
55
|
+
if (r.rfc_status) line += ` (${r.rfc_status})`;
|
|
56
|
+
if (r.obsoleted_by) line += `\n obsoleted by: ${r.obsoleted_by}`;
|
|
57
|
+
if (claimedTitle) line += `\n claimed "${claimedTitle}" -> ${titleMatch ? "MATCH" : "MISMATCH"}`;
|
|
58
|
+
} else {
|
|
59
|
+
line = `RFC ${r.number ?? r.id}: ${String(r.status).toUpperCase()}`;
|
|
60
|
+
if (r.note) line += `\n ${r.note}`;
|
|
61
|
+
if (r.reason) line += `\n ${r.reason}`;
|
|
62
|
+
}
|
|
63
|
+
line += ` (${r.from})`;
|
|
64
|
+
process.stdout.write(line + "\n");
|
|
65
|
+
}
|
|
66
|
+
// A mismatched or nonexistent citation is a non-zero exit for gates.
|
|
67
|
+
if (r.status === "nonexistent" || titleMatch === false) process.exitCode = 2;
|
|
68
|
+
})();
|
|
@@ -92,6 +92,19 @@
|
|
|
92
92
|
"enum": ["confirmed", "suspected", "theoretical", "none", "unknown"],
|
|
93
93
|
"description": "v0.13.5: enum reconciled with the _meta.active_exploitation_vocabulary block (5 values). 'theoretical' added — distinct from 'suspected' because it captures the 'PoC exists but no observation in the wild' state without committing to a probability claim."
|
|
94
94
|
},
|
|
95
|
+
"status": {
|
|
96
|
+
"type": "string",
|
|
97
|
+
"enum": ["published", "rejected", "disputed", "withdrawn", "reserved", "unknown"],
|
|
98
|
+
"description": "Assignment lifecycle status (distinct from active_exploitation). 'rejected'/'disputed' come from NVD vulnStatus/cveTags; 'withdrawn' from OSV/GHSA. Optional — absent means 'published' is assumed. Lets a citation check read a structured field instead of grepping free-text notes. Pair with status_source + status_verified for provenance."
|
|
99
|
+
},
|
|
100
|
+
"status_source": {
|
|
101
|
+
"type": "string",
|
|
102
|
+
"description": "Provenance of `status` (e.g. 'nvd:vulnStatus', 'osv:withdrawn', 'ghsa:withdrawn_at', 'curated')."
|
|
103
|
+
},
|
|
104
|
+
"status_verified": {
|
|
105
|
+
"type": "string",
|
|
106
|
+
"description": "ISO-8601 timestamp the status was last confirmed against its source."
|
|
107
|
+
},
|
|
95
108
|
"affected": {
|
|
96
109
|
"type": "string",
|
|
97
110
|
"minLength": 1,
|
package/lib/source-ghsa.js
CHANGED
|
@@ -364,6 +364,9 @@ function normalizeAdvisory(adv) {
|
|
|
364
364
|
_draft_reason: "Imported from GHSA on " + new Date().toISOString().slice(0, 10) + ". Editorial fields (framework_control_gaps, atlas_refs, attack_refs, iocs, vector, complexity, rwep_factors) require human review. Run `exceptd run sbom --evidence -` against an affected repo to gather IoCs; consult MITRE ATLAS + ATT&CK catalogs for refs.",
|
|
365
365
|
_source_ghsa_id: adv.ghsa_id || null,
|
|
366
366
|
_source_published_at: adv.published_at || null,
|
|
367
|
+
// GitHub sets `withdrawn_at` when an advisory is retracted. Surface it as
|
|
368
|
+
// structured status so a withdrawn advisory is flagged, not imported as live.
|
|
369
|
+
...(adv.withdrawn_at ? { status: "withdrawn", status_source: "ghsa:withdrawn_at", status_verified: new Date().toISOString().slice(0, 10) } : {}),
|
|
367
370
|
last_updated: new Date().toISOString().slice(0, 10),
|
|
368
371
|
},
|
|
369
372
|
};
|
package/lib/source-osv.js
CHANGED
|
@@ -810,6 +810,10 @@ function normalizeAdvisory(rec) {
|
|
|
810
810
|
_draft_reason: "Imported from OSV.dev on " + today + ". Editorial fields (framework_control_gaps, atlas_refs, attack_refs, iocs, vector, complexity, rwep_factors) require human review. Run `exceptd run sbom --evidence -` against an affected repo to gather IoCs; consult MITRE ATLAS + ATT&CK catalogs for refs.",
|
|
811
811
|
_source_osv_id: rec.id,
|
|
812
812
|
_source_published_at: rec.published || null,
|
|
813
|
+
// OSV sets a top-level `withdrawn` timestamp when a record is retracted.
|
|
814
|
+
// Surface it as structured status so a citation check (and the resolver)
|
|
815
|
+
// can flag a withdrawn advisory instead of importing it as if live.
|
|
816
|
+
...(rec.withdrawn ? { status: "withdrawn", status_source: "osv:withdrawn", status_verified: today } : {}),
|
|
813
817
|
last_updated: modified || today,
|
|
814
818
|
},
|
|
815
819
|
};
|
package/lib/validate-package.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*
|
|
8
8
|
* - includes every required file from package.json `files`
|
|
9
9
|
* - excludes every forbidden file (secrets, tests, caches, dev artifacts)
|
|
10
|
-
* - is under the size budget (currently
|
|
10
|
+
* - is under the size budget (currently 7 MB)
|
|
11
11
|
* - `bin/exceptd.js` has the expected shebang
|
|
12
12
|
* - the bin target listed in package.json exists on disk
|
|
13
13
|
*
|
|
@@ -22,7 +22,12 @@ const { spawnSync } = require("child_process");
|
|
|
22
22
|
|
|
23
23
|
const ROOT = path.join(__dirname, "..");
|
|
24
24
|
const ABS = (p) => path.join(ROOT, p);
|
|
25
|
-
|
|
25
|
+
// Published-tarball cap. Guards against accidental bloat (a vendored
|
|
26
|
+
// node_modules, a committed binary — tens of MB), not the curated data that
|
|
27
|
+
// legitimately grows each release: the CVE catalog gains entries and the RFC
|
|
28
|
+
// index spans the full series. Packed size crossed 5 MB through that gradual
|
|
29
|
+
// growth; 7 MB restores headroom while still catching a gross-bloat accident.
|
|
30
|
+
const SIZE_BUDGET_BYTES = 7 * 1024 * 1024;
|
|
26
31
|
|
|
27
32
|
const REQUIRED_PATHS = [
|
|
28
33
|
"package.json",
|