@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.
@@ -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
- const skills = (xref[cveId] || xref.cves?.[cveId] || []).slice();
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
- const theater = entries(theaterFp).filter(([, t]) =>
157
- Array.isArray(t.cve_refs) && t.cve_refs.includes(cveId)
158
- ).map(([id, t]) => ({ id, distinguisher: t.distinguisher || t.test }));
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.cve_refs) && g.cve_refs.includes(cveId)
161
- ).map(([id, g]) => ({ id, framework: g.framework, control: g.control, status: g.status }));
162
- const lessons_learned = entries(lessons).filter(([, l]) =>
163
- Array.isArray(l.cve_refs) && l.cve_refs.includes(cveId)
164
- ).map(([id]) => id);
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.cwes?.[cweId] || []).slice();
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.ttps?.[ttpId] || []).slice();
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
- const cveRefs = Object.entries(xref.cves || {})
217
- .filter(([, skills]) => Array.isArray(skills) && skills.includes(skillName))
218
- .map(([cve]) => cve);
219
- const ttpRefs = Object.entries(xref.ttps || {})
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, scenario) {
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).filter(([, g]) => {
229
- if (g.framework !== frameworkId && g.framework !== 'ALL') return false;
230
- if (scenario && Array.isArray(g.scenarios) && !g.scenarios.includes(scenario)) return false;
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, scenario: scenario || null, framework_meta: fwMeta, gaps: matching, gap_count: matching.length };
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
- for (const [id, t] of entries(fp)) {
246
- const cveMatch = cveIds.some(c => (t.cve_refs || []).includes(c));
247
- const fwMatch = frameworkIds.some(f => (t.framework_refs || []).includes(f));
248
- const skillMatch = skillIds.some(s => (t.skill_refs || []).includes(s));
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, distinguisher: t.distinguisher || t.test, applies_when: t.applies_when });
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.cve_refs || []).some(c => cveSet.has(c));
268
- const ttpHit = (g.ttp_refs || []).some(t => ttpSet.has(t));
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.control, status: g.status, scenarios: g.scenarios });
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
  }
@@ -175,7 +175,12 @@ function readJson(p) {
175
175
  * accept malformed frontmatter.
176
176
  */
177
177
  function parseFrontmatter(text) {
178
- const lines = text.split(/\r?\n/);
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