diff --git a/pkg/cmd/attestation/artifact/file.go b/pkg/cmd/attestation/artifact/file.go index 789a92a5d..237a9bbf7 100644 --- a/pkg/cmd/attestation/artifact/file.go +++ b/pkg/cmd/attestation/artifact/file.go @@ -10,7 +10,7 @@ import ( func digestLocalFileArtifact(filename, digestAlg string) (*DigestedArtifact, error) { data, err := os.Open(filename) if err != nil { - return nil, fmt.Errorf("failed to get open local artifact: %v", err) + return nil, fmt.Errorf("failed to open local artifact: %v", err) } defer data.Close() digest, err := digest.CalculateDigestWithAlgorithm(data, digestAlg) diff --git a/pkg/cmd/release/release.go b/pkg/cmd/release/release.go index f25e8bd3a..f56042c81 100644 --- a/pkg/cmd/release/release.go +++ b/pkg/cmd/release/release.go @@ -10,7 +10,6 @@ import ( cmdUpload "github.com/cli/cli/v2/pkg/cmd/release/upload" cmdVerify "github.com/cli/cli/v2/pkg/cmd/release/verify" cmdVerifyAsset "github.com/cli/cli/v2/pkg/cmd/release/verify-asset" - cmdView "github.com/cli/cli/v2/pkg/cmd/release/view" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/spf13/cobra" diff --git a/pkg/cmd/release/shared/attestation.go b/pkg/cmd/release/shared/attestation.go index a3aa3bea5..4e0377fed 100644 --- a/pkg/cmd/release/shared/attestation.go +++ b/pkg/cmd/release/shared/attestation.go @@ -1,56 +1,58 @@ package shared import ( - "errors" "fmt" + "net/http" - "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmd/attestation/api" "github.com/cli/cli/v2/pkg/cmd/attestation/artifact" + att_io "github.com/cli/cli/v2/pkg/cmd/attestation/io" + "github.com/cli/cli/v2/pkg/cmd/attestation/test/data" "github.com/cli/cli/v2/pkg/cmd/attestation/verification" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/sigstore/sigstore-go/pkg/fulcio/certificate" + "github.com/sigstore/sigstore-go/pkg/verify" v1 "github.com/in-toto/attestation/go/v1" "google.golang.org/protobuf/encoding/protojson" ) -func GetAttestations(o *AttestOptions, sha string) ([]*api.Attestation, string, error) { - if o.APIClient == nil { - errMsg := "X No APIClient provided" - return nil, errMsg, errors.New(errMsg) - } +const ReleasePredicateType = "https://in-toto.io/attestation/release/v0.1" - params := api.FetchParams{ - Digest: sha, - Limit: o.Limit, - Owner: o.Owner, - PredicateType: o.PredicateType, - Repo: o.Repo, - } - - attestations, err := o.APIClient.GetByDigest(params) - if err != nil { - msg := "X Loading attestations from GitHub API failed" - return nil, msg, err - } - pluralAttestation := text.Pluralize(len(attestations), "attestation") - msg := fmt.Sprintf("Loaded %s from GitHub API", pluralAttestation) - return attestations, msg, nil +type Verifier interface { + // VerifyAttestation verifies the attestation for a given artifact + VerifyAttestation(art *artifact.DigestedArtifact, att *api.Attestation) (*verification.AttestationProcessingResult, error) } -func VerifyAttestations(art artifact.DigestedArtifact, att []*api.Attestation, sgVerifier verification.SigstoreVerifier, ec verification.EnforcementCriteria) ([]*verification.AttestationProcessingResult, string, error) { - sgPolicy, err := buildSigstoreVerifyPolicy(ec, art) +type AttestationVerifier struct { + AttClient api.Client + HttpClient *http.Client + IO *iostreams.IOStreams +} + +func (v *AttestationVerifier) VerifyAttestation(art *artifact.DigestedArtifact, att *api.Attestation) (*verification.AttestationProcessingResult, error) { + td, err := v.AttClient.GetTrustDomain() if err != nil { - logMsg := "X Failed to build Sigstore verification policy" - return nil, logMsg, err + return nil, err } - sigstoreVerified, err := sgVerifier.Verify(att, sgPolicy) + verifier, err := verification.NewLiveSigstoreVerifier(verification.SigstoreConfig{ + HttpClient: v.HttpClient, + Logger: att_io.NewHandler(v.IO), + NoPublicGood: true, + TrustDomain: td, + }) if err != nil { - logMsg := "X Sigstore verification failed" - return nil, logMsg, err + return nil, err } - return sigstoreVerified, "", nil + policy := buildVerificationPolicy(*art) + sigstoreVerified, err := verifier.Verify([]*api.Attestation{att}, policy) + if err != nil { + return nil, err + } + + return sigstoreVerified[0], nil } func FilterAttestationsByTag(attestations []*api.Attestation, tagName string) ([]*api.Attestation, error) { @@ -71,7 +73,7 @@ func FilterAttestationsByTag(attestations []*api.Attestation, tagName string) ([ return filtered, nil } -func FilterAttestationsByFileDigest(attestations []*api.Attestation, repo, tagName, fileDigest string) ([]*api.Attestation, error) { +func FilterAttestationsByFileDigest(attestations []*api.Attestation, fileDigest string) ([]*api.Attestation, error) { var filtered []*api.Attestation for _, att := range attestations { statement := att.Bundle.Bundle.GetDsseEnvelope().Payload @@ -95,3 +97,32 @@ func FilterAttestationsByFileDigest(attestations []*api.Attestation, repo, tagNa } return filtered, nil } + +// buildVerificationPolicy constructs a verification policy for GitHub releases +func buildVerificationPolicy(a artifact.DigestedArtifact) verify.PolicyBuilder { + // SAN must match the GitHub releases domain. No issuer extension (match anything) + sanMatcher, _ := verify.NewSANMatcher("", "^https://.*\\.releases\\.github\\.com$") + issuerMatcher, _ := verify.NewIssuerMatcher("", ".*") + certId, _ := verify.NewCertificateIdentity(sanMatcher, issuerMatcher, certificate.Extensions{}) + + artifactDigestPolicyOption, _ := verification.BuildDigestPolicyOption(a) + return verify.NewPolicy(artifactDigestPolicyOption, verify.WithCertificateIdentity(certId)) +} + +type MockVerifier struct { + mockResult *verification.AttestationProcessingResult +} + +func NewMockVerifier(mockResult *verification.AttestationProcessingResult) *MockVerifier { + return &MockVerifier{mockResult: mockResult} +} + +func (v *MockVerifier) VerifyAttestation(art *artifact.DigestedArtifact, att *api.Attestation) (*verification.AttestationProcessingResult, error) { + return &verification.AttestationProcessingResult{ + Attestation: &api.Attestation{ + Bundle: data.GitHubReleaseBundle(nil), + BundleURL: "https://example.com", + }, + VerificationResult: nil, + }, nil +} diff --git a/pkg/cmd/release/shared/options.go b/pkg/cmd/release/shared/options.go deleted file mode 100644 index 86e8ac78b..000000000 --- a/pkg/cmd/release/shared/options.go +++ /dev/null @@ -1,76 +0,0 @@ -package shared - -import ( - "fmt" - "net/http" - "path/filepath" - "strings" - - "github.com/cli/cli/v2/internal/gh" - "github.com/cli/cli/v2/internal/ghinstance" - "github.com/cli/cli/v2/internal/ghrepo" - "github.com/cli/cli/v2/pkg/cmd/attestation/api" - "github.com/cli/cli/v2/pkg/cmd/attestation/io" - "github.com/cli/cli/v2/pkg/cmd/attestation/verification" - "github.com/cli/cli/v2/pkg/cmdutil" - "github.com/cli/cli/v2/pkg/iostreams" -) - -const ReleasePredicateType = "https://in-toto.io/attestation/release/v0.1" - -type AttestOptions struct { - Config func() (gh.Config, error) - HttpClient *http.Client - IO *iostreams.IOStreams - BaseRepo ghrepo.Interface - Exporter cmdutil.Exporter - TagName string - TrustedRoot string - Limit int - Owner string - PredicateType string - Repo string - APIClient api.Client - Logger *io.Handler - SigstoreVerifier verification.SigstoreVerifier - Hostname string - EC verification.EnforcementCriteria - // Tenant is only set when tenancy is used - Tenant string - AssetFilePath string -} - -// Clean cleans the file path option values -func (opts *AttestOptions) Clean() { - if opts.AssetFilePath != "" { - opts.AssetFilePath = filepath.Clean(opts.AssetFilePath) - } -} - -// AreFlagsValid checks that the provided flag combination is valid -// and returns an error otherwise -func (opts *AttestOptions) AreFlagsValid() error { - // If provided, check that the Repo option is in the expected format / - if opts.Repo != "" && !isProvidedRepoValid(opts.Repo) { - return fmt.Errorf("invalid value provided for repo: %s", opts.Repo) - } - - // Check that limit is between 1 and 1000 - if opts.Limit < 1 || opts.Limit > 1000 { - return fmt.Errorf("limit %d not allowed, must be between 1 and 1000", opts.Limit) - } - - if opts.Hostname != "" { - if err := ghinstance.HostnameValidator(opts.Hostname); err != nil { - return fmt.Errorf("error parsing hostname: %w", err) - } - } - - return nil -} - -func isProvidedRepoValid(repo string) bool { - // we expect a provided repository argument be in the format / - splitRepo := strings.Split(repo, "/") - return len(splitRepo) == 2 -} diff --git a/pkg/cmd/release/shared/options_test.go b/pkg/cmd/release/shared/options_test.go deleted file mode 100644 index 7a8fa73dc..000000000 --- a/pkg/cmd/release/shared/options_test.go +++ /dev/null @@ -1,60 +0,0 @@ -package shared - -import ( - "errors" - "testing" -) - -func TestAttestOptions_AreFlagsValid_Valid(t *testing.T) { - opts := &AttestOptions{ - Repo: "owner/repo", - Limit: 10, - } - if err := opts.AreFlagsValid(); err != nil { - t.Errorf("expected no error, got %v", err) - } -} - -func TestAttestOptions_AreFlagsValid_InvalidRepo(t *testing.T) { - opts := &AttestOptions{ - Repo: "invalidrepo", - } - err := opts.AreFlagsValid() - if err == nil || !errors.Is(err, err) { - t.Errorf("expected error for invalid repo, got %v", err) - } -} - -func TestAttestOptions_AreFlagsValid_LimitTooLow(t *testing.T) { - opts := &AttestOptions{ - Repo: "owner/repo", - Limit: 0, - } - err := opts.AreFlagsValid() - if err == nil || !errors.Is(err, err) { - t.Errorf("expected error for limit too low, got %v", err) - } -} - -func TestAttestOptions_AreFlagsValid_LimitTooHigh(t *testing.T) { - opts := &AttestOptions{ - Repo: "owner/repo", - Limit: 1001, - } - err := opts.AreFlagsValid() - if err == nil || !errors.Is(err, err) { - t.Errorf("expected error for limit too high, got %v", err) - } -} - -func TestAttestOptions_AreFlagsValid_ValidHostname(t *testing.T) { - opts := &AttestOptions{ - Repo: "owner/repo", - Limit: 10, - Hostname: "github.com", - } - err := opts.AreFlagsValid() - if err != nil { - t.Errorf("expected no error for valid hostname, got %v", err) - } -} diff --git a/pkg/cmd/release/shared/policy.go b/pkg/cmd/release/shared/policy.go deleted file mode 100644 index 0e3bb322b..000000000 --- a/pkg/cmd/release/shared/policy.go +++ /dev/null @@ -1,76 +0,0 @@ -package shared - -import ( - "fmt" - - "github.com/sigstore/sigstore-go/pkg/fulcio/certificate" - "github.com/sigstore/sigstore-go/pkg/verify" - - "github.com/cli/cli/v2/pkg/cmd/attestation/artifact" - "github.com/cli/cli/v2/pkg/cmd/attestation/verification" -) - -func expandToGitHubURL(tenant, ownerOrRepo string) string { - if tenant == "" { - return fmt.Sprintf("https://github.com/%s", ownerOrRepo) - } - return fmt.Sprintf("https://%s.ghe.com/%s", tenant, ownerOrRepo) -} - -func NewEnforcementCriteria(opts *AttestOptions) (verification.EnforcementCriteria, error) { - // initialize the enforcement criteria with the provided PredicateType and SAN - c := verification.EnforcementCriteria{ - PredicateType: opts.PredicateType, - // TODO: if the proxima is provided, the default uses the proxima-specific SAN - SAN: "https://dotcom.releases.github.com", - } - - // If the Repo option is provided, set the SourceRepositoryURI extension - if opts.Repo != "" { - c.Certificate.SourceRepositoryURI = expandToGitHubURL(opts.Tenant, opts.Repo) - } - - // Set the SourceRepositoryOwnerURI extension using owner and tenant if provided - c.Certificate.SourceRepositoryOwnerURI = expandToGitHubURL(opts.Tenant, opts.Owner) - - return c, nil -} - -func buildCertificateIdentityOption(c verification.EnforcementCriteria) (verify.PolicyOption, error) { - sanMatcher, err := verify.NewSANMatcher(c.SAN, c.SANRegex) - if err != nil { - return nil, err - } - - // Accept any issuer, we will verify the issuer as part of the extension verification - issuerMatcher, err := verify.NewIssuerMatcher("", ".*") - if err != nil { - return nil, err - } - - extensions := certificate.Extensions{ - RunnerEnvironment: c.Certificate.RunnerEnvironment, - } - - certId, err := verify.NewCertificateIdentity(sanMatcher, issuerMatcher, extensions) - if err != nil { - return nil, err - } - - return verify.WithCertificateIdentity(certId), nil -} - -func buildSigstoreVerifyPolicy(c verification.EnforcementCriteria, a artifact.DigestedArtifact) (verify.PolicyBuilder, error) { - artifactDigestPolicyOption, err := verification.BuildDigestPolicyOption(a) - if err != nil { - return verify.PolicyBuilder{}, err - } - - certIdOption, err := buildCertificateIdentityOption(c) - if err != nil { - return verify.PolicyBuilder{}, err - } - - policy := verify.NewPolicy(artifactDigestPolicyOption, certIdOption) - return policy, nil -} diff --git a/pkg/cmd/release/shared/policy_test.go b/pkg/cmd/release/shared/policy_test.go deleted file mode 100644 index 72cc53c2a..000000000 --- a/pkg/cmd/release/shared/policy_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package shared - -import ( - "testing" - - "github.com/stretchr/testify/require" -) - -func TestNewEnforcementCriteria(t *testing.T) { - t.Run("check SAN", func(t *testing.T) { - opts := &AttestOptions{ - Owner: "foo", - Repo: "foo/bar", - PredicateType: "https://in-toto.io/attestation/release/v0.1", - } - - c, err := NewEnforcementCriteria(opts) - require.NoError(t, err) - require.Equal(t, "https://dotcom.releases.github.com", c.SAN) - require.Equal(t, "https://in-toto.io/attestation/release/v0.1", c.PredicateType) - }) - - t.Run("sets Extensions.SourceRepositoryURI using opts.Repo and opts.Tenant", func(t *testing.T) { - opts := &AttestOptions{ - Owner: "foo", - Repo: "foo/bar", - Tenant: "baz", - } - - c, err := NewEnforcementCriteria(opts) - require.NoError(t, err) - require.Equal(t, "https://baz.ghe.com/foo/bar", c.Certificate.SourceRepositoryURI) - }) - - t.Run("sets Extensions.SourceRepositoryURI using opts.Repo", func(t *testing.T) { - opts := &AttestOptions{ - Owner: "foo", - Repo: "foo/bar", - } - - c, err := NewEnforcementCriteria(opts) - require.NoError(t, err) - require.Equal(t, "https://github.com/foo/bar", c.Certificate.SourceRepositoryURI) - }) - - t.Run("sets Extensions.SourceRepositoryOwnerURI using opts.Owner and opts.Tenant", func(t *testing.T) { - opts := &AttestOptions{ - - Owner: "foo", - Repo: "foo/bar", - Tenant: "baz", - } - - c, err := NewEnforcementCriteria(opts) - require.NoError(t, err) - require.Equal(t, "https://baz.ghe.com/foo", c.Certificate.SourceRepositoryOwnerURI) - }) - - t.Run("sets Extensions.SourceRepositoryOwnerURI using opts.Owner", func(t *testing.T) { - opts := &AttestOptions{ - - Owner: "foo", - Repo: "foo/bar", - } - - c, err := NewEnforcementCriteria(opts) - require.NoError(t, err) - require.Equal(t, "https://github.com/foo", c.Certificate.SourceRepositoryOwnerURI) - }) - -} diff --git a/pkg/cmd/release/verify-asset/verify-asset.go b/pkg/cmd/release/verify-asset/verify-asset.go deleted file mode 100644 index 260589d11..000000000 --- a/pkg/cmd/release/verify-asset/verify-asset.go +++ /dev/null @@ -1,219 +0,0 @@ -package verifyasset - -import ( - "context" - "errors" - "fmt" - "path/filepath" - - "github.com/cli/cli/v2/pkg/cmd/attestation/auth" - ghauth "github.com/cli/go-gh/v2/pkg/auth" - - "github.com/cli/cli/v2/internal/text" - "github.com/cli/cli/v2/pkg/cmd/attestation/api" - "github.com/cli/cli/v2/pkg/cmd/attestation/artifact" - att_io "github.com/cli/cli/v2/pkg/cmd/attestation/io" - "github.com/cli/cli/v2/pkg/cmd/attestation/verification" - "github.com/cli/cli/v2/pkg/cmd/release/shared" - - "github.com/cli/cli/v2/pkg/cmdutil" - "github.com/spf13/cobra" -) - -func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*shared.AttestOptions) error) *cobra.Command { - opts := &shared.AttestOptions{} - - cmd := &cobra.Command{ - Use: "verify-asset ", - Short: "Verify that a given asset originated from a specific GitHub Release.", - Hidden: true, - Args: cobra.MaximumNArgs(2), - PreRunE: func(cmd *cobra.Command, args []string) error { - - if len(args) == 2 { - opts.TagName = args[0] - opts.AssetFilePath = args[1] - } else if len(args) == 1 { - opts.AssetFilePath = args[0] - } else { - return cmdutil.FlagErrorf("you must specify an asset filepath") - } - - httpClient, err := f.HttpClient() - if err != nil { - return err - } - baseRepo, err := f.BaseRepo() - if err != nil { - return err - } - logger := att_io.NewHandler(f.IOStreams) - hostname, _ := ghauth.DefaultHost() - - err = auth.IsHostSupported(hostname) - if err != nil { - return err - } - - *opts = shared.AttestOptions{ - TagName: opts.TagName, - AssetFilePath: opts.AssetFilePath, - Repo: baseRepo.RepoOwner() + "/" + baseRepo.RepoName(), - APIClient: api.NewLiveClient(httpClient, hostname, logger), - Limit: 10, - Owner: baseRepo.RepoOwner(), - PredicateType: shared.ReleasePredicateType, - Logger: logger, - HttpClient: httpClient, - BaseRepo: baseRepo, - Hostname: hostname, - } - - // Check that the given flag combination is valid - if err := opts.AreFlagsValid(); err != nil { - return err - } - - return nil - }, - RunE: func(cmd *cobra.Command, args []string) error { - td, err := opts.APIClient.GetTrustDomain() - if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red("X Failed to get trust domain")) - return err - } - - opts.TrustedRoot = td - - ec, err := shared.NewEnforcementCriteria(opts) - if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red("X Failed to build policy information")) - return err - } - - opts.EC = ec - - opts.Clean() - - // Avoid creating a Sigstore verifier if the runF function is provided for testing purposes - if runF != nil { - return runF(opts) - } - - return verifyAssetRun(opts) - }, - } - cmdutil.AddFormatFlags(cmd, &opts.Exporter) - - return cmd -} - -func verifyAssetRun(opts *shared.AttestOptions) error { - ctx := context.Background() - - if opts.SigstoreVerifier == nil { - config := verification.SigstoreConfig{ - HttpClient: opts.HttpClient, - Logger: opts.Logger, - NoPublicGood: true, - TrustDomain: opts.TrustedRoot, - } - - sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(config) - if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red("X Failed to create Sigstore verifier")) - return err - } - - opts.SigstoreVerifier = sigstoreVerifier - } - - if opts.TagName == "" { - release, err := shared.FetchLatestRelease(ctx, opts.HttpClient, opts.BaseRepo) - if err != nil { - return err - } - opts.TagName = release.TagName - } - - fileName := getFileName(opts.AssetFilePath) - - // calculate the digest of the file - fileDigest, err := artifact.NewDigestedArtifact(nil, opts.AssetFilePath, "sha256") - if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red("X Failed to calculate file digest")) - return err - } - - opts.Logger.Printf("Loaded digest %s for %s\n", fileDigest.DigestWithAlg(), fileName) - - ref, err := shared.FetchRefSHA(ctx, opts.HttpClient, opts.BaseRepo, opts.TagName) - if err != nil { - return err - } - releaseRefDigest := artifact.NewDigestedArtifactForRelease(ref, "sha1") - opts.Logger.Printf("Resolved %s to %s\n", opts.TagName, releaseRefDigest.DigestWithAlg()) - - // Attestation fetching - attestations, logMsg, err := shared.GetAttestations(opts, releaseRefDigest.DigestWithAlg()) - if err != nil { - if errors.Is(err, api.ErrNoAttestationsFound) { - opts.Logger.Printf(opts.Logger.ColorScheme.Red("X No attestations found for subject %s\n"), releaseRefDigest.DigestWithAlg()) - return err - } - opts.Logger.Println(opts.Logger.ColorScheme.Red(logMsg)) - return err - } - - // Filter attestations by tag - filteredAttestations, err := shared.FilterAttestationsByTag(attestations, opts.TagName) - if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red(err.Error())) - return err - } - - filteredAttestations, err = shared.FilterAttestationsByFileDigest(filteredAttestations, opts.Repo, opts.TagName, fileDigest.Digest()) - if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red(err.Error())) - return err - } - - if len(filteredAttestations) == 0 { - opts.Logger.Printf(opts.Logger.ColorScheme.Red("Release %s does not contain %s (%s)\n"), opts.TagName, opts.AssetFilePath, fileDigest.DigestWithAlg()) - return fmt.Errorf("release %s does not contain %s (%s)", opts.TagName, opts.AssetFilePath, fileDigest.DigestWithAlg()) - } - - opts.Logger.Printf("Loaded %s from GitHub API\n", text.Pluralize(len(filteredAttestations), "attestation")) - - // Verify attestations - verified, errMsg, err := shared.VerifyAttestations(*releaseRefDigest, filteredAttestations, opts.SigstoreVerifier, opts.EC) - - if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red(errMsg)) - opts.Logger.Printf(opts.Logger.ColorScheme.Red("Release %s does not contain %s (%s)\n"), opts.TagName, opts.AssetFilePath, fileDigest.DigestWithAlg()) - return err - } - - // If an exporter is provided with the --json flag, write the results to the terminal in JSON format - if opts.Exporter != nil { - // print the results to the terminal as an array of JSON objects - if err = opts.Exporter.Write(opts.Logger.IO, verified); err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red("X Failed to write JSON output")) - return err - } - return nil - } - - opts.Logger.Printf("The following %s matched the policy criteria\n\n", text.Pluralize(len(verified), "attestation")) - opts.Logger.Println(opts.Logger.ColorScheme.Green("✓ Verification succeeded!\n")) - opts.Logger.Printf("Attestation found matching release %s (%s)\n", opts.TagName, releaseRefDigest.DigestWithAlg()) - opts.Logger.Printf("%s is present in release %s\n", fileName, opts.TagName) - - return nil -} - -func getFileName(filePath string) string { - // Get the file name from the file path - _, fileName := filepath.Split(filePath) - return fileName -} diff --git a/pkg/cmd/release/verify-asset/verify-asset_test.go b/pkg/cmd/release/verify-asset/verify-asset_test.go deleted file mode 100644 index a85c9066e..000000000 --- a/pkg/cmd/release/verify-asset/verify-asset_test.go +++ /dev/null @@ -1,230 +0,0 @@ -package verifyasset - -import ( - "bytes" - "net/http" - "testing" - - "github.com/cli/cli/v2/pkg/cmd/attestation/api" - "github.com/cli/cli/v2/pkg/cmd/attestation/io" - "github.com/cli/cli/v2/pkg/cmd/attestation/test" - "github.com/cli/cli/v2/pkg/cmd/attestation/verification" - "github.com/cli/cli/v2/pkg/cmd/release/shared" - "github.com/cli/cli/v2/pkg/cmdutil" - "github.com/cli/cli/v2/pkg/iostreams" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/cli/cli/v2/internal/ghrepo" - - attestation "github.com/cli/cli/v2/pkg/cmd/release/shared" - "github.com/cli/cli/v2/pkg/httpmock" -) - -func TestNewCmdVerifyAsset_Args(t *testing.T) { - tests := []struct { - name string - args []string - wantTag string - wantFile string - wantErr string - }{ - { - name: "valid args", - args: []string{"v1.2.3", "../../attestation/test/data/github_release_artifact.zip"}, - wantTag: "v1.2.3", - wantFile: test.NormalizeRelativePath("../../attestation/test/data/github_release_artifact.zip"), - }, - { - name: "valid flag with no tag", - - args: []string{"../../attestation/test/data/github_release_artifact.zip"}, - wantFile: test.NormalizeRelativePath("../../attestation/test/data/github_release_artifact.zip"), - }, - { - name: "no args", - args: []string{}, - wantErr: "you must specify an asset filepath", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - testIO, _, _, _ := iostreams.Test() - var testReg httpmock.Registry - var metaResp = api.MetaResponse{ - Domains: api.Domain{ - ArtifactAttestations: api.ArtifactAttestations{}, - }, - } - testReg.Register(httpmock.REST(http.MethodGet, "meta"), - httpmock.StatusJSONResponse(200, &metaResp)) - - f := &cmdutil.Factory{ - IOStreams: testIO, - HttpClient: func() (*http.Client, error) { - reg := &testReg - client := &http.Client{} - httpmock.ReplaceTripper(client, reg) - return client, nil - }, - BaseRepo: func() (ghrepo.Interface, error) { - return ghrepo.FromFullName("owner/repo") - }, - } - - var opts *shared.AttestOptions - cmd := NewCmdVerifyAsset(f, func(o *shared.AttestOptions) error { - opts = o - return nil - }) - cmd.SetArgs(tt.args) - cmd.SetIn(&bytes.Buffer{}) - cmd.SetOut(&bytes.Buffer{}) - cmd.SetErr(&bytes.Buffer{}) - _, err := cmd.ExecuteC() - if tt.wantErr != "" { - require.Error(t, err) - assert.Contains(t, err.Error(), tt.wantErr) - } else { - require.NoError(t, err) - assert.Equal(t, tt.wantTag, opts.TagName) - assert.Equal(t, tt.wantFile, opts.AssetFilePath) - } - }) - } -} - -func Test_verifyAssetRun_Success(t *testing.T) { - ios, _, _, _ := iostreams.Test() - tagName := "v6" - - fakeHTTP := &httpmock.Registry{} - defer fakeHTTP.Verify(t) - fakeSHA := "1234567890abcdef1234567890abcdef12345678" - shared.StubFetchRefSHA(t, fakeHTTP, "owner", "repo", tagName, fakeSHA) - - baseRepo, err := ghrepo.FromFullName("owner/repo") - require.NoError(t, err) - - opts := &shared.AttestOptions{ - TagName: tagName, - AssetFilePath: test.NormalizeRelativePath("../../attestation/test/data/github_release_artifact.zip"), - Repo: "owner/repo", - Owner: "owner", - Limit: 10, - Logger: io.NewHandler(ios), - APIClient: api.NewTestClient(), - SigstoreVerifier: verification.NewMockSigstoreVerifier(t), - PredicateType: shared.ReleasePredicateType, - HttpClient: &http.Client{Transport: fakeHTTP}, - BaseRepo: baseRepo, - } - - ec, err := shared.NewEnforcementCriteria(opts) - require.NoError(t, err) - opts.EC = ec - opts.Clean() - err = verifyAssetRun(opts) - require.NoError(t, err) -} - -func Test_verifyAssetRun_Failed_With_Invalid_tag(t *testing.T) { - ios, _, _, _ := iostreams.Test() - tagName := "v1" - - fakeHTTP := &httpmock.Registry{} - defer fakeHTTP.Verify(t) - fakeSHA := "1234567890abcdef1234567890abcdef12345678" - shared.StubFetchRefSHA(t, fakeHTTP, "owner", "repo", tagName, fakeSHA) - - baseRepo, err := ghrepo.FromFullName("owner/repo") - require.NoError(t, err) - - opts := &attestation.AttestOptions{ - TagName: tagName, - AssetFilePath: test.NormalizeRelativePath("../../attestation/test/data/github_release_artifact.zip"), - Repo: "owner/repo", - Owner: "owner", - Limit: 10, - Logger: io.NewHandler(ios), - APIClient: api.NewTestClient(), - SigstoreVerifier: verification.NewMockSigstoreVerifier(t), - PredicateType: attestation.ReleasePredicateType, - HttpClient: &http.Client{Transport: fakeHTTP}, - BaseRepo: baseRepo, - } - - ec, err := attestation.NewEnforcementCriteria(opts) - require.NoError(t, err) - opts.EC = ec - - err = verifyAssetRun(opts) - require.Error(t, err, "no attestations found for github_release_artifact.zip in release v1") -} - -func Test_verifyAssetRun_Failed_With_Invalid_Artifact(t *testing.T) { - ios, _, _, _ := iostreams.Test() - tagName := "v6" - - fakeHTTP := &httpmock.Registry{} - defer fakeHTTP.Verify(t) - fakeSHA := "1234567890abcdef1234567890abcdef12345678" - shared.StubFetchRefSHA(t, fakeHTTP, "owner", "repo", tagName, fakeSHA) - - baseRepo, err := ghrepo.FromFullName("owner/repo") - require.NoError(t, err) - - opts := &attestation.AttestOptions{ - TagName: tagName, - AssetFilePath: test.NormalizeRelativePath("../../attestation/test/data/github_release_artifact.zip"), - Repo: "owner/repo", - Owner: "owner", - Limit: 10, - Logger: io.NewHandler(ios), - APIClient: api.NewTestClient(), - SigstoreVerifier: verification.NewMockSigstoreVerifier(t), - PredicateType: attestation.ReleasePredicateType, - HttpClient: &http.Client{Transport: fakeHTTP}, - BaseRepo: baseRepo, - } - - err = verifyAssetRun(opts) - require.Error(t, err, "no attestations found for github_release_artifact_invalid.zip in release v1.2.3") -} - -func Test_verifyAssetRun_NoAttestation(t *testing.T) { - ios, _, _, _ := iostreams.Test() - opts := &attestation.AttestOptions{ - TagName: "v1.2.3", - AssetFilePath: "artifact.tgz", - Repo: "owner/repo", - Limit: 10, - Logger: io.NewHandler(ios), - IO: ios, - APIClient: api.NewTestClient(), - SigstoreVerifier: verification.NewMockSigstoreVerifier(t), - PredicateType: attestation.ReleasePredicateType, - - EC: verification.EnforcementCriteria{}, - } - - err := verifyAssetRun(opts) - require.Error(t, err, "failed to get open local artifact: open artifact.tgz: no such file or director") -} - -func Test_getFileName(t *testing.T) { - tests := []struct { - input string - want string - }{ - {"foo/bar/baz.txt", "baz.txt"}, - {"baz.txt", "baz.txt"}, - {"/tmp/foo.tar.gz", "foo.tar.gz"}, - } - for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { - got := getFileName(tt.input) - assert.Equal(t, tt.want, got) - }) - } -} diff --git a/pkg/cmd/release/verify-asset/verify_asset.go b/pkg/cmd/release/verify-asset/verify_asset.go new file mode 100644 index 000000000..ddafbb265 --- /dev/null +++ b/pkg/cmd/release/verify-asset/verify_asset.go @@ -0,0 +1,182 @@ +package verifyasset + +import ( + "context" + "fmt" + "net/http" + "path/filepath" + + "github.com/cli/cli/v2/pkg/iostreams" + + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/cmd/attestation/api" + "github.com/cli/cli/v2/pkg/cmd/attestation/artifact" + att_io "github.com/cli/cli/v2/pkg/cmd/attestation/io" + "github.com/cli/cli/v2/pkg/cmd/release/shared" + + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/spf13/cobra" +) + +type VerifyAssetOptions struct { + TagName string + BaseRepo ghrepo.Interface + Exporter cmdutil.Exporter + AssetFilePath string +} + +type VerifyAssetConfig struct { + HttpClient *http.Client + IO *iostreams.IOStreams + Opts *VerifyAssetOptions + AttClient api.Client + AttVerifier shared.Verifier +} + +func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*VerifyAssetConfig) error) *cobra.Command { + opts := &VerifyAssetOptions{} + + cmd := &cobra.Command{ + Use: "verify-asset ", + Short: "Verify that a given asset originated from a specific GitHub Release.", + Hidden: true, + Args: cobra.MaximumNArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 2 { + opts.TagName = args[0] + opts.AssetFilePath = args[1] + } else if len(args) == 1 { + opts.AssetFilePath = args[0] + } else { + return cmdutil.FlagErrorf("you must specify an asset filepath") + } + + opts.AssetFilePath = filepath.Clean(opts.AssetFilePath) + + baseRepo, err := f.BaseRepo() + if err != nil { + return fmt.Errorf("failed to determine base repository: %w", err) + } + opts.BaseRepo = baseRepo + + httpClient, err := f.HttpClient() + if err != nil { + return err + } + + io := f.IOStreams + attClient := api.NewLiveClient(httpClient, baseRepo.RepoHost(), att_io.NewHandler(io)) + + attVerifier := &shared.AttestationVerifier{ + AttClient: attClient, + HttpClient: httpClient, + IO: io, + } + + config := &VerifyAssetConfig{ + Opts: opts, + HttpClient: httpClient, + AttClient: attClient, + AttVerifier: attVerifier, + IO: io, + } + + if runF != nil { + return runF(config) + } + + return verifyAssetRun(config) + }, + } + cmdutil.AddFormatFlags(cmd, &opts.Exporter) + + return cmd +} + +func verifyAssetRun(config *VerifyAssetConfig) error { + ctx := context.Background() + opts := config.Opts + baseRepo := opts.BaseRepo + tagName := opts.TagName + + if tagName == "" { + release, err := shared.FetchLatestRelease(ctx, config.HttpClient, baseRepo) + if err != nil { + return err + } + tagName = release.TagName + } + + fileName := getFileName(opts.AssetFilePath) + + // Calculate the digest of the file + fileDigest, err := artifact.NewDigestedArtifact(nil, opts.AssetFilePath, "sha256") + if err != nil { + return err + } + + ref, err := shared.FetchRefSHA(ctx, config.HttpClient, baseRepo, tagName) + if err != nil { + return err + } + + releaseRefDigest := artifact.NewDigestedArtifactForRelease(ref, "sha1") + + // Find attestaitons for the release tag SHA + attestations, err := config.AttClient.GetByDigest(api.FetchParams{ + Digest: releaseRefDigest.DigestWithAlg(), + PredicateType: shared.ReleasePredicateType, + Owner: baseRepo.RepoOwner(), + Repo: baseRepo.RepoOwner() + "/" + baseRepo.RepoName(), + Limit: 10, + }) + if err != nil { + return fmt.Errorf("no attestations found for tag %s (%s)", tagName, releaseRefDigest.DigestWithAlg()) + } + + // Filter attestations by tag name + filteredAttestations, err := shared.FilterAttestationsByTag(attestations, opts.TagName) + if err != nil { + return fmt.Errorf("error parsing attestations for tag %s: %w", tagName, err) + } + + if len(filteredAttestations) == 0 { + return fmt.Errorf("no attestations found for release %s in %s/%s", tagName, baseRepo.RepoOwner(), baseRepo.RepoName()) + } + + // Filter attestations by subject digest + filteredAttestations, err = shared.FilterAttestationsByFileDigest(filteredAttestations, fileDigest.Digest()) + if err != nil { + return fmt.Errorf("error parsing attestations for digest %s: %w", fileDigest.DigestWithAlg(), err) + } + + if len(filteredAttestations) == 0 { + return fmt.Errorf("attestation for %s does not contain subject %s", tagName, fileDigest.DigestWithAlg()) + } + + // Verify attestation + verified, err := config.AttVerifier.VerifyAttestation(releaseRefDigest, filteredAttestations[0]) + if err != nil { + return fmt.Errorf("failed to verify attestation for tag %s: %w", tagName, err) + } + + // If an exporter is provided with the --json flag, write the results to the terminal in JSON format + if opts.Exporter != nil { + return opts.Exporter.Write(config.IO, verified) + } + + io := config.IO + cs := io.ColorScheme() + fmt.Fprintf(io.Out, "Calculated digest for %s: %s\n", fileName, fileDigest.DigestWithAlg()) + fmt.Fprintf(io.Out, "Resolved tag %s to %s\n", opts.TagName, releaseRefDigest.DigestWithAlg()) + fmt.Fprint(io.Out, "Loaded attestation from GitHub API\n\n") + fmt.Fprintf(io.Out, cs.Green("%s Verification succeeded! %s is present in release %s\n"), cs.SuccessIcon(), fileName, opts.TagName) + + return nil +} + +func getFileName(filePath string) string { + // Get the file name from the file path + _, fileName := filepath.Split(filePath) + return fileName +} diff --git a/pkg/cmd/release/verify-asset/verify_asset_test.go b/pkg/cmd/release/verify-asset/verify_asset_test.go new file mode 100644 index 000000000..732de9fd2 --- /dev/null +++ b/pkg/cmd/release/verify-asset/verify_asset_test.go @@ -0,0 +1,267 @@ +package verifyasset + +import ( + "bytes" + "net/http" + "testing" + + "github.com/cli/cli/v2/pkg/cmd/attestation/api" + "github.com/cli/cli/v2/pkg/cmd/attestation/test" + "github.com/cli/cli/v2/pkg/cmd/attestation/test/data" + "github.com/cli/cli/v2/pkg/cmd/attestation/verification" + "github.com/cli/cli/v2/pkg/cmd/release/shared" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/cli/cli/v2/internal/ghrepo" +) + +func TestNewCmdVerifyAsset_Args(t *testing.T) { + tests := []struct { + name string + args []string + wantTag string + wantFile string + wantErr string + }{ + { + name: "valid args", + args: []string{"v1.2.3", "../../attestation/test/data/github_release_artifact.zip"}, + wantTag: "v1.2.3", + wantFile: test.NormalizeRelativePath("../../attestation/test/data/github_release_artifact.zip"), + }, + { + name: "valid flag with no tag", + + args: []string{"../../attestation/test/data/github_release_artifact.zip"}, + wantFile: test.NormalizeRelativePath("../../attestation/test/data/github_release_artifact.zip"), + }, + { + name: "no args", + args: []string{}, + wantErr: "you must specify an asset filepath", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testIO, _, _, _ := iostreams.Test() + + f := &cmdutil.Factory{ + IOStreams: testIO, + HttpClient: func() (*http.Client, error) { + return nil, nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("owner/repo") + }, + } + + var cfg *VerifyAssetConfig + cmd := NewCmdVerifyAsset(f, func(c *VerifyAssetConfig) error { + cfg = c + return nil + }) + cmd.SetArgs(tt.args) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + _, err := cmd.ExecuteC() + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + require.NoError(t, err) + assert.Equal(t, tt.wantTag, cfg.Opts.TagName) + assert.Equal(t, tt.wantFile, cfg.Opts.AssetFilePath) + } + }) + } +} + +func Test_verifyAssetRun_Success(t *testing.T) { + ios, _, _, _ := iostreams.Test() + tagName := "v6" + + fakeHTTP := &httpmock.Registry{} + defer fakeHTTP.Verify(t) + + fakeSHA := "1234567890abcdef1234567890abcdef12345678" + shared.StubFetchRefSHA(t, fakeHTTP, "owner", "repo", tagName, fakeSHA) + + baseRepo, err := ghrepo.FromFullName("owner/repo") + require.NoError(t, err) + + result := &verification.AttestationProcessingResult{ + Attestation: &api.Attestation{ + Bundle: data.GitHubReleaseBundle(t), + BundleURL: "https://example.com", + }, + VerificationResult: nil, + } + + releaseAssetPath := test.NormalizeRelativePath("../../attestation/test/data/github_release_artifact.zip") + + cfg := &VerifyAssetConfig{ + Opts: &VerifyAssetOptions{ + AssetFilePath: releaseAssetPath, + TagName: tagName, + BaseRepo: baseRepo, + Exporter: nil, + }, + IO: ios, + HttpClient: &http.Client{Transport: fakeHTTP}, + AttClient: api.NewTestClient(), + AttVerifier: shared.NewMockVerifier(result), + } + + err = verifyAssetRun(cfg) + require.NoError(t, err) +} + +func Test_verifyAssetRun_FailedNoAttestations(t *testing.T) { + ios, _, _, _ := iostreams.Test() + tagName := "v1" + + fakeHTTP := &httpmock.Registry{} + defer fakeHTTP.Verify(t) + fakeSHA := "1234567890abcdef1234567890abcdef12345678" + shared.StubFetchRefSHA(t, fakeHTTP, "owner", "repo", tagName, fakeSHA) + + baseRepo, err := ghrepo.FromFullName("owner/repo") + require.NoError(t, err) + + releaseAssetPath := test.NormalizeRelativePath("../../attestation/test/data/github_release_artifact.zip") + + cfg := &VerifyAssetConfig{ + Opts: &VerifyAssetOptions{ + AssetFilePath: releaseAssetPath, + TagName: tagName, + BaseRepo: baseRepo, + Exporter: nil, + }, + IO: ios, + HttpClient: &http.Client{Transport: fakeHTTP}, + AttClient: api.NewFailTestClient(), + AttVerifier: nil, + } + + err = verifyAssetRun(cfg) + require.ErrorContains(t, err, "no attestations found for tag v1") +} + +func Test_verifyAssetRun_FailedTagNotInAttestation(t *testing.T) { + ios, _, _, _ := iostreams.Test() + + // Tag name does not match the one present in the attestation which + // will be returned by the mock client. Simulates a scenario where + // multiple releases may point to the same commit SHA, but not all + // of them are attested. + tagName := "v1.2.3" + + fakeHTTP := &httpmock.Registry{} + defer fakeHTTP.Verify(t) + fakeSHA := "1234567890abcdef1234567890abcdef12345678" + shared.StubFetchRefSHA(t, fakeHTTP, "owner", "repo", tagName, fakeSHA) + + baseRepo, err := ghrepo.FromFullName("owner/repo") + require.NoError(t, err) + + releaseAssetPath := test.NormalizeRelativePath("../../attestation/test/data/github_release_artifact.zip") + + cfg := &VerifyAssetConfig{ + Opts: &VerifyAssetOptions{ + AssetFilePath: releaseAssetPath, + TagName: tagName, + BaseRepo: baseRepo, + Exporter: nil, + }, + IO: ios, + HttpClient: &http.Client{Transport: fakeHTTP}, + AttClient: api.NewTestClient(), + AttVerifier: nil, + } + + err = verifyAssetRun(cfg) + require.ErrorContains(t, err, "no attestations found for release v1.2.3") +} + +func Test_verifyAssetRun_FailedInvalidAsset(t *testing.T) { + ios, _, _, _ := iostreams.Test() + tagName := "v6" + + fakeHTTP := &httpmock.Registry{} + defer fakeHTTP.Verify(t) + fakeSHA := "1234567890abcdef1234567890abcdef12345678" + shared.StubFetchRefSHA(t, fakeHTTP, "owner", "repo", tagName, fakeSHA) + + baseRepo, err := ghrepo.FromFullName("owner/repo") + require.NoError(t, err) + + releaseAssetPath := test.NormalizeRelativePath("../../attestation/test/data/github_release_artifact_invalid.zip") + + cfg := &VerifyAssetConfig{ + Opts: &VerifyAssetOptions{ + AssetFilePath: releaseAssetPath, + TagName: tagName, + BaseRepo: baseRepo, + Exporter: nil, + }, + IO: ios, + HttpClient: &http.Client{Transport: fakeHTTP}, + AttClient: api.NewTestClient(), + AttVerifier: nil, + } + + err = verifyAssetRun(cfg) + require.ErrorContains(t, err, "attestation for v6 does not contain subject") +} + +func Test_verifyAssetRun_NoSuchAsset(t *testing.T) { + ios, _, _, _ := iostreams.Test() + tagName := "v6" + + fakeHTTP := &httpmock.Registry{} + fakeSHA := "1234567890abcdef1234567890abcdef12345678" + shared.StubFetchRefSHA(t, fakeHTTP, "owner", "repo", tagName, fakeSHA) + + baseRepo, err := ghrepo.FromFullName("owner/repo") + require.NoError(t, err) + + cfg := &VerifyAssetConfig{ + Opts: &VerifyAssetOptions{ + AssetFilePath: "artifact.zip", + TagName: tagName, + BaseRepo: baseRepo, + Exporter: nil, + }, + IO: ios, + HttpClient: &http.Client{Transport: fakeHTTP}, + AttClient: api.NewTestClient(), + AttVerifier: nil, + } + + err = verifyAssetRun(cfg) + require.ErrorContains(t, err, "failed to open local artifact") +} + +func Test_getFileName(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"foo/bar/baz.txt", "baz.txt"}, + {"baz.txt", "baz.txt"}, + {"/tmp/foo.tar.gz", "foo.tar.gz"}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := getFileName(tt.input) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/pkg/cmd/release/verify/verify.go b/pkg/cmd/release/verify/verify.go index b8276f989..95708fa5a 100644 --- a/pkg/cmd/release/verify/verify.go +++ b/pkg/cmd/release/verify/verify.go @@ -2,94 +2,86 @@ package verify import ( "context" - "errors" "fmt" + "net/http" v1 "github.com/in-toto/attestation/go/v1" "google.golang.org/protobuf/encoding/protojson" - "github.com/cli/cli/v2/internal/text" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/tableprinter" "github.com/cli/cli/v2/pkg/cmd/attestation/api" "github.com/cli/cli/v2/pkg/cmd/attestation/artifact" - "github.com/cli/cli/v2/pkg/cmd/attestation/auth" att_io "github.com/cli/cli/v2/pkg/cmd/attestation/io" "github.com/cli/cli/v2/pkg/cmd/attestation/verification" "github.com/cli/cli/v2/pkg/cmd/release/shared" + "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/pkg/cmdutil" - ghauth "github.com/cli/go-gh/v2/pkg/auth" "github.com/spf13/cobra" ) -func NewCmdVerify(f *cmdutil.Factory, runF func(*shared.AttestOptions) error) *cobra.Command { - opts := &shared.AttestOptions{} +type VerifyOptions struct { + TagName string + BaseRepo ghrepo.Interface + Exporter cmdutil.Exporter +} + +type VerifyConfig struct { + HttpClient *http.Client + IO *iostreams.IOStreams + Opts *VerifyOptions + AttClient api.Client + AttVerifier shared.Verifier +} + +func NewCmdVerify(f *cmdutil.Factory, runF func(config *VerifyConfig) error) *cobra.Command { + opts := &VerifyOptions{} cmd := &cobra.Command{ Use: "verify []", Short: "Verify the attestation for a GitHub Release.", Hidden: true, Args: cobra.MaximumNArgs(1), - PreRunE: func(cmd *cobra.Command, args []string) error { + + RunE: func(cmd *cobra.Command, args []string) error { if len(args) > 0 { opts.TagName = args[0] } + baseRepo, err := f.BaseRepo() + if err != nil { + return fmt.Errorf("failed to determine base repository: %w", err) + } + + opts.BaseRepo = baseRepo + httpClient, err := f.HttpClient() if err != nil { return err } - baseRepo, err := f.BaseRepo() - if err != nil { - return err - } - logger := att_io.NewHandler(f.IOStreams) - hostname, _ := ghauth.DefaultHost() + io := f.IOStreams + attClient := api.NewLiveClient(httpClient, baseRepo.RepoHost(), att_io.NewHandler(io)) - err = auth.IsHostSupported(hostname) - if err != nil { - return err + attVerifier := &shared.AttestationVerifier{ + AttClient: attClient, + HttpClient: httpClient, + IO: io, } - *opts = shared.AttestOptions{ - TagName: opts.TagName, - Repo: baseRepo.RepoOwner() + "/" + baseRepo.RepoName(), - APIClient: api.NewLiveClient(httpClient, hostname, logger), - Limit: 10, - Owner: baseRepo.RepoOwner(), - PredicateType: shared.ReleasePredicateType, - Logger: logger, - HttpClient: httpClient, - BaseRepo: baseRepo, - Hostname: hostname, + config := &VerifyConfig{ + Opts: opts, + HttpClient: httpClient, + AttClient: attClient, + AttVerifier: attVerifier, + IO: io, } - // Check that the given flag combination is valid - if err := opts.AreFlagsValid(); err != nil { - return err - } - return nil - }, - RunE: func(cmd *cobra.Command, args []string) error { - td, err := opts.APIClient.GetTrustDomain() - if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red("X Failed to get trust domain")) - return err - } - opts.TrustedRoot = td - - ec, err := shared.NewEnforcementCriteria(opts) - if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red("X Failed to build policy information")) - return err - } - opts.EC = ec - - // Avoid creating a Sigstore verifier if the runF function is provided for testing purposes if runF != nil { - return runF(opts) + return runF(config) } - return verifyRun(opts) + return verifyRun(config) }, } cmdutil.AddFormatFlags(cmd, &opts.Exporter) @@ -97,115 +89,119 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*shared.AttestOptions) error) *c return cmd } -func verifyRun(opts *shared.AttestOptions) error { +func verifyRun(config *VerifyConfig) error { ctx := context.Background() + opts := config.Opts + baseRepo := opts.BaseRepo + tagName := opts.TagName - if opts.SigstoreVerifier == nil { - config := verification.SigstoreConfig{ - HttpClient: opts.HttpClient, - Logger: opts.Logger, - NoPublicGood: true, - TrustDomain: opts.TrustedRoot, - } - - sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(config) - if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red("X Failed to create Sigstore verifier")) - return err - } - - opts.SigstoreVerifier = sigstoreVerifier - } - - if opts.TagName == "" { - release, err := shared.FetchLatestRelease(ctx, opts.HttpClient, opts.BaseRepo) + if tagName == "" { + release, err := shared.FetchLatestRelease(ctx, config.HttpClient, baseRepo) if err != nil { return err } - opts.TagName = release.TagName + tagName = release.TagName } - ref, err := shared.FetchRefSHA(ctx, opts.HttpClient, opts.BaseRepo, opts.TagName) + // Retrieve the ref for the release tag + ref, err := shared.FetchRefSHA(ctx, config.HttpClient, baseRepo, tagName) if err != nil { return err } releaseRefDigest := artifact.NewDigestedArtifactForRelease(ref, "sha1") - opts.Logger.Printf("Resolved %s to %s\n", opts.TagName, releaseRefDigest.DigestWithAlg()) - // Attestation fetching - attestations, logMsg, err := shared.GetAttestations(opts, releaseRefDigest.DigestWithAlg()) + // Find attestaitons for the release tag SHA + attestations, err := config.AttClient.GetByDigest(api.FetchParams{ + Digest: releaseRefDigest.DigestWithAlg(), + PredicateType: shared.ReleasePredicateType, + Owner: baseRepo.RepoOwner(), + Repo: baseRepo.RepoOwner() + "/" + baseRepo.RepoName(), + Limit: 10, + }) if err != nil { - if errors.Is(err, api.ErrNoAttestationsFound) { - opts.Logger.Printf(opts.Logger.ColorScheme.Red("X No attestations found for subject %s\n"), releaseRefDigest.DigestWithAlg()) - return err - } - opts.Logger.Println(opts.Logger.ColorScheme.Red(logMsg)) - return err + return fmt.Errorf("no attestations for tag %s (%s)", tagName, releaseRefDigest.DigestWithAlg()) } - // Filter attestations by predicate tag - filteredAttestations, err := shared.FilterAttestationsByTag(attestations, opts.TagName) + // Filter attestations by tag name + filteredAttestations, err := shared.FilterAttestationsByTag(attestations, tagName) if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red(err.Error())) - return err + return fmt.Errorf("error parsing attestations for tag %s: %w", tagName, err) } if len(filteredAttestations) == 0 { - opts.Logger.Printf(opts.Logger.ColorScheme.Red("X No attestations found for release %s in %s\n"), opts.TagName, opts.Repo) - return fmt.Errorf("no attestations found for release %s in %s", opts.TagName, opts.Repo) + return fmt.Errorf("no attestations found for release %s in %s", tagName, baseRepo.RepoName()) } - opts.Logger.Printf("Loaded %s from GitHub API\n", text.Pluralize(len(filteredAttestations), "attestation")) - - // Verify attestations - verified, errMsg, err := shared.VerifyAttestations(*releaseRefDigest, filteredAttestations, opts.SigstoreVerifier, opts.EC) + if len(filteredAttestations) > 1 { + return fmt.Errorf("duplicate attestations found for release %s in %s", tagName, baseRepo.RepoName()) + } + // Verify attestation + verified, err := config.AttVerifier.VerifyAttestation(releaseRefDigest, filteredAttestations[0]) if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red(errMsg)) - opts.Logger.Printf(opts.Logger.ColorScheme.Red("X Failed to find an attestation for release %s in %s\n"), opts.TagName, opts.Repo) - return err + return fmt.Errorf("failed to verify attestations for tag %s: %w", tagName, err) } // If an exporter is provided with the --json flag, write the results to the terminal in JSON format if opts.Exporter != nil { - // print the results to the terminal as an array of JSON objects - if err = opts.Exporter.Write(opts.Logger.IO, verified); err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red("X Failed to write JSON output")) - return err - } - return nil + return opts.Exporter.Write(config.IO, verified) } - opts.Logger.Printf("The following %s matched the policy criteria\n\n", text.Pluralize(len(verified), "attestation")) - opts.Logger.Println(opts.Logger.ColorScheme.Green("✓ Verification succeeded!\n")) + io := config.IO + cs := io.ColorScheme() + fmt.Fprintf(io.Out, "Resolved tag %s to %s\n", tagName, releaseRefDigest.DigestWithAlg()) + fmt.Fprint(io.Out, "Loaded attestation from GitHub API\n") + fmt.Fprintf(io.Out, cs.Green("%s Release %s verified!\n"), cs.SuccessIcon(), tagName) + fmt.Fprintln(io.Out) - opts.Logger.Printf("Attestation found matching release %s (%s)\n", opts.TagName, releaseRefDigest.Digest()) - printVerifiedSubjects(verified, opts.Logger) + if err := printVerifiedSubjects(io, verified); err != nil { + return err + } return nil } -func printVerifiedSubjects(verified []*verification.AttestationProcessingResult, logger *att_io.Handler) { - for _, att := range verified { - statement := att.Attestation.Bundle.GetDsseEnvelope().Payload - var statementData v1.Statement - err := protojson.Unmarshal([]byte(statement), &statementData) - if err != nil { - logger.Println(logger.ColorScheme.Red("X Failed to unmarshal statement")) - continue - } - for _, s := range statementData.Subject { - name := s.Name - digest := s.Digest +func printVerifiedSubjects(io *iostreams.IOStreams, att *verification.AttestationProcessingResult) error { + cs := io.ColorScheme() + w := io.Out - if name != "" { - digestStr := "" - for key, value := range digest { - digestStr += key + ":" + value - } - logger.Println(" " + name + " " + digestStr) + statement := att.Attestation.Bundle.GetDsseEnvelope().Payload + var statementData v1.Statement + + err := protojson.Unmarshal([]byte(statement), &statementData) + if err != nil { + return err + } + + // If there aren't at least two subjects, there are no assets to display + if len(statementData.Subject) < 2 { + return nil + } + + fmt.Fprintln(w, cs.Bold("Assets")) + table := tableprinter.New(io, tableprinter.WithHeader("Name", "Digest")) + + for _, s := range statementData.Subject { + name := s.Name + digest := s.Digest + + if name != "" { + digestStr := "" + for key, value := range digest { + digestStr = key + ":" + value } + + table.AddField(name) + table.AddField(digestStr) + table.EndRow() } } + err = table.Render() + if err != nil { + return err + } + fmt.Fprintln(w) + + return nil } diff --git a/pkg/cmd/release/verify/verify_test.go b/pkg/cmd/release/verify/verify_test.go index b0a1c7df5..40009fc7d 100644 --- a/pkg/cmd/release/verify/verify_test.go +++ b/pkg/cmd/release/verify/verify_test.go @@ -7,7 +7,7 @@ import ( "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/cmd/attestation/api" - "github.com/cli/cli/v2/pkg/cmd/attestation/io" + "github.com/cli/cli/v2/pkg/cmd/attestation/test/data" "github.com/cli/cli/v2/pkg/cmd/attestation/verification" "github.com/cli/cli/v2/pkg/cmd/release/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -38,40 +38,30 @@ func TestNewCmdVerify_Args(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { testIO, _, _, _ := iostreams.Test() - var testReg httpmock.Registry - var metaResp = api.MetaResponse{ - Domains: api.Domain{ - ArtifactAttestations: api.ArtifactAttestations{}, - }, - } - testReg.Register(httpmock.REST(http.MethodGet, "meta"), - httpmock.StatusJSONResponse(200, &metaResp)) - f := &cmdutil.Factory{ IOStreams: testIO, HttpClient: func() (*http.Client, error) { - reg := &testReg - client := &http.Client{} - httpmock.ReplaceTripper(client, reg) - return client, nil + return nil, nil }, BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.FromFullName("owner/repo") }, } - var opts *shared.AttestOptions - cmd := NewCmdVerify(f, func(o *shared.AttestOptions) error { - opts = o + var cfg *VerifyConfig + cmd := NewCmdVerify(f, func(c *VerifyConfig) error { + cfg = c return nil }) cmd.SetArgs(tt.args) cmd.SetIn(&bytes.Buffer{}) cmd.SetOut(&bytes.Buffer{}) cmd.SetErr(&bytes.Buffer{}) + _, err := cmd.ExecuteC() + require.NoError(t, err) - assert.Equal(t, tt.wantTag, opts.TagName) + assert.Equal(t, tt.wantTag, cfg.Opts.TagName) }) } } @@ -82,35 +72,72 @@ func Test_verifyRun_Success(t *testing.T) { fakeHTTP := &httpmock.Registry{} defer fakeHTTP.Verify(t) + fakeSHA := "1234567890abcdef1234567890abcdef12345678" shared.StubFetchRefSHA(t, fakeHTTP, "owner", "repo", tagName, fakeSHA) baseRepo, err := ghrepo.FromFullName("owner/repo") require.NoError(t, err) - opts := &shared.AttestOptions{ - TagName: tagName, - Repo: "owner/repo", - Owner: "owner", - Limit: 10, - Logger: io.NewHandler(ios), - APIClient: api.NewTestClient(), - SigstoreVerifier: verification.NewMockSigstoreVerifier(t), - HttpClient: &http.Client{Transport: fakeHTTP}, - BaseRepo: baseRepo, - PredicateType: shared.ReleasePredicateType, + result := &verification.AttestationProcessingResult{ + Attestation: &api.Attestation{ + Bundle: data.GitHubReleaseBundle(t), + BundleURL: "https://example.com", + }, + VerificationResult: nil, } - ec, err := shared.NewEnforcementCriteria(opts) - require.NoError(t, err) - opts.EC = ec + cfg := &VerifyConfig{ + Opts: &VerifyOptions{ + TagName: tagName, + BaseRepo: baseRepo, + Exporter: nil, + }, + IO: ios, + HttpClient: &http.Client{Transport: fakeHTTP}, + AttClient: api.NewTestClient(), + AttVerifier: shared.NewMockVerifier(result), + } - err = verifyRun(opts) + err = verifyRun(cfg) require.NoError(t, err) } -func Test_verifyRun_Failed_With_Invalid_Tag(t *testing.T) { +func Test_verifyRun_FailedNoAttestations(t *testing.T) { ios, _, _, _ := iostreams.Test() + tagName := "v1" + + fakeHTTP := &httpmock.Registry{} + defer fakeHTTP.Verify(t) + fakeSHA := "1234567890abcdef1234567890abcdef12345678" + shared.StubFetchRefSHA(t, fakeHTTP, "owner", "repo", tagName, fakeSHA) + + baseRepo, err := ghrepo.FromFullName("owner/repo") + require.NoError(t, err) + + cfg := &VerifyConfig{ + Opts: &VerifyOptions{ + TagName: tagName, + BaseRepo: baseRepo, + Exporter: nil, + }, + IO: ios, + HttpClient: &http.Client{Transport: fakeHTTP}, + AttClient: api.NewFailTestClient(), + AttVerifier: nil, + } + + err = verifyRun(cfg) + require.ErrorContains(t, err, "no attestations for tag v1") +} + +func Test_verifyRun_FailedTagNotInAttestation(t *testing.T) { + ios, _, _, _ := iostreams.Test() + + // Tag name does not match the one present in the attestation which + // will be returned by the mock client. Simulates a scenario where + // multiple releases may point to the same commit SHA, but not all + // of them are attested. tagName := "v1.2.3" fakeHTTP := &httpmock.Registry{} @@ -121,57 +148,18 @@ func Test_verifyRun_Failed_With_Invalid_Tag(t *testing.T) { baseRepo, err := ghrepo.FromFullName("owner/repo") require.NoError(t, err) - opts := &shared.AttestOptions{ - TagName: tagName, - Repo: "owner/repo", - Owner: "owner", - Limit: 10, - Logger: io.NewHandler(ios), - APIClient: api.NewFailTestClient(), - SigstoreVerifier: verification.NewMockSigstoreVerifier(t), - PredicateType: shared.ReleasePredicateType, - - HttpClient: &http.Client{Transport: fakeHTTP}, - BaseRepo: baseRepo, + cfg := &VerifyConfig{ + Opts: &VerifyOptions{ + TagName: tagName, + BaseRepo: baseRepo, + Exporter: nil, + }, + IO: ios, + HttpClient: &http.Client{Transport: fakeHTTP}, + AttClient: api.NewTestClient(), + AttVerifier: nil, } - ec, err := shared.NewEnforcementCriteria(opts) - require.NoError(t, err) - opts.EC = ec - - err = verifyRun(opts) - require.Error(t, err, "failed to fetch attestations from owner/repo") -} - -func Test_verifyRun_Failed_NoAttestation(t *testing.T) { - ios, _, _, _ := iostreams.Test() - tagName := "v1.2.3" - - fakeHTTP := &httpmock.Registry{} - defer fakeHTTP.Verify(t) - fakeSHA := "1234567890abcdef1234567890abcdef12345678" - shared.StubFetchRefSHA(t, fakeHTTP, "owner", "repo", tagName, fakeSHA) - - baseRepo, err := ghrepo.FromFullName("owner/repo") - require.NoError(t, err) - - opts := &shared.AttestOptions{ - TagName: tagName, - Repo: "owner/repo", - Owner: "owner", - Limit: 10, - Logger: io.NewHandler(ios), - APIClient: api.NewFailTestClient(), - SigstoreVerifier: verification.NewMockSigstoreVerifier(t), - HttpClient: &http.Client{Transport: fakeHTTP}, - BaseRepo: baseRepo, - PredicateType: shared.ReleasePredicateType, - } - - ec, err := shared.NewEnforcementCriteria(opts) - require.NoError(t, err) - opts.EC = ec - - err = verifyRun(opts) - require.Error(t, err, "failed to fetch attestations from owner/repo") + err = verifyRun(cfg) + require.ErrorContains(t, err, "no attestations found for release v1.2.3") }