@blamejs/exceptd-skills 0.16.29 → 0.16.30
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 +14 -0
- package/bin/exceptd.js +212 -12
- package/data/_indexes/_meta.json +2 -2
- package/data/playbooks/crypto.json +6 -0
- package/lib/collectors/README.md +3 -2
- package/lib/cross-ref-api.js +96 -31
- package/lib/playbook-runner.js +247 -48
- package/lib/prefetch.js +78 -6
- package/lib/refresh-external.js +103 -3
- package/lib/scoring.js +49 -5
- package/lib/validate-cve-catalog.js +14 -2
- package/lib/validate-playbooks.js +133 -38
- package/manifest.json +53 -53
- package/package.json +1 -1
- package/sbom.cdx.json +34 -34
- package/scripts/run-e2e-scenarios.js +41 -11
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.16.30 — 2026-06-12
|
|
4
|
+
|
|
5
|
+
The analyze-phase cross-reference layer now returns the correlations it always claimed to. The `byCwe`/`byTtp`/`bySkill` skill links, the per-CVE framework-gap and compliance-theater-test correlations, and the global framework context were reading index and catalog records under field names the data never carried, so every lookup came back empty. They now read the real fields and populate — a CWE resolves its skills, a CVE resolves its framework gaps and theater tests, and the global framework context spans the catalogs it documents.
|
|
6
|
+
|
|
7
|
+
A malformed or timezone-less operator clock value no longer breaks a run. An unparseable `clock_started_at_<event>` signal previously threw out of the close phase, destroying the entire notification, CSAF, deadline, and attestation output and crashing every later reattest of the stored submission; it now degrades to a pending clock with a surfaced reason. A zone-less timestamp is normalized to UTC deterministically instead of the host's local zone, so a statutory deadline (NIS2 24h, DORA 4h, GDPR 72h) no longer shifts by the host's UTC offset. The analyze- and validate-completion clocks now auto-start under operator consent.
|
|
8
|
+
|
|
9
|
+
Standards-bundle identifiers are correct across formats. CSAF `product_tree` branches are named from the package, not from a version-range operator sliced out of a catalog version string; SARIF `helpUri` points GHSA, OSV, RUSTSEC, and malicious-package identifiers at their own authority instead of fabricating an NVD CVE link; and the OpenVEX vulnerability `@id` keeps the canonical identifier case its `name` already carries.
|
|
10
|
+
|
|
11
|
+
`attest diff` closes its remaining tamper-detection gaps. A tampered attestation in a multi-playbook session, and a tampered auto-selected prior attestation, are now detected on every diff path — previously only the explicit `--against` side was verified, so a forged multi-playbook or prior attestation passed at exit 0. Two artifacts that differ only in JSON key order no longer report as changed, and the VEX disposition note no longer lists a fixed disposition as a drop reason.
|
|
12
|
+
|
|
13
|
+
`prefetch` validates its arguments and cache. An empty or comma-only `--source` is refused instead of silently warming every source; a value-less `--cache-dir`, `--source`, or `--max-age` is refused instead of crashing or silently changing scope; and a future-dated (clock-skewed) cache entry is re-fetched instead of trusted as fresh. `refresh --prefetch` reports a clear error naming the prefetchable sources when handed a live-only one, and auto-refresh no longer silently de-lists a curated CISA-KEV entry — matching the curated-CVSS protection already in place.
|
|
14
|
+
|
|
15
|
+
RWEP scoring no longer produces a NaN delta or a false "broadly aligned" verdict when a comparison lacks a CVSS score, and custom scoring rejects a non-numeric blast radius the factor validator already rejected. The crypto playbook declares its Linux-only precondition, so a scan on a non-Linux host blocks rather than reporting a false "not detected." Playbook directive overrides and `applies_to` references are validated and cross-referenced, the CVE framework-control-gap cross-reference for orphaned control identifiers is enforced under `--strict`, and `lint` flags an unknown precondition key. The pre-publish scenario harness enforces its stderr guards even when a verb emits non-JSON output, binds assertions to the correct object, and holds a scenario-count floor under the standard test gate. An irrelevant passthrough flag on a read-only verb is now refused rather than silently ignored.
|
|
16
|
+
|
|
3
17
|
## 0.16.29 — 2026-06-12
|
|
4
18
|
|
|
5
19
|
A correctness pass across the refresh pipeline, scoring, attestation, the collectors, and offline mode.
|
package/bin/exceptd.js
CHANGED
|
@@ -884,7 +884,14 @@ function emit(obj, pretty, humanRenderer) {
|
|
|
884
884
|
// the body. Per-site `verb: "<name>"` is set at the call site; this
|
|
885
885
|
// helper guarantees the `ok` field's presence but does not synthesize
|
|
886
886
|
// verb (the caller knows its own name).
|
|
887
|
-
|
|
887
|
+
//
|
|
888
|
+
// Arrays are excluded: spreading an array into an object literal would
|
|
889
|
+
// produce numeric string keys ({"0":…,"1":…}) plus a spurious ok:true
|
|
890
|
+
// envelope, corrupting array-shaped output. Array bodies (standard
|
|
891
|
+
// documents like SARIF results / OpenVEX statements) pass through
|
|
892
|
+
// verbatim — matching the verbatim-write path those documents already
|
|
893
|
+
// use, which deliberately strips the envelope rather than injecting it.
|
|
894
|
+
if (obj && typeof obj === 'object' && !Array.isArray(obj) && !('ok' in obj)) {
|
|
888
895
|
obj = { ok: true, ...obj };
|
|
889
896
|
}
|
|
890
897
|
const wantJson = !!global.__exceptdWantJson || !!process.env.EXCEPTD_RAW_JSON;
|
|
@@ -1747,6 +1754,33 @@ function dispatchPlaybook(cmd, argv) {
|
|
|
1747
1754
|
runOpts.operator_consent = { acked_at: new Date().toISOString(), explicit: true };
|
|
1748
1755
|
}
|
|
1749
1756
|
|
|
1757
|
+
// Relevance guard for PASSTHROUGH_FLAGS that are meaningful on only a subset
|
|
1758
|
+
// of verbs. PASSTHROUGH_FLAGS short-circuits the typo loop above so it never
|
|
1759
|
+
// reaches the cross-verb guidance fall-through — which meant a run-class flag
|
|
1760
|
+
// (e.g. --max-rwep, consumed only by `ci`) parked there was silently dropped
|
|
1761
|
+
// (exit 0, output unchanged) when supplied to an info-only verb, instead of
|
|
1762
|
+
// refused with the same "pass it on a run-class verb" guidance the bundle
|
|
1763
|
+
// flags (--csaf-status / --tlp / --ack) already give. Each entry maps the
|
|
1764
|
+
// flag to the verbs that actually consume it; supplying it elsewhere is an
|
|
1765
|
+
// irrelevant-flag refusal.
|
|
1766
|
+
const SINGLE_VERB_PASSTHROUGH = {
|
|
1767
|
+
"max-rwep": ["ci"],
|
|
1768
|
+
"diff-from-latest": ["run"],
|
|
1769
|
+
"upstream-check": ["run"],
|
|
1770
|
+
};
|
|
1771
|
+
for (const [flag, relevantVerbs] of Object.entries(SINGLE_VERB_PASSTHROUGH)) {
|
|
1772
|
+
// A value-less boolean flag parses as `true`; a value-bearing one as its
|
|
1773
|
+
// string. Either way, presence (not absence) is what we gate on. `false`
|
|
1774
|
+
// never occurs from the parser but is treated as "not supplied" for safety.
|
|
1775
|
+
if (args[flag] === undefined || args[flag] === false) continue;
|
|
1776
|
+
if (relevantVerbs.includes(cmd)) continue;
|
|
1777
|
+
return emitError(
|
|
1778
|
+
`${cmd}: --${flag} is irrelevant on this verb (nothing here consumes it). --${flag} only applies to: ${relevantVerbs.slice().sort().join(", ")}. Re-invoke without --${flag}, or pass it on \`exceptd ${relevantVerbs[0]} …\`.`,
|
|
1779
|
+
{ verb: cmd, flag, error_class: "irrelevant-flag", accepted_verbs: relevantVerbs.slice().sort() },
|
|
1780
|
+
pretty
|
|
1781
|
+
);
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1750
1784
|
let runner;
|
|
1751
1785
|
try {
|
|
1752
1786
|
runner = loadRunner();
|
|
@@ -2675,6 +2709,18 @@ function cmdLint(runner, args, runOpts, pretty) {
|
|
|
2675
2709
|
p => !(((submission.precondition_checks || {}).hasOwnProperty(p)) || ((normalized.precondition_checks || {}).hasOwnProperty(p)))
|
|
2676
2710
|
);
|
|
2677
2711
|
|
|
2712
|
+
// Symmetric to unknownArtifactKeys/unknownSignalKeys: flag precondition_checks
|
|
2713
|
+
// keys the playbook does not declare. Pre-fix, the flat `observations` shape
|
|
2714
|
+
// surfaced a foreign precondition id (e.g. crypto collector attesting
|
|
2715
|
+
// `linux-platform`, which belongs to kernel/runtime/hardening) as an
|
|
2716
|
+
// unknown_observation_key, but the nested `precondition_checks` shape every
|
|
2717
|
+
// collector actually emits was never checked — so collector↔playbook
|
|
2718
|
+
// precondition-id drift was silent on the canonical collect→lint path.
|
|
2719
|
+
const unknownPreconditionKeys = [...new Set([
|
|
2720
|
+
...Object.keys(submission.precondition_checks || {}),
|
|
2721
|
+
...Object.keys(normalized.precondition_checks || {}),
|
|
2722
|
+
])].filter(k => !knownPreconditions.has(k));
|
|
2723
|
+
|
|
2678
2724
|
const issues = [];
|
|
2679
2725
|
// v0.11.6 (#94): missing_required_artifact downgraded from error to warn.
|
|
2680
2726
|
// The runner doesn't refuse a submission missing required artifacts — it
|
|
@@ -2698,6 +2744,15 @@ function cmdLint(runner, args, runOpts, pretty) {
|
|
|
2698
2744
|
for (const p of unsuppliedPreconditions) {
|
|
2699
2745
|
issues.push({ severity: "info", kind: "precondition_unverified", precondition_id: p, hint: `Add submission.precondition_checks.${p} = true|false (or under observations in the flat shape).` });
|
|
2700
2746
|
}
|
|
2747
|
+
for (const k of unknownPreconditionKeys) {
|
|
2748
|
+
const recognized = [...knownPreconditions];
|
|
2749
|
+
issues.push({
|
|
2750
|
+
severity: "warn",
|
|
2751
|
+
kind: "unknown_precondition_key",
|
|
2752
|
+
precondition_id: k,
|
|
2753
|
+
hint: `Not in playbook ${playbookId} _meta.preconditions[].${recognized.length ? ` Recognized: ${recognized.slice(0, 10).join(", ")}.` : " This playbook declares no preconditions."} A collector emitting a foreign precondition id (e.g. the crypto collector attesting \`linux-platform\`, which belongs to kernel/runtime/hardening) means the attestation will not satisfy any real gate.`,
|
|
2754
|
+
});
|
|
2755
|
+
}
|
|
2701
2756
|
for (const k of unknownObservationKeys) {
|
|
2702
2757
|
issues.push({ severity: "warn", kind: "unknown_observation_key", key: k });
|
|
2703
2758
|
}
|
|
@@ -4934,6 +4989,31 @@ function verifyAttestationSidecar(attFile) {
|
|
|
4934
4989
|
}
|
|
4935
4990
|
}
|
|
4936
4991
|
if (!fs.existsSync(sigPath)) {
|
|
4992
|
+
// A missing sidecar is benign ONLY when none was ever expected (the
|
|
4993
|
+
// attestation was written on a keyless host and no peer in the same
|
|
4994
|
+
// session is signed). When a sig SHOULD exist — a signing key is present,
|
|
4995
|
+
// or a signed peer attestation sits beside this one — an absent sidecar is
|
|
4996
|
+
// a deletion-to-evade-tamper signal. Carry the tamper_class so `attest
|
|
4997
|
+
// diff` and `reattest` refuse a forged attestation whose .sig was stripped,
|
|
4998
|
+
// matching `attest verify`. The keyless case stays benign so keyless CI is
|
|
4999
|
+
// unaffected.
|
|
5000
|
+
const privKeyPath = path.join(PKG_ROOT, ".keys", "private.pem");
|
|
5001
|
+
let expected = fs.existsSync(privKeyPath);
|
|
5002
|
+
if (!expected) {
|
|
5003
|
+
try {
|
|
5004
|
+
const dir = path.dirname(attFile);
|
|
5005
|
+
for (const sf of fs.readdirSync(dir)) {
|
|
5006
|
+
if (!sf.endsWith(".sig")) continue;
|
|
5007
|
+
try {
|
|
5008
|
+
const sd = JSON.parse(fs.readFileSync(path.join(dir, sf), "utf8"));
|
|
5009
|
+
if (sd && sd.algorithm === "Ed25519") { expected = true; break; }
|
|
5010
|
+
} catch { /* skip unparseable sidecar */ }
|
|
5011
|
+
}
|
|
5012
|
+
} catch { /* dir unreadable — fall through to benign */ }
|
|
5013
|
+
}
|
|
5014
|
+
if (expected) {
|
|
5015
|
+
return { file: attFile, signed: false, verified: false, reason: "no .sig sidecar, but one was expected (signing key present or a signed peer attestation exists) — sidecar deletion suspected", tamper_class: "sidecar-missing" };
|
|
5016
|
+
}
|
|
4937
5017
|
return { file: attFile, signed: false, verified: false, reason: "no .sig sidecar" };
|
|
4938
5018
|
}
|
|
4939
5019
|
let sigDoc;
|
|
@@ -5018,6 +5098,39 @@ function verifyAttestationSidecar(attFile) {
|
|
|
5018
5098
|
}
|
|
5019
5099
|
}
|
|
5020
5100
|
|
|
5101
|
+
/**
|
|
5102
|
+
* Resolve the A-side ("self") attestation for `attest diff` to its actual
|
|
5103
|
+
* on-disk file, NOT a hardcoded attestation.json.
|
|
5104
|
+
*
|
|
5105
|
+
* The two diff branches (`--against` and the auto-prior default) both need
|
|
5106
|
+
* the A-side's real signed file to route its sidecar through the tamper
|
|
5107
|
+
* refusal. A single-`run` / `reattest` session writes attestation.json; a
|
|
5108
|
+
* multi-playbook (run-all) session writes per-playbook `<id>.json` +
|
|
5109
|
+
* `<id>.json.sig` with no attestation.json. Selection mirrors the B-side
|
|
5110
|
+
* resolution: prefer attestation.json when present, else the newest by
|
|
5111
|
+
* captured_at. Returns { parsed, file } or null when no attestation exists.
|
|
5112
|
+
*
|
|
5113
|
+
* `attestations[i]` is paired with `files[i]` (the partition loop pushes
|
|
5114
|
+
* them in lockstep), so this never has to re-read the directory.
|
|
5115
|
+
*/
|
|
5116
|
+
function resolveSelfAttestation(dir, attestations, files) {
|
|
5117
|
+
if (!Array.isArray(attestations) || attestations.length === 0) return null;
|
|
5118
|
+
const canonicalPath = path.join(dir, "attestation.json");
|
|
5119
|
+
const canonicalIdx = files.indexOf(canonicalPath);
|
|
5120
|
+
if (canonicalIdx !== -1) {
|
|
5121
|
+
return { parsed: attestations[canonicalIdx], file: files[canonicalIdx] };
|
|
5122
|
+
}
|
|
5123
|
+
// No canonical attestation.json (run-all session) — pick the newest entry
|
|
5124
|
+
// by captured_at, keeping its paired path so the sidecar verify is exact.
|
|
5125
|
+
let best = { parsed: attestations[0], file: files[0] };
|
|
5126
|
+
for (let i = 1; i < attestations.length; i++) {
|
|
5127
|
+
const cur = attestations[i].captured_at || "";
|
|
5128
|
+
const bestCap = best.parsed.captured_at || "";
|
|
5129
|
+
if (cur.localeCompare(bestCap) > 0) best = { parsed: attestations[i], file: files[i] };
|
|
5130
|
+
}
|
|
5131
|
+
return best;
|
|
5132
|
+
}
|
|
5133
|
+
|
|
5021
5134
|
/**
|
|
5022
5135
|
* `attest prune --all-older-than <ISO>` — GC for attestation growth.
|
|
5023
5136
|
*
|
|
@@ -5484,6 +5597,12 @@ function isTamperedSidecarVerify(verify) {
|
|
|
5484
5597
|
// swapped key proves nothing, so replay refuses exactly like the
|
|
5485
5598
|
// other tamper classes (attest verify already refuses on this).
|
|
5486
5599
|
|| verify.tamper_class === "fingerprint-mismatch"
|
|
5600
|
+
// A sidecar that should exist (a signing key is present, or a signed peer
|
|
5601
|
+
// attestation sits in the same session) but is absent is a
|
|
5602
|
+
// deletion-to-evade-tamper signal — refuse exactly as reattest and attest
|
|
5603
|
+
// verify already do, so a forged attestation can't dodge the diff gate by
|
|
5604
|
+
// stripping its .sig.
|
|
5605
|
+
|| verify.tamper_class === "sidecar-missing"
|
|
5487
5606
|
);
|
|
5488
5607
|
return Boolean(isSignedTamper || isClassTamper);
|
|
5489
5608
|
}
|
|
@@ -5601,14 +5720,22 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
5601
5720
|
// sessions. Gate on the parsed payload — not filename prefix — so a
|
|
5602
5721
|
// renamed file cannot smuggle a replay into the attestations[] list.
|
|
5603
5722
|
const attestations = [];
|
|
5723
|
+
const attestationFiles = [];
|
|
5604
5724
|
const replays = [];
|
|
5605
5725
|
for (const f of files) {
|
|
5606
5726
|
let parsed;
|
|
5607
|
-
|
|
5727
|
+
const fp = path.join(dir, f);
|
|
5728
|
+
try { parsed = JSON.parse(fs.readFileSync(fp, "utf8")); }
|
|
5608
5729
|
catch { continue; }
|
|
5609
5730
|
if (!parsed) continue;
|
|
5610
5731
|
if (parsed.kind === "replay") replays.push(parsed);
|
|
5611
|
-
|
|
5732
|
+
// Track the on-disk path alongside the parsed attestation so the A-side
|
|
5733
|
+
// sidecar verify resolves the ACTUAL signed file. A multi-playbook
|
|
5734
|
+
// (run-all) session writes per-playbook `<id>.json` + `<id>.json.sig`
|
|
5735
|
+
// and NEVER an attestation.json — so a hardcoded attestation.json path
|
|
5736
|
+
// would point at a non-existent file and silently report "no .sig
|
|
5737
|
+
// sidecar", letting a forged run-all A-side pass diff at exit 0.
|
|
5738
|
+
else { attestations.push(parsed); attestationFiles.push(fp); }
|
|
5612
5739
|
}
|
|
5613
5740
|
|
|
5614
5741
|
if (subverb === "show") {
|
|
@@ -5667,7 +5794,8 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
5667
5794
|
if (!other) {
|
|
5668
5795
|
return emitError(`attest diff --against ${args.against}: no attestations under that session id.`, null, pretty);
|
|
5669
5796
|
}
|
|
5670
|
-
const
|
|
5797
|
+
const selfResolved = resolveSelfAttestation(dir, attestations, attestationFiles);
|
|
5798
|
+
const self = selfResolved && selfResolved.parsed;
|
|
5671
5799
|
if (!self) {
|
|
5672
5800
|
// Session dir contains only replay records, no attestation —
|
|
5673
5801
|
// diff has nothing to compare on the A side.
|
|
@@ -5681,8 +5809,11 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
5681
5809
|
// drift verdict as much as the A-side, so a forged comparison attestation
|
|
5682
5810
|
// must be refused too, not silently diffed under an A-only green sidecar
|
|
5683
5811
|
// line. Mirrors reattest's tamper-refusal contract (exit TAMPERED unless
|
|
5684
|
-
// --force-replay); surfaces a_/b_sidecar_verify either way.
|
|
5685
|
-
|
|
5812
|
+
// --force-replay); surfaces a_/b_sidecar_verify either way. The A-side
|
|
5813
|
+
// verifies its RESOLVED file (selfResolved.file) so a run-all session,
|
|
5814
|
+
// whose real signed sidecar is `<id>.json.sig` not attestation.json.sig,
|
|
5815
|
+
// is checked against its actual signature rather than a missing path.
|
|
5816
|
+
const aSidecarVerify = verifyAttestationSidecar(selfResolved.file);
|
|
5686
5817
|
const bSidecarVerify = otherPath
|
|
5687
5818
|
? verifyAttestationSidecar(otherPath)
|
|
5688
5819
|
: { file: null, signed: false, verified: false, reason: "no B-side attestation file resolved" };
|
|
@@ -5736,7 +5867,8 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
5736
5867
|
// No --against: find the most-recent prior attestation for the
|
|
5737
5868
|
// SAME playbook as `sessionId` and diff against that. Pure
|
|
5738
5869
|
// comparison — no replay.
|
|
5739
|
-
const
|
|
5870
|
+
const selfResolved = resolveSelfAttestation(dir, attestations, attestationFiles);
|
|
5871
|
+
const self = selfResolved && selfResolved.parsed;
|
|
5740
5872
|
if (!self) {
|
|
5741
5873
|
return emitError(
|
|
5742
5874
|
`attest diff ${sessionId}: no attestation found in session dir.`,
|
|
@@ -5761,7 +5893,35 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
5761
5893
|
}
|
|
5762
5894
|
const other = prior.parsed;
|
|
5763
5895
|
const status = self.evidence_hash === other.evidence_hash ? "unchanged" : "drifted";
|
|
5764
|
-
|
|
5896
|
+
// Verify BOTH sidecars and apply the same dual-side tamper refusal as the
|
|
5897
|
+
// --against branch. Pre-fix this branch verified only the A-side and never
|
|
5898
|
+
// the auto-selected prior, so a forged prior (or a forged run-all A-side,
|
|
5899
|
+
// which the hardcoded attestation.json path missed entirely) produced a
|
|
5900
|
+
// drift verdict at exit 0 under a green sidecar line. The A-side uses its
|
|
5901
|
+
// RESOLVED file; the B-side uses the prior's actual on-disk path
|
|
5902
|
+
// (prior.file), so a run-all prior is checked against its real signature.
|
|
5903
|
+
const aSidecarVerify = verifyAttestationSidecar(selfResolved.file);
|
|
5904
|
+
const bSidecarVerify = prior.file
|
|
5905
|
+
? verifyAttestationSidecar(prior.file)
|
|
5906
|
+
: { file: null, signed: false, verified: false, reason: "no prior attestation file resolved" };
|
|
5907
|
+
const aTampered = isTamperedSidecarVerify(aSidecarVerify);
|
|
5908
|
+
const bTampered = isTamperedSidecarVerify(bSidecarVerify);
|
|
5909
|
+
if ((aTampered || bTampered) && !args["force-replay"]) {
|
|
5910
|
+
const sides = [aTampered && "A-side", bTampered && "prior (B-side)"].filter(Boolean).join(" + ");
|
|
5911
|
+
process.stderr.write(`[exceptd attest diff] TAMPERED: ${sides} attestation failed Ed25519 verification. Refusing to diff against forged input. Pass --force-replay to override (the output records a_sidecar_verify + b_sidecar_verify).\n`);
|
|
5912
|
+
emit({
|
|
5913
|
+
ok: false,
|
|
5914
|
+
error: `attest diff: ${sides} attestation failed signature verification — refusing to diff`,
|
|
5915
|
+
verb: "attest diff",
|
|
5916
|
+
a_session: sessionId,
|
|
5917
|
+
b_session: prior.sessionId,
|
|
5918
|
+
a_sidecar_verify: aSidecarVerify,
|
|
5919
|
+
b_sidecar_verify: bSidecarVerify,
|
|
5920
|
+
hint: "If a sidecar was intentionally removed/rotated and you have inspected the attestation, pass --force-replay.",
|
|
5921
|
+
}, pretty);
|
|
5922
|
+
process.exitCode = EXIT_CODES.TAMPERED;
|
|
5923
|
+
return;
|
|
5924
|
+
}
|
|
5765
5925
|
emit({
|
|
5766
5926
|
verb: "attest diff",
|
|
5767
5927
|
a_session: sessionId,
|
|
@@ -5772,7 +5932,11 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
5772
5932
|
a_evidence_hash: self.evidence_hash,
|
|
5773
5933
|
b_evidence_hash: other.evidence_hash,
|
|
5774
5934
|
status,
|
|
5775
|
-
sidecar_verify
|
|
5935
|
+
// Retain `sidecar_verify` (A-side) for back-compat; add a_/b_ pair so the
|
|
5936
|
+
// default branch's output shape matches the --against branch.
|
|
5937
|
+
sidecar_verify: aSidecarVerify,
|
|
5938
|
+
a_sidecar_verify: aSidecarVerify,
|
|
5939
|
+
b_sidecar_verify: bSidecarVerify,
|
|
5776
5940
|
artifact_diff: diffArtifacts(
|
|
5777
5941
|
normalizedArtifacts(self.submission, runner, self.playbook_id),
|
|
5778
5942
|
normalizedArtifacts(other.submission, runner, other.playbook_id),
|
|
@@ -6169,10 +6333,43 @@ function normalizedSignalOverrides(submission, runner, playbookId) {
|
|
|
6169
6333
|
return _playbookSignalCatalog(runner, playbookId) || {};
|
|
6170
6334
|
}
|
|
6171
6335
|
|
|
6336
|
+
/**
|
|
6337
|
+
* Order-insensitive JSON serializer for the per-field artifact comparison.
|
|
6338
|
+
* Object keys are sorted recursively so two artifacts that differ ONLY in
|
|
6339
|
+
* key insertion order compare equal — matching the key-sorted canonical form
|
|
6340
|
+
* that evidence_hash (and therefore top-level `status`) already uses. Without
|
|
6341
|
+
* this, a side stored as nested `{captured, value}` (raw operator order) vs a
|
|
6342
|
+
* side normalized to `{value, captured}` serialized unequal under
|
|
6343
|
+
* JSON.stringify, so `artifact_diff.changed[]` reported a false "changed"
|
|
6344
|
+
* while `status` said "unchanged" — a self-contradicting diff. Depth-bounded
|
|
6345
|
+
* to defend against adversarial/cyclic input; callers treat a throw as
|
|
6346
|
+
* "cannot canonicalize" and fall back to raw stringify (diff output is
|
|
6347
|
+
* non-fatal context, never a gate).
|
|
6348
|
+
*/
|
|
6349
|
+
function stableArtifactStringify(v, depth = 0) {
|
|
6350
|
+
if (depth > 200) throw new Error("artifact too deep to canonicalize");
|
|
6351
|
+
if (v === null || typeof v !== "object") return JSON.stringify(v);
|
|
6352
|
+
if (Array.isArray(v)) {
|
|
6353
|
+
return "[" + v.map((x) => stableArtifactStringify(x, depth + 1)).join(",") + "]";
|
|
6354
|
+
}
|
|
6355
|
+
const keys = Object.keys(v).sort();
|
|
6356
|
+
return "{" + keys.map((k) => JSON.stringify(k) + ":" + stableArtifactStringify(v[k], depth + 1)).join(",") + "}";
|
|
6357
|
+
}
|
|
6358
|
+
|
|
6359
|
+
function artifactsDiffer(av, bv) {
|
|
6360
|
+
try {
|
|
6361
|
+
return stableArtifactStringify(av) !== stableArtifactStringify(bv);
|
|
6362
|
+
} catch {
|
|
6363
|
+
// Canonicalization bailed (too deep / cyclic) — fall back to the
|
|
6364
|
+
// order-sensitive comparison rather than masking a real difference.
|
|
6365
|
+
return JSON.stringify(av) !== JSON.stringify(bv);
|
|
6366
|
+
}
|
|
6367
|
+
}
|
|
6368
|
+
|
|
6172
6369
|
/**
|
|
6173
6370
|
* Per-artifact diff between two submissions. Returns { added, removed, changed }
|
|
6174
|
-
* keyed by artifact id. Used by `attest diff`
|
|
6175
|
-
*
|
|
6371
|
+
* keyed by artifact id. Used by `attest diff` so operators get field-level
|
|
6372
|
+
* context instead of a binary evidence_hash signal.
|
|
6176
6373
|
*/
|
|
6177
6374
|
function diffArtifacts(a, b) {
|
|
6178
6375
|
a = a || {}; b = b || {};
|
|
@@ -6187,7 +6384,7 @@ function diffArtifacts(a, b) {
|
|
|
6187
6384
|
out.added.push({ id, captured: !!bv.captured, value_preview: previewValue(bv.value) });
|
|
6188
6385
|
} else if (av && !bv) {
|
|
6189
6386
|
out.removed.push({ id, captured: !!av.captured, value_preview: previewValue(av.value) });
|
|
6190
|
-
} else if (av && bv &&
|
|
6387
|
+
} else if (av && bv && artifactsDiffer(av, bv)) {
|
|
6191
6388
|
out.changed.push({
|
|
6192
6389
|
id,
|
|
6193
6390
|
a_captured: !!av.captured, b_captured: !!bv.captured,
|
|
@@ -9101,4 +9298,7 @@ module.exports = {
|
|
|
9101
9298
|
_isTamperedSidecarVerify: isTamperedSidecarVerify,
|
|
9102
9299
|
_classifySidecarVerify: classifySidecarVerify,
|
|
9103
9300
|
_verifyAttestationSidecar: verifyAttestationSidecar,
|
|
9301
|
+
_emit: emit,
|
|
9302
|
+
_diffArtifacts: diffArtifacts,
|
|
9303
|
+
_resolveSelfAttestation: resolveSelfAttestation,
|
|
9104
9304
|
};
|
package/data/_indexes/_meta.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema_version": "1.1.0",
|
|
3
|
-
"generated_at": "2026-06-
|
|
3
|
+
"generated_at": "2026-06-13T06:23:47.498Z",
|
|
4
4
|
"generator": "scripts/build-indexes.js",
|
|
5
5
|
"source_count": 64,
|
|
6
6
|
"source_hashes": {
|
|
7
|
-
"manifest.json": "
|
|
7
|
+
"manifest.json": "70fea689545e1145b6e4e55621b07083d54e657372d1293406697cbcdd216fbb",
|
|
8
8
|
"README.md": "e7b854e7db9a364a1b368b5084b4f0c2a8282f0459ce39800ac1d1dabdc06074",
|
|
9
9
|
"data/atlas-ttps.json": "29f3447ac5c45f42f50b3ed8a46010c2b8ecbcc8094bb19b5db57ba4707b396c",
|
|
10
10
|
"data/attack-techniques.json": "6506db66fdd69bb3564e12aef8f727edddc55d0e6e99f60833a200a57e8ee65e",
|
|
@@ -36,6 +36,12 @@
|
|
|
36
36
|
"description": "At least one TLS library must be present and queryable. If none, the host has no crypto surface this playbook scopes.",
|
|
37
37
|
"check": "exists_any(['openssl', 'libssl.so*', 'libcrypto.so*']) == true",
|
|
38
38
|
"on_fail": "warn"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"id": "linux-platform",
|
|
42
|
+
"description": "Host must be Linux — crypto enumeration reads /etc/ssh and invokes the system openssl/ssh binaries.",
|
|
43
|
+
"check": "host.platform == 'linux'",
|
|
44
|
+
"on_fail": "halt"
|
|
39
45
|
}
|
|
40
46
|
],
|
|
41
47
|
"mutex": [],
|
package/lib/collectors/README.md
CHANGED
|
@@ -62,8 +62,9 @@ exceptd collect <playbook> | exceptd run <playbook> --evidence - # full loop
|
|
|
62
62
|
Exit codes:
|
|
63
63
|
|
|
64
64
|
- `0` — submission emitted successfully (operator should check `collector_errors[]` for partial-evidence warnings)
|
|
65
|
-
- `1` — no collector exists for the playbook id (the AI-evidence path remains)
|
|
66
|
-
|
|
65
|
+
- `1` — failure: either no collector exists for the playbook id (the AI-evidence path remains) **or** the collector threw an unhandled exception (file a bug). Both go through the shared error path, so both exit `1`; the JSON envelope on stderr distinguishes them — `type: "collector_not_found"` for the missing-collector case, an `"threw an unhandled exception"` message plus a `stack` for the crash case.
|
|
66
|
+
|
|
67
|
+
Run `exceptd doctor --exit-codes` for the full exit-code map. Code `2` is reserved for the CI escalation gate (`detected` classification), not used by `collect`.
|
|
67
68
|
|
|
68
69
|
## When to write a collector
|
|
69
70
|
|
package/lib/cross-ref-api.js
CHANGED
|
@@ -119,6 +119,45 @@ function entries(catalog) {
|
|
|
119
119
|
return Object.entries(catalog).filter(([k]) => !k.startsWith('_'));
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
// Single source of truth for the xref sub-maps the skill-correlation
|
|
123
|
+
// queries read. These names MUST stay identical to the keys the index
|
|
124
|
+
// builder emits into data/_indexes/xref.json; reading under a name the
|
|
125
|
+
// builder never writes silently yields empty correlations. The TTP maps
|
|
126
|
+
// are split by id space — ATLAS ids (AML.*) live in atlas_refs, ATT&CK
|
|
127
|
+
// ids (T*) in attack_refs — so a TTP lookup unions both.
|
|
128
|
+
const XREF_KEYS = {
|
|
129
|
+
cwe: 'cwe_refs',
|
|
130
|
+
atlas: 'atlas_refs',
|
|
131
|
+
attack: 'attack_refs',
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// CWE -> [skill, ...] from the xref index.
|
|
135
|
+
function skillsForCwe(xref, cweId) {
|
|
136
|
+
return (xref[XREF_KEYS.cwe] && xref[XREF_KEYS.cwe][cweId]) || [];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// TTP -> [skill, ...]; ATLAS and ATT&CK ids occupy separate maps, so a
|
|
140
|
+
// single id resolves through whichever map owns its prefix (with a fall
|
|
141
|
+
// back to the other in case a caller passes an unprefixed id).
|
|
142
|
+
function skillsForTtp(xref, ttpId) {
|
|
143
|
+
const atlas = xref[XREF_KEYS.atlas] || {};
|
|
144
|
+
const attack = xref[XREF_KEYS.attack] || {};
|
|
145
|
+
return (ttpId.startsWith('AML.') ? atlas[ttpId] : attack[ttpId]) || atlas[ttpId] || attack[ttpId] || [];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// No CVE->skill map exists in the index (no skill declares a CVE list, so
|
|
149
|
+
// the builder never emits one). The real linkage runs through the CVE's
|
|
150
|
+
// declared CWEs: each CWE maps to skills via the cwe_refs map. Union the
|
|
151
|
+
// skills across every CWE the CVE references, sorted + de-duplicated so
|
|
152
|
+
// the result is stable regardless of CWE ordering.
|
|
153
|
+
function skillsForCve(xref, cveEntry) {
|
|
154
|
+
const out = new Set();
|
|
155
|
+
for (const cwe of (cveEntry && cveEntry.cwe_refs) || []) {
|
|
156
|
+
for (const skill of skillsForCwe(xref, cwe)) out.add(skill);
|
|
157
|
+
}
|
|
158
|
+
return [...out].sort();
|
|
159
|
+
}
|
|
160
|
+
|
|
122
161
|
// --- public API ---
|
|
123
162
|
|
|
124
163
|
/**
|
|
@@ -149,19 +188,28 @@ function byCve(cveId, opts) {
|
|
|
149
188
|
const gaps = loadCatalog('framework-control-gaps.json');
|
|
150
189
|
const lessons = loadCatalog('zeroday-lessons.json');
|
|
151
190
|
|
|
152
|
-
|
|
191
|
+
// Skills correlate to a CVE transitively through its declared CWEs
|
|
192
|
+
// (CVE -> cwe_refs -> xref.cwe_refs -> skills); there is no direct
|
|
193
|
+
// CVE->skill index.
|
|
194
|
+
const skills = skillsForCve(xref, entry);
|
|
153
195
|
// (Recipes are use-case curated, not CVE-triggered — recipes.json has no
|
|
154
196
|
// `triggered_by`/CVE keying, so a per-CVE recipe lookup was always empty.
|
|
155
197
|
// The dead `recipes:[]` field is no longer emitted.)
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
198
|
+
//
|
|
199
|
+
// Theater fingerprints live under the index's `patterns` container; each
|
|
200
|
+
// pattern records a single `evidence.cve` (or `evidence.campaign`, which
|
|
201
|
+
// carries no CVE to match). The distinguishing check is `fast_test`.
|
|
202
|
+
const theater = Object.entries(theaterFp.patterns || {})
|
|
203
|
+
.filter(([, t]) => t && t.evidence && t.evidence.cve === cveId)
|
|
204
|
+
.map(([id, t]) => ({ id, pattern_name: t.pattern_name, distinguisher: t.fast_test }));
|
|
205
|
+
// Framework-control-gaps link CVEs through `evidence_cves`; the control
|
|
206
|
+
// identifier field is `control_id`.
|
|
159
207
|
const framework_gaps = entries(gaps).filter(([, g]) =>
|
|
160
|
-
Array.isArray(g.
|
|
161
|
-
).map(([id, g]) => ({ id, framework: g.framework, control: g.
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
208
|
+
Array.isArray(g.evidence_cves) && g.evidence_cves.includes(cveId)
|
|
209
|
+
).map(([id, g]) => ({ id, framework: g.framework, control: g.control_id, status: g.status }));
|
|
210
|
+
// Zero-day lessons are keyed by CVE id, so a referenced lesson is a
|
|
211
|
+
// direct key hit rather than a back-reference scan.
|
|
212
|
+
const lessons_learned = lessons[cveId] ? [cveId] : [];
|
|
165
213
|
|
|
166
214
|
return {
|
|
167
215
|
found: true,
|
|
@@ -185,7 +233,7 @@ function byCwe(cweId) {
|
|
|
185
233
|
const entry = catalog[cweId];
|
|
186
234
|
if (!entry) return { found: false, cwe_id: cweId };
|
|
187
235
|
const xref = loadIndex('xref.json');
|
|
188
|
-
const skills = (xref
|
|
236
|
+
const skills = skillsForCwe(xref, cweId).slice();
|
|
189
237
|
const relatedCves = entries(loadCatalog('cve-catalog.json'))
|
|
190
238
|
.filter(([, c]) => Array.isArray(c.cwe_refs) && c.cwe_refs.includes(cweId))
|
|
191
239
|
.map(([id]) => id);
|
|
@@ -196,7 +244,7 @@ function byTtp(ttpId) {
|
|
|
196
244
|
const atlas = loadCatalog('atlas-ttps.json');
|
|
197
245
|
const xref = loadIndex('xref.json');
|
|
198
246
|
const entry = atlas[ttpId] || null;
|
|
199
|
-
const skills = (xref
|
|
247
|
+
const skills = skillsForTtp(xref, ttpId).slice();
|
|
200
248
|
const relatedCves = entries(loadCatalog('cve-catalog.json'))
|
|
201
249
|
.filter(([, c]) =>
|
|
202
250
|
(Array.isArray(c.atlas_refs) && c.atlas_refs.includes(ttpId)) ||
|
|
@@ -213,25 +261,34 @@ function bySkill(skillName) {
|
|
|
213
261
|
const xref = loadIndex('xref.json');
|
|
214
262
|
const summary = loadIndex('summary-cards.json');
|
|
215
263
|
const card = summary[skillName] || summary.skills?.[skillName] || null;
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
264
|
+
// TTPs invert the atlas_refs + attack_refs maps: any TTP whose skill
|
|
265
|
+
// list contains this skill is a reference. Both id spaces contribute.
|
|
266
|
+
const ttpRefs = Object.entries({
|
|
267
|
+
...(xref[XREF_KEYS.atlas] || {}),
|
|
268
|
+
...(xref[XREF_KEYS.attack] || {}),
|
|
269
|
+
})
|
|
220
270
|
.filter(([, skills]) => Array.isArray(skills) && skills.includes(skillName))
|
|
221
|
-
.map(([ttp]) => ttp)
|
|
271
|
+
.map(([ttp]) => ttp)
|
|
272
|
+
.sort();
|
|
273
|
+
// CVEs link to a skill transitively: a CVE references CWEs, and each CWE
|
|
274
|
+
// maps to skills via cwe_refs. Collect every CVE whose CWE set resolves
|
|
275
|
+
// to this skill.
|
|
276
|
+
const cveCatalog = loadCatalog('cve-catalog.json');
|
|
277
|
+
const cveRefs = entries(cveCatalog)
|
|
278
|
+
.filter(([, c]) => (c.cwe_refs || []).some(cwe => skillsForCwe(xref, cwe).includes(skillName)))
|
|
279
|
+
.map(([cve]) => cve)
|
|
280
|
+
.sort();
|
|
222
281
|
return { skill: skillName, summary_card: card, cve_refs: cveRefs, ttp_refs: ttpRefs };
|
|
223
282
|
}
|
|
224
283
|
|
|
225
|
-
function byFramework(frameworkId
|
|
284
|
+
function byFramework(frameworkId) {
|
|
226
285
|
const gaps = loadCatalog('framework-control-gaps.json');
|
|
227
286
|
const global = loadCatalog('global-frameworks.json');
|
|
228
|
-
const matching = entries(gaps)
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
return true;
|
|
232
|
-
}).map(([id, g]) => ({ id, ...g }));
|
|
287
|
+
const matching = entries(gaps)
|
|
288
|
+
.filter(([, g]) => g.framework === frameworkId || g.framework === 'ALL')
|
|
289
|
+
.map(([id, g]) => ({ id, ...g }));
|
|
233
290
|
const fwMeta = global[frameworkId] || null;
|
|
234
|
-
return { framework: frameworkId,
|
|
291
|
+
return { framework: frameworkId, framework_meta: fwMeta, gaps: matching, gap_count: matching.length };
|
|
235
292
|
}
|
|
236
293
|
|
|
237
294
|
/**
|
|
@@ -242,12 +299,20 @@ function byFramework(frameworkId, scenario) {
|
|
|
242
299
|
function theaterTestsFor({ cveIds = [], frameworkIds = [], skillIds = [] }) {
|
|
243
300
|
const fp = loadIndex('theater-fingerprints.json');
|
|
244
301
|
const matches = [];
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
302
|
+
// Fingerprints are nested under the index's `patterns` container, not at
|
|
303
|
+
// the top level. Each pattern records a single `evidence.cve`, a list of
|
|
304
|
+
// `controls` (each {framework, control_id}), and a `source_skill`. A
|
|
305
|
+
// framework match accepts either the bare control id ("SI-2") or the
|
|
306
|
+
// qualified "framework::control_id" form the by_control index keys on.
|
|
307
|
+
for (const [id, t] of Object.entries(fp.patterns || {})) {
|
|
308
|
+
if (!t) continue;
|
|
309
|
+
const cveMatch = t.evidence && cveIds.includes(t.evidence.cve);
|
|
310
|
+
const fwMatch = (t.controls || []).some(c =>
|
|
311
|
+
frameworkIds.includes(c.control_id) || frameworkIds.includes(`${c.framework}::${c.control_id}`)
|
|
312
|
+
);
|
|
313
|
+
const skillMatch = skillIds.includes(t.source_skill);
|
|
249
314
|
if (cveMatch || fwMatch || skillMatch) {
|
|
250
|
-
matches.push({ id,
|
|
315
|
+
matches.push({ id, pattern_name: t.pattern_name, distinguisher: t.fast_test, controls: t.controls });
|
|
251
316
|
}
|
|
252
317
|
}
|
|
253
318
|
return matches;
|
|
@@ -264,12 +329,12 @@ function globalFrameworkContext({ cveIds = [], ttpIds = [] }) {
|
|
|
264
329
|
const ttpSet = new Set(ttpIds);
|
|
265
330
|
const grouped = {};
|
|
266
331
|
for (const [id, g] of entries(gaps)) {
|
|
267
|
-
const cveHit = (g.
|
|
268
|
-
const ttpHit = (g.
|
|
332
|
+
const cveHit = (g.evidence_cves || []).some(c => cveSet.has(c));
|
|
333
|
+
const ttpHit = [...(g.atlas_refs || []), ...(g.attack_refs || [])].some(t => ttpSet.has(t));
|
|
269
334
|
if (!cveHit && !ttpHit) continue;
|
|
270
335
|
const fw = g.framework || 'unspecified';
|
|
271
336
|
grouped[fw] = grouped[fw] || [];
|
|
272
|
-
grouped[fw].push({ id, control: g.
|
|
337
|
+
grouped[fw].push({ id, control: g.control_id, control_name: g.control_name, status: g.status });
|
|
273
338
|
}
|
|
274
339
|
return grouped;
|
|
275
340
|
}
|