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