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