From c9cb3fcf29e51d16ba5a15a98c027ef8cee4d387 Mon Sep 17 00:00:00 2001 From: Piotr Zaniewski Date: Tue, 27 Oct 2026 10:06:23 +0100 Subject: [PATCH] feat(fork-review-check): verify digest-pinned images and release binaries loft-enterprise now pins kine by tag and digest, and the digest wins: a bump of KINE_VERSION alone builds the old image under the new label. A check that the digest matches the tag would be wrong too, since a fork tag can point at the wrong build (v0.14.16.vcluster.4 holds upstream kine v0.13.5). The design's rule closes both: the digest's build provenance must name the fork's release workflow, the tag, and the tag's commit. image mode resolves the tag to its commit, runs gh attestation verify on the digest with signer workflow, signer and source digest, source ref and no self-hosted runners, then checks the fork commits like ref mode. binary mode downloads a release asset, compares its sha256, and verifies the asset's attestation the same way. Both fail closed on an unknown reference or a missing signer workflow. Checked against the registry with gh attestation verify: the kine digest loft-enterprise pins verifies under v0.16.5.vcluster.1 and is rejected under v0.17.2.vcluster.0; the live-image CI job repeats both cases. References DEVOPS-1740 Claude-Session: https://claude.ai/code/session_017edg9gFKBwMnmsAVXxEJzn --- .github/actions/fork-review-check/README.md | 16 +- .../actions/fork-review-check/src/attest.go | 53 ++++++ .../actions/fork-review-check/src/download.go | 68 +++++++ .../actions/fork-review-check/src/gomod.go | 21 ++- .../fork-review-check/src/gomod_test.go | 2 +- .../actions/fork-review-check/src/inputs.go | 169 ++++++++++++++++++ .../fork-review-check/src/inputs_test.go | 164 +++++++++++++++++ .github/actions/fork-review-check/src/main.go | 75 ++++++-- .../fork-review-check/src/main_test.go | 18 +- .../fork-review-check/test/live-image.sh | 20 +++ .github/workflows/test-fork-review-check.yaml | 31 ++++ 11 files changed, 607 insertions(+), 30 deletions(-) create mode 100644 .github/actions/fork-review-check/src/attest.go create mode 100644 .github/actions/fork-review-check/src/download.go create mode 100644 .github/actions/fork-review-check/src/inputs.go create mode 100644 .github/actions/fork-review-check/src/inputs_test.go create mode 100755 .github/actions/fork-review-check/test/live-image.sh diff --git a/.github/actions/fork-review-check/README.md b/.github/actions/fork-review-check/README.md index 20539d20..d09db605 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 | | `-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 | | `-receipt` | Write the JSON receipt to this path | @@ -57,7 +71,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 ce019c75..dfe821bb 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 ff67139d..ea3c9d48 100644 --- a/.github/actions/fork-review-check/src/gomod_test.go +++ b/.github/actions/fork-review-check/src/gomod_test.go @@ -130,7 +130,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 b51465c0..68f5f3f1 100644 --- a/.github/actions/fork-review-check/src/main.go +++ b/.github/actions/fork-review-check/src/main.go @@ -13,10 +13,13 @@ // 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 only; it writes nothing but its receipt. +// It reads GitHub, the go command and attestation evidence. It writes only its +// 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 @@ -62,14 +65,18 @@ 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 - upstreams multiFlag - asOf string - receipt string - advisory bool + mode string + repo string + ref string + dirs multiFlag + images multiFlag + urls multiFlag + sha256 multiFlag + upstreams multiFlag + signerWorkflows multiFlag + asOf string + receipt string + advisory bool } func main() { @@ -95,7 +102,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 @@ -126,7 +133,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] @@ -134,7 +141,11 @@ 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.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") @@ -151,14 +162,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 @@ -174,6 +193,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 { @@ -207,6 +230,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 edd2656d..29788172 100644 --- a/.github/actions/fork-review-check/src/main_test.go +++ b/.github/actions/fork-review-check/src/main_test.go @@ -8,7 +8,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) } @@ -25,7 +25,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) } @@ -35,7 +35,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) } @@ -45,7 +45,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) } @@ -59,7 +59,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] != "." { @@ -83,7 +89,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