@blamejs/exceptd-skills 0.11.15 → 0.12.0
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/CHANGELOG.md +74 -0
- package/bin/exceptd.js +17 -0
- package/data/_indexes/_meta.json +2 -2
- package/keys/public.pem +1 -1
- package/lib/cve-curation.js +274 -0
- package/lib/refresh-external.js +172 -1
- package/lib/source-ghsa.js +259 -0
- package/lib/validate-cve-catalog.js +22 -5
- package/manifest-snapshot.json +1 -1
- package/manifest.json +39 -39
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,79 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.12.0 — 2026-05-13
|
|
4
|
+
|
|
5
|
+
**Minor: catalog freshness from minutes-old disclosures, not days.**
|
|
6
|
+
|
|
7
|
+
Today's refresh sources (KEV / NVD / EPSS / IETF / MITRE) don't see a fresh-disclosure npm worm. KEV listing takes days; NVD takes ~10 days. The CVE-2026-45321 TanStack worm was caught publicly within 20 minutes — but the only feed that fired in that window was the GitHub Advisory Database. v0.12.0 adds GHSA as a refresh source, plus operator-driven single-advisory seeding, plus an editorial-enrichment helper.
|
|
8
|
+
|
|
9
|
+
### GHSA as a refresh source
|
|
10
|
+
|
|
11
|
+
`exceptd refresh` now pulls from GitHub Advisory Database (covers npm, PyPI, RubyGems, Maven, NuGet, Go, Composer, Swift, Erlang, Pub, Rust). Unauthenticated 60 req/hr; authenticated 5000 req/hr via `GITHUB_TOKEN` env var. New CVE IDs land as **drafts** flagged `_auto_imported: true` + `_draft: true`. The strict catalog validator treats drafts as warnings, not errors — so the nightly auto-PR pipeline can ship them without blocking on editorial review. Framework gaps + IoCs + ATLAS/ATT&CK refs are explicit nulls awaiting human or AI-assisted enrichment.
|
|
12
|
+
|
|
13
|
+
(Note: npm Inc. does not publish a standalone JSON advisory feed; npm advisories are surfaced via GHSA. Adding `npm-advisories` as a separate source would duplicate GHSA data with no fidelity gain.)
|
|
14
|
+
|
|
15
|
+
### `exceptd refresh --advisory <id>`
|
|
16
|
+
|
|
17
|
+
Operator-driven single-advisory seeding. Accepts CVE-* or GHSA-* identifiers. Fetches the advisory from GHSA, normalizes to the catalog draft shape, prints (default) or writes (`--apply`). Always exits **3** ("draft prepared, editorial review pending") so CI pipelines surface the next step.
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
exceptd refresh --advisory CVE-2026-45321 # dry-run, prints draft
|
|
21
|
+
exceptd refresh --advisory CVE-2026-45321 --apply # writes draft into data/cve-catalog.json
|
|
22
|
+
exceptd refresh --advisory GHSA-xxxx-xxxx-xxxx --json # JSON output
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Refuses to overwrite a human-curated entry. Honors `EXCEPTD_GHSA_FIXTURE` env var for offline tests.
|
|
26
|
+
|
|
27
|
+
### `exceptd refresh --curate <CVE-ID>`
|
|
28
|
+
|
|
29
|
+
Editorial-enrichment helper. Reads the draft entry from `data/cve-catalog.json`, cross-references against `data/atlas-ttps.json` + `data/attack-ttps.json` + `data/cwe-catalog.json` + `data/framework-control-gaps.json`, and emits structured **editorial questions** — one per null field — each with ranked candidates and a specific ASK for the reviewer.
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
{
|
|
33
|
+
"editorial_questions": [
|
|
34
|
+
{
|
|
35
|
+
"field": "atlas_refs",
|
|
36
|
+
"current_value": [],
|
|
37
|
+
"candidates": [{"id": "AML.T0010", "score": 68, "reason": "..."}],
|
|
38
|
+
"ask": "Which MITRE ATLAS techniques are present in the attack chain?"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"field": "framework_control_gaps",
|
|
42
|
+
"ask": "Which framework controls CLAIM to cover this CVE's category, and where do they fall short? Per AGENTS.md Hard Rule #6, every framework finding must include a test that distinguishes paper compliance from actual security."
|
|
43
|
+
},
|
|
44
|
+
...
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Pure heuristic — deterministic keyword-overlap scoring against existing catalogs. The reviewer (human or AI assistant) makes the final call on each candidate. Always exits **3** because editorial review is, by definition, pending.
|
|
50
|
+
|
|
51
|
+
(The natural-language form `exceptd run cve-curation --advisory <id>` — wrapping this helper in a full seven-phase playbook with GRC closure — is scoped for v0.13. The helper itself ships in v0.12 so operators can use it now.)
|
|
52
|
+
|
|
53
|
+
### Catalog schema
|
|
54
|
+
|
|
55
|
+
- `data/cve-catalog.json` entries may now carry `_auto_imported`, `_draft`, `_draft_reason`, `_source_ghsa_id`, `_source_published_at` fields.
|
|
56
|
+
- `lib/validate-cve-catalog.js` recognizes drafts: prints them as `DRAFT` lines (not `FAIL`), does not exit-fail. The summary line includes a `<N> draft(s) (auto-imported)` count.
|
|
57
|
+
- `lib/schemas/cve-catalog.schema.json` is unchanged; the draft fields are absorbed by `additionalProperties: true`.
|
|
58
|
+
|
|
59
|
+
### Tests
|
|
60
|
+
|
|
61
|
+
7 new regression cases. 366 total. Coverage: ghsa fixture fetch, advisory normalization (draft shape + cisa_kev_pending heuristic for critical), `refresh --advisory` dry-run + apply paths, `refresh --curate` editorial-question generation, refusal-on-human-curated, validator draft-tolerance.
|
|
62
|
+
|
|
63
|
+
### Operator workflow
|
|
64
|
+
|
|
65
|
+
The end-to-end flow for a fresh-disclosure CVE the nightly job hasn't caught yet:
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
$ exceptd refresh --advisory CVE-2026-XXXXX --apply # seeds draft from GHSA
|
|
69
|
+
$ exceptd refresh --curate CVE-2026-XXXXX # surfaces editorial questions + candidates
|
|
70
|
+
# review the questions, fill the catalog entry, add a zeroday-lessons.json entry,
|
|
71
|
+
# remove _auto_imported and _draft flags, then:
|
|
72
|
+
$ npm run predeploy # strict gate now passes
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The nightly auto-PR mechanism handles the GHSA pull automatically; this surface is for "I want this CVE today, not tomorrow."
|
|
76
|
+
|
|
3
77
|
## 0.11.15 — 2026-05-13
|
|
4
78
|
|
|
5
79
|
**Patch: CVE-2026-45321 (Mini Shai-Hulud TanStack npm worm) — catalog + playbook + IoC sweep.**
|
package/bin/exceptd.js
CHANGED
|
@@ -72,6 +72,7 @@ const COMMANDS = {
|
|
|
72
72
|
prefetch: () => path.join(PKG_ROOT, "lib", "prefetch.js"),
|
|
73
73
|
refresh: () => path.join(PKG_ROOT, "lib", "refresh-external.js"),
|
|
74
74
|
"refresh-network": () => path.join(PKG_ROOT, "lib", "refresh-network.js"),
|
|
75
|
+
"refresh-curate": () => path.join(PKG_ROOT, "lib", "cve-curation.js"),
|
|
75
76
|
"build-indexes": () => path.join(PKG_ROOT, "scripts", "build-indexes.js"),
|
|
76
77
|
verify: () => path.join(PKG_ROOT, "lib", "verify.js"),
|
|
77
78
|
scan: () => path.join(PKG_ROOT, "orchestrator", "index.js"),
|
|
@@ -252,9 +253,19 @@ v0.11.0 canonical surface
|
|
|
252
253
|
catalog snapshot from npm registry,
|
|
253
254
|
verify against local keys/public.pem,
|
|
254
255
|
swap data/ in place (no CLI/lib reload)
|
|
256
|
+
--advisory <id> (v0.12.0) seed a catalog entry from a
|
|
257
|
+
CVE-* or GHSA-* ID via GitHub Advisory
|
|
258
|
+
Database. Writes draft with
|
|
259
|
+
_auto_imported:true. Use --apply to
|
|
260
|
+
write to disk.
|
|
261
|
+
--curate <CVE-ID> (v0.12.0) emit editorial questions +
|
|
262
|
+
ranked candidates (ATLAS/ATT&CK/CWE/
|
|
263
|
+
framework gaps) for a draft entry.
|
|
255
264
|
--prefetch populate offline cache
|
|
256
265
|
--from-cache consume offline cache
|
|
257
266
|
--indexes-only rebuild indexes only
|
|
267
|
+
Sources: kev|epss|nvd|rfc|pins|ghsa (v0.12.0).
|
|
268
|
+
ghsa drafts pass validator as warnings.
|
|
258
269
|
|
|
259
270
|
v0.10.x compatibility (will be removed in v0.12)
|
|
260
271
|
────────────────────────────────────────────────
|
|
@@ -414,6 +425,12 @@ function main() {
|
|
|
414
425
|
// data slice without requiring a full package upgrade.
|
|
415
426
|
effectiveCmd = "refresh-network";
|
|
416
427
|
effectiveRest = rest.filter(a => a !== "--network");
|
|
428
|
+
} else if (cmd === "refresh" && rest.includes("--curate")) {
|
|
429
|
+
// v0.12.0: --curate <CVE-ID> emits editorial questions + ranked
|
|
430
|
+
// candidates (atlas/attack/cwe/framework) for a draft catalog entry.
|
|
431
|
+
// Operator or AI assistant fills the null editorial fields.
|
|
432
|
+
effectiveCmd = "refresh-curate";
|
|
433
|
+
effectiveRest = rest;
|
|
417
434
|
}
|
|
418
435
|
|
|
419
436
|
const resolver = COMMANDS[effectiveCmd];
|
package/data/_indexes/_meta.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema_version": "1.1.0",
|
|
3
|
-
"generated_at": "2026-05-13T02:
|
|
3
|
+
"generated_at": "2026-05-13T02:34:42.448Z",
|
|
4
4
|
"generator": "scripts/build-indexes.js",
|
|
5
5
|
"source_count": 49,
|
|
6
6
|
"source_hashes": {
|
|
7
|
-
"manifest.json": "
|
|
7
|
+
"manifest.json": "31cfbef4aa2a73ae93de1837209c958f5318fadbfc4481f06459617048fec44c",
|
|
8
8
|
"data/atlas-ttps.json": "1500b5830dab070c4252496964a8c0948e1052a656e2c7c6e1efaf0350645e13",
|
|
9
9
|
"data/cve-catalog.json": "e9a3a4ce988caa051e50a467f1cd9c0dcbf9e8f6f3e9522610baf196217b7bdc",
|
|
10
10
|
"data/cwe-catalog.json": "c3367d469b4b3d31e4c56397dd7a8305a0be338ecd85afa27804c0c9ce12157b",
|
package/keys/public.pem
CHANGED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* lib/cve-curation.js
|
|
5
|
+
*
|
|
6
|
+
* Editorial-enrichment helper for auto-imported CVE catalog drafts. Given
|
|
7
|
+
* a CVE ID that exists in data/cve-catalog.json as a draft (flagged
|
|
8
|
+
* `_auto_imported: true`), this module cross-references the draft against
|
|
9
|
+
* the project's existing catalogs and produces STRUCTURED EDITORIAL
|
|
10
|
+
* QUESTIONS — candidate framework gaps, ATLAS techniques, ATT&CK techniques,
|
|
11
|
+
* CWE refs — that a human reviewer or AI assistant uses to fill in the
|
|
12
|
+
* null editorial fields.
|
|
13
|
+
*
|
|
14
|
+
* Not "AI-assisted" in the LLM sense. The cross-reference logic is
|
|
15
|
+
* deterministic pattern-matching against catalogs already in the install.
|
|
16
|
+
* The output is a checklist of "fields to answer, with candidates ranked
|
|
17
|
+
* by relevance" — the reviewer makes the final call on each.
|
|
18
|
+
*
|
|
19
|
+
* Output shape (one JSON object on stdout):
|
|
20
|
+
* {
|
|
21
|
+
* ok: true,
|
|
22
|
+
* verb: "refresh",
|
|
23
|
+
* mode: "cve-curation",
|
|
24
|
+
* cve_id, draft_entry,
|
|
25
|
+
* editorial_questions: [
|
|
26
|
+
* { field, current_value, candidates: [{id, score, reason}], ask }
|
|
27
|
+
* ],
|
|
28
|
+
* next_steps: [...]
|
|
29
|
+
* }
|
|
30
|
+
*
|
|
31
|
+
* Exits non-zero (3) so CI pipelines see "editorial review pending" even
|
|
32
|
+
* on a successful curation run.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const fs = require("fs");
|
|
36
|
+
const path = require("path");
|
|
37
|
+
|
|
38
|
+
const ROOT = path.resolve(__dirname, "..");
|
|
39
|
+
|
|
40
|
+
function loadJson(rel) {
|
|
41
|
+
try { return JSON.parse(fs.readFileSync(path.join(ROOT, rel), "utf8")); }
|
|
42
|
+
catch { return null; }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Score a candidate by counting keyword overlap with the draft entry.
|
|
47
|
+
* Returns 0..100. Pure heuristic — reviewer makes the final call.
|
|
48
|
+
*/
|
|
49
|
+
function keywordOverlapScore(draftText, candidateText) {
|
|
50
|
+
if (!draftText || !candidateText) return 0;
|
|
51
|
+
const draftWords = new Set(
|
|
52
|
+
String(draftText).toLowerCase().split(/[^a-z0-9_]+/).filter(w => w.length > 3)
|
|
53
|
+
);
|
|
54
|
+
const candWords = new Set(
|
|
55
|
+
String(candidateText).toLowerCase().split(/[^a-z0-9_]+/).filter(w => w.length > 3)
|
|
56
|
+
);
|
|
57
|
+
let overlap = 0;
|
|
58
|
+
for (const w of candWords) if (draftWords.has(w)) overlap++;
|
|
59
|
+
const denom = Math.max(candWords.size, 1);
|
|
60
|
+
return Math.round((overlap / denom) * 100);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function pickCandidates(draftDigest, catalog, idField, descriptionField) {
|
|
64
|
+
if (!catalog || typeof catalog !== "object") return [];
|
|
65
|
+
const candidates = [];
|
|
66
|
+
for (const [key, entry] of Object.entries(catalog)) {
|
|
67
|
+
if (key.startsWith("_")) continue;
|
|
68
|
+
const id = entry?.[idField] || key;
|
|
69
|
+
const desc = entry?.[descriptionField] || entry?.name || entry?.description || "";
|
|
70
|
+
const score = keywordOverlapScore(draftDigest, desc + " " + id);
|
|
71
|
+
if (score > 0) {
|
|
72
|
+
candidates.push({ id, score, reason: `keyword overlap with: ${(desc || id).slice(0, 120)}` });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
candidates.sort((a, b) => b.score - a.score);
|
|
76
|
+
return candidates.slice(0, 5);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Build the curation report for a single CVE ID.
|
|
81
|
+
*/
|
|
82
|
+
function curate(cveId, { catalogPath, opts = {} } = {}) {
|
|
83
|
+
const catalog = loadJson(catalogPath || "data/cve-catalog.json");
|
|
84
|
+
if (!catalog || !catalog[cveId]) {
|
|
85
|
+
return {
|
|
86
|
+
ok: false,
|
|
87
|
+
verb: "refresh",
|
|
88
|
+
mode: "cve-curation",
|
|
89
|
+
error: `CVE ${cveId} not in data/cve-catalog.json. Seed it first via \`exceptd refresh --advisory ${cveId} --apply\`.`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
const draft = catalog[cveId];
|
|
93
|
+
|
|
94
|
+
// Only curate drafts. Editing a human-curated entry is intentional and
|
|
95
|
+
// happens via direct file edit — refusing here prevents accidental
|
|
96
|
+
// suggestion-storms on entries that have already been reviewed.
|
|
97
|
+
if (!draft._auto_imported && !draft._draft) {
|
|
98
|
+
return {
|
|
99
|
+
ok: false,
|
|
100
|
+
verb: "refresh",
|
|
101
|
+
mode: "cve-curation",
|
|
102
|
+
error: `${cveId} is a human-curated entry (no _auto_imported flag). Curation only applies to drafts. Edit directly if changes are intended.`,
|
|
103
|
+
existing_last_updated: draft.last_updated,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Digest of the draft — feed into keyword-overlap scoring.
|
|
108
|
+
const draftDigest = [
|
|
109
|
+
draft.name || "",
|
|
110
|
+
draft.affected || "",
|
|
111
|
+
(draft.affected_versions || []).join(" "),
|
|
112
|
+
draft.vector || "",
|
|
113
|
+
draft.type || "",
|
|
114
|
+
].filter(Boolean).join(" ");
|
|
115
|
+
|
|
116
|
+
// Pull candidate catalogs. Each is optional — missing catalogs are skipped
|
|
117
|
+
// gracefully.
|
|
118
|
+
const atlas = loadJson("data/atlas-ttps.json");
|
|
119
|
+
const attack = loadJson("data/attack-ttps.json");
|
|
120
|
+
const cwe = loadJson("data/cwe-catalog.json");
|
|
121
|
+
const frameworkGaps = loadJson("data/framework-control-gaps.json");
|
|
122
|
+
|
|
123
|
+
const atlasCandidates = pickCandidates(draftDigest, atlas, "ttp_id", "description");
|
|
124
|
+
const attackCandidates = pickCandidates(draftDigest, attack, "ttp_id", "description");
|
|
125
|
+
const cweCandidates = pickCandidates(draftDigest, cwe, "cwe_id", "description");
|
|
126
|
+
const frameworkCandidates = pickCandidates(draftDigest, frameworkGaps, "control_id", "description");
|
|
127
|
+
|
|
128
|
+
// Build the editorial-questions list. Each entry names the catalog field,
|
|
129
|
+
// its current value (likely null on a draft), ranked candidates, and a
|
|
130
|
+
// specific ASK to surface what the reviewer needs to decide.
|
|
131
|
+
const questions = [];
|
|
132
|
+
|
|
133
|
+
if (!draft.atlas_refs || draft.atlas_refs.length === 0) {
|
|
134
|
+
questions.push({
|
|
135
|
+
field: "atlas_refs",
|
|
136
|
+
current_value: draft.atlas_refs || [],
|
|
137
|
+
candidates: atlasCandidates,
|
|
138
|
+
ask: "Which MITRE ATLAS techniques are present in the attack chain? (Adversarial ML technique mapping — relevant if any model, training pipeline, or AI agent is involved.)",
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (!draft.attack_refs || draft.attack_refs.length === 0) {
|
|
143
|
+
questions.push({
|
|
144
|
+
field: "attack_refs",
|
|
145
|
+
current_value: draft.attack_refs || [],
|
|
146
|
+
candidates: attackCandidates,
|
|
147
|
+
ask: "Which MITRE ATT&CK techniques are present in the attack chain? (Required for SOC integration and detection-engineering downstream.)",
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!draft.framework_control_gaps) {
|
|
152
|
+
questions.push({
|
|
153
|
+
field: "framework_control_gaps",
|
|
154
|
+
current_value: null,
|
|
155
|
+
candidates: frameworkCandidates,
|
|
156
|
+
ask: "Which framework controls CLAIM to cover this CVE's category, and where do they fall short? Per AGENTS.md Hard Rule #6, every framework finding must include a test that distinguishes paper compliance from actual security. Cover NIST-800-53 + EU (NIS2/DORA/EU AI Act) + UK (CAF) + AU (ISM) + ISO 27001:2022 at minimum.",
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (!draft.iocs) {
|
|
161
|
+
questions.push({
|
|
162
|
+
field: "iocs",
|
|
163
|
+
current_value: null,
|
|
164
|
+
candidates: [],
|
|
165
|
+
ask: "What artifacts indicate compromise on a host running the affected version? Group by (a) payload_artifacts — file/process names the payload writes or runs, (b) persistence_artifacts — hooks, config entries, OS-level units that re-arm the payload, (c) behavioral — log / cache / network patterns, (d) destructive — any tripwire that destroys evidence on remediation. The sbom playbook's detect indicators feed off these.",
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (draft.poc_available === null) {
|
|
170
|
+
questions.push({
|
|
171
|
+
field: "poc_available",
|
|
172
|
+
current_value: null,
|
|
173
|
+
candidates: [],
|
|
174
|
+
ask: "Is a public PoC or in-the-wild exploitation evidence available? If yes, set poc_available: true + add poc_description summarizing capability (deterministic vs probabilistic, single-stage vs multi-stage, byte-size if known).",
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (draft.ai_discovered === null || draft.ai_assisted_weaponization === null) {
|
|
179
|
+
questions.push({
|
|
180
|
+
field: "ai_discovered + ai_assisted_weaponization",
|
|
181
|
+
current_value: { ai_discovered: draft.ai_discovered, ai_assisted_weaponization: draft.ai_assisted_weaponization },
|
|
182
|
+
candidates: [],
|
|
183
|
+
ask: "Was this vulnerability AI-discovered (per AGENTS.md Hard Rule #7, ~41% of 2025 zero-days were)? AI-assisted weaponization? Set both fields explicitly — null is not acceptable on a published entry.",
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (!draft.rwep_factors || draft.rwep_score === null) {
|
|
188
|
+
questions.push({
|
|
189
|
+
field: "rwep_score + rwep_factors",
|
|
190
|
+
current_value: { rwep_score: draft.rwep_score, rwep_factors: draft.rwep_factors },
|
|
191
|
+
candidates: [],
|
|
192
|
+
ask: "Compute RWEP using lib/scoring.js weights: cisa_kev(+25) + poc_available(+20) + ai_factor(+15) + active_exploitation(confirmed=+20 / suspected=+10) + blast_radius(0..30) + patch_available(-15) + live_patch_available(-10) + reboot_required(+5). The score field is the sum; the factors field shows the contributions.",
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (!draft.vector) {
|
|
197
|
+
questions.push({
|
|
198
|
+
field: "vector",
|
|
199
|
+
current_value: null,
|
|
200
|
+
candidates: [],
|
|
201
|
+
ask: "One-paragraph attack vector explanation. Distinguish (a) reachability (how attacker gets to the vulnerable surface), (b) primitive (what the bug actually gives them), (c) escalation (what they do with the primitive). For chained-primitives attacks (e.g. CVE-2026-45321 TanStack worm), enumerate each primitive separately.",
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
ok: true,
|
|
207
|
+
verb: "refresh",
|
|
208
|
+
mode: "cve-curation",
|
|
209
|
+
cve_id: cveId,
|
|
210
|
+
draft_summary: {
|
|
211
|
+
name: draft.name,
|
|
212
|
+
type: draft.type,
|
|
213
|
+
cvss_score: draft.cvss_score,
|
|
214
|
+
severity: draft.cvss_score >= 9 ? "critical" : draft.cvss_score >= 7 ? "high" : draft.cvss_score >= 4 ? "medium" : "low",
|
|
215
|
+
affected: draft.affected,
|
|
216
|
+
published_at: draft._source_published_at || null,
|
|
217
|
+
auto_imported_from: draft._source_ghsa_id ? `GHSA: ${draft._source_ghsa_id}` : "unknown",
|
|
218
|
+
},
|
|
219
|
+
editorial_questions: questions,
|
|
220
|
+
questions_open: questions.length,
|
|
221
|
+
next_steps: [
|
|
222
|
+
`Answer each editorial question above. The catalog gate (lib/validate-cve-catalog.js) currently treats this entry as a DRAFT (warning, not error) — answers convert it to a full entry.`,
|
|
223
|
+
`Add a matching entry to data/zeroday-lessons.json (rule #6: zero-day learning loop must be live).`,
|
|
224
|
+
`Update last_threat_review and threat_currency_score on any playbook whose cve_refs includes ${cveId}.`,
|
|
225
|
+
`Remove the _auto_imported and _draft flags from the catalog entry once editorial review is complete.`,
|
|
226
|
+
`Run \`npm run predeploy\` — the strict gate should now pass without DRAFT warnings on this entry.`,
|
|
227
|
+
],
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* CLI entry — wired from bin/exceptd.js via `refresh --curate <id>`.
|
|
233
|
+
*/
|
|
234
|
+
async function cli(argv) {
|
|
235
|
+
const opts = { advisory: null, json: false, catalogPath: null };
|
|
236
|
+
for (let i = 0; i < argv.length; i++) {
|
|
237
|
+
const a = argv[i];
|
|
238
|
+
if (a === "--json") opts.json = true;
|
|
239
|
+
else if (a === "--catalog") opts.catalogPath = argv[++i];
|
|
240
|
+
else if (a.startsWith("--catalog=")) opts.catalogPath = a.slice("--catalog=".length);
|
|
241
|
+
else if (a === "--curate") opts.advisory = argv[++i];
|
|
242
|
+
else if (a.startsWith("--curate=")) opts.advisory = a.slice("--curate=".length);
|
|
243
|
+
else if (!a.startsWith("--") && !opts.advisory) opts.advisory = a;
|
|
244
|
+
}
|
|
245
|
+
if (!opts.advisory) {
|
|
246
|
+
const err = { ok: false, verb: "refresh", mode: "cve-curation", error: "missing --curate <CVE-ID>" };
|
|
247
|
+
if (opts.json) process.stdout.write(JSON.stringify(err) + "\n");
|
|
248
|
+
else process.stderr.write(`[refresh --curate] ${err.error}\n`);
|
|
249
|
+
process.exitCode = 2;
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const result = curate(opts.advisory, { catalogPath: opts.catalogPath, opts });
|
|
253
|
+
if (opts.json || !result.ok) {
|
|
254
|
+
process.stdout.write(JSON.stringify(result) + "\n");
|
|
255
|
+
} else {
|
|
256
|
+
process.stdout.write(`[refresh --curate] ${result.cve_id} — ${result.questions_open} editorial question(s) open\n`);
|
|
257
|
+
for (const q of result.editorial_questions) {
|
|
258
|
+
process.stdout.write(`\n ${q.field}:\n`);
|
|
259
|
+
process.stdout.write(` ask: ${q.ask}\n`);
|
|
260
|
+
if (q.candidates && q.candidates.length > 0) {
|
|
261
|
+
process.stdout.write(` top candidates: ${q.candidates.slice(0, 3).map(c => c.id).join(", ")}\n`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
process.stdout.write(`\n Run with --json for the full structured output.\n`);
|
|
265
|
+
}
|
|
266
|
+
// Always non-zero on a successful curation — "editorial review pending."
|
|
267
|
+
process.exitCode = result.ok ? 3 : 2;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (require.main === module) {
|
|
271
|
+
cli(process.argv.slice(2));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
module.exports = { curate, keywordOverlapScore };
|
package/lib/refresh-external.js
CHANGED
|
@@ -50,15 +50,20 @@ function parseArgs(argv) {
|
|
|
50
50
|
fromFixture: null, // path to fixture dir
|
|
51
51
|
fromCache: null, // path to .cache/upstream dir (or default if --from-cache passed bare)
|
|
52
52
|
swarm: false, // fan-out sources across worker threads
|
|
53
|
+
advisory: null, // v0.12.0: single-advisory seed (CVE-* or GHSA-*)
|
|
53
54
|
help: false,
|
|
54
55
|
quiet: false,
|
|
56
|
+
json: false,
|
|
55
57
|
};
|
|
56
58
|
for (let i = 2; i < argv.length; i++) {
|
|
57
59
|
const a = argv[i];
|
|
58
60
|
if (a === "--apply") out.apply = true;
|
|
59
61
|
else if (a === "--quiet") out.quiet = true;
|
|
60
62
|
else if (a === "--swarm") out.swarm = true;
|
|
63
|
+
else if (a === "--json") out.json = true;
|
|
61
64
|
else if (a === "--help" || a === "-h") out.help = true;
|
|
65
|
+
else if (a === "--advisory") { out.advisory = argv[++i]; }
|
|
66
|
+
else if (a.startsWith("--advisory=")) { out.advisory = a.slice("--advisory=".length); }
|
|
62
67
|
else if (a === "--from-cache") {
|
|
63
68
|
// accept either --from-cache <path> or --from-cache (default path)
|
|
64
69
|
const next = argv[i + 1];
|
|
@@ -101,6 +106,26 @@ Modes:
|
|
|
101
106
|
--indexes-only rebuild data/_indexes/ only; no network. Equivalent to
|
|
102
107
|
\`exceptd refresh --indexes-only\`.
|
|
103
108
|
--swarm fan out sources across worker threads. Best with --from-cache.
|
|
109
|
+
--advisory <id> (v0.12.0) seed a single catalog entry from a CVE or GHSA ID.
|
|
110
|
+
Fetches from GitHub Advisory Database (covers npm + PyPI +
|
|
111
|
+
Maven + Go + ...) and writes a DRAFT to data/cve-catalog.json
|
|
112
|
+
marked with _auto_imported: true. Editorial fields
|
|
113
|
+
(framework_control_gaps, iocs, atlas_refs, attack_refs)
|
|
114
|
+
remain null pending review via:
|
|
115
|
+
exceptd run cve-curation --advisory <id>
|
|
116
|
+
Examples:
|
|
117
|
+
exceptd refresh --advisory CVE-2026-45321
|
|
118
|
+
exceptd refresh --advisory GHSA-xxxx-xxxx-xxxx --apply
|
|
119
|
+
|
|
120
|
+
Sources (default = all):
|
|
121
|
+
kev CISA Known Exploited Vulnerabilities
|
|
122
|
+
epss FIRST EPSS exploit-prediction scores
|
|
123
|
+
nvd NIST NVD per-CVE feed
|
|
124
|
+
rfc IETF Datatracker per-RFC
|
|
125
|
+
pins Upstream version-pin drift (MITRE ATLAS/ATT&CK/D3FEND/CWE) — report only
|
|
126
|
+
ghsa (v0.12.0) GitHub Advisory Database — npm/PyPI/Maven/etc. Lands new CVE
|
|
127
|
+
IDs as DRAFTS (_auto_imported: true); catalog validator treats drafts
|
|
128
|
+
as warnings, not errors. Editorial review still required.
|
|
104
129
|
|
|
105
130
|
Air-gap workflow:
|
|
106
131
|
1. On a connected host: \`exceptd refresh --prefetch\`
|
|
@@ -439,12 +464,58 @@ const PINS_SOURCE = {
|
|
|
439
464
|
},
|
|
440
465
|
};
|
|
441
466
|
|
|
467
|
+
/**
|
|
468
|
+
* v0.12.0: GHSA (GitHub Advisory Database) source. Covers npm, PyPI,
|
|
469
|
+
* RubyGems, Maven, NuGet, Go, Composer, Swift, Erlang, Pub, Rust — all
|
|
470
|
+
* in one feed, updated within hours of disclosure. Much faster than KEV
|
|
471
|
+
* (variable, often days) or NVD (~10 days).
|
|
472
|
+
*
|
|
473
|
+
* Apply path: new CVE IDs from GHSA land in data/cve-catalog.json as
|
|
474
|
+
* DRAFTS (`_auto_imported: true` + `_draft: true`). The strict catalog
|
|
475
|
+
* validator treats drafts as warnings, not errors, so the nightly
|
|
476
|
+
* auto-PR pipeline can ship them without blocking on editorial review.
|
|
477
|
+
* Framework gaps + IoCs + ATLAS/ATT&CK refs require human or AI-assisted
|
|
478
|
+
* synthesis via `exceptd run cve-curation --advisory <id>`.
|
|
479
|
+
*/
|
|
480
|
+
const GHSA_SOURCE = {
|
|
481
|
+
name: "ghsa",
|
|
482
|
+
description: "GitHub Advisory Database — multi-ecosystem disclosure feed (npm, PyPI, Maven, Go, etc.)",
|
|
483
|
+
applies_to: "data/cve-catalog.json",
|
|
484
|
+
async fetchDiff(ctx) {
|
|
485
|
+
if (ctx.fixtures?.ghsa) return synthesizeFromFixture(ctx, "ghsa");
|
|
486
|
+
if (ctx.cacheDir) {
|
|
487
|
+
// Cache parity: ghsa cache layout is .cache/upstream/ghsa/<published-date>.json
|
|
488
|
+
// For v0.12.0 we fall through to live fetch — caching is a v0.13 follow-up.
|
|
489
|
+
}
|
|
490
|
+
const ghsa = require("./source-ghsa");
|
|
491
|
+
return ghsa.buildDiff(ctx);
|
|
492
|
+
},
|
|
493
|
+
async applyDiff(ctx, diffs) {
|
|
494
|
+
const ghsa = require("./source-ghsa");
|
|
495
|
+
let updated = 0;
|
|
496
|
+
const errors = [];
|
|
497
|
+
for (const d of diffs) {
|
|
498
|
+
if (d.field !== "_new_entry") continue;
|
|
499
|
+
if (!d.after || !d.id) continue;
|
|
500
|
+
if (ctx.cveCatalog[d.id]) continue; // never overwrite existing entries
|
|
501
|
+
try {
|
|
502
|
+
ctx.cveCatalog[d.id] = d.after;
|
|
503
|
+
updated++;
|
|
504
|
+
} catch (e) {
|
|
505
|
+
errors.push(`${d.id}: ${e.message}`);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return { updated, errors };
|
|
509
|
+
},
|
|
510
|
+
};
|
|
511
|
+
|
|
442
512
|
const ALL_SOURCES = {
|
|
443
513
|
kev: KEV_SOURCE,
|
|
444
514
|
epss: EPSS_SOURCE,
|
|
445
515
|
nvd: NVD_SOURCE,
|
|
446
516
|
rfc: RFC_SOURCE,
|
|
447
517
|
pins: PINS_SOURCE,
|
|
518
|
+
ghsa: GHSA_SOURCE,
|
|
448
519
|
};
|
|
449
520
|
|
|
450
521
|
// --- Cache-mode helpers ------------------------------------------------
|
|
@@ -653,7 +724,7 @@ function loadCtx(opts) {
|
|
|
653
724
|
cacheDir: null,
|
|
654
725
|
};
|
|
655
726
|
if (opts.fromFixture) {
|
|
656
|
-
ctx.fixtures = { dir: path.resolve(opts.fromFixture), kev: true, epss: true, nvd: true, rfc: true, pins: true };
|
|
727
|
+
ctx.fixtures = { dir: path.resolve(opts.fromFixture), kev: true, epss: true, nvd: true, rfc: true, pins: true, ghsa: true };
|
|
657
728
|
} else if (opts.fromCache) {
|
|
658
729
|
const abs = path.resolve(opts.fromCache);
|
|
659
730
|
ctx.cacheDir = abs;
|
|
@@ -694,6 +765,97 @@ function chosenSources(opts) {
|
|
|
694
765
|
return out;
|
|
695
766
|
}
|
|
696
767
|
|
|
768
|
+
/**
|
|
769
|
+
* v0.12.0: single-advisory seed. Operator types
|
|
770
|
+
* exceptd refresh --advisory CVE-2026-45321
|
|
771
|
+
* or
|
|
772
|
+
* exceptd refresh --advisory GHSA-xxxx-xxxx-xxxx --apply
|
|
773
|
+
*
|
|
774
|
+
* Tool fetches from GHSA (covers npm, PyPI, etc.), normalizes to the
|
|
775
|
+
* exceptd catalog draft shape, and either prints the seed (default) or
|
|
776
|
+
* writes it to data/cve-catalog.json (--apply). Always exits non-zero
|
|
777
|
+
* when a draft is produced, signaling that editorial review is needed.
|
|
778
|
+
*/
|
|
779
|
+
async function seedSingleAdvisory(opts) {
|
|
780
|
+
const ghsa = require("./source-ghsa");
|
|
781
|
+
const id = opts.advisory;
|
|
782
|
+
const result = await ghsa.fetchAdvisoryById(id, {});
|
|
783
|
+
if (!result.ok) {
|
|
784
|
+
const err = { ok: false, verb: "refresh", error: `--advisory ${id}: ${result.error}`, source: result.source, hint: "Verify the ID format (CVE-YYYY-NNNN or GHSA-*) and network reachability. Set EXCEPTD_GHSA_FIXTURE for offline testing." };
|
|
785
|
+
if (opts.json) process.stdout.write(JSON.stringify(err) + "\n");
|
|
786
|
+
else process.stderr.write(`[refresh --advisory] ${err.error}\n hint: ${err.hint}\n`);
|
|
787
|
+
process.exitCode = 2;
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
const advisory = result.advisories[0];
|
|
791
|
+
if (!advisory) {
|
|
792
|
+
const err = { ok: false, verb: "refresh", error: `--advisory ${id}: no matching advisory found`, source: result.source };
|
|
793
|
+
if (opts.json) process.stdout.write(JSON.stringify(err) + "\n");
|
|
794
|
+
else process.stderr.write(`[refresh --advisory] ${err.error}\n`);
|
|
795
|
+
process.exitCode = 2;
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
const normalized = ghsa.normalizeAdvisory(advisory);
|
|
799
|
+
if (!normalized) {
|
|
800
|
+
const err = { ok: false, verb: "refresh", error: `--advisory ${id}: advisory has no CVE ID (GHSA-only entries are not imported into the CVE catalog in v0.12)`, ghsa_id: advisory.ghsa_id || null };
|
|
801
|
+
if (opts.json) process.stdout.write(JSON.stringify(err) + "\n");
|
|
802
|
+
else process.stderr.write(`[refresh --advisory] ${err.error}\n`);
|
|
803
|
+
process.exitCode = 2;
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
const cveId = Object.keys(normalized)[0];
|
|
807
|
+
|
|
808
|
+
if (!opts.apply) {
|
|
809
|
+
// Print the draft to stdout — operator pipes to jq / inspects /
|
|
810
|
+
// commits manually. Exit 3 = "draft produced, not applied."
|
|
811
|
+
const output = {
|
|
812
|
+
ok: true,
|
|
813
|
+
verb: "refresh",
|
|
814
|
+
mode: "advisory-seed-dry-run",
|
|
815
|
+
advisory_id: id,
|
|
816
|
+
cve_id: cveId,
|
|
817
|
+
draft: normalized[cveId],
|
|
818
|
+
hint: "Re-run with --apply to write this draft into data/cve-catalog.json. After apply, run `exceptd run cve-curation --advisory " + cveId + "` to surface editorial proposals (framework gaps, IoCs, ATLAS/ATT&CK refs).",
|
|
819
|
+
};
|
|
820
|
+
if (opts.json) process.stdout.write(JSON.stringify(output) + "\n");
|
|
821
|
+
else {
|
|
822
|
+
process.stdout.write(`[refresh --advisory] ${cveId} draft prepared (not applied).\n`);
|
|
823
|
+
process.stdout.write(` Run with --apply to write into data/cve-catalog.json.\n`);
|
|
824
|
+
process.stdout.write(` Then: exceptd run cve-curation --advisory ${cveId}\n`);
|
|
825
|
+
}
|
|
826
|
+
process.exitCode = 3;
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// Apply: write to cve-catalog.json with the _auto_imported flag.
|
|
831
|
+
const catalogPath = ABS("data/cve-catalog.json");
|
|
832
|
+
const catalog = JSON.parse(fs.readFileSync(catalogPath, "utf8"));
|
|
833
|
+
if (catalog[cveId] && !catalog[cveId]._auto_imported && !catalog[cveId]._draft) {
|
|
834
|
+
// Refuse to overwrite a human-curated entry.
|
|
835
|
+
const err = { ok: false, verb: "refresh", error: `${cveId} already present in catalog and is human-curated (not a draft). Refusing to overwrite. Edit manually if intentional.`, existing_last_updated: catalog[cveId].last_updated };
|
|
836
|
+
if (opts.json) process.stdout.write(JSON.stringify(err) + "\n");
|
|
837
|
+
else process.stderr.write(`[refresh --advisory] ${err.error}\n`);
|
|
838
|
+
process.exitCode = 4;
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
catalog[cveId] = normalized[cveId];
|
|
842
|
+
fs.writeFileSync(catalogPath, JSON.stringify(catalog, null, 2) + "\n", "utf8");
|
|
843
|
+
const output = {
|
|
844
|
+
ok: true,
|
|
845
|
+
verb: "refresh",
|
|
846
|
+
mode: "advisory-seed-applied",
|
|
847
|
+
advisory_id: id,
|
|
848
|
+
cve_id: cveId,
|
|
849
|
+
written_to: "data/cve-catalog.json",
|
|
850
|
+
is_draft: true,
|
|
851
|
+
hint: "Draft written. Required next steps before this entry passes the strict catalog gate: (1) `exceptd run cve-curation --advisory " + cveId + "` to surface editorial proposals; (2) human review + fill in framework_control_gaps, atlas_refs, attack_refs, iocs; (3) add matching entry to data/zeroday-lessons.json; (4) remove `_auto_imported` and `_draft` flags.",
|
|
852
|
+
};
|
|
853
|
+
if (opts.json) process.stdout.write(JSON.stringify(output) + "\n");
|
|
854
|
+
else process.stdout.write(`[refresh --advisory] ${cveId} draft written to data/cve-catalog.json.\n Next: exceptd run cve-curation --advisory ${cveId}\n`);
|
|
855
|
+
// Exit 3 even on successful write — "draft applied, editorial step pending."
|
|
856
|
+
process.exitCode = 3;
|
|
857
|
+
}
|
|
858
|
+
|
|
697
859
|
async function main() {
|
|
698
860
|
const opts = parseArgs(process.argv);
|
|
699
861
|
if (opts.help) {
|
|
@@ -701,6 +863,15 @@ async function main() {
|
|
|
701
863
|
process.exit(0);
|
|
702
864
|
}
|
|
703
865
|
|
|
866
|
+
// v0.12.0: `--advisory <id>` short-circuits the normal source loop and
|
|
867
|
+
// seeds a single CVE catalog entry from GHSA. Exits non-zero ("draft
|
|
868
|
+
// written, please review") so CI pipelines surface the needed editorial
|
|
869
|
+
// step. Operator must run `--apply` for the write to land; without it,
|
|
870
|
+
// the seed is printed to stdout for review.
|
|
871
|
+
if (opts.advisory) {
|
|
872
|
+
return seedSingleAdvisory(opts);
|
|
873
|
+
}
|
|
874
|
+
|
|
704
875
|
const ctx = loadCtx(opts);
|
|
705
876
|
const sources = chosenSources(opts);
|
|
706
877
|
const log = (s) => opts.quiet || console.log(s);
|