@blamejs/exceptd-skills 0.19.32 → 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 +22 -0
- package/bin/exceptd.js +896 -2824
- package/data/_indexes/_meta.json +8 -8
- package/data/_indexes/activity-feed.json +2 -2
- package/data/_indexes/catalog-summaries.json +7 -7
- package/data/_indexes/chains.json +60118 -0
- package/data/attack-techniques.json +267 -7
- package/data/cve-catalog.json +9991 -3
- package/data/cwe-catalog.json +109 -2
- package/data/framework-control-gaps.json +578 -3
- package/data/zeroday-lessons.json +8330 -1
- 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 +2 -2
- package/sbom.cdx.json +253 -253
- 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/cross-ref-api.js
CHANGED
|
@@ -1,15 +1,9 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Cross-reference API —
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* of every directive. No probes, no shellouts, no network. Every function takes
|
|
8
|
-
* an identifier and returns correlated catalog entries from CVE / CWE / ATLAS /
|
|
9
|
-
* ATT&CK / D3FEND / framework-gaps / global-frameworks / RFC / zero-day-lessons,
|
|
10
|
-
* plus pre-computed indexes (xref, chains, recipes, theater-fingerprints).
|
|
11
|
-
*
|
|
12
|
-
* Catalogs are loaded lazily and cached for the lifetime of the process.
|
|
4
|
+
* Cross-reference API — read-only queries over data/ and data/_indexes/. Every
|
|
5
|
+
* function takes an identifier and returns the correlated catalog entries.
|
|
6
|
+
* Catalogs load lazily and are cached for the lifetime of the process.
|
|
13
7
|
*/
|
|
14
8
|
|
|
15
9
|
const fs = require('fs');
|
|
@@ -19,35 +13,17 @@ const ROOT = path.join(__dirname, '..');
|
|
|
19
13
|
const DATA_DIR = process.env.EXCEPTD_DATA_DIR || path.join(ROOT, 'data');
|
|
20
14
|
const INDEX_DIR = path.join(DATA_DIR, '_indexes');
|
|
21
15
|
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
// the cached value is returned (one syscall, no parse). If mtime changed,
|
|
25
|
-
// re-parse + repopulate. If stat fails (file vanished mid-run, permission
|
|
26
|
-
// glitch), fall back to the cached value. Without mtime-keyed
|
|
27
|
-
// invalidation, long-running `orchestrator watch` processes never see
|
|
28
|
-
// `data/cve-catalog.json` mutations driven by `exceptd refresh --apply`.
|
|
16
|
+
// Each entry stores the parsed payload beside the source file's stat signature,
|
|
17
|
+
// so a long-running process sees a catalog that `refresh --apply` rewrote.
|
|
29
18
|
const _cache = new Map();
|
|
30
19
|
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
// at require-time of any consumer (lib/playbook-runner.js), which threw
|
|
34
|
-
// out of the run() entrypoint without honoring AGENTS.md's "non-zero
|
|
35
|
-
// exit + {ok:false, error} to stderr" contract. Now: caught + degraded
|
|
36
|
-
// to an empty catalog with a recorded _loadError that downstream code
|
|
37
|
-
// can inspect.
|
|
20
|
+
// A malformed JSON file degrades to an empty catalog carrying a recorded load
|
|
21
|
+
// error, rather than throwing out of the run() entrypoint.
|
|
38
22
|
const _loadErrors = [];
|
|
39
23
|
|
|
40
24
|
/**
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* (FAT32, HFS+ pre-APFS, NFSv3, Docker bind-mounts that proxy mtime)
|
|
44
|
-
* a rapid refresh-then-reload within the same second served stale
|
|
45
|
-
* cached data. Adding `size` catches every content change that affects
|
|
46
|
-
* byte count; mtimeMs catches in-place rewrites that preserve byte
|
|
47
|
-
* count. Together they cover every realistic catalog-mutation path
|
|
48
|
-
* without the cost of a per-load SHA computation. SHA-based tier is
|
|
49
|
-
* available via _statContentHash() when callers want full invalidation
|
|
50
|
-
* (e.g. long-running daemons against append-only catalogs).
|
|
25
|
+
* Keyed on (mtimeMs, size): on a filesystem with 1-2s mtime granularity a
|
|
26
|
+
* refresh-then-reload inside the same second serves stale data on mtime alone.
|
|
51
27
|
*/
|
|
52
28
|
function _statSignature(p) {
|
|
53
29
|
try {
|
|
@@ -119,47 +95,34 @@ function entries(catalog) {
|
|
|
119
95
|
return Object.entries(catalog).filter(([k]) => !k.startsWith('_'));
|
|
120
96
|
}
|
|
121
97
|
|
|
122
|
-
// Auto-imported drafts carry
|
|
123
|
-
//
|
|
124
|
-
// default; every transitive enumeration that walks the same catalog
|
|
125
|
-
// (byCwe / byTtp / bySkill) must apply the identical contract so a draft
|
|
126
|
-
// never surfaces as a curated cross-reference. Keyed on `_auto_imported`
|
|
127
|
-
// to match byCve's exact predicate, so all four entry points agree.
|
|
98
|
+
// Auto-imported drafts carry null analytical fields. byCve, byCwe, byTtp and
|
|
99
|
+
// bySkill all exclude on this one predicate, so a draft never reads as curated.
|
|
128
100
|
function _isDraftEntry(c) {
|
|
129
101
|
return !!c && c._auto_imported === true;
|
|
130
102
|
}
|
|
131
103
|
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
// builder never writes silently yields empty correlations. The TTP maps
|
|
136
|
-
// are split by id space — ATLAS ids (AML.*) live in atlas_refs, ATT&CK
|
|
137
|
-
// ids (T*) in attack_refs — so a TTP lookup unions both.
|
|
104
|
+
// These names must match the keys the index builder emits into
|
|
105
|
+
// data/_indexes/xref.json — a name it never writes yields empty correlations with
|
|
106
|
+
// no error. ATLAS ids (AML.*) live in atlas_refs, ATT&CK ids in attack_refs.
|
|
138
107
|
const XREF_KEYS = {
|
|
139
108
|
cwe: 'cwe_refs',
|
|
140
109
|
atlas: 'atlas_refs',
|
|
141
110
|
attack: 'attack_refs',
|
|
142
111
|
};
|
|
143
112
|
|
|
144
|
-
// CWE -> [skill, ...] from the xref index.
|
|
145
113
|
function skillsForCwe(xref, cweId) {
|
|
146
114
|
return (xref[XREF_KEYS.cwe] && xref[XREF_KEYS.cwe][cweId]) || [];
|
|
147
115
|
}
|
|
148
116
|
|
|
149
|
-
//
|
|
150
|
-
// single id resolves through whichever map owns its prefix (with a fall
|
|
151
|
-
// back to the other in case a caller passes an unprefixed id).
|
|
117
|
+
// An id resolves through the map owning its prefix, falling back to the other.
|
|
152
118
|
function skillsForTtp(xref, ttpId) {
|
|
153
119
|
const atlas = xref[XREF_KEYS.atlas] || {};
|
|
154
120
|
const attack = xref[XREF_KEYS.attack] || {};
|
|
155
121
|
return (ttpId.startsWith('AML.') ? atlas[ttpId] : attack[ttpId]) || atlas[ttpId] || attack[ttpId] || [];
|
|
156
122
|
}
|
|
157
123
|
|
|
158
|
-
// No CVE->skill map exists
|
|
159
|
-
//
|
|
160
|
-
// declared CWEs: each CWE maps to skills via the cwe_refs map. Union the
|
|
161
|
-
// skills across every CWE the CVE references, sorted + de-duplicated so
|
|
162
|
-
// the result is stable regardless of CWE ordering.
|
|
124
|
+
// No CVE->skill map exists, so the linkage runs through the CVE's declared CWEs.
|
|
125
|
+
// The union is sorted so the result does not depend on CWE ordering.
|
|
163
126
|
function skillsForCve(xref, cveEntry) {
|
|
164
127
|
const out = new Set();
|
|
165
128
|
for (const cwe of (cveEntry && cveEntry.cwe_refs) || []) {
|
|
@@ -168,21 +131,11 @@ function skillsForCve(xref, cveEntry) {
|
|
|
168
131
|
return [...out].sort();
|
|
169
132
|
}
|
|
170
133
|
|
|
171
|
-
// --- public API ---
|
|
172
|
-
|
|
173
134
|
/**
|
|
174
|
-
* Full correlation for a CVE
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
* Auto-imported drafts (entries with `_auto_imported === true`) are
|
|
179
|
-
* EXCLUDED by default. Drafts carry conservative-default mechanical fields
|
|
180
|
-
* and null analytical fields pending curation; downstream analyze / bundle
|
|
181
|
-
* emitters that assume `byCve()` returns curated data would treat the
|
|
182
|
-
* draft's placeholders as authoritative. The cve-curation flow (which
|
|
183
|
-
* surfaces the editorial questionnaire) opts in via
|
|
184
|
-
* `byCve(id, { include_drafts: true })`; every other caller stays on the
|
|
185
|
-
* default exclude path.
|
|
135
|
+
* Full correlation for a CVE id: the catalog entry plus everything referencing
|
|
136
|
+
* it across skills, framework gaps, theater fingerprints and zero-day lessons.
|
|
137
|
+
* Auto-imported drafts are excluded unless `opts.include_drafts` is set, since
|
|
138
|
+
* callers read what this returns as curated.
|
|
186
139
|
*/
|
|
187
140
|
function byCve(cveId, opts) {
|
|
188
141
|
const includeDrafts = !!(opts && opts.include_drafts);
|
|
@@ -198,27 +151,15 @@ function byCve(cveId, opts) {
|
|
|
198
151
|
const gaps = loadCatalog('framework-control-gaps.json');
|
|
199
152
|
const lessons = loadCatalog('zeroday-lessons.json');
|
|
200
153
|
|
|
201
|
-
// Skills correlate to a CVE transitively through its declared CWEs
|
|
202
|
-
// (CVE -> cwe_refs -> xref.cwe_refs -> skills); there is no direct
|
|
203
|
-
// CVE->skill index.
|
|
204
154
|
const skills = skillsForCve(xref, entry);
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
// The dead `recipes:[]` field is no longer emitted.)
|
|
208
|
-
//
|
|
209
|
-
// Theater fingerprints live under the index's `patterns` container; each
|
|
210
|
-
// pattern records a single `evidence.cve` (or `evidence.campaign`, which
|
|
211
|
-
// carries no CVE to match). The distinguishing check is `fast_test`.
|
|
155
|
+
// Theater fingerprints live under the index's `patterns` container; each records
|
|
156
|
+
// a single `evidence.cve`, and `fast_test` is the distinguishing check.
|
|
212
157
|
const theater = Object.entries(theaterFp.patterns || {})
|
|
213
158
|
.filter(([, t]) => t && t.evidence && t.evidence.cve === cveId)
|
|
214
159
|
.map(([id, t]) => ({ id, pattern_name: t.pattern_name, distinguisher: t.fast_test }));
|
|
215
|
-
// Framework-control-gaps link CVEs through `evidence_cves`; the control
|
|
216
|
-
// identifier field is `control_id`.
|
|
217
160
|
const framework_gaps = entries(gaps).filter(([, g]) =>
|
|
218
161
|
Array.isArray(g.evidence_cves) && g.evidence_cves.includes(cveId)
|
|
219
162
|
).map(([id, g]) => ({ id, framework: g.framework, control: g.control_id, status: g.status }));
|
|
220
|
-
// Zero-day lessons are keyed by CVE id, so a referenced lesson is a
|
|
221
|
-
// direct key hit rather than a back-reference scan.
|
|
222
163
|
const lessons_learned = lessons[cveId] ? [cveId] : [];
|
|
223
164
|
|
|
224
165
|
return {
|
|
@@ -251,11 +192,8 @@ function byCwe(cweId) {
|
|
|
251
192
|
}
|
|
252
193
|
|
|
253
194
|
function byTtp(ttpId) {
|
|
254
|
-
// TTP ids span two disjoint catalogs (ATLAS AML.* vs ATT&CK T*)
|
|
255
|
-
//
|
|
256
|
-
// collide, so order is irrelevant. Previously only atlas-ttps.json was
|
|
257
|
-
// consulted, so every ATT&CK technique reported found:false / entry:null
|
|
258
|
-
// even though skills + related_cves correctly unioned both spaces.
|
|
195
|
+
// TTP ids span two disjoint catalogs (ATLAS AML.* vs ATT&CK T*); consulting
|
|
196
|
+
// only one leaves every technique in the other reporting found:false.
|
|
259
197
|
const atlas = loadCatalog('atlas-ttps.json');
|
|
260
198
|
const attack = loadCatalog('attack-techniques.json');
|
|
261
199
|
const xref = loadIndex('xref.json');
|
|
@@ -269,10 +207,8 @@ function byTtp(ttpId) {
|
|
|
269
207
|
)
|
|
270
208
|
)
|
|
271
209
|
.map(([id]) => id);
|
|
272
|
-
// D3FEND maps
|
|
273
|
-
// `
|
|
274
|
-
// empty across every catalog entry, so this correlation was structurally
|
|
275
|
-
// dead (a non-existent field .includes() is always false).
|
|
210
|
+
// D3FEND maps a countermeasure through `counters_attack_techniques`; the
|
|
211
|
+
// `counters` field is empty catalog-wide, so filtering on it is dead.
|
|
276
212
|
const d3fend = entries(loadCatalog('d3fend-catalog.json'))
|
|
277
213
|
.filter(([, d]) => Array.isArray(d.counters_attack_techniques) && d.counters_attack_techniques.includes(ttpId))
|
|
278
214
|
.map(([id]) => id);
|
|
@@ -283,8 +219,7 @@ function bySkill(skillName) {
|
|
|
283
219
|
const xref = loadIndex('xref.json');
|
|
284
220
|
const summary = loadIndex('summary-cards.json');
|
|
285
221
|
const card = summary[skillName] || summary.skills?.[skillName] || null;
|
|
286
|
-
//
|
|
287
|
-
// list contains this skill is a reference. Both id spaces contribute.
|
|
222
|
+
// Invert both TTP maps; both id spaces contribute.
|
|
288
223
|
const ttpRefs = Object.entries({
|
|
289
224
|
...(xref[XREF_KEYS.atlas] || {}),
|
|
290
225
|
...(xref[XREF_KEYS.attack] || {}),
|
|
@@ -292,9 +227,6 @@ function bySkill(skillName) {
|
|
|
292
227
|
.filter(([, skills]) => Array.isArray(skills) && skills.includes(skillName))
|
|
293
228
|
.map(([ttp]) => ttp)
|
|
294
229
|
.sort();
|
|
295
|
-
// CVEs link to a skill transitively: a CVE references CWEs, and each CWE
|
|
296
|
-
// maps to skills via cwe_refs. Collect every CVE whose CWE set resolves
|
|
297
|
-
// to this skill.
|
|
298
230
|
const cveCatalog = loadCatalog('cve-catalog.json');
|
|
299
231
|
const cveRefs = entries(cveCatalog)
|
|
300
232
|
.filter(([, c]) => !_isDraftEntry(c) && (c.cwe_refs || []).some(cwe => skillsForCwe(xref, cwe).includes(skillName)))
|
|
@@ -303,13 +235,9 @@ function bySkill(skillName) {
|
|
|
303
235
|
return { skill: skillName, summary_card: card, cve_refs: cveRefs, ttp_refs: ttpRefs };
|
|
304
236
|
}
|
|
305
237
|
|
|
306
|
-
// global-frameworks.json is keyed by
|
|
307
|
-
// `frameworks: { SHORTKEY: {
|
|
308
|
-
//
|
|
309
|
-
// short key or a catalog display name, never a region — so framework_meta was
|
|
310
|
-
// universally null. Walk the nested structure and match the requested id against
|
|
311
|
-
// the short key, the full_name, or any catalog_alias (normalized), returning the
|
|
312
|
-
// matched framework object annotated with its region + jurisdiction.
|
|
238
|
+
// global-frameworks.json is keyed by region, each region holding a nested
|
|
239
|
+
// `frameworks: { SHORTKEY: {...} }` map, so a flat `global[frameworkId]` lookup
|
|
240
|
+
// never matches. Match the short key, full_name or any catalog_alias, normalized.
|
|
313
241
|
function resolveFrameworkMeta(global, frameworkId) {
|
|
314
242
|
if (!global || frameworkId == null) return null;
|
|
315
243
|
const norm = (s) => String(s == null ? '' : s).toLowerCase().replace(/\([^)]*\)/g, '').replace(/[\s_-]/g, '');
|
|
@@ -336,13 +264,8 @@ function byFramework(frameworkId) {
|
|
|
336
264
|
const gaps = loadCatalog('framework-control-gaps.json');
|
|
337
265
|
const global = loadCatalog('global-frameworks.json');
|
|
338
266
|
const fwMeta = resolveFrameworkMeta(global, frameworkId);
|
|
339
|
-
// Match gap rows
|
|
340
|
-
//
|
|
341
|
-
// ISM …) that diverge from its short key and full_name, so an exact
|
|
342
|
-
// `g.framework === frameworkId` match returned a gap set inconsistent with the
|
|
343
|
-
// (now alias-resolved) framework_meta — non-null metadata but a partial,
|
|
344
|
-
// sometimes empty, gap list. Resolve the same alias set used for the metadata
|
|
345
|
-
// and match any gap label (string or array element) against it.
|
|
267
|
+
// Match gap rows against the framework's whole label set: the catalog files
|
|
268
|
+
// gaps under labels (au-ism / AU ISM / ACSC ISM) that diverge from the key.
|
|
346
269
|
const norm = (s) => String(s == null ? '' : s).toLowerCase().replace(/\([^)]*\)/g, '').replace(/[\s_-]/g, '');
|
|
347
270
|
const labels = new Set([norm(frameworkId)]);
|
|
348
271
|
if (fwMeta) {
|
|
@@ -368,17 +291,13 @@ function byFramework(frameworkId) {
|
|
|
368
291
|
}
|
|
369
292
|
|
|
370
293
|
/**
|
|
371
|
-
*
|
|
372
|
-
*
|
|
373
|
-
* Rule #6). Drives the validate phase when emit.theater_check = true.
|
|
294
|
+
* For a finding shape, the test that distinguishes paper compliance from actual
|
|
295
|
+
* security. Drives the validate phase when emit.theater_check is true.
|
|
374
296
|
*/
|
|
375
297
|
function theaterTestsFor({ cveIds = [], frameworkIds = [], skillIds = [] }) {
|
|
376
298
|
const fp = loadIndex('theater-fingerprints.json');
|
|
377
299
|
const matches = [];
|
|
378
|
-
//
|
|
379
|
-
// the top level. Each pattern records a single `evidence.cve`, a list of
|
|
380
|
-
// `controls` (each {framework, control_id}), and a `source_skill`. A
|
|
381
|
-
// framework match accepts either the bare control id ("SI-2") or the
|
|
300
|
+
// A framework match accepts either the bare control id ("SI-2") or the
|
|
382
301
|
// qualified "framework::control_id" form the by_control index keys on.
|
|
383
302
|
for (const [id, t] of Object.entries(fp.patterns || {})) {
|
|
384
303
|
if (!t) continue;
|
|
@@ -395,9 +314,8 @@ function theaterTestsFor({ cveIds = [], frameworkIds = [], skillIds = [] }) {
|
|
|
395
314
|
}
|
|
396
315
|
|
|
397
316
|
/**
|
|
398
|
-
*
|
|
399
|
-
*
|
|
400
|
-
* Essential 8) + ISO 27001:2022 + NIST. Satisfies AGENTS.md Hard Rule #5.
|
|
317
|
+
* Given a finding's CVE/TTP set, the relevant gaps across EU (NIS2/DORA/EU AI
|
|
318
|
+
* Act), UK (CAF), AU (ISM / Essential 8), ISO 27001:2022 and NIST.
|
|
401
319
|
*/
|
|
402
320
|
function globalFrameworkContext({ cveIds = [], ttpIds = [] }) {
|
|
403
321
|
const gaps = loadCatalog('framework-control-gaps.json');
|
|
@@ -429,8 +347,6 @@ module.exports = {
|
|
|
429
347
|
// Lower-level access (engine uses these directly)
|
|
430
348
|
_loadCatalog: loadCatalog,
|
|
431
349
|
_loadIndex: loadIndex,
|
|
432
|
-
//
|
|
433
|
-
// [{kind, file, error}, ...] for every catalog/index whose JSON
|
|
434
|
-
// parse failed. Empty array on a healthy install.
|
|
350
|
+
// [{kind, file, error}] per catalog or index whose JSON parse failed.
|
|
435
351
|
getLoadErrors,
|
|
436
352
|
};
|
package/lib/currency-severity.js
CHANGED
|
@@ -1,33 +1,15 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* Pure function the `exceptd doctor` verb uses to turn a currency report into a
|
|
5
|
+
* health verdict, on the ladder orchestrator/pipeline.js scores against:
|
|
5
6
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
7
|
+
* current (>= 90) → healthy, in neither bucket
|
|
8
|
+
* acceptable (70-89, drifting) → severity:"warn", ok:true, exit 0
|
|
9
|
+
* stale / critical_stale (< 70) → ok:false, exit 1
|
|
8
10
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* critical_stale (< 50) — and raises `action_required` only below 70. The
|
|
12
|
-
* pre-extraction predicate in the doctor ignored that ladder and failed on
|
|
13
|
-
* `currency_label !== "current"`, which folded the acceptable tier in with
|
|
14
|
-
* genuine staleness.
|
|
15
|
-
*
|
|
16
|
-
* That made the health check a scheduled failure rather than a signal: a
|
|
17
|
-
* skill's score drops from 90 to 80 the day it crosses 90 days since
|
|
18
|
-
* `last_threat_review`, so any skill will turn `doctor` red on a fixed
|
|
19
|
-
* cadence with no code change and nothing actually wrong.
|
|
20
|
-
*
|
|
21
|
-
* The tiers now map to severity the way the model defines them:
|
|
22
|
-
*
|
|
23
|
-
* current → healthy, no entry in either bucket
|
|
24
|
-
* acceptable (drifting) → severity:"warn", ok:true, exit 0
|
|
25
|
-
* stale / critical_stale → ok:false (error), exit 1
|
|
26
|
-
*
|
|
27
|
-
* `stale_skills` keeps its original meaning — every skill that is not
|
|
28
|
-
* `current` — so an existing consumer reading it still sees the full picture.
|
|
29
|
-
* `drifting_skills` and `action_required_skills` split that list along the
|
|
30
|
-
* boundary the exit code now respects.
|
|
11
|
+
* Failing on `currency_label !== "current"` instead folds the acceptable tier in
|
|
12
|
+
* with real staleness and turns doctor red on a fixed cadence with nothing wrong.
|
|
31
13
|
*/
|
|
32
14
|
|
|
33
15
|
/**
|
|
@@ -43,8 +25,7 @@ function classifyCurrency(report, actionRequired) {
|
|
|
43
25
|
const actionable = rows.filter(s => s.action_required);
|
|
44
26
|
const critical = rows.filter(s => s.currency_score !== undefined && s.currency_score < 50);
|
|
45
27
|
|
|
46
|
-
// The report-level flag is honored even when no row
|
|
47
|
-
// so an upstream that raises the flag some other way still fails closed.
|
|
28
|
+
// The report-level flag is honored even when no row sets it, so this fails closed.
|
|
48
29
|
const ok = actionable.length === 0 && !actionRequired;
|
|
49
30
|
|
|
50
31
|
return {
|
package/lib/cve-batch.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
// Batch curation
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// CLI: `refresh --curate-batch --facts <path> --judgments <path> [--apply]`.
|
|
3
|
+
// Batch curation via `refresh --curate-batch --facts <p> --judgments <p> [--apply]`:
|
|
4
|
+
// facts + judgments through cve-enrich.assembleEntry into the catalog,
|
|
5
|
+
// zeroday-lessons.json and a per-CVE test. Nothing is written unless all assemble.
|
|
7
6
|
|
|
8
7
|
const fs = require('fs');
|
|
9
8
|
const path = require('path');
|
|
@@ -23,19 +22,15 @@ function _metaEnd(text) {
|
|
|
23
22
|
else if (c === '"') { i++; while (i < text.length && text[i] !== '"') { if (text[i] === '\\') i++; i++; } }
|
|
24
23
|
}
|
|
25
24
|
const comma = text.indexOf(',', i);
|
|
26
|
-
//
|
|
27
|
-
// so insert after the separator; otherwise
|
|
28
|
-
// supplies its own leading comma.
|
|
25
|
+
// A whitespace-only gap to the next comma means _meta has a following member,
|
|
26
|
+
// so insert after the separator; otherwise the insert supplies its own comma.
|
|
29
27
|
if (comma !== -1 && text.slice(i, comma).trim() === '') return { at: comma + 1, leadingComma: false };
|
|
30
28
|
return { at: i, leadingComma: true };
|
|
31
29
|
}
|
|
32
30
|
|
|
33
|
-
// Ids already present as top-level catalog members.
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
// still passes. Parsed rather than pattern-matched: a CVE id quoted inside
|
|
37
|
-
// another entry's prose is not a member, and building a RegExp from caller ids
|
|
38
|
-
// would be a ReDoS sink in the one function that exists to distrust them.
|
|
31
|
+
// Ids already present as top-level catalog members. Parsed rather than
|
|
32
|
+
// pattern-matched: a CVE id quoted inside another entry's prose is not a member,
|
|
33
|
+
// and a RegExp built from caller ids would be a ReDoS sink.
|
|
39
34
|
function existingIds(catalogText, ids) {
|
|
40
35
|
let parsed;
|
|
41
36
|
try {
|
|
@@ -67,9 +62,8 @@ function insertEntries(catalogText, entriesById) {
|
|
|
67
62
|
|
|
68
63
|
function recomputeAiMeta(catalogText, aiCount, total) {
|
|
69
64
|
// current_rate is ROUNDED; the floor is enforced against the RAW rate, so the
|
|
70
|
-
// floor
|
|
71
|
-
// floor
|
|
72
|
-
// the floor — raising it fails the corpus it describes.
|
|
65
|
+
// floor is the raw rate FLOORED (rounding both leaves 0.02262 facing a 0.023
|
|
66
|
+
// floor it cannot clear). Only ever lower the floor.
|
|
73
67
|
const rawRate = aiCount / total;
|
|
74
68
|
const rate = Math.round(rawRate * 1000) / 1000;
|
|
75
69
|
let out = catalogText.replace(/("current_rate"\s*:\s*)[0-9.]+/, `$1${rate}`);
|
|
@@ -175,8 +169,7 @@ async function curateBatch({ factsPath, judgmentsPath, apply, catalogRoot, today
|
|
|
175
169
|
const lesObj = addLessons(JSON.parse(fs.readFileSync(lesPath, 'utf8')), lessons);
|
|
176
170
|
fs.writeFileSync(lesPath, JSON.stringify(lesObj, null, 2) + '\n');
|
|
177
171
|
|
|
178
|
-
// The id
|
|
179
|
-
// repo's tests/cve-<year>-<num>.test.js convention.
|
|
172
|
+
// The id carries its "CVE-" prefix, so lowercasing gives tests/cve-<y>-<n>.test.js.
|
|
180
173
|
for (const id of Object.keys(entries))
|
|
181
174
|
fs.writeFileSync(path.join(catalogRoot, 'tests', `${id.toLowerCase()}.test.js`), renderTest(id));
|
|
182
175
|
|
|
@@ -191,9 +184,8 @@ async function cli(argv) {
|
|
|
191
184
|
else if (a === '--judgments') opts.judgments = argv[++i];
|
|
192
185
|
else if (a === '--apply') opts.apply = true;
|
|
193
186
|
}
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
// throwing out of the async function.
|
|
187
|
+
// Checked before curateBatch so a missing path returns the same {ok:false}
|
|
188
|
+
// envelope as every other error path, rather than throwing out of the async fn.
|
|
197
189
|
if (!opts.facts || !opts.judgments || !fs.existsSync(opts.facts) || !fs.existsSync(opts.judgments)) {
|
|
198
190
|
const missing = [];
|
|
199
191
|
if (!opts.facts) missing.push('--facts <path>');
|
package/lib/cve-cli.js
CHANGED
|
@@ -2,11 +2,9 @@
|
|
|
2
2
|
"use strict";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* a cited CVE is published / rejected / disputed / fabricated / nonexistent
|
|
9
|
-
* without it researching NVD by hand. Network is opt-out (--air-gap /
|
|
5
|
+
* `exceptd cve <CVE-ID>` — catalog, then resolved cache, then one cached NVD
|
|
6
|
+
* lookup. Reports published / rejected / disputed / fabricated / nonexistent so
|
|
7
|
+
* an agent need not research NVD by hand. Network is opt-out (--air-gap /
|
|
10
8
|
* --no-network / EXCEPTD_AIR_GAP=1).
|
|
11
9
|
*/
|
|
12
10
|
|
|
@@ -15,9 +13,8 @@ const { resolveCve } = require("./citation-resolve.js");
|
|
|
15
13
|
(async () => {
|
|
16
14
|
const argv = process.argv.slice(2);
|
|
17
15
|
const flags = new Set(argv.filter((a) => a.startsWith("--")));
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
// human text into a pipe that asked for JSON and defeat a CI gate.
|
|
16
|
+
// A swallowed `--josn` would emit human text into a pipe that asked for
|
|
17
|
+
// JSON, so an unknown flag is an error, matching the in-process verbs.
|
|
21
18
|
const KNOWN = new Set(["--json", "--pretty", "--air-gap", "--no-network", "--help", "-h"]);
|
|
22
19
|
const unknown = [...flags].filter((f) => !KNOWN.has(f));
|
|
23
20
|
if (unknown.length > 0) {
|
|
@@ -28,9 +25,8 @@ const { resolveCve } = require("./citation-resolve.js");
|
|
|
28
25
|
process.exitCode = 1;
|
|
29
26
|
return;
|
|
30
27
|
}
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
// "fabricated" lookup of the literal spaces.
|
|
28
|
+
// Trimmed so `cve " "` is a usage error like `cve ""`, not a "fabricated"
|
|
29
|
+
// lookup of the literal spaces.
|
|
34
30
|
const rawId = argv.find((a) => !a.startsWith("--"));
|
|
35
31
|
const id = rawId == null ? rawId : rawId.trim();
|
|
36
32
|
const pretty = flags.has("--pretty");
|
|
@@ -45,10 +41,9 @@ const { resolveCve } = require("./citation-resolve.js");
|
|
|
45
41
|
}
|
|
46
42
|
|
|
47
43
|
const r = await resolveCve(id, { airGap: flags.has("--air-gap"), noNetwork: flags.has("--no-network") });
|
|
48
|
-
// A citation that
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
// envelope previously hardcoded.
|
|
44
|
+
// A citation that will not stand up exits non-zero so a CI gate trips, and
|
|
45
|
+
// `ok` derives from the same statuses — a non-zero exit always carries
|
|
46
|
+
// ok:false.
|
|
52
47
|
const fails = r.status === "rejected" || r.status === "fabricated" || r.status === "nonexistent" || r.status === "withdrawn";
|
|
53
48
|
const body = { verb: "cve", ...r, ok: !fails };
|
|
54
49
|
|
|
@@ -70,11 +65,9 @@ const { resolveCve } = require("./citation-resolve.js");
|
|
|
70
65
|
process.exitCode = 2;
|
|
71
66
|
}
|
|
72
67
|
})().catch((err) => {
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
// raw stack trace, and signal failure via exitCode so the event loop drains
|
|
77
|
-
// stderr before exit.
|
|
68
|
+
// Any throw inside the async body arrives here as a rejected promise: emit
|
|
69
|
+
// the same single-line {ok:false,error} envelope the verb promises rather
|
|
70
|
+
// than a raw stack, and use exitCode so stderr drains before exit.
|
|
78
71
|
process.stderr.write(JSON.stringify({ ok: false, verb: "cve", error: String((err && err.message) || err) }) + "\n");
|
|
79
72
|
process.exitCode = 1;
|
|
80
73
|
});
|