@blamejs/exceptd-skills 0.19.33 → 0.19.35

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 (119) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/bin/exceptd.js +895 -2828
  3. package/data/_indexes/_meta.json +2 -2
  4. package/lib/auto-discovery.js +56 -286
  5. package/lib/canonical-eq.js +7 -40
  6. package/lib/citation-resolve.js +22 -70
  7. package/lib/collectors/ai-api.js +170 -76
  8. package/lib/collectors/cicd-pipeline-compromise.js +113 -136
  9. package/lib/collectors/citation-hygiene.js +72 -210
  10. package/lib/collectors/containers.js +41 -130
  11. package/lib/collectors/cred-stores.js +31 -115
  12. package/lib/collectors/crypto-codebase.js +55 -138
  13. package/lib/collectors/crypto.js +24 -54
  14. package/lib/collectors/hardening.js +20 -78
  15. package/lib/collectors/kernel.js +16 -46
  16. package/lib/collectors/library-author.js +198 -211
  17. package/lib/collectors/mcp.js +24 -70
  18. package/lib/collectors/runtime.js +24 -86
  19. package/lib/collectors/sbom.js +130 -118
  20. package/lib/collectors/scan-excludes.js +33 -139
  21. package/lib/collectors/secrets.js +62 -178
  22. package/lib/cross-ref-api.js +39 -123
  23. package/lib/currency-severity.js +8 -27
  24. package/lib/cve-batch.js +13 -21
  25. package/lib/cve-cli.js +13 -20
  26. package/lib/cve-curation.js +72 -239
  27. package/lib/cve-regression-watcher.js +29 -155
  28. package/lib/cvss.js +13 -54
  29. package/lib/doctor-bucketing.js +3 -19
  30. package/lib/exit-codes.js +10 -42
  31. package/lib/flag-suggest.js +7 -25
  32. package/lib/framework-gap.js +39 -113
  33. package/lib/gap-detectors.js +37 -159
  34. package/lib/id-validation.js +9 -30
  35. package/lib/job-queue.js +13 -36
  36. package/lib/lint-skills.js +88 -236
  37. package/lib/playbook-runner.js +759 -2107
  38. package/lib/prefetch.js +101 -376
  39. package/lib/refresh-external.js +199 -633
  40. package/lib/refresh-network.js +78 -311
  41. package/lib/rfc-cli.js +23 -68
  42. package/lib/scoring.js +85 -146
  43. package/lib/sign.js +43 -229
  44. package/lib/source-advisories.js +43 -194
  45. package/lib/source-ghsa.js +37 -120
  46. package/lib/source-osv.js +94 -266
  47. package/lib/ttp-mapper.js +28 -27
  48. package/lib/upstream-check-cli.js +36 -29
  49. package/lib/upstream-check.js +19 -44
  50. package/lib/validate-catalog-meta.js +17 -61
  51. package/lib/validate-cve-catalog.js +52 -121
  52. package/lib/validate-indexes.js +25 -76
  53. package/lib/validate-package.js +16 -62
  54. package/lib/validate-playbooks.js +78 -286
  55. package/lib/validate-vendor.js +16 -49
  56. package/lib/verify.js +56 -286
  57. package/lib/version-pins.js +5 -34
  58. package/lib/worker-pool.js +11 -30
  59. package/lib/xml-tokenizer.js +47 -152
  60. package/manifest.json +53 -53
  61. package/orchestrator/dispatcher.js +17 -68
  62. package/orchestrator/event-bus.js +11 -74
  63. package/orchestrator/index.js +138 -413
  64. package/orchestrator/pipeline.js +28 -85
  65. package/orchestrator/scanner.js +34 -138
  66. package/orchestrator/scheduler.js +20 -84
  67. package/package.json +1 -1
  68. package/sbom.cdx.json +242 -242
  69. package/scripts/audit-catalog-gaps.js +9 -62
  70. package/scripts/audit-cross-skill.js +5 -31
  71. package/scripts/audit-perf.js +29 -28
  72. package/scripts/backfill-theater-test.js +7 -64
  73. package/scripts/bootstrap.js +12 -44
  74. package/scripts/build-indexes.js +40 -154
  75. package/scripts/builders/activity-feed.js +4 -14
  76. package/scripts/builders/catalog-summaries.js +3 -10
  77. package/scripts/builders/currency.js +7 -20
  78. package/scripts/builders/cwe-chains.js +7 -30
  79. package/scripts/builders/did-ladders.js +6 -13
  80. package/scripts/builders/frequency.js +5 -19
  81. package/scripts/builders/jurisdiction-clocks.js +6 -25
  82. package/scripts/builders/recipes.js +6 -14
  83. package/scripts/builders/section-offsets.js +13 -51
  84. package/scripts/builders/stale-content.js +7 -28
  85. package/scripts/builders/summary-cards.js +8 -29
  86. package/scripts/builders/theater-fingerprints.js +21 -31
  87. package/scripts/builders/token-budget.js +4 -31
  88. package/scripts/check-agents-md-collectors.js +26 -57
  89. package/scripts/check-catalog-gap-budget.js +15 -32
  90. package/scripts/check-changelog-extract.js +18 -48
  91. package/scripts/check-codebase-patterns-currency.js +6 -22
  92. package/scripts/check-codebase-patterns.js +63 -143
  93. package/scripts/check-epss-consistency.js +9 -64
  94. package/scripts/check-framework-gap-coverage.js +13 -31
  95. package/scripts/check-manifest-snapshot.js +62 -81
  96. package/scripts/check-sbom-currency.js +44 -142
  97. package/scripts/check-test-count.js +15 -52
  98. package/scripts/check-test-coverage.js +83 -198
  99. package/scripts/check-test-subjects.js +21 -62
  100. package/scripts/check-ttp-references.js +14 -38
  101. package/scripts/check-ttp-upstream.js +8 -40
  102. package/scripts/check-version-bump.js +9 -61
  103. package/scripts/check-version-tags.js +20 -121
  104. package/scripts/predeploy.js +38 -184
  105. package/scripts/refresh-manifest-snapshot.js +16 -38
  106. package/scripts/refresh-mitre-atlas.js +7 -8
  107. package/scripts/refresh-mitre-attack.js +1 -8
  108. package/scripts/refresh-mitre-d3fend.js +3 -9
  109. package/scripts/refresh-mitre-ics-attack.js +7 -8
  110. package/scripts/refresh-reverse-refs.js +27 -94
  111. package/scripts/refresh-rfc-index.js +7 -10
  112. package/scripts/refresh-sbom.js +31 -161
  113. package/scripts/refresh-upstream-catalogs.js +63 -148
  114. package/scripts/release.js +69 -234
  115. package/scripts/run-e2e-scenarios.js +26 -73
  116. package/scripts/sync-manifest-metadata.js +10 -34
  117. package/scripts/sync-package-description.js +8 -17
  118. package/scripts/validate-vendor-online.js +13 -44
  119. package/scripts/verify-shipped-tarball.js +35 -141
@@ -1,24 +1,11 @@
1
1
  "use strict";
2
2
 
3
3
  /**
4
- * lib/citation-resolve.js
5
- *
6
- * Answers "is this CVE/RFC citation valid?" so an agent gets the answer FROM
7
- * exceptd instead of researching each citation against NVD / the IETF
8
- * datatracker by hand. Offline-first:
9
- *
10
- * CVE: local catalog -> resolved cache -> (opt-in) one NVD lookup, cached.
11
- * RFC: local index -> resolved cache -> (opt-in) one datatracker lookup.
12
- *
13
- * The resolved cache lives at .cache/upstream/resolved/<kind>/<id>.json with a
14
- * 7-day TTL. The FIRST agent to resolve an uncatalogued id pays one network
15
- * call and writes the cache; sibling agents (and later offline runs) read it —
16
- * turning N agents x M citations of redundant lookups into one lookup per id.
17
- *
18
- * Network is opt-out: --air-gap / EXCEPTD_AIR_GAP=1 / { noNetwork:true } make
19
- * resolution offline-only (catalog + cache), returning status "unknown" with a
20
- * reason rather than reaching out. Network-resolved records are transient
21
- * (cache only) and are never written into the signed catalog.
4
+ * Answers "is this CVE/RFC citation valid?" offline-first: local catalog or
5
+ * index, then the resolved cache, then one opt-in network lookup. --air-gap /
6
+ * EXCEPTD_AIR_GAP=1 / { noNetwork:true } make it offline-only, returning status
7
+ * "unknown". Network-resolved records live in the cache only and never enter
8
+ * the signed catalog.
22
9
  */
23
10
 
24
11
  const fs = require("node:fs");
@@ -45,26 +32,16 @@ function rfcIndex() {
45
32
  return _rfc;
46
33
  }
47
34
 
48
- // --- resolved-id cache (atomic JSON files, TTL-bounded, integrity-checked) ---
49
- //
50
- // The cache feeds security verdicts (and, via citation-hygiene --resolve,
51
- // attestations), so a record is only trusted if it carries a matching content
52
- // digest AND its own `resolved_at` is within the freshness window. A file an
53
- // attacker (or a corrupt/half-written process) edits in place without
54
- // recomputing `_digest` is rejected as a cache-miss — it can never launder a
55
- // rejected/fabricated citation into "published". This is the resolved-cache
56
- // analogue of the prefetch cache's sha256+signature model; full maintainer
57
- // signing isn't possible operator-side (no private key), so the digest binds
58
- // the record to itself and makes tampering detectable.
35
+ // The resolved-id cache feeds security verdicts, so a record is trusted only when
36
+ // its `_digest` matches and its own `resolved_at` is inside the freshness window —
37
+ // an in-place edit cannot launder a rejected citation into "published".
59
38
  function cachePath(kind, id) {
60
39
  // Read the env at call time so tests can isolate the cache per-case.
61
40
  const dir = process.env.EXCEPTD_RESOLVE_CACHE_DIR || RESOLVE_CACHE_DIR;
62
41
  const safe = id.replace(/[^A-Za-z0-9._-]/g, "_");
63
42
  return path.join(dir, kind, `${safe}.json`);
64
43
  }
65
- // sha256 over the record's canonical bytes (sorted keys, `_digest` excluded).
66
- // `resolved_at` IS covered, so the staleness clock can't be rewritten apart
67
- // from the verdict.
44
+ // sha256 over canonical bytes (sorted keys, `_digest` excluded); covers `resolved_at`.
68
45
  function recordDigest(record) {
69
46
  const canon = {};
70
47
  for (const k of Object.keys(record).sort()) {
@@ -78,20 +55,14 @@ function cacheGet(kind, id) {
78
55
  const p = cachePath(kind, id);
79
56
  const record = JSON.parse(fs.readFileSync(p, "utf8"));
80
57
  if (!record || typeof record !== "object") return null;
81
- // Integrity: a record without a matching digest is tampered/corrupt → miss.
82
58
  if (typeof record._digest !== "string" || record._digest !== recordDigest(record)) return null;
83
- // Freshness keyed on the record's own resolved_at (not file mtime, which a
84
- // `touch` can reset). Reject future-dated records as a poisoning signal,
85
- // mirroring the prefetch cache's future-date guard.
59
+ // resolved_at, not file mtime which `touch` resets; a future-dated record is poisoning.
86
60
  const ts = Date.parse(record.resolved_at || "");
87
61
  if (!Number.isFinite(ts)) return null;
88
62
  const age = Date.now() - ts;
89
63
  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.
64
+ // The digest proves self-consistency, not that this is the record FOR this
65
+ // id/kind — a valid record filed under another name must not be served.
95
66
  if (record.kind !== kind) return null;
96
67
  if (kind === "cve") {
97
68
  if (typeof record.id !== "string" || record.id.toUpperCase() !== String(id).toUpperCase()) return null;
@@ -108,9 +79,7 @@ function cachePut(kind, id, record) {
108
79
  fs.mkdirSync(path.dirname(p), { recursive: true });
109
80
  const signed = { ...record };
110
81
  signed._digest = recordDigest(signed);
111
- // Random suffix (not just pid) so two cachePut calls for the same id in one
112
- // process — a Promise.all fan-out or worker threads sharing a pid — don't
113
- // race the same tmp path. Matches lib/prefetch.js writeFileAtomic.
82
+ // Random suffix, not just pid: an in-process fan-out would race the same tmp path.
114
83
  const tmp = `${p}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`;
115
84
  fs.writeFileSync(tmp, JSON.stringify(signed));
116
85
  fs.renameSync(tmp, p); // atomic — concurrent readers never see a half-written file
@@ -127,9 +96,7 @@ function isAirGap(opts) {
127
96
  * from: format | catalog | cache | network | offline | error
128
97
  */
129
98
  async function resolveCve(id, opts = {}) {
130
- // Trim before the format test — matches resolveRfc — so a whitespace-only
131
- // identifier is "fabricated/malformed" (empty form) rather than a literal
132
- // whitespace string fed straight into CVE_RE.
99
+ // Trim before the format test so a whitespace-only id is malformed, not fed to CVE_RE.
133
100
  const cveId = String(id || "").trim().toUpperCase();
134
101
  const base = { id: cveId, kind: "cve" };
135
102
 
@@ -153,10 +120,7 @@ async function resolveCve(id, opts = {}) {
153
120
  };
154
121
  }
155
122
 
156
- // 1b. alias lookup — an id may be carried as an alias of a curated entry
157
- // (e.g. a CVE for a sub-incident folded into a campaign-level MAL-* key).
158
- // Catalogued-by-alias must resolve offline too, or `exceptd cve <alias>`
159
- // would report unknown for an incident the catalog actually covers.
123
+ // 1b. alias lookup — a catalogued-by-alias id must resolve offline too.
160
124
  for (const k of Object.keys(catalog)) {
161
125
  if (k === "_meta") continue;
162
126
  const e = catalog[k];
@@ -178,7 +142,6 @@ async function resolveCve(id, opts = {}) {
178
142
  const cached = cacheGet("cve", cveId);
179
143
  if (cached) return { ...cached, from: "cache" };
180
144
 
181
- // 3. offline / air-gap: cannot resolve uncatalogued ids without network
182
145
  if (isAirGap(opts)) {
183
146
  return { ...base, status: "unknown", from: "offline",
184
147
  reason: "air-gap: not in local catalog and no cached resolution — verify against NVD when online" };
@@ -188,9 +151,7 @@ async function resolveCve(id, opts = {}) {
188
151
  reason: "not in local catalog and no cached resolution (network disabled)" };
189
152
  }
190
153
 
191
- // 4. resolve once via NVD, then cache for sibling agents.
192
- // opts._validateCve is a test seam (inject a fake validator); production uses
193
- // the real NVD-backed validator.
154
+ // Resolve once via NVD, then cache. opts._validateCve is a test seam.
194
155
  let validateCve = opts._validateCve;
195
156
  if (!validateCve) {
196
157
  try { ({ validateCve } = require("../sources/validators/cve-validator.js")); }
@@ -203,11 +164,8 @@ async function resolveCve(id, opts = {}) {
203
164
  if (v.status === "unreachable") {
204
165
  return { ...base, status: "unknown", from: "offline", reason: "NVD unreachable — retry online" };
205
166
  }
206
- // NVD is the authority for a CVE's existence and lifecycle. validateCve only
207
- // returns "unreachable" when EVERY source fails — if NVD is down but KEV/EPSS
208
- // answer, it returns match/drift with sources.nvd.reachable === false. Do NOT
209
- // declare "published" on KEV/EPSS alone during an NVD outage; that would
210
- // falsely validate an unconfirmed (or nonexistent) identifier.
167
+ // "unreachable" means EVERY source failed, so an NVD outage with KEV/EPSS still
168
+ // answering slips through. NVD is the existence authority: never publish on KEV alone.
211
169
  const nvd = v.fetched && v.fetched.sources && v.fetched.sources.nvd;
212
170
  if (!nvd || nvd.reachable !== true) {
213
171
  return { ...base, status: "unknown", from: "offline",
@@ -223,9 +181,7 @@ async function resolveCve(id, opts = {}) {
223
181
  id: cveId, kind: "cve", status,
224
182
  cvss: v.fetched?.cvss_score ?? null,
225
183
  kev: v.fetched?.in_kev ?? null,
226
- // NVD English description — carries the product/scope a citation must match,
227
- // so an agent can confirm status=published applies to the right product
228
- // without a second manual NVD lookup.
184
+ // The NVD description carries the product/scope a citation must match.
229
185
  product: v.fetched?.description ?? null,
230
186
  nvd_vuln_status: v.fetched?.nvd_vuln_status ?? null,
231
187
  cve_tags: v.fetched?.cve_tags || [],
@@ -238,11 +194,8 @@ async function resolveCve(id, opts = {}) {
238
194
 
239
195
  /**
240
196
  * Resolve an RFC citation. Returns { id, kind:"rfc", number, title, rfc_status,
241
- * found, from, ... }. The local index covers the whole RFC series — current
242
- * AND obsoleted/historic (the latter carry `_obsoleted` + `obsoleted_by`) — so
243
- * number->title resolution, including "is this RFC superseded?", is fully
244
- * offline. A number absent from the index is almost certainly nonexistent (or
245
- * an UNKNOWN-status placeholder); the optional network step confirms.
197
+ * found, from, ... }. A number absent from the local index is almost certainly
198
+ * nonexistent; the optional network step confirms.
246
199
  */
247
200
  async function resolveRfc(id, opts = {}) {
248
201
  const raw = String(id || "").trim();
@@ -255,7 +208,7 @@ async function resolveRfc(id, opts = {}) {
255
208
  const num = Number(m[1]);
256
209
  const key = `RFC-${num}`;
257
210
 
258
- // 1. local index (offline, whole current series)
211
+ // 1. local index (offline)
259
212
  const entry = rfcIndex()[key];
260
213
  if (entry && typeof entry === "object") {
261
214
  return {
@@ -268,7 +221,6 @@ async function resolveRfc(id, opts = {}) {
268
221
  };
269
222
  }
270
223
 
271
- // 2. resolved cache
272
224
  const cached = cacheGet("rfc", String(num));
273
225
  if (cached) return { ...cached, from: "cache" };
274
226
 
@@ -1,21 +1,9 @@
1
1
  "use strict";
2
2
 
3
3
  /**
4
- * lib/collectors/ai-api.js
5
- *
6
- * Companion collector for the `ai-api` playbook. Scans shell rc
7
- * files for cleartext AI API key exports, plus the standard
8
- * credential carriers (~/.aws, ~/.kube, ~/.config/gcloud) for
4
+ * Companion collector for the `ai-api` playbook: scans shell rc files for
5
+ * cleartext AI API key exports, plus ~/.aws, ~/.kube and ~/.config/gcloud for
9
6
  * long-lived credentials likely to authenticate against AI APIs.
10
- *
11
- * Non-deterministic indicators (ai-api-egress-from-unexpected-
12
- * process, ai-api-anomalous-volume, ai-api-beaconing-cadence,
13
- * base64-or-encoded-payload-in-prompts) require ss/netstat/auditd
14
- * traces and process-list correlation that fall outside the
15
- * stdlib-only collector contract. They stay unflipped — the runner
16
- * returns inconclusive and operator-supplied evidence completes
17
- * the verdict.
18
- *
19
7
  * Interface: see lib/collectors/README.md
20
8
  */
21
9
 
@@ -25,18 +13,40 @@ const os = require("node:os");
25
13
 
26
14
  const COLLECTOR_ID = "ai-api";
27
15
 
28
- function readSafe(full, max = 256 * 1024) {
16
+ // One definition, named at every call site. Written as a literal in both the
17
+ // default and the callers, the two drift apart the moment the cap is raised,
18
+ // and the only symptom is a credential store that quietly stops being scanned
19
+ // at the old size.
20
+ const SCAN_CAP_BYTES = 256 * 1024;
21
+
22
+ // `skip` routes a non-read onto the collector_errors channel:
23
+ // { errors, artifact_id, label }. A null return is indistinguishable between
24
+ // "over the cap" and "unreadable", and both mean the file went unscanned — a
25
+ // clean verdict over an unscanned credential store is the failure mode, so the
26
+ // reason travels with the submission. `label` is home-relative: the absolute
27
+ // path is operator-identifying and collector_meta already carries `home`.
28
+ function readSafe(full, max = SCAN_CAP_BYTES, skip = null) {
29
+ const note = (kind, reason) => {
30
+ if (!skip || !Array.isArray(skip.errors)) return;
31
+ const entry = { kind, reason: `${skip.label || path.basename(full)}: ${reason}` };
32
+ if (skip.artifact_id) entry.artifact_id = skip.artifact_id;
33
+ skip.errors.push(entry);
34
+ };
29
35
  let fd;
30
36
  try {
31
37
  fd = fs.openSync(full, "r");
32
38
  const s = fs.fstatSync(fd);
33
- if (s.size > max) return null;
34
- // readFileSync(fd) loops read() to EOF — a single readSync may return
35
- // fewer than s.size bytes on network/FUSE/sync-backed fds, which would
36
- // leave the buffer tail NUL-filled and silently drop trailing content.
37
- // Reading via the already-open fd keeps the fstat-then-read TOCTOU-free.
39
+ if (s.size > max) {
40
+ note("file_too_large_skipped", `${s.size} bytes exceeds ${max}-byte scan limit; not scanned`);
41
+ return null;
42
+ }
43
+ // readFileSync(fd) loops to EOF; a single readSync can return short on a
44
+ // network or FUSE fd. Reading the open fd keeps fstat-then-read TOCTOU-free.
38
45
  return fs.readFileSync(fd, "utf8");
39
- } catch { return null; }
46
+ } catch (e) {
47
+ note("read_failed", e.message);
48
+ return null;
49
+ }
40
50
  finally { if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* non-fatal */ } } }
41
51
  }
42
52
 
@@ -44,9 +54,28 @@ function fileExists(full) {
44
54
  try { return fs.statSync(full).isFile(); } catch { return false; }
45
55
  }
46
56
 
47
- // Cleartext AI-API-key export patterns. Matches the standard
48
- // `export VAR=value` and `VAR=value` shell shapes plus fish-style
49
- // `set -gx VAR value`.
57
+ // A credential store is one of three things, and only "absent" is a negative
58
+ // finding. Truthiness collapses the other two: an empty file reads as "" and is
59
+ // a completed scan, not a skipped one, while readSafe signals a skip with null.
60
+ // The distinction is null-vs-string, and it drives the artifact's captured flag
61
+ // and the indicator's verdict together so the two cannot disagree.
62
+ const ABSENT = "absent";
63
+ const UNREAD = "unread";
64
+ const READ = "read";
65
+ function storeState(exists, content) {
66
+ if (!exists) return ABSENT;
67
+ return content === null ? UNREAD : READ;
68
+ }
69
+
70
+ // A store that was never read cannot answer the question its indicator asks.
71
+ // `miss` there is a clean bill of health over an unscanned file; a hit stands
72
+ // on its own evidence and is unaffected by a sibling going unread.
73
+ function verdict(found, undetermined) {
74
+ if (found) return "hit";
75
+ return undetermined ? "inconclusive" : "miss";
76
+ }
77
+
78
+ // Cleartext key exports: `export VAR=value`, `VAR=value`, fish `set -gx VAR value`.
50
79
  const AI_KEY_PATTERNS = [
51
80
  { id: "openai", re: /(?:^|\n)\s*(?:export\s+|set\s+-gx\s+)?OPENAI_API_KEY\s*[= ]\s*['"]?sk-[A-Za-z0-9_-]{20,}/m },
52
81
  { id: "anthropic", re: /(?:^|\n)\s*(?:export\s+|set\s+-gx\s+)?ANTHROPIC_API_KEY\s*[= ]\s*['"]?sk-ant-[A-Za-z0-9_-]{20,}/m },
@@ -56,20 +85,15 @@ const AI_KEY_PATTERNS = [
56
85
  { id: "cohere", re: /(?:^|\n)\s*(?:export\s+|set\s+-gx\s+)?COHERE_API_KEY\s*[= ]\s*['"]?[A-Za-z0-9-]{30,}/m },
57
86
  ];
58
87
 
59
- // Capture the exported value so the false_positive_checks_required entries
60
- // (placeholder demotion, entropy floor) can be evaluated. The export
61
- // patterns above end at the prefix; widen to grab the trailing token.
88
+ // The patterns above end at the prefix; these capture the exported value so
89
+ // the placeholder and entropy-floor FP checks have something to evaluate.
62
90
  const AI_KEY_VALUE_RE = {
63
91
  openai: /OPENAI_API_KEY\s*[= ]\s*['"]?(sk-[A-Za-z0-9_-]+)/,
64
92
  anthropic: /ANTHROPIC_API_KEY\s*[= ]\s*['"]?(sk-ant-[A-Za-z0-9_-]+)/,
65
93
  huggingface: /(?:HUGGINGFACE_TOKEN|HF_TOKEN)\s*[= ]\s*['"]?(hf_[A-Za-z0-9]+)/,
66
- // Azure/Google/Cohere keys carry no vendor prefix, so the captured value IS
67
- // the entropy body. Without these, cleartextFpIndices found no value for an
68
- // azure/google/cohere-only dotfile, returned an empty attestation, and the
69
- // runner downgraded a real cleartext-key hit to inconclusive — the indicator
70
- // fired then vanished for half the supported vendors. The 30-char entropy
71
- // floor (the `: 30` else-branch below) and PLACEHOLDER_RE both apply, since
72
- // the prefix-strip leaves these values unchanged.
94
+ // Azure, Google and Cohere keys carry no vendor prefix, so the captured value
95
+ // IS the entropy body. Drop one of these and cleartextFpIndices attests nothing
96
+ // for such a dotfile, downgrading a real hit to inconclusive.
73
97
  azure: /AZURE_OPENAI(?:_API)?_KEY\s*[= ]\s*['"]?([A-Za-z0-9]{20,})/,
74
98
  google: /(?:GOOGLE_API_KEY|GOOGLE_GENAI_API_KEY|GEMINI_API_KEY)\s*[= ]\s*['"]?([A-Za-z0-9_-]{20,})/,
75
99
  cohere: /COHERE_API_KEY\s*[= ]\s*['"]?([A-Za-z0-9-]{30,})/,
@@ -85,12 +109,9 @@ function scanShellRc(content) {
85
109
  return hits;
86
110
  }
87
111
 
88
- // Deterministic false_positive_checks_required evaluation for
89
- // cleartext-api-key-in-dotfile. Returns the satisfiable indices for the
90
- // exports found across the canonical dotfiles (intersection — an index is
91
- // only attested if every export satisfies it). Canonical home rc / dotfile
92
- // paths are never under examples/tests/fixtures, so the path check [1] is
93
- // always satisfied here.
112
+ // The satisfiable false_positive_checks_required indices for
113
+ // cleartext-api-key-in-dotfile, intersected across every export found. Canonical
114
+ // home dotfiles are never under examples/tests/fixtures, so [1] always holds.
94
115
  function cleartextFpIndices(content) {
95
116
  const sat = new Set(["0", "1", "2"]);
96
117
  let sawAny = false;
@@ -101,8 +122,7 @@ function cleartextFpIndices(content) {
101
122
  const value = m[1];
102
123
  // [0] not a documented placeholder / sk-test- fixture
103
124
  if (PLACEHOLDER_RE.test(value)) sat.delete("0");
104
- // [2] entropy floor: OpenAI sk-* >= 48 post-prefix, Anthropic sk-ant-* >= 40,
105
- // HuggingFace hf_* >= 30.
125
+ // [2] entropy floor, post-prefix: OpenAI 48, Anthropic 40, others 30.
106
126
  const floor = vendor === "openai" ? 48 : vendor === "anthropic" ? 40 : 30;
107
127
  const body = value.replace(/^sk-ant-(?:api03|admin01)-|^sk-(?:proj-|svcacct-|admin-)?|^hf_/, "");
108
128
  if (body.length < floor) sat.delete("2");
@@ -128,9 +148,8 @@ function parseAwsCredentials(content) {
128
148
  const staticProfiles = [];
129
149
  const accessKeyIds = [];
130
150
  for (const [name, kv] of Object.entries(profiles)) {
131
- // long-lived-aws-keys: aws_access_key_id present AND no
132
- // aws_session_token sibling (STS temporary creds carry the
133
- // session token; IAM-user long-lived keys do not).
151
+ // Long-lived means an access key id with no session-token sibling: STS
152
+ // temporary credentials always carry one, IAM-user keys never do.
134
153
  if (kv["aws_access_key_id"] && !kv["aws_session_token"]) {
135
154
  staticProfiles.push(name);
136
155
  accessKeyIds.push(kv["aws_access_key_id"]);
@@ -155,7 +174,12 @@ function parseGcloudAdc(content) {
155
174
  privateKey: typeof j?.private_key === "string" ? j.private_key : "",
156
175
  clientEmail: typeof j?.client_email === "string" ? j.client_email : "",
157
176
  };
158
- } catch { return { hasServiceAccount: false }; }
177
+ } catch (e) {
178
+ // Unparseable ADC is NOT evidence the file holds no service account; the
179
+ // parse failure travels so `gcp-service-account-json: miss` is not read as
180
+ // a clean bill of health.
181
+ return { hasServiceAccount: false, parse_error: e.message };
182
+ }
159
183
  }
160
184
 
161
185
  function parseKubeStaticToken(content) {
@@ -187,18 +211,26 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
187
211
  const root = path.resolve(cwd);
188
212
  const home = (env && env.HOME) || (env && env.USERPROFILE) || os.homedir();
189
213
 
190
- // Shell rc + dotfile candidates.
191
214
  const shellRcs = [
192
215
  ".bashrc", ".bash_profile", ".zshrc", ".zprofile", ".profile",
193
216
  path.join(".config", "fish", "config.fish"),
194
217
  ].map(rel => path.join(home, rel));
195
- // Glob fish/conf.d/*.
196
218
  try {
197
219
  const fishConfD = path.join(home, ".config", "fish", "conf.d");
198
220
  for (const e of fs.readdirSync(fishConfD)) {
199
221
  if (e.endsWith(".fish")) shellRcs.push(path.join(fishConfD, e));
200
222
  }
201
- } catch { /* fish not present */ }
223
+ } catch (e) {
224
+ // No fish conf.d is the normal case. A permission or I/O failure on one that
225
+ // IS there means those fragments went unscanned for key exports.
226
+ if (e.code !== "ENOENT") {
227
+ errors.push({
228
+ artifact_id: "shell-rc-files",
229
+ kind: "readdir_failed",
230
+ reason: `.config/fish/conf.d: ${e.message} — fragments not scanned`,
231
+ });
232
+ }
233
+ }
202
234
 
203
235
  const dotfileKeys = [
204
236
  ".openai", ".anthropic",
@@ -208,13 +240,24 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
208
240
  path.join(".config", "azure-openai"),
209
241
  ].map(rel => path.join(home, rel));
210
242
 
211
- const allKeyCarriers = [...shellRcs, ...dotfileKeys];
243
+ // Two artifacts share one scan loop. The carrier's own artifact_id travels
244
+ // with it so a skipped `~/.anthropic` is reported against dotfile-api-keys
245
+ // rather than pointing the operator at the shell-rc row.
246
+ const allKeyCarriers = [
247
+ ...shellRcs.map(p => ({ path: p, artifact_id: "shell-rc-files" })),
248
+ ...dotfileKeys.map(p => ({ path: p, artifact_id: "dotfile-api-keys" })),
249
+ ];
212
250
  const cleartextHitsByFile = {};
251
+ const cleartextUnread = [];
213
252
  let cleartextFp = null;
214
- for (const p of allKeyCarriers) {
253
+ for (const { path: p, artifact_id } of allKeyCarriers) {
215
254
  if (!fileExists(p)) continue;
216
- const c = readSafe(p);
217
- if (c == null) continue;
255
+ const c = readSafe(p, SCAN_CAP_BYTES, {
256
+ errors, artifact_id, label: path.relative(home, p),
257
+ });
258
+ // Present but unread: the file could still hold a key, so it is a gap in
259
+ // the scan rather than a carrier with nothing in it.
260
+ if (c === null) { cleartextUnread.push(path.relative(home, p)); continue; }
218
261
  const hits = scanShellRc(c);
219
262
  if (hits.length > 0) {
220
263
  cleartextHitsByFile[path.relative(home, p)] = hits;
@@ -227,35 +270,70 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
227
270
  }
228
271
  const cleartextAnyHit = Object.keys(cleartextHitsByFile).length > 0;
229
272
 
230
- // AWS / GCP / kube reuse.
231
273
  const awsCredsPath = path.join(home, ".aws", "credentials");
232
- const awsCredsContent = fileExists(awsCredsPath) ? readSafe(awsCredsPath) : null;
274
+ const awsCredsExists = fileExists(awsCredsPath);
275
+ const awsCredsContent = awsCredsExists
276
+ ? readSafe(awsCredsPath, SCAN_CAP_BYTES, {
277
+ errors, artifact_id: "aws-credentials", label: path.relative(home, awsCredsPath),
278
+ })
279
+ : null;
233
280
  const awsParsed = parseAwsCredentials(awsCredsContent);
234
281
  const longLivedAws = awsParsed.staticProfiles.length > 0;
235
282
 
236
283
  const gcloudAdcPath = path.join(home, ".config", "gcloud", "application_default_credentials.json");
237
- const gcloudContent = fileExists(gcloudAdcPath) ? readSafe(gcloudAdcPath) : null;
284
+ const gcloudAdcExists = fileExists(gcloudAdcPath);
285
+ const gcloudContent = gcloudAdcExists
286
+ ? readSafe(gcloudAdcPath, SCAN_CAP_BYTES, {
287
+ errors, artifact_id: "gcp-credentials", label: path.relative(home, gcloudAdcPath),
288
+ })
289
+ : null;
238
290
  const gcloudParsed = parseGcloudAdc(gcloudContent);
291
+ if (gcloudParsed.parse_error) {
292
+ errors.push({
293
+ artifact_id: "gcp-credentials",
294
+ kind: "parse_failed",
295
+ reason: `${path.relative(home, gcloudAdcPath)}: ${gcloudParsed.parse_error} — service-account presence undetermined, not absent`,
296
+ });
297
+ }
239
298
 
240
299
  const kubeCfgPath = (env && env.KUBECONFIG) || path.join(home, ".kube", "config");
241
- const kubeContent = fileExists(kubeCfgPath) ? readSafe(kubeCfgPath) : null;
300
+ // KUBECONFIG can point outside $HOME, where a home-relative label degrades to
301
+ // a `..` chain; the basename is enough to name the file that went unread.
302
+ // The separator is required: a bare prefix test also matches a SIBLING whose
303
+ // name merely starts with $HOME ("/home/rob" vs "/home/robert-backup"), which
304
+ // produces exactly the `../…` chain this branch exists to avoid — and leaks
305
+ // another account's directory name into the warning.
306
+ const kubeInHome = kubeCfgPath === home || kubeCfgPath.startsWith(home + path.sep);
307
+ const kubeLabel = kubeInHome ? path.relative(home, kubeCfgPath) : path.basename(kubeCfgPath);
308
+ const kubeCfgExists = fileExists(kubeCfgPath);
309
+ const kubeContent = kubeCfgExists
310
+ ? readSafe(kubeCfgPath, SCAN_CAP_BYTES, {
311
+ errors, artifact_id: "kube-config", label: kubeLabel,
312
+ })
313
+ : null;
242
314
  const kubeParsed = parseKubeStaticToken(kubeContent);
243
315
  const kubeStaticToken = kubeParsed.found;
244
316
 
317
+ const awsState = storeState(awsCredsExists, awsCredsContent);
318
+ const gcloudState = storeState(gcloudAdcExists, gcloudContent);
319
+ const kubeState = storeState(kubeCfgExists, kubeContent);
320
+
245
321
  const signal_overrides = {
246
- "cleartext-api-key-in-dotfile": cleartextAnyHit ? "hit" : "miss",
247
- "long-lived-aws-keys": longLivedAws ? "hit" : "miss",
248
- "gcp-service-account-json": gcloudParsed.hasServiceAccount ? "hit" : "miss",
249
- "kubeconfig-with-static-token": kubeStaticToken ? "hit" : "miss",
322
+ "cleartext-api-key-in-dotfile": verdict(cleartextAnyHit, cleartextUnread.length > 0),
323
+ "long-lived-aws-keys": verdict(longLivedAws, awsState === UNREAD),
324
+ // Unread and unparseable are the same answer here: nothing in the file was
325
+ // validated, so its service-account presence is undetermined rather than
326
+ // absent. JSON.parse never runs on an unread store, so the state carries it.
327
+ "gcp-service-account-json": verdict(
328
+ gcloudParsed.hasServiceAccount,
329
+ gcloudState === UNREAD || Boolean(gcloudParsed.parse_error),
330
+ ),
331
+ "kubeconfig-with-static-token": verdict(kubeStaticToken, kubeState === UNREAD),
250
332
  };
251
333
 
252
- // Per-indicator __fp_checks attestation. Each canonical-path credential
253
- // store the collector reads is never under an examples/tests/fixtures path,
254
- // so the path-based FP checks are satisfied; value-based checks (placeholder,
255
- // entropy, sample-credential, cluster-locality) are evaluated deterministically.
256
- // Network / sts-validity checks are left unattested so the runner still
257
- // downgrades those. Without this, a real cleartext key or static token
258
- // surfaced by `collect` is downgraded to inconclusive after `run`.
334
+ // Per-indicator __fp_checks attestation. Path checks hold because every store
335
+ // read here is a canonical home path; network and STS-validity checks stay
336
+ // unattested. With no attestation at all, a real hit downgrades to inconclusive.
259
337
  if (cleartextAnyHit && cleartextFp && cleartextFp.size) {
260
338
  const att = {};
261
339
  for (const idx of cleartextFp) att[idx] = true;
@@ -278,8 +356,7 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
278
356
  // [1] client_email is a real *@*.gserviceaccount.com (not example/test)
279
357
  const ce = gcloudParsed.clientEmail || "";
280
358
  if (/@[^@\s]+\.gserviceaccount\.com$/i.test(ce) && !/@example\.com$|@test\./i.test(ce)) att["1"] = true;
281
- // [2] canonical ADC path (not under examples/) AND no GOOGLE_APPLICATION_CREDENTIALS
282
- // redirecting away from it
359
+ // [2] canonical ADC path, with no GOOGLE_APPLICATION_CREDENTIALS redirect
283
360
  if (!(env && env.GOOGLE_APPLICATION_CREDENTIALS)) att["2"] = true;
284
361
  if (Object.keys(att).length) signal_overrides["gcp-service-account-json__fp_checks"] = att;
285
362
  }
@@ -305,15 +382,32 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
305
382
  value: dotfileKeys.filter(p => fileExists(p)).map(p => path.relative(home, p)).join(", ") || "no AI vendor dotfile carriers found at the canonical paths",
306
383
  captured: true,
307
384
  },
308
- "aws-credentials": awsCredsContent
385
+ // A null content is two different worlds: the store is not there, or it IS
386
+ // there and went unread (over the scan cap, permissions, I/O). Only the
387
+ // first is an absence. The second is captured:false with the reason on
388
+ // collector_errors — asserting "absent" over a credential file that exists
389
+ // is the same clean-verdict-over-an-unscanned-store failure the error
390
+ // channel was added to close.
391
+ "aws-credentials": awsState === READ
309
392
  ? { value: `${awsParsed.staticProfiles.length} long-lived profile(s): ${awsParsed.staticProfiles.join(", ") || "none"}`, captured: true }
310
- : { value: "~/.aws/credentials absent", captured: true },
311
- "gcp-credentials": gcloudContent
393
+ : awsState === UNREAD
394
+ ? { value: "~/.aws/credentials present but unread — profile inventory undetermined, not absent", captured: false, reason: "read skipped or failed; see collector_errors for the reason" }
395
+ : { value: "~/.aws/credentials absent", captured: true },
396
+ // Read-but-unparseable is a fourth outcome, distinct from read, unread and
397
+ // absent: the bytes arrived and nothing in them was validated. Reporting
398
+ // captured:true there states service_account=false about a file never parsed.
399
+ "gcp-credentials": gcloudState === READ && !gcloudParsed.parse_error
312
400
  ? { value: `application_default_credentials.json present; service_account=${gcloudParsed.hasServiceAccount}`, captured: true }
313
- : { value: "no gcloud ADC at the canonical path", captured: true, reason: "credentials.db / legacy_credentials/*/adc.json inspection deferred (no stdlib SQLite reader)" },
314
- "kube-config": kubeContent
401
+ : gcloudState === READ
402
+ ? { value: "application_default_credentials.json present but unparseable — service-account presence undetermined, not absent", captured: false, reason: "JSON parse failed; see collector_errors for the reason" }
403
+ : gcloudState === UNREAD
404
+ ? { value: "application_default_credentials.json present but unread — service-account presence undetermined, not absent", captured: false, reason: "read skipped or failed; see collector_errors for the reason" }
405
+ : { value: "no gcloud ADC at the canonical path", captured: true, reason: "credentials.db / legacy_credentials/*/adc.json inspection deferred (no stdlib SQLite reader)" },
406
+ "kube-config": kubeState === READ
315
407
  ? { value: `kubeconfig present; static_token=${kubeStaticToken}`, captured: true }
316
- : { value: "no kubeconfig at the canonical path", captured: true },
408
+ : kubeState === UNREAD
409
+ ? { value: "kubeconfig present but unread — static-token presence undetermined, not absent", captured: false, reason: "read skipped or failed; see collector_errors for the reason" }
410
+ : { value: "no kubeconfig at the canonical path", captured: true },
317
411
  "ai-sdk-inventory": {
318
412
  value: "skipped — npm/pip global listing deferred to operator/AI evidence",
319
413
  captured: false,