@blamejs/exceptd-skills 0.12.11 → 0.12.15
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 +243 -0
- package/bin/exceptd.js +299 -48
- package/data/_indexes/_meta.json +49 -48
- package/data/_indexes/activity-feed.json +13 -5
- package/data/_indexes/catalog-summaries.json +51 -29
- package/data/_indexes/chains.json +3238 -3210
- package/data/_indexes/frequency.json +3 -0
- package/data/_indexes/jurisdiction-map.json +5 -3
- package/data/_indexes/section-offsets.json +712 -685
- package/data/_indexes/theater-fingerprints.json +1 -1
- package/data/_indexes/token-budget.json +355 -340
- package/data/atlas-ttps.json +144 -129
- package/data/attack-techniques.json +339 -0
- package/data/cve-catalog.json +515 -475
- package/data/cwe-catalog.json +1081 -759
- package/data/exploit-availability.json +63 -15
- package/data/framework-control-gaps.json +867 -843
- package/data/rfc-references.json +276 -276
- package/keys/EXPECTED_FINGERPRINT +1 -0
- package/lib/auto-discovery.js +21 -4
- package/lib/cross-ref-api.js +39 -6
- package/lib/cve-curation.js +505 -47
- package/lib/lint-skills.js +217 -15
- package/lib/playbook-runner.js +1224 -183
- package/lib/prefetch.js +121 -8
- package/lib/refresh-external.js +261 -95
- package/lib/refresh-network.js +208 -18
- package/lib/schemas/manifest.schema.json +16 -0
- package/lib/scoring.js +83 -7
- package/lib/sign.js +112 -3
- package/lib/source-ghsa.js +219 -37
- package/lib/source-osv.js +381 -122
- package/lib/validate-catalog-meta.js +64 -9
- package/lib/validate-cve-catalog.js +213 -7
- package/lib/validate-indexes.js +88 -37
- package/lib/validate-playbooks.js +469 -0
- package/lib/verify.js +313 -16
- package/manifest-snapshot.json +1 -1
- package/manifest-snapshot.sha256 +1 -0
- package/manifest.json +73 -73
- package/orchestrator/dispatcher.js +21 -1
- package/orchestrator/event-bus.js +52 -8
- package/orchestrator/index.js +279 -20
- package/orchestrator/pipeline.js +63 -2
- package/orchestrator/scanner.js +32 -10
- package/orchestrator/scheduler.js +196 -20
- package/package.json +3 -1
- package/sbom.cdx.json +9 -9
- package/scripts/check-manifest-snapshot.js +32 -0
- package/scripts/check-sbom-currency.js +65 -3
- package/scripts/check-test-coverage.js +142 -19
- package/scripts/predeploy.js +110 -40
- package/scripts/refresh-manifest-snapshot.js +55 -4
- package/scripts/validate-vendor-online.js +169 -0
- package/scripts/verify-shipped-tarball.js +106 -3
- package/skills/ai-attack-surface/skill.md +18 -10
- package/skills/ai-c2-detection/skill.md +7 -2
- package/skills/ai-risk-management/skill.md +5 -4
- package/skills/api-security/skill.md +3 -3
- package/skills/attack-surface-pentest/skill.md +5 -5
- package/skills/cloud-security/skill.md +1 -1
- package/skills/compliance-theater/skill.md +8 -8
- package/skills/container-runtime-security/skill.md +1 -1
- package/skills/dlp-gap-analysis/skill.md +5 -1
- package/skills/email-security-anti-phishing/skill.md +1 -1
- package/skills/exploit-scoring/skill.md +18 -18
- package/skills/framework-gap-analysis/skill.md +6 -6
- package/skills/global-grc/skill.md +3 -2
- package/skills/identity-assurance/skill.md +2 -2
- package/skills/incident-response-playbook/skill.md +4 -4
- package/skills/kernel-lpe-triage/skill.md +21 -2
- package/skills/mcp-agent-trust/skill.md +17 -10
- package/skills/mlops-security/skill.md +2 -1
- package/skills/ot-ics-security/skill.md +1 -1
- package/skills/policy-exception-gen/skill.md +3 -3
- package/skills/pqc-first/skill.md +1 -1
- package/skills/rag-pipeline-security/skill.md +7 -3
- package/skills/researcher/skill.md +20 -3
- package/skills/sector-energy/skill.md +1 -1
- package/skills/sector-federal-government/skill.md +1 -1
- package/skills/sector-financial/skill.md +3 -3
- package/skills/sector-healthcare/skill.md +2 -2
- package/skills/security-maturity-tiers/skill.md +7 -7
- package/skills/skill-update-loop/skill.md +19 -3
- package/skills/supply-chain-integrity/skill.md +1 -1
- package/skills/threat-model-currency/skill.md +11 -11
- package/skills/threat-modeling-methodology/skill.md +3 -3
- package/skills/webapp-security/skill.md +1 -1
- package/skills/zeroday-gap-learn/skill.md +51 -7
- package/vendor/blamejs/_PROVENANCE.json +4 -1
- package/vendor/blamejs/worker-pool.js +38 -0
package/lib/cve-curation.js
CHANGED
|
@@ -11,37 +11,94 @@
|
|
|
11
11
|
* CWE refs — that a human reviewer or AI assistant uses to fill in the
|
|
12
12
|
* null editorial fields.
|
|
13
13
|
*
|
|
14
|
-
*
|
|
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.
|
|
14
|
+
* Two modes:
|
|
18
15
|
*
|
|
19
|
-
*
|
|
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
|
-
* }
|
|
16
|
+
* curate(id, { apply: false }) → questionnaire (default)
|
|
17
|
+
* curate(id, { apply: true, answers: {} }) → land answers into the catalog
|
|
30
18
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
19
|
+
* The apply path validates the resulting entry against the strict CVE
|
|
20
|
+
* schema. When every required field is populated, the draft markers
|
|
21
|
+
* (`_auto_imported` + `_draft` + `_draft_reason`) are removed and the
|
|
22
|
+
* entry becomes a full catalog citizen. When required fields are still
|
|
23
|
+
* missing, the draft markers stay and `residual_warnings` enumerates what
|
|
24
|
+
* is still blocking promotion.
|
|
25
|
+
*
|
|
26
|
+
* Writes go through an atomic tmp + rename pattern so a concurrent
|
|
27
|
+
* `--apply` against the same catalog can't half-write the file.
|
|
28
|
+
*
|
|
29
|
+
* Not "AI-assisted" in the LLM sense. The cross-reference logic is
|
|
30
|
+
* deterministic pattern-matching against catalogs already in the install.
|
|
33
31
|
*/
|
|
34
32
|
|
|
35
33
|
const fs = require("fs");
|
|
36
34
|
const path = require("path");
|
|
35
|
+
// v0.12.12 (codex P1 #1, #2): the apply path was an unlocked RMW (lost
|
|
36
|
+
// updates under concurrent --apply) and promoted drafts based only on
|
|
37
|
+
// presence checks (not strict schema validation). Borrow the canonical
|
|
38
|
+
// lockfile-gated atomic-write helper from refresh-external and the schema
|
|
39
|
+
// validator from validate-cve-catalog so the apply path:
|
|
40
|
+
// 1. Serializes against any other catalog mutation (refresh + curate),
|
|
41
|
+
// 2. Re-reads the catalog INSIDE the lock so concurrent applies merge,
|
|
42
|
+
// 3. Validates the post-apply entry against lib/schemas/cve-catalog.schema.json
|
|
43
|
+
// before deciding promotion.
|
|
44
|
+
const { withCatalogLock } = require("./refresh-external");
|
|
45
|
+
const { validate: validateAgainstSchema } = require("./validate-cve-catalog");
|
|
37
46
|
|
|
38
47
|
const ROOT = path.resolve(__dirname, "..");
|
|
48
|
+
const CVE_SCHEMA_PATH = path.join(ROOT, "lib", "schemas", "cve-catalog.schema.json");
|
|
49
|
+
let _cveSchemaCache = null;
|
|
50
|
+
function loadCveEntrySchema() {
|
|
51
|
+
if (_cveSchemaCache) return _cveSchemaCache;
|
|
52
|
+
try {
|
|
53
|
+
// v0.12.15 (audit M P1-A): the prior version of this function looked for
|
|
54
|
+
// either `root.patternProperties["^CVE-\\d{4}-\\d+$"]` or an object
|
|
55
|
+
// `root.additionalProperties`. The actual schema at lib/schemas/cve-
|
|
56
|
+
// catalog.schema.json has NEITHER — its top level IS the entry shape
|
|
57
|
+
// (`{type:'object', required:[...], properties: {...}}`) because
|
|
58
|
+
// validate-cve-catalog.js iterates each CVE id key manually and runs
|
|
59
|
+
// the schema validator over each value. Result: loadCveEntrySchema()
|
|
60
|
+
// always returned null, the v0.12.12 codex P1 #1 fix (strict-schema
|
|
61
|
+
// gating of promotion) was silently disabled, and schema-violating
|
|
62
|
+
// entries promoted anyway. Use the root schema directly.
|
|
63
|
+
const root = JSON.parse(fs.readFileSync(CVE_SCHEMA_PATH, "utf8"));
|
|
64
|
+
_cveSchemaCache = root || null;
|
|
65
|
+
return _cveSchemaCache;
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// J7: lazy-loaded module-level catalog cache. The ATLAS / ATT&CK / CWE /
|
|
72
|
+
// framework-control-gaps catalogs don't change inside a single CLI process,
|
|
73
|
+
// so re-reading them per `curate()` call is wasted I/O. Cache once per
|
|
74
|
+
// process; batch-curate paths (future) get the speedup for free.
|
|
75
|
+
const catalogsCache = {};
|
|
39
76
|
|
|
40
|
-
function
|
|
41
|
-
try { return JSON.parse(fs.readFileSync(
|
|
77
|
+
function loadJsonRaw(absPath) {
|
|
78
|
+
try { return JSON.parse(fs.readFileSync(absPath, "utf8")); }
|
|
42
79
|
catch { return null; }
|
|
43
80
|
}
|
|
44
81
|
|
|
82
|
+
// J4: resolve a catalog path that may be absolute (operator passed
|
|
83
|
+
// `--catalog C:\tmp\cat.json` or `/tmp/cat.json`) or repo-relative. Plain
|
|
84
|
+
// `path.join(ROOT, abs)` mishandles absolute paths on Windows.
|
|
85
|
+
function resolveCatalogPath(p) {
|
|
86
|
+
if (!p) return path.join(ROOT, "data", "cve-catalog.json");
|
|
87
|
+
if (path.isAbsolute(p)) return p;
|
|
88
|
+
return path.join(ROOT, p);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function loadJson(relOrAbs) {
|
|
92
|
+
// Repo-relative reads (atlas-ttps.json, attack-ttps.json, etc.) cache
|
|
93
|
+
// by relative key. Absolute paths bypass the cache — they're operator-
|
|
94
|
+
// supplied and may legitimately change between invocations.
|
|
95
|
+
if (path.isAbsolute(relOrAbs)) return loadJsonRaw(relOrAbs);
|
|
96
|
+
if (catalogsCache[relOrAbs] !== undefined) return catalogsCache[relOrAbs];
|
|
97
|
+
const v = loadJsonRaw(path.join(ROOT, relOrAbs));
|
|
98
|
+
catalogsCache[relOrAbs] = v;
|
|
99
|
+
return v;
|
|
100
|
+
}
|
|
101
|
+
|
|
45
102
|
/**
|
|
46
103
|
* Score a candidate by counting keyword overlap with the draft entry.
|
|
47
104
|
* Returns 0..100. Pure heuristic — reviewer makes the final call.
|
|
@@ -76,17 +133,105 @@ function pickCandidates(draftDigest, catalog, idField, descriptionField) {
|
|
|
76
133
|
return candidates.slice(0, 5);
|
|
77
134
|
}
|
|
78
135
|
|
|
136
|
+
// J5: report the actual upstream source on a draft. The previous check
|
|
137
|
+
// only knew about `_source_ghsa_id`; OSV-imported drafts (MAL-*, SNYK-*,
|
|
138
|
+
// RUSTSEC-*, etc. that land via lib/source-osv.js) carry `_source_osv_id`
|
|
139
|
+
// and were always tagged "unknown". Add a registry so future sources are
|
|
140
|
+
// a one-line addition.
|
|
141
|
+
const SOURCE_FIELDS = [
|
|
142
|
+
{ field: "_source_ghsa_id", label: "GHSA" },
|
|
143
|
+
{ field: "_source_osv_id", label: "OSV" },
|
|
144
|
+
{ field: "_source_snyk_id", label: "Snyk" },
|
|
145
|
+
{ field: "_source_rustsec_id", label: "RustSec" },
|
|
146
|
+
{ field: "_source_nvd_id", label: "NVD" },
|
|
147
|
+
];
|
|
148
|
+
|
|
149
|
+
function autoImportedFrom(draft) {
|
|
150
|
+
for (const { field, label } of SOURCE_FIELDS) {
|
|
151
|
+
if (draft[field]) return `${label}: ${draft[field]}`;
|
|
152
|
+
}
|
|
153
|
+
return "unknown";
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// J3: severity word. cvss_score: null no longer collapses to "low" (which
|
|
157
|
+
// is wrong + misleading on a draft that has not been scored yet). Use
|
|
158
|
+
// "unrated" so operators can grep for unsevered drafts and the curate
|
|
159
|
+
// summary reflects the actual state.
|
|
160
|
+
function severityWord(score) {
|
|
161
|
+
if (typeof score !== "number" || Number.isNaN(score)) return "unrated";
|
|
162
|
+
if (score >= 9) return "critical";
|
|
163
|
+
if (score >= 7) return "high";
|
|
164
|
+
if (score >= 4) return "medium";
|
|
165
|
+
return "low";
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// J2: the fields the strict CVE schema requires. Used to (a) extend the
|
|
169
|
+
// questionnaire so the operator is prompted for every blocking gap, and
|
|
170
|
+
// (b) compute `residual_warnings` after an apply.
|
|
171
|
+
const REQUIRED_SCHEMA_FIELDS = [
|
|
172
|
+
"name", "type", "cvss_score", "cvss_vector", "cisa_kev",
|
|
173
|
+
"poc_available", "ai_discovered", "active_exploitation",
|
|
174
|
+
"affected", "affected_versions", "vector",
|
|
175
|
+
"patch_available", "patch_required_reboot", "live_patch_available",
|
|
176
|
+
"framework_control_gaps", "atlas_refs", "attack_refs",
|
|
177
|
+
"rwep_score", "rwep_factors", "source_verified",
|
|
178
|
+
"verification_sources", "last_updated",
|
|
179
|
+
];
|
|
180
|
+
|
|
181
|
+
function isPopulated(field, value) {
|
|
182
|
+
if (value === null || value === undefined) return false;
|
|
183
|
+
if (field === "affected_versions" || field === "atlas_refs" || field === "attack_refs"
|
|
184
|
+
|| field === "verification_sources") {
|
|
185
|
+
return Array.isArray(value) && value.length > 0;
|
|
186
|
+
}
|
|
187
|
+
if (field === "framework_control_gaps") {
|
|
188
|
+
return value && typeof value === "object" && Object.keys(value).length > 0;
|
|
189
|
+
}
|
|
190
|
+
if (field === "rwep_factors") {
|
|
191
|
+
return value && typeof value === "object" && Object.keys(value).length > 0;
|
|
192
|
+
}
|
|
193
|
+
if (field === "cvss_vector" || field === "vector" || field === "affected"
|
|
194
|
+
|| field === "name" || field === "type" || field === "active_exploitation"
|
|
195
|
+
|| field === "source_verified" || field === "last_updated") {
|
|
196
|
+
return typeof value === "string" && value.length > 0;
|
|
197
|
+
}
|
|
198
|
+
// booleans + numbers: any non-null value counts as populated
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function residualWarnings(entry) {
|
|
203
|
+
const warnings = [];
|
|
204
|
+
for (const f of REQUIRED_SCHEMA_FIELDS) {
|
|
205
|
+
if (!isPopulated(f, entry[f])) {
|
|
206
|
+
warnings.push(`${f} still ${entry[f] === null ? "null" : "empty"}`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return warnings;
|
|
210
|
+
}
|
|
211
|
+
|
|
79
212
|
/**
|
|
80
|
-
* Build the curation report
|
|
213
|
+
* Build the curation report — questionnaire or apply.
|
|
214
|
+
*
|
|
215
|
+
* curate(cveId, { catalogPath, apply, answers })
|
|
216
|
+
*
|
|
217
|
+
* apply: false (default) → return { ok, editorial_questions, ... }
|
|
218
|
+
* apply: true → consume answers, write the catalog, return
|
|
219
|
+
* { ok, applied_fields, residual_warnings, ... }
|
|
81
220
|
*/
|
|
82
|
-
function curate(cveId,
|
|
83
|
-
|
|
221
|
+
async function curate(cveId, opts = {}) {
|
|
222
|
+
// Back-compat: earlier signature was `curate(id, { catalogPath, opts })`
|
|
223
|
+
// — accept either shape so the CLI wiring stays stable.
|
|
224
|
+
if (opts && opts.opts && !opts.catalogPath && opts.opts.catalogPath) {
|
|
225
|
+
opts = { ...opts, catalogPath: opts.opts.catalogPath };
|
|
226
|
+
}
|
|
227
|
+
const catalogPath = resolveCatalogPath(opts.catalogPath);
|
|
228
|
+
const catalog = loadJsonRaw(catalogPath);
|
|
84
229
|
if (!catalog || !catalog[cveId]) {
|
|
85
230
|
return {
|
|
86
231
|
ok: false,
|
|
87
232
|
verb: "refresh",
|
|
88
233
|
mode: "cve-curation",
|
|
89
|
-
error: `CVE ${cveId} not in
|
|
234
|
+
error: `CVE ${cveId} not in ${path.relative(ROOT, catalogPath) || catalogPath}. Seed it first via \`exceptd refresh --advisory ${cveId} --apply\`.`,
|
|
90
235
|
};
|
|
91
236
|
}
|
|
92
237
|
const draft = catalog[cveId];
|
|
@@ -104,6 +249,16 @@ function curate(cveId, { catalogPath, opts = {} } = {}) {
|
|
|
104
249
|
};
|
|
105
250
|
}
|
|
106
251
|
|
|
252
|
+
// ----- APPLY PATH (J1) -------------------------------------------------
|
|
253
|
+
if (opts.apply && opts.answers) {
|
|
254
|
+
return applyAnswers(cveId, draft, catalog, catalogPath, opts.answers);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ----- QUESTIONNAIRE PATH ---------------------------------------------
|
|
258
|
+
return buildQuestionnaire(cveId, draft);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function buildQuestionnaire(cveId, draft) {
|
|
107
262
|
// Digest of the draft — feed into keyword-overlap scoring.
|
|
108
263
|
const draftDigest = [
|
|
109
264
|
draft.name || "",
|
|
@@ -114,15 +269,22 @@ function curate(cveId, { catalogPath, opts = {} } = {}) {
|
|
|
114
269
|
].filter(Boolean).join(" ");
|
|
115
270
|
|
|
116
271
|
// Pull candidate catalogs. Each is optional — missing catalogs are skipped
|
|
117
|
-
// gracefully.
|
|
272
|
+
// gracefully. J7 makes these one-shot loads per process.
|
|
118
273
|
const atlas = loadJson("data/atlas-ttps.json");
|
|
119
|
-
|
|
274
|
+
// v0.12.15 (audit M P1-E): the catalog ships as data/attack-techniques.json
|
|
275
|
+
// (renamed from data/attack-ttps.json before the v0.12.12 release; the
|
|
276
|
+
// canonical file path is also what lib/validate-cve-catalog.js consumes).
|
|
277
|
+
// The prior `data/attack-ttps.json` lookup silently fell back to an empty
|
|
278
|
+
// object via loadJsonRaw's ENOENT handling, so the ATT&CK candidate
|
|
279
|
+
// questionnaire branch always returned zero proposals.
|
|
280
|
+
const attack = loadJson("data/attack-techniques.json");
|
|
120
281
|
const cwe = loadJson("data/cwe-catalog.json");
|
|
121
282
|
const frameworkGaps = loadJson("data/framework-control-gaps.json");
|
|
122
283
|
|
|
123
284
|
const atlasCandidates = pickCandidates(draftDigest, atlas, "ttp_id", "description");
|
|
124
285
|
const attackCandidates = pickCandidates(draftDigest, attack, "ttp_id", "description");
|
|
125
|
-
|
|
286
|
+
// CWE candidates surface as part of the vector question — not its own field.
|
|
287
|
+
void cwe;
|
|
126
288
|
const frameworkCandidates = pickCandidates(draftDigest, frameworkGaps, "control_id", "description");
|
|
127
289
|
|
|
128
290
|
// Build the editorial-questions list. Each entry names the catalog field,
|
|
@@ -130,7 +292,14 @@ function curate(cveId, { catalogPath, opts = {} } = {}) {
|
|
|
130
292
|
// specific ASK to surface what the reviewer needs to decide.
|
|
131
293
|
const questions = [];
|
|
132
294
|
|
|
133
|
-
|
|
295
|
+
// J6: pre-filled empty containers (`iocs: {}`, `atlas_refs: []`) used to
|
|
296
|
+
// be treated as "answered" because `if (!draft.iocs)` is truthy for {}.
|
|
297
|
+
// Use explicit emptiness checks so drafts seeded with empty objects /
|
|
298
|
+
// arrays still get the prompt.
|
|
299
|
+
const arrEmpty = (a) => !Array.isArray(a) || a.length === 0;
|
|
300
|
+
const objEmpty = (o) => !o || typeof o !== "object" || Object.keys(o).length === 0;
|
|
301
|
+
|
|
302
|
+
if (arrEmpty(draft.atlas_refs)) {
|
|
134
303
|
questions.push({
|
|
135
304
|
field: "atlas_refs",
|
|
136
305
|
current_value: draft.atlas_refs || [],
|
|
@@ -139,7 +308,7 @@ function curate(cveId, { catalogPath, opts = {} } = {}) {
|
|
|
139
308
|
});
|
|
140
309
|
}
|
|
141
310
|
|
|
142
|
-
if (
|
|
311
|
+
if (arrEmpty(draft.attack_refs)) {
|
|
143
312
|
questions.push({
|
|
144
313
|
field: "attack_refs",
|
|
145
314
|
current_value: draft.attack_refs || [],
|
|
@@ -148,46 +317,47 @@ function curate(cveId, { catalogPath, opts = {} } = {}) {
|
|
|
148
317
|
});
|
|
149
318
|
}
|
|
150
319
|
|
|
151
|
-
if (
|
|
320
|
+
if (objEmpty(draft.framework_control_gaps)) {
|
|
152
321
|
questions.push({
|
|
153
322
|
field: "framework_control_gaps",
|
|
154
|
-
current_value: null,
|
|
323
|
+
current_value: draft.framework_control_gaps || null,
|
|
155
324
|
candidates: frameworkCandidates,
|
|
156
325
|
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
326
|
});
|
|
158
327
|
}
|
|
159
328
|
|
|
160
|
-
if (
|
|
329
|
+
if (objEmpty(draft.iocs)) {
|
|
161
330
|
questions.push({
|
|
162
331
|
field: "iocs",
|
|
163
|
-
current_value: null,
|
|
332
|
+
current_value: draft.iocs || null,
|
|
164
333
|
candidates: [],
|
|
165
334
|
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
335
|
});
|
|
167
336
|
}
|
|
168
337
|
|
|
169
|
-
if (draft.poc_available === null) {
|
|
338
|
+
if (draft.poc_available === null || draft.poc_available === undefined) {
|
|
170
339
|
questions.push({
|
|
171
340
|
field: "poc_available",
|
|
172
|
-
current_value: null,
|
|
341
|
+
current_value: draft.poc_available === undefined ? null : draft.poc_available,
|
|
173
342
|
candidates: [],
|
|
174
343
|
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
344
|
});
|
|
176
345
|
}
|
|
177
346
|
|
|
178
|
-
if (draft.ai_discovered === null || draft.ai_assisted_weaponization === null
|
|
347
|
+
if (draft.ai_discovered === null || draft.ai_assisted_weaponization === null
|
|
348
|
+
|| draft.ai_discovered === undefined || draft.ai_assisted_weaponization === undefined) {
|
|
179
349
|
questions.push({
|
|
180
350
|
field: "ai_discovered + ai_assisted_weaponization",
|
|
181
|
-
current_value: { ai_discovered: draft.ai_discovered, ai_assisted_weaponization: draft.ai_assisted_weaponization },
|
|
351
|
+
current_value: { ai_discovered: draft.ai_discovered ?? null, ai_assisted_weaponization: draft.ai_assisted_weaponization ?? null },
|
|
182
352
|
candidates: [],
|
|
183
353
|
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
354
|
});
|
|
185
355
|
}
|
|
186
356
|
|
|
187
|
-
if (
|
|
357
|
+
if (objEmpty(draft.rwep_factors) || draft.rwep_score === null || draft.rwep_score === undefined) {
|
|
188
358
|
questions.push({
|
|
189
359
|
field: "rwep_score + rwep_factors",
|
|
190
|
-
current_value: { rwep_score: draft.rwep_score, rwep_factors: draft.rwep_factors },
|
|
360
|
+
current_value: { rwep_score: draft.rwep_score ?? null, rwep_factors: draft.rwep_factors ?? null },
|
|
191
361
|
candidates: [],
|
|
192
362
|
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
363
|
});
|
|
@@ -202,6 +372,59 @@ function curate(cveId, { catalogPath, opts = {} } = {}) {
|
|
|
202
372
|
});
|
|
203
373
|
}
|
|
204
374
|
|
|
375
|
+
// J2: schema-required field prompts. Without these populated, the apply
|
|
376
|
+
// path cannot produce a schema-passing entry — i.e. the entry stays a
|
|
377
|
+
// draft even after curate --apply. Prompt for them explicitly so the
|
|
378
|
+
// operator sees what's actually blocking promotion.
|
|
379
|
+
if (draft.cvss_score === null || draft.cvss_score === undefined
|
|
380
|
+
|| draft.cvss_vector === null || draft.cvss_vector === undefined
|
|
381
|
+
|| draft.cvss_vector === "") {
|
|
382
|
+
questions.push({
|
|
383
|
+
field: "cvss_score + cvss_vector",
|
|
384
|
+
current_value: { cvss_score: draft.cvss_score ?? null, cvss_vector: draft.cvss_vector ?? null },
|
|
385
|
+
candidates: [],
|
|
386
|
+
ask: "Determine CVSS via the NVD record (https://nvd.nist.gov/vuln/detail/" + cveId + ") or the vendor advisory. Both score (number, 0..10) and vector (CVSS:3.1/... or CVSS:4.0/...) are schema-required. Drafts without these stay flagged as `unrated` in the curate summary.",
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (draft.patch_available === null || draft.patch_available === undefined
|
|
391
|
+
|| draft.patch_required_reboot === null || draft.patch_required_reboot === undefined
|
|
392
|
+
|| draft.live_patch_available === null || draft.live_patch_available === undefined
|
|
393
|
+
|| arrEmpty(draft.live_patch_tools)) {
|
|
394
|
+
questions.push({
|
|
395
|
+
field: "patch_available + patch_required_reboot + live_patch_available + live_patch_tools",
|
|
396
|
+
current_value: {
|
|
397
|
+
patch_available: draft.patch_available ?? null,
|
|
398
|
+
patch_required_reboot: draft.patch_required_reboot ?? null,
|
|
399
|
+
live_patch_available: draft.live_patch_available ?? null,
|
|
400
|
+
live_patch_tools: draft.live_patch_tools ?? [],
|
|
401
|
+
},
|
|
402
|
+
candidates: [],
|
|
403
|
+
ask: "Patch availability: is an upstream fix released? Reboot required to land it? Is a live-patching path available (kpatch / kgraft / ksplice / vendor-equivalent) — and if so, list the tool names. All four feed RWEP scoring.",
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (arrEmpty(draft.affected_versions)) {
|
|
408
|
+
questions.push({
|
|
409
|
+
field: "affected_versions",
|
|
410
|
+
current_value: draft.affected_versions || [],
|
|
411
|
+
candidates: [],
|
|
412
|
+
ask: "Affected version ranges as an array of strings. Use semver ranges for libraries (e.g. '>=2.4.0 <2.4.7'), kernel ranges for kernel CVEs, build numbers for vendor-shipped firmware. Schema requires at least one entry.",
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// KEV is always-ask: the operator might know KEV applies (and even has
|
|
417
|
+
// a date) even when the upstream KEV feed hasn't caught up yet. False
|
|
418
|
+
// is a valid answer.
|
|
419
|
+
if (draft.cisa_kev === null || draft.cisa_kev === undefined) {
|
|
420
|
+
questions.push({
|
|
421
|
+
field: "cisa_kev + cisa_kev_date",
|
|
422
|
+
current_value: { cisa_kev: draft.cisa_kev ?? null, cisa_kev_date: draft.cisa_kev_date ?? null },
|
|
423
|
+
candidates: [],
|
|
424
|
+
ask: "Is the CVE on CISA's Known Exploited Vulnerabilities list? If yes, capture the date added (YYYY-MM-DD). If no, answer false explicitly — null fails the strict schema gate.",
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
205
428
|
return {
|
|
206
429
|
ok: true,
|
|
207
430
|
verb: "refresh",
|
|
@@ -211,33 +434,227 @@ function curate(cveId, { catalogPath, opts = {} } = {}) {
|
|
|
211
434
|
name: draft.name,
|
|
212
435
|
type: draft.type,
|
|
213
436
|
cvss_score: draft.cvss_score,
|
|
214
|
-
severity: draft.cvss_score
|
|
437
|
+
severity: severityWord(draft.cvss_score),
|
|
215
438
|
affected: draft.affected,
|
|
216
439
|
published_at: draft._source_published_at || null,
|
|
217
|
-
auto_imported_from: draft
|
|
440
|
+
auto_imported_from: autoImportedFrom(draft),
|
|
218
441
|
},
|
|
219
442
|
editorial_questions: questions,
|
|
220
443
|
questions_open: questions.length,
|
|
221
444
|
next_steps: [
|
|
222
445
|
`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.`,
|
|
446
|
+
`Apply the answers in one shot: \`exceptd refresh --curate ${cveId} --answers <path-to-answers.json> --apply\`. residual_warnings will list anything still blocking promotion.`,
|
|
223
447
|
`Add a matching entry to data/zeroday-lessons.json (rule #6: zero-day learning loop must be live).`,
|
|
224
448
|
`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
449
|
`Run \`npm run predeploy\` — the strict gate should now pass without DRAFT warnings on this entry.`,
|
|
227
450
|
],
|
|
228
451
|
};
|
|
229
452
|
}
|
|
230
453
|
|
|
231
454
|
/**
|
|
232
|
-
*
|
|
455
|
+
* J1: apply operator-supplied answers to a draft and write back atomically.
|
|
456
|
+
*
|
|
457
|
+
* Answers shape (each key optional; missing keys leave the draft unchanged
|
|
458
|
+
* for that field):
|
|
459
|
+
*
|
|
460
|
+
* {
|
|
461
|
+
* framework_control_gaps: { "NIST-800-53-SI-2": "...", ... },
|
|
462
|
+
* iocs: { payload_artifacts: [...], behavioral: [...], ... },
|
|
463
|
+
* atlas_refs: ["AML.T0010", ...],
|
|
464
|
+
* attack_refs: ["T1195.002", ...],
|
|
465
|
+
* rwep_factors: { cisa_kev: 25, blast_radius: 30, ... },
|
|
466
|
+
* rwep_score: 80, // optional; derived from sum if absent
|
|
467
|
+
* cvss_score: 9.8,
|
|
468
|
+
* cvss_vector: "CVSS:3.1/AV:N/...",
|
|
469
|
+
* cisa_kev: true, // or false
|
|
470
|
+
* cisa_kev_date: "2026-05-13",
|
|
471
|
+
* poc_available: true,
|
|
472
|
+
* poc_description: "...",
|
|
473
|
+
* ai_discovered: false,
|
|
474
|
+
* ai_assisted_weaponization: false,
|
|
475
|
+
* active_exploitation: "confirmed" | "suspected" | "none" | "unknown",
|
|
476
|
+
* vector: "...",
|
|
477
|
+
* complexity: "...",
|
|
478
|
+
* patch_available: true,
|
|
479
|
+
* patch_required_reboot: false,
|
|
480
|
+
* live_patch_available: true,
|
|
481
|
+
* live_patch_tools: ["kpatch", "kgraft"],
|
|
482
|
+
* affected_versions: ["...", "..."],
|
|
483
|
+
* verification_sources: ["https://...", ...]
|
|
484
|
+
* }
|
|
485
|
+
*/
|
|
486
|
+
async function applyAnswers(cveId, _draftSnapshot, _catalogSnapshot, catalogPath, answers) {
|
|
487
|
+
// v0.12.12 (codex P1 #2): wrap the entire read-modify-write under
|
|
488
|
+
// withCatalogLock. Two concurrent --apply runs against the same catalog
|
|
489
|
+
// previously read the same base, each mutated a different CVE, and the
|
|
490
|
+
// later writer overwrote the earlier writer's changes. The lock + re-read
|
|
491
|
+
// inside the critical section ensures sibling applies merge cleanly.
|
|
492
|
+
let outcome = null;
|
|
493
|
+
await withCatalogLock(catalogPath, (catalog) => {
|
|
494
|
+
if (!catalog || !catalog[cveId]) {
|
|
495
|
+
outcome = {
|
|
496
|
+
ok: false,
|
|
497
|
+
verb: "refresh",
|
|
498
|
+
mode: "cve-curation-apply",
|
|
499
|
+
error: `CVE ${cveId} disappeared from ${path.relative(ROOT, catalogPath) || catalogPath} between question generation and apply.`,
|
|
500
|
+
};
|
|
501
|
+
return catalog; // unchanged
|
|
502
|
+
}
|
|
503
|
+
outcome = applyAnswersUnderLock(cveId, catalog, catalogPath, answers);
|
|
504
|
+
return catalog; // mutated in place
|
|
505
|
+
});
|
|
506
|
+
return outcome;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function applyAnswersUnderLock(cveId, catalog, catalogPath, answers) {
|
|
510
|
+
const entry = catalog[cveId];
|
|
511
|
+
const appliedFields = [];
|
|
512
|
+
|
|
513
|
+
// Whitelist of fields the operator may supply. Anything not in this map
|
|
514
|
+
// is ignored (so a typo'd key in answers.json doesn't pollute the entry).
|
|
515
|
+
// Each entry: [field, validator(value) -> bool, transformer(value) -> stored].
|
|
516
|
+
const id = (x) => x;
|
|
517
|
+
const ALLOWED = [
|
|
518
|
+
["framework_control_gaps", (v) => v && typeof v === "object" && !Array.isArray(v), id],
|
|
519
|
+
["iocs", (v) => v && typeof v === "object" && !Array.isArray(v), id],
|
|
520
|
+
["atlas_refs", (v) => Array.isArray(v), id],
|
|
521
|
+
["attack_refs", (v) => Array.isArray(v), id],
|
|
522
|
+
["rwep_factors", (v) => v && typeof v === "object" && !Array.isArray(v), id],
|
|
523
|
+
["rwep_score", (v) => typeof v === "number", id],
|
|
524
|
+
["rwep_notes", (v) => typeof v === "string", id],
|
|
525
|
+
["cvss_score", (v) => typeof v === "number", id],
|
|
526
|
+
["cvss_vector", (v) => typeof v === "string" && /^CVSS:[0-9]/.test(v), id],
|
|
527
|
+
["cisa_kev", (v) => typeof v === "boolean", id],
|
|
528
|
+
["cisa_kev_date", (v) => v === null || typeof v === "string", id],
|
|
529
|
+
["cisa_kev_due_date", (v) => v === null || typeof v === "string", id],
|
|
530
|
+
["poc_available", (v) => typeof v === "boolean", id],
|
|
531
|
+
["poc_description", (v) => typeof v === "string", id],
|
|
532
|
+
["ai_discovered", (v) => typeof v === "boolean", id],
|
|
533
|
+
["ai_discovery_notes", (v) => typeof v === "string", id],
|
|
534
|
+
["ai_assisted_weaponization", (v) => typeof v === "boolean", id],
|
|
535
|
+
["ai_assisted_notes", (v) => typeof v === "string", id],
|
|
536
|
+
["active_exploitation", (v) => ["confirmed", "suspected", "none", "unknown"].includes(v), id],
|
|
537
|
+
["affected", (v) => typeof v === "string" && v.length > 0, id],
|
|
538
|
+
["affected_versions", (v) => Array.isArray(v) && v.length > 0 && v.every(x => typeof x === "string"), id],
|
|
539
|
+
["vector", (v) => typeof v === "string" && v.length > 0, id],
|
|
540
|
+
["complexity", (v) => typeof v === "string", id],
|
|
541
|
+
["complexity_notes", (v) => typeof v === "string", id],
|
|
542
|
+
["patch_available", (v) => typeof v === "boolean", id],
|
|
543
|
+
["patch_required_reboot", (v) => typeof v === "boolean", id],
|
|
544
|
+
["live_patch_available", (v) => typeof v === "boolean", id],
|
|
545
|
+
["live_patch_tools", (v) => Array.isArray(v) && v.every(x => typeof x === "string"), id],
|
|
546
|
+
["live_patch_notes", (v) => typeof v === "string", id],
|
|
547
|
+
["verification_sources", (v) => Array.isArray(v) && v.length > 0 && v.every(x => typeof x === "string"), id],
|
|
548
|
+
["name", (v) => typeof v === "string" && v.length > 0, id],
|
|
549
|
+
["type", (v) => typeof v === "string" && v.length > 0, id],
|
|
550
|
+
["source_verified", (v) => typeof v === "string" && /^\d{4}-\d{2}-\d{2}$/.test(v), id],
|
|
551
|
+
];
|
|
552
|
+
|
|
553
|
+
const rejected = [];
|
|
554
|
+
for (const [field, validator, transformer] of ALLOWED) {
|
|
555
|
+
if (!(field in answers)) continue;
|
|
556
|
+
const v = answers[field];
|
|
557
|
+
if (!validator(v)) {
|
|
558
|
+
rejected.push({ field, reason: `value ${JSON.stringify(v)} failed validation` });
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
entry[field] = transformer(v);
|
|
562
|
+
appliedFields.push(field);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Derive rwep_score from rwep_factors when factors supplied without an
|
|
566
|
+
// explicit score. lib/scoring.js owns the canonical formula; we sum the
|
|
567
|
+
// numeric values here as a fallback.
|
|
568
|
+
if ("rwep_factors" in answers && !("rwep_score" in answers)
|
|
569
|
+
&& entry.rwep_factors && typeof entry.rwep_factors === "object") {
|
|
570
|
+
let sum = 0;
|
|
571
|
+
for (const v of Object.values(entry.rwep_factors)) {
|
|
572
|
+
if (typeof v === "number") sum += v;
|
|
573
|
+
}
|
|
574
|
+
entry.rwep_score = Math.max(0, Math.min(100, sum));
|
|
575
|
+
appliedFields.push("rwep_score (derived from rwep_factors)");
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// last_updated reflects the apply moment.
|
|
579
|
+
entry.last_updated = new Date().toISOString().slice(0, 10);
|
|
580
|
+
|
|
581
|
+
// v0.12.12 (codex P1 #1): validate the post-apply entry against the
|
|
582
|
+
// strict CVE schema BEFORE deciding whether to promote. Promotion was
|
|
583
|
+
// previously gated on residualWarnings() alone — a presence check — so
|
|
584
|
+
// an input like `"ai_discovered": "false"` (string) passed presence but
|
|
585
|
+
// would have failed lib/validate-cve-catalog.js on type mismatch,
|
|
586
|
+
// creating a "promoted but invalid" entry. Now: presence + strict schema
|
|
587
|
+
// BOTH must hold for promotion.
|
|
588
|
+
const warnings = residualWarnings(entry);
|
|
589
|
+
const schemaErrors = [];
|
|
590
|
+
const entrySchema = loadCveEntrySchema();
|
|
591
|
+
if (entrySchema) {
|
|
592
|
+
const errs = validateAgainstSchema(entry, entrySchema, "cve-entry", `${cveId}`);
|
|
593
|
+
if (Array.isArray(errs) && errs.length > 0) schemaErrors.push(...errs);
|
|
594
|
+
}
|
|
595
|
+
let promoted = false;
|
|
596
|
+
if (warnings.length === 0 && schemaErrors.length === 0) {
|
|
597
|
+
delete entry._auto_imported;
|
|
598
|
+
delete entry._draft;
|
|
599
|
+
delete entry._draft_reason;
|
|
600
|
+
promoted = true;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// catalog has been mutated in-place under the lock; the locker writes it
|
|
604
|
+
// atomically when this function returns. Concurrent applies are
|
|
605
|
+
// serialized + see the post-merge state.
|
|
606
|
+
catalog[cveId] = entry;
|
|
607
|
+
|
|
608
|
+
return {
|
|
609
|
+
ok: true,
|
|
610
|
+
verb: "refresh",
|
|
611
|
+
mode: "cve-curation-apply",
|
|
612
|
+
cve_id: cveId,
|
|
613
|
+
applied_fields: appliedFields,
|
|
614
|
+
rejected_fields: rejected,
|
|
615
|
+
promoted,
|
|
616
|
+
is_draft: !promoted,
|
|
617
|
+
residual_warnings: warnings,
|
|
618
|
+
schema_errors: schemaErrors,
|
|
619
|
+
catalog_path: catalogPath,
|
|
620
|
+
next_steps: promoted
|
|
621
|
+
? [
|
|
622
|
+
`Draft promoted to full entry. Run \`node lib/validate-cve-catalog.js --quiet\` to confirm it passes the strict schema gate.`,
|
|
623
|
+
`Add a matching entry to data/zeroday-lessons.json (AGENTS.md rule #6).`,
|
|
624
|
+
`Run \`npm run predeploy\` before tagging.`,
|
|
625
|
+
]
|
|
626
|
+
: [
|
|
627
|
+
`Entry remains a DRAFT (${warnings.length} required field(s) still unpopulated). Supply answers for: ${warnings.slice(0, 6).join(", ")}${warnings.length > 6 ? ", ..." : ""}.`,
|
|
628
|
+
`Re-run \`exceptd refresh --curate ${cveId}\` to see the updated questionnaire.`,
|
|
629
|
+
],
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function writeJsonAtomic(p, obj) {
|
|
634
|
+
const tmpPath = `${p}.tmp.${process.pid}.${Math.random().toString(36).slice(2, 10)}`;
|
|
635
|
+
fs.writeFileSync(tmpPath, JSON.stringify(obj, null, 2) + "\n", "utf8");
|
|
636
|
+
try {
|
|
637
|
+
fs.renameSync(tmpPath, p);
|
|
638
|
+
} catch (err) {
|
|
639
|
+
try { fs.unlinkSync(tmpPath); } catch {}
|
|
640
|
+
throw err;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* CLI entry — wired from bin/exceptd.js via `refresh --curate <id>` and
|
|
646
|
+
* `refresh --curate <id> --answers <path> [--apply]`.
|
|
233
647
|
*/
|
|
234
648
|
async function cli(argv) {
|
|
235
|
-
const opts = { advisory: null, json: false, catalogPath: null };
|
|
649
|
+
const opts = { advisory: null, json: false, catalogPath: null, answersPath: null, apply: false };
|
|
236
650
|
for (let i = 0; i < argv.length; i++) {
|
|
237
651
|
const a = argv[i];
|
|
238
652
|
if (a === "--json") opts.json = true;
|
|
653
|
+
else if (a === "--apply") opts.apply = true;
|
|
239
654
|
else if (a === "--catalog") opts.catalogPath = argv[++i];
|
|
240
655
|
else if (a.startsWith("--catalog=")) opts.catalogPath = a.slice("--catalog=".length);
|
|
656
|
+
else if (a === "--answers") opts.answersPath = argv[++i];
|
|
657
|
+
else if (a.startsWith("--answers=")) opts.answersPath = a.slice("--answers=".length);
|
|
241
658
|
else if (a === "--curate") opts.advisory = argv[++i];
|
|
242
659
|
else if (a.startsWith("--curate=")) opts.advisory = a.slice("--curate=".length);
|
|
243
660
|
else if (!a.startsWith("--") && !opts.advisory) opts.advisory = a;
|
|
@@ -249,9 +666,47 @@ async function cli(argv) {
|
|
|
249
666
|
process.exitCode = 2;
|
|
250
667
|
return;
|
|
251
668
|
}
|
|
252
|
-
|
|
669
|
+
|
|
670
|
+
// Operator passing --answers <path> means apply. --apply on its own is
|
|
671
|
+
// also accepted (for parity with --advisory --apply), but it's a no-op
|
|
672
|
+
// without --answers — fail loudly so a forgetful operator doesn't think
|
|
673
|
+
// a write succeeded when in fact the questionnaire was returned.
|
|
674
|
+
let answers = null;
|
|
675
|
+
if (opts.answersPath) {
|
|
676
|
+
try {
|
|
677
|
+
const ap = path.isAbsolute(opts.answersPath) ? opts.answersPath : path.resolve(process.cwd(), opts.answersPath);
|
|
678
|
+
answers = JSON.parse(fs.readFileSync(ap, "utf8"));
|
|
679
|
+
} catch (e) {
|
|
680
|
+
const err = { ok: false, verb: "refresh", mode: "cve-curation", error: `--answers ${opts.answersPath}: ${e.message}` };
|
|
681
|
+
if (opts.json) process.stdout.write(JSON.stringify(err) + "\n");
|
|
682
|
+
else process.stderr.write(`[refresh --curate] ${err.error}\n`);
|
|
683
|
+
process.exitCode = 2;
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
// --answers alone implies apply; --apply alone without --answers is a
|
|
688
|
+
// user error.
|
|
689
|
+
const doApply = !!answers || opts.apply;
|
|
690
|
+
if (opts.apply && !answers) {
|
|
691
|
+
const err = { ok: false, verb: "refresh", mode: "cve-curation", error: "--apply requires --answers <path-to-answers.json>" };
|
|
692
|
+
if (opts.json) process.stdout.write(JSON.stringify(err) + "\n");
|
|
693
|
+
else process.stderr.write(`[refresh --curate] ${err.error}\n`);
|
|
694
|
+
process.exitCode = 2;
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const result = await curate(opts.advisory, { catalogPath: opts.catalogPath, apply: doApply, answers });
|
|
699
|
+
|
|
253
700
|
if (opts.json || !result.ok) {
|
|
254
701
|
process.stdout.write(JSON.stringify(result) + "\n");
|
|
702
|
+
} else if (result.mode === "cve-curation-apply") {
|
|
703
|
+
process.stdout.write(`[refresh --curate --apply] ${result.cve_id} — ${result.applied_fields.length} field(s) applied; ${result.promoted ? "PROMOTED to full entry" : `still DRAFT (${result.residual_warnings.length} blocker(s))`}\n`);
|
|
704
|
+
if (result.residual_warnings.length > 0) {
|
|
705
|
+
process.stdout.write(` residual: ${result.residual_warnings.slice(0, 6).join(", ")}${result.residual_warnings.length > 6 ? ", ..." : ""}\n`);
|
|
706
|
+
}
|
|
707
|
+
if (result.rejected_fields.length > 0) {
|
|
708
|
+
process.stdout.write(` rejected: ${result.rejected_fields.map(r => r.field).join(", ")}\n`);
|
|
709
|
+
}
|
|
255
710
|
} else {
|
|
256
711
|
process.stdout.write(`[refresh --curate] ${result.cve_id} — ${result.questions_open} editorial question(s) open\n`);
|
|
257
712
|
for (const q of result.editorial_questions) {
|
|
@@ -263,12 +718,15 @@ async function cli(argv) {
|
|
|
263
718
|
}
|
|
264
719
|
process.stdout.write(`\n Run with --json for the full structured output.\n`);
|
|
265
720
|
}
|
|
266
|
-
//
|
|
267
|
-
|
|
721
|
+
// Questionnaire path: exit 3 ("editorial review pending"). Apply path:
|
|
722
|
+
// exit 0 when promoted, 3 when residual_warnings remain (still draft).
|
|
723
|
+
if (!result.ok) process.exitCode = 2;
|
|
724
|
+
else if (result.mode === "cve-curation-apply") process.exitCode = result.promoted ? 0 : 3;
|
|
725
|
+
else process.exitCode = 3;
|
|
268
726
|
}
|
|
269
727
|
|
|
270
728
|
if (require.main === module) {
|
|
271
729
|
cli(process.argv.slice(2));
|
|
272
730
|
}
|
|
273
731
|
|
|
274
|
-
module.exports = { curate, keywordOverlapScore };
|
|
732
|
+
module.exports = { curate, keywordOverlapScore, resolveCatalogPath, autoImportedFrom, severityWord, residualWarnings };
|