Merge pull request #11018 from ejahnGithub/eugene/release-verify

init release verify subcommands
This commit is contained in:
Andy Feller 2025-06-20 15:55:44 -04:00 committed by GitHub
commit d4efe0bd0a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1134 additions and 2 deletions

4
.github/CODEOWNERS vendored
View file

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

View file

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

View file

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

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

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

View file

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

View 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="
}
]
}
}

View file

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

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

View file

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

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

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

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

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