@blamejs/exceptd-skills 0.18.8 → 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.
- package/CHANGELOG.md +28 -0
- package/bin/exceptd.js +197 -118
- package/data/_indexes/_meta.json +3 -3
- package/data/_indexes/frequency.json +2 -2
- package/data/d3fend-catalog.json +6 -6
- package/data/playbooks/identity-sso-compromise.json +2 -2
- package/data/playbooks/sbom.json +1 -1
- package/lib/citation-resolve.js +11 -0
- package/lib/collectors/containers.js +13 -0
- package/lib/cross-ref-api.js +29 -7
- package/lib/cve-regression-watcher.js +47 -15
- package/lib/framework-gap.js +27 -5
- package/lib/gap-detectors.js +8 -3
- package/lib/lint-skills.js +3 -2
- package/lib/playbook-runner.js +60 -5
- package/lib/refresh-external.js +58 -7
- package/lib/refresh-network.js +24 -8
- package/lib/rfc-cli.js +108 -18
- package/lib/schemas/playbook.schema.json +1 -1
- package/lib/scoring.js +31 -1
- package/lib/source-advisories.js +58 -9
- package/lib/ttp-mapper.js +31 -3
- package/lib/upstream-check-cli.js +13 -1
- package/lib/validate-catalog-meta.js +51 -7
- package/lib/validate-cve-catalog.js +10 -0
- package/lib/validate-playbooks.js +19 -1
- package/lib/xml-tokenizer.js +187 -25
- package/manifest.json +53 -53
- package/orchestrator/dispatcher.js +45 -9
- package/orchestrator/index.js +9 -7
- package/orchestrator/pipeline.js +62 -14
- package/orchestrator/scanner.js +40 -9
- package/package.json +1 -1
- package/sbom.cdx.json +105 -90
- package/scripts/build-indexes.js +21 -3
- package/scripts/builders/section-offsets.js +17 -8
- package/scripts/check-catalog-gap-budget.js +3 -3
- package/scripts/check-codebase-patterns.js +124 -11
- package/scripts/check-sbom-currency.js +69 -3
- package/scripts/check-test-count.js +28 -16
- package/scripts/check-test-subjects.js +127 -0
- package/scripts/check-version-tags.js +24 -5
- package/scripts/predeploy.js +13 -0
- package/scripts/refresh-upstream-catalogs.js +150 -42
- package/scripts/release.js +28 -11
- package/scripts/validate-vendor-online.js +12 -9
package/lib/cross-ref-api.js
CHANGED
|
@@ -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
|
|
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
|
-
(
|
|
251
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
237
|
-
|
|
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#
|
|
296
|
-
*
|
|
297
|
-
*
|
|
298
|
-
*
|
|
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',
|
package/lib/framework-gap.js
CHANGED
|
@@ -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
|
|
97
|
-
|
|
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);
|
package/lib/gap-detectors.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
package/lib/lint-skills.js
CHANGED
|
@@ -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
|
|
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
|
package/lib/playbook-runner.js
CHANGED
|
@@ -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
|
-
|
|
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 || [],
|
|
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
|
-
|
|
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:
|
|
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.
|
package/lib/refresh-external.js
CHANGED
|
@@ -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
|
|
792
|
-
// advisories
|
|
793
|
-
// ctx.
|
|
794
|
-
//
|
|
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
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
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);
|
package/lib/refresh-network.js
CHANGED
|
@@ -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 {
|
|
157
|
-
|
|
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
|
|
407
|
-
//
|
|
408
|
-
|
|
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",
|
|
@@ -469,11 +482,14 @@ async function main() {
|
|
|
469
482
|
// response cannot steer the download's host or path. The metadata URL is
|
|
470
483
|
// still cross-checked for visibility; a divergence is noted, not fatal,
|
|
471
484
|
// because the authenticity anchor is the SHA-512/shasum/Ed25519 chain below.
|
|
472
|
-
|
|
473
|
-
|
|
485
|
+
// Guard the SAME binding that the URL is built from (`safeVersion`), so the
|
|
486
|
+
// semver check is the only path by which a metadata value reaches the fetch.
|
|
487
|
+
const safeVersion = typeof latestVersion === "string" ? latestVersion : "";
|
|
488
|
+
if (!SEMVER_RE.test(safeVersion)) {
|
|
489
|
+
emit({ ok: false, error: `registry metadata version is not valid semver: ${safeVersion.slice(0, 64)}` }, opts.json);
|
|
474
490
|
process.exitCode = 2; return;
|
|
475
491
|
}
|
|
476
|
-
const canonicalUrl = canonicalTarballUrl(
|
|
492
|
+
const canonicalUrl = canonicalTarballUrl(safeVersion);
|
|
477
493
|
if (tarballUrl !== canonicalUrl) {
|
|
478
494
|
progress(`note: registry dist.tarball (${tarballUrl}) differs from canonical npm URL; fetching ${canonicalUrl}`, opts.json);
|
|
479
495
|
}
|