From d21d20bd9dcbdc2d4790a9f6ac4706012295fe7f Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Tue, 13 Oct 2026 14:07:19 -0300 Subject: [PATCH 1/2] attestgate: Builder, the reusable workflow every upload is in A step in a reusable workflow signs with a certificate that names that workflow, not its caller. Gate.Builder names it: it must be on: workflow_call alone, every publishing step must be inside it, and a job that calls it must be in a MainOnly workflow and grant id-token: write (a called workflow has at most its caller's permissions). A verifier then pins the builder: the code that built and attested. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 4 +- attestgate/attestgate.go | 46 +++++++++++++++++++-- attestgate/attestgate_test.go | 77 +++++++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 13f1790..ba2f023 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,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()) From e5907b4dc8b78c1845389449db7e4250b4df9fec Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Tue, 13 Oct 2026 14:11:06 -0300 Subject: [PATCH 2/2] provenance: -builder, the reusable workflow a run signs in In a called workflow GITHUB_WORKFLOW_REF is still the caller, so the predicate's builder named the dispatched workflow while the certificate names the reusable one. Predicate.BuiltBy(path) sets the builder to that workflow of the same repository at the run's ref; the dispatched one stays the build's workflow. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 4 +++- cmd/provenance/main.go | 6 +++++- cmd/provenance/main_test.go | 14 ++++++++++++++ provenance/provenance.go | 13 +++++++++++++ provenance/provenance_test.go | 17 +++++++++++++++++ 5 files changed, 52 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ba2f023..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 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) {