@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,31 +1,10 @@
1
1
  "use strict";
2
2
 
3
3
  /**
4
- * lib/collectors/library-author.js
5
- *
6
- * Companion collector for the `library-author` playbook. Audits a
7
- * publisher-side repository for supply-chain posture markers:
8
- * SECURITY.md / security.txt presence, sbom + signature pair,
9
- * publish-workflow shape (id-token write, action-ref pinning, frozen
10
- * install), package.json provenance opt-in, vendor-tree provenance,
11
- * lockfile integrity.
12
- *
13
- * Skipped indicators (require external API or runtime data, left
14
- * unflipped so the runner returns inconclusive rather than a forced
15
- * miss):
16
- *
17
- * tag-protection-absent GitHub branch-protection API
18
- * private-vuln-reporting-disabled GitHub repo-settings API
19
- * no-rekor-entry-for-latest-release sigstore lookup
20
- * release-tag-not-signed git tag --verify on each release
21
- * release-signed-with-personal-gpg-key GPG identity policy
22
- * sbom-regenerated-at-request-time timing-based; needs build
23
- * pipeline trace
24
- * skill-signing-but-verification-not-gated project-internal CI
25
- * gate inspection
26
- * ssdf-claimed-cra-not-ready operator interview shape
27
- * gha-workflow-script-injection-sink complex pattern; future pass
28
- *
4
+ * Companion collector for the `library-author` playbook: audits a publisher-side
5
+ * repository for supply-chain posture markers. Indicators needing an external API
6
+ * or a build-pipeline trace are left unflipped, so the runner returns
7
+ * inconclusive rather than a forced miss.
29
8
  * Interface: see lib/collectors/README.md
30
9
  */
31
10
 
@@ -35,11 +14,8 @@ const { codeExcludeSet, isLinkedWorktreeDir, buildEvidenceLocations } = require(
35
14
 
36
15
  const COLLECTOR_ID = "library-author";
37
16
 
38
- const DEFAULT_MAX_DEPTH = 6;
39
- // Shared code-scope exclusions (dependency caches, build output, VCS +
40
- // agent/editor scratch including `.claude/`). Applied to the vendor-tree
41
- // walk so a linked worktree or dependency cache nested under `vendor/`
42
- // isn't mistaken for vendored provenance state.
17
+ // Applied to the vendor-tree walk, so a cache nested under `vendor/` is not
18
+ // mistaken for vendored provenance state.
43
19
  const DEFAULT_EXCLUDES = codeExcludeSet();
44
20
 
45
21
  function readSafe(full, max = 512 * 1024) {
@@ -48,10 +24,8 @@ function readSafe(full, max = 512 * 1024) {
48
24
  fd = fs.openSync(full, "r");
49
25
  const s = fs.fstatSync(fd);
50
26
  if (s.size > max) return null;
51
- // readFileSync(fd) loops read() to EOF — a single readSync may return
52
- // fewer than s.size bytes on network/FUSE/sync-backed fds, which would
53
- // leave the buffer tail NUL-filled and silently drop trailing content.
54
- // Reading via the already-open fd keeps the fstat-then-read TOCTOU-free.
27
+ // readFileSync(fd), never a single readSync: a short read on a network fd
28
+ // NUL-fills the tail. Reading the open fd is also TOCTOU-free.
55
29
  return fs.readFileSync(fd, "utf8");
56
30
  } catch { return null; }
57
31
  finally { if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* non-fatal */ } } }
@@ -74,53 +48,34 @@ function walkWorkflows(root) {
74
48
  }
75
49
 
76
50
  function looksLikePublishWorkflow(name, content) {
77
- // Heuristic: filename starts with a publish-prefix, OR the body
78
- // invokes a publish command. Conservative — broad signals like
79
- // `id-token: write`, bare `sigstore/cosign-installer`, or `cosign
80
- // sign-blob` (commonly used in test workflows) DO NOT count; they
81
- // produced false positives on verification + e2e workflows that
82
- // happen to share signing infrastructure.
51
+ // Broad signals — `id-token: write`, a cosign installer — must not count:
52
+ // verification and e2e workflows share the same signing infrastructure.
83
53
  const lower = name.toLowerCase();
84
54
 
85
- // Verification / test / e2e workflows are never publish, regardless
86
- // of body content. Hard demotion.
87
55
  if (/^(test|verify|validate|e2e|kind|check|conformance|coverage)/.test(lower)) return false;
88
56
 
89
- // Filename prefix signals: only the canonical publish words.
90
57
  if (/^(release|publish|deploy|promote|tag-and-release)/.test(lower)) return true;
91
58
 
92
- // Strip YAML line-comments before the content-shape probes so a comment that
93
- // merely MENTIONS a publish verb (e.g. a ci.yml line "# … matches the npm
94
- // publish workflow's depth.") can't masquerade as a publish command. The
95
- // filename-prefix checks above are unaffected. A `#` inside a quoted string
96
- // is rare in workflow YAML and not load-bearing for these command probes
97
- // (stripping can only REMOVE comment text, never create a false match).
59
+ // Comment-stripped, so a `#` line mentioning a publish verb cannot masquerade
60
+ // as one. Stripping can only remove text, never create a match.
98
61
  const code = stripYamlComments(content);
99
62
 
100
- // Explicit publish-shape commands — these are commitments to push
101
- // artifacts, not setup / scaffolding.
102
63
  if (/\bnpm\s+publish\b/.test(code)) return true;
103
64
  if (/pypa\/gh-action-pypi-publish/.test(code)) return true;
104
65
  if (/\bcargo\s+publish\b/.test(code)) return true;
105
66
  if (/goreleaser/.test(code)) return true;
106
67
  if (/softprops\/action-gh-release/.test(code)) return true;
107
- // `ko build` builds AND pushes container images by default (unless
108
- // --push=false). All four sub-commands push to a registry.
68
+ // All four `ko` sub-commands push to a registry by default, `build` included.
109
69
  if (/\bko\s+(?:publish|build|apply|resolve)\b/.test(code)) return true;
110
- // `cosign sign` (container signing — publish-shape). Exclude
111
- // `cosign sign-blob` which signs arbitrary artifacts and shows
112
- // up in verification / e2e tests as often as in publishes.
70
+ // The `(?!-)` keeps out `cosign sign-blob`, as common in verification workflows.
113
71
  if (/\bcosign\s+sign\b(?!-)/.test(code)) return true;
114
72
  if (/\bcrane\s+(?:push|copy|append)\b/.test(code)) return true;
115
73
  if (/\boras\s+(?:push|copy)\b/.test(code)) return true;
116
74
  if (/docker\/build-push-action/.test(code)) return true;
117
75
  if (/\bdocker\s+push\b/.test(code)) return true;
118
76
 
119
- // Registry login actions — when a workflow logs into a registry,
120
- // it's almost certainly publishing. Picks up Makefile / opaque
121
- // publish paths (cosign's build.yaml uses make sign-ci-containers
122
- // after docker/login-action). Does NOT match verification tests
123
- // because those don't authenticate to a registry.
77
+ // Registry login stands in for opaque publish paths (a Makefile target run
78
+ // after docker/login-action); verification workflows never authenticate.
124
79
  if (/docker\/login-action/.test(code)) return true;
125
80
  if (/google-github-actions\/auth/.test(code) && /gcloud auth configure-docker/.test(code)) return true;
126
81
  if (/aws-actions\/configure-aws-credentials/.test(code) && /amazon-ecr/.test(code)) return true;
@@ -128,33 +83,117 @@ function looksLikePublishWorkflow(name, content) {
128
83
  return false;
129
84
  }
130
85
 
131
- // `id-token: write` grants the OIDC token used for npm provenance and
132
- // sigstore keyless signing. It is valid at workflow-level permissions or at
133
- // a specific job's `permissions:` block, so any occurrence of the
134
- // `id-token: write` token within the publish workflow file counts as the
135
- // capability being present — this is what lets a job-scoped declaration
136
- // (not just a workflow-level one) satisfy the check. The match is
137
- // deliberately scoped to the file being scanned, not repo-wide: a sibling
138
- // workflow's OIDC does not grant it to the publish job.
86
+ // `id-token: write` is valid at workflow OR job scope, so any occurrence counts.
87
+ // Scoped to the one file passed in: a sibling workflow's OIDC does not grant it.
139
88
  function hasIdTokenWriteAnyScope(content) {
140
89
  return /\bid-token:\s*write\b/.test(content);
141
90
  }
142
91
 
143
- // Strip YAML line-comments so a `#`-commented MENTION of a publish-shape
144
- // token / command / runner is not read as the real thing. The classifier
145
- // (looksLikePublishWorkflow) already does this; the indicator probes — and the
146
- // provenance / SBOM-capability probes in collect() — must use the same view,
147
- // or a comment produces a false (often deterministic) hit, and in the
148
- // provenance direction a commented `--provenance` would suppress a real gap
149
- // (a false negative on a security-relevant posture check).
92
+ // Every probe that reads workflow bodies must use this view: a commented publish
93
+ // token otherwise produces a false hit, and a commented `--provenance` hides a gap.
150
94
  function stripYamlComments(content) {
151
95
  return content.replace(/#.*$/gm, "");
152
96
  }
153
97
 
154
- function scanPublishWorkflow(content, rel) {
155
- // Whole-content probes below run against a comment-stripped view. The
156
- // `uses:` line scan stays on the raw lines — its anchored regex already
157
- // rejects `#`-prefixed lines, so a commented `uses:` cannot match.
98
+ // Splits comment-stripped workflow text into individual shell commands, so a
99
+ // flag on one install cannot vouch for another. A workflow that hash-pins its
100
+ // build requirements and then resolves its runtime ones must still report the
101
+ // unpinned command, which a whole-file flag test would suppress.
102
+ function shellCommands(code) {
103
+ const out = [];
104
+ const lines = code.split(/\r?\n/);
105
+ for (let i = 0; i < lines.length; i++) {
106
+ // A real `run:` line is never KBs long; skipping overlong ones keeps a
107
+ // crafted whitespace run from driving regex backtracking.
108
+ if (lines[i].length > 4096) continue;
109
+ // A backslash-continued command is ONE command: a `pip install \` whose
110
+ // `-r` sits on the next line is otherwise two fragments that each match
111
+ // nothing. The continuation lines are consumed, never re-emitted.
112
+ const startLine = i + 1;
113
+ let raw = lines[i];
114
+ while (/\\[ \t]*$/.test(raw) && i + 1 < lines.length &&
115
+ lines[i + 1].length <= 4096 && raw.length <= 8192) {
116
+ raw = `${raw.replace(/\\[ \t]*$/, " ")}${lines[i + 1].trim()}`;
117
+ i++;
118
+ }
119
+ for (const seg of raw.split(/&&|\|\||[;|]/)) {
120
+ const cmd = seg.trim();
121
+ // Comment stripping preserves line breaks, so the index still addresses
122
+ // the line the command STARTS on in the file an operator opens.
123
+ if (cmd) out.push({ cmd, line: startLine });
124
+ }
125
+ }
126
+ return out;
127
+ }
128
+
129
+ // Evidence snippets are read in a report, so a pathological one-line script
130
+ // cannot be pasted in whole.
131
+ function snippetOf(cmd, max = 200) {
132
+ return cmd.length > max ? `${cmd.slice(0, max)}…` : cmd;
133
+ }
134
+
135
+ // pip enters hash-checking mode as soon as ONE requirement carries a hash, so
136
+ // `pip install -r <file>` against a `pip-compile --generate-hashes` output is
137
+ // already reproducible. Resolves the named file one level only: a nested `-r`
138
+ // include is not followed, and an unreadable path counts as unhashed.
139
+ // pip accepts the requirement file attached (`-rreq.txt`) as well as separated
140
+ // (`-r req.txt`, `--requirement=req.txt`). One definition for both the detection
141
+ // predicate and the hash lookup: two regexes for one flag drift, and the pair
142
+ // that drifts silently is a command examined by neither.
143
+ // `(?:^|\s)-r` rather than `\b-r` so the `-r` inside `--requirement` is not read
144
+ // as the short form with `equirement...` as its filename.
145
+ const PIP_REQUIREMENT_RE = /(?:^|\s)(?:-r\s*=?\s*|--requirement[=\s]*)['"]?([^'"\s]+)/;
146
+
147
+ function pipRequirementTarget(cmd) {
148
+ const m = cmd.match(PIP_REQUIREMENT_RE);
149
+ return m ? m[1] : null;
150
+ }
151
+
152
+ // `working-directory:`, at job defaults or on a single step, is what a `run:`
153
+ // command resolves its relative paths against. Collected as a set of candidate
154
+ // bases rather than bound to the step that declared it: the scanner is
155
+ // line-based, so attributing one to a specific command would need the step
156
+ // nesting it does not track.
157
+ // Matches the block form and the flow form (`{ run: { working-directory: x } }`)
158
+ // alike. A key-shaped string inside a `run:` command matches too; that only adds
159
+ // a base that does not resolve, and an unresolvable base is skipped.
160
+ function workingDirectories(code) {
161
+ const dirs = [];
162
+ for (const m of code.matchAll(/working-directory:\s*['"]?([^'"\n,}]+?)['"]?\s*(?=$|[,}])/gm)) {
163
+ const d = m[1].trim();
164
+ if (d && d !== "." && !path.isAbsolute(d)) dirs.push(d);
165
+ }
166
+ return dirs;
167
+ }
168
+
169
+ // True only when every candidate that EXISTS is hash-pinned, and at least one
170
+ // does. Requiring all of them keeps a hashed copy in one working directory from
171
+ // vouching for an unhashed copy in another — over-suppression here reports an
172
+ // unpinned release as reproducible, which is the direction that costs something.
173
+ // A target that resolves nowhere stays unproven, so the caller still reports it.
174
+ function requirementsFileIsHashed(cmd, root, extraBases = []) {
175
+ if (!root) return false;
176
+ const target = pipRequirementTarget(cmd);
177
+ if (!target) return false;
178
+ if (path.isAbsolute(target)) return false;
179
+
180
+ let found = 0;
181
+ for (const base of [root, ...extraBases.map((d) => path.resolve(root, d))]) {
182
+ // Never read outside the scanned tree, whatever `../` the workflow names —
183
+ // for the base itself as well as the target resolved against it.
184
+ if (base !== root && !base.startsWith(root + path.sep)) continue;
185
+ const full = path.resolve(base, target);
186
+ if (full !== root && !full.startsWith(root + path.sep)) continue;
187
+ const body = readSafe(full);
188
+ if (body === null) continue;
189
+ found++;
190
+ if (!/--hash=/.test(body)) return false;
191
+ }
192
+ return found > 0;
193
+ }
194
+
195
+ function scanPublishWorkflow(content, rel, root) {
196
+ // Whole-content probes read `code`; the `uses:` scan below stays on raw lines.
158
197
  const code = stripYamlComments(content);
159
198
  const hits = {
160
199
  "publish-workflow-uses-static-token": [],
@@ -164,18 +203,10 @@ function scanPublishWorkflow(content, rel) {
164
203
  "publish-workflow-runs-on-self-hosted": [],
165
204
  };
166
205
 
167
- // static-token: workflow references a publish-credential secret
168
- // without a corresponding `id-token: write` permission (at any scope).
169
- // The predicate (per data/playbooks/library-author.json) lists
170
- // NPM_TOKEN / PYPI_TOKEN / CARGO_TOKEN / RUBYGEMS_API_KEY /
171
- // GEM_HOST_API_KEY; expand to cover the common variants for each
172
- // ecosystem.
206
+ // Widens the predicate in data/playbooks/library-author.json to the common
207
+ // token-name variant per ecosystem.
173
208
  const usesStaticToken = /\bsecrets\.(NPM_TOKEN|PYPI_TOKEN|PYPI_API_TOKEN|CARGO_TOKEN|CARGO_REGISTRY_TOKEN|RUBYGEMS_API_KEY|GEM_HOST_API_KEY|MAVEN_TOKEN|MAVEN_CENTRAL_TOKEN|GH_TOKEN)\b/.test(code);
174
- // OIDC is available when THIS publish file declares `id-token: write` at
175
- // any scope (workflow or job). Scoped to the file by design — a sibling
176
- // workflow's OIDC does not authenticate this publish job. Read from the
177
- // comment-stripped view so a commented `id-token: write` cannot falsely
178
- // satisfy the capability (which would suppress the static-token finding).
209
+ // Comment-stripped: a commented `id-token: write` would suppress the finding.
179
210
  const hasIdTokenWrite = hasIdTokenWriteAnyScope(code);
180
211
  if (usesStaticToken && !hasIdTokenWrite) {
181
212
  hits["publish-workflow-uses-static-token"].push({ file: rel, line: 0, snippet: "publish workflow uses a static long-lived token (NPM_TOKEN / PYPI / Cargo / Maven) without id-token: write for OIDC" });
@@ -184,20 +215,15 @@ function scanPublishWorkflow(content, rel) {
184
215
  hits["publish-workflow-no-id-token-write"].push({ file: rel, line: 0, snippet: "no id-token: write at any scope (workflow or job) — npm provenance / sigstore signing unavailable" });
185
216
  }
186
217
 
187
- // action-refs-mutable: any `uses: <action>@<ref>` where ref is NOT
188
- // a 40-char hex sha. Excludes `uses: ./local-action`.
218
+ // Mutable = any `uses: <action>@<ref>` whose ref is not a 40-char hex sha.
189
219
  const lines = content.split(/\r?\n/);
190
220
  for (let i = 0; i < lines.length; i++) {
191
221
  const line = lines[i];
192
- // A real `uses:` line is never multiple KB. Skip overlong lines so a
193
- // crafted whitespace run can't drive regex backtracking.
222
+ // A real `uses:` line is never KBs long; skipping overlong ones keeps a
223
+ // crafted whitespace run from driving regex backtracking.
194
224
  if (line.length > 4096) continue;
195
- // Allow optional leading `-` from a YAML list item: `- uses: ...`.
196
- // `^[ \t]*(?:-[ \t]*)?` anchors the indentation once, then an optional
197
- // `- ` list marker — no overlapping `\s*` runs that backtrack. Exclude `#`
198
- // from the capture and drop the end anchor (mirroring the cicd collector) so
199
- // a `uses: x@v1 # comment` line is still matched — the `$`-anchored pattern
200
- // silently missed every floating ref that carried a trailing YAML comment.
225
+ // No end anchor and `#` excluded from the capture, so `uses: x@v1 # comment`
226
+ // still matches. The indent and `- ` marker are each anchored once.
201
227
  const m = line.match(/^[ \t]*(?:-[ \t]*)?uses:\s*['"]?([^'"\s#]+)['"]?/);
202
228
  if (!m) continue;
203
229
  const ref = m[1];
@@ -210,17 +236,53 @@ function scanPublishWorkflow(content, rel) {
210
236
  }
211
237
  }
212
238
 
213
- // non-frozen-install: workflow uses `npm install` instead of `npm ci`,
214
- // or `pip install <pkg>` without `--require-hashes`, or `cargo
215
- // install` without `--locked`.
239
+ // npm alone stays a whole-file test, because its suppressor is a DIFFERENT
240
+ // command rather than a flag: `npm ci` elsewhere in the workflow is the
241
+ // frozen install, and the `npm install` line beside it is usually tooling.
216
242
  if (/\bnpm\s+install\b/.test(code) && !/\bnpm\s+ci\b/.test(code)) {
217
243
  hits["release-workflow-non-frozen-install"].push({ file: rel, line: 0, snippet: "publish workflow uses `npm install` rather than `npm ci` — lockfile is not enforced" });
218
244
  }
219
- if (/\bcargo\s+(?:build|install)\b/.test(code) && !/--locked\b/.test(code) && !/--frozen\b/.test(code)) {
220
- hits["release-workflow-non-frozen-install"].push({ file: rel, line: 0, snippet: "cargo build/install without --locked / --frozen" });
245
+
246
+ // Bundler's frozen mode can be set once for the job rather than per command
247
+ // (`bundle config set frozen/deployment`, or the BUNDLE_ env equivalents), so
248
+ // that suppressor is genuinely file-scoped; the flags below are not.
249
+ // The setting has to be turned ON. `bundle config set frozen false` names the
250
+ // key and disables it, so matching the key alone reads an explicit opt-out as
251
+ // the protection it opts out of — the env form already required a value.
252
+ const bundlerFrozenConfig =
253
+ /\bbundle\s+config\b[^\n]*\b(?:frozen|deployment)\s+['"]?(?:true|1)\b/.test(code) ||
254
+ /\bBUNDLE_(?:FROZEN|DEPLOYMENT)\s*[:=]\s*['"]?(?:true|1)\b/i.test(code);
255
+
256
+ const runBases = workingDirectories(code);
257
+
258
+ // Every remaining ecosystem is probed one command at a time. The playbook
259
+ // indicator names npm / pnpm / pip -r / bundle; cargo is probed on the same
260
+ // --locked / --frozen predicate.
261
+ for (const { cmd, line } of shellCommands(code)) {
262
+ if (/\bcargo\s+(?:build|install)\b/.test(cmd) && !/--locked\b/.test(cmd) && !/--frozen\b/.test(cmd)) {
263
+ hits["release-workflow-non-frozen-install"].push({ file: rel, line, snippet: `cargo build/install without --locked / --frozen: ${snippetOf(cmd)}` });
264
+ }
265
+ // Without --require-hashes the requirement set resolves at release time, so
266
+ // the artifact is not reproducible — unless the named file is hash-pinned,
267
+ // which switches pip into hash-checking mode on its own.
268
+ if (/\b(?:pip3?|python3?\s+-m\s+pip)\s+install\b/.test(cmd) &&
269
+ pipRequirementTarget(cmd) !== null &&
270
+ !/--require-hashes\b/.test(cmd) &&
271
+ !requirementsFileIsHashed(cmd, root, runBases)) {
272
+ hits["release-workflow-non-frozen-install"].push({ file: rel, line, snippet: `pip install -r without --require-hashes — requirements are resolved at release time: ${snippetOf(cmd)}` });
273
+ }
274
+ // pnpm defaults to --frozen-lockfile when CI is set, which GitHub Actions
275
+ // always sets — so a bare `pnpm install` here is already frozen and only an
276
+ // explicit opt-out resolves at release time.
277
+ if (/\bpnpm\s+(?:install|i)\b/.test(cmd) &&
278
+ /--no-frozen-lockfile\b|--frozen-lockfile[=\s]+false\b/.test(cmd)) {
279
+ hits["release-workflow-non-frozen-install"].push({ file: rel, line, snippet: `pnpm install opts out of the frozen lockfile: ${snippetOf(cmd)}` });
280
+ }
281
+ if (/\bbundle\s+install\b/.test(cmd) && !/--deployment\b/.test(cmd) && !/--frozen\b/.test(cmd) && !bundlerFrozenConfig) {
282
+ hits["release-workflow-non-frozen-install"].push({ file: rel, line, snippet: `bundle install without --deployment / --frozen: ${snippetOf(cmd)}` });
283
+ }
221
284
  }
222
285
 
223
- // runs-on-self-hosted: any `runs-on: self-hosted` line.
224
286
  if (/runs-on:\s*['"]?(?:self-hosted|\[?\s*self-hosted)/i.test(code)) {
225
287
  hits["publish-workflow-runs-on-self-hosted"].push({ file: rel, line: 0, snippet: "publish workflow runs on a self-hosted runner — non-ephemeral execution context" });
226
288
  }
@@ -233,7 +295,6 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
233
295
  const startTime = Date.now();
234
296
  const root = path.resolve(cwd);
235
297
 
236
- // package-manifest detection.
237
298
  const manifests = [];
238
299
  const manifestFiles = [
239
300
  "package.json", "pyproject.toml", "Cargo.toml", "go.mod",
@@ -246,7 +307,6 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
246
307
  manifests.push({ file: f, path: p, content: readSafe(p) });
247
308
  }
248
309
  }
249
- // *.gemspec — glob at root only.
250
310
  try {
251
311
  for (const e of fs.readdirSync(root)) {
252
312
  if (e.endsWith(".gemspec")) {
@@ -256,11 +316,9 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
256
316
  }
257
317
  } catch {}
258
318
 
259
- // Release / publish workflows.
260
319
  const workflows = walkWorkflows(root).map(w => ({ ...w, content: readSafe(w.full) || "" }));
261
320
  const publishWorkflows = workflows.filter(w => looksLikePublishWorkflow(w.name, w.content));
262
321
 
263
- // Aggregate publish-workflow indicator hits.
264
322
  const workflowHits = {
265
323
  "publish-workflow-uses-static-token": [],
266
324
  "publish-workflow-no-id-token-write": [],
@@ -268,24 +326,15 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
268
326
  "release-workflow-non-frozen-install": [],
269
327
  "publish-workflow-runs-on-self-hosted": [],
270
328
  };
271
- // OIDC capability is evaluated per publish workflow, NOT repo-wide: a
272
- // sibling docs/deploy workflow declaring `id-token: write` does not give
273
- // OIDC to a release job that still publishes with a long-lived
274
- // `secrets.NPM_TOKEN`. Treating it repo-wide would mask exactly the
275
- // static-token publisher-takeover case this indicator exists to catch.
276
- // Job-scoped `id-token: write` inside the publish file still counts —
277
- // hasIdTokenWriteAnyScope matches any scope within the scanned file.
329
+ // OIDC capability is evaluated per publish workflow, never repo-wide: a sibling
330
+ // workflow's `id-token: write` gives nothing to a job using a static token.
278
331
  for (const w of publishWorkflows) {
279
- const h = scanPublishWorkflow(w.content, w.rel);
332
+ const h = scanPublishWorkflow(w.content, w.rel, root);
280
333
  for (const [id, list] of Object.entries(h)) workflowHits[id].push(...list);
281
334
  }
282
335
 
283
- // package.json provenance: per playbook, the indicator fires
284
- // when EITHER the manifest opts in (`publishConfig.provenance:
285
- // true`) is missing AND the publish workflow does not invoke
286
- // `npm publish --provenance`. Repos that flip provenance on at
287
- // the workflow level (the modern path) should not be flagged.
288
- // We need the workflow inventory above this point.
336
+ // Fires only when neither `publishConfig.provenance: true` nor a workflow
337
+ // `npm publish --provenance` opts in. Needs the workflow inventory above.
289
338
  let provenanceMissing = "miss";
290
339
  const pkgManifest = manifests.find(m => m.file === "package.json");
291
340
  if (pkgManifest && pkgManifest.content) {
@@ -299,16 +348,8 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
299
348
  }
300
349
  }
301
350
 
302
- // lockfile-missing-integrity (per playbook): "any pinned entry
303
- // in walked lockfiles lacks an integrity field (sha512/sha384/
304
- // sha256, sri-integrity, go.sum h1: hash)". The predicate
305
- // covers every ecosystem's lockfile shape, not just npm. Walk
306
- // each supported lockfile and flip the indicator if any one of
307
- // them has a missing-integrity entry. When NO lockfile is
308
- // present, leave the indicator unflipped (undefined) so the
309
- // runner returns inconclusive — a no-lockfile repo is not
310
- // evidence of "no missing integrity"; it's evidence we can't
311
- // tell, and the playbook covers that case as inconclusive.
351
+ // Any one lockfile with a missing-integrity entry flips the indicator. With no
352
+ // lockfile it stays undefined: a repo without one is not evidence of integrity.
312
353
  let lockfileMissingIntegrity = undefined;
313
354
  const lockfilesChecked = [];
314
355
  const lockfileScans = [
@@ -317,15 +358,9 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
317
358
  scan: (text) => {
318
359
  const j = JSON.parse(text);
319
360
  let missing = 0;
320
- // Only a REMOTE-tarball entry (`resolved` is an http(s)/git URL) is
321
- // expected to carry an integrity hash. Workspace links — npm v3 emits
322
- // them as `{ "resolved": "<relative-path>", "link": true }` — and
323
- // file:/link:/workspace:/git: refs legitimately have NO integrity (a
324
- // local symlink is not a registry artifact). Counting them flips
325
- // lockfile-missing-integrity to a false hit on every npm-workspaces
326
- // monorepo even when every registry dep is sha512-pinned. Mirrors
327
- // sbom.js's local-ref demotion; the bare-relative-path + `link:true`
328
- // shape is the canonical workspace link.
361
+ // Only a remote-tarball entry (`resolved` is an http(s)/git URL) owes an
362
+ // integrity hash; a workspace link and file:/link:/workspace: refs have
363
+ // none by nature, and counting them false-hits every workspaces monorepo.
329
364
  const walkObj = (obj) => {
330
365
  if (!obj || typeof obj !== "object") return;
331
366
  if (
@@ -343,8 +378,7 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
343
378
  {
344
379
  file: "yarn.lock",
345
380
  scan: (text) => {
346
- // yarn.lock entries: a `resolved "https://..."` line should be
347
- // followed within ~5 lines by an `integrity sha512-...` line.
381
+ // A `resolved "https://..."` line owes an `integrity sha512-...` nearby.
348
382
  const lines = text.split(/\r?\n/);
349
383
  let missing = 0;
350
384
  for (let i = 0; i < lines.length; i++) {
@@ -358,9 +392,7 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
358
392
  {
359
393
  file: "pnpm-lock.yaml",
360
394
  scan: (text) => {
361
- // pnpm entries have an `integrity:` field. Count entries
362
- // with `resolution: { tarball: ... }` lines whose nearby
363
- // block lacks `integrity:`.
395
+ // A pnpm block carrying `resolution:` owes an `integrity:` field.
364
396
  const blocks = text.split(/\n(?=\s+\/)/);
365
397
  let missing = 0;
366
398
  for (const b of blocks) {
@@ -373,14 +405,10 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
373
405
  {
374
406
  file: "Cargo.lock",
375
407
  scan: (text) => {
376
- // Cargo.lock entries have `checksum = "..."` per [[package]]
377
- // (registry deps only — path / git deps legitimately have
378
- // none, so count only registry packages).
408
+ // `checksum` is owed per [[package]] by registry deps only.
379
409
  const packages = text.split(/^\[\[package\]\]/m).slice(1);
380
410
  let missing = 0;
381
411
  for (const p of packages) {
382
- // Skip path / git deps — they have a `source = "<...>"`
383
- // line; registry deps have `source = "registry+..."`.
384
412
  const sourceMatch = p.match(/^source\s*=\s*"([^"]+)"/m);
385
413
  if (!sourceMatch) continue; // path dep, no integrity expected
386
414
  if (!sourceMatch[1].startsWith("registry+")) continue;
@@ -392,8 +420,7 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
392
420
  {
393
421
  file: "go.sum",
394
422
  scan: (text) => {
395
- // go.sum entries are `<module> <version> h1:<hash>=`.
396
- // Missing-integrity = a line without `h1:` (rare; malformed).
423
+ // A go.sum line is `<module> <version> h1:<hash>=`; one without `h1:` is malformed.
397
424
  const lines = text.split(/\r?\n/).filter(Boolean);
398
425
  let missing = 0;
399
426
  for (const l of lines) {
@@ -414,8 +441,6 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
414
441
  lockfileMissingIntegrity = "hit";
415
442
  break;
416
443
  }
417
- // If at least one lockfile scanned cleanly, the indicator
418
- // moves to miss — we have evidence of integrity coverage.
419
444
  if (lockfileMissingIntegrity === undefined) lockfileMissingIntegrity = "miss";
420
445
  } catch (e) {
421
446
  errors.push({
@@ -425,55 +450,37 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
425
450
  });
426
451
  }
427
452
  }
428
- // If no supported lockfile was present, leave the indicator
429
- // unflipped (undefined). The runner returns inconclusive.
430
-
431
- // SECURITY.md presence (root + .github/SECURITY.md).
432
453
  const securityMdPresent =
433
454
  fs.existsSync(path.join(root, "SECURITY.md")) ||
434
455
  fs.existsSync(path.join(root, ".github", "SECURITY.md"));
435
456
 
436
- // security.txt presence (.well-known/security.txt + security.txt).
437
457
  const securityTxtPresent =
438
458
  fs.existsSync(path.join(root, ".well-known", "security.txt")) ||
439
459
  fs.existsSync(path.join(root, "security.txt"));
440
460
 
441
- // sbom-absent-or-unsigned: no sbom file at root, OR sbom file
442
- // present but no matching .sig sidecar.
461
+ // Fires when there is no root SBOM, or one with no matching .sig sidecar.
443
462
  let sbomFile = null;
444
463
  for (const f of ["sbom.cdx.json", "sbom.json", "bom.json", "sbom.spdx.json", "sbom.cdx.xml"]) {
445
464
  if (fs.existsSync(path.join(root, f))) { sbomFile = f; break; }
446
465
  }
447
- // Many publishers don't commit a static SBOM; they generate a
448
- // cosign-signed CycloneDX/SPDX SBOM (and provenance attestation) at
449
- // release time inside the publish workflow. Those assets never land in
450
- // the committed tree, so a repo-state-only check can't see them. Detect
451
- // the *capability* in the publish workflows: an SBOM-generation step
452
- // (cyclonedx / syft / anchore-sbom-action / trivy / `npm sbom`), npm
453
- // provenance (`--provenance` flag or `publishConfig.provenance: true`),
454
- // or a sigstore/cosign signing step. If any is present, the SBOM /
455
- // signed-attestation capability exists at release and the indicator
456
- // should not fire on the absence of a committed artifact.
466
+ // An SBOM generated and signed at release time never lands in the committed
467
+ // tree, so the capability is detected in the publish workflows instead.
457
468
  const releaseSbomCapable = publishWorkflows.some(w => {
458
469
  const c = stripYamlComments(w.content);
459
470
  return (
460
- // SBOM-generation tooling invoked in the workflow.
461
471
  /cyclonedx/i.test(c) ||
462
472
  /\bsyft\b/i.test(c) ||
463
473
  /anchore\/sbom-action/i.test(c) ||
464
474
  /\btrivy\b[^\n]*\bsbom\b/i.test(c) ||
465
475
  /\bnpm\s+sbom\b/.test(c) ||
466
476
  /spdx-sbom-generator/i.test(c) ||
467
- // npm provenance (signed build provenance attestation at publish).
468
477
  /npm\s+publish[^\n]*--provenance\b/.test(c) ||
469
- // sigstore / cosign signing of release artifacts.
470
478
  /\bcosign\s+(?:sign|attest)\b/.test(c) ||
471
479
  /sigstore\//i.test(c) ||
472
480
  /gh-action-sigstore-python/i.test(c)
473
481
  );
474
482
  });
475
- // package.json publishConfig.provenance opt-in also signals a signed
476
- // provenance attestation is produced at publish time.
483
+ // The manifest opt-in likewise produces a signed provenance attestation.
477
484
  const manifestProvenanceOptIn = (() => {
478
485
  if (!pkgManifest || !pkgManifest.content) return false;
479
486
  try { return JSON.parse(pkgManifest.content)?.publishConfig?.provenance === true; }
@@ -484,14 +491,10 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
484
491
  const sigPath = path.join(root, `${sbomFile}.sig`);
485
492
  sbomAbsentOrUnsigned = fs.existsSync(sigPath) ? "miss" : "hit";
486
493
  } else if (releaseSbomCapable || manifestProvenanceOptIn) {
487
- // No committed SBOM, but the release pipeline generates / signs one
488
- // (or emits a signed provenance attestation). Treat the capability
489
- // as present-at-release rather than reporting it absent.
490
494
  sbomAbsentOrUnsigned = "miss";
491
495
  }
492
496
 
493
- // vendored-no-provenance: vendor/ directory exists without a
494
- // _PROVENANCE.json at any level inside it.
497
+ // Fires when vendor/ exists with no _PROVENANCE.json at any level inside it.
495
498
  let vendoredNoProvenance = "miss";
496
499
  const vendorDir = path.join(root, "vendor");
497
500
  if (fs.existsSync(vendorDir)) {
@@ -505,10 +508,7 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
505
508
  if (e.isDirectory()) {
506
509
  if (DEFAULT_EXCLUDES.has(e.name)) continue;
507
510
  const sub = path.join(dir, e.name);
508
- // Don't descend into a linked git worktree nested under
509
- // vendor/ — its `.git` is a gitdir pointer file, and a repo
510
- // copy stamped by agent tooling carries unrelated provenance
511
- // state that shouldn't count toward this tree's vendoring.
511
+ // A linked worktree under vendor/ is a separate repo copy.
512
512
  if (isLinkedWorktreeDir(sub)) continue;
513
513
  walkVendor(sub, depth + 1);
514
514
  }
@@ -530,19 +530,14 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
530
530
  "no-security-txt": securityTxtPresent ? "miss" : "hit",
531
531
  "vendored-no-provenance": vendoredNoProvenance,
532
532
  };
533
- // Conditionally include lockfile-missing-integrity — leave it
534
- // out (so the runner returns inconclusive) when no supported
535
- // lockfile was present to scan.
533
+ // Omitted when no lockfile was there to scan, so the runner reads inconclusive.
536
534
  if (lockfileMissingIntegrity !== undefined) {
537
535
  signal_overrides["lockfile-missing-integrity"] = lockfileMissingIntegrity;
538
536
  }
539
537
 
540
- // __fp_checks attestation for publish-workflow-action-refs-mutable. Both
541
- // false_positive_checks_required entries are deterministic from the repo:
542
- // [0] Dependabot configured for github-actions on a weekly+ schedule
543
- // demotes the finding — attest survival when no such config exists.
544
- // [1] every mutable ref pointing to a github-owned action is lower risk —
545
- // attest survival when at least one mutable ref is third-party.
538
+ // The keys "0" and "1" are positional indexes into the indicator's
539
+ // false_positive_checks_required list: [0] Dependabot on github-actions demotes,
540
+ // [1] all-github-owned refs are lower risk. Reordering that list mis-attests.
546
541
  if (signal_overrides["publish-workflow-action-refs-mutable"] === "hit") {
547
542
  let dependabotActions = false;
548
543
  try {
@@ -567,12 +562,8 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
567
562
  if (Object.keys(att).length) signal_overrides["publish-workflow-action-refs-mutable__fp_checks"] = att;
568
563
  }
569
564
 
570
- // Per-indicator file locations for the publish-workflow indicators
571
- // flipped to "hit", so a SARIF result points at the workflow file (and,
572
- // for mutable action refs, the offending `uses:` line). The other
573
- // indicators (provenance / sbom / security.md / security.txt / vendor /
574
- // lockfile) reflect a whole-repo presence-or-absence state with no single
575
- // offending file, so they carry no file-level location here.
565
+ // Only the workflow indicators get locations, so a SARIF result can point at the
566
+ // offending `uses:` line; the rest are whole-repo presence or absence.
576
567
  const evidence_locations = {};
577
568
  for (const id of Object.keys(workflowHits)) {
578
569
  if (signal_overrides[id] === "hit") {
@@ -610,13 +601,9 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
610
601
  return {
611
602
  precondition_checks: {
612
603
  "publisher-context": manifests.length > 0,
613
- // Auto-attest the playbook's publishable-artifact-evidence gate from what
614
- // we collected (a package manifest or a publish workflow): the runner
615
- // can't probe the scanned --cwd, so otherwise a repo that clearly has a
616
- // manifest surfaces a spurious precondition_unverified warning. (The
617
- // repo-walk-access HALT gate is intentionally NOT attested here — it is
618
- // resolved host-side and attesting it from the collector would turn a
619
- // currently-passing run on a non-git dir into a fail-closed halt.)
604
+ // Attested here because the runner cannot probe the scanned --cwd. The
605
+ // repo-walk-access HALT gate must not be attested from the collector: it is
606
+ // resolved host-side, and attesting it turns a passing run into a halt.
620
607
  "publishable-artifact-evidence": manifests.length > 0 || publishWorkflows.length > 0,
621
608
  },
622
609
  artifacts,