@blamejs/exceptd-skills 0.11.15 → 0.12.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/CHANGELOG.md CHANGED
@@ -1,5 +1,90 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.12.1 — 2026-05-13
4
+
5
+ **Patch: README + website docs for the v0.12.0 freshness surface.**
6
+
7
+ v0.12.0 shipped the GHSA source + `refresh --advisory` + `refresh --curate` but the README operator section + the website still showed the v0.11.x command set. v0.12.1 brings the docs into line:
8
+
9
+ - README: refresh command reference now lists `--network`, `--advisory <CVE-or-GHSA-ID>`, `--curate <CVE-ID>`, `--prefetch`, and the `ghsa` source. Operator section command examples updated. New `EXCEPTD_GHSA_FIXTURE` + `EXCEPTD_REGISTRY_FIXTURE` env vars documented.
10
+ - Website: "nightly upstream refresh" feature card extended to mention GHSA as the minutes-old disclosure path (vs days for KEV / NVD). Operator persona card command list updated to show the advisory + curate workflow.
11
+
12
+ No CLI / catalog / playbook changes — pure docs.
13
+
14
+ ## 0.12.0 — 2026-05-13
15
+
16
+ **Minor: catalog freshness from minutes-old disclosures, not days.**
17
+
18
+ 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.
19
+
20
+ ### GHSA as a refresh source
21
+
22
+ `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.
23
+
24
+ (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.)
25
+
26
+ ### `exceptd refresh --advisory <id>`
27
+
28
+ 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.
29
+
30
+ ```
31
+ exceptd refresh --advisory CVE-2026-45321 # dry-run, prints draft
32
+ exceptd refresh --advisory CVE-2026-45321 --apply # writes draft into data/cve-catalog.json
33
+ exceptd refresh --advisory GHSA-xxxx-xxxx-xxxx --json # JSON output
34
+ ```
35
+
36
+ Refuses to overwrite a human-curated entry. Honors `EXCEPTD_GHSA_FIXTURE` env var for offline tests.
37
+
38
+ ### `exceptd refresh --curate <CVE-ID>`
39
+
40
+ 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.
41
+
42
+ ```
43
+ {
44
+ "editorial_questions": [
45
+ {
46
+ "field": "atlas_refs",
47
+ "current_value": [],
48
+ "candidates": [{"id": "AML.T0010", "score": 68, "reason": "..."}],
49
+ "ask": "Which MITRE ATLAS techniques are present in the attack chain?"
50
+ },
51
+ {
52
+ "field": "framework_control_gaps",
53
+ "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."
54
+ },
55
+ ...
56
+ ]
57
+ }
58
+ ```
59
+
60
+ 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.
61
+
62
+ (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.)
63
+
64
+ ### Catalog schema
65
+
66
+ - `data/cve-catalog.json` entries may now carry `_auto_imported`, `_draft`, `_draft_reason`, `_source_ghsa_id`, `_source_published_at` fields.
67
+ - `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.
68
+ - `lib/schemas/cve-catalog.schema.json` is unchanged; the draft fields are absorbed by `additionalProperties: true`.
69
+
70
+ ### Tests
71
+
72
+ 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.
73
+
74
+ ### Operator workflow
75
+
76
+ The end-to-end flow for a fresh-disclosure CVE the nightly job hasn't caught yet:
77
+
78
+ ```
79
+ $ exceptd refresh --advisory CVE-2026-XXXXX --apply # seeds draft from GHSA
80
+ $ exceptd refresh --curate CVE-2026-XXXXX # surfaces editorial questions + candidates
81
+ # review the questions, fill the catalog entry, add a zeroday-lessons.json entry,
82
+ # remove _auto_imported and _draft flags, then:
83
+ $ npm run predeploy # strict gate now passes
84
+ ```
85
+
86
+ The nightly auto-PR mechanism handles the GHSA pull automatically; this surface is for "I want this CVE today, not tomorrow."
87
+
3
88
  ## 0.11.15 — 2026-05-13
4
89
 
5
90
  **Patch: CVE-2026-45321 (Mini Shai-Hulud TanStack npm worm) — catalog + playbook + IoC sweep.**
package/README.md CHANGED
@@ -132,11 +132,11 @@ No clone, no signing keys, no Node 24 required for assistants that read directly
132
132
  You want to refresh CVE/RFC data, run currency checks, or generate reports. Install + invoke via `npx` (no global install needed):
133
133
 
134
134
  ```bash
135
- npx @blamejs/exceptd-skills prefetch # warm local cache of upstream data
136
- npx @blamejs/exceptd-skills refresh --from-cache --swarm
137
- npx @blamejs/exceptd-skills validate-cves --from-cache --no-fail
138
- npx @blamejs/exceptd-skills currency
139
- npx @blamejs/exceptd-skills report executive
135
+ npx @blamejs/exceptd-skills doctor # health check
136
+ npx @blamejs/exceptd-skills refresh --apply --swarm # pull KEV/NVD/EPSS/RFC/GHSA + apply
137
+ npx @blamejs/exceptd-skills refresh --advisory CVE-2026-45321 # seed one CVE draft from GHSA
138
+ npx @blamejs/exceptd-skills refresh --curate CVE-2026-45321 # surface editorial questions for a draft
139
+ npx @blamejs/exceptd-skills refresh --network # swap data/ from latest signed npm tarball
140
140
  ```
141
141
 
142
142
  For frequent use, install globally to skip the `npx` resolution every time:
@@ -146,14 +146,18 @@ npm install -g @blamejs/exceptd-skills
146
146
  exceptd help
147
147
  ```
148
148
 
149
- Air-gapped operation: run `exceptd prefetch` on a connected host, copy the resulting `.cache/upstream/` to the airgap, run `exceptd refresh --from-cache <path> --apply` over there. The vendored upstream snapshots replace every network call.
149
+ Air-gapped operation: run `exceptd refresh --prefetch` on a connected host, copy the resulting `.cache/upstream/` to the airgap, run `exceptd refresh --from-cache <path> --apply` over there. The vendored upstream snapshots replace every network call.
150
+
151
+ Fresh-disclosure workflow (v0.12.0): the nightly auto-PR job pulls KEV / NVD / EPSS / IETF / **GHSA** (added in v0.12.0). KEV typically takes days; NVD ~10 days; GHSA fires within hours of disclosure and covers npm + PyPI + Maven + Go + NuGet + …. New CVE IDs land as drafts (`_auto_imported: true`, `_draft: true`) that the catalog validator treats as warnings, not errors — operators get the fresh entry immediately, editorial review (framework gaps, IoCs, ATLAS/ATT&CK refs) follows via `exceptd refresh --curate <CVE-ID>`. For "I want this CVE today, not tomorrow": `exceptd refresh --advisory <CVE-or-GHSA-ID> --apply`.
150
152
 
151
153
  Optional env vars for higher rate budgets:
152
154
 
153
155
  | Variable | Purpose |
154
156
  |---|---|
155
157
  | `NVD_API_KEY` | Lifts NVD 2.0 from 5 → 50 requests per 30s window. Free key at <https://nvd.nist.gov/developers/request-an-api-key>. |
156
- | `GITHUB_TOKEN` | Lifts GitHub Releases (used for ATLAS / ATT&CK / D3FEND / CWE pin checks) from 60 → 5000 requests per hour. |
158
+ | `GITHUB_TOKEN` | Lifts GitHub Releases + GHSA from 60 → 5000 requests per hour. |
159
+ | `EXCEPTD_GHSA_FIXTURE` | Path to a JSON fixture matching the api.github.com/advisories shape. For offline tests + air-gap workflows. |
160
+ | `EXCEPTD_REGISTRY_FIXTURE` | Path to a JSON fixture matching the npm registry response. Used by `doctor --registry-check` + `run --upstream-check` + `refresh --network` for offline testing. |
157
161
 
158
162
  ### 3. Maintainer (extend / sign / publish)
159
163
 
@@ -275,9 +279,24 @@ exceptd refresh Refresh upstream catalogs + indexes.
275
279
  Replaces prefetch + refresh + build-indexes.
276
280
  --apply Write diffs back + rebuild indexes.
277
281
  --from-cache [<dir>] Read from prefetch cache.
278
- --no-network Dry-run.
282
+ --prefetch Populate the offline cache (alias for
283
+ --no-network).
284
+ --network (v0.11.14) Fetch latest signed catalog
285
+ snapshot from npm tarball, verify against
286
+ local public.pem, swap data/ in place.
287
+ --advisory <CVE-or-GHSA-ID> (v0.12.0) Seed a single catalog entry from
288
+ GitHub Advisory Database. Writes a draft
289
+ flagged _auto_imported. --apply commits it.
290
+ --curate <CVE-ID> (v0.12.0) Emit editorial questions + ranked
291
+ candidates (ATLAS/ATT&CK/CWE/framework) for
292
+ a draft catalog entry.
279
293
  --indexes-only Rebuild data/_indexes/*.json only.
280
294
 
295
+ Sources (default = all): kev | epss | nvd | rfc | pins | ghsa (v0.12.0).
296
+ GHSA covers npm, PyPI, Maven, Go, NuGet, etc. New CVE IDs land as drafts
297
+ that the catalog validator treats as warnings, not errors — editorial
298
+ review (framework gaps, IoCs, ATLAS/ATT&CK refs) is still required.
299
+
281
300
  exceptd skill <name> Show context for one skill.
282
301
  exceptd framework-gap <FW> <ref> One framework + one CVE/scenario, JSON
283
302
  or human. (Operates outside the seven-
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];
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "schema_version": "1.1.0",
3
- "generated_at": "2026-05-13T02:21:22.318Z",
3
+ "generated_at": "2026-05-13T02:39:53.161Z",
4
4
  "generator": "scripts/build-indexes.js",
5
5
  "source_count": 49,
6
6
  "source_hashes": {
7
- "manifest.json": "8231ac5cd18201c56fd29b5925a86f279708e32eb8fcc8fff35823a7fec0ee3a",
7
+ "manifest.json": "e1c4c5871d6bc399c5e3b01137467ed176f1ef99ac58e71e9f4c14213746909c",
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
@@ -1,3 +1,3 @@
1
1
  -----BEGIN PUBLIC KEY-----
2
- MCowBQYDK2VwAyEAc7dTqpdkqSacW3fFwlplSF3i9c845VcTA118wKCxuvE=
2
+ MCowBQYDK2VwAyEAOdxunbZSk1AhrViFks5WpHXOg8zfpnT4NiUE8dlW2+4=
3
3
  -----END PUBLIC KEY-----
@@ -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 };