@blamejs/exceptd-skills 0.19.33 → 0.19.34

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 +10 -0
  2. package/bin/exceptd.js +896 -2824
  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 +20 -54
  8. package/lib/collectors/cicd-pipeline-compromise.js +40 -108
  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 +57 -206
  17. package/lib/collectors/mcp.js +24 -70
  18. package/lib/collectors/runtime.js +24 -86
  19. package/lib/collectors/sbom.js +34 -106
  20. package/lib/collectors/scan-excludes.js +31 -138
  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 -152
  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 +35 -114
  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 +64 -232
  37. package/lib/playbook-runner.js +693 -2095
  38. package/lib/prefetch.js +100 -376
  39. package/lib/refresh-external.js +199 -627
  40. package/lib/refresh-network.js +75 -307
  41. package/lib/rfc-cli.js +23 -68
  42. package/lib/scoring.js +77 -145
  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 +14 -24
  48. package/lib/upstream-check-cli.js +10 -28
  49. package/lib/upstream-check.js +19 -44
  50. package/lib/validate-catalog-meta.js +17 -61
  51. package/lib/validate-cve-catalog.js +43 -119
  52. package/lib/validate-indexes.js +25 -76
  53. package/lib/validate-package.js +16 -62
  54. package/lib/validate-playbooks.js +69 -275
  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 -412
  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 +241 -241
  69. package/scripts/audit-catalog-gaps.js +9 -62
  70. package/scripts/audit-cross-skill.js +5 -31
  71. package/scripts/audit-perf.js +6 -16
  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 +12 -27
  87. package/scripts/builders/token-budget.js +4 -31
  88. package/scripts/check-agents-md-collectors.js +11 -54
  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 +50 -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 +13 -73
  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 +66 -197
  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 +3 -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 +3 -8
  110. package/scripts/refresh-reverse-refs.js +27 -94
  111. package/scripts/refresh-rfc-index.js +2 -10
  112. package/scripts/refresh-sbom.js +31 -161
  113. package/scripts/refresh-upstream-catalogs.js +40 -137
  114. package/scripts/release.js +69 -232
  115. package/scripts/run-e2e-scenarios.js +24 -71
  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 -140
@@ -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
 
@@ -31,10 +19,8 @@ function readSafe(full, max = 256 * 1024) {
31
19
  fd = fs.openSync(full, "r");
32
20
  const s = fs.fstatSync(fd);
33
21
  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.
22
+ // readFileSync(fd) loops to EOF; a single readSync can return short on a
23
+ // network or FUSE fd. Reading the open fd keeps fstat-then-read TOCTOU-free.
38
24
  return fs.readFileSync(fd, "utf8");
39
25
  } catch { return null; }
40
26
  finally { if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* non-fatal */ } } }
@@ -44,9 +30,7 @@ function fileExists(full) {
44
30
  try { return fs.statSync(full).isFile(); } catch { return false; }
45
31
  }
46
32
 
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`.
33
+ // Cleartext key exports: `export VAR=value`, `VAR=value`, fish `set -gx VAR value`.
50
34
  const AI_KEY_PATTERNS = [
51
35
  { id: "openai", re: /(?:^|\n)\s*(?:export\s+|set\s+-gx\s+)?OPENAI_API_KEY\s*[= ]\s*['"]?sk-[A-Za-z0-9_-]{20,}/m },
52
36
  { 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 +40,15 @@ const AI_KEY_PATTERNS = [
56
40
  { id: "cohere", re: /(?:^|\n)\s*(?:export\s+|set\s+-gx\s+)?COHERE_API_KEY\s*[= ]\s*['"]?[A-Za-z0-9-]{30,}/m },
57
41
  ];
58
42
 
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.
43
+ // The patterns above end at the prefix; these capture the exported value so
44
+ // the placeholder and entropy-floor FP checks have something to evaluate.
62
45
  const AI_KEY_VALUE_RE = {
63
46
  openai: /OPENAI_API_KEY\s*[= ]\s*['"]?(sk-[A-Za-z0-9_-]+)/,
64
47
  anthropic: /ANTHROPIC_API_KEY\s*[= ]\s*['"]?(sk-ant-[A-Za-z0-9_-]+)/,
65
48
  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.
49
+ // Azure, Google and Cohere keys carry no vendor prefix, so the captured value
50
+ // IS the entropy body. Drop one of these and cleartextFpIndices attests nothing
51
+ // for such a dotfile, downgrading a real hit to inconclusive.
73
52
  azure: /AZURE_OPENAI(?:_API)?_KEY\s*[= ]\s*['"]?([A-Za-z0-9]{20,})/,
74
53
  google: /(?:GOOGLE_API_KEY|GOOGLE_GENAI_API_KEY|GEMINI_API_KEY)\s*[= ]\s*['"]?([A-Za-z0-9_-]{20,})/,
75
54
  cohere: /COHERE_API_KEY\s*[= ]\s*['"]?([A-Za-z0-9-]{30,})/,
@@ -85,12 +64,9 @@ function scanShellRc(content) {
85
64
  return hits;
86
65
  }
87
66
 
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.
67
+ // The satisfiable false_positive_checks_required indices for
68
+ // cleartext-api-key-in-dotfile, intersected across every export found. Canonical
69
+ // home dotfiles are never under examples/tests/fixtures, so [1] always holds.
94
70
  function cleartextFpIndices(content) {
95
71
  const sat = new Set(["0", "1", "2"]);
96
72
  let sawAny = false;
@@ -101,8 +77,7 @@ function cleartextFpIndices(content) {
101
77
  const value = m[1];
102
78
  // [0] not a documented placeholder / sk-test- fixture
103
79
  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.
80
+ // [2] entropy floor, post-prefix: OpenAI 48, Anthropic 40, others 30.
106
81
  const floor = vendor === "openai" ? 48 : vendor === "anthropic" ? 40 : 30;
107
82
  const body = value.replace(/^sk-ant-(?:api03|admin01)-|^sk-(?:proj-|svcacct-|admin-)?|^hf_/, "");
108
83
  if (body.length < floor) sat.delete("2");
@@ -128,9 +103,8 @@ function parseAwsCredentials(content) {
128
103
  const staticProfiles = [];
129
104
  const accessKeyIds = [];
130
105
  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).
106
+ // Long-lived means an access key id with no session-token sibling: STS
107
+ // temporary credentials always carry one, IAM-user keys never do.
134
108
  if (kv["aws_access_key_id"] && !kv["aws_session_token"]) {
135
109
  staticProfiles.push(name);
136
110
  accessKeyIds.push(kv["aws_access_key_id"]);
@@ -187,12 +161,10 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
187
161
  const root = path.resolve(cwd);
188
162
  const home = (env && env.HOME) || (env && env.USERPROFILE) || os.homedir();
189
163
 
190
- // Shell rc + dotfile candidates.
191
164
  const shellRcs = [
192
165
  ".bashrc", ".bash_profile", ".zshrc", ".zprofile", ".profile",
193
166
  path.join(".config", "fish", "config.fish"),
194
167
  ].map(rel => path.join(home, rel));
195
- // Glob fish/conf.d/*.
196
168
  try {
197
169
  const fishConfD = path.join(home, ".config", "fish", "conf.d");
198
170
  for (const e of fs.readdirSync(fishConfD)) {
@@ -227,7 +199,6 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
227
199
  }
228
200
  const cleartextAnyHit = Object.keys(cleartextHitsByFile).length > 0;
229
201
 
230
- // AWS / GCP / kube reuse.
231
202
  const awsCredsPath = path.join(home, ".aws", "credentials");
232
203
  const awsCredsContent = fileExists(awsCredsPath) ? readSafe(awsCredsPath) : null;
233
204
  const awsParsed = parseAwsCredentials(awsCredsContent);
@@ -249,13 +220,9 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
249
220
  "kubeconfig-with-static-token": kubeStaticToken ? "hit" : "miss",
250
221
  };
251
222
 
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`.
223
+ // Per-indicator __fp_checks attestation. Path checks hold because every store
224
+ // read here is a canonical home path; network and STS-validity checks stay
225
+ // unattested. With no attestation at all, a real hit downgrades to inconclusive.
259
226
  if (cleartextAnyHit && cleartextFp && cleartextFp.size) {
260
227
  const att = {};
261
228
  for (const idx of cleartextFp) att[idx] = true;
@@ -278,8 +245,7 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
278
245
  // [1] client_email is a real *@*.gserviceaccount.com (not example/test)
279
246
  const ce = gcloudParsed.clientEmail || "";
280
247
  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
248
+ // [2] canonical ADC path, with no GOOGLE_APPLICATION_CREDENTIALS redirect
283
249
  if (!(env && env.GOOGLE_APPLICATION_CREDENTIALS)) att["2"] = true;
284
250
  if (Object.keys(att).length) signal_overrides["gcp-service-account-json__fp_checks"] = att;
285
251
  }
@@ -1,32 +1,18 @@
1
1
  "use strict";
2
2
 
3
3
  /**
4
- * lib/collectors/cicd-pipeline-compromise.js
5
- *
6
- * Companion collector for the `cicd-pipeline-compromise` playbook.
7
- * Consumer-side CI/CD posture: walks .github/workflows/*.yml,
8
- * .gitlab-ci.yml, .circleci/config.yml, and the project's
9
- * infra/terraform/policies dirs for OIDC trust JSON. Flips
10
- * deterministic indicators that detect the published-Action and
11
- * fork-PR attack classes documented in the playbook.
12
- *
13
- * Skipped indicators (require GitHub API or HSM/KMS access, left
14
- * unflipped so the runner returns inconclusive):
15
- *
16
- * self-hosted-runner-non-ephemeral needs GitHub API (runners list)
17
- * runner-scoped-signing-key needs HSM/KMS inspection
18
- *
19
- * Interface: see lib/collectors/README.md
4
+ * Companion collector for the `cicd-pipeline-compromise` playbook: walks CI
5
+ * workflow YAML and the infra / terraform / policies dirs for OIDC trust JSON.
6
+ * `self-hosted-runner-non-ephemeral` and `runner-scoped-signing-key` are left
7
+ * unflipped (GitHub runners API / HSM inspection), so they report inconclusive.
8
+ * Interface: lib/collectors/README.md.
20
9
  */
21
10
 
22
11
  const fs = require("node:fs");
23
12
  const path = require("node:path");
24
13
  const { codeExcludeSet, isLinkedWorktreeDir, buildEvidenceLocations } = require("./scan-excludes");
25
14
 
26
- // Shared code-scope name exclusions (dependency caches, build output, VCS +
27
- // agent scratch). Threaded into the OIDC-policy descent so a trust JSON in a
28
- // build-output dir (e.g. `dist/`) is not scanned — consistent with the other
29
- // tree-walking collectors.
15
+ // Shared code-scope exclusions, so a trust JSON under `dist/` is not scanned.
30
16
  const OIDC_WALK_EXCLUDES = codeExcludeSet();
31
17
 
32
18
  const COLLECTOR_ID = "cicd-pipeline-compromise";
@@ -37,10 +23,8 @@ function readSafe(p, max = 512 * 1024) {
37
23
  fd = fs.openSync(p, "r");
38
24
  const s = fs.fstatSync(fd);
39
25
  if (s.size > max) return null;
40
- // readFileSync(fd) loops read() to EOF — a single readSync may return
41
- // fewer than s.size bytes on network/FUSE/sync-backed fds, which would
42
- // leave the buffer tail NUL-filled and silently drop trailing content.
43
- // Reading via the already-open fd keeps the fstat-then-read TOCTOU-free.
26
+ // readFileSync(fd) loops read() to EOF; a single readSync can return short on
27
+ // a network or FUSE fd. Reading the open fd keeps fstat-then-read TOCTOU-free.
44
28
  return fs.readFileSync(fd, "utf8");
45
29
  } catch { return null; }
46
30
  finally { if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* non-fatal */ } } }
@@ -72,20 +56,13 @@ function walkWorkflows(root) {
72
56
  return out;
73
57
  }
74
58
 
75
- // `on:` trigger detection. Workflows declare triggers via four
76
- // canonical YAML shapes:
77
- // - scalar: `on: push`
78
- // - inline list: `on: [push, pull_request_target]`
79
- // - block list: `on:\n - push\n - pull_request_target`
80
- // - mapping: `on:\n push:\n pull_request_target:`
81
- // Heuristic accepts all four.
59
+ // Trigger detection across the four canonical `on:` shapes: scalar
60
+ // (`on: push`), inline list, block list, and mapping.
82
61
  function workflowHasTrigger(content, name) {
83
62
  if (new RegExp(`^\\s*on:\\s*['"]?${name}['"]?\\s*(?:#.*)?$`, "m").test(content)) return true; // allow:dynamic-regex — `name` is a hardcoded trigger literal (pull_request_target / issue_comment / pull_request), never operator/file input
84
63
  const listMatch = content.match(/^\s*on:\s*\[([^\]]*)\]/m);
85
64
  if (listMatch && new RegExp(`(?:^|,)\\s*['"]?${name}['"]?\\s*(?:,|$)`).test(listMatch[1])) return true; // allow:dynamic-regex — `name` is a hardcoded trigger literal, never operator/file input
86
- // block list AND mapping forms both follow `on:\n` with indented
87
- // continuation lines. Capture the block and inspect for either
88
- // `- <name>` (list) or `<name>:` (mapping) within it.
65
+ // Block-list and mapping forms both follow `on:\n` with indented continuations.
89
66
  const blockMatch = content.match(/^\s*on:\s*\n((?:[ \t]+[^\n]+\n?)+)/m);
90
67
  if (blockMatch) {
91
68
  if (new RegExp(`^[ \\t]+-\\s+['"]?${name}['"]?\\s*(?:#.*)?\\s*$`, "m").test(blockMatch[1])) return true; // allow:dynamic-regex — `name` is a hardcoded trigger literal, never operator/file input
@@ -94,13 +71,9 @@ function workflowHasTrigger(content, name) {
94
71
  return false;
95
72
  }
96
73
 
97
- // Find `actions/checkout` step blocks and return true when any one
98
- // of them carries a `ref:` line referencing the PR head. Each step
99
- // block is delimited by the next sibling `- ` at the same
100
- // indentation (or de-indented line, meaning we've left steps[]).
101
- // Binding the ref match to the checkout step prevents false hits
102
- // when another unrelated step references the PR head while the
103
- // actual checkout is safely fetching the base ref.
74
+ // True when an `actions/checkout` step block carries a `ref:` naming the PR head.
75
+ // Binding the ref to the checkout step keeps an unrelated step's reference to the
76
+ // PR head from reading as a hit while the checkout fetches the base ref.
104
77
  function checkoutBindsPrHead(content) {
105
78
  const lines = content.split(/\r?\n/);
106
79
  for (let i = 0; i < lines.length; i++) {
@@ -114,8 +87,7 @@ function checkoutBindsPrHead(content) {
114
87
  const indentM = line.match(/^(\s*)\S/);
115
88
  if (!indentM) continue;
116
89
  const indent = indentM[1].length;
117
- // Next sibling step starts with `-` at the same indent, or
118
- // any line de-indented past the step base ends the block.
90
+ // The block ends at a sibling `-` on the same indent, or any de-indent.
119
91
  if (indent < baseIndent) { blockEnd = j; break; }
120
92
  if (indent === baseIndent && line.trim().startsWith("- ")) { blockEnd = j; break; }
121
93
  }
@@ -139,18 +111,12 @@ function scanWorkflow(content, rel) {
139
111
  const hasPRTarget = workflowHasTrigger(content, "pull_request_target");
140
112
  const hasIssueComment = workflowHasTrigger(content, "issue_comment");
141
113
 
142
- // pull-request-target-with-pr-checkout: PRT trigger AND the
143
- // PR-head ref reference is bound to an actions/checkout step
144
- // (not to an unrelated step that happens to read head_ref).
145
114
  if (hasPRTarget && checkoutBindsPrHead(content)) {
146
115
  hits["pull-request-target-with-pr-checkout"].push({ file: rel, snippet: "pull_request_target trigger + checkout of PR head" });
147
116
  }
148
117
 
149
- // workflow-injection-sink: ${{ github.event.<title|body|...> }}
150
- // interpolated directly inside a `run:` block. Conservative form:
151
- // file-wide presence of one of the dangerous expressions AND the
152
- // expression appears outside an `env:` mapping context that would
153
- // have made it safe.
118
+ // workflow-injection-sink: an attacker-controlled `${{ github.event.* }}`
119
+ // interpolated inside a `run:` block, outside a safe `env:` mapping.
154
120
  if (hasPRTarget || hasIssueComment || workflowHasTrigger(content, "pull_request")) {
155
121
  const dangerousExprs = [
156
122
  /\$\{\{\s*github\.event\.pull_request\.(?:title|body|head\.ref)\s*\}\}/,
@@ -162,17 +128,12 @@ function scanWorkflow(content, rel) {
162
128
  const lines = content.split(/\r?\n/);
163
129
  for (let i = 0; i < lines.length; i++) {
164
130
  const line = lines[i];
165
- // Quick filter: dangerous expression on this line?
166
131
  const matchedExpr = dangerousExprs.find(re => re.test(line));
167
132
  if (!matchedExpr) continue;
168
- // If the dangerous expr is inside an `env:` mapping (key: value
169
- // shape on a YAML env block), the shell sees it as a variable
170
- // and it's not an injection sink. Walk back up to 3 lines to
171
- // find the nearest preceding YAML key indicator.
133
+ // Inside an `env:` mapping the shell sees a variable, not a sink.
172
134
  const ctx = lines.slice(Math.max(0, i - 3), i + 1).join("\n");
173
135
  const isEnvBinding = /^\s+[A-Z_][A-Z0-9_]*:\s*['"]?\$\{\{\s*github\.event/m.test(ctx);
174
- // Detect `run:` proximity. The expression must land inside a
175
- // run-block; otherwise it's an `env:` binding or `with:` arg.
136
+ // The expression counts only inside a run-block.
176
137
  const inRun = /^\s+run:/m.test(ctx) || /^\s+\|/m.test(ctx) || lines[i].trim().startsWith("- run:");
177
138
  if (inRun && !isEnvBinding) {
178
139
  hits["workflow-injection-sink"].push({ file: rel, line: i + 1, snippet: line.trim().slice(0, 160) });
@@ -181,17 +142,14 @@ function scanWorkflow(content, rel) {
181
142
  }
182
143
  }
183
144
 
184
- // actions-floating-tag-pin: `uses: <owner>/<repo>@<ref>` where ref
185
- // isn't a 40-char hex SHA AND owner isn't `actions` (first-party
186
- // GitHub repos excluded by the playbook predicate). Excludes local
187
- // composite actions (`uses: ./`).
145
+ // actions-floating-tag-pin: `uses: <owner>/<repo>@<ref>` where ref is not a
146
+ // 40-char hex SHA; owner `actions` and local composite actions are excluded.
188
147
  const lines2 = content.split(/\r?\n/);
189
148
  for (let i = 0; i < lines2.length; i++) {
190
- // A real `uses:` line is never multiple KB. Skip overlong lines so a
191
- // crafted whitespace run can't drive regex backtracking.
149
+ // A real `uses:` line is never multiple KB; skipping overlong ones keeps a
150
+ // crafted whitespace run from driving regex backtracking.
192
151
  if (lines2[i].length > 4096) continue;
193
- // `^[ \t]*(?:-[ \t]*)?` anchors the indentation once, then an optional
194
- // `- ` list marker — no overlapping `\s*` runs that backtrack.
152
+ // Indentation is anchored once, then an optional `- ` — no overlapping `\s*`.
195
153
  const m = lines2[i].match(/^[ \t]*(?:-[ \t]*)?uses:\s*['"]?([^'"\s#]+)['"]?/);
196
154
  if (!m) continue;
197
155
  const refStr = m[1];
@@ -208,15 +166,12 @@ function scanWorkflow(content, rel) {
208
166
  }
209
167
  }
210
168
 
211
- // secret-exposed-to-fork-pr: pull_request_target trigger + the
212
- // workflow references `secrets.X` for any X other than GITHUB_TOKEN.
213
- // Pull-request-from-forks (without target) requires runtime info
214
- // to detect fork status — left to operator evidence.
169
+ // secret-exposed-to-fork-pr: a pull_request_target trigger plus a `secrets.X`
170
+ // reference for any X other than GITHUB_TOKEN. A plain pull_request from a fork
171
+ // needs runtime fork status, so it stays operator evidence.
215
172
  if (hasPRTarget) {
216
- // Compare the captured secret NAME exactly to GITHUB_TOKEN. An unanchored
217
- // /secrets\.GITHUB_TOKEN/ substring test also matched custom secrets such
218
- // as GITHUB_TOKEN_PROD, silently treating them as the built-in token and
219
- // dropping a real fork-PR secret exposure (false negative).
173
+ // The captured secret NAME is compared exactly: an unanchored
174
+ // /secrets\.GITHUB_TOKEN/ also matches a custom GITHUB_TOKEN_PROD.
220
175
  const nonDefault = [];
221
176
  for (const m of content.matchAll(/\$\{\{\s*secrets\.([A-Z_][A-Z0-9_]*)\s*\}\}/g)) {
222
177
  if (m[1] !== "GITHUB_TOKEN") nonDefault.push(m[0]);
@@ -230,10 +185,8 @@ function scanWorkflow(content, rel) {
230
185
  }
231
186
 
232
187
  function scanOidcPolicies(root) {
233
- // Walk infra/ + terraform/ + policies/ (depth 4) for *.json that
234
- // names token.actions.githubusercontent.com AND has a wildcarded
235
- // sub-claim. The playbook lists `repo:<org>/*:*`, `repo:*:*`, and
236
- // bare `*` as wildcard shapes.
188
+ // Walks the infra dirs to depth 4 for *.json naming
189
+ // token.actions.githubusercontent.com with a wildcarded sub-claim.
237
190
  const rootDirs = ["infra", "terraform", "policies", ".aws", ".github"].map(d => path.join(root, d));
238
191
  const finds = [];
239
192
  function walk(dir, depth) {
@@ -245,10 +198,7 @@ function scanOidcPolicies(root) {
245
198
  if (OIDC_WALK_EXCLUDES.has(e.name)) continue;
246
199
  const full = path.join(dir, e.name);
247
200
  if (e.isDirectory()) {
248
- // Skip linked git worktrees (gitdir-pointer `.git` file), e.g.
249
- // agent-created repo copies under `.claude/worktrees/<id>/`
250
- // nested below a scanned policy/infra dir — rescanning them
251
- // double-counts the same OIDC trust documents.
201
+ // A linked worktree holds the same trust documents; rescanning double-counts.
252
202
  if (isLinkedWorktreeDir(full)) continue;
253
203
  walk(full, depth + 1);
254
204
  continue;
@@ -256,14 +206,8 @@ function scanOidcPolicies(root) {
256
206
  if (!e.isFile() || !/\.json$/i.test(e.name)) continue;
257
207
  const text = readSafe(full);
258
208
  if (!text) continue;
259
- // The authoritative OIDC sub-claim detection is the self-anchored patterns
260
- // below — each is bound to the leading `"` of the JSON key
261
- // (`"token.actions.githubusercontent.com:sub"`), so a lookalike issuer
262
- // such as `"eviltoken.actions…"` cannot match. No separate substring
263
- // pre-filter on the issuer host: it added nothing the anchored patterns
264
- // don't already enforce, and a bare host-substring check reads as an
265
- // incomplete URL sanitization.
266
- // sub-claim wildcards. Cover the three shapes the playbook lists.
209
+ // Each pattern is bound to the leading `"` of the JSON key, so a lookalike
210
+ // issuer like `"eviltoken.actions…"` cannot match.
267
211
  const subWildcard =
268
212
  /"token\.actions\.githubusercontent\.com:sub"\s*:\s*"\*"/.test(text) ||
269
213
  /"token\.actions\.githubusercontent\.com:sub"\s*:\s*"repo:\*[^"]*"/.test(text) ||
@@ -280,8 +224,7 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
280
224
  const startTime = Date.now();
281
225
  const root = path.resolve(cwd);
282
226
 
283
- // cwd-is-repo precondition: .git directory present. Outside a
284
- // repo we have no workflows / no OIDC trust JSON to walk.
227
+ // Outside a repo there are no workflows and no OIDC trust JSON to walk.
285
228
  const cwdIsRepo = fs.existsSync(path.join(root, ".git"));
286
229
  if (!cwdIsRepo) {
287
230
  return {
@@ -324,11 +267,8 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
324
267
  "wildcarded-oidc-sub-claim": oidcWildcards.length > 0 ? "hit" : "miss",
325
268
  };
326
269
 
327
- // Per-indicator file locations for every indicator flipped to "hit", so a
328
- // SARIF result points at the workflow YAML (or OIDC trust JSON) that
329
- // triggered it. Line-scanned indicators (workflow-injection-sink,
330
- // actions-floating-tag-pin) carry a real line; the trigger-shape and
331
- // OIDC-wildcard indicators record the file only and surface as file-level.
270
+ // File locations for every indicator flipped to "hit", so a SARIF result points
271
+ // at the YAML or trust JSON behind it; without a line it surfaces as file-level.
332
272
  const evidence_locations = {};
333
273
  const evidenceSources = { ...aggregateHits, "wildcarded-oidc-sub-claim": oidcWildcards };
334
274
  for (const [id, list] of Object.entries(evidenceSources)) {
@@ -374,17 +314,9 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
374
314
  };
375
315
 
376
316
  return {
377
- // Attest preconditions.
378
- // ci-config-readable — the collector just walked workflow YAML
379
- // + OIDC trust JSON; filesystem reads succeeded. Auto-true.
380
- // operator-owns-ci-fleet — REQUIRES explicit operator opt-in via
381
- // `--attest-ownership` (or args.attestOwnership === true). The
382
- // playbook gates this `on_fail: halt`; running collect against
383
- // any cwd (e.g. `--cwd /other/repo`) does NOT implicitly attest
384
- // ownership of that fleet's CI authorization scope. Operators
385
- // who own the CI fleet they're auditing pass the flag; running
386
- // collect | run without the flag halts at the runner's
387
- // preflight gate (as the playbook intends).
317
+ // operator-owns-ci-fleet requires explicit opt-in through `--attest-ownership`:
318
+ // pointing collect at any cwd does NOT attest ownership of that fleet's CI
319
+ // authorization scope, and the playbook gates the precondition `on_fail: halt`.
388
320
  precondition_checks: {
389
321
  "cwd-is-repo": true,
390
322
  "ci-config-readable": true,