@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,91 +1,85 @@
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";
33
19
 
20
+ // Returns { text } when the file was read, otherwise { skipped, reason }: a file
21
+ // that is never scanned reads as a "miss" on every indicator it would have
22
+ // flipped, so the caller has to record the skip rather than drop it.
34
23
  function readSafe(p, max = 512 * 1024) {
35
24
  let fd;
36
25
  try {
37
26
  fd = fs.openSync(p, "r");
38
27
  const s = fs.fstatSync(fd);
39
- 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.
44
- return fs.readFileSync(fd, "utf8");
45
- } catch { return null; }
28
+ if (s.size > max) return { skipped: "file_too_large", reason: `${s.size} bytes exceeds the ${max}-byte scan limit; not scanned` };
29
+ // readFileSync(fd) loops read() to EOF; a single readSync can return short on
30
+ // a network or FUSE fd. Reading the open fd keeps fstat-then-read TOCTOU-free.
31
+ return { text: fs.readFileSync(fd, "utf8") };
32
+ } catch (e) { return { skipped: "read_error", reason: (e && e.message) || String(e) }; }
46
33
  finally { if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* non-fatal */ } } }
47
34
  }
48
35
 
49
- function walkWorkflows(root) {
36
+ function skipEntry(artifact_id, rel, r) {
37
+ return {
38
+ artifact_id,
39
+ kind: r.skipped === "file_too_large" ? "file_too_large_skipped" : "read_failed",
40
+ reason: `${rel}: ${r.reason}`,
41
+ };
42
+ }
43
+
44
+ function walkWorkflows(root, errors = []) {
50
45
  const out = [];
51
46
  const wfDir = path.join(root, ".github", "workflows");
52
47
  if (fs.existsSync(wfDir)) {
53
48
  let entries;
54
49
  try { entries = fs.readdirSync(wfDir, { withFileTypes: true }); }
55
- catch { entries = []; }
50
+ catch (e) {
51
+ entries = [];
52
+ errors.push({ artifact_id: "workflow-yaml-inventory", kind: "readdir_failed", reason: `.github/workflows: ${e.message}` });
53
+ }
56
54
  for (const e of entries) {
57
55
  if (!e.isFile()) continue;
58
56
  if (!/\.(ya?ml)$/i.test(e.name)) continue;
59
57
  const full = path.join(wfDir, e.name);
60
- const content = readSafe(full);
61
- if (content != null) out.push({ full, rel: path.relative(root, full).replace(/\\/g, "/"), content });
58
+ const rel = path.relative(root, full).replace(/\\/g, "/");
59
+ const r = readSafe(full);
60
+ if (r.text != null) out.push({ full, rel, content: r.text });
61
+ else errors.push(skipEntry("workflow-yaml-inventory", rel, r));
62
62
  }
63
63
  }
64
64
  // Also recognise the most common single-file CI YAMLs at repo root.
65
65
  for (const top of [".gitlab-ci.yml", ".circleci/config.yml"]) {
66
66
  const full = path.join(root, top);
67
67
  if (fs.existsSync(full)) {
68
- const content = readSafe(full);
69
- if (content != null) out.push({ full, rel: top, content });
68
+ const r = readSafe(full);
69
+ if (r.text != null) out.push({ full, rel: top, content: r.text });
70
+ else errors.push(skipEntry("workflow-yaml-inventory", top, r));
70
71
  }
71
72
  }
72
73
  return out;
73
74
  }
74
75
 
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.
76
+ // Trigger detection across the four canonical `on:` shapes: scalar
77
+ // (`on: push`), inline list, block list, and mapping.
82
78
  function workflowHasTrigger(content, name) {
83
79
  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
80
  const listMatch = content.match(/^\s*on:\s*\[([^\]]*)\]/m);
85
81
  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.
82
+ // Block-list and mapping forms both follow `on:\n` with indented continuations.
89
83
  const blockMatch = content.match(/^\s*on:\s*\n((?:[ \t]+[^\n]+\n?)+)/m);
90
84
  if (blockMatch) {
91
85
  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 +88,9 @@ function workflowHasTrigger(content, name) {
94
88
  return false;
95
89
  }
96
90
 
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.
91
+ // True when an `actions/checkout` step block carries a `ref:` naming the PR head.
92
+ // Binding the ref to the checkout step keeps an unrelated step's reference to the
93
+ // PR head from reading as a hit while the checkout fetches the base ref.
104
94
  function checkoutBindsPrHead(content) {
105
95
  const lines = content.split(/\r?\n/);
106
96
  for (let i = 0; i < lines.length; i++) {
@@ -114,8 +104,7 @@ function checkoutBindsPrHead(content) {
114
104
  const indentM = line.match(/^(\s*)\S/);
115
105
  if (!indentM) continue;
116
106
  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.
107
+ // The block ends at a sibling `-` on the same indent, or any de-indent.
119
108
  if (indent < baseIndent) { blockEnd = j; break; }
120
109
  if (indent === baseIndent && line.trim().startsWith("- ")) { blockEnd = j; break; }
121
110
  }
@@ -139,18 +128,12 @@ function scanWorkflow(content, rel) {
139
128
  const hasPRTarget = workflowHasTrigger(content, "pull_request_target");
140
129
  const hasIssueComment = workflowHasTrigger(content, "issue_comment");
141
130
 
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
131
  if (hasPRTarget && checkoutBindsPrHead(content)) {
146
132
  hits["pull-request-target-with-pr-checkout"].push({ file: rel, snippet: "pull_request_target trigger + checkout of PR head" });
147
133
  }
148
134
 
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.
135
+ // workflow-injection-sink: an attacker-controlled `${{ github.event.* }}`
136
+ // interpolated inside a `run:` block, outside a safe `env:` mapping.
154
137
  if (hasPRTarget || hasIssueComment || workflowHasTrigger(content, "pull_request")) {
155
138
  const dangerousExprs = [
156
139
  /\$\{\{\s*github\.event\.pull_request\.(?:title|body|head\.ref)\s*\}\}/,
@@ -162,17 +145,12 @@ function scanWorkflow(content, rel) {
162
145
  const lines = content.split(/\r?\n/);
163
146
  for (let i = 0; i < lines.length; i++) {
164
147
  const line = lines[i];
165
- // Quick filter: dangerous expression on this line?
166
148
  const matchedExpr = dangerousExprs.find(re => re.test(line));
167
149
  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.
150
+ // Inside an `env:` mapping the shell sees a variable, not a sink.
172
151
  const ctx = lines.slice(Math.max(0, i - 3), i + 1).join("\n");
173
152
  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.
153
+ // The expression counts only inside a run-block.
176
154
  const inRun = /^\s+run:/m.test(ctx) || /^\s+\|/m.test(ctx) || lines[i].trim().startsWith("- run:");
177
155
  if (inRun && !isEnvBinding) {
178
156
  hits["workflow-injection-sink"].push({ file: rel, line: i + 1, snippet: line.trim().slice(0, 160) });
@@ -181,17 +159,14 @@ function scanWorkflow(content, rel) {
181
159
  }
182
160
  }
183
161
 
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: ./`).
162
+ // actions-floating-tag-pin: `uses: <owner>/<repo>@<ref>` where ref is not a
163
+ // 40-char hex SHA; owner `actions` and local composite actions are excluded.
188
164
  const lines2 = content.split(/\r?\n/);
189
165
  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.
166
+ // A real `uses:` line is never multiple KB; skipping overlong ones keeps a
167
+ // crafted whitespace run from driving regex backtracking.
192
168
  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.
169
+ // Indentation is anchored once, then an optional `- ` — no overlapping `\s*`.
195
170
  const m = lines2[i].match(/^[ \t]*(?:-[ \t]*)?uses:\s*['"]?([^'"\s#]+)['"]?/);
196
171
  if (!m) continue;
197
172
  const refStr = m[1];
@@ -208,15 +183,12 @@ function scanWorkflow(content, rel) {
208
183
  }
209
184
  }
210
185
 
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.
186
+ // secret-exposed-to-fork-pr: a pull_request_target trigger plus a `secrets.X`
187
+ // reference for any X other than GITHUB_TOKEN. A plain pull_request from a fork
188
+ // needs runtime fork status, so it stays operator evidence.
215
189
  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).
190
+ // The captured secret NAME is compared exactly: an unanchored
191
+ // /secrets\.GITHUB_TOKEN/ also matches a custom GITHUB_TOKEN_PROD.
220
192
  const nonDefault = [];
221
193
  for (const m of content.matchAll(/\$\{\{\s*secrets\.([A-Z_][A-Z0-9_]*)\s*\}\}/g)) {
222
194
  if (m[1] !== "GITHUB_TOKEN") nonDefault.push(m[0]);
@@ -229,46 +201,44 @@ function scanWorkflow(content, rel) {
229
201
  return hits;
230
202
  }
231
203
 
232
- 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.
204
+ function scanOidcPolicies(root, errors = []) {
205
+ // Walks the infra dirs to depth 4 for *.json naming
206
+ // token.actions.githubusercontent.com with a wildcarded sub-claim.
237
207
  const rootDirs = ["infra", "terraform", "policies", ".aws", ".github"].map(d => path.join(root, d));
238
208
  const finds = [];
239
209
  function walk(dir, depth) {
240
210
  if (depth > 4 || finds.length > 5) return;
241
211
  let entries;
242
212
  try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
243
- catch { return; }
213
+ catch (e) {
214
+ errors.push({
215
+ artifact_id: "oidc-trust-policy-inventory",
216
+ kind: "readdir_failed",
217
+ reason: `${path.relative(root, dir).replace(/\\/g, "/")}: ${e.message}`,
218
+ });
219
+ return;
220
+ }
244
221
  for (const e of entries) {
245
222
  if (OIDC_WALK_EXCLUDES.has(e.name)) continue;
246
223
  const full = path.join(dir, e.name);
247
224
  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.
225
+ // A linked worktree holds the same trust documents; rescanning double-counts.
252
226
  if (isLinkedWorktreeDir(full)) continue;
253
227
  walk(full, depth + 1);
254
228
  continue;
255
229
  }
256
230
  if (!e.isFile() || !/\.json$/i.test(e.name)) continue;
257
- const text = readSafe(full);
258
- 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.
231
+ const rel = path.relative(root, full).replace(/\\/g, "/");
232
+ const r = readSafe(full);
233
+ if (r.text == null) { errors.push(skipEntry("oidc-trust-policy-inventory", rel, r)); continue; }
234
+ const text = r.text;
235
+ // Each pattern is bound to the leading `"` of the JSON key, so a lookalike
236
+ // issuer like `"eviltoken.actions…"` cannot match.
267
237
  const subWildcard =
268
238
  /"token\.actions\.githubusercontent\.com:sub"\s*:\s*"\*"/.test(text) ||
269
239
  /"token\.actions\.githubusercontent\.com:sub"\s*:\s*"repo:\*[^"]*"/.test(text) ||
270
240
  /"token\.actions\.githubusercontent\.com:sub"\s*:\s*"repo:[^"]*\/\*:[^"]*"/.test(text);
271
- if (subWildcard) finds.push({ file: path.relative(root, full).replace(/\\/g, "/"), snippet: "OIDC sub-claim wildcarded across repos or branches" });
241
+ if (subWildcard) finds.push({ file: rel, snippet: "OIDC sub-claim wildcarded across repos or branches" });
272
242
  }
273
243
  }
274
244
  for (const rd of rootDirs) if (fs.existsSync(rd)) walk(rd, 0);
@@ -280,8 +250,7 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
280
250
  const startTime = Date.now();
281
251
  const root = path.resolve(cwd);
282
252
 
283
- // cwd-is-repo precondition: .git directory present. Outside a
284
- // repo we have no workflows / no OIDC trust JSON to walk.
253
+ // Outside a repo there are no workflows and no OIDC trust JSON to walk.
285
254
  const cwdIsRepo = fs.existsSync(path.join(root, ".git"));
286
255
  if (!cwdIsRepo) {
287
256
  return {
@@ -302,7 +271,7 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
302
271
  };
303
272
  }
304
273
 
305
- const workflows = walkWorkflows(root);
274
+ const workflows = walkWorkflows(root, errors);
306
275
  const aggregateHits = {
307
276
  "workflow-injection-sink": [],
308
277
  "pull-request-target-with-pr-checkout": [],
@@ -314,21 +283,29 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
314
283
  for (const [k, v] of Object.entries(h)) aggregateHits[k].push(...v);
315
284
  }
316
285
 
317
- const oidcWildcards = scanOidcPolicies(root);
286
+ const oidcWildcards = scanOidcPolicies(root, errors);
287
+
288
+ // A workflow this collector could not read may hold the very thing each
289
+ // indicator looks for, so `miss` over an incomplete inventory is a clean
290
+ // verdict on evidence that was never examined. collector_errors are advisory
291
+ // and change no verdict on their own; the gap has to reach the signal. A hit
292
+ // stands on what WAS read and is unaffected.
293
+ const workflowScanIncomplete = errors.some(
294
+ (e) => e.artifact_id === "workflow-yaml-inventory");
295
+ const oidcScanIncomplete = errors.some(
296
+ (e) => e.artifact_id === "oidc-trust-policy-inventory");
297
+ const verdict = (found, incomplete) => (found ? "hit" : incomplete ? "inconclusive" : "miss");
318
298
 
319
299
  const signal_overrides = {
320
- "workflow-injection-sink": aggregateHits["workflow-injection-sink"].length > 0 ? "hit" : "miss",
321
- "pull-request-target-with-pr-checkout": aggregateHits["pull-request-target-with-pr-checkout"].length > 0 ? "hit" : "miss",
322
- "actions-floating-tag-pin": aggregateHits["actions-floating-tag-pin"].length > 0 ? "hit" : "miss",
323
- "secret-exposed-to-fork-pr": aggregateHits["secret-exposed-to-fork-pr"].length > 0 ? "hit" : "miss",
324
- "wildcarded-oidc-sub-claim": oidcWildcards.length > 0 ? "hit" : "miss",
300
+ "workflow-injection-sink": verdict(aggregateHits["workflow-injection-sink"].length > 0, workflowScanIncomplete),
301
+ "pull-request-target-with-pr-checkout": verdict(aggregateHits["pull-request-target-with-pr-checkout"].length > 0, workflowScanIncomplete),
302
+ "actions-floating-tag-pin": verdict(aggregateHits["actions-floating-tag-pin"].length > 0, workflowScanIncomplete),
303
+ "secret-exposed-to-fork-pr": verdict(aggregateHits["secret-exposed-to-fork-pr"].length > 0, workflowScanIncomplete),
304
+ "wildcarded-oidc-sub-claim": verdict(oidcWildcards.length > 0, oidcScanIncomplete),
325
305
  };
326
306
 
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.
307
+ // File locations for every indicator flipped to "hit", so a SARIF result points
308
+ // at the YAML or trust JSON behind it; without a line it surfaces as file-level.
332
309
  const evidence_locations = {};
333
310
  const evidenceSources = { ...aggregateHits, "wildcarded-oidc-sub-claim": oidcWildcards };
334
311
  for (const [id, list] of Object.entries(evidenceSources)) {
@@ -340,14 +317,20 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
340
317
 
341
318
  const artifacts = {
342
319
  "workflow-yaml-inventory": {
343
- value: workflows.length ? workflows.map(w => w.rel).join(", ") : "no workflow files found at cwd",
344
- captured: true,
320
+ value: workflowScanIncomplete
321
+ ? `${workflows.length} workflow(s) read, at least one skipped — inventory incomplete, see collector_errors`
322
+ : workflows.length ? workflows.map(w => w.rel).join(", ") : "no workflow files found at cwd",
323
+ captured: !workflowScanIncomplete,
324
+ ...(workflowScanIncomplete ? { reason: "one or more workflow files or the workflow directory could not be read" } : {}),
345
325
  },
346
326
  "oidc-trust-policy-inventory": {
347
- value: oidcWildcards.length
348
- ? `${oidcWildcards.length} wildcarded sub-claim(s): ${oidcWildcards.map(f => f.file).join(", ")}`
349
- : "no wildcarded OIDC sub-claim found in infra / terraform / policies",
350
- captured: true,
327
+ value: oidcScanIncomplete
328
+ ? `${oidcWildcards.length} wildcarded sub-claim(s) found, at least one policy file skipped — inventory incomplete, see collector_errors`
329
+ : oidcWildcards.length
330
+ ? `${oidcWildcards.length} wildcarded sub-claim(s): ${oidcWildcards.map(f => f.file).join(", ")}`
331
+ : "no wildcarded OIDC sub-claim found in infra / terraform / policies",
332
+ captured: !oidcScanIncomplete,
333
+ ...(oidcScanIncomplete ? { reason: "one or more trust-policy files or directories could not be read" } : {}),
351
334
  },
352
335
  "actions-sha-pinning": {
353
336
  value: `${aggregateHits["actions-floating-tag-pin"].length} non-SHA third-party uses: reference(s) across ${workflows.length} workflow(s)`,
@@ -374,20 +357,14 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
374
357
  };
375
358
 
376
359
  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).
360
+ // operator-owns-ci-fleet requires explicit opt-in through `--attest-ownership`:
361
+ // pointing collect at any cwd does NOT attest ownership of that fleet's CI
362
+ // authorization scope, and the playbook gates the precondition `on_fail: halt`.
388
363
  precondition_checks: {
389
364
  "cwd-is-repo": true,
390
- "ci-config-readable": true,
365
+ // An attestation that the CI configuration was readable, so it cannot
366
+ // stay true over a file this collector failed to open.
367
+ "ci-config-readable": !workflowScanIncomplete,
391
368
  "operator-owns-ci-fleet": args.attestOwnership === true || args["attest-ownership"] === true,
392
369
  },
393
370
  artifacts,