@blamejs/exceptd-skills 0.16.28 → 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 +28 -0
- package/README.md +1 -1
- package/bin/exceptd.js +251 -18
- package/data/_indexes/_meta.json +4 -3
- package/data/_indexes/jurisdiction-map.json +31 -158
- package/data/playbooks/crypto.json +6 -0
- package/lib/auto-discovery.js +8 -0
- package/lib/collectors/README.md +3 -2
- package/lib/collectors/library-author.js +26 -9
- package/lib/collectors/secrets.js +8 -1
- package/lib/cross-ref-api.js +96 -31
- package/lib/lint-skills.js +6 -1
- package/lib/playbook-runner.js +264 -52
- package/lib/prefetch.js +78 -6
- package/lib/refresh-external.js +106 -5
- package/lib/scoring.js +49 -5
- package/lib/validate-cve-catalog.js +14 -2
- package/lib/validate-indexes.js +5 -0
- package/lib/validate-playbooks.js +133 -38
- package/manifest.json +53 -53
- package/orchestrator/pipeline.js +16 -4
- package/package.json +1 -1
- package/sbom.cdx.json +73 -58
- package/scripts/build-indexes.js +12 -1
- package/scripts/check-sbom-currency.js +76 -14
- package/scripts/refresh-sbom.js +1 -1
- package/scripts/run-e2e-scenarios.js +41 -11
- package/scripts/sync-package-description.js +74 -0
- package/scripts/verify-shipped-tarball.js +18 -7
- package/sources/validators/cve-validator.js +16 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
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
|
+
|
|
17
|
+
## 0.16.29 — 2026-06-12
|
|
18
|
+
|
|
19
|
+
A correctness pass across the refresh pipeline, scoring, attestation, the collectors, and offline mode.
|
|
20
|
+
|
|
21
|
+
`refresh --apply` over the network no longer downgrades a curated CVSS 3.1 score and vector to NVD's legacy v2 metric on older CVEs — the offline cache path already guarded this in 0.16.27, and the live path now applies the same cross-version guard. New-RFC discovery now honors `--air-gap` (it previously queried IETF Datatracker live regardless), and an intrinsically air-gapped playbook — secrets, cred-stores, containers — refuses the `--upstream-check` npm-registry probe without the explicit flag. The `--from-cache` help no longer implies new-RFC discovery is offline; it stays live unless `--air-gap` is also passed.
|
|
22
|
+
|
|
23
|
+
A CVE that a VEX statement marks fixed no longer inflates a finding's adjusted RWEP through its exploitation, KEV, and proof-of-concept multipliers, and a patched CVE's exploitation status no longer drives the notification draft. Jurisdiction coverage no longer attributes a skill to a jurisdiction from a bare two-letter ISO code that appears only in prose or inside a control identifier; coverage is driven by the regulation-name mapping. The skill-currency staleness check can now reach the warn and critical tiers it gates on — they were unreachable, so the scheduled currency workflow could never flag a skill past its review window; a genuinely abandoned skill now scores into them while a maintained one does not.
|
|
24
|
+
|
|
25
|
+
`attest diff --against` now verifies the comparison attestation's Ed25519 signature, not only the local side, and refuses a tampered `--against` attestation (exit 6; `--force-replay` overrides). Both sides' verification is recorded in the output.
|
|
26
|
+
|
|
27
|
+
The collectors no longer raise false findings from `#`-commented YAML — a commented `npm install`, `runs-on: self-hosted`, or `secrets.NPM_TOKEN` is no longer read as the real thing — and a commented `npm publish --provenance` no longer suppresses the missing-build-provenance finding. A documentation or redaction-pattern snippet of a service-account private key no longer registers as an embedded secret. A skill.md with CRLF line endings no longer produces a misleading frontmatter-parse error, and the `run --format` reference now lists `json`, which the runtime already accepts.
|
|
28
|
+
|
|
29
|
+
The scheduled external-data refresh keeps the package description's entry counts in sync with the data it applies, so an auto-refresh that changes a count no longer fails the SBOM currency check on its pull request.
|
|
30
|
+
|
|
3
31
|
## 0.16.28 — 2026-06-10
|
|
4
32
|
|
|
5
33
|
Refreshes the pinned MITRE threat-framework versions. MITRE ATLAS is now pinned to v2026.05: its content moved to a YYYY.MM calendar-versioning scheme, and the release adds platform tags (Predictive AI, Generative AI, Agentic AI, Enterprise) to every technique. MITRE ATT&CK is pinned to v19.1, a point release of typo and data corrections over v19.0. Both bumps were audited against every ATLAS and ATT&CK technique ID the catalog cites: none was removed, renamed, or revoked, so all existing references remain valid.
|
package/README.md
CHANGED
|
@@ -228,7 +228,7 @@ exceptd run [playbook] Phases 4-7. Auto-detects cwd context when
|
|
|
228
228
|
--evidence-dir <dir> Per-playbook submission files (cron-friendly).
|
|
229
229
|
--scope <type> | --all Multi-playbook run.
|
|
230
230
|
--vex <file> CycloneDX / OpenVEX filter (drop not_affected).
|
|
231
|
-
--format <fmt> ... csaf-2.0 | sarif | openvex | markdown | summary.
|
|
231
|
+
--format <fmt> ... csaf-2.0 | sarif | openvex | markdown | summary | json.
|
|
232
232
|
Repeatable. CSAF is primary; extras go to
|
|
233
233
|
close.evidence_package.bundles_by_format.
|
|
234
234
|
--diff-from-latest Drift vs prior attestation for same playbook.
|
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
|
}
|
|
@@ -3406,8 +3461,10 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
3406
3461
|
// Audit 3 A.6: --air-gap must refuse the registry probe. The
|
|
3407
3462
|
// upstream-check helper has no air-gap awareness of its own; the
|
|
3408
3463
|
// central refusal lives here so any future caller of --upstream-check
|
|
3409
|
-
// inherits it.
|
|
3410
|
-
|
|
3464
|
+
// inherits it. Mirror the line-3444 hoist: an intrinsically air-gapped
|
|
3465
|
+
// playbook (_meta.air_gap_mode — secrets / cred-stores / containers) must
|
|
3466
|
+
// refuse the egress too, even without the explicit --air-gap flag.
|
|
3467
|
+
if (runOpts.airGap || process.env.EXCEPTD_AIR_GAP === "1" || pb._meta?.air_gap_mode) {
|
|
3411
3468
|
upstreamCheck = {
|
|
3412
3469
|
ok: false,
|
|
3413
3470
|
source: "air-gap",
|
|
@@ -4932,6 +4989,31 @@ function verifyAttestationSidecar(attFile) {
|
|
|
4932
4989
|
}
|
|
4933
4990
|
}
|
|
4934
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
|
+
}
|
|
4935
5017
|
return { file: attFile, signed: false, verified: false, reason: "no .sig sidecar" };
|
|
4936
5018
|
}
|
|
4937
5019
|
let sigDoc;
|
|
@@ -5016,6 +5098,39 @@ function verifyAttestationSidecar(attFile) {
|
|
|
5016
5098
|
}
|
|
5017
5099
|
}
|
|
5018
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
|
+
|
|
5019
5134
|
/**
|
|
5020
5135
|
* `attest prune --all-older-than <ISO>` — GC for attestation growth.
|
|
5021
5136
|
*
|
|
@@ -5482,6 +5597,12 @@ function isTamperedSidecarVerify(verify) {
|
|
|
5482
5597
|
// swapped key proves nothing, so replay refuses exactly like the
|
|
5483
5598
|
// other tamper classes (attest verify already refuses on this).
|
|
5484
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"
|
|
5485
5606
|
);
|
|
5486
5607
|
return Boolean(isSignedTamper || isClassTamper);
|
|
5487
5608
|
}
|
|
@@ -5599,14 +5720,22 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
5599
5720
|
// sessions. Gate on the parsed payload — not filename prefix — so a
|
|
5600
5721
|
// renamed file cannot smuggle a replay into the attestations[] list.
|
|
5601
5722
|
const attestations = [];
|
|
5723
|
+
const attestationFiles = [];
|
|
5602
5724
|
const replays = [];
|
|
5603
5725
|
for (const f of files) {
|
|
5604
5726
|
let parsed;
|
|
5605
|
-
|
|
5727
|
+
const fp = path.join(dir, f);
|
|
5728
|
+
try { parsed = JSON.parse(fs.readFileSync(fp, "utf8")); }
|
|
5606
5729
|
catch { continue; }
|
|
5607
5730
|
if (!parsed) continue;
|
|
5608
5731
|
if (parsed.kind === "replay") replays.push(parsed);
|
|
5609
|
-
|
|
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); }
|
|
5610
5739
|
}
|
|
5611
5740
|
|
|
5612
5741
|
if (subverb === "show") {
|
|
@@ -5641,29 +5770,32 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
5641
5770
|
// attestation.json cannot shadow the real attestation in the
|
|
5642
5771
|
// diff.
|
|
5643
5772
|
let other = null;
|
|
5773
|
+
let otherPath = null;
|
|
5644
5774
|
const otherAttestationPath = path.join(otherDir, "attestation.json");
|
|
5645
5775
|
if (fs.existsSync(otherAttestationPath)) {
|
|
5646
5776
|
try {
|
|
5647
5777
|
const parsed = JSON.parse(fs.readFileSync(otherAttestationPath, "utf8"));
|
|
5648
|
-
if (parsed && parsed.kind !== "replay") other = parsed;
|
|
5778
|
+
if (parsed && parsed.kind !== "replay") { other = parsed; otherPath = otherAttestationPath; }
|
|
5649
5779
|
} catch { /* fall through to scan */ }
|
|
5650
5780
|
}
|
|
5651
5781
|
if (!other) {
|
|
5652
5782
|
const candidates = [];
|
|
5653
5783
|
for (const f of otherFiles) {
|
|
5654
5784
|
try {
|
|
5655
|
-
const
|
|
5785
|
+
const fp = path.join(otherDir, f);
|
|
5786
|
+
const parsed = JSON.parse(fs.readFileSync(fp, "utf8"));
|
|
5656
5787
|
if (!parsed || parsed.kind === "replay") continue;
|
|
5657
|
-
candidates.push(parsed);
|
|
5788
|
+
candidates.push({ parsed, file: fp });
|
|
5658
5789
|
} catch { /* skip malformed */ }
|
|
5659
5790
|
}
|
|
5660
|
-
candidates.sort((a, b) => (b.captured_at || "").localeCompare(a.captured_at || ""));
|
|
5661
|
-
other = candidates[0]
|
|
5791
|
+
candidates.sort((a, b) => (b.parsed.captured_at || "").localeCompare(a.parsed.captured_at || ""));
|
|
5792
|
+
if (candidates[0]) { other = candidates[0].parsed; otherPath = candidates[0].file; }
|
|
5662
5793
|
}
|
|
5663
5794
|
if (!other) {
|
|
5664
5795
|
return emitError(`attest diff --against ${args.against}: no attestations under that session id.`, null, pretty);
|
|
5665
5796
|
}
|
|
5666
|
-
const
|
|
5797
|
+
const selfResolved = resolveSelfAttestation(dir, attestations, attestationFiles);
|
|
5798
|
+
const self = selfResolved && selfResolved.parsed;
|
|
5667
5799
|
if (!self) {
|
|
5668
5800
|
// Session dir contains only replay records, no attestation —
|
|
5669
5801
|
// diff has nothing to compare on the A side.
|
|
@@ -5673,6 +5805,36 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
5673
5805
|
pretty
|
|
5674
5806
|
);
|
|
5675
5807
|
}
|
|
5808
|
+
// Verify BOTH attestations' sidecars — the --against (B-side) drives the
|
|
5809
|
+
// drift verdict as much as the A-side, so a forged comparison attestation
|
|
5810
|
+
// must be refused too, not silently diffed under an A-only green sidecar
|
|
5811
|
+
// line. Mirrors reattest's tamper-refusal contract (exit TAMPERED unless
|
|
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);
|
|
5817
|
+
const bSidecarVerify = otherPath
|
|
5818
|
+
? verifyAttestationSidecar(otherPath)
|
|
5819
|
+
: { file: null, signed: false, verified: false, reason: "no B-side attestation file resolved" };
|
|
5820
|
+
const aTampered = isTamperedSidecarVerify(aSidecarVerify);
|
|
5821
|
+
const bTampered = isTamperedSidecarVerify(bSidecarVerify);
|
|
5822
|
+
if ((aTampered || bTampered) && !args["force-replay"]) {
|
|
5823
|
+
const sides = [aTampered && "A-side", bTampered && "--against (B-side)"].filter(Boolean).join(" + ");
|
|
5824
|
+
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`);
|
|
5825
|
+
emit({
|
|
5826
|
+
ok: false,
|
|
5827
|
+
error: `attest diff: ${sides} attestation failed signature verification — refusing to diff`,
|
|
5828
|
+
verb: "attest diff",
|
|
5829
|
+
a_session: sessionId,
|
|
5830
|
+
b_session: args.against,
|
|
5831
|
+
a_sidecar_verify: aSidecarVerify,
|
|
5832
|
+
b_sidecar_verify: bSidecarVerify,
|
|
5833
|
+
hint: "If a sidecar was intentionally removed/rotated and you have inspected the attestation, pass --force-replay.",
|
|
5834
|
+
}, pretty);
|
|
5835
|
+
process.exitCode = EXIT_CODES.TAMPERED;
|
|
5836
|
+
return;
|
|
5837
|
+
}
|
|
5676
5838
|
emit({
|
|
5677
5839
|
verb: "attest diff",
|
|
5678
5840
|
a_session: sessionId,
|
|
@@ -5683,7 +5845,9 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
5683
5845
|
a_evidence_hash: self.evidence_hash,
|
|
5684
5846
|
b_evidence_hash: other.evidence_hash,
|
|
5685
5847
|
status: self.evidence_hash === other.evidence_hash ? "unchanged" : "drifted",
|
|
5686
|
-
sidecar_verify:
|
|
5848
|
+
sidecar_verify: aSidecarVerify,
|
|
5849
|
+
a_sidecar_verify: aSidecarVerify,
|
|
5850
|
+
b_sidecar_verify: bSidecarVerify,
|
|
5687
5851
|
// v0.11.8 (#102): normalize submissions before diffing so flat-shape
|
|
5688
5852
|
// (observations + verdict) submissions emit meaningful artifact_diff
|
|
5689
5853
|
// counts. Pre-0.11.8 (self.submission||{}).artifacts was undefined
|
|
@@ -5703,7 +5867,8 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
5703
5867
|
// No --against: find the most-recent prior attestation for the
|
|
5704
5868
|
// SAME playbook as `sessionId` and diff against that. Pure
|
|
5705
5869
|
// comparison — no replay.
|
|
5706
|
-
const
|
|
5870
|
+
const selfResolved = resolveSelfAttestation(dir, attestations, attestationFiles);
|
|
5871
|
+
const self = selfResolved && selfResolved.parsed;
|
|
5707
5872
|
if (!self) {
|
|
5708
5873
|
return emitError(
|
|
5709
5874
|
`attest diff ${sessionId}: no attestation found in session dir.`,
|
|
@@ -5728,7 +5893,35 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
5728
5893
|
}
|
|
5729
5894
|
const other = prior.parsed;
|
|
5730
5895
|
const status = self.evidence_hash === other.evidence_hash ? "unchanged" : "drifted";
|
|
5731
|
-
|
|
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
|
+
}
|
|
5732
5925
|
emit({
|
|
5733
5926
|
verb: "attest diff",
|
|
5734
5927
|
a_session: sessionId,
|
|
@@ -5739,7 +5932,11 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
5739
5932
|
a_evidence_hash: self.evidence_hash,
|
|
5740
5933
|
b_evidence_hash: other.evidence_hash,
|
|
5741
5934
|
status,
|
|
5742
|
-
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,
|
|
5743
5940
|
artifact_diff: diffArtifacts(
|
|
5744
5941
|
normalizedArtifacts(self.submission, runner, self.playbook_id),
|
|
5745
5942
|
normalizedArtifacts(other.submission, runner, other.playbook_id),
|
|
@@ -6136,10 +6333,43 @@ function normalizedSignalOverrides(submission, runner, playbookId) {
|
|
|
6136
6333
|
return _playbookSignalCatalog(runner, playbookId) || {};
|
|
6137
6334
|
}
|
|
6138
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
|
+
|
|
6139
6369
|
/**
|
|
6140
6370
|
* Per-artifact diff between two submissions. Returns { added, removed, changed }
|
|
6141
|
-
* keyed by artifact id. Used by `attest diff`
|
|
6142
|
-
*
|
|
6371
|
+
* keyed by artifact id. Used by `attest diff` so operators get field-level
|
|
6372
|
+
* context instead of a binary evidence_hash signal.
|
|
6143
6373
|
*/
|
|
6144
6374
|
function diffArtifacts(a, b) {
|
|
6145
6375
|
a = a || {}; b = b || {};
|
|
@@ -6154,7 +6384,7 @@ function diffArtifacts(a, b) {
|
|
|
6154
6384
|
out.added.push({ id, captured: !!bv.captured, value_preview: previewValue(bv.value) });
|
|
6155
6385
|
} else if (av && !bv) {
|
|
6156
6386
|
out.removed.push({ id, captured: !!av.captured, value_preview: previewValue(av.value) });
|
|
6157
|
-
} else if (av && bv &&
|
|
6387
|
+
} else if (av && bv && artifactsDiffer(av, bv)) {
|
|
6158
6388
|
out.changed.push({
|
|
6159
6389
|
id,
|
|
6160
6390
|
a_captured: !!av.captured, b_captured: !!bv.captured,
|
|
@@ -9068,4 +9298,7 @@ module.exports = {
|
|
|
9068
9298
|
_isTamperedSidecarVerify: isTamperedSidecarVerify,
|
|
9069
9299
|
_classifySidecarVerify: classifySidecarVerify,
|
|
9070
9300
|
_verifyAttestationSidecar: verifyAttestationSidecar,
|
|
9301
|
+
_emit: emit,
|
|
9302
|
+
_diffArtifacts: diffArtifacts,
|
|
9303
|
+
_resolveSelfAttestation: resolveSelfAttestation,
|
|
9071
9304
|
};
|
package/data/_indexes/_meta.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
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
|
-
"source_count":
|
|
5
|
+
"source_count": 64,
|
|
6
6
|
"source_hashes": {
|
|
7
|
-
"manifest.json": "
|
|
7
|
+
"manifest.json": "70fea689545e1145b6e4e55621b07083d54e657372d1293406697cbcdd216fbb",
|
|
8
|
+
"README.md": "e7b854e7db9a364a1b368b5084b4f0c2a8282f0459ce39800ac1d1dabdc06074",
|
|
8
9
|
"data/atlas-ttps.json": "29f3447ac5c45f42f50b3ed8a46010c2b8ecbcc8094bb19b5db57ba4707b396c",
|
|
9
10
|
"data/attack-techniques.json": "6506db66fdd69bb3564e12aef8f727edddc55d0e6e99f60833a200a57e8ee65e",
|
|
10
11
|
"data/cve-catalog.json": "51d8425a49e5cc0375d0a154a83a16816e99c3141a5bbafe6383607ca11be240",
|