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 [] ", 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 }