@blamejs/exceptd-skills 0.18.9 → 0.18.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/bin/exceptd.js +204 -118
  3. package/data/_indexes/_meta.json +3 -3
  4. package/data/_indexes/frequency.json +2 -2
  5. package/data/d3fend-catalog.json +6 -6
  6. package/data/playbooks/identity-sso-compromise.json +2 -2
  7. package/data/playbooks/sbom.json +1 -1
  8. package/lib/citation-resolve.js +11 -0
  9. package/lib/collectors/containers.js +13 -0
  10. package/lib/collectors/cred-stores.js +18 -9
  11. package/lib/collectors/secrets.js +4 -2
  12. package/lib/cross-ref-api.js +29 -7
  13. package/lib/cve-regression-watcher.js +47 -15
  14. package/lib/framework-gap.js +52 -19
  15. package/lib/gap-detectors.js +8 -3
  16. package/lib/lint-skills.js +3 -2
  17. package/lib/playbook-runner.js +125 -7
  18. package/lib/refresh-external.js +58 -7
  19. package/lib/refresh-network.js +18 -5
  20. package/lib/rfc-cli.js +113 -18
  21. package/lib/schemas/playbook.schema.json +1 -1
  22. package/lib/scoring.js +71 -8
  23. package/lib/source-advisories.js +58 -9
  24. package/lib/ttp-mapper.js +31 -3
  25. package/lib/upstream-check-cli.js +13 -1
  26. package/lib/validate-catalog-meta.js +51 -7
  27. package/lib/validate-cve-catalog.js +10 -0
  28. package/lib/validate-playbooks.js +19 -1
  29. package/lib/verify.js +35 -34
  30. package/lib/xml-tokenizer.js +187 -25
  31. package/manifest.json +53 -53
  32. package/orchestrator/dispatcher.js +53 -9
  33. package/orchestrator/index.js +9 -7
  34. package/orchestrator/pipeline.js +62 -14
  35. package/orchestrator/scanner.js +60 -9
  36. package/package.json +1 -1
  37. package/sbom.cdx.json +115 -100
  38. package/scripts/build-indexes.js +21 -3
  39. package/scripts/builders/cwe-chains.js +5 -2
  40. package/scripts/builders/section-offsets.js +17 -8
  41. package/scripts/builders/summary-cards.js +12 -4
  42. package/scripts/check-catalog-gap-budget.js +3 -3
  43. package/scripts/check-codebase-patterns-currency.js +1 -0
  44. package/scripts/check-codebase-patterns.js +166 -11
  45. package/scripts/check-sbom-currency.js +69 -3
  46. package/scripts/check-test-count.js +28 -16
  47. package/scripts/check-test-subjects.js +148 -0
  48. package/scripts/check-version-tags.js +24 -5
  49. package/scripts/predeploy.js +32 -8
  50. package/scripts/refresh-upstream-catalogs.js +169 -44
  51. package/scripts/release.js +28 -11
package/CHANGELOG.md CHANGED
@@ -1,5 +1,41 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.18.12 — 2026-06-22
4
+
5
+ A correctness pass across the CLI, the engine, scoring, the collectors, framework-gap reporting, signature verification, and the CWE-chain index.
6
+
7
+ `ci`/`run --evidence-dir` now refuses a `<playbook>.json` entry that is not a JSON object (an array, scalar, or null) with the same object-shape error the single-file and stdin paths use, instead of binding it as empty evidence and reporting a clean `not_detected` PASS at exit 0 — a mis-shaped per-playbook submission no longer produces a false-clean gate result.
8
+
9
+ `verifyManifestSignature` consults the `keys/EXPECTED_FINGERPRINT` key pin before the missing-signature path, so a swapped `keys/public.pem` whose `manifest_signature` was stripped is rejected through the library API rather than treated as a benign legacy state. Escalation and `feeds_into` ordering comparisons against a duration literal — e.g. the kernel playbook's `reboot_window > 24h` raise-severity chain — normalize both sides to hours and compare numerically instead of lexicographically (so a 48-hour window escalates and a 6-hour window does not); an ordering comparison that degrades to two non-numeric, non-duration strings now surfaces a `condition_type_mismatch` diagnostic instead of evaluating silently.
10
+
11
+ `compare()`'s factor explanation lists the reboot (+5) driver whenever a reboot is required, regardless of live-patch availability, matching the score it actually computes; and a post-weight RWEP block that stores `active_exploitation` as a status string is read as a post-weight block (its weighted factors, including the AI factor, are no longer dropped or mis-flagged as a mixed shape).
12
+
13
+ The secrets collector's `ssh-key-bad-perms` posture and the credential-store AWS doc-fixture demotion no longer false-positive on checked-in test-path fixtures or a duplicate profile name. A single-framework `framework-gap` report scopes its theater-risk list and matching-gap count to the requested framework instead of leaking controls from frameworks the operator did not ask about. `rfc --check` no longer falsely matches an unrelated title when the claimed title repeats a token. The CWE-chains index excludes auto-imported draft CVEs, matching the by-CVE half's curated-truth invariant. A null or non-object MCP server entry (config scan) or dispatch finding is skipped with a clear marker rather than dropping the file's other findings or throwing an opaque error.
14
+
15
+ ## 0.18.11 — 2026-06-22
16
+
17
+ Regenerates the CycloneDX SBOM (`sbom.cdx.json`) so its recorded hash for `CHANGELOG.md` matches the shipped file.
18
+
19
+ ## 0.18.10 — 2026-06-20
20
+
21
+ A large correctness pass across the condition engine, scoring, correlation, the collectors, the validators, the orchestrator, the refresh pipeline, and the CLI.
22
+
23
+ Escalation and `feeds_into` conditions that reference host-asserted finding descriptors — `finding.includes_*`, `finding.cve_class`, `finding.tool_surface`, and the rest of that family — now evaluate. The engine merges an agent-supplied `finding` object underneath the engine-computed fields, so a host-AI assertion (e.g. "this finding includes a cloud-role-assumption path") survives into the condition while engine-owned fields like severity and RWEP still win on collision; previously the engine's finding shape replaced the agent's object wholesale and roughly three dozen catalog chains across eighteen playbooks could never fire. The SBOM playbook's EU CRA Art.14 actively-exploited escalation now fires when a matched CVE is actively exploited under an EU obligation (it compared a composite notification key against the obligation set and never matched). `analyze.classification` conditions resolve, so the identity-SSO playbook's incident chain that gates on detection classification fires. A comparison whose dotted left-hand side resolves to nothing surfaces a diagnostic instead of evaluating to an invisible false. A `present` compliance verdict scores as a detected gap in the `theater_score` exposed to chaining, matching the rest of the engine. The playbook RWEP scaler routes the active-exploitation factor through the shared resolver, so a stray-cased or out-of-vocabulary value warns rather than silently scoring zero.
24
+
25
+ `compare()` no longer fabricates a "Low / next maintenance" CVSS SLA for a CVE that carries no CVSS. The post-weight RWEP factor sum excludes unrecognised or mistyped keys (with a warning) instead of letting them inflate the score.
26
+
27
+ ATT&CK technique cross-references resolve (the technique catalog is loaded), and the D3FEND countermeasure correlation reads its populated field instead of an always-empty one. Per-framework gap counts are reported for every framework rather than collapsing to zero for most of them. The containers collector flags a multi-stage Dockerfile whose final stage runs as root. Auto-imported draft CVEs are excluded from the CWE/technique/skill correlations, matching the by-CVE lookup. D3FEND artifact ids are recognised by the reference scanner, and two artifact ids that carried a spurious trailing period are normalised — the D3FEND refresh now normalises the upstream id to the catalog key as well, so a later refresh matches the existing row instead of re-adding a period-suffixed duplicate.
28
+
29
+ `scan`, `dispatch`, and `report` honour `--air-gap` by suppressing the TLS-reachability probe — previously only `EXCEPTD_AIR_GAP=1` did. `refresh --network` refuses under air-gap even when a registry fixture is set, and the tarball host allowlist validates the host it actually connects to. The CVE regression watcher receives advisory observations so it runs, and content-only regression candidates are de-duplicated and source-merged across feeds.
30
+
31
+ The catalog, CVE, and playbook validators report a clear error on a missing `_meta` block, a null catalog entry, or a null playbook file instead of crashing mid-scan, and the catalog freshness gate rejects a malformed `last_updated` date rather than skipping it. The skill staleness gate rejects a structurally-ISO but non-calendar date (e.g. `2026-13-99`), and the playbook air-gap source detector flags API-verb-phrased network sources (`GET /…`, Microsoft Graph, Okta, Entra ID) in both the imperative validator and the schema.
32
+
33
+ The feed tokenizer preserves a title or body field that contains an unescaped `<`, surfaces parse errors on the live RSS/Atom path instead of discarding them, and captures the correct alternate-link href on multi-link Atom entries. The framework-coverage lookup no longer matches every control when the framework id is empty or very short.
34
+
35
+ `doctor --json` and `doctor --pretty` exit 0 on a warnings-only state, matching the human output (a CI consumer no longer sees a false failure). `ci --evidence-dir` applies the same symlink / no-follow / realpath defenses the multi-run evidence reader uses, and its containment check resolves the directory's real path before comparing, so a `<playbook>.json` under a symlinked parent (a symlinked mount, home, or temp directory) is read rather than refused as resolving outside the directory. An error envelope is still emitted under `--json-stdout-only`, and it is JSON even for an error raised before flag parsing (an unknown or removed verb) so a piped `| jq` consumer never receives human-formatted text on stdout; help and welcome output flushes before exit. The dispatch plan keeps an entry for every distinct finding that routes to a skill, so two findings that route to the same skill no longer collapse into one; the currency check reports a malformed `last_threat_review` as stale rather than fully current; and a null or non-object stage output yields a clear handoff error.
36
+
37
+ The upstream catalog refresh does not stamp a catalog fresh or rewrite it when the fetch returned an error body or produced no change, writes the catalog atomically, and bounds redirect depth. Section and token-budget byte offsets are computed end-of-line-aware so they stay correct on a CRLF body, and index writes retry through a transient file lock on Windows. The RFC CLI parses a claimed title regardless of position and matches titles strictly, the citation resolver binds a cached record to the requested citation, and the upstream-check CLI surfaces an unexpected error as a structured envelope.
38
+
3
39
  ## 0.18.9 — 2026-06-20
4
40
 
5
41
  Internal: the refresh tarball pull and the vendor-source online check build their request URL entirely from constant and individually-validated bindings — a GitHub-name-shaped owner/repo, a hex commit id, a traversal-free path, and a strict-semver version — and the request is built from those validated bindings directly, so no unvalidated registry-metadata or provenance value reaches the outbound request. Behavior is unchanged; this tightens the data-flow into the fetchers.
package/bin/exceptd.js CHANGED
@@ -538,6 +538,15 @@ function main() {
538
538
  if (argv.includes("--json-stdout-only")) {
539
539
  process.env.EXCEPTD_DEPRECATION_SHOWN = "1";
540
540
  process.env.EXCEPTD_UNSIGNED_WARNED = "1";
541
+ // Route the canonical error envelope (emitError) to STDOUT under this flag.
542
+ // Without it, --json-stdout-only sends all diagnostics to stderr and then
543
+ // suppresses non-"Error"-prefixed stderr — but emitError's ok:false body is
544
+ // neither stdout-bound nor "Error"-prefixed, so an error invocation produced
545
+ // NO diagnostic on stdout OR stderr (just an exit code). A `| jq` consumer
546
+ // reading stdout would see an empty document on every failure. Set a
547
+ // dedicated global so emitError can detect the flag and write its JSON
548
+ // envelope to stdout (where the consumer is already reading) instead.
549
+ global.__exceptdJsonStdoutOnly = true;
541
550
  const origStderrWrite = process.stderr.write.bind(process.stderr);
542
551
  process.stderr.write = (chunk, encoding, cb) => {
543
552
  // Let actual error frames through (uncaught exceptions need to surface
@@ -575,7 +584,7 @@ function main() {
575
584
 
576
585
  if (argv.length === 0) {
577
586
  printWelcome();
578
- process.exit(0);
587
+ safeExit(EXIT_CODES.SUCCESS); return;
579
588
  }
580
589
  const cmd = argv[0];
581
590
  const rest = argv.slice(1);
@@ -601,14 +610,14 @@ function main() {
601
610
  return;
602
611
  }
603
612
  if (printPlaybookVerbHelp(verb)) {
604
- process.exit(0);
613
+ safeExit(EXIT_CODES.SUCCESS); return;
605
614
  }
606
615
  // Verb not found — emit a one-line note pointing at the top-level
607
616
  // help so operators don't silently see the wrong content.
608
617
  process.stderr.write(`[exceptd help] no verb-specific help for "${verb}" — falling through to top-level help. Run \`exceptd help\` for the full verb list.\n`);
609
618
  }
610
619
  printHelp();
611
- process.exit(0);
620
+ safeExit(EXIT_CODES.SUCCESS); return;
612
621
  }
613
622
  if (cmd === "version" || cmd === "--version" || cmd === "-v") {
614
623
  process.stdout.write(readPkgVersion() + "\n");
@@ -919,7 +928,12 @@ function emitError(msg, extra, pretty) {
919
928
  // Explicit --json / --pretty / --json-stdout-only also force JSON
920
929
  // regardless of TTY (e.g. an operator redirecting to a file).
921
930
  const body = Object.assign({ ok: false, error: msg }, extra || {});
922
- const wantJson = !!global.__exceptdWantJson || !!process.env.EXCEPTD_RAW_JSON;
931
+ // --json-stdout-only routes the envelope to stdout (below), so the envelope
932
+ // MUST be JSON. For a top-level error raised before flag parsing sets
933
+ // __exceptdWantJson (an unknown/removed verb), the stdout-only flag is the
934
+ // only signal present — fold it into the JSON selection so a human string is
935
+ // never written to the machine-readable stdout channel (breaking `| jq`).
936
+ const wantJson = !!global.__exceptdWantJson || !!process.env.EXCEPTD_RAW_JSON || !!global.__exceptdJsonStdoutOnly;
923
937
  const stderrIsTty = process.stderr.isTTY === true;
924
938
  let s;
925
939
  if (wantJson || !stderrIsTty) {
@@ -934,7 +948,16 @@ function emitError(msg, extra, pretty) {
934
948
  }
935
949
  s = lines.join("\n");
936
950
  }
937
- process.stderr.write(s + "\n");
951
+ // Under --json-stdout-only, route the JSON error envelope to STDOUT (where the
952
+ // consumer is already reading machine-readable output) instead of the
953
+ // suppressed stderr channel — otherwise the error would surface on neither
954
+ // stream. The flag forces JSON (global.__exceptdWantJson is set via
955
+ // args._jsonMode), so `s` here is already the JSON envelope, not human text.
956
+ if (global.__exceptdJsonStdoutOnly) {
957
+ process.stdout.write(s + "\n");
958
+ } else {
959
+ process.stderr.write(s + "\n");
960
+ }
938
961
  process.exitCode = EXIT_CODES.GENERIC_FAILURE;
939
962
  }
940
963
 
@@ -1252,7 +1275,7 @@ function dispatchPlaybook(cmd, argv) {
1252
1275
  // get usage text instead of an error about missing arguments.
1253
1276
  if (argv.includes("--help") || argv.includes("-h")) {
1254
1277
  printPlaybookVerbHelp(cmd);
1255
- process.exit(0);
1278
+ safeExit(EXIT_CODES.SUCCESS); return;
1256
1279
  }
1257
1280
 
1258
1281
  const args = parseArgs(argv, {
@@ -4191,6 +4214,151 @@ function buildJurisdictionClockRollup(results) {
4191
4214
  return [...m.values()];
4192
4215
  }
4193
4216
 
4217
+ // Shared, hardened reader for `--evidence-dir <dir>`. Both `run` (cmdRunMulti)
4218
+ // and `ci` (cmdCi) accept --evidence-dir; the symlink / junction / O_NOFOLLOW /
4219
+ // realpath-containment / playbook-id defenses must apply identically to both.
4220
+ // Previously cmdCi read entries with a bare fs.readFileSync, so a `<pb>.json`
4221
+ // symlink/junction inside the dir bypassed every containment check that `run`
4222
+ // applies. Factor the read into one helper so the class is fixed once and a
4223
+ // future third caller can't regress.
4224
+ //
4225
+ // Returns { ok: true, bundle } on success, or
4226
+ // { ok: false, error: <msg>, extra: <obj|null> } on the first refusal — the
4227
+ // caller routes the error through its own emitError() so the verb-prefixed
4228
+ // message ("run: ..." vs "ci: ...") is preserved. The directory's existence /
4229
+ // type and the empty-string guard are the caller's responsibility (the two
4230
+ // verbs surface those with verb-specific wording).
4231
+ function readEvidenceDir(dir, verb) {
4232
+ const bundle = {};
4233
+ const resolvedDir = path.resolve(dir);
4234
+ // Resolve the directory's realpath ONCE so the per-entry containment gate
4235
+ // below compares like-for-like. On macOS the tmpdir — and many operator
4236
+ // directories anywhere — live under a symlinked ancestor (e.g. /var ->
4237
+ // /private/var, or a symlinked mount/home). Without resolving the base, a
4238
+ // legitimate <pb-id>.json whose realpath is /private/var/.../f fails a
4239
+ // `startsWith(resolvedDir)` test against /var/.../ and every evidence file is
4240
+ // wrongly refused. Resolving the base keeps the junction/symlink-escape
4241
+ // defense (an entry whose target leaves the resolved dir still fails) while
4242
+ // accepting files that merely sit under a symlinked parent.
4243
+ let realResolvedDir;
4244
+ try { realResolvedDir = fs.realpathSync(resolvedDir); }
4245
+ catch { realResolvedDir = resolvedDir; }
4246
+ // Only `<playbook-id>.json` entries are honored. Reject anything where the
4247
+ // filename strip leaves traversal segments — npm refuses to write such
4248
+ // filenames so the realistic risk is an operator symlink/junction inside the
4249
+ // dir, but the filter is cheap.
4250
+ for (const f of fs.readdirSync(dir).filter(x => x.endsWith(".json"))) {
4251
+ const pbId = f.replace(/\.json$/, "");
4252
+ // Reuse the shared playbook-id validator so the --evidence-dir entry
4253
+ // filter agrees with the runtime playbook-id allowlist. Rejects
4254
+ // dots / underscores / uppercase that no real playbook id uses, which would
4255
+ // otherwise silently absorb a typo'd filename as a "valid" entry that
4256
+ // loadPlaybook then refused mid-loop.
4257
+ const pbCheck = validateIdComponent(pbId, "playbook");
4258
+ if (!pbCheck.ok) {
4259
+ return {
4260
+ ok: false,
4261
+ error: `${verb}: --evidence-dir entry ${JSON.stringify(f)} has invalid playbook-id segment (${pbCheck.reason}).`,
4262
+ extra: { entry: f, expected_shape: "<playbook-id>.json (lowercase, starts with letter, no dots)" },
4263
+ };
4264
+ }
4265
+ const entryPath = path.resolve(path.join(resolvedDir, f));
4266
+ if (!entryPath.startsWith(resolvedDir + path.sep)) {
4267
+ return { ok: false, error: `${verb}: --evidence-dir entry ${f} resolves outside the directory; refusing.`, extra: null };
4268
+ }
4269
+ // The path.resolve check above only catches `..` traversal in the joined
4270
+ // path; reading the path would still follow symlinks, so a
4271
+ // `<pb-id>.json -> /etc/shadow` symlink inside the dir would slurp the
4272
+ // target. Rather than lstat/realpath the PATH and then re-open it (a
4273
+ // check-then-use TOCTOU window), open a single O_NOFOLLOW descriptor FIRST
4274
+ // and make every subsequent decision about that exact descriptor.
4275
+ // O_NOFOLLOW refuses a symlinked leaf at open (ELOOP) on POSIX; on Windows
4276
+ // it is a no-op, so the fstat type check + lstat + realpath gate below carry
4277
+ // the junction/symlink defense. Opening before any path stat means the bytes
4278
+ // read come from the inode we validated, not a path that could be re-pointed
4279
+ // between check and read.
4280
+ let efd;
4281
+ try {
4282
+ const O_NOFOLLOW = fs.constants.O_NOFOLLOW || 0;
4283
+ efd = fs.openSync(entryPath, fs.constants.O_RDONLY | O_NOFOLLOW);
4284
+ } catch (e) {
4285
+ const why = e.code === "ELOOP"
4286
+ ? "symbolic link refused (symlinks bypass the directory-confinement check)"
4287
+ : e.message;
4288
+ return { ok: false, error: `${verb}: --evidence-dir entry ${f}: open failed: ${why}`, extra: { entry: f } };
4289
+ }
4290
+ try {
4291
+ const st = fs.fstatSync(efd);
4292
+ if (!st.isFile()) {
4293
+ return { ok: false, error: `${verb}: --evidence-dir entry ${f} is not a regular file; refusing (symlink / junction / dir / fifo bypass the directory-confinement check).`, extra: { entry: f } };
4294
+ }
4295
+ // Hardlink defense in depth: no clean cross-platform refusal exists —
4296
+ // hardlinks are indistinguishable from regular files at the inode level.
4297
+ // Surface a stderr warning when nlink > 1 so the operator is aware a
4298
+ // second name may point at the same file. Not a refusal — legitimate use
4299
+ // cases (atomic rename, package-manager dedup) produce nlink > 1 without
4300
+ // malicious intent.
4301
+ if (st.nlink > 1) {
4302
+ process.stderr.write(`[exceptd ${verb} --evidence-dir] WARNING: ${f} has nlink=${st.nlink}; a hardlink to this file exists elsewhere on the filesystem. Hardlinks cannot be refused cross-platform — confirm the file content is what you expect.\n`);
4303
+ }
4304
+ // Read the bytes from `efd` FIRST — the descriptor was opened O_NOFOLLOW
4305
+ // and fstat-confirmed a regular file, so this reads the exact inode we
4306
+ // validated. Reading before the realpath gate (rather than checking the
4307
+ // path then reading) means there is no check-then-use window at all; the
4308
+ // containment gate below decides whether to USE the bytes, and discards
4309
+ // them otherwise.
4310
+ const raw = fs.readFileSync(efd, "utf8");
4311
+ // Symlink refusal. O_NOFOLLOW already rejects a symlinked leaf at open on
4312
+ // POSIX (ELOOP), but it is a no-op on Windows, where the open follows the
4313
+ // link. Detect and refuse a symlink explicitly via lstat — regardless of
4314
+ // where it points — so a symlinked entry is never accepted. This runs
4315
+ // AFTER the descriptor read (the bytes are dropped on refusal), so there
4316
+ // is no path-check-before-read TOCTOU window.
4317
+ let lst;
4318
+ try { lst = fs.lstatSync(entryPath); }
4319
+ catch (e) {
4320
+ return { ok: false, error: `${verb}: --evidence-dir entry ${f}: lstat failed: ${e.message}`, extra: null };
4321
+ }
4322
+ if (lst.isSymbolicLink()) {
4323
+ return { ok: false, error: `${verb}: --evidence-dir entry ${f} is a symbolic link; refusing (symlinks bypass the directory-confinement check).`, extra: { entry: f } };
4324
+ }
4325
+ // Windows directory junctions are reparse-point dirs that
4326
+ // lstat().isSymbolicLink() returns FALSE for, and O_NOFOLLOW is a no-op
4327
+ // there; realpath resolves the entry and confirms it still lives under the
4328
+ // resolved evidence-dir. A target that escapes the dir is refused and the
4329
+ // already-read bytes are dropped unused.
4330
+ let realEntry;
4331
+ try { realEntry = fs.realpathSync(entryPath); }
4332
+ catch (e) {
4333
+ return { ok: false, error: `${verb}: --evidence-dir entry ${f}: realpath failed: ${e.message}`, extra: null };
4334
+ }
4335
+ if (!realEntry.startsWith(realResolvedDir + path.sep)) {
4336
+ return {
4337
+ ok: false,
4338
+ error: `${verb}: --evidence-dir entry ${f} resolves outside the directory (junction / reparse-point / symlink target). Refusing.`,
4339
+ extra: { entry: f, resolved_to: realEntry },
4340
+ };
4341
+ }
4342
+ // Apply the SAME object-shape guard the single-file / stdin path uses
4343
+ // (asEvidenceObject): a `<pb>.json` that parses to an array, scalar, or
4344
+ // null is not a valid evidence document. Without this an mis-shaped entry
4345
+ // was bound verbatim and ran downstream as empty evidence, yielding a
4346
+ // false-clean `not_detected` PASS at exit 0 — the exact hole the
4347
+ // single-file guard closes.
4348
+ bundle[pbId] = asEvidenceObject(JSON.parse(raw));
4349
+ } catch (e) {
4350
+ // A JSON parse error or the asEvidenceObject shape refusal lands here;
4351
+ // surface it with the entry name so the operator sees the real reason
4352
+ // (e.g. "evidence must be a JSON object"). The explicit symlink/junction
4353
+ // refusals above return directly and never reach this catch.
4354
+ return { ok: false, error: `${verb}: --evidence-dir entry ${f}: ${e.message}`, extra: { entry: f } };
4355
+ } finally {
4356
+ try { fs.closeSync(efd); } catch { /* already closed / invalid fd */ }
4357
+ }
4358
+ }
4359
+ return { ok: true, bundle };
4360
+ }
4361
+
4194
4362
  function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
4195
4363
  const sessionId = runOpts.session_id || require("crypto").randomBytes(8).toString("hex");
4196
4364
  runOpts.session_id = sessionId;
@@ -4228,110 +4396,12 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
4228
4396
  if (!fs.existsSync(dir)) {
4229
4397
  return emitError(`run: --evidence-dir ${dir} does not exist.`, null, pretty);
4230
4398
  }
4231
- const resolvedDir = path.resolve(dir);
4232
- // v0.12.12: only `<playbook-id>.json` entries are honored. Reject
4233
- // anything where the filename strip leaves traversal segments — npm
4234
- // refuses to write such filenames so the realistic risk is an operator
4235
- // symlink/junction inside the dir, but the filter is cheap.
4236
- for (const f of fs.readdirSync(dir).filter(x => x.endsWith(".json"))) {
4237
- const pbId = f.replace(/\.json$/, "");
4238
- // Reuse the shared playbook-id validator so the --evidence-dir entry
4239
- // filter agrees with the runtime playbook-id allowlist. Previously
4240
- // accepted dots / underscores / uppercase that no real playbook id
4241
- // uses, which would silently absorb a typo'd filename as a "valid"
4242
- // entry that loadPlaybook then refused mid-loop.
4243
- const pbCheck = validateIdComponent(pbId, "playbook");
4244
- if (!pbCheck.ok) {
4245
- return emitError(
4246
- `run: --evidence-dir entry ${JSON.stringify(f)} has invalid playbook-id segment (${pbCheck.reason}).`,
4247
- { entry: f, expected_shape: "<playbook-id>.json (lowercase, starts with letter, no dots)" },
4248
- pretty
4249
- );
4250
- }
4251
- const entryPath = path.resolve(path.join(resolvedDir, f));
4252
- if (!entryPath.startsWith(resolvedDir + path.sep)) {
4253
- return emitError(`run: --evidence-dir entry ${f} resolves outside the directory; refusing.`, null, pretty);
4254
- }
4255
- // The path.resolve check above only catches `..` traversal in the
4256
- // joined path; reading the path would still follow symlinks, so a
4257
- // `<pb-id>.json -> /etc/shadow` symlink inside the dir would slurp the
4258
- // target. Rather than lstat/realpath the PATH and then re-open it
4259
- // (a check-then-use TOCTOU window), open a single O_NOFOLLOW descriptor
4260
- // FIRST and make every subsequent decision about that exact descriptor.
4261
- // O_NOFOLLOW refuses a symlinked leaf at open (ELOOP) on POSIX; on
4262
- // Windows it is a no-op, so the fstat type check + realpath gate below
4263
- // carry the junction/symlink defense. Opening before any path stat means
4264
- // the bytes read come from the inode we validated, not a path that could
4265
- // be re-pointed between check and read.
4266
- let efd;
4267
- try {
4268
- const O_NOFOLLOW = fs.constants.O_NOFOLLOW || 0;
4269
- efd = fs.openSync(entryPath, fs.constants.O_RDONLY | O_NOFOLLOW);
4270
- } catch (e) {
4271
- const why = e.code === "ELOOP"
4272
- ? "symbolic link refused (symlinks bypass the directory-confinement check)"
4273
- : e.message;
4274
- return emitError(`run: --evidence-dir entry ${f}: open failed: ${why}`, { entry: f }, pretty);
4275
- }
4276
- try {
4277
- const st = fs.fstatSync(efd);
4278
- if (!st.isFile()) {
4279
- return emitError(`run: --evidence-dir entry ${f} is not a regular file; refusing (symlink / junction / dir / fifo bypass the directory-confinement check).`, { entry: f }, pretty);
4280
- }
4281
- // Hardlink defense in depth: no clean cross-platform refusal exists —
4282
- // hardlinks are indistinguishable from regular files at the inode
4283
- // level. Surface a stderr warning when nlink > 1 so the operator is
4284
- // aware a second name may point at the same file. Not a refusal —
4285
- // legitimate use cases (atomic rename, package-manager dedup) produce
4286
- // nlink > 1 without malicious intent.
4287
- if (st.nlink > 1) {
4288
- process.stderr.write(`[exceptd run --evidence-dir] WARNING: ${f} has nlink=${st.nlink}; a hardlink to this file exists elsewhere on the filesystem. Hardlinks cannot be refused cross-platform — confirm the file content is what you expect.\n`);
4289
- }
4290
- // Read the bytes from `efd` FIRST — the descriptor was opened
4291
- // O_NOFOLLOW and fstat-confirmed a regular file, so this reads the exact
4292
- // inode we validated. Reading before the realpath gate (rather than
4293
- // checking the path then reading) means there is no check-then-use
4294
- // window at all; the containment gate below decides whether to USE the
4295
- // bytes, and discards them otherwise.
4296
- const raw = fs.readFileSync(efd, "utf8");
4297
- // Symlink refusal. O_NOFOLLOW already rejects a symlinked leaf at open on
4298
- // POSIX (ELOOP), but it is a no-op on Windows, where the open follows the
4299
- // link. Detect and refuse a symlink explicitly via lstat — regardless of
4300
- // where it points — so a symlinked entry is never accepted. This runs
4301
- // AFTER the descriptor read (the bytes are dropped on refusal), so there
4302
- // is no path-check-before-read TOCTOU window.
4303
- let lst;
4304
- try { lst = fs.lstatSync(entryPath); }
4305
- catch (e) {
4306
- return emitError(`run: --evidence-dir entry ${f}: lstat failed: ${e.message}`, null, pretty);
4307
- }
4308
- if (lst.isSymbolicLink()) {
4309
- return emitError(`run: --evidence-dir entry ${f} is a symbolic link; refusing (symlinks bypass the directory-confinement check).`, { entry: f }, pretty);
4310
- }
4311
- // Windows directory junctions are reparse-point dirs that
4312
- // lstat().isSymbolicLink() returns FALSE for, and O_NOFOLLOW is a
4313
- // no-op there; realpath resolves the entry and confirms it still lives
4314
- // under the resolved evidence-dir. A target that escapes the dir is
4315
- // refused and the already-read bytes are dropped unused.
4316
- let realEntry;
4317
- try { realEntry = fs.realpathSync(entryPath); }
4318
- catch (e) {
4319
- return emitError(`run: --evidence-dir entry ${f}: realpath failed: ${e.message}`, null, pretty);
4320
- }
4321
- if (realEntry !== entryPath && !realEntry.startsWith(resolvedDir + path.sep)) {
4322
- return emitError(
4323
- `run: --evidence-dir entry ${f} resolves outside the directory (junction / reparse-point / symlink target). Refusing.`,
4324
- { entry: f, resolved_to: realEntry },
4325
- pretty
4326
- );
4327
- }
4328
- bundle[pbId] = JSON.parse(raw);
4329
- } catch (e) {
4330
- return emitError(`run: failed to read --evidence-dir entry ${f}: ${e.message}`, null, pretty);
4331
- } finally {
4332
- try { fs.closeSync(efd); } catch { /* already closed / invalid fd */ }
4333
- }
4334
- }
4399
+ // Hardened read (symlink / junction / O_NOFOLLOW / realpath-containment /
4400
+ // playbook-id gate) lives in the shared readEvidenceDir() helper so `run`
4401
+ // and `ci` apply identical defenses.
4402
+ const er = readEvidenceDir(dir, "run");
4403
+ if (!er.ok) return emitError(er.error, er.extra, pretty);
4404
+ Object.assign(bundle, er.bundle);
4335
4405
  }
4336
4406
 
4337
4407
  const results = [];
@@ -7815,7 +7885,15 @@ function cmdDoctor(runner, args, runOpts, pretty) {
7815
7885
 
7816
7886
  if (wantJson) {
7817
7887
  emit(out, indent);
7818
- if (!allGreen) process.exitCode = EXIT_CODES.GENERIC_FAILURE;
7888
+ // Exit-code predicate must match the human path (gates on errorList only):
7889
+ // warnings alone do NOT force exit 1. The body still carries all_green,
7890
+ // warnings_count, and warning_checks for consumers that want the full
7891
+ // picture; only the exit code stops conflating a warn-only nudge (missing
7892
+ // private key on a consumer install, registry-behind) with a hard error.
7893
+ // A genuine signature-verification failure sets ok:false WITHOUT
7894
+ // severity:"warn", so bucketChecks routes it to errorList and it still
7895
+ // forces exit 1 here.
7896
+ if (errorList.length > 0) process.exitCode = EXIT_CODES.GENERIC_FAILURE;
7819
7897
  return;
7820
7898
  }
7821
7899
 
@@ -9044,18 +9122,25 @@ function cmdCi(runner, args, runOpts, pretty) {
9044
9122
  try { bundle = readEvidence(args.evidence); }
9045
9123
  catch (e) { return emitError(`ci: failed to read --evidence: ${e.message}`, null, pretty); }
9046
9124
  }
9125
+ if (args["evidence-dir"] === "") {
9126
+ return emitError("ci: --evidence-dir was given an empty value; pass an existing directory, or omit --evidence-dir", { verb: "ci", flag: "evidence-dir" }, pretty);
9127
+ }
9047
9128
  if (args["evidence-dir"]) {
9048
9129
  const dir = args["evidence-dir"];
9130
+ if (typeof dir !== "string") {
9131
+ return emitError("ci: --evidence-dir must be a string.", null, pretty);
9132
+ }
9049
9133
  if (!fs.existsSync(dir)) {
9050
9134
  return emitError(`ci: --evidence-dir ${dir} does not exist.`, null, pretty);
9051
9135
  }
9052
- for (const f of fs.readdirSync(dir).filter(x => x.endsWith(".json"))) {
9053
- try {
9054
- bundle[f.replace(/\.json$/, "")] = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
9055
- } catch (e) {
9056
- return emitError(`ci: failed to parse evidence-dir entry ${f}: ${e.message}`, null, pretty);
9057
- }
9058
- }
9136
+ // Hardened read (symlink / junction / O_NOFOLLOW / realpath-containment /
9137
+ // playbook-id gate) lives in the shared readEvidenceDir() helper so `ci`
9138
+ // and `run` apply identical defenses. Previously `ci` read entries with a
9139
+ // bare fs.readFileSync, so a `<pb>.json` symlink/junction inside the dir
9140
+ // bypassed every containment check `run` applies.
9141
+ const er = readEvidenceDir(dir, "ci");
9142
+ if (!er.ok) return emitError(er.error, er.extra, pretty);
9143
+ Object.assign(bundle, er.bundle);
9059
9144
  }
9060
9145
 
9061
9146
  // Flat-submission tolerance for a single positional playbook. `ci` keys its
@@ -9579,4 +9664,5 @@ module.exports = {
9579
9664
  _diffArtifacts: diffArtifacts,
9580
9665
  _diffSignalOverrides: diffSignalOverrides,
9581
9666
  _resolveSelfAttestation: resolveSelfAttestation,
9667
+ _readEvidenceDir: readEvidenceDir,
9582
9668
  };
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "schema_version": "1.1.0",
3
- "generated_at": "2026-06-21T01:54:07.230Z",
3
+ "generated_at": "2026-06-22T06:27:15.592Z",
4
4
  "generator": "scripts/build-indexes.js",
5
5
  "source_count": 64,
6
6
  "source_hashes": {
7
- "manifest.json": "f077895655b0127a2e31c8fd34db26fda9f20e663b07d01f2f1cc6132068bc89",
7
+ "manifest.json": "d897efd048acdde209213af36857c5385c036a650096531408ca978590e38226",
8
8
  "README.md": "e7b854e7db9a364a1b368b5084b4f0c2a8282f0459ce39800ac1d1dabdc06074",
9
9
  "data/atlas-ttps.json": "5bc59e23d6c2defa54168de161a0825299b9cc4a49c6b26df2dae70b4f42eedf",
10
10
  "data/attack-techniques.json": "53c6f248760eecb11a0354f74ab467a5814e95075a686b9b3bf18c34e2f7435e",
11
11
  "data/cve-catalog.json": "06ca53e69071dfe94867c10717f3ff2962c50341defcaf050f1a96236b5be51c",
12
12
  "data/cwe-catalog.json": "359263361fa52069e2856cc352d8f1c757d614ea840db6ef3ff5e696185ca220",
13
- "data/d3fend-catalog.json": "349d14b2777342d38e5f8b0149a9ebd7703ee59f67a9efee200d1e19313088ac",
13
+ "data/d3fend-catalog.json": "b5c1e83d216ba8ece6be1368d807d1636f3d77622fd4e9148e9ba2f5fd44ee0d",
14
14
  "data/dlp-controls.json": "d2406c482dddd30e49203879999dc4b3a7fd4d0494d6a61d86b91ee76415df19",
15
15
  "data/exploit-availability.json": "ec2656f0d9a893610e27b43eb6035fe9b18e057c9f6dfaac7e7d4959bbcbb795",
16
16
  "data/framework-control-gaps.json": "760c2275803c6da3665ce538c5176bde6f041b68cc3d4808b8de961dfcdee6b8",
@@ -3707,8 +3707,8 @@
3707
3707
  "D3A-BOC",
3708
3708
  "D3A-BOM",
3709
3709
  "D3A-BOO",
3710
- "D3A-C4.",
3711
- "D3A-C5.",
3710
+ "D3A-C4",
3711
+ "D3A-C5",
3712
3712
  "D3A-CA",
3713
3713
  "D3A-CAR",
3714
3714
  "D3A-CBC",
@@ -6062,8 +6062,8 @@
6062
6062
  "_auto_imported": true,
6063
6063
  "_intake_method": "mitre-d3fend-owl"
6064
6064
  },
6065
- "D3A-C4.": {
6066
- "id": "D3A-C4.",
6065
+ "D3A-C4": {
6066
+ "id": "D3A-C4",
6067
6067
  "name": "C4.5",
6068
6068
  "tactic": "Defensive",
6069
6069
  "description": "C4.5 is an algorithm that is strongly based off ID3.",
@@ -6077,13 +6077,13 @@
6077
6077
  "requires": [],
6078
6078
  "inventories": [],
6079
6079
  "kb_reference": null,
6080
- "reference_url": "https://d3fend.mitre.org/technique/D3A-C4./",
6080
+ "reference_url": "https://d3fend.mitre.org/technique/D3A-C4/",
6081
6081
  "last_verified": "2026-05-19",
6082
6082
  "_auto_imported": true,
6083
6083
  "_intake_method": "mitre-d3fend-owl"
6084
6084
  },
6085
- "D3A-C5.": {
6086
- "id": "D3A-C5.",
6085
+ "D3A-C5": {
6086
+ "id": "D3A-C5",
6087
6087
  "name": "C5.0",
6088
6088
  "tactic": "Defensive",
6089
6089
  "description": "C5.0 is the next version of C4.5, which in turn is the upgrade from ID3.",
@@ -6097,7 +6097,7 @@
6097
6097
  "requires": [],
6098
6098
  "inventories": [],
6099
6099
  "kb_reference": null,
6100
- "reference_url": "https://d3fend.mitre.org/technique/D3A-C5./",
6100
+ "reference_url": "https://d3fend.mitre.org/technique/D3A-C5/",
6101
6101
  "last_verified": "2026-05-19",
6102
6102
  "_auto_imported": true,
6103
6103
  "_intake_method": "mitre-d3fend-owl"
@@ -46,7 +46,7 @@
46
46
  "feeds_into": [
47
47
  {
48
48
  "playbook_id": "idp-incident",
49
- "condition": "phases.detect.classification == 'detected'"
49
+ "condition": "analyze.classification == 'detected'"
50
50
  },
51
51
  {
52
52
  "playbook_id": "cred-stores",
@@ -642,7 +642,7 @@
642
642
  ],
643
643
  "escalation_criteria": [
644
644
  {
645
- "condition": "phases.detect.classification == 'detected'",
645
+ "condition": "analyze.classification == 'detected'",
646
646
  "action": "trigger_playbook",
647
647
  "target_playbook": "idp-incident"
648
648
  },
@@ -1247,7 +1247,7 @@
1247
1247
  "action": "notify_legal"
1248
1248
  },
1249
1249
  {
1250
- "condition": "any actively_exploited_match AND jurisdiction_obligations contains 'EU/EU CRA Art.14 24h'",
1250
+ "condition": "any matched_cve.active_exploitation == 'confirmed' AND jurisdiction_obligations contains 'EU'",
1251
1251
  "action": "notify_legal"
1252
1252
  }
1253
1253
  ]
@@ -87,6 +87,17 @@ function cacheGet(kind, id) {
87
87
  if (!Number.isFinite(ts)) return null;
88
88
  const age = Date.now() - ts;
89
89
  if (age < -60_000 || age > CACHE_TTL_MS) return null;
90
+ // Bind the record to the requested key — a digest proves self-consistency,
91
+ // not that this is the record FOR the looked-up id/kind. A digest-valid
92
+ // record written under one filename but carrying a different internal
93
+ // id/kind would otherwise be served for the wrong lookup (a swapped-file
94
+ // poisoning that the self-digest cannot catch). Mismatch → cache miss.
95
+ if (record.kind !== kind) return null;
96
+ if (kind === "cve") {
97
+ if (typeof record.id !== "string" || record.id.toUpperCase() !== String(id).toUpperCase()) return null;
98
+ } else if (kind === "rfc") {
99
+ if (Number(record.number) !== Number(id)) return null;
100
+ }
90
101
  delete record._digest; // internal integrity field — never surface it
91
102
  return record;
92
103
  } catch { return null; }
@@ -191,6 +191,19 @@ function scanDockerfile(content, rel) {
191
191
  hits["dockerfile-from-latest"].push({ file: rel, line: i + 1, snippet: trimmed.slice(0, 120) });
192
192
  }
193
193
  }
194
+ // USER does NOT carry across Docker build stages: each stage starts
195
+ // from its base image's default user (root) until its own USER
196
+ // directive. A `FROM <prior-stage-alias>` is the one exception — it
197
+ // inherits the parent stage's USER, so it must NOT reset. Any other
198
+ // FROM (registry image, or `scratch`) begins a fresh stage and resets
199
+ // the per-stage USER tracking, so the runs-as-root predicate reflects
200
+ // only the FINAL stage. Evaluate internal-vs-external against the
201
+ // ARG-resolved `ref` and BEFORE the current alias is recorded.
202
+ const isInternalStageRef = stageAliases.has(ref.toLowerCase());
203
+ if (!isInternalStageRef) {
204
+ sawNonRootUser = false;
205
+ sawAnyUser = false;
206
+ }
194
207
  // Record this stage's alias so later `FROM <alias>` lines are recognized
195
208
  // as internal references.
196
209
  if (alias) stageAliases.add(alias.toLowerCase());