@blamejs/exceptd-skills 0.16.28 → 0.16.30
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 +28 -0
- package/README.md +1 -1
- package/bin/exceptd.js +251 -18
- package/data/_indexes/_meta.json +4 -3
- package/data/_indexes/jurisdiction-map.json +31 -158
- package/data/playbooks/crypto.json +6 -0
- package/lib/auto-discovery.js +8 -0
- package/lib/collectors/README.md +3 -2
- package/lib/collectors/library-author.js +26 -9
- package/lib/collectors/secrets.js +8 -1
- package/lib/cross-ref-api.js +96 -31
- package/lib/lint-skills.js +6 -1
- package/lib/playbook-runner.js +264 -52
- package/lib/prefetch.js +78 -6
- package/lib/refresh-external.js +106 -5
- package/lib/scoring.js +49 -5
- package/lib/validate-cve-catalog.js +14 -2
- package/lib/validate-indexes.js +5 -0
- package/lib/validate-playbooks.js +133 -38
- package/manifest.json +53 -53
- package/orchestrator/pipeline.js +16 -4
- package/package.json +1 -1
- package/sbom.cdx.json +73 -58
- package/scripts/build-indexes.js +12 -1
- package/scripts/check-sbom-currency.js +76 -14
- package/scripts/refresh-sbom.js +1 -1
- package/scripts/run-e2e-scenarios.js +41 -11
- package/scripts/sync-package-description.js +74 -0
- package/scripts/verify-shipped-tarball.js +18 -7
- package/sources/validators/cve-validator.js +16 -6
package/lib/cross-ref-api.js
CHANGED
|
@@ -119,6 +119,45 @@ function entries(catalog) {
|
|
|
119
119
|
return Object.entries(catalog).filter(([k]) => !k.startsWith('_'));
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
// Single source of truth for the xref sub-maps the skill-correlation
|
|
123
|
+
// queries read. These names MUST stay identical to the keys the index
|
|
124
|
+
// builder emits into data/_indexes/xref.json; reading under a name the
|
|
125
|
+
// builder never writes silently yields empty correlations. The TTP maps
|
|
126
|
+
// are split by id space — ATLAS ids (AML.*) live in atlas_refs, ATT&CK
|
|
127
|
+
// ids (T*) in attack_refs — so a TTP lookup unions both.
|
|
128
|
+
const XREF_KEYS = {
|
|
129
|
+
cwe: 'cwe_refs',
|
|
130
|
+
atlas: 'atlas_refs',
|
|
131
|
+
attack: 'attack_refs',
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// CWE -> [skill, ...] from the xref index.
|
|
135
|
+
function skillsForCwe(xref, cweId) {
|
|
136
|
+
return (xref[XREF_KEYS.cwe] && xref[XREF_KEYS.cwe][cweId]) || [];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// TTP -> [skill, ...]; ATLAS and ATT&CK ids occupy separate maps, so a
|
|
140
|
+
// single id resolves through whichever map owns its prefix (with a fall
|
|
141
|
+
// back to the other in case a caller passes an unprefixed id).
|
|
142
|
+
function skillsForTtp(xref, ttpId) {
|
|
143
|
+
const atlas = xref[XREF_KEYS.atlas] || {};
|
|
144
|
+
const attack = xref[XREF_KEYS.attack] || {};
|
|
145
|
+
return (ttpId.startsWith('AML.') ? atlas[ttpId] : attack[ttpId]) || atlas[ttpId] || attack[ttpId] || [];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// No CVE->skill map exists in the index (no skill declares a CVE list, so
|
|
149
|
+
// the builder never emits one). The real linkage runs through the CVE's
|
|
150
|
+
// declared CWEs: each CWE maps to skills via the cwe_refs map. Union the
|
|
151
|
+
// skills across every CWE the CVE references, sorted + de-duplicated so
|
|
152
|
+
// the result is stable regardless of CWE ordering.
|
|
153
|
+
function skillsForCve(xref, cveEntry) {
|
|
154
|
+
const out = new Set();
|
|
155
|
+
for (const cwe of (cveEntry && cveEntry.cwe_refs) || []) {
|
|
156
|
+
for (const skill of skillsForCwe(xref, cwe)) out.add(skill);
|
|
157
|
+
}
|
|
158
|
+
return [...out].sort();
|
|
159
|
+
}
|
|
160
|
+
|
|
122
161
|
// --- public API ---
|
|
123
162
|
|
|
124
163
|
/**
|
|
@@ -149,19 +188,28 @@ function byCve(cveId, opts) {
|
|
|
149
188
|
const gaps = loadCatalog('framework-control-gaps.json');
|
|
150
189
|
const lessons = loadCatalog('zeroday-lessons.json');
|
|
151
190
|
|
|
152
|
-
|
|
191
|
+
// Skills correlate to a CVE transitively through its declared CWEs
|
|
192
|
+
// (CVE -> cwe_refs -> xref.cwe_refs -> skills); there is no direct
|
|
193
|
+
// CVE->skill index.
|
|
194
|
+
const skills = skillsForCve(xref, entry);
|
|
153
195
|
// (Recipes are use-case curated, not CVE-triggered — recipes.json has no
|
|
154
196
|
// `triggered_by`/CVE keying, so a per-CVE recipe lookup was always empty.
|
|
155
197
|
// The dead `recipes:[]` field is no longer emitted.)
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
198
|
+
//
|
|
199
|
+
// Theater fingerprints live under the index's `patterns` container; each
|
|
200
|
+
// pattern records a single `evidence.cve` (or `evidence.campaign`, which
|
|
201
|
+
// carries no CVE to match). The distinguishing check is `fast_test`.
|
|
202
|
+
const theater = Object.entries(theaterFp.patterns || {})
|
|
203
|
+
.filter(([, t]) => t && t.evidence && t.evidence.cve === cveId)
|
|
204
|
+
.map(([id, t]) => ({ id, pattern_name: t.pattern_name, distinguisher: t.fast_test }));
|
|
205
|
+
// Framework-control-gaps link CVEs through `evidence_cves`; the control
|
|
206
|
+
// identifier field is `control_id`.
|
|
159
207
|
const framework_gaps = entries(gaps).filter(([, g]) =>
|
|
160
|
-
Array.isArray(g.
|
|
161
|
-
).map(([id, g]) => ({ id, framework: g.framework, control: g.
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
208
|
+
Array.isArray(g.evidence_cves) && g.evidence_cves.includes(cveId)
|
|
209
|
+
).map(([id, g]) => ({ id, framework: g.framework, control: g.control_id, status: g.status }));
|
|
210
|
+
// Zero-day lessons are keyed by CVE id, so a referenced lesson is a
|
|
211
|
+
// direct key hit rather than a back-reference scan.
|
|
212
|
+
const lessons_learned = lessons[cveId] ? [cveId] : [];
|
|
165
213
|
|
|
166
214
|
return {
|
|
167
215
|
found: true,
|
|
@@ -185,7 +233,7 @@ function byCwe(cweId) {
|
|
|
185
233
|
const entry = catalog[cweId];
|
|
186
234
|
if (!entry) return { found: false, cwe_id: cweId };
|
|
187
235
|
const xref = loadIndex('xref.json');
|
|
188
|
-
const skills = (xref
|
|
236
|
+
const skills = skillsForCwe(xref, cweId).slice();
|
|
189
237
|
const relatedCves = entries(loadCatalog('cve-catalog.json'))
|
|
190
238
|
.filter(([, c]) => Array.isArray(c.cwe_refs) && c.cwe_refs.includes(cweId))
|
|
191
239
|
.map(([id]) => id);
|
|
@@ -196,7 +244,7 @@ function byTtp(ttpId) {
|
|
|
196
244
|
const atlas = loadCatalog('atlas-ttps.json');
|
|
197
245
|
const xref = loadIndex('xref.json');
|
|
198
246
|
const entry = atlas[ttpId] || null;
|
|
199
|
-
const skills = (xref
|
|
247
|
+
const skills = skillsForTtp(xref, ttpId).slice();
|
|
200
248
|
const relatedCves = entries(loadCatalog('cve-catalog.json'))
|
|
201
249
|
.filter(([, c]) =>
|
|
202
250
|
(Array.isArray(c.atlas_refs) && c.atlas_refs.includes(ttpId)) ||
|
|
@@ -213,25 +261,34 @@ function bySkill(skillName) {
|
|
|
213
261
|
const xref = loadIndex('xref.json');
|
|
214
262
|
const summary = loadIndex('summary-cards.json');
|
|
215
263
|
const card = summary[skillName] || summary.skills?.[skillName] || null;
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
264
|
+
// TTPs invert the atlas_refs + attack_refs maps: any TTP whose skill
|
|
265
|
+
// list contains this skill is a reference. Both id spaces contribute.
|
|
266
|
+
const ttpRefs = Object.entries({
|
|
267
|
+
...(xref[XREF_KEYS.atlas] || {}),
|
|
268
|
+
...(xref[XREF_KEYS.attack] || {}),
|
|
269
|
+
})
|
|
220
270
|
.filter(([, skills]) => Array.isArray(skills) && skills.includes(skillName))
|
|
221
|
-
.map(([ttp]) => ttp)
|
|
271
|
+
.map(([ttp]) => ttp)
|
|
272
|
+
.sort();
|
|
273
|
+
// CVEs link to a skill transitively: a CVE references CWEs, and each CWE
|
|
274
|
+
// maps to skills via cwe_refs. Collect every CVE whose CWE set resolves
|
|
275
|
+
// to this skill.
|
|
276
|
+
const cveCatalog = loadCatalog('cve-catalog.json');
|
|
277
|
+
const cveRefs = entries(cveCatalog)
|
|
278
|
+
.filter(([, c]) => (c.cwe_refs || []).some(cwe => skillsForCwe(xref, cwe).includes(skillName)))
|
|
279
|
+
.map(([cve]) => cve)
|
|
280
|
+
.sort();
|
|
222
281
|
return { skill: skillName, summary_card: card, cve_refs: cveRefs, ttp_refs: ttpRefs };
|
|
223
282
|
}
|
|
224
283
|
|
|
225
|
-
function byFramework(frameworkId
|
|
284
|
+
function byFramework(frameworkId) {
|
|
226
285
|
const gaps = loadCatalog('framework-control-gaps.json');
|
|
227
286
|
const global = loadCatalog('global-frameworks.json');
|
|
228
|
-
const matching = entries(gaps)
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
return true;
|
|
232
|
-
}).map(([id, g]) => ({ id, ...g }));
|
|
287
|
+
const matching = entries(gaps)
|
|
288
|
+
.filter(([, g]) => g.framework === frameworkId || g.framework === 'ALL')
|
|
289
|
+
.map(([id, g]) => ({ id, ...g }));
|
|
233
290
|
const fwMeta = global[frameworkId] || null;
|
|
234
|
-
return { framework: frameworkId,
|
|
291
|
+
return { framework: frameworkId, framework_meta: fwMeta, gaps: matching, gap_count: matching.length };
|
|
235
292
|
}
|
|
236
293
|
|
|
237
294
|
/**
|
|
@@ -242,12 +299,20 @@ function byFramework(frameworkId, scenario) {
|
|
|
242
299
|
function theaterTestsFor({ cveIds = [], frameworkIds = [], skillIds = [] }) {
|
|
243
300
|
const fp = loadIndex('theater-fingerprints.json');
|
|
244
301
|
const matches = [];
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
302
|
+
// Fingerprints are nested under the index's `patterns` container, not at
|
|
303
|
+
// the top level. Each pattern records a single `evidence.cve`, a list of
|
|
304
|
+
// `controls` (each {framework, control_id}), and a `source_skill`. A
|
|
305
|
+
// framework match accepts either the bare control id ("SI-2") or the
|
|
306
|
+
// qualified "framework::control_id" form the by_control index keys on.
|
|
307
|
+
for (const [id, t] of Object.entries(fp.patterns || {})) {
|
|
308
|
+
if (!t) continue;
|
|
309
|
+
const cveMatch = t.evidence && cveIds.includes(t.evidence.cve);
|
|
310
|
+
const fwMatch = (t.controls || []).some(c =>
|
|
311
|
+
frameworkIds.includes(c.control_id) || frameworkIds.includes(`${c.framework}::${c.control_id}`)
|
|
312
|
+
);
|
|
313
|
+
const skillMatch = skillIds.includes(t.source_skill);
|
|
249
314
|
if (cveMatch || fwMatch || skillMatch) {
|
|
250
|
-
matches.push({ id,
|
|
315
|
+
matches.push({ id, pattern_name: t.pattern_name, distinguisher: t.fast_test, controls: t.controls });
|
|
251
316
|
}
|
|
252
317
|
}
|
|
253
318
|
return matches;
|
|
@@ -264,12 +329,12 @@ function globalFrameworkContext({ cveIds = [], ttpIds = [] }) {
|
|
|
264
329
|
const ttpSet = new Set(ttpIds);
|
|
265
330
|
const grouped = {};
|
|
266
331
|
for (const [id, g] of entries(gaps)) {
|
|
267
|
-
const cveHit = (g.
|
|
268
|
-
const ttpHit = (g.
|
|
332
|
+
const cveHit = (g.evidence_cves || []).some(c => cveSet.has(c));
|
|
333
|
+
const ttpHit = [...(g.atlas_refs || []), ...(g.attack_refs || [])].some(t => ttpSet.has(t));
|
|
269
334
|
if (!cveHit && !ttpHit) continue;
|
|
270
335
|
const fw = g.framework || 'unspecified';
|
|
271
336
|
grouped[fw] = grouped[fw] || [];
|
|
272
|
-
grouped[fw].push({ id, control: g.
|
|
337
|
+
grouped[fw].push({ id, control: g.control_id, control_name: g.control_name, status: g.status });
|
|
273
338
|
}
|
|
274
339
|
return grouped;
|
|
275
340
|
}
|
package/lib/lint-skills.js
CHANGED
|
@@ -175,7 +175,12 @@ function readJson(p) {
|
|
|
175
175
|
* accept malformed frontmatter.
|
|
176
176
|
*/
|
|
177
177
|
function parseFrontmatter(text) {
|
|
178
|
-
|
|
178
|
+
// Strip a trailing CR per line: split(/\r?\n/) consumes interior CRLFs, but a
|
|
179
|
+
// dangling `\r` survives on the final frontmatter line (the close marker
|
|
180
|
+
// consumed the `\n`, not the `\r`). `.` does not match `\r`, so that line's
|
|
181
|
+
// value would fail the per-line regex with a misleading "Could not parse
|
|
182
|
+
// frontmatter line N" on an otherwise valid CRLF skill.md.
|
|
183
|
+
const lines = text.split(/\r?\n/).map((l) => l.replace(/\r$/, ''));
|
|
179
184
|
const result = {};
|
|
180
185
|
// Track every top-level key we've already assigned. YAML's last-wins
|
|
181
186
|
// semantics would let a tampered skill set name twice
|