refactor to simplify implementation

Signed-off-by: Brian DeHamer <bdehamer@github.com>
This commit is contained in:
Brian DeHamer 2025-06-05 10:05:46 -07:00
parent d7d9228609
commit 53cae592f6
No known key found for this signature in database
13 changed files with 705 additions and 974 deletions

View file

@ -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)

View file

@ -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"

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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)
})
}

View file

@ -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
}

View file

@ -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)
})
}
}

View 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
}

View 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)
})
}
}

View file

@ -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
}

View file

@ -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")
}