cli/pkg/cmd/release/verify/verify.go
Brian DeHamer 53cae592f6
refactor to simplify implementation
Signed-off-by: Brian DeHamer <bdehamer@github.com>
2025-06-05 10:35:21 -07:00

207 lines
5.2 KiB
Go

package verify
import (
"context"
"fmt"
"net/http"
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),
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 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 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
}