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
16 changes: 4 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,8 @@

The Go the spin-stack repositories build with, and hold their code to: tools a repository runs
from its go.mod (`go tool <name>`), each a command under `cmd/`. Only `versions`,
`versions/upstream`, `trustroot`, `provenance`, `attestgate`, `sbom` and `fulciotest` (a test
Sigstore whose certificates carry Fulcio's source-repository and build extensions, which a verifier
of a reusable builder pins) are packages another repository imports; the rest is
`versions/upstream`, `trustroot`, `provenance`, `attestgate` and `sbom` are packages another
repository imports; the rest is
`internal/`. Nothing here is linked into what any of them ships but versions' reading half, which
spin's binaries use to read the pins they carry, and the trusted root, which what verifies a
release's signatures checks them against.
Expand Down Expand Up @@ -95,22 +94,15 @@ 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). 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.
a build that adds inputs of its own (`Build`'s extra).

`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`. `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 == '<owner/name>'`. The YAML cannot say which
files a glob covered, so the step right before an
`refs/heads/main`. 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
78 changes: 3 additions & 75 deletions attestgate/attestgate.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,49 +39,6 @@ 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
// 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 == '<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 Builders.
Repository string
}

// 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
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, "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 a builder, as a workflow of the same repository names it.
func (g Gate) callsBuilder(j job) bool {
file, ok := strings.CutPrefix(j.Uses, "./.github/workflows/")
return ok && g.isBuilder(file)
}

type workflow struct {
Expand Down Expand Up @@ -247,7 +204,6 @@ func (g Gate) Check() error {
problems = append(problems, fmt.Sprintf("%s: MainOnly names a workflow there is not", f))
}
}
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))
Expand All @@ -262,48 +218,22 @@ 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,
// 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) {
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
}
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 {
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.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))
}
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, builder, token)...)
attested := covers{}
for _, s := range j.Steps {
where := file + "/" + jn + "/" + s.key()
Expand All @@ -325,8 +255,6 @@ func (g Gate) check(file string, w workflow, excused map[string]bool) []string {
}
has := attested.or(self)
switch {
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:
Expand Down
98 changes: 0 additions & 98 deletions attestgate/attestgate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,104 +173,6 @@ func TestAnUploadNeedsAnAttestationBeforeIt(t *testing.T) {
}
}

// 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 {
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)
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"}, 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
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 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 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"}, 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]"}},
"a builder on something else": {files: map[string]string{"release.yml": good,
"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: 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", "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,
"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", 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"}, 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()
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: 1 addition & 5 deletions 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 [-builder .github/workflows/build-release.yml]
// go tool provenance -version v20261013.1 -out _output/provenance.json
package main

import (
Expand All @@ -27,7 +27,6 @@ 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 @@ -41,9 +40,6 @@ 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: 0 additions & 14 deletions cmd/provenance/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ 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 @@ -50,14 +49,6 @@ 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 @@ -85,11 +76,6 @@ 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)
})
}
}
Loading