Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,15 +94,19 @@ 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 <file>` 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/<file>` (`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
named in `UploadTasks`) without a step before it in its job that attests (`cosign attest-blob
--type slsaprovenance1`, or a task in `AttestTasks`) unconditionally and with `id-token: write`.
`NotPublished` excuses a step by `<file>/<job>/<step>`, 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] <dir>`, which
fails on a file there with no `<file>.provenance.sigstore.json` beside it.

Expand Down
46 changes: 43 additions & 3 deletions attestgate/attestgate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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))
Expand All @@ -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()
Expand All @@ -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:
Expand Down
77 changes: 77 additions & 0 deletions attestgate/attestgate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
6 changes: 5 additions & 1 deletion cmd/provenance/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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/<file>); the dispatched one when empty")
if err := fs.Parse(args); err != nil {
return 2
}
Expand All @@ -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
Expand Down
14 changes: 14 additions & 0 deletions cmd/provenance/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")} }},
Expand All @@ -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,
Expand Down Expand Up @@ -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)
})
}
}
13 changes: 13 additions & 0 deletions provenance/provenance.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/<file>) 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 {
Expand Down
17 changes: 17 additions & 0 deletions provenance/provenance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down