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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,9 @@ named in `UploadTasks`) without a step before it in its job that attests (`cosig
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
certificate then names the builder, which a verifier pins. The YAML cannot say which files a glob covered, so the step right before an
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
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
40 changes: 34 additions & 6 deletions attestgate/attestgate.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,32 @@ type Gate struct {
// 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
// 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 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 == "" {
return nil
}
var problems []string
if !files[g.Builder] {
problems = append(problems, g.Builder+": Builder 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")
}
return problems
}

// 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
Expand Down Expand Up @@ -217,9 +243,7 @@ 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))
}
problems = append(problems, g.checkBuilder(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 @@ -235,12 +259,16 @@ 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,
// 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 {
// 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.
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
}
Expand Down Expand Up @@ -271,7 +299,7 @@ func (g Gate) check(file string, w workflow, excused map[string]bool) []string {
if j.Permissions.Kind != 0 {
token = idToken(j.Permissions)
}
problems = append(problems, g.checkJob(file+"/"+jn, j, mainOnly, token)...)
problems = append(problems, g.checkJob(file+"/"+jn, j, mainOnly, builder, token)...)
attested := covers{}
for _, s := range j.Steps {
where := file + "/" + jn + "/" + s.key()
Expand Down
24 changes: 17 additions & 7 deletions attestgate/attestgate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,8 +190,9 @@ func TestABuilderIsWhereEveryUploadIs(t *testing.T) {
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"}
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"}
for name, tc := range map[string]struct {
files map[string]string
gate attestgate.Gate
Expand All @@ -206,26 +207,35 @@ func TestABuilderIsWhereEveryUploadIs(t *testing.T) {
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"}}},
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"}}},
"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)},
"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", "", "", attest, release)},
"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"}, problems: []string{"release.yml/release: calls the builder from a workflow that is not MainOnly"}},
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"}},
"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)},
"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"}, 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"}},
} {
t.Run(name, func(t *testing.T) {
root := t.TempDir()
Expand Down