diff --git a/README.md b/README.md index 670f3e7..8a3e336 100644 --- a/README.md +++ b/README.md @@ -105,11 +105,12 @@ named in `UploadTasks`) without a step before it in its job that attests (`cosig --type slsaprovenance1`, or a task in `AttestTasks`) unconditionally and with `id-token: write`. `NotPublished` excuses a step by `//`, with its reason, and an excuse that matches nothing fails; `MainOnly` holds release workflows to no push trigger and every job on -`refs/heads/main`. `Builder` names a reusable workflow (`on: workflow_call` alone) that every -upload must be in, called only from a `MainOnly` workflow whose job grants `id-token: write`: the +`refs/heads/main`. `Builders` name the reusable workflows (`on: workflow_call` alone) every upload +must be in, each called only from a job on `refs/heads/main` that grants `id-token: write`: the certificate then names the builder, which a verifier pins - with the source repository too, since any repository can call a public reusable workflow and be issued the builder's SAN: `Repository` -holds every builder job to `if: github.repository == ''`. The YAML cannot say which files a glob covered, so the step right before an +holds every builder job to `if: github.repository == ''`. The YAML cannot say which +files a glob covered, so the step right before an upload runs `go tool attestgate files -require provenance [-except checksums.txt] `, which fails on a file there with no `.provenance.sigstore.json` beside it. diff --git a/attestgate/attestgate.go b/attestgate/attestgate.go index e8ca2c2..db5beef 100644 --- a/attestgate/attestgate.go +++ b/attestgate/attestgate.go @@ -39,45 +39,49 @@ type Gate struct { // MainOnly are the workflow files that cut a release: none has a push trigger, and every job // in one runs only on refs/heads/main. MainOnly []string - // Builder is the reusable workflow every publishing step is in, by its file name: on: - // workflow_call and nothing else, so it runs only as a job of a workflow that calls it with - // uses:, and only a MainOnly one may. The certificate a step in it signs with names the - // builder, not the caller, so a verifier pinning it pins the code that built and attested; a - // caller that published by itself would sign as itself and be outside the pin. Empty: no - // builder, and a release workflow publishes itself. - Builder string - // Repository is the repository the builder is, owner/name, which every job of the builder runs + // Builders are the reusable workflows every publishing step is in, by their file names: each + // on: workflow_call and nothing else, so it runs only as a job of a workflow that calls it with + // uses:, and only from a job that runs on refs/heads/main. The certificate a step in one signs + // with names that builder, not the caller, so a verifier pinning it pins the code that built and + // attested; a caller that published by itself would sign as itself and be outside the pin. None: + // a release workflow publishes itself. + Builders []string + // Repository is the repository the builders are, owner/name, which every job of a builder runs // only in (if: github.repository == ''): any repository can call a public reusable // workflow, and its run's certificate names the builder at the ref it called, over what that - // repository checked out. Required with Builder. + // repository checked out. Required with Builders. Repository string } -// checkBuilder is what is wrong with the Builder named, given the workflow files there are. -func (g Gate) checkBuilder(files map[string]bool) []string { - if g.Builder == "" { +// checkBuilders is what is wrong with the Builders named, given the workflow files there are. +func (g Gate) checkBuilders(files map[string]bool) []string { + if len(g.Builders) == 0 { return nil } var problems []string - if !files[g.Builder] { - problems = append(problems, g.Builder+": Builder names a workflow there is not") + for _, b := range g.Builders { + if !files[b] { + problems = append(problems, b+": Builders names a workflow there is not") + } } if !strings.Contains(g.Repository, "/") { - problems = append(problems, g.Builder+": a Builder with no Repository (owner/name) to run only in") + problems = append(problems, "Builders with no Repository (owner/name) to run only in") } return problems } +func (g Gate) isBuilder(file string) bool { return slices.Contains(g.Builders, file) } + // ownRepository is whether a job's if: holds it to the builder's repository. func (g Gate) ownRepository(cond string) bool { return g.Repository != "" && regexp.MustCompile(`github\.repository\s*==\s*'`+regexp.QuoteMeta(g.Repository)+`'`).MatchString(cond) } -// callsBuilder is whether a job's uses: is the builder, as a workflow of the same repository -// names it. +// callsBuilder is whether a job's uses: is a builder, as a workflow of the same repository names it. func (g Gate) callsBuilder(j job) bool { - return g.Builder != "" && j.Uses == "./.github/workflows/"+g.Builder + file, ok := strings.CutPrefix(j.Uses, "./.github/workflows/") + return ok && g.isBuilder(file) } type workflow struct { @@ -243,7 +247,7 @@ func (g Gate) Check() error { problems = append(problems, fmt.Sprintf("%s: MainOnly names a workflow there is not", f)) } } - problems = append(problems, g.checkBuilder(files)...) + problems = append(problems, g.checkBuilders(files)...) for _, k := range slices.Sorted(maps.Keys(g.NotPublished)) { if strings.TrimSpace(g.NotPublished[k]) == "" { problems = append(problems, fmt.Sprintf("%s: NotPublished with no reason", k)) @@ -259,8 +263,8 @@ func (g Gate) Check() error { } // checkJob is what is wrong with job j at where, as a whole: a release job that may run off main, -// a builder job that may run in another repository, and a call to the builder from where it may -// not be called or with no token to sign. +// a builder job that may run in another repository, and a call to a builder from a job that may +// run off main or with no token to sign. func (g Gate) checkJob(where string, j job, mainOnly, builder, token bool) []string { var problems []string if mainOnly && !mainRef.MatchString(j.If) { @@ -272,8 +276,8 @@ func (g Gate) checkJob(where string, j job, mainOnly, builder, token bool) []str if !g.callsBuilder(j) { return problems } - if !mainOnly { - problems = append(problems, where+": calls the builder from a workflow that is not MainOnly") + if !mainRef.MatchString(j.If) { + problems = append(problems, where+": calls a builder with no if: github.ref == 'refs/heads/main'") } // A called workflow has at most its caller's permissions, whatever it declares. if !token { @@ -288,7 +292,7 @@ func (g Gate) check(file string, w workflow, excused map[string]bool) []string { if mainOnly && slices.Contains(triggers(w.On), "push") { problems = append(problems, fmt.Sprintf("%s: a release workflow with a push trigger: it runs on whatever was pushed", file)) } - builder := g.Builder != "" && file == g.Builder + builder := g.isBuilder(file) if on := triggers(w.On); builder && (len(on) != 1 || on[0] != "workflow_call") { problems = append(problems, fmt.Sprintf("%s: the builder is on: %v, not workflow_call alone: it runs without a release workflow calling it", file, on)) } @@ -321,8 +325,8 @@ func (g Gate) check(file string, w workflow, excused map[string]bool) []string { } has := attested.or(self) switch { - case g.Builder != "" && !builder: - problems = append(problems, fmt.Sprintf("%s: publishes outside the builder %s: a release workflow calls it with uses:", where, g.Builder)) + case len(g.Builders) > 0 && !builder: + problems = append(problems, fmt.Sprintf("%s: publishes outside the builders %v: a workflow calls one with uses:", where, g.Builders)) case !has.provenance: problems = append(problems, fmt.Sprintf("%s: publishes with no provenance attested before it in its job", where)) case g.RequireSBOM && !has.sbom: diff --git a/attestgate/attestgate_test.go b/attestgate/attestgate_test.go index 9797b85..552da85 100644 --- a/attestgate/attestgate_test.go +++ b/attestgate/attestgate_test.go @@ -173,9 +173,9 @@ func TestAnUploadNeedsAnAttestationBeforeIt(t *testing.T) { } } -// With a builder, what is published is published from it alone: a reusable workflow that runs only -// when called, called only by a release workflow from main that gives it a token to sign with, and -// held inside to the rules every publishing job is held to. +// With builders, what is published is published from them alone: reusable workflows that run only +// when called, in their own repository, called only by a job on main that gives them a token to +// sign with, and held inside to the rules every publishing job is held to. func TestABuilderIsWhereEveryUploadIs(t *testing.T) { const main = "github.ref == 'refs/heads/main'" caller := func(on, perms, jobIf string) string { @@ -192,7 +192,11 @@ func TestABuilderIsWhereEveryUploadIs(t *testing.T) { good := caller("workflow_dispatch", "id-token: write", main) const own = "github.repository == 'spin-stack/x'" builder := workflow("workflow_call", "id-token: write", own, "", build, attest, release) - gate := attestgate.Gate{MainOnly: []string{"release.yml"}, Builder: "build-release.yml", Repository: "spin-stack/x"} + gate := attestgate.Gate{MainOnly: []string{"release.yml"}, Builders: []string{"build-release.yml"}, Repository: "spin-stack/x"} + // CI on a push to main, calling a second builder for what it saves. + ci := "name: ci\non: [push, pull_request]\npermissions:\n id-token: write\njobs:\n save:\n if: " + main + + "\n uses: ./.github/workflows/build-packs.yml\n" + two := attestgate.Gate{MainOnly: []string{"release.yml"}, Builders: []string{"build-release.yml", "build-packs.yml"}, Repository: "spin-stack/x"} for name, tc := range map[string]struct { files map[string]string gate attestgate.Gate @@ -201,13 +205,22 @@ func TestABuilderIsWhereEveryUploadIs(t *testing.T) { "called from main": {files: map[string]string{"release.yml": good, "build-release.yml": builder}, gate: gate}, "no builder at all": {files: map[string]string{"release.yml": workflow("workflow_dispatch", "id-token: write", main, "", attest, release)}, gate: attestgate.Gate{MainOnly: []string{"release.yml"}}}, "an upload in the caller": {files: map[string]string{"release.yml": workflow("workflow_dispatch", "id-token: write", main, "", attest, release), "build-release.yml": builder}, - gate: gate, problems: []string{"release.yml/release/Release: publishes outside the builder build-release.yml"}}, + gate: gate, problems: []string{"release.yml/release/Release: publishes outside the builders [build-release.yml]"}}, "an upload in another workflow": {files: map[string]string{"release.yml": good, "build-release.yml": builder, "nightly.yml": workflow("schedule", "id-token: write", "", "", attest, orasPush)}, - gate: gate, problems: []string{"nightly.yml/release/Push: publishes outside the builder"}}, + gate: gate, problems: []string{"nightly.yml/release/Push: publishes outside the builders"}}, "an excused upload elsewhere": {files: map[string]string{"release.yml": good, "build-release.yml": builder, "ci.yml": workflow("push", "", "", "", " - name: Logs\n uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1\n")}, - gate: attestgate.Gate{MainOnly: []string{"release.yml"}, Builder: "build-release.yml", Repository: "spin-stack/x", NotPublished: map[string]string{"ci.yml/release/Logs": "the run's logs"}}}, + gate: attestgate.Gate{MainOnly: []string{"release.yml"}, Builders: []string{"build-release.yml"}, Repository: "spin-stack/x", NotPublished: map[string]string{"ci.yml/release/Logs": "the run's logs"}}}, + "two builders, one called from CI on main": {files: map[string]string{"release.yml": good, "build-release.yml": builder, "ci.yml": ci, + "build-packs.yml": workflow("workflow_call", "id-token: write", own, "", build, attest, orasPush)}, gate: two}, + "a builder called from CI off main": {files: map[string]string{"release.yml": good, "build-release.yml": builder, + "ci.yml": strings.Replace(ci, " if: "+main+"\n", "", 1), + "build-packs.yml": workflow("workflow_call", "id-token: write", own, "", build, attest, orasPush)}, + gate: two, problems: []string{"ci.yml/save: calls a builder with no if: github.ref == 'refs/heads/main'"}}, + "a reusable workflow that is not a builder publishes": {files: map[string]string{"release.yml": good, "build-release.yml": builder, "ci.yml": ci, + "build-packs.yml": workflow("workflow_call", "id-token: write", own, "", build, attest, orasPush)}, + gate: gate, problems: []string{"build-packs.yml/release/Push: publishes outside the builders"}}, "a builder that runs by itself": {files: map[string]string{"release.yml": good, "build-release.yml": workflow("[workflow_call, workflow_dispatch]", "id-token: write", own, "", attest, release)}, gate: gate, problems: []string{"build-release.yml: the builder is on: [workflow_call workflow_dispatch]"}}, @@ -215,11 +228,9 @@ func TestABuilderIsWhereEveryUploadIs(t *testing.T) { "build-release.yml": workflow("push", "id-token: write", own, "", attest, release)}, gate: gate, problems: []string{"build-release.yml: the builder is on: [push]"}}, "a builder there is not": {files: map[string]string{"release.yml": good}, gate: gate, - problems: []string{"build-release.yml: Builder names a workflow there is not"}}, - "a caller that is not main only": {files: map[string]string{"release.yml": good, "build-release.yml": builder}, - gate: attestgate.Gate{Builder: "build-release.yml", Repository: "spin-stack/x"}, problems: []string{"release.yml/release: calls the builder from a workflow that is not MainOnly"}}, + problems: []string{"build-release.yml: Builders names a workflow there is not"}}, "a caller from any ref": {files: map[string]string{"release.yml": caller("workflow_dispatch", "id-token: write", ""), "build-release.yml": builder}, - gate: gate, problems: []string{"release.yml/release: a release job with no if"}}, + gate: gate, problems: []string{"release.yml/release: a release job with no if", "release.yml/release: calls a builder with no if: github.ref"}}, "a caller with no token to give": {files: map[string]string{"release.yml": caller("workflow_dispatch", "contents: write", main), "build-release.yml": builder}, gate: gate, problems: []string{"release.yml/release: calls the builder with no id-token: write"}}, "another workflow's uses is not the builder": {files: map[string]string{"release.yml": good, "build-release.yml": builder, @@ -234,8 +245,8 @@ func TestABuilderIsWhereEveryUploadIs(t *testing.T) { "build-release.yml": workflow("workflow_call", "id-token: write", "github.repository == 'spin-stack/y'", "", build, attest, release)}, gate: gate, problems: []string{"build-release.yml/release: a builder job with no if: github.repository == 'spin-stack/x'"}}, "a builder with no repository": {files: map[string]string{"release.yml": good, "build-release.yml": builder}, - gate: attestgate.Gate{MainOnly: []string{"release.yml"}, Builder: "build-release.yml"}, - problems: []string{"build-release.yml/release: a builder job with no if: github.repository == ''", "build-release.yml: a Builder with no Repository"}}, + gate: attestgate.Gate{MainOnly: []string{"release.yml"}, Builders: []string{"build-release.yml"}}, + problems: []string{"build-release.yml/release: a builder job with no if: github.repository == ''", "Builders with no Repository"}}, } { t.Run(name, func(t *testing.T) { root := t.TempDir()