@blamejs/exceptd-skills 0.19.34 → 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 (37) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/bin/exceptd.js +0 -5
  3. package/data/_indexes/_meta.json +2 -2
  4. package/lib/collectors/ai-api.js +150 -22
  5. package/lib/collectors/cicd-pipeline-compromise.js +73 -28
  6. package/lib/collectors/library-author.js +141 -5
  7. package/lib/collectors/sbom.js +96 -12
  8. package/lib/collectors/scan-excludes.js +3 -2
  9. package/lib/cve-regression-watcher.js +0 -3
  10. package/lib/framework-gap.js +5 -0
  11. package/lib/lint-skills.js +24 -4
  12. package/lib/playbook-runner.js +68 -14
  13. package/lib/prefetch.js +3 -2
  14. package/lib/refresh-external.js +0 -6
  15. package/lib/refresh-network.js +3 -4
  16. package/lib/scoring.js +8 -1
  17. package/lib/ttp-mapper.js +14 -3
  18. package/lib/upstream-check-cli.js +26 -1
  19. package/lib/validate-cve-catalog.js +9 -2
  20. package/lib/validate-playbooks.js +9 -11
  21. package/manifest.json +53 -53
  22. package/orchestrator/index.js +0 -1
  23. package/package.json +1 -1
  24. package/sbom.cdx.json +78 -78
  25. package/scripts/audit-perf.js +24 -13
  26. package/scripts/builders/theater-fingerprints.js +9 -4
  27. package/scripts/check-agents-md-collectors.js +15 -3
  28. package/scripts/check-codebase-patterns.js +16 -3
  29. package/scripts/check-manifest-snapshot.js +49 -8
  30. package/scripts/check-test-coverage.js +17 -1
  31. package/scripts/refresh-mitre-atlas.js +5 -1
  32. package/scripts/refresh-mitre-ics-attack.js +5 -1
  33. package/scripts/refresh-rfc-index.js +6 -1
  34. package/scripts/refresh-upstream-catalogs.js +25 -13
  35. package/scripts/release.js +0 -2
  36. package/scripts/run-e2e-scenarios.js +2 -2
  37. package/scripts/verify-shipped-tarball.js +0 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.19.35 — 2026-08-25
4
+
5
+ A scan that could not read something no longer reports that it found nothing there. The evidence collectors treated an unreadable file the same as an absent one: a credential store over the size cap, a workflow file without read permission, a lockfile that failed to parse all produced the output of a clean scan. A credential store that exists but went unread now reports its indicator as inconclusive rather than a miss, and its artifact as uncaptured with the reason attached. An unparseable Google Cloud credentials file reports the same way, since a parse that failed says nothing about what the file holds. A file read successfully but empty stays a completed scan, because the collector did read it and there was nothing in it — the two cases were previously indistinguishable, and both read as clean.
6
+
7
+ The same failure closed in the pipeline collector, where it ran deeper. A workflow file too large or unreadable was skipped in silence, and every indicator it would have flipped stayed at miss. Read, parse and directory-walk failures now travel to the operator as warnings — but a warning changes no verdict on its own, so the gap reaches the indicators too: where the workflow or trust-policy inventory is incomplete, the indicators drawn from it report inconclusive rather than a clean miss, the inventory is marked uncaptured, and the readability precondition no longer attests a file the collector could not open. A repository whose one vulnerable workflow was the file that could not be read was previously reported clean.
8
+
9
+ Release workflows that resolve dependencies at build time are detected for pip, pnpm and Bundler alongside npm and cargo, so a Python or Ruby publisher no longer reads as clean. Each install command is judged on its own flags: a hash-pinned install no longer vouches for an unpinned one elsewhere in the same file, and commands chained on one line or split across lines with a trailing backslash are read as the commands they are. The requirement file is recognised whether it is attached to the short option or separated from it, and an install is not reported when the file it names is itself hash-pinned, since one hashed requirement puts pip into hash-checking mode for all of them. A bare `pnpm install` is no longer reported, because pnpm enforces the frozen lockfile whenever CI is set; only an explicit opt-out is. Bundler's job-wide frozen setting suppresses the finding only when it is switched on — `bundle config set frozen false` named the setting and disabled it, and was read as though it enabled it. A requirement file is resolved against the job's working directory as well as the repository root, so a hash-pinned file in a subdirectory is no longer reported as unpinned; where copies resolve in several directories, every one of them has to be pinned. Findings point at the line their command starts on.
10
+
11
+ Capping an upstream-catalog refresh now bounds every source. The RFC index, ATLAS and ICS-ATT&CK commands accepted the cap and discarded it, so a run asked to import a bounded batch imported the whole upstream index — and the RFC index is an order of magnitude larger than any other source the refresh reads. The cap bounds new additions only; context fields on rows already curated are still completed. The ICS-ATT&CK refresh also stops importing a cross-listed enterprise technique it cannot assign an ICS tactic to. Those rows arrived with an empty tactic and were counted as missing context by the next audit, so the import spent the gap budget on itself; they are skipped now, and the run reports how many and which.
12
+
13
+ A mistyped flag on the upstream freshness probe is refused with a non-zero exit instead of being ignored. A misspelled air-gap flag previously left the check running as though nothing had been passed, so a run believed to be offline reached the network.
14
+
15
+ Diagnostics no longer disappear when output is piped. The catalog validator's enum-mismatch and entry-count-drift messages and the skill linter's help and unknown-argument text were written to a stream the process could exit before draining, which a terminal hid and a CI log collector did not.
16
+
17
+ A malformed analyze result no longer aborts the closing phase. The evidence bundle, notification deadlines, exception language and downstream chaining are still produced, and the shape problem is reported alongside them naming the part of the result that was wrong. That holds for a single unusable entry among good ones as well as for a malformed result overall: the unusable entries are dropped and counted, the rest still reach the advisory. Catalog scoring validation likewise reports a null or non-object entry and continues through the rest of the catalog rather than stopping at the first one, and asking for the documented gaps behind an attack pattern without supplying a gap catalog returns the same answer an empty one gives instead of raising.
18
+
19
+ The manifest snapshot gate reports where it used to crash or wave through. A baseline written before a surface field existed ended the run with a stack trace on precisely the case the gate exists to report; it is now recorded as an additive change. A corrupt value where a list belongs stops the gate with the skill and field named, rather than reporting that skill's entire surface as newly added and passing.
20
+
3
21
  ## 0.19.34 — 2026-08-23
4
22
 
5
23
  Comment volume across the shipped JavaScript falls from 16,627 lines to 5,585, against 36,875 lines of code that are byte-for-byte unchanged. What went is the reasoning that produced a line rather than the constraint on it: pull-request references, version histories, "this used to", and module docblocks that toured a file's exports above the exports themselves. What stayed is what the code cannot say for itself — units and nullability on an exported function, the specification clause an emitter satisfies, the trap whose deletion invites a specific dangerous edit, and the cross-file coupling no grep will surface.
package/bin/exceptd.js CHANGED
@@ -544,11 +544,7 @@ function main() {
544
544
  // Strip only `--prefetch` (the alias). `--no-network` must reach prefetch.js:
545
545
  // dropping it leaves prefetch fetching, so a cold cache hits every upstream.
546
546
  effectiveCmd = "prefetch";
547
- const wantedNoNetwork = rest.includes("--no-network");
548
547
  effectiveRest = rest.filter(a => a !== "--prefetch");
549
- if (wantedNoNetwork && !effectiveRest.includes("--no-network")) {
550
- // Unreachable while the filter above spares --no-network.
551
- }
552
548
  } else if (cmd === "refresh" && rest.includes("--indexes-only")) {
553
549
  effectiveCmd = "build-indexes";
554
550
  effectiveRest = rest.filter(a => a !== "--indexes-only");
@@ -6937,7 +6933,6 @@ function cmdAiRun(runner, args, runOpts, pretty) {
6937
6933
  if (!handled) {
6938
6934
  // stdin closed with no evidence event. Before erroring, try the raw text as a
6939
6935
  // bare submission — `echo '{...}' | ai-run secrets` pipes the body unwrapped.
6940
- const raw = (process.stdin._consumed || "") || buf;
6941
6936
  const allText = process.stdin._allText;
6942
6937
  if (allText && allText.trim()) {
6943
6938
  try {
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "schema_version": "1.1.0",
3
- "generated_at": "2026-08-23T11:56:28.977Z",
3
+ "generated_at": "2026-08-25T14:19:57.183Z",
4
4
  "generator": "scripts/build-indexes.js",
5
5
  "source_count": 64,
6
6
  "source_hashes": {
7
- "manifest.json": "592a1040e06e609cfc5fa7b690343735f4ad2f52ed3607c526b9ec057f9373eb",
7
+ "manifest.json": "ec8d77f3c80b458eeb5901346be0e0f8fde9903da2de9ed85740372beddb8261",
8
8
  "README.md": "bc575c178c21e3d07f1491710e24b26cad3a3cee012c43641b6ea7e83468e64e",
9
9
  "data/atlas-ttps.json": "a14089671efcb4f06dd0f82d688fe49276c1370e167e4483d76e9c0a40ef41eb",
10
10
  "data/attack-techniques.json": "2179122ff8347bc691f245d5fd2601ca81fd1d6f790feae44abca86d3c08f865",
@@ -13,16 +13,40 @@ const os = require("node:os");
13
13
 
14
14
  const COLLECTOR_ID = "ai-api";
15
15
 
16
- 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
+ };
17
35
  let fd;
18
36
  try {
19
37
  fd = fs.openSync(full, "r");
20
38
  const s = fs.fstatSync(fd);
21
- if (s.size > max) return null;
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
+ }
22
43
  // readFileSync(fd) loops to EOF; a single readSync can return short on a
23
44
  // network or FUSE fd. Reading the open fd keeps fstat-then-read TOCTOU-free.
24
45
  return fs.readFileSync(fd, "utf8");
25
- } catch { return null; }
46
+ } catch (e) {
47
+ note("read_failed", e.message);
48
+ return null;
49
+ }
26
50
  finally { if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* non-fatal */ } } }
27
51
  }
28
52
 
@@ -30,6 +54,27 @@ function fileExists(full) {
30
54
  try { return fs.statSync(full).isFile(); } catch { return false; }
31
55
  }
32
56
 
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
+
33
78
  // Cleartext key exports: `export VAR=value`, `VAR=value`, fish `set -gx VAR value`.
34
79
  const AI_KEY_PATTERNS = [
35
80
  { id: "openai", re: /(?:^|\n)\s*(?:export\s+|set\s+-gx\s+)?OPENAI_API_KEY\s*[= ]\s*['"]?sk-[A-Za-z0-9_-]{20,}/m },
@@ -129,7 +174,12 @@ function parseGcloudAdc(content) {
129
174
  privateKey: typeof j?.private_key === "string" ? j.private_key : "",
130
175
  clientEmail: typeof j?.client_email === "string" ? j.client_email : "",
131
176
  };
132
- } 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
+ }
133
183
  }
134
184
 
135
185
  function parseKubeStaticToken(content) {
@@ -170,7 +220,17 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
170
220
  for (const e of fs.readdirSync(fishConfD)) {
171
221
  if (e.endsWith(".fish")) shellRcs.push(path.join(fishConfD, e));
172
222
  }
173
- } 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
+ }
174
234
 
175
235
  const dotfileKeys = [
176
236
  ".openai", ".anthropic",
@@ -180,13 +240,24 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
180
240
  path.join(".config", "azure-openai"),
181
241
  ].map(rel => path.join(home, rel));
182
242
 
183
- 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
+ ];
184
250
  const cleartextHitsByFile = {};
251
+ const cleartextUnread = [];
185
252
  let cleartextFp = null;
186
- for (const p of allKeyCarriers) {
253
+ for (const { path: p, artifact_id } of allKeyCarriers) {
187
254
  if (!fileExists(p)) continue;
188
- const c = readSafe(p);
189
- 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; }
190
261
  const hits = scanShellRc(c);
191
262
  if (hits.length > 0) {
192
263
  cleartextHitsByFile[path.relative(home, p)] = hits;
@@ -200,24 +271,64 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
200
271
  const cleartextAnyHit = Object.keys(cleartextHitsByFile).length > 0;
201
272
 
202
273
  const awsCredsPath = path.join(home, ".aws", "credentials");
203
- 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;
204
280
  const awsParsed = parseAwsCredentials(awsCredsContent);
205
281
  const longLivedAws = awsParsed.staticProfiles.length > 0;
206
282
 
207
283
  const gcloudAdcPath = path.join(home, ".config", "gcloud", "application_default_credentials.json");
208
- 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;
209
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
+ }
210
298
 
211
299
  const kubeCfgPath = (env && env.KUBECONFIG) || path.join(home, ".kube", "config");
212
- 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;
213
314
  const kubeParsed = parseKubeStaticToken(kubeContent);
214
315
  const kubeStaticToken = kubeParsed.found;
215
316
 
317
+ const awsState = storeState(awsCredsExists, awsCredsContent);
318
+ const gcloudState = storeState(gcloudAdcExists, gcloudContent);
319
+ const kubeState = storeState(kubeCfgExists, kubeContent);
320
+
216
321
  const signal_overrides = {
217
- "cleartext-api-key-in-dotfile": cleartextAnyHit ? "hit" : "miss",
218
- "long-lived-aws-keys": longLivedAws ? "hit" : "miss",
219
- "gcp-service-account-json": gcloudParsed.hasServiceAccount ? "hit" : "miss",
220
- "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),
221
332
  };
222
333
 
223
334
  // Per-indicator __fp_checks attestation. Path checks hold because every store
@@ -271,15 +382,32 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
271
382
  value: dotfileKeys.filter(p => fileExists(p)).map(p => path.relative(home, p)).join(", ") || "no AI vendor dotfile carriers found at the canonical paths",
272
383
  captured: true,
273
384
  },
274
- "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
275
392
  ? { value: `${awsParsed.staticProfiles.length} long-lived profile(s): ${awsParsed.staticProfiles.join(", ") || "none"}`, captured: true }
276
- : { value: "~/.aws/credentials absent", captured: true },
277
- "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
278
400
  ? { value: `application_default_credentials.json present; service_account=${gcloudParsed.hasServiceAccount}`, captured: true }
279
- : { value: "no gcloud ADC at the canonical path", captured: true, reason: "credentials.db / legacy_credentials/*/adc.json inspection deferred (no stdlib SQLite reader)" },
280
- "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
281
407
  ? { value: `kubeconfig present; static_token=${kubeStaticToken}`, captured: true }
282
- : { 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 },
283
411
  "ai-sdk-inventory": {
284
412
  value: "skipped — npm/pip global listing deferred to operator/AI evidence",
285
413
  captured: false,
@@ -17,40 +17,57 @@ const OIDC_WALK_EXCLUDES = codeExcludeSet();
17
17
 
18
18
  const COLLECTOR_ID = "cicd-pipeline-compromise";
19
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.
20
23
  function readSafe(p, max = 512 * 1024) {
21
24
  let fd;
22
25
  try {
23
26
  fd = fs.openSync(p, "r");
24
27
  const s = fs.fstatSync(fd);
25
- if (s.size > max) return null;
28
+ if (s.size > max) return { skipped: "file_too_large", reason: `${s.size} bytes exceeds the ${max}-byte scan limit; not scanned` };
26
29
  // readFileSync(fd) loops read() to EOF; a single readSync can return short on
27
30
  // a network or FUSE fd. Reading the open fd keeps fstat-then-read TOCTOU-free.
28
- return fs.readFileSync(fd, "utf8");
29
- } catch { return null; }
31
+ return { text: fs.readFileSync(fd, "utf8") };
32
+ } catch (e) { return { skipped: "read_error", reason: (e && e.message) || String(e) }; }
30
33
  finally { if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* non-fatal */ } } }
31
34
  }
32
35
 
33
- 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 = []) {
34
45
  const out = [];
35
46
  const wfDir = path.join(root, ".github", "workflows");
36
47
  if (fs.existsSync(wfDir)) {
37
48
  let entries;
38
49
  try { entries = fs.readdirSync(wfDir, { withFileTypes: true }); }
39
- catch { entries = []; }
50
+ catch (e) {
51
+ entries = [];
52
+ errors.push({ artifact_id: "workflow-yaml-inventory", kind: "readdir_failed", reason: `.github/workflows: ${e.message}` });
53
+ }
40
54
  for (const e of entries) {
41
55
  if (!e.isFile()) continue;
42
56
  if (!/\.(ya?ml)$/i.test(e.name)) continue;
43
57
  const full = path.join(wfDir, e.name);
44
- const content = readSafe(full);
45
- 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));
46
62
  }
47
63
  }
48
64
  // Also recognise the most common single-file CI YAMLs at repo root.
49
65
  for (const top of [".gitlab-ci.yml", ".circleci/config.yml"]) {
50
66
  const full = path.join(root, top);
51
67
  if (fs.existsSync(full)) {
52
- const content = readSafe(full);
53
- 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));
54
71
  }
55
72
  }
56
73
  return out;
@@ -184,7 +201,7 @@ function scanWorkflow(content, rel) {
184
201
  return hits;
185
202
  }
186
203
 
187
- function scanOidcPolicies(root) {
204
+ function scanOidcPolicies(root, errors = []) {
188
205
  // Walks the infra dirs to depth 4 for *.json naming
189
206
  // token.actions.githubusercontent.com with a wildcarded sub-claim.
190
207
  const rootDirs = ["infra", "terraform", "policies", ".aws", ".github"].map(d => path.join(root, d));
@@ -193,7 +210,14 @@ function scanOidcPolicies(root) {
193
210
  if (depth > 4 || finds.length > 5) return;
194
211
  let entries;
195
212
  try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
196
- 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
+ }
197
221
  for (const e of entries) {
198
222
  if (OIDC_WALK_EXCLUDES.has(e.name)) continue;
199
223
  const full = path.join(dir, e.name);
@@ -204,15 +228,17 @@ function scanOidcPolicies(root) {
204
228
  continue;
205
229
  }
206
230
  if (!e.isFile() || !/\.json$/i.test(e.name)) continue;
207
- const text = readSafe(full);
208
- if (!text) continue;
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;
209
235
  // Each pattern is bound to the leading `"` of the JSON key, so a lookalike
210
236
  // issuer like `"eviltoken.actions…"` cannot match.
211
237
  const subWildcard =
212
238
  /"token\.actions\.githubusercontent\.com:sub"\s*:\s*"\*"/.test(text) ||
213
239
  /"token\.actions\.githubusercontent\.com:sub"\s*:\s*"repo:\*[^"]*"/.test(text) ||
214
240
  /"token\.actions\.githubusercontent\.com:sub"\s*:\s*"repo:[^"]*\/\*:[^"]*"/.test(text);
215
- 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" });
216
242
  }
217
243
  }
218
244
  for (const rd of rootDirs) if (fs.existsSync(rd)) walk(rd, 0);
@@ -245,7 +271,7 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
245
271
  };
246
272
  }
247
273
 
248
- const workflows = walkWorkflows(root);
274
+ const workflows = walkWorkflows(root, errors);
249
275
  const aggregateHits = {
250
276
  "workflow-injection-sink": [],
251
277
  "pull-request-target-with-pr-checkout": [],
@@ -257,14 +283,25 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
257
283
  for (const [k, v] of Object.entries(h)) aggregateHits[k].push(...v);
258
284
  }
259
285
 
260
- 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");
261
298
 
262
299
  const signal_overrides = {
263
- "workflow-injection-sink": aggregateHits["workflow-injection-sink"].length > 0 ? "hit" : "miss",
264
- "pull-request-target-with-pr-checkout": aggregateHits["pull-request-target-with-pr-checkout"].length > 0 ? "hit" : "miss",
265
- "actions-floating-tag-pin": aggregateHits["actions-floating-tag-pin"].length > 0 ? "hit" : "miss",
266
- "secret-exposed-to-fork-pr": aggregateHits["secret-exposed-to-fork-pr"].length > 0 ? "hit" : "miss",
267
- "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),
268
305
  };
269
306
 
270
307
  // File locations for every indicator flipped to "hit", so a SARIF result points
@@ -280,14 +317,20 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
280
317
 
281
318
  const artifacts = {
282
319
  "workflow-yaml-inventory": {
283
- value: workflows.length ? workflows.map(w => w.rel).join(", ") : "no workflow files found at cwd",
284
- 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" } : {}),
285
325
  },
286
326
  "oidc-trust-policy-inventory": {
287
- value: oidcWildcards.length
288
- ? `${oidcWildcards.length} wildcarded sub-claim(s): ${oidcWildcards.map(f => f.file).join(", ")}`
289
- : "no wildcarded OIDC sub-claim found in infra / terraform / policies",
290
- 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" } : {}),
291
334
  },
292
335
  "actions-sha-pinning": {
293
336
  value: `${aggregateHits["actions-floating-tag-pin"].length} non-SHA third-party uses: reference(s) across ${workflows.length} workflow(s)`,
@@ -319,7 +362,9 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
319
362
  // authorization scope, and the playbook gates the precondition `on_fail: halt`.
320
363
  precondition_checks: {
321
364
  "cwd-is-repo": true,
322
- "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,
323
368
  "operator-owns-ci-fleet": args.attestOwnership === true || args["attest-ownership"] === true,
324
369
  },
325
370
  artifacts,