@blamejs/exceptd-skills 0.19.32 → 0.19.34
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 +22 -0
- package/bin/exceptd.js +896 -2824
- package/data/_indexes/_meta.json +8 -8
- package/data/_indexes/activity-feed.json +2 -2
- package/data/_indexes/catalog-summaries.json +7 -7
- package/data/_indexes/chains.json +60118 -0
- package/data/attack-techniques.json +267 -7
- package/data/cve-catalog.json +9991 -3
- package/data/cwe-catalog.json +109 -2
- package/data/framework-control-gaps.json +578 -3
- package/data/zeroday-lessons.json +8330 -1
- package/lib/auto-discovery.js +56 -286
- package/lib/canonical-eq.js +7 -40
- package/lib/citation-resolve.js +22 -70
- package/lib/collectors/ai-api.js +20 -54
- package/lib/collectors/cicd-pipeline-compromise.js +40 -108
- package/lib/collectors/citation-hygiene.js +72 -210
- package/lib/collectors/containers.js +41 -130
- package/lib/collectors/cred-stores.js +31 -115
- package/lib/collectors/crypto-codebase.js +55 -138
- package/lib/collectors/crypto.js +24 -54
- package/lib/collectors/hardening.js +20 -78
- package/lib/collectors/kernel.js +16 -46
- package/lib/collectors/library-author.js +57 -206
- package/lib/collectors/mcp.js +24 -70
- package/lib/collectors/runtime.js +24 -86
- package/lib/collectors/sbom.js +34 -106
- package/lib/collectors/scan-excludes.js +31 -138
- package/lib/collectors/secrets.js +62 -178
- package/lib/cross-ref-api.js +39 -123
- package/lib/currency-severity.js +8 -27
- package/lib/cve-batch.js +13 -21
- package/lib/cve-cli.js +13 -20
- package/lib/cve-curation.js +72 -239
- package/lib/cve-regression-watcher.js +29 -152
- package/lib/cvss.js +13 -54
- package/lib/doctor-bucketing.js +3 -19
- package/lib/exit-codes.js +10 -42
- package/lib/flag-suggest.js +7 -25
- package/lib/framework-gap.js +35 -114
- package/lib/gap-detectors.js +37 -159
- package/lib/id-validation.js +9 -30
- package/lib/job-queue.js +13 -36
- package/lib/lint-skills.js +64 -232
- package/lib/playbook-runner.js +693 -2095
- package/lib/prefetch.js +100 -376
- package/lib/refresh-external.js +199 -627
- package/lib/refresh-network.js +75 -307
- package/lib/rfc-cli.js +23 -68
- package/lib/scoring.js +77 -145
- package/lib/sign.js +43 -229
- package/lib/source-advisories.js +43 -194
- package/lib/source-ghsa.js +37 -120
- package/lib/source-osv.js +94 -266
- package/lib/ttp-mapper.js +14 -24
- package/lib/upstream-check-cli.js +10 -28
- package/lib/upstream-check.js +19 -44
- package/lib/validate-catalog-meta.js +17 -61
- package/lib/validate-cve-catalog.js +43 -119
- package/lib/validate-indexes.js +25 -76
- package/lib/validate-package.js +16 -62
- package/lib/validate-playbooks.js +69 -275
- package/lib/validate-vendor.js +16 -49
- package/lib/verify.js +56 -286
- package/lib/version-pins.js +5 -34
- package/lib/worker-pool.js +11 -30
- package/lib/xml-tokenizer.js +47 -152
- package/manifest.json +53 -53
- package/orchestrator/dispatcher.js +17 -68
- package/orchestrator/event-bus.js +11 -74
- package/orchestrator/index.js +138 -412
- package/orchestrator/pipeline.js +28 -85
- package/orchestrator/scanner.js +34 -138
- package/orchestrator/scheduler.js +20 -84
- package/package.json +2 -2
- package/sbom.cdx.json +253 -253
- package/scripts/audit-catalog-gaps.js +9 -62
- package/scripts/audit-cross-skill.js +5 -31
- package/scripts/audit-perf.js +6 -16
- package/scripts/backfill-theater-test.js +7 -64
- package/scripts/bootstrap.js +12 -44
- package/scripts/build-indexes.js +40 -154
- package/scripts/builders/activity-feed.js +4 -14
- package/scripts/builders/catalog-summaries.js +3 -10
- package/scripts/builders/currency.js +7 -20
- package/scripts/builders/cwe-chains.js +7 -30
- package/scripts/builders/did-ladders.js +6 -13
- package/scripts/builders/frequency.js +5 -19
- package/scripts/builders/jurisdiction-clocks.js +6 -25
- package/scripts/builders/recipes.js +6 -14
- package/scripts/builders/section-offsets.js +13 -51
- package/scripts/builders/stale-content.js +7 -28
- package/scripts/builders/summary-cards.js +8 -29
- package/scripts/builders/theater-fingerprints.js +12 -27
- package/scripts/builders/token-budget.js +4 -31
- package/scripts/check-agents-md-collectors.js +11 -54
- package/scripts/check-catalog-gap-budget.js +15 -32
- package/scripts/check-changelog-extract.js +18 -48
- package/scripts/check-codebase-patterns-currency.js +6 -22
- package/scripts/check-codebase-patterns.js +50 -143
- package/scripts/check-epss-consistency.js +9 -64
- package/scripts/check-framework-gap-coverage.js +13 -31
- package/scripts/check-manifest-snapshot.js +13 -73
- package/scripts/check-sbom-currency.js +44 -142
- package/scripts/check-test-count.js +15 -52
- package/scripts/check-test-coverage.js +66 -197
- package/scripts/check-test-subjects.js +21 -62
- package/scripts/check-ttp-references.js +14 -38
- package/scripts/check-ttp-upstream.js +8 -40
- package/scripts/check-version-bump.js +9 -61
- package/scripts/check-version-tags.js +20 -121
- package/scripts/predeploy.js +38 -184
- package/scripts/refresh-manifest-snapshot.js +16 -38
- package/scripts/refresh-mitre-atlas.js +3 -8
- package/scripts/refresh-mitre-attack.js +1 -8
- package/scripts/refresh-mitre-d3fend.js +3 -9
- package/scripts/refresh-mitre-ics-attack.js +3 -8
- package/scripts/refresh-reverse-refs.js +27 -94
- package/scripts/refresh-rfc-index.js +2 -10
- package/scripts/refresh-sbom.js +31 -161
- package/scripts/refresh-upstream-catalogs.js +40 -137
- package/scripts/release.js +69 -232
- package/scripts/run-e2e-scenarios.js +24 -71
- package/scripts/sync-manifest-metadata.js +10 -34
- package/scripts/sync-package-description.js +8 -17
- package/scripts/validate-vendor-online.js +13 -44
- package/scripts/verify-shipped-tarball.js +35 -140
package/lib/playbook-runner.js
CHANGED
|
@@ -1,46 +1,9 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Playbook runner — executes the seven-phase
|
|
5
|
-
* lib/schemas/playbook.schema.json
|
|
6
|
-
*
|
|
7
|
-
* 1. govern exceptd. Loads GRC context: jurisdiction obligations, theater
|
|
8
|
-
* fingerprints, framework gaps, skills to preload. Sets the
|
|
9
|
-
* compliance lens before any investigation runs.
|
|
10
|
-
* 2. direct exceptd. Scopes the investigation: threat context with current
|
|
11
|
-
* CVE/TTP citations, RWEP thresholds, framework lag declaration,
|
|
12
|
-
* skill chain, token budget.
|
|
13
|
-
* 3. look host AI. Collects typed artifacts (logs/files/processes/
|
|
14
|
-
* network/etc.) per artifact spec, with air-gap fallbacks.
|
|
15
|
-
* 4. detect host AI. Evaluates artifacts against typed indicators, applies
|
|
16
|
-
* false-positive profile, classifies as detected | inconclusive
|
|
17
|
-
* | not_detected.
|
|
18
|
-
* 5. analyze exceptd. Computes RWEP from rwep_inputs, scores blast radius,
|
|
19
|
-
* runs compliance_theater_check, generates framework_gap_mapping
|
|
20
|
-
* entries, fires escalation_criteria.
|
|
21
|
-
* 6. validate exceptd. Picks remediation_path by priority + preconditions,
|
|
22
|
-
* emits validation_tests, renders residual_risk_statement, lists
|
|
23
|
-
* evidence_requirements, computes regression schedule.
|
|
24
|
-
* 7. close exceptd. Closes the GRC loop: assembles evidence_package
|
|
25
|
-
* (signed by default), drafts learning_loop lesson, computes
|
|
26
|
-
* notification_actions deadlines from govern.jurisdiction_obligations
|
|
27
|
-
* clock_starts + window_hours, evaluates exception_generation
|
|
28
|
-
* trigger and renders auditor-ready language, finalizes
|
|
29
|
-
* regression_schedule.next_run.
|
|
30
|
-
*
|
|
31
|
-
* Currency gate: _meta.threat_currency_score < 50 hard-blocks execution unless
|
|
32
|
-
* the caller passes { forceStale: true }. Below 70 warns. The schema declares
|
|
33
|
-
* the score; the runner enforces.
|
|
34
|
-
*
|
|
35
|
-
* Preconditions: each _meta.preconditions entry has on_fail = halt|warn|skip_phase.
|
|
36
|
-
* Engine evaluates the (host AI-supplied) check value and reacts accordingly.
|
|
37
|
-
*
|
|
38
|
-
* Mutex: an in-process Set tracks active playbook runs. Engine refuses to start
|
|
39
|
-
* a playbook whose _meta.mutex intersects active runs.
|
|
40
|
-
*
|
|
41
|
-
* feeds_into: close() returns a list of downstream playbook IDs whose
|
|
42
|
-
* conditions are satisfied by this run's finding — the agent decides whether
|
|
43
|
-
* to chain into them.
|
|
4
|
+
* Playbook runner — executes the seven-phase contract declared in
|
|
5
|
+
* lib/schemas/playbook.schema.json. The engine owns govern, direct, analyze,
|
|
6
|
+
* validate and close; the host AI owns look and detect.
|
|
44
7
|
*/
|
|
45
8
|
|
|
46
9
|
const fs = require('fs');
|
|
@@ -51,15 +14,8 @@ const scoring = require('./scoring');
|
|
|
51
14
|
const { assertIdComponent } = require('./id-validation');
|
|
52
15
|
const codepointClass = require('../vendor/blamejs/codepoint-class.js');
|
|
53
16
|
|
|
54
|
-
// cross-ref-api
|
|
55
|
-
//
|
|
56
|
-
// failure, returns an empty stub, and accumulates the error in
|
|
57
|
-
// getLoadErrors(). run() probes for accumulated load errors and returns
|
|
58
|
-
// a structured `blocked_by:'catalog_corrupt'` rather than letting analyze
|
|
59
|
-
// silently operate against an empty catalog. Note: the call to
|
|
60
|
-
// xref.byCve below force-touches the catalog so the load error surfaces
|
|
61
|
-
// at module load (it's lazy otherwise), which gives run() a deterministic
|
|
62
|
-
// signal regardless of submission shape.
|
|
17
|
+
// cross-ref-api swallows a corrupt cve-catalog.json into an empty stub and
|
|
18
|
+
// records the failure in getLoadErrors(), which run() probes before analyze.
|
|
63
19
|
let xref;
|
|
64
20
|
let _xrefLoadError = null;
|
|
65
21
|
let _xrefProbed = false;
|
|
@@ -74,13 +30,7 @@ try {
|
|
|
74
30
|
_xrefProbed = true; // require itself failed; nothing left to probe
|
|
75
31
|
}
|
|
76
32
|
|
|
77
|
-
//
|
|
78
|
-
// rather than at module load. The probe parses the ~2.6MB CVE catalog (~8.5ms);
|
|
79
|
-
// doing it eagerly charged that to every cheap verb (brief/ask/lint/
|
|
80
|
-
// discover) that never analyzes. run() calls this before the analyze path, so
|
|
81
|
-
// a corrupt catalog still surfaces as blocked_by:'catalog_corrupt' before
|
|
82
|
-
// analyze — just not on verbs that don't touch the catalog. Memoized: probes
|
|
83
|
-
// at most once per process.
|
|
33
|
+
// Lazy and memoized: only a verb that analyzes pays the ~2.6MB catalog parse.
|
|
84
34
|
function getXrefLoadError() {
|
|
85
35
|
if (_xrefProbed) return _xrefLoadError;
|
|
86
36
|
_xrefProbed = true;
|
|
@@ -101,24 +51,13 @@ function getXrefLoadError() {
|
|
|
101
51
|
const ROOT = path.join(__dirname, '..');
|
|
102
52
|
const PLAYBOOK_DIR = process.env.EXCEPTD_PLAYBOOK_DIR || path.join(ROOT, 'data', 'playbooks');
|
|
103
53
|
|
|
104
|
-
// In-process mutex tracker
|
|
105
|
-
// Persistent cross-process coordination is out of scope — that's for the GRC
|
|
106
|
-
// platform integration, not the runner.
|
|
54
|
+
// In-process mutex tracker; survives only the current Node process.
|
|
107
55
|
const _activeRuns = new Set();
|
|
108
56
|
|
|
109
|
-
// Bounded push into a runtime_errors array
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
// fires the helper records a `_truncated` sentinel so downstream consumers
|
|
114
|
-
// see the drop without needing to compare cardinalities.
|
|
115
|
-
//
|
|
116
|
-
// opts.cap per-kind cap (default 100)
|
|
117
|
-
// opts.totalCap total array cap (default 1000)
|
|
118
|
-
// opts.dedupeKey optional fn(entry) returning a string key. When supplied,
|
|
119
|
-
// a push with the same (kind, dedupeKey) tuple is skipped.
|
|
120
|
-
//
|
|
121
|
-
// Returns true if the entry was pushed, false otherwise (capped or deduped).
|
|
57
|
+
// Bounded push into a runtime_errors array: opts.cap (100) per kind,
|
|
58
|
+
// opts.totalCap (1000) overall, opts.dedupeKey(entry) collapsing same-(kind,
|
|
59
|
+
// key) pushes. A capped push records a `_truncated` sentinel instead, and
|
|
60
|
+
// returns false — true means the entry was pushed.
|
|
122
61
|
function pushRunError(arr, entry, opts) {
|
|
123
62
|
if (!Array.isArray(arr) || !entry || typeof entry !== 'object') return false;
|
|
124
63
|
opts = opts || {};
|
|
@@ -149,10 +88,7 @@ function pushRunError(arr, entry, opts) {
|
|
|
149
88
|
return true;
|
|
150
89
|
}
|
|
151
90
|
|
|
152
|
-
//
|
|
153
|
-
// into the flat fields pushRunError dedupes on. Used by evalCondition()'s
|
|
154
|
-
// regex-failure path so per-(source, expr) duplicates collapse to one entry
|
|
155
|
-
// plus a `_truncated` sentinel when the cap fires.
|
|
91
|
+
// Flatten a `_regex_eval_error` record into the fields pushRunError dedupes on.
|
|
156
92
|
function _regexErrorPayload(rec) {
|
|
157
93
|
if (rec && typeof rec === 'object' && rec._regex_eval_error) {
|
|
158
94
|
const { source, expr, message } = rec._regex_eval_error;
|
|
@@ -161,8 +97,6 @@ function _regexErrorPayload(rec) {
|
|
|
161
97
|
return { _regex_eval_error: rec };
|
|
162
98
|
}
|
|
163
99
|
|
|
164
|
-
// --- catalog access ---
|
|
165
|
-
|
|
166
100
|
function listPlaybooks() {
|
|
167
101
|
if (!fs.existsSync(PLAYBOOK_DIR)) return [];
|
|
168
102
|
return fs.readdirSync(PLAYBOOK_DIR)
|
|
@@ -171,26 +105,20 @@ function listPlaybooks() {
|
|
|
171
105
|
}
|
|
172
106
|
|
|
173
107
|
function loadPlaybook(playbookId) {
|
|
174
|
-
// Traversal defense
|
|
175
|
-
//
|
|
108
|
+
// Traversal defense sits with the path.join so every caller gets it, not
|
|
109
|
+
// only the CLI dispatcher.
|
|
176
110
|
assertIdComponent(playbookId, 'playbook');
|
|
177
111
|
const p = path.join(PLAYBOOK_DIR, `${playbookId}.json`);
|
|
178
112
|
if (!fs.existsSync(p)) throw new Error(`Playbook not found: ${playbookId} (expected ${p})`);
|
|
179
113
|
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
180
114
|
}
|
|
181
115
|
|
|
182
|
-
// Per-run playbook cache. Each phase function reads runOpts._playbookCache
|
|
183
|
-
// before falling back to loadPlaybook(). run() sets _playbookCache once at
|
|
184
|
-
// entry so seven phases share one disk read + JSON parse instead of seven.
|
|
185
|
-
|
|
186
116
|
function findDirective(playbook, directiveId) {
|
|
187
117
|
const d = playbook.directives.find(x => x.id === directiveId);
|
|
188
118
|
if (!d) throw new Error(`Directive not found: ${directiveId} in playbook ${playbook._meta.id}`);
|
|
189
119
|
return d;
|
|
190
120
|
}
|
|
191
121
|
|
|
192
|
-
// --- phase-resolution: merge playbook.phases with directive.phase_overrides ---
|
|
193
|
-
|
|
194
122
|
function resolvedPhase(playbook, directiveId, phaseName) {
|
|
195
123
|
const base = playbook.phases[phaseName] || {};
|
|
196
124
|
const directive = playbook.directives.find(x => x.id === directiveId);
|
|
@@ -204,55 +132,28 @@ function deepMerge(a, b) {
|
|
|
204
132
|
if (typeof b !== 'object' || Array.isArray(b)) return b;
|
|
205
133
|
const out = { ...a };
|
|
206
134
|
for (const [k, v] of Object.entries(b)) {
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
// and must never rebind a prototype if reused with parsed/operator input —
|
|
210
|
-
// same guard as the precondition_checks merge below. `out[k]=` on
|
|
211
|
-
// k='__proto__' would invoke the prototype-rebinding setter.
|
|
135
|
+
// `out[k]=` on k='__proto__' invokes the prototype-rebinding setter, and
|
|
136
|
+
// deepMerge is exported (_deepMerge) so it may see parsed operator input.
|
|
212
137
|
if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
|
|
213
138
|
out[k] = (k in out) ? deepMerge(out[k], v) : v;
|
|
214
139
|
}
|
|
215
140
|
return out;
|
|
216
141
|
}
|
|
217
142
|
|
|
218
|
-
// --- pre-flight: currency + preconditions + mutex ---
|
|
219
|
-
|
|
220
143
|
/**
|
|
221
|
-
* Pre-flight gate
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
* 2. Preconditions. _meta.preconditions[] entries with on_fail in
|
|
226
|
-
* {halt, warn, skip_phase} are evaluated against
|
|
227
|
-
* runOpts.precondition_checks[id]. Missing values → precondition_unverified
|
|
228
|
-
* issue (plus halt if on_fail=halt). False values → precondition_warn or
|
|
229
|
-
* precondition_skip per on_fail.
|
|
230
|
-
* 3. Mutex. _meta.mutex[] intersect with the in-process active runs set
|
|
231
|
-
* AND with the filesystem lockfile dir blocks the run.
|
|
232
|
-
*
|
|
233
|
-
* When runOpts.strictPreconditions === true, warn-level outcomes
|
|
234
|
-
* (precondition_warn, precondition_unverified with on_fail=warn or
|
|
235
|
-
* skip_phase) are ESCALATED to halts. The function returns ok:false
|
|
236
|
-
* with blocked_by='precondition' and an issues array containing
|
|
237
|
-
* precondition_halt entries. Callers wanting "CI gate: any unverified
|
|
238
|
-
* precondition is a failure" pass strictPreconditions=true.
|
|
239
|
-
*
|
|
240
|
-
* When a precondition with on_fail='skip_phase' fails, the issue carries
|
|
241
|
-
* skip_phase: 'detect' (default) so run() can route to a skipped-phase
|
|
242
|
-
* placeholder rather than executing detect against a missing
|
|
243
|
-
* prerequisite.
|
|
144
|
+
* Pre-flight gate over currency, preconditions and mutex.
|
|
145
|
+
* runOpts.strictPreconditions escalates every warn-level precondition outcome
|
|
146
|
+
* to a halt; an on_fail='skip_phase' failure instead emits an issue carrying
|
|
147
|
+
* skip_phase (default 'detect'), which run() routes to a skipped placeholder.
|
|
244
148
|
*/
|
|
245
149
|
function preflight(playbook, runOpts = {}) {
|
|
246
150
|
const issues = [];
|
|
247
151
|
const meta = playbook._meta;
|
|
248
152
|
const strict = runOpts.strictPreconditions === true;
|
|
249
153
|
|
|
250
|
-
// 1. Currency gate
|
|
251
154
|
const score = meta.threat_currency_score;
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
// bypass the staleness gate on exactly the playbooks whose currency metadata
|
|
255
|
-
// is broken. Treat "no usable score" as the most-stale state.
|
|
155
|
+
// No usable score is the most-stale state: `undefined < 50` is false, which
|
|
156
|
+
// would bypass the gate on exactly the broken metadata.
|
|
256
157
|
const scoreUsable = typeof score === 'number' && !Number.isNaN(score);
|
|
257
158
|
if ((!scoreUsable || score < 50) && !runOpts.forceStale) {
|
|
258
159
|
return {
|
|
@@ -268,14 +169,11 @@ function preflight(playbook, runOpts = {}) {
|
|
|
268
169
|
issues.push({ kind: 'currency_warn', message: `threat_currency_score = ${score} (< 70). Threat model is stale — recommend running the skill-update-loop before relying on findings.` });
|
|
269
170
|
}
|
|
270
171
|
|
|
271
|
-
// 2. Preconditions
|
|
272
172
|
for (const pc of meta.preconditions || []) {
|
|
273
173
|
const submitted = runOpts.precondition_checks?.[pc.id];
|
|
274
174
|
if (submitted === undefined) {
|
|
275
175
|
const submission_hint = `Submit precondition_checks in your evidence JSON, e.g. { "precondition_checks": { "${pc.id}": true } }. Pass via --evidence <file.json> or pipe to stdin with --evidence -. The runner lifts precondition_checks into runOpts before the gate evaluates.`;
|
|
276
176
|
if (strict) {
|
|
277
|
-
// strictPreconditions promotes unverified to halt regardless of
|
|
278
|
-
// declared on_fail.
|
|
279
177
|
issues.push({ kind: 'precondition_halt', id: pc.id, check: pc.check, on_fail: pc.on_fail, submission_hint, escalated_from: 'precondition_unverified' });
|
|
280
178
|
return {
|
|
281
179
|
ok: false,
|
|
@@ -303,15 +201,13 @@ function preflight(playbook, runOpts = {}) {
|
|
|
303
201
|
ok: false,
|
|
304
202
|
blocked_by: 'precondition',
|
|
305
203
|
reason: `Precondition ${pc.id} failed: ${pc.description}`,
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
// gate (e.g. operator-owns-ci-fleet) is not a platform mismatch.
|
|
204
|
+
// Its own remediation, so the renderer prefers it to the generic
|
|
205
|
+
// platform-gate hint: an ownership gate is not a platform mismatch.
|
|
309
206
|
remediation: `Precondition ${pc.id} was submitted as false: ${pc.description} Attest it as true and re-run — submit precondition_checks {"${pc.id}": true} in your evidence JSON, or pass the owning collector's attestation flag at collect time (for example: collect cicd-pipeline-compromise --attest-ownership).`,
|
|
310
207
|
issues
|
|
311
208
|
};
|
|
312
209
|
}
|
|
313
210
|
if (strict) {
|
|
314
|
-
// Warn-level + skip_phase outcomes escalate to halt under strict.
|
|
315
211
|
issues.push({ kind: 'precondition_halt', id: pc.id, message: pc.description, escalated_from: pc.on_fail === 'skip_phase' ? 'precondition_skip' : 'precondition_warn' });
|
|
316
212
|
return {
|
|
317
213
|
ok: false,
|
|
@@ -321,10 +217,6 @@ function preflight(playbook, runOpts = {}) {
|
|
|
321
217
|
};
|
|
322
218
|
}
|
|
323
219
|
if (pc.on_fail === 'skip_phase') {
|
|
324
|
-
// Emit a skip_phase field so run() can route to a skipped-phase
|
|
325
|
-
// placeholder. Default target phase is 'detect' (the most common
|
|
326
|
-
// skip target — preconditions typically gate host-side detection).
|
|
327
|
-
// Playbooks may override via pc.skip_phase.
|
|
328
220
|
issues.push({ kind: 'precondition_skip', id: pc.id, message: pc.description, skip_phase: pc.skip_phase || 'detect' });
|
|
329
221
|
} else {
|
|
330
222
|
issues.push({ kind: 'precondition_warn', id: pc.id, message: pc.description });
|
|
@@ -332,17 +224,14 @@ function preflight(playbook, runOpts = {}) {
|
|
|
332
224
|
}
|
|
333
225
|
}
|
|
334
226
|
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
// enforced intra-process; v0.11.1 adds cross-process so two parallel CLI
|
|
338
|
-
// invocations of mutex-conflicting playbooks correctly race-detect.
|
|
227
|
+
// Both the in-process Set and the lockfile, so two parallel CLI invocations
|
|
228
|
+
// of conflicting playbooks race-detect.
|
|
339
229
|
for (const conflictId of meta.mutex || []) {
|
|
340
230
|
if (_activeRuns.has(conflictId)) {
|
|
341
231
|
return { ok: false, blocked_by: 'mutex', reason: `Mutex conflict (intra-process): playbook ${conflictId} is currently active and listed in this playbook's mutex set.`, issues };
|
|
342
232
|
}
|
|
343
233
|
const lockPath = lockFilePath(conflictId);
|
|
344
234
|
if (lockPath && fs.existsSync(lockPath)) {
|
|
345
|
-
// Stale-lock detection: if the recorded PID is dead, ignore the lock.
|
|
346
235
|
try {
|
|
347
236
|
const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
|
|
348
237
|
if (lock.pid && !pidAlive(lock.pid)) {
|
|
@@ -364,27 +253,10 @@ function preflight(playbook, runOpts = {}) {
|
|
|
364
253
|
return { ok: true, issues };
|
|
365
254
|
}
|
|
366
255
|
|
|
367
|
-
//
|
|
368
|
-
//
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
// locks dir and both run unchallenged.
|
|
372
|
-
//
|
|
373
|
-
// Resolution order (most-specific first):
|
|
374
|
-
// 1. EXCEPTD_LOCK_DIR explicit container/CI override
|
|
375
|
-
// 2. EXCEPTD_HOME || ~/.exceptd + /locks/<platform> per-user default
|
|
376
|
-
// 3. os.tmpdir()/exceptd-locks-<platform> last-resort fallback
|
|
377
|
-
// when the per-user home is non-writable (read-only home, restricted
|
|
378
|
-
// sandbox/CI runner).
|
|
379
|
-
//
|
|
380
|
-
// The per-user home (mirroring the orchestrator's watch.lock + attestation
|
|
381
|
-
// roots) is both the safer location — a per-user dir is not the
|
|
382
|
-
// world-writable shared OS tempdir a preplant/symlink attack targets — and
|
|
383
|
-
// the better fit for lockDir's stated goal: ~/.exceptd is per-user-stable
|
|
384
|
-
// across working directories, whereas the shared tmpdir is the weaker
|
|
385
|
-
// choice. The path keys on os.platform() so Windows/macOS/Linux locks live
|
|
386
|
-
// under separate directories (avoids cross-platform stale-PID confusion when
|
|
387
|
-
// a host is shared across OSes via networked FS).
|
|
256
|
+
// EXCEPTD_LOCK_DIR, then (EXCEPTD_HOME || ~/.exceptd)/locks/<platform>, then
|
|
257
|
+
// os.tmpdir() when the home is non-writable. The path must not depend on the
|
|
258
|
+
// working directory, or two invocations see different lock sets and both run
|
|
259
|
+
// unchallenged. The platform segment separates PIDs on a shared networked FS.
|
|
388
260
|
function resolveLockDir() {
|
|
389
261
|
if (process.env.EXCEPTD_LOCK_DIR) return process.env.EXCEPTD_LOCK_DIR;
|
|
390
262
|
const home = process.env.EXCEPTD_HOME || (os.homedir() && path.join(os.homedir(), '.exceptd'));
|
|
@@ -392,10 +264,8 @@ function resolveLockDir() {
|
|
|
392
264
|
const dir = path.join(home, 'locks', process.platform);
|
|
393
265
|
try {
|
|
394
266
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
// fall through to the tmpdir fallback rather than silently no-op every
|
|
398
|
-
// lock write.
|
|
267
|
+
// A home that mkdirs but cannot be written must fall through to tmpdir
|
|
268
|
+
// rather than no-op every lock write.
|
|
399
269
|
const probe = path.join(dir, `.write-probe-${process.pid}`);
|
|
400
270
|
fs.writeFileSync(probe, '');
|
|
401
271
|
fs.unlinkSync(probe);
|
|
@@ -407,9 +277,7 @@ function resolveLockDir() {
|
|
|
407
277
|
|
|
408
278
|
function lockDir() {
|
|
409
279
|
const dir = resolveLockDir();
|
|
410
|
-
// Owner-only
|
|
411
|
-
// tightening the parent directory keeps another local user from listing or
|
|
412
|
-
// tampering with this user's lock set.
|
|
280
|
+
// Owner-only: another local user must not list or tamper with this lock set.
|
|
413
281
|
try { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } catch { /* exists / EACCES — non-fatal */ }
|
|
414
282
|
return dir;
|
|
415
283
|
}
|
|
@@ -419,24 +287,13 @@ function lockFilePath(playbookId) {
|
|
|
419
287
|
catch { return null; }
|
|
420
288
|
}
|
|
421
289
|
|
|
422
|
-
// Same-PID stale-lockfile
|
|
423
|
-
//
|
|
424
|
-
// swallowed the release) older than this is presumed dead and reclaimed.
|
|
425
|
-
// 30s mirrors lib/refresh-external.js and lib/prefetch.js; long enough
|
|
426
|
-
// that no legitimate playbook hold reaches it (govern/look/run phases
|
|
427
|
-
// complete well inside one second per playbook), short enough that a
|
|
428
|
-
// wedged process recovers within one CI step rather than the rest of its
|
|
429
|
-
// lifetime.
|
|
290
|
+
// Same-PID stale-lockfile threshold, matching lib/refresh-external.js and
|
|
291
|
+
// lib/prefetch.js. No legitimate playbook hold reaches 30s.
|
|
430
292
|
const STALE_LOCK_MS = 30_000;
|
|
431
293
|
|
|
432
|
-
//
|
|
433
|
-
//
|
|
434
|
-
//
|
|
435
|
-
// must agree on it; security comes from the exclusive create (a preplanted file
|
|
436
|
-
// or symlink makes the open throw EEXIST/EPERM, never a silent follow), the
|
|
437
|
-
// 0o700 lock directory, and the 0o600 mode — written through openSync so the
|
|
438
|
-
// secure-create primitive is explicit rather than a bare writeFileSync. Throws
|
|
439
|
-
// EEXIST when the lock is already held, which every caller already handles.
|
|
294
|
+
// Exclusive owner-only create ('wx', 0o600). The file NAME is predictable by
|
|
295
|
+
// design — it IS the mutex — and safety comes from the exclusive create, which
|
|
296
|
+
// throws EEXIST/EPERM on a preplanted file or symlink rather than following it.
|
|
440
297
|
function writeLockFile(p, playbookId) {
|
|
441
298
|
const fd = fs.openSync(p, 'wx', 0o600);
|
|
442
299
|
try {
|
|
@@ -454,14 +311,9 @@ function acquireLock(playbookId) {
|
|
|
454
311
|
writePayload();
|
|
455
312
|
return p;
|
|
456
313
|
} catch (e) {
|
|
457
|
-
// Stale-PID reclaim
|
|
458
|
-
//
|
|
459
|
-
//
|
|
460
|
-
// probe with `process.kill(pid, 0)`. ESRCH means the holder is dead —
|
|
461
|
-
// unlink and retry once. EPERM (alive, different user) or any other
|
|
462
|
-
// condition: leave the lock alone and return null with a diagnostic so
|
|
463
|
-
// the caller knows acquisition failed because the lock is genuinely
|
|
464
|
-
// held (not because the FS is broken or the playbook id is malformed).
|
|
314
|
+
// Stale-PID reclaim: without it a crashed process's lockfile leaves every
|
|
315
|
+
// later invocation running UNLOCKED. ESRCH means dead; EPERM means alive
|
|
316
|
+
// under another user, and the lock stands.
|
|
465
317
|
if (e && (e.code === 'EEXIST' || e.code === 'EPERM')) {
|
|
466
318
|
try {
|
|
467
319
|
const raw = fs.readFileSync(p, 'utf8');
|
|
@@ -475,15 +327,9 @@ function acquireLock(playbookId) {
|
|
|
475
327
|
try { fs.unlinkSync(p); } catch {}
|
|
476
328
|
try { writePayload(); return p; } catch { /* fall through */ }
|
|
477
329
|
}
|
|
478
|
-
// Same-PID
|
|
479
|
-
//
|
|
480
|
-
//
|
|
481
|
-
// (e.g. nested run() within one process) must still return null
|
|
482
|
-
// so the caller knows the lock is held. A fresh same-PID lockfile
|
|
483
|
-
// is reentrancy; one older than STALE_LOCK_MS is an orphan from
|
|
484
|
-
// a crashed prior hold (or a try/catch that swallowed the release)
|
|
485
|
-
// and must be reclaimed — otherwise the process can never acquire
|
|
486
|
-
// this lock again for the rest of its lifetime.
|
|
330
|
+
// Same-PID reclaim goes by mtime: a fresh lockfile is legitimate
|
|
331
|
+
// reentrancy and stays held, while an older one is an orphan that
|
|
332
|
+
// would deny this process the lock for the rest of its lifetime.
|
|
487
333
|
if (Number.isInteger(pid) && pid === process.pid) {
|
|
488
334
|
try {
|
|
489
335
|
const stat = fs.statSync(p);
|
|
@@ -495,19 +341,13 @@ function acquireLock(playbookId) {
|
|
|
495
341
|
}
|
|
496
342
|
} catch { /* unreadable lockfile — treat as held by a live process */ }
|
|
497
343
|
}
|
|
498
|
-
//
|
|
499
|
-
// back-compat with existing call sites that test `if (!lockPath)`.
|
|
500
|
-
// Callers that want a clearer diagnostic should call
|
|
501
|
-
// `acquireLockDiagnostic` instead.
|
|
344
|
+
// Null on failure — the contract every call site tests as `if (!lockPath)`.
|
|
502
345
|
return null;
|
|
503
346
|
}
|
|
504
347
|
}
|
|
505
348
|
|
|
506
|
-
//
|
|
507
|
-
//
|
|
508
|
-
// unexpected error" can use this thin diagnostic wrapper.
|
|
509
|
-
// Returns either { ok: true, path } or { ok: false, reason, lock_path?, holder_pid? }.
|
|
510
|
-
// The bare `acquireLock` keeps its historical null-on-failure contract.
|
|
349
|
+
// acquireLock, but separating "held by a live process" from an unexpected
|
|
350
|
+
// error: { ok:true, path } or { ok:false, reason, lock_path?, holder_pid? }.
|
|
511
351
|
function acquireLockDiagnostic(playbookId) {
|
|
512
352
|
const p = lockFilePath(playbookId);
|
|
513
353
|
if (!p) return { ok: false, reason: 'no_lock_path' };
|
|
@@ -534,10 +374,7 @@ function acquireLockDiagnostic(playbookId) {
|
|
|
534
374
|
return { ok: false, reason: 'reclaim_failed', error: e2.message, lock_path: p, holder_pid: pid };
|
|
535
375
|
}
|
|
536
376
|
}
|
|
537
|
-
// Same-PID
|
|
538
|
-
// semantics as in acquireLock: a same-process lockfile older than
|
|
539
|
-
// STALE_LOCK_MS is an orphan and must be reclaimed; a fresher one
|
|
540
|
-
// is legitimate reentrancy and stays held.
|
|
377
|
+
// Same-PID reclaim, same mtime semantics as acquireLock.
|
|
541
378
|
if (Number.isInteger(pid) && pid === process.pid) {
|
|
542
379
|
let mtimeMs = null;
|
|
543
380
|
try { mtimeMs = fs.statSync(p).mtimeMs; } catch {}
|
|
@@ -569,20 +406,12 @@ function pidAlive(pid) {
|
|
|
569
406
|
catch (e) { return e.code !== 'ESRCH'; }
|
|
570
407
|
}
|
|
571
408
|
|
|
572
|
-
//
|
|
573
|
-
|
|
574
|
-
/**
|
|
575
|
-
* Load GRC context for the agent. Returns jurisdiction obligations (with
|
|
576
|
-
* window_hours + clock_starts so close() can compute deadlines later), theater
|
|
577
|
-
* fingerprints, framework gap summary, and skills to preload.
|
|
578
|
-
*/
|
|
409
|
+
// Phase 1. GRC context for the agent. Jurisdiction obligations carry
|
|
410
|
+
// window_hours + clock_starts because close() computes deadlines from them.
|
|
579
411
|
function govern(playbookId, directiveId, runOpts = {}) {
|
|
580
412
|
const playbook = runOpts._playbookCache || loadPlaybook(playbookId);
|
|
581
413
|
const g = resolvedPhase(playbook, directiveId, 'govern');
|
|
582
|
-
//
|
|
583
|
-
// tightest deadline (e.g. DORA's 4h, NIS2's 24h, GDPR's 72h) surfaces
|
|
584
|
-
// first. Operators reading the govern output for ack-time briefing need
|
|
585
|
-
// the most urgent clock at the top of the list.
|
|
414
|
+
// Ascending window_hours: the tightest clock heads the list.
|
|
586
415
|
const obligations = (g.jurisdiction_obligations || []).slice().sort((a, b) => {
|
|
587
416
|
const aw = (a && typeof a.window_hours === 'number') ? a.window_hours : Number.POSITIVE_INFINITY;
|
|
588
417
|
const bw = (b && typeof b.window_hours === 'number') ? b.window_hours : Number.POSITIVE_INFINITY;
|
|
@@ -600,16 +429,11 @@ function govern(playbookId, directiveId, runOpts = {}) {
|
|
|
600
429
|
theater_fingerprints: g.theater_fingerprints || [],
|
|
601
430
|
framework_context: g.framework_context || {},
|
|
602
431
|
skill_preload: g.skill_preload || [],
|
|
603
|
-
//
|
|
604
|
-
// the jurisdiction_obligations surfaced here). Carry it forward so
|
|
605
|
-
// phases.govern.operator_consent reflects the consent state. Null when
|
|
606
|
-
// --ack was not passed.
|
|
432
|
+
// --ack acknowledges the obligations surfaced here; null when not passed.
|
|
607
433
|
operator_consent: runOpts.operator_consent || null
|
|
608
434
|
};
|
|
609
435
|
}
|
|
610
436
|
|
|
611
|
-
// --- phase 2: direct ---
|
|
612
|
-
|
|
613
437
|
function direct(playbookId, directiveId, runOpts = {}) {
|
|
614
438
|
const playbook = runOpts._playbookCache || loadPlaybook(playbookId);
|
|
615
439
|
const d = resolvedPhase(playbook, directiveId, 'direct');
|
|
@@ -625,8 +449,6 @@ function direct(playbookId, directiveId, runOpts = {}) {
|
|
|
625
449
|
};
|
|
626
450
|
}
|
|
627
451
|
|
|
628
|
-
// --- phase 3: look (engine emits, agent executes) ---
|
|
629
|
-
|
|
630
452
|
function look(playbookId, directiveId, runOpts = {}) {
|
|
631
453
|
const playbook = runOpts._playbookCache || loadPlaybook(playbookId);
|
|
632
454
|
const l = resolvedPhase(playbook, directiveId, 'look');
|
|
@@ -636,11 +458,8 @@ function look(playbookId, directiveId, runOpts = {}) {
|
|
|
636
458
|
playbook_id: playbookId,
|
|
637
459
|
directive_id: directiveId,
|
|
638
460
|
air_gap_mode: airGap,
|
|
639
|
-
//
|
|
640
|
-
//
|
|
641
|
-
// back through submission.precondition_checks. Without this list, the AI
|
|
642
|
-
// is blind to the gate and run() will halt with a precondition_unverified
|
|
643
|
-
// failure the AI can't diagnose. See AGENTS.md Hard Rule context.
|
|
461
|
+
// The host AI probes these itself and declares the results through
|
|
462
|
+
// submission.precondition_checks; without the list it is blind to the gate.
|
|
644
463
|
preconditions: (playbook._meta.preconditions || []).map(pc => ({
|
|
645
464
|
id: pc.id,
|
|
646
465
|
description: pc.description,
|
|
@@ -653,14 +472,10 @@ function look(playbookId, directiveId, runOpts = {}) {
|
|
|
653
472
|
},
|
|
654
473
|
artifacts: (l.artifacts || []).map(a => ({
|
|
655
474
|
...a,
|
|
656
|
-
// Surface the air-gap alternative as the primary source when air_gap_mode
|
|
657
|
-
// is active, so the agent doesn't accidentally hit the network.
|
|
658
475
|
source: airGap && a.air_gap_alternative ? a.air_gap_alternative : a.source,
|
|
659
476
|
_original_source: a.source,
|
|
660
|
-
//
|
|
661
|
-
//
|
|
662
|
-
// signal. Flag it so the agent treats the source as not-offline-verified
|
|
663
|
-
// and the gap is observable instead of a silent network fallback.
|
|
477
|
+
// An artifact with no alternative keeps its possibly network-bound
|
|
478
|
+
// source, so the flag marks it not-offline-verified.
|
|
664
479
|
...(airGap && !a.air_gap_alternative ? { air_gap_alternative_missing: true } : {}),
|
|
665
480
|
})),
|
|
666
481
|
collection_scope: l.collection_scope,
|
|
@@ -669,16 +484,12 @@ function look(playbookId, directiveId, runOpts = {}) {
|
|
|
669
484
|
};
|
|
670
485
|
}
|
|
671
486
|
|
|
672
|
-
// --- phase 4: detect ---
|
|
673
|
-
|
|
674
487
|
/**
|
|
675
|
-
*
|
|
676
|
-
* indicators
|
|
488
|
+
* Phase 4. Evaluates submitted artifacts against the playbook's typed
|
|
489
|
+
* indicators: a per-indicator hit/miss/inconclusive verdict plus a
|
|
677
490
|
* minimum_signal classification (detected | inconclusive | not_detected).
|
|
678
|
-
*
|
|
679
|
-
*
|
|
680
|
-
* and (optionally) `signal_overrides` as { indicator_id: 'hit'|'miss'|'inconclusive' } to
|
|
681
|
-
* record an indicator outcome the agent computed using its own pattern matching.
|
|
491
|
+
* The agent submits `artifacts` as { artifact_id: { value, captured, reason? } }
|
|
492
|
+
* and optionally `signal_overrides` as { indicator_id: verdict }.
|
|
682
493
|
*/
|
|
683
494
|
function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
684
495
|
const playbook = runOpts._playbookCache || loadPlaybook(playbookId);
|
|
@@ -686,13 +497,8 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
686
497
|
const artifacts = agentSubmission.artifacts || {};
|
|
687
498
|
const overrides = agentSubmission.signal_overrides || {};
|
|
688
499
|
|
|
689
|
-
//
|
|
690
|
-
//
|
|
691
|
-
// CI/security tooling convention; the engine internally uses
|
|
692
|
-
// hit | miss | inconclusive. Without canonicalization every flat-shape
|
|
693
|
-
// observation with result:"no_hit" silently fell through to inconclusive
|
|
694
|
-
// and broke per-indicator detection. Canonicalization happens here so
|
|
695
|
-
// both detect() and normalizeSubmission consumers see the same outcomes.
|
|
500
|
+
// Operators submit the CI/security vocabulary ("no_hit", "clean", "ok"); the
|
|
501
|
+
// engine speaks hit | miss | inconclusive.
|
|
696
502
|
const canonicalize = (v) => {
|
|
697
503
|
if (v === true || v === 'hit' || v === 'detected' || v === 'positive') return 'hit';
|
|
698
504
|
if (v === false || v === 'miss' || v === 'no_hit' || v === 'no-hit' || v === 'clean' || v === 'clear' || v === 'not_hit' || v === 'ok' || v === 'pass' || v === 'negative') return 'miss';
|
|
@@ -700,19 +506,9 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
700
506
|
return null; // truly unknown — fall through
|
|
701
507
|
};
|
|
702
508
|
|
|
703
|
-
//
|
|
704
|
-
//
|
|
705
|
-
//
|
|
706
|
-
// indicator have been satisfied. An unverified FP check downgrades the
|
|
707
|
-
// verdict from 'hit' to 'inconclusive' and surfaces fp_checks_unsatisfied
|
|
708
|
-
// on the per-indicator result. See AGENTS.md Hard Rule #6 (compliance
|
|
709
|
-
// theater) and AGENTS.md §"detect (AI)" — a `hit` without its FP checks
|
|
710
|
-
// is not yet a `detected` classification.
|
|
711
|
-
// Optional per-indicator evidence locations the submission supplies
|
|
712
|
-
// (`evidence_locations: { "<indicator-id>": ["path", {uri,startLine}] }`).
|
|
713
|
-
// Threaded onto each firing indicator so the SARIF renderer can populate
|
|
714
|
-
// results[].locations — without this, secret/file findings ship
|
|
715
|
-
// location-less and GitHub code-scanning drops them.
|
|
509
|
+
// `evidence_locations: { "<indicator-id>": ["path", {uri,startLine}] }`,
|
|
510
|
+
// threaded onto each firing indicator so the SARIF renderer can fill
|
|
511
|
+
// results[].locations — GitHub code-scanning drops a location-less result.
|
|
716
512
|
const _evLocs = (agentSubmission && typeof agentSubmission.evidence_locations === 'object'
|
|
717
513
|
&& agentSubmission.evidence_locations !== null && !Array.isArray(agentSubmission.evidence_locations))
|
|
718
514
|
? agentSubmission.evidence_locations : {};
|
|
@@ -723,35 +519,22 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
723
519
|
let fpChecksUnsatisfied = null;
|
|
724
520
|
if (override === 'hit' || override === 'miss' || override === 'inconclusive') {
|
|
725
521
|
verdict = override;
|
|
726
|
-
//
|
|
727
|
-
//
|
|
728
|
-
//
|
|
729
|
-
// attestation) treats every required FP check as UNSATISFIED.
|
|
522
|
+
// A 'hit' is gated on the indicator's false_positive_checks_required,
|
|
523
|
+
// attested through a sibling signal_overrides key '<id>__fp_checks'. No
|
|
524
|
+
// attestation leaves every check UNSATISFIED and downgrades the verdict.
|
|
730
525
|
if (verdict === 'hit' && Array.isArray(ind.false_positive_checks_required) && ind.false_positive_checks_required.length) {
|
|
731
|
-
// A
|
|
732
|
-
//
|
|
733
|
-
// required check; an exception inside the read would crash detect()
|
|
734
|
-
// and abort the entire run. Wrap the FP-check evaluation in a
|
|
735
|
-
// try/catch: on throw, treat ALL required checks as unsatisfied
|
|
736
|
-
// (safest default — never silently honor an attestation we couldn't
|
|
737
|
-
// read) and surface a runtime_error so the operator sees why.
|
|
526
|
+
// A Proxy attestation whose getters throw must not abort the run: on
|
|
527
|
+
// throw every check is unsatisfied — never honor an unreadable one.
|
|
738
528
|
try {
|
|
739
529
|
const attestation = overrides[`${ind.id}__fp_checks`];
|
|
740
|
-
//
|
|
741
|
-
//
|
|
742
|
-
//
|
|
743
|
-
// would otherwise have its truthy entries matched via the index
|
|
744
|
-
// fallback (att['0'] === true), silently bypassing every FP-check
|
|
745
|
-
// requirement. Reject arrays explicitly so they fall through to
|
|
746
|
-
// the empty-attestation branch (every required check
|
|
747
|
-
// unsatisfied).
|
|
530
|
+
// An array satisfies `typeof === 'object'` but is not an attestation
|
|
531
|
+
// map: `[true, true]` would match through the index fallback below
|
|
532
|
+
// and bypass every FP-check requirement.
|
|
748
533
|
const safeAtt = Array.isArray(attestation) ? null : attestation;
|
|
749
534
|
const att = (safeAtt && typeof safeAtt === 'object') ? safeAtt : {};
|
|
750
535
|
const unsatisfied = ind.false_positive_checks_required.filter(fpName => {
|
|
751
|
-
//
|
|
752
|
-
//
|
|
753
|
-
// strings, not ids. Operators may attest either by the literal
|
|
754
|
-
// string or by index. Default: unsatisfied.
|
|
536
|
+
// The entries are free text, not ids, so an operator may attest by
|
|
537
|
+
// the literal string or by index. Anything else is unsatisfied.
|
|
755
538
|
if (att[fpName] === true) return false;
|
|
756
539
|
const idx = ind.false_positive_checks_required.indexOf(fpName);
|
|
757
540
|
if (idx !== -1 && att[String(idx)] === true) return false;
|
|
@@ -762,10 +545,6 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
762
545
|
fpChecksUnsatisfied = unsatisfied;
|
|
763
546
|
}
|
|
764
547
|
} catch (e) {
|
|
765
|
-
// Treat every required check as unsatisfied — we couldn't trust the
|
|
766
|
-
// attestation map. Surface the throw so operators can chase the
|
|
767
|
-
// root cause (Proxy with a throwing getter, frozen object that
|
|
768
|
-
// tripped invariants, etc.).
|
|
769
548
|
verdict = 'inconclusive';
|
|
770
549
|
fpChecksUnsatisfied = ind.false_positive_checks_required.slice();
|
|
771
550
|
if (runOpts && Array.isArray(runOpts._runErrors)) {
|
|
@@ -778,12 +557,8 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
778
557
|
}
|
|
779
558
|
}
|
|
780
559
|
} else {
|
|
781
|
-
// An override
|
|
782
|
-
//
|
|
783
|
-
// number). Pre-fix this was silently dropped — the operator believed
|
|
784
|
-
// they'd asserted a result but the signal vanished, yielding a false
|
|
785
|
-
// not_detected. Surface it as a runtime_error (mirrors the
|
|
786
|
-
// classification_override_invalid signal) so the drop is visible.
|
|
560
|
+
// An override that did not canonicalize ("maybe", a number). Dropping it
|
|
561
|
+
// silently turns an asserted result into a false not_detected.
|
|
787
562
|
if (rawOverride !== undefined && runOpts && Array.isArray(runOpts._runErrors)) {
|
|
788
563
|
pushRunError(runOpts._runErrors, {
|
|
789
564
|
kind: 'signal_override_unrecognized',
|
|
@@ -792,12 +567,9 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
792
567
|
message: `signal_overrides["${ind.id}"] value was not recognized (expected hit/miss/inconclusive or a boolean); the signal was ignored.`,
|
|
793
568
|
}, { dedupeKey: e => e.indicator_id || '' });
|
|
794
569
|
}
|
|
795
|
-
//
|
|
796
|
-
// the indicator
|
|
797
|
-
//
|
|
798
|
-
// host AI is responsible for that). With NO captured artifacts, this is
|
|
799
|
-
// a clean empty submission — emit 'miss' so the run can reach
|
|
800
|
-
// classification:'not_detected' rather than getting stuck inconclusive.
|
|
570
|
+
// The engine does not pattern-match raw artifact content, so a captured
|
|
571
|
+
// artifact means only that the indicator COULD be evaluated. Nothing
|
|
572
|
+
// captured is an empty submission, which 'miss' lets reach not_detected.
|
|
801
573
|
const anyCaptured = Object.values(artifacts).some(a => a && a.captured);
|
|
802
574
|
verdict = anyCaptured ? 'inconclusive' : 'miss';
|
|
803
575
|
}
|
|
@@ -811,8 +583,7 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
811
583
|
};
|
|
812
584
|
});
|
|
813
585
|
|
|
814
|
-
//
|
|
815
|
-
// should still run against any indicator the agent reported as 'hit'.
|
|
586
|
+
// The FP tests the agent still owes for each indicator it reported as 'hit'.
|
|
816
587
|
const fpChecksRequired = (det.false_positive_profile || []).filter(fp =>
|
|
817
588
|
indicatorResults.find(r => r.id === fp.indicator_id && r.verdict === 'hit')
|
|
818
589
|
);
|
|
@@ -821,20 +592,12 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
821
592
|
const hasDeterministicHit = hits.some(r => r.deterministic);
|
|
822
593
|
const hasHighConfHit = hits.some(r => r.confidence === 'high' || r.confidence === 'deterministic');
|
|
823
594
|
|
|
824
|
-
//
|
|
825
|
-
//
|
|
826
|
-
// classification as a fallback. Use the override when the agent has run the
|
|
827
|
-
// full false_positive_profile checks and reached an explicit verdict —
|
|
828
|
-
// engine-computed classification can't represent "I saw the indicators and
|
|
829
|
-
// confirmed they're all benign" without this override.
|
|
595
|
+
// The agent's own verdict after running the full false_positive_profile: the
|
|
596
|
+
// engine classification cannot express "I saw these and they are all benign".
|
|
830
597
|
const rawOverride = (agentSubmission.signals && agentSubmission.signals.detection_classification);
|
|
831
598
|
const validOverrides = new Set(['detected', 'inconclusive', 'not_detected', 'clean']);
|
|
832
|
-
//
|
|
833
|
-
//
|
|
834
|
-
// runtime_error rather than silently falling through to engine-computed
|
|
835
|
-
// classification. Operators submitting case variants / whitespace-padded
|
|
836
|
-
// strings deserve a clear diagnostic, not a quiet downgrade. Treat the
|
|
837
|
-
// override as absent for classification purposes once recorded.
|
|
599
|
+
// Matching is exact — case-sensitive, unpadded. A near-miss surfaces a
|
|
600
|
+
// runtime_error and is then treated as absent.
|
|
838
601
|
const overrideIsString = typeof rawOverride === 'string';
|
|
839
602
|
const overrideIsInAllowlist = overrideIsString && validOverrides.has(rawOverride);
|
|
840
603
|
if (rawOverride !== undefined && rawOverride !== null && !overrideIsInAllowlist) {
|
|
@@ -849,28 +612,19 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
849
612
|
}
|
|
850
613
|
const override = overrideIsInAllowlist ? rawOverride : undefined;
|
|
851
614
|
|
|
852
|
-
//
|
|
853
|
-
//
|
|
854
|
-
//
|
|
855
|
-
// which maps to `'not_detected'` at this site) MUST NOT hide a
|
|
856
|
-
// `verdict: 'hit'` indicator whose `false_positive_checks_required[]`
|
|
857
|
-
// were unattested — that's a strictly worse false-negative outcome than
|
|
858
|
-
// allowing 'detected' through. Substitute 'inconclusive' and emit a
|
|
859
|
-
// runtime_error.
|
|
860
|
-
// Record indicator IDs and an unsatisfied-checks count ONLY — never the
|
|
861
|
-
// literal FP-check check-name strings (those are an attestation-bypass
|
|
862
|
-
// hint for a hostile agent reading the runtime_errors).
|
|
615
|
+
// Every classification override is refused once any indicator was
|
|
616
|
+
// FP-downgraded. The runtime_error records indicator ids and an unsatisfied
|
|
617
|
+
// count only: the literal check names are an attestation-bypass hint.
|
|
863
618
|
const anyFpDowngrade = indicatorResults.some(r => Array.isArray(r.fp_checks_unsatisfied) && r.fp_checks_unsatisfied.length > 0);
|
|
864
619
|
|
|
865
620
|
let classification;
|
|
866
|
-
//
|
|
867
|
-
// refused override as "applied" (L6).
|
|
621
|
+
// A refused override must not be reported as applied.
|
|
868
622
|
let overrideEffective = !!override;
|
|
869
623
|
if (override) {
|
|
870
624
|
classification = override === 'clean' ? 'not_detected' : override;
|
|
871
625
|
if (anyFpDowngrade) {
|
|
872
626
|
const substituted = 'inconclusive';
|
|
873
|
-
const attempted = override; //
|
|
627
|
+
const attempted = override; // what the operator submitted, not the mapped form
|
|
874
628
|
classification = substituted;
|
|
875
629
|
overrideEffective = false;
|
|
876
630
|
if (runOpts && Array.isArray(runOpts._runErrors)) {
|
|
@@ -885,12 +639,9 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
885
639
|
}, { dedupeKey: e => String(e.attempted) });
|
|
886
640
|
}
|
|
887
641
|
} else if (classification === 'not_detected' && hasDeterministicHit) {
|
|
888
|
-
// A
|
|
889
|
-
//
|
|
890
|
-
//
|
|
891
|
-
// run inconclusive. (Probabilistic hits remain overridable — that's the
|
|
892
|
-
// legitimate "I confirmed these are benign" workflow.) Substitute
|
|
893
|
-
// inconclusive and surface why.
|
|
642
|
+
// A deterministic hit cannot be buried under not_detected/clean — a
|
|
643
|
+
// worse false negative than an inconclusive run. Probabilistic hits stay
|
|
644
|
+
// overridable; that is the legitimate "these are benign" path.
|
|
894
645
|
classification = 'inconclusive';
|
|
895
646
|
overrideEffective = false;
|
|
896
647
|
if (runOpts && Array.isArray(runOpts._runErrors)) {
|
|
@@ -919,57 +670,31 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
919
670
|
false_positive_checks_required: fpChecksRequired,
|
|
920
671
|
classification,
|
|
921
672
|
minimum_signal_basis: det.minimum_signal?.[classification === 'detected' ? 'detected' : classification === 'not_detected' ? 'not_detected' : 'inconclusive'],
|
|
922
|
-
//
|
|
923
|
-
// the detect output now see whether their flat-shape observations + the
|
|
924
|
-
// signal_overrides + the classification override all reached the runner.
|
|
673
|
+
// What detect consumed, so an operator can see what reached the runner.
|
|
925
674
|
observations_received: Object.keys(agentSubmission.artifacts || {}),
|
|
926
675
|
signals_received: Object.keys(agentSubmission.signal_overrides || {}),
|
|
927
|
-
//
|
|
928
|
-
// expect an array, not a count. Restore as array; provide
|
|
929
|
-
// `indicators_evaluated_count` for callers wanting the integer.
|
|
676
|
+
// An array — indicators_evaluated_count carries the integer.
|
|
930
677
|
indicators_evaluated: indicatorResults.map(i => ({
|
|
931
678
|
signal_id: i.id,
|
|
932
679
|
outcome: i.verdict,
|
|
933
680
|
confidence: i.confidence,
|
|
934
|
-
//
|
|
935
|
-
// outcome (when the agent submitted it via flat-shape observation +
|
|
936
|
-
// indicator + result fields). Null when no observation drove the
|
|
937
|
-
// indicator (engine-computed default).
|
|
681
|
+
// The observation behind this outcome; null on the engine default.
|
|
938
682
|
from_observation: agentSubmission._signal_origins?.[i.id] || null,
|
|
939
683
|
})),
|
|
940
684
|
indicators_evaluated_count: indicatorResults.length,
|
|
941
|
-
//
|
|
942
|
-
// refused override (FP-downgrade or deterministic-hit masking) left
|
|
943
|
-
// `classification` as inconclusive, so reporting the attempted value here
|
|
944
|
-
// would contradict the verdict.
|
|
685
|
+
// Null for a refused override, which would contradict the verdict.
|
|
945
686
|
classification_override_applied: (override && overrideEffective) ? (override === 'clean' ? 'not_detected' : override) : null,
|
|
946
687
|
submission_shape_seen: agentSubmission._original_shape || (agentSubmission.artifacts ? 'nested (v0.10.x)' : 'empty'),
|
|
947
|
-
//
|
|
948
|
-
// normalize time so analyze() can publish them under
|
|
949
|
-
// analyze.signal_origins_with_collisions.
|
|
688
|
+
// Republished by analyze() as analyze.signal_origins_with_collisions.
|
|
950
689
|
_signal_origins_collisions: Array.isArray(agentSubmission._signal_origins_collisions) ? agentSubmission._signal_origins_collisions.slice() : []
|
|
951
690
|
};
|
|
952
691
|
}
|
|
953
692
|
|
|
954
|
-
//
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
* eval contexts. Indicator hits arrive from the collector / AI evidence path as
|
|
960
|
-
* `signal_overrides` (which detect() reads); the condition contexts spread
|
|
961
|
-
* `agentSubmission.signals`, NOT signal_overrides — so an escalation written as
|
|
962
|
-
* `<indicator-id> == true` (the catalog's canonical form) stayed false even when
|
|
963
|
-
* that indicator DETECTED, unless the operator redundantly re-submitted the id
|
|
964
|
-
* under `signals`. The standard `exceptd collect <playbook>` path never does
|
|
965
|
-
* that, so the entire indicator-gated escalation/chain layer was dead on the
|
|
966
|
-
* real evidence path. Mirroring fired indicators here binds the condition layer
|
|
967
|
-
* to the detect layer. Only verdict 'hit' (FP-checks satisfied) is mirrored — an
|
|
968
|
-
* 'inconclusive' indicator is unconfirmed and must not fire an escalation. The
|
|
969
|
-
* map is spread with the LOWEST precedence (before agentSignals, which is before
|
|
970
|
-
* the engine roots) so an operator can still override a specific id and the
|
|
971
|
-
* engine-computed values always win.
|
|
972
|
-
*/
|
|
693
|
+
// Mirror the fired detect indicators into a flat `{ <indicator-id>: true }` map
|
|
694
|
+
// for the escalation / feeds_into / precondition contexts, which spread
|
|
695
|
+
// `signals` and not `signal_overrides`. Only verdict 'hit' mirrors: an
|
|
696
|
+
// 'inconclusive' indicator is unconfirmed and must not fire an escalation.
|
|
697
|
+
// Spread at the LOWEST precedence, so engine values still win.
|
|
973
698
|
function firedIndicatorSignals(detectIndicators) {
|
|
974
699
|
const out = {};
|
|
975
700
|
for (const ind of (detectIndicators || [])) {
|
|
@@ -978,66 +703,31 @@ function firedIndicatorSignals(detectIndicators) {
|
|
|
978
703
|
return out;
|
|
979
704
|
}
|
|
980
705
|
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
* mapping + escalation evaluation. Inputs are the detect result + any
|
|
984
|
-
* agent-submitted signal_values (e.g. blast_radius classification).
|
|
985
|
-
*/
|
|
706
|
+
// Phase 5. RWEP composition, blast-radius scoring, theater check, framework gap
|
|
707
|
+
// mapping and escalation evaluation, from the detect result plus agent signals.
|
|
986
708
|
function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOpts = {}) {
|
|
987
709
|
const playbook = runOpts._playbookCache || loadPlaybook(playbookId);
|
|
988
710
|
const an = resolvedPhase(playbook, directiveId, 'analyze');
|
|
989
711
|
const directive = findDirective(playbook, directiveId);
|
|
990
|
-
//
|
|
991
|
-
//
|
|
992
|
-
// local array so blast_radius / theater / xref errors surface in the
|
|
993
|
-
// returned analyze.runtime_errors.
|
|
712
|
+
// A direct analyze() call carries no accumulator, and without a local one
|
|
713
|
+
// blast_radius / theater / xref errors never reach analyze.runtime_errors.
|
|
994
714
|
if (!Array.isArray(runOpts._runErrors)) {
|
|
995
715
|
runOpts = { ...runOpts, _runErrors: [] };
|
|
996
716
|
}
|
|
997
717
|
|
|
998
|
-
//
|
|
999
|
-
//
|
|
1000
|
-
//
|
|
1001
|
-
//
|
|
1002
|
-
//
|
|
1003
|
-
// Two distinct sets are computed below:
|
|
1004
|
-
//
|
|
1005
|
-
// catalogBaselineCves — every CVE the playbook scans for, with full
|
|
1006
|
-
// per-CVE catalog context (RWEP / KEV / CVSS / AI-discovery /
|
|
1007
|
-
// active-exploitation / patch state). Always populated when the
|
|
1008
|
-
// playbook has domain.cve_refs. Each entry carries correlated_via=null
|
|
1009
|
-
// and a `note` flagging it as catalog-only.
|
|
1010
|
-
//
|
|
1011
|
-
// matchedCves — CVEs the operator's submitted evidence actually
|
|
1012
|
-
// correlates to. Correlation paths:
|
|
1013
|
-
// (a) An indicator fired (verdict === 'hit') whose attack_ref or
|
|
1014
|
-
// atlas_ref intersects the CVE's attack_refs / atlas_refs in
|
|
1015
|
-
// the catalog.
|
|
1016
|
-
// (b) An agentSignal explicitly references the CVE id with a
|
|
1017
|
-
// truthy value (`agentSignals[cveId] === true`) or with a
|
|
1018
|
-
// string value 'hit' / 'detected' / 'affected'.
|
|
1019
|
-
// Each entry carries correlated_via=<reason string> so downstream
|
|
1020
|
-
// consumers (CSAF / SARIF / OpenVEX / human renderer) can show the
|
|
1021
|
-
// provenance, and so an empty matchedCves means "no evidence
|
|
1022
|
-
// correlated to operator's submission" rather than "playbook has
|
|
1023
|
-
// no CVEs of interest."
|
|
1024
|
-
//
|
|
1025
|
-
// VEX filter (agentSignals.vex_filter): a set of CVE IDs the operator has
|
|
1026
|
-
// formally declared not_affected via CycloneDX/OpenVEX. VEX-dropped CVEs
|
|
1027
|
-
// are removed from BOTH arrays (they're not affected — neither correlated
|
|
1028
|
-
// nor part of effective scan coverage for this run).
|
|
718
|
+
// domain.cve_refs enumerates the playbook's scan coverage, never a claim that
|
|
719
|
+
// the operator is affected. catalogBaselineCves is all of it with
|
|
720
|
+
// correlated_via null; matchedCves is the subset the evidence correlates to,
|
|
721
|
+
// and correlated_via names what tied them. agentSignals.vex_filter removes
|
|
722
|
+
// entries from BOTH arrays.
|
|
1029
723
|
const cveRefs = playbook.domain.cve_refs || [];
|
|
1030
724
|
const vexFilter = agentSignals.vex_filter instanceof Set ? agentSignals.vex_filter
|
|
1031
725
|
: (Array.isArray(agentSignals.vex_filter) ? new Set(agentSignals.vex_filter) : null);
|
|
1032
|
-
//
|
|
1033
|
-
//
|
|
1034
|
-
// (fixed / resolved). vexFilterFromDoc returns the union; the "fixed" set
|
|
1035
|
-
// is computed below from agentSignals.vex_fixed when the operator passes
|
|
1036
|
-
// it (CLI populates it from the VEX doc alongside vex_filter).
|
|
726
|
+
// not_affected / false_positive drop entirely; fixed / resolved are kept and
|
|
727
|
+
// annotated. The CLI populates vex_fixed from the VEX doc.
|
|
1037
728
|
const vexFixed = agentSignals.vex_fixed instanceof Set ? agentSignals.vex_fixed
|
|
1038
729
|
: (Array.isArray(agentSignals.vex_fixed) ? new Set(agentSignals.vex_fixed) : null);
|
|
1039
|
-
//
|
|
1040
|
-
// anomaly) surfaces as a runtime_error rather than crashing analyze().
|
|
730
|
+
// A corrupt catalog or missing index becomes a runtime_error, not a crash.
|
|
1041
731
|
const _byCveSafe = (id) => {
|
|
1042
732
|
try { return xref.byCve(id); }
|
|
1043
733
|
catch (e) {
|
|
@@ -1054,24 +744,19 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
1054
744
|
const vexDropped = vexFilter
|
|
1055
745
|
? allCves.filter(c => vexFilter.has(c.cve_id)).map(c => c.cve_id)
|
|
1056
746
|
: [];
|
|
1057
|
-
//
|
|
1058
|
-
//
|
|
1059
|
-
// Source from catalogBaselineCves (post-vexFilter survivors), NOT allCves: a
|
|
1060
|
-
// CVE the operator marked BOTH not_affected (dropped by vexFilter) AND fixed
|
|
1061
|
-
// is contradictory, and listing it as fixed while it's absent from
|
|
1062
|
-
// matched/baseline would have it appear in fixed_cves but nowhere else.
|
|
747
|
+
// From the post-vexFilter survivors, not allCves: a CVE marked BOTH
|
|
748
|
+
// not_affected and fixed would land in fixed_cves and nowhere else.
|
|
1063
749
|
const vexFixedIds = vexFixed
|
|
1064
750
|
? catalogBaselineCves.filter(c => vexFixed.has(c.cve_id)).map(c => c.cve_id)
|
|
1065
751
|
: [];
|
|
1066
752
|
|
|
1067
|
-
//
|
|
753
|
+
// cve_id -> array of "indicator_hit:<id>" / "signal:<id>" reasons.
|
|
1068
754
|
const correlationsByCve = new Map();
|
|
1069
755
|
const addCorrelation = (cveId, reason) => {
|
|
1070
756
|
if (!correlationsByCve.has(cveId)) correlationsByCve.set(cveId, []);
|
|
1071
757
|
const arr = correlationsByCve.get(cveId);
|
|
1072
758
|
if (!arr.includes(reason)) arr.push(reason);
|
|
1073
759
|
};
|
|
1074
|
-
// (a) indicator-hit → CVE via shared attack_ref / atlas_ref.
|
|
1075
760
|
const playbookDetect = resolvedPhase(playbook, directiveId, 'detect');
|
|
1076
761
|
const indicatorRefs = new Map(); // indicator.id -> { attack_ref, atlas_ref }
|
|
1077
762
|
for (const ind of (playbookDetect.indicators || [])) {
|
|
@@ -1087,7 +772,6 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
1087
772
|
if (attackHit || atlasHit) addCorrelation(c.cve_id, `indicator_hit:${fired.id}`);
|
|
1088
773
|
}
|
|
1089
774
|
}
|
|
1090
|
-
// (b) agentSignals explicitly referencing a CVE id.
|
|
1091
775
|
for (const c of catalogBaselineCves) {
|
|
1092
776
|
const sig = agentSignals[c.cve_id];
|
|
1093
777
|
if (sig === true || sig === 'hit' || sig === 'detected' || sig === 'affected') {
|
|
@@ -1095,14 +779,9 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
1095
779
|
}
|
|
1096
780
|
}
|
|
1097
781
|
|
|
1098
|
-
//
|
|
1099
|
-
//
|
|
1100
|
-
//
|
|
1101
|
-
// in the catalog, the CVE joins matched_cves with correlated_via=
|
|
1102
|
-
// 'indicator_cve_ref:<indicator-id>'. The catalog lookup also brings in
|
|
1103
|
-
// CVEs the playbook didn't enumerate in domain.cve_refs — they're appended
|
|
1104
|
-
// to the working catalog set so the downstream matchedCves filter picks
|
|
1105
|
-
// them up. Dedupe is automatic via correlationsByCve (Map keyed on cve_id).
|
|
782
|
+
// An indicator's own cve_ref (string or string[]), correlated as
|
|
783
|
+
// 'indicator_cve_ref:<indicator-id>'. This reaches CVEs domain.cve_refs
|
|
784
|
+
// never enumerated, which join the working set for the filter below.
|
|
1106
785
|
const extraCatalogCves = [];
|
|
1107
786
|
const seenCatalogIds = new Set(catalogBaselineCves.map(c => c.cve_id));
|
|
1108
787
|
for (const fired of firedIndicators) {
|
|
@@ -1111,7 +790,6 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
1111
790
|
const raw = indicator.cve_ref;
|
|
1112
791
|
const refs = Array.isArray(raw) ? raw : (typeof raw === 'string' && raw ? [raw] : []);
|
|
1113
792
|
for (const cveId of refs) {
|
|
1114
|
-
// VEX-drop these the same as catalog CVEs.
|
|
1115
793
|
if (vexFilter && vexFilter.has(cveId)) continue;
|
|
1116
794
|
let cveEntry = catalogBaselineCves.find(c => c.cve_id === cveId);
|
|
1117
795
|
if (!cveEntry) {
|
|
@@ -1129,24 +807,15 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
1129
807
|
|
|
1130
808
|
const matchedCves = workingCatalogCves.filter(c => correlationsByCve.has(c.cve_id));
|
|
1131
809
|
|
|
1132
|
-
//
|
|
1133
|
-
// so consumers can iterate either without branching. matched_cves entries
|
|
1134
|
-
// carry a non-null correlated_via array; catalog_baseline_cves entries
|
|
1135
|
-
// carry correlated_via:null and a `note` clarifying the field's intent.
|
|
810
|
+
// One shape for both arrays, so consumers iterate either without branching.
|
|
1136
811
|
const cveShape = (c, correlatedVia) => {
|
|
1137
|
-
//
|
|
1138
|
-
// includes them so audit trails and SBOM reports surface "we know this
|
|
1139
|
-
// is in scope but vendor declared it fixed."
|
|
812
|
+
// VEX-fixed CVEs stay in matched_cves so the audit trail keeps them.
|
|
1140
813
|
const vexStatus = (vexFixed && vexFixed.has(c.cve_id)) ? 'fixed' : null;
|
|
1141
814
|
return {
|
|
1142
815
|
cve_id: c.cve_id,
|
|
1143
|
-
//
|
|
1144
|
-
//
|
|
1145
|
-
//
|
|
1146
|
-
// (`any matched_cve.attack_class == 'kernel-lpe'`). It comes from the
|
|
1147
|
-
// catalog entry's explicit attack_class only — no fuzzy inference from
|
|
1148
|
-
// the free-form `type`, so an unclassified CVE stays null and the chain
|
|
1149
|
-
// correctly does not fire (no false escalation) rather than misrouting.
|
|
816
|
+
// What the sbom → deep-dive feeds_into rules quantify over. The catalog
|
|
817
|
+
// entry's explicit attack_class only — never inferred from the free-form
|
|
818
|
+
// `type`, so an unclassified CVE stays null and the chain does not fire.
|
|
1150
819
|
attack_class: c.entry?.attack_class ?? null,
|
|
1151
820
|
rwep: c.rwep_score,
|
|
1152
821
|
cvss_score: c.entry?.cvss_score ?? null,
|
|
@@ -1177,33 +846,16 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
1177
846
|
note: 'Catalog-baseline entry — this CVE is in the playbook\'s scan coverage but no submitted evidence correlated to it. Not a statement that the operator is affected.',
|
|
1178
847
|
}));
|
|
1179
848
|
|
|
1180
|
-
//
|
|
1181
|
-
//
|
|
1182
|
-
//
|
|
1183
|
-
// intentional — RWEP is a "worst-case real-world exploit priority", not
|
|
1184
|
-
// an arithmetic average. The most-exploitable CVE in the set drives the
|
|
1185
|
-
// base; secondary CVEs add via rwep_inputs adjustments below rather than
|
|
1186
|
-
// through base summing (which would double-count overlapping risk).
|
|
1187
|
-
// vex_status='fixed' CVEs do NOT drive the base — vendor declared them
|
|
1188
|
-
// resolved. They still appear in matched_cves for audit traceability but
|
|
1189
|
-
// don't elevate RWEP.
|
|
849
|
+
// Evidence-correlated matches only, reduced with max: RWEP is a worst-case
|
|
850
|
+
// priority, so the most-exploitable CVE sets the base and summing bases would
|
|
851
|
+
// double-count overlapping risk. A vex_status='fixed' CVE never drives it.
|
|
1190
852
|
const rwepEligible = matchedCves.filter(c => !(vexFixed && vexFixed.has(c.cve_id)));
|
|
1191
853
|
const baseRwep = rwepEligible.length ? Math.max(...rwepEligible.map(c => c.rwep_score)) : 0;
|
|
1192
854
|
|
|
1193
|
-
//
|
|
1194
|
-
//
|
|
1195
|
-
//
|
|
1196
|
-
//
|
|
1197
|
-
// (e.g. active_exploitation +25 only when the matched CVE is under
|
|
1198
|
-
// active exploitation). blast_radius is sourced from the analyze-phase
|
|
1199
|
-
// blast_radius_score / 5 (rubric ceiling). Negative weights
|
|
1200
|
-
// (patch_available, live_patch_available) keep their sign so a patched
|
|
1201
|
-
// CVE deducts the full magnitude when the catalog confirms a
|
|
1202
|
-
// patch is available.
|
|
1203
|
-
//
|
|
1204
|
-
// Aliasing: playbooks ship rwep_factor values `public_poc` and
|
|
1205
|
-
// `ai_weaponization` for what F5 calls `poc_available` and `ai_factor`.
|
|
1206
|
-
// Both spellings resolve here.
|
|
855
|
+
// Each rwep_input.weight scales by a [0, 1] factor from the matched CVE's
|
|
856
|
+
// catalog attributes. Negative weights keep their sign, so a patched CVE
|
|
857
|
+
// deducts in full. `public_poc` and `ai_weaponization` are catalog aliases for
|
|
858
|
+
// `poc_available` and `ai_factor`; both spellings resolve.
|
|
1207
859
|
const _factorScale = (factorName, cve, blastScore) => {
|
|
1208
860
|
if (!cve) return 0;
|
|
1209
861
|
switch (factorName) {
|
|
@@ -1211,15 +863,9 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
1211
863
|
return cve.cisa_kev === true ? 1 : 0;
|
|
1212
864
|
case 'active_exploitation': {
|
|
1213
865
|
const v = cve.active_exploitation || (cve.entry && cve.entry.active_exploitation);
|
|
1214
|
-
//
|
|
1215
|
-
//
|
|
1216
|
-
//
|
|
1217
|
-
// ('in-the-wild') surfaces the RWEP_AE_UNRECOGNISED warning rather than
|
|
1218
|
-
// silently zeroing the active-exploitation weight. activeExploitationMultiplier
|
|
1219
|
-
// (not the bare resolveActiveExploitation().multiplier) is used precisely so
|
|
1220
|
-
// the no-match path is OBSERVABLE — the "out-of-vocab token must surface,
|
|
1221
|
-
// not silently default" class. For every canonical catalog value the
|
|
1222
|
-
// returned multiplier is identical to the prior inline lookup.
|
|
866
|
+
// The shared resolver, not an inline `ladder[v] ?? 0`: a stray-cased
|
|
867
|
+
// value scales as the catalog scorer scales it, and an out-of-vocabulary
|
|
868
|
+
// one raises RWEP_AE_UNRECOGNISED instead of silently zeroing.
|
|
1223
869
|
return scoring.activeExploitationMultiplier(v);
|
|
1224
870
|
}
|
|
1225
871
|
case 'poc_available':
|
|
@@ -1242,24 +888,19 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
1242
888
|
case 'reboot_required':
|
|
1243
889
|
return cve.entry?.patch_required_reboot === true ? 1 : 0;
|
|
1244
890
|
case 'blast_radius': {
|
|
1245
|
-
// blast_radius weights scale by the 0-5 rubric score so a max-blast
|
|
1246
|
-
// finding gets full weight and a low-blast finding gets a fraction.
|
|
1247
891
|
if (typeof blastScore !== 'number' || blastScore < 0) return 0;
|
|
1248
892
|
return Math.min(1, blastScore / 5);
|
|
1249
893
|
}
|
|
1250
894
|
default:
|
|
1251
|
-
//
|
|
1252
|
-
//
|
|
895
|
+
// An unrecognised factor fires binary, so a playbook shipping a novel
|
|
896
|
+
// rwep_factor string does not silently zero out.
|
|
1253
897
|
return 1;
|
|
1254
898
|
}
|
|
1255
899
|
};
|
|
1256
900
|
|
|
1257
|
-
// blast_radius_score
|
|
1258
|
-
//
|
|
1259
|
-
//
|
|
1260
|
-
// signal='supplied'. The runner never defaults to a rubric entry — that
|
|
1261
|
-
// would be the opposite of safe-default when the rubric's lowest entry
|
|
1262
|
-
// is the LOWEST-blast row.
|
|
901
|
+
// blast_radius_score: in-range is 'supplied', absent is null + 'default',
|
|
902
|
+
// out of [0,5] is null + 'rejected' + runtime_error. Never a rubric entry —
|
|
903
|
+
// the rubric's lowest row is the LOWEST-blast one, so a guess understates.
|
|
1263
904
|
const blastRubric = an.blast_radius_model?.scoring_rubric || [];
|
|
1264
905
|
let blastRadiusScore = null;
|
|
1265
906
|
let blastRadiusSignal = 'default';
|
|
@@ -1276,56 +917,26 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
1276
917
|
}
|
|
1277
918
|
}
|
|
1278
919
|
}
|
|
1279
|
-
//
|
|
1280
|
-
//
|
|
1281
|
-
//
|
|
1282
|
-
// `factorCve = null` → every factor returned 0 → catalog-shape playbooks
|
|
1283
|
-
// (secrets, library-author, crypto-codebase, framework, cred-stores,
|
|
1284
|
-
// containers, runtime, crypto, ai-api) that detect WITHOUT a per-CVE
|
|
1285
|
-
// evidence correlation emitted `weight_applied: 0` for every fired
|
|
1286
|
-
// indicator, producing `adjusted: 0` for every detection. The e2e suite
|
|
1287
|
-
// caught this — 9/20 scenarios failed `json_path_min.adjusted >= N`.
|
|
1288
|
-
//
|
|
1289
|
-
// Domain-level fallback: when no evidence-correlated CVE is available,
|
|
1290
|
-
// use the highest-rwep_score entry from `workingCatalogCves` (which is
|
|
1291
|
-
// built from `playbook.domain.cve_refs[]` — the playbook's canonical
|
|
1292
|
-
// "what we're about"). This preserves factor-scaling semantics while
|
|
1293
|
-
// recognizing that a catalog-shape playbook's threat class is already
|
|
1294
|
-
// declared by its domain refs. The factor-scale annotation surfaces
|
|
1295
|
-
// `factor_cve_source: 'evidence' | 'domain' | 'none'` so operators see
|
|
1296
|
-
// which fallback was used.
|
|
920
|
+
// Factor scaling reads one CVE, resolved in order and annotated as
|
|
921
|
+
// factor_cve_source: 'evidence' (an RWEP-eligible match), 'domain' (the
|
|
922
|
+
// highest-rwep entry from domain.cve_refs), or 'none'.
|
|
1297
923
|
let factorCveSource = 'none';
|
|
1298
|
-
//
|
|
1299
|
-
//
|
|
1300
|
-
//
|
|
1301
|
-
// when EVERY evidence-correlated CVE is VEX-fixed (rwepEligible empty but
|
|
1302
|
-
// matchedCves non-empty) the finding is remediated, so factor scaling must be
|
|
1303
|
-
// suppressed entirely — base is already 0 and the fired factors must not
|
|
1304
|
-
// raise the adjusted score (a vendor-fixed CVE's KEV/exploitation/PoC would
|
|
1305
|
-
// otherwise lift it above 0). The domain-CVE and class-weight fallbacks below
|
|
1306
|
-
// are skipped in that case too, so every fired factor scales by 0 via
|
|
1307
|
-
// _factorScale(factor, null, …).
|
|
924
|
+
// Never fall back to matchedCves[0]: when every correlated CVE is VEX-fixed
|
|
925
|
+
// the finding is remediated, and a patched CVE's KEV / exploitation / PoC
|
|
926
|
+
// multipliers would lift the adjusted score above its base of 0.
|
|
1308
927
|
const allMatchedVexFixed = matchedCves.length > 0 && rwepEligible.length === 0;
|
|
1309
928
|
let factorCve = rwepEligible[0] || null;
|
|
1310
929
|
if (factorCve) {
|
|
1311
930
|
factorCveSource = 'evidence';
|
|
1312
931
|
} else if (!allMatchedVexFixed && workingCatalogCves.length > 0) {
|
|
1313
|
-
// Highest rwep_score from domain refs.
|
|
1314
932
|
factorCve = workingCatalogCves.reduce((worst, c) =>
|
|
1315
933
|
(typeof c.rwep_score === 'number' && (!worst || c.rwep_score > worst.rwep_score)) ? c : worst,
|
|
1316
934
|
null);
|
|
1317
935
|
if (factorCve) factorCveSource = 'domain';
|
|
1318
936
|
}
|
|
1319
|
-
//
|
|
1320
|
-
//
|
|
1321
|
-
//
|
|
1322
|
-
// class-of-vulnerability rather than CVE-specific. For those playbooks
|
|
1323
|
-
// neither evidence-correlation NOR the domain-CVE fallback yields a
|
|
1324
|
-
// factorCve, so every fired indicator's `weight_applied` was forced to
|
|
1325
|
-
// zero by `_factorScale` returning 0. Fall back to the pre-v0.12.14
|
|
1326
|
-
// semantics for this case only: apply the declared weight as-is
|
|
1327
|
-
// (factor_scale=1, legacy semantics). The factor_cve_source annotation
|
|
1328
|
-
// surfaces 'class' so operators see which mode the run used.
|
|
937
|
+
// A class-of-vulnerability playbook ships an empty domain.cve_refs, so
|
|
938
|
+
// neither rung above yields a factorCve and every indicator would scale to
|
|
939
|
+
// zero. Those apply the declared weight as-is, annotated 'class'.
|
|
1329
940
|
const _classScaleFallback = !factorCve && !allMatchedVexFixed;
|
|
1330
941
|
let adjustedRwep = baseRwep;
|
|
1331
942
|
const rwepBreakdown = [];
|
|
@@ -1336,17 +947,10 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
1336
947
|
rwepBreakdown.push({ signal_id: input.signal_id, rwep_factor: input.rwep_factor, weight_applied: 0, fired: false, factor_scale: 0 });
|
|
1337
948
|
continue;
|
|
1338
949
|
}
|
|
1339
|
-
//
|
|
1340
|
-
// evidence OR domain) apply weights as-is via the legacy semantics.
|
|
1341
|
-
// For CVE-anchored playbooks, scale by the matched CVE's attributes.
|
|
1342
|
-
// Class fallback covers blast_radius too — when the agent submitted a
|
|
1343
|
-
// blast score, _factorScale honors it; otherwise the class-fallback
|
|
1344
|
-
// applies full weight (matching pre-v0.12.14 behavior, where every
|
|
1345
|
-
// fired indicator contributed its full declared weight).
|
|
950
|
+
// An operator-supplied blast score is honored in either mode.
|
|
1346
951
|
let scale, factorCveSourceForBreakdown;
|
|
1347
952
|
if (_classScaleFallback) {
|
|
1348
953
|
if (input.rwep_factor === 'blast_radius' && typeof blastRadiusScore === 'number') {
|
|
1349
|
-
// Operator-supplied blast score is still honored even in class mode.
|
|
1350
954
|
scale = Math.min(1, blastRadiusScore / 5);
|
|
1351
955
|
} else {
|
|
1352
956
|
scale = 1;
|
|
@@ -1370,20 +974,9 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
1370
974
|
}
|
|
1371
975
|
adjustedRwep = Math.max(0, Math.min(100, adjustedRwep));
|
|
1372
976
|
|
|
1373
|
-
//
|
|
1374
|
-
//
|
|
1375
|
-
//
|
|
1376
|
-
// derive one rather than leaving the field stuck in 'pending_agent_run':
|
|
1377
|
-
// detect.classification = not_detected → theater_verdict = clear
|
|
1378
|
-
// detect.classification = detected → theater_verdict = pending_agent_run
|
|
1379
|
-
// (agent still must run reality_test)
|
|
1380
|
-
// detect.classification = inconclusive → theater_verdict = pending_agent_run
|
|
1381
|
-
// Aliases 'clean' / 'no_theater' map to 'clear' for ergonomics.
|
|
1382
|
-
//
|
|
1383
|
-
// Validate agentSignals.theater_verdict against an allowlist so
|
|
1384
|
-
// downstream consumers (CSAF/SARIF/OpenVEX) never emit bundles with
|
|
1385
|
-
// garbage verdicts like "TODO" or free-text strings. Allowlist: clear,
|
|
1386
|
-
// present, theater, pending_agent_run, unknown.
|
|
977
|
+
// The engine surfaces the theater test, the agent runs it, and the verdict
|
|
978
|
+
// arrives as agentSignals.theater_verdict. The allowlist keeps free text out
|
|
979
|
+
// of the CSAF / SARIF / OpenVEX bundles.
|
|
1387
980
|
const _theaterAllowlist = new Set(['clear', 'present', 'theater', 'pending_agent_run', 'unknown']);
|
|
1388
981
|
let theaterVerdict = agentSignals.theater_verdict;
|
|
1389
982
|
if (theaterVerdict === 'clean' || theaterVerdict === 'no_theater') theaterVerdict = 'clear';
|
|
@@ -1403,50 +996,29 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
1403
996
|
}
|
|
1404
997
|
theaterVerdict = theaterVerdict || (an.compliance_theater_check ? 'pending_agent_run' : null);
|
|
1405
998
|
|
|
1406
|
-
//
|
|
1407
|
-
// not compute new gaps here, just attaches the playbook-declared ones.
|
|
999
|
+
// The playbook-declared gaps, emitted verbatim; analyze computes none.
|
|
1408
1000
|
const frameworkGaps = an.framework_gap_mapping || [];
|
|
1409
1001
|
|
|
1410
|
-
//
|
|
1411
|
-
//
|
|
1412
|
-
// `finding.*` paths — the same roots close()'s feeds_into context
|
|
1413
|
-
// resolves. The flat keys (rwep, blast_radius_score, theater_verdict,
|
|
1414
|
-
// agent signals) remain available unchanged.
|
|
1002
|
+
// Escalation criteria evaluate below the result literal so their conditions
|
|
1003
|
+
// can reference `analyze.*` and `finding.*`.
|
|
1415
1004
|
const escalations = [];
|
|
1416
|
-
const runtimeErrors = [];
|
|
1005
|
+
const runtimeErrors = [];
|
|
1417
1006
|
const evalCtxRoot = { _runErrors: runOpts._runErrors || runtimeErrors };
|
|
1418
1007
|
|
|
1419
1008
|
const result = {
|
|
1420
1009
|
phase: 'analyze',
|
|
1421
1010
|
playbook_id: playbookId,
|
|
1422
1011
|
directive_id: directiveId,
|
|
1423
|
-
//
|
|
1424
|
-
//
|
|
1425
|
-
//
|
|
1426
|
-
// when the catalog itself lacks the value, never when we just forgot to
|
|
1427
|
-
// forward it. EPSS is included because validate-cves --live populates it.
|
|
1428
|
-
//
|
|
1429
|
-
// matched_cves — evidence-correlated only. Each entry has a non-null
|
|
1430
|
-
// correlated_via[] array naming the indicator hits or agent signals that
|
|
1431
|
-
// tied the operator's submission to this CVE. Empty array means the
|
|
1432
|
-
// playbook's scan coverage saw no matching evidence in this run.
|
|
1012
|
+
// Every CVE entry carries CVSS, KEV, PoC, AI-discovery, active-exploitation
|
|
1013
|
+
// and patch state (AGENTS.md Hard Rule #1) from the catalog: a null means
|
|
1014
|
+
// the catalog lacks the value, never that the runner dropped it.
|
|
1433
1015
|
matched_cves: matchedCveEntries,
|
|
1434
|
-
//
|
|
1435
|
-
// same per-CVE shape but correlated_via=null and a note explaining the
|
|
1436
|
-
// field is scan-coverage metadata, NOT an operator-affected list. Use
|
|
1437
|
-
// this when surfacing "what CVEs does this playbook check for?" Use
|
|
1438
|
-
// matched_cves when surfacing "what CVEs is the operator actually
|
|
1439
|
-
// affected by based on submitted evidence?"
|
|
1016
|
+
// Scan coverage, not an affected list; correlated_via is null throughout.
|
|
1440
1017
|
catalog_baseline_cves: catalogBaselineEntries,
|
|
1441
|
-
// rwep base is reduced via Math.max across matched CVEs. Surface the
|
|
1442
|
-
// reduction strategy as a discoverable field so operators reading the
|
|
1443
|
-
// bundle understand the semantics without grepping source.
|
|
1444
1018
|
rwep: { base: baseRwep, adjusted: adjustedRwep, breakdown: rwepBreakdown, threshold: directive ? resolvedPhase(playbook, directiveId, 'direct').rwep_threshold : null, _rwep_base_strategy: 'max' },
|
|
1445
1019
|
blast_radius_score: blastRadiusScore,
|
|
1446
|
-
//
|
|
1447
|
-
//
|
|
1448
|
-
// 'default' — no value supplied; runner returned null (no rubric guess).
|
|
1449
|
-
// 'rejected' — value supplied but out of range; treated as default + runtime_error.
|
|
1020
|
+
// 'supplied' | 'default' (none given, no rubric guess) | 'rejected'
|
|
1021
|
+
// (out of range; treated as default and surfaced as a runtime_error).
|
|
1450
1022
|
blast_radius_signal: blastRadiusSignal,
|
|
1451
1023
|
blast_radius_basis: blastRubric.find(r => r.blast_radius_score === blastRadiusScore) || null,
|
|
1452
1024
|
compliance_theater_check: {
|
|
@@ -1454,58 +1026,38 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
1454
1026
|
audit_evidence: an.compliance_theater_check?.audit_evidence,
|
|
1455
1027
|
reality_test: an.compliance_theater_check?.reality_test,
|
|
1456
1028
|
verdict: theaterVerdict,
|
|
1457
|
-
//
|
|
1458
|
-
// ('present' is a synonym used by some playbooks for "theater is here").
|
|
1029
|
+
// 'present' is a playbook synonym for 'theater'; both render the text.
|
|
1459
1030
|
verdict_text: (theaterVerdict === 'theater' || theaterVerdict === 'present')
|
|
1460
1031
|
? an.compliance_theater_check?.theater_verdict_if_gap
|
|
1461
1032
|
: null
|
|
1462
1033
|
},
|
|
1463
1034
|
framework_gap_mapping: frameworkGaps,
|
|
1464
1035
|
escalations,
|
|
1465
|
-
//
|
|
1466
|
-
//
|
|
1467
|
-
// and emit them as SARIF results / OpenVEX statements / CSAF notes.
|
|
1468
|
-
// Prefixed with underscore to signal "for internal/render use".
|
|
1036
|
+
// Underscore marks these render-internal: close()'s bundle builders read
|
|
1037
|
+
// them to emit SARIF results / OpenVEX statements / CSAF notes.
|
|
1469
1038
|
_detect_indicators: detectResult.indicators || [],
|
|
1470
1039
|
_detect_classification: detectResult.classification,
|
|
1471
|
-
//
|
|
1472
|
-
//
|
|
1473
|
-
// underscore-prefixed key was render-internal only, so those conditions
|
|
1474
|
-
// resolved undefined and were silently dead — e.g. citation-hygiene →
|
|
1475
|
-
// sbom, crypto-codebase → secrets). The underscore key stays for the
|
|
1476
|
-
// SARIF/CSAF render consumers. On the detect-skip path run() re-sets
|
|
1477
|
-
// analyze.classification to 'skipped', which is the same value
|
|
1478
|
-
// detectResult.classification already carries there, so this is benign.
|
|
1040
|
+
// The path catalog feeds_into / escalation conditions actually write —
|
|
1041
|
+
// without the alias they resolve undefined and are silently dead.
|
|
1479
1042
|
classification: detectResult.classification,
|
|
1480
1043
|
vex: vexFilter ? {
|
|
1481
1044
|
filter_applied: true,
|
|
1482
1045
|
dropped_cve_count: vexDropped.length,
|
|
1483
1046
|
dropped_cves: vexDropped,
|
|
1484
|
-
//
|
|
1485
|
-
// annotated vex_status:'fixed' and never enter vexDropped. Surface them
|
|
1486
|
-
// so the two dispositions are distinguishable and the note can be
|
|
1487
|
-
// accurate (the drop note must not list a keep-disposition as a reason).
|
|
1047
|
+
// A keep disposition — these stay in matched_cves, never in vexDropped.
|
|
1488
1048
|
fixed_cves: vexFixedIds,
|
|
1489
1049
|
fixed_cve_count: vexFixedIds.length,
|
|
1490
1050
|
note: vexDropped.length
|
|
1491
1051
|
? `${vexDropped.length} CVE(s) dropped from analyze because the operator-supplied VEX statement marks them not_affected / false_positive. Vendor-fixed CVEs are NOT dropped — they remain in matched_cves with vex_status:'fixed'. The dropped CVEs remain in cve-catalog.json; the disposition lives in the VEX file.`
|
|
1492
1052
|
: "VEX filter supplied; zero matches dropped (no CVEs in domain.cve_refs matched the VEX not-affected / false_positive set)."
|
|
1493
1053
|
} : null,
|
|
1494
|
-
//
|
|
1495
|
-
// condition expression crashed without the runner dying. Only present
|
|
1496
|
-
// when at least one evalCondition() call hit a regex exception during
|
|
1497
|
-
// this analyze pass; runOpts._runErrors is the same accumulator
|
|
1498
|
-
// populated by run() across all phases, so callers reading this field
|
|
1499
|
-
// see every regex problem in the run.
|
|
1054
|
+
// The run-wide accumulator: every non-fatal anomaly, without the run dying.
|
|
1500
1055
|
runtime_errors: (runOpts._runErrors && runOpts._runErrors.length) ? runOpts._runErrors.slice() : (runtimeErrors.length ? runtimeErrors.slice() : []),
|
|
1501
|
-
//
|
|
1502
|
-
// indicator id. Empty when there were no collisions or no flat-shape
|
|
1503
|
-
// observations submitted.
|
|
1056
|
+
// Two flat-shape observations that targeted the same indicator id.
|
|
1504
1057
|
signal_origins_with_collisions: Array.isArray(agentSignals?._signal_origins_collisions) ? agentSignals._signal_origins_collisions.slice() : (Array.isArray(detectResult?._signal_origins_collisions) ? detectResult._signal_origins_collisions.slice() : [])
|
|
1505
1058
|
};
|
|
1506
1059
|
|
|
1507
|
-
//
|
|
1508
|
-
// close()) so a malformed analyze result can't abort escalation handling.
|
|
1060
|
+
// Wrapped so a malformed analyze result cannot abort escalation handling.
|
|
1509
1061
|
let findingShape;
|
|
1510
1062
|
try { findingShape = analyzeFindingShape(result); }
|
|
1511
1063
|
catch (e) {
|
|
@@ -1516,47 +1068,24 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
1516
1068
|
}
|
|
1517
1069
|
for (const ec of an.escalation_criteria || []) {
|
|
1518
1070
|
if (evalCondition(ec.condition, { ...firedIndicatorSignals(detectResult.indicators), ...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 || [],
|
|
1519
|
-
// finding.* is two-sourced:
|
|
1520
|
-
//
|
|
1521
|
-
//
|
|
1522
|
-
//
|
|
1523
|
-
//
|
|
1524
|
-
// host-AI-asserted — the agent knows whether the finding includes a
|
|
1525
|
-
// cloud-role-assumption path. Merge agent-supplied finding sub-fields
|
|
1526
|
-
// UNDER the engine shape so the descriptive keys survive while engine-owned
|
|
1527
|
-
// keys still WIN on collision (a poisoning signals.finding.severity can't
|
|
1528
|
-
// override the engine-computed severity). The !Array.isArray guard rejects
|
|
1529
|
-
// an array (typeof [] === 'object') that would inject numeric-index noise;
|
|
1530
|
-
// null is already excluded by the leading &&.
|
|
1071
|
+
// finding.* is two-sourced: analyzeFindingShape computes the
|
|
1072
|
+
// CVE/severity keys, while the descriptive ones the catalog gates on
|
|
1073
|
+
// (finding.includes_*, cve_class, tool_surface) are host-AI-asserted.
|
|
1074
|
+
// Agent fields merge UNDER the engine shape; !Array.isArray rejects an
|
|
1075
|
+
// array's numeric-index noise.
|
|
1531
1076
|
finding: { ...(agentSignals.finding && typeof agentSignals.finding === 'object' && !Array.isArray(agentSignals.finding) ? agentSignals.finding : {}), ...findingShape } }, playbook)) {
|
|
1532
1077
|
escalations.push({ condition: ec.condition, action: ec.action, target_playbook: ec.target_playbook || null });
|
|
1533
1078
|
}
|
|
1534
1079
|
}
|
|
1535
|
-
// Escalation evaluation may have appended
|
|
1536
|
-
// the snapshot so analyze.runtime_errors reflects them.
|
|
1080
|
+
// Escalation evaluation may have appended diagnostics; refresh the snapshot.
|
|
1537
1081
|
result.runtime_errors = (runOpts._runErrors && runOpts._runErrors.length) ? runOpts._runErrors.slice() : (runtimeErrors.length ? runtimeErrors.slice() : []);
|
|
1538
1082
|
return result;
|
|
1539
1083
|
}
|
|
1540
1084
|
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
* "drop" set — they have different semantics:
|
|
1546
|
-
*
|
|
1547
|
-
* - not_affected / false_positive → drop from matched_cves entirely.
|
|
1548
|
-
* The vendor has formally declared the product not vulnerable; the CVE
|
|
1549
|
-
* is not in scope.
|
|
1550
|
-
* - fixed / resolved → KEEP in matched_cves but annotate vex_status:'fixed'.
|
|
1551
|
-
* The product was vulnerable; the vendor shipped a patch. Operators
|
|
1552
|
-
* still need audit trails, SBOM coverage, and confirmation that the
|
|
1553
|
-
* fix landed in their build.
|
|
1554
|
-
*
|
|
1555
|
-
* Returns a `Set<string>` for the legacy "drop" set (the function's
|
|
1556
|
-
* historical contract), with `.fixed` attached as an own property for
|
|
1557
|
-
* callers that want the split. The CLI passes both as
|
|
1558
|
-
* agentSignals.vex_filter + agentSignals.vex_fixed to analyze().
|
|
1559
|
-
*/
|
|
1085
|
+
// The VEX disposition sets of a CycloneDX/OpenVEX document, which must not
|
|
1086
|
+
// collapse into one "drop" set: not_affected / false_positive drop from
|
|
1087
|
+
// matched_cves, while fixed / resolved are KEPT and annotated. Returns the drop
|
|
1088
|
+
// set as a Set with the fixed set attached as its own `.fixed` property.
|
|
1560
1089
|
function vexFilterFromDoc(doc) {
|
|
1561
1090
|
const out = new Set();
|
|
1562
1091
|
const fixed = new Set();
|
|
@@ -1565,9 +1094,6 @@ function vexFilterFromDoc(doc) {
|
|
|
1565
1094
|
return out;
|
|
1566
1095
|
}
|
|
1567
1096
|
|
|
1568
|
-
// CycloneDX shape — analysis.state values per CycloneDX VEX spec:
|
|
1569
|
-
// not_affected / false_positive → drop
|
|
1570
|
-
// resolved → fixed-annotation
|
|
1571
1097
|
for (const v of (doc.vulnerabilities || [])) {
|
|
1572
1098
|
const state = v.analysis && v.analysis.state;
|
|
1573
1099
|
if (state === 'not_affected' || state === 'false_positive') {
|
|
@@ -1576,7 +1102,6 @@ function vexFilterFromDoc(doc) {
|
|
|
1576
1102
|
if (v.id) fixed.add(v.id);
|
|
1577
1103
|
}
|
|
1578
1104
|
}
|
|
1579
|
-
// OpenVEX shape
|
|
1580
1105
|
for (const s of (doc.statements || [])) {
|
|
1581
1106
|
const id = s.vulnerability && (s.vulnerability['@id'] || s.vulnerability.name || s.vulnerability);
|
|
1582
1107
|
if (typeof id !== 'string') continue;
|
|
@@ -1587,11 +1112,8 @@ function vexFilterFromDoc(doc) {
|
|
|
1587
1112
|
return out;
|
|
1588
1113
|
}
|
|
1589
1114
|
|
|
1590
|
-
// Cap a
|
|
1591
|
-
//
|
|
1592
|
-
// .slice() left blocked-preflight summaries ending mid-word ("...and conne")
|
|
1593
|
-
// with no signal that anything was dropped (the full text remains in the JSON
|
|
1594
|
-
// envelope's reason/remediation fields).
|
|
1115
|
+
// Cap on a word boundary and mark the cut, so a truncated line reads as
|
|
1116
|
+
// truncated. The full text stays in the envelope's reason / remediation.
|
|
1595
1117
|
function capSummary(s, max = 240) {
|
|
1596
1118
|
if (typeof s !== 'string' || s.length <= max) return s;
|
|
1597
1119
|
const slice = s.slice(0, max - 1);
|
|
@@ -1600,30 +1122,18 @@ function capSummary(s, max = 240) {
|
|
|
1600
1122
|
return base.replace(/[\s—.,;:-]+$/, '') + '…';
|
|
1601
1123
|
}
|
|
1602
1124
|
|
|
1603
|
-
// --- phase 6: validate ---
|
|
1604
|
-
|
|
1605
1125
|
function validate(playbookId, directiveId, analyzeResult, agentSignals = {}, runOpts = {}) {
|
|
1606
1126
|
const playbook = runOpts._playbookCache || loadPlaybook(playbookId);
|
|
1607
|
-
// Remediation-path preconditions
|
|
1608
|
-
//
|
|
1609
|
-
//
|
|
1610
|
-
//
|
|
1611
|
-
//
|
|
1612
|
-
// satisfied, so its remediation path is only ever reachable as the priority
|
|
1613
|
-
// fallback (kernel's `any matched_cve.vector matches 'userns|bpf|ptrace|kptr'`
|
|
1614
|
-
// hardening-compensation path was permanently unsatisfiable for this reason).
|
|
1615
|
-
// agentSignals spread FIRST so the engine-computed values WIN on collision (a
|
|
1616
|
-
// poisoning signals.rwep can't override the engine value) — mirrors the
|
|
1617
|
-
// escalation (analyze) and feeds_into (close) contexts. finding.* merges the
|
|
1618
|
-
// host-AI-asserted descriptive keys UNDER the engine shape with the same
|
|
1619
|
-
// !Array.isArray guard used there.
|
|
1127
|
+
// Remediation-path preconditions run through the same evalCondition as
|
|
1128
|
+
// analyze and close, so this context must expose the same engine-computed
|
|
1129
|
+
// roots: one gating on `rwep`, `finding.severity` or `matched_cve.*`
|
|
1130
|
+
// otherwise resolves null and can never be satisfied. agentSignals spread
|
|
1131
|
+
// FIRST so engine values win on collision.
|
|
1620
1132
|
const govern = (playbook.phases && playbook.phases.govern) || {};
|
|
1621
1133
|
let findingShape = {};
|
|
1622
1134
|
try { findingShape = analyzeFindingShape(analyzeResult || {}); } catch { findingShape = {}; }
|
|
1623
1135
|
const evalCtx = {
|
|
1624
|
-
//
|
|
1625
|
-
// referencing an indicator id resolves on the collector path. See
|
|
1626
|
-
// firedIndicatorSignals.
|
|
1136
|
+
// Lowest precedence, so an indicator id resolves on the collector path.
|
|
1627
1137
|
...firedIndicatorSignals(analyzeResult && analyzeResult._detect_indicators),
|
|
1628
1138
|
...agentSignals,
|
|
1629
1139
|
rwep: analyzeResult && analyzeResult.rwep ? analyzeResult.rwep.adjusted : undefined,
|
|
@@ -1638,12 +1148,10 @@ function validate(playbookId, directiveId, analyzeResult, agentSignals = {}, run
|
|
|
1638
1148
|
};
|
|
1639
1149
|
const v = resolvedPhase(playbook, directiveId, 'validate');
|
|
1640
1150
|
|
|
1641
|
-
//
|
|
1642
|
-
// either satisfied by agentSignals or marked unverified=allow.
|
|
1151
|
+
// Lower priority number sorts first, per the schema convention.
|
|
1643
1152
|
const paths = (v.remediation_paths || []).slice().sort((a, b) => a.priority - b.priority);
|
|
1644
|
-
//
|
|
1645
|
-
//
|
|
1646
|
-
// priority-1 default when no path's preconditions are verified.
|
|
1153
|
+
// The indicators that fired, so a remediation linked to one through
|
|
1154
|
+
// for_signals can outrank the bare priority-1 default.
|
|
1647
1155
|
const firedSignalIds = new Set(
|
|
1648
1156
|
(analyzeResult._detect_indicators || []).filter(i => i.verdict === 'hit').map(i => i.id)
|
|
1649
1157
|
);
|
|
@@ -1661,15 +1169,9 @@ function validate(playbookId, directiveId, analyzeResult, agentSignals = {}, run
|
|
|
1661
1169
|
if (allSatisfied) satisfiedIds.add(p.id);
|
|
1662
1170
|
considered.push({ id: p.id, priority: p.priority, all_satisfied: allSatisfied, addresses_fired_signal: addressesFired(p), preconditions: pcResult });
|
|
1663
1171
|
}
|
|
1664
|
-
//
|
|
1665
|
-
//
|
|
1666
|
-
//
|
|
1667
|
-
// finding (just because its preconditions happen to hold) was the original
|
|
1668
|
-
// defect — for_signals must beat it.
|
|
1669
|
-
// 1. addresses a fired indicator AND preconditions satisfied (relevant + ready)
|
|
1670
|
-
// 2. addresses a fired indicator (relevant; operator must meet its preconditions)
|
|
1671
|
-
// 3. preconditions satisfied (no fired-signal-linked path — actionable fallback)
|
|
1672
|
-
// 4. priority-1 (nothing else — at least propose the top path)
|
|
1172
|
+
// paths is priority-sorted, so each `find` takes the highest-priority match.
|
|
1173
|
+
// Relevance to a fired indicator outranks a satisfied-but-unrelated path. The
|
|
1174
|
+
// last rung is priority-1, so something is always proposed.
|
|
1673
1175
|
const selected =
|
|
1674
1176
|
paths.find(p => addressesFired(p) && satisfiedIds.has(p.id))
|
|
1675
1177
|
|| paths.find(addressesFired)
|
|
@@ -1677,30 +1179,11 @@ function validate(playbookId, directiveId, analyzeResult, agentSignals = {}, run
|
|
|
1677
1179
|
|| paths[0]
|
|
1678
1180
|
|| null;
|
|
1679
1181
|
|
|
1680
|
-
//
|
|
1681
|
-
//
|
|
1682
|
-
// higher priority per schema convention).
|
|
1683
|
-
// 2. Pick the FIRST path whose every precondition (evaluated against
|
|
1684
|
-
// agentSignals + playbook context) is satisfied.
|
|
1685
|
-
// 3. Fallback: when nothing satisfies, surface the highest-priority
|
|
1686
|
-
// path anyway so the agent has SOMETHING to propose to the operator —
|
|
1687
|
-
// better than emitting null and forcing the agent to guess.
|
|
1688
|
-
// Above this block: paths.sort + the loop populating `considered` +
|
|
1689
|
-
// `selected`. `remediation_options_considered[]` carries the full per-path
|
|
1690
|
-
// precondition trace so operators can see why a higher-priority path was
|
|
1691
|
-
// skipped.
|
|
1692
|
-
|
|
1693
|
-
// Regression schedule. Returns a structured object with next_run +
|
|
1694
|
-
// event_triggers + unparseable. Backwards compatibility: keep
|
|
1695
|
-
// regression_next_run as the ISO string (or null) so existing CSAF /
|
|
1696
|
-
// attestation consumers don't break; expose the structured form
|
|
1697
|
-
// separately.
|
|
1182
|
+
// regression_next_run stays the plain ISO string CSAF and attestation
|
|
1183
|
+
// consumers read; the structured form sits alongside it.
|
|
1698
1184
|
const triggers = v.regression_trigger || [];
|
|
1699
1185
|
const regressionResult = computeRegressionNextRun(triggers);
|
|
1700
1186
|
|
|
1701
|
-
// Reason annotation for null next_run — operators see WHY a schedule
|
|
1702
|
-
// didn't emit a calendar date (no day intervals declared, every trigger
|
|
1703
|
-
// is event-driven, or every trigger was unparseable).
|
|
1704
1187
|
let nextRunReason = null;
|
|
1705
1188
|
if (!regressionResult.next_run) {
|
|
1706
1189
|
if (triggers.length === 0) nextRunReason = 'no_regression_triggers_declared';
|
|
@@ -1730,18 +1213,9 @@ function validate(playbookId, directiveId, analyzeResult, agentSignals = {}, run
|
|
|
1730
1213
|
};
|
|
1731
1214
|
}
|
|
1732
1215
|
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
* <N>wk — N weeks
|
|
1737
|
-
* <N>mo — N calendar months (Date.setMonth semantics)
|
|
1738
|
-
* <N>yr — N calendar years
|
|
1739
|
-
* on_event — event-triggered, no date computed; surfaces in
|
|
1740
|
-
* regression_event_triggers[] for the consumer.
|
|
1741
|
-
* Without all five forms, a playbook declaring "regression on every
|
|
1742
|
-
* release" or
|
|
1743
|
-
* "monthly review" lost its schedule entry.
|
|
1744
|
-
*/
|
|
1216
|
+
// `<N>d`, `<N>wk`, `<N>mo` and `<N>yr` return a date, months and years by
|
|
1217
|
+
// Date.setMonth / setFullYear semantics. `on_event` computes no date and
|
|
1218
|
+
// surfaces in regression_event_triggers[]; anything else is { unparseable }.
|
|
1745
1219
|
function parseInterval(intervalStr, now) {
|
|
1746
1220
|
if (!intervalStr || typeof intervalStr !== 'string') return null;
|
|
1747
1221
|
const s = intervalStr.trim();
|
|
@@ -1774,9 +1248,8 @@ function computeRegressionNextRun(triggers) {
|
|
|
1774
1248
|
const parsed = parseInterval(t.interval, now);
|
|
1775
1249
|
if (!parsed) continue;
|
|
1776
1250
|
if (parsed.event) {
|
|
1777
|
-
// Shipped playbooks key the trigger string as `condition`; `trigger`
|
|
1778
|
-
// `event` are
|
|
1779
|
-
// the latter two left regression_event_triggers[].trigger always null.
|
|
1251
|
+
// Shipped playbooks key the trigger string as `condition`; `trigger` and
|
|
1252
|
+
// `event` are the spellings external submissions and fixtures use.
|
|
1780
1253
|
eventTriggers.push({ interval: t.interval, trigger: t.trigger || t.event || t.condition || null });
|
|
1781
1254
|
continue;
|
|
1782
1255
|
}
|
|
@@ -1793,51 +1266,28 @@ function computeRegressionNextRun(triggers) {
|
|
|
1793
1266
|
};
|
|
1794
1267
|
}
|
|
1795
1268
|
|
|
1796
|
-
//
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
* - evidence_package (CSAF-2.0 shaped if requested; signed if signing key present)
|
|
1801
|
-
* - learning_loop lesson template populated with current finding context
|
|
1802
|
-
* - notification_actions with computed ISO 8601 deadlines from clock_starts + window_hours
|
|
1803
|
-
* - exception_generation auditor-ready language if trigger fires
|
|
1804
|
-
* - regression_schedule.next_run from validate.regression_next_run
|
|
1805
|
-
* - feeds_into chaining suggestions
|
|
1806
|
-
*/
|
|
1269
|
+
// Phase 7. The closure artifacts: the evidence_package (signed when a session
|
|
1270
|
+
// key is present), the learning_loop lesson, notification_actions with deadlines
|
|
1271
|
+
// from clock_starts + window_hours, the auditor-ready exception language, the
|
|
1272
|
+
// regression schedule and the feeds_into chaining suggestions.
|
|
1807
1273
|
function close(playbookId, directiveId, analyzeResult, validateResult, agentSignals = {}, runOpts = {}) {
|
|
1808
1274
|
const playbook = runOpts._playbookCache || loadPlaybook(playbookId);
|
|
1809
1275
|
const c = resolvedPhase(playbook, directiveId, 'close');
|
|
1810
1276
|
const g = resolvedPhase(playbook, directiveId, 'govern');
|
|
1811
|
-
//
|
|
1812
|
-
//
|
|
1813
|
-
// so CSAF tracking.id, OpenVEX @id, the attestation file name on disk, and
|
|
1814
|
-
// the run()-returned session_id were all different hex strings — operators
|
|
1815
|
-
// couldn't correlate the attestation file with the bundle URN inside it.
|
|
1816
|
-
// crypto.randomBytes() fallback only fires for direct close() calls that
|
|
1817
|
-
// bypass run() (e.g. unit tests).
|
|
1277
|
+
// run() mints session_id once and threads it here, so CSAF tracking.id, the
|
|
1278
|
+
// OpenVEX @id and the on-disk attestation name are one identifier.
|
|
1818
1279
|
const sessionId = runOpts.session_id || crypto.randomBytes(8).toString('hex');
|
|
1819
1280
|
|
|
1820
|
-
//
|
|
1821
|
-
//
|
|
1822
|
-
// the whole close() call so notification_actions, regression_schedule,
|
|
1823
|
-
// and the bundle emitter all agree on the same Date.
|
|
1281
|
+
// One frozen epoch for every timestamp surface below, so notification_actions,
|
|
1282
|
+
// regression_schedule and the bundle emitter agree on the same instant.
|
|
1824
1283
|
const deterministic = runOpts.bundleDeterministic === true;
|
|
1825
1284
|
const frozenEpoch = deterministic ? resolveFrozenEpoch(runOpts, playbook) : null;
|
|
1826
1285
|
|
|
1827
|
-
//
|
|
1828
|
-
//
|
|
1829
|
-
//
|
|
1830
|
-
//
|
|
1831
|
-
//
|
|
1832
|
-
// `jurisdiction_notifications[i].jurisdiction` got `undefined`. The
|
|
1833
|
-
// upstream `govern.jurisdiction_obligations` has the real data — carry it
|
|
1834
|
-
// forward. `notification_deadline` is published as an alias for `deadline`
|
|
1835
|
-
// (matches the field name compliance teams expect on a notification record).
|
|
1836
|
-
// Which engine phases completed in this run. analyze_complete /
|
|
1837
|
-
// validate_complete jurisdictional clocks auto-start (under --ack) only when
|
|
1838
|
-
// their named phase actually ran — by the time close() executes, a
|
|
1839
|
-
// populated analyzeResult / validateResult proves the phase completed in the
|
|
1840
|
-
// same synchronous pass.
|
|
1286
|
+
// A playbook's notification_actions entry carries only obligation_ref,
|
|
1287
|
+
// recipient and draft_notification; jurisdiction, regulation, window_hours
|
|
1288
|
+
// and evidence_required merge in from the govern obligation below. The
|
|
1289
|
+
// analyze_complete and validate_complete clocks auto-start only when a
|
|
1290
|
+
// populated result proves their phase ran in this pass.
|
|
1841
1291
|
const phaseFlags = {
|
|
1842
1292
|
analyze_complete: !!(analyzeResult && typeof analyzeResult === 'object'),
|
|
1843
1293
|
validate_complete: !!(validateResult && typeof validateResult === 'object'),
|
|
@@ -1847,29 +1297,23 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
1847
1297
|
o && typeof o === 'object' &&
|
|
1848
1298
|
`${o.jurisdiction}/${o.regulation} ${o.window_hours}h` === na.obligation_ref
|
|
1849
1299
|
);
|
|
1850
|
-
//
|
|
1851
|
-
// jurisdiction / regulation / deadline fields null below — surface it as a
|
|
1852
|
-
// runtime_error so the unmatched ref is observable, not a silent null record.
|
|
1300
|
+
// An unresolved obligation_ref would leave the record's fields all null.
|
|
1853
1301
|
if (!obligation && na && typeof na.obligation_ref === 'string' && na.obligation_ref &&
|
|
1854
1302
|
Array.isArray(runOpts && runOpts._runErrors)) {
|
|
1855
1303
|
pushRunError(runOpts._runErrors, { kind: 'unresolved_obligation_ref', obligation_ref: na.obligation_ref }, { dedupeKey: x => x.obligation_ref || '' });
|
|
1856
1304
|
}
|
|
1857
|
-
//
|
|
1858
|
-
//
|
|
1859
|
-
//
|
|
1860
|
-
// starts the clock even without a separately-submitted classification.
|
|
1305
|
+
// computeClockStart checks operator_consent.explicit before auto-stamping,
|
|
1306
|
+
// and takes the engine classification so an engine-confirmed detection
|
|
1307
|
+
// starts the clock without a separately submitted one.
|
|
1861
1308
|
const engineClassification = analyzeResult?._detect_classification || null;
|
|
1862
1309
|
const clockStart = obligation
|
|
1863
1310
|
? computeClockStart(obligation.clock_starts, agentSignals, runOpts, engineClassification, phaseFlags, frozenEpoch)
|
|
1864
1311
|
: null;
|
|
1865
|
-
//
|
|
1866
|
-
//
|
|
1867
|
-
// arithmetic below independently so no caller (or future code path) can
|
|
1868
|
-
// ever reach new Date(NaN).toISOString() and crash the close phase.
|
|
1312
|
+
// Guarded independently of computeClockStart so no path reaches
|
|
1313
|
+
// new Date(NaN).toISOString() and crashes the close phase.
|
|
1869
1314
|
const clockValid = clockStart instanceof Date && !Number.isNaN(clockStart.getTime());
|
|
1870
|
-
//
|
|
1871
|
-
//
|
|
1872
|
-
// waiting on acknowledgement rather than silently stalled.
|
|
1315
|
+
// An auto-startable event that fired without --ack leaves the record
|
|
1316
|
+
// visibly waiting on acknowledgement rather than silently stalled.
|
|
1873
1317
|
const autoStartEvent = obligation
|
|
1874
1318
|
&& (obligation.clock_starts === 'detect_confirmed'
|
|
1875
1319
|
|| obligation.clock_starts === 'analyze_complete'
|
|
@@ -1884,54 +1328,39 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
1884
1328
|
&& autoStartEvent
|
|
1885
1329
|
&& eventReady
|
|
1886
1330
|
&& !(runOpts && runOpts.operator_consent && runOpts.operator_consent.explicit === true);
|
|
1887
|
-
//
|
|
1888
|
-
//
|
|
1889
|
-
// window_hours would otherwise compute `getTime() + NaN` and crash the
|
|
1890
|
-
// close phase at `new Date(NaN).toISOString()`. Runtime validation of the
|
|
1891
|
-
// playbook is not enforced, so guard here independently of the schema.
|
|
1331
|
+
// The playbook is not schema-validated at runtime, and a non-number
|
|
1332
|
+
// window_hours computes `getTime() + NaN`, crashing close().
|
|
1892
1333
|
const windowValid = obligation && typeof obligation.window_hours === 'number' && Number.isFinite(obligation.window_hours);
|
|
1893
1334
|
const deadline = obligation && clockValid && windowValid
|
|
1894
1335
|
? new Date(clockStart.getTime() + obligation.window_hours * 3600 * 1000).toISOString()
|
|
1895
1336
|
: 'pending_clock_start_event';
|
|
1896
|
-
//
|
|
1897
|
-
//
|
|
1898
|
-
// obligation_ref to name a real govern obligation). The unmatched ref is
|
|
1899
|
-
// already surfaced as a runtime_error above; drop the record rather than
|
|
1900
|
-
// emit a null-jurisdiction/regulation entry that pollutes the deadline list.
|
|
1337
|
+
// Already a runtime_error above; the record is dropped rather than
|
|
1338
|
+
// polluting the deadline list with a null-jurisdiction entry.
|
|
1901
1339
|
if (!obligation && na && typeof na.obligation_ref === 'string' && na.obligation_ref) {
|
|
1902
1340
|
return null;
|
|
1903
1341
|
}
|
|
1904
1342
|
return {
|
|
1905
1343
|
...na,
|
|
1906
|
-
//
|
|
1907
|
-
// operationally usable on its own (calendar deadlines, regulator
|
|
1908
|
-
// routing, evidence checklist).
|
|
1344
|
+
// Each entry must stand alone: deadline, routing, evidence checklist.
|
|
1909
1345
|
jurisdiction: obligation?.jurisdiction || null,
|
|
1910
1346
|
regulation: obligation?.regulation || null,
|
|
1911
1347
|
obligation_type: obligation?.obligation || null,
|
|
1912
1348
|
window_hours: obligation?.window_hours ?? null,
|
|
1913
1349
|
clock_start_event: obligation?.clock_starts || null,
|
|
1914
|
-
//
|
|
1915
|
-
//
|
|
1916
|
-
// still reach .toISOString() and throw. clockValid guarantees a finite
|
|
1917
|
-
// instant before we serialize.
|
|
1350
|
+
// clockValid, not optional chaining: `?.` only short-circuits
|
|
1351
|
+
// null/undefined, so an Invalid Date would still reach .toISOString().
|
|
1918
1352
|
clock_started_at: clockValid ? clockStart.toISOString() : null,
|
|
1919
1353
|
...(clockPendingAck ? { clock_pending_ack: true } : {}),
|
|
1920
1354
|
deadline,
|
|
1921
1355
|
// Alias matching compliance-team vocabulary.
|
|
1922
1356
|
notification_deadline: deadline,
|
|
1923
|
-
//
|
|
1924
|
-
// just the operator-facing recipient bundle on the notification entry).
|
|
1357
|
+
// What the regulator expects attached, per the obligation.
|
|
1925
1358
|
evidence_required: obligation?.evidence_required || na.evidence_attached || [],
|
|
1926
|
-
//
|
|
1927
|
-
// which template vars failed to resolve. Empty array when all
|
|
1928
|
-
// placeholders rendered cleanly.
|
|
1359
|
+
// Which template vars failed to resolve; empty when all rendered.
|
|
1929
1360
|
...(function () {
|
|
1930
1361
|
const missing = [];
|
|
1931
|
-
//
|
|
1932
|
-
//
|
|
1933
|
-
// can't bring down the whole close phase. Failures surface in
|
|
1934
|
-
// runtime_errors via runOpts._runErrors when available.
|
|
1362
|
+
// Wrapped so a malformed analyze result cannot bring down close();
|
|
1363
|
+
// the failure surfaces through runOpts._runErrors instead.
|
|
1935
1364
|
let findingShape;
|
|
1936
1365
|
try { findingShape = analyzeFindingShape(analyzeResult); }
|
|
1937
1366
|
catch (e) {
|
|
@@ -1949,27 +1378,17 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
1949
1378
|
})(),
|
|
1950
1379
|
};
|
|
1951
1380
|
};
|
|
1952
|
-
// enrichNotification returns null for an unresolved specified obligation_ref
|
|
1953
|
-
// (already surfaced as a runtime_error); filter those out so the notification
|
|
1954
|
-
// list never carries a null-jurisdiction record.
|
|
1955
1381
|
const notificationActions = (c.notification_actions || []).map(enrichNotification).filter(Boolean);
|
|
1956
1382
|
|
|
1957
|
-
// A
|
|
1958
|
-
//
|
|
1959
|
-
//
|
|
1960
|
-
// operators reading jurisdiction_notifications. Synthesize a record for
|
|
1961
|
-
// each uncovered notify-type obligation so the deadline is computed and
|
|
1962
|
-
// visible. The synthesized entry carries no draft template (the playbook
|
|
1963
|
-
// declared none) and is marked synthesized_from_obligation so operators
|
|
1964
|
-
// can tell it apart from a playbook-authored action.
|
|
1383
|
+
// A notify obligation with no matching close.notification_actions entry would
|
|
1384
|
+
// leave its regulatory clock invisible. The synthesized record carries no
|
|
1385
|
+
// draft and is marked synthesized_from_obligation.
|
|
1965
1386
|
const coveredObligationRefs = new Set((c.notification_actions || []).map(na => na.obligation_ref));
|
|
1966
1387
|
for (const o of (g.jurisdiction_obligations || [])) {
|
|
1967
1388
|
if (!o || typeof o !== 'object') continue; // skip a null/malformed obligation rather than crash close() during synthesis
|
|
1968
1389
|
if (!String(o.obligation || '').startsWith('notify')) continue;
|
|
1969
|
-
// A
|
|
1970
|
-
//
|
|
1971
|
-
// Surface it as a runtime_error and skip synthesis rather than emit a bogus
|
|
1972
|
-
// record (the deadline guard in enrichNotification also prevents the crash).
|
|
1390
|
+
// A non-number window_hours would synthesize a "…/… undefinedh" ref that
|
|
1391
|
+
// can never produce a deadline; surface it and skip the synthesis.
|
|
1973
1392
|
if (typeof o.window_hours !== 'number' || !Number.isFinite(o.window_hours)) {
|
|
1974
1393
|
if (Array.isArray(runOpts && runOpts._runErrors)) {
|
|
1975
1394
|
pushRunError(runOpts._runErrors, { kind: 'malformed_obligation_window_hours', obligation: `${o.jurisdiction}/${o.regulation}` }, { dedupeKey: x => x.obligation || '' });
|
|
@@ -1987,23 +1406,15 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
1987
1406
|
if (synthesized) notificationActions.push(synthesized);
|
|
1988
1407
|
}
|
|
1989
1408
|
|
|
1990
|
-
// exception_generation — evaluate trigger.
|
|
1991
1409
|
let exception = null;
|
|
1992
1410
|
if (c.exception_generation) {
|
|
1993
1411
|
const closeEvalCtx = runOpts._runErrors ? { ...agentSignals, _runErrors: runOpts._runErrors } : agentSignals;
|
|
1994
1412
|
const triggered = evalCondition(c.exception_generation.trigger_condition, closeEvalCtx, playbook);
|
|
1995
1413
|
if (triggered) {
|
|
1996
1414
|
const t = c.exception_generation.exception_template;
|
|
1997
|
-
//
|
|
1998
|
-
//
|
|
1999
|
-
//
|
|
2000
|
-
// patch_available_status, …) that analyzeFindingShape does NOT supply and
|
|
2001
|
-
// that operators rarely pre-stage on the standard `exceptd run` path, so the
|
|
2002
|
-
// auditor-ready risk-acceptance language otherwise ships literal
|
|
2003
|
-
// `<MISSING:…>` tokens with no machine-readable signal of which placeholders
|
|
2004
|
-
// failed. Surface them as exception.missing_interpolation_vars (empty when
|
|
2005
|
-
// all resolved) so a `ci`/JSON consumer — and the operator filling the
|
|
2006
|
-
// exception — sees exactly what still needs a value.
|
|
1415
|
+
// Exception-template tokens are operator-fill values analyzeFindingShape
|
|
1416
|
+
// does not supply, so the auditor-ready language routinely renders
|
|
1417
|
+
// `<MISSING:…>`. missing_interpolation_vars names which.
|
|
2007
1418
|
const exMissing = [];
|
|
2008
1419
|
const findingShape = (() => { try { return analyzeFindingShape(analyzeResult); } catch { return {}; } })();
|
|
2009
1420
|
exception = {
|
|
@@ -2017,16 +1428,15 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
2017
1428
|
framework_id: playbook.domain.frameworks_in_scope[0] || 'unspecified',
|
|
2018
1429
|
control_id: analyzeResult.framework_gap_mapping?.[0]?.claimed_control || 'unspecified',
|
|
2019
1430
|
ciso_name: agentSignals.ciso_name || '<CISO NAME>',
|
|
2020
|
-
//
|
|
2021
|
-
//
|
|
2022
|
-
// same auditor-facing date.
|
|
1431
|
+
// Deterministic mode roots the auditor-facing date in the frozen
|
|
1432
|
+
// epoch, so two runs over the same evidence agree.
|
|
2023
1433
|
acceptance_date: (deterministic ? frozenEpoch : new Date().toISOString()).slice(0, 10),
|
|
2024
1434
|
duration_expiry: agentSignals.duration_expiry || 'until vendor patch'
|
|
2025
1435
|
}, exMissing),
|
|
2026
1436
|
missing_interpolation_vars: exMissing,
|
|
2027
1437
|
};
|
|
2028
|
-
//
|
|
2029
|
-
//
|
|
1438
|
+
// Also on runtime_errors, so JSON/ci consumers see the gap without
|
|
1439
|
+
// walking into the exception object.
|
|
2030
1440
|
if (exMissing.length && Array.isArray(runOpts._runErrors)) {
|
|
2031
1441
|
pushRunError(runOpts._runErrors,
|
|
2032
1442
|
{ kind: 'exception_unresolved_placeholders', placeholders: exMissing.slice() },
|
|
@@ -2035,26 +1445,15 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
2035
1445
|
}
|
|
2036
1446
|
}
|
|
2037
1447
|
|
|
2038
|
-
// evidence_package — playbook declares one primary bundle_format; the
|
|
2039
|
-
// operator may request additional formats via agentSignals._bundle_formats
|
|
2040
|
-
// (e.g. SARIF for GitHub Code Scanning + OpenVEX for supply-chain tooling
|
|
2041
|
-
// alongside the CSAF default).
|
|
2042
1448
|
const primaryFormat = c.evidence_package?.bundle_format || 'csaf-2.0';
|
|
2043
1449
|
const extraFormats = Array.isArray(agentSignals._bundle_formats)
|
|
2044
1450
|
? agentSignals._bundle_formats.filter(f => f !== primaryFormat)
|
|
2045
1451
|
: [];
|
|
2046
|
-
//
|
|
2047
|
-
//
|
|
2048
|
-
// Without memoisation, buildEvidenceBundle gets invoked twice for the
|
|
2049
|
-
// primary format and each invocation crystallises a fresh Date.now() —
|
|
2050
|
-
// operators diffing bundle_body against bundles_by_format.<primary> see
|
|
2051
|
-
// spurious millisecond drift on tracking.initial_release_date /
|
|
2052
|
-
// timestamp / current_release_date.
|
|
1452
|
+
// Each bundle is built once, so bundle_body and bundles_by_format[primary]
|
|
1453
|
+
// share identity — a second build would crystallise a fresh Date.now().
|
|
2053
1454
|
const evidencePackage = c.evidence_package ? (() => {
|
|
2054
|
-
//
|
|
2055
|
-
//
|
|
2056
|
-
// generator.date,revision_history[0].date} and OpenVEX timestamp +
|
|
2057
|
-
// statements[].timestamp all collapse to a single, byte-stable value.
|
|
1455
|
+
// Deterministic mode pins issuedAt so every CSAF tracking date and every
|
|
1456
|
+
// OpenVEX timestamp collapses to one byte-stable value.
|
|
2058
1457
|
const issuedAt = deterministic ? frozenEpoch : new Date().toISOString();
|
|
2059
1458
|
const builtFormats = new Map();
|
|
2060
1459
|
const buildOnce = (format) => {
|
|
@@ -2064,12 +1463,8 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
2064
1463
|
return builtFormats.get(format);
|
|
2065
1464
|
};
|
|
2066
1465
|
const primaryBody = buildOnce(primaryFormat);
|
|
2067
|
-
//
|
|
2068
|
-
//
|
|
2069
|
-
// was null in the single-format case, forcing downstream tooling into a
|
|
2070
|
-
// `bundles_by_format ?? { [primaryFormat]: bundle_body }` shim in every
|
|
2071
|
-
// consumer. Now the field is canonically present so iteration is
|
|
2072
|
-
// uniform across single- and multi-format emissions.
|
|
1466
|
+
// Always an object keyed by the primary format, even for a single format,
|
|
1467
|
+
// so consumers iterate uniformly instead of shimming a null.
|
|
2073
1468
|
const byFormat = Object.fromEntries(
|
|
2074
1469
|
[primaryFormat, ...extraFormats].map(f => [f, buildOnce(f)])
|
|
2075
1470
|
);
|
|
@@ -2095,7 +1490,6 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
2095
1490
|
evidencePackage.signature_pending = 'No session_key provided. Sign with Ed25519 via `node $(exceptd path)/lib/sign.js sign-evidence <bundle.json>` post-emit (contributor checkout) or `exceptd doctor --fix` to enable signing.';
|
|
2096
1491
|
}
|
|
2097
1492
|
|
|
2098
|
-
// learning_loop lesson
|
|
2099
1493
|
const lesson = c.learning_loop?.enabled ? {
|
|
2100
1494
|
enabled: true,
|
|
2101
1495
|
attack_vector: interpolate(c.learning_loop.lesson_template.attack_vector, analyzeFindingShape(analyzeResult)),
|
|
@@ -2106,19 +1500,12 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
2106
1500
|
proposed_for_zeroday_lessons_id: `lesson-${playbook._meta.id}-${sessionId}`
|
|
2107
1501
|
} : { enabled: false };
|
|
2108
1502
|
|
|
2109
|
-
//
|
|
2110
|
-
//
|
|
2111
|
-
// v0.12.27: deterministic mode re-derives next_run from the frozen epoch
|
|
2112
|
-
// rather than wall-clock-now-at-validate-time. Without this, two runs
|
|
2113
|
-
// against the same evidence diverge on next_run by the interval between
|
|
2114
|
-
// the two `validate()` invocations. Frozen base + the same interval set
|
|
2115
|
-
// = byte-identical schedule.
|
|
1503
|
+
// Re-derived from the frozen epoch: taken from wall-clock-at-validate-time,
|
|
1504
|
+
// two runs diverge by the interval between their validate() calls.
|
|
2116
1505
|
const regressionSchedule = c.regression_schedule ? (() => {
|
|
2117
1506
|
let nextRun = validateResult.regression_next_run;
|
|
2118
1507
|
if (deterministic) {
|
|
2119
|
-
//
|
|
2120
|
-
// close phase's regression_schedule subtree — close has no triggers
|
|
2121
|
-
// of its own, just the canonical interval declared upstream).
|
|
1508
|
+
// Against the validate phase's triggers — close declares none of its own.
|
|
2122
1509
|
const v = resolvedPhase(playbook, directiveId, 'validate');
|
|
2123
1510
|
nextRun = frozenRegressionNextRun(v.regression_trigger || [], new Date(frozenEpoch));
|
|
2124
1511
|
}
|
|
@@ -2129,69 +1516,35 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
2129
1516
|
};
|
|
2130
1517
|
})() : null;
|
|
2131
1518
|
|
|
2132
|
-
//
|
|
2133
|
-
// reference `analyze.compliance_theater_check.verdict` etc.
|
|
1519
|
+
// The whole analyze result, so conditions can reference `analyze.*` paths.
|
|
2134
1520
|
const feedsCtx = {
|
|
2135
|
-
//
|
|
2136
|
-
//
|
|
2137
|
-
// collector path (signal_overrides), not only when the id is re-submitted
|
|
2138
|
-
// under signals. Lowest precedence — operator signals and engine values win.
|
|
1521
|
+
// Lowest precedence, so a feeds_into condition written as
|
|
1522
|
+
// `<indicator-id> == true` resolves on the standard collector path.
|
|
2139
1523
|
...firedIndicatorSignals(analyzeResult && analyzeResult._detect_indicators),
|
|
2140
|
-
// Operator
|
|
2141
|
-
//
|
|
2142
|
-
// analyze would override the engine value the feeds_into condition tests
|
|
2143
|
-
// and could suppress a legitimate downstream chain.
|
|
1524
|
+
// Operator signals FIRST so the engine keys below win: a submitted
|
|
1525
|
+
// signals.rwep must not override the value the condition tests.
|
|
2144
1526
|
...agentSignals,
|
|
2145
1527
|
rwep: analyzeResult.rwep?.adjusted,
|
|
2146
|
-
// Bare-token parity with the escalation context
|
|
2147
|
-
//
|
|
2148
|
-
//
|
|
2149
|
-
// these top-level keys resolvePath returns null and `null >= 4` is false
|
|
2150
|
-
// regardless of the engine-computed blast radius, so the chain is dead. They
|
|
2151
|
-
// are spread AFTER ...agentSignals so the engine value wins over any
|
|
2152
|
-
// operator-submitted signals.blast_radius_score / signals.theater_verdict.
|
|
1528
|
+
// Bare-token parity with the escalation context: catalog conditions write
|
|
1529
|
+
// these unqualified, and without the top-level keys resolvePath returns
|
|
1530
|
+
// null, so `null >= 4` kills the chain whatever the engine computed.
|
|
2153
1531
|
blast_radius_score: analyzeResult.blast_radius_score,
|
|
2154
1532
|
theater_verdict: analyzeResult.compliance_theater_check?.verdict,
|
|
2155
|
-
// Bare top-level `compliance_theater_check` so catalog conditions that
|
|
2156
|
-
// reference `compliance_theater_check.verdict` unqualified (framework.json's
|
|
2157
|
-
// feeds_into into sbom) resolve — without it resolvePath returns null and the
|
|
2158
|
-
// clause is dead regardless of the engine verdict. The `analyze.*` alias
|
|
2159
|
-
// below stays for conditions that use the qualified path.
|
|
2160
1533
|
compliance_theater_check: analyzeResult.compliance_theater_check,
|
|
2161
|
-
// Govern-phase jurisdiction obligations so feeds_into conditions like
|
|
2162
|
-
// `… AND jurisdiction_obligations contains 'EU'` (framework → sbom) resolve.
|
|
2163
1534
|
jurisdiction_obligations: (g && g.jurisdiction_obligations) || (playbook.phases && playbook.phases.govern && playbook.phases.govern.jurisdiction_obligations) || [],
|
|
2164
|
-
// theater_score follows lib/framework-gap.js
|
|
2165
|
-
//
|
|
2166
|
-
//
|
|
2167
|
-
// this was inverted, so a feeds_into condition like `theater_score >= 50`
|
|
2168
|
-
// would have failed to fire exactly when a gap was found.)
|
|
2169
|
-
// 'present' is an allowlisted theater-equivalent verdict (the same
|
|
2170
|
-
// gap-present set verdict_text uses at line 1426 and the allowlist comment
|
|
2171
|
-
// names at line 1352-1353), so it must score 100 (gap detected = worse), not
|
|
2172
|
-
// 0. Scoring only the 'theater' spelling left a 'present' verdict scored as
|
|
2173
|
-
// clear — inverted for any future feeds_into condition gating on theater_score.
|
|
1535
|
+
// theater_score follows lib/framework-gap.js: high means more theater, so a
|
|
1536
|
+
// gap-present verdict scores 100 and 'clear' scores 0. 'present' is the
|
|
1537
|
+
// allowlisted synonym for 'theater' and must score with it.
|
|
2174
1538
|
theater_score: (analyzeResult.compliance_theater_check?.verdict === 'theater' || analyzeResult.compliance_theater_check?.verdict === 'present') ? 100 : 0,
|
|
2175
|
-
//
|
|
2176
|
-
//
|
|
2177
|
-
// at each matched CVE. Without it the quantifier head resolves null and the
|
|
2178
|
-
// sbom -> kernel/mcp/ai-api deep-dive chains stay dead even when a matched
|
|
2179
|
-
// CVE carries the attack_class. The `analyze.matched_cves` alias stays for
|
|
2180
|
-
// conditions that use the qualified path.
|
|
1539
|
+
// The head the sbom feeds_into quantifiers re-root at; without the
|
|
1540
|
+
// top-level key it resolves null and the deep-dive chains stay dead.
|
|
2181
1541
|
matched_cve: analyzeResult.matched_cves || [],
|
|
2182
1542
|
analyze: analyzeResult,
|
|
2183
1543
|
validate: validateResult,
|
|
2184
|
-
//
|
|
2185
|
-
// via analyzeFindingShape; the DESCRIPTIVE keys the catalog feeds_into
|
|
2186
|
-
// conditions gate on (finding.includes_*, cve_class, tool_surface,
|
|
2187
|
-
// mcp_server_location, pipeline_credentials_in_scope, …) are host-AI-asserted.
|
|
2188
|
-
// Merge the agent-supplied finding sub-fields UNDER the engine shape so the
|
|
2189
|
-
// descriptive keys survive while engine-owned keys WIN on collision. Same
|
|
2190
|
-
// shape + guards as the escalation ctx above.
|
|
1544
|
+
// Same two-sourced finding.* shape and guards as the escalation context.
|
|
2191
1545
|
finding: { ...(agentSignals.finding && typeof agentSignals.finding === 'object' && !Array.isArray(agentSignals.finding) ? agentSignals.finding : {}), ...analyzeFindingShape(analyzeResult) },
|
|
2192
|
-
//
|
|
2193
|
-
//
|
|
2194
|
-
// analyze.runtime_errors[] never sees it.
|
|
1546
|
+
// Without the accumulator, a feeds_into condition failure never reaches
|
|
1547
|
+
// analyze.runtime_errors[].
|
|
2195
1548
|
...(runOpts._runErrors ? { _runErrors: runOpts._runErrors } : {})
|
|
2196
1549
|
};
|
|
2197
1550
|
const feeds = (playbook._meta.feeds_into || [])
|
|
@@ -2205,32 +1558,22 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
2205
1558
|
evidence_package: evidencePackage,
|
|
2206
1559
|
learning_loop: lesson,
|
|
2207
1560
|
notification_actions: notificationActions,
|
|
2208
|
-
//
|
|
2209
|
-
//
|
|
2210
|
-
// notification_actions list, plus a `jurisdiction_clocks_count` that
|
|
2211
|
-
// mirrors `ci.summary.jurisdiction_clocks_started` — the count of
|
|
2212
|
-
// notifications whose clock has actually started (clock_started_at != null).
|
|
1561
|
+
// Alias for notification_actions. jurisdiction_clocks_count mirrors
|
|
1562
|
+
// ci.summary.jurisdiction_clocks_started — those whose clock has started.
|
|
2213
1563
|
jurisdiction_notifications: notificationActions,
|
|
2214
1564
|
jurisdiction_clocks_count: notificationActions.filter(n => n && n.clock_started_at != null).length,
|
|
2215
1565
|
exception: exception,
|
|
2216
1566
|
regression_schedule: regressionSchedule,
|
|
2217
1567
|
feeds_into: feeds,
|
|
2218
|
-
// feeds_into
|
|
2219
|
-
//
|
|
2220
|
-
// into them — the agent / operator decides whether to invoke them.
|
|
2221
|
-
// Surface that contract on the result so consumers don't assume an
|
|
2222
|
-
// automated handoff happened.
|
|
1568
|
+
// The runner never chains into feeds_into itself; the agent or operator
|
|
1569
|
+
// decides. Stated on the result so no consumer assumes a handoff happened.
|
|
2223
1570
|
feeds_into_auto_chained: false,
|
|
2224
1571
|
};
|
|
2225
1572
|
}
|
|
2226
1573
|
|
|
2227
|
-
// Severity ladder for active_exploitation.
|
|
2228
|
-
//
|
|
2229
|
-
// the
|
|
2230
|
-
// `theoretical` (PoC exists, no in-the-wild use) must rank between `none` and
|
|
2231
|
-
// `unknown`; omitting it made `?? -1` lose to the -1 start, so an all-theoretical
|
|
2232
|
-
// matched set wrongly reduced to 'unknown' and a theoretical+none set dropped
|
|
2233
|
-
// the theoretical entry entirely. This vocabulary is first-class in scoring.js.
|
|
1574
|
+
// Severity ladder for active_exploitation, higher being worse. Every value in
|
|
1575
|
+
// scoring.js's vocabulary must appear — an omitted one falls to `?? -1`, loses
|
|
1576
|
+
// to the -1 start, and drops out of the reduction entirely.
|
|
2234
1577
|
const ACTIVE_EXPLOITATION_RANK = { none: 0, theoretical: 1, unknown: 2, suspected: 3, confirmed: 4 };
|
|
2235
1578
|
|
|
2236
1579
|
function worstActiveExploitation(matchedCves) {
|
|
@@ -2242,19 +1585,13 @@ function worstActiveExploitation(matchedCves) {
|
|
|
2242
1585
|
const rank = ACTIVE_EXPLOITATION_RANK[v] ?? -1;
|
|
2243
1586
|
if (rank > worstRank) { worst = v; worstRank = rank; }
|
|
2244
1587
|
}
|
|
2245
|
-
//
|
|
2246
|
-
// 'unknown' exploitation it never observed
|
|
1588
|
+
// An empty or all-unrecognized set is 'none': a draft must not assert
|
|
1589
|
+
// 'unknown' exploitation it never observed.
|
|
2247
1590
|
return worst || 'none';
|
|
2248
1591
|
}
|
|
2249
1592
|
|
|
2250
|
-
//
|
|
2251
|
-
//
|
|
2252
|
-
// emit it so those conditions resolve against a real value rather than
|
|
2253
|
-
// undefined. Thresholds:
|
|
2254
|
-
// rwep >= 80 → critical
|
|
2255
|
-
// rwep >= 50 → high
|
|
2256
|
-
// rwep >= 20 → medium
|
|
2257
|
-
// rwep < 20 → low
|
|
1593
|
+
// The value playbooks gate on as `finding.severity` in feeds_into and
|
|
1594
|
+
// escalation_criteria; without it those conditions resolve undefined.
|
|
2258
1595
|
function severityForRwep(rwep) {
|
|
2259
1596
|
const r = typeof rwep === 'number' ? rwep : 0;
|
|
2260
1597
|
if (r >= 80) return 'critical';
|
|
@@ -2268,23 +1605,17 @@ function analyzeFindingShape(a) {
|
|
|
2268
1605
|
const rwepAdjusted = a.rwep?.adjusted ?? 0;
|
|
2269
1606
|
return {
|
|
2270
1607
|
matched_cve_ids: matched.map(c => c.cve_id).join(', '),
|
|
2271
|
-
//
|
|
2272
|
-
//
|
|
2273
|
-
// compatibility with notification-draft templates that interpolate
|
|
2274
|
-
// `${matched_cve_ids}` verbatim.
|
|
1608
|
+
// The joined form is what notification-draft templates interpolate as
|
|
1609
|
+
// `${matched_cve_ids}`; this is for consumers that want to iterate.
|
|
2275
1610
|
matched_cve_ids_array: matched.map(c => c.cve_id),
|
|
2276
1611
|
matched_cve_count: matched.length,
|
|
2277
1612
|
kev_listed_count: matched.filter(c => c.cisa_kev).length,
|
|
2278
|
-
//
|
|
2279
|
-
//
|
|
2280
|
-
//
|
|
2281
|
-
// the threat in notification drafts.
|
|
2282
|
-
// Exclude VEX-fixed (vendor-patched) CVEs: a notification draft must not
|
|
2283
|
-
// assert active exploitation sourced from an already-remediated CVE.
|
|
1613
|
+
// Worst rank, not the first truthy entry: a draft reporting 'suspected'
|
|
1614
|
+
// while another CVE is 'confirmed' understates the threat. VEX-fixed CVEs
|
|
1615
|
+
// are excluded — a draft must not source exploitation from a fixed CVE.
|
|
2284
1616
|
active_exploitation: worstActiveExploitation(matched.filter(c => c.vex_status !== 'fixed')),
|
|
2285
1617
|
rwep_adjusted: rwepAdjusted,
|
|
2286
1618
|
rwep_base: a.rwep?.base ?? 0,
|
|
2287
|
-
// Severity surface for playbook conditions.
|
|
2288
1619
|
severity: severityForRwep(rwepAdjusted),
|
|
2289
1620
|
blast_radius_score: a.blast_radius_score ?? 0,
|
|
2290
1621
|
framework_id_first: a.framework_gap_mapping?.[0]?.framework || null,
|
|
@@ -2292,18 +1623,10 @@ function analyzeFindingShape(a) {
|
|
|
2292
1623
|
};
|
|
2293
1624
|
}
|
|
2294
1625
|
|
|
2295
|
-
//
|
|
2296
|
-
//
|
|
2297
|
-
//
|
|
2298
|
-
// -
|
|
2299
|
-
// still labelled with its system_name). An unrecognised prefix resolves to a
|
|
2300
|
-
// null helpUri rather than a fabricated link.
|
|
2301
|
-
//
|
|
2302
|
-
// Used by the SARIF rule emitter (helpUri) so non-CVE matched ids no longer
|
|
2303
|
-
// get a hardcoded nvd.nist.gov/vuln/detail/<id> URL — that URL 404s for every
|
|
2304
|
-
// MAL-/GHSA-/OSV-/RUSTSEC- id and mislabels it as an NVD CVE. The same
|
|
2305
|
-
// prefix→authority knowledge lives in the CSAF ids[] branch (csafIdsFor); both
|
|
2306
|
-
// derive from this table so the two exports cannot drift.
|
|
1626
|
+
// A vulnerability identifier's issuing authority and canonical advisory URL. An
|
|
1627
|
+
// id with no public per-id page, and an unrecognised prefix, both resolve to a
|
|
1628
|
+
// null helpUri rather than a fabricated link: an nvd.nist.gov URL 404s for a
|
|
1629
|
+
// non-CVE id. The CSAF ids[] branch (csafIdsFor) carries the same knowledge.
|
|
2307
1630
|
const CVE_ID_RE = /^CVE-\d{4}-\d{4,}$/;
|
|
2308
1631
|
function advisoryAuthorityFor(id) {
|
|
2309
1632
|
if (typeof id !== 'string' || !id) return { system_name: null, helpUri: null };
|
|
@@ -2312,21 +1635,17 @@ function advisoryAuthorityFor(id) {
|
|
|
2312
1635
|
if (id.startsWith('OSV-')) return { system_name: 'OSV', helpUri: `https://osv.dev/vulnerability/${id}` };
|
|
2313
1636
|
if (id.startsWith('RUSTSEC-')) return { system_name: 'RUSTSEC', helpUri: `https://rustsec.org/advisories/${id}.html` };
|
|
2314
1637
|
if (id.startsWith('SNYK-')) return { system_name: 'Snyk', helpUri: `https://security.snyk.io/vuln/${id}` };
|
|
2315
|
-
// Malicious-package ids have no canonical per-id advisory page; label the
|
|
2316
|
-
// authority but emit no link rather than a fabricated NVD URL.
|
|
2317
1638
|
if (id.startsWith('MAL-')) return { system_name: 'Malicious-Package', helpUri: null };
|
|
2318
1639
|
return { system_name: 'exceptd-unknown', helpUri: null };
|
|
2319
1640
|
}
|
|
2320
1641
|
|
|
2321
|
-
// Route a vulnerability identifier to its
|
|
2322
|
-
//
|
|
2323
|
-
//
|
|
2324
|
-
// namespace so OpenVEX statements still carry a valid IRI per RFC 8141.
|
|
1642
|
+
// Route a vulnerability identifier to its registered URN namespace, or to the
|
|
1643
|
+
// private `urn:exceptd:advisory:` one, so every OpenVEX statement carries a
|
|
1644
|
+
// valid IRI per RFC 8141.
|
|
2325
1645
|
function vulnIdToUrn(id) {
|
|
2326
1646
|
if (typeof id !== 'string' || id.length === 0) return `urn:exceptd:advisory:${urnSlug(id)}`;
|
|
2327
|
-
// Registered identifiers keep their canonical case
|
|
2328
|
-
//
|
|
2329
|
-
// namespace slugs arbitrary text, so it stays lowercase.
|
|
1647
|
+
// Registered identifiers keep their canonical case so the @id matches the
|
|
1648
|
+
// OpenVEX `name` / CSAF id; the private namespace slugs text and lowercases.
|
|
2330
1649
|
const canonical = urnSlug(id, true);
|
|
2331
1650
|
if (/^CVE-/i.test(id)) return `urn:cve:${canonical}`;
|
|
2332
1651
|
if (/^GHSA-/i.test(id)) return `urn:ghsa:${canonical}`;
|
|
@@ -2335,26 +1654,16 @@ function vulnIdToUrn(id) {
|
|
|
2335
1654
|
return `urn:exceptd:advisory:${urnSlug(id)}`;
|
|
2336
1655
|
}
|
|
2337
1656
|
|
|
2338
|
-
// Build a CSAF product_tree.branches[]
|
|
2339
|
-
//
|
|
2340
|
-
//
|
|
2341
|
-
//
|
|
2342
|
-
//
|
|
2343
|
-
//
|
|
2344
|
-
// error and are dropped from the tree. Sort alphabetical at each level so
|
|
2345
|
-
// the output is deterministic across runs.
|
|
2346
|
-
//
|
|
2347
|
-
// Returns `{ branches, productIds }`. productIds is a stable enumeration
|
|
2348
|
-
// CSAFPID-0..N keyed by (vendor, product, version) insertion order so other
|
|
2349
|
-
// emit paths can reference the leaf products by id later.
|
|
1657
|
+
// Build a CSAF product_tree.branches[] from the catalog's `affected_products`
|
|
1658
|
+
// records, falling back to a heuristic parse of `affected_components[]`.
|
|
1659
|
+
// Unparseable components raise `csaf_branch_unparseable` and drop; each level
|
|
1660
|
+
// sorts alphabetically for determinism. Returns { branches, productIds,
|
|
1661
|
+
// cveProductIds }, the last binding each CVE to the leaves it contributed so
|
|
1662
|
+
// the caller can reference them from vulnerabilities[].product_status.
|
|
2350
1663
|
function buildCsafBranches(matchedCves, runOpts) {
|
|
2351
|
-
// Build a (vendor → product → Set<version>) map.
|
|
2352
1664
|
const tree = new Map();
|
|
2353
|
-
//
|
|
2354
|
-
//
|
|
2355
|
-
// this the branch tree's per-version products are referenced by nothing and a
|
|
2356
|
-
// CSAF consumer correlating product_status to the tree resolves only the
|
|
2357
|
-
// generic synthetic product. leafKey = vendor\0product\0version.
|
|
1665
|
+
// Without the per-CVE binding the version leaves are referenced by nothing,
|
|
1666
|
+
// and product_status correlates only to the synthetic product.
|
|
2358
1667
|
const leafKey = (vendor, product, version) => JSON.stringify([vendor, product, version]);
|
|
2359
1668
|
const cveLeaves = new Map(); // cve_id → Set<leafKey>
|
|
2360
1669
|
const addLeaf = (vendor, product, version, cveId) => {
|
|
@@ -2369,13 +1678,11 @@ function buildCsafBranches(matchedCves, runOpts) {
|
|
|
2369
1678
|
}
|
|
2370
1679
|
};
|
|
2371
1680
|
|
|
2372
|
-
//
|
|
2373
|
-
// version
|
|
2374
|
-
// shape (e.g. "linux-kernel >= 4.14", "runc <= 1.1.11", "litellm < 1.83.7").
|
|
2375
|
-
// These are operators, never package names.
|
|
1681
|
+
// What sits between package and version in the catalog's dominant
|
|
1682
|
+
// `package OP version` shape. These are never package names.
|
|
2376
1683
|
const RANGE_OP_RE = /^(<=|>=|==|!=|~>|<|>|=|~|\^)$/;
|
|
2377
1684
|
|
|
2378
|
-
//
|
|
1685
|
+
// Returns { vendor, product, version } or null.
|
|
2379
1686
|
const parseComponentString = (s) => {
|
|
2380
1687
|
if (typeof s !== 'string' || !s.trim()) return null;
|
|
2381
1688
|
const trimmed = s.trim();
|
|
@@ -2383,27 +1690,19 @@ function buildCsafBranches(matchedCves, runOpts) {
|
|
|
2383
1690
|
let m = trimmed.match(/^([^/\s@]+)\/([^/\s@]+)@(.+)$/);
|
|
2384
1691
|
if (m) return { vendor: m[1], product: m[2], version: m[3].trim() };
|
|
2385
1692
|
const parts = trimmed.split(/\s+/);
|
|
2386
|
-
// `package OP version
|
|
2387
|
-
//
|
|
2388
|
-
//
|
|
2389
|
-
// product_name. Pre-fix this split named the product after the operator
|
|
2390
|
-
// ('>=', '<', '=='), corrupting the CSAF affected-product list. Carry the
|
|
2391
|
-
// operator into the version string ('>= 4.14') so the range qualifier
|
|
2392
|
-
// survives while the product_name stays the real package. Multiple leading
|
|
2393
|
-
// operator tokens (a compound range emitted as one string) collapse into
|
|
2394
|
-
// the version qualifier too.
|
|
1693
|
+
// `package OP version`. The operator belongs to the version qualifier
|
|
1694
|
+
// ('>= 4.14'), not the product_name — naming the product after '>=' or '<'
|
|
1695
|
+
// corrupts the CSAF affected-product list.
|
|
2395
1696
|
if (parts.length >= 3 && RANGE_OP_RE.test(parts[1])) {
|
|
2396
1697
|
const product = parts[0];
|
|
2397
1698
|
const versionTokens = parts.slice(1);
|
|
2398
|
-
//
|
|
2399
|
-
// (starts with a digit or v\d) — otherwise it isn't a `package OP version`.
|
|
1699
|
+
// The shape only holds when the trailing token really is a version.
|
|
2400
1700
|
const lastTok = versionTokens[versionTokens.length - 1];
|
|
2401
1701
|
if (/^v?\d/.test(lastTok)) {
|
|
2402
1702
|
return { vendor: product, product, version: versionTokens.join(' ') };
|
|
2403
1703
|
}
|
|
2404
1704
|
}
|
|
2405
|
-
// `vendor product version
|
|
2406
|
-
// where the last token starts with a digit or `v\d`.
|
|
1705
|
+
// `vendor product version`, where the last token starts with a digit or `v\d`.
|
|
2407
1706
|
if (parts.length >= 3) {
|
|
2408
1707
|
const last = parts[parts.length - 1];
|
|
2409
1708
|
if (/^v?\d/.test(last)) {
|
|
@@ -2438,7 +1737,6 @@ function buildCsafBranches(matchedCves, runOpts) {
|
|
|
2438
1737
|
}
|
|
2439
1738
|
}
|
|
2440
1739
|
|
|
2441
|
-
// Sort + emit.
|
|
2442
1740
|
const productIds = [];
|
|
2443
1741
|
const leafKeyToPid = new Map();
|
|
2444
1742
|
let pidCounter = 0;
|
|
@@ -2471,8 +1769,6 @@ function buildCsafBranches(matchedCves, runOpts) {
|
|
|
2471
1769
|
}),
|
|
2472
1770
|
};
|
|
2473
1771
|
});
|
|
2474
|
-
// Per-CVE CSAFPID binding so the caller can add the version leaves to each
|
|
2475
|
-
// vulnerability's product_status.known_affected.
|
|
2476
1772
|
const cveProductIds = {};
|
|
2477
1773
|
for (const [cveId, keys] of cveLeaves.entries()) {
|
|
2478
1774
|
const pids = [];
|
|
@@ -2482,11 +1778,9 @@ function buildCsafBranches(matchedCves, runOpts) {
|
|
|
2482
1778
|
return { branches, productIds, cveProductIds };
|
|
2483
1779
|
}
|
|
2484
1780
|
|
|
2485
|
-
// Slugify
|
|
2486
|
-
// 'unknown'
|
|
2487
|
-
//
|
|
2488
|
-
// case-sensitive per RFC 8141, and the OpenVEX `name`/CSAF id fields carry the
|
|
2489
|
-
// canonical case, so the URN @id must match rather than fold to lowercase.
|
|
1781
|
+
// Slugify into a URN-safe segment (RFC 8141 NSS); empty input returns
|
|
1782
|
+
// 'unknown', never a zero-length segment. The NSS is case-sensitive, so
|
|
1783
|
+
// preserveCase keeps a registered identifier matching its OpenVEX / CSAF form.
|
|
2490
1784
|
function urnSlug(s, preserveCase = false) {
|
|
2491
1785
|
if (s == null) return 'unknown';
|
|
2492
1786
|
let str = String(s);
|
|
@@ -2497,10 +1791,9 @@ function urnSlug(s, preserveCase = false) {
|
|
|
2497
1791
|
return slug.length ? slug : 'unknown';
|
|
2498
1792
|
}
|
|
2499
1793
|
|
|
2500
|
-
//
|
|
2501
|
-
//
|
|
2502
|
-
//
|
|
2503
|
-
// `products` array per spec §4.3.
|
|
1794
|
+
// The product binding CSAF and OpenVEX share: product_tree must declare every
|
|
1795
|
+
// product product_status references, and an OpenVEX statement MUST carry a
|
|
1796
|
+
// `products` array (spec §4.3).
|
|
2504
1797
|
function buildProductBinding(playbook, sessionId) {
|
|
2505
1798
|
const playbookSlug = urnSlug(playbook._meta.id);
|
|
2506
1799
|
const sessionSlug = urnSlug(sessionId || 'session');
|
|
@@ -2513,30 +1806,10 @@ function buildProductBinding(playbook, sessionId) {
|
|
|
2513
1806
|
};
|
|
2514
1807
|
}
|
|
2515
1808
|
|
|
2516
|
-
//
|
|
2517
|
-
//
|
|
2518
|
-
//
|
|
2519
|
-
//
|
|
2520
|
-
// surface at least one candidate when any is known. Returns null when no
|
|
2521
|
-
// candidate exists — caller MUST omit `locations` rather than emit empty.
|
|
2522
|
-
//
|
|
2523
|
-
// Source segments are heterogeneous — many playbook artifacts
|
|
2524
|
-
// describe a shell-command capture (`uname -r`) or human prose, not a real
|
|
2525
|
-
// file or URI. SARIF `artifactLocation.uri` is defined as a URI reference
|
|
2526
|
-
// (RFC 3986); shell-command text + prose breaks downstream consumers
|
|
2527
|
-
// (GitHub Code Scanning rejects with "invalid URI" or renders garbled).
|
|
2528
|
-
// We accept only path-shaped candidates: absolute POSIX paths, `~`-home
|
|
2529
|
-
// paths, relative paths, drive-prefixed Windows paths, or file-URI
|
|
2530
|
-
// strings. Everything else (commands, English) is dropped, and locations
|
|
2531
|
-
// is omitted entirely when no candidate survives.
|
|
2532
|
-
// Path-shape predicate: accept anything that begins with a POSIX absolute
|
|
2533
|
-
// path (`/...`), home (`~/...` or `~`), relative dot (`./...`, `../...`,
|
|
2534
|
-
// or a bare `.`), drive-prefixed Windows path (`C:\...`, `C:/...`), or a
|
|
2535
|
-
// `file:` URI. Also accept simple relative names that contain a slash
|
|
2536
|
-
// (e.g. `etc/os-release`, `subdir/file.json`) — these are common in
|
|
2537
|
-
// playbook artifact source fields. Reject anything with internal
|
|
2538
|
-
// whitespace (commands like `uname -r`, prose like `kpatch list || ls
|
|
2539
|
-
// /sys/kernel/livepatch`) or that looks like a sentence.
|
|
1809
|
+
// Path-shape predicate for a SARIF artifactLocation.uri candidate. A
|
|
1810
|
+
// look-artifact `source` is as often a shell command or prose as a file, and
|
|
1811
|
+
// SARIF requires an RFC 3986 URI reference — anything with internal whitespace
|
|
1812
|
+
// is a command or a sentence, not a path.
|
|
2540
1813
|
function looksLikePath(src) {
|
|
2541
1814
|
if (typeof src !== 'string') return false;
|
|
2542
1815
|
const trimmed = src.trim();
|
|
@@ -2549,20 +1822,16 @@ function looksLikePath(src) {
|
|
|
2549
1822
|
if (/^[A-Za-z0-9_.+-]+[/\\][^\s]+$/.test(trimmed)) return true; // bare relative path
|
|
2550
1823
|
return false;
|
|
2551
1824
|
}
|
|
1825
|
+
// Physical SARIF locations for an indicator hit, or null — on which the caller
|
|
1826
|
+
// must omit `locations` rather than emit an empty array. Submission-supplied
|
|
1827
|
+
// evidence_locations give a real file; look-artifact sources are the fallback.
|
|
2552
1828
|
function sarifLocationsForIndicator(playbook, indicator) {
|
|
2553
|
-
// Prefer per-indicator evidence locations the submission supplied (threaded
|
|
2554
|
-
// onto the firing indicator by detect()). Each entry is a path string or
|
|
2555
|
-
// { uri, startLine?, endLine? }. This is what gives SARIF results a real
|
|
2556
|
-
// file location instead of the coarse playbook-source fallback below.
|
|
2557
1829
|
const ev = indicator && Array.isArray(indicator.evidence_locations) ? indicator.evidence_locations : null;
|
|
2558
1830
|
if (ev && ev.length) {
|
|
2559
1831
|
const locs = [];
|
|
2560
1832
|
for (const e of ev) {
|
|
2561
|
-
//
|
|
2562
|
-
//
|
|
2563
|
-
// arrives with backslashes; normalize them so the emitted SARIF is
|
|
2564
|
-
// valid (the collectors already normalize via buildEvidenceLocations,
|
|
2565
|
-
// but submission-threaded locations bypass that path).
|
|
1833
|
+
// artifactLocation.uri is an RFC 3986 reference, so the separator must be
|
|
1834
|
+
// `/`; submission-threaded locations bypass the collectors' normalization.
|
|
2566
1835
|
if (typeof e === "string" && e.trim()) {
|
|
2567
1836
|
locs.push({ physicalLocation: { artifactLocation: { uri: e.trim().replace(/\\/g, "/") } } });
|
|
2568
1837
|
} else if (e && typeof e === "object" && typeof e.uri === "string" && e.uri.trim()) {
|
|
@@ -2586,27 +1855,18 @@ function sarifLocationsForIndicator(playbook, indicator) {
|
|
|
2586
1855
|
return [{ physicalLocation: { artifactLocation: { uri: candidates[0] } } }];
|
|
2587
1856
|
}
|
|
2588
1857
|
|
|
2589
|
-
// Locations for a finding-class SARIF result,
|
|
2590
|
-
//
|
|
2591
|
-
//
|
|
2592
|
-
//
|
|
2593
|
-
// source (which carries whitespace and is rejected by looksLikePath), so the
|
|
2594
|
-
// physical fallback is null for ~most playbooks. A SARIF result with no
|
|
2595
|
-
// `locations[]` is silently DROPPED by GitHub Code Scanning — so a result with
|
|
2596
|
-
// no concrete file still gets a `logicalLocations` entry naming its rule, which
|
|
2597
|
-
// is SARIF-conformant (§3.33), keeps the finding attributable + visible in the
|
|
2598
|
-
// alerts list and in SARIF viewers, and is honest (there is no physical file to
|
|
2599
|
-
// point at when the agent supplied no evidence location). Physical location is
|
|
2600
|
-
// always preferred when available.
|
|
1858
|
+
// Locations for a finding-class SARIF result, never empty: GitHub Code Scanning
|
|
1859
|
+
// silently DROPS a result with no `locations[]`, and most playbooks have no
|
|
1860
|
+
// physical location to give. A rule-naming `logicalLocations` entry is
|
|
1861
|
+
// SARIF-conformant (§3.33) and honest about there being no file to point at.
|
|
2601
1862
|
function sarifResultLocations(playbook, indicator, fqRuleId) {
|
|
2602
1863
|
const phys = sarifLocationsForIndicator(playbook, indicator);
|
|
2603
1864
|
if (phys && phys.length) return phys;
|
|
2604
1865
|
return [{ logicalLocations: [{ name: fqRuleId, fullyQualifiedName: fqRuleId, kind: 'rule' }] }];
|
|
2605
1866
|
}
|
|
2606
1867
|
|
|
2607
|
-
//
|
|
2608
|
-
//
|
|
2609
|
-
// emission must not crash if package.json is missing (e.g. exotic install).
|
|
1868
|
+
// The engine version CSAF tracking.generator names, resolved once per process.
|
|
1869
|
+
// Bundle emission must not crash on an install where package.json is unreadable.
|
|
2610
1870
|
let _CACHED_PKG_VERSION = null;
|
|
2611
1871
|
function getEngineVersion() {
|
|
2612
1872
|
if (_CACHED_PKG_VERSION != null) return _CACHED_PKG_VERSION;
|
|
@@ -2619,16 +1879,9 @@ function getEngineVersion() {
|
|
|
2619
1879
|
return _CACHED_PKG_VERSION;
|
|
2620
1880
|
}
|
|
2621
1881
|
|
|
2622
|
-
//
|
|
2623
|
-
//
|
|
2624
|
-
//
|
|
2625
|
-
// gates every shipped playbook — stable across re-runs of the same
|
|
2626
|
-
// catalog version)
|
|
2627
|
-
// 3. '1970-01-01T00:00:00Z' fallback (effectively impossible in practice
|
|
2628
|
-
// because every shipped playbook carries last_threat_review, but
|
|
2629
|
-
// guarantees the deterministic path never crashes on a malformed
|
|
2630
|
-
// playbook).
|
|
2631
|
-
// Returns a full ISO-8601 timestamp (date-only inputs are normalised).
|
|
1882
|
+
// The deterministic-bundle epoch: runOpts.bundleEpoch, then
|
|
1883
|
+
// playbook._meta.last_threat_review (stable across re-runs of one catalog
|
|
1884
|
+
// version), then 1970 so a malformed playbook cannot crash the path.
|
|
2632
1885
|
function resolveFrozenEpoch(runOpts, playbook) {
|
|
2633
1886
|
const raw = runOpts && runOpts.bundleEpoch
|
|
2634
1887
|
? runOpts.bundleEpoch
|
|
@@ -2638,11 +1891,8 @@ function resolveFrozenEpoch(runOpts, playbook) {
|
|
|
2638
1891
|
catch { return '1970-01-01T00:00:00Z'; }
|
|
2639
1892
|
}
|
|
2640
1893
|
|
|
2641
|
-
//
|
|
2642
|
-
// deterministic-mode runs
|
|
2643
|
-
// schedules. Mirrors computeRegressionNextRun but with an injected base
|
|
2644
|
-
// date. Returns the soonest ISO timestamp or null when no interval-based
|
|
2645
|
-
// trigger fired.
|
|
1894
|
+
// computeRegressionNextRun against an injected base date, so two
|
|
1895
|
+
// deterministic-mode runs produce byte-identical schedules.
|
|
2646
1896
|
function frozenRegressionNextRun(triggers, frozenNow) {
|
|
2647
1897
|
let soonest = null;
|
|
2648
1898
|
for (const t of (triggers || [])) {
|
|
@@ -2653,42 +1903,19 @@ function frozenRegressionNextRun(triggers, frozenNow) {
|
|
|
2653
1903
|
return soonest ? soonest.toISOString() : null;
|
|
2654
1904
|
}
|
|
2655
1905
|
|
|
2656
|
-
//
|
|
2657
|
-
//
|
|
2658
|
-
//
|
|
2659
|
-
//
|
|
2660
|
-
// library consumers that may bypass the CLI surface.
|
|
2661
|
-
//
|
|
2662
|
-
// Strip Unicode bidi / format / control / surrogate / private-use /
|
|
2663
|
-
// unassigned categories (\p{C} under the `u` regex flag) so direct
|
|
2664
|
-
// library callers of buildEvidenceBundle cannot smuggle a U+202E "RTL
|
|
2665
|
-
// OVERRIDE" or zero-width joiner past the sanitiser the way the CLI
|
|
2666
|
-
// already refuses. NFC-normalise first so a decomposed sequence can't
|
|
2667
|
-
// combine past the codepoint check; cap the result at 256 codepoints
|
|
2668
|
-
// (NOT UTF-16 code units) so a string of astral-plane codepoints can't
|
|
2669
|
-
// smuggle a longer-than-256-display string past the cap by exploiting
|
|
2670
|
-
// JavaScript's surrogate-pair string length. Returns null on rejection
|
|
2671
|
-
// (empty after strip, or NFC normalise threw); callers (the
|
|
2672
|
-
// publisher-namespace + contact_details + tracking.generator sites)
|
|
2673
|
-
// treat null as "operator-unclaimed" and route through the existing
|
|
2674
|
-
// fallback (publisher.namespace = urn:exceptd:operator:unknown +
|
|
2675
|
-
// bundle_publisher_unclaimed runtime warning).
|
|
1906
|
+
// --operator and --publisher-namespace land on operator-facing CSAF surfaces.
|
|
1907
|
+
// bin/exceptd.js validates the CLI inputs, but a library consumer bypasses it
|
|
1908
|
+
// and must not be able to smuggle a U+202E RTL OVERRIDE or a zero-width joiner
|
|
1909
|
+
// into a bundle. Null on rejection, which callers treat as operator-unclaimed.
|
|
2676
1910
|
function sanitizeOperatorText(s) {
|
|
2677
1911
|
if (typeof s !== 'string') return null;
|
|
2678
|
-
// NFC first: a Cf codepoint
|
|
2679
|
-
//
|
|
2680
|
-
// strip catches it.
|
|
1912
|
+
// NFC first: a Cf codepoint can arrive as a base plus combining mark that
|
|
1913
|
+
// only recomposes into the format category under normalisation.
|
|
2681
1914
|
let normalised;
|
|
2682
1915
|
try { normalised = s.normalize('NFC'); }
|
|
2683
1916
|
catch { return null; }
|
|
2684
|
-
//
|
|
2685
|
-
//
|
|
2686
|
-
// so the family vocabulary has a single source of truth. Then strip any
|
|
2687
|
-
// remaining General Category C codepoint: \p{C} (Cc/Cf/Cs/Co/Cn) is the
|
|
2688
|
-
// backstop — it is strictly broader than the family union (also catches
|
|
2689
|
-
// U+007F, U+0080-009F, private-use, unassigned), so the family pass is a
|
|
2690
|
-
// documented-intent superset removal and the result is identical to the
|
|
2691
|
-
// single \p{C} strip.
|
|
1917
|
+
// The named threat families go through the shared vendored tables, so that
|
|
1918
|
+
// vocabulary has one source of truth; \p{C} is the strictly broader backstop.
|
|
2692
1919
|
const familyStripped = codepointClass.applyCharStripPolicies(normalised, {
|
|
2693
1920
|
bidiPolicy: 'strip',
|
|
2694
1921
|
controlPolicy: 'strip',
|
|
@@ -2698,79 +1925,33 @@ function sanitizeOperatorText(s) {
|
|
|
2698
1925
|
const stripped = familyStripped.replace(/\p{C}/gu, '');
|
|
2699
1926
|
const trimmed = stripped.trim();
|
|
2700
1927
|
if (trimmed.length === 0) return null;
|
|
2701
|
-
//
|
|
2702
|
-
// units, so
|
|
2703
|
-
// past the cap by surrogate-pair encoding).
|
|
1928
|
+
// 256 CODEPOINTS, counted with Array.from: a `.length` cap measures UTF-16
|
|
1929
|
+
// code units, so astral-plane text would slip a longer string past it.
|
|
2704
1930
|
const cps = Array.from(trimmed);
|
|
2705
1931
|
if (cps.length <= 256) return cps.join('');
|
|
2706
1932
|
return cps.slice(0, 256).join('');
|
|
2707
1933
|
}
|
|
2708
1934
|
|
|
2709
1935
|
/**
|
|
2710
|
-
* Build
|
|
2711
|
-
*
|
|
2712
|
-
*
|
|
2713
|
-
*
|
|
2714
|
-
*
|
|
2715
|
-
*
|
|
2716
|
-
*
|
|
2717
|
-
*
|
|
2718
|
-
* @param {string} format Output dialect. One of: 'csaf-2.0',
|
|
2719
|
-
* 'sarif' / 'sarif-2.1.0', 'openvex' /
|
|
2720
|
-
* 'openvex-0.2.0', 'summary', 'markdown'.
|
|
2721
|
-
* Unknown values return a stub with
|
|
2722
|
-
* supported_formats so callers can branch.
|
|
2723
|
-
* @param {object} playbook Playbook record loaded via loadPlaybook().
|
|
2724
|
-
* Provides _meta.id / version, domain.name,
|
|
2725
|
-
* phases.look.artifacts (for SARIF
|
|
2726
|
-
* locations), and feeds_into / mutex.
|
|
2727
|
-
* @param {object} analyze Output of analyze(). Carries matched_cves,
|
|
2728
|
-
* _detect_indicators, framework_gap_mapping,
|
|
2729
|
-
* rwep, blast_radius_score,
|
|
2730
|
-
* _detect_classification.
|
|
2731
|
-
* @param {object} validate Output of validate(). Carries
|
|
2732
|
-
* selected_remediation, remediation_paths,
|
|
2733
|
-
* evidence_requirements,
|
|
2734
|
-
* residual_risk_statement.
|
|
2735
|
-
* @param {object} agentSignals Agent-submitted signals (signal_overrides
|
|
2736
|
-
* merged + cleaned). Drives the OpenVEX
|
|
2737
|
-
* vex_status:'fixed' attestation trail and
|
|
2738
|
-
* the CSAF cvss_v3 score-block gate.
|
|
2739
|
-
* @param {string} sessionId Run session id (threaded from run()).
|
|
2740
|
-
* Becomes part of CSAF tracking.id,
|
|
2741
|
-
* OpenVEX @id, and the on-disk attestation
|
|
2742
|
-
* file name so all three correlate.
|
|
2743
|
-
* @param {string=} issuedAt Optional ISO 8601 timestamp. Pinning this
|
|
2744
|
-
* across multi-format emits keeps CSAF /
|
|
2745
|
-
* OpenVEX / SARIF agreed on milliseconds;
|
|
2746
|
-
* each call would otherwise crystallise a
|
|
2747
|
-
* fresh Date.now().
|
|
2748
|
-
* @param {object=} runOpts Operator / library knobs. Recognised
|
|
2749
|
-
* fields: operator, publisherNamespace,
|
|
2750
|
-
* csafStatus, tlp, _runErrors accumulator.
|
|
2751
|
-
* @returns {object} The requested format's document body.
|
|
1936
|
+
* Build one evidence bundle in the requested format ('csaf-2.0', 'sarif',
|
|
1937
|
+
* 'openvex', 'summary', 'markdown', 'json' and their versioned spellings). An
|
|
1938
|
+
* unrecognised format returns a stub carrying supported_formats. `sessionId`
|
|
1939
|
+
* becomes part of the CSAF tracking.id, the OpenVEX @id and the attestation
|
|
1940
|
+
* name, so all three correlate. `issuedAt` pins the timestamp across a
|
|
1941
|
+
* multi-format emit, without which each call crystallises its own Date.now().
|
|
1942
|
+
* `runOpts` is read for operator, publisherNamespace, csafStatus, tlp and the
|
|
1943
|
+
* _runErrors accumulator.
|
|
2752
1944
|
*/
|
|
2753
1945
|
function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals, sessionId, issuedAt, runOpts) {
|
|
2754
1946
|
runOpts = runOpts || {};
|
|
2755
1947
|
const playbookSlug = urnSlug(playbook._meta.id);
|
|
2756
1948
|
const { productId, productPurl, productName } = buildProductBinding(playbook, sessionId);
|
|
2757
|
-
// Pin one `now` value per bundle build (and accept an
|
|
2758
|
-
// upstream-provided issuedAt) so multi-format emit produces identical
|
|
2759
|
-
// tracking timestamps across CSAF / OpenVEX / SARIF when close() is
|
|
2760
|
-
// building several formats from the same run. Without the parameter,
|
|
2761
|
-
// each invocation crystallises a fresh `Date.now()` and bundle_body
|
|
2762
|
-
// versus bundles_by_format[primary] diverge on milliseconds.
|
|
2763
1949
|
const now = typeof issuedAt === 'string' && issuedAt ? issuedAt : new Date().toISOString();
|
|
2764
1950
|
|
|
2765
|
-
// CSAF-2.0
|
|
2766
|
-
//
|
|
2767
|
-
//
|
|
2768
|
-
//
|
|
2769
|
-
//
|
|
2770
|
-
// v0.12.12 (B5): emit a product_tree so csaf_security_advisory documents
|
|
2771
|
-
// pass NVD/ENISA/Red Hat dashboard validation. Every vulnerability
|
|
2772
|
-
// entry references the product via product_status so the binding is
|
|
2773
|
-
// real, not cosmetic.
|
|
1951
|
+
// CSAF-2.0. vulnerabilities[] covers matched catalogue CVEs AND fired
|
|
1952
|
+
// indicators (as pseudo-CVEs), so a playbook with no catalogue CVEs still
|
|
1953
|
+
// emits a non-empty bundle. Every entry references the product through
|
|
1954
|
+
// product_status, which NVD / ENISA / Red Hat dashboards validate.
|
|
2774
1955
|
if (format === 'csaf-2.0') {
|
|
2775
1956
|
const indicatorHits = (analyze._detect_indicators || []).filter(i => i.verdict === 'hit');
|
|
2776
1957
|
const fullProductNames = [{
|
|
@@ -2778,28 +1959,17 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
2778
1959
|
name: productName,
|
|
2779
1960
|
product_identification_helper: { purl: productPurl }
|
|
2780
1961
|
}];
|
|
2781
|
-
// `fixed` product_status
|
|
2782
|
-
//
|
|
2783
|
-
//
|
|
2784
|
-
//
|
|
2785
|
-
//
|
|
2786
|
-
// fixed regardless of operator evidence would produce CSAF documents
|
|
2787
|
-
// that lie to downstream NVD / Red Hat dashboards. When
|
|
2788
|
-
// live_patch_available is the only signal, status stays
|
|
2789
|
-
// known_affected and the live-patch route is surfaced as a
|
|
1962
|
+
// A `fixed` product_status reflects the operator's VEX disposition, never
|
|
1963
|
+
// the catalog's live_patch_available flag: that flag says the vendor
|
|
1964
|
+
// publishes a live-patch, not that this operator deployed it, and treating
|
|
1965
|
+
// it as fixed makes the document lie to downstream dashboards.
|
|
1966
|
+
// Live-patch-only stays known_affected, with the route offered as a
|
|
2790
1967
|
// `vendor_fix` remediation.
|
|
2791
|
-
// CSAF §3.2.1.2 restricts the `cve` field to the CVE-id
|
|
2792
|
-
// regex `^CVE-[0-9]{4}-[0-9]{4,}$`. The catalog also keys non-CVE
|
|
2793
|
-
// identifiers off `cve_id` (MAL-2026-3083, GHSA-…, OSV-…); strict
|
|
2794
|
-
// validators (BSI CSAF validator, ENISA dashboard) refuse documents that
|
|
2795
|
-
// place non-CVE values in `cve`. Branch by prefix and route non-CVE ids
|
|
2796
|
-
// to the `ids[]` array with a real `system_name`.
|
|
2797
1968
|
//
|
|
2798
|
-
// CSAF §3.2.1.
|
|
2799
|
-
//
|
|
2800
|
-
//
|
|
2801
|
-
//
|
|
2802
|
-
// catalog entry.
|
|
1969
|
+
// CSAF §3.2.1.2 restricts `cve` to `^CVE-[0-9]{4}-[0-9]{4,}$`, and the
|
|
1970
|
+
// catalog also keys MAL-/GHSA-/OSV- identifiers off cve_id, so those route
|
|
1971
|
+
// to `ids[]`. §3.2.1.5 requires a vectorString whenever a cvss_v3 block is
|
|
1972
|
+
// emitted, so the block is dropped when there is neither score nor vector.
|
|
2803
1973
|
const csafCvssSeverity = (score) => {
|
|
2804
1974
|
if (typeof score !== 'number') return null;
|
|
2805
1975
|
if (score >= 9.0) return 'CRITICAL';
|
|
@@ -2812,39 +1982,27 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
2812
1982
|
if (typeof vec !== 'string') return '3.1';
|
|
2813
1983
|
const m = vec.match(/^CVSS:(\d+\.\d+)\//);
|
|
2814
1984
|
if (!m) return '3.1';
|
|
2815
|
-
//
|
|
2816
|
-
//
|
|
2817
|
-
// 4.0 vectors are tagged here for diagnostic clarity but never reach
|
|
2818
|
-
// the cvss_v3 block downstream.
|
|
1985
|
+
// The declared version, verbatim — gating emission to 3.0 / 3.1 per the
|
|
1986
|
+
// CSAF 2.0 schema is the CALLER's job.
|
|
2819
1987
|
return m[1];
|
|
2820
1988
|
};
|
|
2821
1989
|
const csafIdsFor = (id) => {
|
|
2822
|
-
//
|
|
2823
|
-
// "
|
|
2824
|
-
// would coerce both to those literals; strict validators then
|
|
2825
|
-
// reject the document and operators see a phantom "null" CVE in
|
|
2826
|
-
// dashboards. Return null so the caller skips the entry entirely
|
|
2827
|
-
// and surfaces a runtime_error for the missing id.
|
|
1990
|
+
// Null for a missing id, so the caller drops the entry: String(id) puts
|
|
1991
|
+
// literal "null" text into vulnerabilities[], which validators reject.
|
|
2828
1992
|
if (typeof id !== 'string' || !id) return null;
|
|
2829
1993
|
if (id.startsWith('GHSA-')) return { system_name: 'GHSA', text: id };
|
|
2830
1994
|
if (id.startsWith('MAL-')) return { system_name: 'Malicious-Package', text: id };
|
|
2831
1995
|
if (id.startsWith('OSV-')) return { system_name: 'OSV', text: id };
|
|
2832
1996
|
if (id.startsWith('SNYK-')) return { system_name: 'Snyk', text: id };
|
|
2833
|
-
// RUSTSEC
|
|
2834
|
-
// (
|
|
2835
|
-
// loses the upstream provenance link and confuses downstream
|
|
2836
|
-
// ingesters that resolve by (system_name, text) pair.
|
|
1997
|
+
// RUSTSEC is its own tracking authority; routing it to 'OSV' breaks the
|
|
1998
|
+
// (system_name, text) pair downstream ingesters resolve by.
|
|
2837
1999
|
if (id.startsWith('RUSTSEC-')) return { system_name: 'RUSTSEC', text: id };
|
|
2838
|
-
//
|
|
2839
|
-
// downstream ingesters see that the authority wasn't recognised
|
|
2840
|
-
// rather than misattributing every unknown id to OSV.
|
|
2000
|
+
// An unrecognised authority says so, rather than being misattributed.
|
|
2841
2001
|
return { system_name: 'exceptd-unknown', text: id };
|
|
2842
2002
|
};
|
|
2843
2003
|
const CSAF_CVE_RE = /^CVE-\d{4}-\d{4,}$/;
|
|
2844
2004
|
|
|
2845
|
-
//
|
|
2846
|
-
// leaves can be bound into its product_status.known_affected (and the same
|
|
2847
|
-
// tree reused for product_tree below — buildCsafBranches is not re-run).
|
|
2005
|
+
// Built up front so the CSAFPID leaves bind into product_status below.
|
|
2848
2006
|
const csafProductTree = buildCsafBranches(analyze.matched_cves || [], runOpts);
|
|
2849
2007
|
|
|
2850
2008
|
const cveVulns = analyze.matched_cves.map(c => {
|
|
@@ -2855,11 +2013,6 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
2855
2013
|
|| (c.live_patch_available ? 'Vendor publishes a live-patch — see CVE catalog `live_patch_tools` for the operator-side step.' : 'See selected remediation path.'),
|
|
2856
2014
|
product_ids: [productId],
|
|
2857
2015
|
}];
|
|
2858
|
-
// Catalog entries with a missing / non-string cve_id would
|
|
2859
|
-
// otherwise produce literal `text: "null"` / `text: "undefined"`
|
|
2860
|
-
// entries under ids[]. Skip the vulnerability entry entirely and
|
|
2861
|
-
// surface a runtime_error so the catalog gap is visible to
|
|
2862
|
-
// operators / CI gates.
|
|
2863
2016
|
const idIsCve = typeof c.cve_id === 'string' && CSAF_CVE_RE.test(c.cve_id);
|
|
2864
2017
|
let idEntry = null;
|
|
2865
2018
|
if (!idIsCve) {
|
|
@@ -2875,28 +2028,12 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
2875
2028
|
return null;
|
|
2876
2029
|
}
|
|
2877
2030
|
}
|
|
2878
|
-
//
|
|
2879
|
-
//
|
|
2880
|
-
// `cvss_v3: { base_score: 0 }` even when the catalog had no CVSS
|
|
2881
|
-
// signal — strict validators reject the truncated block, and
|
|
2882
|
-
// `base_score: 0` was a downstream-misleading default that suggested
|
|
2883
|
-
// an authoritative "informational" score where there was simply no
|
|
2884
|
-
// data.
|
|
2885
|
-
//
|
|
2886
|
-
// CSAF 2.0 `cvss_v3` ONLY accepts version 3.0 / 3.1. Catalog
|
|
2887
|
-
// vectors prefixed CVSS:2.0/ or CVSS:4.0/ would otherwise emit a
|
|
2888
|
-
// cvss_v3 block with version: '2.0' / '4.0', which strict
|
|
2889
|
-
// validators (BSI CSAF Validator) reject outright. Drop the block
|
|
2890
|
-
// for non-3.x vectors and surface a runtime_error so operators can
|
|
2891
|
-
// see why their CVSS data didn't make it through.
|
|
2031
|
+
// A real vector AND a numeric score: an emitted `base_score: 0` reads as
|
|
2032
|
+
// an authoritative "informational" score where there is simply no data.
|
|
2892
2033
|
const hasCvss = typeof c.cvss_score === 'number' && typeof c.cvss_vector === 'string' && c.cvss_vector.length > 0;
|
|
2893
|
-
//
|
|
2894
|
-
//
|
|
2895
|
-
//
|
|
2896
|
-
// (BSI CSAF Validator, ENISA dashboard) then reject the whole
|
|
2897
|
-
// document. Strict parse failures surface as a `csaf_cvss_invalid`
|
|
2898
|
-
// runtime_error, the cvss_v3 block is omitted, and the rest of the
|
|
2899
|
-
// vulnerability entry (product_status, remediations, etc.) survives.
|
|
2034
|
+
// cvss_v3 accepts only 3.0 / 3.1, and validators reject a block keyed off
|
|
2035
|
+
// a 2.0 / 4.0 or malformed vector. A strict-parse failure raises
|
|
2036
|
+
// csaf_cvss_invalid and omits the block alone — the entry survives.
|
|
2900
2037
|
let strictParse = null;
|
|
2901
2038
|
if (hasCvss) {
|
|
2902
2039
|
strictParse = scoring.parseCvss31Vector(c.cvss_vector);
|
|
@@ -2920,10 +2057,8 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
2920
2057
|
}
|
|
2921
2058
|
}] : [];
|
|
2922
2059
|
const base = {
|
|
2923
|
-
// CSAF Profile 4
|
|
2924
|
-
//
|
|
2925
|
-
// CVE-keyed entries failed strict validation (the indicator pseudo-CVE
|
|
2926
|
-
// entries already carried notes; real-CVE entries did not).
|
|
2060
|
+
// CSAF Profile 4 mandatory test 6.1.27.5: every /vulnerabilities[]
|
|
2061
|
+
// item carries `notes`.
|
|
2927
2062
|
notes: [{
|
|
2928
2063
|
category: 'description',
|
|
2929
2064
|
text: `${c.cve_id}: RWEP ${c.rwep}${c.active_exploitation ? `, active_exploitation=${c.active_exploitation}` : ''}${c.cisa_kev ? ', CISA KEV' : ''}.`,
|
|
@@ -2931,44 +2066,30 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
2931
2066
|
scores,
|
|
2932
2067
|
threats: c.active_exploitation === 'confirmed' ? [{ category: 'exploit_status', details: `Active exploitation confirmed${c.cisa_kev ? ' (CISA KEV)' : ''}.` }] : [],
|
|
2933
2068
|
remediations,
|
|
2934
|
-
//
|
|
2935
|
-
// known_affected
|
|
2936
|
-
//
|
|
2937
|
-
// opaque synthetic product. The leaves are parsed from affected_products
|
|
2938
|
-
// / affected_versions — VULNERABLE version ranges — so they belong ONLY
|
|
2939
|
-
// under known_affected. The VEX-fixed disposition keeps just the synthetic
|
|
2940
|
-
// target product under `fixed`; adding affected ranges there would
|
|
2941
|
-
// mislabel vulnerable versions as fixed releases.
|
|
2069
|
+
// The CSAFPID leaves are VULNERABLE ranges, so they belong only under
|
|
2070
|
+
// known_affected. `fixed` keeps the synthetic product alone — affected
|
|
2071
|
+
// ranges there would read as fixed releases.
|
|
2942
2072
|
product_status: isFixed
|
|
2943
2073
|
? { fixed: [productId] }
|
|
2944
2074
|
: { known_affected: [productId, ...(csafProductTree.cveProductIds[c.cve_id] || [])] }
|
|
2945
2075
|
};
|
|
2946
|
-
// route by id shape.
|
|
2947
2076
|
if (idIsCve) {
|
|
2948
2077
|
return { cve: c.cve_id, ...base };
|
|
2949
2078
|
}
|
|
2950
2079
|
return { ids: [idEntry], ...base };
|
|
2951
2080
|
}).filter(v => v != null);
|
|
2952
2081
|
const indicatorVulns = indicatorHits.map(i => ({
|
|
2953
|
-
//
|
|
2954
|
-
//
|
|
2955
|
-
//
|
|
2956
|
-
// misattributing to a real registry (CVE, GHSA, OSV).
|
|
2082
|
+
// The 'exceptd-indicator' pseudo-authority is namespaced so NVD / Red Hat
|
|
2083
|
+
// / ENISA dashboards render a non-CVE finding without misattributing it
|
|
2084
|
+
// to a real registry.
|
|
2957
2085
|
ids: [{ system_name: 'exceptd-indicator', text: `${playbook._meta.id}:${i.id}` }],
|
|
2958
2086
|
notes: [{ category: 'description', text: `Indicator ${i.id} fired (${i.confidence}${i.deterministic ? ' / deterministic' : ''}) in playbook ${playbook._meta.id}.` }],
|
|
2959
2087
|
remediations: [{ category: 'mitigation', details: validate.selected_remediation?.description || `Consult playbook brief: exceptd brief ${playbook._meta.id}.`, product_ids: [productId] }],
|
|
2960
2088
|
product_status: { known_affected: [productId] }
|
|
2961
2089
|
}));
|
|
2962
|
-
// Framework
|
|
2963
|
-
//
|
|
2964
|
-
//
|
|
2965
|
-
// slot is reserved for recognised vulnerability tracking authorities
|
|
2966
|
-
// (CVE, GHSA, etc.); exceptd-framework-gap is not one, and every
|
|
2967
|
-
// downstream CSAF consumer (NVD ingester, Red Hat dashboard, ENISA
|
|
2968
|
-
// validator) would flag the run for unknown ids and render
|
|
2969
|
-
// false-positive advisories at the framework_gap_mapping length.
|
|
2970
|
-
// Notes are the right home for advisory context that is not itself
|
|
2971
|
-
// a pseudo-CVE.
|
|
2090
|
+
// Framework gaps belong in document.notes[], not vulnerabilities[]: the
|
|
2091
|
+
// system_name slot is for recognised tracking authorities, and a made-up
|
|
2092
|
+
// one renders a false-positive advisory per gap downstream.
|
|
2972
2093
|
const gapNotes = (analyze.framework_gap_mapping || []).map((g, idx) => {
|
|
2973
2094
|
const lines = [
|
|
2974
2095
|
`Framework: ${g.framework}`,
|
|
@@ -2982,14 +2103,10 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
2982
2103
|
text: lines.join('\n'),
|
|
2983
2104
|
};
|
|
2984
2105
|
});
|
|
2985
|
-
// CSAF §3.1.7.4 publisher.namespace
|
|
2986
|
-
//
|
|
2987
|
-
//
|
|
2988
|
-
//
|
|
2989
|
-
// responsibility for advisory accuracy to the tooling provider. Resolve
|
|
2990
|
-
// in priority order: explicit --publisher-namespace > --operator if it
|
|
2991
|
-
// looks URL-shaped > fallback `urn:exceptd:operator:unknown` with a note
|
|
2992
|
-
// documenting the gap.
|
|
2106
|
+
// CSAF §3.1.7.4: publisher.namespace is the trust anchor of the entity
|
|
2107
|
+
// publishing the advisory — the OPERATOR running the scan, not the tool
|
|
2108
|
+
// vendor, who is not answerable for its accuracy. Resolved as
|
|
2109
|
+
// --publisher-namespace, then a URL-shaped --operator, then a marked fallback.
|
|
2993
2110
|
const operatorClean = sanitizeOperatorText(runOpts.operator);
|
|
2994
2111
|
const explicitNs = sanitizeOperatorText(runOpts.publisherNamespace);
|
|
2995
2112
|
let publisherNamespace;
|
|
@@ -3009,14 +2126,8 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
3009
2126
|
title: 'Publisher namespace not supplied',
|
|
3010
2127
|
text: 'No --publisher-namespace and no URL-shaped --operator were supplied to this run. CSAF §3.1.7.4 requires the namespace to be the publisher\'s trust anchor — i.e. the OPERATOR running the scan, not the tooling vendor. Re-emit with `--publisher-namespace https://your-org.example` (or a URL-shaped `--operator`) to attribute responsibility for advisory accuracy correctly.'
|
|
3011
2128
|
}] : [];
|
|
3012
|
-
//
|
|
3013
|
-
//
|
|
3014
|
-
// consumers (CI gates, dashboards) can branch on it without parsing
|
|
3015
|
-
// notes[] prose. The orchestrator's post-close pass folds late-pushed
|
|
3016
|
-
// _runErrors into phases.analyze.runtime_errors before the run-level
|
|
3017
|
-
// return, so the warning surfaces alongside other run-time anomalies.
|
|
3018
|
-
// De-dupe: only push once per bundle-build pass (multi-format emit
|
|
3019
|
-
// builds CSAF once via memoization, so this fires at most once per run).
|
|
2129
|
+
// Also on runtime_errors[], so a CI gate can branch on the unclaimed
|
|
2130
|
+
// publisher without parsing notes[] prose.
|
|
3020
2131
|
if (publisherNamespaceSource === 'fallback' && Array.isArray(runOpts._runErrors)) {
|
|
3021
2132
|
pushRunError(runOpts._runErrors, {
|
|
3022
2133
|
kind: 'bundle_publisher_unclaimed',
|
|
@@ -3025,11 +2136,8 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
3025
2136
|
}, { dedupeKey: () => 'singleton' });
|
|
3026
2137
|
}
|
|
3027
2138
|
|
|
3028
|
-
//
|
|
3029
|
-
//
|
|
3030
|
-
// (operator-of-record). engine.version is read from the package once per
|
|
3031
|
-
// process. contact_details is omitted when no operator was supplied so
|
|
3032
|
-
// the field doesn't carry a misleading null.
|
|
2139
|
+
// contact_details is the operator-of-record, omitted entirely when no
|
|
2140
|
+
// operator was supplied rather than carrying a misleading null.
|
|
3033
2141
|
const publisherBlock = {
|
|
3034
2142
|
category: 'vendor',
|
|
3035
2143
|
name: 'exceptd',
|
|
@@ -3037,42 +2145,29 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
3037
2145
|
};
|
|
3038
2146
|
if (operatorClean) publisherBlock.contact_details = operatorClean;
|
|
3039
2147
|
|
|
3040
|
-
// CSAF §3.1.11.3.5.1
|
|
3041
|
-
//
|
|
3042
|
-
//
|
|
3043
|
-
//
|
|
3044
|
-
// default is `interim`. Operators who have reviewed and are ready to
|
|
3045
|
-
// promote pass `--csaf-status final` (threaded via runOpts.csafStatus);
|
|
3046
|
-
// any other value falls back to `interim` rather than emitting an
|
|
3047
|
-
// unrecognized status word.
|
|
2148
|
+
// CSAF §3.1.11.3.5.1 makes `final` immutable — validators refuse a re-emit
|
|
2149
|
+
// against the same tracking.id — and a detection run with no operator
|
|
2150
|
+
// review loop is revisable, so the default is `interim`. --csaf-status
|
|
2151
|
+
// promotes it; anything unrecognised falls back rather than being emitted.
|
|
3048
2152
|
const allowedCsafStatuses = new Set(['draft', 'interim', 'final']);
|
|
3049
2153
|
const csafStatus = allowedCsafStatuses.has(runOpts.csafStatus)
|
|
3050
2154
|
? runOpts.csafStatus
|
|
3051
2155
|
: 'interim';
|
|
3052
2156
|
|
|
3053
|
-
// CSAF §3.1.4
|
|
3054
|
-
//
|
|
3055
|
-
// distribution.tlp.label + distribution.text. CSAF allows omission of
|
|
3056
|
-
// the whole distribution block when no level is declared; the
|
|
3057
|
-
// pre-fix runner had no surface for this at all.
|
|
2157
|
+
// CSAF §3.1.4 distribution.tlp is optional, and the whole block is omitted
|
|
2158
|
+
// when --tlp declares no level.
|
|
3058
2159
|
const allowedTlp = new Set(['CLEAR', 'GREEN', 'AMBER', 'AMBER+STRICT', 'RED']);
|
|
3059
|
-
// CSAF 2.0 §3.2.1.5.2 pins tlp.label to the TLP 1.0 enum
|
|
3060
|
-
//
|
|
3061
|
-
//
|
|
3062
|
-
// CSAF 2.0 consumers, while preserving the operator's exact label in the
|
|
3063
|
-
// free-form `text` field. (CLEAR≡WHITE; AMBER+STRICT carries AMBER's
|
|
3064
|
-
// disclosure scope plus a stricter handling note.)
|
|
2160
|
+
// CSAF 2.0 §3.2.1.5.2 pins tlp.label to the TLP 1.0 enum, so the TLP 2.0
|
|
2161
|
+
// labels the CLI accepts map onto it and the operator's exact label
|
|
2162
|
+
// survives in the free-form `text`.
|
|
3065
2163
|
const CSAF_TLP_LABEL = { CLEAR: 'WHITE', GREEN: 'GREEN', AMBER: 'AMBER', 'AMBER+STRICT': 'AMBER', RED: 'RED' };
|
|
3066
2164
|
const csafDistribution = (runOpts.tlp && allowedTlp.has(runOpts.tlp))
|
|
3067
2165
|
? { tlp: { label: CSAF_TLP_LABEL[runOpts.tlp] }, text: `TLP:${runOpts.tlp}` }
|
|
3068
2166
|
: null;
|
|
3069
2167
|
|
|
3070
|
-
//
|
|
3071
|
-
//
|
|
3072
|
-
//
|
|
3073
|
-
// semantically wrong and warns under strict profile validators). A clean run
|
|
3074
|
-
// becomes an informational attestation; any firing CVE/indicator keeps the
|
|
3075
|
-
// security-advisory category.
|
|
2168
|
+
// A zero-vulnerability advisory is informational (the profile that does not
|
|
2169
|
+
// require /vulnerabilities), not a security advisory with an empty array —
|
|
2170
|
+
// which is semantically wrong and warns under strict profile validators.
|
|
3076
2171
|
const csafCategory = (cveVulns.length + indicatorVulns.length) > 0
|
|
3077
2172
|
? 'csaf_security_advisory'
|
|
3078
2173
|
: 'csaf_informational_advisory';
|
|
@@ -3083,59 +2178,49 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
3083
2178
|
publisher: publisherBlock,
|
|
3084
2179
|
title: `exceptd finding: ${playbook.domain.name} (${analyze.matched_cves.length} CVE(s), ${indicatorHits.length} indicator hit(s), ${(analyze.framework_gap_mapping || []).length} framework gap(s))`,
|
|
3085
2180
|
notes: [...namespaceFallbackNote, ...gapNotes],
|
|
3086
|
-
//
|
|
3087
|
-
//
|
|
3088
|
-
//
|
|
3089
|
-
// so add this only for the informational (clean-run) profile.
|
|
2181
|
+
// Mandatory test 6.1.27.2 requires /document/references with at least
|
|
2182
|
+
// one `external` item; a security_advisory carries its references
|
|
2183
|
+
// inside the vulnerabilities instead.
|
|
3090
2184
|
...(csafCategory === 'csaf_informational_advisory' ? {
|
|
3091
2185
|
references: [{ category: 'external', summary: `exceptd playbook: ${playbook._meta.id}`, url: `https://exceptd.com/playbooks/${playbook._meta.id}` }],
|
|
3092
2186
|
} : {}),
|
|
3093
2187
|
...(csafDistribution ? { distribution: csafDistribution } : {}),
|
|
3094
2188
|
tracking: {
|
|
3095
|
-
//
|
|
3096
|
-
//
|
|
3097
|
-
//
|
|
3098
|
-
// identifier. Pre-fix the timestamp was used, so two runs in
|
|
3099
|
-
// the same millisecond collided and one run's documents
|
|
3100
|
-
// referenced ids that didn't match anything else on disk.
|
|
2189
|
+
// Keyed on session_id, not a timestamp: two runs in the same
|
|
2190
|
+
// millisecond would collide, and the id must match the OpenVEX @id
|
|
2191
|
+
// and the attestation file name on disk.
|
|
3101
2192
|
id: `exceptd-${playbook._meta.id}-${sessionId}`,
|
|
3102
2193
|
status: csafStatus,
|
|
3103
2194
|
version: playbook._meta.version,
|
|
3104
|
-
//
|
|
3105
|
-
// CSAF §3.1.11.3.2 places this under tracking.generator.engine.
|
|
2195
|
+
// CSAF §3.1.11.3.2 places the emitting engine here.
|
|
3106
2196
|
generator: {
|
|
3107
2197
|
engine: { name: 'exceptd', version: getEngineVersion() },
|
|
3108
2198
|
date: now,
|
|
3109
2199
|
},
|
|
3110
2200
|
initial_release_date: now,
|
|
3111
2201
|
current_release_date: now,
|
|
3112
|
-
// CSAF 6.1.30 requires
|
|
3113
|
-
//
|
|
3114
|
-
//
|
|
3115
|
-
//
|
|
3116
|
-
// integer "1", which mixed schemes AND mismatched the version).
|
|
2202
|
+
// CSAF 6.1.30 requires one versioning scheme throughout and 6.1.16
|
|
2203
|
+
// requires tracking.version to equal the last revision_history
|
|
2204
|
+
// number. tracking.version is the playbook semver, so this entry
|
|
2205
|
+
// carries the same semver — an integer "1" would break both.
|
|
3117
2206
|
revision_history: [{ number: playbook._meta.version, date: now, summary: 'Initial finding emission' }]
|
|
3118
2207
|
}
|
|
3119
2208
|
},
|
|
3120
|
-
//
|
|
3121
|
-
//
|
|
3122
|
-
//
|
|
3123
|
-
// product is affected). Emit both ONLY for the security_advisory profile;
|
|
3124
|
-
// a clean run's informational advisory carries neither.
|
|
2209
|
+
// The informational profile forbids /vulnerabilities (test 6.1.27.3),
|
|
2210
|
+
// and a /product_tree there is misleading — §4.3 has the reader assume
|
|
2211
|
+
// every named product is affected. Both are security-advisory only.
|
|
3125
2212
|
...(csafCategory === 'csaf_security_advisory' ? {
|
|
3126
2213
|
product_tree: (function () {
|
|
3127
|
-
//
|
|
3128
|
-
//
|
|
3129
|
-
// recommended because NVD / ENISA / Red Hat dashboards render the
|
|
3130
|
-
// affected-product list off the branches tree, not full_product_names[].
|
|
2214
|
+
// NVD / ENISA / Red Hat dashboards render the affected-product list
|
|
2215
|
+
// off branches[], not full_product_names[] (CSAF §3.1.5.1).
|
|
3131
2216
|
const branches = csafProductTree.branches;
|
|
3132
2217
|
const tree = { full_product_names: fullProductNames };
|
|
3133
2218
|
if (branches.length > 0) tree.branches = branches;
|
|
3134
2219
|
return tree;
|
|
3135
2220
|
})(),
|
|
3136
2221
|
vulnerabilities: (function () {
|
|
3137
|
-
//
|
|
3138
|
-
//
|
|
2222
|
+
// Deterministic mode sorts by primary identifier; otherwise
|
|
2223
|
+
// insertion order stands.
|
|
3139
2224
|
const all = [...cveVulns, ...indicatorVulns];
|
|
3140
2225
|
if (runOpts && runOpts.bundleDeterministic === true) {
|
|
3141
2226
|
const keyOf = (v) => (typeof v.cve === 'string' && v.cve)
|
|
@@ -3159,30 +2244,14 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
3159
2244
|
};
|
|
3160
2245
|
}
|
|
3161
2246
|
|
|
3162
|
-
// SARIF 2.1.0 — GitHub Code Scanning / VS Code SARIF Viewer / Azure DevOps
|
|
3163
|
-
// / most static-analysis tooling.
|
|
3164
|
-
//
|
|
3165
|
-
// v0.12.12 (B6): thread artifact source paths through to
|
|
3166
|
-
// result.locations[].physicalLocation.artifactLocation.uri. GitHub Code
|
|
3167
|
-
// Scanning hides results without populated locations, so the heuristic
|
|
3168
|
-
// ensures clean playbook runs still surface findings in the alerts UI.
|
|
3169
|
-
// v0.12.12 (B7): omit null property-bag keys so SARIF viewers don't
|
|
3170
|
-
// render empty fields.
|
|
3171
2247
|
if (format === 'sarif' || format === 'sarif-2.1.0') {
|
|
2248
|
+
// Null property-bag keys render as empty rows in SARIF viewers.
|
|
3172
2249
|
const stripNulls = (obj) => Object.fromEntries(Object.entries(obj).filter(([, v]) => v != null));
|
|
3173
|
-
//
|
|
3174
|
-
//
|
|
3175
|
-
//
|
|
3176
|
-
// were merged into one SARIF document — GitHub Code Scanning de-dupes
|
|
3177
|
-
// by ruleId, so the second playbook's rule definition silently
|
|
3178
|
-
// overwrote the first. Prefix every ruleId with the playbook slug so
|
|
3179
|
-
// every rule definition is unambiguously attributable to one playbook,
|
|
3180
|
-
// and cross-playbook merges retain all results.
|
|
2250
|
+
// Rule ids are global within a sarif-log run and GitHub Code Scanning
|
|
2251
|
+
// de-dupes by ruleId, so a generic id (`framework-gap-0`, a shared CVE id)
|
|
2252
|
+
// would let one playbook's rule silently overwrite another's on merge.
|
|
3181
2253
|
const rulePrefix = `${playbookSlug}/`;
|
|
3182
|
-
//
|
|
3183
|
-
// (passing a null indicator skips the per-indicator evidence-locations
|
|
3184
|
-
// branch). Without any `locations`, GitHub Code Scanning silently DROPS
|
|
3185
|
-
// these results — the highest-severity result class would never surface.
|
|
2254
|
+
// A null indicator takes the coarse playbook-source location fallback.
|
|
3186
2255
|
const cveResults = analyze.matched_cves.map(c => {
|
|
3187
2256
|
const result = {
|
|
3188
2257
|
ruleId: `${rulePrefix}${c.cve_id}`,
|
|
@@ -3198,9 +2267,8 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
3198
2267
|
blast_radius_score: analyze.blast_radius_score,
|
|
3199
2268
|
}),
|
|
3200
2269
|
};
|
|
3201
|
-
// Always
|
|
3202
|
-
//
|
|
3203
|
-
// highest-severity result class.
|
|
2270
|
+
// Always a location — physical when known, else the rule-scoped logical
|
|
2271
|
+
// fallback — or Code Scanning drops the highest-severity result class.
|
|
3204
2272
|
result.locations = sarifResultLocations(playbook, null, result.ruleId);
|
|
3205
2273
|
return result;
|
|
3206
2274
|
});
|
|
@@ -3224,32 +2292,27 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
3224
2292
|
});
|
|
3225
2293
|
const gapResults = (analyze.framework_gap_mapping || []).map((g, idx) => ({
|
|
3226
2294
|
ruleId: `${rulePrefix}framework-gap-${idx}`,
|
|
3227
|
-
// Framework gaps are control-design observations, not vulnerabilities
|
|
3228
|
-
// SARIF §3.27.9
|
|
3229
|
-
//
|
|
3230
|
-
//
|
|
3231
|
-
// strict validators / GitHub code-scanning reject — use 'none'.
|
|
2295
|
+
// Framework gaps are control-design observations, not vulnerabilities.
|
|
2296
|
+
// SARIF §3.27.9 requires a present `level` to be 'none' whenever kind is
|
|
2297
|
+
// not 'fail' — pairing 'informational' with 'note' is a schema violation
|
|
2298
|
+
// strict validators and Code Scanning reject.
|
|
3232
2299
|
kind: 'informational',
|
|
3233
2300
|
level: 'none',
|
|
3234
2301
|
message: { text: `${g.framework}: ${g.claimed_control} — ${g.actual_gap}${g.required_control ? '. Required: ' + g.required_control : ''}` },
|
|
3235
2302
|
properties: stripNulls({ kind: 'framework_gap', framework: g.framework, control: g.claimed_control }),
|
|
3236
2303
|
}));
|
|
3237
2304
|
const cveRules = analyze.matched_cves.map(c => {
|
|
3238
|
-
// Resolve the issuing authority by id shape rather than hardcoding NVD.
|
|
3239
|
-
// A non-CVE matched id (MAL-/GHSA-/OSV-/RUSTSEC-/SNYK-) must NOT carry an
|
|
3240
|
-
// nvd.nist.gov URL — that link 404s and presents the id as an NVD CVE.
|
|
3241
2305
|
const authority = advisoryAuthorityFor(c.cve_id);
|
|
3242
2306
|
const isCve = CVE_ID_RE.test(typeof c.cve_id === 'string' ? c.cve_id : '');
|
|
3243
2307
|
const rule = {
|
|
3244
2308
|
id: `${rulePrefix}${c.cve_id}`,
|
|
3245
|
-
//
|
|
3246
|
-
// a
|
|
2309
|
+
// A non-CVE id is qualified by its authority so a viewer does not read
|
|
2310
|
+
// a MAL- id as an NVD CVE.
|
|
3247
2311
|
shortDescription: { text: isCve ? c.cve_id : `${c.cve_id} (${authority.system_name || 'non-CVE advisory'})` },
|
|
3248
2312
|
fullDescription: { text: `RWEP ${c.rwep} · KEV=${c.cisa_kev} · active_exploitation=${c.active_exploitation}` },
|
|
3249
2313
|
defaultConfiguration: { level: c.rwep >= 90 ? 'error' : c.rwep >= 70 ? 'warning' : 'note' },
|
|
3250
2314
|
};
|
|
3251
|
-
// helpUri is optional
|
|
3252
|
-
// has no canonical per-id advisory page rather than emit a broken link.
|
|
2315
|
+
// helpUri is optional; omitting it beats emitting a link that 404s.
|
|
3253
2316
|
if (authority.helpUri) rule.helpUri = authority.helpUri;
|
|
3254
2317
|
return rule;
|
|
3255
2318
|
});
|
|
@@ -3275,11 +2338,6 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
3275
2338
|
} },
|
|
3276
2339
|
results: [...cveResults, ...indicatorResults, ...gapResults],
|
|
3277
2340
|
invocations: [{ executionSuccessful: (analyze._detect_classification !== 'inconclusive'), properties: stripNulls({
|
|
3278
|
-
// Apply the stripNulls contract here too — the `remediation`
|
|
3279
|
-
// field is null for any run that didn't surface a
|
|
3280
|
-
// selected_remediation, and SARIF viewers render null property
|
|
3281
|
-
// values as visible empty rows. Same helper as the result
|
|
3282
|
-
// property bags above.
|
|
3283
2341
|
playbook: playbook._meta.id, classification: analyze._detect_classification || 'unknown',
|
|
3284
2342
|
rwep_adjusted: analyze.rwep?.adjusted || 0,
|
|
3285
2343
|
remediation: validate.selected_remediation?.id || null,
|
|
@@ -3288,27 +2346,11 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
3288
2346
|
};
|
|
3289
2347
|
}
|
|
3290
2348
|
|
|
3291
|
-
// OpenVEX 0.2.0
|
|
3292
|
-
//
|
|
3293
|
-
//
|
|
3294
|
-
//
|
|
3295
|
-
// - B2: `status` derives from the verdict + confidence rather than being
|
|
3296
|
-
// hard-coded to `under_investigation`. Hits emit `affected` with
|
|
3297
|
-
// an action_statement; misses emit `not_affected` with a
|
|
3298
|
-
// justification; inconclusive findings keep `under_investigation`.
|
|
3299
|
-
// - B3: framework gaps are control-design observations, not
|
|
3300
|
-
// vulnerabilities — they are removed from the VEX emit path. They
|
|
3301
|
-
// remain in CSAF (informational notes) and SARIF (kind:
|
|
3302
|
-
// informational rules).
|
|
3303
|
-
// - B4: vulnerability `@id` values switch to the registered URN namespace
|
|
3304
|
-
// `urn:exceptd:indicator:<playbook>:<indicator-id>` (RFC 8141) so
|
|
3305
|
-
// they pass IRI validation in downstream VEX consumers.
|
|
2349
|
+
// OpenVEX 0.2.0. Every statement carries a `products` array (spec MUST) and a
|
|
2350
|
+
// status from the verdict: a hit is `affected` with an action_statement, a
|
|
2351
|
+
// miss `not_affected` with a justification, anything else
|
|
2352
|
+
// `under_investigation`. Framework gaps never enter this path.
|
|
3306
2353
|
if (format === 'openvex' || format === 'openvex-0.2.0') {
|
|
3307
|
-
// Reuse the bundle-wide `now` so OpenVEX `timestamp` aligns with
|
|
3308
|
-
// CSAF `document.tracking.initial_release_date` when both formats are
|
|
3309
|
-
// emitted in the same close() pass. A per-format Date.now() would
|
|
3310
|
-
// cause the two bundles in bundles_by_format to disagree on
|
|
3311
|
-
// milliseconds.
|
|
3312
2354
|
const issued = now;
|
|
3313
2355
|
const productEntry = {
|
|
3314
2356
|
'@id': productPurl,
|
|
@@ -3324,17 +2366,9 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
3324
2366
|
if (remediationDescription) return `Apply remediation from validate phase: ${remediationDescription}`;
|
|
3325
2367
|
return fallback;
|
|
3326
2368
|
};
|
|
3327
|
-
//
|
|
3328
|
-
//
|
|
3329
|
-
//
|
|
3330
|
-
// disposition. Treating it as `status: fixed` would make OpenVEX
|
|
3331
|
-
// statements claim resolution the operator hadn't attested to. VEX
|
|
3332
|
-
// consumers downstream of CISA / SBOM / supply-chain pipelines treat
|
|
3333
|
-
// `fixed` as authoritative — emitting it without operator attestation
|
|
3334
|
-
// is a downstream-misleading bug. The OpenVEX statement says
|
|
3335
|
-
// `affected` (with action_statement pointing to the remediation,
|
|
3336
|
-
// which may itself be the vendor live-patch route) unless the
|
|
3337
|
-
// operator declared `vex_status: fixed` on the matched CVE.
|
|
2369
|
+
// As in the CSAF emitter, only an operator-declared vex_status:'fixed'
|
|
2370
|
+
// yields `fixed`: supply-chain consumers treat it as authoritative, so the
|
|
2371
|
+
// catalog's live_patch_available flag must not claim an unattested fix.
|
|
3338
2372
|
const cveStatements = analyze.matched_cves.map(c => {
|
|
3339
2373
|
const stmt = {
|
|
3340
2374
|
vulnerability: { '@id': vulnIdToUrn(c.cve_id), name: c.cve_id },
|
|
@@ -3344,13 +2378,8 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
3344
2378
|
};
|
|
3345
2379
|
if (c.vex_status === 'fixed') {
|
|
3346
2380
|
stmt.status = 'fixed';
|
|
3347
|
-
//
|
|
3348
|
-
//
|
|
3349
|
-
// evidence trail so downstream supply-chain consumers can chase
|
|
3350
|
-
// the attestation back to the operator's submitted evidence.
|
|
3351
|
-
// Short-hash is deterministic for the same (cve_id, signals)
|
|
3352
|
-
// input — re-emitting the bundle for the same submission yields
|
|
3353
|
-
// the same trail.
|
|
2381
|
+
// A trail back to the operator's submission, hashed deterministically
|
|
2382
|
+
// over (cve_id, signals) so a re-emit yields the same one.
|
|
3354
2383
|
const trailSrc = canonicalStringify({
|
|
3355
2384
|
cve_id: c.cve_id,
|
|
3356
2385
|
vex_status: 'fixed',
|
|
@@ -3379,10 +2408,8 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
3379
2408
|
impact_statement: `Indicator ${i.id} (${i.verdict}; ${i.confidence}${i.deterministic ? '/deterministic' : ''}) in playbook ${playbook._meta.id}.`,
|
|
3380
2409
|
};
|
|
3381
2410
|
if (i.verdict === 'hit') {
|
|
3382
|
-
//
|
|
3383
|
-
//
|
|
3384
|
-
// operator-evidence confidence — neither warrants
|
|
3385
|
-
// under_investigation when the indicator actually fired.
|
|
2411
|
+
// `deterministic` describes regex specificity, not evidence
|
|
2412
|
+
// confidence, so a fired indicator is `affected` either way.
|
|
3386
2413
|
stmt.status = 'affected';
|
|
3387
2414
|
stmt.action_statement = actionStatementFor(`Run \`exceptd brief ${playbook._meta.id}\` for context.`);
|
|
3388
2415
|
} else if (i.verdict === 'miss') {
|
|
@@ -3393,15 +2420,8 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
3393
2420
|
}
|
|
3394
2421
|
return stmt;
|
|
3395
2422
|
});
|
|
3396
|
-
//
|
|
3397
|
-
//
|
|
3398
|
-
// tool vendor. Mirror the CSAF publisher.namespace fallback ladder so a
|
|
3399
|
-
// downstream supply-chain consumer keying on `author` resolves to the
|
|
3400
|
-
// operator URN. Pre-fix every OpenVEX document falsely attributed
|
|
3401
|
-
// dispositions to the tooling provider. Falls back to
|
|
3402
|
-
// urn:exceptd:operator:unknown + bundle_publisher_unclaimed runtime
|
|
3403
|
-
// warning if neither runOpts.operator nor runOpts.publisherNamespace
|
|
3404
|
-
// is supplied.
|
|
2423
|
+
// `author` is the entity attesting to the disposition — the operator, not
|
|
2424
|
+
// the tool vendor. Same fallback ladder as the CSAF publisher.namespace.
|
|
3405
2425
|
const vexOperatorClean = sanitizeOperatorText(runOpts.operator);
|
|
3406
2426
|
const vexExplicitNs = sanitizeOperatorText(runOpts.publisherNamespace);
|
|
3407
2427
|
let vexAuthor;
|
|
@@ -3411,9 +2431,8 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
3411
2431
|
vexAuthor = vexOperatorClean;
|
|
3412
2432
|
} else {
|
|
3413
2433
|
vexAuthor = 'urn:exceptd:operator:unknown';
|
|
3414
|
-
// Same shape
|
|
3415
|
-
// produces one canonical bundle_publisher_unclaimed entry
|
|
3416
|
-
// consumers can read consistently (reason/remediation, not message).
|
|
2434
|
+
// Same shape and singleton dedupe as the CSAF path, so a multi-format
|
|
2435
|
+
// emit produces one canonical bundle_publisher_unclaimed entry.
|
|
3417
2436
|
pushRunError(runOpts._runErrors, {
|
|
3418
2437
|
kind: 'bundle_publisher_unclaimed',
|
|
3419
2438
|
reason: 'OpenVEX author fell back to urn:exceptd:operator:unknown because no --publisher-namespace and no URL-shaped --operator were supplied. Disposition attribution is unclaimed on this VEX document.',
|
|
@@ -3422,17 +2441,15 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
3422
2441
|
}
|
|
3423
2442
|
return {
|
|
3424
2443
|
'@context': 'https://openvex.dev/ns/v0.2.0',
|
|
3425
|
-
//
|
|
3426
|
-
//
|
|
3427
|
-
// attestation file name. Falls back to a urnSlug if sessionId
|
|
3428
|
-
// somehow arrived empty.
|
|
2444
|
+
// Baked from session_id, not Date.now(), so the document URN aligns with
|
|
2445
|
+
// the CSAF tracking.id and the on-disk attestation file name.
|
|
3429
2446
|
'@id': `https://exceptd.com/vex/${playbookSlug}/${urnSlug(sessionId || 'session')}`,
|
|
3430
2447
|
author: vexAuthor,
|
|
3431
2448
|
timestamp: issued,
|
|
3432
2449
|
version: 1,
|
|
3433
2450
|
statements: (function () {
|
|
3434
|
-
//
|
|
3435
|
-
//
|
|
2451
|
+
// Deterministic mode sorts by vulnerability['@id']; otherwise
|
|
2452
|
+
// insertion order stands.
|
|
3436
2453
|
const all = [...cveStatements, ...indicatorStatements];
|
|
3437
2454
|
if (runOpts && runOpts.bundleDeterministic === true) {
|
|
3438
2455
|
const keyOf = (s) => (s && s.vulnerability && typeof s.vulnerability['@id'] === 'string')
|
|
@@ -3444,9 +2461,6 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
3444
2461
|
};
|
|
3445
2462
|
}
|
|
3446
2463
|
|
|
3447
|
-
// v0.11.0 redesign #39: --format summary emits a 5-line digest for CI gates
|
|
3448
|
-
// and human triage. Drops everything except verdict + RWEP + blast +
|
|
3449
|
-
// feeds_into + jurisdiction clock count.
|
|
3450
2464
|
if (format === 'summary') {
|
|
3451
2465
|
return {
|
|
3452
2466
|
format: 'summary',
|
|
@@ -3480,14 +2494,8 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
3480
2494
|
return { format: 'markdown', body: lines.join('\n') };
|
|
3481
2495
|
}
|
|
3482
2496
|
|
|
3483
|
-
//
|
|
3484
|
-
//
|
|
3485
|
-
// cred-stores / runtime / citation-hygiene playbooks; without an explicit
|
|
3486
|
-
// branch their default bundle fell through to the Unknown-format placeholder
|
|
3487
|
-
// below. Reuses the pinned `now` so multi-format builds stay
|
|
3488
|
-
// timestamp-consistent, and exposes the same finding fields the supported
|
|
3489
|
-
// csaf / markdown branches already surface — `json` is an intentional,
|
|
3490
|
-
// operator-selectable format, not the fallback leak path.
|
|
2497
|
+
// A superset of `summary` carrying the full finding record, and the declared
|
|
2498
|
+
// bundle_format of several shipped playbooks — not the fallback below.
|
|
3491
2499
|
if (format === 'json') {
|
|
3492
2500
|
return {
|
|
3493
2501
|
format: 'json',
|
|
@@ -3505,11 +2513,8 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
3505
2513
|
};
|
|
3506
2514
|
}
|
|
3507
2515
|
|
|
3508
|
-
// The
|
|
3509
|
-
//
|
|
3510
|
-
// "format" name — operators piping output to logging or third-party
|
|
3511
|
-
// tooling could leak finding details just by typo'ing the format flag.
|
|
3512
|
-
// Return the shape advertisement only.
|
|
2516
|
+
// The supported formats and nothing else: emitting analyze and validate
|
|
2517
|
+
// internals here leaks finding details on a typo'd format flag.
|
|
3513
2518
|
return {
|
|
3514
2519
|
format,
|
|
3515
2520
|
note: 'Unknown format',
|
|
@@ -3517,34 +2522,20 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
3517
2522
|
};
|
|
3518
2523
|
}
|
|
3519
2524
|
|
|
3520
|
-
//
|
|
3521
|
-
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
* {
|
|
3526
|
-
* observations: {
|
|
3527
|
-
* <artifact-id>: { captured, value, indicator?, result? } | "<precondition-value>",
|
|
3528
|
-
* },
|
|
3529
|
-
* verdict: { theater, classification, blast_radius }
|
|
3530
|
-
* }
|
|
3531
|
-
*
|
|
3532
|
-
* Already-nested submissions pass through unchanged.
|
|
3533
|
-
*/
|
|
2525
|
+
// Translate the flat submission shape into the engine's nested one; an
|
|
2526
|
+
// already-nested submission passes through unchanged. Flat is:
|
|
2527
|
+
// { observations: { <artifact-id>: { captured, value, indicator?, result? }
|
|
2528
|
+
// | "<precondition-value>" },
|
|
2529
|
+
// verdict: { theater, classification, blast_radius } }
|
|
3534
2530
|
function normalizeSubmission(submission, playbook) {
|
|
3535
2531
|
if (!submission || typeof submission !== "object") return submission || {};
|
|
3536
2532
|
|
|
3537
|
-
// signal_overrides must be a plain object
|
|
3538
|
-
//
|
|
3539
|
-
// out.signal_overrides via `{ ...(submission.signal_overrides || {}) }`
|
|
3540
|
-
// — spreading a string splatters it into { '0': 'f', '1': 'o', '2': 'o' },
|
|
3541
|
-
// which confuses detect()'s indicator-id lookup. Strip and log instead.
|
|
2533
|
+
// signal_overrides must be a plain object: spreading a string splatters it
|
|
2534
|
+
// into { '0': 'f', '1': 'o', … } and confuses detect()'s indicator lookup.
|
|
3542
2535
|
if (submission.signal_overrides !== undefined && submission.signal_overrides !== null
|
|
3543
2536
|
&& (typeof submission.signal_overrides !== 'object' || Array.isArray(submission.signal_overrides))) {
|
|
3544
|
-
// Clone before
|
|
3545
|
-
//
|
|
3546
|
-
// frozen submission (Object.freeze for safety, or a shared reference
|
|
3547
|
-
// across parallel runs) threw uncaught on the _runErrors push.
|
|
2537
|
+
// Clone before touching _runErrors: the caller's submission may be frozen,
|
|
2538
|
+
// or shared across parallel runs.
|
|
3548
2539
|
const carry = Array.isArray(submission._runErrors) ? submission._runErrors.slice() : [];
|
|
3549
2540
|
pushRunError(carry, {
|
|
3550
2541
|
kind: 'signal_overrides_invalid',
|
|
@@ -3554,17 +2545,12 @@ function normalizeSubmission(submission, playbook) {
|
|
|
3554
2545
|
submission = { ...submission, signal_overrides: {}, _runErrors: carry };
|
|
3555
2546
|
}
|
|
3556
2547
|
|
|
3557
|
-
//
|
|
3558
|
-
//
|
|
3559
|
-
//
|
|
3560
|
-
// `observations` / `verdict` untranslated and breaking detect. The shape
|
|
3561
|
-
// detector now treats `observations` or `verdict` as authoritative for
|
|
3562
|
-
// "this is flat" — even when nested keys also exist — and merges any
|
|
3563
|
-
// pre-existing nested keys into the normalized result.
|
|
2548
|
+
// `observations` or `verdict` decides the shape even when nested keys are
|
|
2549
|
+
// present: the CLI injects `signals._bundle_formats` before calling this,
|
|
2550
|
+
// and reading that as "already nested" leaves the flat keys untranslated.
|
|
3564
2551
|
const hasFlat = submission.observations || submission.verdict;
|
|
3565
2552
|
|
|
3566
2553
|
if (!hasFlat) {
|
|
3567
|
-
// Truly already-nested. Mark shape and return.
|
|
3568
2554
|
if (!submission._original_shape) submission._original_shape = 'nested (v0.10.x)';
|
|
3569
2555
|
return submission;
|
|
3570
2556
|
}
|
|
@@ -3574,18 +2560,13 @@ function normalizeSubmission(submission, playbook) {
|
|
|
3574
2560
|
signal_overrides: { ...(submission.signal_overrides || {}) },
|
|
3575
2561
|
signals: { ...(submission.signals || {}) },
|
|
3576
2562
|
precondition_checks: { ...(submission.precondition_checks || {}) },
|
|
3577
|
-
//
|
|
3578
|
-
//
|
|
2563
|
+
// Carried through so detect() can thread them onto firing indicators for
|
|
2564
|
+
// SARIF results[].locations.
|
|
3579
2565
|
...(submission.evidence_locations && typeof submission.evidence_locations === 'object'
|
|
3580
2566
|
? { evidence_locations: submission.evidence_locations } : {}),
|
|
3581
2567
|
_original_shape: 'flat (v0.11.0)',
|
|
3582
|
-
//
|
|
3583
|
-
//
|
|
3584
|
-
// submissions the fresh `out` literal built here loses that accumulator
|
|
3585
|
-
// unless we forward it; run()'s harvest at the entry to detect/analyze
|
|
3586
|
-
// reads agentSubmission._runErrors, so without the carry, flat
|
|
3587
|
-
// submissions with invalid signal_overrides drop the errors before
|
|
3588
|
-
// they can reach analyze.runtime_errors.
|
|
2568
|
+
// run() harvests the accumulator from agentSubmission._runErrors, which
|
|
2569
|
+
// the fresh `out` literal would otherwise drop.
|
|
3589
2570
|
...(Array.isArray(submission._runErrors) && submission._runErrors.length
|
|
3590
2571
|
? { _runErrors: submission._runErrors.slice() }
|
|
3591
2572
|
: {}),
|
|
@@ -3593,9 +2574,6 @@ function normalizeSubmission(submission, playbook) {
|
|
|
3593
2574
|
const knownPreconditions = new Set((playbook?._meta?.preconditions || []).map(p => p.id));
|
|
3594
2575
|
const knownArtifacts = new Set((playbook?.phases?.look?.artifacts || []).map(a => a.id));
|
|
3595
2576
|
|
|
3596
|
-
// v0.11.4 (#71): canonicalize indicator outcome strings here too so the
|
|
3597
|
-
// signal_overrides object handed to detect() carries the runner's expected
|
|
3598
|
-
// hit|miss|inconclusive vocabulary regardless of what the operator typed.
|
|
3599
2577
|
const canonicalizeOutcome = (v) => {
|
|
3600
2578
|
if (v === true || v === 'hit' || v === 'detected' || v === 'positive') return 'hit';
|
|
3601
2579
|
if (v === false || v === 'miss' || v === 'no_hit' || v === 'no-hit' || v === 'clean' || v === 'clear' || v === 'not_hit' || v === 'ok' || v === 'pass' || v === 'negative') return 'miss';
|
|
@@ -3603,13 +2581,9 @@ function normalizeSubmission(submission, playbook) {
|
|
|
3603
2581
|
return v; // leave unrecognized values for detect() to decide
|
|
3604
2582
|
};
|
|
3605
2583
|
|
|
3606
|
-
//
|
|
3607
|
-
//
|
|
3608
|
-
//
|
|
3609
|
-
//
|
|
3610
|
-
// When two observations target the same indicator id, last-write-wins
|
|
3611
|
-
// silently. Track discards in _signal_origins_collisions so analyze can
|
|
3612
|
-
// surface analyze.signal_origins_with_collisions for batch evidence runs.
|
|
2584
|
+
// Which observation produced each signal_override, so detect can emit
|
|
2585
|
+
// `from_observation`. Two observations on one indicator is last-write-wins,
|
|
2586
|
+
// and the discard is recorded as a collision for analyze to publish.
|
|
3613
2587
|
out._signal_origins = out._signal_origins || {};
|
|
3614
2588
|
out._signal_origins_collisions = out._signal_origins_collisions || [];
|
|
3615
2589
|
for (const [key, val] of Object.entries(submission.observations || {})) {
|
|
@@ -3619,15 +2593,10 @@ function normalizeSubmission(submission, playbook) {
|
|
|
3619
2593
|
}
|
|
3620
2594
|
if (typeof val === "object" && val !== null) {
|
|
3621
2595
|
const aid = knownArtifacts.has(key) ? key : (val.artifact || key);
|
|
3622
|
-
//
|
|
3623
|
-
//
|
|
3624
|
-
//
|
|
3625
|
-
//
|
|
3626
|
-
// discarding the actual evidence. Two observations capturing DIFFERENT
|
|
3627
|
-
// secrets under path/matched then hashed and diffed byte-identical, so
|
|
3628
|
-
// `attest diff` reported a false "unchanged" and masked real drift.
|
|
3629
|
-
// The reserved control keys (artifact/indicator/result) drive
|
|
3630
|
-
// signal_overrides below and are intentionally not echoed as evidence.
|
|
2596
|
+
// Every evidence-bearing key survives, not just `value`: a collector shape
|
|
2597
|
+
// like { path, matched, reason } would collapse to { value: undefined,
|
|
2598
|
+
// captured }, so two observations capturing DIFFERENT secrets hash
|
|
2599
|
+
// byte-identical. artifact/indicator/result are control keys, not evidence.
|
|
3631
2600
|
const { artifact: _a, indicator: _i, result: _r, captured: _c, value: _v, ...evidence } = val;
|
|
3632
2601
|
const normalizedArtifact = { captured: val.captured !== false };
|
|
3633
2602
|
if (val.value !== undefined) normalizedArtifact.value = val.value;
|
|
@@ -3639,9 +2608,6 @@ function normalizeSubmission(submission, playbook) {
|
|
|
3639
2608
|
if (val.indicator && val.result !== undefined) {
|
|
3640
2609
|
const newVerdict = canonicalizeOutcome(val.result);
|
|
3641
2610
|
if (out.signal_overrides[val.indicator] !== undefined && out._signal_origins[val.indicator] !== undefined) {
|
|
3642
|
-
// Collision: a prior observation already set this indicator.
|
|
3643
|
-
// Record the prior (which is now discarded) into the collision
|
|
3644
|
-
// log, then overwrite with the new one (last-write-wins).
|
|
3645
2611
|
out._signal_origins_collisions.push({
|
|
3646
2612
|
indicator_id: val.indicator,
|
|
3647
2613
|
source_observation_key: out._signal_origins[val.indicator],
|
|
@@ -3661,18 +2627,9 @@ function normalizeSubmission(submission, playbook) {
|
|
|
3661
2627
|
if (v.classification) out.signals.detection_classification = v.classification;
|
|
3662
2628
|
if (v.blast_radius !== undefined) out.signals.blast_radius_score = v.blast_radius;
|
|
3663
2629
|
|
|
3664
|
-
//
|
|
3665
|
-
//
|
|
3666
|
-
//
|
|
3667
|
-
// The prior `Object.assign(out.precondition_checks,
|
|
3668
|
-
// submission.precondition_checks)` form re-invoked the `__proto__` setter when
|
|
3669
|
-
// the operator submitted JSON containing a `__proto__` key. JSON.parse keeps
|
|
3670
|
-
// `__proto__` as an own data property (CreateDataProperty), but Object.assign
|
|
3671
|
-
// reads it via `[[Get]]` and writes via `[[Set]]`, which DOES trigger the
|
|
3672
|
-
// prototype-rebinding setter. The polluted prototype is confined to
|
|
3673
|
-
// `out.precondition_checks` (not global Object.prototype), but any future code
|
|
3674
|
-
// path that calls `.hasOwnProperty()` directly on the bag would observe the
|
|
3675
|
-
// pollution. Switch to own-key iteration so the prototype stays unmodified.
|
|
2630
|
+
// Own-key iteration, never Object.assign: JSON.parse keeps a submitted
|
|
2631
|
+
// `__proto__` as an own data property, but Object.assign writes it through
|
|
2632
|
+
// [[Set]] and triggers the prototype-rebinding setter on the bag.
|
|
3676
2633
|
if (submission.precondition_checks) {
|
|
3677
2634
|
for (const k of Object.keys(submission.precondition_checks)) {
|
|
3678
2635
|
if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
|
|
@@ -3683,13 +2640,9 @@ function normalizeSubmission(submission, playbook) {
|
|
|
3683
2640
|
return out;
|
|
3684
2641
|
}
|
|
3685
2642
|
|
|
3686
|
-
|
|
3687
|
-
|
|
3688
|
-
|
|
3689
|
-
* readability, command-on-PATH. The AI shouldn't have to declare these;
|
|
3690
|
-
* we resolve them ourselves and only escalate to AI declaration when the
|
|
3691
|
-
* check requires intent (e.g. "operator authorized this scan").
|
|
3692
|
-
*/
|
|
2643
|
+
// Answer the preconditions the runner can answer itself — host platform, cwd
|
|
2644
|
+
// readability, command-on-PATH — leaving only the ones that require intent
|
|
2645
|
+
// ("operator authorized this scan") for the AI to declare.
|
|
3693
2646
|
function autoDetectPreconditions(submission, playbook) {
|
|
3694
2647
|
const fs = require('fs');
|
|
3695
2648
|
const out = { ...(submission || {}) };
|
|
@@ -3712,17 +2665,14 @@ function autoDetectPreconditions(submission, playbook) {
|
|
|
3712
2665
|
const probe = spawnSync(process.platform === 'win32' ? 'where' : 'which', [cmdName], { stdio: 'ignore' });
|
|
3713
2666
|
out.precondition_checks[pc.id] = probe.status === 0;
|
|
3714
2667
|
}
|
|
3715
|
-
//
|
|
3716
|
-
//
|
|
3717
|
-
// undefined and the preflight gate handles missing values per on_fail.
|
|
2668
|
+
// An intent check ("operator_authorized == true") is left undefined for
|
|
2669
|
+
// the operator to declare; preflight handles the missing value per on_fail.
|
|
3718
2670
|
}
|
|
3719
2671
|
return out;
|
|
3720
2672
|
}
|
|
3721
2673
|
|
|
2674
|
+
// The seven phases in one call.
|
|
3722
2675
|
function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
3723
|
-
// Catalog corruption blocks runs cleanly. Probed lazily here (before the
|
|
3724
|
-
// analyze path) rather than at module load, so cheap verbs don't pay the
|
|
3725
|
-
// catalog-parse cost.
|
|
3726
2676
|
const xrefErr = getXrefLoadError();
|
|
3727
2677
|
if (xrefErr) {
|
|
3728
2678
|
return {
|
|
@@ -3737,7 +2687,6 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
3737
2687
|
try {
|
|
3738
2688
|
playbook = loadPlaybook(playbookId);
|
|
3739
2689
|
} catch (e) {
|
|
3740
|
-
// loadPlaybook failure → structured error (not crash).
|
|
3741
2690
|
return {
|
|
3742
2691
|
ok: false,
|
|
3743
2692
|
blocked_by: 'playbook_not_found',
|
|
@@ -3746,10 +2695,8 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
3746
2695
|
};
|
|
3747
2696
|
}
|
|
3748
2697
|
|
|
3749
|
-
//
|
|
3750
|
-
//
|
|
3751
|
-
// as a 500-style stack trace; instead return a clean structured error
|
|
3752
|
-
// with the valid directive list.
|
|
2698
|
+
// Before any phase runs: an unknown id otherwise throws uncaught inside
|
|
2699
|
+
// findDirective() and surfaces as a stack trace.
|
|
3753
2700
|
const validDirectives = (playbook.directives || []).map(d => d.id);
|
|
3754
2701
|
if (!validDirectives.includes(directiveId)) {
|
|
3755
2702
|
return {
|
|
@@ -3760,27 +2707,15 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
3760
2707
|
};
|
|
3761
2708
|
}
|
|
3762
2709
|
|
|
3763
|
-
// v0.11.0: accept flat submission shape (observations + verdict). Normalize
|
|
3764
|
-
// to the engine's internal nested shape before preflight/detect. Smart
|
|
3765
|
-
// precondition auto-detect (redesign #9) fires here when the cwd is readable
|
|
3766
|
-
// / the host platform matches — the runner can answer those itself rather
|
|
3767
|
-
// than blocking on AI declaration.
|
|
3768
2710
|
agentSubmission = normalizeSubmission(agentSubmission, playbook);
|
|
3769
|
-
//
|
|
3770
|
-
//
|
|
2711
|
+
// Captured before auto-detect so the provenance report distinguishes what
|
|
2712
|
+
// the operator declared from what the engine resolved.
|
|
3771
2713
|
const originalSubmissionPCs = { ...(agentSubmission.precondition_checks || {}) };
|
|
3772
2714
|
agentSubmission = autoDetectPreconditions(agentSubmission, playbook);
|
|
3773
2715
|
|
|
3774
|
-
// precondition_checks merge
|
|
3775
|
-
//
|
|
3776
|
-
//
|
|
3777
|
-
// process), whereas submission was captured earlier during evidence
|
|
3778
|
-
// collection. The order is documented here AND surfaced as
|
|
3779
|
-
// preflight.precondition_check_source on the result so callers can see
|
|
3780
|
-
// whether the value came from the submission, runOpts, or both
|
|
3781
|
-
// (merged with runOpts winning). Provenance reports the ORIGINAL submission
|
|
3782
|
-
// contents — autoDetectPreconditions adds engine-derived values that
|
|
3783
|
-
// wouldn't be meaningful as "submission" provenance.
|
|
2716
|
+
// precondition_checks merge submission → runOpts, runOpts winning as the
|
|
2717
|
+
// most recent caller intent; the submission was captured earlier, during
|
|
2718
|
+
// evidence collection. precondition_check_source names which side won.
|
|
3784
2719
|
const fullSubmissionPCs = agentSubmission.precondition_checks || {};
|
|
3785
2720
|
const runOptsPCs = runOpts.precondition_checks || {};
|
|
3786
2721
|
const mergedPCs = { ...fullSubmissionPCs, ...runOptsPCs };
|
|
@@ -3788,23 +2723,15 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
3788
2723
|
for (const k of Object.keys(mergedPCs)) {
|
|
3789
2724
|
const inOrigSub = Object.prototype.hasOwnProperty.call(originalSubmissionPCs, k);
|
|
3790
2725
|
const inRun = Object.prototype.hasOwnProperty.call(runOptsPCs, k);
|
|
3791
|
-
//
|
|
3792
|
-
//
|
|
3793
|
-
// report it as 'auto', not 'submission'. Otherwise: submission ∩ runOpts =
|
|
3794
|
-
// a genuine programmatic override of a submitted value ('merged');
|
|
3795
|
-
// runOpts-only = 'runOpts'; submission-only = 'submission'.
|
|
2726
|
+
// In neither side means autoDetectPreconditions supplied it ('auto'); in
|
|
2727
|
+
// both means a programmatic override of a submitted value ('merged').
|
|
3796
2728
|
if (!inOrigSub && !inRun) pcSource[k] = 'auto';
|
|
3797
2729
|
else pcSource[k] = (inOrigSub && inRun) ? 'merged' : (inRun ? 'runOpts' : 'submission');
|
|
3798
2730
|
}
|
|
3799
2731
|
const pre = preflight(playbook, { ...runOpts, precondition_checks: mergedPCs });
|
|
3800
2732
|
if (!pre.ok) {
|
|
3801
|
-
//
|
|
3802
|
-
//
|
|
3803
|
-
// consumers iterating results[] can't identify which playbook
|
|
3804
|
-
// produced a preflight failure without joining against
|
|
3805
|
-
// playbooks_run[] by array index. `verdict:"blocked"` +
|
|
3806
|
-
// `summary_line` keep the flat result-envelope shape consistent
|
|
3807
|
-
// across both branches.
|
|
2733
|
+
// A blocked result carries the same envelope fields as a successful one, so
|
|
2734
|
+
// a consumer iterating results[] can name the playbook that failed.
|
|
3808
2735
|
const summaryLine = capSummary(`${playbookId}: blocked at preflight (${pre.blocked_by || 'unknown'}) — ${pre.reason || ''}`);
|
|
3809
2736
|
return {
|
|
3810
2737
|
ok: false,
|
|
@@ -3822,21 +2749,13 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
3822
2749
|
};
|
|
3823
2750
|
}
|
|
3824
2751
|
|
|
3825
|
-
//
|
|
3826
|
-
//
|
|
3827
|
-
//
|
|
3828
|
-
// acquire) and the run then proceeded UNLOCKED, defeating the mutex. Block
|
|
3829
|
-
// only on `held_by_live_pid` — same-PID reentrancy and FS quirks still
|
|
3830
|
-
// proceed best-effort (lockPath null), so no legitimate nested/edge case
|
|
3831
|
-
// regresses. Released in the finally block.
|
|
2752
|
+
// The diagnostic variant, because bare acquireLock returns null on a race
|
|
2753
|
+
// lost in the TOCTOU window since preflight's check, and the run would then
|
|
2754
|
+
// proceed UNLOCKED. Released in the finally block below.
|
|
3832
2755
|
const lockResult = acquireLockDiagnostic(playbookId);
|
|
3833
|
-
//
|
|
3834
|
-
//
|
|
3835
|
-
//
|
|
3836
|
-
// probe). Blocking on that would let one corrupted lockfile left by a crash
|
|
3837
|
-
// deny every future run forever. A null-pid (malformed) lock instead falls
|
|
3838
|
-
// through to proceed best-effort (lockPath null) — matching how the preflight
|
|
3839
|
-
// mutex check treats unparsable lockfiles as stale.
|
|
2756
|
+
// Only on a confirmed live foreign holder with a real pid. A malformed
|
|
2757
|
+
// lockfile reports held_by_live_pid too, with holder_pid null, and blocking on
|
|
2758
|
+
// that lets one file left by a crash deny every future run forever.
|
|
3840
2759
|
if (!lockResult.ok && lockResult.reason === 'held_by_live_pid'
|
|
3841
2760
|
&& Number.isInteger(lockResult.holder_pid) && lockResult.holder_pid > 0) {
|
|
3842
2761
|
return {
|
|
@@ -3854,26 +2773,11 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
3854
2773
|
}
|
|
3855
2774
|
const lockPath = lockResult.ok ? lockResult.path : null;
|
|
3856
2775
|
_activeRuns.add(playbookId);
|
|
3857
|
-
//
|
|
3858
|
-
//
|
|
3859
|
-
//
|
|
3860
|
-
//
|
|
3861
|
-
//
|
|
3862
|
-
//
|
|
3863
|
-
// session_id is generated ONCE here and threaded into close() via
|
|
3864
|
-
// cachedRunOpts.session_id so CSAF tracking.id / OpenVEX @id / product
|
|
3865
|
-
// PURLs / on-disk attestation filenames all share one identifier.
|
|
3866
|
-
// Without the single-source-of-truth, close() would mint its own id
|
|
3867
|
-
// and operators correlating attestation files to embedded bundle URNs
|
|
3868
|
-
// would see mismatches.
|
|
3869
|
-
//
|
|
3870
|
-
// v0.12.27: when runOpts.bundleDeterministic is set AND the operator did
|
|
3871
|
-
// not pass --session-id, derive the session_id from the submission shape
|
|
3872
|
-
// so two runs against identical evidence produce the same id (and
|
|
3873
|
-
// therefore the same CSAF tracking.id / OpenVEX @id / attestation file
|
|
3874
|
-
// name). Mirrors the evidence_hash path further down but is computed
|
|
3875
|
-
// here so close() can thread it through. Operator-supplied --session-id
|
|
3876
|
-
// still wins on collision.
|
|
2776
|
+
// The playbook is parsed once and threaded through every phase as
|
|
2777
|
+
// runOpts._playbookCache. session_id is minted once, so the CSAF tracking.id,
|
|
2778
|
+
// OpenVEX @id, product PURLs and attestation filename carry one correlatable
|
|
2779
|
+
// identifier. Under bundleDeterministic it derives from the submission, so two
|
|
2780
|
+
// runs over identical evidence agree; --session-id still wins.
|
|
3877
2781
|
let sessionId;
|
|
3878
2782
|
if (runOpts.session_id) {
|
|
3879
2783
|
sessionId = runOpts.session_id;
|
|
@@ -3884,12 +2788,9 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
3884
2788
|
.update(canonicalStringify(extractSubmissionForHash(agentSubmission)))
|
|
3885
2789
|
.digest('hex');
|
|
3886
2790
|
} catch (e) {
|
|
3887
|
-
// canonicalStringify
|
|
3888
|
-
//
|
|
3889
|
-
//
|
|
3890
|
-
// not opened yet — release both before rethrowing, or the leaked
|
|
3891
|
-
// lockfile blocks every subsequent run of this playbook for as long
|
|
3892
|
-
// as this PID lives.
|
|
2791
|
+
// canonicalStringify throws EVIDENCE_TOO_DEEP on pathological nesting,
|
|
2792
|
+
// and the try/finally below has not opened yet — a leaked lockfile would
|
|
2793
|
+
// block every later run of this playbook for this PID's lifetime.
|
|
3893
2794
|
_activeRuns.delete(playbookId);
|
|
3894
2795
|
releaseLock(lockPath);
|
|
3895
2796
|
throw e;
|
|
@@ -3902,24 +2803,19 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
3902
2803
|
sessionId = crypto.randomBytes(8).toString('hex');
|
|
3903
2804
|
}
|
|
3904
2805
|
const cachedRunOpts = { ...runOpts, _playbookCache: playbook, session_id: sessionId };
|
|
3905
|
-
//
|
|
3906
|
-
// non-fatal anomalies surfaced into analyze.runtime_errors[].
|
|
2806
|
+
// The run-level accumulator behind analyze.runtime_errors[].
|
|
3907
2807
|
const runErrors = [];
|
|
3908
2808
|
cachedRunOpts._runErrors = runErrors;
|
|
3909
|
-
// normalizeSubmission
|
|
3910
|
-
//
|
|
3911
|
-
//
|
|
3912
|
-
// them, and strip the field off the submission so it doesn't pollute
|
|
3913
|
-
// the evidence_hash digest (the hash canonicalizes the submission and
|
|
3914
|
-
// a non-deterministic _runErrors would change it).
|
|
2809
|
+
// Splice in what normalizeSubmission pushed, then strip the field off the
|
|
2810
|
+
// submission: the evidence_hash canonicalizes the submission, and a
|
|
2811
|
+
// non-deterministic _runErrors would change the digest.
|
|
3915
2812
|
if (Array.isArray(agentSubmission._runErrors) && agentSubmission._runErrors.length) {
|
|
3916
2813
|
runErrors.push(...agentSubmission._runErrors);
|
|
3917
2814
|
}
|
|
3918
2815
|
if (agentSubmission && Object.prototype.hasOwnProperty.call(agentSubmission, '_runErrors')) {
|
|
3919
2816
|
delete agentSubmission._runErrors;
|
|
3920
2817
|
}
|
|
3921
|
-
// Phases
|
|
3922
|
-
// preconditions surfaced in preflight.issues.
|
|
2818
|
+
// Phases to skip, from the skip_phase preconditions preflight surfaced.
|
|
3923
2819
|
const skipPhases = new Set();
|
|
3924
2820
|
for (const issue of (pre.issues || [])) {
|
|
3925
2821
|
if (issue.kind === 'precondition_skip' && issue.skip_phase) {
|
|
@@ -3948,10 +2844,9 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
3948
2844
|
observations_received: [],
|
|
3949
2845
|
signals_received: []
|
|
3950
2846
|
};
|
|
3951
|
-
// analyze
|
|
3952
|
-
//
|
|
2847
|
+
// analyze still runs, on an empty submission so it cannot resolve
|
|
2848
|
+
// indicator hits against a detect result that never happened.
|
|
3953
2849
|
phases.analyze = analyze(playbookId, directiveId, phases.detect, {}, cachedRunOpts);
|
|
3954
|
-
// Annotate analyze with the skip vocabulary so consumers can branch.
|
|
3955
2850
|
phases.analyze.classification = 'skipped';
|
|
3956
2851
|
} else {
|
|
3957
2852
|
phases.detect = detect(playbookId, directiveId, agentSubmission, cachedRunOpts);
|
|
@@ -3960,19 +2855,12 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
3960
2855
|
phases.validate = validate(playbookId, directiveId, phases.analyze, agentSubmission.signals || {}, cachedRunOpts);
|
|
3961
2856
|
phases.close = close(playbookId, directiveId, phases.analyze, phases.validate, agentSubmission.signals || {}, cachedRunOpts);
|
|
3962
2857
|
|
|
3963
|
-
// analyze()
|
|
3964
|
-
//
|
|
3965
|
-
// have pushed additional regex errors AFTER analyze returned; surface
|
|
3966
|
-
// those onto phases.analyze.runtime_errors so the field reflects every
|
|
3967
|
-
// regex failure in the run. De-dupe by JSON shape so the analyze-time
|
|
3968
|
-
// snapshot doesn't double-count.
|
|
2858
|
+
// analyze() snapshotted the accumulator when it returned; validate and
|
|
2859
|
+
// close push after that, so late entries merge back in.
|
|
3969
2860
|
if (runErrors.length && phases.analyze) {
|
|
3970
|
-
// `_truncated`
|
|
3971
|
-
//
|
|
3972
|
-
//
|
|
3973
|
-
// the late-push `runErrors` ref. Skip them on the dedupe-merge pass
|
|
3974
|
-
// to keep the snapshot's authoritative dropped-count, rather than
|
|
3975
|
-
// double-stamping a second sentinel with the same `dropped` value.
|
|
2861
|
+
// A `_truncated` sentinel aggregates through in-place `dropped`
|
|
2862
|
+
// increments, so the same object sits in both arrays; skipping it keeps
|
|
2863
|
+
// one authoritative count instead of stamping a duplicate.
|
|
3976
2864
|
const existing = new Set(
|
|
3977
2865
|
(phases.analyze.runtime_errors || [])
|
|
3978
2866
|
.filter(e => !(e && e.kind === '_truncated'))
|
|
@@ -3984,16 +2872,10 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
3984
2872
|
}
|
|
3985
2873
|
}
|
|
3986
2874
|
|
|
3987
|
-
// evidence_hash
|
|
3988
|
-
//
|
|
3989
|
-
//
|
|
3990
|
-
//
|
|
3991
|
-
// different evidence collide on the same hash whenever their
|
|
3992
|
-
// classifications match. Use SHA-256 over the recursively sorted
|
|
3993
|
-
// submission. `captured_at` and other timestamp-like fields are
|
|
3994
|
-
// INTENTIONALLY excluded so that re-running with the same submission
|
|
3995
|
-
// produces the same hash — `reattest` relies on this to detect drift
|
|
3996
|
-
// (different submission → different hash → drift exists).
|
|
2875
|
+
// evidence_hash covers the canonicalized submission itself: keyed on only
|
|
2876
|
+
// { playbook, directive, cves, rwep, classification }, two operators with
|
|
2877
|
+
// different evidence collide whenever their classifications match.
|
|
2878
|
+
// Timestamps are excluded so one submission always hashes the same.
|
|
3997
2879
|
const submissionDigest = crypto.createHash('sha256')
|
|
3998
2880
|
.update(canonicalStringify(extractSubmissionForHash(agentSubmission)))
|
|
3999
2881
|
.digest('hex');
|
|
@@ -4007,24 +2889,14 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
4007
2889
|
}))
|
|
4008
2890
|
.digest('hex');
|
|
4009
2891
|
|
|
4010
|
-
//
|
|
4011
|
-
//
|
|
4012
|
-
//
|
|
4013
|
-
// common ops question ("did this playbook detect anything?").
|
|
4014
|
-
//
|
|
4015
|
-
// Verdict source: phases.detect.classification is the canonical
|
|
4016
|
-
// signal — one of detected / not_detected / inconclusive / pending
|
|
4017
|
-
// / skipped. validate() does NOT carry a `verdict` field, so
|
|
4018
|
-
// sourcing the hoist from phases.validate.verdict would degrade
|
|
4019
|
-
// every non-blocked result to the fallback "inconclusive".
|
|
2892
|
+
// Hoisted to the result root so a `ci` consumer answers "did this detect
|
|
2893
|
+
// anything?" without walking into phases. detect.classification is the
|
|
2894
|
+
// canonical verdict — validate() carries no `verdict` field.
|
|
4020
2895
|
const verdict = (phases.detect && phases.detect.classification) || 'inconclusive';
|
|
4021
2896
|
const rwepScore = phases.analyze && phases.analyze.rwep && typeof phases.analyze.rwep.adjusted === 'number'
|
|
4022
2897
|
? phases.analyze.rwep.adjusted : null;
|
|
4023
|
-
//
|
|
4024
|
-
//
|
|
4025
|
-
// the verdict string itself. Echoing the classification made top_finding
|
|
4026
|
-
// carry no information beyond `verdict` and duplicated it in summary_line
|
|
4027
|
-
// (e.g. "detected (rwep=10, detected, ...)"). The full set lives in phases.
|
|
2898
|
+
// The first matched CVE id, else the fired indicator that drove the
|
|
2899
|
+
// verdict — never the classification, which duplicates `verdict`.
|
|
4028
2900
|
let topFinding = null;
|
|
4029
2901
|
if (phases.analyze && Array.isArray(phases.analyze.matched_cves) && phases.analyze.matched_cves.length) {
|
|
4030
2902
|
topFinding = phases.analyze.matched_cves[0].cve_id;
|
|
@@ -4034,14 +2906,9 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
4034
2906
|
&& phases.detect.classification !== 'not_detected'
|
|
4035
2907
|
&& phases.detect.classification !== 'inconclusive'
|
|
4036
2908
|
&& phases.detect.classification !== 'pending') {
|
|
4037
|
-
// Only on a real detection verdict
|
|
4038
|
-
//
|
|
4039
|
-
//
|
|
4040
|
-
// indicator that actually drove the RWEP score — the greatest
|
|
4041
|
-
// weight_applied among fired, weight-bearing rwep_inputs — so the
|
|
4042
|
-
// headline names the finding that produced the number shown beside it.
|
|
4043
|
-
// Fall back to the dominant fired indicator (deterministic/high
|
|
4044
|
-
// confidence), then the first hit, when no weighted signal fired.
|
|
2909
|
+
// Only on a real detection verdict, so a stray hit on an inconclusive run
|
|
2910
|
+
// cannot advertise a finding. The indicator that drove the RWEP score
|
|
2911
|
+
// wins, then the dominant fired indicator, then the first hit.
|
|
4045
2912
|
const breakdown = (phases.analyze.rwep && Array.isArray(phases.analyze.rwep.breakdown))
|
|
4046
2913
|
? phases.analyze.rwep.breakdown : [];
|
|
4047
2914
|
const driver = breakdown
|
|
@@ -4052,12 +2919,9 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
4052
2919
|
if (driver && driver.signal_id) topFinding = driver.signal_id;
|
|
4053
2920
|
else if (dominant && dominant.id) topFinding = dominant.id;
|
|
4054
2921
|
}
|
|
4055
|
-
//
|
|
4056
|
-
//
|
|
4057
|
-
//
|
|
4058
|
-
// at the result-root level — a not_detected verdict with zero
|
|
4059
|
-
// indicators-evaluated reads the same as one with every indicator
|
|
4060
|
-
// evaluated and miss.
|
|
2922
|
+
// Evaluated against known separates "ran fully and found nothing" from
|
|
2923
|
+
// "could not evaluate" — a not_detected verdict over zero indicators
|
|
2924
|
+
// otherwise reads exactly like one where every indicator missed.
|
|
4061
2925
|
const indicatorsKnown = (playbook.phases && playbook.phases.detect && playbook.phases.detect.indicators)
|
|
4062
2926
|
? playbook.phases.detect.indicators.length : null;
|
|
4063
2927
|
const indicatorsEvaluated = phases.detect && typeof phases.detect.indicators_evaluated_count === 'number'
|
|
@@ -4076,10 +2940,7 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
4076
2940
|
playbook_id: playbookId,
|
|
4077
2941
|
directive_id: directiveId,
|
|
4078
2942
|
session_id: sessionId,
|
|
4079
|
-
//
|
|
4080
|
-
// to spelunk through phases.* to get the answer to the most
|
|
4081
|
-
// common question. The phases tree is still present below for
|
|
4082
|
-
// anyone who needs the full CSAF / analyze / detect dumps.
|
|
2943
|
+
// The flat answer; the phases tree below carries the full dumps.
|
|
4083
2944
|
verdict,
|
|
4084
2945
|
rwep_score: rwepScore,
|
|
4085
2946
|
top_finding: topFinding,
|
|
@@ -4090,15 +2951,12 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
4090
2951
|
evidence_hash: evidenceHash,
|
|
4091
2952
|
submission_digest: submissionDigest,
|
|
4092
2953
|
preflight_issues: pre.issues,
|
|
4093
|
-
//
|
|
4094
|
-
//
|
|
4095
|
-
// consumer can see what the collector could not scan. Advisory only:
|
|
4096
|
-
// never affects verdict, rwep, or evidence_completeness.
|
|
2954
|
+
// What the collector could not scan. Advisory only — never affects
|
|
2955
|
+
// verdict, rwep or evidence_completeness.
|
|
4097
2956
|
...(Array.isArray(agentSubmission.collector_errors) && agentSubmission.collector_errors.length
|
|
4098
2957
|
? { collector_warnings: agentSubmission.collector_errors }
|
|
4099
2958
|
: {}),
|
|
4100
|
-
//
|
|
4101
|
-
// { '<pc-id>': 'submission' | 'runOpts' | 'merged', ... }
|
|
2959
|
+
// { '<pc-id>': 'submission' | 'runOpts' | 'merged' | 'auto', … }
|
|
4102
2960
|
precondition_check_source: pcSource,
|
|
4103
2961
|
phases
|
|
4104
2962
|
};
|
|
@@ -4108,25 +2966,15 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
4108
2966
|
}
|
|
4109
2967
|
}
|
|
4110
2968
|
|
|
4111
|
-
//
|
|
4112
|
-
|
|
4113
|
-
|
|
4114
|
-
|
|
4115
|
-
* Without sorted keys two semantically identical submissions ({a:1, b:2}
|
|
4116
|
-
* vs {b:2, a:1}) would hash to different digests, breaking reattest's
|
|
4117
|
-
* "same submission → same hash" contract. Arrays preserve order
|
|
4118
|
-
* (submission order is meaningful for evidence). null + primitives pass
|
|
4119
|
-
* through directly. Avoids JSON.stringify's replacer indirection because
|
|
4120
|
-
* a top-level array would otherwise miss the canonicalization recursion.
|
|
4121
|
-
*/
|
|
2969
|
+
// Deterministic JSON with recursively sorted keys: {a:1, b:2} and {b:2, a:1}
|
|
2970
|
+
// must hash alike or reattest's "same submission → same hash" breaks. Array
|
|
2971
|
+
// order is preserved — submission order is meaningful for evidence. Throws
|
|
2972
|
+
// EVIDENCE_TOO_DEEP past CANONICAL_MAX_DEPTH.
|
|
4122
2973
|
const CANONICAL_MAX_DEPTH = 200;
|
|
4123
2974
|
function canonicalStringify(v, _depth = 0) {
|
|
4124
2975
|
if (v === null || typeof v !== 'object') return JSON.stringify(v);
|
|
4125
|
-
//
|
|
4126
|
-
//
|
|
4127
|
-
// runs on every run() (evidence_hash + session_id derivation), so the guard
|
|
4128
|
-
// turns a crash into an actionable rejection. 200 levels is far beyond any
|
|
4129
|
-
// legitimate submission.
|
|
2976
|
+
// Deeply-nested evidence would overflow the stack with an opaque internal
|
|
2977
|
+
// error; 200 is far beyond any legitimate submission.
|
|
4130
2978
|
if (_depth > CANONICAL_MAX_DEPTH) {
|
|
4131
2979
|
const e = new Error(`evidence nesting exceeds the maximum depth of ${CANONICAL_MAX_DEPTH} — flatten the submission`);
|
|
4132
2980
|
e.code = 'EVIDENCE_TOO_DEEP';
|
|
@@ -4137,20 +2985,12 @@ function canonicalStringify(v, _depth = 0) {
|
|
|
4137
2985
|
return '{' + keys.map(k => JSON.stringify(k) + ':' + canonicalStringify(v[k], _depth + 1)).join(',') + '}';
|
|
4138
2986
|
}
|
|
4139
2987
|
|
|
4140
|
-
// Re-key an artifacts map by the stable indicator id
|
|
4141
|
-
//
|
|
4142
|
-
//
|
|
4143
|
-
// the
|
|
4144
|
-
//
|
|
4145
|
-
// (obs-kver vs x1) hashed differently and attest/reattest reported false drift —
|
|
4146
|
-
// the attest diff re-keys the COMPARISON (bin/exceptd.js); the hash itself was
|
|
4147
|
-
// not covered. Collision-safe (mirrors that helper): re-key only when the stable
|
|
4148
|
-
// id is not already a DISTINCT original key and has not been claimed by an
|
|
4149
|
-
// earlier entry, else keep the original key so no artifact is silently dropped.
|
|
2988
|
+
// Re-key an artifacts map by the stable indicator id, recovered by inverting
|
|
2989
|
+
// _signal_origins, so evidence_hash reflects the evidence VALUE and its binding
|
|
2990
|
+
// rather than the operator's free-text observation label. bin/exceptd.js re-keys
|
|
2991
|
+
// the attest COMPARISON the same way. Collision-safe: a stable id already used
|
|
2992
|
+
// as a distinct key keeps the original, dropping nothing.
|
|
4150
2993
|
function _rekeyArtifactsByStableId(artifacts, signalOrigins) {
|
|
4151
|
-
// The sole caller (extractSubmissionForHash) only invokes this inside
|
|
4152
|
-
// `if (sub.artifacts && typeof sub.artifacts === 'object')`, so `artifacts`
|
|
4153
|
-
// is always a non-null object — guard only on the re-key map being usable.
|
|
4154
2994
|
if (!signalOrigins || typeof signalOrigins !== 'object') return artifacts;
|
|
4155
2995
|
const obsKeyToIndicator = {};
|
|
4156
2996
|
for (const [indicatorId, obsKey] of Object.entries(signalOrigins)) {
|
|
@@ -4167,20 +3007,13 @@ function _rekeyArtifactsByStableId(artifacts, signalOrigins) {
|
|
|
4167
3007
|
return out;
|
|
4168
3008
|
}
|
|
4169
3009
|
|
|
4170
|
-
|
|
4171
|
-
|
|
4172
|
-
|
|
4173
|
-
|
|
4174
|
-
* timestamps (would break "same submission → same hash") or runner-internal
|
|
4175
|
-
* provenance metadata that isn't part of what the operator submitted.
|
|
4176
|
-
*/
|
|
3010
|
+
// The operator-meaningful fields of a normalized submission, for hashing.
|
|
3011
|
+
// captured_at, _signal_origins, _signal_origins_collisions and _original_shape
|
|
3012
|
+
// are excluded as timestamps, which break "same submission → same hash", or as
|
|
3013
|
+
// runner-internal provenance the operator never submitted.
|
|
4177
3014
|
function extractSubmissionForHash(sub) {
|
|
4178
3015
|
if (!sub || typeof sub !== 'object') return {};
|
|
4179
3016
|
const pick = {};
|
|
4180
|
-
// Strip captured_at from artifact entries so timestamp drift doesn't
|
|
4181
|
-
// perturb the digest. The semantic content (value + captured-ness +
|
|
4182
|
-
// optional indicator binding) is what matters for "did the operator
|
|
4183
|
-
// submit the same evidence?".
|
|
4184
3017
|
if (sub.artifacts && typeof sub.artifacts === 'object') {
|
|
4185
3018
|
const stripped = {};
|
|
4186
3019
|
for (const [k, v] of Object.entries(sub.artifacts)) {
|
|
@@ -4191,45 +3024,26 @@ function extractSubmissionForHash(sub) {
|
|
|
4191
3024
|
stripped[k] = v;
|
|
4192
3025
|
}
|
|
4193
3026
|
}
|
|
4194
|
-
// Re-key by the stable indicator id so the digest reflects the evidence
|
|
4195
|
-
// VALUE + its binding, never the operator's free-text observation label.
|
|
4196
|
-
// Identical (indicator, value) evidence under different observation keys
|
|
4197
|
-
// must produce the SAME evidence_hash / submission_digest / session_id.
|
|
4198
3027
|
pick.artifacts = _rekeyArtifactsByStableId(stripped, sub._signal_origins);
|
|
4199
3028
|
}
|
|
4200
3029
|
if (sub.signal_overrides && typeof sub.signal_overrides === 'object') {
|
|
4201
3030
|
pick.signal_overrides = sub.signal_overrides;
|
|
4202
3031
|
}
|
|
4203
3032
|
if (sub.signals && typeof sub.signals === 'object') {
|
|
4204
|
-
// vex_filter and vex_fixed
|
|
4205
|
-
// canonicalStringify can serialize them.
|
|
3033
|
+
// vex_filter and vex_fixed arrive as Sets; sorted arrays serialize.
|
|
4206
3034
|
const signals = {};
|
|
4207
3035
|
for (const [k, v] of Object.entries(sub.signals)) {
|
|
4208
|
-
//
|
|
4209
|
-
//
|
|
4210
|
-
//
|
|
4211
|
-
//
|
|
4212
|
-
// "same evidence → same evidence_hash" contract: two runs over identical
|
|
4213
|
-
// evidence that differ only in `--format` produced different digests and
|
|
4214
|
-
// a self-contradicting attest diff. Exclude every `_`-prefixed signal key
|
|
4215
|
-
// (matches the file-wide "underscore = internal" convention). vex_filter /
|
|
4216
|
-
// vex_fixed are deliberately NOT excluded — they DROP CVEs from
|
|
4217
|
-
// matched_cves and change the finding, so they are posture-affecting
|
|
4218
|
-
// evidence; excluding them would make reattest blind to a VEX-driven
|
|
4219
|
-
// posture change (a real drift hidden behind an unchanged hash).
|
|
3036
|
+
// An underscore-prefixed signal is a render directive, not evidence:
|
|
3037
|
+
// hashing `_bundle_formats` makes two runs differing only in --format
|
|
3038
|
+
// produce different digests. vex_filter and vex_fixed stay IN — they drop
|
|
3039
|
+
// CVEs and change the finding.
|
|
4220
3040
|
if (k.startsWith('_')) continue;
|
|
4221
3041
|
if (v instanceof Set) signals[k] = Array.from(v).sort();
|
|
4222
3042
|
else signals[k] = v;
|
|
4223
3043
|
}
|
|
4224
|
-
//
|
|
4225
|
-
//
|
|
4226
|
-
//
|
|
4227
|
-
// {} after the filter above; the baseline no-format submission carries no
|
|
4228
|
-
// `signals` key at all. Recording `{}` vs omitting it would hash identical
|
|
4229
|
-
// evidence as `{signals:{}}` vs no-signals and reintroduce the exact
|
|
4230
|
-
// --format-driven evidence_hash / session-id drift this exclusion exists to
|
|
4231
|
-
// prevent. Only attach signals when at least one operator-meaningful key
|
|
4232
|
-
// survived.
|
|
3044
|
+
// An empty bag is omitted, not recorded as `signals: {}`: a submission whose
|
|
3045
|
+
// only signal was a render directive reduces to {} here while the baseline
|
|
3046
|
+
// has no `signals` key, reintroducing the --format drift filtered above.
|
|
4233
3047
|
if (Object.keys(signals).length > 0) pick.signals = signals;
|
|
4234
3048
|
}
|
|
4235
3049
|
if (sub.precondition_checks && typeof sub.precondition_checks === 'object') {
|
|
@@ -4244,13 +3058,10 @@ function extractSubmissionForHash(sub) {
|
|
|
4244
3058
|
return pick;
|
|
4245
3059
|
}
|
|
4246
3060
|
|
|
4247
|
-
//
|
|
4248
|
-
//
|
|
4249
|
-
//
|
|
4250
|
-
//
|
|
4251
|
-
// the predicate. `jurisdiction` is the only field the catalog's object-array
|
|
4252
|
-
// `contains` conditions target today; the allowlist is the seam to extend if a
|
|
4253
|
-
// future condition deliberately matches a different identity field.
|
|
3061
|
+
// The identity fields a `contains`/`includes` test targets when the array holds
|
|
3062
|
+
// objects. Scoping membership to these keeps a non-identity field that happens
|
|
3063
|
+
// to equal the member (a clock_starts of 'detect_confirmed', a free-form tag
|
|
3064
|
+
// reading 'EU') from satisfying the predicate.
|
|
4254
3065
|
const OBJECT_MEMBERSHIP_FIELDS = ['jurisdiction'];
|
|
4255
3066
|
|
|
4256
3067
|
function evalCondition(expr, ctx, playbook) {
|
|
@@ -4261,44 +3072,23 @@ function evalCondition(expr, ctx, playbook) {
|
|
|
4261
3072
|
if (expr === 'true') return true;
|
|
4262
3073
|
if (expr === 'false') return false;
|
|
4263
3074
|
|
|
4264
|
-
//
|
|
4265
|
-
//
|
|
4266
|
-
// group sub-expressions — i.e. `A OR (B AND C)` parses with B,C as one AND
|
|
4267
|
-
// group rather than splitting at the inner AND.
|
|
3075
|
+
// OR binds looser than AND, so it splits first; splitAtTopLevel is
|
|
3076
|
+
// depth-aware, so `A OR (B AND C)` keeps B and C as one group.
|
|
4268
3077
|
const orParts = splitAtTopLevel(expr, 'OR');
|
|
4269
3078
|
if (orParts.length > 1) return orParts.some(s => evalCondition(s, ctx, playbook));
|
|
4270
3079
|
|
|
4271
3080
|
const andParts = splitAtTopLevel(expr, 'AND');
|
|
4272
3081
|
if (andParts.length > 1) return andParts.every(s => evalCondition(s, ctx, playbook));
|
|
4273
3082
|
|
|
4274
|
-
//
|
|
4275
|
-
//
|
|
4276
|
-
//
|
|
4277
|
-
// through to `false`, silently disabling the clause it gates. Strip the
|
|
4278
|
-
// quantifier and re-evaluate the inner comparison: when the LHS path resolves
|
|
4279
|
-
// to a scalar (the framework theater_verdict case) the quantifier is prose
|
|
4280
|
-
// emphasis and the scalar comparison is the intended test; when the LHS first
|
|
4281
|
-
// segment resolves to an array, apply the predicate existentially (`any`) /
|
|
4282
|
-
// universally (`all`) across its elements. The inner clause is only the leaf
|
|
4283
|
-
// comparison (the surrounding AND/OR has already been split off above).
|
|
3083
|
+
// Catalog conditions write `any <path> <op> <value>`. An array head applies
|
|
3084
|
+
// the predicate existentially or universally across its elements; a scalar
|
|
3085
|
+
// head means the quantifier was prose and the comparison is the real test.
|
|
4284
3086
|
const quant = expr.match(/^(any|all)\s+(.+)$/);
|
|
4285
3087
|
if (quant) {
|
|
4286
3088
|
const [, kind, inner] = quant;
|
|
4287
|
-
//
|
|
4288
|
-
//
|
|
4289
|
-
//
|
|
4290
|
-
// `IN [...]`, `contains`, `matches /…/`, `>=`, …) is operator-agnostic — the
|
|
4291
|
-
// inner clause is re-evaluated whole against `{ …ctx, [head]: el }`, so every
|
|
4292
|
-
// operator the leaf parser already understands works under a quantifier.
|
|
4293
|
-
// Earlier this branch only re-rooted clauses whose operator was in the
|
|
4294
|
-
// comparison set (`>=|<=|==|=|<|>|!=`); `IN`/`contains`/`matches` were
|
|
4295
|
-
// omitted, so e.g. sbom.json's `any matched_cve.attack_class IN
|
|
4296
|
-
// ['ai-c2','prompt-injection']` (the sbom→ai-api feeds_into trigger) fell
|
|
4297
|
-
// through to a whole-ctx eval where `matched_cve.attack_class` resolved to
|
|
4298
|
-
// undefined on the array and the clause was permanently false, while the
|
|
4299
|
-
// sibling `== 'kernel-lpe'` quantifiers fired. Requiring a `.`-qualified head
|
|
4300
|
-
// keeps the bare-token form (`any <path>`) routed to the non-emptiness branch
|
|
4301
|
-
// below.
|
|
3089
|
+
// The clause is re-evaluated WHOLE against `{ …ctx, [head]: el }`, so every
|
|
3090
|
+
// operator the leaf parser understands works under a quantifier. A
|
|
3091
|
+
// `.`-qualified head routes the bare `any <path>` form to the branch below.
|
|
4302
3092
|
const headMatch = inner.match(/^([A-Za-z_][\w-]*)(?:\.[A-Za-z_][\w-]*)+(?:[\s.[]|$)/);
|
|
4303
3093
|
if (headMatch) {
|
|
4304
3094
|
const head = headMatch[1];
|
|
@@ -4308,44 +3098,24 @@ function evalCondition(expr, ctx, playbook) {
|
|
|
4308
3098
|
return kind === 'all' ? arr.length > 0 && arr.every(test) : arr.some(test);
|
|
4309
3099
|
}
|
|
4310
3100
|
}
|
|
4311
|
-
//
|
|
4312
|
-
//
|
|
4313
|
-
//
|
|
4314
|
-
// truthy" (`all`), i.e. a non-emptiness / existence test. sbom.json's
|
|
4315
|
-
// `any actively_exploited_match …` EU CRA Art.14 (24h) notify_legal
|
|
4316
|
-
// escalation is the canonical case. Without this, the inner bare token has
|
|
4317
|
-
// no operator, so no comparison branch parses it and it falls through to
|
|
4318
|
-
// condition_unparsed → false, silently disabling the escalation even when
|
|
4319
|
-
// the array holds entries. Match ONLY a pure dotted-path token (no operator,
|
|
4320
|
-
// keyword, bracket, or space); anything with a comparison/keyword still
|
|
4321
|
-
// routes to the prose fall-through below so a genuinely malformed inner
|
|
4322
|
-
// clause stays observable.
|
|
3101
|
+
// `any <path>` with no operator quantifies over the collection itself. The
|
|
3102
|
+
// match is a pure dotted-path token, so a malformed inner clause falls
|
|
3103
|
+
// through to the diagnostic instead of reading as an existence test.
|
|
4323
3104
|
if (/^[A-Za-z_][\w-]*(?:\.[A-Za-z_][\w-]*)*$/.test(inner)) {
|
|
4324
3105
|
const v = resolvePath(ctx, inner);
|
|
4325
3106
|
if (Array.isArray(v)) {
|
|
4326
3107
|
return kind === 'all' ? v.length > 0 && v.every(Boolean) : v.length > 0;
|
|
4327
3108
|
}
|
|
4328
|
-
// Non-array scalar: existence/truthiness (null/undefined/0/'' → false).
|
|
4329
3109
|
return !!v;
|
|
4330
3110
|
}
|
|
4331
|
-
//
|
|
4332
|
-
// bare inner comparison. If it still doesn't parse it falls through to the
|
|
4333
|
-
// condition_unparsed diagnostic below, so a genuinely malformed clause stays
|
|
4334
|
-
// observable rather than silently passing.
|
|
3111
|
+
// The quantifier was prose: evaluate the bare inner comparison.
|
|
4335
3112
|
return evalCondition(inner, ctx, playbook);
|
|
4336
3113
|
}
|
|
4337
3114
|
|
|
4338
|
-
//
|
|
4339
|
-
//
|
|
4340
|
-
//
|
|
4341
|
-
//
|
|
4342
|
-
// silent `false` that disables whatever escalation / feeds_into the clause
|
|
4343
|
-
// gates with no signal. The clause PARSED, so condition_unparsed (a
|
|
4344
|
-
// parse-failure diagnostic) never fires. Surface a distinct, low-severity
|
|
4345
|
-
// condition_path_unresolved so an LHS-path typo is observable — deduped on the
|
|
4346
|
-
// condition string like condition_unparsed. A present-but-empty array (or any
|
|
4347
|
-
// present value that simply isn't a collection) is a LEGITIMATE false, not an
|
|
4348
|
-
// unresolved path, so it does NOT push: only a literally-absent path does.
|
|
3115
|
+
// A clause whose LHS path is absent from ctx parses fine and returns a silent
|
|
3116
|
+
// false, disabling what it gates without firing condition_unparsed. Only a
|
|
3117
|
+
// literally-absent path pushes: a present-but-empty array, or any present
|
|
3118
|
+
// non-collection, is a legitimate false.
|
|
4349
3119
|
const pushPathUnresolved = () => {
|
|
4350
3120
|
const target = (ctx && Array.isArray(ctx._runErrors)) ? ctx._runErrors
|
|
4351
3121
|
: (playbook && Array.isArray(playbook._runErrors)) ? playbook._runErrors
|
|
@@ -4356,71 +3126,49 @@ function evalCondition(expr, ctx, playbook) {
|
|
|
4356
3126
|
}
|
|
4357
3127
|
};
|
|
4358
3128
|
|
|
4359
|
-
// "rwep >= 90".
|
|
4360
|
-
//
|
|
4361
|
-
//
|
|
4362
|
-
// failed to match every hyphenated condition and fell through to `false`.
|
|
3129
|
+
// "rwep >= 90". Path tokens admit hyphens because signal and indicator ids
|
|
3130
|
+
// are canonically hyphenated across the catalog, and a `\w`-only token
|
|
3131
|
+
// matches none of them — every such condition falls silently to `false`.
|
|
4363
3132
|
let m = expr.match(/^([A-Za-z_][\w-]*(?:\.[A-Za-z_][\w-]*)*)\s*(>=|<=|==|=|<|>|!=)\s*(['"]?)([^'"]+)\3$/);
|
|
4364
3133
|
if (m) {
|
|
4365
3134
|
const [, lhs, op, quote, rhsRaw] = m;
|
|
4366
3135
|
const lv = resolvePath(ctx, lhs);
|
|
4367
|
-
//
|
|
4368
|
-
//
|
|
4369
|
-
//
|
|
4370
|
-
//
|
|
4371
|
-
// unchanged). Use `== null` (not strict undefined): resolvePath returns
|
|
4372
|
-
// `null` for a missing INTERMEDIATE parent and `undefined` for a missing
|
|
4373
|
-
// LEAF, so a strict-undefined gate would miss `analyze.classification` when
|
|
4374
|
-
// `analyze` itself is absent. Bare single-segment flags
|
|
4375
|
-
// (agent_has_filesystem_read, operator-submitted signals) are legitimately
|
|
4376
|
-
// absent and do NOT push. A present-but-null flag is a legitimate false:
|
|
4377
|
-
// single-segment, so the dot guard already excludes it. pushPathUnresolved
|
|
4378
|
-
// dedupes on the condition string and is per-kind capped, so it can't spam.
|
|
3136
|
+
// Diagnostics only; the boolean is unchanged. `== null`, not strict
|
|
3137
|
+
// undefined: resolvePath returns null for a missing intermediate parent and
|
|
3138
|
+
// undefined for a missing leaf. A bare single-segment flag is legitimately
|
|
3139
|
+
// absent and does not push.
|
|
4379
3140
|
if (lhs.includes('.') && lv == null) pushPathUnresolved();
|
|
4380
3141
|
let rv = rhsRaw;
|
|
4381
3142
|
if (quote) {
|
|
4382
|
-
//
|
|
3143
|
+
// A quoted literal stays as written.
|
|
4383
3144
|
} else if (rv === 'true') rv = true;
|
|
4384
3145
|
else if (rv === 'false') rv = false;
|
|
4385
3146
|
else if (!isNaN(parseFloat(rv)) && /^-?\d+(\.\d+)?$/.test(rv.trim())) rv = parseFloat(rv);
|
|
4386
3147
|
else if (/^[a-z_][\w.-]*$/i.test(rv.trim())) {
|
|
4387
|
-
//
|
|
4388
|
-
//
|
|
4389
|
-
// for literals like `theater` that aren't quoted).
|
|
3148
|
+
// An unquoted identifier is a context path, falling back to the raw
|
|
3149
|
+
// string when it does not resolve (an unquoted literal like `theater`).
|
|
4390
3150
|
const resolved = resolvePath(ctx, rv.trim());
|
|
4391
3151
|
if (resolved !== undefined && resolved !== null) rv = resolved;
|
|
4392
3152
|
}
|
|
4393
|
-
// Severity is an ordinal
|
|
4394
|
-
//
|
|
4395
|
-
// by rank so `severity >= 'high'` includes 'critical' (raw string `>=`
|
|
4396
|
-
// would exclude it: 'critical' < 'high' lexicographically).
|
|
3153
|
+
// Severity is an ordinal ladder, not a lexical one: with raw string `>=`,
|
|
3154
|
+
// `severity >= 'high'` EXCLUDES 'critical', because 'critical' < 'high'.
|
|
4397
3155
|
const SEV = { low: 0, medium: 1, high: 2, critical: 3 };
|
|
4398
3156
|
const lr = SEV[String(lv).toLowerCase()], rr = SEV[String(rv).toLowerCase()];
|
|
4399
3157
|
let a = (lr !== undefined && rr !== undefined) ? lr : lv;
|
|
4400
3158
|
let b = (lr !== undefined && rr !== undefined) ? rr : rv;
|
|
4401
3159
|
const isOrdering = op === '>=' || op === '<=' || op === '>' || op === '<';
|
|
4402
3160
|
if (isOrdering && (lr === undefined || rr === undefined)) {
|
|
4403
|
-
//
|
|
4404
|
-
//
|
|
4405
|
-
// `
|
|
4406
|
-
//
|
|
4407
|
-
// suffixed literal stays a string. A numeric LHS then compares against a
|
|
4408
|
-
// string RHS (`48 > '24h'` → `48 > NaN` → false: a 48h window silently
|
|
4409
|
-
// fails to escalate) and a string LHS compares lexicographically
|
|
4410
|
-
// (`'6h' > '24h'` → `'6' > '2'` → true: a 6h window WRONGLY escalates).
|
|
4411
|
-
// Normalize both sides to canonical hours when a duration unit appears on
|
|
4412
|
-
// either side: a unit-suffixed literal converts by its unit family; a
|
|
4413
|
-
// bare number is taken in the same family as the duration it is compared
|
|
4414
|
-
// against (hours-equivalent magnitude). The comparison is then numeric.
|
|
3161
|
+
// The catalog writes ordering comparisons against unit-suffixed duration
|
|
3162
|
+
// literals (`reboot_window > 24h`), which the RHS coercion above leaves as
|
|
3163
|
+
// strings: `48 > '24h'` becomes `48 > NaN`, and `'6h' > '24h'` compares as
|
|
3164
|
+
// `'6' > '2'`. Both sides normalize to hours instead.
|
|
4415
3165
|
const la = parseDurationHours(a), ba = parseDurationHours(b);
|
|
4416
3166
|
if ((la !== null || ba !== null) && la !== null && ba !== null) {
|
|
4417
3167
|
a = la; b = ba;
|
|
4418
3168
|
} else if (
|
|
4419
3169
|
// Two non-numeric, non-severity, non-duration strings under an ordering
|
|
4420
|
-
// operator
|
|
4421
|
-
//
|
|
4422
|
-
// condition_type_mismatch so the degraded comparison is observable
|
|
4423
|
-
// (the boolean result is unchanged; this is diagnostics only).
|
|
3170
|
+
// operator degrade to a lexicographic or NaN comparison. The clause
|
|
3171
|
+
// parsed, so only condition_type_mismatch makes that observable.
|
|
4424
3172
|
typeof a !== 'number' && typeof b !== 'number' &&
|
|
4425
3173
|
!(typeof a === 'string' && /^-?\d+(?:\.\d+)?$/.test(a.trim())) &&
|
|
4426
3174
|
!(typeof b === 'string' && /^-?\d+(?:\.\d+)?$/.test(b.trim()))
|
|
@@ -4444,22 +3192,10 @@ function evalCondition(expr, ctx, playbook) {
|
|
|
4444
3192
|
}
|
|
4445
3193
|
}
|
|
4446
3194
|
|
|
4447
|
-
// "scope.targets includes named_remote"
|
|
4448
|
-
//
|
|
4449
|
-
//
|
|
4450
|
-
//
|
|
4451
|
-
// `jurisdiction_obligations contains 'EU'` / `contains 'EU/EU CRA Art.14 24h'`
|
|
4452
|
-
// forms). When the array holds objects (jurisdiction_obligations are records
|
|
4453
|
-
// like `{ jurisdiction:'EU', regulation:'NIS2 Art.21', … }`), membership is
|
|
4454
|
-
// field-TARGETED: it matches an identity field of the obligation, not ANY
|
|
4455
|
-
// field value. An unscoped `Object.values(el).includes(member)` over-matched
|
|
4456
|
-
// — `contains 'EU'` would be satisfied by a non-jurisdiction field that
|
|
4457
|
-
// happened to equal 'EU' (e.g. a tag), and `contains 'detect_confirmed'`
|
|
4458
|
-
// would match the unrelated `clock_starts` field that holds that exact value
|
|
4459
|
-
// in the shipped obligations. Scoping to OBJECT_MEMBERSHIP_FIELDS keeps the
|
|
4460
|
-
// intended "the obligation is for jurisdiction X" semantic and prevents a
|
|
4461
|
-
// future field collision (or an operator-/agent-supplied obligations array)
|
|
4462
|
-
// from forcing a notify_legal escalation via a non-jurisdiction field.
|
|
3195
|
+
// "scope.targets includes named_remote". `contains` is a synonym the catalog
|
|
3196
|
+
// also writes, and the member may be bare or quoted. Against objects,
|
|
3197
|
+
// membership is scoped to OBJECT_MEMBERSHIP_FIELDS: an unscoped
|
|
3198
|
+
// `Object.values(el).includes(member)` over-matches.
|
|
4463
3199
|
m = expr.match(/^([A-Za-z_][\w-]*(?:\.[A-Za-z_][\w-]*)*)\s+(?:includes|contains)\s+(?:'([^']+)'|"([^"]+)"|([\w-]+))$/);
|
|
4464
3200
|
if (m) {
|
|
4465
3201
|
const member = m[2] !== undefined ? m[2] : (m[3] !== undefined ? m[3] : m[4]);
|
|
@@ -4475,25 +3211,11 @@ function evalCondition(expr, ctx, playbook) {
|
|
|
4475
3211
|
);
|
|
4476
3212
|
}
|
|
4477
3213
|
|
|
4478
|
-
// "matched_cve.attack_class IN ['kernel-lpe', 'rce']" —
|
|
4479
|
-
//
|
|
4480
|
-
//
|
|
4481
|
-
//
|
|
4482
|
-
//
|
|
4483
|
-
// quotes, so a quoted member that itself contains a comma (`'EU, US'`) would
|
|
4484
|
-
// be broken into two members (`EU` and `US`), neither of which equals the
|
|
4485
|
-
// author's intended whole member — the clause then evaluated false with no
|
|
4486
|
-
// diagnostic (the regex still matched the bracket, so condition_unparsed never
|
|
4487
|
-
// fired). splitInMembers walks the list tracking single/double quote state and
|
|
4488
|
-
// only splits on commas at quote-depth 0, then strips the surrounding quotes.
|
|
4489
|
-
// The CLOSING `]` is located quote-aware too: a `[^\]]*` capture stops at the
|
|
4490
|
-
// FIRST `]`, so a quoted member containing a literal `]` (`'a]b'`) truncated
|
|
4491
|
-
// the list early and left trailing text the `$` anchor couldn't match — the
|
|
4492
|
-
// whole clause then fell through to condition_unparsed for every input. The
|
|
4493
|
-
// matching bracket is now found at quote-depth 0 (an unquoted `]` is still the
|
|
4494
|
-
// terminator; a `]` inside a quoted member is part of that member). Trailing
|
|
4495
|
-
// text after the closing bracket, or an unterminated bracket, still fails to
|
|
4496
|
-
// match and surfaces as condition_unparsed.
|
|
3214
|
+
// "matched_cve.attack_class IN ['kernel-lpe', 'rce']" — a scalar LHS must be
|
|
3215
|
+
// in the list, an array LHS must intersect it. Both the member split and the
|
|
3216
|
+
// closing bracket are quote-aware: `split(',')` tears `'EU, US'` into members
|
|
3217
|
+
// that match nothing, and a `[^\]]*` capture truncates `'a]b'`. Either failure
|
|
3218
|
+
// evaluates false with no diagnostic, since the regex still matched.
|
|
4497
3219
|
m = expr.match(/^([A-Za-z_][\w-]*(?:\.[A-Za-z_][\w-]*)*)\s+IN\s+\[/);
|
|
4498
3220
|
if (m) {
|
|
4499
3221
|
const body = sliceInBracketBody(expr.slice(m[0].length));
|
|
@@ -4501,44 +3223,29 @@ function evalCondition(expr, ctx, playbook) {
|
|
|
4501
3223
|
const members = splitInMembers(body);
|
|
4502
3224
|
const lv = resolvePath(ctx, m[1]);
|
|
4503
3225
|
if (Array.isArray(lv)) return lv.some((x) => members.includes(String(x)));
|
|
4504
|
-
//
|
|
4505
|
-
//
|
|
4506
|
-
// mirroring the contains/includes branch. A present scalar that's simply not
|
|
4507
|
-
// in the member list is a legitimate false and pushes nothing.
|
|
3226
|
+
// An absent LHS path is a dead clause, as in the membership branch; a
|
|
3227
|
+
// present scalar simply not in the list is a legitimate false.
|
|
4508
3228
|
if (lv == null) { pushPathUnresolved(); return false; }
|
|
4509
3229
|
return members.includes(String(lv));
|
|
4510
3230
|
}
|
|
4511
|
-
//
|
|
4512
|
-
// to the condition_unparsed diagnostic so the malformed clause stays visible.
|
|
3231
|
+
// No closing bracket at quote-depth 0 — fall through to condition_unparsed.
|
|
4513
3232
|
}
|
|
4514
3233
|
|
|
4515
|
-
// "matched_cve.vector matches /regex/"
|
|
4516
|
-
//
|
|
4517
|
-
// The catalog authors both (mcp.json's feeds_into uses the quoted form), and a
|
|
4518
|
-
// delimiter-specific parser silently disabled whichever form it didn't match —
|
|
4519
|
-
// the same class as the hyphenated-token gap above.
|
|
3234
|
+
// "matched_cve.vector matches /regex/". The catalog authors the slash and the
|
|
3235
|
+
// quote form, and a one-delimiter parser silently disables the other.
|
|
4520
3236
|
m = expr.match(/^([A-Za-z_][\w-]*(?:\.[A-Za-z_][\w-]*)*)\s+matches\s+(?:\/(.+)\/|'([^']+)'|"([^"]+)")$/);
|
|
4521
3237
|
if (m) {
|
|
4522
3238
|
const pattern = m[2] !== undefined ? m[2] : (m[3] !== undefined ? m[3] : m[4]);
|
|
4523
3239
|
const val = resolvePath(ctx, m[1]);
|
|
4524
3240
|
if (typeof val !== 'string') return false;
|
|
4525
|
-
//
|
|
4526
|
-
//
|
|
4527
|
-
// Catch construction + test exceptions, return false, and push a
|
|
4528
|
-
// structured _regex_eval_error into ctx._runErrors (when present) so
|
|
4529
|
-
// analyze() can surface analyze.runtime_errors[] without losing the
|
|
4530
|
-
// diagnostic.
|
|
3241
|
+
// A regex with a syntax bug must not crash the engine mid-analyze: the
|
|
3242
|
+
// clause returns false and the failure reaches analyze.runtime_errors[].
|
|
4531
3243
|
try {
|
|
4532
3244
|
return new RegExp(pattern, 'i').test(val); // allow:dynamic-regex — pattern comes from an Ed25519-signed catalog playbook condition (/…/ or '…'), so it cannot be attacker-controlled without breaking the signature; the try/catch covers construction-time syntax errors only (it does NOT defend against catastrophic backtracking — do not reuse this shape for operator-supplied patterns)
|
|
4533
3245
|
} catch (e) {
|
|
4534
3246
|
const errorRec = { _regex_eval_error: { source: m[1], expr: pattern, message: e && e.message ? String(e.message) : String(e) } };
|
|
4535
|
-
//
|
|
4536
|
-
//
|
|
4537
|
-
// form; fall back to ctx.
|
|
4538
|
-
// Tag with a `kind` so pushRunError can apply per-kind cap + dedupe
|
|
4539
|
-
// (same source+expr regex error firing N times per playbook would
|
|
4540
|
-
// otherwise spam runtime_errors). The original `_regex_eval_error`
|
|
4541
|
-
// payload is preserved for backward compatibility.
|
|
3247
|
+
// The `kind` tag is what lets pushRunError cap and dedupe: one bad regex
|
|
3248
|
+
// otherwise fires once per evaluated element.
|
|
4542
3249
|
const taggedErr = { kind: 'regex_eval_error', ..._regexErrorPayload(errorRec) };
|
|
4543
3250
|
const target = (ctx && Array.isArray(ctx._runErrors)) ? ctx._runErrors
|
|
4544
3251
|
: (playbook && Array.isArray(playbook._runErrors)) ? playbook._runErrors
|
|
@@ -4552,11 +3259,9 @@ function evalCondition(expr, ctx, playbook) {
|
|
|
4552
3259
|
}
|
|
4553
3260
|
}
|
|
4554
3261
|
|
|
4555
|
-
//
|
|
4556
|
-
//
|
|
4557
|
-
//
|
|
4558
|
-
// condition is observable instead of an invisible `false` (this is how an
|
|
4559
|
-
// authoring typo or an unimplemented operator gets caught at runtime).
|
|
3262
|
+
// An unparseable condition is a DEAD condition: false for every input,
|
|
3263
|
+
// silently disabling what it gates. The runtime_error is how an authoring
|
|
3264
|
+
// typo or an unimplemented operator gets caught at all.
|
|
4560
3265
|
if (process.env.EXCEPTD_DEBUG) console.warn(`[runner] unknown condition: ${expr}`);
|
|
4561
3266
|
{
|
|
4562
3267
|
const target = (ctx && Array.isArray(ctx._runErrors)) ? ctx._runErrors
|
|
@@ -4574,15 +3279,9 @@ function resolvePath(obj, dot) {
|
|
|
4574
3279
|
return dot.split('.').reduce((acc, k) => acc == null ? null : acc[k], obj);
|
|
4575
3280
|
}
|
|
4576
3281
|
|
|
4577
|
-
|
|
4578
|
-
|
|
4579
|
-
|
|
4580
|
-
* its unit family, OR a bare number / numeric string (returned as its own
|
|
4581
|
-
* magnitude — the catalog writes `reboot_window > 24h` where the LHS resolves to
|
|
4582
|
-
* a bare hour count). Returns null for anything that is not a recognized
|
|
4583
|
-
* duration or plain number, so the caller can detect that BOTH sides normalized
|
|
4584
|
-
* before comparing numerically (and surface a type-mismatch otherwise).
|
|
4585
|
-
*/
|
|
3282
|
+
// Normalize a duration operand to hours: a unit-suffixed literal by its unit, a
|
|
3283
|
+
// bare number as its own magnitude. Null for anything else, so the caller can
|
|
3284
|
+
// require BOTH sides to normalize before comparing.
|
|
4586
3285
|
const DURATION_UNIT_HOURS = {
|
|
4587
3286
|
h: 1, hr: 1, hrs: 1,
|
|
4588
3287
|
m: 1 / 60, min: 1 / 60,
|
|
@@ -4593,7 +3292,6 @@ function parseDurationHours(v) {
|
|
|
4593
3292
|
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
|
|
4594
3293
|
if (typeof v !== 'string') return null;
|
|
4595
3294
|
const s = v.trim();
|
|
4596
|
-
// Bare numeric string (no unit) — take its magnitude as-is.
|
|
4597
3295
|
if (/^-?\d+(?:\.\d+)?$/.test(s)) return parseFloat(s);
|
|
4598
3296
|
const m = s.match(/^(\d+(?:\.\d+)?)\s*(h|hr|hrs|d|day|days|wk|w|m|min)$/i);
|
|
4599
3297
|
if (!m) return null;
|
|
@@ -4601,26 +3299,17 @@ function parseDurationHours(v) {
|
|
|
4601
3299
|
return mult === undefined ? null : parseFloat(m[1]) * mult;
|
|
4602
3300
|
}
|
|
4603
3301
|
|
|
4604
|
-
|
|
4605
|
-
|
|
4606
|
-
* surrounding spaces) that are at parenthesis depth 0. Returns the (trimmed)
|
|
4607
|
-
* sub-expression list. Used by evalCondition so `A OR (B AND C)` splits into
|
|
4608
|
-
* [`A`, `(B AND C)`] on OR, instead of naively splitting at the inner AND.
|
|
4609
|
-
*/
|
|
3302
|
+
// Split `expr` at each ` <sep> ` sitting at parenthesis depth 0, so
|
|
3303
|
+
// `A OR (B AND C)` splits into [`A`, `(B AND C)`] rather than at the inner AND.
|
|
4610
3304
|
function splitAtTopLevel(expr, sep) {
|
|
4611
3305
|
const parts = [];
|
|
4612
3306
|
const needle = ' ' + sep + ' ';
|
|
4613
3307
|
let depth = 0, buf = '', i = 0, quote = null;
|
|
4614
3308
|
while (i < expr.length) {
|
|
4615
3309
|
const ch = expr[i];
|
|
4616
|
-
// Inside a quoted
|
|
4617
|
-
//
|
|
4618
|
-
//
|
|
4619
|
-
// a real top-level OR/AND would never split at depth 0 (silently disabling
|
|
4620
|
-
// the disjunct/conjunct), and a member like `contains 'EU AND US'` would be
|
|
4621
|
-
// torn at the inner ` AND ` as if it were an operator. Track quote state and
|
|
4622
|
-
// skip both while inside a quote. An unescaped matching quote closes the
|
|
4623
|
-
// literal; `\'`/`\"` stay in. Mirrors splitInMembers' quote-aware walk.
|
|
3310
|
+
// Inside a quoted literal, parens and the needle are text, not structure:
|
|
3311
|
+
// `matches 'foo('` counted blindly leaves depth 1 so no later top-level
|
|
3312
|
+
// OR/AND ever splits, and `contains 'EU AND US'` is torn at its inner ` AND `.
|
|
4624
3313
|
if (quote) {
|
|
4625
3314
|
if (ch === '\\' && i + 1 < expr.length) { buf += ch + expr[i + 1]; i += 2; continue; }
|
|
4626
3315
|
if (ch === quote) quote = null;
|
|
@@ -4642,16 +3331,9 @@ function splitAtTopLevel(expr, sep) {
|
|
|
4642
3331
|
return parts;
|
|
4643
3332
|
}
|
|
4644
3333
|
|
|
4645
|
-
|
|
4646
|
-
|
|
4647
|
-
|
|
4648
|
-
* trims each member. A naive `.split(',')` is quote-unaware, so a quoted member
|
|
4649
|
-
* that contains a comma (`'EU, US'`) would be torn into two members; this walks
|
|
4650
|
-
* the string tracking single/double quote state so a comma inside a quoted run
|
|
4651
|
-
* is treated as a literal part of that member. Bare (unquoted) members are
|
|
4652
|
-
* supported too — the catalog authors both forms (`'ai-c2', 'prompt-injection'`
|
|
4653
|
-
* and bare `kernel-lpe`). Empty members (e.g. a trailing comma) are dropped.
|
|
4654
|
-
*/
|
|
3334
|
+
// Split an `IN [...]` member list on commas outside any quoted run, stripping
|
|
3335
|
+
// the quotes; a comma inside a quoted member (`'EU, US'`) belongs to it. Bare
|
|
3336
|
+
// and quoted members both parse, and empty members are dropped.
|
|
4655
3337
|
function splitInMembers(listStr) {
|
|
4656
3338
|
const out = [];
|
|
4657
3339
|
let buf = '';
|
|
@@ -4671,20 +3353,10 @@ function splitInMembers(listStr) {
|
|
|
4671
3353
|
return out.filter((s) => s.length);
|
|
4672
3354
|
}
|
|
4673
3355
|
|
|
4674
|
-
|
|
4675
|
-
|
|
4676
|
-
|
|
4677
|
-
|
|
4678
|
-
* a `]` inside a single/double-quoted member (`'a]b'`) is part of the member, not
|
|
4679
|
-
* the terminator. A `[^\]]*` regex capture instead stops at the FIRST `]`, so a
|
|
4680
|
-
* quoted `]` truncated the list and left trailing text the `$` anchor rejected —
|
|
4681
|
-
* the clause then fell through to condition_unparsed for every input.
|
|
4682
|
-
*
|
|
4683
|
-
* Returns the body string on success, or `null` when the list is malformed:
|
|
4684
|
-
* unterminated (no quote-depth-0 `]`) or carrying non-whitespace text after the
|
|
4685
|
-
* closing bracket. `null` lets the caller fall through to the condition_unparsed
|
|
4686
|
-
* diagnostic so a malformed clause stays observable rather than passing silently.
|
|
4687
|
-
*/
|
|
3356
|
+
// The body of an `IN [...]` list, given the text after the opening bracket. The
|
|
3357
|
+
// terminator is the first `]` at quote-depth 0; one inside a quoted member
|
|
3358
|
+
// (`'a]b'`) belongs to the member. Null when the list is unterminated or carries
|
|
3359
|
+
// trailing text, so the caller falls through to condition_unparsed.
|
|
4688
3360
|
function sliceInBracketBody(rest) {
|
|
4689
3361
|
let quote = null;
|
|
4690
3362
|
for (let i = 0; i < rest.length; i++) {
|
|
@@ -4692,25 +3364,18 @@ function sliceInBracketBody(rest) {
|
|
|
4692
3364
|
if (quote) { if (ch === quote) quote = null; continue; }
|
|
4693
3365
|
if (ch === "'" || ch === '"') { quote = ch; continue; }
|
|
4694
3366
|
if (ch === ']') {
|
|
4695
|
-
//
|
|
4696
|
-
//
|
|
4697
|
-
// trailing text) is malformed for this leaf parser.
|
|
3367
|
+
// Only whitespace may follow: the bracket is the final structural token
|
|
3368
|
+
// for this leaf parser.
|
|
4698
3369
|
return rest.slice(i + 1).trim() === '' ? rest.slice(0, i) : null;
|
|
4699
3370
|
}
|
|
4700
3371
|
}
|
|
4701
|
-
return null; //
|
|
3372
|
+
return null; // unterminated list
|
|
4702
3373
|
}
|
|
4703
3374
|
|
|
4704
|
-
|
|
4705
|
-
|
|
4706
|
-
|
|
4707
|
-
|
|
4708
|
-
* paren inside a quoted string literal (e.g. a regex member `matches '(a|b)'` or
|
|
4709
|
-
* an unbalanced `matches 'foo('`) is literal text, not grouping structure, so it
|
|
4710
|
-
* must not move the depth counter — otherwise a quoted `)` could make the outer
|
|
4711
|
-
* pair look unbalanced (skipping a legitimate strip) or an unbalanced quoted `(`
|
|
4712
|
-
* could make a non-wrapping pair look outer-spanning (stripping wrongly).
|
|
4713
|
-
*/
|
|
3375
|
+
// Peel one balanced pair of outer parens, only when the first and last
|
|
3376
|
+
// characters match at the same depth boundary: `((A AND B))` loses a layer,
|
|
3377
|
+
// `(A) AND (B)` keeps both. The depth scan skips quoted literals — counting a
|
|
3378
|
+
// paren in `matches '(a|b)'` misreads which pair, if any, is the outer one.
|
|
4714
3379
|
function stripOuterParens(expr) {
|
|
4715
3380
|
while (expr.length >= 2 && expr[0] === '(' && expr[expr.length - 1] === ')') {
|
|
4716
3381
|
let depth = 0;
|
|
@@ -4734,28 +3399,12 @@ function stripOuterParens(expr) {
|
|
|
4734
3399
|
return expr;
|
|
4735
3400
|
}
|
|
4736
3401
|
|
|
4737
|
-
// Parse
|
|
4738
|
-
//
|
|
4739
|
-
//
|
|
4740
|
-
//
|
|
4741
|
-
//
|
|
4742
|
-
//
|
|
4743
|
-
// .toISOString() on it later throws RangeError, which crashes the
|
|
4744
|
-
// deadline math in close() and propagates uncaught out of run(),
|
|
4745
|
-
// destroying the entire phase-7 notification/CSAF/deadline output. We
|
|
4746
|
-
// return { date: null } so the caller routes to the pending-clock branch
|
|
4747
|
-
// instead of crashing.
|
|
4748
|
-
//
|
|
4749
|
-
// 2. Zone-less ISO ('2026-06-12T10:00:00' or '2026-06-12 10:00:00'). new
|
|
4750
|
-
// Date() interprets a designator-less datetime in the HOST timezone, so
|
|
4751
|
-
// the published statutory deadline shifts by the host's UTC offset — a
|
|
4752
|
-
// 4h DORA window computed on a UTC-7 host lands 7h late. We normalize the
|
|
4753
|
-
// space separator to 'T' and append 'Z' so a zone-less value is read as
|
|
4754
|
-
// UTC deterministically on every host, and flag assumed_utc so the caller
|
|
4755
|
-
// can surface that the zone was assumed.
|
|
4756
|
-
//
|
|
4757
|
-
// Returns { date, assumed_utc } where date is a valid Date or null, and
|
|
4758
|
-
// assumed_utc is true only when a zone-less value was coerced to UTC.
|
|
3402
|
+
// Parse a clock_started_at_<event> timestamp host-timezone-independently,
|
|
3403
|
+
// returning { date, assumed_utc } with a null date on rejection. A bare
|
|
3404
|
+
// `new Date(s)` fails twice here: an unparseable value yields an Invalid Date
|
|
3405
|
+
// whose .toISOString() throws later, crashing the deadline math in close(); and
|
|
3406
|
+
// a zone-less ISO value is read in the HOST timezone, shifting a statutory
|
|
3407
|
+
// deadline by the host's UTC offset. Such a value is coerced to UTC.
|
|
4759
3408
|
function parseOperatorClock(raw) {
|
|
4760
3409
|
if (typeof raw !== 'string') {
|
|
4761
3410
|
const d = new Date(raw);
|
|
@@ -4763,10 +3412,8 @@ function parseOperatorClock(raw) {
|
|
|
4763
3412
|
}
|
|
4764
3413
|
const trimmed = raw.trim();
|
|
4765
3414
|
if (!trimmed) return { date: null, assumed_utc: false };
|
|
4766
|
-
// A full ISO datetime
|
|
4767
|
-
//
|
|
4768
|
-
// trailing 'Z' / [+-]HH:MM offset. Date-only values (YYYY-MM-DD) are already
|
|
4769
|
-
// parsed as UTC midnight by spec, so they are left untouched.
|
|
3415
|
+
// A full ISO datetime missing only its zone designator. A date-only value is
|
|
3416
|
+
// already parsed as UTC midnight by spec and is left alone.
|
|
4770
3417
|
let assumedUtc = false;
|
|
4771
3418
|
let candidate = trimmed;
|
|
4772
3419
|
const zonelessDateTime = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?$/;
|
|
@@ -4779,45 +3426,17 @@ function parseOperatorClock(raw) {
|
|
|
4779
3426
|
return { date: d, assumed_utc: assumedUtc };
|
|
4780
3427
|
}
|
|
4781
3428
|
|
|
4782
|
-
|
|
4783
|
-
|
|
4784
|
-
|
|
4785
|
-
|
|
4786
|
-
|
|
4787
|
-
|
|
4788
|
-
* from OPERATOR AWARENESS — not from the moment the engine emits a
|
|
4789
|
-
* `detected` classification. Auto-stamping Date.now() on detect_confirmed
|
|
4790
|
-
* whenever the engine classifies as detected would be incorrect: the
|
|
4791
|
-
* operator may not have seen the result yet. Semantics:
|
|
4792
|
-
*
|
|
4793
|
-
* - If the agent explicitly submits clock_started_at_<event>: use it,
|
|
4794
|
-
* after validating it parses and normalizing a zone-less value to UTC.
|
|
4795
|
-
* An unparseable value returns null (clock stays pending) and surfaces
|
|
4796
|
-
* a runtime error naming the offending key, instead of crashing close().
|
|
4797
|
-
* - Otherwise, for 'detect_confirmed' with classification='detected', and
|
|
4798
|
-
* for 'analyze_complete' / 'validate_complete' once their phase has run:
|
|
4799
|
-
* stamp `now` ONLY if runOpts.operator_consent?.explicit === true
|
|
4800
|
-
* (i.e. the operator passed --ack). The analyze/validate phases provably
|
|
4801
|
-
* complete inside the same synchronous run before close() computes these
|
|
4802
|
-
* clocks, so under --ack there is no operator-awareness gap. Without
|
|
4803
|
-
* --ack, return null and the caller (close()) surfaces clock_pending_ack:
|
|
4804
|
-
* true on the notification_actions entry so the operator sees that the
|
|
4805
|
-
* clock is waiting on acknowledgement.
|
|
4806
|
-
* - 'manual' and any other event without an explicit timestamp: return null.
|
|
4807
|
-
*
|
|
4808
|
-
* `phaseFlags` carries which engine phases completed in this run
|
|
4809
|
-
* ({ analyze_complete, validate_complete }) so the auto-stamp for those two
|
|
4810
|
-
* events only fires when the named phase actually ran.
|
|
4811
|
-
*/
|
|
3429
|
+
// The start instant for a jurisdictional clock event. The legal contract is that
|
|
3430
|
+
// the clock starts from OPERATOR AWARENESS, not from the moment the engine emits
|
|
3431
|
+
// a `detected` classification. An explicitly submitted clock_started_at_<event>
|
|
3432
|
+
// wins. detect_confirmed on a detected classification, and analyze_complete /
|
|
3433
|
+
// validate_complete once phaseFlags says their phase ran, auto-stamp `now` ONLY
|
|
3434
|
+
// under runOpts.operator_consent.explicit (--ack). Everything else is null.
|
|
4812
3435
|
function computeClockStart(eventName, agentSignals, runOpts = {}, engineClassification = null, phaseFlags = {}, frozenEpoch = null) {
|
|
4813
|
-
// The agent submits clock_started_at_<event> ISO strings as it progresses.
|
|
4814
3436
|
const key = `clock_started_at_${eventName}`;
|
|
4815
3437
|
if (agentSignals && agentSignals[key]) {
|
|
4816
3438
|
const { date, assumed_utc } = parseOperatorClock(agentSignals[key]);
|
|
4817
3439
|
if (!date) {
|
|
4818
|
-
// A present-but-unparseable timestamp must not crash the close phase via
|
|
4819
|
-
// a downstream new Date(NaN).toISOString(). Null routes to the pending
|
|
4820
|
-
// branch; the runtime error tells the operator which field was bad.
|
|
4821
3440
|
if (runOpts && Array.isArray(runOpts._runErrors)) {
|
|
4822
3441
|
pushRunError(runOpts._runErrors, {
|
|
4823
3442
|
kind: 'invalid_clock_value',
|
|
@@ -4840,26 +3459,15 @@ function computeClockStart(eventName, agentSignals, runOpts = {}, engineClassifi
|
|
|
4840
3459
|
}
|
|
4841
3460
|
return date;
|
|
4842
3461
|
}
|
|
4843
|
-
//
|
|
4844
|
-
//
|
|
4845
|
-
//
|
|
4846
|
-
// honored, so an engine-confirmed detection (indicators fired from
|
|
4847
|
-
// signal_overrides without a separate classification submission) never
|
|
4848
|
-
// started the regulatory clock — notification deadlines silently stalled.
|
|
3462
|
+
// Confirmed from EITHER a submitted detection_classification or the engine's
|
|
3463
|
+
// own verdict: indicators fired through signal_overrides carry no separate
|
|
3464
|
+
// classification, and their regulatory clock would stall silently.
|
|
4849
3465
|
const detected = agentSignals?.detection_classification === 'detected'
|
|
4850
3466
|
|| engineClassification === 'detected';
|
|
4851
3467
|
const ack = !!(runOpts && runOpts.operator_consent && runOpts.operator_consent.explicit === true);
|
|
4852
3468
|
if (!ack) return null;
|
|
4853
|
-
//
|
|
4854
|
-
//
|
|
4855
|
-
// pass — the event literally names a phase that completed synchronously
|
|
4856
|
-
// before close(), so --ack closes the awareness gap exactly as it does for
|
|
4857
|
-
// detect_confirmed.
|
|
4858
|
-
// Deterministic bundle mode roots every auto-started clock in the single
|
|
4859
|
-
// frozen epoch so two runs over the same evidence emit identical
|
|
4860
|
-
// clock_started_at and deadline values; otherwise the clock starts at
|
|
4861
|
-
// wall-clock now. Operator-supplied clock timestamps (handled above) are
|
|
4862
|
-
// never overridden — they are the explicit input.
|
|
3469
|
+
// Deterministic mode roots every auto-started clock in the frozen epoch, so
|
|
3470
|
+
// two runs over the same evidence agree.
|
|
4863
3471
|
const autoNow = () => (frozenEpoch ? new Date(frozenEpoch) : new Date());
|
|
4864
3472
|
if (eventName === 'detect_confirmed' && detected) return autoNow();
|
|
4865
3473
|
if (eventName === 'analyze_complete' && phaseFlags && phaseFlags.analyze_complete === true) return autoNow();
|
|
@@ -4867,24 +3475,17 @@ function computeClockStart(eventName, agentSignals, runOpts = {}, engineClassifi
|
|
|
4867
3475
|
return null;
|
|
4868
3476
|
}
|
|
4869
3477
|
|
|
3478
|
+
// The agentSignals lookup key for a precondition expression: the leading path
|
|
3479
|
+
// token, hyphens included, since a hyphenated id is the catalog norm.
|
|
4870
3480
|
function expressionKey(expr) {
|
|
4871
|
-
// For agentSignals precondition lookups — strip operators/values to leave key.
|
|
4872
|
-
// Admits hyphens so a hyphenated precondition id (the catalog norm) resolves
|
|
4873
|
-
// to the full key instead of being truncated at the first hyphen.
|
|
4874
3481
|
const m = expr.match(/^([A-Za-z_][\w-]*(?:\.[A-Za-z_][\w-]*)*)/);
|
|
4875
3482
|
return m ? m[1] : expr;
|
|
4876
3483
|
}
|
|
4877
3484
|
|
|
4878
|
-
|
|
4879
|
-
|
|
4880
|
-
|
|
4881
|
-
|
|
4882
|
-
* the raw template — a visible failure that operators wouldn't catch
|
|
4883
|
-
* before sending. Now: render as `<MISSING:${var}>` so the failure mode
|
|
4884
|
-
* is loud, AND if a tracker array is passed as the third argument,
|
|
4885
|
-
* collect the missing keys for caller surfacing as
|
|
4886
|
-
* missing_interpolation_vars[].
|
|
4887
|
-
*/
|
|
3485
|
+
// Substitute ${var} placeholders against ctx. An unresolved key renders as
|
|
3486
|
+
// `<MISSING:key>`, never the literal `${key}` — a notification draft otherwise
|
|
3487
|
+
// reaches a regulator with a raw template in it. missingTracker, when an array,
|
|
3488
|
+
// collects the keys for missing_interpolation_vars[].
|
|
4888
3489
|
function interpolate(tpl, ctx, missingTracker) {
|
|
4889
3490
|
if (!tpl || typeof tpl !== 'string') return tpl;
|
|
4890
3491
|
return tpl.replace(/\$\{(\w+)\}/g, (_, key) => {
|
|
@@ -4897,8 +3498,7 @@ function interpolate(tpl, ctx, missingTracker) {
|
|
|
4897
3498
|
});
|
|
4898
3499
|
}
|
|
4899
3500
|
|
|
4900
|
-
//
|
|
4901
|
-
|
|
3501
|
+
// Pre-run discovery: every directive across every playbook.
|
|
4902
3502
|
function plan(opts = {}) {
|
|
4903
3503
|
const ids = opts.playbookIds || listPlaybooks();
|
|
4904
3504
|
return {
|
|
@@ -4919,8 +3519,7 @@ function plan(opts = {}) {
|
|
|
4919
3519
|
directives: pb.directives.map(d => {
|
|
4920
3520
|
const overrideDirect = d.phase_overrides?.direct || {};
|
|
4921
3521
|
const threatContext = overrideDirect.threat_context || baseDirect.threat_context || null;
|
|
4922
|
-
//
|
|
4923
|
-
// Operators picking a directive need operator-facing prose.
|
|
3522
|
+
// An operator picking a directive needs operator-facing prose.
|
|
4924
3523
|
const desc = d.description
|
|
4925
3524
|
|| (threatContext ? (threatContext.split(/(?<=[.!?])\s+/)[0] || "").slice(0, 240) : null)
|
|
4926
3525
|
|| pb.domain?.name
|
|
@@ -4948,9 +3547,8 @@ module.exports = {
|
|
|
4948
3547
|
vexFilterFromDoc,
|
|
4949
3548
|
normalizeSubmission,
|
|
4950
3549
|
autoDetectPreconditions,
|
|
4951
|
-
// Exported so library-side
|
|
4952
|
-
//
|
|
4953
|
-
// subprocess.
|
|
3550
|
+
// Exported so the library-side path the CLI guard cannot reach is testable
|
|
3551
|
+
// without spawning a subprocess.
|
|
4954
3552
|
sanitizeOperatorText,
|
|
4955
3553
|
// internal helpers exposed for tests
|
|
4956
3554
|
_resolvedPhase: resolvedPhase,
|
|
@@ -4967,7 +3565,7 @@ module.exports = {
|
|
|
4967
3565
|
_advisoryAuthorityFor: advisoryAuthorityFor,
|
|
4968
3566
|
_computeClockStart: computeClockStart,
|
|
4969
3567
|
_worstActiveExploitation: worstActiveExploitation,
|
|
4970
|
-
// Re-exported from scoring so parity between the catalog
|
|
4971
|
-
//
|
|
3568
|
+
// Re-exported from scoring so a test can enforce parity between the catalog
|
|
3569
|
+
// scorer and the runtime evaluator at the seam.
|
|
4972
3570
|
_activeExploitationLadder: scoring.ACTIVE_EXPLOITATION_LADDER,
|
|
4973
3571
|
};
|