diff --git a/README.md b/README.md index 13f1790..23edf69 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,9 @@ an hour. provenance predicate of that run - the workflow at its ref as the builder, the repository at its commit and every versions.yaml pin as the inputs, and no clock - for `cosign attest-blob --type slsaprovenance1 --predicate ` to sign over the files. Package `provenance` is the same, for -a build that adds inputs of its own (`Build`'s extra). +a build that adds inputs of its own (`Build`'s extra). In a reusable workflow, `-builder +.github/workflows/` (`Predicate.BuiltBy`) makes that workflow the builder, as the run's +certificate names it; GitHub's environment names only the caller there. `attestgate.Gate`, run from a test like the versions gate, fails on a workflow step that publishes (a release action, `oras push`, `gh release upload`, `docker push`, `aws s3 cp`, a task @@ -102,7 +104,9 @@ 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`. The YAML cannot say which files a glob covered, so the step right before an +`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 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 9a840b2..9ac308b 100644 --- a/attestgate/attestgate.go +++ b/attestgate/attestgate.go @@ -39,6 +39,19 @@ 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 +} + +// callsBuilder is whether a job's uses: is the 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 } type workflow struct { @@ -204,6 +217,9 @@ 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)) + } 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)) @@ -218,22 +234,44 @@ func (g Gate) Check() error { return nil } +// 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 { + 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 !g.callsBuilder(j) { + return problems + } + if !mainOnly { + problems = append(problems, where+": calls the builder from a workflow that is not MainOnly") + } + // A called workflow has at most its caller's permissions, whatever it declares. + if !token { + problems = append(problems, where+": calls the builder with no id-token: write, so it cannot sign") + } + return problems +} + func (g Gate) check(file string, w workflow, excused map[string]bool) []string { var problems []string mainOnly := slices.Contains(g.MainOnly, file) 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 + 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)) + } for _, jn := range slices.Sorted(maps.Keys(w.Jobs)) { j := w.Jobs[jn] - if mainOnly && !mainRef.MatchString(j.If) { - problems = append(problems, fmt.Sprintf("%s/%s: a release job with no if: github.ref == 'refs/heads/main'", file, jn)) - } // A job's permissions replace the workflow's whole, they do not add to them. token := idToken(w.Permissions) if j.Permissions.Kind != 0 { token = idToken(j.Permissions) } + problems = append(problems, g.checkJob(file+"/"+jn, j, mainOnly, token)...) attested := covers{} for _, s := range j.Steps { where := file + "/" + jn + "/" + s.key() @@ -255,6 +293,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 !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 7146b11..4961f7a 100644 --- a/attestgate/attestgate_test.go +++ b/attestgate/attestgate_test.go @@ -173,6 +173,83 @@ 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. +func TestABuilderIsWhereEveryUploadIs(t *testing.T) { + const main = "github.ref == 'refs/heads/main'" + caller := func(on, perms, jobIf string) string { + w := "name: release\non: " + on + "\n" + if perms != "" { + w += "permissions:\n " + perms + "\n" + } + w += "jobs:\n release:\n" + if jobIf != "" { + w += " if: " + jobIf + "\n" + } + 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"} + for name, tc := range map[string]struct { + files map[string]string + gate attestgate.Gate + problems []string + }{ + "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"}}, + "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"}}, + "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"}}}, + "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)}, + 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)}, + 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"}}, + "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}, + 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, + "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)}, + gate: gate, problems: []string{"build-release.yml/release/Release: publishes with no provenance"}}, + } { + t.Run(name, func(t *testing.T) { + root := t.TempDir() + dir := filepath.Join(root, ".github", "workflows") + require.NoError(t, os.MkdirAll(dir, 0o755)) + for f, body := range tc.files { + require.NoError(t, os.WriteFile(filepath.Join(dir, f), []byte(body), 0o600)) + } + tc.gate.Root = root + err := tc.gate.Check() + if len(tc.problems) == 0 { + require.NoError(t, err) + return + } + require.Error(t, err) + lines := strings.Split(err.Error(), "\n")[1:] + require.Len(t, lines, len(tc.problems), err.Error()) + for i, p := range tc.problems { + assert.Contains(t, lines[i], p) + } + }) + } +} + // A repository with no workflows, or one that is not YAML, is no clean answer. func TestWorkflowsItCannotReadAreAnError(t *testing.T) { require.Error(t, attestgate.Gate{Root: t.TempDir()}.Check()) diff --git a/cmd/provenance/main.go b/cmd/provenance/main.go index 23daf7f..901a6cb 100644 --- a/cmd/provenance/main.go +++ b/cmd/provenance/main.go @@ -3,7 +3,7 @@ // cosign attest-blob --type slsaprovenance1 --predicate to sign over the files a release // publishes. Outside a workflow it refuses. // -// go tool provenance -version v20261013.1 -out _output/provenance.json +// go tool provenance -version v20261013.1 -out _output/provenance.json [-builder .github/workflows/build-release.yml] package main import ( @@ -27,6 +27,7 @@ func run(env func(string) string, args []string, stderr io.Writer) int { version := fs.String("version", "", "the release the run builds") out := fs.String("out", "", "where to write the predicate") pins := fs.String("versions", versions.File, "the repository's versions.yaml") + builder := fs.String("builder", "", "the reusable workflow the run signs in (.github/workflows/); the dispatched one when empty") if err := fs.Parse(args); err != nil { return 2 } @@ -40,6 +41,9 @@ func run(env func(string) string, args []string, stderr io.Writer) int { return 2 } p, err := provenance.Build(env, *version, v, nil) + if err == nil && *builder != "" { + p, err = p.BuiltBy(*builder) + } if err != nil { say(stderr, err) return 2 diff --git a/cmd/provenance/main_test.go b/cmd/provenance/main_test.go index 65c3b1f..c0786d2 100644 --- a/cmd/provenance/main_test.go +++ b/cmd/provenance/main_test.go @@ -40,6 +40,7 @@ func TestItWritesTheRunsPredicateOrNothing(t *testing.T) { args func(dir string) []string pins string written bool + builder string }{ "in a run": {env: run1, pins: pins, written: true, args: func(dir string) []string { return []string{"-version", "v1", "-out", filepath.Join(dir, "p.json")} }}, @@ -49,6 +50,14 @@ func TestItWritesTheRunsPredicateOrNothing(t *testing.T) { args: func(dir string) []string { return []string{"-version", "v1", "-out", filepath.Join(dir, "p.json")} }}, "no versions.yaml": {env: run1, args: func(dir string) []string { return []string{"-version", "v1", "-out", filepath.Join(dir, "p.json")} }}, + "built by a reusable workflow": {env: run1, pins: pins, written: true, builder: "https://github.com/spin-stack/machine/.github/workflows/build-release.yml@refs/heads/main", + args: func(dir string) []string { + return []string{"-version", "v1", "-builder", ".github/workflows/build-release.yml", "-out", filepath.Join(dir, "p.json")} + }}, + "a builder that is no workflow": {env: run1, pins: pins, + args: func(dir string) []string { + return []string{"-version", "v1", "-builder", "build-release.yml", "-out", filepath.Join(dir, "p.json")} + }}, "an unknown flag": {env: run1, pins: pins, args: func(string) []string { return []string{"-subject", "x"} }}, "an -out it cannot write": {env: run1, pins: pins, @@ -76,6 +85,11 @@ func TestItWritesTheRunsPredicateOrNothing(t *testing.T) { require.NoError(t, json.Unmarshal(got, &p)) assert.Equal(t, "v1", p.BuildDefinition.ExternalParameters.Version) assert.Equal(t, "debian-snapshot", p.BuildDefinition.ResolvedDependencies[1].Name) + builder := tc.builder + if builder == "" { + builder = "https://github.com/spin-stack/machine/.github/workflows/release.yml@refs/heads/main" + } + assert.Equal(t, builder, p.RunDetails.Builder.ID) }) } } diff --git a/provenance/provenance.go b/provenance/provenance.go index dd472f3..08f8d67 100644 --- a/provenance/provenance.go +++ b/provenance/provenance.go @@ -137,6 +137,19 @@ func Build(env func(string) string, version string, pins *versions.Versions, ext }, nil } +// BuiltBy is p built by the reusable workflow at path (.github/workflows/) of the same +// repository, at the same ref: what the run's certificate names when the steps that sign are that +// workflow's. GitHub sets GITHUB_WORKFLOW_REF to the caller in a called workflow too, so the run +// cannot say this itself; the caller stays the build's workflow, which is what was dispatched. +func (p Predicate) BuiltBy(path string) (Predicate, error) { + if !strings.HasPrefix(path, ".github/workflows/") || strings.Contains(path, "@") { + return Predicate{}, fmt.Errorf("provenance: builder %q is not a workflow file of the repository", path) + } + w := p.BuildDefinition.ExternalParameters.Workflow + p.RunDetails.Builder.ID = w.Repository + "/" + path + "@" + w.Ref + return p, nil +} + // Pinned is a versions.yaml entry as an input: its name, where it is, and the digest that holds // it - none for a module, which the checksum database holds by its version, or a date. func Pinned(e versions.Entry) ResourceDescriptor { diff --git a/provenance/provenance_test.go b/provenance/provenance_test.go index f3700af..5cf5b15 100644 --- a/provenance/provenance_test.go +++ b/provenance/provenance_test.go @@ -96,6 +96,23 @@ func TestARunsPredicateIsTheGolden(t *testing.T) { assert.Equal(t, p, again) } +// Built by a reusable workflow, the builder is that workflow at the run's ref, and the dispatched +// one stays the build's workflow; a builder that is not a workflow file of the repository is no +// builder. +func TestABuilderIsTheReusableWorkflowAtTheRunsRef(t *testing.T) { + p, err := provenance.Build(getenv(run), "v20261013.1", nil, nil) + require.NoError(t, err) + built, err := p.BuiltBy(".github/workflows/build-release.yml") + require.NoError(t, err) + assert.Equal(t, "https://github.com/spin-stack/spin/.github/workflows/build-release.yml@refs/heads/main", built.RunDetails.Builder.ID) + assert.Equal(t, ".github/workflows/release.yml", built.BuildDefinition.ExternalParameters.Workflow.Path) + assert.Equal(t, "https://github.com/spin-stack/spin/.github/workflows/release.yml@refs/heads/main", p.RunDetails.Builder.ID) + for _, path := range []string{"hack/build.yml", "build-release.yml", ".github/workflows/build-release.yml@refs/heads/x"} { + _, err := p.BuiltBy(path) + assert.Error(t, err, path) + } +} + // An environment that is not a workflow run's, or a workflow ref that is not the repository's, // is refused: provenance with no builder in it names nobody. func TestAnEnvironmentThatIsNotARunIsRefused(t *testing.T) {