diff --git a/.github/actions/fork-review-check/README.md b/.github/actions/fork-review-check/README.md index b7db9a37..8ac3548f 100644 --- a/.github/actions/fork-review-check/README.md +++ b/.github/actions/fork-review-check/README.md @@ -32,12 +32,26 @@ go run . ref -repo loft-sh/kine -ref v0.16.5.vcluster.1 # Every loft-sh fork in the effective Go module graph of one or more modules. GOPRIVATE=github.com/loft-sh/* go run . go -dir /path/to/vnode-internal + +# A digest-pinned fork image. -image and -signer-workflow are repeatable. +go run . image \ + -image ghcr.io/loft-sh/kine:v0.16.5.vcluster.1@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef \ + -signer-workflow loft-sh/kine=.github/workflows/release.yml + +# A fork release asset. -url and -sha256 are paired in flag order. +go run . binary \ + -url https://github.com/loft-sh/kine/releases/download/v0.16.5.vcluster.1/kine \ + -sha256 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef \ + -signer-workflow loft-sh/kine=.github/workflows/release.yml ``` | Flag | Meaning | | --- | --- | | `-repo`, `-ref` | `ref` mode: the fork and the tag or commit to check | | `-dir` | `go` mode: module directory, repeatable. S is the commit Go downloaded (`go mod download -json`, `Origin.Hash`), not today's tag | +| `-image` | `image` mode: a `ghcr.io//:@sha256:` image, repeatable | +| `-url`, `-sha256` | `binary` mode: a GitHub release asset URL and its 64-character SHA-256, paired and repeatable | +| `-signer-workflow` | `fork=.github/workflows/release.yml`, repeatable. Required for every image or binary fork | | `-vendor-verifier` | `go` mode: trusted command that verifies the committed vendor tree in a temporary checkout. Required when a module has `vendor/` | | `-upstream` | `fork=upstream[@branch]`, used only while a fork has no `fork-upstream` property. The missing property still fails; the override only keeps the per-commit evidence coming | | `-as-of` | RFC 3339 time for rule 3 | @@ -62,7 +76,7 @@ Reading PRs, reviews and compares on public forks works with any token. The appr ## Not yet covered -Container images by digest, release binaries by sha256, source clones, and the fail-closed net for unknown fork references are step 4 of the design. +Source clones and the fail-closed net for unknown fork references are still step 4 of the design. ## Tests diff --git a/.github/actions/fork-review-check/src/attest.go b/.github/actions/fork-review-check/src/attest.go new file mode 100644 index 00000000..f1a3c5b0 --- /dev/null +++ b/.github/actions/fork-review-check/src/attest.go @@ -0,0 +1,53 @@ +package main + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "strings" +) + +// attestTool verifies the provenance of a build input; tests replace it. +type attestTool interface { + Verify(ctx context.Context, subject string, args attestArgs) error +} + +type attestArgs struct { + Owner string + Repo string + SignerWorkflow string + SignerDigest string + SourceDigest string + SourceRef string +} + +type execAttest struct{} + +func (execAttest) Verify(ctx context.Context, subject string, a attestArgs) error { + args := []string{"attestation", "verify", subject} + switch { + case a.Owner != "": + args = append(args, "--owner", a.Owner) + case a.Repo != "": + args = append(args, "--repo", a.Repo) + default: + return fmt.Errorf("attestation verifier needs an owner or repository") + } + args = append(args, + "--signer-workflow", a.SignerWorkflow, + "--signer-digest", a.SignerDigest, + "--source-digest", a.SourceDigest, + "--source-ref", a.SourceRef, + "--deny-self-hosted-runners", + "--format", "json", + ) + + cmd := exec.CommandContext(ctx, "gh", args...) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if _, err := cmd.Output(); err != nil { + return fmt.Errorf("gh %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(stderr.String())) + } + return nil +} diff --git a/.github/actions/fork-review-check/src/download.go b/.github/actions/fork-review-check/src/download.go new file mode 100644 index 00000000..56439f29 --- /dev/null +++ b/.github/actions/fork-review-check/src/download.go @@ -0,0 +1,68 @@ +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "os" +) + +// downloadTool downloads a release asset to a temporary file; tests replace it. +type downloadTool interface { + Download(ctx context.Context, url string) (string, error) +} + +type execDownload struct{} + +func (execDownload) Download(ctx context.Context, url string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return "", fmt.Errorf("GET %s: %s", url, resp.Status) + } + + f, err := os.CreateTemp("", "fork-review-check-*") + if err != nil { + return "", err + } + path := f.Name() + ok := false + defer func() { + if !ok { + _ = os.Remove(path) + } + }() + if _, err := io.Copy(f, resp.Body); err != nil { + _ = f.Close() + return "", err + } + if err := f.Close(); err != nil { + return "", err + } + ok = true + return path, nil +} + +func sha256File(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} diff --git a/.github/actions/fork-review-check/src/gomod.go b/.github/actions/fork-review-check/src/gomod.go index 2611e247..18ba807a 100644 --- a/.github/actions/fork-review-check/src/gomod.go +++ b/.github/actions/fork-review-check/src/gomod.go @@ -50,15 +50,18 @@ type goTool interface { // inputRecord is one build input the receipt lists, with its hash. type inputRecord struct { - Kind string `json:"kind"` - Source string `json:"source"` - Module string `json:"module,omitempty"` - Path string `json:"path"` - Version string `json:"version"` - Repo string `json:"repo"` - Commit string `json:"commit"` - Sum string `json:"sum,omitempty"` - Fork bool `json:"fork"` + Kind string `json:"kind"` + Source string `json:"source"` + Module string `json:"module,omitempty"` + Path string `json:"path"` + Version string `json:"version"` + Repo string `json:"repo"` + Commit string `json:"commit"` + Sum string `json:"sum,omitempty"` + Digest string `json:"digest,omitempty"` + SHA256 string `json:"sha256,omitempty"` + SignerWorkflow string `json:"signer_workflow,omitempty"` + Fork bool `json:"fork"` } // goForkInputs resolves the loft-sh modules in the effective module graph of diff --git a/.github/actions/fork-review-check/src/gomod_test.go b/.github/actions/fork-review-check/src/gomod_test.go index 7b346d8b..1cc97491 100644 --- a/.github/actions/fork-review-check/src/gomod_test.go +++ b/.github/actions/fork-review-check/src/gomod_test.go @@ -326,7 +326,7 @@ func TestGoModeChecksOnlyForks(t *testing.T) { f.repos["loft-sh/tailscale"] = repoInfo{FullName: "loft-sh/tailscale", Fork: true} f.upstreams["loft-sh/tailscale"] = "tailscale/tailscale" - rc, err := check(context.Background(), f, vnodeGraph(), options{mode: "go", dirs: multiFlag{"vnode"}}, time.Now()) + rc, err := check(context.Background(), f, vnodeGraph(), nil, nil, options{mode: "go", dirs: multiFlag{"vnode"}}, time.Now()) if err != nil { t.Fatal(err) } diff --git a/.github/actions/fork-review-check/src/inputs.go b/.github/actions/fork-review-check/src/inputs.go new file mode 100644 index 00000000..d92f8f73 --- /dev/null +++ b/.github/actions/fork-review-check/src/inputs.go @@ -0,0 +1,169 @@ +package main + +import ( + "context" + "fmt" + "net/url" + "os" + "regexp" + "strings" +) + +var imageDigestRe = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) +var sha256Re = regexp.MustCompile(`^[0-9a-f]{64}$`) + +type imageReference struct { + Repo string + Owner string + Tag string + Digest string +} + +type binaryReference struct { + Repo string + Tag string + URL string +} + +func parseSignerWorkflows(values []string) (map[string]string, error) { + out := map[string]string{} + for _, value := range values { + repo, workflow, ok := strings.Cut(value, "=") + if !ok || !validRepo(repo) || workflow == "" { + return nil, fmt.Errorf("-signer-workflow %q: want fork=workflow path", value) + } + out[repo] = workflow + } + return out, nil +} + +func imagePin(ctx context.Context, api forkAPI, attest attestTool, signers map[string]string, source string) (pin, inputRecord, string, error) { + ref, failure := parseImageReference(source) + if failure != "" { + return pin{}, inputRecord{}, failure, nil + } + workflow, failure := signerWorkflow(signers, ref.Repo) + if failure != "" { + return pin{}, inputRecord{}, fmt.Sprintf("image %s: %s", source, failure), nil + } + commit, err := api.ResolveTag(ctx, ref.Repo, ref.Tag) + if err != nil { + return pin{}, inputRecord{}, "", fmt.Errorf("resolve image tag %s in %s: %w", ref.Tag, ref.Repo, err) + } + if commit == "" { + return pin{}, inputRecord{}, fmt.Sprintf("image %s: %s has no tag %s", source, ref.Repo, ref.Tag), nil + } + if err := attest.Verify(ctx, "oci://"+source, attestArgs{ + Owner: ref.Owner, + SignerWorkflow: workflow, + SignerDigest: commit, + SourceDigest: commit, + SourceRef: "refs/tags/" + ref.Tag, + }); err != nil { + return pin{}, inputRecord{}, fmt.Sprintf("image %s: verify provenance: %v", source, err), nil + } + return pin{Repo: ref.Repo, Ref: ref.Tag, Tag: ref.Tag, Commit: commit}, inputRecord{ + Kind: "image", + Source: source, + Path: "ghcr.io/" + ref.Repo, + Version: ref.Tag, + Repo: ref.Repo, + Commit: commit, + Digest: ref.Digest, + SignerWorkflow: workflow, + Fork: true, + }, "", nil +} + +func binaryPin(ctx context.Context, api forkAPI, attest attestTool, download downloadTool, signers map[string]string, source, want string) (pin, inputRecord, string, error) { + ref, failure := parseBinaryReference(source) + if failure != "" { + return pin{}, inputRecord{}, failure, nil + } + if !sha256Re.MatchString(want) { + return pin{}, inputRecord{}, fmt.Sprintf("binary %s: -sha256 must be 64 lowercase hexadecimal characters", source), nil + } + workflow, failure := signerWorkflow(signers, ref.Repo) + if failure != "" { + return pin{}, inputRecord{}, fmt.Sprintf("binary %s: %s", source, failure), nil + } + + path, err := download.Download(ctx, source) + if err != nil { + return pin{}, inputRecord{}, "", fmt.Errorf("download binary %s: %w", source, err) + } + defer os.Remove(path) + got, err := sha256File(path) + if err != nil { + return pin{}, inputRecord{}, "", fmt.Errorf("hash binary %s: %w", source, err) + } + if got != want { + return pin{}, inputRecord{}, fmt.Sprintf("binary %s: sha256 is %s, want %s", source, got, want), nil + } + + commit, err := api.ResolveTag(ctx, ref.Repo, ref.Tag) + if err != nil { + return pin{}, inputRecord{}, "", fmt.Errorf("resolve binary tag %s in %s: %w", ref.Tag, ref.Repo, err) + } + if commit == "" { + return pin{}, inputRecord{}, fmt.Sprintf("binary %s: %s has no tag %s", source, ref.Repo, ref.Tag), nil + } + if err := attest.Verify(ctx, path, attestArgs{ + Repo: ref.Repo, + SignerWorkflow: workflow, + SignerDigest: commit, + SourceDigest: commit, + SourceRef: "refs/tags/" + ref.Tag, + }); err != nil { + return pin{}, inputRecord{}, fmt.Sprintf("binary %s: verify provenance: %v", source, err), nil + } + return pin{Repo: ref.Repo, Ref: ref.Tag, Tag: ref.Tag, Commit: commit}, inputRecord{ + Kind: "binary", + Source: source, + Path: source, + Version: ref.Tag, + Repo: ref.Repo, + Commit: commit, + SHA256: want, + SignerWorkflow: workflow, + Fork: true, + }, "", nil +} + +func parseImageReference(source string) (imageReference, string) { + image, digest, ok := strings.Cut(source, "@") + if !ok || !imageDigestRe.MatchString(digest) { + return imageReference{}, fmt.Sprintf("image %s needs both a fork tag and sha256 digest", source) + } + name, tag, ok := strings.Cut(image, ":") + parts := strings.Split(name, "/") + if !ok || len(parts) != 3 || parts[0] != "ghcr.io" || !validRepo(parts[1]+"/"+parts[2]) || !forkTagRe.MatchString(tag) { + return imageReference{}, fmt.Sprintf("unknown fork image reference %s", source) + } + return imageReference{Repo: parts[1] + "/" + parts[2], Owner: parts[1], Tag: tag, Digest: digest}, "" +} + +func parseBinaryReference(source string) (binaryReference, string) { + u, err := url.Parse(source) + if err != nil || u.Scheme != "https" || u.Host != "github.com" || u.RawQuery != "" || u.Fragment != "" { + return binaryReference{}, fmt.Sprintf("unknown fork binary reference %s", source) + } + parts := strings.Split(strings.TrimPrefix(u.Path, "/"), "/") + if len(parts) != 6 || !validRepo(parts[0]+"/"+parts[1]) || parts[2] != "releases" || parts[3] != "download" || !forkTagRe.MatchString(parts[4]) || parts[5] == "" { + return binaryReference{}, fmt.Sprintf("unknown fork binary reference %s", source) + } + return binaryReference{Repo: parts[0] + "/" + parts[1], Tag: parts[4], URL: source}, "" +} + +func signerWorkflow(signers map[string]string, repo string) (string, string) { + workflow := signers[repo] + if workflow == "" { + return "", fmt.Sprintf("%s has no -signer-workflow mapping", repo) + } + return repo + "/" + strings.TrimPrefix(workflow, "/"), "" +} + +func validRepo(repo string) bool { + parts := strings.Split(repo, "/") + return len(parts) == 2 && parts[0] != "" && parts[1] != "" +} diff --git a/.github/actions/fork-review-check/src/inputs_test.go b/.github/actions/fork-review-check/src/inputs_test.go new file mode 100644 index 00000000..163b7e33 --- /dev/null +++ b/.github/actions/fork-review-check/src/inputs_test.go @@ -0,0 +1,164 @@ +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +type fakeAttest struct { + err error + subjects []string + args []attestArgs +} + +func (f *fakeAttest) Verify(_ context.Context, subject string, args attestArgs) error { + f.subjects = append(f.subjects, subject) + f.args = append(f.args, args) + return f.err +} + +type fakeDownload struct { + path string + url string +} + +func (f fakeDownload) Download(_ context.Context, url string) (string, error) { + if f.url != "" && url != f.url { + return "", fmt.Errorf("downloaded %s, want %s", url, f.url) + } + return f.path, nil +} + +func imageSource(digest string) string { + return "ghcr.io/loft-sh/kine:" + tag + "@" + digest +} + +func TestImageModeResolvesAttestedImage(t *testing.T) { + digest := "sha256:" + strings.Repeat("a", 64) + source := imageSource(digest) + attest := &fakeAttest{} + rc, err := check(context.Background(), reviewedFork(), nil, attest, nil, options{ + mode: "image", + images: multiFlag{source}, + signerWorkflows: multiFlag{fork + "=.github/workflows/release.yml"}, + }, t0) + if err != nil { + t.Fatal(err) + } + if !rc.Pass || len(rc.Forks) != 1 || len(rc.Inputs) != 1 { + t.Fatalf("want one passing image: %+v", rc) + } + in := rc.Inputs[0] + if in.Kind != "image" || in.Source != source || in.Digest != digest || in.Commit != merge || in.SignerWorkflow != fork+"/.github/workflows/release.yml" { + t.Errorf("image input = %+v", in) + } + if len(attest.subjects) != 1 || attest.subjects[0] != "oci://"+source { + t.Errorf("attestation subjects = %v", attest.subjects) + } + got := attest.args[0] + if got.Owner != "loft-sh" || got.Repo != "" || got.SignerDigest != merge || got.SourceDigest != merge || got.SourceRef != "refs/tags/"+tag { + t.Errorf("attestation args = %+v", got) + } +} + +func TestImageModeAttestationFailureIsARuleFailure(t *testing.T) { + source := imageSource("sha256:" + strings.Repeat("a", 64)) + attest := &fakeAttest{err: fmt.Errorf("no matching attestation")} + rc, err := check(context.Background(), reviewedFork(), nil, attest, nil, options{ + mode: "image", + images: multiFlag{source}, + signerWorkflows: multiFlag{fork + "=.github/workflows/release.yml"}, + }, t0) + if err != nil { + t.Fatal(err) + } + if rc.Pass || !strings.Contains(strings.Join(rc.Failures, "\n"), source) { + t.Fatalf("want attestation failure naming the image: %+v", rc) + } +} + +func TestImageModeRejectsUnpinnedAndUnknownImages(t *testing.T) { + for _, source := range []string{ + "ghcr.io/loft-sh/kine:" + tag, + "quay.io/loft-sh/kine:" + tag + "@sha256:" + strings.Repeat("a", 64), + } { + rc, err := check(context.Background(), reviewedFork(), nil, nil, nil, options{ + mode: "image", + images: multiFlag{source}, + signerWorkflows: multiFlag{fork + "=.github/workflows/release.yml"}, + }, t0) + if err != nil { + t.Fatal(err) + } + if rc.Pass || len(rc.Failures) != 1 { + t.Fatalf("%s should fail as one rule failure: %+v", source, rc) + } + } +} + +func TestImageModeNeedsSignerWorkflow(t *testing.T) { + source := imageSource("sha256:" + strings.Repeat("a", 64)) + rc, err := check(context.Background(), reviewedFork(), nil, nil, nil, options{ + mode: "image", + images: multiFlag{source}, + }, t0) + if err != nil { + t.Fatal(err) + } + if rc.Pass || !strings.Contains(strings.Join(rc.Failures, "\n"), "no -signer-workflow") { + t.Fatalf("want the missing signer workflow to fail: %+v", rc) + } +} + +func TestBinaryModeResolvesAttestedAsset(t *testing.T) { + source := "https://github.com/loft-sh/kine/releases/download/" + tag + "/kine" + path := filepath.Join(t.TempDir(), "kine") + if err := os.WriteFile(path, []byte("kine"), 0o600); err != nil { + t.Fatal(err) + } + sum := sha256.Sum256([]byte("kine")) + want := hex.EncodeToString(sum[:]) + attest := &fakeAttest{} + rc, err := check(context.Background(), reviewedFork(), nil, attest, fakeDownload{path: path, url: source}, options{ + mode: "binary", + urls: multiFlag{source}, + sha256: multiFlag{want}, + signerWorkflows: multiFlag{fork + "=.github/workflows/release.yml"}, + }, t0) + if err != nil { + t.Fatal(err) + } + if !rc.Pass || len(rc.Inputs) != 1 || rc.Inputs[0].Kind != "binary" || rc.Inputs[0].SHA256 != want { + t.Fatalf("want one passing binary: %+v", rc) + } + if len(attest.args) != 1 || attest.args[0].Repo != fork || attest.args[0].Owner != "" { + t.Errorf("attestation args = %+v", attest.args) + } +} + +func TestBinaryModeRejectsSHA256Mismatch(t *testing.T) { + source := "https://github.com/loft-sh/kine/releases/download/" + tag + "/kine" + path := filepath.Join(t.TempDir(), "kine") + if err := os.WriteFile(path, []byte("kine"), 0o600); err != nil { + t.Fatal(err) + } + attest := &fakeAttest{} + rc, err := check(context.Background(), reviewedFork(), nil, attest, fakeDownload{path: path, url: source}, options{ + mode: "binary", + urls: multiFlag{source}, + sha256: multiFlag{strings.Repeat("0", 64)}, + signerWorkflows: multiFlag{fork + "=.github/workflows/release.yml"}, + }, t0) + if err != nil { + t.Fatal(err) + } + if rc.Pass || !strings.Contains(strings.Join(rc.Failures, "\n"), "sha256 is") || len(attest.args) != 0 { + t.Fatalf("want a checksum rule failure before attestation: %+v", rc) + } +} diff --git a/.github/actions/fork-review-check/src/main.go b/.github/actions/fork-review-check/src/main.go index 40b4832b..53e5c7f6 100644 --- a/.github/actions/fork-review-check/src/main.go +++ b/.github/actions/fork-review-check/src/main.go @@ -13,11 +13,14 @@ // 3. With -as-of, every approval predates that time (the product release). // 4. It fails and names every commit that breaks a rule. // -// It reads GitHub and the go command. For vendored modules, it runs a trusted -// verifier in a temporary checkout, and it can write a receipt. +// It reads GitHub, the go command and attestation evidence. For vendored modules, +// it runs a trusted verifier in a temporary checkout. It can write a receipt and +// temporary downloaded release assets. // // fork-review-check ref -repo loft-sh/kine -ref v0.16.5.vcluster.1 // fork-review-check go -dir . -dir ./sub +// fork-review-check image -image ghcr.io/loft-sh/kine:v0.16.5.vcluster.1@sha256:... +// fork-review-check binary -url https://github.com/loft-sh/kine/releases/download/v0.16.5.vcluster.1/kine -sha256 ... // // Exit codes: 0 pass (or -advisory), 1 a rule failed, 2 the check could not run. package main @@ -63,15 +66,19 @@ func (m *multiFlag) String() string { return strings.Join(*m, ",") } func (m *multiFlag) Set(v string) error { *m = append(*m, v); return nil } type options struct { - mode string - repo string - ref string - dirs multiFlag - vendorVerifier string - upstreams multiFlag - asOf string - receipt string - advisory bool + mode string + repo string + ref string + dirs multiFlag + vendorVerifier string + images multiFlag + urls multiFlag + sha256 multiFlag + upstreams multiFlag + signerWorkflows multiFlag + asOf string + receipt string + advisory bool } func main() { @@ -97,7 +104,7 @@ func run(args []string, stdout io.Writer) int { return 2 } - rc, err := check(context.Background(), githubAPI{gh: gh}, execGo{}, opts, time.Now().UTC()) + rc, err := check(context.Background(), githubAPI{gh: gh}, execGo{}, execAttest{}, execDownload{}, opts, time.Now().UTC()) if err != nil { log.Printf("::error::%v", err) return 2 @@ -128,7 +135,7 @@ func run(args []string, stdout io.Writer) int { func parseArgs(args []string) (options, error) { var opts options if len(args) == 0 { - return opts, errors.New("usage: fork-review-check ref|go [flags]") + return opts, errors.New("usage: fork-review-check ref|go|image|binary [flags]") } opts.mode = args[0] @@ -136,8 +143,12 @@ func parseArgs(args []string) (options, error) { fs.StringVar(&opts.repo, "repo", "", "ref mode: fork repository, owner/name") fs.StringVar(&opts.ref, "ref", "", "ref mode: fork release tag or commit SHA") fs.Var(&opts.dirs, "dir", "go mode: module directory to resolve (repeatable)") + fs.Var(&opts.images, "image", "image mode: digest-pinned ghcr.io fork image (repeatable)") + fs.Var(&opts.urls, "url", "binary mode: GitHub release asset URL (repeatable)") + fs.Var(&opts.sha256, "sha256", "binary mode: release asset SHA-256, paired with -url (repeatable)") fs.StringVar(&opts.vendorVerifier, "vendor-verifier", "", "go mode: trusted command to verify vendor in an isolated checkout") fs.Var(&opts.upstreams, "upstream", "fork=upstream[@branch] used when a fork has no fork-upstream property; in ref mode a bare upstream applies to -repo (repeatable)") + fs.Var(&opts.signerWorkflows, "signer-workflow", "fork=workflow path used to verify an image or binary (repeatable)") fs.StringVar(&opts.asOf, "as-of", "", "RFC 3339 time every approval must predate (rule 3), normally the product release time") fs.StringVar(&opts.receipt, "receipt", "", "write the JSON receipt to this path") fs.BoolVar(&opts.advisory, "advisory", false, "report failures as warnings and exit 0") @@ -154,14 +165,22 @@ func parseArgs(args []string) (options, error) { if len(opts.dirs) == 0 { opts.dirs = multiFlag{"."} } + case "image": + if len(opts.images) == 0 { + return opts, errors.New("image mode needs -image") + } + case "binary": + if len(opts.urls) == 0 || len(opts.sha256) == 0 || len(opts.urls) != len(opts.sha256) { + return opts, errors.New("binary mode needs paired -url and -sha256 flags") + } default: - return opts, fmt.Errorf("unknown mode %q: want ref or go", opts.mode) + return opts, fmt.Errorf("unknown mode %q: want ref, go, image or binary", opts.mode) } return opts, nil } // check resolves the pins for the mode and evaluates every fork. -func check(ctx context.Context, api forkAPI, tool goTool, opts options, now time.Time) (receipt, error) { +func check(ctx context.Context, api forkAPI, tool goTool, attest attestTool, download downloadTool, opts options, now time.Time) (receipt, error) { rc := receipt{Schema: receiptSchema, CLIVersion: version, Mode: opts.mode, CheckedAt: now, Inputs: []inputRecord{}, Forks: []forkResult{}} var asOf time.Time @@ -177,6 +196,10 @@ func check(ctx context.Context, api forkAPI, tool goTool, opts options, now time if err != nil { return rc, err } + signers, err := parseSignerWorkflows(opts.signerWorkflows) + if err != nil { + return rc, err + } var pins []pin switch opts.mode { @@ -214,6 +237,32 @@ func check(ctx context.Context, api forkAPI, tool goTool, opts options, now time } } } + case "image": + for _, source := range opts.images { + p, in, failure, err := imagePin(ctx, api, attest, signers, source) + if err != nil { + return rc, err + } + if failure != "" { + rc.Failures = append(rc.Failures, failure) + continue + } + pins = append(pins, p) + rc.Inputs = append(rc.Inputs, in) + } + case "binary": + for i, source := range opts.urls { + p, in, failure, err := binaryPin(ctx, api, attest, download, signers, source, opts.sha256[i]) + if err != nil { + return rc, err + } + if failure != "" { + rc.Failures = append(rc.Failures, failure) + continue + } + pins = append(pins, p) + rc.Inputs = append(rc.Inputs, in) + } } c := newChecker(api, asOf, overrides) diff --git a/.github/actions/fork-review-check/src/main_test.go b/.github/actions/fork-review-check/src/main_test.go index 89303a7c..2461d425 100644 --- a/.github/actions/fork-review-check/src/main_test.go +++ b/.github/actions/fork-review-check/src/main_test.go @@ -10,7 +10,7 @@ import ( ) func TestRefModeResolvesTag(t *testing.T) { - rc, err := check(context.Background(), reviewedFork(), nil, options{mode: "ref", repo: fork, ref: tag}, time.Now()) + rc, err := check(context.Background(), reviewedFork(), nil, nil, nil, options{mode: "ref", repo: fork, ref: tag}, time.Now()) if err != nil { t.Fatal(err) } @@ -27,7 +27,7 @@ func TestRefModeAcceptsFullSHA(t *testing.T) { f.repos[upstream] = repoInfo{FullName: upstream, DefaultBranch: "master"} f.compares[fork+"@k3s-io:master..."+merge] = comparison{Status: "diverged", Total: 3, MergeBase: baseSHA, Commits: f.compares[fork+"@"+baseSHA+"..."+merge].Commits} sha := merge - rc, err := check(context.Background(), f, nil, options{mode: "ref", repo: fork, ref: sha}, time.Now()) + rc, err := check(context.Background(), f, nil, nil, nil, options{mode: "ref", repo: fork, ref: sha}, time.Now()) if err != nil { t.Fatal(err) } @@ -37,7 +37,7 @@ func TestRefModeAcceptsFullSHA(t *testing.T) { } func TestRefModeUnknownRefFails(t *testing.T) { - rc, err := check(context.Background(), reviewedFork(), nil, options{mode: "ref", repo: fork, ref: "v9.9.9"}, time.Now()) + rc, err := check(context.Background(), reviewedFork(), nil, nil, nil, options{mode: "ref", repo: fork, ref: "v9.9.9"}, time.Now()) if err != nil { t.Fatal(err) } @@ -47,7 +47,7 @@ func TestRefModeUnknownRefFails(t *testing.T) { } func TestAsOfFlagAppliesRule3(t *testing.T) { - rc, err := check(context.Background(), reviewedFork(), nil, options{mode: "ref", repo: fork, ref: tag, asOf: t0.Add(30 * time.Minute).Format(time.RFC3339)}, time.Now()) + rc, err := check(context.Background(), reviewedFork(), nil, nil, nil, options{mode: "ref", repo: fork, ref: tag, asOf: t0.Add(30 * time.Minute).Format(time.RFC3339)}, time.Now()) if err != nil { t.Fatal(err) } @@ -61,7 +61,13 @@ func TestParseArgs(t *testing.T) { t.Error("ref mode without -ref must fail") } if _, err := parseArgs([]string{"image"}); err == nil { - t.Error("an unknown mode must fail") + t.Error("image mode without -image must fail") + } + if _, err := parseArgs([]string{"binary", "-url", "https://github.com/loft-sh/kine/releases/download/" + tag + "/kine"}); err == nil { + t.Error("binary mode without -sha256 must fail") + } + if _, err := parseSignerWorkflows([]string{"loft-sh/kine"}); err == nil { + t.Error("a signer workflow without a mapping must fail") } o, err := parseArgs([]string{"go"}) if err != nil || len(o.dirs) != 1 || o.dirs[0] != "." { @@ -74,7 +80,7 @@ func TestGoModeRequiresVendorVerification(t *testing.T) { if err := os.Mkdir(filepath.Join(dir, "vendor"), 0o755); err != nil { t.Fatal(err) } - rc, err := check(context.Background(), newFake(), vnodeGraph(), options{mode: "go", dirs: multiFlag{dir}}, time.Now()) + rc, err := check(context.Background(), newFake(), vnodeGraph(), nil, nil, options{mode: "go", dirs: multiFlag{dir}}, time.Now()) if err != nil { t.Fatal(err) } @@ -106,7 +112,7 @@ func TestParseUpstreams(t *testing.T) { func TestReportsNameFailures(t *testing.T) { f := reviewedFork() delete(f.pulls, fork+"@"+c1) - rc, _ := check(context.Background(), f, nil, options{mode: "ref", repo: fork, ref: tag}, time.Now()) + rc, _ := check(context.Background(), f, nil, nil, nil, options{mode: "ref", repo: fork, ref: tag}, time.Now()) text, md := renderText(rc), renderMarkdown(rc) for _, want := range []string{"FAIL", short(c1), "direct push"} { if !strings.Contains(text, want) || !strings.Contains(md, want) { diff --git a/.github/actions/fork-review-check/test/live-image.sh b/.github/actions/fork-review-check/test/live-image.sh new file mode 100755 index 00000000..88ee4c12 --- /dev/null +++ b/.github/actions/fork-review-check/test/live-image.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Runs image mode against the real registry and attestations and checks one +# expectation: WANT=attested means the image's provenance verified, so it is +# listed as an input; WANT=rejected means verification failed and is reported. +set -euo pipefail + +receipt=$(mktemp) +go run . image -image "${IMAGE:?}" \ + -signer-workflow loft-sh/kine=.github/workflows/release.yml \ + -upstream loft-sh/kine=k3s-io/kine \ + -advisory -receipt "$receipt" + +attested=$(jq '[.inputs[] | select(.kind == "image")] | length' "$receipt") +rejected=$(jq '[.failures[]? | select(contains("verify provenance"))] | length' "$receipt") +case "${WANT:?}" in + attested) [[ "$attested" == 1 && "$rejected" == 0 ]] ;; + rejected) [[ "$attested" == 0 && "$rejected" == 1 ]] ;; + *) echo "::error::WANT must be attested or rejected" >&2; exit 2 ;; +esac || { echo "::error::$IMAGE: want $WANT, got attested=$attested rejected=$rejected" >&2; exit 1; } +echo "$IMAGE: $WANT as expected" diff --git a/.github/workflows/test-fork-review-check.yaml b/.github/workflows/test-fork-review-check.yaml index 5bb91efe..1ed440f1 100644 --- a/.github/workflows/test-fork-review-check.yaml +++ b/.github/workflows/test-fork-review-check.yaml @@ -67,3 +67,34 @@ jobs: REF: ${{ matrix.ref }} UPSTREAM: ${{ matrix.upstream }} run: go run . ref -repo "$REPO" -ref "$REF" -upstream "$UPSTREAM" -advisory -receipt /dev/stdout + + # The image path against the real registry and attestations: the kine digest + # loft-enterprise pins must verify against its tag, and the same digest under + # another tag (a bump of KINE_VERSION alone) must not. + live-image: + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - image: ghcr.io/loft-sh/kine:v0.16.5.vcluster.1@sha256:7e72281879c7683b3d28d48b330e2f2db9d8a53a8015db10712819a0b8ec855a + want: attested + - image: ghcr.io/loft-sh/kine:v0.17.2.vcluster.0@sha256:7e72281879c7683b3d28d48b330e2f2db9d8a53a8015db10712819a0b8ec855a + want: rejected + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: .github/actions/fork-review-check/src/go.mod + - name: Check ${{ matrix.image }} + working-directory: .github/actions/fork-review-check/src + env: + GITHUB_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ github.token }} + IMAGE: ${{ matrix.image }} + WANT: ${{ matrix.want }} + run: bash ../test/live-image.sh