Skip to content
Open
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: 15 additions & 1 deletion .github/actions/fork-review-check/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<owner>/<name>:<fork-tag>@sha256:<digest>` 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 |
Expand All @@ -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

Expand Down
53 changes: 53 additions & 0 deletions .github/actions/fork-review-check/src/attest.go
Original file line number Diff line number Diff line change
@@ -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
}
68 changes: 68 additions & 0 deletions .github/actions/fork-review-check/src/download.go
Original file line number Diff line number Diff line change
@@ -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
}
21 changes: 12 additions & 9 deletions .github/actions/fork-review-check/src/gomod.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/actions/fork-review-check/src/gomod_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
169 changes: 169 additions & 0 deletions .github/actions/fork-review-check/src/inputs.go
Original file line number Diff line number Diff line change
@@ -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] != ""
}
Loading
Loading