@blamejs/exceptd-skills 0.12.20 → 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 +98 -0
- package/bin/exceptd.js +504 -41
- 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 +18 -0
- 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 +9 -9
- package/lib/cross-ref-api.js +43 -10
- package/lib/cve-curation.js +4 -4
- package/lib/playbook-runner.js +395 -69
- package/lib/prefetch.js +3 -3
- package/lib/refresh-external.js +13 -2
- package/lib/refresh-network.js +13 -13
- package/lib/scoring.js +22 -13
- package/lib/sign.js +5 -5
- package/lib/validate-catalog-meta.js +1 -1
- package/lib/validate-indexes.js +2 -2
- package/lib/verify.js +28 -9
- package/manifest.json +47 -47
- 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 +6 -6
- package/scripts/refresh-manifest-snapshot.js +2 -2
- package/scripts/validate-vendor-online.js +1 -1
- package/scripts/verify-shipped-tarball.js +9 -10
- 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
|
@@ -59,7 +59,7 @@ const { spawnSync } = require("child_process");
|
|
|
59
59
|
const PKG_ROOT = path.resolve(__dirname, "..");
|
|
60
60
|
|
|
61
61
|
/**
|
|
62
|
-
*
|
|
62
|
+
* Factor the EXPECTED_FINGERPRINT pin check used by
|
|
63
63
|
* the attestation pipeline. Centralizes the policy (compute live SHA-256
|
|
64
64
|
* fingerprint of the loaded public.pem, compare to keys/EXPECTED_FINGERPRINT,
|
|
65
65
|
* honor KEYS_ROTATED=1 bypass, tolerate missing pin file) so every site
|
|
@@ -607,7 +607,7 @@ function emit(obj, pretty, humanRenderer) {
|
|
|
607
607
|
}
|
|
608
608
|
|
|
609
609
|
function emitError(msg, extra, pretty) {
|
|
610
|
-
// v0.12.14
|
|
610
|
+
// v0.12.14: the v0.11.13 emit() fix used exitCode + return
|
|
611
611
|
// to defend stdout-buffered writes from truncation under piped consumers.
|
|
612
612
|
// emitError() (stderr) kept process.exit(1), which has the same truncation
|
|
613
613
|
// class — CLAUDE.md's "fix the class, not the instance." Now: write to
|
|
@@ -619,6 +619,50 @@ function emitError(msg, extra, pretty) {
|
|
|
619
619
|
process.exitCode = 1;
|
|
620
620
|
}
|
|
621
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
|
+
|
|
622
666
|
function readEvidence(evidenceFlag) {
|
|
623
667
|
if (!evidenceFlag) return {};
|
|
624
668
|
if (evidenceFlag === "-") {
|
|
@@ -637,13 +681,67 @@ function readEvidence(evidenceFlag) {
|
|
|
637
681
|
if (stat.size > MAX_EVIDENCE_BYTES) {
|
|
638
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.`);
|
|
639
683
|
}
|
|
640
|
-
|
|
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);
|
|
641
688
|
}
|
|
642
689
|
|
|
643
690
|
function loadRunner() {
|
|
644
691
|
return require(path.join(PKG_ROOT, "lib", "playbook-runner.js"));
|
|
645
692
|
}
|
|
646
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
|
+
|
|
647
745
|
/**
|
|
648
746
|
* R-F10: ISO-8601 shape regex applied BEFORE Date.parse. Pre-fix, both
|
|
649
747
|
* `attest list --since` and `reattest --since` accepted anything Date.parse
|
|
@@ -717,8 +815,17 @@ function detectVexShape(doc) {
|
|
|
717
815
|
if (doc.document && doc.document.category && String(doc.document.category).startsWith("csaf_")) {
|
|
718
816
|
return { ok: false, detected: "csaf-advisory-not-vex", top_level_keys: keys };
|
|
719
817
|
}
|
|
720
|
-
|
|
721
|
-
|
|
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 };
|
|
722
829
|
}
|
|
723
830
|
if (Array.isArray(doc.statements) && !ctxStr) {
|
|
724
831
|
return { ok: false, detected: "statements-array-but-no-openvex-context", top_level_keys: keys };
|
|
@@ -863,13 +970,137 @@ function dispatchPlaybook(cmd, argv) {
|
|
|
863
970
|
pretty
|
|
864
971
|
);
|
|
865
972
|
}
|
|
866
|
-
|
|
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;
|
|
867
1077
|
}
|
|
1078
|
+
|
|
868
1079
|
// --ack: operator acknowledges the jurisdiction obligations surfaced by
|
|
869
1080
|
// govern. Captured in attestation; downstream tooling can check whether
|
|
870
1081
|
// consent was explicit vs. implicit. AGENTS.md says the AI should surface
|
|
871
1082
|
// and wait for ack — this is how the ack gets recorded.
|
|
872
|
-
|
|
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
|
+
}
|
|
873
1104
|
|
|
874
1105
|
let runner;
|
|
875
1106
|
try {
|
|
@@ -1209,7 +1440,7 @@ Flags:
|
|
|
1209
1440
|
Stdin event grammar (one JSON object per line):
|
|
1210
1441
|
{"event":"evidence","payload":{"observations":{},"verdict":{}}}
|
|
1211
1442
|
|
|
1212
|
-
Stdin acceptance contract
|
|
1443
|
+
Stdin acceptance contract:
|
|
1213
1444
|
In streaming mode, ai-run reads JSON-Lines from stdin until the FIRST
|
|
1214
1445
|
parseable {"event":"evidence","payload":{...}} line. That line wins:
|
|
1215
1446
|
subsequent evidence events on the same run are ignored (the handler
|
|
@@ -1643,7 +1874,7 @@ function cmdPlan(runner, args, runOpts, pretty) {
|
|
|
1643
1874
|
emit(plan, pretty);
|
|
1644
1875
|
}
|
|
1645
1876
|
|
|
1646
|
-
// v0.12.15
|
|
1877
|
+
// v0.12.15: --scope must validate against the accepted
|
|
1647
1878
|
// set. The prior shape silently returned [] for any unknown scope, which
|
|
1648
1879
|
// in `run --scope nonsense` produced `count: 0` + exit 0 (cmd reports
|
|
1649
1880
|
// "ran 0 playbooks") and in `ci --scope nonsense` silently ran only the
|
|
@@ -1823,7 +2054,15 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
1823
2054
|
// fired, making `echo '{...}' | exceptd run <pb>` silently behave like
|
|
1824
2055
|
// no-evidence on Windows. cmdAiRun's path (below) already uses the
|
|
1825
2056
|
// truthy form, so this brings cmdRun + cmdIngest to parity.
|
|
1826
|
-
|
|
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()) {
|
|
1827
2066
|
args.evidence = "-";
|
|
1828
2067
|
}
|
|
1829
2068
|
if (args.evidence) {
|
|
@@ -1859,10 +2098,11 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
1859
2098
|
if (args.vex) {
|
|
1860
2099
|
let vexDoc;
|
|
1861
2100
|
// R-F5: cap --vex file size the same way readEvidence() caps --evidence
|
|
1862
|
-
// (32
|
|
1863
|
-
//
|
|
1864
|
-
//
|
|
1865
|
-
//
|
|
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.
|
|
1866
2106
|
const MAX_VEX_BYTES = 32 * 1024 * 1024;
|
|
1867
2107
|
let vstat;
|
|
1868
2108
|
try { vstat = fs.statSync(args.vex); }
|
|
@@ -1870,14 +2110,19 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
1870
2110
|
return emitError(`run: failed to stat --vex ${args.vex}: ${e.message}`, null, pretty);
|
|
1871
2111
|
}
|
|
1872
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).
|
|
1873
2115
|
return emitError(
|
|
1874
|
-
`run: --vex file too large: ${vstat.size} bytes
|
|
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.`,
|
|
1875
2117
|
{ provided_path: args.vex, size_bytes: vstat.size, limit_bytes: MAX_VEX_BYTES },
|
|
1876
2118
|
pretty
|
|
1877
2119
|
);
|
|
1878
2120
|
}
|
|
1879
2121
|
try {
|
|
1880
|
-
|
|
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);
|
|
1881
2126
|
} catch (e) {
|
|
1882
2127
|
return emitError(`run: failed to load --vex ${args.vex}: ${e.message}`, null, pretty);
|
|
1883
2128
|
}
|
|
@@ -1899,6 +2144,16 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
1899
2144
|
const vexSet = runner.vexFilterFromDoc(vexDoc);
|
|
1900
2145
|
submission.signals = submission.signals || {};
|
|
1901
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] : [];
|
|
1902
2157
|
} catch (e) {
|
|
1903
2158
|
return emitError(`run: failed to apply --vex ${args.vex}: ${e.message}`, null, pretty);
|
|
1904
2159
|
}
|
|
@@ -1935,9 +2190,27 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
1935
2190
|
// v0.11.10 (#119): add result.ack alias for consumers reading the
|
|
1936
2191
|
// ack state by that name (`result.ack` is shorter + matches the CLI flag).
|
|
1937
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";
|
|
1938
2207
|
if (result && runOpts.operator_consent) {
|
|
1939
2208
|
result.operator_consent = runOpts.operator_consent;
|
|
1940
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
|
+
}
|
|
1941
2214
|
} else if (result) {
|
|
1942
2215
|
result.ack = false;
|
|
1943
2216
|
}
|
|
@@ -1950,7 +2223,8 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
1950
2223
|
directiveId: result.directive_id,
|
|
1951
2224
|
evidenceHash: result.evidence_hash,
|
|
1952
2225
|
operator: runOpts.operator,
|
|
1953
|
-
|
|
2226
|
+
// EE P1-6: gate consent persistence on classification=detected.
|
|
2227
|
+
operatorConsent: consentApplies ? runOpts.operator_consent : null,
|
|
1954
2228
|
submission,
|
|
1955
2229
|
runOpts,
|
|
1956
2230
|
forceOverwrite: !!args["force-overwrite"],
|
|
@@ -1968,7 +2242,7 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
1968
2242
|
hint: "Pass --force-overwrite to replace, or supply a fresh --session-id (omit the flag for an auto-generated hex).",
|
|
1969
2243
|
verb: "run",
|
|
1970
2244
|
};
|
|
1971
|
-
// v0.12.14
|
|
2245
|
+
// v0.12.14: exitCode + return instead of process.exit
|
|
1972
2246
|
// so the stderr line drains under piped CI consumers.
|
|
1973
2247
|
process.stderr.write(JSON.stringify(err) + "\n");
|
|
1974
2248
|
process.exitCode = 3;
|
|
@@ -2390,6 +2664,34 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
|
2390
2664
|
if (!lst.isFile()) {
|
|
2391
2665
|
return emitError(`run: --evidence-dir entry ${f} is not a regular file; refusing.`, { entry: f }, pretty);
|
|
2392
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
|
+
}
|
|
2393
2695
|
try {
|
|
2394
2696
|
bundle[pbId] = JSON.parse(fs.readFileSync(entryPath, "utf8"));
|
|
2395
2697
|
} catch (e) {
|
|
@@ -2486,7 +2788,11 @@ function cmdIngest(runner, args, runOpts, pretty) {
|
|
|
2486
2788
|
// strict `=== false` check failed and ingest silently treated the
|
|
2487
2789
|
// routing JSON as absent. CHANGELOG v0.12.16 F4 ("cmdIngest auto-
|
|
2488
2790
|
// detects piped stdin") was a no-op on Windows pre-fix.
|
|
2489
|
-
|
|
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()) {
|
|
2490
2796
|
args.evidence = "-";
|
|
2491
2797
|
}
|
|
2492
2798
|
if (args.evidence) {
|
|
@@ -2691,8 +2997,17 @@ function persistAttestation(args) {
|
|
|
2691
2997
|
// matches withCatalogLock + withIndexLock: O_EXCL 'wx' on a sibling
|
|
2692
2998
|
// .lock file with bounded retry, PID-liveness check on contention,
|
|
2693
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.
|
|
2694
3009
|
const lockPath = filePath + ".lock";
|
|
2695
|
-
const MAX_RETRIES =
|
|
3010
|
+
const MAX_RETRIES = 10;
|
|
2696
3011
|
const STALE_LOCK_MS = 30_000;
|
|
2697
3012
|
let acquired = false;
|
|
2698
3013
|
for (let i = 0; i < MAX_RETRIES; i++) {
|
|
@@ -2729,10 +3044,16 @@ function persistAttestation(args) {
|
|
|
2729
3044
|
}
|
|
2730
3045
|
}
|
|
2731
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.
|
|
2732
3052
|
return {
|
|
2733
3053
|
ok: false,
|
|
2734
|
-
error: `Failed to acquire attestation lock at ${path.relative(process.cwd(), lockPath)} after ${MAX_RETRIES} attempts.`,
|
|
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.`,
|
|
2735
3055
|
existingPath: path.relative(process.cwd(), filePath),
|
|
3056
|
+
lock_contention: true,
|
|
2736
3057
|
};
|
|
2737
3058
|
}
|
|
2738
3059
|
try {
|
|
@@ -2770,7 +3091,7 @@ function persistAttestation(args) {
|
|
|
2770
3091
|
* from "the .sig file was deleted by an attacker."
|
|
2771
3092
|
*/
|
|
2772
3093
|
/**
|
|
2773
|
-
*
|
|
3094
|
+
* C: byte-stability normalize() for the attestation pipeline.
|
|
2774
3095
|
* Strips a leading UTF-8 BOM and collapses CRLF → LF. Mirrors the
|
|
2775
3096
|
* normalize() implementations in lib/sign.js, lib/verify.js,
|
|
2776
3097
|
* lib/refresh-network.js, and scripts/verify-shipped-tarball.js. Five
|
|
@@ -2796,7 +3117,7 @@ function maybeSignAttestation(filePath) {
|
|
|
2796
3117
|
// (reporting only PKG_ROOT now), not by making `run` follow a cwd key the
|
|
2797
3118
|
// verifier doesn't trust.
|
|
2798
3119
|
const privKeyPath = path.join(PKG_ROOT, ".keys", "private.pem");
|
|
2799
|
-
//
|
|
3120
|
+
// C: normalize attestation bytes before sign — strip leading
|
|
2800
3121
|
// UTF-8 BOM + collapse CRLF to LF. Mirrors lib/sign.js / lib/verify.js /
|
|
2801
3122
|
// lib/refresh-network.js / scripts/verify-shipped-tarball.js. The
|
|
2802
3123
|
// attestation file lives on disk under .exceptd/ and can pick up CRLF
|
|
@@ -2852,7 +3173,7 @@ function maybeSignAttestation(filePath) {
|
|
|
2852
3173
|
* Returns null if neither has the session.
|
|
2853
3174
|
*/
|
|
2854
3175
|
/**
|
|
2855
|
-
* v0.12.14
|
|
3176
|
+
* v0.12.14: session-id validation — applied at every READ
|
|
2856
3177
|
* site, not just writes. The write path (persistAttestation) was hardened
|
|
2857
3178
|
* in v0.12.12, but the read paths (findSessionDir / cmdAttest / cmdReattest)
|
|
2858
3179
|
* accepted arbitrary strings and joined them into path.join(root, id) with
|
|
@@ -2871,7 +3192,7 @@ function validateSessionIdForRead(sessionId) {
|
|
|
2871
3192
|
}
|
|
2872
3193
|
|
|
2873
3194
|
function findSessionDir(sessionId, runOpts) {
|
|
2874
|
-
// v0.12.14
|
|
3195
|
+
// v0.12.14: validate the session-id at every read path.
|
|
2875
3196
|
try { validateSessionIdForRead(sessionId); }
|
|
2876
3197
|
catch { return null; }
|
|
2877
3198
|
const candidates = [
|
|
@@ -2947,7 +3268,7 @@ function verifyAttestationSidecar(attFile) {
|
|
|
2947
3268
|
const sigPath = attFile + ".sig";
|
|
2948
3269
|
const pubKeyPath = path.join(PKG_ROOT, "keys", "public.pem");
|
|
2949
3270
|
const pubKey = fs.existsSync(pubKeyPath) ? fs.readFileSync(pubKeyPath, "utf8") : null;
|
|
2950
|
-
//
|
|
3271
|
+
// Consult keys/EXPECTED_FINGERPRINT before honoring
|
|
2951
3272
|
// the loaded public key. The v0.12.16 CHANGELOG claimed "pin consulted
|
|
2952
3273
|
// at every public-key load site," but reattest's signature verifier
|
|
2953
3274
|
// loaded keys/public.pem without the pin cross-check. A coordinated
|
|
@@ -2965,8 +3286,39 @@ function verifyAttestationSidecar(attFile) {
|
|
|
2965
3286
|
}
|
|
2966
3287
|
let sigDoc;
|
|
2967
3288
|
try { sigDoc = JSON.parse(fs.readFileSync(sigPath, "utf8")); }
|
|
2968
|
-
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
|
+
}
|
|
2969
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
|
+
}
|
|
2970
3322
|
return { file: attFile, signed: false, verified: false, reason: "attestation explicitly unsigned (no private key when written)" };
|
|
2971
3323
|
}
|
|
2972
3324
|
if (!pubKey) {
|
|
@@ -2975,7 +3327,7 @@ function verifyAttestationSidecar(attFile) {
|
|
|
2975
3327
|
let content;
|
|
2976
3328
|
try {
|
|
2977
3329
|
const raw = fs.readFileSync(attFile, "utf8");
|
|
2978
|
-
//
|
|
3330
|
+
// C: apply the same normalize() used by the signer so the
|
|
2979
3331
|
// verify path is byte-stable across CRLF / BOM churn (Windows checkout
|
|
2980
3332
|
// with core.autocrlf=true, editor round-trips, git-attributes flips).
|
|
2981
3333
|
content = normalizeAttestationBytes(raw);
|
|
@@ -3036,7 +3388,16 @@ function cmdReattest(runner, args, runOpts, pretty) {
|
|
|
3036
3388
|
// tampering. `verified === false && signed === true` is the real tamper
|
|
3037
3389
|
// signal.
|
|
3038
3390
|
const verify = verifyAttestationSidecar(attFile);
|
|
3039
|
-
|
|
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"]) {
|
|
3040
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`);
|
|
3041
3402
|
const body = {
|
|
3042
3403
|
ok: false,
|
|
@@ -3051,10 +3412,10 @@ function cmdReattest(runner, args, runOpts, pretty) {
|
|
|
3051
3412
|
process.exitCode = 6;
|
|
3052
3413
|
return;
|
|
3053
3414
|
}
|
|
3054
|
-
if (
|
|
3415
|
+
if ((isSignedTamper || isClassTamper) && args["force-replay"]) {
|
|
3055
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`);
|
|
3056
3417
|
} else if (!verify.signed && verify.reason && verify.reason.includes("no .sig sidecar") && !args["force-replay"]) {
|
|
3057
|
-
//
|
|
3418
|
+
// missing-sidecar is NOT benign. The previous flow accepted
|
|
3058
3419
|
// a missing .sig file silently (only blocked on signed-but-invalid).
|
|
3059
3420
|
// Sidecar deletion is observationally identical to sidecar tamper —
|
|
3060
3421
|
// an attacker who can rewrite the attestation can also rm the sidecar,
|
|
@@ -3079,6 +3440,30 @@ function cmdReattest(runner, args, runOpts, pretty) {
|
|
|
3079
3440
|
return;
|
|
3080
3441
|
} else if (!verify.signed && verify.reason && verify.reason.includes("no .sig sidecar") && args["force-replay"]) {
|
|
3081
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`);
|
|
3082
3467
|
} else if (!verify.signed && verify.reason !== "no .sig sidecar") {
|
|
3083
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`);
|
|
3084
3469
|
}
|
|
@@ -3151,10 +3536,40 @@ function cmdReattest(runner, args, runOpts, pretty) {
|
|
|
3151
3536
|
// F10: persist the sidecar verify result + the force-replay flag so the
|
|
3152
3537
|
// audit trail records whether the replay was authenticated input.
|
|
3153
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),
|
|
3154
3551
|
force_replay: !!args["force-replay"],
|
|
3155
3552
|
}, pretty);
|
|
3156
3553
|
}
|
|
3157
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
|
+
|
|
3158
3573
|
/**
|
|
3159
3574
|
* `exceptd attest <subverb> <session-id>` — auditor-facing operations on
|
|
3160
3575
|
* persisted attestations. Subverbs:
|
|
@@ -3261,7 +3676,7 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
3261
3676
|
const crypto = require("crypto");
|
|
3262
3677
|
const pubKeyPath = path.join(PKG_ROOT, "keys", "public.pem");
|
|
3263
3678
|
const pubKey = fs.existsSync(pubKeyPath) ? fs.readFileSync(pubKeyPath, "utf8") : null;
|
|
3264
|
-
//
|
|
3679
|
+
// Same pin cross-check as verifyAttestationSidecar().
|
|
3265
3680
|
// The v0.12.16 promise that EXPECTED_FINGERPRINT is consulted at every
|
|
3266
3681
|
// public-key load site was not honored here — `attest verify` loaded
|
|
3267
3682
|
// keys/public.pem raw. Refuse to verify any sidecar when the local
|
|
@@ -3274,13 +3689,48 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
3274
3689
|
pretty
|
|
3275
3690
|
);
|
|
3276
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);
|
|
3277
3699
|
const results = files.map(f => {
|
|
3278
3700
|
const sigPath = path.join(dir, f + ".sig");
|
|
3279
3701
|
if (!fs.existsSync(sigPath)) return { file: f, signed: false, verified: false, reason: "no .sig sidecar" };
|
|
3280
|
-
|
|
3281
|
-
|
|
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
|
+
}
|
|
3282
3732
|
if (!pubKey) return { file: f, signed: true, verified: false, reason: "no public key at keys/public.pem to verify against" };
|
|
3283
|
-
//
|
|
3733
|
+
// C: normalize before crypto.verify — mirrors the signer
|
|
3284
3734
|
// path so the verify pair is byte-stable across CRLF / BOM churn.
|
|
3285
3735
|
const rawContent = fs.readFileSync(path.join(dir, f), "utf8");
|
|
3286
3736
|
const content = normalizeAttestationBytes(rawContent);
|
|
@@ -3300,7 +3750,17 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
3300
3750
|
// signal even when an attestation had been forged. emit()'s ok:false
|
|
3301
3751
|
// → exitCode = 1 auto-promotion would stop at 1; tamper is distinct
|
|
3302
3752
|
// from generic failure, so explicitly raise to 6 (cmdReattest's code).
|
|
3303
|
-
|
|
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
|
+
);
|
|
3304
3764
|
const body = { verb: "attest verify", session_id: sessionId, results };
|
|
3305
3765
|
if (tampered) {
|
|
3306
3766
|
body.ok = false;
|
|
@@ -4088,7 +4548,7 @@ function cmdDoctor(runner, args, runOpts, pretty) {
|
|
|
4088
4548
|
}
|
|
4089
4549
|
|
|
4090
4550
|
function cmdListAttestations(runner, args, runOpts, pretty) {
|
|
4091
|
-
// v0.12.14
|
|
4551
|
+
// v0.12.14: --playbook is registered as `multi:` so
|
|
4092
4552
|
// `--playbook a --playbook b` lands as an array. The prior filter used
|
|
4093
4553
|
// strict equality (`j.playbook_id !== args.playbook`) — always false for
|
|
4094
4554
|
// array, silently producing count: 0. Normalize to a Set up-front.
|
|
@@ -4097,7 +4557,7 @@ function cmdListAttestations(runner, args, runOpts, pretty) {
|
|
|
4097
4557
|
const list = Array.isArray(args.playbook) ? args.playbook : [args.playbook];
|
|
4098
4558
|
return new Set(list.filter(x => typeof x === "string" && x.length > 0));
|
|
4099
4559
|
})();
|
|
4100
|
-
// v0.12.14
|
|
4560
|
+
// v0.12.14: --since must be a parseable ISO-8601 timestamp.
|
|
4101
4561
|
// Prior behavior silently accepted any string and lexically compared to
|
|
4102
4562
|
// captured_at, producing 0-result or full-result depending on the string.
|
|
4103
4563
|
if (args.since != null) {
|
|
@@ -4248,7 +4708,10 @@ function cmdAiRun(runner, args, runOpts, pretty) {
|
|
|
4248
4708
|
if (args.evidence) {
|
|
4249
4709
|
try { payload = readEvidence(args.evidence); }
|
|
4250
4710
|
catch (e) { return emitError(`ai-run: failed to read --evidence: ${e.message}`, null, pretty); }
|
|
4251
|
-
} 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.
|
|
4252
4715
|
// Drain stdin for any evidence event.
|
|
4253
4716
|
try {
|
|
4254
4717
|
const buf = fs.readFileSync(0, "utf8");
|
|
@@ -4289,7 +4752,7 @@ function cmdAiRun(runner, args, runOpts, pretty) {
|
|
|
4289
4752
|
process.exitCode = 1;
|
|
4290
4753
|
return;
|
|
4291
4754
|
}
|
|
4292
|
-
// v0.12.14
|
|
4755
|
+
// v0.12.14: ai-run --no-stream previously emitted a
|
|
4293
4756
|
// session_id but never persisted the attestation, so the AI agent
|
|
4294
4757
|
// calling ai-run couldn't chain into `attest show / verify / diff`
|
|
4295
4758
|
// or `reattest` with the returned id. Now: same persistAttestation
|
|
@@ -4395,7 +4858,7 @@ function cmdAiRun(runner, args, runOpts, pretty) {
|
|
|
4395
4858
|
writeLine({ phase: "analyze", ...result.phases?.analyze });
|
|
4396
4859
|
writeLine({ phase: "validate", ...result.phases?.validate });
|
|
4397
4860
|
writeLine({ phase: "close", ...result.phases?.close });
|
|
4398
|
-
// v0.12.14
|
|
4861
|
+
// v0.12.14: persist the attestation in streaming mode
|
|
4399
4862
|
// too. Without this, the session_id emitted in the `done` frame
|
|
4400
4863
|
// can't be resolved by `attest show / verify / diff` or `reattest`.
|
|
4401
4864
|
if (result.session_id) {
|