@blamejs/exceptd-skills 0.18.9 → 0.18.12

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.
Files changed (51) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/bin/exceptd.js +204 -118
  3. package/data/_indexes/_meta.json +3 -3
  4. package/data/_indexes/frequency.json +2 -2
  5. package/data/d3fend-catalog.json +6 -6
  6. package/data/playbooks/identity-sso-compromise.json +2 -2
  7. package/data/playbooks/sbom.json +1 -1
  8. package/lib/citation-resolve.js +11 -0
  9. package/lib/collectors/containers.js +13 -0
  10. package/lib/collectors/cred-stores.js +18 -9
  11. package/lib/collectors/secrets.js +4 -2
  12. package/lib/cross-ref-api.js +29 -7
  13. package/lib/cve-regression-watcher.js +47 -15
  14. package/lib/framework-gap.js +52 -19
  15. package/lib/gap-detectors.js +8 -3
  16. package/lib/lint-skills.js +3 -2
  17. package/lib/playbook-runner.js +125 -7
  18. package/lib/refresh-external.js +58 -7
  19. package/lib/refresh-network.js +18 -5
  20. package/lib/rfc-cli.js +113 -18
  21. package/lib/schemas/playbook.schema.json +1 -1
  22. package/lib/scoring.js +71 -8
  23. package/lib/source-advisories.js +58 -9
  24. package/lib/ttp-mapper.js +31 -3
  25. package/lib/upstream-check-cli.js +13 -1
  26. package/lib/validate-catalog-meta.js +51 -7
  27. package/lib/validate-cve-catalog.js +10 -0
  28. package/lib/validate-playbooks.js +19 -1
  29. package/lib/verify.js +35 -34
  30. package/lib/xml-tokenizer.js +187 -25
  31. package/manifest.json +53 -53
  32. package/orchestrator/dispatcher.js +53 -9
  33. package/orchestrator/index.js +9 -7
  34. package/orchestrator/pipeline.js +62 -14
  35. package/orchestrator/scanner.js +60 -9
  36. package/package.json +1 -1
  37. package/sbom.cdx.json +115 -100
  38. package/scripts/build-indexes.js +21 -3
  39. package/scripts/builders/cwe-chains.js +5 -2
  40. package/scripts/builders/section-offsets.js +17 -8
  41. package/scripts/builders/summary-cards.js +12 -4
  42. package/scripts/check-catalog-gap-budget.js +3 -3
  43. package/scripts/check-codebase-patterns-currency.js +1 -0
  44. package/scripts/check-codebase-patterns.js +166 -11
  45. package/scripts/check-sbom-currency.js +69 -3
  46. package/scripts/check-test-count.js +28 -16
  47. package/scripts/check-test-subjects.js +148 -0
  48. package/scripts/check-version-tags.js +24 -5
  49. package/scripts/predeploy.js +32 -8
  50. package/scripts/refresh-upstream-catalogs.js +169 -44
  51. package/scripts/release.js +28 -11
@@ -57,7 +57,7 @@ function fileExists(full) {
57
57
  // AWS credentials INI: any [profile] block carrying
58
58
  // `aws_access_key_id` AND no `sso_session` / `credential_process`.
59
59
  function parseAwsCredentials(content) {
60
- if (!content) return { staticProfiles: [], federatedProfiles: [] };
60
+ if (!content) return { staticProfiles: [], federatedProfiles: [], staticKeys: {} };
61
61
  const lines = content.split(/\r?\n/);
62
62
  const profiles = {};
63
63
  let current = null;
@@ -77,13 +77,21 @@ function parseAwsCredentials(content) {
77
77
  }
78
78
  const staticProfiles = [];
79
79
  const federatedProfiles = [];
80
+ // Per-static-profile aws_access_key_id, so doc-fixture demotion can key off
81
+ // the exact parsed value instead of re-finding the first name-matching block.
82
+ // A duplicate profile name resolves to the LAST occurrence's keys here, which
83
+ // is the same precedence the AWS SDK applies.
84
+ const staticKeys = {};
80
85
  for (const [name, kv] of Object.entries(profiles)) {
81
86
  const hasKey = !!kv["aws_access_key_id"];
82
87
  const hasFederation = !!(kv["sso_session"] || kv["credential_process"] || kv["role_arn"]);
83
- if (hasKey && !hasFederation) staticProfiles.push(name);
88
+ if (hasKey && !hasFederation) {
89
+ staticProfiles.push(name);
90
+ staticKeys[name] = kv["aws_access_key_id"];
91
+ }
84
92
  if (hasFederation) federatedProfiles.push(name);
85
93
  }
86
- return { staticProfiles, federatedProfiles };
94
+ return { staticProfiles, federatedProfiles, staticKeys };
87
95
  }
88
96
 
89
97
  // kubeconfig: users[].user.token field present (non-empty) with no
@@ -247,12 +255,13 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
247
255
  // unsatisfied, which is the honest outcome.
248
256
  const AWS_DOC_FIXTURE_KEY = "AKIAIOSFODNN7EXAMPLE";
249
257
  const realAwsProfiles = awsCredsParsed.staticProfiles.filter(p => {
250
- // Parse the raw INI again for this profile's key value + name.
251
- // For doc-fixture demotion (FP[0]) we look up the key value; for
252
- // break-glass demotion (FP[2]) we check the profile name pattern.
253
- const block = (awsCredsContent || "").split(/^\[/m).find(b => b.startsWith(p + "]"));
254
- if (!block) return true;
255
- if (block.includes(AWS_DOC_FIXTURE_KEY)) return false; // FP[0]
258
+ // Demote off this profile's EXACT parsed key value (FP[0]) and its name
259
+ // (FP[2]). Keying off the parsed value — not the first raw block whose
260
+ // name matches — means a duplicate profile name whose first occurrence
261
+ // holds the doc-fixture key cannot demote the later real key under the
262
+ // same name (the parser resolves the live last-occurrence value).
263
+ const keyVal = awsCredsParsed.staticKeys[p];
264
+ if (keyVal === AWS_DOC_FIXTURE_KEY) return false; // FP[0]
256
265
  if (/^breakglass-/i.test(p) || /^break-glass-/i.test(p)) return false; // FP[2]
257
266
  return true;
258
267
  });
@@ -495,8 +495,10 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
495
495
  // any private-key file with mode != 0600
496
496
  // The collector scope is the cwd; ~/.ssh enumeration is outside this
497
497
  // walk root. Within cwd, flag any discovered private key whose mode
498
- // is anything other than 0600 (strict).
499
- const sshKeyPostures = process.platform === "win32" ? [] : sshPrivateKeys.map(f => ({ file: f.rel, ...statPosture(f.full) }));
498
+ // is anything other than 0600 (strict). Use the test-path-filtered set
499
+ // (prodSshPrivateKeys) — matching ssh-private-key-block — so a fixture
500
+ // key checked in under a test/ path doesn't raise a bad-perms posture.
501
+ const sshKeyPostures = process.platform === "win32" ? [] : prodSshPrivateKeys.map(f => ({ file: f.rel, ...statPosture(f.full) }));
500
502
  signal_overrides["ssh-key-bad-perms"] = sshKeyPostures.some(p => p.error == null && p.mode !== 0o600) ? "hit" : "miss";
501
503
 
502
504
  // Per-indicator file locations for every indicator flipped to "hit", so
@@ -119,6 +119,16 @@ function entries(catalog) {
119
119
  return Object.entries(catalog).filter(([k]) => !k.startsWith('_'));
120
120
  }
121
121
 
122
+ // Auto-imported drafts carry conservative-default mechanical fields and
123
+ // null analytical fields pending curation. byCve() excludes them by
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.
128
+ function _isDraftEntry(c) {
129
+ return !!c && c._auto_imported === true;
130
+ }
131
+
122
132
  // Single source of truth for the xref sub-maps the skill-correlation
123
133
  // queries read. These names MUST stay identical to the keys the index
124
134
  // builder emits into data/_indexes/xref.json; reading under a name the
@@ -179,7 +189,7 @@ function byCve(cveId, opts) {
179
189
  const catalog = loadCatalog('cve-catalog.json');
180
190
  const entry = catalog[cveId];
181
191
  if (!entry) return { found: false, cve_id: cveId };
182
- if (!includeDrafts && entry._auto_imported === true) {
192
+ if (!includeDrafts && _isDraftEntry(entry)) {
183
193
  return { found: false, cve_id: cveId, _draft_excluded: true };
184
194
  }
185
195
 
@@ -235,24 +245,36 @@ function byCwe(cweId) {
235
245
  const xref = loadIndex('xref.json');
236
246
  const skills = skillsForCwe(xref, cweId).slice();
237
247
  const relatedCves = entries(loadCatalog('cve-catalog.json'))
238
- .filter(([, c]) => Array.isArray(c.cwe_refs) && c.cwe_refs.includes(cweId))
248
+ .filter(([, c]) => !_isDraftEntry(c) && Array.isArray(c.cwe_refs) && c.cwe_refs.includes(cweId))
239
249
  .map(([id]) => id);
240
250
  return { found: true, cwe_id: cweId, entry, skills, related_cves: relatedCves };
241
251
  }
242
252
 
243
253
  function byTtp(ttpId) {
254
+ // TTP ids span two disjoint catalogs (ATLAS AML.* vs ATT&CK T*).
255
+ // Resolve the record from whichever owns the id — namespaces never
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.
244
259
  const atlas = loadCatalog('atlas-ttps.json');
260
+ const attack = loadCatalog('attack-techniques.json');
245
261
  const xref = loadIndex('xref.json');
246
- const entry = atlas[ttpId] || null;
262
+ const entry = atlas[ttpId] || attack[ttpId] || null;
247
263
  const skills = skillsForTtp(xref, ttpId).slice();
248
264
  const relatedCves = entries(loadCatalog('cve-catalog.json'))
249
265
  .filter(([, c]) =>
250
- (Array.isArray(c.atlas_refs) && c.atlas_refs.includes(ttpId)) ||
251
- (Array.isArray(c.attack_refs) && c.attack_refs.includes(ttpId))
266
+ !_isDraftEntry(c) && (
267
+ (Array.isArray(c.atlas_refs) && c.atlas_refs.includes(ttpId)) ||
268
+ (Array.isArray(c.attack_refs) && c.attack_refs.includes(ttpId))
269
+ )
252
270
  )
253
271
  .map(([id]) => id);
272
+ // D3FEND maps countermeasures to the techniques they defeat through the
273
+ // `counters_attack_techniques` field. The earlier `counters` field is
274
+ // empty across every catalog entry, so this correlation was structurally
275
+ // dead (a non-existent field .includes() is always false).
254
276
  const d3fend = entries(loadCatalog('d3fend-catalog.json'))
255
- .filter(([, d]) => Array.isArray(d.counters) && d.counters.includes(ttpId))
277
+ .filter(([, d]) => Array.isArray(d.counters_attack_techniques) && d.counters_attack_techniques.includes(ttpId))
256
278
  .map(([id]) => id);
257
279
  return { found: !!entry, ttp_id: ttpId, entry, skills, related_cves: relatedCves, d3fend_countermeasures: d3fend };
258
280
  }
@@ -275,7 +297,7 @@ function bySkill(skillName) {
275
297
  // to this skill.
276
298
  const cveCatalog = loadCatalog('cve-catalog.json');
277
299
  const cveRefs = entries(cveCatalog)
278
- .filter(([, c]) => (c.cwe_refs || []).some(cwe => skillsForCwe(xref, cwe).includes(skillName)))
300
+ .filter(([, c]) => !_isDraftEntry(c) && (c.cwe_refs || []).some(cwe => skillsForCwe(xref, cwe).includes(skillName)))
279
301
  .map(([cve]) => cve)
280
302
  .sort();
281
303
  return { skill: skillName, summary_card: card, cve_refs: cveRefs, ttp_refs: ttpRefs };
@@ -187,8 +187,12 @@ function findRegressionCandidates(diffs, catalog, opts) {
187
187
  // Group historical-CVE refs by id so multi-feed surfacing collapses.
188
188
  const byHistoricalId = new Map();
189
189
  // Content-only candidates — surfaced by language/component pattern
190
- // matching even when no CVE ID was extracted from the diff text.
191
- const contentCandidates = [];
190
+ // matching even when no CVE ID was extracted from the diff text. Grouped
191
+ // by a stable key (the diff id when present, else a composite of the
192
+ // matched signal) so N duplicate rows for the same regression claim
193
+ // across feeds collapse to one candidate with a merged surfaced_by — the
194
+ // same group-by-merge-sources pattern the historical-CVE branch uses.
195
+ const byContentKey = new Map();
192
196
  for (const d of (diffs || [])) {
193
197
  if (!d || typeof d.id !== 'string') continue;
194
198
  // Title field name depends on input shape:
@@ -231,16 +235,25 @@ function findRegressionCandidates(diffs, catalog, opts) {
231
235
  }
232
236
  // No historical CVE-ID in this diff. If content signals fire, still
233
237
  // surface as a content-only candidate so an operator can triage.
238
+ // Group by a stable key so duplicate rows for the same claim across
239
+ // multiple feeds merge their surfaced_by — mirrors byHistoricalId.
240
+ // The diff id (a current-year / non-historical CVE id) is the cleanest
241
+ // key when present; ID-less prose rows key off the matched signal
242
+ // (regression phrase + researcher + sorted component tokens).
234
243
  if (hasRegressionSignal) {
244
+ const key = (typeof d.id === 'string' && d.id)
245
+ ? `id:${d.id}`
246
+ : `sig:${signals.regression_language || ''}|${signals.researcher || ''}|${(signals.components || []).slice().sort().join(',')}`;
247
+ if (!byContentKey.has(key)) byContentKey.set(key, { sources: new Set(), titles: [], signals: {} });
248
+ const slot = byContentKey.get(key);
249
+ if (Array.isArray(d.sources)) {
250
+ for (const s of d.sources) slot.sources.add(s);
251
+ } else if (typeof d.source === 'string') {
252
+ slot.sources.add(d.source);
253
+ }
235
254
  const titleStr = d.title || d.first_title || '';
236
- contentCandidates.push({
237
- historical_cve: null,
238
- surfaced_by: Array.isArray(d.sources) ? d.sources.slice().sort() : (d.source ? [d.source] : []),
239
- first_seen_titles: titleStr ? [titleStr] : [],
240
- existing_regression_key: null,
241
- action: 'content-only-investigate',
242
- signals,
243
- });
255
+ if (titleStr && !slot.titles.includes(titleStr)) slot.titles.push(titleStr);
256
+ Object.assign(slot.signals, signals);
244
257
  }
245
258
  }
246
259
 
@@ -267,6 +280,20 @@ function findRegressionCandidates(diffs, catalog, opts) {
267
280
  }
268
281
 
269
282
  candidates.sort((a, b) => a.historical_cve.localeCompare(b.historical_cve));
283
+ // Emit one content-only candidate per grouped key, with surfaced_by as
284
+ // the sorted union of every feed that surfaced the same regression claim
285
+ // and the title list capped at 5 (matching the historical branch).
286
+ const contentCandidates = [];
287
+ for (const slot of byContentKey.values()) {
288
+ contentCandidates.push({
289
+ historical_cve: null,
290
+ surfaced_by: Array.from(slot.sources).sort(),
291
+ first_seen_titles: slot.titles.slice(0, 5),
292
+ existing_regression_key: null,
293
+ action: 'content-only-investigate',
294
+ signals: slot.signals,
295
+ });
296
+ }
270
297
  candidates.push(...contentCandidates);
271
298
 
272
299
  return {
@@ -291,11 +318,16 @@ function findRegressionCandidates(diffs, catalog, opts) {
291
318
  * falls back to ctx.advisoriesDiffs when the advisories source ran on a
292
319
  * pre-v0.13.17 build that did not emit observations.
293
320
  *
294
- * Chaining is explicit — the watcher does not poll feeds itself.
295
- * lib/refresh-external.js#loadCtx wires the prior advisories run output
296
- * onto ctx.advisoriesObservations + ctx.advisoriesDiffs so order matters:
297
- * advisories must run before cve-regression-watcher in a multi-source
298
- * invocation.
321
+ * Chaining is explicit — the watcher does not poll feeds itself. The
322
+ * refresh orchestrator's sequential runner (lib/refresh-external.js#main)
323
+ * threads the resolved advisories fetchDiff() result onto
324
+ * ctx.advisoriesObservations + ctx.advisoriesDiffs immediately after the
325
+ * advisories source resolves and BEFORE the next source's fetchDiff is
326
+ * invoked, so order matters: advisories must run before
327
+ * cve-regression-watcher in a multi-source invocation. Under --swarm
328
+ * (Promise.all) the two sources cannot share ctx mid-flight, so the
329
+ * orchestrator runs the watcher in a second pass after the parallel batch
330
+ * resolves, reading observations from the resolved advisories outcome.
299
331
  */
300
332
  const REGRESSION_WATCHER_SOURCE = {
301
333
  name: 'cve-regression-watcher',
@@ -93,16 +93,38 @@ const OPTIMAL = {
93
93
  * @returns {{ score: number, breakdown: object, label: string }}
94
94
  */
95
95
  function lagScore(frameworkId, controlGaps, globalFrameworks) {
96
- const gaps = Object.values(controlGaps).filter(g =>
97
- g.framework?.includes(frameworkId) && g.status === 'open'
98
- );
96
+ const frameworkData = _findFrameworkData(frameworkId, globalFrameworks);
97
+
98
+ // global-frameworks uses short KEYS (EU_AI_ACT, NCSC_CAF) while
99
+ // framework-control-gaps stores human-readable framework strings ("EU
100
+ // Artificial Intelligence Act (2024/1689)"). A naive
101
+ // `g.framework.includes(frameworkId)` only accidentally matched the few
102
+ // frameworks whose key happens to be a substring of the catalog string
103
+ // (DORA, GDPR, NIS2); every other framework reported
104
+ // framework_specific_gaps:0. Resolve the framework's display name first,
105
+ // then match the catalog with the SAME normalized scheme gapReport() and
106
+ // the orchestrator use, so the two paths converge. g.framework may be a
107
+ // string or an array — iterate either form.
108
+ const normalize = (s) => String(s).toLowerCase().replace(/[\s_-]/g, '');
109
+ const idNorm = normalize(frameworkId);
110
+ const nameNorm = frameworkData?.full_name ? normalize(frameworkData.full_name) : null;
111
+ const gaps = Object.entries(controlGaps).filter(([key, g]) => {
112
+ if (key.startsWith('_')) return false;
113
+ if (g.status !== 'open' || !g.framework) return false;
114
+ const fwList = Array.isArray(g.framework) ? g.framework : [g.framework];
115
+ for (const fw of fwList) {
116
+ const fwNorm = normalize(fw);
117
+ if (nameNorm && fwNorm.includes(nameNorm)) return true; // display-name match
118
+ if (fwNorm.includes(idNorm)) return true; // short-key substring
119
+ }
120
+ if (normalize(key).startsWith(idNorm)) return true; // gap-key prefix
121
+ return false;
122
+ });
99
123
 
100
124
  const universalGaps = Object.values(controlGaps).filter(g =>
101
125
  g.framework === 'ALL' && g.status === 'open'
102
126
  );
103
127
 
104
- const frameworkData = _findFrameworkData(frameworkId, globalFrameworks);
105
-
106
128
  const patchSlaScore = _scorePatchSla(frameworkData?.patch_sla);
107
129
  const notifSlaScore = _scoreNotifSla(frameworkData?.notification_sla);
108
130
  const aiCoverageScore = _scoreAiCoverage(frameworkData?.ai_coverage);
@@ -189,6 +211,24 @@ function gapReport(frameworkIds, threatScenario, controlGaps, cveCatalog = {}, o
189
211
  };
190
212
  }
191
213
 
214
+ // Scope the report to what the operator actually requested. With an explicit
215
+ // framework filter, only gaps that survived the per-framework filter
216
+ // (frameworkResults[*].gaps) belong in the report; with `all`, every
217
+ // scenario-relevant gap does. `seen` is the de-duplicated set of surviving
218
+ // gap keys (a single gap can match multiple requested frameworks). Both
219
+ // theater_risks and the matching count derive from it so the per-framework
220
+ // body, the theater-risk list, and the summary footer all agree.
221
+ let scopedGaps;
222
+ if (opts.allFrameworks) {
223
+ scopedGaps = relevantGaps;
224
+ } else {
225
+ const seen = new Set();
226
+ for (const id of frameworkIds) {
227
+ for (const g of frameworkResults[id]?.gaps ?? []) seen.add(g.id);
228
+ }
229
+ scopedGaps = relevantGaps.filter(([key]) => seen.has(key));
230
+ }
231
+
192
232
  // Cycle 20 A P1 (v0.12.40): pre-fix this filtered on `theater_pattern`
193
233
  // (a legacy field) but the v0.12.29 backfill added a structured
194
234
  // `theater_test` block on all 118 entries while leaving most without
@@ -198,7 +238,11 @@ function gapReport(frameworkIds, threatScenario, controlGaps, cveCatalog = {}, o
198
238
  // the legacy field. Now: an entry is theater-risk if it's open AND
199
239
  // carries EITHER `theater_test` OR `theater_pattern`. Footer + badge
200
240
  // count agree.
201
- const theaterRisks = relevantGaps
241
+ //
242
+ // theater_risks is built from scopedGaps (not the full relevantGaps) so a
243
+ // single-framework request cannot leak or mis-summarize theater controls
244
+ // from frameworks the operator never asked about.
245
+ const theaterRisks = scopedGaps
202
246
  .filter(([, g]) => g.status === 'open' && (g.theater_test || g.theater_pattern))
203
247
  .map(([key, g]) => ({
204
248
  control: key,
@@ -212,19 +256,8 @@ function gapReport(frameworkIds, threatScenario, controlGaps, cveCatalog = {}, o
212
256
  // explicit framework filter the summary must agree with the per-framework
213
257
  // body the operator actually sees — otherwise `framework-gap nist-800-53
214
258
  // <cve>` shows e.g. "2 matching control gap(s)" per-framework but "Summary:
215
- // 8 matching gaps" (every framework's hits, pre-filter). Sum the per-
216
- // framework gap_count so body + summary agree. De-duplicate by gap key in
217
- // case a single gap matches multiple requested frameworks.
218
- let matchingGapCount;
219
- if (opts.allFrameworks) {
220
- matchingGapCount = relevantGaps.length;
221
- } else {
222
- const seen = new Set();
223
- for (const id of frameworkIds) {
224
- for (const g of frameworkResults[id]?.gaps ?? []) seen.add(g.id);
225
- }
226
- matchingGapCount = seen.size;
227
- }
259
+ // 8 matching gaps" (every framework's hits, pre-filter).
260
+ const matchingGapCount = scopedGaps.length;
228
261
 
229
262
  return {
230
263
  threat_scenario: threatScenario,
@@ -409,11 +409,16 @@ function operatorActionSlaFindings(loaded, opts = {}) {
409
409
  // sets internally when the caller doesn't supply them.
410
410
  //
411
411
  // The regex is permissive — any CWE-NNN / T1234[.456] / AML.TNNNN /
412
- // D3-XX / RFC-NNN token in a skill body or playbook JSON counts as a
412
+ // D3[AF]-XX / RFC-NNN token in a skill body or playbook JSON counts as a
413
413
  // reference. We deliberately scan the FULL text, not just structured
414
414
  // fields, because skill bodies cite IDs in prose ("see CWE-79") as
415
- // often as in frontmatter.
416
- const REFERENCE_TOKEN_RE = /\b(?:CWE-\d+|T\d{4}(?:\.\d{3})?|AML\.T\d{4}(?:\.\d{3})?|D3-[A-Z]+(?:-[A-Z]+)*|RFC-\d+)\b/g;
415
+ // often as in frontmatter. The D3FEND alternative covers all three
416
+ // namespaces — D3- techniques, D3A- digital artifacts, and D3F-
417
+ // fingerprints — with alphanumeric segments; the prior `D3-[A-Z]+`
418
+ // pattern matched neither the D3A-/D3F- prefixes nor digit-bearing
419
+ // segments, so a D3A-* citation in a skill body went unrecognized and
420
+ // the entry it referenced was mis-flagged as an unused orphan.
421
+ const REFERENCE_TOKEN_RE = /\b(?:CWE-\d+|T\d{4}(?:\.\d{3})?|AML\.T\d{4}(?:\.\d{3})?|D3[AF]?-[A-Z0-9]+(?:-[A-Z0-9]+)*|RFC-\d+)\b/g;
417
422
 
418
423
  function buildExternalRefs(rootPath) {
419
424
  // Lazy require — `path` + `fs` are already in scope at module level.
@@ -433,9 +433,10 @@ function validateFrontmatter(fm, skillName) {
433
433
  }
434
434
 
435
435
  if ('last_threat_review' in fm) {
436
- if (typeof fm.last_threat_review !== 'string' || !ISO_DATE_RE.test(fm.last_threat_review)) {
436
+ if (typeof fm.last_threat_review !== 'string' || !ISO_DATE_RE.test(fm.last_threat_review) ||
437
+ !Number.isFinite(Date.parse(fm.last_threat_review + 'T00:00:00Z'))) {
437
438
  errors.push(
438
- `frontmatter.last_threat_review "${fm.last_threat_review}" is not an ISO date (YYYY-MM-DD)`,
439
+ `frontmatter.last_threat_review "${fm.last_threat_review}" is not a valid ISO date (YYYY-MM-DD). A structurally ISO but non-calendar value (e.g. 2026-13-99) is rejected so a malformed date cannot slip past the staleness gate.`,
439
440
  );
440
441
  } else {
441
442
  // v0.13.0: Hard Rule #8 forcing function — refuse skills whose
@@ -1179,7 +1179,6 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
1179
1179
  // Aliasing: playbooks ship rwep_factor values `public_poc` and
1180
1180
  // `ai_weaponization` for what F5 calls `poc_available` and `ai_factor`.
1181
1181
  // Both spellings resolve here.
1182
- const _activeExploitationLadder = scoring.ACTIVE_EXPLOITATION_LADDER;
1183
1182
  const _factorScale = (factorName, cve, blastScore) => {
1184
1183
  if (!cve) return 0;
1185
1184
  switch (factorName) {
@@ -1187,7 +1186,16 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
1187
1186
  return cve.cisa_kev === true ? 1 : 0;
1188
1187
  case 'active_exploitation': {
1189
1188
  const v = cve.active_exploitation || (cve.entry && cve.entry.active_exploitation);
1190
- return _activeExploitationLadder[v] ?? 0;
1189
+ // Route through the shared scoring resolver instead of an inline
1190
+ // `ladder[v] ?? 0` lookup so a stray-cased value ('Confirmed') scales
1191
+ // identically to the catalog scorer AND an out-of-vocabulary value
1192
+ // ('in-the-wild') surfaces the RWEP_AE_UNRECOGNISED warning rather than
1193
+ // silently zeroing the active-exploitation weight. activeExploitationMultiplier
1194
+ // (not the bare resolveActiveExploitation().multiplier) is used precisely so
1195
+ // the no-match path is OBSERVABLE — the "out-of-vocab token must surface,
1196
+ // not silently default" class. For every canonical catalog value the
1197
+ // returned multiplier is identical to the prior inline lookup.
1198
+ return scoring.activeExploitationMultiplier(v);
1191
1199
  }
1192
1200
  case 'poc_available':
1193
1201
  case 'public_poc': {
@@ -1435,6 +1443,15 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
1435
1443
  // Prefixed with underscore to signal "for internal/render use".
1436
1444
  _detect_indicators: detectResult.indicators || [],
1437
1445
  _detect_classification: detectResult.classification,
1446
+ // Non-underscore alias so catalog feeds_into / escalation conditions that
1447
+ // reference the natural `analyze.classification` path resolve (the
1448
+ // underscore-prefixed key was render-internal only, so those conditions
1449
+ // resolved undefined and were silently dead — e.g. citation-hygiene →
1450
+ // sbom, crypto-codebase → secrets). The underscore key stays for the
1451
+ // SARIF/CSAF render consumers. On the detect-skip path run() re-sets
1452
+ // analyze.classification to 'skipped', which is the same value
1453
+ // detectResult.classification already carries there, so this is benign.
1454
+ classification: detectResult.classification,
1438
1455
  vex: vexFilter ? {
1439
1456
  filter_applied: true,
1440
1457
  dropped_cve_count: vexDropped.length,
@@ -1473,7 +1490,20 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
1473
1490
  findingShape = {};
1474
1491
  }
1475
1492
  for (const ec of an.escalation_criteria || []) {
1476
- if (evalCondition(ec.condition, { ...agentSignals, ...evalCtxRoot, rwep: adjustedRwep, blast_radius_score: blastRadiusScore, theater_verdict: theaterVerdict, compliance_theater_check: result.compliance_theater_check, jurisdiction_obligations: (playbook.phases && playbook.phases.govern && playbook.phases.govern.jurisdiction_obligations) || [], analyze: result, matched_cve: result.matched_cves || [], finding: findingShape }, playbook)) {
1493
+ if (evalCondition(ec.condition, { ...agentSignals, ...evalCtxRoot, rwep: adjustedRwep, blast_radius_score: blastRadiusScore, theater_verdict: theaterVerdict, compliance_theater_check: result.compliance_theater_check, jurisdiction_obligations: (playbook.phases && playbook.phases.govern && playbook.phases.govern.jurisdiction_obligations) || [], analyze: result, matched_cve: result.matched_cves || [],
1494
+ // finding.* is two-sourced: the engine computes the CVE/severity-derived
1495
+ // keys (severity, rwep_adjusted, matched_cve_*, blast_radius_score,
1496
+ // active_exploitation, framework/control_id_first) via analyzeFindingShape,
1497
+ // while the DESCRIPTIVE keys the catalog conditions gate on
1498
+ // (finding.includes_*, finding.cve_class, finding.tool_surface, …) are
1499
+ // host-AI-asserted — the agent knows whether the finding includes a
1500
+ // cloud-role-assumption path. Merge agent-supplied finding sub-fields
1501
+ // UNDER the engine shape so the descriptive keys survive while engine-owned
1502
+ // keys still WIN on collision (a poisoning signals.finding.severity can't
1503
+ // override the engine-computed severity). The !Array.isArray guard rejects
1504
+ // an array (typeof [] === 'object') that would inject numeric-index noise;
1505
+ // null is already excluded by the leading &&.
1506
+ finding: { ...(agentSignals.finding && typeof agentSignals.finding === 'object' && !Array.isArray(agentSignals.finding) ? agentSignals.finding : {}), ...findingShape } }, playbook)) {
1477
1507
  escalations.push({ condition: ec.condition, action: ec.action, target_playbook: ec.target_playbook || null });
1478
1508
  }
1479
1509
  }
@@ -2057,7 +2087,12 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
2057
2087
  // concerning case, so it scores 100; a clear verdict scores 0. (Earlier
2058
2088
  // this was inverted, so a feeds_into condition like `theater_score >= 50`
2059
2089
  // would have failed to fire exactly when a gap was found.)
2060
- theater_score: analyzeResult.compliance_theater_check?.verdict === 'theater' ? 100 : 0,
2090
+ // 'present' is an allowlisted theater-equivalent verdict (the same
2091
+ // gap-present set verdict_text uses at line 1426 and the allowlist comment
2092
+ // names at line 1352-1353), so it must score 100 (gap detected = worse), not
2093
+ // 0. Scoring only the 'theater' spelling left a 'present' verdict scored as
2094
+ // clear — inverted for any future feeds_into condition gating on theater_score.
2095
+ theater_score: (analyzeResult.compliance_theater_check?.verdict === 'theater' || analyzeResult.compliance_theater_check?.verdict === 'present') ? 100 : 0,
2061
2096
  // Top-level matched_cve array so the shipped sbom feeds_into quantifiers
2062
2097
  // (`any matched_cve.attack_class == 'kernel-lpe'`, … IN ['ai-c2', …]) re-root
2063
2098
  // at each matched CVE. Without it the quantifier head resolves null and the
@@ -2067,7 +2102,14 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
2067
2102
  matched_cve: analyzeResult.matched_cves || [],
2068
2103
  analyze: analyzeResult,
2069
2104
  validate: validateResult,
2070
- finding: analyzeFindingShape(analyzeResult),
2105
+ // finding.* is two-sourced: the engine computes the CVE/severity-derived keys
2106
+ // via analyzeFindingShape; the DESCRIPTIVE keys the catalog feeds_into
2107
+ // conditions gate on (finding.includes_*, cve_class, tool_surface,
2108
+ // mcp_server_location, pipeline_credentials_in_scope, …) are host-AI-asserted.
2109
+ // Merge the agent-supplied finding sub-fields UNDER the engine shape so the
2110
+ // descriptive keys survive while engine-owned keys WIN on collision. Same
2111
+ // shape + guards as the escalation ctx above.
2112
+ finding: { ...(agentSignals.finding && typeof agentSignals.finding === 'object' && !Array.isArray(agentSignals.finding) ? agentSignals.finding : {}), ...analyzeFindingShape(analyzeResult) },
2071
2113
  // Surface evalCondition regex failures from the feeds_into chain into
2072
2114
  // the same accumulator. Without this the regex failure happens but
2073
2115
  // analyze.runtime_errors[] never sees it.
@@ -4130,6 +4172,19 @@ function evalCondition(expr, ctx, playbook) {
4130
4172
  if (m) {
4131
4173
  const [, lhs, op, quote, rhsRaw] = m;
4132
4174
  const lv = resolvePath(ctx, lhs);
4175
+ // A DOTTED (multi-segment) LHS that resolves absent is a suspicious dead
4176
+ // condition (authoring typo / wrong-shape ctx) — the same silent-false class
4177
+ // the contains/includes/IN branches already surface. Surface it as
4178
+ // condition_path_unresolved (observability only; the boolean result is
4179
+ // unchanged). Use `== null` (not strict undefined): resolvePath returns
4180
+ // `null` for a missing INTERMEDIATE parent and `undefined` for a missing
4181
+ // LEAF, so a strict-undefined gate would miss `analyze.classification` when
4182
+ // `analyze` itself is absent. Bare single-segment flags
4183
+ // (agent_has_filesystem_read, operator-submitted signals) are legitimately
4184
+ // absent and do NOT push. A present-but-null flag is a legitimate false:
4185
+ // single-segment, so the dot guard already excludes it. pushPathUnresolved
4186
+ // dedupes on the condition string and is per-kind capped, so it can't spam.
4187
+ if (lhs.includes('.') && lv == null) pushPathUnresolved();
4133
4188
  let rv = rhsRaw;
4134
4189
  if (quote) {
4135
4190
  // Explicit quoted string literal — keep as-is.
@@ -4149,8 +4204,44 @@ function evalCondition(expr, ctx, playbook) {
4149
4204
  // would exclude it: 'critical' < 'high' lexicographically).
4150
4205
  const SEV = { low: 0, medium: 1, high: 2, critical: 3 };
4151
4206
  const lr = SEV[String(lv).toLowerCase()], rr = SEV[String(rv).toLowerCase()];
4152
- const a = (lr !== undefined && rr !== undefined) ? lr : lv;
4153
- const b = (lr !== undefined && rr !== undefined) ? rr : rv;
4207
+ let a = (lr !== undefined && rr !== undefined) ? lr : lv;
4208
+ let b = (lr !== undefined && rr !== undefined) ? rr : rv;
4209
+ const isOrdering = op === '>=' || op === '<=' || op === '>' || op === '<';
4210
+ if (isOrdering && (lr === undefined || rr === undefined)) {
4211
+ // Duration literals carry a unit suffix (`24h`, `7d`, `30min`) — the
4212
+ // catalog writes ordering comparisons against them (kernel.json's
4213
+ // `reboot_window > 24h` raise_severity escalation). The RHS-coercion
4214
+ // above only converts a BARE numeric (`/^-?\d+(\.\d+)?$/`), so a unit-
4215
+ // suffixed literal stays a string. A numeric LHS then compares against a
4216
+ // string RHS (`48 > '24h'` → `48 > NaN` → false: a 48h window silently
4217
+ // fails to escalate) and a string LHS compares lexicographically
4218
+ // (`'6h' > '24h'` → `'6' > '2'` → true: a 6h window WRONGLY escalates).
4219
+ // Normalize both sides to canonical hours when a duration unit appears on
4220
+ // either side: a unit-suffixed literal converts by its unit family; a
4221
+ // bare number is taken in the same family as the duration it is compared
4222
+ // against (hours-equivalent magnitude). The comparison is then numeric.
4223
+ const la = parseDurationHours(a), ba = parseDurationHours(b);
4224
+ if ((la !== null || ba !== null) && la !== null && ba !== null) {
4225
+ a = la; b = ba;
4226
+ } else if (
4227
+ // Two non-numeric, non-severity, non-duration strings under an ordering
4228
+ // operator is a silently-degraded comparison (lexicographic / NaN) — the
4229
+ // clause PARSED so condition_unparsed never fires. Surface a distinct
4230
+ // condition_type_mismatch so the degraded comparison is observable
4231
+ // (the boolean result is unchanged; this is diagnostics only).
4232
+ typeof a !== 'number' && typeof b !== 'number' &&
4233
+ !(typeof a === 'string' && /^-?\d+(?:\.\d+)?$/.test(a.trim())) &&
4234
+ !(typeof b === 'string' && /^-?\d+(?:\.\d+)?$/.test(b.trim()))
4235
+ ) {
4236
+ const target = (ctx && Array.isArray(ctx._runErrors)) ? ctx._runErrors
4237
+ : (playbook && Array.isArray(playbook._runErrors)) ? playbook._runErrors
4238
+ : null;
4239
+ if (target) {
4240
+ pushRunError(target, { kind: 'condition_type_mismatch', condition: String(expr).slice(0, 200) },
4241
+ { dedupeKey: x => x.condition || '' });
4242
+ }
4243
+ }
4244
+ }
4154
4245
  switch (op) {
4155
4246
  case '==': case '=': return lv == rv;
4156
4247
  case '!=': return lv != rv;
@@ -4291,6 +4382,33 @@ function resolvePath(obj, dot) {
4291
4382
  return dot.split('.').reduce((acc, k) => acc == null ? null : acc[k], obj);
4292
4383
  }
4293
4384
 
4385
+ /**
4386
+ * Normalize a duration operand to canonical hours for a numeric comparison.
4387
+ * Accepts a unit-suffixed literal (`24h`, `7d`, `2wk`, `30min`) and converts by
4388
+ * its unit family, OR a bare number / numeric string (returned as its own
4389
+ * magnitude — the catalog writes `reboot_window > 24h` where the LHS resolves to
4390
+ * a bare hour count). Returns null for anything that is not a recognized
4391
+ * duration or plain number, so the caller can detect that BOTH sides normalized
4392
+ * before comparing numerically (and surface a type-mismatch otherwise).
4393
+ */
4394
+ const DURATION_UNIT_HOURS = {
4395
+ h: 1, hr: 1, hrs: 1,
4396
+ m: 1 / 60, min: 1 / 60,
4397
+ d: 24, day: 24, days: 24,
4398
+ w: 168, wk: 168,
4399
+ };
4400
+ function parseDurationHours(v) {
4401
+ if (typeof v === 'number') return Number.isFinite(v) ? v : null;
4402
+ if (typeof v !== 'string') return null;
4403
+ const s = v.trim();
4404
+ // Bare numeric string (no unit) — take its magnitude as-is.
4405
+ if (/^-?\d+(?:\.\d+)?$/.test(s)) return parseFloat(s);
4406
+ const m = s.match(/^(\d+(?:\.\d+)?)\s*(h|hr|hrs|d|day|days|wk|w|m|min)$/i);
4407
+ if (!m) return null;
4408
+ const mult = DURATION_UNIT_HOURS[m[2].toLowerCase()];
4409
+ return mult === undefined ? null : parseFloat(m[1]) * mult;
4410
+ }
4411
+
4294
4412
  /**
4295
4413
  * Depth-aware splitter — split `expr` at occurrences of ` <sep> ` (with
4296
4414
  * surrounding spaces) that are at parenthesis depth 0. Returns the (trimmed)