@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/CHANGELOG.md +126 -52
- package/README.md +1 -1
- package/bin/exceptd.js +355 -45
- package/data/_indexes/_meta.json +2 -2
- package/data/playbooks/ai-api.json +2 -1
- package/lib/auto-discovery.js +62 -9
- package/lib/playbook-runner.js +188 -32
- package/lib/prefetch.js +62 -15
- package/lib/refresh-external.js +27 -0
- package/lib/refresh-network.js +91 -3
- package/lib/sign.js +10 -2
- package/lib/verify.js +26 -4
- package/manifest.json +40 -41
- package/orchestrator/scheduler.js +10 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
- package/scripts/predeploy.js +5 -0
- package/scripts/verify-shipped-tarball.js +89 -0
package/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
|
-
//
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
|
|
465
|
-
|
|
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
|
-
|
|
474
|
-
|
|
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
|
-
|
|
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
|
|
617
|
-
|
|
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
|
-
|
|
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 &&
|
|
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
|
|
1726
|
-
//
|
|
1727
|
-
//
|
|
1728
|
-
//
|
|
1729
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3793
|
-
|
|
3794
|
-
|
|
3795
|
-
|
|
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 };
|
package/data/_indexes/_meta.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema_version": "1.1.0",
|
|
3
|
-
"generated_at": "2026-05-
|
|
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": "
|
|
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",
|