@blamejs/exceptd-skills 0.14.11 → 0.14.13

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,50 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.14.13 — 2026-05-27
4
+
5
+ Security: a collector scanning a hostile repository no longer hangs on a crafted file. Three workflow/Dockerfile/manifest scanners (`library-author`, `cicd-pipeline-compromise`, `containers`) had a regex that backtracked catastrophically on a long whitespace line — a single planted file could wedge the scan for minutes. The regexes are fixed and a per-line length cap bounds any future regression.
6
+
7
+ Deeply-nested evidence is now rejected with an actionable message instead of crashing with an opaque "internal error". The submission canonicalizer (which runs on every `run` to compute the evidence hash) recursed without bound; it now refuses a submission nested beyond 200 levels.
8
+
9
+ `run --strict-preconditions` now fails (exit 1) when a `skip_phase` precondition is false. Previously such a run skipped the detect phase and exited 0, so a CI gate relying on the flag silently passed despite the detection never running.
10
+
11
+ Detection no longer silently loses or buries a result:
12
+ - A `signal_overrides` value that isn't a recognized result (e.g. `"maybe"`, a number) now surfaces a `signal_override_unrecognized` runtime error instead of being dropped as if the signal were never supplied.
13
+ - A `not_detected` / `clean` classification override is refused when it would bury a deterministic indicator hit (a deterministic hit is too strong to downgrade to "nothing found"); the run stays inconclusive with an explanatory error. Probabilistic hits remain overridable for the legitimate "I confirmed these are benign" workflow. A refused override is no longer reported as applied.
14
+
15
+ `run --all` / `run-all` now exits 7 (session-id collision) when a reused `--session-id` collides across the batch, matching the single-run behavior — previously a batch that persisted nothing exited 0 and reported success.
16
+
17
+ `watch --help` prints usage and exits instead of starting the blocking daemon and hanging the terminal; `collect --help` now prints its synopsis. The `--help` synopsis for the spawned verbs (`watch`, `watchlist`, `report`, `scan`, `dispatch`, `currency`, `validate-cves`, `validate-rfcs`) is filled in.
18
+
19
+ README corrects the `watch` / `watchlist` documentation (the one-shot aggregator with `--alerts` / `--org-scan` is `watchlist`; `watch` is the long-running daemon) and the `refresh --prefetch` description (it warms the cache by fetching, the opposite of the report-only `--no-network`).
20
+
21
+ ## 0.14.12 — 2026-05-27
22
+
23
+ Structured-bundle accuracy:
24
+ - 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.
25
+ - 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).
26
+ - SARIF `cve_match` results now carry a `locations` entry. Without it, GitHub Code Scanning silently dropped the highest-severity result class.
27
+ - SARIF and OpenVEX render "not assessed" for an unassessed blast radius instead of the literal "null" / "null/5".
28
+ - `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.
29
+
30
+ External-source command hardening:
31
+ - `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.
32
+ - `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.
33
+ - `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.
34
+ - `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.
35
+
36
+ `doctor`:
37
+ - `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.
38
+ - `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.
39
+ - `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.
40
+
41
+ Playbook validation hardening (enforcement for future drift; the shipped corpus is unaffected):
42
+ - `domain.attack_refs` are cross-referenced against the ATT&CK catalog (they were unchecked).
43
+ - 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).
44
+ - 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.
45
+
46
+ RWEP factor validation accepts a numeric string consistently with the scorer (the two surfaces previously disagreed).
47
+
3
48
  ## 0.14.11 — 2026-05-27
4
49
 
5
50
  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.
package/README.md CHANGED
@@ -116,7 +116,7 @@ npx @blamejs/exceptd-skills path
116
116
  That prints the absolute path of the installed package. Point your AI assistant at:
117
117
 
118
118
  - `<path>/AGENTS.md` — canonical project rules + ground truth for every skill
119
- - `<path>/data/_indexes/summary-cards.json` — 100-word abstract per skill (12 KB)
119
+ - `<path>/data/_indexes/summary-cards.json` — 100-word abstract per skill (~95 KB)
120
120
  - `<path>/data/_indexes/recipes.json` — curated multi-skill chains for common use cases
121
121
 
122
122
  No clone, no signing keys, no Node 24 required for assistants that read directly from disk. If your assistant needs a local copy as a regular checkout, use `npx degit blamejs/exceptd-skills my-skills` instead.
@@ -156,9 +156,9 @@ Fresh-disclosure workflow (v0.12.0): the nightly auto-PR job pulls KEV / NVD / E
156
156
 
157
157
  Primary-source advisory polling: `exceptd refresh --check-advisories` polls 15 vendor and coordinated-disclosure feeds — 8 advisory/coordinated-disclosure venues (Qualys TRU, Red Hat RHSA, Ubuntu USN, Zero Day Initiative, kernel.org commits, oss-security mailing list, JFrog SecOps, CISA current advisories), 4 vendor security research blogs added in v0.13.14 (Microsoft Security Blog, Sysdig, Trail of Bits, Embrace the Red), and 3 additions in v0.13.17 (BleepingComputer security, The Hacker News, and a GitLab activity-feed tracker for the Nightmare-Eclipse researcher handle that anchors NEW-CTRL-073). Combined coverage publishes CVE IDs at T+0 to T+1 — typically 3–14 days ahead of NVD enrichment. The command is report-only: it returns a structured `diffs[]` listing each newly-seen CVE ID with its source attributions and advisory URLs, but does not mutate the catalog. v0.13.17 also adds a complementary detection method (NEW-CTRL-074 / `lib/cve-regression-watcher.js`): the watcher cross-checks poller diffs for historical-CVE references (year ≤ currentYear − 2) and surfaces candidate silent-regression cases — the class anchored by MiniPlasma (a 2026 PoC drop that re-broke CVE-2020-17103 without any new ID being assigned). Operators triage the output and route promising IDs through `exceptd refresh --advisory <CVE-ID> --apply`. Pairs naturally with the daily scheduled remote agent below.
158
158
 
159
- CVE-class alert surfacing: `exceptd watch --alerts` matches the live `cve-catalog.json` against five operational patterns (`kernel_lpe_with_poc`, `supply_chain_family`, `ai_discovered_kev`, `active_exploitation_unpatched`, `recent_poc_no_kev_yet`) and returns the matches sorted critical-severity-first, then by RWEP. Use as a fast operational triage on a refreshed catalog without scanning every entry by hand.
159
+ CVE-class alert surfacing: `exceptd watchlist --alerts` matches the live `cve-catalog.json` against five operational patterns (`kernel_lpe_with_poc`, `supply_chain_family`, `ai_discovered_kev`, `active_exploitation_unpatched`, `recent_poc_no_kev_yet`) and returns the matches sorted critical-severity-first, then by RWEP. Use as a fast operational triage on a refreshed catalog without scanning every entry by hand.
160
160
 
161
- GitHub repo-pattern monitoring: `exceptd watch --org-scan --org <login>` probes GitHub Search for repositories matching known threat-actor naming patterns ("A Gift From TeamPCP", "Shai-Hulud", "TeamPCP") scoped to one org. Custom patterns via repeatable `--pattern <s>`. Implements the canonical detection for the Shai-Hulud / TeamPCP supply-chain framework class — the attacker uses GitHub itself as the exfil channel. Set `GITHUB_TOKEN` for private-repo coverage and rate-limit headroom; public-repo search works without auth.
161
+ GitHub repo-pattern monitoring: `exceptd watchlist --org-scan --org <login>` probes GitHub Search for repositories matching known threat-actor naming patterns ("A Gift From TeamPCP", "Shai-Hulud", "TeamPCP") scoped to one org. Custom patterns via repeatable `--pattern <s>`. Implements the canonical detection for the Shai-Hulud / TeamPCP supply-chain framework class — the attacker uses GitHub itself as the exfil channel. Set `GITHUB_TOKEN` for private-repo coverage and rate-limit headroom; public-repo search works without auth.
162
162
 
163
163
  AI-assistant config-file audit: `exceptd doctor --ai-config` walks `~/.claude`, `~/.cursor`, `~/.codeium`, `~/.aider`, and `~/.continue`, flagging sensitive files (`settings.json`, `mcp.json`, `*.mcp_config.json`, `api_key*`, `*.token`, `*.credentials`) not at mode 0600 on POSIX. On Windows the mode bits aren't load-bearing; each finding is surfaced with an info-level "manual ACL review" note. Catches the AI-config-credential-exfil class that the Shai-Hulud framework targets. Opt-in — does not run as part of the default no-flag `doctor` pass.
164
164
 
@@ -329,6 +329,21 @@ exceptd doctor One-shot health check.
329
329
  note for each sensitive file on Windows.
330
330
  Opt-in; not part of the default doctor
331
331
  pass.
332
+ --fix Auto-remediate signing gaps: regenerate
333
+ the local Ed25519 private key when
334
+ keys/public.pem exists but .keys/private.pem
335
+ is absent. No-op when the key is present.
336
+ --registry-check Probe the npm registry for the latest
337
+ published version + days-since-publish.
338
+ Off by default; --air-gap suppresses it.
339
+ --collectors Enumerate the per-playbook collector layer:
340
+ which playbooks ship a collector, which are
341
+ policy-skipped, and which are unwired.
342
+ --shipped-tarball Run the pack + extract + verify round-trip
343
+ against the tarball operators receive, not
344
+ just the source tree.
345
+ --exit-codes Print the canonical exit-code table as
346
+ JSON for CI / scripting consumers.
332
347
 
333
348
  exceptd ci One-shot CI gate. Exit codes: 0 PASS,
334
349
  1 framework error, 2 detected/escalate
@@ -382,8 +397,12 @@ exceptd refresh Refresh upstream catalogs + indexes.
382
397
  Replaces prefetch + refresh + build-indexes.
383
398
  --apply Write diffs back + rebuild indexes.
384
399
  --from-cache [<dir>] Read from prefetch cache.
385
- --prefetch Populate the offline cache (alias for
386
- --no-network).
400
+ --prefetch Warm the offline cache by fetching every
401
+ upstream artifact now (network required).
402
+ Run on a connected host, then point
403
+ --from-cache at the result on the air-gap.
404
+ --no-network Report-only dry-run: list what would be
405
+ fetched without touching the network.
387
406
  --network (v0.11.14) Fetch latest signed catalog
388
407
  snapshot from npm tarball, verify against
389
408
  local public.pem, swap data/ in place.
@@ -417,9 +436,10 @@ Packages dataset (`MAL-*` keys). New IDs land as drafts that the catalog
417
436
  validator treats as warnings, not errors — editorial review (framework
418
437
  gaps, IoCs, ATLAS/ATT&CK refs) is still required.
419
438
 
420
- exceptd watch Default mode: aggregate every skill's
439
+ exceptd watchlist Default mode: aggregate every skill's
421
440
  forward_watch entries (upcoming standards,
422
- RFC publications, new TTPs to monitor).
441
+ RFC publications, new TTPs to monitor) in
442
+ one shot.
423
443
  `--by-skill` inverts the grouping.
424
444
  --alerts Switch to CVE-catalog pattern alerts.
425
445
  Five patterns ship:
@@ -448,6 +468,15 @@ exceptd watch Default mode: aggregate every skill's
448
468
  limit; without it, public-repo search
449
469
  only.
450
470
 
471
+ exceptd watch Long-running forward-watch daemon. Blocks
472
+ and listens for KEV additions, ATLAS
473
+ updates, CVE drops, and framework
474
+ amendments, with scheduled currency /
475
+ validation checks. Ctrl-C (or SIGTERM /
476
+ SIGHUP / SIGBREAK) to stop. For one-shot
477
+ aggregation, pattern alerts, or org-scan,
478
+ use `exceptd watchlist`.
479
+
451
480
  exceptd skill <name> Show context for one skill.
452
481
  exceptd framework-gap <FW> <ref> One framework + one CVE/scenario, JSON
453
482
  or human. (Operates outside the seven-
@@ -455,7 +484,8 @@ exceptd framework-gap <FW> <ref> One framework + one CVE/scenario, JSON
455
484
  exceptd path Absolute path to the installed package.
456
485
  exceptd version Package version.
457
486
  exceptd help This help.
458
- exceptd <verb> --help Per-verb usage with flag descriptions.
487
+ exceptd <verb> --help Most verbs print per-verb usage with flag
488
+ descriptions.
459
489
  ```
460
490
 
461
491
  ### Legacy v0.10.x verbs
package/bin/exceptd.js CHANGED
@@ -761,6 +761,16 @@ function main() {
761
761
  "framework-gap-analysis": "exceptd framework-gap <framework> <cve-or-scenario> One-framework gap analysis.",
762
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
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
+ // watch MUST be here: without the interception `watch --help` falls through
765
+ // to spawning the blocking daemon, hanging the operator's terminal.
766
+ watch: "exceptd watch Long-running forward-watch daemon (blocks; Ctrl-C to stop). For a one-shot aggregator use `exceptd watchlist`.",
767
+ watchlist: "exceptd watchlist [--alerts] [--org-scan --org <login>] [--by-skill] [--json] One-shot forward-watch aggregator across skills.",
768
+ report: "exceptd report [executive] [--json] Structured posture report.",
769
+ scan: "exceptd scan [--json] [legacy] Working-directory CVE/KEV scan (orchestrator). See `exceptd discover`.",
770
+ dispatch: "exceptd dispatch [--json] [legacy] Scan + route findings to skills (orchestrator). See `exceptd discover`.",
771
+ currency: "exceptd currency [--json] [legacy] Skill threat-currency report. See `exceptd doctor --currency`.",
772
+ "validate-cves": "exceptd validate-cves [--offline|--air-gap] [--json] Validate the CVE catalog against upstream (offline-first).",
773
+ "validate-rfcs": "exceptd validate-rfcs [--offline|--air-gap] [--json] Validate the RFC index against upstream (offline-first).",
764
774
  };
765
775
  if ((effectiveRest.includes("--help") || effectiveRest.includes("-h")) && SPAWN_HELP_USAGE[effectiveCmd]) {
766
776
  process.stdout.write(SPAWN_HELP_USAGE[effectiveCmd] + "\n Full reference: exceptd help\n");
@@ -2314,6 +2324,20 @@ Exit codes:
2314
2324
  Output: verb, session_id, playbooks_run, summary{total, detected,
2315
2325
  max_rwep_observed, jurisdiction_clocks_started, verdict, fail_reasons[]},
2316
2326
  results[].`,
2327
+ collect: `collect <playbook> [--cwd <dir>] [--resolve] [--air-gap] [--json]
2328
+
2329
+ Scan the working directory (or --cwd <dir>) and emit an evidence submission
2330
+ for <playbook>, ready to pipe into \`run\`:
2331
+
2332
+ exceptd collect <playbook> | exceptd run <playbook> --evidence -
2333
+
2334
+ Flags:
2335
+ --cwd <dir> Scan <dir> instead of the current directory.
2336
+ --resolve (citation-hygiene) resolve uncatalogued CVE/RFC
2337
+ citations found during the scan.
2338
+ --air-gap Do not touch the network during collection.
2339
+ --json Raw JSON (default when piped; collect output is the
2340
+ submission, not a human digest).`,
2317
2341
  brief: `brief [playbook] — unified info doc (v0.11.0).
2318
2342
 
2319
2343
  Collapses the three info-only phases plan + govern + direct + look into a
@@ -3553,8 +3577,13 @@ function cmdRun(runner, args, runOpts, pretty) {
3553
3577
  // behavior where warn-level issues stay informational. CI gates wanting
3554
3578
  // "fail on any unverified precondition" pass this flag.
3555
3579
  if (args["strict-preconditions"] && result && Array.isArray(result.preflight_issues)) {
3580
+ // precondition_skip MUST be included: a false skip_phase precondition
3581
+ // means detect never ran, so a CI gate relying on --strict-preconditions
3582
+ // ("any precondition_check returning false fails the run", per --help) would
3583
+ // otherwise silently pass (verdict:skipped, exit 0) — the exact gap the
3584
+ // flag exists to close.
3556
3585
  const warnIssues = result.preflight_issues.filter(i =>
3557
- i.kind === "precondition_unverified" || i.kind === "precondition_warn"
3586
+ i.kind === "precondition_unverified" || i.kind === "precondition_warn" || i.kind === "precondition_skip"
3558
3587
  );
3559
3588
  if (warnIssues.length > 0) {
3560
3589
  // v0.12.12: surface the contract violation in the emitted body so
@@ -4303,9 +4332,19 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
4303
4332
  // remediation without parsing the body.
4304
4333
  const anyLockBusy = results.some(r => r.attestation_persist && r.attestation_persist.lock_contention === true);
4305
4334
  const anyStorageExhausted = results.some(r => r.attestation_persist && r.attestation_persist.storage_exhausted === true);
4335
+ // A persist failure that is neither lock-contention nor storage-exhaustion is
4336
+ // a session-id collision (the single-run path exits 7 for the same
4337
+ // condition). Pre-fix a batch where every attestation refused to overwrite
4338
+ // exited 0, so a re-run with a reused --session-id silently persisted nothing
4339
+ // while reporting success. Surface it with the same code as the single-run
4340
+ // path so a CI gate sees it.
4341
+ const anySessionCollision = results.some(r =>
4342
+ r.attestation_persist && r.attestation_persist.ok === false
4343
+ && !r.attestation_persist.lock_contention && !r.attestation_persist.storage_exhausted);
4306
4344
  const anyBlocked = results.some(r => r.ok === false);
4307
4345
  if (anyLockBusy) { process.exitCode = EXIT_CODES.LOCK_CONTENTION; return; }
4308
4346
  if (anyStorageExhausted) { process.exitCode = EXIT_CODES.STORAGE_EXHAUSTED; return; }
4347
+ if (anySessionCollision) { process.exitCode = EXIT_CODES.SESSION_ID_COLLISION; return; }
4309
4348
  if (anyBlocked) { process.exitCode = EXIT_CODES.GENERIC_FAILURE; return; }
4310
4349
  }
4311
4350
 
@@ -6469,7 +6508,11 @@ function cmdDoctor(runner, args, runOpts, pretty) {
6469
6508
  const onlyAiConfig = !!args["ai-config"];
6470
6509
  const onlyCollectors = !!args.collectors;
6471
6510
  const anySelected = onlySigs || onlyCurrency || onlyCves || onlyRfcs || onlyAiConfig || onlyCollectors;
6472
- const runSigs = !anySelected || onlySigs;
6511
+ // --shipped-tarball lives inside the signatures check, so it must imply it.
6512
+ // Pre-fix, `doctor --shipped-tarball --cves` made runSigs false (a selective
6513
+ // flag was set, but not --signatures), silently skipping the tarball
6514
+ // round-trip while the operator believed it ran.
6515
+ const runSigs = !anySelected || onlySigs || !!args["shipped-tarball"];
6473
6516
  const runCurrency = !anySelected || onlyCurrency;
6474
6517
  const runCves = !anySelected || onlyCves;
6475
6518
  const runRfcs = !anySelected || onlyRfcs;
@@ -6670,23 +6713,34 @@ function cmdDoctor(runner, args, runOpts, pretty) {
6670
6713
  timeout: 30000,
6671
6714
  });
6672
6715
  const text = (res.stdout || "") + (res.stderr || "");
6673
- const rfcRows = (text.match(/^RFC-\d+/gm) || []).length;
6674
6716
  const driftMatch = text.match(/drift[:\s]+(\d+)/i);
6675
6717
  const ok = res.status === 0;
6676
- // Audit 3 B.7: surface the RFC catalog file's mtime + age so
6677
- // operators can answer "is the offline RFC index fresh?" without
6678
- // running a separate refresh.
6718
+ // Count the catalog directly (same approach the CVE subcheck uses) rather
6719
+ // than scraping `^RFC-\d+` table rows from the validate-rfcs output. The
6720
+ // text scrape dropped every non-RFC family (CSAF / DRAFT / ISO entries),
6721
+ // undercounting the catalog and hiding those citation families. Read the
6722
+ // canonical file and emit a by_prefix breakdown.
6723
+ const rfcCatalogPath = path.join(PKG_ROOT, "data", "rfc-references.json");
6724
+ let rfcTotal = 0;
6725
+ const byPrefix = {};
6679
6726
  let rfcMtime = null;
6680
6727
  let rfcAgeDays = null;
6681
6728
  try {
6682
- const rfcPath = path.join(PKG_ROOT, "data", "rfc-index.json");
6683
- const st = fs.statSync(rfcPath);
6729
+ const catalog = JSON.parse(fs.readFileSync(rfcCatalogPath, "utf8"));
6730
+ for (const k of Object.keys(catalog)) {
6731
+ if (k.startsWith("_")) continue;
6732
+ rfcTotal++;
6733
+ const prefix = (k.match(/^[A-Za-z]+/) || ["?"])[0].toUpperCase();
6734
+ byPrefix[prefix] = (byPrefix[prefix] || 0) + 1;
6735
+ }
6736
+ const st = fs.statSync(rfcCatalogPath);
6684
6737
  rfcMtime = st.mtime.toISOString();
6685
6738
  rfcAgeDays = Math.floor((Date.now() - st.mtimeMs) / 86400000);
6686
- } catch { /* file may not exist on contributor checkouts */ }
6739
+ } catch { /* file may be absent on exotic installs — total stays 0 */ }
6687
6740
  checks.rfcs = {
6688
6741
  ok,
6689
- total: rfcRows,
6742
+ total: rfcTotal,
6743
+ by_prefix: byPrefix,
6690
6744
  drift: driftMatch ? Number(driftMatch[1]) : 0,
6691
6745
  index_last_modified: rfcMtime,
6692
6746
  index_age_days: rfcAgeDays,
@@ -6968,9 +7022,14 @@ function cmdDoctor(runner, args, runOpts, pretty) {
6968
7022
  }
6969
7023
  }
6970
7024
 
7025
+ // A truncated walk (hit the file/depth cap) means the audit is INCOMPLETE —
7026
+ // a sensitive file beyond the cap would be unseen. Don't report an
7027
+ // unqualified clean pass: downgrade to a warn so automation can branch on
7028
+ // incompleteness even when zero findings surfaced within the cap.
7029
+ const baseSeverity = errorFindings.length > 0 && fixesFailed > 0 ? 'warn' : (errorFindings.length > 0 && !args.fix ? 'warn' : 'info');
6971
7030
  checks.ai_config = {
6972
- ok: errorFindings.length === 0 || (args.fix && fixesFailed === 0),
6973
- severity: errorFindings.length > 0 && fixesFailed > 0 ? 'warn' : (errorFindings.length > 0 && !args.fix ? 'warn' : 'info'),
7031
+ ok: (errorFindings.length === 0 || (args.fix && fixesFailed === 0)) && !walkAborted,
7032
+ severity: walkAborted && baseSeverity === 'info' ? 'warn' : baseSeverity,
6974
7033
  scanned_dirs: scannedDirs,
6975
7034
  scanned_files: scannedFiles,
6976
7035
  walk_truncated: walkAborted,
@@ -7089,8 +7148,8 @@ function cmdDoctor(runner, args, runOpts, pretty) {
7089
7148
  // global `npm install -g` reported `failed_checks: ["signing"]` with
7090
7149
  // `warnings_count: 0`, contradicting the [!! warn] text-mode icon.
7091
7150
  const { bucketChecks } = require(path.join(PKG_ROOT, "lib", "doctor-bucketing.js"));
7092
- const { warnList, errorList } = bucketChecks(checks);
7093
- const allGreen = errorList.length === 0 && warnList.length === 0;
7151
+ let { warnList, errorList } = bucketChecks(checks);
7152
+ let allGreen = errorList.length === 0 && warnList.length === 0;
7094
7153
  // Audit 3 B.11: surface the local version on the default doctor output
7095
7154
  // so operators answer both "is my install healthy?" AND "which version
7096
7155
  // am I running?" without having to invoke `exceptd version` separately.
@@ -7127,10 +7186,21 @@ function cmdDoctor(runner, args, runOpts, pretty) {
7127
7186
  // `exceptd doctor` (signatures check) reports 0/N passing.
7128
7187
  if (args.fix && checks.signing && !checks.signing.private_key_present) {
7129
7188
  const pubKeyExists = fs.existsSync(path.join(PKG_ROOT, "keys", "public.pem"));
7189
+ const fingerprintPinExists = fs.existsSync(path.join(PKG_ROOT, "keys", "EXPECTED_FINGERPRINT"));
7130
7190
  if (pubKeyExists) {
7131
7191
  out.summary.fix_attempted = "ed25519_keypair_generation_declined";
7132
7192
  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.";
7133
7193
  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");
7194
+ } else if (fingerprintPinExists) {
7195
+ // A committed EXPECTED_FINGERPRINT without keys/public.pem signals an
7196
+ // intended committed signing identity on a corrupted/partial checkout.
7197
+ // Generating a fresh keypair here would write a public.pem whose
7198
+ // fingerprint can never match the pin, leaving verify.js permanently
7199
+ // refusing (fingerprint-mismatch) while --fix claimed success. Decline
7200
+ // and tell the operator to restore the real public key.
7201
+ out.summary.fix_attempted = "ed25519_keypair_generation_declined";
7202
+ 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).";
7203
+ 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");
7134
7204
  } else {
7135
7205
  process.stderr.write("[doctor --fix] generating Ed25519 keypair...\n");
7136
7206
  const r = require("child_process").spawnSync(process.execPath, [path.join(PKG_ROOT, "lib", "sign.js"), "generate-keypair"], {
@@ -7188,6 +7258,37 @@ function cmdDoctor(runner, args, runOpts, pretty) {
7188
7258
  }
7189
7259
  }
7190
7260
 
7261
+ // After a --fix that re-signed skills (keypair generation OR re-sign), the
7262
+ // captured `checks.signatures` is STALE — it was the verify.js result taken
7263
+ // before any key existed. Re-verify now and recompute the buckets, so a
7264
+ // successful --fix reports success (and exits 0) instead of carrying the
7265
+ // pre-fix "signatures FAILED" through to failed_checks + a non-zero exit.
7266
+ if (args.fix
7267
+ && (out.summary.fix_applied === "ed25519_keypair_generated_and_skills_signed"
7268
+ || out.summary.fix_applied === "skills_resigned_against_current_keypair")) {
7269
+ try {
7270
+ const verifyPath = path.join(PKG_ROOT, "lib", "verify.js");
7271
+ const rv = spawnSync(process.execPath, [verifyPath], { encoding: "utf8", cwd: PKG_ROOT, timeout: 30000 });
7272
+ const rvText = (rv.stdout || "") + (rv.stderr || "");
7273
+ const rvMatch = rvText.match(/(\d+)\/(\d+)\s+skills?\s+passed/i);
7274
+ const rvFp = rvText.match(/SHA256:\s*([A-Za-z0-9+/=]+)/);
7275
+ const rvOk = rv.status === 0;
7276
+ checks.signatures = {
7277
+ ok: rvOk,
7278
+ skills_passed: rvMatch ? Number(rvMatch[1]) : null,
7279
+ skills_total: rvMatch ? Number(rvMatch[2]) : null,
7280
+ fingerprint_sha256: rvFp ? rvFp[1] : null,
7281
+ ...(rvOk ? {} : { exit_code: rv.status, raw: rvText.slice(0, 500) }),
7282
+ };
7283
+ out.checks = checks;
7284
+ ({ warnList, errorList } = bucketChecks(checks));
7285
+ allGreen = errorList.length === 0 && warnList.length === 0;
7286
+ out.summary.failed_checks = errorList;
7287
+ out.summary.warning_checks = warnList;
7288
+ out.summary.all_green = allGreen;
7289
+ } catch { /* re-verify best-effort; leave the pre-fix state if it throws */ }
7290
+ }
7291
+
7191
7292
  // Audit 3 B.3: --fix was passed but nothing to fix. Pre-fix this was
7192
7293
  // silently a no-op — operators couldn't distinguish "we tried and were
7193
7294
  // already healthy" from "we tried and failed silently." Now surfaces a
@@ -8543,9 +8644,15 @@ function cmdCi(runner, args, runOpts, pretty) {
8543
8644
  }
8544
8645
  process.stdout.write(lines.join("\n") + "\n");
8545
8646
  } else if (fmt === "csaf" || fmt === "sarif" || fmt === "openvex") {
8546
- // Aggregate the per-run bundles_by_format if present.
8647
+ // Aggregate the per-run bundles_by_format if present. ci spans N playbooks,
8648
+ // so there is no single conformant CSAF/SARIF/OpenVEX document — emit a JSON
8649
+ // ARRAY of the pure documents. Critically, do NOT wrap them in an exceptd
8650
+ // envelope carrying a top-level `ok` key: that key is invalid in all three
8651
+ // standard formats, so a downstream CSAF/SARIF/OpenVEX consumer pointed at
8652
+ // `ci --format` output got a non-conformant top-level shape. Each array
8653
+ // element is now a verbatim, conformant document.
8547
8654
  const bundles = results.map(r => r.phases?.close?.evidence_package?.bundles_by_format?.[fmt === "csaf" ? "csaf-2.0" : fmt]).filter(Boolean);
8548
- emit({ verb: "ci", session_id: sessionId, format: fmt, bundles_count: bundles.length, bundles }, pretty);
8655
+ process.stdout.write(JSON.stringify(bundles, null, pretty ? 2 : 0) + "\n");
8549
8656
  } else if (fmt && fmt !== "json") {
8550
8657
  // v0.11.4 (#76): garbage format rejected with structured error, not silent empty stdout.
8551
8658
  // Route through emitError so the body propagates exit codes via the
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "schema_version": "1.1.0",
3
- "generated_at": "2026-05-27T20:36:13.795Z",
3
+ "generated_at": "2026-05-27T22:17:04.450Z",
4
4
  "generator": "scripts/build-indexes.js",
5
5
  "source_count": 54,
6
6
  "source_hashes": {
7
- "manifest.json": "3db842fa75688111c96edd57712b9447a3df84cb250df1e052ac45b38aff74f2",
7
+ "manifest.json": "a1f0cb852fd487d12bd1a304d36eb175f3ee36a26e37a1ca9cb25d9c576d2afc",
8
8
  "data/atlas-ttps.json": "d24bc02859d40ccf1615db75cca68c077585904e41e0d8f6de448121e9b1abb0",
9
9
  "data/attack-techniques.json": "fa193f0d2d248176a8beddb641e9fe56ba4faa9e15dc253ff876dbf0c5d58a77",
10
10
  "data/cve-catalog.json": "3d451dda7ac0c7d57a4075ae4bafd3148c6184b35dc1bc59d8b81d1f2641e430",
@@ -180,7 +180,12 @@ function scanWorkflow(content, rel) {
180
180
  // composite actions (`uses: ./`).
181
181
  const lines2 = content.split(/\r?\n/);
182
182
  for (let i = 0; i < lines2.length; i++) {
183
- const m = lines2[i].match(/^\s*-?\s*uses:\s*['"]?([^'"\s#]+)['"]?/);
183
+ // A real `uses:` line is never multiple KB. Skip overlong lines so a
184
+ // crafted whitespace run can't drive regex backtracking.
185
+ if (lines2[i].length > 4096) continue;
186
+ // `^[ \t]*(?:-[ \t]*)?` anchors the indentation once, then an optional
187
+ // `- ` list marker — no overlapping `\s*` runs that backtrack.
188
+ const m = lines2[i].match(/^[ \t]*(?:-[ \t]*)?uses:\s*['"]?([^'"\s#]+)['"]?/);
184
189
  if (!m) continue;
185
190
  const refStr = m[1];
186
191
  if (refStr.startsWith("./") || refStr.startsWith("docker://")) continue;
@@ -222,6 +222,9 @@ function scanCompose(content, rel) {
222
222
 
223
223
  for (let i = 0; i < lines.length; i++) {
224
224
  const line = lines[i];
225
+ // A real compose line is never multiple KB. Skip overlong lines so a
226
+ // crafted whitespace run can't drive regex backtracking.
227
+ if (line.length > 4096) continue;
225
228
  if (/^\s*#/.test(line)) continue;
226
229
  if (/^\s*privileged:\s*true\b/i.test(line)) hits["compose-privileged"].push({ file: rel, line: i + 1, snippet: line.trim() });
227
230
  // compose-host-network: per playbook, fires on any of
@@ -268,6 +271,9 @@ function scanK8s(content, rel) {
268
271
 
269
272
  for (let i = 0; i < lines.length; i++) {
270
273
  const line = lines[i];
274
+ // A real manifest line is never multiple KB. Skip overlong lines so a
275
+ // crafted whitespace run can't drive regex backtracking.
276
+ if (line.length > 4096) continue;
271
277
  if (/^\s*#/.test(line)) continue;
272
278
  if (/^\s*privileged:\s*true\b/i.test(line)) hits["k8s-privileged"].push({ file: rel, line: i + 1, snippet: line.trim() });
273
279
  if (/^\s*(hostNetwork|hostPID|hostIPC):\s*true\b/.test(line)) hits["k8s-host-namespaces"].push({ file: rel, line: i + 1, snippet: line.trim() });
@@ -287,7 +293,9 @@ function scanK8s(content, rel) {
287
293
  }
288
294
  // image: ...:latest OR image: ... (no tag, defaults to latest)
289
295
  // Allow optional leading `-` from a YAML list item: `- image: ...`.
290
- const imageMatch = line.match(/^\s*-?\s*image:\s*['"]?([^'"@\s]+)(?:@[^'"]+)?['"]?\s*$/);
296
+ // `^[ \t]*(?:-[ \t]*)?` anchors the indentation once, then an optional
297
+ // `- ` list marker — no overlapping `\s*` runs that backtrack.
298
+ const imageMatch = line.match(/^[ \t]*(?:-[ \t]*)?image:\s*['"]?([^'"@\s]+)(?:@[^'"]+)?['"]?\s*$/);
291
299
  if (imageMatch) {
292
300
  const ref = imageMatch[1];
293
301
  const tagMatch = ref.match(/:([^/]+)$/);
@@ -157,8 +157,13 @@ function scanPublishWorkflow(content, rel) {
157
157
  const lines = content.split(/\r?\n/);
158
158
  for (let i = 0; i < lines.length; i++) {
159
159
  const line = lines[i];
160
+ // A real `uses:` line is never multiple KB. Skip overlong lines so a
161
+ // crafted whitespace run can't drive regex backtracking.
162
+ if (line.length > 4096) continue;
160
163
  // Allow optional leading `-` from a YAML list item: `- uses: ...`.
161
- const m = line.match(/^\s*-?\s*uses:\s*['"]?([^'"\s]+)['"]?\s*$/);
164
+ // `^[ \t]*(?:-[ \t]*)?` anchors the indentation once, then an optional
165
+ // `- ` list marker — no overlapping `\s*` runs that backtrack.
166
+ const m = line.match(/^[ \t]*(?:-[ \t]*)?uses:\s*['"]?([^'"\s]+)['"]?\s*$/);
162
167
  if (!m) continue;
163
168
  const ref = m[1];
164
169
  if (ref.startsWith("./") || ref.startsWith("./.github/")) continue; // local
package/lib/cve-cli.js CHANGED
@@ -45,7 +45,12 @@ const { resolveCve } = require("./citation-resolve.js");
45
45
  }
46
46
 
47
47
  const r = await resolveCve(id, { airGap: flags.has("--air-gap"), noNetwork: flags.has("--no-network") });
48
- const body = { ok: true, verb: "cve", ...r };
48
+ // A citation that won't stand up exits non-zero so a CI/script gate trips.
49
+ // Derive `ok` from the same set of statuses that drive the exit code — a
50
+ // non-zero exit must carry ok:false, never the inverted ok:true the
51
+ // envelope previously hardcoded.
52
+ const fails = r.status === "rejected" || r.status === "fabricated" || r.status === "nonexistent" || r.status === "withdrawn";
53
+ const body = { verb: "cve", ...r, ok: !fails };
49
54
 
50
55
  if (json) {
51
56
  process.stdout.write(JSON.stringify(body, null, pretty ? 2 : 0) + "\n");
@@ -61,8 +66,7 @@ const { resolveCve } = require("./citation-resolve.js");
61
66
  if (r.reason) line += `\n ${r.reason}`;
62
67
  process.stdout.write(line + "\n");
63
68
  }
64
- // A citation that won't stand up is a non-zero exit so a CI/script gate trips.
65
- if (r.status === "rejected" || r.status === "fabricated" || r.status === "nonexistent" || r.status === "withdrawn") {
69
+ if (fails) {
66
70
  process.exitCode = 2;
67
71
  }
68
72
  })();