@blamejs/exceptd-skills 0.10.1 → 0.10.3
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 +51 -0
- package/CHANGELOG.md +72 -0
- package/bin/exceptd.js +468 -37
- package/data/_indexes/_meta.json +2 -2
- package/data/playbooks/crypto-codebase.json +1387 -0
- package/data/playbooks/kernel.json +1 -1
- package/data/playbooks/library-author.json +1792 -0
- package/lib/framework-gap.js +17 -1
- package/lib/playbook-runner.js +146 -11
- package/lib/prefetch.js +9 -1
- package/manifest-snapshot.json +1 -1
- package/manifest.json +39 -39
- package/orchestrator/index.js +98 -8
- package/package.json +2 -1
- package/sbom.cdx.json +6 -6
- package/sources/README.md +170 -0
- package/sources/validators/atlas-validator.js +158 -0
- package/sources/validators/cve-validator.js +277 -0
- package/sources/validators/index.js +86 -0
- package/sources/validators/rfc-validator.js +165 -0
- package/sources/validators/version-pin-validator.js +144 -0
package/bin/exceptd.js
CHANGED
|
@@ -101,7 +101,7 @@ const ORCHESTRATOR_PASSTHROUGH = new Set([
|
|
|
101
101
|
|
|
102
102
|
// Seven-phase playbook verbs handled in-process (no subprocess dispatch).
|
|
103
103
|
const PLAYBOOK_VERBS = new Set([
|
|
104
|
-
"plan", "govern", "direct", "look", "run", "ingest", "reattest", "list-attestations",
|
|
104
|
+
"plan", "govern", "direct", "look", "run", "ingest", "reattest", "list-attestations", "attest",
|
|
105
105
|
]);
|
|
106
106
|
|
|
107
107
|
function readPkgVersion() {
|
|
@@ -225,7 +225,11 @@ function main() {
|
|
|
225
225
|
|
|
226
226
|
const resolver = COMMANDS[cmd];
|
|
227
227
|
if (typeof resolver !== "function") {
|
|
228
|
-
|
|
228
|
+
// Emit a structured JSON error matching the seven-phase verbs so operators
|
|
229
|
+
// piping through `jq` get one consistent shape across the CLI surface.
|
|
230
|
+
// Plain-text "unknown command" still reaches stderr for human readers.
|
|
231
|
+
const err = { ok: false, error: `unknown command "${cmd}"`, hint: "Run `exceptd help` for the list of verbs.", verb: cmd };
|
|
232
|
+
process.stderr.write(JSON.stringify(err) + "\n");
|
|
229
233
|
process.exit(2);
|
|
230
234
|
}
|
|
231
235
|
|
|
@@ -335,8 +339,9 @@ function dispatchPlaybook(cmd, argv) {
|
|
|
335
339
|
}
|
|
336
340
|
|
|
337
341
|
const args = parseArgs(argv, {
|
|
338
|
-
bool: ["pretty", "air-gap", "force-stale", "all", "flat", "directives"
|
|
339
|
-
|
|
342
|
+
bool: ["pretty", "air-gap", "force-stale", "all", "flat", "directives",
|
|
343
|
+
"ci", "latest", "diff-from-latest", "explain", "signal-list", "ack"],
|
|
344
|
+
multi: ["playbook", "format"],
|
|
340
345
|
});
|
|
341
346
|
const pretty = !!args.pretty;
|
|
342
347
|
const runOpts = {
|
|
@@ -346,6 +351,15 @@ function dispatchPlaybook(cmd, argv) {
|
|
|
346
351
|
if (args["session-id"]) runOpts.session_id = args["session-id"];
|
|
347
352
|
if (args["session-key"]) runOpts.session_key = args["session-key"];
|
|
348
353
|
if (args.mode) runOpts.mode = args.mode;
|
|
354
|
+
// Multi-operator teams need attestations bound to a specific human or
|
|
355
|
+
// service identity. --operator <name> persists into the attestation file
|
|
356
|
+
// for audit-trail accountability. Free-form string; no validation.
|
|
357
|
+
if (args.operator) runOpts.operator = args.operator;
|
|
358
|
+
// --ack: operator acknowledges the jurisdiction obligations surfaced by
|
|
359
|
+
// govern. Captured in attestation; downstream tooling can check whether
|
|
360
|
+
// consent was explicit vs. implicit. AGENTS.md says the AI should surface
|
|
361
|
+
// and wait for ack — this is how the ack gets recorded.
|
|
362
|
+
if (args.ack) runOpts.operator_consent = { acked_at: new Date().toISOString(), explicit: true };
|
|
349
363
|
|
|
350
364
|
let runner;
|
|
351
365
|
try {
|
|
@@ -365,6 +379,7 @@ function dispatchPlaybook(cmd, argv) {
|
|
|
365
379
|
case "ingest": return cmdIngest(runner, args, runOpts, pretty);
|
|
366
380
|
case "reattest": return cmdReattest(runner, args, runOpts, pretty);
|
|
367
381
|
case "list-attestations": return cmdListAttestations(runner, args, runOpts, pretty);
|
|
382
|
+
case "attest": return cmdAttest(runner, args, runOpts, pretty);
|
|
368
383
|
}
|
|
369
384
|
} catch (e) {
|
|
370
385
|
emitError(e.message, { verb: cmd }, pretty);
|
|
@@ -429,6 +444,34 @@ Flags:
|
|
|
429
444
|
{ artifacts, signal_overrides, signals, precondition_checks }
|
|
430
445
|
Multi-playbook shape:
|
|
431
446
|
{ "<playbook_id>": { artifacts, ... }, ... }
|
|
447
|
+
--evidence-dir <dir> Read <playbook-id>.json files from a directory and
|
|
448
|
+
merge into the multi-run bundle. Cron-friendly.
|
|
449
|
+
--vex <file> Load a CycloneDX or OpenVEX document. CVEs marked
|
|
450
|
+
not_affected | resolved | false_positive (CycloneDX)
|
|
451
|
+
or not_affected | fixed (OpenVEX) drop out of
|
|
452
|
+
analyze.matched_cves. The disposition is preserved
|
|
453
|
+
under analyze.vex.dropped_cves.
|
|
454
|
+
--format <fmt> ... Emit the close.evidence_package bundle in additional
|
|
455
|
+
formats. Repeatable. Supported: csaf-2.0 | sarif |
|
|
456
|
+
openvex | markdown. CSAF is always primary; extras
|
|
457
|
+
populate close.evidence_package.bundles_by_format.
|
|
458
|
+
--explain Dry-run: emit preconditions, required artifacts,
|
|
459
|
+
recognized signal keys, and a submission skeleton.
|
|
460
|
+
Does not run detect/analyze/validate/close.
|
|
461
|
+
--signal-list Emit only the signal_overrides keys the detect phase
|
|
462
|
+
recognizes (lighter than --explain).
|
|
463
|
+
--operator <name> Bind the attestation to a specific human/service
|
|
464
|
+
identity. Persisted under attestation.operator.
|
|
465
|
+
--ack Mark explicit operator consent to the jurisdiction
|
|
466
|
+
obligations surfaced by govern. Persisted under
|
|
467
|
+
attestation.operator_consent.
|
|
468
|
+
--diff-from-latest Compare evidence_hash against the most recent prior
|
|
469
|
+
attestation for the same playbook in
|
|
470
|
+
.exceptd/attestations/. Emits status: unchanged | drifted.
|
|
471
|
+
--ci Machine-readable verdict for CI gates. Exits non-zero
|
|
472
|
+
(code 2) when phases.detect.classification === 'detected'
|
|
473
|
+
OR phases.analyze.rwep.adjusted >= rwep_threshold.escalate.
|
|
474
|
+
Logs PASS/FAIL reason to stderr.
|
|
432
475
|
--session-id <id> Reuse a specific session ID.
|
|
433
476
|
--session-key <hex> HMAC sign the evidence_package with this key.
|
|
434
477
|
--force-stale Override the threat_currency_score < 50 hard-block.
|
|
@@ -444,13 +487,36 @@ Flags:
|
|
|
444
487
|
--directive <id> Directive ID (overrides submission.directive_id).
|
|
445
488
|
--evidence <file|-> Submission JSON. May include playbook_id/directive_id.
|
|
446
489
|
--pretty Indented JSON output.`,
|
|
447
|
-
reattest: `reattest <session-id> — replay a prior session and diff the evidence_hash.
|
|
490
|
+
reattest: `reattest [<session-id> | --latest] — replay a prior session and diff the evidence_hash.
|
|
448
491
|
|
|
449
492
|
Args / flags:
|
|
450
|
-
<session-id>
|
|
493
|
+
<session-id> Looks under .exceptd/attestations/<id>/attestation.json.
|
|
494
|
+
--latest Find the most-recent attestation automatically.
|
|
495
|
+
--playbook <id> Restrict --latest to a specific playbook.
|
|
496
|
+
--since <ISO> Restrict --latest to attestations after this ISO 8601 timestamp.
|
|
451
497
|
--pretty Indented JSON output.
|
|
452
498
|
|
|
453
499
|
Reports: unchanged | drifted | resolved from evidence_hash + classification deltas.`,
|
|
500
|
+
"list-attestations": `list-attestations [--playbook <id>] — enumerate prior attestations.
|
|
501
|
+
|
|
502
|
+
Args / flags:
|
|
503
|
+
--playbook <id> Filter to one playbook.
|
|
504
|
+
--pretty Indented JSON output.
|
|
505
|
+
|
|
506
|
+
Lists every attestation under .exceptd/attestations/<session_id>/, sorted
|
|
507
|
+
newest-first, with truncated evidence_hash + capture timestamp + file path.`,
|
|
508
|
+
attest: `attest <subverb> <session-id> — auditor-facing attestation operations.
|
|
509
|
+
|
|
510
|
+
Subverbs:
|
|
511
|
+
attest show <sid> Emit the full (unredacted) attestation.
|
|
512
|
+
attest export <sid> Emit redacted JSON suitable for audit submission.
|
|
513
|
+
Strips raw artifact values; preserves evidence_hash,
|
|
514
|
+
signature, classification, RWEP, remediation choice.
|
|
515
|
+
--format csaf wraps the export in a CSAF envelope.
|
|
516
|
+
attest verify <sid> Verify .sig sidecar against keys/public.pem.
|
|
517
|
+
Reports tamper status per attestation file.
|
|
518
|
+
|
|
519
|
+
All subverbs honor --pretty for indented JSON output.`,
|
|
454
520
|
};
|
|
455
521
|
process.stdout.write((cmds[verb] || `${verb} — no per-verb help available; see \`exceptd help\` for the full list.`) + "\n");
|
|
456
522
|
}
|
|
@@ -475,13 +541,27 @@ function cmdPlan(runner, args, runOpts, pretty) {
|
|
|
475
541
|
Object.entries(plan.grouped_by_scope).map(([s, list]) => [s, list.length])
|
|
476
542
|
);
|
|
477
543
|
}
|
|
478
|
-
// --directives expands each playbook entry with
|
|
479
|
-
// applies_to
|
|
480
|
-
//
|
|
544
|
+
// --directives expands each playbook entry with directive id + title +
|
|
545
|
+
// applies_to + description. v0.10.3-aware fallback: pull description from
|
|
546
|
+
// (a) explicit d.description, (b) directive override threat_context,
|
|
547
|
+
// (c) playbook-level direct.threat_context first sentence, (d) playbook
|
|
548
|
+
// domain.name. Operators need operator-facing prose, not just an ID + enum.
|
|
481
549
|
if (args.directives) {
|
|
482
550
|
for (const pb of plan.playbooks) {
|
|
483
551
|
const full = runner.loadPlaybook(pb.id);
|
|
484
|
-
|
|
552
|
+
const baseDirect = full.phases?.direct || {};
|
|
553
|
+
pb.directives = full.directives.map(d => {
|
|
554
|
+
const overrideDirect = d.phase_overrides?.direct || {};
|
|
555
|
+
const threatContext = overrideDirect.threat_context || baseDirect.threat_context || null;
|
|
556
|
+
const firstSentence = threatContext ? (threatContext.split(/(?<=[.!?])\s+/)[0] || "").slice(0, 240) : null;
|
|
557
|
+
return {
|
|
558
|
+
id: d.id,
|
|
559
|
+
title: d.title,
|
|
560
|
+
description: d.description || firstSentence || full.domain?.name || null,
|
|
561
|
+
applies_to: d.applies_to,
|
|
562
|
+
threat_context_preview: firstSentence,
|
|
563
|
+
};
|
|
564
|
+
});
|
|
485
565
|
}
|
|
486
566
|
}
|
|
487
567
|
emit(plan, pretty);
|
|
@@ -583,6 +663,53 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
583
663
|
const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
|
|
584
664
|
if (!directiveId) return emitError(`run: playbook ${playbookId} has no directives.`, null, pretty);
|
|
585
665
|
|
|
666
|
+
// --explain: dry-run that emits the preconditions + artifacts + indicators
|
|
667
|
+
// + signal keys the agent would need to supply, WITHOUT running detect/
|
|
668
|
+
// analyze/validate/close. Lets operators preview before assembling evidence.
|
|
669
|
+
if (args.explain) {
|
|
670
|
+
const lookPhase = runner.look(playbookId, directiveId, runOpts);
|
|
671
|
+
const detectPhase = runner.loadPlaybook(playbookId).phases?.detect || {};
|
|
672
|
+
const detectResolved = runner._resolvedPhase ? runner._resolvedPhase(pb, directiveId, "detect") : detectPhase;
|
|
673
|
+
emit({
|
|
674
|
+
verb: "run",
|
|
675
|
+
mode: "explain",
|
|
676
|
+
playbook_id: playbookId,
|
|
677
|
+
directive_id: directiveId,
|
|
678
|
+
scope: pb._meta?.scope || null,
|
|
679
|
+
preconditions: lookPhase.preconditions,
|
|
680
|
+
precondition_submission_shape: lookPhase.precondition_submission_shape,
|
|
681
|
+
artifacts_required: lookPhase.artifacts.filter(a => a.required).map(a => ({ id: a.id, type: a.type, source: a.source })),
|
|
682
|
+
artifacts_optional: lookPhase.artifacts.filter(a => !a.required).map(a => ({ id: a.id, type: a.type, source: a.source, fallback: lookPhase.fallback_if_unavailable.find(f => f.artifact_id === a.id) })),
|
|
683
|
+
signal_keys: (detectResolved.indicators || []).map(i => ({ id: i.id, type: i.type, deterministic: !!i.deterministic, confidence: i.confidence })),
|
|
684
|
+
detect_classification_override: { hint: "submit signals.detection_classification = 'detected' | 'inconclusive' | 'not_detected' | 'clean' to override engine-computed classification.", valid_values: ["detected", "inconclusive", "not_detected", "clean"] },
|
|
685
|
+
submission_skeleton: {
|
|
686
|
+
artifacts: Object.fromEntries(lookPhase.artifacts.map(a => [a.id, { value: "<your captured output>", captured: true }])),
|
|
687
|
+
signal_overrides: Object.fromEntries((detectResolved.indicators || []).map(i => [i.id, "hit | miss | inconclusive"])),
|
|
688
|
+
signals: { detection_classification: "<one of: detected|inconclusive|not_detected|clean>", theater_verdict: "<clear | theater | pending_agent_run>" },
|
|
689
|
+
precondition_checks: Object.fromEntries(lookPhase.preconditions.map(p => [p.id, true])),
|
|
690
|
+
}
|
|
691
|
+
}, pretty);
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// --signal-list: enumerate every signal_overrides key the detect phase
|
|
696
|
+
// recognizes. Lighter than --explain.
|
|
697
|
+
if (args["signal-list"]) {
|
|
698
|
+
const detectResolved = runner._resolvedPhase
|
|
699
|
+
? runner._resolvedPhase(pb, directiveId, "detect")
|
|
700
|
+
: pb.phases?.detect;
|
|
701
|
+
emit({
|
|
702
|
+
verb: "run",
|
|
703
|
+
mode: "signal-list",
|
|
704
|
+
playbook_id: playbookId,
|
|
705
|
+
directive_id: directiveId,
|
|
706
|
+
signal_overrides_keys: (detectResolved?.indicators || []).map(i => i.id),
|
|
707
|
+
signal_value_grammar: "hit | miss | inconclusive",
|
|
708
|
+
detection_classification_override_keys: ["detected", "inconclusive", "not_detected", "clean"],
|
|
709
|
+
}, pretty);
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
|
|
586
713
|
let submission = {};
|
|
587
714
|
if (args.evidence) {
|
|
588
715
|
try {
|
|
@@ -598,6 +725,28 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
598
725
|
runOpts.precondition_checks = submission.precondition_checks;
|
|
599
726
|
}
|
|
600
727
|
|
|
728
|
+
// --format <fmt>: override the playbook's declared evidence_package.bundle_format.
|
|
729
|
+
// Supports csaf-2.0 | sarif | openvex | markdown. Multiple --format flags
|
|
730
|
+
// produce multiple bundles in the close response under bundles_by_format.
|
|
731
|
+
if (args.format) {
|
|
732
|
+
const formats = Array.isArray(args.format) ? args.format : [args.format];
|
|
733
|
+
submission.signals = submission.signals || {};
|
|
734
|
+
submission.signals._bundle_formats = formats;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// --vex <file>: load a CycloneDX/OpenVEX document and pass the not_affected
|
|
738
|
+
// CVE ID set through to analyze() so matched_cves drops them.
|
|
739
|
+
if (args.vex) {
|
|
740
|
+
try {
|
|
741
|
+
const vexDoc = JSON.parse(fs.readFileSync(args.vex, "utf8"));
|
|
742
|
+
const vexSet = runner.vexFilterFromDoc(vexDoc);
|
|
743
|
+
submission.signals = submission.signals || {};
|
|
744
|
+
submission.signals.vex_filter = [...vexSet];
|
|
745
|
+
} catch (e) {
|
|
746
|
+
return emitError(`run: failed to load --vex ${args.vex}: ${e.message}`, null, pretty);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
601
750
|
const result = runner.run(playbookId, directiveId, submission, runOpts);
|
|
602
751
|
|
|
603
752
|
// Persist attestation for reattest cycles when the run succeeded.
|
|
@@ -605,18 +754,23 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
605
754
|
try {
|
|
606
755
|
const dir = path.join(process.cwd(), ".exceptd", "attestations", result.session_id);
|
|
607
756
|
fs.mkdirSync(dir, { recursive: true });
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
);
|
|
757
|
+
const attestation = {
|
|
758
|
+
session_id: result.session_id,
|
|
759
|
+
playbook_id: result.playbook_id,
|
|
760
|
+
directive_id: result.directive_id,
|
|
761
|
+
evidence_hash: result.evidence_hash,
|
|
762
|
+
operator: runOpts.operator || null,
|
|
763
|
+
operator_consent: runOpts.operator_consent || null,
|
|
764
|
+
submission,
|
|
765
|
+
run_opts: { airGap: runOpts.airGap, forceStale: runOpts.forceStale, mode: runOpts.mode },
|
|
766
|
+
captured_at: new Date().toISOString(),
|
|
767
|
+
};
|
|
768
|
+
fs.writeFileSync(path.join(dir, "attestation.json"), JSON.stringify(attestation, null, 2));
|
|
769
|
+
// Feature #27: Ed25519-sign the attestation if .keys/private.pem exists
|
|
770
|
+
// (signing infrastructure is already shared with skill signing). Without
|
|
771
|
+
// a private key, write an unsigned `.sig` placeholder so tooling can tell
|
|
772
|
+
// the difference between "unsigned" and "tampered post-hoc".
|
|
773
|
+
maybeSignAttestation(path.join(dir, "attestation.json"));
|
|
620
774
|
} catch { /* non-fatal — attestation persistence is best-effort */ }
|
|
621
775
|
}
|
|
622
776
|
|
|
@@ -624,6 +778,68 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
624
778
|
process.stderr.write((pretty ? JSON.stringify(result, null, 2) : JSON.stringify(result)) + "\n");
|
|
625
779
|
process.exit(1);
|
|
626
780
|
}
|
|
781
|
+
|
|
782
|
+
// --diff-from-latest: compare evidence_hash against the most recent prior
|
|
783
|
+
// attestation for this playbook. Drift mode for cron baselines.
|
|
784
|
+
// We've already persisted the CURRENT attestation above, so the find must
|
|
785
|
+
// skip our session_id to get the actual prior one.
|
|
786
|
+
if (args["diff-from-latest"] && result && result.evidence_hash) {
|
|
787
|
+
const prior = findLatestAttestation({ playbookId, excludeSessionId: result.session_id });
|
|
788
|
+
if (prior) {
|
|
789
|
+
const priorHash = prior.parsed.evidence_hash;
|
|
790
|
+
result.diff_from_latest = {
|
|
791
|
+
prior_session_id: prior.parsed.session_id,
|
|
792
|
+
prior_captured_at: prior.parsed.captured_at,
|
|
793
|
+
prior_evidence_hash: priorHash,
|
|
794
|
+
new_evidence_hash: result.evidence_hash,
|
|
795
|
+
status: priorHash === result.evidence_hash ? "unchanged" : "drifted",
|
|
796
|
+
};
|
|
797
|
+
} else {
|
|
798
|
+
result.diff_from_latest = { status: "no_prior_attestation_for_playbook", playbook_id: playbookId };
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// --ci: machine-readable verdict for CI gates.
|
|
803
|
+
//
|
|
804
|
+
// The detect phase classification is the host-specific signal — "is THIS
|
|
805
|
+
// environment exploitable for the catalogued CVEs". rwep.base is the
|
|
806
|
+
// worst-known catalog score for the domain, which is roughly constant
|
|
807
|
+
// regardless of the local environment; we don't fail CI on that alone or
|
|
808
|
+
// every CI run against a domain with KEV-listed catalog entries would
|
|
809
|
+
// perma-fail.
|
|
810
|
+
//
|
|
811
|
+
// Verdict:
|
|
812
|
+
// detected → FAIL (exit 2)
|
|
813
|
+
// inconclusive + rwep ≥ escalate
|
|
814
|
+
// → FAIL (the agent's evidence raised RWEP)
|
|
815
|
+
// not_detected → PASS (exit 0)
|
|
816
|
+
// inconclusive + rwep < escalate
|
|
817
|
+
// → PASS with WARN to stderr (visibility gap)
|
|
818
|
+
if (args.ci && result && result.phases) {
|
|
819
|
+
const classification = result.phases.detect && result.phases.detect.classification;
|
|
820
|
+
const rwep = result.phases.analyze && result.phases.analyze.rwep;
|
|
821
|
+
const threshold = rwep && rwep.threshold && rwep.threshold.escalate;
|
|
822
|
+
const adjusted = rwep && typeof rwep.adjusted === "number" ? rwep.adjusted : 0;
|
|
823
|
+
const escalate = typeof threshold === "number" && adjusted >= threshold;
|
|
824
|
+
|
|
825
|
+
emit(result, pretty);
|
|
826
|
+
|
|
827
|
+
if (classification === "detected") {
|
|
828
|
+
process.stderr.write(`[exceptd run --ci] FAIL: classification=detected rwep=${adjusted} threshold=${threshold}\n`);
|
|
829
|
+
process.exit(2);
|
|
830
|
+
}
|
|
831
|
+
if (classification === "inconclusive" && escalate) {
|
|
832
|
+
process.stderr.write(`[exceptd run --ci] FAIL: classification=inconclusive AND rwep=${adjusted} >= threshold=${threshold}\n`);
|
|
833
|
+
process.exit(2);
|
|
834
|
+
}
|
|
835
|
+
if (classification === "inconclusive") {
|
|
836
|
+
process.stderr.write(`[exceptd run --ci] PASS+WARN: classification=inconclusive rwep=${adjusted} < threshold=${threshold} (visibility gap)\n`);
|
|
837
|
+
} else {
|
|
838
|
+
process.stderr.write(`[exceptd run --ci] PASS: classification=${classification} rwep=${adjusted}\n`);
|
|
839
|
+
}
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
|
|
627
843
|
emit(result, pretty);
|
|
628
844
|
}
|
|
629
845
|
|
|
@@ -648,6 +864,24 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
|
648
864
|
return emitError(`run: failed to read evidence bundle: ${e.message}`, { evidence: args.evidence }, pretty);
|
|
649
865
|
}
|
|
650
866
|
}
|
|
867
|
+
// --evidence-dir <dir>: each <playbook-id>.json under the directory is read
|
|
868
|
+
// as that playbook's submission. Lets operators wire up one cron job that
|
|
869
|
+
// collects per-playbook evidence into a directory, then runs the whole
|
|
870
|
+
// contract in one pass.
|
|
871
|
+
if (args["evidence-dir"]) {
|
|
872
|
+
const dir = args["evidence-dir"];
|
|
873
|
+
if (!fs.existsSync(dir)) {
|
|
874
|
+
return emitError(`run: --evidence-dir ${dir} does not exist.`, null, pretty);
|
|
875
|
+
}
|
|
876
|
+
for (const f of fs.readdirSync(dir).filter(x => x.endsWith(".json"))) {
|
|
877
|
+
const pbId = f.replace(/\.json$/, "");
|
|
878
|
+
try {
|
|
879
|
+
bundle[pbId] = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
|
|
880
|
+
} catch (e) {
|
|
881
|
+
return emitError(`run: failed to parse --evidence-dir entry ${f}: ${e.message}`, null, pretty);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
}
|
|
651
885
|
|
|
652
886
|
const results = [];
|
|
653
887
|
for (const id of ids) {
|
|
@@ -668,18 +902,19 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
|
668
902
|
try {
|
|
669
903
|
const dir = path.join(process.cwd(), ".exceptd", "attestations", sessionId);
|
|
670
904
|
fs.mkdirSync(dir, { recursive: true });
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
);
|
|
905
|
+
const filePath = path.join(dir, `${id}.json`);
|
|
906
|
+
fs.writeFileSync(filePath, JSON.stringify({
|
|
907
|
+
session_id: sessionId,
|
|
908
|
+
playbook_id: id,
|
|
909
|
+
directive_id: directiveId,
|
|
910
|
+
evidence_hash: result.evidence_hash,
|
|
911
|
+
operator: perRunOpts.operator || null,
|
|
912
|
+
operator_consent: perRunOpts.operator_consent || null,
|
|
913
|
+
submission,
|
|
914
|
+
run_opts: { airGap: perRunOpts.airGap, forceStale: perRunOpts.forceStale, mode: perRunOpts.mode },
|
|
915
|
+
captured_at: new Date().toISOString(),
|
|
916
|
+
}, null, 2));
|
|
917
|
+
maybeSignAttestation(filePath);
|
|
683
918
|
} catch { /* non-fatal */ }
|
|
684
919
|
}
|
|
685
920
|
results.push(result);
|
|
@@ -760,11 +995,94 @@ function cmdIngest(runner, args, runOpts, pretty) {
|
|
|
760
995
|
emit(result, pretty);
|
|
761
996
|
}
|
|
762
997
|
|
|
998
|
+
/**
|
|
999
|
+
* Ed25519-sign an attestation file when .keys/private.pem is available
|
|
1000
|
+
* (matches lib/sign.js convention for skill signing). Writes a sidecar
|
|
1001
|
+
* `<file>.sig` alongside the attestation. Defense against post-hoc tampering
|
|
1002
|
+
* by anyone who can write to .exceptd/.
|
|
1003
|
+
*
|
|
1004
|
+
* Without a private key, writes a marker file documenting the signed=false
|
|
1005
|
+
* state so downstream tooling can distinguish "operator declined signing"
|
|
1006
|
+
* from "the .sig file was deleted by an attacker."
|
|
1007
|
+
*/
|
|
1008
|
+
function maybeSignAttestation(filePath) {
|
|
1009
|
+
const crypto = require("crypto");
|
|
1010
|
+
const sigPath = filePath + ".sig";
|
|
1011
|
+
const privKeyPath = path.join(PKG_ROOT, ".keys", "private.pem");
|
|
1012
|
+
const content = fs.readFileSync(filePath, "utf8");
|
|
1013
|
+
try {
|
|
1014
|
+
if (fs.existsSync(privKeyPath)) {
|
|
1015
|
+
const privateKey = fs.readFileSync(privKeyPath, "utf8");
|
|
1016
|
+
const sig = crypto.sign(null, Buffer.from(content, "utf8"), {
|
|
1017
|
+
key: privateKey,
|
|
1018
|
+
dsaEncoding: "ieee-p1363",
|
|
1019
|
+
});
|
|
1020
|
+
fs.writeFileSync(sigPath, JSON.stringify({
|
|
1021
|
+
algorithm: "Ed25519",
|
|
1022
|
+
signature_base64: sig.toString("base64"),
|
|
1023
|
+
signed_at: new Date().toISOString(),
|
|
1024
|
+
signs_path: path.basename(filePath),
|
|
1025
|
+
signs_sha256: crypto.createHash("sha256").update(content).digest("base64"),
|
|
1026
|
+
}, null, 2));
|
|
1027
|
+
} else {
|
|
1028
|
+
fs.writeFileSync(sigPath, JSON.stringify({
|
|
1029
|
+
algorithm: "unsigned",
|
|
1030
|
+
signed: false,
|
|
1031
|
+
signed_at: null,
|
|
1032
|
+
signs_path: path.basename(filePath),
|
|
1033
|
+
signs_sha256: crypto.createHash("sha256").update(content).digest("base64"),
|
|
1034
|
+
note: "No private key at .keys/private.pem — attestation is hash-stable but unsigned. Run `node lib/sign.js generate-keypair` to enable signing.",
|
|
1035
|
+
}, null, 2));
|
|
1036
|
+
}
|
|
1037
|
+
} catch { /* non-fatal — signing failure shouldn't block the run */ }
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
/**
|
|
1041
|
+
* Find the latest attestation file under .exceptd/attestations/.
|
|
1042
|
+
* Filters: optional playbook ID and optional "since" ISO timestamp.
|
|
1043
|
+
* Returns { sessionId, playbookId, file, parsed } or null.
|
|
1044
|
+
*/
|
|
1045
|
+
function findLatestAttestation(opts = {}) {
|
|
1046
|
+
const root = path.join(process.cwd(), ".exceptd", "attestations");
|
|
1047
|
+
if (!fs.existsSync(root)) return null;
|
|
1048
|
+
const sessions = fs.readdirSync(root, { withFileTypes: true })
|
|
1049
|
+
.filter(d => d.isDirectory())
|
|
1050
|
+
.map(d => d.name);
|
|
1051
|
+
const candidates = [];
|
|
1052
|
+
for (const sid of sessions) {
|
|
1053
|
+
const sdir = path.join(root, sid);
|
|
1054
|
+
for (const f of fs.readdirSync(sdir).filter(x => x.endsWith(".json"))) {
|
|
1055
|
+
try {
|
|
1056
|
+
const p = path.join(sdir, f);
|
|
1057
|
+
const j = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
1058
|
+
if (opts.playbookId && j.playbook_id !== opts.playbookId) continue;
|
|
1059
|
+
if (opts.since && (j.captured_at || "") < opts.since) continue;
|
|
1060
|
+
if (opts.excludeSessionId && sid === opts.excludeSessionId) continue;
|
|
1061
|
+
candidates.push({ sessionId: sid, playbookId: j.playbook_id, file: p, parsed: j });
|
|
1062
|
+
} catch { /* skip malformed */ }
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
candidates.sort((a, b) => (b.parsed.captured_at || "").localeCompare(a.parsed.captured_at || ""));
|
|
1066
|
+
return candidates[0] || null;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
763
1069
|
function cmdReattest(runner, args, runOpts, pretty) {
|
|
764
|
-
|
|
765
|
-
|
|
1070
|
+
// --latest [--playbook <id>] [--since <ISO>] — find prior attestation
|
|
1071
|
+
// without requiring the operator to know the session-id.
|
|
1072
|
+
let sessionId = args._[0];
|
|
1073
|
+
let attFile = null;
|
|
1074
|
+
if (!sessionId && args.latest) {
|
|
1075
|
+
const found = findLatestAttestation({
|
|
1076
|
+
playbookId: args.playbook ? (Array.isArray(args.playbook) ? args.playbook[0] : args.playbook) : null,
|
|
1077
|
+
since: args.since || null,
|
|
1078
|
+
});
|
|
1079
|
+
if (!found) return emitError("reattest: --latest found no matching attestations.", { filter: { playbook: args.playbook || null, since: args.since || null } }, pretty);
|
|
1080
|
+
sessionId = found.sessionId;
|
|
1081
|
+
attFile = found.file;
|
|
1082
|
+
}
|
|
1083
|
+
if (!sessionId) return emitError("reattest: missing <session-id>. Pass a session-id or --latest [--playbook <id>] [--since <ISO>].", null, pretty);
|
|
766
1084
|
const dir = path.join(process.cwd(), ".exceptd", "attestations", sessionId);
|
|
767
|
-
|
|
1085
|
+
if (!attFile) attFile = path.join(dir, "attestation.json");
|
|
768
1086
|
if (!fs.existsSync(attFile)) {
|
|
769
1087
|
return emitError(`reattest: no attestation found at ${path.relative(process.cwd(), attFile)}`, { session_id: sessionId }, pretty);
|
|
770
1088
|
}
|
|
@@ -836,6 +1154,119 @@ function cmdReattest(runner, args, runOpts, pretty) {
|
|
|
836
1154
|
}, pretty);
|
|
837
1155
|
}
|
|
838
1156
|
|
|
1157
|
+
/**
|
|
1158
|
+
* `exceptd attest <subverb> <session-id>` — auditor-facing operations on
|
|
1159
|
+
* persisted attestations. Subverbs:
|
|
1160
|
+
* export <session-id> Emit redacted JSON suitable for audit submission.
|
|
1161
|
+
* Strips raw artifact values; preserves only
|
|
1162
|
+
* evidence_hash + signatures + classification + RWEP.
|
|
1163
|
+
* Falls back to a CSAF-shaped envelope when --format csaf.
|
|
1164
|
+
* verify <session-id> Verify the .sig sidecar against keys/public.pem.
|
|
1165
|
+
* Reports signed_by + tamper status.
|
|
1166
|
+
* show <session-id> Emit the full (unredacted) attestation. Convenience
|
|
1167
|
+
* alias for `cat .exceptd/attestations/<sid>/attestation.json`.
|
|
1168
|
+
*/
|
|
1169
|
+
function cmdAttest(runner, args, runOpts, pretty) {
|
|
1170
|
+
const subverb = args._[0];
|
|
1171
|
+
const sessionId = args._[1];
|
|
1172
|
+
if (!subverb) {
|
|
1173
|
+
return emitError("attest: missing subverb. Usage: attest export|verify|show <session-id>", null, pretty);
|
|
1174
|
+
}
|
|
1175
|
+
if (!sessionId) {
|
|
1176
|
+
return emitError(`attest ${subverb}: missing <session-id> positional argument.`, null, pretty);
|
|
1177
|
+
}
|
|
1178
|
+
const dir = path.join(process.cwd(), ".exceptd", "attestations", sessionId);
|
|
1179
|
+
if (!fs.existsSync(dir)) {
|
|
1180
|
+
return emitError(`attest ${subverb}: no session dir at ${path.relative(process.cwd(), dir)}`, { session_id: sessionId }, pretty);
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
const files = fs.readdirSync(dir).filter(f => f.endsWith(".json") && !f.endsWith(".sig"));
|
|
1184
|
+
const attestations = files.map(f => {
|
|
1185
|
+
try { return JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")); }
|
|
1186
|
+
catch { return null; }
|
|
1187
|
+
}).filter(Boolean);
|
|
1188
|
+
|
|
1189
|
+
if (subverb === "show") {
|
|
1190
|
+
emit({ session_id: sessionId, attestations }, pretty);
|
|
1191
|
+
return;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
if (subverb === "verify") {
|
|
1195
|
+
const crypto = require("crypto");
|
|
1196
|
+
const pubKeyPath = path.join(PKG_ROOT, "keys", "public.pem");
|
|
1197
|
+
const pubKey = fs.existsSync(pubKeyPath) ? fs.readFileSync(pubKeyPath, "utf8") : null;
|
|
1198
|
+
const results = files.map(f => {
|
|
1199
|
+
const sigPath = path.join(dir, f + ".sig");
|
|
1200
|
+
if (!fs.existsSync(sigPath)) return { file: f, signed: false, verified: false, reason: "no .sig sidecar" };
|
|
1201
|
+
const sigDoc = JSON.parse(fs.readFileSync(sigPath, "utf8"));
|
|
1202
|
+
if (sigDoc.algorithm === "unsigned") return { file: f, signed: false, verified: false, reason: "attestation explicitly unsigned (no private key when written)" };
|
|
1203
|
+
if (!pubKey) return { file: f, signed: true, verified: false, reason: "no public key at keys/public.pem to verify against" };
|
|
1204
|
+
const content = fs.readFileSync(path.join(dir, f), "utf8");
|
|
1205
|
+
try {
|
|
1206
|
+
const ok = crypto.verify(null, Buffer.from(content, "utf8"), {
|
|
1207
|
+
key: pubKey, dsaEncoding: "ieee-p1363",
|
|
1208
|
+
}, Buffer.from(sigDoc.signature_base64, "base64"));
|
|
1209
|
+
return { file: f, signed: true, verified: !!ok, reason: ok ? "Ed25519 signature valid" : "Ed25519 signature INVALID — possible post-hoc tampering" };
|
|
1210
|
+
} catch (e) {
|
|
1211
|
+
return { file: f, signed: true, verified: false, reason: `verify error: ${e.message}` };
|
|
1212
|
+
}
|
|
1213
|
+
});
|
|
1214
|
+
emit({ verb: "attest verify", session_id: sessionId, results }, pretty);
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
if (subverb === "export") {
|
|
1219
|
+
// Redaction: strip raw `value` fields from submitted artifacts; preserve
|
|
1220
|
+
// captured-state flag, evidence_hash, classification, RWEP, confidence,
|
|
1221
|
+
// remediation choice, residual risk acceptance, signature. Auditors get
|
|
1222
|
+
// what they need (the verdict + proof of process) without leaking raw
|
|
1223
|
+
// captured data (which may contain PII / secret shapes).
|
|
1224
|
+
const format = args.format || "json";
|
|
1225
|
+
const redacted = attestations.map(a => ({
|
|
1226
|
+
session_id: a.session_id,
|
|
1227
|
+
playbook_id: a.playbook_id,
|
|
1228
|
+
directive_id: a.directive_id,
|
|
1229
|
+
evidence_hash: a.evidence_hash,
|
|
1230
|
+
operator: a.operator,
|
|
1231
|
+
operator_consent: a.operator_consent,
|
|
1232
|
+
captured_at: a.captured_at,
|
|
1233
|
+
run_opts: a.run_opts,
|
|
1234
|
+
artifacts_redacted: Object.fromEntries(Object.entries((a.submission && a.submission.artifacts) || {})
|
|
1235
|
+
.map(([k, v]) => [k, { captured: !!v.captured, reason: v.reason || null, redacted_value: "[redacted]" }])),
|
|
1236
|
+
signal_overrides: (a.submission && a.submission.signal_overrides) || {},
|
|
1237
|
+
signals_redacted: Object.fromEntries(Object.entries((a.submission && a.submission.signals) || {})
|
|
1238
|
+
.filter(([k]) => !/_filter$|_key$|token|secret|password/i.test(k))),
|
|
1239
|
+
precondition_checks: (a.submission && a.submission.precondition_checks) || {},
|
|
1240
|
+
}));
|
|
1241
|
+
|
|
1242
|
+
if (format === "csaf") {
|
|
1243
|
+
// Lightweight CSAF envelope for audit submission — caller can post this
|
|
1244
|
+
// directly to a CSAF-aware GRC platform.
|
|
1245
|
+
emit({
|
|
1246
|
+
document: {
|
|
1247
|
+
category: "csaf_security_advisory",
|
|
1248
|
+
csaf_version: "2.0",
|
|
1249
|
+
publisher: { category: "vendor", name: "exceptd", namespace: "https://exceptd.com" },
|
|
1250
|
+
title: `Auditor export — session ${sessionId}`,
|
|
1251
|
+
tracking: { id: `exceptd-export-${sessionId}`, status: "final", version: "1", initial_release_date: new Date().toISOString() },
|
|
1252
|
+
},
|
|
1253
|
+
exceptd_export: { session_id: sessionId, attestations: redacted, exported_at: new Date().toISOString(), redaction_policy: "v0.10.3-default" },
|
|
1254
|
+
}, pretty);
|
|
1255
|
+
} else {
|
|
1256
|
+
emit({
|
|
1257
|
+
verb: "attest export",
|
|
1258
|
+
session_id: sessionId,
|
|
1259
|
+
exported_at: new Date().toISOString(),
|
|
1260
|
+
redaction_policy: "v0.10.3-default — artifact values stripped; signal_overrides + precondition_checks + evidence_hash + signature preserved.",
|
|
1261
|
+
attestations: redacted,
|
|
1262
|
+
}, pretty);
|
|
1263
|
+
}
|
|
1264
|
+
return;
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
return emitError(`attest: unknown subverb "${subverb}". Try export | verify | show.`, null, pretty);
|
|
1268
|
+
}
|
|
1269
|
+
|
|
839
1270
|
function cmdListAttestations(runner, args, runOpts, pretty) {
|
|
840
1271
|
const root = path.join(process.cwd(), ".exceptd", "attestations");
|
|
841
1272
|
if (!fs.existsSync(root)) {
|
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-12T14:08:27.651Z",
|
|
4
4
|
"generator": "scripts/build-indexes.js",
|
|
5
5
|
"source_count": 49,
|
|
6
6
|
"source_hashes": {
|
|
7
|
-
"manifest.json": "
|
|
7
|
+
"manifest.json": "35c0e5e79c31c68c0a45e0bcad009fd34c935df5d0ba738e1f106a9be0c3d357",
|
|
8
8
|
"data/atlas-ttps.json": "1500b5830dab070c4252496964a8c0948e1052a656e2c7c6e1efaf0350645e13",
|
|
9
9
|
"data/cve-catalog.json": "a81d3e4b491b27ccc084596b063a6108ff10c9eb01d7776922fc393980b534fe",
|
|
10
10
|
"data/cwe-catalog.json": "c3367d469b4b3d31e4c56397dd7a8305a0be338ecd85afa27804c0c9ce12157b",
|