@blamejs/exceptd-skills 0.16.29 → 0.16.31

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 CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.16.31 — 2026-06-13
4
+
5
+ The data refresh no longer overwrites a curator-pinned CVSS score or vector with NVD's same-version re-score. A curated catalog entry — the hand-verified norm — keeps its maintainer-set CVSS; the NVD delta is surfaced in the refresh report for a maintainer to accept deliberately, instead of silently lowering a curated 10.0 to NVD's 9.8. Raw auto-imported drafts, which are not yet curated, still take NVD's score directly. This extends the existing curated-data protections — the CVSS version-downgrade guard and the CISA-KEV de-listing guard — to same-version CVSS re-scores: an upstream that disagrees with curated intel is surfaced, never silently applied.
6
+
7
+ ## 0.16.30 — 2026-06-12
8
+
9
+ 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.
10
+
11
+ 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.
12
+
13
+ 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.
14
+
15
+ `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.
16
+
17
+ `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.
18
+
19
+ 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.
20
+
3
21
  ## 0.16.29 — 2026-06-12
4
22
 
5
23
  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
- if (obj && typeof obj === 'object' && !('ok' in obj)) {
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
- try { parsed = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")); }
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
- else attestations.push(parsed);
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 self = attestations[0];
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
- const aSidecarVerify = verifyAttestationSidecar(path.join(dir, "attestation.json"));
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 self = attestations[0];
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
- const sidecarVerify = verifyAttestationSidecar(path.join(dir, "attestation.json"));
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: sidecarVerify,
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` (bug #34 fix) so operators get
6175
- * field-level context instead of a binary evidence_hash signal.
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 && JSON.stringify(av) !== JSON.stringify(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
  };
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "schema_version": "1.1.0",
3
- "generated_at": "2026-06-12T07:53:17.256Z",
3
+ "generated_at": "2026-06-13T14:42:56.398Z",
4
4
  "generator": "scripts/build-indexes.js",
5
5
  "source_count": 64,
6
6
  "source_hashes": {
7
- "manifest.json": "7cdbf86213bc03cc55f8cd1ec5516f7c492177f0968c27216beabf68fdd68ef1",
7
+ "manifest.json": "c11cdd490e2de2c62ce2d25caa613abdf8d425419efe1e9e7b3ab356686c664f",
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": [],
@@ -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
- - `2` — collector threw an unhandled exception (file a bug)
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
 
@@ -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
- const skills = (xref[cveId] || xref.cves?.[cveId] || []).slice();
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
- const theater = entries(theaterFp).filter(([, t]) =>
157
- Array.isArray(t.cve_refs) && t.cve_refs.includes(cveId)
158
- ).map(([id, t]) => ({ id, distinguisher: t.distinguisher || t.test }));
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.cve_refs) && g.cve_refs.includes(cveId)
161
- ).map(([id, g]) => ({ id, framework: g.framework, control: g.control, status: g.status }));
162
- const lessons_learned = entries(lessons).filter(([, l]) =>
163
- Array.isArray(l.cve_refs) && l.cve_refs.includes(cveId)
164
- ).map(([id]) => id);
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.cwes?.[cweId] || []).slice();
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.ttps?.[ttpId] || []).slice();
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
- const cveRefs = Object.entries(xref.cves || {})
217
- .filter(([, skills]) => Array.isArray(skills) && skills.includes(skillName))
218
- .map(([cve]) => cve);
219
- const ttpRefs = Object.entries(xref.ttps || {})
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, scenario) {
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).filter(([, g]) => {
229
- if (g.framework !== frameworkId && g.framework !== 'ALL') return false;
230
- if (scenario && Array.isArray(g.scenarios) && !g.scenarios.includes(scenario)) return false;
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, scenario: scenario || null, framework_meta: fwMeta, gaps: matching, gap_count: matching.length };
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
- for (const [id, t] of entries(fp)) {
246
- const cveMatch = cveIds.some(c => (t.cve_refs || []).includes(c));
247
- const fwMatch = frameworkIds.some(f => (t.framework_refs || []).includes(f));
248
- const skillMatch = skillIds.some(s => (t.skill_refs || []).includes(s));
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, distinguisher: t.distinguisher || t.test, applies_when: t.applies_when });
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.cve_refs || []).some(c => cveSet.has(c));
268
- const ttpHit = (g.ttp_refs || []).some(t => ttpSet.has(t));
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.control, status: g.status, scenarios: g.scenarios });
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
  }