@blamejs/exceptd-skills 0.18.9 → 0.18.11

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 (45) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/bin/exceptd.js +197 -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/cross-ref-api.js +29 -7
  11. package/lib/cve-regression-watcher.js +47 -15
  12. package/lib/framework-gap.js +27 -5
  13. package/lib/gap-detectors.js +8 -3
  14. package/lib/lint-skills.js +3 -2
  15. package/lib/playbook-runner.js +60 -5
  16. package/lib/refresh-external.js +58 -7
  17. package/lib/refresh-network.js +18 -5
  18. package/lib/rfc-cli.js +108 -18
  19. package/lib/schemas/playbook.schema.json +1 -1
  20. package/lib/scoring.js +31 -1
  21. package/lib/source-advisories.js +58 -9
  22. package/lib/ttp-mapper.js +31 -3
  23. package/lib/upstream-check-cli.js +13 -1
  24. package/lib/validate-catalog-meta.js +51 -7
  25. package/lib/validate-cve-catalog.js +10 -0
  26. package/lib/validate-playbooks.js +19 -1
  27. package/lib/xml-tokenizer.js +187 -25
  28. package/manifest.json +53 -53
  29. package/orchestrator/dispatcher.js +45 -9
  30. package/orchestrator/index.js +9 -7
  31. package/orchestrator/pipeline.js +62 -14
  32. package/orchestrator/scanner.js +40 -9
  33. package/package.json +1 -1
  34. package/sbom.cdx.json +103 -88
  35. package/scripts/build-indexes.js +21 -3
  36. package/scripts/builders/section-offsets.js +17 -8
  37. package/scripts/check-catalog-gap-budget.js +3 -3
  38. package/scripts/check-codebase-patterns.js +124 -11
  39. package/scripts/check-sbom-currency.js +69 -3
  40. package/scripts/check-test-count.js +28 -16
  41. package/scripts/check-test-subjects.js +127 -0
  42. package/scripts/check-version-tags.js +24 -5
  43. package/scripts/predeploy.js +13 -0
  44. package/scripts/refresh-upstream-catalogs.js +150 -42
  45. package/scripts/release.js +28 -11
@@ -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);
@@ -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.
@@ -788,10 +788,12 @@ const { ADVISORIES_SOURCE } = require('./source-advisories');
788
788
  // detection method that surfaces poller-diff historical-CVE references as
789
789
  // candidate silent-regression cases (the MiniPlasma class — a 2026 PoC
790
790
  // drop that re-broke CVE-2020-17103 without any new ID being assigned).
791
- // Report-only; consumes diffs + extracted-CVE-id list from a prior
792
- // advisories run (loadCtx populates ctx.advisoriesDiffs +
793
- // ctx.advisoriesExtractedCveIds when advisories runs alongside the
794
- // watcher, or operators can chain explicitly via the source registry).
791
+ // Report-only; consumes the prior advisories run's output. The main()
792
+ // source loop threads the advisories fetchDiff() result onto
793
+ // ctx.advisoriesObservations (preferred) + ctx.advisoriesDiffs (fallback)
794
+ // after the advisories source resolves and before the watcher runs, so the
795
+ // two must be invoked in that order (advisories first). Under --swarm the
796
+ // watcher runs in a second pass after the parallel batch resolves.
795
797
  const { REGRESSION_WATCHER_SOURCE } = require('./cve-regression-watcher');
796
798
 
797
799
  const ALL_SOURCES = {
@@ -1795,9 +1797,53 @@ async function main() {
1795
1797
  return { src, diff };
1796
1798
  };
1797
1799
 
1798
- const outcomes = opts.swarm
1799
- ? await Promise.all(sources.map(runOne))
1800
- : await sequential(sources, runOne);
1800
+ // NEW-CTRL-074 chaining. cve-regression-watcher consumes the advisories
1801
+ // source's per-feed CVE observations (preferred — includes in-catalog
1802
+ // historical IDs the annotate verdict needs) and falls back to its diffs.
1803
+ // The orchestrator otherwise runs every source independently and only
1804
+ // persists outputs into the report, never back onto ctx — so without this
1805
+ // thread the watcher always sees empty input and emits zero candidates.
1806
+ const threadAdvisoriesIntoCtx = (src, diff) => {
1807
+ if (src && src.name === "advisories" && diff && !diff.air_gap_blocked) {
1808
+ if (Array.isArray(diff.observations)) ctx.advisoriesObservations = diff.observations;
1809
+ if (Array.isArray(diff.diffs)) ctx.advisoriesDiffs = diff.diffs;
1810
+ }
1811
+ };
1812
+
1813
+ let outcomes;
1814
+ if (!opts.swarm) {
1815
+ // Sequential: thread the advisories output onto ctx the instant it
1816
+ // resolves, BEFORE the next source's fetchDiff(ctx) is invoked.
1817
+ outcomes = [];
1818
+ for (const src of sources) {
1819
+ const outcome = await runOne(src);
1820
+ if (!outcome.error) threadAdvisoriesIntoCtx(outcome.src, outcome.diff);
1821
+ outcomes.push(outcome);
1822
+ }
1823
+ } else {
1824
+ // --swarm: advisories and the watcher race when run via the same
1825
+ // Promise.all, so chaining via shared ctx cannot work. Split the
1826
+ // watcher into a second pass: run every other source in parallel,
1827
+ // thread the resolved advisories observations onto ctx, then run the
1828
+ // watcher. If advisories is NOT also selected, the watcher runs in the
1829
+ // first batch like any other source (its empty-input contract is the
1830
+ // operator's choice, not a silent race).
1831
+ const hasAdvisories = sources.some((s) => s.name === "advisories");
1832
+ const watcher = hasAdvisories
1833
+ ? sources.find((s) => s.name === "cve-regression-watcher")
1834
+ : null;
1835
+ const firstBatch = watcher ? sources.filter((s) => s !== watcher) : sources;
1836
+ outcomes = await Promise.all(firstBatch.map(runOne));
1837
+ if (watcher) {
1838
+ for (const o of outcomes) {
1839
+ if (!o.error) threadAdvisoriesIntoCtx(o.src, o.diff);
1840
+ }
1841
+ const watcherOutcome = await runOne(watcher);
1842
+ // Preserve the operator's declared source order in the report.
1843
+ const idx = sources.indexOf(watcher);
1844
+ outcomes.splice(idx, 0, watcherOutcome);
1845
+ }
1846
+ }
1801
1847
 
1802
1848
  // Cache-integrity refusals (sha256 mismatch, missing/partial _index.json,
1803
1849
  // unindexed payload) are thrown by readCachedJson with _exceptd_exit_code=4
@@ -1832,6 +1878,11 @@ async function main() {
1832
1878
  // marker through to the persisted report so stdout-parsing consumers
1833
1879
  // and the regression test can verify the network refusal happened.
1834
1880
  ...(diff.air_gap_blocked ? { air_gap_blocked: true } : {}),
1881
+ // NEW-CTRL-074: persist the per-source _meta (the watcher stamps
1882
+ // input_field_used here so the chaining is observable in the report)
1883
+ // and the advisories observations[] the watcher consumes.
1884
+ ...(diff._meta ? { _meta: diff._meta } : {}),
1885
+ ...(Array.isArray(diff.observations) ? { observations: diff.observations } : {}),
1835
1886
  };
1836
1887
  if (opts.apply && diff.diffs.length > 0 && !src.report_only) {
1837
1888
  const r = await src.applyDiff(ctx, diff.diffs);
@@ -152,9 +152,17 @@ function getJson(url, timeoutMs) {
152
152
  const ALLOWED_TARBALL_HOST = /(?:^|\.)npmjs\.org$|(?:^|\.)npmjs\.com$/;
153
153
 
154
154
  // Exported for in-process tests of the fetch-destination guard.
155
+ // The guard parses the URL once and rejects any non-default port BEFORE the
156
+ // hostname test, so validation and the subsequent https.get({host,path})
157
+ // connect (which reuses u.host — port-inclusive) agree on the same value.
158
+ // Without the port check a `registry.npmjs.org:9999` URL would pass the
159
+ // hostname-only allowlist yet connect to the attacker-chosen port.
155
160
  function isAllowedTarballHost(url) {
156
- try { return ALLOWED_TARBALL_HOST.test(new URL(url).hostname.toLowerCase()); }
157
- catch { return false; }
161
+ try {
162
+ const u = new URL(url);
163
+ if (!(u.port === "" || u.port === "443")) return false;
164
+ return ALLOWED_TARBALL_HOST.test(u.hostname.toLowerCase());
165
+ } catch { return false; }
158
166
  }
159
167
 
160
168
  function getBufferOnce(url, timeoutMs) {
@@ -403,9 +411,14 @@ async function main() {
403
411
 
404
412
  // Air-gap refusal. --network needs egress to registry.npmjs.org for the
405
413
  // /latest metadata + tarball pull. Under air-gap there is no offline
406
- // substitute (the test fixture path remains available for offline tests),
407
- // so refuse before any network attempt and point at the offline workflow.
408
- if (opts.airGap && !process.env.EXCEPTD_REGISTRY_FIXTURE) {
414
+ // substitute, so refuse before any network attempt and point at the
415
+ // offline workflow. The refusal is UNCONDITIONAL w.r.t.
416
+ // EXCEPTD_REGISTRY_FIXTURE: a present metadata fixture only stubs the
417
+ // /latest read, not the subsequent tarball fetch (getBuffer still hits
418
+ // canonicalUrl), so honoring the fixture under air-gap would let egress
419
+ // happen anyway while silently disabling an operator-facing control.
420
+ // Offline tests exercise the metadata+tarball path WITHOUT --air-gap.
421
+ if (opts.airGap) {
409
422
  emit({
410
423
  ok: false,
411
424
  source: "air-gap",
package/lib/rfc-cli.js CHANGED
@@ -13,7 +13,85 @@
13
13
 
14
14
  const { resolveRfc } = require("./citation-resolve.js");
15
15
 
16
- (async () => {
16
+ // Stopwords that don't disambiguate one RFC title from another. A claimed title
17
+ // run preceded by one of these in the index title is still a clean match; a run
18
+ // preceded by a CONTENT word (e.g. "datagram" before "transport layer security")
19
+ // is the tail of a more-specific title and must NOT be accepted as a match.
20
+ const TITLE_STOPWORDS = new Set(["the", "a", "an", "of", "for", "to", "in", "on", "and", "or"]);
21
+
22
+ function normTitle(s) {
23
+ return String(s).toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
24
+ }
25
+
26
+ /**
27
+ * Decide whether a claimed RFC title matches the authoritative index title.
28
+ *
29
+ * Replaces the old lenient bidirectional substring test (`a.includes(b) ||
30
+ * b.includes(a)`), which let "TLS" match the DTLS title (substring of "dtls")
31
+ * and let "Transport Layer Security" match the DTLS title (tail-of-phrase).
32
+ * The comparison is now whole-word and phrase-aware:
33
+ *
34
+ * 1. Every claimed token must appear as a WHOLE word in the index title
35
+ * (so "tls" never matches inside "dtls").
36
+ * 2. The claimed token sequence must appear as a CONTIGUOUS run in the index
37
+ * title, OR the claim must cover enough of the index title (containment
38
+ * ratio floor) to be unambiguous.
39
+ * 3. A contiguous run that is immediately preceded by a distinguishing
40
+ * CONTENT word in the index title is rejected — it is the tail of a
41
+ * more-specific title (the "datagram transport layer security" trap).
42
+ *
43
+ * Returns true / false. Only called when both a claim and an index title exist.
44
+ */
45
+ function titleMatches(claimed, indexTitle) {
46
+ const claimTokens = normTitle(claimed).split(" ").filter(Boolean);
47
+ const titleTokens = normTitle(indexTitle).split(" ").filter(Boolean);
48
+ if (claimTokens.length === 0 || titleTokens.length === 0) return false;
49
+
50
+ // (1) Whole-word containment: every claimed token must be a standalone token
51
+ // in the index title. Kills the tls-inside-dtls substring false positive.
52
+ const titleSet = new Set(titleTokens);
53
+ for (const t of claimTokens) {
54
+ if (!titleSet.has(t)) return false;
55
+ }
56
+
57
+ // Find every contiguous run of the claim inside the index title.
58
+ const runStarts = [];
59
+ for (let i = 0; i + claimTokens.length <= titleTokens.length; i++) {
60
+ let hit = true;
61
+ for (let j = 0; j < claimTokens.length; j++) {
62
+ if (titleTokens[i + j] !== claimTokens[j]) { hit = false; break; }
63
+ }
64
+ if (hit) runStarts.push(i);
65
+ }
66
+
67
+ if (runStarts.length > 0) {
68
+ // A single-token claim that is a whole word in the title is unambiguous on
69
+ // its own — the whole-word check above already excluded the substring trap
70
+ // (e.g. "tls" is NOT a token inside "dtls"), so "TLS" correctly matches the
71
+ // 8446 title (standalone "tls" token) but not the 9147 DTLS title.
72
+ if (claimTokens.length === 1) return true;
73
+ // (3) For a MULTI-token run, accept only if at least one occurrence is NOT
74
+ // preceded by a distinguishing content word — i.e. it begins the title
75
+ // or is preceded only by a stopword. A run preceded solely by a content
76
+ // qualifier (e.g. "datagram" before "transport layer security") is the
77
+ // tail of a more-specific title and must not be accepted as a match.
78
+ for (const start of runStarts) {
79
+ if (start === 0) return true;
80
+ const prev = titleTokens[start - 1];
81
+ if (TITLE_STOPWORDS.has(prev)) return true;
82
+ }
83
+ return false;
84
+ }
85
+
86
+ // No contiguous run, but all tokens present out of order. Accept only when the
87
+ // claim covers a strong majority of the index title's tokens (containment
88
+ // ratio floor) — a few scattered tokens against a long title is ambiguous,
89
+ // not a match.
90
+ const ratio = claimTokens.length / titleTokens.length;
91
+ return ratio >= 0.8;
92
+ }
93
+
94
+ async function main() {
17
95
  const argv = process.argv.slice(2);
18
96
  const flags = new Set(argv.filter((a) => a.startsWith("--")));
19
97
  // Reject unknown flags (same contract as the in-process verbs). `--check`
@@ -29,17 +107,22 @@ const { resolveRfc } = require("./citation-resolve.js");
29
107
  process.exitCode = 1;
30
108
  return;
31
109
  }
32
- const positionals = argv.filter((a) => !a.startsWith("--"));
110
+ // --check "<claimed title>" consumes the FOLLOWING token as its value. Exclude
111
+ // that value token by INDEX from the positional pool before selecting id, so
112
+ // the RFC number resolves correctly regardless of flag order
113
+ // (`rfc --check "Some Title" 9404` reads id=9404, not id="Some Title").
114
+ const checkIdx = argv.indexOf("--check");
115
+ const checkValueIdx = (checkIdx !== -1 && argv[checkIdx + 1] && !argv[checkIdx + 1].startsWith("--")) ? checkIdx + 1 : -1;
116
+ const positionals = argv.filter((a, i) => !a.startsWith("--") && i !== checkValueIdx);
33
117
  const id = positionals[0];
34
118
  const pretty = flags.has("--pretty");
35
119
  const json = flags.has("--json") || pretty;
36
120
 
37
- // --check "<claimed title>" : the next non-flag token after the number.
121
+ // The claimed title is exactly the excluded value token (kept in lockstep with
122
+ // checkValueIdx so the two never diverge); a trailing `--check` with no value
123
+ // leaves it null.
38
124
  let claimedTitle = null;
39
- const checkIdx = argv.indexOf("--check");
40
- if (checkIdx !== -1 && argv[checkIdx + 1] && !argv[checkIdx + 1].startsWith("--")) {
41
- claimedTitle = argv[checkIdx + 1];
42
- }
125
+ if (checkValueIdx !== -1) claimedTitle = argv[checkValueIdx];
43
126
 
44
127
  if (!id) {
45
128
  process.stderr.write(
@@ -53,9 +136,7 @@ const { resolveRfc } = require("./citation-resolve.js");
53
136
 
54
137
  let titleMatch = null;
55
138
  if (claimedTitle && r.title) {
56
- const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
57
- const a = norm(claimedTitle), b = norm(r.title);
58
- titleMatch = a.length > 0 && (b.includes(a) || a.includes(b));
139
+ titleMatch = titleMatches(claimedTitle, r.title);
59
140
  }
60
141
  // Derive `ok` from the resolved status + title-check the same way the exit
61
142
  // code is derived below — a non-zero exit (status nonexistent OR an explicit
@@ -83,11 +164,20 @@ const { resolveRfc } = require("./citation-resolve.js");
83
164
  }
84
165
  // A mismatched or nonexistent citation is a non-zero exit for gates.
85
166
  if (fails) process.exitCode = 2;
86
- })().catch((err) => {
87
- // A corrupt/unreadable RFC index (or any unexpected throw inside the async
88
- // body) becomes a rejected promise. Emit the documented {ok:false,error}
89
- // envelope rather than crashing with a raw stack trace, and signal failure
90
- // via exitCode so the event loop drains stderr before exit.
91
- process.stderr.write(JSON.stringify({ ok: false, verb: "rfc", error: String((err && err.message) || err) }) + "\n");
92
- process.exitCode = 1;
93
- });
167
+ }
168
+
169
+ // Only run the CLI when invoked directly (`exceptd rfc ...`). When required by a
170
+ // test the IIFE must not fire — it would read process.argv and write to stdout —
171
+ // so the pure title-match helper can be exercised in-process.
172
+ if (require.main === module) {
173
+ main().catch((err) => {
174
+ // A corrupt/unreadable RFC index (or any unexpected throw inside the async
175
+ // body) becomes a rejected promise. Emit the documented {ok:false,error}
176
+ // envelope rather than crashing with a raw stack trace, and signal failure
177
+ // via exitCode so the event loop drains stderr before exit.
178
+ process.stderr.write(JSON.stringify({ ok: false, verb: "rfc", error: String((err && err.message) || err) }) + "\n");
179
+ process.exitCode = 1;
180
+ });
181
+ }
182
+
183
+ module.exports = { titleMatches, normTitle, main };