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
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,11 +105,12 @@ 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 `<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`. `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
`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
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
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
56 changes: 30 additions & 26 deletions attestgate/attestgate.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,45 +39,49 @@ 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
// Repository is the repository the builder is, owner/name, which every job of the builder runs
// 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 Builder.
// repository checked out. Required with Builders.
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 == "" {
// 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
if !files[g.Builder] {
problems = append(problems, g.Builder+": Builder names a workflow there is not")
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, g.Builder+": a Builder with no Repository (owner/name) to run only in")
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 the builder, as a workflow of the same repository
// names it.
// 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 {
return g.Builder != "" && j.Uses == "./.github/workflows/"+g.Builder
file, ok := strings.CutPrefix(j.Uses, "./.github/workflows/")
return ok && g.isBuilder(file)
}

type workflow struct {
Expand Down Expand Up @@ -243,7 +247,7 @@ func (g Gate) Check() error {
problems = append(problems, fmt.Sprintf("%s: MainOnly names a workflow there is not", f))
}
}
problems = append(problems, g.checkBuilder(files)...)
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 @@ -259,8 +263,8 @@ 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,
// 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.
// 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) {
Expand All @@ -272,8 +276,8 @@ func (g Gate) checkJob(where string, j job, mainOnly, builder, token bool) []str
if !g.callsBuilder(j) {
return problems
}
if !mainOnly {
problems = append(problems, where+": calls the builder from a workflow that is not MainOnly")
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 {
Expand All @@ -288,7 +292,7 @@ func (g Gate) check(file string, w workflow, excused map[string]bool) []string {
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
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))
}
Expand Down Expand Up @@ -321,8 +325,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 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
37 changes: 24 additions & 13 deletions attestgate/attestgate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,9 +173,9 @@ 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.
// 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 {
Expand All @@ -192,7 +192,11 @@ func TestABuilderIsWhereEveryUploadIs(t *testing.T) {
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"}, Builder: "build-release.yml", Repository: "spin-stack/x"}
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
Expand All @@ -201,25 +205,32 @@ func TestABuilderIsWhereEveryUploadIs(t *testing.T) {
"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"}},
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 builder"}},
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"}, Builder: "build-release.yml", Repository: "spin-stack/x", NotPublished: map[string]string{"ci.yml/release/Logs": "the run's logs"}}},
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: 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", Repository: "spin-stack/x"}, problems: []string{"release.yml/release: calls the builder from a workflow that is not MainOnly"}},
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"}},
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,
Expand All @@ -234,8 +245,8 @@ func TestABuilderIsWhereEveryUploadIs(t *testing.T) {
"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"}},
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()
Expand Down