@blamejs/exceptd-skills 0.10.2 → 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/CHANGELOG.md +31 -0
- package/bin/exceptd.js +324 -32
- package/data/_indexes/_meta.json +2 -2
- package/lib/playbook-runner.js +97 -7
- 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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.10.3 — 2026-05-12
|
|
4
|
+
|
|
5
|
+
**Patch: 14 operator-reported items — 5 bugs + 9 features.**
|
|
6
|
+
|
|
7
|
+
### Bugs
|
|
8
|
+
|
|
9
|
+
1. **`exceptd validate-cves` crashed with `MODULE_NOT_FOUND`** in the installed npm package because `sources/` wasn't in the `files` allowlist. Two-part fix: (a) `sources/validators/` added to `package.json` `files`; (b) `runValidateCves` now wraps the require in the same try/catch graceful-fallback pattern `runValidateRfcs` was already using, so the command degrades to offline mode instead of crashing.
|
|
10
|
+
2. **Inconsistent error shapes across verbs.** `exceptd <unknown>` and `exceptd skill <missing>` emitted plain stderr text while seven-phase verbs emitted structured JSON. Unified: every CLI verb now emits `{ok:false,error,hint,verb}` JSON on error so operators piping through `jq` get one shape.
|
|
11
|
+
3. **`prefetch --no-network --quiet` was completely silent on success.** Now emits a one-line `prefetch summary: …` unconditionally; `--quiet` suppresses only the per-entry chatter.
|
|
12
|
+
4. **`plan --directives` exposed `id + title + applies_to` only — no `description`.** Now also surfaces a `description` field (falls back through explicit `directive.description` → `phase_overrides.direct.threat_context` → playbook-level `direct.threat_context` first sentence → `domain.name`) plus a `threat_context_preview`. Operators / AIs get operator-facing prose, not just an ID + enum.
|
|
13
|
+
5. **Analyst verbs (`scan`/`dispatch`/`currency`/`watchlist`/`report`) defaulted to human-readable text** while every seven-phase verb defaulted to JSON. Added `--json` flag passthrough across all analyst verbs. Operators scripting around both surfaces now have a consistent switch.
|
|
14
|
+
|
|
15
|
+
### Features
|
|
16
|
+
|
|
17
|
+
6. **`run --explain` dry-run** — emits preconditions, required + optional artifacts (with fallback notes), recognized signal keys with types + deterministic flags, and a `submission_skeleton` JSON the operator can fill in. No detect/analyze/validate/close happens. Lets operators preview before assembling evidence.
|
|
18
|
+
7. **`attest <subverb> <session-id>`** — `attest export` emits redacted JSON for audit submission (strips raw artifact values, preserves evidence_hash + signature + classification + RWEP + remediation choice + residual risk acceptance). `--format csaf` wraps the export in a CSAF envelope. `attest verify` checks the `.sig` sidecar against `keys/public.pem` and reports tamper status. `attest show` emits the full unredacted attestation.
|
|
19
|
+
8. **`run --signal-list`** — lighter than `--explain`; enumerates only the signal_overrides keys the detect phase recognizes plus the four valid `detection_classification` values. Closes the "agent submits a key and runner silently ignores it" gap (v0.10.1 bug #5).
|
|
20
|
+
9. **Continuous-compliance: `run --evidence-dir <dir>`** — each `<playbook-id>.json` under the directory becomes that playbook's submission in a multi-playbook run. One cron job → full posture in one CSAF bundle. Pairs with `run --all`.
|
|
21
|
+
10. **`validate-cves` + `validate-rfcs` gained `--since <ISO|YYYY-MM-DD>`** — scope-limit validation to entries whose `last_updated` / `cisa_kev_date` / `last_verified` / `published` is on or after the date. Cuts upstream calls for fleet operators running cron.
|
|
22
|
+
11. **Ed25519-signed attestations** — every `attestation.json` now gets a `<file>.sig` sidecar. With `.keys/private.pem` present, the runner signs (matches the existing skill-signing convention). Without a private key, writes an `unsigned` marker file so downstream tooling can distinguish "operator declined signing" from "the .sig file was deleted by an attacker." `attest verify` cross-checks the signature against `keys/public.pem`.
|
|
23
|
+
12. **`run --operator <name>`** — binds the attestation to a specific human or service identity. Persisted under `attestation.operator` for multi-operator audit-trail accountability.
|
|
24
|
+
13. **`run --ack`** — explicit operator consent to the jurisdiction obligations surfaced by `govern`. Persisted under `attestation.operator_consent = { acked_at, explicit: true }`. Without `--ack`, the field is null (consent implicit / unverified).
|
|
25
|
+
14. **`run --format <fmt>` repeatable** — emit the close.evidence_package in additional formats alongside the playbook-declared primary. Supported: `csaf-2.0` (primary), `sarif` (2.1.0 — GitHub Code Scanning / VS Code SARIF Viewer / Azure DevOps), `openvex` (0.2.0 — sigstore / in-toto / GUAC consumers), `markdown` (human review). Extras populate `close.evidence_package.bundles_by_format`.
|
|
26
|
+
|
|
27
|
+
### Internal
|
|
28
|
+
|
|
29
|
+
- `lib/playbook-runner.js` `buildEvidenceBundle` now handles `csaf-2.0`, `sarif` (with per-CVE rules + properties), `openvex` (with status derived from active_exploitation + live_patch_available), and `markdown`.
|
|
30
|
+
- `bin/exceptd.js` `maybeSignAttestation` helper uses the same Ed25519 primitive as `lib/sign.js` against `.keys/private.pem`.
|
|
31
|
+
- CSAF envelope cvss_v3.base_score now reflects the catalog's real cvss_score (previously hardcoded 0).
|
|
32
|
+
- `submission.signals._bundle_formats` is the agent-side hook for requesting extra formats.
|
|
33
|
+
|
|
3
34
|
## 0.10.2 — 2026-05-12
|
|
4
35
|
|
|
5
36
|
**Patch: v0.10.1 deferred set — framework-gap filter fix, VEX consumption, CI gating, drift mode, 2 new playbooks (13 total), feeds_into matrix.**
|
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,11 +444,27 @@ 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.
|
|
432
449
|
--vex <file> Load a CycloneDX or OpenVEX document. CVEs marked
|
|
433
450
|
not_affected | resolved | false_positive (CycloneDX)
|
|
434
451
|
or not_affected | fixed (OpenVEX) drop out of
|
|
435
452
|
analyze.matched_cves. The disposition is preserved
|
|
436
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.
|
|
437
468
|
--diff-from-latest Compare evidence_hash against the most recent prior
|
|
438
469
|
attestation for the same playbook in
|
|
439
470
|
.exceptd/attestations/. Emits status: unchanged | drifted.
|
|
@@ -474,6 +505,18 @@ Args / flags:
|
|
|
474
505
|
|
|
475
506
|
Lists every attestation under .exceptd/attestations/<session_id>/, sorted
|
|
476
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.`,
|
|
477
520
|
};
|
|
478
521
|
process.stdout.write((cmds[verb] || `${verb} — no per-verb help available; see \`exceptd help\` for the full list.`) + "\n");
|
|
479
522
|
}
|
|
@@ -498,13 +541,27 @@ function cmdPlan(runner, args, runOpts, pretty) {
|
|
|
498
541
|
Object.entries(plan.grouped_by_scope).map(([s, list]) => [s, list.length])
|
|
499
542
|
);
|
|
500
543
|
}
|
|
501
|
-
// --directives expands each playbook entry with
|
|
502
|
-
// applies_to
|
|
503
|
-
//
|
|
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.
|
|
504
549
|
if (args.directives) {
|
|
505
550
|
for (const pb of plan.playbooks) {
|
|
506
551
|
const full = runner.loadPlaybook(pb.id);
|
|
507
|
-
|
|
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
|
+
});
|
|
508
565
|
}
|
|
509
566
|
}
|
|
510
567
|
emit(plan, pretty);
|
|
@@ -606,6 +663,53 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
606
663
|
const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
|
|
607
664
|
if (!directiveId) return emitError(`run: playbook ${playbookId} has no directives.`, null, pretty);
|
|
608
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
|
+
|
|
609
713
|
let submission = {};
|
|
610
714
|
if (args.evidence) {
|
|
611
715
|
try {
|
|
@@ -621,6 +725,15 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
621
725
|
runOpts.precondition_checks = submission.precondition_checks;
|
|
622
726
|
}
|
|
623
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
|
+
|
|
624
737
|
// --vex <file>: load a CycloneDX/OpenVEX document and pass the not_affected
|
|
625
738
|
// CVE ID set through to analyze() so matched_cves drops them.
|
|
626
739
|
if (args.vex) {
|
|
@@ -641,18 +754,23 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
641
754
|
try {
|
|
642
755
|
const dir = path.join(process.cwd(), ".exceptd", "attestations", result.session_id);
|
|
643
756
|
fs.mkdirSync(dir, { recursive: true });
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
);
|
|
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"));
|
|
656
774
|
} catch { /* non-fatal — attestation persistence is best-effort */ }
|
|
657
775
|
}
|
|
658
776
|
|
|
@@ -746,6 +864,24 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
|
746
864
|
return emitError(`run: failed to read evidence bundle: ${e.message}`, { evidence: args.evidence }, pretty);
|
|
747
865
|
}
|
|
748
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
|
+
}
|
|
749
885
|
|
|
750
886
|
const results = [];
|
|
751
887
|
for (const id of ids) {
|
|
@@ -766,18 +902,19 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
|
766
902
|
try {
|
|
767
903
|
const dir = path.join(process.cwd(), ".exceptd", "attestations", sessionId);
|
|
768
904
|
fs.mkdirSync(dir, { recursive: true });
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
);
|
|
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);
|
|
781
918
|
} catch { /* non-fatal */ }
|
|
782
919
|
}
|
|
783
920
|
results.push(result);
|
|
@@ -858,6 +995,48 @@ function cmdIngest(runner, args, runOpts, pretty) {
|
|
|
858
995
|
emit(result, pretty);
|
|
859
996
|
}
|
|
860
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
|
+
|
|
861
1040
|
/**
|
|
862
1041
|
* Find the latest attestation file under .exceptd/attestations/.
|
|
863
1042
|
* Filters: optional playbook ID and optional "since" ISO timestamp.
|
|
@@ -975,6 +1154,119 @@ function cmdReattest(runner, args, runOpts, pretty) {
|
|
|
975
1154
|
}, pretty);
|
|
976
1155
|
}
|
|
977
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
|
+
|
|
978
1270
|
function cmdListAttestations(runner, args, runOpts, pretty) {
|
|
979
1271
|
const root = path.join(process.cwd(), ".exceptd", "attestations");
|
|
980
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",
|
package/lib/playbook-runner.js
CHANGED
|
@@ -586,13 +586,23 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
586
586
|
}
|
|
587
587
|
}
|
|
588
588
|
|
|
589
|
-
// evidence_package
|
|
589
|
+
// evidence_package — playbook declares one primary bundle_format; the
|
|
590
|
+
// operator may request additional formats via agentSignals._bundle_formats
|
|
591
|
+
// (e.g. SARIF for GitHub Code Scanning + OpenVEX for supply-chain tooling
|
|
592
|
+
// alongside the CSAF default).
|
|
593
|
+
const primaryFormat = c.evidence_package?.bundle_format || 'csaf-2.0';
|
|
594
|
+
const extraFormats = Array.isArray(agentSignals._bundle_formats)
|
|
595
|
+
? agentSignals._bundle_formats.filter(f => f !== primaryFormat)
|
|
596
|
+
: [];
|
|
590
597
|
const evidencePackage = c.evidence_package ? {
|
|
591
|
-
bundle_format:
|
|
598
|
+
bundle_format: primaryFormat,
|
|
592
599
|
contents: c.evidence_package.contents || [],
|
|
593
600
|
destination: c.evidence_package.destination || 'local_only',
|
|
594
601
|
signed: c.evidence_package.signed !== false,
|
|
595
|
-
bundle_body: buildEvidenceBundle(
|
|
602
|
+
bundle_body: buildEvidenceBundle(primaryFormat, playbook, analyzeResult, validateResult, agentSignals),
|
|
603
|
+
bundles_by_format: extraFormats.length ? Object.fromEntries(
|
|
604
|
+
[primaryFormat, ...extraFormats].map(f => [f, buildEvidenceBundle(f, playbook, analyzeResult, validateResult, agentSignals)])
|
|
605
|
+
) : null,
|
|
596
606
|
} : null;
|
|
597
607
|
|
|
598
608
|
if (evidencePackage && evidencePackage.signed && runOpts.session_key) {
|
|
@@ -687,22 +697,102 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals)
|
|
|
687
697
|
},
|
|
688
698
|
vulnerabilities: analyze.matched_cves.map(c => ({
|
|
689
699
|
cve: c.cve_id,
|
|
690
|
-
scores: [{ products: [], cvss_v3: { base_score: 0 } }],
|
|
700
|
+
scores: [{ products: [], cvss_v3: { base_score: c.cvss_score || 0 } }],
|
|
691
701
|
threats: c.active_exploitation === 'confirmed' ? [{ category: 'exploit_status', details: 'Active exploitation confirmed (CISA KEV).' }] : [],
|
|
692
702
|
remediations: [{ category: 'vendor_fix', details: validate.selected_remediation?.description || 'See selected remediation path.' }]
|
|
693
703
|
})),
|
|
694
704
|
exceptd_extension: {
|
|
695
705
|
rwep: analyze.rwep,
|
|
696
706
|
blast_radius_score: analyze.blast_radius_score,
|
|
697
|
-
compliance_theater: analyze.
|
|
707
|
+
compliance_theater: analyze.compliance_theater_check,
|
|
698
708
|
framework_gap_mapping: analyze.framework_gap_mapping,
|
|
699
709
|
evidence_requirements: validate.evidence_requirements,
|
|
700
710
|
residual_risk_statement: validate.residual_risk_statement
|
|
701
711
|
}
|
|
702
712
|
};
|
|
703
713
|
}
|
|
704
|
-
|
|
705
|
-
|
|
714
|
+
|
|
715
|
+
// SARIF 2.1.0 — GitHub Code Scanning / VS Code SARIF Viewer / Azure DevOps
|
|
716
|
+
// and most static analysis tooling. One run per playbook directive, one
|
|
717
|
+
// result per matched CVE. Each result references a rule (cve_id) and ties
|
|
718
|
+
// back to the directive as the "tool" producer.
|
|
719
|
+
if (format === 'sarif' || format === 'sarif-2.1.0') {
|
|
720
|
+
return {
|
|
721
|
+
$schema: 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json',
|
|
722
|
+
version: '2.1.0',
|
|
723
|
+
runs: [{
|
|
724
|
+
tool: {
|
|
725
|
+
driver: {
|
|
726
|
+
name: 'exceptd',
|
|
727
|
+
version: playbook._meta.version,
|
|
728
|
+
informationUri: 'https://exceptd.com',
|
|
729
|
+
rules: analyze.matched_cves.map(c => ({
|
|
730
|
+
id: c.cve_id,
|
|
731
|
+
shortDescription: { text: c.cve_id },
|
|
732
|
+
fullDescription: { text: `RWEP ${c.rwep} · KEV=${c.cisa_kev} · active_exploitation=${c.active_exploitation} · PoC=${c.poc_available}` },
|
|
733
|
+
defaultConfiguration: { level: c.rwep >= 90 ? 'error' : c.rwep >= 70 ? 'warning' : 'note' },
|
|
734
|
+
helpUri: `https://nvd.nist.gov/vuln/detail/${c.cve_id}`,
|
|
735
|
+
}))
|
|
736
|
+
}
|
|
737
|
+
},
|
|
738
|
+
results: analyze.matched_cves.map(c => ({
|
|
739
|
+
ruleId: c.cve_id,
|
|
740
|
+
level: c.rwep >= 90 ? 'error' : c.rwep >= 70 ? 'warning' : 'note',
|
|
741
|
+
message: { text: `${c.cve_id}: RWEP ${c.rwep}, blast_radius ${analyze.blast_radius_score}. ${validate.selected_remediation?.description || ''}` },
|
|
742
|
+
properties: {
|
|
743
|
+
rwep: c.rwep,
|
|
744
|
+
cisa_kev: c.cisa_kev,
|
|
745
|
+
cisa_kev_due_date: c.cisa_kev_due_date,
|
|
746
|
+
active_exploitation: c.active_exploitation,
|
|
747
|
+
ai_discovered: c.ai_discovered,
|
|
748
|
+
blast_radius_score: analyze.blast_radius_score,
|
|
749
|
+
framework_gaps: analyze.framework_gap_mapping?.length || 0,
|
|
750
|
+
}
|
|
751
|
+
}))
|
|
752
|
+
}]
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// OpenVEX 0.2.0 — supply-chain VEX statements. Each matched CVE becomes a
|
|
757
|
+
// statement with status derived from confidence + RWEP. Downstream tools
|
|
758
|
+
// (sigstore, in-toto, GUAC) consume this directly.
|
|
759
|
+
if (format === 'openvex' || format === 'openvex-0.2.0') {
|
|
760
|
+
const issued = new Date().toISOString();
|
|
761
|
+
return {
|
|
762
|
+
'@context': 'https://openvex.dev/ns/v0.2.0',
|
|
763
|
+
'@id': `https://exceptd.com/vex/${playbook._meta.id}/${Date.now()}`,
|
|
764
|
+
author: 'exceptd',
|
|
765
|
+
timestamp: issued,
|
|
766
|
+
version: 1,
|
|
767
|
+
statements: analyze.matched_cves.map(c => ({
|
|
768
|
+
vulnerability: { '@id': c.cve_id, name: c.cve_id },
|
|
769
|
+
status: c.active_exploitation === 'confirmed' ? 'under_investigation' : (c.live_patch_available ? 'fixed' : 'affected'),
|
|
770
|
+
timestamp: issued,
|
|
771
|
+
action_statement: validate.selected_remediation?.description || null,
|
|
772
|
+
impact_statement: `RWEP ${c.rwep}. Blast radius ${analyze.blast_radius_score}/5.`
|
|
773
|
+
}))
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
if (format === 'markdown') {
|
|
778
|
+
const lines = [
|
|
779
|
+
`# exceptd finding: ${playbook.domain.name}`,
|
|
780
|
+
`**Playbook:** ${playbook._meta.id} v${playbook._meta.version}`,
|
|
781
|
+
`**Matched CVEs:** ${analyze.matched_cves.length}`,
|
|
782
|
+
`**Top RWEP:** ${analyze.rwep?.adjusted || 0}`,
|
|
783
|
+
`**Blast radius:** ${analyze.blast_radius_score || 'unknown'}/5`,
|
|
784
|
+
`**Theater verdict:** ${analyze.compliance_theater_check?.verdict || 'n/a'}`,
|
|
785
|
+
`\n## Matched CVEs`,
|
|
786
|
+
...analyze.matched_cves.map(c => `- **${c.cve_id}** RWEP ${c.rwep} · KEV=${c.cisa_kev} · ${c.active_exploitation}`),
|
|
787
|
+
`\n## Selected remediation`,
|
|
788
|
+
validate.selected_remediation ? `${validate.selected_remediation.id} (priority ${validate.selected_remediation.priority}): ${validate.selected_remediation.description}` : 'No remediation path selected.',
|
|
789
|
+
`\n## Residual risk`,
|
|
790
|
+
validate.residual_risk_statement ? `${validate.residual_risk_statement.risk}\n\n_Acceptance level: ${validate.residual_risk_statement.acceptance_level}_` : 'None recorded.',
|
|
791
|
+
];
|
|
792
|
+
return { format: 'markdown', body: lines.join('\n') };
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
return { format, note: 'Unknown format — supported: csaf-2.0, sarif, openvex, markdown.', analyze, validate };
|
|
706
796
|
}
|
|
707
797
|
|
|
708
798
|
// --- orchestrate: full run in one call ---
|