@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.
- package/CHANGELOG.md +36 -0
- package/bin/exceptd.js +204 -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/collectors/cred-stores.js +18 -9
- package/lib/collectors/secrets.js +4 -2
- package/lib/cross-ref-api.js +29 -7
- package/lib/cve-regression-watcher.js +47 -15
- package/lib/framework-gap.js +52 -19
- package/lib/gap-detectors.js +8 -3
- package/lib/lint-skills.js +3 -2
- package/lib/playbook-runner.js +125 -7
- package/lib/refresh-external.js +58 -7
- package/lib/refresh-network.js +18 -5
- package/lib/rfc-cli.js +113 -18
- package/lib/schemas/playbook.schema.json +1 -1
- package/lib/scoring.js +71 -8
- 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/verify.js +35 -34
- package/lib/xml-tokenizer.js +187 -25
- package/manifest.json +53 -53
- package/orchestrator/dispatcher.js +53 -9
- package/orchestrator/index.js +9 -7
- package/orchestrator/pipeline.js +62 -14
- package/orchestrator/scanner.js +60 -9
- package/package.json +1 -1
- package/sbom.cdx.json +115 -100
- package/scripts/build-indexes.js +21 -3
- package/scripts/builders/cwe-chains.js +5 -2
- package/scripts/builders/section-offsets.js +17 -8
- package/scripts/builders/summary-cards.js +12 -4
- package/scripts/check-catalog-gap-budget.js +3 -3
- package/scripts/check-codebase-patterns-currency.js +1 -0
- package/scripts/check-codebase-patterns.js +166 -11
- package/scripts/check-sbom-currency.js +69 -3
- package/scripts/check-test-count.js +28 -16
- package/scripts/check-test-subjects.js +148 -0
- package/scripts/check-version-tags.js +24 -5
- package/scripts/predeploy.js +32 -8
- package/scripts/refresh-upstream-catalogs.js +169 -44
- 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)
|
|
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
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
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
|
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);
|
|
@@ -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
|
-
|
|
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).
|
|
216
|
-
|
|
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,
|
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.
|
|
@@ -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
|
-
|
|
4153
|
-
|
|
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)
|