Merge pull request #11018 from ejahnGithub/eugene/release-verify
init release verify subcommands
This commit is contained in:
commit
d4efe0bd0a
16 changed files with 1134 additions and 2 deletions
4
.github/CODEOWNERS
vendored
4
.github/CODEOWNERS
vendored
|
|
@ -5,6 +5,10 @@ internal/codespaces/ @cli/codespaces
|
|||
|
||||
# Limit Package Security team ownership to the attestation command package and related integration tests
|
||||
pkg/cmd/attestation/ @cli/package-security
|
||||
pkg/cmd/release/attestation @cli/package-security
|
||||
pkg/cmd/release/verify @cli/package-security
|
||||
pkg/cmd/release/verify-asset @cli/package-security
|
||||
|
||||
test/integration/attestation-cmd @cli/package-security
|
||||
|
||||
pkg/cmd/attestation/verification/embed/tuf-repo.github.com/ @cli/tuf-root-reviewers
|
||||
|
|
|
|||
|
|
@ -6,6 +6,13 @@ import (
|
|||
"github.com/cli/cli/v2/pkg/cmd/attestation/test/data"
|
||||
)
|
||||
|
||||
func makeTestReleaseAttestation() Attestation {
|
||||
return Attestation{
|
||||
Bundle: data.GitHubReleaseBundle(nil),
|
||||
BundleURL: "https://example.com",
|
||||
}
|
||||
}
|
||||
|
||||
func makeTestAttestation() Attestation {
|
||||
return Attestation{Bundle: data.SigstoreBundle(nil), BundleURL: "https://example.com"}
|
||||
}
|
||||
|
|
@ -26,8 +33,12 @@ func (m MockClient) GetTrustDomain() (string, error) {
|
|||
func OnGetByDigestSuccess(params FetchParams) ([]*Attestation, error) {
|
||||
att1 := makeTestAttestation()
|
||||
att2 := makeTestAttestation()
|
||||
att3 := makeTestReleaseAttestation()
|
||||
attestations := []*Attestation{&att1, &att2}
|
||||
if params.PredicateType != "" {
|
||||
if params.PredicateType == "https://in-toto.io/attestation/release/v0.1" {
|
||||
attestations = append(attestations, &att3)
|
||||
}
|
||||
return FilterAttestations(params.PredicateType, attestations)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -54,6 +54,13 @@ func normalizeReference(reference string, pathSeparator rune) (normalized string
|
|||
return filepath.Clean(reference), fileArtifactType, nil
|
||||
}
|
||||
|
||||
func NewDigestedArtifactForRelease(digest string, digestAlg string) (artifact *DigestedArtifact) {
|
||||
return &DigestedArtifact{
|
||||
digest: digest,
|
||||
digestAlg: digestAlg,
|
||||
}
|
||||
}
|
||||
|
||||
func NewDigestedArtifact(client oci.Client, reference, digestAlg string) (artifact *DigestedArtifact, err error) {
|
||||
normalized, artifactType, err := normalizeReference(reference, os.PathSeparator)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
23
pkg/cmd/attestation/artifact/file_test.go
Normal file
23
pkg/cmd/attestation/artifact/file_test.go
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
package artifact
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/cli/cli/v2/pkg/cmd/attestation/test"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_digestLocalFileArtifact_withRealZip(t *testing.T) {
|
||||
// Path to the test artifact
|
||||
artifactPath := test.NormalizeRelativePath("../../attestation/test/data/github_release_artifact.zip")
|
||||
|
||||
// Calculate expected digest using the same algorithm as the function under test
|
||||
expectedDigest := "e15b593c6ab8d7725a3cc82226ef816cac6bf9c70eed383bd459295cc65f5ec3"
|
||||
|
||||
// Call the function under test
|
||||
artifact, err := digestLocalFileArtifact(artifactPath, "sha256")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "file://"+artifactPath, artifact.URL)
|
||||
require.Equal(t, expectedDigest, artifact.digest)
|
||||
require.Equal(t, "sha256", artifact.digestAlg)
|
||||
}
|
||||
|
|
@ -10,6 +10,9 @@ import (
|
|||
//go:embed sigstore-js-2.1.0-bundle.json
|
||||
var SigstoreBundleRaw []byte
|
||||
|
||||
//go:embed github_release_bundle.json
|
||||
var GitHubReleaseBundleRaw []byte
|
||||
|
||||
// SigstoreBundle returns a test sigstore-go bundle.Bundle
|
||||
func SigstoreBundle(t *testing.T) *bundle.Bundle {
|
||||
b := &bundle.Bundle{}
|
||||
|
|
@ -19,3 +22,12 @@ func SigstoreBundle(t *testing.T) *bundle.Bundle {
|
|||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func GitHubReleaseBundle(t *testing.T) *bundle.Bundle {
|
||||
b := &bundle.Bundle{}
|
||||
err := b.UnmarshalJSON(GitHubReleaseBundleRaw)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to unmarshal GitHub release bundle: %v", err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
|
|
|||
BIN
pkg/cmd/attestation/test/data/github_release_artifact.zip
Normal file
BIN
pkg/cmd/attestation/test/data/github_release_artifact.zip
Normal file
Binary file not shown.
Binary file not shown.
24
pkg/cmd/attestation/test/data/github_release_bundle.json
Normal file
24
pkg/cmd/attestation/test/data/github_release_bundle.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"mediaType": "application/vnd.dev.sigstore.bundle.v0.3+json",
|
||||
"verificationMaterial": {
|
||||
"timestampVerificationData": {
|
||||
"rfc3161Timestamps": [
|
||||
{
|
||||
"signedTimestamp": "MIIC0DADAgEAMIICxwYJKoZIhvcNAQcCoIICuDCCArQCAQMxDTALBglghkgBZQMEAgIwgbwGCyqGSIb3DQEJEAEEoIGsBIGpMIGmAgEBBgkrBgEEAYO/MAIwMTANBglghkgBZQMEAgEFAAQg1c3kQQpo4Adf2E+nx78lNg8EjRSLpIRERpPF0HIavogCFQCOfZuxr0DOc1LsM+y+sjQCMFrtbxgPMjAyNTA1MzAyMDEzMzlaMAMCAQGgNqQ0MDIxFTATBgNVBAoTDEdpdEh1YiwgSW5jLjEZMBcGA1UEAxMQVFNBIFRpbWVzdGFtcGluZ6AAMYIB3TCCAdkCAQEwSjAyMRUwEwYDVQQKEwxHaXRIdWIsIEluYy4xGTAXBgNVBAMTEFRTQSBpbnRlcm1lZGlhdGUCFB+7MIjE5/rL4XA4fNDnmXHA04+wMAsGCWCGSAFlAwQCAqCCAQUwGgYJKoZIhvcNAQkDMQ0GCyqGSIb3DQEJEAEEMBwGCSqGSIb3DQEJBTEPFw0yNTA1MzAyMDEzMzlaMD8GCSqGSIb3DQEJBDEyBDDkw1fXMZ6l/uWne+PcdzhLl2ckTZftcUuHcnYCwjhyYMeGOcgbpNNMDem46JCxItwwgYcGCyqGSIb3DQEJEAIvMXgwdjB0MHIEIHuISsKSyiJtlhGjT+RyS+tYQ7iwCMsMCTGmz2NK3D7DME4wNqQ0MDIxFTATBgNVBAoTDEdpdEh1YiwgSW5jLjEZMBcGA1UEAxMQVFNBIGludGVybWVkaWF0ZQIUH7swiMTn+svhcDh80OeZccDTj7AwCgYIKoZIzj0EAwMEZjBkAjBhE76zis18/xOtQdx6rJUuaRoZCflXHCjH6BqEk1B29r9C8STztZhAKalXL+Wy4rsCMFFaGPKF1uOl5JADiKMg5/7chJbWrfwyO9oa0tbmvcGrtBCdFeJ1Ic0tIi1sOVvq5Q=="
|
||||
}
|
||||
]
|
||||
},
|
||||
"certificate": {
|
||||
"rawBytes": "MIICKjCCAbCgAwIBAgIUaa62dj98DUB+TpyvKtVaR4vGSM0wCgYIKoZIzj0EAwMwODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZGdWxjaW8gSW50ZXJtZWRpYXRlIGwxMB4XDTI1MDMxMDE1MDMwMloXDTI2MDMxMDE1MDMwMlowKjEVMBMGA1UEChMMR2l0SHViLCBJbmMuMREwDwYDVQQDEwhBdHRlc3RlcjBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABIMB7plPnZvBRlC2lvAocKTAqAPMJqstEqYk26e9vDJDC1yqoiHxZfPV4W/1RqUMZD1dFKm9t4RiSmm73/QnQKajgaUwgaIwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoGCCsGAQUFBwMDMAwGA1UdEwEB/wQCMAAwHQYDVR0OBBYEFOqaGpr5SbdYk5CQXsmmDZCBHR+XMB8GA1UdIwQYMBaAFMDhuFKkS08+3no4EQbPSY6hRZszMC0GA1UdEQQmMCSGImh0dHBzOi8vZG90Y29tLnJlbGVhc2VzLmdpdGh1Yi5jb20wCgYIKoZIzj0EAwMDaAAwZQIwWFdF6xcXazHVPHEAtd1SeaizLdY1erRl5hK+XlwhfpnasQHHZ9bdu4Zj8ARhW/AhAjEArujhmJGo7Fi4/Ek1RN8bufs6UhIQneQd/pxE8QdorwZkj2C8nf2EzrUYzlxKfktC"
|
||||
}
|
||||
},
|
||||
"dsseEnvelope": {
|
||||
"payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEiLCAic3ViamVjdCI6W3sidXJpIjoicGtnOmdpdGh1Yi9iZGVoYW1lci9kZWxtZUB2NiIsICJkaWdlc3QiOnsic2hhMSI6ImM1ZTE3YTYyZTA2YTFkMjAxNTcwMjQ5YzYxZmFlNTMxZTkyNDRlMWIifX0sIHsibmFtZSI6ImFydGlmYWN0LnppcCIsICJkaWdlc3QiOnsic2hhMjU2IjoiZTE1YjU5M2M2YWI4ZDc3MjVhM2NjODIyMjZlZjgxNmNhYzZiZjljNzBlZWQzODNiZDQ1OTI5NWNjNjVmNWVjMyJ9fV0sICJwcmVkaWNhdGVUeXBlIjoiaHR0cHM6Ly9pbi10b3RvLmlvL2F0dGVzdGF0aW9uL3JlbGVhc2UvdjAuMSIsICJwcmVkaWNhdGUiOnsib3duZXJJZCI6IjM5ODAyNyIsICJwdXJsIjoicGtnOmdpdGh1Yi9iZGVoYW1lci9kZWxtZUB2NiIsICJyZWxlYXNlSWQiOiIyMjIxNTg2NzEiLCAicmVwb3NpdG9yeSI6ImJkZWhhbWVyL2RlbG1lIiwgInJlcG9zaXRvcnlJZCI6IjkwNTk4ODA0NCIsICJ0YWciOiJ2NiJ9fQ==",
|
||||
"payloadType": "application/vnd.in-toto+json",
|
||||
"signatures": [
|
||||
{
|
||||
"sig": "MEUCIGq+T2g2gV2+lcmgyCaVPrjO1tj86RxitwiEOjU5dH/GAiEAvKaT/7H0sIVdAY7EzLq1IFaF8LmlW6eV68eZQvtuA0c="
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,8 @@ import (
|
|||
cmdUpdate "github.com/cli/cli/v2/pkg/cmd/release/edit"
|
||||
cmdList "github.com/cli/cli/v2/pkg/cmd/release/list"
|
||||
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"
|
||||
|
|
@ -34,6 +36,8 @@ func NewCmdRelease(f *cmdutil.Factory) *cobra.Command {
|
|||
cmdDownload.NewCmdDownload(f, nil),
|
||||
cmdDelete.NewCmdDelete(f, nil),
|
||||
cmdDeleteAsset.NewCmdDeleteAsset(f, nil),
|
||||
cmdVerify.NewCmdVerify(f, nil),
|
||||
cmdVerifyAsset.NewCmdVerifyAsset(f, nil),
|
||||
)
|
||||
|
||||
return cmd
|
||||
|
|
|
|||
128
pkg/cmd/release/shared/attestation.go
Normal file
128
pkg/cmd/release/shared/attestation.go
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
package shared
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
const ReleasePredicateType = "https://in-toto.io/attestation/release/v0.1"
|
||||
|
||||
type Verifier interface {
|
||||
// VerifyAttestation verifies the attestation for a given artifact
|
||||
VerifyAttestation(art *artifact.DigestedArtifact, att *api.Attestation) (*verification.AttestationProcessingResult, error)
|
||||
}
|
||||
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
verifier, err := verification.NewLiveSigstoreVerifier(verification.SigstoreConfig{
|
||||
HttpClient: v.HttpClient,
|
||||
Logger: att_io.NewHandler(v.IO),
|
||||
NoPublicGood: true,
|
||||
TrustDomain: td,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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) {
|
||||
var filtered []*api.Attestation
|
||||
for _, att := range attestations {
|
||||
statement := att.Bundle.Bundle.GetDsseEnvelope().Payload
|
||||
var statementData v1.Statement
|
||||
err := protojson.Unmarshal([]byte(statement), &statementData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal statement: %w", err)
|
||||
}
|
||||
tagValue := statementData.Predicate.GetFields()["tag"].GetStringValue()
|
||||
|
||||
if tagValue == tagName {
|
||||
filtered = append(filtered, att)
|
||||
}
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
func FilterAttestationsByFileDigest(attestations []*api.Attestation, fileDigest string) ([]*api.Attestation, error) {
|
||||
var filtered []*api.Attestation
|
||||
for _, att := range attestations {
|
||||
statement := att.Bundle.Bundle.GetDsseEnvelope().Payload
|
||||
var statementData v1.Statement
|
||||
err := protojson.Unmarshal([]byte(statement), &statementData)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal statement: %w", err)
|
||||
}
|
||||
subjects := statementData.Subject
|
||||
for _, subject := range subjects {
|
||||
digestMap := subject.GetDigest()
|
||||
alg := "sha256"
|
||||
|
||||
digest := digestMap[alg]
|
||||
if digest == fileDigest {
|
||||
filtered = append(filtered, att)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
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
|
||||
}
|
||||
|
|
@ -133,6 +133,41 @@ type fetchResult struct {
|
|||
error error
|
||||
}
|
||||
|
||||
func FetchRefSHA(ctx context.Context, httpClient *http.Client, repo ghrepo.Interface, tagName string) (string, error) {
|
||||
path := fmt.Sprintf("repos/%s/%s/git/refs/tags/%s", repo.RepoOwner(), repo.RepoName(), tagName)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", ghinstance.RESTPrefix(repo.RepoHost())+path, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
// ErrRefNotFound
|
||||
return "", ErrReleaseNotFound
|
||||
}
|
||||
|
||||
if resp.StatusCode > 299 {
|
||||
return "", api.HandleHTTPError(resp)
|
||||
}
|
||||
|
||||
var ref struct {
|
||||
Object struct {
|
||||
SHA string `json:"sha"`
|
||||
} `json:"object"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&ref); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return ref.Object.SHA, nil
|
||||
}
|
||||
|
||||
// FetchRelease finds a published repository release by its tagName, or a draft release by its pending tag name.
|
||||
func FetchRelease(ctx context.Context, httpClient *http.Client, repo ghrepo.Interface, tagName string) (*Release, error) {
|
||||
cc, cancel := context.WithCancel(ctx)
|
||||
|
|
@ -213,7 +248,7 @@ func fetchReleasePath(ctx context.Context, httpClient *http.Client, host string,
|
|||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 404 {
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
return nil, ErrReleaseNotFound
|
||||
} else if resp.StatusCode > 299 {
|
||||
|
|
@ -248,3 +283,11 @@ func StubFetchRelease(t *testing.T, reg *httpmock.Registry, owner, repoName, tag
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
func StubFetchRefSHA(t *testing.T, reg *httpmock.Registry, owner, repoName, tagName, sha string) {
|
||||
path := fmt.Sprintf("repos/%s/%s/git/refs/tags/%s", owner, repoName, tagName)
|
||||
reg.Register(
|
||||
httpmock.REST("GET", path),
|
||||
httpmock.StringResponse(fmt.Sprintf(`{"object": {"sha": "%s"}}`, sha)),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
211
pkg/cmd/release/verify-asset/verify_asset.go
Normal file
211
pkg/cmd/release/verify-asset/verify_asset.go
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
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/MakeNowJust/heredoc"
|
||||
"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.",
|
||||
Long: heredoc.Doc(`
|
||||
Verify that a given asset file originated from a specific GitHub Release using cryptographically signed attestations.
|
||||
|
||||
## Understanding Verification
|
||||
|
||||
An attestation is a claim made by GitHub regarding a release and its assets.
|
||||
|
||||
## What This Command Does
|
||||
|
||||
This command checks that the asset you provide matches an attestation produced by GitHub for a particular release.
|
||||
It ensures the asset's integrity by validating:
|
||||
* The asset's digest matches the subject in the attestation
|
||||
* The attestation is associated with the specified release
|
||||
`),
|
||||
Hidden: true,
|
||||
Args: cobra.MaximumNArgs(2),
|
||||
Example: heredoc.Doc(`
|
||||
# Verify an asset from the latest release
|
||||
$ gh release verify-asset ./dist/my-asset.zip
|
||||
|
||||
# Verify an asset from a specific release tag
|
||||
$ gh release verify-asset v1.2.3 ./dist/my-asset.zip
|
||||
|
||||
# Verify an asset from a specific release tag and output the attestation in JSON format
|
||||
$ gh release verify-asset v1.2.3 ./dist/my-asset.zip --format json
|
||||
`),
|
||||
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 attestations 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(),
|
||||
// TODO: Allow this value to be set via a flag.
|
||||
// The limit is set to 100 to ensure we fetch all attestations for a given SHA.
|
||||
// While multiple attestations can exist for a single SHA,
|
||||
// only one attestation is associated with each release tag.
|
||||
Limit: 100,
|
||||
})
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
233
pkg/cmd/release/verify/verify.go
Normal file
233
pkg/cmd/release/verify/verify.go
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
package verify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
v1 "github.com/in-toto/attestation/go/v1"
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
|
||||
"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"
|
||||
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"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
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),
|
||||
Long: heredoc.Doc(`
|
||||
Verify that a GitHub Release is accompanied by a valid cryptographically signed attestation.
|
||||
|
||||
## Understanding Verification
|
||||
|
||||
An attestation is a claim made by GitHub regarding a release and its assets.
|
||||
|
||||
## What This Command Does
|
||||
|
||||
This command checks that the specified release (or the latest release, if no tag is given) has a valid attestation.
|
||||
It fetches the attestation for the release and prints out metadata about all assets referenced in the attestation, including their digests.
|
||||
`),
|
||||
Example: heredoc.Doc(`
|
||||
# Verify the latest release
|
||||
gh release verify
|
||||
|
||||
# Verify a specific release by tag
|
||||
gh release verify v1.2.3
|
||||
|
||||
# Verify a specific release by tag and output the attestation in JSON format
|
||||
gh release verify v1.2.3 --format json
|
||||
`),
|
||||
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
|
||||
}
|
||||
|
||||
io := f.IOStreams
|
||||
attClient := api.NewLiveClient(httpClient, baseRepo.RepoHost(), att_io.NewHandler(io))
|
||||
|
||||
attVerifier := &shared.AttestationVerifier{
|
||||
AttClient: attClient,
|
||||
HttpClient: httpClient,
|
||||
IO: io,
|
||||
}
|
||||
|
||||
config := &VerifyConfig{
|
||||
Opts: opts,
|
||||
HttpClient: httpClient,
|
||||
AttClient: attClient,
|
||||
AttVerifier: attVerifier,
|
||||
IO: io,
|
||||
}
|
||||
|
||||
if runF != nil {
|
||||
return runF(config)
|
||||
}
|
||||
return verifyRun(config)
|
||||
},
|
||||
}
|
||||
cmdutil.AddFormatFlags(cmd, &opts.Exporter)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func verifyRun(config *VerifyConfig) 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
|
||||
}
|
||||
|
||||
// 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")
|
||||
|
||||
// Find all the attestations 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(),
|
||||
// TODO: Allow this value to be set via a flag.
|
||||
// The limit is set to 100 to ensure we fetch all attestations for a given SHA.
|
||||
// While multiple attestations can exist for a single SHA,
|
||||
// only one attestation is associated with each release tag.
|
||||
Limit: 100,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("no attestations for tag %s (%s)", tagName, releaseRefDigest.DigestWithAlg())
|
||||
}
|
||||
|
||||
// Filter attestations by tag name
|
||||
filteredAttestations, err := shared.FilterAttestationsByTag(attestations, 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", tagName, baseRepo.RepoName())
|
||||
}
|
||||
|
||||
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 {
|
||||
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 {
|
||||
return opts.Exporter.Write(config.IO, verified)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
if err := printVerifiedSubjects(io, verified); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func printVerifiedSubjects(io *iostreams.IOStreams, att *verification.AttestationProcessingResult) error {
|
||||
cs := io.ColorScheme()
|
||||
w := io.Out
|
||||
|
||||
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
|
||||
}
|
||||
165
pkg/cmd/release/verify/verify_test.go
Normal file
165
pkg/cmd/release/verify/verify_test.go
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
package verify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/cli/cli/v2/internal/ghrepo"
|
||||
"github.com/cli/cli/v2/pkg/cmd/attestation/api"
|
||||
"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"
|
||||
)
|
||||
|
||||
func TestNewCmdVerify_Args(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantTag string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "valid tag arg",
|
||||
args: []string{"v1.2.3"},
|
||||
wantTag: "v1.2.3",
|
||||
},
|
||||
{
|
||||
name: "no tag arg",
|
||||
args: []string{},
|
||||
wantTag: "",
|
||||
},
|
||||
}
|
||||
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 *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, cfg.Opts.TagName)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_verifyRun_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,
|
||||
}
|
||||
|
||||
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(cfg)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
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{}
|
||||
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.NewTestClient(),
|
||||
AttVerifier: nil,
|
||||
}
|
||||
|
||||
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