@blamejs/exceptd-skills 0.14.10 → 0.14.12
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 +47 -0
- package/bin/exceptd.js +195 -57
- package/data/_indexes/_meta.json +2 -2
- package/lib/citation-resolve.js +4 -1
- package/lib/collectors/cicd-pipeline-compromise.js +8 -2
- package/lib/collectors/citation-hygiene.js +10 -5
- package/lib/collectors/crypto-codebase.js +11 -6
- package/lib/collectors/sbom.js +9 -2
- package/lib/collectors/scan-excludes.js +0 -0
- package/lib/collectors/secrets.js +32 -4
- package/lib/cve-cli.js +12 -4
- package/lib/framework-gap.js +21 -2
- package/lib/playbook-runner.js +41 -20
- package/lib/prefetch.js +35 -1
- package/lib/refresh-external.js +70 -4
- package/lib/refresh-network.js +16 -1
- package/lib/rfc-cli.js +7 -2
- package/lib/schemas/playbook.schema.json +3 -1
- package/lib/scoring.js +8 -1
- package/lib/validate-playbooks.js +119 -0
- package/manifest.json +44 -44
- package/orchestrator/index.js +121 -14
- package/package.json +1 -1
- package/sbom.cdx.json +50 -50
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,52 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.14.12 — 2026-05-27
|
|
4
|
+
|
|
5
|
+
Structured-bundle accuracy:
|
|
6
|
+
- CSAF advisories no longer attribute exploitation to the CISA KEV catalog for a CVE that is confirmed-exploited but not actually in KEV — the "(CISA KEV)" parenthetical is now conditional on the CVE's KEV status.
|
|
7
|
+
- An empty-evidence run emits a `csaf_informational_advisory` instead of a `csaf_security_advisory` with an empty `vulnerabilities` array (Profile 4 expects vulnerabilities; the informational profile does not).
|
|
8
|
+
- SARIF `cve_match` results now carry a `locations` entry. Without it, GitHub Code Scanning silently dropped the highest-severity result class.
|
|
9
|
+
- SARIF and OpenVEX render "not assessed" for an unassessed blast radius instead of the literal "null" / "null/5".
|
|
10
|
+
- `ci --format csaf|sarif|openvex` emits a JSON array of the pure documents instead of an exceptd wrapper carrying a top-level `ok` key (which is invalid in all three formats). Each array element is now a conformant document.
|
|
11
|
+
|
|
12
|
+
External-source command hardening:
|
|
13
|
+
- `validate-rfcs` / `validate-cves` reject an unknown flag before doing any work, instead of silently defaulting to a live-network run that hangs on a typo'd flag.
|
|
14
|
+
- `cve` and `rfc` now return `ok:false` (not `ok:true`) when the citation fails to stand up — the envelope matched the exit code was already 2, but `ok` was inverted.
|
|
15
|
+
- `refresh`, `prefetch`, and the `scan`/`dispatch`/`currency`/`watchlist` verbs reject unknown flags instead of silently ignoring them; the latter four also emit a top-level `ok` in their `--json` output.
|
|
16
|
+
- `framework-gap` and `skill` honor `--json` on their missing-argument paths (structured error, not plain text), and `skill --json` no longer treats `--json` as the skill name.
|
|
17
|
+
|
|
18
|
+
`doctor`:
|
|
19
|
+
- `doctor --rfcs` counts the whole RFC catalog (including the CSAF/draft/ISO citation families it previously dropped) with a `by_prefix` breakdown, and its freshness fields read the real catalog file instead of a path that never existed.
|
|
20
|
+
- `doctor --fix` re-verifies signatures after generating a key and signing, so a successful bootstrap reports success (exit 0) rather than carrying the pre-fix "signatures failed" state through to a non-zero exit. It also refuses to generate a key when a fingerprint pin is present without the public key (a corrupted checkout) rather than producing an install that can never verify.
|
|
21
|
+
- `doctor --shipped-tarball` runs the tarball round-trip even when combined with another selective flag (it was silently skipped). `doctor --ai-config` reports a warning when its scan hits the file cap, rather than an unqualified clean pass on an incomplete walk.
|
|
22
|
+
|
|
23
|
+
Playbook validation hardening (enforcement for future drift; the shipped corpus is unaffected):
|
|
24
|
+
- `domain.attack_refs` are cross-referenced against the ATT&CK catalog (they were unchecked).
|
|
25
|
+
- An air-gap playbook with a network-sourced artifact lacking an `air_gap_alternative` is now rejected (the schema's air-gap conditional was never executed by the validator).
|
|
26
|
+
- Empty `detect.indicators` / `look.artifacts` are rejected; every playbook must map to at least one real TTP (cross-cutting analysis playbooks excepted). Dangling `false_positive_profile` indicator references and invalid `clock_starts` / `frameworks_in_scope` values now fail validation instead of passing as warnings.
|
|
27
|
+
|
|
28
|
+
RWEP factor validation accepts a numeric string consistently with the scorer (the two surfaces previously disagreed).
|
|
29
|
+
|
|
30
|
+
## 0.14.11 — 2026-05-27
|
|
31
|
+
|
|
32
|
+
Security: `reattest <session-id>` now validates the session-id before it is joined into a filesystem path, the same gate the other read verbs use. A `../`-bearing id previously escaped the attestation root — reading a forged attestation and writing a signed replay record outside the root. Such an id is now refused (exit 1) and nothing is written.
|
|
33
|
+
|
|
34
|
+
Air-gap is now honored on every external-source path that previously leaked. `watchlist --org-scan`, `refresh --network`, and `prefetch` all consulted the network even under `--air-gap` / `EXCEPTD_AIR_GAP=1`; each now refuses (or, for `prefetch`, runs report-only) instead of egressing.
|
|
35
|
+
|
|
36
|
+
The `sbom` collector no longer reports `lockfile-no-integrity` on every clean repository. It counted the npm lockfile's root entry — which legitimately has no integrity hash — as a missing-integrity dependency, so the indicator fired on any normal `package-lock.json`. It now counts only remote-tarball entries that lack integrity.
|
|
37
|
+
|
|
38
|
+
The `secrets` collector no longer fires on the published AWS documentation example key (`AKIAIOSFODNN7EXAMPLE`), and a text file skipped for exceeding the size limit is now surfaced in `collector_errors` instead of being dropped silently. Secret/citation/crypto findings now carry the exact line in their evidence locations, so SARIF points at the line rather than the file.
|
|
39
|
+
|
|
40
|
+
Cache-integrity refusals during `refresh` (sha256 mismatch, tampered or unindexed cache) now exit 4 — the documented "cache precondition failed" code — instead of the generic 1. `refresh --source ""` errors with the valid-source list instead of silently running every source; `cve " "` (whitespace) is treated as a missing argument; `refresh --advisory " "` gets the dedicated empty-advisory message. `refresh --help` documents exit 1 and the full meaning of exit 4.
|
|
41
|
+
|
|
42
|
+
Human-readable output gaps closed across several verbs:
|
|
43
|
+
- `run --all` / `run-all` print a per-playbook summary table instead of dumping the full JSON.
|
|
44
|
+
- `attest diff --against` renders the same one-screen summary the no-argument form already did, rather than raw JSON.
|
|
45
|
+
- A matched CVE renders `KEV=Y`/`KEV=N` (not the raw boolean); a deterministic indicator no longer prints `deterministic/deterministic`; a truncated remediation, an over-long fired-indicator list, and the `ci` framework-gap / jurisdiction-clock rollups now show how much was elided; a preflight warning that carries its text in `message` and a runtime warning that carries only context fields are now shown instead of `(no detail)` / a blank line.
|
|
46
|
+
- `framework-gap <framework> <scenario>` summary line counts only the queried framework's gaps, matching the per-framework body (it previously reported the all-frameworks total).
|
|
47
|
+
- `report executive` writes its progress notice to stderr so piped markdown is clean.
|
|
48
|
+
- The synopsis now describes `watchlist` (the one-shot forward-watch aggregator) and `watch` (the long-running daemon) correctly; the inverted deprecation arrow is gone. `cve`/`rfc` help states their exit-2 contract.
|
|
49
|
+
|
|
3
50
|
## 0.14.10 — 2026-05-27
|
|
4
51
|
|
|
5
52
|
`ci <playbook> --evidence -` no longer reports a false PASS when handed a flat submission. `run` accepts a flat submission (`{ "signal_overrides": {...} }`) and so do operators by habit; `ci` keyed the input by playbook id, found nothing under that key, and evaluated an empty submission — a detected finding came back PASS. A single-positional `ci` invocation now treats a flat (non-bundle-shaped) submission as belonging to that playbook, so `ci` and `run` agree. A real bundle keyed by playbook id is still routed per-key.
|
package/bin/exceptd.js
CHANGED
|
@@ -464,7 +464,8 @@ Canonical verbs
|
|
|
464
464
|
(citation-hygiene) resolves uncatalogued citations.
|
|
465
465
|
skill <name> Show context for a specific skill.
|
|
466
466
|
framework-gap <fw> <ref> Programmatic gap analysis (one framework, one CVE/scenario).
|
|
467
|
-
|
|
467
|
+
watchlist [--alerts] Forward-watch aggregator across skills (one-shot).
|
|
468
|
+
watch Long-running forward-watch daemon (blocks; Ctrl-C).
|
|
468
469
|
report [executive] Structured posture report.
|
|
469
470
|
path Absolute path to the installed package.
|
|
470
471
|
version Package version.
|
|
@@ -519,7 +520,6 @@ surfaces.
|
|
|
519
520
|
[DEPRECATED] verify → doctor --signatures
|
|
520
521
|
[DEPRECATED] validate-cves → doctor --cves
|
|
521
522
|
[DEPRECATED] validate-rfcs → doctor --rfcs
|
|
522
|
-
[DEPRECATED] watchlist → watch
|
|
523
523
|
[DEPRECATED] prefetch → refresh --no-network
|
|
524
524
|
[DEPRECATED] build-indexes → refresh --indexes-only
|
|
525
525
|
|
|
@@ -759,8 +759,8 @@ function main() {
|
|
|
759
759
|
skill: "exceptd skill <name> Show the full context document for one skill.",
|
|
760
760
|
"framework-gap": "exceptd framework-gap <framework> <cve-or-scenario> One-framework gap analysis.",
|
|
761
761
|
"framework-gap-analysis": "exceptd framework-gap <framework> <cve-or-scenario> One-framework gap analysis.",
|
|
762
|
-
cve: "exceptd cve <CVE-ID> [--json] [--air-gap|--no-network] Resolve a CVE: published/rejected/disputed/fabricated/nonexistent (catalog -> cache -> NVD).",
|
|
763
|
-
rfc: "exceptd rfc <number> [--check \"<title>\"] [--json] [--air-gap] Resolve an RFC number -> title + status (local index, offline).",
|
|
762
|
+
cve: "exceptd cve <CVE-ID> [--json] [--air-gap|--no-network] Resolve a CVE: published/rejected/disputed/fabricated/nonexistent (catalog -> cache -> NVD). Exit 2 when the citation won't stand up (rejected/fabricated/nonexistent/withdrawn).",
|
|
763
|
+
rfc: "exceptd rfc <number> [--check \"<title>\"] [--json] [--air-gap] Resolve an RFC number -> title + status (local index, offline). Exit 2 when nonexistent or --check title MISMATCH.",
|
|
764
764
|
};
|
|
765
765
|
if ((effectiveRest.includes("--help") || effectiveRest.includes("-h")) && SPAWN_HELP_USAGE[effectiveCmd]) {
|
|
766
766
|
process.stdout.write(SPAWN_HELP_USAGE[effectiveCmd] + "\n Full reference: exceptd help\n");
|
|
@@ -2858,13 +2858,14 @@ function cmdBrief(runner, args, runOpts, pretty) {
|
|
|
2858
2858
|
const required = (obj.artifacts || []).filter(a => a.required);
|
|
2859
2859
|
const optional = (obj.artifacts || []).filter(a => !a.required);
|
|
2860
2860
|
lines.push(`\nRequired artifacts (${required.length}): ${required.map(a => a.id).join(", ") || "(none)"}`);
|
|
2861
|
-
if (optional.length) lines.push(`Optional artifacts (${optional.length}): ${optional.map(a => a.id).slice(0, 8).join(", ")}${optional.length > 8 ?
|
|
2861
|
+
if (optional.length) lines.push(`Optional artifacts (${optional.length}): ${optional.map(a => a.id).slice(0, 8).join(", ")}${optional.length > 8 ? `, … +${optional.length - 8}` : ""}`);
|
|
2862
2862
|
const indicators = obj.detect_indicators_preview || [];
|
|
2863
|
-
lines.push(`\nIndicators (${indicators.length}): ${indicators.map(i => i.id).slice(0, 8).join(", ")}${indicators.length > 8 ?
|
|
2863
|
+
lines.push(`\nIndicators (${indicators.length}): ${indicators.map(i => i.id).slice(0, 8).join(", ")}${indicators.length > 8 ? `, … +${indicators.length - 8}` : ""}`);
|
|
2864
2864
|
if (obj.preconditions?.length) {
|
|
2865
2865
|
lines.push(`\nPreconditions (${obj.preconditions.length}):`);
|
|
2866
2866
|
for (const p of obj.preconditions) {
|
|
2867
|
-
|
|
2867
|
+
const pdesc = p.description || p.check || "";
|
|
2868
|
+
lines.push(` ${p.id} (${p.on_fail}): ${pdesc.length > 80 ? pdesc.slice(0, 80) + "…" : pdesc}`);
|
|
2868
2869
|
}
|
|
2869
2870
|
}
|
|
2870
2871
|
lines.push(`\nRun: exceptd run ${obj.playbook_id} --evidence <file|-> --json`);
|
|
@@ -3859,7 +3860,7 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
3859
3860
|
lines.push(`\nMatched CVEs (${cves.length}):`);
|
|
3860
3861
|
for (const c of cves.slice(0, 6)) {
|
|
3861
3862
|
const via = Array.isArray(c.correlated_via) && c.correlated_via.length ? ` via ${c.correlated_via[0]}${c.correlated_via.length > 1 ? ` (+${c.correlated_via.length - 1})` : ""}` : "";
|
|
3862
|
-
lines.push(` ${c.cve_id} RWEP ${c.rwep} KEV=${c.cisa_kev} ${c.active_exploitation || ""}${via}`);
|
|
3863
|
+
lines.push(` ${c.cve_id} RWEP ${c.rwep} KEV=${c.cisa_kev ? "Y" : "N"} ${c.active_exploitation || ""}${via}`);
|
|
3863
3864
|
}
|
|
3864
3865
|
if (cves.length > 6) lines.push(` … ${cves.length - 6} more`);
|
|
3865
3866
|
} else if (baseline.length) {
|
|
@@ -3872,7 +3873,13 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
3872
3873
|
const hits = indicators.filter(i => i.verdict === "hit");
|
|
3873
3874
|
if (hits.length) {
|
|
3874
3875
|
lines.push(`\nIndicators that fired (${hits.length}):`);
|
|
3875
|
-
for (const i of hits.slice(0, 8))
|
|
3876
|
+
for (const i of hits.slice(0, 8)) {
|
|
3877
|
+
// Don't double-print "deterministic/deterministic" when confidence is
|
|
3878
|
+
// already the literal "deterministic".
|
|
3879
|
+
const detSuffix = (i.deterministic && i.confidence !== "deterministic") ? "/deterministic" : "";
|
|
3880
|
+
lines.push(` ${i.id} (${i.confidence}${detSuffix})`);
|
|
3881
|
+
}
|
|
3882
|
+
if (hits.length > 8) lines.push(` … ${hits.length - 8} more`);
|
|
3876
3883
|
}
|
|
3877
3884
|
// selected_remediation is informational on non-detect runs:
|
|
3878
3885
|
// validate() always picks the highest-priority remediation path
|
|
@@ -3888,7 +3895,8 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
3888
3895
|
} else {
|
|
3889
3896
|
lines.push(`\nRemediation path (informational — verdict=${cls}, no action required now): ${rem.id} (priority ${rem.priority})`);
|
|
3890
3897
|
}
|
|
3891
|
-
|
|
3898
|
+
const remDesc = rem.description || "";
|
|
3899
|
+
lines.push(` ${remDesc.length > 200 ? remDesc.slice(0, 200) + "… (full steps: --json)" : remDesc}`);
|
|
3892
3900
|
}
|
|
3893
3901
|
// Surface BOTH started and pending notification clocks on detected
|
|
3894
3902
|
// runs. The detection IS the regulatory event for the obligations
|
|
@@ -3940,7 +3948,9 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
3940
3948
|
// to the description if `check` is missing too.
|
|
3941
3949
|
for (const i of issues) {
|
|
3942
3950
|
const tag = i.on_fail ? `[${i.on_fail}] ` : "";
|
|
3943
|
-
|
|
3951
|
+
// precondition_warn issues carry their text in `message`; without it in
|
|
3952
|
+
// the fallback chain they rendered "(no detail)".
|
|
3953
|
+
const detail = i.check || i.description || i.reason || i.message || "(no detail)";
|
|
3944
3954
|
lines.push(` ${tag}${i.id}: ${detail}`);
|
|
3945
3955
|
}
|
|
3946
3956
|
}
|
|
@@ -3953,7 +3963,11 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
3953
3963
|
if (runtimeErrors.length) {
|
|
3954
3964
|
lines.push(`\nRuntime warnings (${runtimeErrors.length}):`);
|
|
3955
3965
|
for (const e of runtimeErrors) {
|
|
3956
|
-
|
|
3966
|
+
// Some runtime-warning kinds (e.g. csaf_branch_unparseable) carry no
|
|
3967
|
+
// `reason` but do carry context fields (component / cve_id); compose
|
|
3968
|
+
// from those rather than rendering a blank line.
|
|
3969
|
+
const rawReason = e.reason || [e.component, e.cve_id].filter(Boolean).join(" / ") || "(no detail)";
|
|
3970
|
+
const reason = rawReason.length > 180 ? rawReason.slice(0, 177) + "..." : rawReason;
|
|
3957
3971
|
lines.push(` [${e.kind || "warning"}] ${reason}`);
|
|
3958
3972
|
if (e.remediation) lines.push(` → ${e.remediation}`);
|
|
3959
3973
|
}
|
|
@@ -4242,7 +4256,39 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
|
4242
4256
|
},
|
|
4243
4257
|
jurisdiction_clock_rollup: jurisdictionClockRollup,
|
|
4244
4258
|
results,
|
|
4245
|
-
}, pretty)
|
|
4259
|
+
}, pretty, (obj) => {
|
|
4260
|
+
// Per-playbook summary table. Without this renderer a multi-run dumped its
|
|
4261
|
+
// entire (often hundreds-of-KB) JSON even in default human mode.
|
|
4262
|
+
const s = obj.summary;
|
|
4263
|
+
const lines = [];
|
|
4264
|
+
const detectedTotal = s.detected;
|
|
4265
|
+
const icon = s.blocked > 0 ? "[!! BLOCKED]" : detectedTotal > 0 ? "[!! DETECTED]" : "[ok]";
|
|
4266
|
+
lines.push(`run ${obj.trigger || "multi"}: ${obj.playbooks_run.length} playbook(s) session-id: ${obj.session_id}`);
|
|
4267
|
+
lines.push(`\n${icon} detected=${detectedTotal} inconclusive=${s.inconclusive} clean=${s.total - detectedTotal - s.inconclusive - s.blocked} blocked=${s.blocked} total=${s.total}`);
|
|
4268
|
+
const rows = (obj.results || []).map(r => (r && r.ok === false)
|
|
4269
|
+
? { id: r.playbook_id || "?", verdict: "blocked", rwep: "-", evidence: r.evidence_completeness || "not-evaluated", top: r.blocked_by || r.reason || r.error || "" }
|
|
4270
|
+
: { id: r.playbook_id || "?", verdict: r?.phases?.detect?.classification || r?.verdict || "?", rwep: (r?.rwep_score != null) ? String(r.rwep_score) : "-", evidence: r?.evidence_completeness || "unknown", top: r?.top_finding || "" });
|
|
4271
|
+
const wId = Math.max(8, ...rows.map(r => r.id.length));
|
|
4272
|
+
const wV = Math.max(8, ...rows.map(r => r.verdict.length));
|
|
4273
|
+
const wR = Math.max(4, ...rows.map(r => r.rwep.length));
|
|
4274
|
+
const wE = Math.max(8, ...rows.map(r => r.evidence.length));
|
|
4275
|
+
const pad = (str, w) => (str + " ".repeat(w)).slice(0, w);
|
|
4276
|
+
lines.push("");
|
|
4277
|
+
lines.push(` ${pad("playbook", wId)} ${pad("verdict", wV)} ${pad("rwep", wR)} ${pad("evidence", wE)} finding`);
|
|
4278
|
+
lines.push(` ${"-".repeat(wId)} ${"-".repeat(wV)} ${"-".repeat(wR)} ${"-".repeat(wE)} -------`);
|
|
4279
|
+
for (const row of rows) {
|
|
4280
|
+
const finding = row.top.length > 80 ? row.top.slice(0, 77) + "..." : row.top;
|
|
4281
|
+
lines.push(` ${pad(row.id, wId)} ${pad(row.verdict, wV)} ${pad(row.rwep, wR)} ${pad(row.evidence, wE)} ${finding}`);
|
|
4282
|
+
}
|
|
4283
|
+
const clocks = obj.jurisdiction_clock_rollup || [];
|
|
4284
|
+
if (clocks.length) {
|
|
4285
|
+
lines.push(`\nJurisdiction clocks (${clocks.length}):`);
|
|
4286
|
+
for (const n of clocks.slice(0, 5)) lines.push(` ${n.jurisdiction || "?"}/${n.regulation || "?"} → deadline ${n.deadline || "?"}`);
|
|
4287
|
+
if (clocks.length > 5) lines.push(` … ${clocks.length - 5} more (--json for all)`);
|
|
4288
|
+
}
|
|
4289
|
+
lines.push(`\nFull structured results: --json or --pretty`);
|
|
4290
|
+
return lines.join("\n");
|
|
4291
|
+
});
|
|
4246
4292
|
// v0.11.9 (#100): cmdRunMulti exits non-zero when any individual run
|
|
4247
4293
|
// returned ok:false. Pre-0.11.9 the aggregate result had {ok:false} in
|
|
4248
4294
|
// the body but exit code stayed 0 — CI gates couldn't distinguish "ran
|
|
@@ -5076,6 +5122,15 @@ function cmdReattest(runner, args, runOpts, pretty) {
|
|
|
5076
5122
|
attFile = found.file;
|
|
5077
5123
|
}
|
|
5078
5124
|
if (!sessionId) return emitError("reattest: missing <session-id>. Pass a session-id or --latest [--playbook <id>] [--since <ISO>].", null, pretty);
|
|
5125
|
+
// Validate the session-id BEFORE it is joined into a filesystem path. The
|
|
5126
|
+
// other read verbs (attest show/verify/diff --against) gate on this; reattest
|
|
5127
|
+
// did not, so `findSessionDir` returning null let the `||` fallback join an
|
|
5128
|
+
// unvalidated `../`-bearing id straight onto the attestation root — escaping
|
|
5129
|
+
// it to read a forged attestation and write a signed replay record outside
|
|
5130
|
+
// the root. Ids resolved from the store via the latest-match path are already
|
|
5131
|
+
// safe; an operator-supplied id is the one that must be checked.
|
|
5132
|
+
try { validateSessionIdForRead(sessionId); }
|
|
5133
|
+
catch (e) { return emitError(`reattest: ${e.message}`, { session_id_input: typeof sessionId === "string" ? sessionId.slice(0, 80) : typeof sessionId }, pretty); }
|
|
5079
5134
|
const dir = findSessionDir(sessionId, runOpts) || path.join(resolveAttestationRoot(runOpts), sessionId);
|
|
5080
5135
|
if (!attFile) attFile = path.join(dir, "attestation.json");
|
|
5081
5136
|
if (!fs.existsSync(attFile)) {
|
|
@@ -5437,6 +5492,31 @@ function classifySidecarVerify(verify) {
|
|
|
5437
5492
|
* show <session-id> Emit the full (unredacted) attestation. Convenience
|
|
5438
5493
|
* alias for `cat .exceptd/attestations/<sid>/attestation.json`.
|
|
5439
5494
|
*/
|
|
5495
|
+
// Shared one-screen renderer for `attest diff` (both the --against and the
|
|
5496
|
+
// no-against/most-recent-prior branches). Reads only fields off the emitted
|
|
5497
|
+
// object so both call sites render identically; the sidecar line is shown only
|
|
5498
|
+
// when a sidecar verification was performed (the --against path may omit it).
|
|
5499
|
+
function renderAttestDiff(obj) {
|
|
5500
|
+
const lines = [];
|
|
5501
|
+
lines.push(`attest diff: ${obj.a_session}${obj.a_playbook ? ` (${obj.a_playbook})` : ""}`);
|
|
5502
|
+
lines.push(` vs ${obj.b_session}${obj.b_captured ? ` (captured ${obj.b_captured})` : ""}`);
|
|
5503
|
+
const icon = obj.status === "unchanged" ? "[ok]" : "[!]";
|
|
5504
|
+
lines.push(` ${icon} status=${obj.status} evidence_hash=${(obj.a_evidence_hash || "").slice(0, 12)}...`);
|
|
5505
|
+
const ad = obj.artifact_diff || {};
|
|
5506
|
+
const sd = obj.signal_override_diff || {};
|
|
5507
|
+
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})`);
|
|
5508
|
+
lines.push(` signal diff: ${sd.changed?.length ?? 0} changed, ${sd.unchanged_count ?? 0} unchanged (of ${sd.total_compared ?? 0})`);
|
|
5509
|
+
if (obj.sidecar_verify) {
|
|
5510
|
+
const sv = obj.sidecar_verify;
|
|
5511
|
+
let sidecarClass = "verified";
|
|
5512
|
+
if (!sv.signed && sv.reason && sv.reason.includes("explicitly unsigned")) sidecarClass = "explicitly-unsigned";
|
|
5513
|
+
else if (!sv.signed && sv.reason && sv.reason.includes("no .sig sidecar")) sidecarClass = "no-sidecar";
|
|
5514
|
+
else if (sv.signed && !sv.verified) sidecarClass = "tamper-detected";
|
|
5515
|
+
else if (!sv.signed) sidecarClass = "no-public-key";
|
|
5516
|
+
lines.push(` sidecar verify: ${sidecarClass}`);
|
|
5517
|
+
}
|
|
5518
|
+
return lines.join("\n");
|
|
5519
|
+
}
|
|
5440
5520
|
function cmdAttest(runner, args, runOpts, pretty) {
|
|
5441
5521
|
const subverb = args._[0];
|
|
5442
5522
|
const sessionId = args._[1];
|
|
@@ -5574,12 +5654,14 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
5574
5654
|
emit({
|
|
5575
5655
|
verb: "attest diff",
|
|
5576
5656
|
a_session: sessionId,
|
|
5657
|
+
a_playbook: self.playbook_id,
|
|
5577
5658
|
b_session: args.against,
|
|
5578
5659
|
a_captured: self.captured_at,
|
|
5579
5660
|
b_captured: other.captured_at,
|
|
5580
5661
|
a_evidence_hash: self.evidence_hash,
|
|
5581
5662
|
b_evidence_hash: other.evidence_hash,
|
|
5582
5663
|
status: self.evidence_hash === other.evidence_hash ? "unchanged" : "drifted",
|
|
5664
|
+
sidecar_verify: verifyAttestationSidecar(path.join(dir, "attestation.json")),
|
|
5583
5665
|
// v0.11.8 (#102): normalize submissions before diffing so flat-shape
|
|
5584
5666
|
// (observations + verdict) submissions emit meaningful artifact_diff
|
|
5585
5667
|
// counts. Pre-0.11.8 (self.submission||{}).artifacts was undefined
|
|
@@ -5593,7 +5675,7 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
5593
5675
|
normalizedSignalOverrides(self.submission, runner, self.playbook_id),
|
|
5594
5676
|
normalizedSignalOverrides(other.submission, runner, other.playbook_id)
|
|
5595
5677
|
),
|
|
5596
|
-
}, pretty);
|
|
5678
|
+
}, pretty, renderAttestDiff);
|
|
5597
5679
|
return;
|
|
5598
5680
|
}
|
|
5599
5681
|
// No --against: find the most-recent prior attestation for the
|
|
@@ -5628,6 +5710,7 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
5628
5710
|
emit({
|
|
5629
5711
|
verb: "attest diff",
|
|
5630
5712
|
a_session: sessionId,
|
|
5713
|
+
a_playbook: self.playbook_id,
|
|
5631
5714
|
b_session: prior.sessionId,
|
|
5632
5715
|
a_captured: self.captured_at,
|
|
5633
5716
|
b_captured: other.captured_at,
|
|
@@ -5643,28 +5726,7 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
5643
5726
|
normalizedSignalOverrides(self.submission, runner, self.playbook_id),
|
|
5644
5727
|
normalizedSignalOverrides(other.submission, runner, other.playbook_id),
|
|
5645
5728
|
),
|
|
5646
|
-
}, pretty,
|
|
5647
|
-
// Human renderer for the no-against `attest diff` path. Same
|
|
5648
|
-
// one-screen shape the old cmdReattest renderer used so the
|
|
5649
|
-
// operator sees the verdict + drift summary + sidecar class.
|
|
5650
|
-
const lines = [];
|
|
5651
|
-
lines.push(`attest diff: ${obj.a_session} (${self.playbook_id})`);
|
|
5652
|
-
lines.push(` vs prior: ${obj.b_session} (captured ${obj.b_captured})`);
|
|
5653
|
-
const icon = obj.status === "unchanged" ? "[ok]" : "[!]";
|
|
5654
|
-
lines.push(` ${icon} status=${obj.status} evidence_hash=${(obj.a_evidence_hash || "").slice(0, 12)}...`);
|
|
5655
|
-
const ad = obj.artifact_diff || {};
|
|
5656
|
-
const sd = obj.signal_override_diff || {};
|
|
5657
|
-
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})`);
|
|
5658
|
-
lines.push(` signal diff: ${sd.changed?.length ?? 0} changed, ${sd.unchanged_count ?? 0} unchanged (of ${sd.total_compared ?? 0})`);
|
|
5659
|
-
const sv = obj.sidecar_verify || {};
|
|
5660
|
-
let sidecarClass = "verified";
|
|
5661
|
-
if (!sv.signed && sv.reason && sv.reason.includes("explicitly unsigned")) sidecarClass = "explicitly-unsigned";
|
|
5662
|
-
else if (!sv.signed && sv.reason && sv.reason.includes("no .sig sidecar")) sidecarClass = "no-sidecar";
|
|
5663
|
-
else if (sv.signed && !sv.verified) sidecarClass = "tamper-detected";
|
|
5664
|
-
else if (!sv.signed) sidecarClass = "no-public-key";
|
|
5665
|
-
lines.push(` sidecar verify: ${sidecarClass}`);
|
|
5666
|
-
return lines.join("\n");
|
|
5667
|
-
});
|
|
5729
|
+
}, pretty, renderAttestDiff);
|
|
5668
5730
|
return;
|
|
5669
5731
|
}
|
|
5670
5732
|
|
|
@@ -6407,7 +6469,11 @@ function cmdDoctor(runner, args, runOpts, pretty) {
|
|
|
6407
6469
|
const onlyAiConfig = !!args["ai-config"];
|
|
6408
6470
|
const onlyCollectors = !!args.collectors;
|
|
6409
6471
|
const anySelected = onlySigs || onlyCurrency || onlyCves || onlyRfcs || onlyAiConfig || onlyCollectors;
|
|
6410
|
-
|
|
6472
|
+
// --shipped-tarball lives inside the signatures check, so it must imply it.
|
|
6473
|
+
// Pre-fix, `doctor --shipped-tarball --cves` made runSigs false (a selective
|
|
6474
|
+
// flag was set, but not --signatures), silently skipping the tarball
|
|
6475
|
+
// round-trip while the operator believed it ran.
|
|
6476
|
+
const runSigs = !anySelected || onlySigs || !!args["shipped-tarball"];
|
|
6411
6477
|
const runCurrency = !anySelected || onlyCurrency;
|
|
6412
6478
|
const runCves = !anySelected || onlyCves;
|
|
6413
6479
|
const runRfcs = !anySelected || onlyRfcs;
|
|
@@ -6608,23 +6674,34 @@ function cmdDoctor(runner, args, runOpts, pretty) {
|
|
|
6608
6674
|
timeout: 30000,
|
|
6609
6675
|
});
|
|
6610
6676
|
const text = (res.stdout || "") + (res.stderr || "");
|
|
6611
|
-
const rfcRows = (text.match(/^RFC-\d+/gm) || []).length;
|
|
6612
6677
|
const driftMatch = text.match(/drift[:\s]+(\d+)/i);
|
|
6613
6678
|
const ok = res.status === 0;
|
|
6614
|
-
//
|
|
6615
|
-
//
|
|
6616
|
-
//
|
|
6679
|
+
// Count the catalog directly (same approach the CVE subcheck uses) rather
|
|
6680
|
+
// than scraping `^RFC-\d+` table rows from the validate-rfcs output. The
|
|
6681
|
+
// text scrape dropped every non-RFC family (CSAF / DRAFT / ISO entries),
|
|
6682
|
+
// undercounting the catalog and hiding those citation families. Read the
|
|
6683
|
+
// canonical file and emit a by_prefix breakdown.
|
|
6684
|
+
const rfcCatalogPath = path.join(PKG_ROOT, "data", "rfc-references.json");
|
|
6685
|
+
let rfcTotal = 0;
|
|
6686
|
+
const byPrefix = {};
|
|
6617
6687
|
let rfcMtime = null;
|
|
6618
6688
|
let rfcAgeDays = null;
|
|
6619
6689
|
try {
|
|
6620
|
-
const
|
|
6621
|
-
const
|
|
6690
|
+
const catalog = JSON.parse(fs.readFileSync(rfcCatalogPath, "utf8"));
|
|
6691
|
+
for (const k of Object.keys(catalog)) {
|
|
6692
|
+
if (k.startsWith("_")) continue;
|
|
6693
|
+
rfcTotal++;
|
|
6694
|
+
const prefix = (k.match(/^[A-Za-z]+/) || ["?"])[0].toUpperCase();
|
|
6695
|
+
byPrefix[prefix] = (byPrefix[prefix] || 0) + 1;
|
|
6696
|
+
}
|
|
6697
|
+
const st = fs.statSync(rfcCatalogPath);
|
|
6622
6698
|
rfcMtime = st.mtime.toISOString();
|
|
6623
6699
|
rfcAgeDays = Math.floor((Date.now() - st.mtimeMs) / 86400000);
|
|
6624
|
-
} catch { /* file may
|
|
6700
|
+
} catch { /* file may be absent on exotic installs — total stays 0 */ }
|
|
6625
6701
|
checks.rfcs = {
|
|
6626
6702
|
ok,
|
|
6627
|
-
total:
|
|
6703
|
+
total: rfcTotal,
|
|
6704
|
+
by_prefix: byPrefix,
|
|
6628
6705
|
drift: driftMatch ? Number(driftMatch[1]) : 0,
|
|
6629
6706
|
index_last_modified: rfcMtime,
|
|
6630
6707
|
index_age_days: rfcAgeDays,
|
|
@@ -6906,9 +6983,14 @@ function cmdDoctor(runner, args, runOpts, pretty) {
|
|
|
6906
6983
|
}
|
|
6907
6984
|
}
|
|
6908
6985
|
|
|
6986
|
+
// A truncated walk (hit the file/depth cap) means the audit is INCOMPLETE —
|
|
6987
|
+
// a sensitive file beyond the cap would be unseen. Don't report an
|
|
6988
|
+
// unqualified clean pass: downgrade to a warn so automation can branch on
|
|
6989
|
+
// incompleteness even when zero findings surfaced within the cap.
|
|
6990
|
+
const baseSeverity = errorFindings.length > 0 && fixesFailed > 0 ? 'warn' : (errorFindings.length > 0 && !args.fix ? 'warn' : 'info');
|
|
6909
6991
|
checks.ai_config = {
|
|
6910
|
-
ok: errorFindings.length === 0 || (args.fix && fixesFailed === 0),
|
|
6911
|
-
severity:
|
|
6992
|
+
ok: (errorFindings.length === 0 || (args.fix && fixesFailed === 0)) && !walkAborted,
|
|
6993
|
+
severity: walkAborted && baseSeverity === 'info' ? 'warn' : baseSeverity,
|
|
6912
6994
|
scanned_dirs: scannedDirs,
|
|
6913
6995
|
scanned_files: scannedFiles,
|
|
6914
6996
|
walk_truncated: walkAborted,
|
|
@@ -7027,8 +7109,8 @@ function cmdDoctor(runner, args, runOpts, pretty) {
|
|
|
7027
7109
|
// global `npm install -g` reported `failed_checks: ["signing"]` with
|
|
7028
7110
|
// `warnings_count: 0`, contradicting the [!! warn] text-mode icon.
|
|
7029
7111
|
const { bucketChecks } = require(path.join(PKG_ROOT, "lib", "doctor-bucketing.js"));
|
|
7030
|
-
|
|
7031
|
-
|
|
7112
|
+
let { warnList, errorList } = bucketChecks(checks);
|
|
7113
|
+
let allGreen = errorList.length === 0 && warnList.length === 0;
|
|
7032
7114
|
// Audit 3 B.11: surface the local version on the default doctor output
|
|
7033
7115
|
// so operators answer both "is my install healthy?" AND "which version
|
|
7034
7116
|
// am I running?" without having to invoke `exceptd version` separately.
|
|
@@ -7065,10 +7147,21 @@ function cmdDoctor(runner, args, runOpts, pretty) {
|
|
|
7065
7147
|
// `exceptd doctor` (signatures check) reports 0/N passing.
|
|
7066
7148
|
if (args.fix && checks.signing && !checks.signing.private_key_present) {
|
|
7067
7149
|
const pubKeyExists = fs.existsSync(path.join(PKG_ROOT, "keys", "public.pem"));
|
|
7150
|
+
const fingerprintPinExists = fs.existsSync(path.join(PKG_ROOT, "keys", "EXPECTED_FINGERPRINT"));
|
|
7068
7151
|
if (pubKeyExists) {
|
|
7069
7152
|
out.summary.fix_attempted = "ed25519_keypair_generation_declined";
|
|
7070
7153
|
out.summary.fix_decline_reason = "keys/public.pem already exists but no matching private key. Generating a fresh keypair would overwrite the public key and orphan every shipped signature. If you intend to establish a new signing identity, run `node $(exceptd path)/lib/sign.js generate-keypair --rotate` followed by sign-all.";
|
|
7071
7154
|
process.stderr.write("[doctor --fix] refused: keys/public.pem present without matching private key. Pass --rotate via the underlying lib/sign.js if a new identity is intended.\n");
|
|
7155
|
+
} else if (fingerprintPinExists) {
|
|
7156
|
+
// A committed EXPECTED_FINGERPRINT without keys/public.pem signals an
|
|
7157
|
+
// intended committed signing identity on a corrupted/partial checkout.
|
|
7158
|
+
// Generating a fresh keypair here would write a public.pem whose
|
|
7159
|
+
// fingerprint can never match the pin, leaving verify.js permanently
|
|
7160
|
+
// refusing (fingerprint-mismatch) while --fix claimed success. Decline
|
|
7161
|
+
// and tell the operator to restore the real public key.
|
|
7162
|
+
out.summary.fix_attempted = "ed25519_keypair_generation_declined";
|
|
7163
|
+
out.summary.fix_decline_reason = "keys/EXPECTED_FINGERPRINT is present but keys/public.pem is missing — this is a corrupted checkout of a project with a committed signing identity, not a fresh contributor checkout. Generating a keypair would produce a public key whose fingerprint cannot match the pin, so verify would refuse forever. Restore keys/public.pem from version control instead (git checkout -- keys/public.pem).";
|
|
7164
|
+
process.stderr.write("[doctor --fix] refused: keys/EXPECTED_FINGERPRINT present without keys/public.pem. Restore the committed public key (git checkout -- keys/public.pem) rather than generating a new identity.\n");
|
|
7072
7165
|
} else {
|
|
7073
7166
|
process.stderr.write("[doctor --fix] generating Ed25519 keypair...\n");
|
|
7074
7167
|
const r = require("child_process").spawnSync(process.execPath, [path.join(PKG_ROOT, "lib", "sign.js"), "generate-keypair"], {
|
|
@@ -7126,6 +7219,37 @@ function cmdDoctor(runner, args, runOpts, pretty) {
|
|
|
7126
7219
|
}
|
|
7127
7220
|
}
|
|
7128
7221
|
|
|
7222
|
+
// After a --fix that re-signed skills (keypair generation OR re-sign), the
|
|
7223
|
+
// captured `checks.signatures` is STALE — it was the verify.js result taken
|
|
7224
|
+
// before any key existed. Re-verify now and recompute the buckets, so a
|
|
7225
|
+
// successful --fix reports success (and exits 0) instead of carrying the
|
|
7226
|
+
// pre-fix "signatures FAILED" through to failed_checks + a non-zero exit.
|
|
7227
|
+
if (args.fix
|
|
7228
|
+
&& (out.summary.fix_applied === "ed25519_keypair_generated_and_skills_signed"
|
|
7229
|
+
|| out.summary.fix_applied === "skills_resigned_against_current_keypair")) {
|
|
7230
|
+
try {
|
|
7231
|
+
const verifyPath = path.join(PKG_ROOT, "lib", "verify.js");
|
|
7232
|
+
const rv = spawnSync(process.execPath, [verifyPath], { encoding: "utf8", cwd: PKG_ROOT, timeout: 30000 });
|
|
7233
|
+
const rvText = (rv.stdout || "") + (rv.stderr || "");
|
|
7234
|
+
const rvMatch = rvText.match(/(\d+)\/(\d+)\s+skills?\s+passed/i);
|
|
7235
|
+
const rvFp = rvText.match(/SHA256:\s*([A-Za-z0-9+/=]+)/);
|
|
7236
|
+
const rvOk = rv.status === 0;
|
|
7237
|
+
checks.signatures = {
|
|
7238
|
+
ok: rvOk,
|
|
7239
|
+
skills_passed: rvMatch ? Number(rvMatch[1]) : null,
|
|
7240
|
+
skills_total: rvMatch ? Number(rvMatch[2]) : null,
|
|
7241
|
+
fingerprint_sha256: rvFp ? rvFp[1] : null,
|
|
7242
|
+
...(rvOk ? {} : { exit_code: rv.status, raw: rvText.slice(0, 500) }),
|
|
7243
|
+
};
|
|
7244
|
+
out.checks = checks;
|
|
7245
|
+
({ warnList, errorList } = bucketChecks(checks));
|
|
7246
|
+
allGreen = errorList.length === 0 && warnList.length === 0;
|
|
7247
|
+
out.summary.failed_checks = errorList;
|
|
7248
|
+
out.summary.warning_checks = warnList;
|
|
7249
|
+
out.summary.all_green = allGreen;
|
|
7250
|
+
} catch { /* re-verify best-effort; leave the pre-fix state if it throws */ }
|
|
7251
|
+
}
|
|
7252
|
+
|
|
7129
7253
|
// Audit 3 B.3: --fix was passed but nothing to fix. Pre-fix this was
|
|
7130
7254
|
// silently a no-op — operators couldn't distinguish "we tried and were
|
|
7131
7255
|
// already healthy" from "we tried and failed silently." Now surfaces a
|
|
@@ -8481,9 +8605,15 @@ function cmdCi(runner, args, runOpts, pretty) {
|
|
|
8481
8605
|
}
|
|
8482
8606
|
process.stdout.write(lines.join("\n") + "\n");
|
|
8483
8607
|
} else if (fmt === "csaf" || fmt === "sarif" || fmt === "openvex") {
|
|
8484
|
-
// Aggregate the per-run bundles_by_format if present.
|
|
8608
|
+
// Aggregate the per-run bundles_by_format if present. ci spans N playbooks,
|
|
8609
|
+
// so there is no single conformant CSAF/SARIF/OpenVEX document — emit a JSON
|
|
8610
|
+
// ARRAY of the pure documents. Critically, do NOT wrap them in an exceptd
|
|
8611
|
+
// envelope carrying a top-level `ok` key: that key is invalid in all three
|
|
8612
|
+
// standard formats, so a downstream CSAF/SARIF/OpenVEX consumer pointed at
|
|
8613
|
+
// `ci --format` output got a non-conformant top-level shape. Each array
|
|
8614
|
+
// element is now a verbatim, conformant document.
|
|
8485
8615
|
const bundles = results.map(r => r.phases?.close?.evidence_package?.bundles_by_format?.[fmt === "csaf" ? "csaf-2.0" : fmt]).filter(Boolean);
|
|
8486
|
-
|
|
8616
|
+
process.stdout.write(JSON.stringify(bundles, null, pretty ? 2 : 0) + "\n");
|
|
8487
8617
|
} else if (fmt && fmt !== "json") {
|
|
8488
8618
|
// v0.11.4 (#76): garbage format rejected with structured error, not silent empty stdout.
|
|
8489
8619
|
// Route through emitError so the body propagates exit codes via the
|
|
@@ -8576,17 +8706,21 @@ function cmdCi(runner, args, runOpts, pretty) {
|
|
|
8576
8706
|
// Jurisdiction clocks.
|
|
8577
8707
|
if (s.jurisdiction_clocks_started > 0) {
|
|
8578
8708
|
lines.push(`\nJurisdiction clocks started: ${s.jurisdiction_clocks_started}`);
|
|
8579
|
-
|
|
8709
|
+
const clocks = s.jurisdiction_clock_rollup || [];
|
|
8710
|
+
for (const n of clocks.slice(0, 5)) {
|
|
8580
8711
|
lines.push(` ${n.jurisdiction || "?"}/${n.regulation || "?"} → deadline ${n.deadline || "?"}`);
|
|
8581
8712
|
}
|
|
8713
|
+
if (clocks.length > 5) lines.push(` … ${clocks.length - 5} more (--json for all)`);
|
|
8582
8714
|
}
|
|
8583
8715
|
|
|
8584
8716
|
// Framework gap rollup.
|
|
8585
8717
|
if (s.framework_gap_count > 0) {
|
|
8586
8718
|
lines.push(`\nFramework gaps (${s.framework_gap_count}):`);
|
|
8587
|
-
|
|
8719
|
+
const fgaps = s.framework_gap_rollup || [];
|
|
8720
|
+
for (const g of fgaps.slice(0, 5)) {
|
|
8588
8721
|
lines.push(` ${g.framework || "?"} :: ${g.claimed_control || "?"} (${g.playbooks.length} playbook(s))`);
|
|
8589
8722
|
}
|
|
8723
|
+
if (fgaps.length > 5) lines.push(` … ${fgaps.length - 5} more (--json for all)`);
|
|
8590
8724
|
}
|
|
8591
8725
|
|
|
8592
8726
|
// Fail reasons.
|
|
@@ -8608,17 +8742,21 @@ function cmdCi(runner, args, runOpts, pretty) {
|
|
|
8608
8742
|
// CLOCK_STARTED → notification clock running; see deadline above.
|
|
8609
8743
|
// PASS → nothing to do.
|
|
8610
8744
|
const blockedRows = (obj.results || []).filter(r => r && r.ok === false);
|
|
8611
|
-
|
|
8745
|
+
// Pad the playbook id to a common width so the trailing `#` comments line
|
|
8746
|
+
// up across variable-length ids instead of using a fixed space run.
|
|
8747
|
+
const lintCmd = (id, w) => ` exceptd lint ${(id + " ".repeat(w)).slice(0, w)} - # paste {} on stdin, get exact JSON paths`;
|
|
8612
8748
|
if (s.verdict === "BLOCKED" && blockedRows.length) {
|
|
8613
8749
|
lines.push(`\nNext steps (unblock the ${blockedRows.length} halted playbook(s)):`);
|
|
8614
|
-
|
|
8615
|
-
|
|
8750
|
+
const shown = blockedRows.slice(0, 4);
|
|
8751
|
+
const wLint = Math.max(...shown.map(r => (r.playbook_id || "?").length));
|
|
8752
|
+
for (const row of shown) {
|
|
8753
|
+
lines.push(lintCmd(row.playbook_id || "?", wLint));
|
|
8616
8754
|
}
|
|
8617
8755
|
lines.push(` exceptd run <playbook> --evidence <file> # re-run after filling in evidence`);
|
|
8618
8756
|
} else if (s.verdict === "NO_EVIDENCE") {
|
|
8619
8757
|
const firstId = (obj.results[0] && obj.results[0].playbook_id) || (obj.playbooks_run[0]) || "<playbook>";
|
|
8620
8758
|
lines.push(`\nNext steps (every playbook ran inconclusive — no evidence supplied):`);
|
|
8621
|
-
lines.push(lintCmd(firstId));
|
|
8759
|
+
lines.push(lintCmd(firstId, firstId.length));
|
|
8622
8760
|
lines.push(` exceptd ci --scope <type> --evidence-dir <dir> # gate again with real submissions`);
|
|
8623
8761
|
} else if (s.verdict === "FAIL") {
|
|
8624
8762
|
// FAIL fires in two distinct shapes:
|
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-27T21:30:21.745Z",
|
|
4
4
|
"generator": "scripts/build-indexes.js",
|
|
5
5
|
"source_count": 54,
|
|
6
6
|
"source_hashes": {
|
|
7
|
-
"manifest.json": "
|
|
7
|
+
"manifest.json": "9dfc540e93aa24c5c61de5a568ac4ed94a21c04cddea458bb647c17ee3a4ec48",
|
|
8
8
|
"data/atlas-ttps.json": "d24bc02859d40ccf1615db75cca68c077585904e41e0d8f6de448121e9b1abb0",
|
|
9
9
|
"data/attack-techniques.json": "fa193f0d2d248176a8beddb641e9fe56ba4faa9e15dc253ff876dbf0c5d58a77",
|
|
10
10
|
"data/cve-catalog.json": "3d451dda7ac0c7d57a4075ae4bafd3148c6184b35dc1bc59d8b81d1f2641e430",
|
package/lib/citation-resolve.js
CHANGED
|
@@ -116,7 +116,10 @@ function isAirGap(opts) {
|
|
|
116
116
|
* from: format | catalog | cache | network | offline | error
|
|
117
117
|
*/
|
|
118
118
|
async function resolveCve(id, opts = {}) {
|
|
119
|
-
|
|
119
|
+
// Trim before the format test — matches resolveRfc — so a whitespace-only
|
|
120
|
+
// identifier is "fabricated/malformed" (empty form) rather than a literal
|
|
121
|
+
// whitespace string fed straight into CVE_RE.
|
|
122
|
+
const cveId = String(id || "").trim().toUpperCase();
|
|
120
123
|
const base = { id: cveId, kind: "cve" };
|
|
121
124
|
|
|
122
125
|
if (!CVE_RE.test(cveId)) {
|
|
@@ -21,7 +21,13 @@
|
|
|
21
21
|
|
|
22
22
|
const fs = require("node:fs");
|
|
23
23
|
const path = require("node:path");
|
|
24
|
-
const { isLinkedWorktreeDir, buildEvidenceLocations } = require("./scan-excludes");
|
|
24
|
+
const { codeExcludeSet, isLinkedWorktreeDir, buildEvidenceLocations } = require("./scan-excludes");
|
|
25
|
+
|
|
26
|
+
// Shared code-scope name exclusions (dependency caches, build output, VCS +
|
|
27
|
+
// agent scratch). Threaded into the OIDC-policy descent so a trust JSON in a
|
|
28
|
+
// build-output dir (e.g. `dist/`) is not scanned — consistent with the other
|
|
29
|
+
// tree-walking collectors.
|
|
30
|
+
const OIDC_WALK_EXCLUDES = codeExcludeSet();
|
|
25
31
|
|
|
26
32
|
const COLLECTOR_ID = "cicd-pipeline-compromise";
|
|
27
33
|
|
|
@@ -218,7 +224,7 @@ function scanOidcPolicies(root) {
|
|
|
218
224
|
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
|
219
225
|
catch { return; }
|
|
220
226
|
for (const e of entries) {
|
|
221
|
-
if (
|
|
227
|
+
if (OIDC_WALK_EXCLUDES.has(e.name)) continue;
|
|
222
228
|
const full = path.join(dir, e.name);
|
|
223
229
|
if (e.isDirectory()) {
|
|
224
230
|
// Skip linked git worktrees (gitdir-pointer `.git` file), e.g.
|