@blamejs/exceptd-skills 0.18.6 → 0.18.8
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 +42 -0
- package/bin/exceptd.js +364 -119
- package/data/_indexes/_meta.json +22 -3
- package/data/cve-catalog.json +25 -0
- package/data/playbooks/framework.json +2 -2
- package/data/playbooks/post-quantum-migration.json +1 -1
- package/lib/auto-discovery.js +30 -10
- package/lib/collectors/ai-api.js +9 -2
- package/lib/collectors/cicd-pipeline-compromise.js +24 -5
- package/lib/collectors/cred-stores.js +17 -4
- package/lib/collectors/crypto.js +9 -2
- package/lib/collectors/hardening.js +9 -2
- package/lib/collectors/library-author.js +29 -5
- package/lib/collectors/mcp.js +9 -2
- package/lib/collectors/runtime.js +9 -2
- package/lib/collectors/sbom.js +28 -15
- package/lib/collectors/scan-excludes.js +25 -0
- package/lib/collectors/secrets.js +40 -4
- package/lib/cve-curation.js +84 -8
- package/lib/lint-skills.js +75 -3
- package/lib/playbook-runner.js +443 -50
- package/lib/prefetch.js +22 -2
- package/lib/refresh-external.js +32 -1
- package/lib/refresh-network.js +235 -26
- package/lib/schemas/cve-catalog.schema.json +5 -0
- package/lib/scoring.js +141 -21
- package/lib/sign.js +107 -29
- package/lib/source-advisories.js +23 -5
- package/lib/source-ghsa.js +25 -1
- package/lib/source-osv.js +26 -1
- package/lib/upstream-check.js +1 -1
- package/lib/validate-cve-catalog.js +30 -4
- package/lib/validate-indexes.js +135 -29
- package/lib/validate-playbooks.js +19 -7
- package/lib/validate-vendor.js +69 -8
- package/lib/verify.js +23 -6
- package/manifest.json +53 -53
- package/orchestrator/dispatcher.js +14 -3
- package/orchestrator/index.js +100 -20
- package/orchestrator/scanner.js +8 -0
- package/package.json +1 -1
- package/sbom.cdx.json +130 -130
- package/scripts/audit-cross-skill.js +1 -1
- package/scripts/bootstrap.js +1 -0
- package/scripts/build-indexes.js +84 -11
- package/scripts/check-agents-md-collectors.js +41 -13
- package/scripts/check-changelog-extract.js +4 -4
- package/scripts/check-codebase-patterns.js +19 -5
- package/scripts/check-manifest-snapshot.js +74 -30
- package/scripts/check-sbom-currency.js +25 -5
- package/scripts/check-test-count.js +26 -7
- package/scripts/check-test-coverage.js +44 -4
- package/scripts/check-version-tags.js +27 -8
- package/scripts/predeploy.js +1 -1
- package/scripts/refresh-manifest-snapshot.js +14 -4
- package/scripts/refresh-reverse-refs.js +7 -1
- package/scripts/refresh-sbom.js +1 -1
- package/scripts/release.js +3 -3
- package/scripts/run-e2e-scenarios.js +18 -8
- package/scripts/validate-vendor-online.js +28 -2
- package/scripts/verify-shipped-tarball.js +65 -6
- package/sources/validators/cve-validator.js +17 -1
- package/vendor/blamejs/_PROVENANCE.json +4 -2
package/bin/exceptd.js
CHANGED
|
@@ -60,7 +60,7 @@ const PKG_ROOT = path.resolve(__dirname, "..");
|
|
|
60
60
|
// constants so a new verb cannot regress the exit-code contract by typo,
|
|
61
61
|
// and so the help-text dump (`doctor --exit-codes`) and the runtime
|
|
62
62
|
// behavior share the same source of truth.
|
|
63
|
-
const { EXIT_CODES, listExitCodes } = require(path.join(PKG_ROOT, "lib", "exit-codes.js"));
|
|
63
|
+
const { EXIT_CODES, listExitCodes, safeExit } = require(path.join(PKG_ROOT, "lib", "exit-codes.js"));
|
|
64
64
|
const { validateIdComponent } = require(path.join(PKG_ROOT, "lib", "id-validation.js"));
|
|
65
65
|
const { suggestFlag, flagsFor, VERB_FLAG_ALLOWLIST } = require(path.join(PKG_ROOT, "lib", "flag-suggest.js"));
|
|
66
66
|
const codepointClass = require(path.join(PKG_ROOT, "vendor", "blamejs", "codepoint-class.js"));
|
|
@@ -612,7 +612,7 @@ function main() {
|
|
|
612
612
|
}
|
|
613
613
|
if (cmd === "version" || cmd === "--version" || cmd === "-v") {
|
|
614
614
|
process.stdout.write(readPkgVersion() + "\n");
|
|
615
|
-
|
|
615
|
+
safeExit(EXIT_CODES.SUCCESS); return;
|
|
616
616
|
}
|
|
617
617
|
if (cmd === "path") {
|
|
618
618
|
// v0.11.14 (#130): `path copy` was silently consuming the `copy` arg and
|
|
@@ -638,14 +638,14 @@ function main() {
|
|
|
638
638
|
if (copied) {
|
|
639
639
|
process.stderr.write(`[exceptd path] copied to clipboard: ${PKG_ROOT}\n`);
|
|
640
640
|
process.stdout.write(PKG_ROOT + "\n");
|
|
641
|
-
|
|
641
|
+
safeExit(EXIT_CODES.SUCCESS); return;
|
|
642
642
|
}
|
|
643
643
|
process.stderr.write(`[exceptd path] copy: no clipboard tool available (tried: ${tried.join(", ")}). Path printed to stdout instead.\n`);
|
|
644
644
|
process.stdout.write(PKG_ROOT + "\n");
|
|
645
|
-
|
|
645
|
+
safeExit(EXIT_CODES.SUCCESS); return;
|
|
646
646
|
}
|
|
647
647
|
process.stdout.write(PKG_ROOT + "\n");
|
|
648
|
-
|
|
648
|
+
safeExit(EXIT_CODES.SUCCESS); return;
|
|
649
649
|
}
|
|
650
650
|
|
|
651
651
|
// v0.13.0: hard-refuse the v0.10.x legacy verbs that were
|
|
@@ -1157,9 +1157,9 @@ function hasReadableStdin() {
|
|
|
1157
1157
|
* on failure so the caller can wrap it with its own verb prefix.
|
|
1158
1158
|
*/
|
|
1159
1159
|
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/;
|
|
1160
|
-
function validateIsoSince(raw) {
|
|
1160
|
+
function validateIsoSince(raw, flagName = "--since") {
|
|
1161
1161
|
if (typeof raw !== "string" || !ISO_DATE_RE.test(raw) || isNaN(Date.parse(raw))) {
|
|
1162
|
-
return
|
|
1162
|
+
return `${flagName} 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)}`;
|
|
1163
1163
|
}
|
|
1164
1164
|
return null;
|
|
1165
1165
|
}
|
|
@@ -1207,7 +1207,7 @@ function detectVexShape(doc) {
|
|
|
1207
1207
|
// OpenVEX: @context starts with https://openvex.dev AND statements[]
|
|
1208
1208
|
const ctx = doc["@context"];
|
|
1209
1209
|
const ctxStr = Array.isArray(ctx) ? ctx[0] : ctx;
|
|
1210
|
-
if (typeof ctxStr === "string" && ctxStr.startsWith("https://openvex.dev") && Array.isArray(doc.statements)) {
|
|
1210
|
+
if (typeof ctxStr === "string" && ctxStr.startsWith("https://openvex.dev/") && Array.isArray(doc.statements)) {
|
|
1211
1211
|
return { ok: true, detected: "openvex", top_level_keys: keys };
|
|
1212
1212
|
}
|
|
1213
1213
|
// Common false-positive shapes — give the operator a hint.
|
|
@@ -1399,13 +1399,20 @@ function dispatchPlaybook(cmd, argv) {
|
|
|
1399
1399
|
);
|
|
1400
1400
|
process.env.EXCEPTD_AIR_GAP_NOTICE_SHOWN = "1";
|
|
1401
1401
|
}
|
|
1402
|
-
if (args["session-id"]) {
|
|
1402
|
+
if (args["session-id"] !== undefined) {
|
|
1403
1403
|
// --session-id is a filesystem path component (resolves to
|
|
1404
1404
|
// .exceptd/attestations/<id>/attestation.json). Operator-supplied input
|
|
1405
1405
|
// with `..` or path separators escapes the attestation root. Route
|
|
1406
1406
|
// through the shared validateIdComponent('session') helper so the regex
|
|
1407
1407
|
// + all-dots refusal stay aligned with persistAttestation /
|
|
1408
1408
|
// validateSessionIdForRead.
|
|
1409
|
+
//
|
|
1410
|
+
// Presence-gated (`!== undefined`), not truthy-gated: `--session-id ""`
|
|
1411
|
+
// / `--session-id=` carry an explicit empty value the operator meant to
|
|
1412
|
+
// pin. A truthy gate skips the validator for "", silently substituting a
|
|
1413
|
+
// random id and discarding the operator's intent. validateIdComponent
|
|
1414
|
+
// rejects "" with "must not be empty", matching the --operator empty
|
|
1415
|
+
// refusal below.
|
|
1409
1416
|
const sid = args["session-id"];
|
|
1410
1417
|
const r = validateIdComponent(sid, "session");
|
|
1411
1418
|
if (!r.ok) {
|
|
@@ -1462,9 +1469,11 @@ function dispatchPlaybook(cmd, argv) {
|
|
|
1462
1469
|
}
|
|
1463
1470
|
runOpts.session_key = args["session-key"];
|
|
1464
1471
|
}
|
|
1465
|
-
if (args.mode) {
|
|
1472
|
+
if (args.mode !== undefined) {
|
|
1466
1473
|
// Bug #32: validate --mode against the accepted set. Previously
|
|
1467
|
-
// `--mode garbage` was silently accepted.
|
|
1474
|
+
// `--mode garbage` was silently accepted. Gate on `!== undefined` (not
|
|
1475
|
+
// truthiness) so `--mode ""` is also rejected by the set check below rather
|
|
1476
|
+
// than silently slipping past as a falsy value.
|
|
1468
1477
|
const VALID_MODES = ["self_service", "authorized_pentest", "ir_response", "ctf", "research", "compliance_audit"];
|
|
1469
1478
|
if (!VALID_MODES.includes(args.mode)) {
|
|
1470
1479
|
// v0.13.2: did-you-mean on flag-value typos (Levenshtein ≤ 2).
|
|
@@ -2497,6 +2506,11 @@ async function cmdCollect(runner, args, runOpts, pretty) {
|
|
|
2497
2506
|
// --cwd <path> overrides process.cwd(). Validated as an existing
|
|
2498
2507
|
// directory; non-existent / non-directory cwd is operator error.
|
|
2499
2508
|
let cwd = process.cwd();
|
|
2509
|
+
// An explicit empty value (`--cwd ""`) would otherwise be falsy and silently
|
|
2510
|
+
// scan process.cwd() — the wrong directory — reported as a successful run.
|
|
2511
|
+
if (args.cwd === "") {
|
|
2512
|
+
return emitError(`collect: --cwd was given an empty value; pass an existing directory path`, { verb: "collect", playbook_id: playbookId }, pretty);
|
|
2513
|
+
}
|
|
2500
2514
|
if (args.cwd) {
|
|
2501
2515
|
const resolved = path.resolve(String(args.cwd));
|
|
2502
2516
|
let stat;
|
|
@@ -2830,7 +2844,10 @@ function cmdLint(runner, args, runOpts, pretty) {
|
|
|
2830
2844
|
|
|
2831
2845
|
function cmdBrief(runner, args, runOpts, pretty) {
|
|
2832
2846
|
const playbookId = args._[0];
|
|
2833
|
-
|
|
2847
|
+
// Preserve an explicit empty string (don't coerce "" -> null) so the
|
|
2848
|
+
// accepted-set check below rejects `--phase ""` instead of silently treating
|
|
2849
|
+
// it as "no filter" and emitting the full brief. Only an OMITTED flag is null.
|
|
2850
|
+
const onlyPhase = args.phase === undefined ? null : args.phase;
|
|
2834
2851
|
|
|
2835
2852
|
// v0.12.9 (P2 #7 from production smoke): refuse garbage values to --phase.
|
|
2836
2853
|
// Pre-v0.12.9 `brief secrets --phase foo` silently accepted any string and
|
|
@@ -2953,6 +2970,11 @@ function cmdVerifyAttestation(runner, args, runOpts, pretty) {
|
|
|
2953
2970
|
}
|
|
2954
2971
|
|
|
2955
2972
|
function cmdPlan(runner, args, runOpts, pretty) {
|
|
2973
|
+
// Reject an empty --playbook value rather than letting the truthy gate below
|
|
2974
|
+
// coerce it to null and silently plan across ALL playbooks (wrong scope).
|
|
2975
|
+
if (args.playbook === "" || (Array.isArray(args.playbook) && args.playbook.some(p => p === ""))) {
|
|
2976
|
+
return emitError("plan: --playbook was given an empty value; pass a playbook id, or omit --playbook to plan across all.", { verb: "plan", flag: "playbook" }, pretty);
|
|
2977
|
+
}
|
|
2956
2978
|
let playbookIds = args.playbook
|
|
2957
2979
|
? (Array.isArray(args.playbook) ? args.playbook : [args.playbook])
|
|
2958
2980
|
: null;
|
|
@@ -3358,6 +3380,12 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
3358
3380
|
// first, then falls back to a strict isTTY===false check only on Windows
|
|
3359
3381
|
// (where fstat on a pipe is unreliable). MSYS-bash on win32 reports
|
|
3360
3382
|
// isTTY === false for genuine piped input, so that path still works.
|
|
3383
|
+
// An explicit empty value (`--evidence ""`) is operator error: it would
|
|
3384
|
+
// otherwise be falsy and silently produce a no-evidence "not_detected" run at
|
|
3385
|
+
// exit 0, masking the fact that the intended evidence never loaded.
|
|
3386
|
+
if (args.evidence === "") {
|
|
3387
|
+
return emitError("run: --evidence was given an empty value; pass a file path, '-' for stdin, or omit --evidence for a no-evidence run", { verb: "run" }, pretty);
|
|
3388
|
+
}
|
|
3361
3389
|
const autoStdin = !args.evidence && hasReadableStdin();
|
|
3362
3390
|
if (autoStdin) {
|
|
3363
3391
|
args.evidence = "-";
|
|
@@ -4168,6 +4196,20 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
|
4168
4196
|
runOpts.session_id = sessionId;
|
|
4169
4197
|
|
|
4170
4198
|
let bundle = {};
|
|
4199
|
+
// An explicit empty value (--evidence "" / --evidence= / an unset shell
|
|
4200
|
+
// variable) is falsy, so the truthiness-gated reads below would skip
|
|
4201
|
+
// entirely, the bundle would stay {}, every playbook would run with no
|
|
4202
|
+
// evidence, and the contract would report a clean not_detected at exit 0 —
|
|
4203
|
+
// a false-clean that hides the fact the operator's intended evidence never
|
|
4204
|
+
// loaded. Mirror the single-playbook run / ci empty-value guards: refuse
|
|
4205
|
+
// the empty value loudly rather than running a vacuous contract. Presence,
|
|
4206
|
+
// not truthiness, is the test.
|
|
4207
|
+
if (args.evidence === "") {
|
|
4208
|
+
return emitError("run: --evidence was given an empty value; pass a file path, '-' for stdin, or omit --evidence for a no-evidence run", { verb: "run", flag: "evidence" }, pretty);
|
|
4209
|
+
}
|
|
4210
|
+
if (args["evidence-dir"] === "") {
|
|
4211
|
+
return emitError("run: --evidence-dir was given an empty value; pass an existing directory, or omit --evidence-dir", { verb: "run", flag: "evidence-dir" }, pretty);
|
|
4212
|
+
}
|
|
4171
4213
|
if (args.evidence) {
|
|
4172
4214
|
try { bundle = readEvidence(args.evidence); } catch (e) {
|
|
4173
4215
|
return emitError(`run: failed to read evidence bundle: ${e.message}`, { evidence: args.evidence }, pretty);
|
|
@@ -4176,11 +4218,12 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
|
4176
4218
|
// --evidence-dir <dir>: each <playbook-id>.json under the directory is read
|
|
4177
4219
|
// as that playbook's submission. Lets operators wire up one cron job that
|
|
4178
4220
|
// collects per-playbook evidence into a directory, then runs the whole
|
|
4179
|
-
// contract in one pass.
|
|
4221
|
+
// contract in one pass. The empty-string form is already refused above; the
|
|
4222
|
+
// truthy gate here only ever sees a non-empty directory path.
|
|
4180
4223
|
if (args["evidence-dir"]) {
|
|
4181
4224
|
const dir = args["evidence-dir"];
|
|
4182
|
-
if (typeof dir !== "string"
|
|
4183
|
-
return emitError("run: --evidence-dir must be a
|
|
4225
|
+
if (typeof dir !== "string") {
|
|
4226
|
+
return emitError("run: --evidence-dir must be a string.", null, pretty);
|
|
4184
4227
|
}
|
|
4185
4228
|
if (!fs.existsSync(dir)) {
|
|
4186
4229
|
return emitError(`run: --evidence-dir ${dir} does not exist.`, null, pretty);
|
|
@@ -4210,55 +4253,83 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
|
4210
4253
|
return emitError(`run: --evidence-dir entry ${f} resolves outside the directory; refusing.`, null, pretty);
|
|
4211
4254
|
}
|
|
4212
4255
|
// The path.resolve check above only catches `..` traversal in the
|
|
4213
|
-
// joined path;
|
|
4214
|
-
//
|
|
4215
|
-
//
|
|
4216
|
-
//
|
|
4217
|
-
//
|
|
4218
|
-
//
|
|
4219
|
-
|
|
4220
|
-
|
|
4221
|
-
|
|
4222
|
-
|
|
4223
|
-
|
|
4224
|
-
|
|
4225
|
-
|
|
4226
|
-
|
|
4227
|
-
|
|
4228
|
-
|
|
4229
|
-
|
|
4230
|
-
|
|
4231
|
-
|
|
4232
|
-
// ordinary directories), bypassing the symlink refusal above. Use
|
|
4233
|
-
// realpathSync to resolve the entry and confirm it still lives under
|
|
4234
|
-
// the resolved evidence-dir — the realpath approach is portable
|
|
4235
|
-
// (catches POSIX symlinks too, defense in depth) and works regardless
|
|
4236
|
-
// of whether the OS exposes reparse-point bits.
|
|
4237
|
-
let realEntry;
|
|
4238
|
-
try { realEntry = fs.realpathSync(entryPath); }
|
|
4239
|
-
catch (e) {
|
|
4240
|
-
return emitError(`run: --evidence-dir entry ${f}: realpath failed: ${e.message}`, null, pretty);
|
|
4241
|
-
}
|
|
4242
|
-
if (realEntry !== entryPath && !realEntry.startsWith(resolvedDir + path.sep)) {
|
|
4243
|
-
return emitError(
|
|
4244
|
-
`run: --evidence-dir entry ${f} resolves outside the directory (junction / reparse-point / symlink target). Refusing.`,
|
|
4245
|
-
{ entry: f, resolved_to: realEntry },
|
|
4246
|
-
pretty
|
|
4247
|
-
);
|
|
4248
|
-
}
|
|
4249
|
-
// Hardlink defense in depth: no clean cross-platform refusal exists —
|
|
4250
|
-
// hardlinks are indistinguishable from regular files at the inode
|
|
4251
|
-
// level. Surface a stderr warning when nlink > 1 so the operator is
|
|
4252
|
-
// aware a second name may point at the same file. Not a refusal —
|
|
4253
|
-
// legitimate use cases (atomic rename, package-manager dedup) produce
|
|
4254
|
-
// nlink > 1 without malicious intent.
|
|
4255
|
-
if (lst.nlink > 1) {
|
|
4256
|
-
process.stderr.write(`[exceptd run --evidence-dir] WARNING: ${f} has nlink=${lst.nlink}; a hardlink to this file exists elsewhere on the filesystem. Hardlinks cannot be refused cross-platform — confirm the file content is what you expect.\n`);
|
|
4256
|
+
// joined path; reading the path would still follow symlinks, so a
|
|
4257
|
+
// `<pb-id>.json -> /etc/shadow` symlink inside the dir would slurp the
|
|
4258
|
+
// target. Rather than lstat/realpath the PATH and then re-open it
|
|
4259
|
+
// (a check-then-use TOCTOU window), open a single O_NOFOLLOW descriptor
|
|
4260
|
+
// FIRST and make every subsequent decision about that exact descriptor.
|
|
4261
|
+
// O_NOFOLLOW refuses a symlinked leaf at open (ELOOP) on POSIX; on
|
|
4262
|
+
// Windows it is a no-op, so the fstat type check + realpath gate below
|
|
4263
|
+
// carry the junction/symlink defense. Opening before any path stat means
|
|
4264
|
+
// the bytes read come from the inode we validated, not a path that could
|
|
4265
|
+
// be re-pointed between check and read.
|
|
4266
|
+
let efd;
|
|
4267
|
+
try {
|
|
4268
|
+
const O_NOFOLLOW = fs.constants.O_NOFOLLOW || 0;
|
|
4269
|
+
efd = fs.openSync(entryPath, fs.constants.O_RDONLY | O_NOFOLLOW);
|
|
4270
|
+
} catch (e) {
|
|
4271
|
+
const why = e.code === "ELOOP"
|
|
4272
|
+
? "symbolic link refused (symlinks bypass the directory-confinement check)"
|
|
4273
|
+
: e.message;
|
|
4274
|
+
return emitError(`run: --evidence-dir entry ${f}: open failed: ${why}`, { entry: f }, pretty);
|
|
4257
4275
|
}
|
|
4258
4276
|
try {
|
|
4259
|
-
|
|
4277
|
+
const st = fs.fstatSync(efd);
|
|
4278
|
+
if (!st.isFile()) {
|
|
4279
|
+
return emitError(`run: --evidence-dir entry ${f} is not a regular file; refusing (symlink / junction / dir / fifo bypass the directory-confinement check).`, { entry: f }, pretty);
|
|
4280
|
+
}
|
|
4281
|
+
// Hardlink defense in depth: no clean cross-platform refusal exists —
|
|
4282
|
+
// hardlinks are indistinguishable from regular files at the inode
|
|
4283
|
+
// level. Surface a stderr warning when nlink > 1 so the operator is
|
|
4284
|
+
// aware a second name may point at the same file. Not a refusal —
|
|
4285
|
+
// legitimate use cases (atomic rename, package-manager dedup) produce
|
|
4286
|
+
// nlink > 1 without malicious intent.
|
|
4287
|
+
if (st.nlink > 1) {
|
|
4288
|
+
process.stderr.write(`[exceptd run --evidence-dir] WARNING: ${f} has nlink=${st.nlink}; a hardlink to this file exists elsewhere on the filesystem. Hardlinks cannot be refused cross-platform — confirm the file content is what you expect.\n`);
|
|
4289
|
+
}
|
|
4290
|
+
// Read the bytes from `efd` FIRST — the descriptor was opened
|
|
4291
|
+
// O_NOFOLLOW and fstat-confirmed a regular file, so this reads the exact
|
|
4292
|
+
// inode we validated. Reading before the realpath gate (rather than
|
|
4293
|
+
// checking the path then reading) means there is no check-then-use
|
|
4294
|
+
// window at all; the containment gate below decides whether to USE the
|
|
4295
|
+
// bytes, and discards them otherwise.
|
|
4296
|
+
const raw = fs.readFileSync(efd, "utf8");
|
|
4297
|
+
// Symlink refusal. O_NOFOLLOW already rejects a symlinked leaf at open on
|
|
4298
|
+
// POSIX (ELOOP), but it is a no-op on Windows, where the open follows the
|
|
4299
|
+
// link. Detect and refuse a symlink explicitly via lstat — regardless of
|
|
4300
|
+
// where it points — so a symlinked entry is never accepted. This runs
|
|
4301
|
+
// AFTER the descriptor read (the bytes are dropped on refusal), so there
|
|
4302
|
+
// is no path-check-before-read TOCTOU window.
|
|
4303
|
+
let lst;
|
|
4304
|
+
try { lst = fs.lstatSync(entryPath); }
|
|
4305
|
+
catch (e) {
|
|
4306
|
+
return emitError(`run: --evidence-dir entry ${f}: lstat failed: ${e.message}`, null, pretty);
|
|
4307
|
+
}
|
|
4308
|
+
if (lst.isSymbolicLink()) {
|
|
4309
|
+
return emitError(`run: --evidence-dir entry ${f} is a symbolic link; refusing (symlinks bypass the directory-confinement check).`, { entry: f }, pretty);
|
|
4310
|
+
}
|
|
4311
|
+
// Windows directory junctions are reparse-point dirs that
|
|
4312
|
+
// lstat().isSymbolicLink() returns FALSE for, and O_NOFOLLOW is a
|
|
4313
|
+
// no-op there; realpath resolves the entry and confirms it still lives
|
|
4314
|
+
// under the resolved evidence-dir. A target that escapes the dir is
|
|
4315
|
+
// refused and the already-read bytes are dropped unused.
|
|
4316
|
+
let realEntry;
|
|
4317
|
+
try { realEntry = fs.realpathSync(entryPath); }
|
|
4318
|
+
catch (e) {
|
|
4319
|
+
return emitError(`run: --evidence-dir entry ${f}: realpath failed: ${e.message}`, null, pretty);
|
|
4320
|
+
}
|
|
4321
|
+
if (realEntry !== entryPath && !realEntry.startsWith(resolvedDir + path.sep)) {
|
|
4322
|
+
return emitError(
|
|
4323
|
+
`run: --evidence-dir entry ${f} resolves outside the directory (junction / reparse-point / symlink target). Refusing.`,
|
|
4324
|
+
{ entry: f, resolved_to: realEntry },
|
|
4325
|
+
pretty
|
|
4326
|
+
);
|
|
4327
|
+
}
|
|
4328
|
+
bundle[pbId] = JSON.parse(raw);
|
|
4260
4329
|
} catch (e) {
|
|
4261
|
-
return emitError(`run: failed to
|
|
4330
|
+
return emitError(`run: failed to read --evidence-dir entry ${f}: ${e.message}`, null, pretty);
|
|
4331
|
+
} finally {
|
|
4332
|
+
try { fs.closeSync(efd); } catch { /* already closed / invalid fd */ }
|
|
4262
4333
|
}
|
|
4263
4334
|
}
|
|
4264
4335
|
}
|
|
@@ -4951,7 +5022,11 @@ function walkAttestationDir(root, opts, candidates) {
|
|
|
4951
5022
|
// Gate on the parsed kind so a renamed file cannot smuggle a replay
|
|
4952
5023
|
// record into the listing.
|
|
4953
5024
|
if (j && j.kind === "replay") continue;
|
|
4954
|
-
|
|
5025
|
+
// Filter on an explicitly-supplied playbook id. `!= null` (not a
|
|
5026
|
+
// truthiness check) so a future caller that threads an empty-string
|
|
5027
|
+
// id can't silently disable the filter and widen the match to every
|
|
5028
|
+
// playbook; the only legitimate "no filter" is `null`/`undefined`.
|
|
5029
|
+
if (opts.playbookId != null && j.playbook_id !== opts.playbookId) continue;
|
|
4955
5030
|
if (opts.since && (j.captured_at || "") < opts.since) continue;
|
|
4956
5031
|
if (opts.excludeSessionId && sid === opts.excludeSessionId) continue;
|
|
4957
5032
|
candidates.push({ sessionId: sid, playbookId: j.playbook_id, file: p, parsed: j });
|
|
@@ -5154,12 +5229,29 @@ function cmdPruneAttestations(runner, args, runOpts, pretty) {
|
|
|
5154
5229
|
pretty,
|
|
5155
5230
|
);
|
|
5156
5231
|
}
|
|
5157
|
-
const isoErr = validateIsoSince(cutoffRaw);
|
|
5232
|
+
const isoErr = validateIsoSince(cutoffRaw, "--all-older-than");
|
|
5158
5233
|
if (isoErr) return emitError(`attest prune: ${isoErr}`, { verb: "attest prune" }, pretty);
|
|
5159
5234
|
const cutoffMs = Date.parse(cutoffRaw);
|
|
5160
5235
|
const dryRun = !!args["dry-run"];
|
|
5161
5236
|
|
|
5162
|
-
|
|
5237
|
+
// Canonicalize before dedup. A plain Set over the two root strings only
|
|
5238
|
+
// collapses byte-identical paths, so when the default root and the cwd-
|
|
5239
|
+
// relative root resolve to the SAME directory via different strings (e.g. a
|
|
5240
|
+
// relative EXCEPTD_HOME like `.exceptd`, or the home-mkdir-fail fallback),
|
|
5241
|
+
// both survive and every session under that dir is scanned twice — inflating
|
|
5242
|
+
// scanned/kept/pruned_count and double-listing each session in the preview.
|
|
5243
|
+
// realpathSync resolves symlinks + makes absolute for an existing dir; for a
|
|
5244
|
+
// not-yet-created root it throws, so fall back to path.resolve (absolute +
|
|
5245
|
+
// normalized). Mirrors the realpath confinement used at delete time below.
|
|
5246
|
+
const canonicalRoot = (p) => { try { return fs.realpathSync(p); } catch { return path.resolve(p); } };
|
|
5247
|
+
const roots = [];
|
|
5248
|
+
const seenRoots = new Set();
|
|
5249
|
+
for (const r of [resolveAttestationRoot(runOpts), path.join(process.cwd(), ".exceptd", "attestations")]) {
|
|
5250
|
+
const c = canonicalRoot(r);
|
|
5251
|
+
if (seenRoots.has(c)) continue;
|
|
5252
|
+
seenRoots.add(c);
|
|
5253
|
+
roots.push(c);
|
|
5254
|
+
}
|
|
5163
5255
|
const pruned = [];
|
|
5164
5256
|
let kept = 0;
|
|
5165
5257
|
let scanned = 0;
|
|
@@ -5197,16 +5289,29 @@ function cmdPruneAttestations(runner, args, runOpts, pretty) {
|
|
|
5197
5289
|
const ts = dateStr ? Date.parse(dateStr) : NaN;
|
|
5198
5290
|
if (!Number.isFinite(ts)) { kept++; continue; }
|
|
5199
5291
|
if (ts < cutoffMs) {
|
|
5200
|
-
|
|
5292
|
+
// Confinement: resolve and confirm sdir is a direct child of root
|
|
5293
|
+
// before it can be deleted, so a crafted session name can't escape the
|
|
5294
|
+
// root. Evaluate this in BOTH modes so the dry-run preview lists exactly
|
|
5295
|
+
// the set a real run will remove — a session the real run would refuse
|
|
5296
|
+
// (realpath escapes the root, or realpathSync throws) must not show up
|
|
5297
|
+
// as [would-delete]. realDir is reused for the rmSync below so the
|
|
5298
|
+
// delete and the gate operate on the same canonical path (no TOCTOU
|
|
5299
|
+
// between the check and the removal).
|
|
5300
|
+
let realDir = null;
|
|
5301
|
+
try {
|
|
5302
|
+
const realRoot = fs.realpathSync(root);
|
|
5303
|
+
const candidate = fs.realpathSync(sdir);
|
|
5304
|
+
if (path.dirname(candidate) === realRoot) realDir = candidate;
|
|
5305
|
+
} catch { /* unresolvable -> not deletable */ }
|
|
5306
|
+
if (realDir === null) { kept++; continue; }
|
|
5201
5307
|
if (!dryRun) {
|
|
5202
|
-
//
|
|
5203
|
-
//
|
|
5204
|
-
|
|
5205
|
-
|
|
5206
|
-
|
|
5207
|
-
if (path.dirname(realDir) === realRoot) fs.rmSync(realDir, { recursive: true, force: true });
|
|
5208
|
-
} catch { /* skip undeletable */ }
|
|
5308
|
+
// Real run: count the session as pruned only after the delete
|
|
5309
|
+
// succeeds, so pruned_count is a post-condition (sessions actually
|
|
5310
|
+
// removed from disk), never a candidate tally.
|
|
5311
|
+
try { fs.rmSync(realDir, { recursive: true, force: true }); }
|
|
5312
|
+
catch { kept++; continue; /* skip undeletable */ }
|
|
5209
5313
|
}
|
|
5314
|
+
pruned.push({ session_id: sid, captured_at: captured, replayed_at: captured ? undefined : replayFallback, dir: sdir });
|
|
5210
5315
|
} else {
|
|
5211
5316
|
kept++;
|
|
5212
5317
|
}
|
|
@@ -5246,13 +5351,27 @@ function cmdReattest(runner, args, runOpts, pretty) {
|
|
|
5246
5351
|
const sinceErr = validateIsoSince(args.since);
|
|
5247
5352
|
if (sinceErr) return emitError(`reattest: ${sinceErr}`, null, pretty);
|
|
5248
5353
|
}
|
|
5354
|
+
// Normalize --playbook (registered `multi:`, so a single value arrives as a
|
|
5355
|
+
// one-element array) and refuse an empty value. `--playbook ""` would
|
|
5356
|
+
// otherwise unwrap to "" and slip past walkAttestationDir's truthy filter
|
|
5357
|
+
// guard — silently widening --latest to the newest attestation across ALL
|
|
5358
|
+
// playbooks rather than the requested one. Refuse explicitly, the same way
|
|
5359
|
+
// --since refuses a malformed value above, so the operator sees the bad
|
|
5360
|
+
// input instead of an unintended cross-playbook match.
|
|
5361
|
+
let playbookFilter = null;
|
|
5362
|
+
if (args.playbook != null) {
|
|
5363
|
+
playbookFilter = Array.isArray(args.playbook) ? args.playbook[0] : args.playbook;
|
|
5364
|
+
if (typeof playbookFilter !== "string" || playbookFilter === "") {
|
|
5365
|
+
return emitError("reattest: --playbook was given an empty value. Pass a playbook id (e.g. --playbook kernel) or omit --playbook to match across all playbooks.", { verb: "reattest", flag: "playbook" }, pretty);
|
|
5366
|
+
}
|
|
5367
|
+
}
|
|
5249
5368
|
// --latest [--playbook <id>] [--since <ISO>] — find prior attestation
|
|
5250
5369
|
// without requiring the operator to know the session-id.
|
|
5251
5370
|
let sessionId = args._[0];
|
|
5252
5371
|
let attFile = null;
|
|
5253
5372
|
if (!sessionId && args.latest) {
|
|
5254
5373
|
const found = findLatestAttestation({
|
|
5255
|
-
playbookId:
|
|
5374
|
+
playbookId: playbookFilter,
|
|
5256
5375
|
since: args.since || null,
|
|
5257
5376
|
});
|
|
5258
5377
|
if (!found) return emitError("reattest: --latest found no matching attestations.", { filter: { playbook: args.playbook || null, since: args.since || null } }, pretty);
|
|
@@ -5560,8 +5679,8 @@ function cmdReattest(runner, args, runOpts, pretty) {
|
|
|
5560
5679
|
lines.push(`attest diff: ${obj.session_id} (${obj.playbook_id})`);
|
|
5561
5680
|
const icon = obj.status === "unchanged" ? "[ok]" : "[i DRIFTED]";
|
|
5562
5681
|
lines.push(`\n${icon} status=${obj.status}`);
|
|
5563
|
-
lines.push(` prior: ${obj.prior_evidence_hash} (${obj.prior_captured_at})`);
|
|
5564
|
-
lines.push(` replay: ${obj.replay_evidence_hash} (${obj.replayed_at})`);
|
|
5682
|
+
lines.push(` prior: ${obj.prior_evidence_hash} (${obj.prior_captured_at || '(no detail)'})`);
|
|
5683
|
+
lines.push(` replay: ${obj.replay_evidence_hash} (${obj.replayed_at || '(no detail)'})`);
|
|
5565
5684
|
if (obj.replay_classification) {
|
|
5566
5685
|
lines.push(` replay classification: ${obj.replay_classification} RWEP=${obj.replay_rwep_adjusted ?? 0}`);
|
|
5567
5686
|
}
|
|
@@ -5667,13 +5786,13 @@ function renderAttestDiff(obj) {
|
|
|
5667
5786
|
lines.push(` artifact diff: ${ad.added?.length ?? 0} added, ${ad.removed?.length ?? 0} removed, ${ad.changed?.length ?? 0} changed, ${ad.unchanged_count ?? 0} unchanged (of ${ad.total_compared ?? 0})`);
|
|
5668
5787
|
lines.push(` signal diff: ${sd.changed?.length ?? 0} changed, ${sd.unchanged_count ?? 0} unchanged (of ${sd.total_compared ?? 0})`);
|
|
5669
5788
|
if (obj.sidecar_verify) {
|
|
5670
|
-
|
|
5671
|
-
|
|
5672
|
-
|
|
5673
|
-
|
|
5674
|
-
|
|
5675
|
-
|
|
5676
|
-
lines.push(` sidecar verify: ${
|
|
5789
|
+
// Use the canonical classifier, which checks tamper_class BEFORE the reason
|
|
5790
|
+
// strings. The previous inline logic matched reason.includes("explicitly
|
|
5791
|
+
// unsigned") first, so an unsigned-SUBSTITUTION attack (whose reason also
|
|
5792
|
+
// contains "explicitly unsigned" but carries tamper_class:
|
|
5793
|
+
// 'unsigned-substitution') was mislabeled as the benign 'explicitly-unsigned'
|
|
5794
|
+
// — hiding the substitution signal in the human diff output.
|
|
5795
|
+
lines.push(` sidecar verify: ${classifySidecarVerify(obj.sidecar_verify)}`);
|
|
5677
5796
|
}
|
|
5678
5797
|
return lines.join("\n");
|
|
5679
5798
|
}
|
|
@@ -5765,6 +5884,22 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
5765
5884
|
// comparison. Without --against, replays current state against prior
|
|
5766
5885
|
// session (= reattest). With --against, compares two sessions A vs B
|
|
5767
5886
|
// by evidence_hash + artifact-level field diff.
|
|
5887
|
+
//
|
|
5888
|
+
// An empty `--against ""` / `--against=` parses to the empty string, which
|
|
5889
|
+
// is falsy — without this guard it would skip the explicit two-session
|
|
5890
|
+
// branch and silently fall through to the auto-prior path, comparing
|
|
5891
|
+
// against a DIFFERENT baseline than the operator named (a `--against
|
|
5892
|
+
// "$VAR"` that expanded to empty is the common footgun). REQUIRES_VALUE
|
|
5893
|
+
// only catches the value-less `--against` (parsed as `true`), not the
|
|
5894
|
+
// empty-string form. Refuse explicitly so the dropped target is signalled
|
|
5895
|
+
// rather than swapped under the operator.
|
|
5896
|
+
if (args.against === "") {
|
|
5897
|
+
return emitError(
|
|
5898
|
+
'attest diff: --against was given an empty value; pass a session-id, or omit --against to diff against the most-recent prior.',
|
|
5899
|
+
{ verb: "attest diff", flag: "against" },
|
|
5900
|
+
pretty
|
|
5901
|
+
);
|
|
5902
|
+
}
|
|
5768
5903
|
if (args.against) {
|
|
5769
5904
|
// Validate the --against id with the same gate as the primary sid, so a
|
|
5770
5905
|
// traversal/garbage value (`../../etc/passwd`) gets the explicit "invalid
|
|
@@ -5870,14 +6005,23 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
5870
6005
|
// counts. Pre-0.11.8 (self.submission||{}).artifacts was undefined
|
|
5871
6006
|
// for flat submissions; the diff returned all zeros even when
|
|
5872
6007
|
// artifacts were present in observations.
|
|
5873
|
-
|
|
5874
|
-
|
|
5875
|
-
|
|
5876
|
-
)
|
|
5877
|
-
|
|
5878
|
-
|
|
5879
|
-
|
|
5880
|
-
|
|
6008
|
+
// The catalog stub stands in for an empty side ONLY when BOTH sides
|
|
6009
|
+
// are empty — substituting it for one empty side while the peer passes
|
|
6010
|
+
// through its real keys manufactures phantom drift (every catalog id
|
|
6011
|
+
// the populated side did not submit reads as added/changed).
|
|
6012
|
+
...(() => {
|
|
6013
|
+
const bothEmpty = !submissionHasData(self.submission) && !submissionHasData(other.submission);
|
|
6014
|
+
return {
|
|
6015
|
+
artifact_diff: diffArtifacts(
|
|
6016
|
+
normalizedArtifacts(self.submission, runner, self.playbook_id, bothEmpty),
|
|
6017
|
+
normalizedArtifacts(other.submission, runner, other.playbook_id, bothEmpty)
|
|
6018
|
+
),
|
|
6019
|
+
signal_override_diff: diffSignalOverrides(
|
|
6020
|
+
normalizedSignalOverrides(self.submission, runner, self.playbook_id, bothEmpty),
|
|
6021
|
+
normalizedSignalOverrides(other.submission, runner, other.playbook_id, bothEmpty)
|
|
6022
|
+
),
|
|
6023
|
+
};
|
|
6024
|
+
})(),
|
|
5881
6025
|
}, pretty, renderAttestDiff);
|
|
5882
6026
|
return;
|
|
5883
6027
|
}
|
|
@@ -5954,14 +6098,22 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
5954
6098
|
sidecar_verify: aSidecarVerify,
|
|
5955
6099
|
a_sidecar_verify: aSidecarVerify,
|
|
5956
6100
|
b_sidecar_verify: bSidecarVerify,
|
|
5957
|
-
|
|
5958
|
-
|
|
5959
|
-
|
|
5960
|
-
)
|
|
5961
|
-
|
|
5962
|
-
|
|
5963
|
-
|
|
5964
|
-
|
|
6101
|
+
// Catalog stub stands in for an empty side only when BOTH sides are
|
|
6102
|
+
// empty (same peer-symmetric gate as the --against branch); real-vs-empty
|
|
6103
|
+
// diffs the populated side's keys against {}, not against the full catalog.
|
|
6104
|
+
...(() => {
|
|
6105
|
+
const bothEmpty = !submissionHasData(self.submission) && !submissionHasData(other.submission);
|
|
6106
|
+
return {
|
|
6107
|
+
artifact_diff: diffArtifacts(
|
|
6108
|
+
normalizedArtifacts(self.submission, runner, self.playbook_id, bothEmpty),
|
|
6109
|
+
normalizedArtifacts(other.submission, runner, other.playbook_id, bothEmpty),
|
|
6110
|
+
),
|
|
6111
|
+
signal_override_diff: diffSignalOverrides(
|
|
6112
|
+
normalizedSignalOverrides(self.submission, runner, self.playbook_id, bothEmpty),
|
|
6113
|
+
normalizedSignalOverrides(other.submission, runner, other.playbook_id, bothEmpty),
|
|
6114
|
+
),
|
|
6115
|
+
};
|
|
6116
|
+
})(),
|
|
5965
6117
|
}, pretty, renderAttestDiff);
|
|
5966
6118
|
return;
|
|
5967
6119
|
}
|
|
@@ -6232,9 +6384,29 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
6232
6384
|
run_opts: a.run_opts,
|
|
6233
6385
|
artifacts_redacted: Object.fromEntries(Object.entries((a.submission && a.submission.artifacts) || {})
|
|
6234
6386
|
.map(([k, v]) => [k, { captured: !!v.captured, reason: v.reason || null, redacted_value: "[redacted]" }])),
|
|
6235
|
-
signal_overrides
|
|
6387
|
+
// signal_overrides are operator-controllable: the contract canonicalizes
|
|
6388
|
+
// hit/miss/inconclusive verdicts but does NOT reject free-form values
|
|
6389
|
+
// (an unrecognized value surfaces a signal_override_unrecognized runtime
|
|
6390
|
+
// error yet is still stored verbatim in the submission), and the sibling
|
|
6391
|
+
// `<id>__fp_checks` keys carry arbitrary operator attestation maps. Under
|
|
6392
|
+
// a bundle labelled "redacted ... suitable for audit submission" the only
|
|
6393
|
+
// audit-meaningful content is the indicator verdict itself, so keep an
|
|
6394
|
+
// exact hit/miss/inconclusive value and replace everything else (free-form
|
|
6395
|
+
// strings, captured-data values, __fp_checks objects) with "[redacted]".
|
|
6396
|
+
// Apply the same keyname denylist as signals_redacted so an obviously-
|
|
6397
|
+
// sensitive key can't ride through under this field either. Matches the
|
|
6398
|
+
// signal-value-redaction contract asserted in tests/cli-coverage.js.
|
|
6399
|
+
signal_overrides: Object.fromEntries(Object.entries((a.submission && a.submission.signal_overrides) || {})
|
|
6400
|
+
.filter(([k]) => !/_filter$|_key$|token|secret|password/i.test(k))
|
|
6401
|
+
.map(([k, v]) => [k, (v === "hit" || v === "miss" || v === "inconclusive") ? v : "[redacted]"])),
|
|
6402
|
+
// Redact the VALUES, not just drop obviously-sensitive keys: a submitted
|
|
6403
|
+
// signal value can hold operator data (e.g. a captured credential string),
|
|
6404
|
+
// and this field is labelled "redacted". The keyname denylist still drops
|
|
6405
|
+
// the obviously-sensitive keys entirely; every retained key keeps only a
|
|
6406
|
+
// "[redacted]" placeholder value, matching artifacts_redacted above.
|
|
6236
6407
|
signals_redacted: Object.fromEntries(Object.entries((a.submission && a.submission.signals) || {})
|
|
6237
|
-
.filter(([k]) => !/_filter$|_key$|token|secret|password/i.test(k))
|
|
6408
|
+
.filter(([k]) => !/_filter$|_key$|token|secret|password/i.test(k))
|
|
6409
|
+
.map(([k]) => [k, "[redacted]"])),
|
|
6238
6410
|
precondition_checks: (a.submission && a.submission.precondition_checks) || {},
|
|
6239
6411
|
}));
|
|
6240
6412
|
|
|
@@ -6256,7 +6428,7 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
6256
6428
|
verb: "attest export",
|
|
6257
6429
|
session_id: sessionId,
|
|
6258
6430
|
exported_at: new Date().toISOString(),
|
|
6259
|
-
redaction_policy: "v0.10.3-default — artifact values stripped; signal_overrides
|
|
6431
|
+
redaction_policy: "v0.10.3-default — artifact values stripped; signal_overrides reduced to hit/miss/inconclusive verdicts (free-form values redacted); precondition_checks + evidence_hash + signature preserved.",
|
|
6260
6432
|
attestations: redacted,
|
|
6261
6433
|
}, pretty);
|
|
6262
6434
|
}
|
|
@@ -6296,9 +6468,25 @@ function _playbookSignalCatalog(runner, playbookId) {
|
|
|
6296
6468
|
return Object.fromEntries(inds.map(i => [i.id, 'inconclusive']));
|
|
6297
6469
|
} catch { return null; }
|
|
6298
6470
|
}
|
|
6299
|
-
|
|
6471
|
+
// A submission carries real operator data for a diff when it supplied
|
|
6472
|
+
// artifacts, signal_overrides, OR observations. The empty case ({} or
|
|
6473
|
+
// {observations:{}}) is "no operator data was supplied." Diff symmetry hinges
|
|
6474
|
+
// on this predicate: the playbook catalog stub may only stand in for an empty
|
|
6475
|
+
// side when BOTH sides are empty (so the count reflects "N catalog ids,
|
|
6476
|
+
// uniformly empty on both sides"). Substituting the full catalog for one empty
|
|
6477
|
+
// side while the peer passes through its real keys manufactures phantom drift —
|
|
6478
|
+
// every catalog id the populated side did not submit shows up as "added"
|
|
6479
|
+
// (artifacts) or "changed" (signals). See callers for the bothEmpty gate.
|
|
6480
|
+
function submissionHasData(submission) {
|
|
6481
|
+
if (!submission || typeof submission !== "object") return false;
|
|
6482
|
+
const nonEmpty = (o) => o && typeof o === "object" && Object.keys(o).length > 0;
|
|
6483
|
+
return nonEmpty(submission.artifacts)
|
|
6484
|
+
|| nonEmpty(submission.signal_overrides)
|
|
6485
|
+
|| nonEmpty(submission.observations);
|
|
6486
|
+
}
|
|
6487
|
+
function normalizedArtifacts(submission, runner, playbookId, applyEmptyFallback = true) {
|
|
6300
6488
|
if (!submission || typeof submission !== "object") {
|
|
6301
|
-
return _playbookArtifactCatalog(runner, playbookId) || {};
|
|
6489
|
+
return applyEmptyFallback ? (_playbookArtifactCatalog(runner, playbookId) || {}) : {};
|
|
6302
6490
|
}
|
|
6303
6491
|
if (submission.artifacts && Object.keys(submission.artifacts).length > 0) return submission.artifacts;
|
|
6304
6492
|
if (submission.observations && Object.keys(submission.observations).length > 0) {
|
|
@@ -6320,15 +6508,16 @@ function normalizedArtifacts(submission, runner, playbookId) {
|
|
|
6320
6508
|
}
|
|
6321
6509
|
return out;
|
|
6322
6510
|
}
|
|
6323
|
-
//
|
|
6324
|
-
//
|
|
6325
|
-
//
|
|
6326
|
-
//
|
|
6327
|
-
|
|
6511
|
+
// Empty submission ({} or {observations:{}}). The catalog stub may only
|
|
6512
|
+
// stand in when the PEER side is also empty (applyEmptyFallback). When the
|
|
6513
|
+
// peer carried real artifacts, return an empty map so the populated side's
|
|
6514
|
+
// keys diff against nothing — yielding genuine added/removed instead of one
|
|
6515
|
+
// fabricated "added" per catalog id the operator never submitted.
|
|
6516
|
+
return applyEmptyFallback ? (_playbookArtifactCatalog(runner, playbookId) || {}) : {};
|
|
6328
6517
|
}
|
|
6329
|
-
function normalizedSignalOverrides(submission, runner, playbookId) {
|
|
6518
|
+
function normalizedSignalOverrides(submission, runner, playbookId, applyEmptyFallback = true) {
|
|
6330
6519
|
if (!submission || typeof submission !== "object") {
|
|
6331
|
-
return _playbookSignalCatalog(runner, playbookId) || {};
|
|
6520
|
+
return applyEmptyFallback ? (_playbookSignalCatalog(runner, playbookId) || {}) : {};
|
|
6332
6521
|
}
|
|
6333
6522
|
if (submission.signal_overrides && Object.keys(submission.signal_overrides).length > 0) return submission.signal_overrides;
|
|
6334
6523
|
if (submission.observations && Object.keys(submission.observations).length > 0) {
|
|
@@ -6347,7 +6536,10 @@ function normalizedSignalOverrides(submission, runner, playbookId) {
|
|
|
6347
6536
|
}
|
|
6348
6537
|
return out;
|
|
6349
6538
|
}
|
|
6350
|
-
|
|
6539
|
+
// Empty submission — same peer-symmetric gate as normalizedArtifacts: only
|
|
6540
|
+
// stand in the inconclusive catalog stub when the peer is also empty, so a
|
|
6541
|
+
// real-vs-empty signal diff reports only the genuinely-differing indicators.
|
|
6542
|
+
return applyEmptyFallback ? (_playbookSignalCatalog(runner, playbookId) || {}) : {};
|
|
6351
6543
|
}
|
|
6352
6544
|
|
|
6353
6545
|
/**
|
|
@@ -6398,14 +6590,14 @@ function diffArtifacts(a, b) {
|
|
|
6398
6590
|
for (const id of allIds) {
|
|
6399
6591
|
const av = a[id], bv = b[id];
|
|
6400
6592
|
if (!av && bv) {
|
|
6401
|
-
out.added.push({ id, captured: !!bv.captured, value_preview:
|
|
6593
|
+
out.added.push({ id, captured: !!bv.captured, value_preview: artifactPreview(bv) });
|
|
6402
6594
|
} else if (av && !bv) {
|
|
6403
|
-
out.removed.push({ id, captured: !!av.captured, value_preview:
|
|
6595
|
+
out.removed.push({ id, captured: !!av.captured, value_preview: artifactPreview(av) });
|
|
6404
6596
|
} else if (av && bv && artifactsDiffer(av, bv)) {
|
|
6405
6597
|
out.changed.push({
|
|
6406
6598
|
id,
|
|
6407
6599
|
a_captured: !!av.captured, b_captured: !!bv.captured,
|
|
6408
|
-
a_value_preview:
|
|
6600
|
+
a_value_preview: artifactPreview(av), b_value_preview: artifactPreview(bv),
|
|
6409
6601
|
});
|
|
6410
6602
|
} else if (av && bv) {
|
|
6411
6603
|
// v0.11.8 (#102): both sides have the entry AND they're identical →
|
|
@@ -6438,6 +6630,23 @@ function previewValue(v) {
|
|
|
6438
6630
|
return s.length > 80 ? s.slice(0, 80) + "…" : s;
|
|
6439
6631
|
}
|
|
6440
6632
|
|
|
6633
|
+
// Preview the evidence an artifact carries for the diff output. `.value` is the
|
|
6634
|
+
// canonical carrier, but observations legitimately store their secret/path/match
|
|
6635
|
+
// under other keys (path, matched, reason, or a custom key). When `.value` is
|
|
6636
|
+
// absent, fall back to a preview of the remaining evidence-bearing keys (every
|
|
6637
|
+
// key except the bookkeeping `captured`/`captured_at` flags) so non-`value`
|
|
6638
|
+
// carriers still render instead of collapsing to a null preview — which hid the
|
|
6639
|
+
// actual differing content even when the per-field equality compare correctly
|
|
6640
|
+
// flagged the artifact as changed.
|
|
6641
|
+
function artifactPreview(art) {
|
|
6642
|
+
if (art === null || typeof art !== "object" || Array.isArray(art)) return previewValue(art);
|
|
6643
|
+
if (art.value !== undefined && art.value !== null) return previewValue(art.value);
|
|
6644
|
+
const { captured, captured_at, _captured_at, value, ...evidence } = art;
|
|
6645
|
+
const keys = Object.keys(evidence);
|
|
6646
|
+
if (keys.length === 0) return null;
|
|
6647
|
+
return previewValue(evidence);
|
|
6648
|
+
}
|
|
6649
|
+
|
|
6441
6650
|
// ---------------------------------------------------------------------------
|
|
6442
6651
|
// v0.11.0: cmdDiscover — context-aware playbook recommender.
|
|
6443
6652
|
// Collapses scan + dispatch + recommend into one verb. Sniffs the cwd, reads
|
|
@@ -6448,6 +6657,11 @@ function cmdDiscover(runner, args, runOpts, pretty) {
|
|
|
6448
6657
|
// process cwd. Pre-fix it was silently ignored — recommendations were
|
|
6449
6658
|
// computed for the wrong directory with no signal. Validated like collect.
|
|
6450
6659
|
let cwd = process.cwd();
|
|
6660
|
+
// An explicit empty value (`--cwd ""`) would otherwise be falsy and silently
|
|
6661
|
+
// scan process.cwd() — the wrong directory — reported as a successful run.
|
|
6662
|
+
if (args.cwd === "") {
|
|
6663
|
+
return emitError(`discover: --cwd was given an empty value; pass an existing directory path`, { verb: "discover" }, pretty);
|
|
6664
|
+
}
|
|
6451
6665
|
if (args.cwd) {
|
|
6452
6666
|
const resolved = path.resolve(String(args.cwd));
|
|
6453
6667
|
let stat;
|
|
@@ -6509,11 +6723,13 @@ function cmdDiscover(runner, args, runOpts, pretty) {
|
|
|
6509
6723
|
let hostDistro = null;
|
|
6510
6724
|
if (hostPlatform === "linux") {
|
|
6511
6725
|
try {
|
|
6512
|
-
|
|
6513
|
-
|
|
6514
|
-
|
|
6515
|
-
|
|
6516
|
-
const
|
|
6726
|
+
// Read the file directly instead of spawning `cat` (a process to do what
|
|
6727
|
+
// fs does, and a static-analysis "unnecessary use of cat" flag).
|
|
6728
|
+
const osRelease = fs.readFileSync("/etc/os-release", "utf8");
|
|
6729
|
+
if (osRelease) {
|
|
6730
|
+
const idMatch = osRelease.match(/^ID=(.+)$/m);
|
|
6731
|
+
const verMatch = osRelease.match(/^VERSION_ID=(.+)$/m);
|
|
6732
|
+
const prettyMatch = osRelease.match(/^PRETTY_NAME=(.+)$/m);
|
|
6517
6733
|
hostDistro = {
|
|
6518
6734
|
id: idMatch ? idMatch[1].replace(/^"|"$/g, "") : null,
|
|
6519
6735
|
version_id: verMatch ? verMatch[1].replace(/^"|"$/g, "") : null,
|
|
@@ -7935,6 +8151,14 @@ function cmdAiRun(runner, args, runOpts, pretty) {
|
|
|
7935
8151
|
if (!playbookId) {
|
|
7936
8152
|
return emitError("ai-run: missing <playbook> positional argument.", null, pretty);
|
|
7937
8153
|
}
|
|
8154
|
+
// An explicit empty value (`--evidence ""`) is operator error, same as `run`.
|
|
8155
|
+
// The `--no-stream` path tests `args.evidence` for truthiness, so `""` fell
|
|
8156
|
+
// through to the stdin branch and — with empty/closed stdin — ran an empty
|
|
8157
|
+
// submission to ok:true at exit 0, masking that the intended evidence never
|
|
8158
|
+
// loaded. Reject it here so both stream and no-stream entry behave like `run`.
|
|
8159
|
+
if (args.evidence === "") {
|
|
8160
|
+
return emitError("ai-run: --evidence was given an empty value; pass a file path, '-' for stdin, or omit --evidence to read evidence from the stream", { verb: "ai-run" }, pretty);
|
|
8161
|
+
}
|
|
7938
8162
|
if (refuseInvalidPlaybookId("ai-run", playbookId, pretty)) return;
|
|
7939
8163
|
let pb;
|
|
7940
8164
|
try { pb = runner.loadPlaybook(playbookId); }
|
|
@@ -8684,6 +8908,27 @@ function cmdCi(runner, args, runOpts, pretty) {
|
|
|
8684
8908
|
pretty,
|
|
8685
8909
|
);
|
|
8686
8910
|
}
|
|
8911
|
+
// An explicit empty value (`--evidence ""` / `--evidence=` / an unset shell
|
|
8912
|
+
// variable) is falsy, so the truthiness-gated evidence reads below skip
|
|
8913
|
+
// entirely, the bundle stays {}, every playbook runs with no evidence, and
|
|
8914
|
+
// the gate reports a clean PASS at exit 0 — a false-green that hides the fact
|
|
8915
|
+
// the operator's intended evidence never loaded. Mirror the run/collect/
|
|
8916
|
+
// discover empty-value guards: refuse the empty value loudly rather than
|
|
8917
|
+
// running a vacuous gate.
|
|
8918
|
+
if (args.evidence === "") {
|
|
8919
|
+
return emitError(
|
|
8920
|
+
"ci: --evidence was given an empty value; pass a file path, '-' for stdin, or omit --evidence for a no-evidence run",
|
|
8921
|
+
{ verb: "ci", flag: "evidence" },
|
|
8922
|
+
pretty,
|
|
8923
|
+
);
|
|
8924
|
+
}
|
|
8925
|
+
if (args["evidence-dir"] === "") {
|
|
8926
|
+
return emitError(
|
|
8927
|
+
"ci: --evidence-dir was given an empty value; pass an existing directory, or omit --evidence-dir",
|
|
8928
|
+
{ verb: "ci", flag: "evidence-dir" },
|
|
8929
|
+
pretty,
|
|
8930
|
+
);
|
|
8931
|
+
}
|
|
8687
8932
|
const blockOnClock = !!args["block-on-jurisdiction-clock"];
|
|
8688
8933
|
|
|
8689
8934
|
// v0.11.9 (#115): --required <playbook,playbook,...> takes precedence over
|