@blamejs/exceptd-skills 0.12.22 → 0.12.23
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/AGENTS.md +18 -12
- package/ARCHITECTURE.md +2 -2
- package/CHANGELOG.md +47 -1
- package/CONTEXT.md +126 -69
- package/README.md +7 -7
- package/bin/exceptd.js +437 -347
- package/data/_indexes/_meta.json +3 -3
- package/data/_indexes/stale-content.json +10 -3
- package/data/playbooks/ai-api.json +1 -1
- package/data/playbooks/containers.json +1 -1
- package/data/playbooks/cred-stores.json +1 -1
- package/data/playbooks/crypto-codebase.json +1 -1
- package/data/playbooks/crypto.json +1 -1
- package/data/playbooks/hardening.json +1 -1
- package/data/playbooks/kernel.json +1 -1
- package/data/playbooks/mcp.json +1 -1
- package/data/playbooks/runtime.json +1 -1
- package/data/playbooks/sbom.json +1 -1
- package/data/playbooks/secrets.json +15 -1
- package/lib/auto-discovery.js +2 -2
- package/lib/cross-ref-api.js +12 -11
- package/lib/cve-curation.js +18 -19
- package/lib/lint-skills.js +5 -5
- package/lib/playbook-runner.js +296 -297
- package/lib/prefetch.js +21 -21
- package/lib/refresh-external.js +15 -18
- package/lib/refresh-network.js +33 -12
- package/lib/scoring.js +8 -7
- package/lib/sign.js +10 -11
- package/lib/source-osv.js +7 -7
- package/lib/validate-catalog-meta.js +1 -1
- package/lib/validate-cve-catalog.js +1 -1
- package/lib/verify.js +36 -26
- package/manifest.json +40 -40
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
- package/scripts/verify-shipped-tarball.js +18 -18
package/bin/exceptd.js
CHANGED
|
@@ -85,14 +85,21 @@ function assertExpectedFingerprint(pubKeyPem) {
|
|
|
85
85
|
} catch (e) {
|
|
86
86
|
return `EXPECTED_FINGERPRINT check: failed to derive live fingerprint: ${e.message}`;
|
|
87
87
|
}
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
88
|
+
// Route through the shared lib/verify loader so a BOM-prefixed pin file
|
|
89
|
+
// (Notepad with files.encoding=utf8bom) is tolerated identically across
|
|
90
|
+
// every verify site. The helper strips leading U+FEFF + ignores comment
|
|
91
|
+
// lines.
|
|
92
92
|
const { loadExpectedFingerprintFirstLine } = require(path.join(PKG_ROOT, "lib", "verify.js"));
|
|
93
93
|
const firstLine = loadExpectedFingerprintFirstLine(pinPath) || "";
|
|
94
94
|
if (firstLine === liveFp) return null;
|
|
95
|
-
if (process.env.KEYS_ROTATED === "1")
|
|
95
|
+
if (process.env.KEYS_ROTATED === "1") {
|
|
96
|
+
process.emitWarning(
|
|
97
|
+
`EXPECTED_FINGERPRINT mismatch accepted via KEYS_ROTATED=1: live=${liveFp} pin=${firstLine}. ` +
|
|
98
|
+
`Update keys/EXPECTED_FINGERPRINT to lock the new pin.`,
|
|
99
|
+
{ code: 'EXCEPTD_KEYS_ROTATED_OVERRIDE' }
|
|
100
|
+
);
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
96
103
|
return (
|
|
97
104
|
`EXPECTED_FINGERPRINT mismatch: live=${liveFp} pin=${firstLine}. ` +
|
|
98
105
|
`If this is an intentional rotation, re-run with KEYS_ROTATED=1 and ` +
|
|
@@ -268,9 +275,11 @@ v0.12.0 canonical surface
|
|
|
268
275
|
--registry-check (v0.11.14) opt-in: query npm registry
|
|
269
276
|
for latest published version + days behind
|
|
270
277
|
|
|
271
|
-
ci One-shot CI gate. Exit codes: 0 PASS,
|
|
272
|
-
3 ran-but-no-evidence,
|
|
273
|
-
5 jurisdiction clock started,
|
|
278
|
+
ci One-shot CI gate. Exit codes: 0 PASS, 1 framework error,
|
|
279
|
+
2 detected/escalate, 3 ran-but-no-evidence,
|
|
280
|
+
4 blocked (ok:false), 5 jurisdiction clock started,
|
|
281
|
+
6 TAMPERED (sidecar verification failed),
|
|
282
|
+
8 LOCK_CONTENTION (concurrent playbook lock held).
|
|
274
283
|
--all | --scope <type> | (auto-detect)
|
|
275
284
|
--max-rwep <n> cap below playbook default
|
|
276
285
|
--block-on-jurisdiction-clock
|
|
@@ -495,11 +504,9 @@ function main() {
|
|
|
495
504
|
if (typeof resolver !== "function") {
|
|
496
505
|
// Emit a structured JSON error matching the seven-phase verbs so operators
|
|
497
506
|
// piping through `jq` get one consistent shape across the CLI surface.
|
|
498
|
-
//
|
|
499
|
-
//
|
|
500
|
-
//
|
|
501
|
-
// exitCode + return per v0.12.14) so the JSON drains, then promote
|
|
502
|
-
// the exit code to 2 (unknown-command remains a distinct exit class).
|
|
507
|
+
// emitError() sets exitCode + returns rather than calling process.exit()
|
|
508
|
+
// so the stderr JSON drains before teardown; promote the exit code to 2
|
|
509
|
+
// afterwards (unknown-command remains a distinct exit class).
|
|
503
510
|
emitError(`unknown command "${cmd}"`, { hint: "Run `exceptd help` for the list of verbs.", verb: cmd });
|
|
504
511
|
process.exitCode = 2;
|
|
505
512
|
return;
|
|
@@ -507,7 +514,7 @@ function main() {
|
|
|
507
514
|
|
|
508
515
|
const script = resolver();
|
|
509
516
|
if (!fs.existsSync(script)) {
|
|
510
|
-
//
|
|
517
|
+
// emitError + exitCode rather than stderr + exit() so the JSON drains.
|
|
511
518
|
emitError(
|
|
512
519
|
`command "${cmd}" not available — expected ${path.relative(PKG_ROOT, script)} in the installed package.`,
|
|
513
520
|
{ verb: cmd }
|
|
@@ -521,7 +528,7 @@ function main() {
|
|
|
521
528
|
const finalArgs = ORCHESTRATOR_PASSTHROUGH.has(effectiveCmd) ? [script, effectiveCmd, ...effectiveRest] : [script, ...effectiveRest];
|
|
522
529
|
const res = spawnSync(process.execPath, finalArgs, { stdio: "inherit", cwd: PKG_ROOT });
|
|
523
530
|
if (res.error) {
|
|
524
|
-
//
|
|
531
|
+
// emitError + exitCode rather than stderr + exit() so the JSON drains.
|
|
525
532
|
emitError(`failed to run ${cmd}: ${res.error.message}`, { verb: cmd });
|
|
526
533
|
process.exitCode = 2;
|
|
527
534
|
return;
|
|
@@ -624,7 +631,7 @@ function emitError(msg, extra, pretty) {
|
|
|
624
631
|
}
|
|
625
632
|
|
|
626
633
|
/**
|
|
627
|
-
*
|
|
634
|
+
* Shared BOM-tolerant JSON file reader. Windows tools commonly emit
|
|
628
635
|
* UTF-8-BOM (EF BB BF) or UTF-16 LE/BE (FF FE / FE FF). The default
|
|
629
636
|
* `fs.readFileSync(path, "utf8")` chokes on the leading 0xFEFF (UTF-8-BOM
|
|
630
637
|
* becomes a literal BOM codepoint that `JSON.parse` refuses) and decodes
|
|
@@ -699,7 +706,7 @@ function readEvidence(evidenceFlag) {
|
|
|
699
706
|
if (stat.size > MAX_EVIDENCE_BYTES) {
|
|
700
707
|
throw new Error(`evidence file too large: ${stat.size} bytes > ${MAX_EVIDENCE_BYTES} byte limit. Reduce the submission or split into multiple playbook runs.`);
|
|
701
708
|
}
|
|
702
|
-
//
|
|
709
|
+
// Route through readJsonFile() for UTF-8-BOM / UTF-16 tolerance.
|
|
703
710
|
// Windows-tool-emitted JSON commonly carries these markers; the raw "utf8"
|
|
704
711
|
// decode in readFileSync chokes on the leading 0xFEFF.
|
|
705
712
|
return readJsonFile(evidenceFlag);
|
|
@@ -710,7 +717,7 @@ function loadRunner() {
|
|
|
710
717
|
}
|
|
711
718
|
|
|
712
719
|
/**
|
|
713
|
-
*
|
|
720
|
+
* Detect whether stdin actually has data without blocking.
|
|
714
721
|
*
|
|
715
722
|
* `!process.stdin.isTTY` (the previous heuristic) fires when isTTY is
|
|
716
723
|
* `false`, `undefined`, OR `null`. Test harnesses with custom stdin
|
|
@@ -742,13 +749,12 @@ function hasReadableStdin() {
|
|
|
742
749
|
let st;
|
|
743
750
|
try { st = fs.fstatSync(0); }
|
|
744
751
|
catch {
|
|
745
|
-
//
|
|
746
|
-
//
|
|
747
|
-
//
|
|
748
|
-
//
|
|
749
|
-
//
|
|
750
|
-
//
|
|
751
|
-
// win32 when piped, so the strict check preserves that working case.
|
|
752
|
+
// fstat failed — on Windows require `isTTY === false` STRICTLY (not
|
|
753
|
+
// falsy). A non-strict check returns true when isTTY is undefined (e.g.
|
|
754
|
+
// Mocha/Jest test harnesses with a wrapped duplexer on Windows), which
|
|
755
|
+
// causes fs.readFileSync(0) to block indefinitely waiting on an EOF
|
|
756
|
+
// that never arrives. MSYS-bash piping on win32 sets isTTY === false,
|
|
757
|
+
// so the strict check still admits genuine piped input.
|
|
752
758
|
if (process.platform === "win32") return process.stdin.isTTY === false;
|
|
753
759
|
return false;
|
|
754
760
|
}
|
|
@@ -778,13 +784,11 @@ function hasReadableStdin() {
|
|
|
778
784
|
}
|
|
779
785
|
|
|
780
786
|
/**
|
|
781
|
-
*
|
|
782
|
-
*
|
|
783
|
-
*
|
|
784
|
-
*
|
|
785
|
-
*
|
|
786
|
-
* filtered the wrong years. Now: require an explicit calendar-date shape
|
|
787
|
-
* (YYYY-MM-DD with optional time component) BEFORE handing to Date.parse.
|
|
787
|
+
* ISO-8601 shape regex applied BEFORE Date.parse for --since flags. Without
|
|
788
|
+
* the regex check, bare integers like "99" coerce through Date.parse to
|
|
789
|
+
* 1999-12-01T00:00:00Z (two-digit-year heuristic), silently filtering the
|
|
790
|
+
* wrong years. Requires an explicit calendar-date shape (YYYY-MM-DD with
|
|
791
|
+
* optional time component) before handing to Date.parse.
|
|
788
792
|
*
|
|
789
793
|
* Returns null on success; returns the human-facing error message string
|
|
790
794
|
* on failure so the caller can wrap it with its own verb prefix.
|
|
@@ -798,7 +802,7 @@ function validateIsoSince(raw) {
|
|
|
798
802
|
}
|
|
799
803
|
|
|
800
804
|
/**
|
|
801
|
-
*
|
|
805
|
+
* Detect whether a parsed JSON document is plausibly CycloneDX VEX or
|
|
802
806
|
* OpenVEX. The runner's vexFilterFromDoc returns Set(0) tolerantly for
|
|
803
807
|
* anything else, which means an operator who passes SARIF / SBOM / CSAF /
|
|
804
808
|
* advisory JSON by mistake gets zero filter + zero feedback. We pre-validate
|
|
@@ -820,12 +824,12 @@ function detectVexShape(doc) {
|
|
|
820
824
|
const isBom = doc.bomFormat === "CycloneDX";
|
|
821
825
|
const specStr = typeof doc.specVersion === "string" ? doc.specVersion : "";
|
|
822
826
|
const hasCyclonedxMarker = isBom || specStr.startsWith("1.");
|
|
823
|
-
//
|
|
824
|
-
//
|
|
825
|
-
//
|
|
827
|
+
// Empty vulnerabilities arrays cannot vouch for CycloneDX shape on their
|
|
828
|
+
// own — `{"bomFormat":"NOT-CycloneDX","vulnerabilities":[]}` would
|
|
829
|
+
// otherwise pass because `length === 0` trivially satisfies
|
|
826
830
|
// `entriesLookVex`. Require a real CycloneDX marker (bomFormat or
|
|
827
|
-
// specVersion) when the array is empty; non-empty arrays still pass
|
|
828
|
-
//
|
|
831
|
+
// specVersion) when the array is empty; non-empty arrays still pass when
|
|
832
|
+
// any entry has vex-shaped fields (id / bom-ref / analysis).
|
|
829
833
|
if (doc.vulnerabilities.length === 0) {
|
|
830
834
|
if (hasCyclonedxMarker) {
|
|
831
835
|
return { ok: true, detected: "cyclonedx-vex", top_level_keys: keys };
|
|
@@ -850,8 +854,8 @@ function detectVexShape(doc) {
|
|
|
850
854
|
if (doc.document && doc.document.category && String(doc.document.category).startsWith("csaf_")) {
|
|
851
855
|
return { ok: false, detected: "csaf-advisory-not-vex", top_level_keys: keys };
|
|
852
856
|
}
|
|
853
|
-
//
|
|
854
|
-
//
|
|
857
|
+
// A CycloneDX SBOM with no `vulnerabilities` key is a legitimate "0-CVE
|
|
858
|
+
// VEX filter" submission — the operator is asserting nothing here is
|
|
855
859
|
// exploitable. Accept it as cyclonedx-vex with an empty filter set (the
|
|
856
860
|
// runner's vexFilterFromDoc returns Set(0) for the same shape). Same logic
|
|
857
861
|
// for documents that carry a CycloneDX-flavored specVersion ("1.x") without
|
|
@@ -928,6 +932,17 @@ function dispatchPlaybook(cmd, argv) {
|
|
|
928
932
|
pretty
|
|
929
933
|
);
|
|
930
934
|
}
|
|
935
|
+
// The character-class regex accepts any all-dots string (`.`, `..`,
|
|
936
|
+
// `...`); each resolves into or above the attestation root. Refuse
|
|
937
|
+
// them explicitly so the attestation is never written outside the
|
|
938
|
+
// intended directory.
|
|
939
|
+
if (/^\.+$/.test(sid)) {
|
|
940
|
+
return emitError(
|
|
941
|
+
"run: --session-id cannot consist entirely of dots (rejected: '.', '..', etc.).",
|
|
942
|
+
{ provided: sid },
|
|
943
|
+
pretty
|
|
944
|
+
);
|
|
945
|
+
}
|
|
931
946
|
runOpts.session_id = sid;
|
|
932
947
|
}
|
|
933
948
|
if (args["attestation-root"]) {
|
|
@@ -973,12 +988,12 @@ function dispatchPlaybook(cmd, argv) {
|
|
|
973
988
|
// service identity. --operator <name> persists into the attestation file
|
|
974
989
|
// for audit-trail accountability.
|
|
975
990
|
//
|
|
976
|
-
//
|
|
977
|
-
//
|
|
978
|
-
// into attestation export output (multi-line "operator:" key/value
|
|
979
|
-
// are a forgery surface — a forged second line could look like a
|
|
980
|
-
// attestation field to a naive parser).
|
|
981
|
-
// (\x00-\x1F + \x7F), cap length at 256, reject if all-whitespace.
|
|
991
|
+
// Validate the input. Without this, a value flows into runOpts unchanged
|
|
992
|
+
// and an operator could inject newlines / control chars / arbitrary
|
|
993
|
+
// length into attestation export output (multi-line "operator:" key/value
|
|
994
|
+
// pairs are a forgery surface — a forged second line could look like a
|
|
995
|
+
// separate attestation field to a naive parser). Strip ASCII control
|
|
996
|
+
// chars (\x00-\x1F + \x7F), cap length at 256, reject if all-whitespace.
|
|
982
997
|
if (args.operator !== undefined) {
|
|
983
998
|
if (typeof args.operator !== "string") {
|
|
984
999
|
return emitError("run: --operator must be a string.", { provided: typeof args.operator }, pretty);
|
|
@@ -1005,8 +1020,8 @@ function dispatchPlaybook(cmd, argv) {
|
|
|
1005
1020
|
pretty
|
|
1006
1021
|
);
|
|
1007
1022
|
}
|
|
1008
|
-
//
|
|
1009
|
-
//
|
|
1023
|
+
// The ASCII-only control-char regex above misses Unicode categories
|
|
1024
|
+
// Cc / Cf / Co / Cn — bidi overrides (U+202E "RTL OVERRIDE"),
|
|
1010
1025
|
// zero-width joiners (U+200B-D), invisible format chars, private-use
|
|
1011
1026
|
// codepoints, unassigned codepoints. An operator string like
|
|
1012
1027
|
// "aliceevilbob" renders as "alicebobevila" in any UI that respects
|
|
@@ -1055,15 +1070,15 @@ function dispatchPlaybook(cmd, argv) {
|
|
|
1055
1070
|
runOpts.operator = normalized;
|
|
1056
1071
|
}
|
|
1057
1072
|
|
|
1058
|
-
//
|
|
1059
|
-
//
|
|
1060
|
-
//
|
|
1061
|
-
//
|
|
1062
|
-
//
|
|
1063
|
-
//
|
|
1064
|
-
//
|
|
1065
|
-
//
|
|
1066
|
-
//
|
|
1073
|
+
// --csaf-status and --publisher-namespace shape the CSAF bundle emitted by
|
|
1074
|
+
// phases 5-7. Verbs that don't drive those phases (brief, plan, govern,
|
|
1075
|
+
// direct, look, attest, list-attestations, discover, doctor, lint, ask,
|
|
1076
|
+
// verify-attestation, reattest) never assemble a bundle, so silently
|
|
1077
|
+
// consuming these flags is a UX trap. Refuse on those verbs so the
|
|
1078
|
+
// operator knows the flag was discarded — same pattern as --ack. Error
|
|
1079
|
+
// message templates and emitError prefixes use the in-scope `cmd` verb so
|
|
1080
|
+
// a brief invocation says "brief:" rather than misattributing the flag
|
|
1081
|
+
// to run.
|
|
1067
1082
|
const BUNDLE_FLAG_RELEVANT_VERBS = new Set([
|
|
1068
1083
|
"run", "ci", "run-all", "ai-run", "ingest",
|
|
1069
1084
|
]);
|
|
@@ -1143,13 +1158,13 @@ function dispatchPlaybook(cmd, argv) {
|
|
|
1143
1158
|
// consent was explicit vs. implicit. AGENTS.md says the AI should surface
|
|
1144
1159
|
// and wait for ack — this is how the ack gets recorded.
|
|
1145
1160
|
//
|
|
1146
|
-
//
|
|
1147
|
-
//
|
|
1148
|
-
//
|
|
1149
|
-
//
|
|
1150
|
-
//
|
|
1151
|
-
//
|
|
1152
|
-
//
|
|
1161
|
+
// --ack only makes sense on verbs that drive phases 5-7 (run / ingest /
|
|
1162
|
+
// ai-run / ci / run-all / reattest). Info-only verbs (brief, plan,
|
|
1163
|
+
// govern, direct, look, attest, list-attestations, discover, doctor,
|
|
1164
|
+
// lint, ask, verify-attestation) never consume an attestation clock —
|
|
1165
|
+
// accepting --ack silently is a UX trap where operators believe they have
|
|
1166
|
+
// recorded consent. Refuse on those verbs so the operator knows the flag
|
|
1167
|
+
// is irrelevant.
|
|
1153
1168
|
const ACK_RELEVANT_VERBS = new Set([
|
|
1154
1169
|
"run", "ingest", "ai-run", "ci", "run-all", "reattest",
|
|
1155
1170
|
]);
|
|
@@ -1418,13 +1433,22 @@ Exit codes (per-verb, post-run):
|
|
|
1418
1433
|
(~1-2s). Distinct from 1 so callers can retry the
|
|
1419
1434
|
operation rather than treat it as a hard failure.
|
|
1420
1435
|
Surfaces as body.lock_contention=true,
|
|
1421
|
-
body.exit_code=8
|
|
1436
|
+
body.exit_code=8.
|
|
1437
|
+
6-7 — reserved (6=TAMPERED on attest verifier; 7 unused)`,
|
|
1422
1438
|
ingest: `ingest — alias for 'run' matching AGENTS.md terminology.
|
|
1423
1439
|
|
|
1424
1440
|
Flags:
|
|
1425
1441
|
--domain <id> Playbook ID (overrides submission.playbook_id).
|
|
1426
1442
|
--directive <id> Directive ID (overrides submission.directive_id).
|
|
1427
1443
|
--evidence <file|-> Submission JSON. May include playbook_id/directive_id.
|
|
1444
|
+
--csaf-status <s> CSAF tracking.status for the close.evidence_package
|
|
1445
|
+
bundle. One of: draft | interim (default) | final.
|
|
1446
|
+
'final' commits to CSAF §3.1.11.3.5.1 immutability —
|
|
1447
|
+
set this only after operator review of the advisory.
|
|
1448
|
+
--publisher-namespace <url>
|
|
1449
|
+
CSAF document.publisher.namespace (§3.1.7.4). The
|
|
1450
|
+
operator's organisation URL, NOT the tooling vendor.
|
|
1451
|
+
Must be an http://… or https://… URL, ≤256 chars.
|
|
1428
1452
|
--pretty Indented JSON output.`,
|
|
1429
1453
|
reattest: `reattest [<session-id> | --latest] — replay a prior session and diff the evidence_hash.
|
|
1430
1454
|
|
|
@@ -1435,7 +1459,12 @@ Args / flags:
|
|
|
1435
1459
|
--since <ISO> Restrict --latest to attestations after this ISO 8601 timestamp.
|
|
1436
1460
|
--pretty Indented JSON output.
|
|
1437
1461
|
|
|
1438
|
-
Reports: unchanged | drifted | resolved from evidence_hash + classification deltas
|
|
1462
|
+
Reports: unchanged | drifted | resolved from evidence_hash + classification deltas.
|
|
1463
|
+
|
|
1464
|
+
Exit codes:
|
|
1465
|
+
0 verification succeeded
|
|
1466
|
+
1 generic failure
|
|
1467
|
+
6 TAMPERED (sidecar or signature mismatch on the prior attestation)`,
|
|
1439
1468
|
"list-attestations": `list-attestations [--playbook <id>] — enumerate prior attestations.
|
|
1440
1469
|
|
|
1441
1470
|
Args / flags:
|
|
@@ -1465,7 +1494,12 @@ Subverbs:
|
|
|
1465
1494
|
for an explicit pair. Reports unchanged | drifted |
|
|
1466
1495
|
resolved per evidence_hash + classification deltas.
|
|
1467
1496
|
|
|
1468
|
-
All subverbs honor --pretty for indented JSON output
|
|
1497
|
+
All subverbs honor --pretty for indented JSON output.
|
|
1498
|
+
|
|
1499
|
+
Exit codes (attest verify):
|
|
1500
|
+
0 verification succeeded
|
|
1501
|
+
1 generic failure
|
|
1502
|
+
6 TAMPERED (sidecar or signature mismatch)`,
|
|
1469
1503
|
discover: `discover — context-aware playbook recommender (v0.11.0).
|
|
1470
1504
|
|
|
1471
1505
|
Replaces: scan + dispatch + recommend.
|
|
@@ -1520,6 +1554,14 @@ Flags:
|
|
|
1520
1554
|
--directive <id> Specific directive (default: first one).
|
|
1521
1555
|
--no-stream Single-shot mode: emit all phases as one JSON doc
|
|
1522
1556
|
without reading stdin (uses runner.run directly).
|
|
1557
|
+
--csaf-status <s> CSAF tracking.status for the close.evidence_package
|
|
1558
|
+
bundle. One of: draft | interim (default) | final.
|
|
1559
|
+
'final' commits to CSAF §3.1.11.3.5.1 immutability —
|
|
1560
|
+
set this only after operator review of the advisory.
|
|
1561
|
+
--publisher-namespace <url>
|
|
1562
|
+
CSAF document.publisher.namespace (§3.1.7.4). The
|
|
1563
|
+
operator's organisation URL, NOT the tooling vendor.
|
|
1564
|
+
Must be an http://… or https://… URL, ≤256 chars.
|
|
1523
1565
|
--pretty Indented JSON output (single-shot only).
|
|
1524
1566
|
|
|
1525
1567
|
Stdin event grammar (one JSON object per line):
|
|
@@ -1600,6 +1642,13 @@ Exit codes:
|
|
|
1600
1642
|
close.notification_actions entry started a
|
|
1601
1643
|
regulatory clock (NIS2 24h, GDPR 72h, DORA 4h,
|
|
1602
1644
|
etc.) and the operator has not acked.
|
|
1645
|
+
6 TAMPERED Attestation sidecar verification failed (Ed25519
|
|
1646
|
+
signature mismatch on a prior session referenced
|
|
1647
|
+
by the run, or the replay record's sidecar did
|
|
1648
|
+
not verify against keys/public.pem).
|
|
1649
|
+
8 LOCK_CONTENTION Concurrent run holds the per-playbook attestation
|
|
1650
|
+
lock; the bounded retry budget (~1-2s) elapsed
|
|
1651
|
+
without acquiring it. Retry the operation.
|
|
1603
1652
|
|
|
1604
1653
|
Output: verb, session_id, playbooks_run, summary{total, detected,
|
|
1605
1654
|
max_rwep_observed, jurisdiction_clocks_started, verdict, fail_reasons[]},
|
|
@@ -1652,7 +1701,15 @@ Flags: --pretty.`,
|
|
|
1652
1701
|
Identical exit-code and output contract as \`run --all\`. Maintained for
|
|
1653
1702
|
operators who script the verb form rather than the flag.
|
|
1654
1703
|
|
|
1655
|
-
|
|
1704
|
+
Flags (selected — see \`exceptd run --help\` for the full list):
|
|
1705
|
+
--csaf-status <s> CSAF tracking.status for per-run close.evidence_package
|
|
1706
|
+
bundles. One of: draft | interim (default) | final.
|
|
1707
|
+
'final' commits to CSAF §3.1.11.3.5.1 immutability —
|
|
1708
|
+
set this only after operator review of the advisory.
|
|
1709
|
+
--publisher-namespace <url>
|
|
1710
|
+
CSAF document.publisher.namespace (§3.1.7.4). The
|
|
1711
|
+
operator's organisation URL, NOT the tooling vendor.
|
|
1712
|
+
Must be an http://… or https://… URL, ≤256 chars.`,
|
|
1656
1713
|
};
|
|
1657
1714
|
process.stdout.write((cmds[verb] || `${verb} — no per-verb help available; see \`exceptd help\` for the full list.`) + "\n");
|
|
1658
1715
|
}
|
|
@@ -2054,13 +2111,12 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
2054
2111
|
// Multi-playbook dispatch path. Triggered by --all, --scope <type>, or by
|
|
2055
2112
|
// a bare `exceptd run` (no positional, no flags) which auto-detects scopes
|
|
2056
2113
|
// from the cwd.
|
|
2057
|
-
//
|
|
2058
|
-
//
|
|
2059
|
-
//
|
|
2060
|
-
//
|
|
2061
|
-
//
|
|
2062
|
-
//
|
|
2063
|
-
// message, matching the rest of the v0.12.15 scope-validation contract.
|
|
2114
|
+
// Gate on `args.scope !== undefined` rather than truthy `args.scope`.
|
|
2115
|
+
// `--scope ""` parses to `args.scope === ""`, which is falsy; a truthy
|
|
2116
|
+
// gate would silently fall through to auto-detect and run whatever
|
|
2117
|
+
// scopes happened to match the cwd, masking the operator's explicit
|
|
2118
|
+
// (if malformed) intent. An empty string reaches validateScopeOrThrow
|
|
2119
|
+
// which rejects with the accepted-set message.
|
|
2064
2120
|
if (!positional && (args.all || args.scope !== undefined)) {
|
|
2065
2121
|
let ids;
|
|
2066
2122
|
if (args.all) {
|
|
@@ -2138,20 +2194,14 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
2138
2194
|
// v0.11.1: auto-detect piped stdin. If no --evidence flag and stdin is a
|
|
2139
2195
|
// pipe, assume `--evidence -`. Operators forgetting the flag previously
|
|
2140
2196
|
// got a confusing precondition halt; now the common case "just works."
|
|
2141
|
-
//
|
|
2142
|
-
//
|
|
2143
|
-
//
|
|
2144
|
-
//
|
|
2145
|
-
//
|
|
2146
|
-
//
|
|
2147
|
-
//
|
|
2148
|
-
//
|
|
2149
|
-
// the raw `!process.stdin.isTTY` truthy check. Test harnesses with
|
|
2150
|
-
// wrapped stdin duplexers (Mocha/Jest, Docker stdin-passthrough) leave
|
|
2151
|
-
// isTTY === undefined but have no data — the raw check fell into
|
|
2152
|
-
// readFileSync(0) and BLOCKED waiting for an EOF that never arrived.
|
|
2153
|
-
// hasReadableStdin() does an fstat() probe first, then falls back to
|
|
2154
|
-
// the truthy check only on Windows (where fstat on a pipe is unreliable).
|
|
2197
|
+
// Use the fstat-probing hasReadableStdin() helper. A raw `!isTTY` check
|
|
2198
|
+
// fires when isTTY is undefined (test harnesses with wrapped duplexers —
|
|
2199
|
+
// Mocha/Jest, Docker stdin-passthrough — leave isTTY === undefined but
|
|
2200
|
+
// never write any bytes), which causes readFileSync(0) to block waiting
|
|
2201
|
+
// on an EOF that never arrives. hasReadableStdin() does an fstat() probe
|
|
2202
|
+
// first, then falls back to a strict isTTY===false check only on Windows
|
|
2203
|
+
// (where fstat on a pipe is unreliable). MSYS-bash on win32 reports
|
|
2204
|
+
// isTTY === false for genuine piped input, so that path still works.
|
|
2155
2205
|
if (!args.evidence && hasReadableStdin()) {
|
|
2156
2206
|
args.evidence = "-";
|
|
2157
2207
|
}
|
|
@@ -2187,11 +2237,10 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
2187
2237
|
// CVE ID set through to analyze() so matched_cves drops them.
|
|
2188
2238
|
if (args.vex) {
|
|
2189
2239
|
let vexDoc;
|
|
2190
|
-
//
|
|
2191
|
-
//
|
|
2192
|
-
//
|
|
2193
|
-
//
|
|
2194
|
-
// the event loop for minutes / OOM'd the process. 32 MiB is well beyond
|
|
2240
|
+
// Cap --vex file size at 32 MiB (binary mebibytes, i.e. 32 * 1024 * 1024
|
|
2241
|
+
// = 33,554,432 bytes), matching readEvidence()'s --evidence cap. Without
|
|
2242
|
+
// the cap, a multi-GB file (binary log, JSON bomb, or accident) blocks
|
|
2243
|
+
// the event loop for minutes / OOM's the process. 32 MiB is well beyond
|
|
2195
2244
|
// any legitimate VEX submission.
|
|
2196
2245
|
const MAX_VEX_BYTES = 32 * 1024 * 1024;
|
|
2197
2246
|
let vstat;
|
|
@@ -2200,7 +2249,7 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
2200
2249
|
return emitError(`run: failed to stat --vex ${args.vex}: ${e.message}`, null, pretty);
|
|
2201
2250
|
}
|
|
2202
2251
|
if (vstat.size > MAX_VEX_BYTES) {
|
|
2203
|
-
//
|
|
2252
|
+
// Error message names the binary mebi convention explicitly so
|
|
2204
2253
|
// operators don't mistake the cap for 32 * 10^6 = 32,000,000 bytes (MB).
|
|
2205
2254
|
return emitError(
|
|
2206
2255
|
`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.`,
|
|
@@ -2209,14 +2258,14 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
2209
2258
|
);
|
|
2210
2259
|
}
|
|
2211
2260
|
try {
|
|
2212
|
-
//
|
|
2213
|
-
//
|
|
2261
|
+
// BOM-tolerant read. Windows-tool-emitted CycloneDX commonly carries
|
|
2262
|
+
// UTF-8-BOM or UTF-16 LE/BE markers; the raw "utf8" decode in
|
|
2214
2263
|
// readFileSync chokes on the leading 0xFEFF.
|
|
2215
2264
|
vexDoc = readJsonFile(args.vex);
|
|
2216
2265
|
} catch (e) {
|
|
2217
2266
|
return emitError(`run: failed to load --vex ${args.vex}: ${e.message}`, null, pretty);
|
|
2218
2267
|
}
|
|
2219
|
-
//
|
|
2268
|
+
// Validate the VEX shape BEFORE handing to runner.vexFilterFromDoc.
|
|
2220
2269
|
// The runner tolerantly returns Set(0) for anything that's not CycloneDX
|
|
2221
2270
|
// or OpenVEX shape, so an operator who passes a SARIF / SBOM / CSAF
|
|
2222
2271
|
// advisory by mistake got ZERO filter applied and ZERO feedback. Now:
|
|
@@ -2234,15 +2283,13 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
2234
2283
|
const vexSet = runner.vexFilterFromDoc(vexDoc);
|
|
2235
2284
|
submission.signals = submission.signals || {};
|
|
2236
2285
|
submission.signals.vex_filter = [...vexSet];
|
|
2237
|
-
//
|
|
2238
|
-
//
|
|
2239
|
-
//
|
|
2240
|
-
//
|
|
2241
|
-
//
|
|
2242
|
-
//
|
|
2243
|
-
//
|
|
2244
|
-
// the CLI. The bundle-correctness tests only exercised the analyze()
|
|
2245
|
-
// direct-call path with vex_fixed pre-injected, hiding this regression.
|
|
2286
|
+
// vexFilterFromDoc attaches a `.fixed` Set as an own property on the
|
|
2287
|
+
// returned filter Set (CycloneDX `analysis.state: 'resolved'` + OpenVEX
|
|
2288
|
+
// `status: 'fixed'` populate it). Forward it through to
|
|
2289
|
+
// signals.vex_fixed so analyze() receives the fixed-disposition CVE
|
|
2290
|
+
// ids, `vex_status: 'fixed'` annotates matched_cves entries, and CSAF
|
|
2291
|
+
// product_status.fixed + OpenVEX status:'fixed' propagate into the
|
|
2292
|
+
// bundle.
|
|
2246
2293
|
submission.signals.vex_fixed = vexSet.fixed ? [...vexSet.fixed] : [];
|
|
2247
2294
|
} catch (e) {
|
|
2248
2295
|
return emitError(`run: failed to apply --vex ${args.vex}: ${e.message}`, null, pretty);
|
|
@@ -2281,14 +2328,14 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
2281
2328
|
// ack state by that name (`result.ack` is shorter + matches the CLI flag).
|
|
2282
2329
|
if (result && runOpts.operator) result.operator = runOpts.operator;
|
|
2283
2330
|
|
|
2284
|
-
//
|
|
2285
|
-
//
|
|
2331
|
+
// --ack consent only counts when a jurisdiction clock is actually at
|
|
2332
|
+
// stake — i.e. the run produced classification=detected (a real finding
|
|
2286
2333
|
// that may trigger NIS2 24h / DORA 4h / GDPR 72h obligations). On a
|
|
2287
|
-
// not-detected or inconclusive run, persisting the consent
|
|
2288
|
-
//
|
|
2289
|
-
//
|
|
2290
|
-
//
|
|
2291
|
-
//
|
|
2334
|
+
// not-detected or inconclusive run, persisting the consent would record
|
|
2335
|
+
// operator acknowledgement of a clock that never started. Surface the
|
|
2336
|
+
// ack state in the run body either way so operators see what happened,
|
|
2337
|
+
// but only persist `operator_consent` into the attestation when
|
|
2338
|
+
// classification === detected.
|
|
2292
2339
|
const detectClassification = result && result.phases && result.phases.detect
|
|
2293
2340
|
? result.phases.detect.classification
|
|
2294
2341
|
: null;
|
|
@@ -2313,7 +2360,7 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
2313
2360
|
directiveId: result.directive_id,
|
|
2314
2361
|
evidenceHash: result.evidence_hash,
|
|
2315
2362
|
operator: runOpts.operator,
|
|
2316
|
-
//
|
|
2363
|
+
// Gate consent persistence on classification=detected.
|
|
2317
2364
|
operatorConsent: consentApplies ? runOpts.operator_consent : null,
|
|
2318
2365
|
submission,
|
|
2319
2366
|
runOpts,
|
|
@@ -2360,12 +2407,11 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
2360
2407
|
}
|
|
2361
2408
|
|
|
2362
2409
|
if (result && result.ok === false) {
|
|
2363
|
-
//
|
|
2364
|
-
//
|
|
2365
|
-
//
|
|
2366
|
-
//
|
|
2367
|
-
//
|
|
2368
|
-
// (ok:false bodies are framework signals when no CI gating is asked for).
|
|
2410
|
+
// Align preflight-halt exit code between `run --ci` and `ci`: both use
|
|
2411
|
+
// 4 (BLOCKED) when --ci is in effect so operators can wire one set of
|
|
2412
|
+
// exit-code expectations regardless of which verb they call. Without
|
|
2413
|
+
// --ci the legacy exit 1 is preserved (ok:false bodies are framework
|
|
2414
|
+
// signals when no CI gating is requested).
|
|
2369
2415
|
process.stderr.write((pretty ? JSON.stringify(result, null, 2) : JSON.stringify(result)) + "\n");
|
|
2370
2416
|
process.exitCode = args.ci ? 4 : 1;
|
|
2371
2417
|
return;
|
|
@@ -2573,9 +2619,10 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
2573
2619
|
const top = rwep?.threshold?.escalate ?? "n/a";
|
|
2574
2620
|
const verdictIcon = cls === "detected" ? "[!! DETECTED]" : cls === "inconclusive" ? "[i INCONCLUSIVE]" : "[ok]";
|
|
2575
2621
|
lines.push(`\n${verdictIcon} classification=${cls} RWEP ${adj}/${top}${adj !== base ? ` (Δ${adj - base} from operator evidence)` : " (catalog baseline)"} blast_radius=${obj.phases?.analyze?.blast_radius_score ?? "n/a"}/5`);
|
|
2576
|
-
// F11: surface --diff-from-latest verdict in the human renderer
|
|
2577
|
-
// operators
|
|
2578
|
-
//
|
|
2622
|
+
// F11: surface --diff-from-latest verdict in the human renderer so
|
|
2623
|
+
// operators see whether the run drifted from the previous attestation
|
|
2624
|
+
// without adding --json. One summary line follows the classification.
|
|
2625
|
+
// Marker text is grep-matched by tests/audit-i-l-m-fixes.test.js F11.
|
|
2579
2626
|
// - unchanged: same evidence_hash as prior → reassuring single line.
|
|
2580
2627
|
// - drifted: evidence differs → loud DRIFTED marker.
|
|
2581
2628
|
// - no_prior_attestation_for_playbook: no line — don't clutter the
|
|
@@ -2653,7 +2700,7 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
2653
2700
|
* inconclusive findings + visibility gaps) when no --evidence is given.
|
|
2654
2701
|
*/
|
|
2655
2702
|
/**
|
|
2656
|
-
*
|
|
2703
|
+
* Collapse per-playbook notification_actions into a deduped rollup.
|
|
2657
2704
|
* Multi-playbook runs frequently surface the same jurisdiction clock from
|
|
2658
2705
|
* 5-10 contributing playbooks (every EU-touching playbook starts a fresh
|
|
2659
2706
|
* NIS2 Art.23 24h clock). Operators were drafting one notification per
|
|
@@ -2688,12 +2735,11 @@ function buildJurisdictionClockRollup(results) {
|
|
|
2688
2735
|
existing.deadline = n.deadline;
|
|
2689
2736
|
}
|
|
2690
2737
|
} else {
|
|
2691
|
-
//
|
|
2692
|
-
// entry promised) AND retain `obligation_ref` as a kept-name alias
|
|
2738
|
+
// Emit `obligation` and retain `obligation_ref` as a kept-name alias
|
|
2693
2739
|
// for any consumer that already parses the older shape. The dedupe
|
|
2694
2740
|
// key still keys on n.obligation_ref since that's the field
|
|
2695
2741
|
// notification-action stubs carry; the rollup body just exposes
|
|
2696
|
-
// both names
|
|
2742
|
+
// both names.
|
|
2697
2743
|
const obligation = n.obligation_ref || null;
|
|
2698
2744
|
m.set(key, {
|
|
2699
2745
|
jurisdiction: n.jurisdiction || null,
|
|
@@ -2747,13 +2793,13 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
|
2747
2793
|
if (!entryPath.startsWith(resolvedDir + path.sep)) {
|
|
2748
2794
|
return emitError(`run: --evidence-dir entry ${f} resolves outside the directory; refusing.`, null, pretty);
|
|
2749
2795
|
}
|
|
2750
|
-
//
|
|
2751
|
-
//
|
|
2752
|
-
//
|
|
2753
|
-
//
|
|
2754
|
-
//
|
|
2755
|
-
//
|
|
2756
|
-
//
|
|
2796
|
+
// The path.resolve check above only catches `..` traversal in the
|
|
2797
|
+
// joined path; fs.readFileSync(entryPath) still follows symlinks, so
|
|
2798
|
+
// a `<pb-id>.json -> /etc/shadow` symlink inside the dir would happily
|
|
2799
|
+
// slurp the target. lstat is symlink-aware (it does NOT follow);
|
|
2800
|
+
// refuse anything that's not a regular file. Defense in depth on top
|
|
2801
|
+
// of the readdir filter — a junction (Windows) or bind-mount can
|
|
2802
|
+
// shape-shift in between filter and read.
|
|
2757
2803
|
let lst;
|
|
2758
2804
|
try { lst = fs.lstatSync(entryPath); }
|
|
2759
2805
|
catch (e) {
|
|
@@ -2765,9 +2811,9 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
|
2765
2811
|
if (!lst.isFile()) {
|
|
2766
2812
|
return emitError(`run: --evidence-dir entry ${f} is not a regular file; refusing.`, { entry: f }, pretty);
|
|
2767
2813
|
}
|
|
2768
|
-
//
|
|
2814
|
+
// Windows directory junctions are reparse-point dirs that
|
|
2769
2815
|
// `lstat().isSymbolicLink()` returns FALSE for (Node treats them as
|
|
2770
|
-
// ordinary directories)
|
|
2816
|
+
// ordinary directories), bypassing the symlink refusal above. Use
|
|
2771
2817
|
// realpathSync to resolve the entry and confirm it still lives under
|
|
2772
2818
|
// the resolved evidence-dir — the realpath approach is portable
|
|
2773
2819
|
// (catches POSIX symlinks too, defense in depth) and works regardless
|
|
@@ -2784,10 +2830,10 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
|
2784
2830
|
pretty
|
|
2785
2831
|
);
|
|
2786
2832
|
}
|
|
2787
|
-
//
|
|
2788
|
-
//
|
|
2789
|
-
//
|
|
2790
|
-
//
|
|
2833
|
+
// Hardlink defense in depth: no clean cross-platform refusal exists —
|
|
2834
|
+
// hardlinks are indistinguishable from regular files at the inode
|
|
2835
|
+
// level. Surface a stderr warning when nlink > 1 so the operator is
|
|
2836
|
+
// aware a second name may point at the same file. Not a refusal —
|
|
2791
2837
|
// legitimate use cases (atomic rename, package-manager dedup) produce
|
|
2792
2838
|
// nlink > 1 without malicious intent.
|
|
2793
2839
|
if (lst.nlink > 1) {
|
|
@@ -2815,16 +2861,13 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
|
2815
2861
|
|
|
2816
2862
|
const result = runner.run(id, directiveId, submission, perRunOpts);
|
|
2817
2863
|
|
|
2818
|
-
//
|
|
2819
|
-
//
|
|
2820
|
-
//
|
|
2821
|
-
//
|
|
2822
|
-
// playbook
|
|
2823
|
-
//
|
|
2824
|
-
//
|
|
2825
|
-
// Now: per-playbook gating with the same `ack_skipped_reason` surface
|
|
2826
|
-
// cmdRun emits, so consumers see exactly which playbooks consumed the
|
|
2827
|
-
// ack and which didn't.
|
|
2864
|
+
// Per-playbook --ack gating: consent only counts when a jurisdiction
|
|
2865
|
+
// clock is actually at stake on THIS playbook's verdict — i.e. its
|
|
2866
|
+
// detect.classification === 'detected'. Without this gate, a single
|
|
2867
|
+
// --ack on a run-all invocation would persist explicit consent into
|
|
2868
|
+
// every playbook's attestation regardless of whether that playbook's
|
|
2869
|
+
// run started a clock. The `ack_skipped_reason` surface mirrors cmdRun
|
|
2870
|
+
// so consumers see exactly which playbooks consumed the ack.
|
|
2828
2871
|
const perDetectClassification = result && result.phases && result.phases.detect
|
|
2829
2872
|
? result.phases.detect.classification
|
|
2830
2873
|
: null;
|
|
@@ -2849,8 +2892,8 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
|
2849
2892
|
directiveId,
|
|
2850
2893
|
evidenceHash: result.evidence_hash,
|
|
2851
2894
|
operator: perRunOpts.operator,
|
|
2852
|
-
//
|
|
2853
|
-
//
|
|
2895
|
+
// Gate consent persistence on this playbook's classification, not
|
|
2896
|
+
// on the aggregate run's --ack presence.
|
|
2854
2897
|
operatorConsent: perConsentApplies ? perRunOpts.operator_consent : null,
|
|
2855
2898
|
submission,
|
|
2856
2899
|
runOpts: perRunOpts,
|
|
@@ -2869,12 +2912,12 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
|
2869
2912
|
results.push(result);
|
|
2870
2913
|
}
|
|
2871
2914
|
|
|
2872
|
-
//
|
|
2873
|
-
// results into a single rollup.
|
|
2874
|
-
// contributors of "EU NIS2 Art.23 24h"
|
|
2875
|
-
// operators
|
|
2876
|
-
// playbook entries are preserved on individual results; this rollup
|
|
2877
|
-
// additive — keyed on (jurisdiction, regulation, obligation_ref,
|
|
2915
|
+
// Dedupe jurisdiction-clock notification actions across all playbook
|
|
2916
|
+
// results into a single rollup. Without this, a 13-playbook multi-run
|
|
2917
|
+
// with 8 contributors of "EU NIS2 Art.23 24h" produces 8 separate
|
|
2918
|
+
// entries and operators draft 8 NIS2 notifications when one suffices.
|
|
2919
|
+
// Per-playbook entries are preserved on individual results; this rollup
|
|
2920
|
+
// is additive — keyed on (jurisdiction, regulation, obligation_ref,
|
|
2878
2921
|
// window_hours) — with a triggered_by_playbooks[] list so operators see
|
|
2879
2922
|
// which playbooks contributed.
|
|
2880
2923
|
const jurisdictionClockRollup = buildJurisdictionClockRollup(results);
|
|
@@ -2908,19 +2951,14 @@ function cmdIngest(runner, args, runOpts, pretty) {
|
|
|
2908
2951
|
// `ingest` matches the AGENTS.md ingest contract. The submission JSON may
|
|
2909
2952
|
// carry playbook_id + directive_id; --domain/--directive flags override.
|
|
2910
2953
|
let submission = {};
|
|
2911
|
-
//
|
|
2912
|
-
// `echo '{...}' | exceptd ingest`
|
|
2913
|
-
//
|
|
2914
|
-
//
|
|
2915
|
-
//
|
|
2916
|
-
//
|
|
2917
|
-
//
|
|
2918
|
-
//
|
|
2919
|
-
// detects piped stdin") was a no-op on Windows pre-fix.
|
|
2920
|
-
//
|
|
2921
|
-
// EE P1-7: route through hasReadableStdin() — see cmdRun for rationale.
|
|
2922
|
-
// Wrapped-stdin test harnesses (Mocha/Jest, Docker stdin-passthrough)
|
|
2923
|
-
// would otherwise block here forever on the readFileSync(0) call.
|
|
2954
|
+
// Auto-detect piped stdin (parity with cmdRun) so
|
|
2955
|
+
// `echo '{...}' | exceptd ingest` reads the routing JSON instead of
|
|
2956
|
+
// failing with "no playbook resolved" because args.evidence stays
|
|
2957
|
+
// undefined.
|
|
2958
|
+
// Route stdin auto-detection through hasReadableStdin() (see cmdRun for
|
|
2959
|
+
// rationale). Wrapped-stdin test harnesses (Mocha/Jest, Docker
|
|
2960
|
+
// stdin-passthrough) would otherwise block here forever on the
|
|
2961
|
+
// readFileSync(0) call when isTTY === undefined.
|
|
2924
2962
|
if (!args.evidence && hasReadableStdin()) {
|
|
2925
2963
|
args.evidence = "-";
|
|
2926
2964
|
}
|
|
@@ -3141,21 +3179,21 @@ function persistAttestation(args) {
|
|
|
3141
3179
|
existingPath: path.relative(process.cwd(), filePath),
|
|
3142
3180
|
};
|
|
3143
3181
|
}
|
|
3144
|
-
//
|
|
3145
|
-
//
|
|
3146
|
-
//
|
|
3147
|
-
//
|
|
3148
|
-
//
|
|
3149
|
-
//
|
|
3150
|
-
// DD P1-2: MAX_RETRIES capped at 10
|
|
3151
|
-
//
|
|
3152
|
-
//
|
|
3153
|
-
//
|
|
3154
|
-
//
|
|
3155
|
-
//
|
|
3156
|
-
//
|
|
3157
|
-
//
|
|
3158
|
-
//
|
|
3182
|
+
// Serialize the read-prior + write-new sequence behind a lockfile so
|
|
3183
|
+
// concurrent --force-overwrite invocations against the same session-id
|
|
3184
|
+
// slot do not degrade to last-write-wins. Pattern matches
|
|
3185
|
+
// withCatalogLock + withIndexLock: O_EXCL 'wx' on a sibling .lock file
|
|
3186
|
+
// with bounded retry, PID-liveness check on contention, mtime fallback
|
|
3187
|
+
// for orphaned lockfiles.
|
|
3188
|
+
// DD P1-2: MAX_RETRIES is capped at 10. persistAttestation is sync and
|
|
3189
|
+
// called from sync callers, so the wait loop must busy-spin (no
|
|
3190
|
+
// event-loop yield available). A larger bound would peg the CPU and
|
|
3191
|
+
// freeze the event loop for multiple seconds under attestation
|
|
3192
|
+
// contention. Capping at 10 bounds the freeze at ~1-2s; beyond that
|
|
3193
|
+
// callers receive the LOCK_CONTENTION sentinel on the result object
|
|
3194
|
+
// and can retry from the outside without holding the CPU. Async
|
|
3195
|
+
// refactor of persistAttestation + every caller is a v0.13.0
|
|
3196
|
+
// candidate.
|
|
3159
3197
|
const lockPath = filePath + ".lock";
|
|
3160
3198
|
const MAX_RETRIES = 10;
|
|
3161
3199
|
const STALE_LOCK_MS = 30_000;
|
|
@@ -3194,21 +3232,18 @@ function persistAttestation(args) {
|
|
|
3194
3232
|
}
|
|
3195
3233
|
}
|
|
3196
3234
|
if (!acquired) {
|
|
3197
|
-
//
|
|
3198
|
-
// genuine lock-busy condition (retry-from-outside is
|
|
3199
|
-
// from a hard failure (write error, permission
|
|
3200
|
-
// spin budget
|
|
3201
|
-
//
|
|
3235
|
+
// Surface lock_contention as a distinct sentinel so callers can
|
|
3236
|
+
// distinguish a genuine lock-busy condition (retry-from-outside is
|
|
3237
|
+
// the right move) from a hard failure (write error, permission
|
|
3238
|
+
// denial). The sync spin budget is bounded above so this return
|
|
3239
|
+
// fires after ~1-2s of contention.
|
|
3202
3240
|
//
|
|
3203
|
-
//
|
|
3204
|
-
//
|
|
3205
|
-
// the
|
|
3206
|
-
//
|
|
3207
|
-
//
|
|
3208
|
-
//
|
|
3209
|
-
// preserve the already-non-zero value. Exit code 8 is reserved
|
|
3210
|
-
// exclusively for LOCK_CONTENTION (attestation persist); see the
|
|
3211
|
-
// exit-code table in printGlobalHelp().
|
|
3241
|
+
// emit() auto-maps any ok:false body to process.exitCode = 1 (only
|
|
3242
|
+
// when the current value is still 0). Pin process.exitCode = 8 HERE
|
|
3243
|
+
// before the caller hands the body to emit(); emit() preserves the
|
|
3244
|
+
// already-non-zero value. Exit code 8 is reserved exclusively for
|
|
3245
|
+
// LOCK_CONTENTION (attestation persist); see the exit-code table in
|
|
3246
|
+
// printGlobalHelp().
|
|
3212
3247
|
process.exitCode = 8;
|
|
3213
3248
|
return {
|
|
3214
3249
|
ok: false,
|
|
@@ -3253,7 +3288,7 @@ function persistAttestation(args) {
|
|
|
3253
3288
|
* from "the .sig file was deleted by an attacker."
|
|
3254
3289
|
*/
|
|
3255
3290
|
/**
|
|
3256
|
-
*
|
|
3291
|
+
* Byte-stability normalize() for the attestation pipeline.
|
|
3257
3292
|
* Strips a leading UTF-8 BOM and collapses CRLF → LF. Mirrors the
|
|
3258
3293
|
* normalize() implementations in lib/sign.js, lib/verify.js,
|
|
3259
3294
|
* lib/refresh-network.js, and scripts/verify-shipped-tarball.js. Five
|
|
@@ -3279,13 +3314,13 @@ function maybeSignAttestation(filePath) {
|
|
|
3279
3314
|
// (reporting only PKG_ROOT now), not by making `run` follow a cwd key the
|
|
3280
3315
|
// verifier doesn't trust.
|
|
3281
3316
|
const privKeyPath = path.join(PKG_ROOT, ".keys", "private.pem");
|
|
3282
|
-
//
|
|
3283
|
-
//
|
|
3317
|
+
// Normalize attestation bytes before sign — strip leading UTF-8 BOM +
|
|
3318
|
+
// collapse CRLF to LF. Mirrors lib/sign.js / lib/verify.js /
|
|
3284
3319
|
// lib/refresh-network.js / scripts/verify-shipped-tarball.js. The
|
|
3285
3320
|
// attestation file lives on disk under .exceptd/ and can pick up CRLF
|
|
3286
3321
|
// through git-attribute / editor round-trips on Windows; without
|
|
3287
3322
|
// normalization the sign/verify pair diverges on the same logical content.
|
|
3288
|
-
// The byte-stability contract
|
|
3323
|
+
// The byte-stability contract spans five sites; tests/normalize-contract
|
|
3289
3324
|
// .test.js enforces byte-identical output across all of them.
|
|
3290
3325
|
const rawContent = fs.readFileSync(filePath, "utf8");
|
|
3291
3326
|
const content = normalizeAttestationBytes(rawContent);
|
|
@@ -3309,16 +3344,15 @@ function maybeSignAttestation(filePath) {
|
|
|
3309
3344
|
key: privateKey,
|
|
3310
3345
|
dsaEncoding: "ieee-p1363",
|
|
3311
3346
|
});
|
|
3312
|
-
//
|
|
3313
|
-
//
|
|
3314
|
-
//
|
|
3315
|
-
//
|
|
3316
|
-
//
|
|
3317
|
-
//
|
|
3318
|
-
//
|
|
3319
|
-
// mtime; the attestation file's `captured_at` field is
|
|
3320
|
-
// signed.
|
|
3321
|
-
// Ed25519 signature payload, and an explanatory note.
|
|
3347
|
+
// The sidecar's Ed25519 signature covers ONLY the attestation file
|
|
3348
|
+
// bytes. Fields that travel inside the .sig but are NOT in the signed
|
|
3349
|
+
// message are replay-rewrite trivial: an attacker who can write the
|
|
3350
|
+
// directory can mutate them without invalidating the signature. The
|
|
3351
|
+
// sidecar therefore carries only the algorithm tag, the Ed25519
|
|
3352
|
+
// signature payload, and an explanatory note — no `signed_at`,
|
|
3353
|
+
// `signs_path`, or `signs_sha256`. Operators reading freshness use
|
|
3354
|
+
// filesystem mtime; the attestation file's `captured_at` field is
|
|
3355
|
+
// what's signed.
|
|
3322
3356
|
fs.writeFileSync(sigPath, JSON.stringify({
|
|
3323
3357
|
algorithm: "Ed25519",
|
|
3324
3358
|
signature_base64: sig.toString("base64"),
|
|
@@ -3411,6 +3445,12 @@ function walkAttestationDir(root, opts, candidates) {
|
|
|
3411
3445
|
try {
|
|
3412
3446
|
const p = path.join(sdir, f);
|
|
3413
3447
|
const j = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
3448
|
+
// Replay records (kind: 'replay') are an audit trail of force-replay
|
|
3449
|
+
// overrides, not a separate attestation. They have no captured_at /
|
|
3450
|
+
// evidence_hash and must not surface as candidates for --latest.
|
|
3451
|
+
// Gate on the parsed kind so a renamed file cannot smuggle a replay
|
|
3452
|
+
// record into the listing.
|
|
3453
|
+
if (j && j.kind === "replay") continue;
|
|
3414
3454
|
if (opts.playbookId && j.playbook_id !== opts.playbookId) continue;
|
|
3415
3455
|
if (opts.since && (j.captured_at || "") < opts.since) continue;
|
|
3416
3456
|
if (opts.excludeSessionId && sid === opts.excludeSessionId) continue;
|
|
@@ -3421,14 +3461,14 @@ function walkAttestationDir(root, opts, candidates) {
|
|
|
3421
3461
|
}
|
|
3422
3462
|
|
|
3423
3463
|
/**
|
|
3424
|
-
*
|
|
3425
|
-
*
|
|
3464
|
+
* Factored Ed25519-sidecar verification used by both `attest verify` and
|
|
3465
|
+
* `reattest`. Returns { file, signed, verified, reason } for a given
|
|
3426
3466
|
* attestation file path.
|
|
3427
3467
|
*
|
|
3428
|
-
*
|
|
3429
|
-
*
|
|
3430
|
-
*
|
|
3431
|
-
*
|
|
3468
|
+
* Callers must check `signed && verified` before consuming the
|
|
3469
|
+
* attestation. cmdReattest refuses to replay on verify-fail unless
|
|
3470
|
+
* --force-replay is set, so a tampered attestation cannot silently feed
|
|
3471
|
+
* forged input into the drift verdict.
|
|
3432
3472
|
*/
|
|
3433
3473
|
function verifyAttestationSidecar(attFile) {
|
|
3434
3474
|
const crypto = require("crypto");
|
|
@@ -3488,15 +3528,15 @@ function verifyAttestationSidecar(attFile) {
|
|
|
3488
3528
|
}
|
|
3489
3529
|
return { file: attFile, signed: false, verified: false, reason: "attestation explicitly unsigned (no private key when written)" };
|
|
3490
3530
|
}
|
|
3491
|
-
//
|
|
3492
|
-
//
|
|
3493
|
-
//
|
|
3494
|
-
//
|
|
3495
|
-
//
|
|
3496
|
-
//
|
|
3497
|
-
//
|
|
3498
|
-
//
|
|
3499
|
-
//
|
|
3531
|
+
// Strict algorithm check. A branch on `=== "unsigned"` alone would let
|
|
3532
|
+
// null, undefined, "RSA-PSS", arrays, etc. fall through to crypto.verify
|
|
3533
|
+
// with default Ed25519 args — which can either succeed against
|
|
3534
|
+
// wrong-algorithm signature bytes accidentally (an attacker who can
|
|
3535
|
+
// write the sidecar replays an existing Ed25519 signature under a
|
|
3536
|
+
// downgrade-bait algorithm tag) or throw a generic verify error.
|
|
3537
|
+
// Refuse anything that isn't exactly "Ed25519" or "unsigned" with a
|
|
3538
|
+
// structured tamper class so callers can route the refusal through the
|
|
3539
|
+
// same exit-6 path as other tamper events.
|
|
3500
3540
|
if (sigDoc.algorithm !== "Ed25519") {
|
|
3501
3541
|
return {
|
|
3502
3542
|
file: attFile,
|
|
@@ -3512,9 +3552,9 @@ function verifyAttestationSidecar(attFile) {
|
|
|
3512
3552
|
let content;
|
|
3513
3553
|
try {
|
|
3514
3554
|
const raw = fs.readFileSync(attFile, "utf8");
|
|
3515
|
-
//
|
|
3516
|
-
//
|
|
3517
|
-
//
|
|
3555
|
+
// Apply the same normalize() used by the signer so the verify path is
|
|
3556
|
+
// byte-stable across CRLF / BOM churn (Windows checkout with
|
|
3557
|
+
// core.autocrlf=true, editor round-trips, git-attributes flips).
|
|
3518
3558
|
content = normalizeAttestationBytes(raw);
|
|
3519
3559
|
}
|
|
3520
3560
|
catch (e) { return { file: attFile, signed: true, verified: false, reason: `attestation read error: ${e.message}` }; }
|
|
@@ -3534,13 +3574,14 @@ function verifyAttestationSidecar(attFile) {
|
|
|
3534
3574
|
}
|
|
3535
3575
|
|
|
3536
3576
|
function cmdReattest(runner, args, runOpts, pretty) {
|
|
3537
|
-
|
|
3538
|
-
//
|
|
3539
|
-
// through to walkAttestationDir, where
|
|
3540
|
-
//
|
|
3577
|
+
const crypto = require("crypto");
|
|
3578
|
+
// Validate --since as ISO-8601, mirroring `attest list --since`. An
|
|
3579
|
+
// invalid date would otherwise pass through to walkAttestationDir, where
|
|
3580
|
+
// the lexical comparison either matches all or none unpredictably.
|
|
3541
3581
|
if (args.since != null) {
|
|
3542
|
-
//
|
|
3543
|
-
// otherwise parse as the year 1999 and silently filter wrong
|
|
3582
|
+
// ISO-8601 shape regex BEFORE Date.parse — bare integers like "99"
|
|
3583
|
+
// would otherwise parse as the year 1999 and silently filter wrong
|
|
3584
|
+
// eras.
|
|
3544
3585
|
const sinceErr = validateIsoSince(args.since);
|
|
3545
3586
|
if (sinceErr) return emitError(`reattest: ${sinceErr}`, null, pretty);
|
|
3546
3587
|
}
|
|
@@ -3564,35 +3605,31 @@ function cmdReattest(runner, args, runOpts, pretty) {
|
|
|
3564
3605
|
return emitError(`reattest: no attestation found at ${attFile}`, { session_id: sessionId }, pretty);
|
|
3565
3606
|
}
|
|
3566
3607
|
|
|
3567
|
-
//
|
|
3568
|
-
//
|
|
3569
|
-
// verdict
|
|
3608
|
+
// Verify the .sig sidecar BEFORE consuming the prior attestation. A
|
|
3609
|
+
// tampered attestation.json would otherwise be silently parsed and the
|
|
3610
|
+
// drift verdict computed against forged input. Refuse on verify-fail
|
|
3570
3611
|
// with exit 6 (TAMPERED) unless --force-replay is explicitly set.
|
|
3571
3612
|
// Unsigned attestations (no private key was available at run time) emit
|
|
3572
3613
|
// a stderr warning but proceed — that's an operator config issue, not
|
|
3573
3614
|
// tampering. `verified === false && signed === true` is the real tamper
|
|
3574
3615
|
// signal.
|
|
3575
3616
|
const verify = verifyAttestationSidecar(attFile);
|
|
3576
|
-
//
|
|
3577
|
-
//
|
|
3578
|
-
//
|
|
3579
|
-
//
|
|
3580
|
-
//
|
|
3581
|
-
//
|
|
3582
|
-
// NOTE branch and replay proceeded against forged input.
|
|
3617
|
+
// Collapse tamper-class detection. Any non-benign sidecar state
|
|
3618
|
+
// (signed-but-invalid, sidecar-corrupt, unsigned-substitution) refuses
|
|
3619
|
+
// replay unless --force-replay is set. A predicate of only
|
|
3620
|
+
// `verify.signed && !verify.verified` would miss corrupt-JSON sidecars
|
|
3621
|
+
// and substituted "unsigned" sidecars on a host WITH a private key —
|
|
3622
|
+
// both of which let replay proceed against forged input.
|
|
3583
3623
|
const isSignedTamper = verify.signed && !verify.verified;
|
|
3584
3624
|
const isClassTamper = !verify.signed && (
|
|
3585
3625
|
verify.tamper_class === "sidecar-corrupt"
|
|
3586
3626
|
|| verify.tamper_class === "unsigned-substitution"
|
|
3587
|
-
//
|
|
3588
|
-
//
|
|
3589
|
-
//
|
|
3590
|
-
//
|
|
3591
|
-
//
|
|
3592
|
-
//
|
|
3593
|
-
// through the catch block and emerged as signed:true + verified:false
|
|
3594
|
-
// by happy accident. The strict pre-check now surfaces the class
|
|
3595
|
-
// directly; refuse on that class too.
|
|
3627
|
+
// Extend tamper-class refusal to algorithm-unsupported sidecars —
|
|
3628
|
+
// anything other than "Ed25519" or "unsigned". Without explicit
|
|
3629
|
+
// refusal, a sidecar that throws inside crypto.verify (e.g.
|
|
3630
|
+
// signature_base64 missing on a downgrade-bait shape) emerges as
|
|
3631
|
+
// signed:true + verified:false through the catch block by accident.
|
|
3632
|
+
// The strict pre-check surfaces the class directly; refuse on it too.
|
|
3596
3633
|
|| verify.tamper_class === "algorithm-unsupported"
|
|
3597
3634
|
);
|
|
3598
3635
|
if ((isSignedTamper || isClassTamper) && !args["force-replay"]) {
|
|
@@ -3722,52 +3759,80 @@ function cmdReattest(runner, args, runOpts, pretty) {
|
|
|
3722
3759
|
const sidecarVerifyClass = classifySidecarVerify(verify);
|
|
3723
3760
|
const forceReplay = !!args["force-replay"];
|
|
3724
3761
|
|
|
3725
|
-
//
|
|
3726
|
-
//
|
|
3727
|
-
// force-replay
|
|
3728
|
-
//
|
|
3729
|
-
//
|
|
3730
|
-
//
|
|
3731
|
-
//
|
|
3732
|
-
//
|
|
3733
|
-
//
|
|
3762
|
+
// Persist a `replay-<isoZ>.json` record under the session directory for
|
|
3763
|
+
// every cmdReattest replay verdict. Without disk persistence, a
|
|
3764
|
+
// force-replay override emitted to stdout becomes invisible to any
|
|
3765
|
+
// subsequent auditor once the operator's shell closes. Each replay
|
|
3766
|
+
// writes a new file alongside the original attestation.json, signed via
|
|
3767
|
+
// the standard maybeSignAttestation path so the audit chain remains
|
|
3768
|
+
// tamper-evident. The file is picked up automatically by
|
|
3769
|
+
// `attest verify <sid>` (which iterates every *.json under the session
|
|
3770
|
+
// dir).
|
|
3734
3771
|
//
|
|
3735
3772
|
// Filename shape: ISO-8601 uses ':' which the persistAttestation regex
|
|
3736
3773
|
// refuses; substitute ':' with '-' and keep millisecond precision so
|
|
3737
3774
|
// multiple replays in the same second do not collide on EEXIST. The
|
|
3738
3775
|
// resulting filename satisfies /^[A-Za-z0-9._-]{1,64}\.json$/.
|
|
3739
|
-
const
|
|
3776
|
+
const replayBaseName = "replay-" + replayedAt.replace(/:/g, "-");
|
|
3777
|
+
const replayBody = {
|
|
3778
|
+
kind: "replay",
|
|
3779
|
+
session_id: sessionId,
|
|
3780
|
+
playbook_id: prior.playbook_id,
|
|
3781
|
+
directive_id: prior.directive_id,
|
|
3782
|
+
status,
|
|
3783
|
+
prior_evidence_hash: priorHash,
|
|
3784
|
+
replay_evidence_hash: newHash,
|
|
3785
|
+
prior_captured_at: prior.captured_at,
|
|
3786
|
+
replayed_at: replayedAt,
|
|
3787
|
+
replay_classification: replay.phases && replay.phases.detect && replay.phases.detect.classification,
|
|
3788
|
+
replay_rwep_adjusted: replay.phases && replay.phases.analyze && replay.phases.analyze.rwep && replay.phases.analyze.rwep.adjusted,
|
|
3789
|
+
sidecar_verify: verify,
|
|
3790
|
+
sidecar_verify_class: sidecarVerifyClass,
|
|
3791
|
+
force_replay: forceReplay,
|
|
3792
|
+
};
|
|
3740
3793
|
let replayPersisted = null;
|
|
3794
|
+
let replayPath = null;
|
|
3741
3795
|
try {
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
3748
|
-
|
|
3749
|
-
|
|
3750
|
-
|
|
3751
|
-
|
|
3752
|
-
|
|
3753
|
-
|
|
3754
|
-
|
|
3755
|
-
|
|
3756
|
-
|
|
3757
|
-
|
|
3758
|
-
|
|
3759
|
-
|
|
3760
|
-
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
replayPersisted = { ok: true, path: replayPath };
|
|
3796
|
+
// Retry on EEXIST: two concurrent reattests sharing the same
|
|
3797
|
+
// millisecond timestamp would collide on the base name. Append a short
|
|
3798
|
+
// random suffix until O_EXCL accepts the write or the cap is exhausted.
|
|
3799
|
+
const dir = path.dirname(attFile);
|
|
3800
|
+
const MAX_SUFFIX_TRIES = 8;
|
|
3801
|
+
let written = false;
|
|
3802
|
+
let lastErr = null;
|
|
3803
|
+
for (let i = 0; i < MAX_SUFFIX_TRIES; i++) {
|
|
3804
|
+
const suffix = i === 0 ? "" : "-" + crypto.randomBytes(3).toString("hex");
|
|
3805
|
+
const candidate = path.join(dir, replayBaseName + suffix + ".json");
|
|
3806
|
+
try {
|
|
3807
|
+
fs.writeFileSync(candidate, JSON.stringify(replayBody, null, 2), { flag: "wx" });
|
|
3808
|
+
replayPath = candidate;
|
|
3809
|
+
written = true;
|
|
3810
|
+
break;
|
|
3811
|
+
} catch (e) {
|
|
3812
|
+
lastErr = e;
|
|
3813
|
+
if (!e || e.code !== "EEXIST") throw e;
|
|
3814
|
+
}
|
|
3815
|
+
}
|
|
3816
|
+
if (!written) throw lastErr || new Error("replay-record write: EEXIST after " + MAX_SUFFIX_TRIES + " attempts");
|
|
3817
|
+
replayPersisted = { ok: true, path: replayPath, sidecar_signed: true };
|
|
3764
3818
|
} catch (e) {
|
|
3765
|
-
// Non-fatal —
|
|
3819
|
+
// Non-fatal — stdout emit is the operator's primary surface; a
|
|
3766
3820
|
// disk-persistence failure shouldn't mask the verdict. Surface the
|
|
3767
3821
|
// condition in the response body so an operator-side audit pipeline
|
|
3768
3822
|
// can re-run the persist later.
|
|
3769
3823
|
replayPersisted = { ok: false, error: String((e && e.message) || e) };
|
|
3770
3824
|
}
|
|
3825
|
+
if (replayPersisted && replayPersisted.ok && replayPath) {
|
|
3826
|
+
// Sidecar signing is best-effort: the unsigned replay record on disk
|
|
3827
|
+
// is still a valid audit-trail entry. Split from the write try{} so a
|
|
3828
|
+
// sign-time failure doesn't mask a successful write.
|
|
3829
|
+
try {
|
|
3830
|
+
maybeSignAttestation(replayPath);
|
|
3831
|
+
} catch (e) {
|
|
3832
|
+
replayPersisted.sidecar_signed = false;
|
|
3833
|
+
replayPersisted.sidecar_sign_error = String((e && e.message) || e);
|
|
3834
|
+
}
|
|
3835
|
+
}
|
|
3771
3836
|
|
|
3772
3837
|
emit({
|
|
3773
3838
|
ok: true,
|
|
@@ -3782,7 +3847,7 @@ function cmdReattest(runner, args, runOpts, pretty) {
|
|
|
3782
3847
|
replayed_at: replayedAt,
|
|
3783
3848
|
replay_classification: replay.phases && replay.phases.detect && replay.phases.detect.classification,
|
|
3784
3849
|
replay_rwep_adjusted: replay.phases && replay.phases.analyze && replay.phases.analyze.rwep && replay.phases.analyze.rwep.adjusted,
|
|
3785
|
-
//
|
|
3850
|
+
// Persist the sidecar verify result + the force-replay flag so the
|
|
3786
3851
|
// audit trail records whether the replay was authenticated input.
|
|
3787
3852
|
sidecar_verify: verify,
|
|
3788
3853
|
// emit a one-token classification label alongside the
|
|
@@ -3800,8 +3865,8 @@ function cmdReattest(runner, args, runOpts, pretty) {
|
|
|
3800
3865
|
// 'no-public-key' — infra-missing (operator-side keys/public.pem absent)
|
|
3801
3866
|
sidecar_verify_class: sidecarVerifyClass,
|
|
3802
3867
|
force_replay: forceReplay,
|
|
3803
|
-
//
|
|
3804
|
-
//
|
|
3868
|
+
// Surface the persisted replay-record path (or persistence failure
|
|
3869
|
+
// reason) so an auditor reading the CLI response can locate the
|
|
3805
3870
|
// on-disk artifact without re-deriving the filename.
|
|
3806
3871
|
replay_persisted: replayPersisted,
|
|
3807
3872
|
}, pretty);
|
|
@@ -3819,7 +3884,7 @@ function classifySidecarVerify(verify) {
|
|
|
3819
3884
|
if (verify.signed && !verify.verified) return "tampered";
|
|
3820
3885
|
if (verify.tamper_class === "sidecar-corrupt") return "sidecar-corrupt";
|
|
3821
3886
|
if (verify.tamper_class === "unsigned-substitution") return "unsigned-substitution";
|
|
3822
|
-
//
|
|
3887
|
+
// `algorithm-unsupported` is its own class label so log scrapers /
|
|
3823
3888
|
// dashboards can filter downgrade-bait events without parsing the reason.
|
|
3824
3889
|
if (verify.tamper_class === "algorithm-unsupported") return "algorithm-unsupported";
|
|
3825
3890
|
if (typeof verify.reason === "string" && verify.reason.startsWith("attestation explicitly unsigned")) return "explicitly-unsigned";
|
|
@@ -3853,12 +3918,12 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
3853
3918
|
if (!sessionId) {
|
|
3854
3919
|
return emitError(`attest ${subverb}: missing <session-id> positional argument.`, null, pretty);
|
|
3855
3920
|
}
|
|
3856
|
-
//
|
|
3857
|
-
//
|
|
3858
|
-
//
|
|
3859
|
-
//
|
|
3860
|
-
//
|
|
3861
|
-
//
|
|
3921
|
+
// Distinguish "validation rejected" from "valid format but not found".
|
|
3922
|
+
// findSessionDir() returns null for BOTH (regex-rejected ids collapse to
|
|
3923
|
+
// the "no session dir" message), which gives operators a misleading
|
|
3924
|
+
// error — a string with `..` or `/` looks to them like an existing-
|
|
3925
|
+
// session lookup that failed, not a refusal. Call the same validator
|
|
3926
|
+
// up front; emit its specific message when it throws.
|
|
3862
3927
|
try { validateSessionIdForRead(sessionId); }
|
|
3863
3928
|
catch (e) {
|
|
3864
3929
|
return emitError(`attest ${subverb}: ${e.message}`, { session_id_input: typeof sessionId === "string" ? sessionId.slice(0, 80) : typeof sessionId }, pretty);
|
|
@@ -3869,13 +3934,24 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
3869
3934
|
}
|
|
3870
3935
|
|
|
3871
3936
|
const files = fs.readdirSync(dir).filter(f => f.endsWith(".json") && !f.endsWith(".sig"));
|
|
3872
|
-
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
|
|
3937
|
+
// Partition session-dir JSON files by parsed `kind` field. Replay records
|
|
3938
|
+
// (written by `cmdReattest`) live alongside attestations under the same
|
|
3939
|
+
// session directory but represent audit-trail entries, not separate
|
|
3940
|
+
// sessions. Gate on the parsed payload — not filename prefix — so a
|
|
3941
|
+
// renamed file cannot smuggle a replay into the attestations[] list.
|
|
3942
|
+
const attestations = [];
|
|
3943
|
+
const replays = [];
|
|
3944
|
+
for (const f of files) {
|
|
3945
|
+
let parsed;
|
|
3946
|
+
try { parsed = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")); }
|
|
3947
|
+
catch { continue; }
|
|
3948
|
+
if (!parsed) continue;
|
|
3949
|
+
if (parsed.kind === "replay") replays.push(parsed);
|
|
3950
|
+
else attestations.push(parsed);
|
|
3951
|
+
}
|
|
3876
3952
|
|
|
3877
3953
|
if (subverb === "show") {
|
|
3878
|
-
emit({ session_id: sessionId, attestations }, pretty);
|
|
3954
|
+
emit({ session_id: sessionId, attestations, attestation_replays: replays }, pretty);
|
|
3879
3955
|
return;
|
|
3880
3956
|
}
|
|
3881
3957
|
|
|
@@ -3890,10 +3966,22 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
3890
3966
|
return emitError(`attest diff --against ${args.against}: no session dir found.`, null, pretty);
|
|
3891
3967
|
}
|
|
3892
3968
|
const otherFiles = fs.readdirSync(otherDir).filter(f => f.endsWith(".json") && !f.endsWith(".sig"));
|
|
3893
|
-
|
|
3969
|
+
// Skip replay-record files: those carry `kind: 'replay'` and are
|
|
3970
|
+
// audit-trail entries rather than attestations. Without this gate
|
|
3971
|
+
// a replay file sorted ahead of attestation.json would shadow the
|
|
3972
|
+
// real attestation in the diff.
|
|
3973
|
+
let other = null;
|
|
3974
|
+
for (const f of otherFiles) {
|
|
3975
|
+
try {
|
|
3976
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(otherDir, f), "utf8"));
|
|
3977
|
+
if (parsed && parsed.kind === "replay") continue;
|
|
3978
|
+
other = parsed;
|
|
3979
|
+
break;
|
|
3980
|
+
} catch { /* skip malformed */ }
|
|
3981
|
+
}
|
|
3982
|
+
if (!other) {
|
|
3894
3983
|
return emitError(`attest diff --against ${args.against}: no attestations under that session id.`, null, pretty);
|
|
3895
3984
|
}
|
|
3896
|
-
const other = JSON.parse(fs.readFileSync(path.join(otherDir, otherFiles[0]), "utf8"));
|
|
3897
3985
|
const self = attestations[0];
|
|
3898
3986
|
emit({
|
|
3899
3987
|
verb: "attest diff",
|
|
@@ -3987,10 +4075,10 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
3987
4075
|
}
|
|
3988
4076
|
return { file: f, signed: false, verified: false, reason: "attestation explicitly unsigned (no private key when written)" };
|
|
3989
4077
|
}
|
|
3990
|
-
//
|
|
3991
|
-
//
|
|
3992
|
-
// tamper-class
|
|
3993
|
-
// crypto.verify with Ed25519 defaults, producing either an opaque
|
|
4078
|
+
// Strict algorithm check (mirrors verifyAttestationSidecar). Anything
|
|
4079
|
+
// that isn't exactly "Ed25519" or "unsigned" is refused as
|
|
4080
|
+
// tamper-class; null / "RSA-PSS" / arrays would otherwise fall through
|
|
4081
|
+
// to crypto.verify with Ed25519 defaults, producing either an opaque
|
|
3994
4082
|
// verify-throw or a downgrade-bait acceptance path.
|
|
3995
4083
|
if (sigDoc.algorithm !== "Ed25519") {
|
|
3996
4084
|
return {
|
|
@@ -4002,8 +4090,8 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
4002
4090
|
};
|
|
4003
4091
|
}
|
|
4004
4092
|
if (!pubKey) return { file: f, signed: true, verified: false, reason: "no public key at keys/public.pem to verify against" };
|
|
4005
|
-
//
|
|
4006
|
-
//
|
|
4093
|
+
// Normalize before crypto.verify — mirrors the signer path so the
|
|
4094
|
+
// verify pair is byte-stable across CRLF / BOM churn.
|
|
4007
4095
|
const rawContent = fs.readFileSync(path.join(dir, f), "utf8");
|
|
4008
4096
|
const content = normalizeAttestationBytes(rawContent);
|
|
4009
4097
|
try {
|
|
@@ -4015,25 +4103,22 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
4015
4103
|
return { file: f, signed: true, verified: false, reason: `verify error: ${e.message}` };
|
|
4016
4104
|
}
|
|
4017
4105
|
});
|
|
4018
|
-
//
|
|
4019
|
-
//
|
|
4020
|
-
//
|
|
4021
|
-
//
|
|
4022
|
-
// signal even when an attestation had been forged. emit()'s ok:false
|
|
4023
|
-
// → exitCode = 1 auto-promotion would stop at 1; tamper is distinct
|
|
4024
|
-
// from generic failure, so explicitly raise to 6 (cmdReattest's code).
|
|
4106
|
+
// When ANY result is signed-but-failed-verify, surface ok:false AND
|
|
4107
|
+
// set exit 6 for parity with cmdReattest's TAMPERED code. The
|
|
4108
|
+
// ok:false → exitCode = 1 auto-promotion would stop at 1; tamper is
|
|
4109
|
+
// distinct from generic failure, so explicitly raise to 6.
|
|
4025
4110
|
//
|
|
4026
|
-
//
|
|
4027
|
-
//
|
|
4028
|
-
//
|
|
4029
|
-
//
|
|
4030
|
-
//
|
|
4111
|
+
// The tamper predicate covers every tamper_class variant — bare
|
|
4112
|
+
// `r.signed && !r.verified` would miss (a) corrupt-JSON sidecars
|
|
4113
|
+
// (signed:false) and (b) "unsigned" sidecar substitution on hosts with
|
|
4114
|
+
// a private key (signed:false). Both are tamper-class events and must
|
|
4115
|
+
// promote to exit 6.
|
|
4031
4116
|
const tampered = results.some(r =>
|
|
4032
4117
|
(r.signed && !r.verified)
|
|
4033
4118
|
|| r.tamper_class === "sidecar-corrupt"
|
|
4034
4119
|
|| r.tamper_class === "unsigned-substitution"
|
|
4035
|
-
//
|
|
4036
|
-
//
|
|
4120
|
+
// A sidecar whose algorithm field is not "Ed25519" or "unsigned" is
|
|
4121
|
+
// a downgrade-bait substitution; promote to exit 6.
|
|
4037
4122
|
|| r.tamper_class === "algorithm-unsupported"
|
|
4038
4123
|
);
|
|
4039
4124
|
const body = { verb: "attest verify", session_id: sessionId, results };
|
|
@@ -4836,8 +4921,9 @@ function cmdListAttestations(runner, args, runOpts, pretty) {
|
|
|
4836
4921
|
// Prior behavior silently accepted any string and lexically compared to
|
|
4837
4922
|
// captured_at, producing 0-result or full-result depending on the string.
|
|
4838
4923
|
if (args.since != null) {
|
|
4839
|
-
//
|
|
4840
|
-
// otherwise parse as the year 1999 and silently filter wrong
|
|
4924
|
+
// ISO-8601 shape regex BEFORE Date.parse — bare integers like "99"
|
|
4925
|
+
// would otherwise parse as the year 1999 and silently filter wrong
|
|
4926
|
+
// eras.
|
|
4841
4927
|
const sinceErr = validateIsoSince(args.since);
|
|
4842
4928
|
if (sinceErr) return emitError(`attest list: ${sinceErr}`, null, pretty);
|
|
4843
4929
|
}
|
|
@@ -4858,6 +4944,11 @@ function cmdListAttestations(runner, args, runOpts, pretty) {
|
|
|
4858
4944
|
for (const f of files) {
|
|
4859
4945
|
try {
|
|
4860
4946
|
const j = JSON.parse(fs.readFileSync(path.join(sdir, f), "utf8"));
|
|
4947
|
+
// replay-<isoZ>.json records share the session dir with
|
|
4948
|
+
// attestation.json but are not separate sessions. Gate on the
|
|
4949
|
+
// parsed `kind` field rather than filename so a rename cannot
|
|
4950
|
+
// smuggle a replay record into the listing.
|
|
4951
|
+
if (j && j.kind === "replay") continue;
|
|
4861
4952
|
// v0.12.14: normalized array-set filter (see top of fn).
|
|
4862
4953
|
if (playbookFilter && !playbookFilter.has(j.playbook_id)) continue;
|
|
4863
4954
|
if (args.since && (j.captured_at || "") < args.since) continue;
|
|
@@ -4935,7 +5026,7 @@ function cmdAiRun(runner, args, runOpts, pretty) {
|
|
|
4935
5026
|
directPhase = runner.direct(playbookId, directiveId);
|
|
4936
5027
|
lookPhase = runner.look(playbookId, directiveId, runOpts);
|
|
4937
5028
|
} catch (e) {
|
|
4938
|
-
//
|
|
5029
|
+
// process.exit(1) immediately after a stdout write can
|
|
4939
5030
|
// truncate buffered output under piped consumers (same class as v0.11.10
|
|
4940
5031
|
// #100). Use exitCode+return so the JSONL error frame drains. Also write
|
|
4941
5032
|
// the framed error event so the stdout-only JSONL contract holds — host
|
|
@@ -4984,7 +5075,7 @@ function cmdAiRun(runner, args, runOpts, pretty) {
|
|
|
4984
5075
|
try { payload = readEvidence(args.evidence); }
|
|
4985
5076
|
catch (e) { return emitError(`ai-run: failed to read --evidence: ${e.message}`, null, pretty); }
|
|
4986
5077
|
} else if (hasReadableStdin()) {
|
|
4987
|
-
//
|
|
5078
|
+
// hasReadableStdin() probes via fstat before falling into
|
|
4988
5079
|
// readFileSync(0). Wrapped-stdin test harnesses (isTTY===undefined,
|
|
4989
5080
|
// size===0) would otherwise hang here.
|
|
4990
5081
|
// Drain stdin for any evidence event.
|
|
@@ -5478,9 +5569,9 @@ function cmdCi(runner, args, runOpts, pretty) {
|
|
|
5478
5569
|
const results = [];
|
|
5479
5570
|
let fail = false;
|
|
5480
5571
|
let failReasons = [];
|
|
5481
|
-
//
|
|
5482
|
-
// exit code can distinguish "detected/escalated" (2) from "regulatory
|
|
5483
|
-
// running, operator must notify" (5).
|
|
5572
|
+
// Track jurisdiction-clock signals separately from generic FAIL so the
|
|
5573
|
+
// exit code can distinguish "detected/escalated" (2) from "regulatory
|
|
5574
|
+
// clock running, operator must notify" (5).
|
|
5484
5575
|
let clockStartedFail = false;
|
|
5485
5576
|
let clockStartedReasons = [];
|
|
5486
5577
|
|
|
@@ -5534,11 +5625,10 @@ function cmdCi(runner, args, runOpts, pretty) {
|
|
|
5534
5625
|
failReasons.push(`${id}: rwep_delta=${rwepAdj - rwepBase} >= cap=${cap} (classification=inconclusive; operator evidence raised the score)`);
|
|
5535
5626
|
}
|
|
5536
5627
|
if (blockOnClock && clockStarted) {
|
|
5537
|
-
//
|
|
5538
|
-
//
|
|
5539
|
-
// detected" from "regulatory
|
|
5540
|
-
//
|
|
5541
|
-
// FAIL but not BLOCKED.
|
|
5628
|
+
// Separate "clock started" from generic FAIL: exit 5 (CLOCK_STARTED)
|
|
5629
|
+
// is selected below, taking precedence over FAIL but not BLOCKED, so
|
|
5630
|
+
// operators can distinguish "playbook detected" from "regulatory
|
|
5631
|
+
// clock running."
|
|
5542
5632
|
clockStartedFail = true;
|
|
5543
5633
|
clockStartedReasons.push(`${id}: jurisdiction clock started`);
|
|
5544
5634
|
}
|
|
@@ -5558,7 +5648,7 @@ function cmdCi(runner, args, runOpts, pretty) {
|
|
|
5558
5648
|
const inconclusiveCount = results.filter(r => r.phases?.detect?.classification === "inconclusive").length;
|
|
5559
5649
|
const totalForVerdict = results.length;
|
|
5560
5650
|
const noEvidenceAllInconclusive = !suppliedEvidenceForVerdict && totalForVerdict > 0 && inconclusiveCount === totalForVerdict;
|
|
5561
|
-
//
|
|
5651
|
+
// Precedence: BLOCKED > CLOCK_STARTED > FAIL > NO_EVIDENCE > PASS.
|
|
5562
5652
|
// CLOCK_STARTED outranks FAIL because the operator explicitly opted into
|
|
5563
5653
|
// the clock gate (--block-on-jurisdiction-clock); when that gate fires,
|
|
5564
5654
|
// they want the regulatory-deadline signal even if a detected finding
|
|
@@ -5614,11 +5704,11 @@ function cmdCi(runner, args, runOpts, pretty) {
|
|
|
5614
5704
|
.filter(n => n && n.clock_started_at != null).length,
|
|
5615
5705
|
framework_gap_rollup: frameworkGapRollup,
|
|
5616
5706
|
framework_gap_count: frameworkGapRollup.length,
|
|
5617
|
-
//
|
|
5618
|
-
// buildJurisdictionClockRollup.
|
|
5619
|
-
// one notification entry per contributing playbook (often 8+)
|
|
5620
|
-
// single notification per (jurisdiction, regulation,
|
|
5621
|
-
// window)
|
|
5707
|
+
// Dedupe jurisdiction-clock notifications across playbooks; see
|
|
5708
|
+
// buildJurisdictionClockRollup. Without this, multi-playbook ci runs
|
|
5709
|
+
// produce one notification entry per contributing playbook (often 8+)
|
|
5710
|
+
// when a single notification per (jurisdiction, regulation,
|
|
5711
|
+
// obligation, window) is the right shape.
|
|
5622
5712
|
jurisdiction_clock_rollup: buildJurisdictionClockRollup(results),
|
|
5623
5713
|
verdict: computedVerdict,
|
|
5624
5714
|
fail_reasons: failReasons,
|
|
@@ -5682,7 +5772,7 @@ function cmdCi(runner, args, runOpts, pretty) {
|
|
|
5682
5772
|
process.exitCode = 4;
|
|
5683
5773
|
return;
|
|
5684
5774
|
}
|
|
5685
|
-
//
|
|
5775
|
+
// Precedence: BLOCKED > CLOCK_STARTED > FAIL. The operator opted into
|
|
5686
5776
|
// --block-on-jurisdiction-clock; when a clock fires, that's the gate
|
|
5687
5777
|
// result they want to see at the exit-code layer. Per-playbook detected
|
|
5688
5778
|
// findings remain in the body for them to investigate.
|