@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 +45 -0
- package/README.md +38 -8
- package/bin/exceptd.js +123 -16
- package/data/_indexes/_meta.json +2 -2
- package/lib/collectors/cicd-pipeline-compromise.js +6 -1
- package/lib/collectors/containers.js +9 -1
- package/lib/collectors/library-author.js +6 -1
- package/lib/cve-cli.js +7 -3
- package/lib/playbook-runner.js +97 -28
- package/lib/prefetch.js +30 -0
- package/lib/refresh-external.js +41 -0
- 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 +98 -11
- package/package.json +1 -1
- package/sbom.cdx.json +40 -40
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 (
|
|
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
|
|
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
|
|
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
|
|
386
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
6677
|
-
//
|
|
6678
|
-
//
|
|
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
|
|
6683
|
-
const
|
|
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
|
|
6739
|
+
} catch { /* file may be absent on exotic installs — total stays 0 */ }
|
|
6687
6740
|
checks.rfcs = {
|
|
6688
6741
|
ok,
|
|
6689
|
-
total:
|
|
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:
|
|
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
|
-
|
|
7093
|
-
|
|
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
|
-
|
|
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
|
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-27T22:17:04.450Z",
|
|
4
4
|
"generator": "scripts/build-indexes.js",
|
|
5
5
|
"source_count": 54,
|
|
6
6
|
"source_hashes": {
|
|
7
|
-
"manifest.json": "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
})();
|