diff --git a/README.md b/README.md index 23edf69..cdd6c78 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,9 @@ named in `UploadTasks`) without a step before it in its job that attests (`cosig 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 -certificate then names the builder, which a verifier pins. The YAML cannot say which files a glob covered, so the step right before an +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 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 9ac308b..e8ca2c2 100644 --- a/attestgate/attestgate.go +++ b/attestgate/attestgate.go @@ -46,6 +46,32 @@ type Gate struct { // 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 + // 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 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 == "" { + return nil + } + var problems []string + if !files[g.Builder] { + problems = append(problems, g.Builder+": Builder 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") + } + return problems +} + +// 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 @@ -217,9 +243,7 @@ func (g Gate) Check() error { problems = append(problems, fmt.Sprintf("%s: MainOnly names a workflow there is not", f)) } } - if g.Builder != "" && !files[g.Builder] { - problems = append(problems, fmt.Sprintf("%s: Builder names a workflow there is not", g.Builder)) - } + problems = append(problems, g.checkBuilder(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)) @@ -235,12 +259,16 @@ 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, -// and a call to the builder from where it may not be called or with no token to sign. -func (g Gate) checkJob(where string, j job, mainOnly, token bool) []string { +// 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. +func (g Gate) checkJob(where string, j job, mainOnly, builder, token bool) []string { var problems []string if mainOnly && !mainRef.MatchString(j.If) { problems = append(problems, where+": a release job with no if: github.ref == 'refs/heads/main'") } + if builder && !g.ownRepository(j.If) { + problems = append(problems, where+": a builder job with no if: github.repository == '"+g.Repository+"': another repository's call would sign as the builder") + } if !g.callsBuilder(j) { return problems } @@ -271,7 +299,7 @@ func (g Gate) check(file string, w workflow, excused map[string]bool) []string { if j.Permissions.Kind != 0 { token = idToken(j.Permissions) } - problems = append(problems, g.checkJob(file+"/"+jn, j, mainOnly, token)...) + problems = append(problems, g.checkJob(file+"/"+jn, j, mainOnly, builder, token)...) attested := covers{} for _, s := range j.Steps { where := file + "/" + jn + "/" + s.key() diff --git a/attestgate/attestgate_test.go b/attestgate/attestgate_test.go index 4961f7a..9797b85 100644 --- a/attestgate/attestgate_test.go +++ b/attestgate/attestgate_test.go @@ -190,8 +190,9 @@ func TestABuilderIsWhereEveryUploadIs(t *testing.T) { return w + " uses: ./.github/workflows/build-release.yml\n" } good := caller("workflow_dispatch", "id-token: write", main) - builder := workflow("workflow_call", "id-token: write", "", "", build, attest, release) - gate := attestgate.Gate{MainOnly: []string{"release.yml"}, Builder: "build-release.yml"} + 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"} for name, tc := range map[string]struct { files map[string]string gate attestgate.Gate @@ -206,17 +207,17 @@ func TestABuilderIsWhereEveryUploadIs(t *testing.T) { gate: gate, problems: []string{"nightly.yml/release/Push: publishes outside the builder"}}, "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", NotPublished: map[string]string{"ci.yml/release/Logs": "the run's logs"}}}, + 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"}}}, "a builder that runs by itself": {files: map[string]string{"release.yml": good, - "build-release.yml": workflow("[workflow_call, workflow_dispatch]", "id-token: write", "", "", attest, release)}, + "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]"}}, "a builder on something else": {files: map[string]string{"release.yml": good, - "build-release.yml": workflow("push", "id-token: write", "", "", attest, release)}, + "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"}, problems: []string{"release.yml/release: calls the builder from a workflow that is not MainOnly"}}, + 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"}}, "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"}}, "a caller with no token to give": {files: map[string]string{"release.yml": caller("workflow_dispatch", "contents: write", main), "build-release.yml": builder}, @@ -224,8 +225,17 @@ func TestABuilderIsWhereEveryUploadIs(t *testing.T) { "another workflow's uses is not the builder": {files: map[string]string{"release.yml": good, "build-release.yml": builder, "lint.yml": strings.Replace(caller("push", "", ""), "build-release.yml", "lint-reusable.yml", 1)}, gate: gate}, "a builder that publishes unattested": {files: map[string]string{"release.yml": good, - "build-release.yml": workflow("workflow_call", "id-token: write", "", "", build, release)}, + "build-release.yml": workflow("workflow_call", "id-token: write", own, "", build, release)}, gate: gate, problems: []string{"build-release.yml/release/Release: publishes with no provenance"}}, + "a builder that runs in any repository": {files: map[string]string{"release.yml": good, + "build-release.yml": workflow("workflow_call", "id-token: write", "", "", build, attest, release)}, + gate: gate, problems: []string{"build-release.yml/release: a builder job with no if: github.repository == 'spin-stack/x'"}}, + "a builder that runs in another repository": {files: map[string]string{"release.yml": good, + "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"}}, } { t.Run(name, func(t *testing.T) { root := t.TempDir()