@blamejs/exceptd-skills 0.19.33 → 0.19.34
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 +10 -0
- package/bin/exceptd.js +896 -2824
- package/data/_indexes/_meta.json +2 -2
- package/lib/auto-discovery.js +56 -286
- package/lib/canonical-eq.js +7 -40
- package/lib/citation-resolve.js +22 -70
- package/lib/collectors/ai-api.js +20 -54
- package/lib/collectors/cicd-pipeline-compromise.js +40 -108
- package/lib/collectors/citation-hygiene.js +72 -210
- package/lib/collectors/containers.js +41 -130
- package/lib/collectors/cred-stores.js +31 -115
- package/lib/collectors/crypto-codebase.js +55 -138
- package/lib/collectors/crypto.js +24 -54
- package/lib/collectors/hardening.js +20 -78
- package/lib/collectors/kernel.js +16 -46
- package/lib/collectors/library-author.js +57 -206
- package/lib/collectors/mcp.js +24 -70
- package/lib/collectors/runtime.js +24 -86
- package/lib/collectors/sbom.js +34 -106
- package/lib/collectors/scan-excludes.js +31 -138
- package/lib/collectors/secrets.js +62 -178
- package/lib/cross-ref-api.js +39 -123
- package/lib/currency-severity.js +8 -27
- package/lib/cve-batch.js +13 -21
- package/lib/cve-cli.js +13 -20
- package/lib/cve-curation.js +72 -239
- package/lib/cve-regression-watcher.js +29 -152
- package/lib/cvss.js +13 -54
- package/lib/doctor-bucketing.js +3 -19
- package/lib/exit-codes.js +10 -42
- package/lib/flag-suggest.js +7 -25
- package/lib/framework-gap.js +35 -114
- package/lib/gap-detectors.js +37 -159
- package/lib/id-validation.js +9 -30
- package/lib/job-queue.js +13 -36
- package/lib/lint-skills.js +64 -232
- package/lib/playbook-runner.js +693 -2095
- package/lib/prefetch.js +100 -376
- package/lib/refresh-external.js +199 -627
- package/lib/refresh-network.js +75 -307
- package/lib/rfc-cli.js +23 -68
- package/lib/scoring.js +77 -145
- package/lib/sign.js +43 -229
- package/lib/source-advisories.js +43 -194
- package/lib/source-ghsa.js +37 -120
- package/lib/source-osv.js +94 -266
- package/lib/ttp-mapper.js +14 -24
- package/lib/upstream-check-cli.js +10 -28
- package/lib/upstream-check.js +19 -44
- package/lib/validate-catalog-meta.js +17 -61
- package/lib/validate-cve-catalog.js +43 -119
- package/lib/validate-indexes.js +25 -76
- package/lib/validate-package.js +16 -62
- package/lib/validate-playbooks.js +69 -275
- package/lib/validate-vendor.js +16 -49
- package/lib/verify.js +56 -286
- package/lib/version-pins.js +5 -34
- package/lib/worker-pool.js +11 -30
- package/lib/xml-tokenizer.js +47 -152
- package/manifest.json +53 -53
- package/orchestrator/dispatcher.js +17 -68
- package/orchestrator/event-bus.js +11 -74
- package/orchestrator/index.js +138 -412
- package/orchestrator/pipeline.js +28 -85
- package/orchestrator/scanner.js +34 -138
- package/orchestrator/scheduler.js +20 -84
- package/package.json +1 -1
- package/sbom.cdx.json +241 -241
- package/scripts/audit-catalog-gaps.js +9 -62
- package/scripts/audit-cross-skill.js +5 -31
- package/scripts/audit-perf.js +6 -16
- package/scripts/backfill-theater-test.js +7 -64
- package/scripts/bootstrap.js +12 -44
- package/scripts/build-indexes.js +40 -154
- package/scripts/builders/activity-feed.js +4 -14
- package/scripts/builders/catalog-summaries.js +3 -10
- package/scripts/builders/currency.js +7 -20
- package/scripts/builders/cwe-chains.js +7 -30
- package/scripts/builders/did-ladders.js +6 -13
- package/scripts/builders/frequency.js +5 -19
- package/scripts/builders/jurisdiction-clocks.js +6 -25
- package/scripts/builders/recipes.js +6 -14
- package/scripts/builders/section-offsets.js +13 -51
- package/scripts/builders/stale-content.js +7 -28
- package/scripts/builders/summary-cards.js +8 -29
- package/scripts/builders/theater-fingerprints.js +12 -27
- package/scripts/builders/token-budget.js +4 -31
- package/scripts/check-agents-md-collectors.js +11 -54
- package/scripts/check-catalog-gap-budget.js +15 -32
- package/scripts/check-changelog-extract.js +18 -48
- package/scripts/check-codebase-patterns-currency.js +6 -22
- package/scripts/check-codebase-patterns.js +50 -143
- package/scripts/check-epss-consistency.js +9 -64
- package/scripts/check-framework-gap-coverage.js +13 -31
- package/scripts/check-manifest-snapshot.js +13 -73
- package/scripts/check-sbom-currency.js +44 -142
- package/scripts/check-test-count.js +15 -52
- package/scripts/check-test-coverage.js +66 -197
- package/scripts/check-test-subjects.js +21 -62
- package/scripts/check-ttp-references.js +14 -38
- package/scripts/check-ttp-upstream.js +8 -40
- package/scripts/check-version-bump.js +9 -61
- package/scripts/check-version-tags.js +20 -121
- package/scripts/predeploy.js +38 -184
- package/scripts/refresh-manifest-snapshot.js +16 -38
- package/scripts/refresh-mitre-atlas.js +3 -8
- package/scripts/refresh-mitre-attack.js +1 -8
- package/scripts/refresh-mitre-d3fend.js +3 -9
- package/scripts/refresh-mitre-ics-attack.js +3 -8
- package/scripts/refresh-reverse-refs.js +27 -94
- package/scripts/refresh-rfc-index.js +2 -10
- package/scripts/refresh-sbom.js +31 -161
- package/scripts/refresh-upstream-catalogs.js +40 -137
- package/scripts/release.js +69 -232
- package/scripts/run-e2e-scenarios.js +24 -71
- package/scripts/sync-manifest-metadata.js +10 -34
- package/scripts/sync-package-description.js +8 -17
- package/scripts/validate-vendor-online.js +13 -44
- package/scripts/verify-shipped-tarball.js +35 -140
package/lib/cve-curation.js
CHANGED
|
@@ -1,67 +1,27 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
* Two modes:
|
|
15
|
-
*
|
|
16
|
-
* curate(id, { apply: false }) → questionnaire (default)
|
|
17
|
-
* curate(id, { apply: true, answers: {} }) → land answers into the catalog
|
|
18
|
-
*
|
|
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.
|
|
4
|
+
* Editorial enrichment for auto-imported CVE catalog drafts: cross-references a
|
|
5
|
+
* draft in data/cve-catalog.json against the installed catalogs and produces
|
|
6
|
+
* structured editorial questions for a reviewer to answer. The cross-referencing
|
|
7
|
+
* is deterministic pattern-matching, not an LLM.
|
|
31
8
|
*/
|
|
32
9
|
|
|
33
10
|
const fs = require("fs");
|
|
34
11
|
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
12
|
const { withCatalogLock } = require("./refresh-external");
|
|
45
|
-
// `validate` is the LOOSE JSON-schema checker; `additionalChecks` is the
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
// promotes additionalChecks warnings to FATAL errors on NON-draft entries. The
|
|
50
|
-
// apply path must gate promotion on BOTH — otherwise it strips the _draft
|
|
51
|
-
// markers off an entry the very next predeploy run then FATAL-rejects.
|
|
13
|
+
// `validate` is the LOOSE JSON-schema checker; `additionalChecks` is the strict
|
|
14
|
+
// rule set `validate-cve-catalog.js --strict` promotes to FATAL on NON-draft
|
|
15
|
+
// entries. Promotion must gate on BOTH, or the apply path strips the _draft
|
|
16
|
+
// markers off an entry the next predeploy run then FATAL-rejects.
|
|
52
17
|
const {
|
|
53
18
|
validate: validateAgainstSchema,
|
|
54
19
|
additionalChecks,
|
|
55
20
|
STRICT_CVSS_PATTERN,
|
|
56
21
|
isUsableDate,
|
|
57
22
|
} = require("./validate-cve-catalog");
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
// (boolean inputs → scoreCustom; post-weight numeric inputs → sum + clamp)
|
|
61
|
-
// so the curation apply-path produces a score that matches whatever the
|
|
62
|
-
// catalog scorer or playbook-runner would have produced for the same
|
|
63
|
-
// factors. Direct dependency on scoring.js is intentional — scoring.js is
|
|
64
|
-
// the authoritative formula.
|
|
23
|
+
// deriveRwepFromFactors detects factor shape (boolean inputs → scoreCustom;
|
|
24
|
+
// post-weight numeric inputs → sum + clamp); scoring.js holds the formula.
|
|
65
25
|
const { deriveRwepFromFactors } = require("./scoring");
|
|
66
26
|
|
|
67
27
|
const ROOT = path.resolve(__dirname, "..");
|
|
@@ -70,16 +30,8 @@ let _cveSchemaCache = null;
|
|
|
70
30
|
function loadCveEntrySchema() {
|
|
71
31
|
if (_cveSchemaCache) return _cveSchemaCache;
|
|
72
32
|
try {
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
// `root.additionalProperties`. The actual schema at lib/schemas/cve-
|
|
76
|
-
// catalog.schema.json has NEITHER — its top level IS the entry shape
|
|
77
|
-
// (`{type:'object', required:[...], properties: {...}}`) because
|
|
78
|
-
// validate-cve-catalog.js iterates each CVE id key manually and runs
|
|
79
|
-
// the schema validator over each value. Result: loadCveEntrySchema()
|
|
80
|
-
// always returned null, the v0.12.12 codex P1 #1 fix (strict-schema
|
|
81
|
-
// gating of promotion) was silently disabled, and schema-violating
|
|
82
|
-
// entries promoted anyway. Use the root schema directly.
|
|
33
|
+
// The schema's top level IS the entry shape — no patternProperties wrapper
|
|
34
|
+
// to unwrap; validate-cve-catalog.js iterates the CVE id keys itself.
|
|
83
35
|
const root = JSON.parse(fs.readFileSync(CVE_SCHEMA_PATH, "utf8"));
|
|
84
36
|
_cveSchemaCache = root || null;
|
|
85
37
|
return _cveSchemaCache;
|
|
@@ -88,10 +40,7 @@ function loadCveEntrySchema() {
|
|
|
88
40
|
}
|
|
89
41
|
}
|
|
90
42
|
|
|
91
|
-
//
|
|
92
|
-
// framework-control-gaps catalogs don't change inside a single CLI process,
|
|
93
|
-
// so re-reading them per `curate()` call is wasted I/O. Cache once per
|
|
94
|
-
// process; batch-curate paths (future) get the speedup for free.
|
|
43
|
+
// These catalogs cannot change inside a CLI process; read once per process.
|
|
95
44
|
const catalogsCache = {};
|
|
96
45
|
|
|
97
46
|
function loadJsonRaw(absPath) {
|
|
@@ -99,9 +48,8 @@ function loadJsonRaw(absPath) {
|
|
|
99
48
|
catch { return null; }
|
|
100
49
|
}
|
|
101
50
|
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
// `path.join(ROOT, abs)` mishandles absolute paths on Windows.
|
|
51
|
+
// A `--catalog` path may be absolute or repo-relative, and a plain
|
|
52
|
+
// `path.join(ROOT, abs)` mishandles an absolute path on Windows.
|
|
105
53
|
function resolveCatalogPath(p) {
|
|
106
54
|
if (!p) return path.join(ROOT, "data", "cve-catalog.json");
|
|
107
55
|
if (path.isAbsolute(p)) return p;
|
|
@@ -109,9 +57,8 @@ function resolveCatalogPath(p) {
|
|
|
109
57
|
}
|
|
110
58
|
|
|
111
59
|
function loadJson(relOrAbs) {
|
|
112
|
-
// Repo-relative reads
|
|
113
|
-
//
|
|
114
|
-
// supplied and may legitimately change between invocations.
|
|
60
|
+
// Repo-relative reads cache by relative key. Absolute paths bypass the cache:
|
|
61
|
+
// they are operator-supplied and may change between invocations.
|
|
115
62
|
if (path.isAbsolute(relOrAbs)) return loadJsonRaw(relOrAbs);
|
|
116
63
|
if (catalogsCache[relOrAbs] !== undefined) return catalogsCache[relOrAbs];
|
|
117
64
|
const v = loadJsonRaw(path.join(ROOT, relOrAbs));
|
|
@@ -119,11 +66,8 @@ function loadJson(relOrAbs) {
|
|
|
119
66
|
return v;
|
|
120
67
|
}
|
|
121
68
|
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
// (the underlying catalogs are immutable inside one CLI run). A `null` Set
|
|
125
|
-
// (catalog absent) makes additionalChecks() skip that ref family, matching
|
|
126
|
-
// the validator's defense-in-depth behaviour.
|
|
69
|
+
// The cross-reference context additionalChecks() consumes — the same id Sets
|
|
70
|
+
// lib/validate-cve-catalog.js#main() assembles. A `null` Set skips that family.
|
|
127
71
|
let _strictCtxCache = null;
|
|
128
72
|
function strictCheckCtx() {
|
|
129
73
|
if (_strictCtxCache) return _strictCtxCache;
|
|
@@ -143,10 +87,7 @@ function strictCheckCtx() {
|
|
|
143
87
|
return _strictCtxCache;
|
|
144
88
|
}
|
|
145
89
|
|
|
146
|
-
|
|
147
|
-
* Score a candidate by counting keyword overlap with the draft entry.
|
|
148
|
-
* Returns 0..100. Pure heuristic — reviewer makes the final call.
|
|
149
|
-
*/
|
|
90
|
+
// Keyword overlap between a candidate and the draft, 0..100; a ranking heuristic.
|
|
150
91
|
function keywordOverlapScore(draftText, candidateText) {
|
|
151
92
|
if (!draftText || !candidateText) return 0;
|
|
152
93
|
const draftWords = new Set(
|
|
@@ -177,10 +118,8 @@ function pickCandidates(draftDigest, catalog, idField, descriptionField) {
|
|
|
177
118
|
return candidates.slice(0, 5);
|
|
178
119
|
}
|
|
179
120
|
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
// SNYK-*, RUSTSEC-*, etc. that land via lib/source-osv.js) as "unknown".
|
|
183
|
-
// The registry makes future sources a one-line addition.
|
|
121
|
+
// A registry rather than a `_source_ghsa_id` check, which would report every
|
|
122
|
+
// OSV-imported draft (MAL-*, SNYK-*, RUSTSEC-*) as "unknown".
|
|
184
123
|
const SOURCE_FIELDS = [
|
|
185
124
|
{ field: "_source_ghsa_id", label: "GHSA" },
|
|
186
125
|
{ field: "_source_osv_id", label: "OSV" },
|
|
@@ -196,10 +135,7 @@ function autoImportedFrom(draft) {
|
|
|
196
135
|
return "unknown";
|
|
197
136
|
}
|
|
198
137
|
|
|
199
|
-
//
|
|
200
|
-
// "low" would be wrong + misleading on a draft that has not been scored
|
|
201
|
-
// yet. "unrated" lets operators grep for unscored drafts and the curate
|
|
202
|
-
// summary reflects the actual state.
|
|
138
|
+
// An unscored draft is "unrated", never "low": no score is not a low score.
|
|
203
139
|
function severityWord(score) {
|
|
204
140
|
if (typeof score !== "number" || Number.isNaN(score)) return "unrated";
|
|
205
141
|
if (score >= 9) return "critical";
|
|
@@ -208,9 +144,7 @@ function severityWord(score) {
|
|
|
208
144
|
return "low";
|
|
209
145
|
}
|
|
210
146
|
|
|
211
|
-
// The fields the strict CVE schema requires
|
|
212
|
-
// questionnaire so the operator is prompted for every blocking gap, and
|
|
213
|
-
// (b) compute `residual_warnings` after an apply.
|
|
147
|
+
// The fields the strict CVE schema requires; drives `residual_warnings`.
|
|
214
148
|
const REQUIRED_SCHEMA_FIELDS = [
|
|
215
149
|
"name", "type", "cvss_score", "cvss_vector", "cisa_kev",
|
|
216
150
|
"poc_available", "ai_discovered", "ai_assisted_weaponization", "active_exploitation",
|
|
@@ -253,17 +187,14 @@ function residualWarnings(entry) {
|
|
|
253
187
|
}
|
|
254
188
|
|
|
255
189
|
/**
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
* curate(cveId, { catalogPath, apply, answers })
|
|
190
|
+
* The curation report for one CVE.
|
|
259
191
|
*
|
|
260
|
-
* apply: false (default) →
|
|
261
|
-
* apply: true →
|
|
262
|
-
*
|
|
192
|
+
* apply: false (default) → { ok, editorial_questions, ... }
|
|
193
|
+
* apply: true → consumes answers, writes the catalog, and returns
|
|
194
|
+
* { ok, applied_fields, residual_warnings, ... }
|
|
263
195
|
*/
|
|
264
196
|
async function curate(cveId, opts = {}) {
|
|
265
|
-
//
|
|
266
|
-
// — accept either shape so the CLI wiring stays stable.
|
|
197
|
+
// The nested `{ opts: { catalogPath } }` shape is accepted as well.
|
|
267
198
|
if (opts && opts.opts && !opts.catalogPath && opts.opts.catalogPath) {
|
|
268
199
|
opts = { ...opts, catalogPath: opts.opts.catalogPath };
|
|
269
200
|
}
|
|
@@ -279,9 +210,7 @@ async function curate(cveId, opts = {}) {
|
|
|
279
210
|
}
|
|
280
211
|
const draft = catalog[cveId];
|
|
281
212
|
|
|
282
|
-
//
|
|
283
|
-
// happens via direct file edit — refusing here prevents accidental
|
|
284
|
-
// suggestion-storms on entries that have already been reviewed.
|
|
213
|
+
// Drafts only: a reviewed entry is edited directly.
|
|
285
214
|
if (!draft._auto_imported && !draft._draft) {
|
|
286
215
|
return {
|
|
287
216
|
ok: false,
|
|
@@ -292,17 +221,14 @@ async function curate(cveId, opts = {}) {
|
|
|
292
221
|
};
|
|
293
222
|
}
|
|
294
223
|
|
|
295
|
-
// ----- APPLY PATH (J1) -------------------------------------------------
|
|
296
224
|
if (opts.apply && opts.answers) {
|
|
297
225
|
return applyAnswers(cveId, draft, catalog, catalogPath, opts.answers);
|
|
298
226
|
}
|
|
299
227
|
|
|
300
|
-
// ----- QUESTIONNAIRE PATH ---------------------------------------------
|
|
301
228
|
return buildQuestionnaire(cveId, draft);
|
|
302
229
|
}
|
|
303
230
|
|
|
304
231
|
function buildQuestionnaire(cveId, draft) {
|
|
305
|
-
// Digest of the draft — feed into keyword-overlap scoring.
|
|
306
232
|
const draftDigest = [
|
|
307
233
|
draft.name || "",
|
|
308
234
|
draft.affected || "",
|
|
@@ -311,15 +237,9 @@ function buildQuestionnaire(cveId, draft) {
|
|
|
311
237
|
draft.type || "",
|
|
312
238
|
].filter(Boolean).join(" ");
|
|
313
239
|
|
|
314
|
-
//
|
|
315
|
-
//
|
|
240
|
+
// Each candidate catalog is optional; an absent one loads as null and is
|
|
241
|
+
// skipped. A misnamed path reads the same way — as zero proposals.
|
|
316
242
|
const atlas = loadJson("data/atlas-ttps.json");
|
|
317
|
-
// v0.12.15 (E): the catalog ships as data/attack-techniques.json
|
|
318
|
-
// (renamed from data/attack-ttps.json before the v0.12.12 release; the
|
|
319
|
-
// canonical file path is also what lib/validate-cve-catalog.js consumes).
|
|
320
|
-
// The prior `data/attack-ttps.json` lookup silently fell back to an empty
|
|
321
|
-
// object via loadJsonRaw's ENOENT handling, so the ATT&CK candidate
|
|
322
|
-
// questionnaire branch always returned zero proposals.
|
|
323
243
|
const attack = loadJson("data/attack-techniques.json");
|
|
324
244
|
const frameworkGaps = loadJson("data/framework-control-gaps.json");
|
|
325
245
|
|
|
@@ -328,15 +248,10 @@ function buildQuestionnaire(cveId, draft) {
|
|
|
328
248
|
// CWE candidates surface as part of the vector question — not its own field.
|
|
329
249
|
const frameworkCandidates = pickCandidates(draftDigest, frameworkGaps, "control_id", "description");
|
|
330
250
|
|
|
331
|
-
// Build the editorial-questions list. Each entry names the catalog field,
|
|
332
|
-
// its current value (likely null on a draft), ranked candidates, and a
|
|
333
|
-
// specific ASK to surface what the reviewer needs to decide.
|
|
334
251
|
const questions = [];
|
|
335
252
|
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
// would skip the prompt. Use explicit emptiness checks so drafts
|
|
339
|
-
// seeded with empty objects / arrays still get the prompt.
|
|
253
|
+
// A pre-filled empty container (`iocs: {}`, `atlas_refs: []`) is not an
|
|
254
|
+
// answer, and `!draft.iocs` is false for `{}` — so emptiness is explicit.
|
|
340
255
|
const arrEmpty = (a) => !Array.isArray(a) || a.length === 0;
|
|
341
256
|
const objEmpty = (o) => !o || typeof o !== "object" || Object.keys(o).length === 0;
|
|
342
257
|
|
|
@@ -413,10 +328,7 @@ function buildQuestionnaire(cveId, draft) {
|
|
|
413
328
|
});
|
|
414
329
|
}
|
|
415
330
|
|
|
416
|
-
// Schema-required
|
|
417
|
-
// path cannot produce a schema-passing entry — the entry stays a draft
|
|
418
|
-
// even after curate --apply. Prompt for them explicitly so the
|
|
419
|
-
// operator sees what's actually blocking promotion.
|
|
331
|
+
// Schema-required: unpopulated, `--apply` leaves the entry a draft.
|
|
420
332
|
if (draft.cvss_score === null || draft.cvss_score === undefined
|
|
421
333
|
|| draft.cvss_vector === null || draft.cvss_vector === undefined
|
|
422
334
|
|| draft.cvss_vector === "") {
|
|
@@ -454,9 +366,7 @@ function buildQuestionnaire(cveId, draft) {
|
|
|
454
366
|
});
|
|
455
367
|
}
|
|
456
368
|
|
|
457
|
-
//
|
|
458
|
-
// a date) even when the upstream KEV feed hasn't caught up yet. False
|
|
459
|
-
// is a valid answer.
|
|
369
|
+
// Always asked: the operator may know a listing the KEV feed has not caught up.
|
|
460
370
|
if (draft.cisa_kev === null || draft.cisa_kev === undefined) {
|
|
461
371
|
questions.push({
|
|
462
372
|
field: "cisa_kev + cisa_kev_date",
|
|
@@ -466,9 +376,8 @@ function buildQuestionnaire(cveId, draft) {
|
|
|
466
376
|
});
|
|
467
377
|
}
|
|
468
378
|
|
|
469
|
-
// The
|
|
470
|
-
//
|
|
471
|
-
// fields that come from the same record and are easy to leave behind.
|
|
379
|
+
// The block above fires only while cisa_kev is unanswered, so a draft that
|
|
380
|
+
// already knows it is listed needs its own check for the sibling fields.
|
|
472
381
|
if (draft.cisa_kev === true && !draft.cisa_kev_due_date) {
|
|
473
382
|
questions.push({
|
|
474
383
|
field: "cisa_kev_due_date",
|
|
@@ -478,9 +387,8 @@ function buildQuestionnaire(cveId, draft) {
|
|
|
478
387
|
});
|
|
479
388
|
}
|
|
480
389
|
|
|
481
|
-
// A KEV-listed entry
|
|
482
|
-
//
|
|
483
|
-
// record may not say.
|
|
390
|
+
// A KEV-listed entry carries the designation as a boolean: an absent field
|
|
391
|
+
// reads as false to every consumer, asserting something CISA may not say.
|
|
484
392
|
if (draft.cisa_kev === true && typeof draft.known_ransomware_use !== "boolean") {
|
|
485
393
|
questions.push({
|
|
486
394
|
field: "known_ransomware_use",
|
|
@@ -517,43 +425,13 @@ function buildQuestionnaire(cveId, draft) {
|
|
|
517
425
|
}
|
|
518
426
|
|
|
519
427
|
/**
|
|
520
|
-
*
|
|
521
|
-
*
|
|
522
|
-
*
|
|
523
|
-
* for that field):
|
|
524
|
-
*
|
|
525
|
-
* {
|
|
526
|
-
* framework_control_gaps: { "NIST-800-53-SI-2": "...", ... },
|
|
527
|
-
* iocs: { payload_artifacts: [...], behavioral: [...], ... },
|
|
528
|
-
* atlas_refs: ["AML.T0010", ...],
|
|
529
|
-
* attack_refs: ["T1195.002", ...],
|
|
530
|
-
* rwep_factors: { cisa_kev: 25, blast_radius: 30, ... },
|
|
531
|
-
* rwep_score: 80, // optional; derived from sum if absent
|
|
532
|
-
* cvss_score: 9.8,
|
|
533
|
-
* cvss_vector: "CVSS:3.1/AV:N/...",
|
|
534
|
-
* cisa_kev: true, // or false
|
|
535
|
-
* cisa_kev_date: "2026-05-13",
|
|
536
|
-
* poc_available: true,
|
|
537
|
-
* poc_description: "...",
|
|
538
|
-
* ai_discovered: false,
|
|
539
|
-
* ai_assisted_weaponization: false,
|
|
540
|
-
* active_exploitation: "confirmed" | "suspected" | "theoretical" | "none" | "unknown",
|
|
541
|
-
* vector: "...",
|
|
542
|
-
* complexity: "...",
|
|
543
|
-
* patch_available: true,
|
|
544
|
-
* patch_required_reboot: false,
|
|
545
|
-
* live_patch_available: true,
|
|
546
|
-
* live_patch_tools: ["kpatch", "kgraft"],
|
|
547
|
-
* affected_versions: ["...", "..."],
|
|
548
|
-
* verification_sources: ["https://...", ...]
|
|
549
|
-
* }
|
|
428
|
+
* Applies operator-supplied answers to a draft and writes back atomically. Every
|
|
429
|
+
* answer key is optional; the ALLOWED table below is the authoritative list of
|
|
430
|
+
* keys and their shapes. rwep_score derives from rwep_factors when it is absent.
|
|
550
431
|
*/
|
|
551
432
|
async function applyAnswers(cveId, _draftSnapshot, _catalogSnapshot, catalogPath, answers) {
|
|
552
|
-
//
|
|
553
|
-
//
|
|
554
|
-
// previously read the same base, each mutated a different CVE, and the
|
|
555
|
-
// later writer overwrote the earlier writer's changes. The lock + re-read
|
|
556
|
-
// inside the critical section ensures sibling applies merge cleanly.
|
|
433
|
+
// The whole read-modify-write runs under withCatalogLock, re-reading the
|
|
434
|
+
// catalog inside the critical section so sibling applies merge.
|
|
557
435
|
let outcome = null;
|
|
558
436
|
await withCatalogLock(catalogPath, (catalog) => {
|
|
559
437
|
if (!catalog || !catalog[cveId]) {
|
|
@@ -575,24 +453,14 @@ function applyAnswersUnderLock(cveId, catalog, catalogPath, answers) {
|
|
|
575
453
|
const entry = catalog[cveId];
|
|
576
454
|
const appliedFields = [];
|
|
577
455
|
|
|
578
|
-
//
|
|
579
|
-
// is ignored
|
|
580
|
-
// Each entry: [field, validator(value) -> bool, transformer(value) -> stored].
|
|
456
|
+
// The fields an operator may supply, as [field, validator, transformer]. A key
|
|
457
|
+
// absent from the table is ignored, so a typo cannot pollute the entry.
|
|
581
458
|
const id = (x) => x;
|
|
582
459
|
|
|
583
|
-
// rwep_factors must arrive as the post-weight
|
|
584
|
-
//
|
|
585
|
-
//
|
|
586
|
-
//
|
|
587
|
-
// plus a rogue extra key holding a ladder string ('confirmed'). Either
|
|
588
|
-
// shape flips deriveRwepFromFactors() onto its Shape-A (scoreCustom) route,
|
|
589
|
-
// which reads the post-weight integers as booleans and DROPS the unmapped
|
|
590
|
-
// ai_factor weight — deriving a silently wrong rwep_score (e.g. 55/75 for a
|
|
591
|
-
// bag the operator intended as 90) and writing it to the catalog. Rejecting
|
|
592
|
-
// any non-numeric value here turns that silent mis-score into a surfaced
|
|
593
|
-
// rejected_fields entry at apply time, instead of relying on the downstream
|
|
594
|
-
// scoring.validate() divergence gate (which runs only at predeploy, after
|
|
595
|
-
// the wrong score is already on disk).
|
|
460
|
+
// rwep_factors must arrive as the post-weight block the schema mandates: a
|
|
461
|
+
// non-array object whose EVERY value is a finite number. A mixed bag flips
|
|
462
|
+
// deriveRwepFromFactors() onto its scoreCustom route, which reads the integers
|
|
463
|
+
// as booleans and writes a silently wrong rwep_score.
|
|
596
464
|
const isPostWeightFactors = (v) =>
|
|
597
465
|
v && typeof v === "object" && !Array.isArray(v) &&
|
|
598
466
|
Object.values(v).every((x) => typeof x === "number" && Number.isFinite(x));
|
|
@@ -606,11 +474,8 @@ function applyAnswersUnderLock(cveId, catalog, catalogPath, answers) {
|
|
|
606
474
|
["rwep_score", (v) => typeof v === "number", id],
|
|
607
475
|
["rwep_notes", (v) => typeof v === "string", id],
|
|
608
476
|
["cvss_score", (v) => typeof v === "number", id],
|
|
609
|
-
//
|
|
610
|
-
//
|
|
611
|
-
// survived the per-field check, landed on the entry, then blocked
|
|
612
|
-
// promotion downstream. Rejecting them here surfaces the exact field as a
|
|
613
|
-
// rejected_fields entry instead of a silent block.
|
|
477
|
+
// These match the predeploy strict gate at apply time: a `CVSS:9/...` vector
|
|
478
|
+
// or a malformed KEV date otherwise blocks promotion with no field named.
|
|
614
479
|
["cvss_vector", (v) => typeof v === "string" && STRICT_CVSS_PATTERN.test(v), id],
|
|
615
480
|
["cisa_kev", (v) => typeof v === "boolean", id],
|
|
616
481
|
["cisa_kev_date", (v) => v === null || (typeof v === "string" && isUsableDate(v).ok), id],
|
|
@@ -653,27 +518,18 @@ function applyAnswersUnderLock(cveId, catalog, catalogPath, answers) {
|
|
|
653
518
|
appliedFields.push(field);
|
|
654
519
|
}
|
|
655
520
|
|
|
656
|
-
//
|
|
657
|
-
//
|
|
658
|
-
// → scoreCustom; post-weight numeric inputs → sum + clamp) and routes
|
|
659
|
-
// accordingly, so the apply-path produces a score that agrees with
|
|
660
|
-
// scoring.validate() instead of diverging from it.
|
|
521
|
+
// Derived through the canonical helper, not a blind sum, so the score agrees
|
|
522
|
+
// with scoring.validate() rather than diverging from it.
|
|
661
523
|
if ("rwep_factors" in answers && !("rwep_score" in answers)
|
|
662
524
|
&& entry.rwep_factors && typeof entry.rwep_factors === "object") {
|
|
663
525
|
entry.rwep_score = deriveRwepFromFactors(entry.rwep_factors);
|
|
664
526
|
appliedFields.push("rwep_score (derived from rwep_factors via scoring.deriveRwepFromFactors)");
|
|
665
527
|
}
|
|
666
528
|
|
|
667
|
-
// last_updated reflects the apply moment.
|
|
668
529
|
entry.last_updated = new Date().toISOString().slice(0, 10);
|
|
669
530
|
|
|
670
|
-
//
|
|
671
|
-
//
|
|
672
|
-
// previously gated on residualWarnings() alone — a presence check — so
|
|
673
|
-
// an input like `"ai_discovered": "false"` (string) passed presence but
|
|
674
|
-
// would have failed lib/validate-cve-catalog.js on type mismatch,
|
|
675
|
-
// creating a "promoted but invalid" entry. Now: presence + strict schema
|
|
676
|
-
// BOTH must hold for promotion.
|
|
531
|
+
// Promotion needs presence AND the strict schema: a presence check alone
|
|
532
|
+
// accepts `"ai_discovered": "false"`, leaving a promoted-but-invalid entry.
|
|
677
533
|
const warnings = residualWarnings(entry);
|
|
678
534
|
const schemaErrors = [];
|
|
679
535
|
const entrySchema = loadCveEntrySchema();
|
|
@@ -681,17 +537,9 @@ function applyAnswersUnderLock(cveId, catalog, catalogPath, answers) {
|
|
|
681
537
|
const errs = validateAgainstSchema(entry, entrySchema, "cve-entry", `${cveId}`);
|
|
682
538
|
if (Array.isArray(errs) && errs.length > 0) schemaErrors.push(...errs);
|
|
683
539
|
}
|
|
684
|
-
// The loose schema accepts values
|
|
685
|
-
//
|
|
686
|
-
//
|
|
687
|
-
// `^CVSS:[0-9]+(\.[0-9]+)?/` but fails STRICT_CVSS_PATTERN), a cisa_kev=true
|
|
688
|
-
// with a malformed/impossible cisa_kev_date, an atlas/attack/cwe/d3fend/
|
|
689
|
-
// framework ref that resolves to no catalog entry, or a poc+public-exploit
|
|
690
|
-
// source with no iocs. Run the SAME additionalChecks() the validator runs
|
|
691
|
-
// and fold its findings into the promotion gate, so the apply path never
|
|
692
|
-
// strips _draft off an entry the next predeploy run then rejects. Surfaced
|
|
693
|
-
// under a dedicated `strict_warnings` key so the operator sees exactly which
|
|
694
|
-
// strict rule blocked promotion.
|
|
540
|
+
// The loose schema accepts values `validate-cve-catalog.js --strict`
|
|
541
|
+
// FATAL-rejects on a non-draft entry, so the validator's own additionalChecks()
|
|
542
|
+
// runs here too; `strict_warnings` names the rule.
|
|
695
543
|
const strictWarnings = additionalChecks(cveId, entry, strictCheckCtx());
|
|
696
544
|
let promoted = false;
|
|
697
545
|
if (warnings.length === 0 && schemaErrors.length === 0 && strictWarnings.length === 0) {
|
|
@@ -701,9 +549,7 @@ function applyAnswersUnderLock(cveId, catalog, catalogPath, answers) {
|
|
|
701
549
|
promoted = true;
|
|
702
550
|
}
|
|
703
551
|
|
|
704
|
-
//
|
|
705
|
-
// atomically when this function returns. Concurrent applies are
|
|
706
|
-
// serialized + see the post-merge state.
|
|
552
|
+
// Mutated in place under the lock; the locker writes it atomically on return.
|
|
707
553
|
catalog[cveId] = entry;
|
|
708
554
|
|
|
709
555
|
return {
|
|
@@ -738,11 +584,8 @@ function applyAnswersUnderLock(cveId, catalog, catalogPath, answers) {
|
|
|
738
584
|
|
|
739
585
|
function writeJsonAtomic(p, obj) {
|
|
740
586
|
const tmpPath = `${p}.tmp.${process.pid}.${Math.random().toString(36).slice(2, 10)}`;
|
|
741
|
-
//
|
|
742
|
-
//
|
|
743
|
-
// fsync the data sits in the OS page cache and the rename succeeds
|
|
744
|
-
// atomically, but the renamed file may be zero-length / partial on
|
|
745
|
-
// crash. Open + write + fsync + close + rename is the durable idiom.
|
|
587
|
+
// fsync before the rename: without it the rename is still atomic, but the
|
|
588
|
+
// renamed file can be zero-length or partial after a crash.
|
|
746
589
|
const fd = fs.openSync(tmpPath, 'w');
|
|
747
590
|
try {
|
|
748
591
|
fs.writeSync(fd, JSON.stringify(obj, null, 2) + "\n", 0, "utf8");
|
|
@@ -758,14 +601,10 @@ function writeJsonAtomic(p, obj) {
|
|
|
758
601
|
}
|
|
759
602
|
}
|
|
760
603
|
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
* `refresh --curate <id> --answers <path> [--apply]`.
|
|
764
|
-
*/
|
|
604
|
+
// CLI entry, wired from bin/exceptd.js as `refresh --curate <id>` and
|
|
605
|
+
// `refresh --curate <id> --answers <path> [--apply]`.
|
|
765
606
|
async function cli(argv) {
|
|
766
|
-
//
|
|
767
|
-
// entries) is a distinct workflow from the single-CVE questionnaire
|
|
768
|
-
// below; delegate before the --curate opts loop touches argv.
|
|
607
|
+
// A distinct workflow, delegated before the opts loop below consumes argv.
|
|
769
608
|
if (argv.includes("--curate-batch")) return require("./cve-batch.js").cli(argv);
|
|
770
609
|
const opts = { advisory: null, json: false, catalogPath: null, answersPath: null, apply: false };
|
|
771
610
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -788,10 +627,8 @@ async function cli(argv) {
|
|
|
788
627
|
return;
|
|
789
628
|
}
|
|
790
629
|
|
|
791
|
-
//
|
|
792
|
-
//
|
|
793
|
-
// without --answers — fail loudly so a forgetful operator doesn't think
|
|
794
|
-
// a write succeeded when in fact the questionnaire was returned.
|
|
630
|
+
// `--answers <path>` implies apply. `--apply` alone is a no-op that would
|
|
631
|
+
// return the questionnaire, so it fails loudly rather than read as a write.
|
|
795
632
|
let answers = null;
|
|
796
633
|
if (opts.answersPath) {
|
|
797
634
|
try {
|
|
@@ -805,8 +642,6 @@ async function cli(argv) {
|
|
|
805
642
|
return;
|
|
806
643
|
}
|
|
807
644
|
}
|
|
808
|
-
// --answers alone implies apply; --apply alone without --answers is a
|
|
809
|
-
// user error.
|
|
810
645
|
const doApply = !!answers || opts.apply;
|
|
811
646
|
if (opts.apply && !answers) {
|
|
812
647
|
const err = { ok: false, verb: "refresh", mode: "cve-curation", error: "--apply requires --answers <path-to-answers.json>" };
|
|
@@ -839,8 +674,8 @@ async function cli(argv) {
|
|
|
839
674
|
}
|
|
840
675
|
process.stdout.write(`\n Run with --json for the full structured output.\n`);
|
|
841
676
|
}
|
|
842
|
-
//
|
|
843
|
-
//
|
|
677
|
+
// Exit 2 on error, 3 for "editorial review pending" — the questionnaire, or
|
|
678
|
+
// an apply that left the entry a draft — and 0 only on promotion.
|
|
844
679
|
if (!result.ok) process.exitCode = 2;
|
|
845
680
|
else if (result.mode === "cve-curation-apply") process.exitCode = result.promoted ? 0 : 3;
|
|
846
681
|
else process.exitCode = 3;
|
|
@@ -848,10 +683,8 @@ async function cli(argv) {
|
|
|
848
683
|
|
|
849
684
|
if (require.main === module) {
|
|
850
685
|
cli(process.argv.slice(2)).catch((err) => {
|
|
851
|
-
//
|
|
852
|
-
//
|
|
853
|
-
// envelope every other error path in cli() produces instead of crashing
|
|
854
|
-
// with a raw stack trace, and set exitCode so stderr drains before exit.
|
|
686
|
+
// A throw out of cli() emits the same {ok:false,verb,mode,error} envelope as
|
|
687
|
+
// every other error path, and `process.exitCode`, so stderr drains first.
|
|
855
688
|
process.stderr.write(JSON.stringify({ ok: false, verb: "refresh", mode: "cve-curation", error: String((err && err.message) || err) }) + "\n");
|
|
856
689
|
process.exitCode = 2;
|
|
857
690
|
});
|