@blamejs/exceptd-skills 0.11.14 → 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.
@@ -1,10 +1,22 @@
1
1
  {
2
2
  "_meta": {
3
3
  "id": "sbom",
4
- "version": "1.0.0",
5
- "last_threat_review": "2026-05-11",
6
- "threat_currency_score": 95,
4
+ "version": "1.1.0",
5
+ "last_threat_review": "2026-05-13",
6
+ "threat_currency_score": 97,
7
7
  "changelog": [
8
+ {
9
+ "version": "1.1.0",
10
+ "date": "2026-05-13",
11
+ "summary": "Adds CVE-2026-45321 (Mini Shai-Hulud TanStack npm worm, 2026-05-11). Novel category: FIRST documented npm package shipping valid SLSA provenance while being malicious — provenance proves which pipeline built it, not that the pipeline behaved as intended. Detect path includes chained-primitives signature (pull_request_target + actions/cache + id-token:write co-residency), IoC sweep for .claude/settings.json SessionStart hooks + .vscode/tasks.json folder-open hooks + LaunchAgent / systemd-user persistence, registry-cooldown mitigation (.npmrc before=72h or minimumReleaseAge=4320).",
12
+ "cves_added": [
13
+ "CVE-2026-45321"
14
+ ],
15
+ "framework_gaps_updated": [
16
+ "slsa-l3-insufficient-vs-cache-poisoning",
17
+ "nist-800-218-SSDF-PS3-PO3"
18
+ ]
19
+ },
8
20
  {
9
21
  "version": "1.0.0",
10
22
  "date": "2026-05-11",
@@ -80,7 +92,8 @@
80
92
  "CVE-2026-43284",
81
93
  "CVE-2026-43500",
82
94
  "CVE-2025-53773",
83
- "CVE-2026-30615"
95
+ "CVE-2026-30615",
96
+ "CVE-2026-45321"
84
97
  ],
85
98
  "cwe_refs": [
86
99
  "CWE-1357",
@@ -505,6 +518,48 @@
505
518
  "description": "Authoritative catalog for matched-CVE correlation.",
506
519
  "required": true,
507
520
  "air_gap_alternative": "Catalog is shipped with exceptd; available offline."
521
+ },
522
+ {
523
+ "id": "tanstack-payload-sweep",
524
+ "type": "file_path",
525
+ "source": "find node_modules -path '*/@tanstack/*' \\( -name 'router_init.js' -o -name 'router_runtime.js' \\) 2>/dev/null",
526
+ "description": "CVE-2026-45321 IoC sweep — payload markers inside any installed @tanstack/* package. Captures both flat npm and pnpm-style nested layouts.",
527
+ "required": false
528
+ },
529
+ {
530
+ "id": "agent-persistence-claude-settings",
531
+ "type": "config_file",
532
+ "source": ".claude/settings.json and $HOME/.claude/settings.json — read `hooks` keys, in particular SessionStart entries",
533
+ "description": "CVE-2026-45321 persistence vector — read every Claude Code settings file in scope to inspect hook entries.",
534
+ "required": false
535
+ },
536
+ {
537
+ "id": "agent-persistence-vscode-tasks",
538
+ "type": "config_file",
539
+ "source": ".vscode/tasks.json — read `tasks[].runOptions.runOn` for any folderOpen entries",
540
+ "description": "CVE-2026-45321 persistence vector — VS Code folder-open hooks re-arm the worm on every IDE re-open.",
541
+ "required": false
542
+ },
543
+ {
544
+ "id": "agent-persistence-os-level",
545
+ "type": "config_file",
546
+ "source": "$HOME/Library/LaunchAgents/*.plist (macOS) AND $HOME/.config/systemd/user/*.service (Linux) — list and read",
547
+ "description": "CVE-2026-45321 OS-level persistence — outlives any IDE/agent restart.",
548
+ "required": false
549
+ },
550
+ {
551
+ "id": "npmrc-cooldown-policy",
552
+ "type": "config_file",
553
+ "source": "Read .npmrc (project) and $HOME/.npmrc (user) — look for `before=` or `minimumReleaseAge=` settings",
554
+ "description": "Mitigation status for CVE-2026-45321 and similar fresh-publish worms. Absence is a high-confidence finding for any project that consumes npm packages.",
555
+ "required": false
556
+ },
557
+ {
558
+ "id": "github-workflows",
559
+ "type": "config_file",
560
+ "source": "Read .github/workflows/*.yml and *.yaml — extract `on:` triggers, `permissions:`, and `uses: actions/cache@*` step references",
561
+ "description": "CVE-2026-45321 architectural pre-condition check — detects pull_request_target + id-token:write + shared actions/cache co-residency in the same repo.",
562
+ "required": false
508
563
  }
509
564
  ],
510
565
  "collection_scope": {
@@ -628,6 +683,68 @@
628
683
  "description": "KEV-listed match — fast-path escalation required.",
629
684
  "confidence": "deterministic",
630
685
  "deterministic": true
686
+ },
687
+ {
688
+ "id": "tanstack-worm-payload-files",
689
+ "type": "file_path",
690
+ "value": "node_modules/@tanstack/*/router_init.js exists OR node_modules/@tanstack/*/router_runtime.js exists",
691
+ "description": "CVE-2026-45321 (Mini Shai-Hulud) payload markers — these files do not exist in clean TanStack packages.",
692
+ "confidence": "deterministic",
693
+ "deterministic": true,
694
+ "attack_ref": "T1195.002"
695
+ },
696
+ {
697
+ "id": "tanstack-worm-resolved-during-publish-window",
698
+ "type": "log_pattern",
699
+ "value": "Lockfile entry for any @tanstack/* package resolved within 2026-05-11T19:20Z..2026-05-11T19:26Z (the malicious publish window)",
700
+ "description": "CVE-2026-45321 timing match — any @tanstack/* package whose lockfile-recorded resolution timestamp falls inside the 6-minute attacker publish window is suspect even if the payload markers were since cleaned.",
701
+ "confidence": "high",
702
+ "deterministic": false,
703
+ "attack_ref": "T1195.002"
704
+ },
705
+ {
706
+ "id": "agent-persistence-claude-session-start-hook",
707
+ "type": "file_path",
708
+ "value": ".claude/settings.json contains hooks.SessionStart referencing .vscode/setup.mjs OR any non-blamejs-installed script",
709
+ "description": "CVE-2026-45321 persistence vector — worm installs a SessionStart hook to re-arm on next Claude Code launch. Any SessionStart hook running an in-repo .mjs that the operator didn't author is suspect.",
710
+ "confidence": "deterministic",
711
+ "deterministic": true,
712
+ "attack_ref": "T1574"
713
+ },
714
+ {
715
+ "id": "agent-persistence-vscode-folder-open-task",
716
+ "type": "file_path",
717
+ "value": ".vscode/tasks.json contains a runOptions.runOn=folderOpen task pointing at .vscode/setup.mjs or similar",
718
+ "description": "CVE-2026-45321 persistence vector — folder-open hook re-arms on every VS Code re-open of the directory.",
719
+ "confidence": "deterministic",
720
+ "deterministic": true,
721
+ "attack_ref": "T1547"
722
+ },
723
+ {
724
+ "id": "agent-persistence-os-level",
725
+ "type": "file_path",
726
+ "value": "~/Library/LaunchAgents/com.tanstack.*.plist exists (macOS) OR ~/.config/systemd/user/*.service references an in-repo staged setup.mjs (Linux)",
727
+ "description": "CVE-2026-45321 OS-level persistence — outlives any IDE/agent restart. Targets macOS LaunchAgents + Linux systemd-user units.",
728
+ "confidence": "deterministic",
729
+ "deterministic": true,
730
+ "attack_ref": "T1547"
731
+ },
732
+ {
733
+ "id": "ci-cache-poisoning-co-residency",
734
+ "type": "log_pattern",
735
+ "value": "Repo .github/workflows/ contains BOTH (a) a workflow with `on: pull_request_target` AND (b) any workflow with `permissions: id-token: write` AND (c) any actions/cache step shared between the two",
736
+ "description": "Architectural pre-condition for CVE-2026-45321-style chained-primitives attacks. Even without the payload, this co-residency means any successful fork-PR exploit can poison the cache that the publish workflow restores. Mitigation: separate cache namespaces, or remove pull_request_target.",
737
+ "confidence": "deterministic",
738
+ "deterministic": true,
739
+ "attack_ref": "T1195.002"
740
+ },
741
+ {
742
+ "id": "npm-registry-no-cooldown",
743
+ "type": "file_path",
744
+ "value": ".npmrc and ~/.npmrc both lack `before=` or `minimumReleaseAge=` settings, AND project consumes any npm package",
745
+ "description": "Mitigation gap for CVE-2026-45321 and similar fresh-publish worms. Without a registry cooldown, `npm install` will accept a version published seconds ago. Recommended: `before=72h` (npm 11+) or `minimumReleaseAge=4320` minutes. The worm was caught publicly within 20 minutes; 72h is overkill-safe.",
746
+ "confidence": "high",
747
+ "deterministic": false
631
748
  }
632
749
  ],
633
750
  "false_positive_profile": [
@@ -373,5 +373,98 @@
373
373
  "basis": "No vendor management or supply chain control covers MCP servers. 150M+ affected downloads suggests extremely broad exposure.",
374
374
  "theater_pattern": "vendor_management_ai"
375
375
  }
376
+ },
377
+ "CVE-2026-45321": {
378
+ "name": "Mini Shai-Hulud TanStack npm worm",
379
+ "lesson_date": "2026-05-13",
380
+ "attack_vector": {
381
+ "description": "Three chained primitives across one repository's CI: (1) pull_request_target on a non-publishing workflow ran fork-PR code with base-repo permissions, (2) that run poisoned actions/cache under a key the publish workflow would later restore, (3) on next main push the publish workflow restored the poisoned cache, attacker code read /proc/<runner>/mem to lift the OIDC token before the official publish step, and shipped malicious tarballs to npm with VALID SLSA provenance. 84 versions across 42 @tanstack/* packages published in a 6-minute window 2026-05-11 19:20-19:26 UTC.",
382
+ "privileges_required": "Any GitHub account that can open a pull request to the target repository (no maintainer access required).",
383
+ "complexity": "engineering-grade — chained primitives, deep CI knowledge, /proc/<pid>/mem token-scraping under id-token:write",
384
+ "ai_factor": "None observed. Engineering-grade tradecraft attributed to TeamPCP. Notable for what it didn't need: AI didn't make this attack possible. CI-trust-boundary misuse + cache co-residency made it possible."
385
+ },
386
+ "defense_chain": {
387
+ "prevention": {
388
+ "what_would_have_worked": "Forbid pull_request_target co-residency with id-token:write workflows in the same repo, OR isolate actions/cache namespaces per trigger class so fork-PR runs cannot write to a key any tag/main run will read.",
389
+ "was_this_required": false,
390
+ "framework_requiring_it": null,
391
+ "adequacy": "Architectural — eliminates the chain. Hardest part is auditing every repo for the architectural pre-condition; this is what the sbom playbook's `ci-cache-poisoning-co-residency` indicator checks."
392
+ },
393
+ "detection": {
394
+ "what_would_have_worked": "Consumer-side fresh-publish cooldown (.npmrc before=72h or minimumReleaseAge=4320 minutes). External researchers caught this worm within 20 minutes of publish; 72h is overkill-safe.",
395
+ "was_this_required": false,
396
+ "framework_requiring_it": null,
397
+ "adequacy": "Defense in depth — operators who would have installed the malicious version on 2026-05-11 19:25Z would not have, because the cooldown would have rejected it."
398
+ },
399
+ "response": {
400
+ "what_would_have_worked": "Token rotation triggered by the npm yank notice, paired with host-snapshot BEFORE rotation (the worm payload carries a destructive wipe on token-revocation).",
401
+ "was_this_required": false,
402
+ "framework_requiring_it": null,
403
+ "adequacy": "Reduces blast radius post-exploitation. The destructive-on-revocation property means hasty rotation can lose evidence."
404
+ }
405
+ },
406
+ "framework_coverage": {
407
+ "SLSA-L3": {
408
+ "covered": true,
409
+ "adequate": false,
410
+ "gap": "SLSA L3 build-integrity is necessary but insufficient against cache-poisoning attacks within the build. The malicious tarballs shipped with VALID SLSA provenance — provenance proves which pipeline built the artifact, not that the pipeline behaved as intended."
411
+ },
412
+ "NIST-800-218-SSDF": {
413
+ "covered": true,
414
+ "adequate": false,
415
+ "gap": "PS.3 + PO.3 don't address cache poisoning between sibling workflows in the same repo. SSDF presumes per-workflow trust isolation that GitHub Actions' shared actions/cache breaks."
416
+ },
417
+ "NIST-800-53-SA-12": {
418
+ "covered": true,
419
+ "adequate": false,
420
+ "gap": "Supply chain protection treats provenance + signing as the trust anchor. CVE-2026-45321 demonstrates both can be intact on a malicious package."
421
+ },
422
+ "EU-CRA-Art13": {
423
+ "covered": true,
424
+ "adequate": false,
425
+ "gap": "Vulnerability-handling provisions presume detectable signal at consumption. Valid provenance neutralizes the standard consumer-side check."
426
+ },
427
+ "NIS2-Art21-2d": {
428
+ "covered": true,
429
+ "adequate": false,
430
+ "gap": "Supply chain risk management presumes detectable signal at consumption."
431
+ }
432
+ },
433
+ "new_control_requirements": [
434
+ {
435
+ "id": "NEW-CTRL-008",
436
+ "name": "CI-WORKFLOW-TRUST-BOUNDARY-ISOLATION",
437
+ "description": "Forbid pull_request_target co-residency with id-token:write workflows in the same repository. If co-residency is required, isolate actions/cache namespaces per trigger class.",
438
+ "evidence": "CVE-2026-45321 — chained primitives required exactly this co-residency to succeed",
439
+ "gap_closes": [
440
+ "NIST-800-218-SSDF",
441
+ "SLSA-L3"
442
+ ]
443
+ },
444
+ {
445
+ "id": "NEW-CTRL-009",
446
+ "name": "REGISTRY-COOLDOWN-POLICY",
447
+ "description": "Consumer-side registry cooldown (.npmrc before=72h or minimumReleaseAge=4320 minutes) refuses to install any version published within the last 72 hours.",
448
+ "evidence": "CVE-2026-45321 — caught publicly within 20 minutes; 72h is overkill-safe",
449
+ "gap_closes": [
450
+ "NIST-800-53-SA-12",
451
+ "NIS2-Art21-2d"
452
+ ]
453
+ },
454
+ {
455
+ "id": "NEW-CTRL-010",
456
+ "name": "AGENT-PERSISTENCE-HOOK-ALLOWLIST",
457
+ "description": "AI coding assistants must allowlist hooks. SessionStart hooks in .claude/settings.json + folder-open tasks in .vscode/tasks.json + OS-level LaunchAgents/systemd-user units that reference in-repo staged scripts must be approved by the operator before execution.",
458
+ "evidence": "CVE-2026-45321 — the worm installs persistence via all three hook surfaces",
459
+ "gap_closes": [
460
+ "ALL-AI-AGENT-PERSISTENCE"
461
+ ]
462
+ }
463
+ ],
464
+ "compliance_exposure_score": {
465
+ "percent_audit_passing_orgs_still_exposed": 95,
466
+ "basis": "SLSA L3 + provenance + signing all pass on the malicious package. Standard supply-chain audits (SBOM check, provenance verify, signature verify) all give green. The architectural pre-condition (pull_request_target + id-token:write + shared actions/cache) is not in any compliance framework's control catalog. Combined ~150M+ weekly downloads across 42 packages = extremely broad exposure.",
467
+ "theater_pattern": "provenance_signed_therefore_safe"
468
+ }
376
469
  }
377
470
  }
package/keys/public.pem CHANGED
@@ -1,3 +1,3 @@
1
1
  -----BEGIN PUBLIC KEY-----
2
- MCowBQYDK2VwAyEAbyrz9k9voneYsqY63g6A5y4jTcuiJd0FEDtk4li5uIE=
2
+ MCowBQYDK2VwAyEA8r4wi/onMuK7+R3naEBpBUU8LDfUlt0D67FKS7IpRp4=
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 };