@blamejs/exceptd-skills 0.12.18 → 0.12.21
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 +224 -52
- package/README.md +1 -1
- package/bin/exceptd.js +841 -68
- package/data/_indexes/_meta.json +14 -14
- package/data/_indexes/activity-feed.json +3 -3
- package/data/_indexes/catalog-summaries.json +3 -3
- package/data/_indexes/chains.json +15 -0
- package/data/_indexes/jurisdiction-map.json +3 -2
- package/data/_indexes/section-offsets.json +175 -175
- package/data/_indexes/summary-cards.json +1 -1
- package/data/_indexes/token-budget.json +83 -83
- package/data/cve-catalog.json +169 -2
- package/data/exploit-availability.json +16 -0
- package/data/playbooks/ai-api.json +20 -1
- package/data/playbooks/containers.json +30 -0
- package/data/playbooks/cred-stores.json +18 -0
- package/data/playbooks/crypto.json +18 -0
- package/data/playbooks/hardening.json +26 -1
- package/data/playbooks/kernel.json +22 -2
- package/data/playbooks/mcp.json +18 -0
- package/data/playbooks/runtime.json +20 -1
- package/data/playbooks/sbom.json +18 -0
- package/data/playbooks/secrets.json +6 -0
- package/data/zeroday-lessons.json +102 -0
- package/lib/auto-discovery.js +68 -15
- package/lib/cross-ref-api.js +43 -10
- package/lib/cve-curation.js +4 -4
- package/lib/playbook-runner.js +545 -63
- package/lib/prefetch.js +65 -18
- package/lib/refresh-external.js +40 -2
- package/lib/refresh-network.js +100 -12
- package/lib/scoring.js +22 -13
- package/lib/sign.js +14 -6
- package/lib/validate-catalog-meta.js +1 -1
- package/lib/validate-indexes.js +2 -2
- package/lib/verify.js +51 -10
- package/manifest.json +47 -48
- package/orchestrator/scheduler.js +10 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
- package/scripts/check-manifest-snapshot.js +1 -1
- package/scripts/check-sbom-currency.js +1 -1
- package/scripts/predeploy.js +10 -5
- package/scripts/refresh-manifest-snapshot.js +2 -2
- package/scripts/validate-vendor-online.js +1 -1
- package/scripts/verify-shipped-tarball.js +94 -6
- package/skills/compliance-theater/skill.md +4 -1
- package/skills/exploit-scoring/skill.md +20 -1
- package/skills/framework-gap-analysis/skill.md +6 -2
- package/skills/kernel-lpe-triage/skill.md +50 -3
- package/skills/threat-model-currency/skill.md +6 -4
- package/skills/webapp-security/skill.md +1 -1
- package/skills/zeroday-gap-learn/skill.md +44 -1
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
|
+
* 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
|
// ---------------------------------------------------------------------------
|
|
@@ -555,7 +607,7 @@ function emit(obj, pretty, humanRenderer) {
|
|
|
555
607
|
}
|
|
556
608
|
|
|
557
609
|
function emitError(msg, extra, pretty) {
|
|
558
|
-
// v0.12.14
|
|
610
|
+
// v0.12.14: the v0.11.13 emit() fix used exitCode + return
|
|
559
611
|
// to defend stdout-buffered writes from truncation under piped consumers.
|
|
560
612
|
// emitError() (stderr) kept process.exit(1), which has the same truncation
|
|
561
613
|
// class — CLAUDE.md's "fix the class, not the instance." Now: write to
|
|
@@ -567,6 +619,50 @@ function emitError(msg, extra, pretty) {
|
|
|
567
619
|
process.exitCode = 1;
|
|
568
620
|
}
|
|
569
621
|
|
|
622
|
+
/**
|
|
623
|
+
* EE P1-2: shared BOM-tolerant JSON file reader. Windows tools commonly emit
|
|
624
|
+
* UTF-8-BOM (EF BB BF) or UTF-16 LE/BE (FF FE / FE FF). The default
|
|
625
|
+
* `fs.readFileSync(path, "utf8")` chokes on the leading 0xFEFF (UTF-8-BOM
|
|
626
|
+
* becomes a literal BOM codepoint that `JSON.parse` refuses) and decodes
|
|
627
|
+
* UTF-16 as garbage. Route every operator-supplied JSON file through here.
|
|
628
|
+
*
|
|
629
|
+
* 1. read as Buffer
|
|
630
|
+
* 2. detect BOM (UTF-16 LE / BE / UTF-8 BOM)
|
|
631
|
+
* 3. decode appropriately, strip leading BOM if present
|
|
632
|
+
* 4. JSON.parse
|
|
633
|
+
*
|
|
634
|
+
* On parse failure, throw a clean message that preserves the operator-facing
|
|
635
|
+
* path but does NOT leak the raw V8 parser stack — operators see "failed to
|
|
636
|
+
* parse JSON at <path>: <reason>", not a 12-line trace.
|
|
637
|
+
*/
|
|
638
|
+
function readJsonFile(filePath) {
|
|
639
|
+
let buf;
|
|
640
|
+
try { buf = fs.readFileSync(filePath); }
|
|
641
|
+
catch (e) { throw new Error(`failed to read ${filePath}: ${e.message}`); }
|
|
642
|
+
let text;
|
|
643
|
+
if (buf.length >= 2 && buf[0] === 0xFF && buf[1] === 0xFE) {
|
|
644
|
+
text = buf.slice(2).toString("utf16le");
|
|
645
|
+
} else if (buf.length >= 2 && buf[0] === 0xFE && buf[1] === 0xFF) {
|
|
646
|
+
// UTF-16 BE: Node has no native decoder. Swap byte pairs to LE, then decode.
|
|
647
|
+
const swapped = Buffer.allocUnsafe(buf.length - 2);
|
|
648
|
+
for (let i = 2; i < buf.length - 1; i += 2) {
|
|
649
|
+
swapped[i - 2] = buf[i + 1];
|
|
650
|
+
swapped[i - 1] = buf[i];
|
|
651
|
+
}
|
|
652
|
+
text = swapped.toString("utf16le");
|
|
653
|
+
} else if (buf.length >= 3 && buf[0] === 0xEF && buf[1] === 0xBB && buf[2] === 0xBF) {
|
|
654
|
+
text = buf.slice(3).toString("utf8");
|
|
655
|
+
} else {
|
|
656
|
+
text = buf.toString("utf8");
|
|
657
|
+
}
|
|
658
|
+
// Belt-and-braces: strip any residual leading U+FEFF the decode may have left.
|
|
659
|
+
if (text.charCodeAt(0) === 0xFEFF) text = text.slice(1);
|
|
660
|
+
try { return JSON.parse(text); }
|
|
661
|
+
catch (e) {
|
|
662
|
+
throw new Error(`failed to parse JSON at ${filePath}: ${e.message}`);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
570
666
|
function readEvidence(evidenceFlag) {
|
|
571
667
|
if (!evidenceFlag) return {};
|
|
572
668
|
if (evidenceFlag === "-") {
|
|
@@ -585,13 +681,87 @@ function readEvidence(evidenceFlag) {
|
|
|
585
681
|
if (stat.size > MAX_EVIDENCE_BYTES) {
|
|
586
682
|
throw new Error(`evidence file too large: ${stat.size} bytes > ${MAX_EVIDENCE_BYTES} byte limit. Reduce the submission or split into multiple playbook runs.`);
|
|
587
683
|
}
|
|
588
|
-
|
|
684
|
+
// EE P1-2: route through readJsonFile() for UTF-8-BOM / UTF-16 tolerance.
|
|
685
|
+
// Windows-tool-emitted JSON commonly carries these markers; the raw "utf8"
|
|
686
|
+
// decode in readFileSync chokes on the leading 0xFEFF.
|
|
687
|
+
return readJsonFile(evidenceFlag);
|
|
589
688
|
}
|
|
590
689
|
|
|
591
690
|
function loadRunner() {
|
|
592
691
|
return require(path.join(PKG_ROOT, "lib", "playbook-runner.js"));
|
|
593
692
|
}
|
|
594
693
|
|
|
694
|
+
/**
|
|
695
|
+
* EE P1-7: detect whether stdin actually has data without blocking.
|
|
696
|
+
*
|
|
697
|
+
* `!process.stdin.isTTY` (the previous heuristic) fires when isTTY is
|
|
698
|
+
* `false`, `undefined`, OR `null`. Test harnesses with custom stdin
|
|
699
|
+
* duplexers (Mocha/Jest, some Docker stdin-passthrough wrappers) leave
|
|
700
|
+
* isTTY === undefined but never write any bytes — falling into
|
|
701
|
+
* `fs.readFileSync(0, "utf8")` then BLOCKS waiting for an EOF that
|
|
702
|
+
* never arrives.
|
|
703
|
+
*
|
|
704
|
+
* Strategy:
|
|
705
|
+
*
|
|
706
|
+
* 1. If isTTY is truthy → operator is at a terminal, never read stdin.
|
|
707
|
+
* 2. Probe `fs.fstatSync(0)`:
|
|
708
|
+
* - On POSIX pipes / regular files, `stat.size` is reliable.
|
|
709
|
+
* - On Windows, fstat on a pipe returns size === 0 even when data
|
|
710
|
+
* is queued — so size === 0 alone cannot decide.
|
|
711
|
+
* 3. When size > 0 → real data is queued; safe to read.
|
|
712
|
+
* 4. When size === 0 AND isTTY is falsy:
|
|
713
|
+
* - On POSIX, treat as empty (wrapped duplexer / closed stdin).
|
|
714
|
+
* - On Windows, fall back to the legacy truthy check so we don't
|
|
715
|
+
* regress the MSYS-bash auto-detect (R-F3 in v0.12.16).
|
|
716
|
+
*
|
|
717
|
+
* Returns `true` if the caller may safely fs.readFileSync(0) without
|
|
718
|
+
* risking an indefinite block on a wrapped empty stream.
|
|
719
|
+
*/
|
|
720
|
+
function hasReadableStdin() {
|
|
721
|
+
if (process.stdin.isTTY) return false;
|
|
722
|
+
let st;
|
|
723
|
+
try { st = fs.fstatSync(0); }
|
|
724
|
+
catch { return !process.stdin.isTTY; /* fstat failed — fall back */ }
|
|
725
|
+
// POSIX pipes / FIFOs / sockets / character devices report size 0
|
|
726
|
+
// even when bytes are queued (or about to be). Trust them — a real
|
|
727
|
+
// `echo '{...}' | exceptd run` pipeline lands here, and readFileSync(0)
|
|
728
|
+
// will read to EOF cleanly. If the write end is open and no bytes
|
|
729
|
+
// arrive, the read blocks — that's the operator's contract, not the
|
|
730
|
+
// CLI's to second-guess. Wrapped test harnesses that never write
|
|
731
|
+
// should pass `--evidence -` explicitly.
|
|
732
|
+
if (typeof st.isFIFO === "function" && st.isFIFO()) return true;
|
|
733
|
+
if (typeof st.isSocket === "function" && st.isSocket()) return true;
|
|
734
|
+
if (typeof st.isCharacterDevice === "function" && st.isCharacterDevice()) return true;
|
|
735
|
+
// Regular file (e.g. `exceptd run <evidence.json` shell redirect).
|
|
736
|
+
// size 0 here means a legitimately empty file.
|
|
737
|
+
if (typeof st.size === "number" && st.size > 0) return true;
|
|
738
|
+
// Windows fallback: pipes don't surface as FIFOs via fstat on win32
|
|
739
|
+
// (they appear as regular files with size 0 even when bytes queued).
|
|
740
|
+
// Preserve the legacy truthy check so MSYS-bash piping keeps working.
|
|
741
|
+
if (process.platform === "win32") return !process.stdin.isTTY;
|
|
742
|
+
return false;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* R-F10: ISO-8601 shape regex applied BEFORE Date.parse. Pre-fix, both
|
|
747
|
+
* `attest list --since` and `reattest --since` accepted anything Date.parse
|
|
748
|
+
* could chew on — including bare integers like "99", which JavaScript
|
|
749
|
+
* happily resolves to 1999-12-01T00:00:00Z (Y2K-era millisecond / two-digit
|
|
750
|
+
* year heuristic). Operators got a "valid timestamp" check that silently
|
|
751
|
+
* filtered the wrong years. Now: require an explicit calendar-date shape
|
|
752
|
+
* (YYYY-MM-DD with optional time component) BEFORE handing to Date.parse.
|
|
753
|
+
*
|
|
754
|
+
* Returns null on success; returns the human-facing error message string
|
|
755
|
+
* on failure so the caller can wrap it with its own verb prefix.
|
|
756
|
+
*/
|
|
757
|
+
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/;
|
|
758
|
+
function validateIsoSince(raw) {
|
|
759
|
+
if (typeof raw !== "string" || !ISO_DATE_RE.test(raw) || isNaN(Date.parse(raw))) {
|
|
760
|
+
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)}`;
|
|
761
|
+
}
|
|
762
|
+
return null;
|
|
763
|
+
}
|
|
764
|
+
|
|
595
765
|
/**
|
|
596
766
|
* F5: detect whether a parsed JSON document is plausibly CycloneDX VEX or
|
|
597
767
|
* OpenVEX. The runner's vexFilterFromDoc returns Set(0) tolerantly for
|
|
@@ -613,8 +783,21 @@ function detectVexShape(doc) {
|
|
|
613
783
|
// entries look vex-shaped (have id/bom_ref/analysis).
|
|
614
784
|
if (Array.isArray(doc.vulnerabilities)) {
|
|
615
785
|
const isBom = doc.bomFormat === "CycloneDX";
|
|
616
|
-
const
|
|
617
|
-
|
|
786
|
+
const specStr = typeof doc.specVersion === "string" ? doc.specVersion : "";
|
|
787
|
+
const hasCyclonedxMarker = isBom || specStr.startsWith("1.");
|
|
788
|
+
// R-F4: empty vulnerabilities arrays cannot vouch for CycloneDX shape
|
|
789
|
+
// on their own — `{"bomFormat":"NOT-CycloneDX","vulnerabilities":[]}`
|
|
790
|
+
// previously passed because `length === 0` always satisfied
|
|
791
|
+
// `entriesLookVex`. Require a real CycloneDX marker (bomFormat or
|
|
792
|
+
// specVersion) when the array is empty; non-empty arrays still pass
|
|
793
|
+
// when any entry has vex-shaped fields (id / bom-ref / analysis).
|
|
794
|
+
if (doc.vulnerabilities.length === 0) {
|
|
795
|
+
if (hasCyclonedxMarker) {
|
|
796
|
+
return { ok: true, detected: "cyclonedx-vex", top_level_keys: keys };
|
|
797
|
+
}
|
|
798
|
+
return { ok: false, detected: "empty-vulnerabilities-without-cyclonedx-marker", top_level_keys: keys };
|
|
799
|
+
}
|
|
800
|
+
const entriesLookVex = doc.vulnerabilities.some(v => v && typeof v === "object" && (v.id || v["bom-ref"] || v.bom_ref || v.analysis));
|
|
618
801
|
if (isBom || entriesLookVex) {
|
|
619
802
|
return { ok: true, detected: "cyclonedx-vex", top_level_keys: keys };
|
|
620
803
|
}
|
|
@@ -632,8 +815,17 @@ function detectVexShape(doc) {
|
|
|
632
815
|
if (doc.document && doc.document.category && String(doc.document.category).startsWith("csaf_")) {
|
|
633
816
|
return { ok: false, detected: "csaf-advisory-not-vex", top_level_keys: keys };
|
|
634
817
|
}
|
|
635
|
-
|
|
636
|
-
|
|
818
|
+
// EE P1-1: a CycloneDX SBOM with no `vulnerabilities` key is a legitimate
|
|
819
|
+
// "0-CVE VEX filter" submission — the operator is asserting nothing here is
|
|
820
|
+
// exploitable. Accept it as cyclonedx-vex with an empty filter set (the
|
|
821
|
+
// runner's vexFilterFromDoc returns Set(0) for the same shape). Same logic
|
|
822
|
+
// for documents that carry a CycloneDX-flavored specVersion ("1.x") without
|
|
823
|
+
// bomFormat — Windows tooling sometimes drops the marker on export.
|
|
824
|
+
const cyclonedxMarker =
|
|
825
|
+
doc.bomFormat === "CycloneDX" ||
|
|
826
|
+
(typeof doc.specVersion === "string" && /^1\./.test(doc.specVersion));
|
|
827
|
+
if (cyclonedxMarker && !Array.isArray(doc.vulnerabilities)) {
|
|
828
|
+
return { ok: true, detected: "cyclonedx-vex-zero-cve", top_level_keys: keys };
|
|
637
829
|
}
|
|
638
830
|
if (Array.isArray(doc.statements) && !ctxStr) {
|
|
639
831
|
return { ok: false, detected: "statements-array-but-no-openvex-context", top_level_keys: keys };
|
|
@@ -778,13 +970,137 @@ function dispatchPlaybook(cmd, argv) {
|
|
|
778
970
|
pretty
|
|
779
971
|
);
|
|
780
972
|
}
|
|
781
|
-
|
|
973
|
+
// EE P1-3: the ASCII-only control-char regex above misses Unicode
|
|
974
|
+
// categories Cc / Cf / Co / Cn — bidi overrides (U+202E "RTL OVERRIDE"),
|
|
975
|
+
// zero-width joiners (U+200B-D), invisible format chars, private-use
|
|
976
|
+
// codepoints, unassigned codepoints. An operator string like
|
|
977
|
+
// "aliceevilbob" renders as "alicebobevila" in any UI that respects
|
|
978
|
+
// bidi — a forgery surface where the attested name looks like Bob but the
|
|
979
|
+
// bytes are Alice. Reject anything outside a positive allowlist of
|
|
980
|
+
// printable ASCII + most BMP printable codepoints (skipping the format /
|
|
981
|
+
// control / surrogate gaps).
|
|
982
|
+
//
|
|
983
|
+
// Implementation: NFC-normalise first (so a decomposed sequence can't
|
|
984
|
+
// smuggle a combining mark past the codepoint check), then iterate
|
|
985
|
+
// codepoints and refuse Cc/Cf/Co/Cn. We use \p{C} via the `u` regex flag,
|
|
986
|
+
// which matches Cc + Cf + Cs + Co + Cn in one shot. Unicode 15.1 is the
|
|
987
|
+
// baseline supported by Node 20.
|
|
988
|
+
let normalized;
|
|
989
|
+
try { normalized = args.operator.normalize("NFC"); }
|
|
990
|
+
catch (e) {
|
|
991
|
+
return emitError(
|
|
992
|
+
`run: --operator failed Unicode NFC normalisation: ${e.message}`,
|
|
993
|
+
{ provided_length: args.operator.length },
|
|
994
|
+
pretty
|
|
995
|
+
);
|
|
996
|
+
}
|
|
997
|
+
if (normalized.length === 0) {
|
|
998
|
+
return emitError(
|
|
999
|
+
"run: --operator is empty after Unicode NFC normalisation. Pass a meaningful identifier or omit the flag.",
|
|
1000
|
+
null,
|
|
1001
|
+
pretty
|
|
1002
|
+
);
|
|
1003
|
+
}
|
|
1004
|
+
if (/\p{C}/u.test(normalized)) {
|
|
1005
|
+
// Find the offending codepoint to surface a useful hint without
|
|
1006
|
+
// round-tripping the raw bytes into the error body.
|
|
1007
|
+
let offending = "";
|
|
1008
|
+
for (const cp of normalized) {
|
|
1009
|
+
if (/\p{C}/u.test(cp)) {
|
|
1010
|
+
offending = "U+" + cp.codePointAt(0).toString(16).toUpperCase().padStart(4, "0");
|
|
1011
|
+
break;
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
return emitError(
|
|
1015
|
+
`run: --operator contains a Unicode control / format / private-use / unassigned codepoint (${offending}). Bidi overrides (U+202E), zero-width joiners (U+200B–D), and format marks corrupt attestation rendering and enable name-forgery. Use printable identifiers only.`,
|
|
1016
|
+
{ provided_length: args.operator.length, offending_codepoint: offending },
|
|
1017
|
+
pretty
|
|
1018
|
+
);
|
|
1019
|
+
}
|
|
1020
|
+
runOpts.operator = normalized;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
// audit CC P1-3: --publisher-namespace <url> threads into the CSAF
|
|
1024
|
+
// bundle's document.publisher.namespace field. CSAF §3.1.7.4 requires the
|
|
1025
|
+
// namespace to be the publisher's trust anchor — i.e. the OPERATOR
|
|
1026
|
+
// running the scan, not the tooling vendor. Pre-fix this was hard-coded
|
|
1027
|
+
// to https://exceptd.com, misattributing responsibility for advisory
|
|
1028
|
+
// accuracy. Validation mirrors --operator (string, ≤256 chars, no
|
|
1029
|
+
// ASCII / Unicode control characters), plus a URL-shape check (`^https?:`).
|
|
1030
|
+
if (args["publisher-namespace"] !== undefined) {
|
|
1031
|
+
const ns = args["publisher-namespace"];
|
|
1032
|
+
if (typeof ns !== "string") {
|
|
1033
|
+
return emitError("run: --publisher-namespace must be a string.", { provided: typeof ns }, pretty);
|
|
1034
|
+
}
|
|
1035
|
+
// eslint-disable-next-line no-control-regex
|
|
1036
|
+
if (/[\x00-\x1F\x7F]/.test(ns)) {
|
|
1037
|
+
return emitError(
|
|
1038
|
+
"run: --publisher-namespace contains ASCII control characters. Refusing — these would corrupt CSAF rendering and break URL parsing in downstream consumers.",
|
|
1039
|
+
{ provided_length: ns.length },
|
|
1040
|
+
pretty
|
|
1041
|
+
);
|
|
1042
|
+
}
|
|
1043
|
+
if (ns.length === 0 || ns.length > 256) {
|
|
1044
|
+
return emitError(
|
|
1045
|
+
`run: --publisher-namespace length ${ns.length} out of bounds (1–256).`,
|
|
1046
|
+
{ provided_length: ns.length },
|
|
1047
|
+
pretty
|
|
1048
|
+
);
|
|
1049
|
+
}
|
|
1050
|
+
if (!/^https?:\/\//i.test(ns)) {
|
|
1051
|
+
return emitError(
|
|
1052
|
+
"run: --publisher-namespace must be a URL starting with http:// or https:// (e.g. https://your-org.example). CSAF §3.1.7.4 requires the namespace to be the publisher's trust anchor.",
|
|
1053
|
+
{ provided: ns.slice(0, 80) },
|
|
1054
|
+
pretty
|
|
1055
|
+
);
|
|
1056
|
+
}
|
|
1057
|
+
runOpts.publisherNamespace = ns;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
// audit CC P1-1: --csaf-status promotes the CSAF tracking.status from the
|
|
1061
|
+
// runtime default (`interim`) to `final` for operators who have reviewed
|
|
1062
|
+
// the advisory and accept the immutable-advisory contract of CSAF
|
|
1063
|
+
// §3.1.11.3.5.1. Accepts the three CSAF spec values; anything else is
|
|
1064
|
+
// rejected at input so an operator typo (`finel`) doesn't silently fall
|
|
1065
|
+
// back to interim and produce surprise.
|
|
1066
|
+
if (args["csaf-status"] !== undefined) {
|
|
1067
|
+
const cs = args["csaf-status"];
|
|
1068
|
+
const allowed = ["draft", "interim", "final"];
|
|
1069
|
+
if (typeof cs !== "string" || !allowed.includes(cs)) {
|
|
1070
|
+
return emitError(
|
|
1071
|
+
`run: --csaf-status must be one of ${JSON.stringify(allowed)}. Got: ${JSON.stringify(String(cs)).slice(0, 40)}`,
|
|
1072
|
+
{ provided: cs },
|
|
1073
|
+
pretty
|
|
1074
|
+
);
|
|
1075
|
+
}
|
|
1076
|
+
runOpts.csafStatus = cs;
|
|
782
1077
|
}
|
|
1078
|
+
|
|
783
1079
|
// --ack: operator acknowledges the jurisdiction obligations surfaced by
|
|
784
1080
|
// govern. Captured in attestation; downstream tooling can check whether
|
|
785
1081
|
// consent was explicit vs. implicit. AGENTS.md says the AI should surface
|
|
786
1082
|
// and wait for ack — this is how the ack gets recorded.
|
|
787
|
-
|
|
1083
|
+
//
|
|
1084
|
+
// EE P1-6: --ack only makes sense on verbs that drive phases 5-7 (run /
|
|
1085
|
+
// ingest / ai-run / ci / run-all / reattest). Info-only verbs (brief,
|
|
1086
|
+
// plan, govern, direct, look, attest, list-attestations, discover,
|
|
1087
|
+
// doctor, lint, ask, verify-attestation) never consume an attestation
|
|
1088
|
+
// clock — accepting --ack silently here was a UX trap where operators
|
|
1089
|
+
// believed they had recorded consent. Refuse on those verbs so the
|
|
1090
|
+
// operator knows the flag is irrelevant.
|
|
1091
|
+
const ACK_RELEVANT_VERBS = new Set([
|
|
1092
|
+
"run", "ingest", "ai-run", "ci", "run-all", "reattest",
|
|
1093
|
+
]);
|
|
1094
|
+
if (args.ack) {
|
|
1095
|
+
if (!ACK_RELEVANT_VERBS.has(cmd)) {
|
|
1096
|
+
return emitError(
|
|
1097
|
+
`${cmd}: --ack is irrelevant on this verb (no jurisdiction clock at stake). --ack only applies to verbs that drive phases 5-7: ${[...ACK_RELEVANT_VERBS].sort().join(", ")}. Re-invoke without --ack, or use \`exceptd run ${cmd === "brief" ? args._[0] || "<playbook>" : "<playbook>"} --ack\` once you're past the briefing step.`,
|
|
1098
|
+
{ verb: cmd, accepted_verbs: [...ACK_RELEVANT_VERBS].sort() },
|
|
1099
|
+
pretty
|
|
1100
|
+
);
|
|
1101
|
+
}
|
|
1102
|
+
runOpts.operator_consent = { acked_at: new Date().toISOString(), explicit: true };
|
|
1103
|
+
}
|
|
788
1104
|
|
|
789
1105
|
let runner;
|
|
790
1106
|
try {
|
|
@@ -1124,7 +1440,7 @@ Flags:
|
|
|
1124
1440
|
Stdin event grammar (one JSON object per line):
|
|
1125
1441
|
{"event":"evidence","payload":{"observations":{},"verdict":{}}}
|
|
1126
1442
|
|
|
1127
|
-
Stdin acceptance contract
|
|
1443
|
+
Stdin acceptance contract:
|
|
1128
1444
|
In streaming mode, ai-run reads JSON-Lines from stdin until the FIRST
|
|
1129
1445
|
parseable {"event":"evidence","payload":{...}} line. That line wins:
|
|
1130
1446
|
subsequent evidence events on the same run are ignored (the handler
|
|
@@ -1558,7 +1874,7 @@ function cmdPlan(runner, args, runOpts, pretty) {
|
|
|
1558
1874
|
emit(plan, pretty);
|
|
1559
1875
|
}
|
|
1560
1876
|
|
|
1561
|
-
// v0.12.15
|
|
1877
|
+
// v0.12.15: --scope must validate against the accepted
|
|
1562
1878
|
// set. The prior shape silently returned [] for any unknown scope, which
|
|
1563
1879
|
// in `run --scope nonsense` produced `count: 0` + exit 0 (cmd reports
|
|
1564
1880
|
// "ran 0 playbooks") and in `ci --scope nonsense` silently ran only the
|
|
@@ -1648,7 +1964,14 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
1648
1964
|
// Multi-playbook dispatch path. Triggered by --all, --scope <type>, or by
|
|
1649
1965
|
// a bare `exceptd run` (no positional, no flags) which auto-detects scopes
|
|
1650
1966
|
// from the cwd.
|
|
1651
|
-
|
|
1967
|
+
// R-F9: gate on `args.scope !== undefined` rather than `args.scope`
|
|
1968
|
+
// truthy. Pre-fix, `--scope ""` parsed to `args.scope === ""`, which
|
|
1969
|
+
// is falsy — the dispatcher fell through to the auto-detect path and
|
|
1970
|
+
// silently ran whatever scopes happened to match the cwd, masking the
|
|
1971
|
+
// operator's explicit (if malformed) intent. Now: an empty string
|
|
1972
|
+
// reaches validateScopeOrThrow which rejects with the accepted-set
|
|
1973
|
+
// message, matching the rest of the v0.12.15 scope-validation contract.
|
|
1974
|
+
if (!positional && (args.all || args.scope !== undefined)) {
|
|
1652
1975
|
let ids;
|
|
1653
1976
|
if (args.all) {
|
|
1654
1977
|
ids = runner.listPlaybooks();
|
|
@@ -1658,7 +1981,7 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
1658
1981
|
}
|
|
1659
1982
|
return cmdRunMulti(runner, ids, args, runOpts, pretty, { trigger: args.all ? "--all" : `--scope ${args.scope}` });
|
|
1660
1983
|
}
|
|
1661
|
-
if (!positional && !args.all &&
|
|
1984
|
+
if (!positional && !args.all && args.scope === undefined) {
|
|
1662
1985
|
const scopes = detectScopes();
|
|
1663
1986
|
const ids = scopes.flatMap(s => filterPlaybooksByScope(runner, s));
|
|
1664
1987
|
const unique = [...new Set(ids)];
|
|
@@ -1722,11 +2045,24 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
1722
2045
|
}
|
|
1723
2046
|
|
|
1724
2047
|
let submission = {};
|
|
1725
|
-
// v0.11.1: auto-detect piped stdin
|
|
1726
|
-
//
|
|
1727
|
-
//
|
|
1728
|
-
//
|
|
1729
|
-
|
|
2048
|
+
// v0.11.1: auto-detect piped stdin. If no --evidence flag and stdin is a
|
|
2049
|
+
// pipe, assume `--evidence -`. Operators forgetting the flag previously
|
|
2050
|
+
// got a confusing precondition halt; now the common case "just works."
|
|
2051
|
+
// R-F3: use truthy `!process.stdin.isTTY` instead of `=== false`. On
|
|
2052
|
+
// Windows MSYS bash, process.stdin.isTTY is `undefined` for a piped
|
|
2053
|
+
// stream — the strict `=== false` check failed and auto-detect never
|
|
2054
|
+
// fired, making `echo '{...}' | exceptd run <pb>` silently behave like
|
|
2055
|
+
// no-evidence on Windows. cmdAiRun's path (below) already uses the
|
|
2056
|
+
// truthy form, so this brings cmdRun + cmdIngest to parity.
|
|
2057
|
+
//
|
|
2058
|
+
// EE P1-7: use the fstat-probing hasReadableStdin() helper instead of
|
|
2059
|
+
// the raw `!process.stdin.isTTY` truthy check. Test harnesses with
|
|
2060
|
+
// wrapped stdin duplexers (Mocha/Jest, Docker stdin-passthrough) leave
|
|
2061
|
+
// isTTY === undefined but have no data — the raw check fell into
|
|
2062
|
+
// readFileSync(0) and BLOCKED waiting for an EOF that never arrived.
|
|
2063
|
+
// hasReadableStdin() does an fstat() probe first, then falls back to
|
|
2064
|
+
// the truthy check only on Windows (where fstat on a pipe is unreliable).
|
|
2065
|
+
if (!args.evidence && hasReadableStdin()) {
|
|
1730
2066
|
args.evidence = "-";
|
|
1731
2067
|
}
|
|
1732
2068
|
if (args.evidence) {
|
|
@@ -1761,8 +2097,32 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
1761
2097
|
// CVE ID set through to analyze() so matched_cves drops them.
|
|
1762
2098
|
if (args.vex) {
|
|
1763
2099
|
let vexDoc;
|
|
2100
|
+
// R-F5: cap --vex file size the same way readEvidence() caps --evidence
|
|
2101
|
+
// (32 MiB — binary mebibytes, i.e. 32 * 1024 * 1024 = 33,554,432 bytes).
|
|
2102
|
+
// Pre-fix, --vex did a raw readFileSync with no size check — an operator
|
|
2103
|
+
// passing a multi-GB file (binary log, JSON bomb, or accident) blocked
|
|
2104
|
+
// the event loop for minutes / OOM'd the process. 32 MiB is well beyond
|
|
2105
|
+
// any legitimate VEX submission.
|
|
2106
|
+
const MAX_VEX_BYTES = 32 * 1024 * 1024;
|
|
2107
|
+
let vstat;
|
|
2108
|
+
try { vstat = fs.statSync(args.vex); }
|
|
2109
|
+
catch (e) {
|
|
2110
|
+
return emitError(`run: failed to stat --vex ${args.vex}: ${e.message}`, null, pretty);
|
|
2111
|
+
}
|
|
2112
|
+
if (vstat.size > MAX_VEX_BYTES) {
|
|
2113
|
+
// EE P1-4: error message names the binary mebi convention explicitly so
|
|
2114
|
+
// operators don't mistake the cap for 32 * 10^6 = 32,000,000 bytes (MB).
|
|
2115
|
+
return emitError(
|
|
2116
|
+
`run: --vex file too large: ${vstat.size} bytes exceeds 32 MiB limit (${MAX_VEX_BYTES.toLocaleString("en-US")} bytes). Reduce the document or split into multiple passes.`,
|
|
2117
|
+
{ provided_path: args.vex, size_bytes: vstat.size, limit_bytes: MAX_VEX_BYTES },
|
|
2118
|
+
pretty
|
|
2119
|
+
);
|
|
2120
|
+
}
|
|
1764
2121
|
try {
|
|
1765
|
-
|
|
2122
|
+
// EE P1-2: BOM-tolerant read. Windows-tool-emitted CycloneDX commonly
|
|
2123
|
+
// carries UTF-8-BOM or UTF-16 LE/BE markers; the raw "utf8" decode in
|
|
2124
|
+
// readFileSync chokes on the leading 0xFEFF.
|
|
2125
|
+
vexDoc = readJsonFile(args.vex);
|
|
1766
2126
|
} catch (e) {
|
|
1767
2127
|
return emitError(`run: failed to load --vex ${args.vex}: ${e.message}`, null, pretty);
|
|
1768
2128
|
}
|
|
@@ -1784,6 +2144,16 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
1784
2144
|
const vexSet = runner.vexFilterFromDoc(vexDoc);
|
|
1785
2145
|
submission.signals = submission.signals || {};
|
|
1786
2146
|
submission.signals.vex_filter = [...vexSet];
|
|
2147
|
+
// BB P1-3: vexFilterFromDoc attaches a `.fixed` Set as an own property
|
|
2148
|
+
// on the returned filter Set (CycloneDX `analysis.state: 'resolved'`
|
|
2149
|
+
// + OpenVEX `status: 'fixed'` go into this set). Without forwarding it
|
|
2150
|
+
// through to signals.vex_fixed, analyze() never receives the fixed-
|
|
2151
|
+
// disposition CVE ids — `vexFixed` stays null, `vex_status: 'fixed'`
|
|
2152
|
+
// never gets annotated onto matched_cves entries, and CSAF
|
|
2153
|
+
// product_status.fixed + OpenVEX status:'fixed' are unreachable from
|
|
2154
|
+
// the CLI. The bundle-correctness tests only exercised the analyze()
|
|
2155
|
+
// direct-call path with vex_fixed pre-injected, hiding this regression.
|
|
2156
|
+
submission.signals.vex_fixed = vexSet.fixed ? [...vexSet.fixed] : [];
|
|
1787
2157
|
} catch (e) {
|
|
1788
2158
|
return emitError(`run: failed to apply --vex ${args.vex}: ${e.message}`, null, pretty);
|
|
1789
2159
|
}
|
|
@@ -1820,9 +2190,27 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
1820
2190
|
// v0.11.10 (#119): add result.ack alias for consumers reading the
|
|
1821
2191
|
// ack state by that name (`result.ack` is shorter + matches the CLI flag).
|
|
1822
2192
|
if (result && runOpts.operator) result.operator = runOpts.operator;
|
|
2193
|
+
|
|
2194
|
+
// EE P1-6: --ack consent only counts when a jurisdiction clock is actually
|
|
2195
|
+
// at stake — i.e. the run produced classification=detected (a real finding
|
|
2196
|
+
// that may trigger NIS2 24h / DORA 4h / GDPR 72h obligations). On a
|
|
2197
|
+
// not-detected or inconclusive run, persisting the consent silently was
|
|
2198
|
+
// misleading: the attestation file recorded operator acknowledgement of
|
|
2199
|
+
// a clock that never started. Now: surface the ack state in the run body
|
|
2200
|
+
// either way so operators see what happened, but only persist
|
|
2201
|
+
// `operator_consent` into the attestation when classification === detected.
|
|
2202
|
+
const detectClassification = result && result.phases && result.phases.detect
|
|
2203
|
+
? result.phases.detect.classification
|
|
2204
|
+
: null;
|
|
2205
|
+
const consentApplies =
|
|
2206
|
+
!!runOpts.operator_consent && detectClassification === "detected";
|
|
1823
2207
|
if (result && runOpts.operator_consent) {
|
|
1824
2208
|
result.operator_consent = runOpts.operator_consent;
|
|
1825
2209
|
result.ack = !!runOpts.operator_consent.explicit;
|
|
2210
|
+
result.ack_applied = consentApplies;
|
|
2211
|
+
if (!consentApplies) {
|
|
2212
|
+
result.ack_skipped_reason = `classification=${detectClassification || "unknown"}; consent only persisted when classification=detected (jurisdiction clock at stake).`;
|
|
2213
|
+
}
|
|
1826
2214
|
} else if (result) {
|
|
1827
2215
|
result.ack = false;
|
|
1828
2216
|
}
|
|
@@ -1835,7 +2223,8 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
1835
2223
|
directiveId: result.directive_id,
|
|
1836
2224
|
evidenceHash: result.evidence_hash,
|
|
1837
2225
|
operator: runOpts.operator,
|
|
1838
|
-
|
|
2226
|
+
// EE P1-6: gate consent persistence on classification=detected.
|
|
2227
|
+
operatorConsent: consentApplies ? runOpts.operator_consent : null,
|
|
1839
2228
|
submission,
|
|
1840
2229
|
runOpts,
|
|
1841
2230
|
forceOverwrite: !!args["force-overwrite"],
|
|
@@ -1853,7 +2242,7 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
1853
2242
|
hint: "Pass --force-overwrite to replace, or supply a fresh --session-id (omit the flag for an auto-generated hex).",
|
|
1854
2243
|
verb: "run",
|
|
1855
2244
|
};
|
|
1856
|
-
// v0.12.14
|
|
2245
|
+
// v0.12.14: exitCode + return instead of process.exit
|
|
1857
2246
|
// so the stderr line drains under piped CI consumers.
|
|
1858
2247
|
process.stderr.write(JSON.stringify(err) + "\n");
|
|
1859
2248
|
process.exitCode = 3;
|
|
@@ -2198,10 +2587,18 @@ function buildJurisdictionClockRollup(results) {
|
|
|
2198
2587
|
existing.deadline = n.deadline;
|
|
2199
2588
|
}
|
|
2200
2589
|
} else {
|
|
2590
|
+
// R-F11: emit `obligation` (the field name the CHANGELOG v0.12.16
|
|
2591
|
+
// entry promised) AND retain `obligation_ref` as a kept-name alias
|
|
2592
|
+
// for any consumer that already parses the older shape. The dedupe
|
|
2593
|
+
// key still keys on n.obligation_ref since that's the field
|
|
2594
|
+
// notification-action stubs carry; the rollup body just exposes
|
|
2595
|
+
// both names so the documented contract is truthful.
|
|
2596
|
+
const obligation = n.obligation_ref || null;
|
|
2201
2597
|
m.set(key, {
|
|
2202
2598
|
jurisdiction: n.jurisdiction || null,
|
|
2203
2599
|
regulation: n.regulation || null,
|
|
2204
|
-
|
|
2600
|
+
obligation,
|
|
2601
|
+
obligation_ref: obligation,
|
|
2205
2602
|
window_hours: n.window_hours ?? null,
|
|
2206
2603
|
clock_started_at: n.clock_started_at,
|
|
2207
2604
|
deadline: n.deadline || null,
|
|
@@ -2249,6 +2646,52 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
|
2249
2646
|
if (!entryPath.startsWith(resolvedDir + path.sep)) {
|
|
2250
2647
|
return emitError(`run: --evidence-dir entry ${f} resolves outside the directory; refusing.`, null, pretty);
|
|
2251
2648
|
}
|
|
2649
|
+
// R-F12: the path.resolve check above only catches `..` traversal in
|
|
2650
|
+
// the joined path; fs.readFileSync(entryPath) still follows symlinks,
|
|
2651
|
+
// so a `<pb-id>.json -> /etc/shadow` symlink inside the dir would
|
|
2652
|
+
// happily slurp the target. lstat is symlink-aware (it does NOT
|
|
2653
|
+
// follow); refuse anything that's not a regular file. Defense in
|
|
2654
|
+
// depth on top of the readdir filter — a junction (Windows) or
|
|
2655
|
+
// bind-mount can shape-shift in between filter and read.
|
|
2656
|
+
let lst;
|
|
2657
|
+
try { lst = fs.lstatSync(entryPath); }
|
|
2658
|
+
catch (e) {
|
|
2659
|
+
return emitError(`run: --evidence-dir entry ${f}: lstat failed: ${e.message}`, null, pretty);
|
|
2660
|
+
}
|
|
2661
|
+
if (lst.isSymbolicLink()) {
|
|
2662
|
+
return emitError(`run: --evidence-dir entry ${f} is a symbolic link; refusing (symlinks bypass the directory-confinement check).`, { entry: f }, pretty);
|
|
2663
|
+
}
|
|
2664
|
+
if (!lst.isFile()) {
|
|
2665
|
+
return emitError(`run: --evidence-dir entry ${f} is not a regular file; refusing.`, { entry: f }, pretty);
|
|
2666
|
+
}
|
|
2667
|
+
// EE P1-5: Windows directory junctions are reparse-point dirs that
|
|
2668
|
+
// `lstat().isSymbolicLink()` returns FALSE for (Node treats them as
|
|
2669
|
+
// ordinary directories). They bypass the symlink refusal above. Use
|
|
2670
|
+
// realpathSync to resolve the entry and confirm it still lives under
|
|
2671
|
+
// the resolved evidence-dir — the realpath approach is portable
|
|
2672
|
+
// (catches POSIX symlinks too, defense in depth) and works regardless
|
|
2673
|
+
// of whether the OS exposes reparse-point bits.
|
|
2674
|
+
let realEntry;
|
|
2675
|
+
try { realEntry = fs.realpathSync(entryPath); }
|
|
2676
|
+
catch (e) {
|
|
2677
|
+
return emitError(`run: --evidence-dir entry ${f}: realpath failed: ${e.message}`, null, pretty);
|
|
2678
|
+
}
|
|
2679
|
+
if (realEntry !== entryPath && !realEntry.startsWith(resolvedDir + path.sep)) {
|
|
2680
|
+
return emitError(
|
|
2681
|
+
`run: --evidence-dir entry ${f} resolves outside the directory (junction / reparse-point / symlink target). Refusing.`,
|
|
2682
|
+
{ entry: f, resolved_to: realEntry },
|
|
2683
|
+
pretty
|
|
2684
|
+
);
|
|
2685
|
+
}
|
|
2686
|
+
// EE P1-5 (hardlink defense in depth): no clean cross-platform refusal
|
|
2687
|
+
// exists — hardlinks are indistinguishable from regular files at the
|
|
2688
|
+
// inode level. Surface a stderr warning when nlink > 1 so the operator
|
|
2689
|
+
// is aware a second name may point at the same file. Not a refusal —
|
|
2690
|
+
// legitimate use cases (atomic rename, package-manager dedup) produce
|
|
2691
|
+
// nlink > 1 without malicious intent.
|
|
2692
|
+
if (lst.nlink > 1) {
|
|
2693
|
+
process.stderr.write(`[exceptd run --evidence-dir] WARNING: ${f} has nlink=${lst.nlink}; a hardlink to this file exists elsewhere on the filesystem. Hardlinks cannot be refused cross-platform — confirm the file content is what you expect.\n`);
|
|
2694
|
+
}
|
|
2252
2695
|
try {
|
|
2253
2696
|
bundle[pbId] = JSON.parse(fs.readFileSync(entryPath, "utf8"));
|
|
2254
2697
|
} catch (e) {
|
|
@@ -2340,7 +2783,16 @@ function cmdIngest(runner, args, runOpts, pretty) {
|
|
|
2340
2783
|
// `echo '{...}' | exceptd ingest` failed with "no playbook resolved"
|
|
2341
2784
|
// because args.evidence stayed undefined and the routing JSON never got
|
|
2342
2785
|
// read. Mirrors the cmdRun behavior at line 1614.
|
|
2343
|
-
|
|
2786
|
+
// R-F3: use truthy `!process.stdin.isTTY` rather than `=== false`. On
|
|
2787
|
+
// Windows MSYS bash, isTTY is `undefined` for piped streams — the
|
|
2788
|
+
// strict `=== false` check failed and ingest silently treated the
|
|
2789
|
+
// routing JSON as absent. CHANGELOG v0.12.16 F4 ("cmdIngest auto-
|
|
2790
|
+
// detects piped stdin") was a no-op on Windows pre-fix.
|
|
2791
|
+
//
|
|
2792
|
+
// EE P1-7: route through hasReadableStdin() — see cmdRun for rationale.
|
|
2793
|
+
// Wrapped-stdin test harnesses (Mocha/Jest, Docker stdin-passthrough)
|
|
2794
|
+
// would otherwise block here forever on the readFileSync(0) call.
|
|
2795
|
+
if (!args.evidence && hasReadableStdin()) {
|
|
2344
2796
|
args.evidence = "-";
|
|
2345
2797
|
}
|
|
2346
2798
|
if (args.evidence) {
|
|
@@ -2539,14 +2991,89 @@ function persistAttestation(args) {
|
|
|
2539
2991
|
existingPath: path.relative(process.cwd(), filePath),
|
|
2540
2992
|
};
|
|
2541
2993
|
}
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2994
|
+
// T P1-2: serialize the read-prior + write-new sequence behind a
|
|
2995
|
+
// lockfile so concurrent --force-overwrite invocations against the
|
|
2996
|
+
// same session-id slot do not degrade to last-write-wins. Pattern
|
|
2997
|
+
// matches withCatalogLock + withIndexLock: O_EXCL 'wx' on a sibling
|
|
2998
|
+
// .lock file with bounded retry, PID-liveness check on contention,
|
|
2999
|
+
// mtime fallback for orphaned lockfiles.
|
|
3000
|
+
// DD P1-2: MAX_RETRIES capped at 10 (was 50). persistAttestation is a
|
|
3001
|
+
// sync function called from sync callers throughout the CLI, so the
|
|
3002
|
+
// wait loop must busy-spin (no event-loop yield available). At 50
|
|
3003
|
+
// retries × ~200ms backoff per spin the worst case was ~10s of pegged-
|
|
3004
|
+
// CPU + frozen-event-loop stall under attestation contention. Capping
|
|
3005
|
+
// at 10 bounds the freeze at ~1-2s; beyond that callers receive the
|
|
3006
|
+
// LOCK_CONTENTION sentinel on the result object and can retry from the
|
|
3007
|
+
// outside without holding the CPU. Async refactor of persistAttestation
|
|
3008
|
+
// + every caller is a v0.13.0 candidate.
|
|
3009
|
+
const lockPath = filePath + ".lock";
|
|
3010
|
+
const MAX_RETRIES = 10;
|
|
3011
|
+
const STALE_LOCK_MS = 30_000;
|
|
3012
|
+
let acquired = false;
|
|
3013
|
+
for (let i = 0; i < MAX_RETRIES; i++) {
|
|
3014
|
+
try {
|
|
3015
|
+
fs.writeFileSync(lockPath, String(process.pid), { flag: "wx" });
|
|
3016
|
+
acquired = true;
|
|
3017
|
+
break;
|
|
3018
|
+
} catch (lockErr) {
|
|
3019
|
+
if (lockErr.code !== "EEXIST" && lockErr.code !== "EPERM") throw lockErr;
|
|
3020
|
+
let reclaimed = false;
|
|
3021
|
+
try {
|
|
3022
|
+
const raw = fs.readFileSync(lockPath, "utf8").trim();
|
|
3023
|
+
const pid = Number.parseInt(raw, 10);
|
|
3024
|
+
if (Number.isInteger(pid) && pid > 0 && pid !== process.pid) {
|
|
3025
|
+
try { process.kill(pid, 0); }
|
|
3026
|
+
catch (probeErr) {
|
|
3027
|
+
if (probeErr && probeErr.code === "ESRCH") {
|
|
3028
|
+
try { fs.unlinkSync(lockPath); reclaimed = true; } catch {}
|
|
3029
|
+
}
|
|
3030
|
+
}
|
|
3031
|
+
}
|
|
3032
|
+
} catch {}
|
|
3033
|
+
if (reclaimed) continue;
|
|
3034
|
+
try {
|
|
3035
|
+
const stat = fs.statSync(lockPath);
|
|
3036
|
+
if (Date.now() - stat.mtimeMs > STALE_LOCK_MS) {
|
|
3037
|
+
try { fs.unlinkSync(lockPath); } catch {}
|
|
3038
|
+
continue;
|
|
3039
|
+
}
|
|
3040
|
+
} catch {}
|
|
3041
|
+
// Synchronous spin — persistAttestation is sync; we cannot await.
|
|
3042
|
+
const deadline = Date.now() + 50 + Math.floor(Math.random() * 150);
|
|
3043
|
+
while (Date.now() < deadline) { /* spin */ }
|
|
3044
|
+
}
|
|
3045
|
+
}
|
|
3046
|
+
if (!acquired) {
|
|
3047
|
+
// DD P1-2: lock_contention sentinel so callers can distinguish a
|
|
3048
|
+
// genuine lock-busy condition (retry-from-outside is the right move)
|
|
3049
|
+
// from a hard failure (write error, permission denial). The sync
|
|
3050
|
+
// spin budget was bounded above so we hit this return after ~1-2s
|
|
3051
|
+
// of contention rather than the prior ~10s.
|
|
3052
|
+
return {
|
|
3053
|
+
ok: false,
|
|
3054
|
+
error: `LOCK_CONTENTION: Failed to acquire attestation lock at ${path.relative(process.cwd(), lockPath)} after ${MAX_RETRIES} attempts (~1-2s of contention). Retry the operation; if it persists, inspect the lockfile for a stale holder.`,
|
|
3055
|
+
existingPath: path.relative(process.cwd(), filePath),
|
|
3056
|
+
lock_contention: true,
|
|
3057
|
+
};
|
|
3058
|
+
}
|
|
3059
|
+
try {
|
|
3060
|
+
// Re-read prior INSIDE the lock — the value captured before lock
|
|
3061
|
+
// acquisition may be stale if another --force-overwrite landed
|
|
3062
|
+
// between our EEXIST probe and the lock grab.
|
|
3063
|
+
let lockedPrior = prior;
|
|
3064
|
+
try { lockedPrior = JSON.parse(fs.readFileSync(filePath, "utf8")); }
|
|
3065
|
+
catch { /* keep pre-lock prior */ }
|
|
3066
|
+
writeAttestation(lockedPrior ? (lockedPrior.evidence_hash || null) : null,
|
|
3067
|
+
lockedPrior ? (lockedPrior.captured_at || null) : null,
|
|
3068
|
+
"w");
|
|
3069
|
+
return {
|
|
3070
|
+
ok: true,
|
|
3071
|
+
prior_session_id: lockedPrior ? sessionId : null,
|
|
3072
|
+
overwrote_at: lockedPrior ? lockedPrior.captured_at : null,
|
|
3073
|
+
};
|
|
3074
|
+
} finally {
|
|
3075
|
+
try { fs.unlinkSync(lockPath); } catch {}
|
|
3076
|
+
}
|
|
2550
3077
|
}
|
|
2551
3078
|
} catch (e) {
|
|
2552
3079
|
return { ok: false, error: `Failed to write attestation: ${e.message}`, existingPath: null };
|
|
@@ -2563,6 +3090,20 @@ function persistAttestation(args) {
|
|
|
2563
3090
|
* state so downstream tooling can distinguish "operator declined signing"
|
|
2564
3091
|
* from "the .sig file was deleted by an attacker."
|
|
2565
3092
|
*/
|
|
3093
|
+
/**
|
|
3094
|
+
* C: byte-stability normalize() for the attestation pipeline.
|
|
3095
|
+
* Strips a leading UTF-8 BOM and collapses CRLF → LF. Mirrors the
|
|
3096
|
+
* normalize() implementations in lib/sign.js, lib/verify.js,
|
|
3097
|
+
* lib/refresh-network.js, and scripts/verify-shipped-tarball.js. Five
|
|
3098
|
+
* sites total; tests/normalize-contract.test.js asserts byte-identical
|
|
3099
|
+
* output across all of them.
|
|
3100
|
+
*/
|
|
3101
|
+
function normalizeAttestationBytes(input) {
|
|
3102
|
+
let s = Buffer.isBuffer(input) ? input.toString("utf8") : String(input);
|
|
3103
|
+
if (s.length > 0 && s.charCodeAt(0) === 0xFEFF) s = s.slice(1);
|
|
3104
|
+
return s.replace(/\r\n/g, "\n");
|
|
3105
|
+
}
|
|
3106
|
+
|
|
2566
3107
|
function maybeSignAttestation(filePath) {
|
|
2567
3108
|
const crypto = require("crypto");
|
|
2568
3109
|
const sigPath = filePath + ".sig";
|
|
@@ -2576,7 +3117,16 @@ function maybeSignAttestation(filePath) {
|
|
|
2576
3117
|
// (reporting only PKG_ROOT now), not by making `run` follow a cwd key the
|
|
2577
3118
|
// verifier doesn't trust.
|
|
2578
3119
|
const privKeyPath = path.join(PKG_ROOT, ".keys", "private.pem");
|
|
2579
|
-
|
|
3120
|
+
// C: normalize attestation bytes before sign — strip leading
|
|
3121
|
+
// UTF-8 BOM + collapse CRLF to LF. Mirrors lib/sign.js / lib/verify.js /
|
|
3122
|
+
// lib/refresh-network.js / scripts/verify-shipped-tarball.js. The
|
|
3123
|
+
// attestation file lives on disk under .exceptd/ and can pick up CRLF
|
|
3124
|
+
// through git-attribute / editor round-trips on Windows; without
|
|
3125
|
+
// normalization the sign/verify pair diverges on the same logical content.
|
|
3126
|
+
// The byte-stability contract is now five sites; tests/normalize-contract
|
|
3127
|
+
// .test.js enforces byte-identical output across all of them.
|
|
3128
|
+
const rawContent = fs.readFileSync(filePath, "utf8");
|
|
3129
|
+
const content = normalizeAttestationBytes(rawContent);
|
|
2580
3130
|
// One-time-per-process unsigned warning so cron jobs don't spam stderr.
|
|
2581
3131
|
// Operators who set `.keys/private.pem` get tamper-evident attestations;
|
|
2582
3132
|
// operators without the keypair get a single nudge per session telling them
|
|
@@ -2623,7 +3173,7 @@ function maybeSignAttestation(filePath) {
|
|
|
2623
3173
|
* Returns null if neither has the session.
|
|
2624
3174
|
*/
|
|
2625
3175
|
/**
|
|
2626
|
-
* v0.12.14
|
|
3176
|
+
* v0.12.14: session-id validation — applied at every READ
|
|
2627
3177
|
* site, not just writes. The write path (persistAttestation) was hardened
|
|
2628
3178
|
* in v0.12.12, but the read paths (findSessionDir / cmdAttest / cmdReattest)
|
|
2629
3179
|
* accepted arbitrary strings and joined them into path.join(root, id) with
|
|
@@ -2642,7 +3192,7 @@ function validateSessionIdForRead(sessionId) {
|
|
|
2642
3192
|
}
|
|
2643
3193
|
|
|
2644
3194
|
function findSessionDir(sessionId, runOpts) {
|
|
2645
|
-
// v0.12.14
|
|
3195
|
+
// v0.12.14: validate the session-id at every read path.
|
|
2646
3196
|
try { validateSessionIdForRead(sessionId); }
|
|
2647
3197
|
catch { return null; }
|
|
2648
3198
|
const candidates = [
|
|
@@ -2718,20 +3268,70 @@ function verifyAttestationSidecar(attFile) {
|
|
|
2718
3268
|
const sigPath = attFile + ".sig";
|
|
2719
3269
|
const pubKeyPath = path.join(PKG_ROOT, "keys", "public.pem");
|
|
2720
3270
|
const pubKey = fs.existsSync(pubKeyPath) ? fs.readFileSync(pubKeyPath, "utf8") : null;
|
|
3271
|
+
// Consult keys/EXPECTED_FINGERPRINT before honoring
|
|
3272
|
+
// the loaded public key. The v0.12.16 CHANGELOG claimed "pin consulted
|
|
3273
|
+
// at every public-key load site," but reattest's signature verifier
|
|
3274
|
+
// loaded keys/public.pem without the pin cross-check. A coordinated
|
|
3275
|
+
// attacker who swapped keys/public.pem on the operator's host could
|
|
3276
|
+
// verify-against-attacker-key without surfacing the divergence. Honors
|
|
3277
|
+
// KEYS_ROTATED=1 to bypass during legitimate rotation.
|
|
3278
|
+
if (pubKey) {
|
|
3279
|
+
const pinError = assertExpectedFingerprint(pubKey);
|
|
3280
|
+
if (pinError) {
|
|
3281
|
+
return { file: attFile, signed: false, verified: false, reason: pinError };
|
|
3282
|
+
}
|
|
3283
|
+
}
|
|
2721
3284
|
if (!fs.existsSync(sigPath)) {
|
|
2722
3285
|
return { file: attFile, signed: false, verified: false, reason: "no .sig sidecar" };
|
|
2723
3286
|
}
|
|
2724
3287
|
let sigDoc;
|
|
2725
3288
|
try { sigDoc = JSON.parse(fs.readFileSync(sigPath, "utf8")); }
|
|
2726
|
-
catch (e) {
|
|
3289
|
+
catch (e) {
|
|
3290
|
+
// Audit AA P1-2: a corrupt-JSON sidecar is observationally indistinguishable
|
|
3291
|
+
// from sidecar tamper — an attacker who can rewrite attestation.json can
|
|
3292
|
+
// also truncate / mangle the .sig file. Surface as a distinct
|
|
3293
|
+
// tamper-class reason so callers can require --force-replay. Pre-fix,
|
|
3294
|
+
// cmdReattest only refused on `reason === "no .sig sidecar"`; a
|
|
3295
|
+
// parse-error reason fell through to the benign NOTE branch and replay
|
|
3296
|
+
// proceeded against forged input.
|
|
3297
|
+
return {
|
|
3298
|
+
file: attFile,
|
|
3299
|
+
signed: false,
|
|
3300
|
+
verified: false,
|
|
3301
|
+
reason: `sidecar parse error: ${e.message}`,
|
|
3302
|
+
tamper_class: "sidecar-corrupt",
|
|
3303
|
+
};
|
|
3304
|
+
}
|
|
2727
3305
|
if (sigDoc.algorithm === "unsigned") {
|
|
3306
|
+
// Audit AA P1-1: `algorithm: "unsigned"` is only legitimate when written
|
|
3307
|
+
// by maybeSignAttestation() at attestation-creation time on a host
|
|
3308
|
+
// WITHOUT .keys/private.pem. If the verifying host HAS a private key,
|
|
3309
|
+
// an "unsigned" sidecar is a substitution attack: tamper attestation.json
|
|
3310
|
+
// (breaking Ed25519) then overwrite .sig with the unsigned stub to bypass
|
|
3311
|
+
// the tamper detector. Promote to tamper-class so callers can refuse.
|
|
3312
|
+
const privKeyPath = path.join(PKG_ROOT, ".keys", "private.pem");
|
|
3313
|
+
if (fs.existsSync(privKeyPath)) {
|
|
3314
|
+
return {
|
|
3315
|
+
file: attFile,
|
|
3316
|
+
signed: false,
|
|
3317
|
+
verified: false,
|
|
3318
|
+
reason: "attestation explicitly unsigned but .keys/private.pem IS present on this host — sidecar substitution suspected (legitimate unsigned attestations cannot exist alongside a private key)",
|
|
3319
|
+
tamper_class: "unsigned-substitution",
|
|
3320
|
+
};
|
|
3321
|
+
}
|
|
2728
3322
|
return { file: attFile, signed: false, verified: false, reason: "attestation explicitly unsigned (no private key when written)" };
|
|
2729
3323
|
}
|
|
2730
3324
|
if (!pubKey) {
|
|
2731
3325
|
return { file: attFile, signed: true, verified: false, reason: "no public key at keys/public.pem to verify against" };
|
|
2732
3326
|
}
|
|
2733
3327
|
let content;
|
|
2734
|
-
try {
|
|
3328
|
+
try {
|
|
3329
|
+
const raw = fs.readFileSync(attFile, "utf8");
|
|
3330
|
+
// C: apply the same normalize() used by the signer so the
|
|
3331
|
+
// verify path is byte-stable across CRLF / BOM churn (Windows checkout
|
|
3332
|
+
// with core.autocrlf=true, editor round-trips, git-attributes flips).
|
|
3333
|
+
content = normalizeAttestationBytes(raw);
|
|
3334
|
+
}
|
|
2735
3335
|
catch (e) { return { file: attFile, signed: true, verified: false, reason: `attestation read error: ${e.message}` }; }
|
|
2736
3336
|
try {
|
|
2737
3337
|
const ok = crypto.verify(null, Buffer.from(content, "utf8"), {
|
|
@@ -2754,12 +3354,10 @@ function cmdReattest(runner, args, runOpts, pretty) {
|
|
|
2754
3354
|
// through to walkAttestationDir, where the lexical comparison either
|
|
2755
3355
|
// matched all or none unpredictably.
|
|
2756
3356
|
if (args.since != null) {
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
);
|
|
2762
|
-
}
|
|
3357
|
+
// R-F10: regex BEFORE Date.parse — bare integers like "99" would
|
|
3358
|
+
// otherwise parse as the year 1999 and silently filter wrong eras.
|
|
3359
|
+
const sinceErr = validateIsoSince(args.since);
|
|
3360
|
+
if (sinceErr) return emitError(`reattest: ${sinceErr}`, null, pretty);
|
|
2763
3361
|
}
|
|
2764
3362
|
// --latest [--playbook <id>] [--since <ISO>] — find prior attestation
|
|
2765
3363
|
// without requiring the operator to know the session-id.
|
|
@@ -2790,7 +3388,16 @@ function cmdReattest(runner, args, runOpts, pretty) {
|
|
|
2790
3388
|
// tampering. `verified === false && signed === true` is the real tamper
|
|
2791
3389
|
// signal.
|
|
2792
3390
|
const verify = verifyAttestationSidecar(attFile);
|
|
2793
|
-
|
|
3391
|
+
// Audit AA P1-1 + P1-2: collapse tamper-class detection. Any non-benign
|
|
3392
|
+
// sidecar state (signed-but-invalid, sidecar-corrupt, unsigned-substitution)
|
|
3393
|
+
// refuses replay unless --force-replay is set. The pre-fix shape only
|
|
3394
|
+
// refused on `verify.signed && !verify.verified` (signed-tamper) and on
|
|
3395
|
+
// `no .sig sidecar` (missing); corrupt-JSON sidecars and substituted
|
|
3396
|
+
// "unsigned" sidecars on a host WITH a private key fell into the benign
|
|
3397
|
+
// NOTE branch and replay proceeded against forged input.
|
|
3398
|
+
const isSignedTamper = verify.signed && !verify.verified;
|
|
3399
|
+
const isClassTamper = !verify.signed && (verify.tamper_class === "sidecar-corrupt" || verify.tamper_class === "unsigned-substitution");
|
|
3400
|
+
if ((isSignedTamper || isClassTamper) && !args["force-replay"]) {
|
|
2794
3401
|
process.stderr.write(`[exceptd reattest] TAMPERED: attestation at ${attFile} failed Ed25519 verification (${verify.reason}). Refusing to replay against forged input. Pass --force-replay to override (the replay output records sidecar_verify so the audit trail captures the override).\n`);
|
|
2795
3402
|
const body = {
|
|
2796
3403
|
ok: false,
|
|
@@ -2805,8 +3412,58 @@ function cmdReattest(runner, args, runOpts, pretty) {
|
|
|
2805
3412
|
process.exitCode = 6;
|
|
2806
3413
|
return;
|
|
2807
3414
|
}
|
|
2808
|
-
if (
|
|
3415
|
+
if ((isSignedTamper || isClassTamper) && args["force-replay"]) {
|
|
2809
3416
|
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`);
|
|
3417
|
+
} else if (!verify.signed && verify.reason && verify.reason.includes("no .sig sidecar") && !args["force-replay"]) {
|
|
3418
|
+
// missing-sidecar is NOT benign. The previous flow accepted
|
|
3419
|
+
// a missing .sig file silently (only blocked on signed-but-invalid).
|
|
3420
|
+
// Sidecar deletion is observationally identical to sidecar tamper —
|
|
3421
|
+
// an attacker who can rewrite the attestation can also rm the sidecar,
|
|
3422
|
+
// and pre-fix that path produced a green replay with no audit warning.
|
|
3423
|
+
// Now: refuse unless --force-replay, and the persisted replay body
|
|
3424
|
+
// records sidecar_verify so the override is audit-visible. Operators
|
|
3425
|
+
// whose original run wrote unsigned attestations (no private key
|
|
3426
|
+
// available) hit the "explicitly unsigned" branch below, which is
|
|
3427
|
+
// distinguishable from a missing sidecar.
|
|
3428
|
+
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`);
|
|
3429
|
+
const body = {
|
|
3430
|
+
ok: false,
|
|
3431
|
+
error: `reattest: prior attestation has no .sig sidecar — refusing to replay`,
|
|
3432
|
+
verb: "reattest",
|
|
3433
|
+
session_id: sessionId,
|
|
3434
|
+
attestation_file: attFile,
|
|
3435
|
+
sidecar_verify: verify,
|
|
3436
|
+
hint: "If the sidecar was intentionally removed (e.g. a clean operator rotation) and you have inspected the attestation, pass --force-replay.",
|
|
3437
|
+
};
|
|
3438
|
+
process.stderr.write(JSON.stringify(body) + "\n");
|
|
3439
|
+
process.exitCode = 6;
|
|
3440
|
+
return;
|
|
3441
|
+
} else if (!verify.signed && verify.reason && verify.reason.includes("no .sig sidecar") && args["force-replay"]) {
|
|
3442
|
+
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`);
|
|
3443
|
+
} else if (!verify.signed && verify.reason && verify.reason.startsWith("attestation explicitly unsigned") && !args["force-replay"]) {
|
|
3444
|
+
// Audit AA P1-1: legitimately-unsigned attestations (written when the
|
|
3445
|
+
// attesting host had no private key) require --force-replay to consume.
|
|
3446
|
+
// Pre-fix, the NOTE branch accepted them silently — which let an
|
|
3447
|
+
// attacker swap a valid .sig with the unsigned stub on a host that
|
|
3448
|
+
// happens to be private-key-absent at verify time. The cost of
|
|
3449
|
+
// requiring --force-replay is one explicit operator step; the benefit
|
|
3450
|
+
// is that any unsigned-substitution event becomes audit-visible via
|
|
3451
|
+
// sidecar_verify + force_replay in the emitted body.
|
|
3452
|
+
process.stderr.write(`[exceptd reattest] EXPLICITLY-UNSIGNED: attestation at ${attFile} carries an "unsigned" sidecar (${verify.reason}). Replay against unsigned input requires --force-replay so the audit trail captures the override.\n`);
|
|
3453
|
+
const body = {
|
|
3454
|
+
ok: false,
|
|
3455
|
+
error: `reattest: prior attestation is explicitly unsigned — refusing to replay without --force-replay`,
|
|
3456
|
+
verb: "reattest",
|
|
3457
|
+
session_id: sessionId,
|
|
3458
|
+
attestation_file: attFile,
|
|
3459
|
+
sidecar_verify: verify,
|
|
3460
|
+
hint: "If the original attestation was legitimately produced without a private key, pass --force-replay. The replay body will record sidecar_verify: 'explicitly-unsigned' + force_replay: true.",
|
|
3461
|
+
};
|
|
3462
|
+
process.stderr.write(JSON.stringify(body) + "\n");
|
|
3463
|
+
process.exitCode = 6;
|
|
3464
|
+
return;
|
|
3465
|
+
} else if (!verify.signed && verify.reason && verify.reason.startsWith("attestation explicitly unsigned") && args["force-replay"]) {
|
|
3466
|
+
process.stderr.write(`[exceptd reattest] WARNING: --force-replay overriding explicitly-unsigned attestation on ${attFile}. The replay output records sidecar_verify: 'explicitly-unsigned' so the override is audit-visible.\n`);
|
|
2810
3467
|
} else if (!verify.signed && verify.reason !== "no .sig sidecar") {
|
|
2811
3468
|
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
3469
|
}
|
|
@@ -2879,10 +3536,40 @@ function cmdReattest(runner, args, runOpts, pretty) {
|
|
|
2879
3536
|
// F10: persist the sidecar verify result + the force-replay flag so the
|
|
2880
3537
|
// audit trail records whether the replay was authenticated input.
|
|
2881
3538
|
sidecar_verify: verify,
|
|
3539
|
+
// Audit AA P1-1: emit a one-token classification label alongside the
|
|
3540
|
+
// full sidecar_verify object so log scrapers / dashboards can filter on
|
|
3541
|
+
// override events without parsing reason strings. Values:
|
|
3542
|
+
// 'verified' — Ed25519 sidecar verified
|
|
3543
|
+
// 'tampered' — signed-but-invalid signature (post-hoc tamper)
|
|
3544
|
+
// 'sidecar-corrupt' — sidecar JSON parse failure (tamper class)
|
|
3545
|
+
// 'unsigned-substitution'— "unsigned" sidecar on a host with private key
|
|
3546
|
+
// (substitution attack signal)
|
|
3547
|
+
// 'explicitly-unsigned' — legitimately-unsigned attestation
|
|
3548
|
+
// 'no-sidecar' — sidecar file absent
|
|
3549
|
+
// 'no-public-key' — infra-missing (operator-side keys/public.pem absent)
|
|
3550
|
+
sidecar_verify_class: classifySidecarVerify(verify),
|
|
2882
3551
|
force_replay: !!args["force-replay"],
|
|
2883
3552
|
}, pretty);
|
|
2884
3553
|
}
|
|
2885
3554
|
|
|
3555
|
+
/**
|
|
3556
|
+
* Audit AA P1-1: map a verifyAttestationSidecar() result to a one-token
|
|
3557
|
+
* classification label. The label is persisted alongside the full
|
|
3558
|
+
* sidecar_verify object so auditors can filter override events by class
|
|
3559
|
+
* without regexing the human-readable reason string.
|
|
3560
|
+
*/
|
|
3561
|
+
function classifySidecarVerify(verify) {
|
|
3562
|
+
if (!verify || typeof verify !== "object") return "unknown";
|
|
3563
|
+
if (verify.signed && verify.verified) return "verified";
|
|
3564
|
+
if (verify.signed && !verify.verified) return "tampered";
|
|
3565
|
+
if (verify.tamper_class === "sidecar-corrupt") return "sidecar-corrupt";
|
|
3566
|
+
if (verify.tamper_class === "unsigned-substitution") return "unsigned-substitution";
|
|
3567
|
+
if (typeof verify.reason === "string" && verify.reason.startsWith("attestation explicitly unsigned")) return "explicitly-unsigned";
|
|
3568
|
+
if (typeof verify.reason === "string" && verify.reason.includes("no .sig sidecar")) return "no-sidecar";
|
|
3569
|
+
if (typeof verify.reason === "string" && verify.reason.includes("no public key")) return "no-public-key";
|
|
3570
|
+
return "unknown";
|
|
3571
|
+
}
|
|
3572
|
+
|
|
2886
3573
|
/**
|
|
2887
3574
|
* `exceptd attest <subverb> <session-id>` — auditor-facing operations on
|
|
2888
3575
|
* persisted attestations. Subverbs:
|
|
@@ -2908,6 +3595,16 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
2908
3595
|
if (!sessionId) {
|
|
2909
3596
|
return emitError(`attest ${subverb}: missing <session-id> positional argument.`, null, pretty);
|
|
2910
3597
|
}
|
|
3598
|
+
// R-F7: distinguish "validation rejected" from "valid format but not
|
|
3599
|
+
// found". findSessionDir() returns null for BOTH (regex-rejected ids
|
|
3600
|
+
// collapse to the "no session dir" message), which gives operators a
|
|
3601
|
+
// misleading error — a string with `..` or `/` looks to them like an
|
|
3602
|
+
// existing-session lookup that failed, not a refusal. Call the same
|
|
3603
|
+
// validator up front; emit its specific message when it throws.
|
|
3604
|
+
try { validateSessionIdForRead(sessionId); }
|
|
3605
|
+
catch (e) {
|
|
3606
|
+
return emitError(`attest ${subverb}: ${e.message}`, { session_id_input: typeof sessionId === "string" ? sessionId.slice(0, 80) : typeof sessionId }, pretty);
|
|
3607
|
+
}
|
|
2911
3608
|
const dir = findSessionDir(sessionId, runOpts);
|
|
2912
3609
|
if (!dir) {
|
|
2913
3610
|
return emitError(`attest ${subverb}: no session dir for ${sessionId}. Searched: ${resolveAttestationRoot(runOpts)} + .exceptd/attestations/`, { session_id: sessionId }, pretty);
|
|
@@ -2979,13 +3676,64 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
2979
3676
|
const crypto = require("crypto");
|
|
2980
3677
|
const pubKeyPath = path.join(PKG_ROOT, "keys", "public.pem");
|
|
2981
3678
|
const pubKey = fs.existsSync(pubKeyPath) ? fs.readFileSync(pubKeyPath, "utf8") : null;
|
|
3679
|
+
// Same pin cross-check as verifyAttestationSidecar().
|
|
3680
|
+
// The v0.12.16 promise that EXPECTED_FINGERPRINT is consulted at every
|
|
3681
|
+
// public-key load site was not honored here — `attest verify` loaded
|
|
3682
|
+
// keys/public.pem raw. Refuse to verify any sidecar when the local
|
|
3683
|
+
// public.pem diverges from the pinned fingerprint (unless KEYS_ROTATED=1).
|
|
3684
|
+
const pinError = pubKey ? assertExpectedFingerprint(pubKey) : null;
|
|
3685
|
+
if (pinError) {
|
|
3686
|
+
return emitError(
|
|
3687
|
+
`attest verify: ${pinError}`,
|
|
3688
|
+
{ verb: "attest verify", session_id: sessionId, pin_error: pinError },
|
|
3689
|
+
pretty
|
|
3690
|
+
);
|
|
3691
|
+
}
|
|
3692
|
+
// Audit AA P1-1: on the verifying host, detect "unsigned" sidecar
|
|
3693
|
+
// substitution by checking whether .keys/private.pem is present. A
|
|
3694
|
+
// legitimately-unsigned attestation cannot coexist with a private key on
|
|
3695
|
+
// the same host — that combination is sidecar substitution (attacker
|
|
3696
|
+
// tampered attestation.json and overwrote .sig with the unsigned stub).
|
|
3697
|
+
const privKeyPath = path.join(PKG_ROOT, ".keys", "private.pem");
|
|
3698
|
+
const hasPrivKey = fs.existsSync(privKeyPath);
|
|
2982
3699
|
const results = files.map(f => {
|
|
2983
3700
|
const sigPath = path.join(dir, f + ".sig");
|
|
2984
3701
|
if (!fs.existsSync(sigPath)) return { file: f, signed: false, verified: false, reason: "no .sig sidecar" };
|
|
2985
|
-
|
|
2986
|
-
|
|
3702
|
+
// Audit AA P1-2: wrap JSON.parse so a corrupt sidecar surfaces as a
|
|
3703
|
+
// structured tamper-class result (signed:false, verified:false,
|
|
3704
|
+
// tamper_class:"sidecar-corrupt") rather than throwing into the outer
|
|
3705
|
+
// dispatcher catch → exit 1. Pre-fix, a corrupt .sig produced a
|
|
3706
|
+
// generic exit-1 with no `results` array — operators piping through
|
|
3707
|
+
// `set -e` saw "command failed" with no tamper signal.
|
|
3708
|
+
let sigDoc;
|
|
3709
|
+
try { sigDoc = JSON.parse(fs.readFileSync(sigPath, "utf8")); }
|
|
3710
|
+
catch (e) {
|
|
3711
|
+
return {
|
|
3712
|
+
file: f,
|
|
3713
|
+
signed: false,
|
|
3714
|
+
verified: false,
|
|
3715
|
+
reason: `sidecar parse error: ${e.message}`,
|
|
3716
|
+
tamper_class: "sidecar-corrupt",
|
|
3717
|
+
};
|
|
3718
|
+
}
|
|
3719
|
+
if (sigDoc.algorithm === "unsigned") {
|
|
3720
|
+
// Audit AA P1-1: substitution detection.
|
|
3721
|
+
if (hasPrivKey) {
|
|
3722
|
+
return {
|
|
3723
|
+
file: f,
|
|
3724
|
+
signed: false,
|
|
3725
|
+
verified: false,
|
|
3726
|
+
reason: "attestation explicitly unsigned but .keys/private.pem IS present on this host — sidecar substitution suspected (legitimate unsigned attestations cannot exist alongside a private key)",
|
|
3727
|
+
tamper_class: "unsigned-substitution",
|
|
3728
|
+
};
|
|
3729
|
+
}
|
|
3730
|
+
return { file: f, signed: false, verified: false, reason: "attestation explicitly unsigned (no private key when written)" };
|
|
3731
|
+
}
|
|
2987
3732
|
if (!pubKey) return { file: f, signed: true, verified: false, reason: "no public key at keys/public.pem to verify against" };
|
|
2988
|
-
|
|
3733
|
+
// C: normalize before crypto.verify — mirrors the signer
|
|
3734
|
+
// path so the verify pair is byte-stable across CRLF / BOM churn.
|
|
3735
|
+
const rawContent = fs.readFileSync(path.join(dir, f), "utf8");
|
|
3736
|
+
const content = normalizeAttestationBytes(rawContent);
|
|
2989
3737
|
try {
|
|
2990
3738
|
const ok = crypto.verify(null, Buffer.from(content, "utf8"), {
|
|
2991
3739
|
key: pubKey, dsaEncoding: "ieee-p1363",
|
|
@@ -2995,7 +3743,31 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
2995
3743
|
return { file: f, signed: true, verified: false, reason: `verify error: ${e.message}` };
|
|
2996
3744
|
}
|
|
2997
3745
|
});
|
|
2998
|
-
|
|
3746
|
+
// R-F1: when ANY result is signed-but-failed-verify, surface ok:false
|
|
3747
|
+
// AND set exit 6 for parity with cmdReattest's TAMPERED code. Pre-fix,
|
|
3748
|
+
// `attest verify` emitted {verb, session_id, results} without ok:false
|
|
3749
|
+
// and exited 0 — operators piping through `set -e` saw no failure
|
|
3750
|
+
// signal even when an attestation had been forged. emit()'s ok:false
|
|
3751
|
+
// → exitCode = 1 auto-promotion would stop at 1; tamper is distinct
|
|
3752
|
+
// from generic failure, so explicitly raise to 6 (cmdReattest's code).
|
|
3753
|
+
//
|
|
3754
|
+
// Audit AA P1-1 + P1-2: extend the tamper predicate to cover the new
|
|
3755
|
+
// tamper_class variants. Pre-fix the predicate was `r.signed && !r.verified`
|
|
3756
|
+
// which missed (a) corrupt-JSON sidecars (signed:false) and (b) "unsigned"
|
|
3757
|
+
// sidecar substitution on hosts with a private key (signed:false). Both
|
|
3758
|
+
// are tamper-class events and must promote to exit 6.
|
|
3759
|
+
const tampered = results.some(r =>
|
|
3760
|
+
(r.signed && !r.verified)
|
|
3761
|
+
|| r.tamper_class === "sidecar-corrupt"
|
|
3762
|
+
|| r.tamper_class === "unsigned-substitution"
|
|
3763
|
+
);
|
|
3764
|
+
const body = { verb: "attest verify", session_id: sessionId, results };
|
|
3765
|
+
if (tampered) {
|
|
3766
|
+
body.ok = false;
|
|
3767
|
+
body.error = "attest verify: one or more attestations failed Ed25519 verification — possible post-hoc tampering";
|
|
3768
|
+
process.exitCode = 6;
|
|
3769
|
+
}
|
|
3770
|
+
emit(body, pretty);
|
|
2999
3771
|
return;
|
|
3000
3772
|
}
|
|
3001
3773
|
|
|
@@ -3776,7 +4548,7 @@ function cmdDoctor(runner, args, runOpts, pretty) {
|
|
|
3776
4548
|
}
|
|
3777
4549
|
|
|
3778
4550
|
function cmdListAttestations(runner, args, runOpts, pretty) {
|
|
3779
|
-
// v0.12.14
|
|
4551
|
+
// v0.12.14: --playbook is registered as `multi:` so
|
|
3780
4552
|
// `--playbook a --playbook b` lands as an array. The prior filter used
|
|
3781
4553
|
// strict equality (`j.playbook_id !== args.playbook`) — always false for
|
|
3782
4554
|
// array, silently producing count: 0. Normalize to a Set up-front.
|
|
@@ -3785,16 +4557,14 @@ function cmdListAttestations(runner, args, runOpts, pretty) {
|
|
|
3785
4557
|
const list = Array.isArray(args.playbook) ? args.playbook : [args.playbook];
|
|
3786
4558
|
return new Set(list.filter(x => typeof x === "string" && x.length > 0));
|
|
3787
4559
|
})();
|
|
3788
|
-
// v0.12.14
|
|
4560
|
+
// v0.12.14: --since must be a parseable ISO-8601 timestamp.
|
|
3789
4561
|
// Prior behavior silently accepted any string and lexically compared to
|
|
3790
4562
|
// captured_at, producing 0-result or full-result depending on the string.
|
|
3791
4563
|
if (args.since != null) {
|
|
3792
|
-
|
|
3793
|
-
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
);
|
|
3797
|
-
}
|
|
4564
|
+
// R-F10: regex BEFORE Date.parse — bare integers like "99" would
|
|
4565
|
+
// otherwise parse as the year 1999 and silently filter wrong eras.
|
|
4566
|
+
const sinceErr = validateIsoSince(args.since);
|
|
4567
|
+
if (sinceErr) return emitError(`attest list: ${sinceErr}`, null, pretty);
|
|
3798
4568
|
}
|
|
3799
4569
|
// Enumerate sessions across both v0.11.0 default root and legacy cwd-
|
|
3800
4570
|
// relative root, so operators with prior attestations still see them.
|
|
@@ -3938,7 +4708,10 @@ function cmdAiRun(runner, args, runOpts, pretty) {
|
|
|
3938
4708
|
if (args.evidence) {
|
|
3939
4709
|
try { payload = readEvidence(args.evidence); }
|
|
3940
4710
|
catch (e) { return emitError(`ai-run: failed to read --evidence: ${e.message}`, null, pretty); }
|
|
3941
|
-
} else if (
|
|
4711
|
+
} else if (hasReadableStdin()) {
|
|
4712
|
+
// EE P1-7: hasReadableStdin() probes via fstat before falling into
|
|
4713
|
+
// readFileSync(0). Wrapped-stdin test harnesses (isTTY===undefined,
|
|
4714
|
+
// size===0) would otherwise hang here.
|
|
3942
4715
|
// Drain stdin for any evidence event.
|
|
3943
4716
|
try {
|
|
3944
4717
|
const buf = fs.readFileSync(0, "utf8");
|
|
@@ -3979,7 +4752,7 @@ function cmdAiRun(runner, args, runOpts, pretty) {
|
|
|
3979
4752
|
process.exitCode = 1;
|
|
3980
4753
|
return;
|
|
3981
4754
|
}
|
|
3982
|
-
// v0.12.14
|
|
4755
|
+
// v0.12.14: ai-run --no-stream previously emitted a
|
|
3983
4756
|
// session_id but never persisted the attestation, so the AI agent
|
|
3984
4757
|
// calling ai-run couldn't chain into `attest show / verify / diff`
|
|
3985
4758
|
// or `reattest` with the returned id. Now: same persistAttestation
|
|
@@ -4085,7 +4858,7 @@ function cmdAiRun(runner, args, runOpts, pretty) {
|
|
|
4085
4858
|
writeLine({ phase: "analyze", ...result.phases?.analyze });
|
|
4086
4859
|
writeLine({ phase: "validate", ...result.phases?.validate });
|
|
4087
4860
|
writeLine({ phase: "close", ...result.phases?.close });
|
|
4088
|
-
// v0.12.14
|
|
4861
|
+
// v0.12.14: persist the attestation in streaming mode
|
|
4089
4862
|
// too. Without this, the session_id emitted in the `done` frame
|
|
4090
4863
|
// can't be resolved by `attest show / verify / diff` or `reattest`.
|
|
4091
4864
|
if (result.session_id) {
|
|
@@ -4632,4 +5405,4 @@ function cmdCi(runner, args, runOpts, pretty) {
|
|
|
4632
5405
|
|
|
4633
5406
|
if (require.main === module) main();
|
|
4634
5407
|
|
|
4635
|
-
module.exports = { COMMANDS, PKG_ROOT, PLAYBOOK_VERBS };
|
|
5408
|
+
module.exports = { COMMANDS, PKG_ROOT, PLAYBOOK_VERBS, persistAttestation };
|