@blamejs/exceptd-skills 0.14.10 → 0.14.11

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 CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.14.11 — 2026-05-27
4
+
5
+ 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.
6
+
7
+ 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.
8
+
9
+ 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.
10
+
11
+ 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.
12
+
13
+ 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.
14
+
15
+ Human-readable output gaps closed across several verbs:
16
+ - `run --all` / `run-all` print a per-playbook summary table instead of dumping the full JSON.
17
+ - `attest diff --against` renders the same one-screen summary the no-argument form already did, rather than raw JSON.
18
+ - 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.
19
+ - `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).
20
+ - `report executive` writes its progress notice to stderr so piped markdown is clean.
21
+ - 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.
22
+
3
23
  ## 0.14.10 — 2026-05-27
4
24
 
5
25
  `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
- watch [--alerts] Forward-watch aggregator across skills.
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
- lines.push(` ${p.id} (${p.on_fail}): ${p.description?.slice(0, 80) || p.check}`);
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)) lines.push(` ${i.id} (${i.confidence}${i.deterministic ? "/deterministic" : ""})`);
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
- lines.push(` ${rem.description?.slice(0, 200) || ""}`);
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
- const detail = i.check || i.description || i.reason || "(no detail)";
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
- const reason = (e.reason || "").length > 180 ? (e.reason || "").slice(0, 177) + "..." : (e.reason || "");
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, (obj) => {
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
 
@@ -8576,17 +8638,21 @@ function cmdCi(runner, args, runOpts, pretty) {
8576
8638
  // Jurisdiction clocks.
8577
8639
  if (s.jurisdiction_clocks_started > 0) {
8578
8640
  lines.push(`\nJurisdiction clocks started: ${s.jurisdiction_clocks_started}`);
8579
- for (const n of (s.jurisdiction_clock_rollup || []).slice(0, 5)) {
8641
+ const clocks = s.jurisdiction_clock_rollup || [];
8642
+ for (const n of clocks.slice(0, 5)) {
8580
8643
  lines.push(` ${n.jurisdiction || "?"}/${n.regulation || "?"} → deadline ${n.deadline || "?"}`);
8581
8644
  }
8645
+ if (clocks.length > 5) lines.push(` … ${clocks.length - 5} more (--json for all)`);
8582
8646
  }
8583
8647
 
8584
8648
  // Framework gap rollup.
8585
8649
  if (s.framework_gap_count > 0) {
8586
8650
  lines.push(`\nFramework gaps (${s.framework_gap_count}):`);
8587
- for (const g of (s.framework_gap_rollup || []).slice(0, 5)) {
8651
+ const fgaps = s.framework_gap_rollup || [];
8652
+ for (const g of fgaps.slice(0, 5)) {
8588
8653
  lines.push(` ${g.framework || "?"} :: ${g.claimed_control || "?"} (${g.playbooks.length} playbook(s))`);
8589
8654
  }
8655
+ if (fgaps.length > 5) lines.push(` … ${fgaps.length - 5} more (--json for all)`);
8590
8656
  }
8591
8657
 
8592
8658
  // Fail reasons.
@@ -8608,17 +8674,21 @@ function cmdCi(runner, args, runOpts, pretty) {
8608
8674
  // CLOCK_STARTED → notification clock running; see deadline above.
8609
8675
  // PASS → nothing to do.
8610
8676
  const blockedRows = (obj.results || []).filter(r => r && r.ok === false);
8611
- const lintCmd = (id) => ` exceptd lint ${id} - # paste {} on stdin, get exact JSON paths`;
8677
+ // Pad the playbook id to a common width so the trailing `#` comments line
8678
+ // up across variable-length ids instead of using a fixed space run.
8679
+ const lintCmd = (id, w) => ` exceptd lint ${(id + " ".repeat(w)).slice(0, w)} - # paste {} on stdin, get exact JSON paths`;
8612
8680
  if (s.verdict === "BLOCKED" && blockedRows.length) {
8613
8681
  lines.push(`\nNext steps (unblock the ${blockedRows.length} halted playbook(s)):`);
8614
- for (const row of blockedRows.slice(0, 4)) {
8615
- lines.push(lintCmd(row.playbook_id || "?"));
8682
+ const shown = blockedRows.slice(0, 4);
8683
+ const wLint = Math.max(...shown.map(r => (r.playbook_id || "?").length));
8684
+ for (const row of shown) {
8685
+ lines.push(lintCmd(row.playbook_id || "?", wLint));
8616
8686
  }
8617
8687
  lines.push(` exceptd run <playbook> --evidence <file> # re-run after filling in evidence`);
8618
8688
  } else if (s.verdict === "NO_EVIDENCE") {
8619
8689
  const firstId = (obj.results[0] && obj.results[0].playbook_id) || (obj.playbooks_run[0]) || "<playbook>";
8620
8690
  lines.push(`\nNext steps (every playbook ran inconclusive — no evidence supplied):`);
8621
- lines.push(lintCmd(firstId));
8691
+ lines.push(lintCmd(firstId, firstId.length));
8622
8692
  lines.push(` exceptd ci --scope <type> --evidence-dir <dir> # gate again with real submissions`);
8623
8693
  } else if (s.verdict === "FAIL") {
8624
8694
  // FAIL fires in two distinct shapes:
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "schema_version": "1.1.0",
3
- "generated_at": "2026-05-27T19:33:19.529Z",
3
+ "generated_at": "2026-05-27T20:36:13.795Z",
4
4
  "generator": "scripts/build-indexes.js",
5
5
  "source_count": 54,
6
6
  "source_hashes": {
7
- "manifest.json": "4d13f4f27b890997b5da18055a4bb0eac69a60b24b33dad97616c9fea8ba50d0",
7
+ "manifest.json": "3db842fa75688111c96edd57712b9447a3df84cb250df1e052ac45b38aff74f2",
8
8
  "data/atlas-ttps.json": "d24bc02859d40ccf1615db75cca68c077585904e41e0d8f6de448121e9b1abb0",
9
9
  "data/attack-techniques.json": "fa193f0d2d248176a8beddb641e9fe56ba4faa9e15dc253ff876dbf0c5d58a77",
10
10
  "data/cve-catalog.json": "3d451dda7ac0c7d57a4075ae4bafd3148c6184b35dc1bc59d8b81d1f2641e430",
@@ -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
- const cveId = String(id || "").toUpperCase();
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 (e.name === "node_modules" || e.name === ".git") continue;
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.
@@ -30,7 +30,7 @@
30
30
  const fs = require("node:fs");
31
31
  const path = require("node:path");
32
32
 
33
- const { codeExcludeSet, isLinkedWorktreeDir, buildEvidenceLocations } = require("./scan-excludes");
33
+ const { codeExcludeSet, isLinkedWorktreeDir, buildEvidenceLocations, lineFromOffset } = require("./scan-excludes");
34
34
 
35
35
  const COLLECTOR_ID = "citation-hygiene";
36
36
 
@@ -334,12 +334,15 @@ function collect({ cwd = process.cwd() } = {}) {
334
334
  for (const m of content.matchAll(CVE_CITATION_RE)) {
335
335
  const full = m[0];
336
336
  totalCveCitations++;
337
+ // 1-based line of the citation so the evidence location carries a SARIF
338
+ // startLine region. Does not change any hit/miss verdict.
339
+ const cveLine = lineFromOffset(content, m.index);
337
340
  const canonical = CVE_CANONICAL_RE.test(full);
338
341
  if (!canonical) {
339
342
  // Fabricated / malformed. Illustrative surfaces (templates,
340
343
  // fixtures, the format-explaining docs) are demoted.
341
344
  if (!illustrative) {
342
- hits["fabricated-cve-id"].push({ file: f.rel, citation: full });
345
+ hits["fabricated-cve-id"].push({ file: f.rel, citation: full, line: cveLine });
343
346
  }
344
347
  continue;
345
348
  }
@@ -347,7 +350,7 @@ function collect({ cwd = process.cwd() } = {}) {
347
350
  if (cveKeys.has(full)) {
348
351
  const note = cveNotes.get(full) || "";
349
352
  if (REJECT_DISPUTE_RE.test(note) && !illustrative) {
350
- hits["rejected-or-disputed-cve"].push({ file: f.rel, citation: full });
353
+ hits["rejected-or-disputed-cve"].push({ file: f.rel, citation: full, line: cveLine });
351
354
  }
352
355
  } else if (catalogsLoaded && !illustrative) {
353
356
  // Absent from the curated catalog: needs an external lookup.
@@ -362,6 +365,7 @@ function collect({ cwd = process.cwd() } = {}) {
362
365
  const num = Number(m[1]);
363
366
  if (!Number.isFinite(num)) continue;
364
367
  const line = lineAround(content, m.index);
368
+ const rfcLineNo = lineFromOffset(content, m.index);
365
369
  if (rfcTitles.has(num)) {
366
370
  const verdict = classifyRfcTitle(line, rfcTitles.get(num));
367
371
  if (verdict === "mismatch" && !illustrative) {
@@ -369,6 +373,7 @@ function collect({ cwd = process.cwd() } = {}) {
369
373
  file: f.rel,
370
374
  citation: `RFC ${num}`,
371
375
  real_title: rfcTitles.get(num),
376
+ line: rfcLineNo,
372
377
  });
373
378
  }
374
379
  } else if (catalogsLoaded && !illustrative) {
@@ -449,8 +454,8 @@ function collect({ cwd = process.cwd() } = {}) {
449
454
 
450
455
  // Per-indicator file locations for the indicators flipped to "hit",
451
456
  // so SARIF results point at the source file that carries the bad
452
- // citation. The hits record the file but not a line, so locations are
453
- // file-level (no startLine).
457
+ // citation. The hits record a 1-based `line` (from the match offset),
458
+ // so locations include a startLine region.
454
459
  const evidence_locations = {};
455
460
  for (const id of Object.keys(hits)) {
456
461
  if (signal_overrides[id] === "hit") {
@@ -16,7 +16,7 @@
16
16
 
17
17
  const fs = require("node:fs");
18
18
  const path = require("node:path");
19
- const { codeExcludeSet, isLinkedWorktreeDir, buildEvidenceLocations } = require("./scan-excludes");
19
+ const { codeExcludeSet, isLinkedWorktreeDir, buildEvidenceLocations, lineFromOffset } = require("./scan-excludes");
20
20
 
21
21
  const COLLECTOR_ID = "crypto-codebase";
22
22
 
@@ -298,14 +298,17 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
298
298
  if (RSA_1024_RE.test(content)) {
299
299
  hits["rsa-1024-anywhere"].push({ file: f.rel });
300
300
  }
301
+ // Attach a 1-based `line` (from the match offset) so the evidence
302
+ // location carries a SARIF startLine region rather than pointing at
303
+ // the file. Does not change hit/miss — the same matches still fire.
301
304
  const mrHits = scanMathRandom(content);
302
- for (const h of mrHits) hits["math-random-in-security-path"].push({ file: f.rel, offset: h.offset });
305
+ for (const h of mrHits) hits["math-random-in-security-path"].push({ file: f.rel, offset: h.offset, line: lineFromOffset(content, h.offset) });
303
306
 
304
307
  const pHits = scanPbkdf2(content);
305
- for (const h of pHits) hits["pbkdf2-under-iterated"].push({ file: f.rel, iter: h.iter, threshold: h.threshold });
308
+ for (const h of pHits) hits["pbkdf2-under-iterated"].push({ file: f.rel, offset: h.offset, line: lineFromOffset(content, h.offset), iter: h.iter, threshold: h.threshold });
306
309
 
307
310
  const bHits = scanBcrypt(content);
308
- for (const h of bHits) hits["bcrypt-cost-low"].push({ file: f.rel, cost: h.cost });
311
+ for (const h of bHits) hits["bcrypt-cost-low"].push({ file: f.rel, offset: h.offset, line: lineFromOffset(content, h.offset), cost: h.cost });
309
312
 
310
313
  if (PEM_RE.test(content)) {
311
314
  hits["hardcoded-key-material"].push({ file: f.rel });
@@ -460,8 +463,10 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
460
463
  // no-ml-kem-implementation, fips-claim-without-runtime-activation,
461
464
  // vendored-pqc-no-provenance) describe a whole-repo state rather than a
462
465
  // single offending file, so they carry no file-level location. The
463
- // call-site scans record the file but not a line, so locations are
464
- // file-level (no startLine).
466
+ // offset-bearing call-site scans (math-random / pbkdf2 / bcrypt) now record
467
+ // a 1-based `line`, so their locations include a startLine region; the
468
+ // remaining whole-file scans (weak-hash / weak-cipher / rsa-1024 /
469
+ // hardcoded-key / tls) stay file-level (no startLine).
465
470
  const evidence_locations = {};
466
471
  for (const id of Object.keys(hits)) {
467
472
  if (signal_overrides[id] === "hit") {
@@ -356,8 +356,15 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
356
356
  let withoutIntegrity = 0;
357
357
  const walk = (obj) => {
358
358
  if (!obj || typeof obj !== "object") return;
359
- if (obj.integrity != null) withIntegrity++;
360
- else if (obj.resolved != null || obj.version != null) withoutIntegrity++;
359
+ // Only remote-tarball entries (those with a `resolved` URL) are
360
+ // expected to carry an `integrity` hash. The npm 7+ root entry
361
+ // `"": { name, version }` legitimately has no `resolved` and no
362
+ // `integrity`, so keying off `version` would false-positive on
363
+ // every clean lockfile. Mirror library-author.js's guard.
364
+ if (obj.resolved != null) {
365
+ if (obj.integrity != null) withIntegrity++;
366
+ else withoutIntegrity++;
367
+ }
361
368
  for (const v of Object.values(obj)) if (v && typeof v === "object") walk(v);
362
369
  };
363
370
  walk(j.packages || j.dependencies || {});
Binary file
@@ -18,7 +18,7 @@
18
18
 
19
19
  const fs = require("node:fs");
20
20
  const path = require("node:path");
21
- const { codeExcludeSet, isLinkedWorktreeDir, buildEvidenceLocations } = require("./scan-excludes");
21
+ const { codeExcludeSet, isLinkedWorktreeDir, buildEvidenceLocations, lineFromOffset } = require("./scan-excludes");
22
22
 
23
23
  const COLLECTOR_ID = "secrets";
24
24
 
@@ -83,6 +83,18 @@ const IAC_GLOB_PREFIX = ["pulumi.", "arm."];
83
83
  // source of truth for what counts as a hit; the collector
84
84
  // implements the same patterns so its signal_overrides match what
85
85
  // the runner would compute.
86
+ // AWS-published documentation/example access-key IDs. These appear verbatim
87
+ // throughout AWS docs, SDK samples, and countless READMEs, so a literal match
88
+ // is example material, not a leaked credential. `cred-stores` demotes the same
89
+ // value (its FP[0]); secrets.js must too or it false-positives on any README
90
+ // that quotes the AWS docs. The 40-char example secret
91
+ // (`wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY`) carries the literal `EXAMPLE`
92
+ // token, which the AWS-secret-access-key pattern already requires elsewhere;
93
+ // the access-key ID is the one that needs an explicit allowlist.
94
+ const AWS_EXAMPLE_ACCESS_KEY_IDS = new Set([
95
+ "AKIAIOSFODNN7EXAMPLE",
96
+ ]);
97
+
86
98
  const INDICATOR_PATTERNS = [
87
99
  { id: "aws-access-key-id", re: /\bAKIA[0-9A-Z]{16}\b/g },
88
100
  { id: "aws-secret-access-key", re: /\baws_secret_access_key\s*[=:]\s*['"]?([A-Za-z0-9/+=]{40})['"]?/gi },
@@ -199,10 +211,16 @@ function scanContent(full, rel) {
199
211
  const matches = buf.matchAll(p.re);
200
212
  let count = 0;
201
213
  for (const m of matches) {
214
+ // Demote AWS-published example access-key IDs (e.g. the docs' canonical
215
+ // AKIAIOSFODNN7EXAMPLE). A README quoting the AWS docs must not hit.
216
+ if (p.id === "aws-access-key-id" && AWS_EXAMPLE_ACCESS_KEY_IDS.has(m[0])) continue;
202
217
  hits.push({
203
218
  indicator_id: p.id,
204
219
  file: rel,
205
220
  offset: m.index,
221
+ // 1-based line of the match so buildEvidenceLocations emits a region
222
+ // (SARIF startLine) instead of a bare file-level location.
223
+ line: lineFromOffset(buf, m.index),
206
224
  redacted_match: redactMatch(m[0]),
207
225
  });
208
226
  if (++count >= 5) break; // cap per-indicator-per-file
@@ -264,6 +282,15 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
264
282
  if (r.hits) allHits.push(...r.hits);
265
283
  if (r.skipped === "read_error") {
266
284
  errors.push({ artifact_id: "secret-regex-scan-text-files", kind: "read_failed", reason: `${f.rel}: ${r.reason}` });
285
+ } else if (r.skipped === "file_too_large") {
286
+ // A secret in the first bytes of a large file would otherwise be
287
+ // dropped silently. Record the skip so the operator knows this file
288
+ // was NOT scanned (mirrors crypto-codebase's >1 MB read_failed entry).
289
+ errors.push({
290
+ artifact_id: "secret-regex-scan-text-files",
291
+ kind: "file_too_large_skipped",
292
+ reason: `${f.rel}: ${r.bytes} bytes exceeds ${MAX_FILE_BYTES}-byte scan limit; not scanned for secrets`,
293
+ });
267
294
  }
268
295
  }
269
296
 
@@ -311,9 +338,10 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
311
338
 
312
339
  // Per-indicator file locations for every indicator flipped to "hit", so
313
340
  // a SARIF result points at the file carrying the secret / bad posture.
314
- // Content-regex hits record a byte offset rather than a line, so these
315
- // are file-level locations (no startLine). The file-presence and
316
- // posture indicators contribute the carrier file path directly.
341
+ // Content-regex hits carry a 1-based `line` (derived from the match offset),
342
+ // so these locations include a startLine region. The file-presence and
343
+ // posture indicators contribute the carrier file path directly (file-level,
344
+ // no line).
317
345
  const evidence_locations = {};
318
346
  for (const p of INDICATOR_PATTERNS) {
319
347
  if (signal_overrides[p.id] === "hit") {
package/lib/cve-cli.js CHANGED
@@ -28,7 +28,11 @@ const { resolveCve } = require("./citation-resolve.js");
28
28
  process.exitCode = 1;
29
29
  return;
30
30
  }
31
- const id = argv.find((a) => !a.startsWith("--"));
31
+ // Trim the positional so a whitespace-only argument (`cve " "`) is
32
+ // treated identically to a missing one (`cve ""`) — a usage error, not a
33
+ // "fabricated" lookup of the literal spaces.
34
+ const rawId = argv.find((a) => !a.startsWith("--"));
35
+ const id = rawId == null ? rawId : rawId.trim();
32
36
  const pretty = flags.has("--pretty");
33
37
  const json = flags.has("--json") || pretty;
34
38