diff --git a/README.md b/README.md index d318159..50a2e40 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,8 @@ The following process is used to create a VSA: `--policy-url`: Location of policy bundle that will be used to determine VSA result. Supports http(s) urls for unauthenticated external downloads. -Absolute and relative paths can be used for an existing, local bundle. +Absolute and relative paths can be used for an existing, local bundle or directory. +It's also possible to download files from a particular GitHub commit Examples: @@ -128,6 +129,8 @@ Examples: - `bundle.tar.gz` - `../bundle.tar.gz` - `/Users/myhome/bundle.tar.gz` +- `../policy` +- `https://github.com/liatrio/gh-trusted-builds-policy/tree/ef3194db6ca9a7a4b030686e4669c45db360a0c2/policy` `--signer-identities-query`: A Rego query that should specify the expected attestation signer identities. The result should be a list of objects that can be unmarshalled into `cosign.Identity`. Defaults to `data.governance.signer_identities`. diff --git a/cmd/vsa.go b/cmd/vsa.go index 65f09a0..782891f 100644 --- a/cmd/vsa.go +++ b/cmd/vsa.go @@ -13,6 +13,8 @@ func VsaCmd() *cobra.Command { Use: "vsa", Short: "Creates a SLSA verification summary attestation by evaluating an artifact against an OPA policy", RunE: func(cmd *cobra.Command, args []string) error { + opts.GetTokenFromEnv() + return vsa.Attest(opts) }, } diff --git a/internal/attestors/vsa/vsa.go b/internal/attestors/vsa/vsa.go index 79caefa..8c8578e 100644 --- a/internal/attestors/vsa/vsa.go +++ b/internal/attestors/vsa/vsa.go @@ -1,16 +1,22 @@ package vsa import ( + "archive/tar" + "bytes" + "compress/gzip" "context" "encoding/base64" "encoding/json" "errors" "fmt" + gh "github.com/liatrio/gh-trusted-builds-attestations/internal/github" "io" "log" "net/http" - "net/url" "os" + "path/filepath" + "regexp" + "strings" "time" "github.com/google/go-containerregistry/pkg/name" @@ -25,16 +31,22 @@ import ( "github.com/sigstore/sigstore/pkg/fulcioroots" ) +var ( + matchTreeUrl = regexp.MustCompile(`https://github\.com/(?P[^/]+)/(?P[^/]+)/tree/(?P[^/]+)/(?P[^?]+)`) +) + func Attest(opts *config.VsaCommandOptions) error { ctx := context.Background() + var err error + bundlePath := opts.PolicyUrl.String() if opts.PolicyUrl.Value().IsAbs() { - if err := downloadOPABundle(ctx, opts, policyBundleFilePath(opts.PolicyUrl.Value())); err != nil { + if bundlePath, err = downloadOPABundle(ctx, opts); err != nil { return err } } - identities, err := querySignerIdentitiesFromPolicy(ctx, opts) + identities, err := querySignerIdentitiesFromPolicy(ctx, opts, bundlePath) if err != nil { return err } @@ -44,7 +56,7 @@ func Attest(opts *config.VsaCommandOptions) error { return err } - allowed, err := evaluatePolicy(ctx, opts, attestations) + allowed, err := evaluatePolicy(ctx, opts, bundlePath, attestations) if err != nil { return err } @@ -118,12 +130,12 @@ func collectAttestations(ctx context.Context, opts *config.VsaCommandOptions, id return attestations, nil } -func querySignerIdentitiesFromPolicy(ctx context.Context, opts *config.VsaCommandOptions) ([]cosign.Identity, error) { +func querySignerIdentitiesFromPolicy(ctx context.Context, opts *config.VsaCommandOptions, bundlePath string) ([]cosign.Identity, error) { r := rego.New( rego.Query(opts.SignerIdentitiesQuery), rego.EnablePrintStatements(opts.Debug), rego.PrintHook(topdown.NewPrintHook(os.Stderr)), - rego.LoadBundle(policyBundleFilePath(opts.PolicyUrl.Value())), + rego.LoadBundle(bundlePath), ) rs, err := r.Eval(ctx) @@ -148,7 +160,7 @@ func querySignerIdentitiesFromPolicy(ctx context.Context, opts *config.VsaComman return identities, nil } -func evaluatePolicy(ctx context.Context, opts *config.VsaCommandOptions, attestations []oci.Signature) (bool, error) { +func evaluatePolicy(ctx context.Context, opts *config.VsaCommandOptions, bundlePath string, attestations []oci.Signature) (bool, error) { var input []map[string]string for _, attestation := range attestations { @@ -180,7 +192,7 @@ func evaluatePolicy(ctx context.Context, opts *config.VsaCommandOptions, attesta rego.Input(input), rego.EnablePrintStatements(opts.Debug), rego.PrintHook(topdown.NewPrintHook(os.Stderr)), - rego.LoadBundle(policyBundleFilePath(opts.PolicyUrl.Value())), + rego.LoadBundle(bundlePath), ) rs, err := r.Eval(ctx) @@ -191,38 +203,122 @@ func evaluatePolicy(ctx context.Context, opts *config.VsaCommandOptions, attesta return rs.Allowed(), nil } -func downloadOPABundle(ctx context.Context, opts *config.VsaCommandOptions, outputFilepath string) error { +func downloadOPABundle(ctx context.Context, opts *config.VsaCommandOptions) (string, error) { + if matchTreeUrl.MatchString(opts.PolicyUrl.String()) { + return downloadGitHubArchive(ctx, opts) + } + + return downloadBundleArchive(ctx, opts) +} + +func downloadBundleArchive(ctx context.Context, opts *config.VsaCommandOptions) (string, error) { + outputFilePath := "bundle.tar.gz" client := http.Client{Timeout: time.Minute} request, err := http.NewRequestWithContext(ctx, http.MethodGet, opts.PolicyUrl.String(), nil) if err != nil { - return err + return "", err } resp, err := client.Do(request) if err != nil { - return err + return "", err } defer resp.Body.Close() - bundleFile, err := os.Create(outputFilepath) + bundleFile, err := os.Create(outputFilePath) if err != nil { - return err + return "", err } defer bundleFile.Close() _, err = io.Copy(bundleFile, resp.Body) if err != nil { - return err + return "", err } - return nil + return outputFilePath, nil +} + +func downloadGitHubArchive(ctx context.Context, opts *config.VsaCommandOptions) (string, error) { + matches := matchTreeUrl.FindStringSubmatch(opts.PolicyUrl.String()) + if len(matches) == 0 { + return "", fmt.Errorf("unexpected url format") + } + + owner := matches[matchTreeUrl.SubexpIndex("owner")] + repo := matches[matchTreeUrl.SubexpIndex("repo")] + branchOrCommit := matches[matchTreeUrl.SubexpIndex("branchOrCommit")] + path := matches[matchTreeUrl.SubexpIndex("path")] + + githubClient, err := gh.New(ctx, opts.GitHubToken) + if err != nil { + return "", err + } + + archive, err := githubClient.GetRepositoryArchiveAtRef(ctx, &gh.RepositorySlug{Owner: owner, Repo: repo}, branchOrCommit) + if err != nil { + return "", err + } + + tmpDir, err := writeArchiveToTmpDir("vsa-policy-*", archive) + if err != nil { + return "", err + } + + entries, err := os.ReadDir(tmpDir) + if err != nil { + return "", err + } + + // the archive contains a single directory named in the pattern 'org-repo-commitShortSha' + // it's difficult to know this upfront because the user can provide a branch name as well as a commit + dirName := "" + for _, e := range entries { + if e.IsDir() && strings.Contains(e.Name(), repo) { + dirName = e.Name() + } + } + + return filepath.Join(tmpDir, dirName, path), nil } -func policyBundleFilePath(policyUrl *url.URL) string { - if policyUrl.IsAbs() { - return "bundle.tar.gz" +func writeArchiveToTmpDir(tmpDirPrefix string, archive []byte) (string, error) { + tmpDir, err := os.MkdirTemp(os.TempDir(), tmpDirPrefix) + if err != nil { + return "", err + } + + gr, err := gzip.NewReader(bytes.NewReader(archive)) + defer gr.Close() + + tr := tar.NewReader(gr) + for { + header, err := tr.Next() + if err != nil { + break + } + + tmpPath := filepath.Join(tmpDir, header.Name) + + if header.FileInfo().IsDir() { + if err := os.MkdirAll(tmpPath, os.FileMode(header.Mode)); err != nil { + return "", err + } + } else { + file, err := os.Create(tmpPath) + if err != nil { + return "", err + } + + if _, err := io.Copy(file, tr); err != nil { + _ = file.Close() + return "", err + } + + _ = file.Close() + } } - return policyUrl.String() + return tmpDir, nil } diff --git a/internal/config/vsa.go b/internal/config/vsa.go index eca8237..90f268f 100644 --- a/internal/config/vsa.go +++ b/internal/config/vsa.go @@ -2,6 +2,7 @@ package config import ( "github.com/spf13/cobra" + "os" ) type VsaCommandOptions struct { @@ -11,6 +12,7 @@ type VsaCommandOptions struct { Debug bool SignerIdentitiesQuery string PolicyQuery string + GitHubToken string } func NewVsaCommandOptions() *VsaCommandOptions { @@ -33,3 +35,7 @@ func (vsa *VsaCommandOptions) AddFlags(cmd *cobra.Command) { vsa.GlobalOptions.AddFlags(cmd) } + +func (vsa *VsaCommandOptions) GetTokenFromEnv() { + vsa.GitHubToken = os.Getenv("GITHUB_TOKEN") +} diff --git a/internal/github/client.go b/internal/github/client.go index da1a9f0..59899c2 100644 --- a/internal/github/client.go +++ b/internal/github/client.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "io" "log" "net/http" "net/url" @@ -43,6 +44,7 @@ type Client interface { GetPullRequest(ctx context.Context, slug *RepositorySlug, number int) (*github.PullRequest, error) ListPullRequestCommits(ctx context.Context, slug *RepositorySlug, number int) ([]*github.RepositoryCommit, error) ListPullRequestReviews(ctx context.Context, slug *RepositorySlug, number int) ([]*github.PullRequestReview, error) + GetRepositoryArchiveAtRef(ctx context.Context, slug *RepositorySlug, ref string) ([]byte, error) } type githubClient struct { @@ -159,6 +161,34 @@ func (g *githubClient) ListPullRequestReviews(ctx context.Context, slug *Reposit return reviews, nil } +func (g *githubClient) GetRepositoryArchiveAtRef(ctx context.Context, slug *RepositorySlug, ref string) ([]byte, error) { + archiveLocation, _, err := g.github.Repositories.GetArchiveLink(ctx, slug.Owner, slug.Repo, github.Tarball, &github.RepositoryContentGetOptions{ + Ref: ref, + }, 0) + + if err != nil { + return nil, err + } + + response, err := g.github.Client().Get(archiveLocation.String()) + if err != nil { + return nil, err + } + + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) + } + + defer response.Body.Close() + + archive, err := io.ReadAll(response.Body) + if err != nil { + return nil, err + } + + return archive, nil +} + type paginatedEndpoint[T any] func(*github.ListOptions) ([]*T, *github.Response, error) func paginate[T any](endpoint paginatedEndpoint[T]) ([]*T, error) {