|
36 | 36 | // under provenance, so a moved tag in the build job publishes as surely |
37 | 37 | // as one in the publish job would. (The build was split out of the |
38 | 38 | // publish job to keep the OIDC token away from dependency installs; this |
39 | | -// keeps a moving tag out of what gets published.) |
| 39 | +// keeps a moving tag out of what gets published.); or |
| 40 | +// 4. has outputs that a credentialed job reads (`needs.<job>.outputs.…`). |
| 41 | +// The reader acts on them next to its credential. Here that is |
| 42 | +// `release.yml`'s `detect-packages`: the publish jobs take from it the |
| 43 | +// name and version an artifact must match before it is published, so a |
| 44 | +// moved tag there could approve a tampered artifact. |
40 | 45 | // |
41 | 46 | // `GITHUB_TOKEN` alone does not count: every job holds one, so counting it |
42 | 47 | // would turn this into "pin everything", which #4873 does not ask. That is why |
@@ -199,10 +204,35 @@ export function credentialedJobs(yaml, file) { |
199 | 204 | } |
200 | 205 | return seen; |
201 | 206 | }; |
| 207 | + // The jobs whose outputs a job reads: every `needs.<job>.outputs` anywhere |
| 208 | + // in it. `needs.*.outputs` and a dynamic index name no job, so they count |
| 209 | + // every job it needs. |
| 210 | + const outputsReadBy = (job) => { |
| 211 | + const read = new Set(); |
| 212 | + for (const text of stringsIn(job)) { |
| 213 | + if (!text.includes(EXPRESSION_OPEN)) continue; |
| 214 | + for (const m of text.matchAll( |
| 215 | + /\bneeds\s*(?:\.\s*([\w-]+|\*)|\[\s*(?:'([^']*)'|"([^"]*)"|[^\]]*)\s*\])\s*(?:\.\s*outputs\b|\[\s*['"]outputs['"]\s*\])/g, |
| 216 | + )) { |
| 217 | + const named = m[1] ?? m[2] ?? m[3]; |
| 218 | + if (named === undefined || named === "*") |
| 219 | + for (const n of needsOf(job)) read.add(n); |
| 220 | + else read.add(named); |
| 221 | + } |
| 222 | + } |
| 223 | + return read; |
| 224 | + }; |
202 | 225 | for (let grew = true; grew; ) { |
203 | 226 | grew = false; |
204 | 227 | for (const [name, job] of jobs) { |
205 | | - if (!held.has(name) || !mayDownloadArtifact(job)) continue; |
| 228 | + if (!held.has(name)) continue; |
| 229 | + for (const source of outputsReadBy(job)) { |
| 230 | + if (workflow.jobs[source] && !held.has(source)) { |
| 231 | + held.add(source); |
| 232 | + grew = true; |
| 233 | + } |
| 234 | + } |
| 235 | + if (!mayDownloadArtifact(job)) continue; |
206 | 236 | for (const producer of upstreamOf(name)) { |
207 | 237 | if (!held.has(producer) && mayUploadArtifact(workflow.jobs[producer])) { |
208 | 238 | held.add(producer); |
@@ -284,10 +314,10 @@ export function main(root = repoRoot) { |
284 | 314 | "\n\nA job holding `id-token`/`packages: write`, a non-default secret, or building an" + |
285 | 315 | "\nartifact such a job consumes runs only immutable refs. Pin each as" + |
286 | 316 | "\n uses: owner/repo@<40-hex sha> # vX.Y.Z" + |
287 | | - "\nA local `./…` action or workflow is refused there as well: this guard does not" + |
288 | | - "\nread what it runs. Inline its steps, or extend the guard to follow it." + |
289 | | - "\nresolving the SHA and the exact release from the same tag lookup. The comment" + |
290 | | - "\nnames the release the SHA came from, for a reviewer and for the dependency sweep.", |
| 317 | + "\nResolve the SHA and the exact release from the same tag lookup. The comment" + |
| 318 | + "\nnames the release the SHA came from, for a reviewer and for the dependency sweep." + |
| 319 | + "\n\nA local `./…` action or workflow is refused in such a job as well: this guard" + |
| 320 | + "\ndoes not read what it runs. Inline its steps, or extend the guard to follow it.", |
291 | 321 | ); |
292 | 322 | return 1; |
293 | 323 | } |
|
0 commit comments