refactor to simplify implementation
Signed-off-by: Brian DeHamer <bdehamer@github.com>
This commit is contained in:
parent
d7d9228609
commit
53cae592f6
13 changed files with 705 additions and 974 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <OWNER>/<REPO>
|
||||
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 <OWNER>/<REPO>
|
||||
splitRepo := strings.Split(repo, "/")
|
||||
return len(splitRepo) == 2
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
|
||||
}
|
||||
|
|
@ -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 <tag> <file-path>",
|
||||
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
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
182
pkg/cmd/release/verify-asset/verify_asset.go
Normal file
182
pkg/cmd/release/verify-asset/verify_asset.go
Normal file
|
|
@ -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 <tag> <file-path>",
|
||||
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
|
||||
}
|
||||
267
pkg/cmd/release/verify-asset/verify_asset_test.go
Normal file
267
pkg/cmd/release/verify-asset/verify_asset_test.go
Normal file
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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 [<tag>]",
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue