@blamejs/exceptd-skills 0.12.16 → 0.12.20
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 +174 -31
- package/README.md +1 -1
- package/bin/exceptd.js +378 -50
- package/data/_indexes/_meta.json +2 -2
- package/data/playbooks/ai-api.json +26 -5
- package/data/playbooks/containers.json +23 -4
- package/data/playbooks/cred-stores.json +18 -3
- package/data/playbooks/crypto-codebase.json +18 -3
- package/data/playbooks/crypto.json +12 -2
- package/data/playbooks/framework.json +15 -3
- package/data/playbooks/hardening.json +21 -4
- package/data/playbooks/kernel.json +10 -2
- package/data/playbooks/mcp.json +15 -3
- package/data/playbooks/runtime.json +17 -3
- package/data/playbooks/sbom.json +16 -3
- package/data/playbooks/secrets.json +30 -5
- package/lib/auto-discovery.js +96 -10
- package/lib/playbook-runner.js +188 -32
- package/lib/prefetch.js +62 -15
- package/lib/refresh-external.js +27 -0
- package/lib/refresh-network.js +91 -3
- package/lib/schemas/playbook.schema.json +7 -1
- package/lib/sign.js +171 -2
- package/lib/verify.js +171 -2
- package/manifest-snapshot.json +1 -1
- package/manifest-snapshot.sha256 +1 -1
- package/manifest.json +44 -40
- package/orchestrator/scheduler.js +10 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
- package/scripts/predeploy.js +5 -0
- package/scripts/verify-shipped-tarball.js +89 -0
package/lib/auto-discovery.js
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
|
|
30
30
|
const fs = require("fs");
|
|
31
31
|
const path = require("path");
|
|
32
|
-
const { scoreCustom } = require("./scoring");
|
|
32
|
+
const { scoreCustom, RWEP_WEIGHTS, ACTIVE_EXPLOITATION_LADDER } = require("./scoring");
|
|
33
33
|
|
|
34
34
|
// audit M P1-C: stored rwep_factors must reproduce the stored rwep_score.
|
|
35
35
|
// `buildScoringInputs` is the single source of truth for both — it captures
|
|
@@ -56,6 +56,70 @@ function buildScoringInputs(kevEntry /*, nvdPayload */) {
|
|
|
56
56
|
};
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
// audit X P1: cve-catalog.schema.json's `rwep_factors` requires the post-weight
|
|
60
|
+
// numeric shape (cisa_kev: 0|25, poc_available: 0|20, ai_factor: 0|15,
|
|
61
|
+
// active_exploitation: 0|5|10|20, blast_radius: 0..30, patch_available: 0|-15,
|
|
62
|
+
// live_patch_available: 0|-10, reboot_required: 0|5). Pre-fix the auto-discovery
|
|
63
|
+
// builder stored the SHAPE-A (boolean + string-ladder) factor bag — semantically
|
|
64
|
+
// fine because deriveRwepFromFactors handles either shape, but the strict
|
|
65
|
+
// JSON-schema validator (and any downstream tooling that types-checks the
|
|
66
|
+
// catalog field) rejected drafts as malformed. Convert the boolean inputs to
|
|
67
|
+
// the schema-required post-weight shape so the curate-apply gate (which loads
|
|
68
|
+
// the strict schema) doesn't reject KEV-discovered drafts post-promotion.
|
|
69
|
+
//
|
|
70
|
+
// `ai_factor` is the schema-required key name; auto-discovery's boolean
|
|
71
|
+
// inputs are split across `ai_discovered` + `ai_assisted_weapon`, mirroring
|
|
72
|
+
// scoreCustom's contract. The factor fires when either flag is true (same as
|
|
73
|
+
// scoreCustom).
|
|
74
|
+
function toPostWeightFactors(inputs) {
|
|
75
|
+
const aeMultiplier = ACTIVE_EXPLOITATION_LADDER[inputs.active_exploitation] ?? 0;
|
|
76
|
+
const reboot = (inputs.reboot_required === true) || (inputs.patch_required_reboot === true);
|
|
77
|
+
const blastRaw = Number.isFinite(Number(inputs.blast_radius)) ? Number(inputs.blast_radius) : 0;
|
|
78
|
+
const blastClamped = Math.max(0, Math.min(RWEP_WEIGHTS.blast_radius, blastRaw));
|
|
79
|
+
return {
|
|
80
|
+
cisa_kev: inputs.cisa_kev ? RWEP_WEIGHTS.cisa_kev : 0,
|
|
81
|
+
poc_available: inputs.poc_available ? RWEP_WEIGHTS.poc_available : 0,
|
|
82
|
+
ai_factor: (inputs.ai_assisted_weapon || inputs.ai_discovered) ? RWEP_WEIGHTS.ai_factor : 0,
|
|
83
|
+
active_exploitation: RWEP_WEIGHTS.active_exploitation * aeMultiplier,
|
|
84
|
+
blast_radius: blastClamped,
|
|
85
|
+
patch_available: inputs.patch_available ? RWEP_WEIGHTS.patch_available : 0,
|
|
86
|
+
live_patch_available: inputs.live_patch_available ? RWEP_WEIGHTS.live_patch_available : 0,
|
|
87
|
+
reboot_required: reboot ? RWEP_WEIGHTS.reboot_required : 0,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Audit M P3-O — diff severity nuance for KEV-discovered drafts.
|
|
93
|
+
*
|
|
94
|
+
* Pre-fix every KEV-derived diff carried `severity: "high"`. Operators
|
|
95
|
+
* scanning the diff stream had no way to distinguish "patch in 21 days"
|
|
96
|
+
* from "active ransomware campaign, patch yesterday." Now:
|
|
97
|
+
*
|
|
98
|
+
* - ransomware_use === "Known" → "critical" (campaigns observed in the wild)
|
|
99
|
+
* - dueDate within 7 days of now → "critical" (CISA escalation window)
|
|
100
|
+
* - otherwise → "high" (still actively exploited per KEV listing)
|
|
101
|
+
*
|
|
102
|
+
* A KEV listing inherently means active exploitation; "low" / "medium"
|
|
103
|
+
* never apply here. The split is between "act today" and "act this sprint."
|
|
104
|
+
*
|
|
105
|
+
* @param {object} kevEntry
|
|
106
|
+
* @returns {"critical" | "high"}
|
|
107
|
+
*/
|
|
108
|
+
function deriveKevSeverity(kevEntry) {
|
|
109
|
+
const ransomware = String(kevEntry?.knownRansomwareCampaignUse || "").toLowerCase() === "known";
|
|
110
|
+
if (ransomware) return "critical";
|
|
111
|
+
const due = kevEntry?.dueDate;
|
|
112
|
+
if (typeof due === "string" && /^\d{4}-\d{2}-\d{2}/.test(due)) {
|
|
113
|
+
const dueMs = Date.parse(due);
|
|
114
|
+
if (Number.isFinite(dueMs)) {
|
|
115
|
+
const deltaMs = dueMs - Date.now();
|
|
116
|
+
// Within the next 7 days OR already past due → critical.
|
|
117
|
+
if (deltaMs <= 7 * 86_400_000) return "critical";
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return "high";
|
|
121
|
+
}
|
|
122
|
+
|
|
59
123
|
const TODAY = new Date().toISOString().slice(0, 10);
|
|
60
124
|
const TIMEOUT_MS = 10_000;
|
|
61
125
|
const USER_AGENT = "exceptd-security/auto-discovery (+https://exceptd.com)";
|
|
@@ -140,11 +204,22 @@ function buildKevDraftEntry(kevEntry, nvdPayload, epssPayload) {
|
|
|
140
204
|
// rwep_score was computed from concrete defaults (poc=true, reboot=true).
|
|
141
205
|
// `scoring.validate()` then flagged every auto-imported draft for
|
|
142
206
|
// divergence > 5. Now: one canonical input object → both surfaces.
|
|
207
|
+
//
|
|
208
|
+
// audit X P1: the catalog's JSON-schema for `rwep_factors` requires the
|
|
209
|
+
// POST-WEIGHT numeric shape (ai_factor / numeric ladder contributions /
|
|
210
|
+
// numeric ±deductions) — not the SHAPE-A boolean + string-ladder shape
|
|
211
|
+
// that scoreCustom consumes. Pre-fix the boolean shape was stored
|
|
212
|
+
// verbatim, so curate-apply's strict-schema gate rejected KEV-discovered
|
|
213
|
+
// drafts as soon as anyone tried to promote them — they were
|
|
214
|
+
// permanently unpromotable.
|
|
215
|
+
//
|
|
143
216
|
// The curation flow rewrites these once an operator answers the editorial
|
|
144
|
-
// questions; until then, the
|
|
145
|
-
//
|
|
217
|
+
// questions; until then, the post-weight numeric shape on rwep_factors
|
|
218
|
+
// reproduces the score exactly (sum of values === rwep_score, because
|
|
219
|
+
// blast_radius weight=30 matches the raw-cap convention documented in
|
|
220
|
+
// scoring.js header).
|
|
146
221
|
const scoringInputs = buildScoringInputs(kevEntry, nvdPayload);
|
|
147
|
-
const rwep_factors =
|
|
222
|
+
const rwep_factors = toPostWeightFactors(scoringInputs);
|
|
148
223
|
const rwep_score = scoreCustom(scoringInputs);
|
|
149
224
|
|
|
150
225
|
const product = [kevEntry.vendorProject, kevEntry.product]
|
|
@@ -190,10 +265,21 @@ function buildKevDraftEntry(kevEntry, nvdPayload, epssPayload) {
|
|
|
190
265
|
kevEntry.notes ? String(kevEntry.notes) : null,
|
|
191
266
|
].filter(Boolean),
|
|
192
267
|
// v0.12.15 (audit M P1-B): schema requires source_verified to be a
|
|
193
|
-
// YYYY-MM-DD string
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
|
|
268
|
+
// YYYY-MM-DD string; the prior `false` boolean (then null) produced
|
|
269
|
+
// entries that failed strict catalog validation.
|
|
270
|
+
//
|
|
271
|
+
// audit X P1: the CISA KEV listing IS the verification source for a
|
|
272
|
+
// KEV-discovered draft — the entry's `verification_sources` array
|
|
273
|
+
// already points to the KEV catalog URL, and KEV's appearance is what
|
|
274
|
+
// triggered the auto-import. Pre-fix the field stayed null, which
|
|
275
|
+
// (a) blocked curate-apply's strict-schema check (which requires a
|
|
276
|
+
// YYYY-MM-DD string) and (b) left operators no signal that the
|
|
277
|
+
// upstream HAD in fact verified the entry's authoritative listing.
|
|
278
|
+
// Now we date-stamp it as TODAY (the import day). Operators may
|
|
279
|
+
// overwrite during full curation if they revalidate from a fresher
|
|
280
|
+
// KEV pull — the field always semantically means "the date a
|
|
281
|
+
// verification source confirmed this CVE id."
|
|
282
|
+
source_verified: TODAY,
|
|
197
283
|
last_updated: TODAY,
|
|
198
284
|
last_verified: TODAY,
|
|
199
285
|
// v0.12.15 (audit M P1-D): `_auto_imported` must be the boolean `true`
|
|
@@ -217,7 +303,6 @@ function buildKevDraftEntry(kevEntry, nvdPayload, epssPayload) {
|
|
|
217
303
|
"patch_available + live_patch_available + live_patch_tools",
|
|
218
304
|
"blast_radius numeric in rwep_factors (currently default 15)",
|
|
219
305
|
"RWEP score recompute after the above land",
|
|
220
|
-
"source_verified once a project maintainer has confirmed the upstream",
|
|
221
306
|
],
|
|
222
307
|
},
|
|
223
308
|
};
|
|
@@ -259,7 +344,7 @@ function discoverNewKev(ctx, cap = DEFAULT_CAP) {
|
|
|
259
344
|
op: "add",
|
|
260
345
|
target: "cveCatalog",
|
|
261
346
|
entry,
|
|
262
|
-
severity:
|
|
347
|
+
severity: deriveKevSeverity(kev),
|
|
263
348
|
meta: {
|
|
264
349
|
date_added: kev.dateAdded || null,
|
|
265
350
|
vendor: kev.vendorProject || null,
|
|
@@ -534,6 +619,7 @@ module.exports = {
|
|
|
534
619
|
discoverNewRfcs,
|
|
535
620
|
buildKevDraftEntry,
|
|
536
621
|
getProjectRfcGroups,
|
|
622
|
+
deriveKevSeverity,
|
|
537
623
|
SEED_RFC_GROUPS,
|
|
538
624
|
DEFAULT_CAP,
|
|
539
625
|
};
|
package/lib/playbook-runner.js
CHANGED
|
@@ -454,7 +454,15 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
454
454
|
// attestation) treats every required FP check as UNSATISFIED.
|
|
455
455
|
if (verdict === 'hit' && Array.isArray(ind.false_positive_checks_required) && ind.false_positive_checks_required.length) {
|
|
456
456
|
const attestation = overrides[`${ind.id}__fp_checks`];
|
|
457
|
-
|
|
457
|
+
// S P1-A: arrays satisfy `typeof === 'object'` but are NOT a valid
|
|
458
|
+
// attestation map. A submission like
|
|
459
|
+
// signal_overrides: { sig__fp_checks: [true, true] }
|
|
460
|
+
// would previously have its truthy entries matched via the index
|
|
461
|
+
// fallback (att['0'] === true), silently bypassing every FP-check
|
|
462
|
+
// requirement. Reject arrays explicitly so they fall through to the
|
|
463
|
+
// empty-attestation branch (every required check unsatisfied).
|
|
464
|
+
const safeAtt = Array.isArray(attestation) ? null : attestation;
|
|
465
|
+
const att = (safeAtt && typeof safeAtt === 'object') ? safeAtt : {};
|
|
458
466
|
const unsatisfied = ind.false_positive_checks_required.filter(fpName => {
|
|
459
467
|
// Match either by exact name string OR by indexed key '0', '1', ...
|
|
460
468
|
// because false_positive_checks_required entries are free-text
|
|
@@ -510,9 +518,33 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
510
518
|
const override = (agentSubmission.signals && agentSubmission.signals.detection_classification);
|
|
511
519
|
const validOverrides = new Set(['detected', 'inconclusive', 'not_detected', 'clean']);
|
|
512
520
|
|
|
521
|
+
// S P1-B: block a `detected` agent override when any indicator was
|
|
522
|
+
// downgraded to inconclusive because its false_positive_checks_required[]
|
|
523
|
+
// entries were not attested. Without this gate, an agent that submits
|
|
524
|
+
// `signals.detection_classification: 'detected'` can force the run-level
|
|
525
|
+
// classification past FP checks the engine just refused to honor — exactly
|
|
526
|
+
// the contract Hard Rule #6 (compliance theater) forbids. Substitute
|
|
527
|
+
// 'inconclusive' and surface a runtime_error so the operator sees the
|
|
528
|
+
// override was refused (not silently ignored).
|
|
529
|
+
const anyFpDowngrade = indicatorResults.some(r => Array.isArray(r.fp_checks_unsatisfied) && r.fp_checks_unsatisfied.length > 0);
|
|
530
|
+
|
|
513
531
|
let classification;
|
|
514
532
|
if (override && validOverrides.has(override)) {
|
|
515
533
|
classification = override === 'clean' ? 'not_detected' : override;
|
|
534
|
+
if (classification === 'detected' && anyFpDowngrade) {
|
|
535
|
+
classification = 'inconclusive';
|
|
536
|
+
if (runOpts && Array.isArray(runOpts._runErrors)) {
|
|
537
|
+
runOpts._runErrors.push({
|
|
538
|
+
kind: 'classification_override_blocked',
|
|
539
|
+
attempted: 'detected',
|
|
540
|
+
substituted: 'inconclusive',
|
|
541
|
+
reason: 'FP-check downgrade: one or more indicators downgraded to inconclusive because false_positive_checks_required entries were not attested. Agent override to `detected` refused.',
|
|
542
|
+
indicators_with_unsatisfied_fp_checks: indicatorResults
|
|
543
|
+
.filter(r => Array.isArray(r.fp_checks_unsatisfied) && r.fp_checks_unsatisfied.length > 0)
|
|
544
|
+
.map(r => ({ id: r.id, fp_checks_unsatisfied: r.fp_checks_unsatisfied })),
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
}
|
|
516
548
|
} else if (hasDeterministicHit || hasHighConfHit) {
|
|
517
549
|
classification = 'detected';
|
|
518
550
|
} else if (hits.length === 0 && indicatorResults.every(r => r.verdict === 'miss')) {
|
|
@@ -1361,16 +1393,35 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
1361
1393
|
const extraFormats = Array.isArray(agentSignals._bundle_formats)
|
|
1362
1394
|
? agentSignals._bundle_formats.filter(f => f !== primaryFormat)
|
|
1363
1395
|
: [];
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1396
|
+
// audit W P2-B: build every bundle once and reuse, so bundle_body and
|
|
1397
|
+
// bundles_by_format[primary] are the same object identity (and hence
|
|
1398
|
+
// identical on every nested timestamp). Pre-fix, buildEvidenceBundle was
|
|
1399
|
+
// invoked twice for the primary format and each invocation crystallised
|
|
1400
|
+
// a fresh Date.now() — operators diffing bundle_body against
|
|
1401
|
+
// bundles_by_format.<primary> saw spurious millisecond drift on
|
|
1402
|
+
// tracking.initial_release_date / timestamp / current_release_date.
|
|
1403
|
+
const evidencePackage = c.evidence_package ? (() => {
|
|
1404
|
+
const issuedAt = new Date().toISOString();
|
|
1405
|
+
const builtFormats = new Map();
|
|
1406
|
+
const buildOnce = (format) => {
|
|
1407
|
+
if (!builtFormats.has(format)) {
|
|
1408
|
+
builtFormats.set(format, buildEvidenceBundle(format, playbook, analyzeResult, validateResult, agentSignals, sessionId, issuedAt));
|
|
1409
|
+
}
|
|
1410
|
+
return builtFormats.get(format);
|
|
1411
|
+
};
|
|
1412
|
+
const primaryBody = buildOnce(primaryFormat);
|
|
1413
|
+
const byFormat = extraFormats.length
|
|
1414
|
+
? Object.fromEntries([primaryFormat, ...extraFormats].map(f => [f, buildOnce(f)]))
|
|
1415
|
+
: null;
|
|
1416
|
+
return {
|
|
1417
|
+
bundle_format: primaryFormat,
|
|
1418
|
+
contents: c.evidence_package.contents || [],
|
|
1419
|
+
destination: c.evidence_package.destination || 'local_only',
|
|
1420
|
+
signed: c.evidence_package.signed !== false,
|
|
1421
|
+
bundle_body: primaryBody,
|
|
1422
|
+
bundles_by_format: byFormat,
|
|
1423
|
+
};
|
|
1424
|
+
})() : null;
|
|
1374
1425
|
|
|
1375
1426
|
if (evidencePackage && evidencePackage.signed && runOpts.session_key) {
|
|
1376
1427
|
const body = JSON.stringify(evidencePackage.bundle_body);
|
|
@@ -1540,20 +1591,59 @@ function buildProductBinding(playbook, sessionId) {
|
|
|
1540
1591
|
// Code Scanning hides results without `artifactLocation.uri`, so we
|
|
1541
1592
|
// surface at least one candidate when any is known. Returns null when no
|
|
1542
1593
|
// candidate exists — caller MUST omit `locations` rather than emit empty.
|
|
1594
|
+
//
|
|
1595
|
+
// audit W P2-A: source segments are heterogeneous — many playbook artifacts
|
|
1596
|
+
// describe a shell-command capture (`uname -r`) or human prose, not a real
|
|
1597
|
+
// file or URI. SARIF `artifactLocation.uri` is defined as a URI reference
|
|
1598
|
+
// (RFC 3986); shell-command text + prose breaks downstream consumers
|
|
1599
|
+
// (GitHub Code Scanning rejects with "invalid URI" or renders garbled).
|
|
1600
|
+
// We accept only path-shaped candidates: absolute POSIX paths, `~`-home
|
|
1601
|
+
// paths, relative paths, drive-prefixed Windows paths, or file-URI
|
|
1602
|
+
// strings. Everything else (commands, English) is dropped, and locations
|
|
1603
|
+
// is omitted entirely when no candidate survives.
|
|
1604
|
+
// Path-shape predicate: accept anything that begins with a POSIX absolute
|
|
1605
|
+
// path (`/...`), home (`~/...` or `~`), relative dot (`./...`, `../...`,
|
|
1606
|
+
// or a bare `.`), drive-prefixed Windows path (`C:\...`, `C:/...`), or a
|
|
1607
|
+
// `file:` URI. Also accept simple relative names that contain a slash
|
|
1608
|
+
// (e.g. `etc/os-release`, `subdir/file.json`) — these are common in
|
|
1609
|
+
// playbook artifact source fields. Reject anything with internal
|
|
1610
|
+
// whitespace (commands like `uname -r`, prose like `kpatch list || ls
|
|
1611
|
+
// /sys/kernel/livepatch`) or that looks like a sentence.
|
|
1612
|
+
function looksLikePath(src) {
|
|
1613
|
+
if (typeof src !== 'string') return false;
|
|
1614
|
+
const trimmed = src.trim();
|
|
1615
|
+
if (!trimmed) return false;
|
|
1616
|
+
if (/\s/.test(trimmed)) return false;
|
|
1617
|
+
if (/^file:/i.test(trimmed)) return true;
|
|
1618
|
+
if (/^[A-Za-z]:[/\\]/.test(trimmed)) return true; // Windows drive
|
|
1619
|
+
if (/^[/~]/.test(trimmed)) return true; // POSIX abs / home
|
|
1620
|
+
if (/^\.\.?(?:[/\\]|$)/.test(trimmed)) return true; // relative dot
|
|
1621
|
+
if (/^[A-Za-z0-9_.+-]+[/\\][^\s]+$/.test(trimmed)) return true; // bare relative path
|
|
1622
|
+
return false;
|
|
1623
|
+
}
|
|
1543
1624
|
function sarifLocationsForIndicator(playbook, indicator) {
|
|
1625
|
+
void indicator;
|
|
1544
1626
|
const arts = (playbook.phases?.look?.artifacts) || [];
|
|
1545
1627
|
const candidates = arts
|
|
1546
1628
|
.map(a => a && (a.source || a.air_gap_alternative))
|
|
1547
1629
|
.filter(Boolean)
|
|
1548
1630
|
.map(src => String(src).split(/\s+(?:AND|OR)\s+/i)[0].trim())
|
|
1549
|
-
.filter(src => src && !/^https?:/i.test(src))
|
|
1631
|
+
.filter(src => src && !/^https?:/i.test(src))
|
|
1632
|
+
.filter(looksLikePath);
|
|
1550
1633
|
if (!candidates.length) return null;
|
|
1551
1634
|
return [{ physicalLocation: { artifactLocation: { uri: candidates[0] } } }];
|
|
1552
1635
|
}
|
|
1553
1636
|
|
|
1554
|
-
function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals, sessionId) {
|
|
1637
|
+
function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals, sessionId, issuedAt) {
|
|
1555
1638
|
const playbookSlug = urnSlug(playbook._meta.id);
|
|
1556
1639
|
const { productId, productPurl, productName } = buildProductBinding(playbook, sessionId);
|
|
1640
|
+
// audit W P2-B: pin one `now` value per bundle build (and accept an
|
|
1641
|
+
// upstream-provided issuedAt) so multi-format emit produces identical
|
|
1642
|
+
// tracking timestamps across CSAF / OpenVEX / SARIF when close() is
|
|
1643
|
+
// building several formats from the same run. Without the parameter,
|
|
1644
|
+
// each invocation crystallised a fresh `Date.now()` and bundle_body
|
|
1645
|
+
// versus bundles_by_format[primary] would diverge on milliseconds.
|
|
1646
|
+
const now = typeof issuedAt === 'string' && issuedAt ? issuedAt : new Date().toISOString();
|
|
1557
1647
|
|
|
1558
1648
|
// CSAF-2.0 shape. v0.11.5 (#82): include vulnerabilities for both matched
|
|
1559
1649
|
// catalogue CVEs AND fired indicators (treated as advisory pseudo-CVEs
|
|
@@ -1571,14 +1661,30 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
1571
1661
|
name: productName,
|
|
1572
1662
|
product_identification_helper: { purl: productPurl }
|
|
1573
1663
|
}];
|
|
1664
|
+
// audit W P1-A: `fixed` product_status MUST reflect operator-supplied VEX
|
|
1665
|
+
// disposition (vex_status === 'fixed' — see analyze() F17), not the
|
|
1666
|
+
// catalog's global `live_patch_available` flag. The catalog flag means
|
|
1667
|
+
// "vendor publishes a live-patch in the world", not "operator deployed
|
|
1668
|
+
// it on this host". Pre-fix the CSAF emitter declared every
|
|
1669
|
+
// live-patchable CVE as fixed regardless of whether the operator's
|
|
1670
|
+
// evidence actually showed the patch applied, producing CSAF documents
|
|
1671
|
+
// that lied to downstream NVD / Red Hat dashboards. When
|
|
1672
|
+
// live_patch_available is the only signal, status stays known_affected
|
|
1673
|
+
// and the live-patch route is surfaced as a `vendor_fix` remediation.
|
|
1574
1674
|
const cveVulns = analyze.matched_cves.map(c => {
|
|
1575
|
-
const
|
|
1675
|
+
const isFixed = c.vex_status === 'fixed';
|
|
1676
|
+
const remediations = [{
|
|
1677
|
+
category: 'vendor_fix',
|
|
1678
|
+
details: validate.selected_remediation?.description
|
|
1679
|
+
|| (c.live_patch_available ? 'Vendor publishes a live-patch — see CVE catalog `live_patch_tools` for the operator-side step.' : 'See selected remediation path.'),
|
|
1680
|
+
product_ids: [productId],
|
|
1681
|
+
}];
|
|
1576
1682
|
return {
|
|
1577
1683
|
cve: c.cve_id,
|
|
1578
1684
|
scores: [{ products: [productId], cvss_v3: { base_score: c.cvss_score || 0 } }],
|
|
1579
1685
|
threats: c.active_exploitation === 'confirmed' ? [{ category: 'exploit_status', details: 'Active exploitation confirmed (CISA KEV).' }] : [],
|
|
1580
|
-
remediations
|
|
1581
|
-
product_status:
|
|
1686
|
+
remediations,
|
|
1687
|
+
product_status: isFixed ? { fixed: [productId] } : { known_affected: [productId] }
|
|
1582
1688
|
};
|
|
1583
1689
|
});
|
|
1584
1690
|
const indicatorVulns = indicatorHits.map(i => ({
|
|
@@ -1587,22 +1693,35 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
1587
1693
|
remediations: [{ category: 'mitigation', details: validate.selected_remediation?.description || `Consult playbook brief: exceptd brief ${playbook._meta.id}.`, product_ids: [productId] }],
|
|
1588
1694
|
product_status: { known_affected: [productId] }
|
|
1589
1695
|
}));
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
const
|
|
1696
|
+
// audit W P2-D: framework-gap entries used to ride in `vulnerabilities[]`
|
|
1697
|
+
// with `ids: [{ system_name: 'exceptd-framework-gap' }]`. The
|
|
1698
|
+
// `system_name` slot is reserved for recognised vulnerability tracking
|
|
1699
|
+
// authorities (CVE, GHSA, etc.); exceptd-framework-gap is not one, and
|
|
1700
|
+
// every downstream CSAF consumer (NVD ingester, Red Hat dashboard,
|
|
1701
|
+
// ENISA validator) flagged every run for unknown ids and rendered
|
|
1702
|
+
// false-positive advisories at the framework_gap_mapping length. Now
|
|
1703
|
+
// framework gaps land in `document.notes[]` with `category: details`
|
|
1704
|
+
// where they belong as advisory context, not pseudo-CVEs.
|
|
1705
|
+
const gapNotes = (analyze.framework_gap_mapping || []).map((g, idx) => {
|
|
1706
|
+
const lines = [
|
|
1707
|
+
`Framework: ${g.framework}`,
|
|
1708
|
+
g.claimed_control ? `Claimed control: ${g.claimed_control}` : null,
|
|
1709
|
+
g.actual_gap ? `Gap: ${g.actual_gap}` : null,
|
|
1710
|
+
g.required_control ? `Required: ${g.required_control}` : null,
|
|
1711
|
+
].filter(Boolean);
|
|
1712
|
+
return {
|
|
1713
|
+
category: 'details',
|
|
1714
|
+
title: `Framework gap ${idx + 1}: ${g.framework}${g.claimed_control ? ' / ' + g.claimed_control : ''}`,
|
|
1715
|
+
text: lines.join('\n'),
|
|
1716
|
+
};
|
|
1717
|
+
});
|
|
1600
1718
|
return {
|
|
1601
1719
|
document: {
|
|
1602
1720
|
category: 'csaf_security_advisory',
|
|
1603
1721
|
csaf_version: '2.0',
|
|
1604
1722
|
publisher: { category: 'vendor', name: 'exceptd', namespace: 'https://exceptd.com' },
|
|
1605
1723
|
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))`,
|
|
1724
|
+
notes: gapNotes,
|
|
1606
1725
|
tracking: {
|
|
1607
1726
|
// F2/F9: CSAF tracking.id binds to the run's session_id (threaded
|
|
1608
1727
|
// from run() via close()) so attestation file names, OpenVEX
|
|
@@ -1619,7 +1738,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
1619
1738
|
}
|
|
1620
1739
|
},
|
|
1621
1740
|
product_tree: { full_product_names: fullProductNames },
|
|
1622
|
-
vulnerabilities: [...cveVulns, ...indicatorVulns
|
|
1741
|
+
vulnerabilities: [...cveVulns, ...indicatorVulns],
|
|
1623
1742
|
exceptd_extension: {
|
|
1624
1743
|
classification: analyze._detect_classification,
|
|
1625
1744
|
rwep: analyze.rwep,
|
|
@@ -1712,11 +1831,16 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
1712
1831
|
rules: [...cveRules, ...indicatorRules, ...gapRules],
|
|
1713
1832
|
} },
|
|
1714
1833
|
results: [...cveResults, ...indicatorResults, ...gapResults],
|
|
1715
|
-
invocations: [{ executionSuccessful: true, properties: {
|
|
1834
|
+
invocations: [{ executionSuccessful: true, properties: stripNulls({
|
|
1835
|
+
// audit W P3-A: apply the B7 stripNulls contract here too — the
|
|
1836
|
+
// `remediation` field is null for any run that didn't surface a
|
|
1837
|
+
// selected_remediation, and SARIF viewers render null property
|
|
1838
|
+
// values as visible empty rows. Same helper as the result
|
|
1839
|
+
// property bags above.
|
|
1716
1840
|
playbook: playbook._meta.id, classification: analyze._detect_classification || 'unknown',
|
|
1717
1841
|
rwep_adjusted: analyze.rwep?.adjusted || 0,
|
|
1718
1842
|
remediation: validate.selected_remediation?.id || null,
|
|
1719
|
-
} }],
|
|
1843
|
+
}) }],
|
|
1720
1844
|
}]
|
|
1721
1845
|
};
|
|
1722
1846
|
}
|
|
@@ -1737,7 +1861,12 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
1737
1861
|
// `urn:exceptd:indicator:<playbook>:<indicator-id>` (RFC 8141) so
|
|
1738
1862
|
// they pass IRI validation in downstream VEX consumers.
|
|
1739
1863
|
if (format === 'openvex' || format === 'openvex-0.2.0') {
|
|
1740
|
-
|
|
1864
|
+
// audit W P2-B: reuse the bundle-wide `now` so OpenVEX `timestamp`
|
|
1865
|
+
// aligns with CSAF `document.tracking.initial_release_date` when both
|
|
1866
|
+
// formats are emitted in the same close() pass. Pre-fix each format
|
|
1867
|
+
// crystallised its own Date.now() value, and the two bundles in
|
|
1868
|
+
// bundles_by_format disagreed on milliseconds.
|
|
1869
|
+
const issued = now;
|
|
1741
1870
|
const productEntry = {
|
|
1742
1871
|
'@id': productPurl,
|
|
1743
1872
|
subcomponents: [{ '@id': productPurl }],
|
|
@@ -1752,6 +1881,17 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
1752
1881
|
if (remediationDescription) return `Apply remediation from validate phase: ${remediationDescription}`;
|
|
1753
1882
|
return fallback;
|
|
1754
1883
|
};
|
|
1884
|
+
// audit W P1-A: same `vex_status === 'fixed'` correctness rule as the
|
|
1885
|
+
// CSAF emitter. The catalog `live_patch_available` flag is a global
|
|
1886
|
+
// "vendor publishes a live-patch" signal, not an operator-host
|
|
1887
|
+
// disposition. Treating it as `status: fixed` made OpenVEX statements
|
|
1888
|
+
// claim resolution that the operator hadn't actually attested to.
|
|
1889
|
+
// VEX consumers downstream of CISA / SBOM / supply-chain pipelines
|
|
1890
|
+
// treat `fixed` as authoritative — emitting it without operator
|
|
1891
|
+
// attestation is a downstream-misleading bug. Now the OpenVEX
|
|
1892
|
+
// statement says `affected` (with action_statement pointing to the
|
|
1893
|
+
// remediation, which may itself be the vendor live-patch route) unless
|
|
1894
|
+
// the operator declared `vex_status: fixed` on the matched CVE.
|
|
1755
1895
|
const cveStatements = analyze.matched_cves.map(c => {
|
|
1756
1896
|
const stmt = {
|
|
1757
1897
|
vulnerability: { '@id': `urn:cve:${urnSlug(c.cve_id)}`, name: c.cve_id },
|
|
@@ -1759,11 +1899,13 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
|
|
|
1759
1899
|
timestamp: issued,
|
|
1760
1900
|
impact_statement: `RWEP ${c.rwep}. Blast radius ${analyze.blast_radius_score}/5.`,
|
|
1761
1901
|
};
|
|
1762
|
-
if (c.
|
|
1902
|
+
if (c.vex_status === 'fixed') {
|
|
1763
1903
|
stmt.status = 'fixed';
|
|
1764
1904
|
} else {
|
|
1765
1905
|
stmt.status = 'affected';
|
|
1766
|
-
stmt.action_statement = actionStatementFor(
|
|
1906
|
+
stmt.action_statement = actionStatementFor(c.live_patch_available
|
|
1907
|
+
? 'Vendor publishes a live-patch — see catalog `live_patch_tools` and apply, then re-attest.'
|
|
1908
|
+
: 'Apply remediation from validate phase.');
|
|
1767
1909
|
}
|
|
1768
1910
|
return stmt;
|
|
1769
1911
|
});
|
|
@@ -2104,6 +2246,20 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
|
|
|
2104
2246
|
// non-fatal anomalies surfaced into analyze.runtime_errors[].
|
|
2105
2247
|
const runErrors = [];
|
|
2106
2248
|
cachedRunOpts._runErrors = runErrors;
|
|
2249
|
+
// U REG-1: normalizeSubmission may push structured errors (e.g.
|
|
2250
|
+
// signal_overrides_invalid) onto submission._runErrors. Pre-fix these were
|
|
2251
|
+
// stranded — they never reached the run-level accumulator that analyze()
|
|
2252
|
+
// slices into runtime_errors[], so F20's "analyze surfaces all runtime
|
|
2253
|
+
// errors" contract was silently broken. Splice the pre-run errors into
|
|
2254
|
+
// the run-level accumulator and strip the field off the submission so it
|
|
2255
|
+
// doesn't pollute the F1 evidence_hash digest (the hash canonicalizes the
|
|
2256
|
+
// submission and a non-deterministic _runErrors would change it).
|
|
2257
|
+
if (Array.isArray(agentSubmission._runErrors) && agentSubmission._runErrors.length) {
|
|
2258
|
+
runErrors.push(...agentSubmission._runErrors);
|
|
2259
|
+
}
|
|
2260
|
+
if (agentSubmission && Object.prototype.hasOwnProperty.call(agentSubmission, '_runErrors')) {
|
|
2261
|
+
delete agentSubmission._runErrors;
|
|
2262
|
+
}
|
|
2107
2263
|
// E6: phases the runner should SKIP execution for, based on skip_phase
|
|
2108
2264
|
// preconditions surfaced in preflight.issues.
|
|
2109
2265
|
const skipPhases = new Set();
|
package/lib/prefetch.js
CHANGED
|
@@ -237,6 +237,26 @@ async function withIndexLock(cacheDir, mutator) {
|
|
|
237
237
|
// raised when the other process is mid-unlink). Treat both as
|
|
238
238
|
// "lock held, back off" rather than a fatal error.
|
|
239
239
|
if (e.code !== "EEXIST" && e.code !== "EPERM") throw e;
|
|
240
|
+
// T P1-1: PID-liveness check. Same pattern as withCatalogLock in
|
|
241
|
+
// lib/refresh-external.js — read the lockfile's PID, probe with
|
|
242
|
+
// process.kill(pid, 0); ESRCH → holder dead, reclaim immediately;
|
|
243
|
+
// EPERM → holder alive (different user), keep waiting. The mtime
|
|
244
|
+
// fallback below covers malformed / unreadable lockfiles.
|
|
245
|
+
let reclaimedByPid = false;
|
|
246
|
+
try {
|
|
247
|
+
const raw = fs.readFileSync(lockPath, "utf8").trim();
|
|
248
|
+
const pid = Number.parseInt(raw, 10);
|
|
249
|
+
if (Number.isInteger(pid) && pid > 0 && pid !== process.pid) {
|
|
250
|
+
try {
|
|
251
|
+
process.kill(pid, 0);
|
|
252
|
+
} catch (probeErr) {
|
|
253
|
+
if (probeErr && probeErr.code === "ESRCH") {
|
|
254
|
+
try { fs.unlinkSync(lockPath); reclaimedByPid = true; } catch {}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
} catch {}
|
|
259
|
+
if (reclaimedByPid) continue;
|
|
240
260
|
try {
|
|
241
261
|
const stat = fs.statSync(lockPath);
|
|
242
262
|
if (Date.now() - stat.mtimeMs > STALE_LOCK_MS) {
|
|
@@ -394,11 +414,20 @@ async function prefetch(options = {}) {
|
|
|
394
414
|
const targetPath = entryPath(opts.cacheDir, item.source, item.id);
|
|
395
415
|
const dir = path.dirname(targetPath);
|
|
396
416
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
397
|
-
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
|
|
417
|
+
const body = JSON.stringify(res.json, null, 2) + "\n";
|
|
418
|
+
// T P1-3: stage the payload to a same-volume tmp file BEFORE
|
|
419
|
+
// attempting to acquire the index lock. If withIndexLock fails
|
|
420
|
+
// (timeout after MAX_RETRIES), we want the partially-completed
|
|
421
|
+
// download discarded — not left on disk as an orphan payload
|
|
422
|
+
// with no index entry. Air-gap operators feed off `readCached`,
|
|
423
|
+
// which consults the index; an unindexed payload silently becomes
|
|
424
|
+
// junk taking cache space. Pattern: stage → lock → rename+index
|
|
425
|
+
// → release. The rename is atomic same-volume; if it fails inside
|
|
426
|
+
// the lock we clean up the tmp file. If we never reach the rename
|
|
427
|
+
// (lock acquisition throws), the tmp file is unlinked in the
|
|
428
|
+
// catch block below.
|
|
429
|
+
const tmpPath = `${targetPath}.tmp.${process.pid}.${Math.random().toString(36).slice(2, 10)}`;
|
|
430
|
+
fs.writeFileSync(tmpPath, body);
|
|
402
431
|
const meta = {
|
|
403
432
|
fetched_at: new Date().toISOString(),
|
|
404
433
|
etag: res.etag,
|
|
@@ -406,16 +435,34 @@ async function prefetch(options = {}) {
|
|
|
406
435
|
url: item.url,
|
|
407
436
|
sha256: crypto.createHash("sha256").update(JSON.stringify(res.json)).digest("hex"),
|
|
408
437
|
};
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
438
|
+
try {
|
|
439
|
+
// v0.12.12 C2: persist this entry's metadata to _index.json under
|
|
440
|
+
// lock immediately, merging with whatever the on-disk index has
|
|
441
|
+
// (another concurrent prefetch may have written sibling entries).
|
|
442
|
+
// Inside the lock we also rename the staged tmp → final path so
|
|
443
|
+
// a concurrent reader sees the new payload + new index entry as
|
|
444
|
+
// an atomic pair.
|
|
445
|
+
await withIndexLock(opts.cacheDir, (current) => {
|
|
446
|
+
try {
|
|
447
|
+
fs.renameSync(tmpPath, targetPath);
|
|
448
|
+
} catch (renameErr) {
|
|
449
|
+
// Surface as a failure to mutator: throwing here aborts the
|
|
450
|
+
// lock's write step. We re-throw to the outer catch which
|
|
451
|
+
// will increment errors.
|
|
452
|
+
throw renameErr;
|
|
453
|
+
}
|
|
454
|
+
current.entries[entryKey(item.source, item.id)] = meta;
|
|
455
|
+
return current;
|
|
456
|
+
});
|
|
457
|
+
// Mirror the entry into the in-memory idx for callers that read
|
|
458
|
+
// it later in this run (e.g. the final saveIndex merge).
|
|
459
|
+
idx.entries[entryKey(item.source, item.id)] = meta;
|
|
460
|
+
} catch (lockErr) {
|
|
461
|
+
// Lock failure OR rename-inside-lock failure — unlink the staged
|
|
462
|
+
// tmp so the cache directory does not accumulate orphans.
|
|
463
|
+
try { fs.unlinkSync(tmpPath); } catch {}
|
|
464
|
+
throw lockErr;
|
|
465
|
+
}
|
|
419
466
|
result.fetched++;
|
|
420
467
|
result.by_source[item.source].fetched++;
|
|
421
468
|
log(` [${item.source}] ${item.id} — ok`);
|
package/lib/refresh-external.js
CHANGED
|
@@ -938,6 +938,33 @@ async function withCatalogLock(catalogPath, mutator) {
|
|
|
938
938
|
// Windows the same race surfaces as EPERM (sharing-violation raised
|
|
939
939
|
// when the holder is mid-unlink). Treat both as "lock held, back off."
|
|
940
940
|
if (e.code !== "EEXIST" && e.code !== "EPERM") throw e;
|
|
941
|
+
// T P1-1: PID-liveness check before falling back to mtime. The
|
|
942
|
+
// lockfile already contains String(process.pid) of the holder; parse
|
|
943
|
+
// it and probe with `process.kill(pid, 0)`. ESRCH means the holder is
|
|
944
|
+
// dead — reclaim immediately rather than waiting STALE_LOCK_MS for
|
|
945
|
+
// the mtime gate to expire. EPERM (holder alive, different user) is
|
|
946
|
+
// treated as "alive, keep waiting." The mtime gate remains as a
|
|
947
|
+
// belt-and-suspenders for the case where the lockfile content is
|
|
948
|
+
// missing / malformed / belongs to a recycled PID. Matches the PID
|
|
949
|
+
// pattern in orchestrator/index.js _acquireWatchLock and
|
|
950
|
+
// lib/playbook-runner.js pidAlive().
|
|
951
|
+
let reclaimedByPid = false;
|
|
952
|
+
try {
|
|
953
|
+
const raw = fs.readFileSync(lockPath, "utf8").trim();
|
|
954
|
+
const pid = Number.parseInt(raw, 10);
|
|
955
|
+
if (Number.isInteger(pid) && pid > 0 && pid !== process.pid) {
|
|
956
|
+
try {
|
|
957
|
+
process.kill(pid, 0);
|
|
958
|
+
// holder alive
|
|
959
|
+
} catch (probeErr) {
|
|
960
|
+
if (probeErr && probeErr.code === "ESRCH") {
|
|
961
|
+
try { fs.unlinkSync(lockPath); reclaimedByPid = true; } catch {}
|
|
962
|
+
}
|
|
963
|
+
// EPERM and anything else: treat as alive, fall through to mtime/sleep.
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
} catch {} // unreadable lockfile — proceed to mtime fallback
|
|
967
|
+
if (reclaimedByPid) continue;
|
|
941
968
|
// Stale-lock check before sleeping — a long-dead holder shouldn't keep
|
|
942
969
|
// us waiting MAX_RETRIES * backoff before we recover.
|
|
943
970
|
try {
|