@blamejs/exceptd-skills 0.14.0 → 0.14.1
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 +10 -0
- package/README.md +31 -0
- package/bin/exceptd.js +6 -0
- package/data/_indexes/_meta.json +2 -2
- package/data/playbooks/citation-hygiene.json +1 -1
- package/lib/citation-resolve.js +226 -0
- package/lib/cve-cli.js +51 -0
- 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 +1 -1
- package/sbom.cdx.json +75 -30
- package/sources/validators/cve-validator.js +46 -1
package/AGENTS.md
CHANGED
|
@@ -8,7 +8,7 @@ Also read [CONTEXT.md](CONTEXT.md) for a complete orientation to the skill syste
|
|
|
8
8
|
|
|
9
9
|
Each rule below carries a **Forcing function** annotation declaring whether it is mechanically enforced by a script in the predeploy / CI gate sequence, or whether it is policy-only (reviewer trust). Policy-only rules are not weaker — they are auditable through reviewer judgment, not via a script — but operators should know which class a given rule sits in.
|
|
10
10
|
|
|
11
|
-
1. **No stale threat intel** — Every CVE reference must include: CVSS score, KEV status, PoC availability, AI-discovery flag, active exploitation status, and patch/live-patch availability. No theoretical vulnerabilities without real-world grounding.
|
|
11
|
+
1. **No stale threat intel** — Every CVE reference must include: CVSS score, KEV status, PoC availability, AI-discovery flag, active exploitation status, and patch/live-patch availability. No theoretical vulnerabilities without real-world grounding. When validating a CVE or RFC citation during security work, the canonical move is `exceptd cve <CVE-ID>` / `exceptd rfc <number>` — it resolves the citation and caches the result, so a multi-agent fan-out resolves each id once rather than re-researching it independently against NVD/the datatracker.
|
|
12
12
|
*Forcing function:* enforced by `lib/validate-cve-catalog.js` (predeploy gate).
|
|
13
13
|
|
|
14
14
|
2. **Framework lag is a first-class concept** — Every skill must explicitly declare which framework controls are insufficient for the threats it covers. Never imply a framework control is adequate when current TTPs bypass it.
|
|
@@ -169,6 +169,8 @@ Cross-cutting playbook `framework` is the natural correlation layer — many pla
|
|
|
169
169
|
| `exceptd attest list` | Inventory `.exceptd/attestations/` — newest first. `--playbook <id>` filters. |
|
|
170
170
|
| `exceptd attest show <sid>` | Print the attestation body. |
|
|
171
171
|
| `exceptd doctor` | Health checks. `--signatures` verifies Ed25519 chains; `--cves` / `--rfcs` check catalog currency; `--fix` repairs recoverable state; `--ai-config` audits AI-assistant config-file permissions (`~/.claude`, `~/.cursor`, `~/.codeium`, `~/.aider`, `~/.continue`) and flags sensitive files not at mode `0o600` on POSIX (NEW-CTRL-050). |
|
|
172
|
+
| `exceptd cve <CVE-ID>` | Resolve a single CVE citation — returns status (`published`/`rejected`/`disputed`/`fabricated`/`nonexistent`/`unknown`) plus cvss/kev/product. Resolution order: curated catalog (offline) → resolved cache (`.cache/upstream/resolved/`, 7-day TTL) → one NVD lookup, then cached. `--air-gap`/`--no-network`/`EXCEPTD_AIR_GAP=1` force offline-only (returns `unknown` with a reason). Exit 2 when the citation won't stand up (rejected/fabricated/nonexistent/withdrawn). |
|
|
173
|
+
| `exceptd rfc <number>` | Resolve an RFC number → title + status from the local index (whole current series, offline). `--check "<title>"` reports `title_match` true/false, exit 2 on mismatch (catches e.g. RFC 9404 cited as the Sieve spec — it's JMAP Blob Management). Not-found numbers are likely obsoleted/historic or nonexistent; with network it disambiguates via the datatracker. |
|
|
172
174
|
| `exceptd lint` | Skill format lint — frontmatter completeness, required body sections, signature presence. |
|
|
173
175
|
| `exceptd refresh --check-advisories` | Poll 15 primary-source advisory feeds — 8 advisory/coordinated-disclosure venues (Qualys TRU, Red Hat RHSA, Ubuntu USN, ZDI, kernel.org commits, oss-security mailing list, JFrog SecOps, CISA current advisories), 4 vendor security research blogs (Microsoft Security Blog, Sysdig, Trail of Bits, Embrace the Red — added in v0.13.14 after DirtyDecrypt fell through the advisory-only set), and 3 sources added in v0.13.17 (BleepingComputer security, The Hacker News, Nightmare-Eclipse GitLab activity-feed tracker, migrated from GitHub after the account was removed — closes the researcher-drop class anchored by MiniPlasma / YellowKey / GreenPlasma / UnDefend, NEW-CTRL-073). Pairs with `lib/cve-regression-watcher.js` (NEW-CTRL-074) which cross-checks poller diffs for historical-CVE references that may indicate silent vendor regression — the class anchored by MiniPlasma re-breaking CVE-2020-17103. Report-only; emits structured `diffs[]` without mutating the catalog. Route promising IDs through `refresh --advisory <CVE-ID> --apply` to enrich. |
|
|
174
176
|
| `exceptd watchlist` | Default: aggregate every skill's `forward_watch` entries. `--by-skill` inverts grouping. `--alerts` switches to CVE-catalog pattern alerts (5 patterns: `kernel_lpe_with_poc`, `supply_chain_family`, `ai_discovered_kev`, `active_exploitation_unpatched`, `recent_poc_no_kev_yet`); sorts critical-first, then by RWEP. `--org-scan --org <login>` probes GitHub Search for repos matching threat-actor naming patterns ("A Gift From TeamPCP", "Shai-Hulud", "TeamPCP"); custom patterns via repeatable `--pattern <s>`; set `GITHUB_TOKEN` for private-repo + rate-limit headroom (NEW-CTRL-052). |
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.14.1 — 2026-05-27
|
|
4
|
+
|
|
5
|
+
Two citation resolvers — `exceptd cve <id>` and `exceptd rfc <number>` — answer "is this CVE/RFC citation valid?" so an agent gets the answer from exceptd instead of researching each identifier against NVD or the IETF datatracker by hand. A fan-out of agents auditing a codebase previously re-researched the same citations independently; these resolvers do it once and cache the result for the rest.
|
|
6
|
+
|
|
7
|
+
`exceptd cve <id>` returns a structured status — published, rejected, disputed, fabricated, nonexistent, or unknown — alongside CVSS / KEV / product. It resolves offline-first: the curated catalog, then a resolved cache, then a single NVD lookup whose result is cached under `.cache/upstream/resolved/` (7-day TTL). The first lookup of an uncatalogued identifier serves every later agent and every offline run. NVD's authoritative `vulnStatus` and `cveTags` are now read — they were previously fetched and discarded — so a rejected or disputed CVE is flagged rather than treated as valid (the class that lets a withdrawn identifier sit cited in a codebase unnoticed). A non-canonical identifier such as `CVE-2024-XXXX` is caught as fabricated with no network call. Network is opt-out: `--air-gap`, `--no-network`, or `EXCEPTD_AIR_GAP=1` keep resolution offline-only and return `unknown` with a reason. Exit code 2 when a citation will not stand up.
|
|
8
|
+
|
|
9
|
+
`exceptd rfc <number>` resolves an RFC number to its title and status from the local index — the whole current RFC series, fully offline. `--check "<claimed title>"` reports whether a claimed title matches the real one (exit code 2 on mismatch), catching an RFC number cited under the wrong specification.
|
|
10
|
+
|
|
11
|
+
Catalog entries may now carry a structured `status` field (`published` / `rejected` / `disputed` / `withdrawn` / `reserved`), sourced from NVD `vulnStatus` / `cveTags` or OSV / GHSA `withdrawn`, replacing the prior free-text heuristic. The `citation-hygiene` playbook now routes its "needs external verification" guidance through `exceptd cve` / `exceptd rfc`.
|
|
12
|
+
|
|
3
13
|
## 0.14.0 — 2026-05-26
|
|
4
14
|
|
|
5
15
|
New playbook — `citation-hygiene`. Validates a codebase's own cited security references: it scans source, comments, and docs for CVE and RFC citations and flags fabricated CVE IDs (the non-numeric `CVE-2024-XXXX` form), catalog-rejected/disputed CVEs, and RFC number-vs-title mismatches. Well-formed CVE IDs absent from the curated catalog are routed to an inconclusive "needs external verification" result rather than a false clear or a false fabrication flag. Ships with a companion collector — `exceptd collect citation-hygiene | exceptd run citation-hygiene --evidence -`. The catalog now holds 24 playbooks.
|
package/README.md
CHANGED
|
@@ -349,6 +349,35 @@ exceptd lint <pb> <evidence> Pre-flight check submission shape vs
|
|
|
349
349
|
playbook (preconditions / artifacts /
|
|
350
350
|
indicators) without executing phases 4-7.
|
|
351
351
|
|
|
352
|
+
exceptd cve <CVE-ID> Resolve one CVE citation → status
|
|
353
|
+
(published / rejected / disputed /
|
|
354
|
+
fabricated / nonexistent / unknown) plus
|
|
355
|
+
cvss / kev / product. Order: curated
|
|
356
|
+
catalog (offline) → resolved cache
|
|
357
|
+
(7-day TTL, warmed by a prior lookup) →
|
|
358
|
+
one NVD lookup, then cached. Lets a
|
|
359
|
+
fan-out of agents share one answer
|
|
360
|
+
instead of each researching the same id.
|
|
361
|
+
--air-gap | --no-network Offline-only (also EXCEPTD_AIR_GAP=1).
|
|
362
|
+
Returns unknown + a reason when the id
|
|
363
|
+
isn't in catalog/cache.
|
|
364
|
+
--json | --pretty Machine output.
|
|
365
|
+
Exit 2 when the citation won't stand up
|
|
366
|
+
(rejected / fabricated / nonexistent /
|
|
367
|
+
withdrawn).
|
|
368
|
+
|
|
369
|
+
exceptd rfc <number> Resolve an RFC number → title + status
|
|
370
|
+
from the local index (whole current
|
|
371
|
+
series, fully offline).
|
|
372
|
+
--check "<title>" Report title_match true/false; exit 2 on
|
|
373
|
+
mismatch (e.g. RFC 9404 cited as the
|
|
374
|
+
Sieve spec — it's JMAP Blob Management).
|
|
375
|
+
--air-gap Offline-only. Not-found numbers are
|
|
376
|
+
likely obsoleted/historic or nonexistent;
|
|
377
|
+
with network it disambiguates via the
|
|
378
|
+
datatracker.
|
|
379
|
+
--json | --pretty Machine output.
|
|
380
|
+
|
|
352
381
|
exceptd refresh Refresh upstream catalogs + indexes.
|
|
353
382
|
Replaces prefetch + refresh + build-indexes.
|
|
354
383
|
--apply Write diffs back + rebuild indexes.
|
|
@@ -549,6 +578,8 @@ The `agents/` directory ships markdown role cards documenting authoring conventi
|
|
|
549
578
|
|
|
550
579
|
All skills pull from `data/`. Cross-validated against canonical upstream sources via `exceptd refresh` / `exceptd doctor --cves` / `exceptd doctor --rfcs`.
|
|
551
580
|
|
|
581
|
+
To resolve a single citation rather than refresh the whole catalog, `exceptd cve <CVE-ID>` and `exceptd rfc <number>` return a status verdict for one id (catalog → resolved cache → one NVD / datatracker lookup, offline-capable). The lookup caches, so a fan-out of agents shares the answer instead of each independently re-researching the same citation.
|
|
582
|
+
|
|
552
583
|
- `cve-catalog.json` — CVE metadata with RWEP scores, CISA KEV status, PoC availability, live-patch info
|
|
553
584
|
- `atlas-ttps.json` — MITRE ATLAS v5.6.0 TTPs with gap flags and exploitation examples. Each TTP now carries a `cve_refs[]` back-edge — operators reading an ATLAS entry see the catalogued CVEs that cite it without grepping `cve-catalog.json`. The same back-edge is populated on `attack-techniques.json`, and each playbook carries a `_meta.fed_by[]` reverse field naming the upstream playbooks that chain into it.
|
|
554
585
|
- `framework-control-gaps.json` — Per-framework, per-control: what it was designed for vs. what it misses
|
package/bin/exceptd.js
CHANGED
|
@@ -154,6 +154,10 @@ const COMMANDS = {
|
|
|
154
154
|
watch: () => path.join(PKG_ROOT, "orchestrator", "index.js"),
|
|
155
155
|
"framework-gap": () => path.join(PKG_ROOT, "orchestrator", "index.js"),
|
|
156
156
|
"framework-gap-analysis": () => path.join(PKG_ROOT, "orchestrator", "index.js"),
|
|
157
|
+
// Citation resolvers — answer "is this CVE/RFC citation valid?" offline-first
|
|
158
|
+
// (catalog/index -> resolved cache -> opt-in single network lookup, cached).
|
|
159
|
+
cve: () => path.join(PKG_ROOT, "lib", "cve-cli.js"),
|
|
160
|
+
rfc: () => path.join(PKG_ROOT, "lib", "rfc-cli.js"),
|
|
157
161
|
// Seven-phase playbook verbs — handled in-process via lib/playbook-runner.js.
|
|
158
162
|
plan: null,
|
|
159
163
|
govern: null,
|
|
@@ -738,6 +742,8 @@ function main() {
|
|
|
738
742
|
skill: "exceptd skill <name> Show the full context document for one skill.",
|
|
739
743
|
"framework-gap": "exceptd framework-gap <framework> <cve-or-scenario> One-framework gap analysis.",
|
|
740
744
|
"framework-gap-analysis": "exceptd framework-gap <framework> <cve-or-scenario> One-framework gap analysis.",
|
|
745
|
+
cve: "exceptd cve <CVE-ID> [--json] [--air-gap|--no-network] Resolve a CVE: published/rejected/disputed/fabricated/nonexistent (catalog -> cache -> NVD).",
|
|
746
|
+
rfc: "exceptd rfc <number> [--check \"<title>\"] [--json] [--air-gap] Resolve an RFC number -> title + status (local index, offline).",
|
|
741
747
|
};
|
|
742
748
|
if ((effectiveRest.includes("--help") || effectiveRest.includes("-h")) && SPAWN_HELP_USAGE[effectiveCmd]) {
|
|
743
749
|
process.stdout.write(SPAWN_HELP_USAGE[effectiveCmd] + "\n Full reference: exceptd help\n");
|
package/data/_indexes/_meta.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema_version": "1.1.0",
|
|
3
|
-
"generated_at": "2026-05-
|
|
3
|
+
"generated_at": "2026-05-27T12:31:54.921Z",
|
|
4
4
|
"generator": "scripts/build-indexes.js",
|
|
5
5
|
"source_count": 54,
|
|
6
6
|
"source_hashes": {
|
|
7
|
-
"manifest.json": "
|
|
7
|
+
"manifest.json": "cc72d668222e9cd18eaef7928e173ad17434ff4b411235bbba2b13fca722db9e",
|
|
8
8
|
"data/atlas-ttps.json": "d24bc02859d40ccf1615db75cca68c077585904e41e0d8f6de448121e9b1abb0",
|
|
9
9
|
"data/attack-techniques.json": "fa193f0d2d248176a8beddb641e9fe56ba4faa9e15dc253ff876dbf0c5d58a77",
|
|
10
10
|
"data/cve-catalog.json": "3d451dda7ac0c7d57a4075ae4bafd3148c6184b35dc1bc59d8b81d1f2641e430",
|
|
@@ -372,7 +372,7 @@
|
|
|
372
372
|
"confidence": "low",
|
|
373
373
|
"deterministic": false,
|
|
374
374
|
"false_positive_checks_required": [
|
|
375
|
-
"Resolve the identifier
|
|
375
|
+
"Resolve the identifier with `exceptd cve <id>` (catalog -> resolved cache -> one NVD lookup, then cached so sibling agents reuse it instead of each researching the same id). If it returns status=published with a product matching the surrounding claim, the citation is sound; record verified and demote to miss. For RFC numbers use `exceptd rfc <number> --check \"<claimed title>\"`.",
|
|
376
376
|
"If it resolves to a REJECTED / DISPUTED record at NVD, re-classify under rejected-or-disputed-cve rather than leaving it inconclusive."
|
|
377
377
|
]
|
|
378
378
|
},
|
|
@@ -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 };
|
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/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",
|