cli/pkg/cmd/release/verify-asset/verify_asset.go

211 lines
6.3 KiB
Go

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
}