@blamejs/exceptd-skills 0.12.18 → 0.12.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/exceptd.js CHANGED
@@ -58,6 +58,44 @@ const { spawnSync } = require("child_process");
58
58
  // (e.g. <somewhere>/node_modules/@blamejs/exceptd-skills).
59
59
  const PKG_ROOT = path.resolve(__dirname, "..");
60
60
 
61
+ /**
62
+ * Audit Q P1 + R F6: factor the EXPECTED_FINGERPRINT pin check used by
63
+ * the attestation pipeline. Centralizes the policy (compute live SHA-256
64
+ * fingerprint of the loaded public.pem, compare to keys/EXPECTED_FINGERPRINT,
65
+ * honor KEYS_ROTATED=1 bypass, tolerate missing pin file) so every site
66
+ * that loads keys/public.pem applies the same check.
67
+ *
68
+ * Returns null when the check passes (or when no pin file exists), or a
69
+ * human-readable error string when the pin diverges and the rotation env
70
+ * is not set. lib/verify.js exposes a parallel checkExpectedFingerprint()
71
+ * that operates on a precomputed fingerprint shape; this wrapper accepts
72
+ * the raw PEM directly so callers don't have to compute the fingerprint
73
+ * themselves.
74
+ */
75
+ function assertExpectedFingerprint(pubKeyPem) {
76
+ if (!pubKeyPem) return null;
77
+ const cryptoMod = require("crypto");
78
+ const pinPath = path.join(PKG_ROOT, "keys", "EXPECTED_FINGERPRINT");
79
+ if (!fs.existsSync(pinPath)) return null;
80
+ let liveFp;
81
+ try {
82
+ const ko = cryptoMod.createPublicKey(pubKeyPem);
83
+ const der = ko.export({ type: "spki", format: "der" });
84
+ liveFp = "SHA256:" + cryptoMod.createHash("sha256").update(der).digest("base64");
85
+ } catch (e) {
86
+ return `EXPECTED_FINGERPRINT check: failed to derive live fingerprint: ${e.message}`;
87
+ }
88
+ const raw = fs.readFileSync(pinPath, "utf8");
89
+ const firstLine = raw.split(/\r?\n/).map((l) => l.trim()).find((l) => l.length > 0) || "";
90
+ if (firstLine === liveFp) return null;
91
+ if (process.env.KEYS_ROTATED === "1") return null;
92
+ return (
93
+ `EXPECTED_FINGERPRINT mismatch: live=${liveFp} pin=${firstLine}. ` +
94
+ `If this is an intentional rotation, re-run with KEYS_ROTATED=1 and ` +
95
+ `update keys/EXPECTED_FINGERPRINT.`
96
+ );
97
+ }
98
+
61
99
  // Subcommand → resolved script path. Lazy-resolved per call so a missing
62
100
  // optional component (e.g. orchestrator/) just fails that one command
63
101
  // instead of crashing dispatcher init.
@@ -453,16 +491,25 @@ function main() {
453
491
  if (typeof resolver !== "function") {
454
492
  // Emit a structured JSON error matching the seven-phase verbs so operators
455
493
  // piping through `jq` get one consistent shape across the CLI surface.
456
- // Plain-text "unknown command" still reaches stderr for human readers.
457
- const err = { ok: false, error: `unknown command "${cmd}"`, hint: "Run `exceptd help` for the list of verbs.", verb: cmd };
458
- process.stderr.write(JSON.stringify(err) + "\n");
459
- process.exit(2);
494
+ // R-F8: pre-fix, the structured-JSON stderr write was followed by
495
+ // process.exit(2) the v0.11.10 truncation class applied to stderr
496
+ // just as it does to stdout. Route through emitError() (which uses
497
+ // exitCode + return per v0.12.14) so the JSON drains, then promote
498
+ // the exit code to 2 (unknown-command remains a distinct exit class).
499
+ emitError(`unknown command "${cmd}"`, { hint: "Run `exceptd help` for the list of verbs.", verb: cmd });
500
+ process.exitCode = 2;
501
+ return;
460
502
  }
461
503
 
462
504
  const script = resolver();
463
505
  if (!fs.existsSync(script)) {
464
- process.stderr.write(`exceptd: command "${cmd}" not available expected ${path.relative(PKG_ROOT, script)} in the installed package.\n`);
465
- process.exit(2);
506
+ // R-F8: same class emitError + exitCode rather than stderr + exit().
507
+ emitError(
508
+ `command "${cmd}" not available — expected ${path.relative(PKG_ROOT, script)} in the installed package.`,
509
+ { verb: cmd }
510
+ );
511
+ process.exitCode = 2;
512
+ return;
466
513
  }
467
514
 
468
515
  // Orchestrator subcommands need the subcommand name preserved as argv[0]
@@ -470,10 +517,15 @@ function main() {
470
517
  const finalArgs = ORCHESTRATOR_PASSTHROUGH.has(effectiveCmd) ? [script, effectiveCmd, ...effectiveRest] : [script, ...effectiveRest];
471
518
  const res = spawnSync(process.execPath, finalArgs, { stdio: "inherit", cwd: PKG_ROOT });
472
519
  if (res.error) {
473
- process.stderr.write(`exceptd: failed to run ${cmd}: ${res.error.message}\n`);
474
- process.exit(2);
520
+ // R-F8: same class emitError + exitCode.
521
+ emitError(`failed to run ${cmd}: ${res.error.message}`, { verb: cmd });
522
+ process.exitCode = 2;
523
+ return;
475
524
  }
476
- process.exit(typeof res.status === "number" ? res.status : 1);
525
+ // Propagate the child's exit status via exitCode so any buffered output
526
+ // from the child (rare with stdio:"inherit", possible on Windows) gets
527
+ // a chance to drain before the parent tears down.
528
+ process.exitCode = typeof res.status === "number" ? res.status : 1;
477
529
  }
478
530
 
479
531
  // ---------------------------------------------------------------------------
@@ -592,6 +644,26 @@ function loadRunner() {
592
644
  return require(path.join(PKG_ROOT, "lib", "playbook-runner.js"));
593
645
  }
594
646
 
647
+ /**
648
+ * R-F10: ISO-8601 shape regex applied BEFORE Date.parse. Pre-fix, both
649
+ * `attest list --since` and `reattest --since` accepted anything Date.parse
650
+ * could chew on — including bare integers like "99", which JavaScript
651
+ * happily resolves to 1999-12-01T00:00:00Z (Y2K-era millisecond / two-digit
652
+ * year heuristic). Operators got a "valid timestamp" check that silently
653
+ * filtered the wrong years. Now: require an explicit calendar-date shape
654
+ * (YYYY-MM-DD with optional time component) BEFORE handing to Date.parse.
655
+ *
656
+ * Returns null on success; returns the human-facing error message string
657
+ * on failure so the caller can wrap it with its own verb prefix.
658
+ */
659
+ const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/;
660
+ function validateIsoSince(raw) {
661
+ if (typeof raw !== "string" || !ISO_DATE_RE.test(raw) || isNaN(Date.parse(raw))) {
662
+ return `--since must be a parseable ISO-8601 calendar timestamp (e.g. 2026-05-01 or 2026-05-01T00:00:00Z). Got: ${JSON.stringify(String(raw)).slice(0, 80)}`;
663
+ }
664
+ return null;
665
+ }
666
+
595
667
  /**
596
668
  * F5: detect whether a parsed JSON document is plausibly CycloneDX VEX or
597
669
  * OpenVEX. The runner's vexFilterFromDoc returns Set(0) tolerantly for
@@ -613,8 +685,21 @@ function detectVexShape(doc) {
613
685
  // entries look vex-shaped (have id/bom_ref/analysis).
614
686
  if (Array.isArray(doc.vulnerabilities)) {
615
687
  const isBom = doc.bomFormat === "CycloneDX";
616
- const entriesLookVex = doc.vulnerabilities.length === 0
617
- || doc.vulnerabilities.some(v => v && typeof v === "object" && (v.id || v["bom-ref"] || v.bom_ref || v.analysis));
688
+ const specStr = typeof doc.specVersion === "string" ? doc.specVersion : "";
689
+ const hasCyclonedxMarker = isBom || specStr.startsWith("1.");
690
+ // R-F4: empty vulnerabilities arrays cannot vouch for CycloneDX shape
691
+ // on their own — `{"bomFormat":"NOT-CycloneDX","vulnerabilities":[]}`
692
+ // previously passed because `length === 0` always satisfied
693
+ // `entriesLookVex`. Require a real CycloneDX marker (bomFormat or
694
+ // specVersion) when the array is empty; non-empty arrays still pass
695
+ // when any entry has vex-shaped fields (id / bom-ref / analysis).
696
+ if (doc.vulnerabilities.length === 0) {
697
+ if (hasCyclonedxMarker) {
698
+ return { ok: true, detected: "cyclonedx-vex", top_level_keys: keys };
699
+ }
700
+ return { ok: false, detected: "empty-vulnerabilities-without-cyclonedx-marker", top_level_keys: keys };
701
+ }
702
+ const entriesLookVex = doc.vulnerabilities.some(v => v && typeof v === "object" && (v.id || v["bom-ref"] || v.bom_ref || v.analysis));
618
703
  if (isBom || entriesLookVex) {
619
704
  return { ok: true, detected: "cyclonedx-vex", top_level_keys: keys };
620
705
  }
@@ -1648,7 +1733,14 @@ function cmdRun(runner, args, runOpts, pretty) {
1648
1733
  // Multi-playbook dispatch path. Triggered by --all, --scope <type>, or by
1649
1734
  // a bare `exceptd run` (no positional, no flags) which auto-detects scopes
1650
1735
  // from the cwd.
1651
- if (!positional && (args.all || args.scope)) {
1736
+ // R-F9: gate on `args.scope !== undefined` rather than `args.scope`
1737
+ // truthy. Pre-fix, `--scope ""` parsed to `args.scope === ""`, which
1738
+ // is falsy — the dispatcher fell through to the auto-detect path and
1739
+ // silently ran whatever scopes happened to match the cwd, masking the
1740
+ // operator's explicit (if malformed) intent. Now: an empty string
1741
+ // reaches validateScopeOrThrow which rejects with the accepted-set
1742
+ // message, matching the rest of the v0.12.15 scope-validation contract.
1743
+ if (!positional && (args.all || args.scope !== undefined)) {
1652
1744
  let ids;
1653
1745
  if (args.all) {
1654
1746
  ids = runner.listPlaybooks();
@@ -1658,7 +1750,7 @@ function cmdRun(runner, args, runOpts, pretty) {
1658
1750
  }
1659
1751
  return cmdRunMulti(runner, ids, args, runOpts, pretty, { trigger: args.all ? "--all" : `--scope ${args.scope}` });
1660
1752
  }
1661
- if (!positional && !args.all && !args.scope) {
1753
+ if (!positional && !args.all && args.scope === undefined) {
1662
1754
  const scopes = detectScopes();
1663
1755
  const ids = scopes.flatMap(s => filterPlaybooksByScope(runner, s));
1664
1756
  const unique = [...new Set(ids)];
@@ -1722,11 +1814,16 @@ function cmdRun(runner, args, runOpts, pretty) {
1722
1814
  }
1723
1815
 
1724
1816
  let submission = {};
1725
- // v0.11.1: auto-detect piped stdin (process.stdin.isTTY === false means
1726
- // something is piping into us). If no --evidence flag and stdin is a pipe,
1727
- // assume `--evidence -`. Operators forgetting the flag previously got a
1728
- // confusing precondition halt; now the common case "just works."
1729
- if (!args.evidence && process.stdin.isTTY === false) {
1817
+ // v0.11.1: auto-detect piped stdin. If no --evidence flag and stdin is a
1818
+ // pipe, assume `--evidence -`. Operators forgetting the flag previously
1819
+ // got a confusing precondition halt; now the common case "just works."
1820
+ // R-F3: use truthy `!process.stdin.isTTY` instead of `=== false`. On
1821
+ // Windows MSYS bash, process.stdin.isTTY is `undefined` for a piped
1822
+ // stream — the strict `=== false` check failed and auto-detect never
1823
+ // fired, making `echo '{...}' | exceptd run <pb>` silently behave like
1824
+ // no-evidence on Windows. cmdAiRun's path (below) already uses the
1825
+ // truthy form, so this brings cmdRun + cmdIngest to parity.
1826
+ if (!args.evidence && !process.stdin.isTTY) {
1730
1827
  args.evidence = "-";
1731
1828
  }
1732
1829
  if (args.evidence) {
@@ -1761,6 +1858,24 @@ function cmdRun(runner, args, runOpts, pretty) {
1761
1858
  // CVE ID set through to analyze() so matched_cves drops them.
1762
1859
  if (args.vex) {
1763
1860
  let vexDoc;
1861
+ // R-F5: cap --vex file size the same way readEvidence() caps --evidence
1862
+ // (32 MB). Pre-fix, --vex did a raw readFileSync with no size check —
1863
+ // an operator passing a multi-GB file (binary log, JSON bomb, or
1864
+ // accident) blocked the event loop for minutes / OOM'd the process.
1865
+ // 32 MB is well beyond any legitimate VEX submission.
1866
+ const MAX_VEX_BYTES = 32 * 1024 * 1024;
1867
+ let vstat;
1868
+ try { vstat = fs.statSync(args.vex); }
1869
+ catch (e) {
1870
+ return emitError(`run: failed to stat --vex ${args.vex}: ${e.message}`, null, pretty);
1871
+ }
1872
+ if (vstat.size > MAX_VEX_BYTES) {
1873
+ return emitError(
1874
+ `run: --vex file too large: ${vstat.size} bytes > ${MAX_VEX_BYTES} byte limit. Reduce the document or split into multiple passes.`,
1875
+ { provided_path: args.vex, size_bytes: vstat.size, limit_bytes: MAX_VEX_BYTES },
1876
+ pretty
1877
+ );
1878
+ }
1764
1879
  try {
1765
1880
  vexDoc = JSON.parse(fs.readFileSync(args.vex, "utf8"));
1766
1881
  } catch (e) {
@@ -2198,10 +2313,18 @@ function buildJurisdictionClockRollup(results) {
2198
2313
  existing.deadline = n.deadline;
2199
2314
  }
2200
2315
  } else {
2316
+ // R-F11: emit `obligation` (the field name the CHANGELOG v0.12.16
2317
+ // entry promised) AND retain `obligation_ref` as a kept-name alias
2318
+ // for any consumer that already parses the older shape. The dedupe
2319
+ // key still keys on n.obligation_ref since that's the field
2320
+ // notification-action stubs carry; the rollup body just exposes
2321
+ // both names so the documented contract is truthful.
2322
+ const obligation = n.obligation_ref || null;
2201
2323
  m.set(key, {
2202
2324
  jurisdiction: n.jurisdiction || null,
2203
2325
  regulation: n.regulation || null,
2204
- obligation_ref: n.obligation_ref || null,
2326
+ obligation,
2327
+ obligation_ref: obligation,
2205
2328
  window_hours: n.window_hours ?? null,
2206
2329
  clock_started_at: n.clock_started_at,
2207
2330
  deadline: n.deadline || null,
@@ -2249,6 +2372,24 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
2249
2372
  if (!entryPath.startsWith(resolvedDir + path.sep)) {
2250
2373
  return emitError(`run: --evidence-dir entry ${f} resolves outside the directory; refusing.`, null, pretty);
2251
2374
  }
2375
+ // R-F12: the path.resolve check above only catches `..` traversal in
2376
+ // the joined path; fs.readFileSync(entryPath) still follows symlinks,
2377
+ // so a `<pb-id>.json -> /etc/shadow` symlink inside the dir would
2378
+ // happily slurp the target. lstat is symlink-aware (it does NOT
2379
+ // follow); refuse anything that's not a regular file. Defense in
2380
+ // depth on top of the readdir filter — a junction (Windows) or
2381
+ // bind-mount can shape-shift in between filter and read.
2382
+ let lst;
2383
+ try { lst = fs.lstatSync(entryPath); }
2384
+ catch (e) {
2385
+ return emitError(`run: --evidence-dir entry ${f}: lstat failed: ${e.message}`, null, pretty);
2386
+ }
2387
+ if (lst.isSymbolicLink()) {
2388
+ return emitError(`run: --evidence-dir entry ${f} is a symbolic link; refusing (symlinks bypass the directory-confinement check).`, { entry: f }, pretty);
2389
+ }
2390
+ if (!lst.isFile()) {
2391
+ return emitError(`run: --evidence-dir entry ${f} is not a regular file; refusing.`, { entry: f }, pretty);
2392
+ }
2252
2393
  try {
2253
2394
  bundle[pbId] = JSON.parse(fs.readFileSync(entryPath, "utf8"));
2254
2395
  } catch (e) {
@@ -2340,7 +2481,12 @@ function cmdIngest(runner, args, runOpts, pretty) {
2340
2481
  // `echo '{...}' | exceptd ingest` failed with "no playbook resolved"
2341
2482
  // because args.evidence stayed undefined and the routing JSON never got
2342
2483
  // read. Mirrors the cmdRun behavior at line 1614.
2343
- if (!args.evidence && process.stdin.isTTY === false) {
2484
+ // R-F3: use truthy `!process.stdin.isTTY` rather than `=== false`. On
2485
+ // Windows MSYS bash, isTTY is `undefined` for piped streams — the
2486
+ // strict `=== false` check failed and ingest silently treated the
2487
+ // routing JSON as absent. CHANGELOG v0.12.16 F4 ("cmdIngest auto-
2488
+ // detects piped stdin") was a no-op on Windows pre-fix.
2489
+ if (!args.evidence && !process.stdin.isTTY) {
2344
2490
  args.evidence = "-";
2345
2491
  }
2346
2492
  if (args.evidence) {
@@ -2539,14 +2685,74 @@ function persistAttestation(args) {
2539
2685
  existingPath: path.relative(process.cwd(), filePath),
2540
2686
  };
2541
2687
  }
2542
- writeAttestation(prior ? (prior.evidence_hash || null) : null,
2543
- prior ? (prior.captured_at || null) : null,
2544
- "w");
2545
- return {
2546
- ok: true,
2547
- prior_session_id: prior ? sessionId : null,
2548
- overwrote_at: prior ? prior.captured_at : null,
2549
- };
2688
+ // T P1-2: serialize the read-prior + write-new sequence behind a
2689
+ // lockfile so concurrent --force-overwrite invocations against the
2690
+ // same session-id slot do not degrade to last-write-wins. Pattern
2691
+ // matches withCatalogLock + withIndexLock: O_EXCL 'wx' on a sibling
2692
+ // .lock file with bounded retry, PID-liveness check on contention,
2693
+ // mtime fallback for orphaned lockfiles.
2694
+ const lockPath = filePath + ".lock";
2695
+ const MAX_RETRIES = 50;
2696
+ const STALE_LOCK_MS = 30_000;
2697
+ let acquired = false;
2698
+ for (let i = 0; i < MAX_RETRIES; i++) {
2699
+ try {
2700
+ fs.writeFileSync(lockPath, String(process.pid), { flag: "wx" });
2701
+ acquired = true;
2702
+ break;
2703
+ } catch (lockErr) {
2704
+ if (lockErr.code !== "EEXIST" && lockErr.code !== "EPERM") throw lockErr;
2705
+ let reclaimed = false;
2706
+ try {
2707
+ const raw = fs.readFileSync(lockPath, "utf8").trim();
2708
+ const pid = Number.parseInt(raw, 10);
2709
+ if (Number.isInteger(pid) && pid > 0 && pid !== process.pid) {
2710
+ try { process.kill(pid, 0); }
2711
+ catch (probeErr) {
2712
+ if (probeErr && probeErr.code === "ESRCH") {
2713
+ try { fs.unlinkSync(lockPath); reclaimed = true; } catch {}
2714
+ }
2715
+ }
2716
+ }
2717
+ } catch {}
2718
+ if (reclaimed) continue;
2719
+ try {
2720
+ const stat = fs.statSync(lockPath);
2721
+ if (Date.now() - stat.mtimeMs > STALE_LOCK_MS) {
2722
+ try { fs.unlinkSync(lockPath); } catch {}
2723
+ continue;
2724
+ }
2725
+ } catch {}
2726
+ // Synchronous spin — persistAttestation is sync; we cannot await.
2727
+ const deadline = Date.now() + 50 + Math.floor(Math.random() * 150);
2728
+ while (Date.now() < deadline) { /* spin */ }
2729
+ }
2730
+ }
2731
+ if (!acquired) {
2732
+ return {
2733
+ ok: false,
2734
+ error: `Failed to acquire attestation lock at ${path.relative(process.cwd(), lockPath)} after ${MAX_RETRIES} attempts.`,
2735
+ existingPath: path.relative(process.cwd(), filePath),
2736
+ };
2737
+ }
2738
+ try {
2739
+ // Re-read prior INSIDE the lock — the value captured before lock
2740
+ // acquisition may be stale if another --force-overwrite landed
2741
+ // between our EEXIST probe and the lock grab.
2742
+ let lockedPrior = prior;
2743
+ try { lockedPrior = JSON.parse(fs.readFileSync(filePath, "utf8")); }
2744
+ catch { /* keep pre-lock prior */ }
2745
+ writeAttestation(lockedPrior ? (lockedPrior.evidence_hash || null) : null,
2746
+ lockedPrior ? (lockedPrior.captured_at || null) : null,
2747
+ "w");
2748
+ return {
2749
+ ok: true,
2750
+ prior_session_id: lockedPrior ? sessionId : null,
2751
+ overwrote_at: lockedPrior ? lockedPrior.captured_at : null,
2752
+ };
2753
+ } finally {
2754
+ try { fs.unlinkSync(lockPath); } catch {}
2755
+ }
2550
2756
  }
2551
2757
  } catch (e) {
2552
2758
  return { ok: false, error: `Failed to write attestation: ${e.message}`, existingPath: null };
@@ -2563,6 +2769,20 @@ function persistAttestation(args) {
2563
2769
  * state so downstream tooling can distinguish "operator declined signing"
2564
2770
  * from "the .sig file was deleted by an attacker."
2565
2771
  */
2772
+ /**
2773
+ * Audit P P1-C: byte-stability normalize() for the attestation pipeline.
2774
+ * Strips a leading UTF-8 BOM and collapses CRLF → LF. Mirrors the
2775
+ * normalize() implementations in lib/sign.js, lib/verify.js,
2776
+ * lib/refresh-network.js, and scripts/verify-shipped-tarball.js. Five
2777
+ * sites total; tests/normalize-contract.test.js asserts byte-identical
2778
+ * output across all of them.
2779
+ */
2780
+ function normalizeAttestationBytes(input) {
2781
+ let s = Buffer.isBuffer(input) ? input.toString("utf8") : String(input);
2782
+ if (s.length > 0 && s.charCodeAt(0) === 0xFEFF) s = s.slice(1);
2783
+ return s.replace(/\r\n/g, "\n");
2784
+ }
2785
+
2566
2786
  function maybeSignAttestation(filePath) {
2567
2787
  const crypto = require("crypto");
2568
2788
  const sigPath = filePath + ".sig";
@@ -2576,7 +2796,16 @@ function maybeSignAttestation(filePath) {
2576
2796
  // (reporting only PKG_ROOT now), not by making `run` follow a cwd key the
2577
2797
  // verifier doesn't trust.
2578
2798
  const privKeyPath = path.join(PKG_ROOT, ".keys", "private.pem");
2579
- const content = fs.readFileSync(filePath, "utf8");
2799
+ // Audit P P1-C: normalize attestation bytes before sign — strip leading
2800
+ // UTF-8 BOM + collapse CRLF to LF. Mirrors lib/sign.js / lib/verify.js /
2801
+ // lib/refresh-network.js / scripts/verify-shipped-tarball.js. The
2802
+ // attestation file lives on disk under .exceptd/ and can pick up CRLF
2803
+ // through git-attribute / editor round-trips on Windows; without
2804
+ // normalization the sign/verify pair diverges on the same logical content.
2805
+ // The byte-stability contract is now five sites; tests/normalize-contract
2806
+ // .test.js enforces byte-identical output across all of them.
2807
+ const rawContent = fs.readFileSync(filePath, "utf8");
2808
+ const content = normalizeAttestationBytes(rawContent);
2580
2809
  // One-time-per-process unsigned warning so cron jobs don't spam stderr.
2581
2810
  // Operators who set `.keys/private.pem` get tamper-evident attestations;
2582
2811
  // operators without the keypair get a single nudge per session telling them
@@ -2718,6 +2947,19 @@ function verifyAttestationSidecar(attFile) {
2718
2947
  const sigPath = attFile + ".sig";
2719
2948
  const pubKeyPath = path.join(PKG_ROOT, "keys", "public.pem");
2720
2949
  const pubKey = fs.existsSync(pubKeyPath) ? fs.readFileSync(pubKeyPath, "utf8") : null;
2950
+ // Audit Q P1 + R F6: consult keys/EXPECTED_FINGERPRINT before honoring
2951
+ // the loaded public key. The v0.12.16 CHANGELOG claimed "pin consulted
2952
+ // at every public-key load site," but reattest's signature verifier
2953
+ // loaded keys/public.pem without the pin cross-check. A coordinated
2954
+ // attacker who swapped keys/public.pem on the operator's host could
2955
+ // verify-against-attacker-key without surfacing the divergence. Honors
2956
+ // KEYS_ROTATED=1 to bypass during legitimate rotation.
2957
+ if (pubKey) {
2958
+ const pinError = assertExpectedFingerprint(pubKey);
2959
+ if (pinError) {
2960
+ return { file: attFile, signed: false, verified: false, reason: pinError };
2961
+ }
2962
+ }
2721
2963
  if (!fs.existsSync(sigPath)) {
2722
2964
  return { file: attFile, signed: false, verified: false, reason: "no .sig sidecar" };
2723
2965
  }
@@ -2731,7 +2973,13 @@ function verifyAttestationSidecar(attFile) {
2731
2973
  return { file: attFile, signed: true, verified: false, reason: "no public key at keys/public.pem to verify against" };
2732
2974
  }
2733
2975
  let content;
2734
- try { content = fs.readFileSync(attFile, "utf8"); }
2976
+ try {
2977
+ const raw = fs.readFileSync(attFile, "utf8");
2978
+ // Audit P P1-C: apply the same normalize() used by the signer so the
2979
+ // verify path is byte-stable across CRLF / BOM churn (Windows checkout
2980
+ // with core.autocrlf=true, editor round-trips, git-attributes flips).
2981
+ content = normalizeAttestationBytes(raw);
2982
+ }
2735
2983
  catch (e) { return { file: attFile, signed: true, verified: false, reason: `attestation read error: ${e.message}` }; }
2736
2984
  try {
2737
2985
  const ok = crypto.verify(null, Buffer.from(content, "utf8"), {
@@ -2754,12 +3002,10 @@ function cmdReattest(runner, args, runOpts, pretty) {
2754
3002
  // through to walkAttestationDir, where the lexical comparison either
2755
3003
  // matched all or none unpredictably.
2756
3004
  if (args.since != null) {
2757
- if (typeof args.since !== "string" || isNaN(Date.parse(args.since))) {
2758
- return emitError(
2759
- `reattest: --since must be a parseable ISO-8601 timestamp (e.g. 2026-05-01 or 2026-05-01T00:00:00Z). Got: ${JSON.stringify(String(args.since)).slice(0, 80)}`,
2760
- null, pretty
2761
- );
2762
- }
3005
+ // R-F10: regex BEFORE Date.parse bare integers like "99" would
3006
+ // otherwise parse as the year 1999 and silently filter wrong eras.
3007
+ const sinceErr = validateIsoSince(args.since);
3008
+ if (sinceErr) return emitError(`reattest: ${sinceErr}`, null, pretty);
2763
3009
  }
2764
3010
  // --latest [--playbook <id>] [--since <ISO>] — find prior attestation
2765
3011
  // without requiring the operator to know the session-id.
@@ -2807,6 +3053,32 @@ function cmdReattest(runner, args, runOpts, pretty) {
2807
3053
  }
2808
3054
  if (verify.signed && !verify.verified && args["force-replay"]) {
2809
3055
  process.stderr.write(`[exceptd reattest] WARNING: --force-replay overriding failed signature verification on ${attFile} (${verify.reason}). The replay output records sidecar_verify so the override is audit-visible.\n`);
3056
+ } else if (!verify.signed && verify.reason && verify.reason.includes("no .sig sidecar") && !args["force-replay"]) {
3057
+ // Audit Q P2: missing-sidecar is NOT benign. The previous flow accepted
3058
+ // a missing .sig file silently (only blocked on signed-but-invalid).
3059
+ // Sidecar deletion is observationally identical to sidecar tamper —
3060
+ // an attacker who can rewrite the attestation can also rm the sidecar,
3061
+ // and pre-fix that path produced a green replay with no audit warning.
3062
+ // Now: refuse unless --force-replay, and the persisted replay body
3063
+ // records sidecar_verify so the override is audit-visible. Operators
3064
+ // whose original run wrote unsigned attestations (no private key
3065
+ // available) hit the "explicitly unsigned" branch below, which is
3066
+ // distinguishable from a missing sidecar.
3067
+ process.stderr.write(`[exceptd reattest] TAMPERED-OR-MISSING: no .sig sidecar at ${attFile}.sig. Sidecar deletion is treated the same as sidecar tamper — refusing to replay against potentially-forged input. Pass --force-replay to override (the replay output records sidecar_verify so the audit trail captures the override).\n`);
3068
+ const body = {
3069
+ ok: false,
3070
+ error: `reattest: prior attestation has no .sig sidecar — refusing to replay`,
3071
+ verb: "reattest",
3072
+ session_id: sessionId,
3073
+ attestation_file: attFile,
3074
+ sidecar_verify: verify,
3075
+ hint: "If the sidecar was intentionally removed (e.g. a clean operator rotation) and you have inspected the attestation, pass --force-replay.",
3076
+ };
3077
+ process.stderr.write(JSON.stringify(body) + "\n");
3078
+ process.exitCode = 6;
3079
+ return;
3080
+ } else if (!verify.signed && verify.reason && verify.reason.includes("no .sig sidecar") && args["force-replay"]) {
3081
+ process.stderr.write(`[exceptd reattest] WARNING: --force-replay overriding missing .sig sidecar on ${attFile}. The replay output records sidecar_verify so the override is audit-visible.\n`);
2810
3082
  } else if (!verify.signed && verify.reason !== "no .sig sidecar") {
2811
3083
  process.stderr.write(`[exceptd reattest] NOTE: attestation at ${attFile} has no Ed25519 signature (${verify.reason}). Proceeding — unsigned attestations are an operator config issue, not tamper evidence.\n`);
2812
3084
  }
@@ -2908,6 +3180,16 @@ function cmdAttest(runner, args, runOpts, pretty) {
2908
3180
  if (!sessionId) {
2909
3181
  return emitError(`attest ${subverb}: missing <session-id> positional argument.`, null, pretty);
2910
3182
  }
3183
+ // R-F7: distinguish "validation rejected" from "valid format but not
3184
+ // found". findSessionDir() returns null for BOTH (regex-rejected ids
3185
+ // collapse to the "no session dir" message), which gives operators a
3186
+ // misleading error — a string with `..` or `/` looks to them like an
3187
+ // existing-session lookup that failed, not a refusal. Call the same
3188
+ // validator up front; emit its specific message when it throws.
3189
+ try { validateSessionIdForRead(sessionId); }
3190
+ catch (e) {
3191
+ return emitError(`attest ${subverb}: ${e.message}`, { session_id_input: typeof sessionId === "string" ? sessionId.slice(0, 80) : typeof sessionId }, pretty);
3192
+ }
2911
3193
  const dir = findSessionDir(sessionId, runOpts);
2912
3194
  if (!dir) {
2913
3195
  return emitError(`attest ${subverb}: no session dir for ${sessionId}. Searched: ${resolveAttestationRoot(runOpts)} + .exceptd/attestations/`, { session_id: sessionId }, pretty);
@@ -2979,13 +3261,29 @@ function cmdAttest(runner, args, runOpts, pretty) {
2979
3261
  const crypto = require("crypto");
2980
3262
  const pubKeyPath = path.join(PKG_ROOT, "keys", "public.pem");
2981
3263
  const pubKey = fs.existsSync(pubKeyPath) ? fs.readFileSync(pubKeyPath, "utf8") : null;
3264
+ // Audit Q P1 + R F6: same pin cross-check as verifyAttestationSidecar().
3265
+ // The v0.12.16 promise that EXPECTED_FINGERPRINT is consulted at every
3266
+ // public-key load site was not honored here — `attest verify` loaded
3267
+ // keys/public.pem raw. Refuse to verify any sidecar when the local
3268
+ // public.pem diverges from the pinned fingerprint (unless KEYS_ROTATED=1).
3269
+ const pinError = pubKey ? assertExpectedFingerprint(pubKey) : null;
3270
+ if (pinError) {
3271
+ return emitError(
3272
+ `attest verify: ${pinError}`,
3273
+ { verb: "attest verify", session_id: sessionId, pin_error: pinError },
3274
+ pretty
3275
+ );
3276
+ }
2982
3277
  const results = files.map(f => {
2983
3278
  const sigPath = path.join(dir, f + ".sig");
2984
3279
  if (!fs.existsSync(sigPath)) return { file: f, signed: false, verified: false, reason: "no .sig sidecar" };
2985
3280
  const sigDoc = JSON.parse(fs.readFileSync(sigPath, "utf8"));
2986
3281
  if (sigDoc.algorithm === "unsigned") return { file: f, signed: false, verified: false, reason: "attestation explicitly unsigned (no private key when written)" };
2987
3282
  if (!pubKey) return { file: f, signed: true, verified: false, reason: "no public key at keys/public.pem to verify against" };
2988
- const content = fs.readFileSync(path.join(dir, f), "utf8");
3283
+ // Audit P P1-C: normalize before crypto.verify mirrors the signer
3284
+ // path so the verify pair is byte-stable across CRLF / BOM churn.
3285
+ const rawContent = fs.readFileSync(path.join(dir, f), "utf8");
3286
+ const content = normalizeAttestationBytes(rawContent);
2989
3287
  try {
2990
3288
  const ok = crypto.verify(null, Buffer.from(content, "utf8"), {
2991
3289
  key: pubKey, dsaEncoding: "ieee-p1363",
@@ -2995,7 +3293,21 @@ function cmdAttest(runner, args, runOpts, pretty) {
2995
3293
  return { file: f, signed: true, verified: false, reason: `verify error: ${e.message}` };
2996
3294
  }
2997
3295
  });
2998
- emit({ verb: "attest verify", session_id: sessionId, results }, pretty);
3296
+ // R-F1: when ANY result is signed-but-failed-verify, surface ok:false
3297
+ // AND set exit 6 for parity with cmdReattest's TAMPERED code. Pre-fix,
3298
+ // `attest verify` emitted {verb, session_id, results} without ok:false
3299
+ // and exited 0 — operators piping through `set -e` saw no failure
3300
+ // signal even when an attestation had been forged. emit()'s ok:false
3301
+ // → exitCode = 1 auto-promotion would stop at 1; tamper is distinct
3302
+ // from generic failure, so explicitly raise to 6 (cmdReattest's code).
3303
+ const tampered = results.some(r => r.signed && !r.verified);
3304
+ const body = { verb: "attest verify", session_id: sessionId, results };
3305
+ if (tampered) {
3306
+ body.ok = false;
3307
+ body.error = "attest verify: one or more attestations failed Ed25519 verification — possible post-hoc tampering";
3308
+ process.exitCode = 6;
3309
+ }
3310
+ emit(body, pretty);
2999
3311
  return;
3000
3312
  }
3001
3313
 
@@ -3789,12 +4101,10 @@ function cmdListAttestations(runner, args, runOpts, pretty) {
3789
4101
  // Prior behavior silently accepted any string and lexically compared to
3790
4102
  // captured_at, producing 0-result or full-result depending on the string.
3791
4103
  if (args.since != null) {
3792
- if (typeof args.since !== "string" || isNaN(Date.parse(args.since))) {
3793
- return emitError(
3794
- `attest list: --since must be a parseable ISO-8601 timestamp (e.g. 2026-05-01 or 2026-05-01T00:00:00Z). Got: ${JSON.stringify(String(args.since)).slice(0, 80)}`,
3795
- null, pretty
3796
- );
3797
- }
4104
+ // R-F10: regex BEFORE Date.parse bare integers like "99" would
4105
+ // otherwise parse as the year 1999 and silently filter wrong eras.
4106
+ const sinceErr = validateIsoSince(args.since);
4107
+ if (sinceErr) return emitError(`attest list: ${sinceErr}`, null, pretty);
3798
4108
  }
3799
4109
  // Enumerate sessions across both v0.11.0 default root and legacy cwd-
3800
4110
  // relative root, so operators with prior attestations still see them.
@@ -4632,4 +4942,4 @@ function cmdCi(runner, args, runOpts, pretty) {
4632
4942
 
4633
4943
  if (require.main === module) main();
4634
4944
 
4635
- module.exports = { COMMANDS, PKG_ROOT, PLAYBOOK_VERBS };
4945
+ module.exports = { COMMANDS, PKG_ROOT, PLAYBOOK_VERBS, persistAttestation };
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "schema_version": "1.1.0",
3
- "generated_at": "2026-05-14T19:30:40.635Z",
3
+ "generated_at": "2026-05-14T21:23:42.566Z",
4
4
  "generator": "scripts/build-indexes.js",
5
5
  "source_count": 50,
6
6
  "source_hashes": {
7
- "manifest.json": "c0edebf10be0a638970d4e9e4c95459815f0226fc69276d1c965a71fa39b324f",
7
+ "manifest.json": "61f41199c180e8b61ed2cd10bee517a64f96bb3179af0fd5666579c541805a38",
8
8
  "data/atlas-ttps.json": "20339e0ae3cd89c06f1385be31c50f408f827edc2e8ab8aef026ade3bcf0a917",
9
9
  "data/attack-techniques.json": "6db08a8e8a4d03d9309b1d185112de7f3c9595d2cd3d24566b7ce0b3b8aa5d1a",
10
10
  "data/cve-catalog.json": "6e198d414a3a86dcae93ef36a2b1978734d0b1224fa66ba5184819ea0e3fb49f",
@@ -68,7 +68,8 @@
68
68
  "T1555"
69
69
  ],
70
70
  "cve_refs": [
71
- "CVE-2026-30615"
71
+ "CVE-2026-30615",
72
+ "CVE-2026-42208"
72
73
  ],
73
74
  "cwe_refs": [
74
75
  "CWE-522",