From 3a6e42f73f373cdf086d3b12f64634d2cba60b32 Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Thu, 15 May 2025 17:21:13 -0400 Subject: [PATCH 01/31] init --- pkg/cmd/attestation/artifact/artifact.go | 8 + pkg/cmd/release/release.go | 2 + pkg/cmd/release/shared/fetch.go | 35 +++ pkg/cmd/release/verify/attestation.go | 58 +++++ pkg/cmd/release/verify/options.go | 104 +++++++++ pkg/cmd/release/verify/policy.go | 168 +++++++++++++ pkg/cmd/release/verify/verify.go | 285 +++++++++++++++++++++++ 7 files changed, 660 insertions(+) create mode 100644 pkg/cmd/release/verify/attestation.go create mode 100644 pkg/cmd/release/verify/options.go create mode 100644 pkg/cmd/release/verify/policy.go create mode 100644 pkg/cmd/release/verify/verify.go diff --git a/pkg/cmd/attestation/artifact/artifact.go b/pkg/cmd/attestation/artifact/artifact.go index 131785166..53f8d8aad 100644 --- a/pkg/cmd/attestation/artifact/artifact.go +++ b/pkg/cmd/attestation/artifact/artifact.go @@ -54,6 +54,14 @@ func normalizeReference(reference string, pathSeparator rune) (normalized string return filepath.Clean(reference), fileArtifactType, nil } +func NewDigestedArtifactForRelease(URL string, digest string, digestAlg string) (artifact *DigestedArtifact) { + return &DigestedArtifact{ + URL: URL, + 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 { diff --git a/pkg/cmd/release/release.go b/pkg/cmd/release/release.go index 6805b09eb..3e40b03e7 100644 --- a/pkg/cmd/release/release.go +++ b/pkg/cmd/release/release.go @@ -8,6 +8,7 @@ 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" cmdView "github.com/cli/cli/v2/pkg/cmd/release/view" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/spf13/cobra" @@ -34,6 +35,7 @@ func NewCmdRelease(f *cmdutil.Factory) *cobra.Command { cmdDownload.NewCmdDownload(f, nil), cmdDelete.NewCmdDelete(f, nil), cmdDeleteAsset.NewCmdDeleteAsset(f, nil), + cmdVerify.NewCmdVerify(f, nil), ) return cmd diff --git a/pkg/cmd/release/shared/fetch.go b/pkg/cmd/release/shared/fetch.go index 8db7e502a..5fea30b7c 100644 --- a/pkg/cmd/release/shared/fetch.go +++ b/pkg/cmd/release/shared/fetch.go @@ -131,6 +131,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 == 404 { + _, _ = 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) diff --git a/pkg/cmd/release/verify/attestation.go b/pkg/cmd/release/verify/attestation.go new file mode 100644 index 000000000..9aa7461ae --- /dev/null +++ b/pkg/cmd/release/verify/attestation.go @@ -0,0 +1,58 @@ +package verify + +import ( + "errors" + "fmt" + + "github.com/cli/cli/v2/internal/text" + "github.com/cli/cli/v2/pkg/cmd/attestation/api" + "github.com/cli/cli/v2/pkg/cmd/attestation/artifact" + "github.com/cli/cli/v2/pkg/cmd/attestation/verification" +) + +func getAttestations(o *Options, sha string) ([]*api.Attestation, string, error) { + if o.APIClient == nil { + errMsg := "✗ No APIClient provided" + return nil, errMsg, errors.New(errMsg) + } + + params := api.FetchParams{ + Digest: sha, + Limit: o.Limit, + Owner: o.Owner, + PredicateType: o.PredicateType, + Repo: o.Repo, + } + + attestations, err := o.APIClient.GetByDigest(params) + if err != nil { + msg := "✗ Loading attestations from GitHub API failed" + return nil, msg, err + } + pluralAttestation := text.Pluralize(len(attestations), "attestation") + msg := fmt.Sprintf("Loaded %s from GitHub API", pluralAttestation) + return attestations, msg, nil +} + +func verifyAttestations(art artifact.DigestedArtifact, att []*api.Attestation, sgVerifier verification.SigstoreVerifier, ec verification.EnforcementCriteria) ([]*verification.AttestationProcessingResult, string, error) { + sgPolicy, err := buildSigstoreVerifyPolicy(ec, art) + if err != nil { + logMsg := "✗ Failed to build Sigstore verification policy" + return nil, logMsg, err + } + + sigstoreVerified, err := sgVerifier.Verify(att, sgPolicy) + if err != nil { + logMsg := "✗ Sigstore verification failed" + return nil, logMsg, err + } + + // Verify extensions + certExtVerified, err := verification.VerifyCertExtensions(sigstoreVerified, ec) + if err != nil { + logMsg := "✗ Policy verification failed" + return nil, logMsg, err + } + + return certExtVerified, "", nil +} diff --git a/pkg/cmd/release/verify/options.go b/pkg/cmd/release/verify/options.go new file mode 100644 index 000000000..e47c4f4a8 --- /dev/null +++ b/pkg/cmd/release/verify/options.go @@ -0,0 +1,104 @@ +package verify + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/ghinstance" + "github.com/cli/cli/v2/pkg/cmd/attestation/api" + "github.com/cli/cli/v2/pkg/cmd/attestation/artifact/oci" + "github.com/cli/cli/v2/pkg/cmd/attestation/io" + "github.com/cli/cli/v2/pkg/cmd/attestation/verification" + "github.com/cli/cli/v2/pkg/cmdutil" +) + +// Options captures the options for the verify command +type Options struct { + ArtifactPath string + BundlePath string + UseBundleFromRegistry bool + Config func() (gh.Config, error) + TrustedRoot string + DenySelfHostedRunner bool + DigestAlgorithm string + Limit int + NoPublicGood bool + OIDCIssuer string + Owner string + PredicateType string + Repo string + SAN string + SANRegex string + SignerDigest string + SignerRepo string + SignerWorkflow string + SourceDigest string + SourceRef string + APIClient api.Client + Logger *io.Handler + OCIClient oci.Client + SigstoreVerifier verification.SigstoreVerifier + exporter cmdutil.Exporter + Hostname string + // Tenant is only set when tenancy is used + Tenant string +} + +// Clean cleans the file path option values +func (opts *Options) Clean() { + if opts.BundlePath != "" { + opts.BundlePath = filepath.Clean(opts.BundlePath) + } +} + +// FetchAttestationsFromGitHubAPI returns true if the command should fetch attestations from the GitHub API +// It checks that a bundle path is not provided and that the "use bundle from registry" flag is not set +func (opts *Options) FetchAttestationsFromGitHubAPI() bool { + return opts.BundlePath == "" && !opts.UseBundleFromRegistry +} + +// AreFlagsValid checks that the provided flag combination is valid +// and returns an error otherwise +func (opts *Options) AreFlagsValid() error { + // If provided, check that the Repo option is in the expected format / + if opts.Repo != "" && !isProvidedRepoValid(opts.Repo) { + return fmt.Errorf("invalid value provided for repo: %s", opts.Repo) + } + + // If provided, check that the SignerRepo option is in the expected format / + if opts.SignerRepo != "" && !isProvidedRepoValid(opts.SignerRepo) { + return fmt.Errorf("invalid value provided for signer-repo: %s", opts.SignerRepo) + } + + // Check that limit is between 1 and 1000 + if opts.Limit < 1 || opts.Limit > 1000 { + return fmt.Errorf("limit %d not allowed, must be between 1 and 1000", opts.Limit) + } + + // Check that the bundle-from-oci flag is only used with OCI artifact paths + if opts.UseBundleFromRegistry && !strings.HasPrefix(opts.ArtifactPath, "oci://") { + return fmt.Errorf("bundle-from-oci flag can only be used with OCI artifact paths") + } + + // Check that both the bundle-from-oci and bundle-path flags are not used together + if opts.UseBundleFromRegistry && opts.BundlePath != "" { + return fmt.Errorf("bundle-from-oci flag cannot be used with bundle-path flag") + } + + // Verify provided hostname + if opts.Hostname != "" { + if err := ghinstance.HostnameValidator(opts.Hostname); err != nil { + return fmt.Errorf("error parsing hostname: %w", err) + } + } + + return nil +} + +func isProvidedRepoValid(repo string) bool { + // we expect a provided repository argument be in the format / + splitRepo := strings.Split(repo, "/") + return len(splitRepo) == 2 +} diff --git a/pkg/cmd/release/verify/policy.go b/pkg/cmd/release/verify/policy.go new file mode 100644 index 000000000..1d1595eca --- /dev/null +++ b/pkg/cmd/release/verify/policy.go @@ -0,0 +1,168 @@ +package verify + +import ( + "errors" + "fmt" + "regexp" + "strings" + + "github.com/sigstore/sigstore-go/pkg/fulcio/certificate" + "github.com/sigstore/sigstore-go/pkg/verify" + + "github.com/cli/cli/v2/pkg/cmd/attestation/artifact" + "github.com/cli/cli/v2/pkg/cmd/attestation/verification" +) + +const hostRegex = `^[a-zA-Z0-9-]+\.[a-zA-Z0-9-]+.*$` + +func expandToGitHubURL(tenant, ownerOrRepo string) string { + if tenant == "" { + return fmt.Sprintf("https://github.com/%s", ownerOrRepo) + } + return fmt.Sprintf("https://%s.ghe.com/%s", tenant, ownerOrRepo) +} + +func expandToGitHubURLRegex(tenant, ownerOrRepo string) string { + url := expandToGitHubURL(tenant, ownerOrRepo) + return fmt.Sprintf("(?i)^%s/", url) +} + +func newEnforcementCriteria(opts *Options) (verification.EnforcementCriteria, error) { + // initialize the enforcement criteria with the provided PredicateType + c := verification.EnforcementCriteria{ + PredicateType: opts.PredicateType, + } + + // set the owner value by checking the repo and owner options + var owner string + if opts.Repo != "" { + // we expect the repo argument to be in the format / + splitRepo := strings.Split(opts.Repo, "/") + // if Repo is provided but owner is not, set the OWNER portion of the Repo value + // to Owner + owner = splitRepo[0] + } else { + // otherwise use the user provided owner value + owner = opts.Owner + } + + // Set the SANRegex and SAN values using the provided options + // First check if the opts.SANRegex or opts.SAN values are provided + if opts.SANRegex != "" || opts.SAN != "" { + c.SANRegex = opts.SANRegex + c.SAN = opts.SAN + } else if opts.SignerRepo != "" { + // next check if opts.SignerRepo was provided + signedRepoRegex := expandToGitHubURLRegex(opts.Tenant, opts.SignerRepo) + c.SANRegex = signedRepoRegex + } else if opts.SignerWorkflow != "" { + validatedWorkflowRegex, err := validateSignerWorkflow(opts.Hostname, opts.SignerWorkflow) + if err != nil { + return verification.EnforcementCriteria{}, err + } + c.SANRegex = validatedWorkflowRegex + } else if opts.Repo != "" { + // if the user has not provided the SAN, SANRegex, SignerRepo, or SignerWorkflow options + // then we default to the repo option + c.SANRegex = expandToGitHubURLRegex(opts.Tenant, opts.Repo) + } else { + // if opts.Repo was not provided, we fall back to the opts.Owner value + c.SANRegex = expandToGitHubURLRegex(opts.Tenant, owner) + } + + // if the DenySelfHostedRunner option is set to true, set the + // RunnerEnvironment extension to the GitHub hosted runner value + if opts.DenySelfHostedRunner { + c.Certificate.RunnerEnvironment = verification.GitHubRunner + } else { + // if Certificate.RunnerEnvironment value is set to the empty string + // through the second function argument, + // no certificate matching will happen on the RunnerEnvironment field + c.Certificate.RunnerEnvironment = "" + } + + // If the Repo option is provided, set the SourceRepositoryURI extension + if opts.Repo != "" { + c.Certificate.SourceRepositoryURI = expandToGitHubURL(opts.Tenant, opts.Repo) + } + + // Set the SourceRepositoryOwnerURI extension using owner and tenant if provided + c.Certificate.SourceRepositoryOwnerURI = expandToGitHubURL(opts.Tenant, owner) + + // if the tenant is provided and OIDC issuer provided matches the default + // use the tenant-specific issuer + if opts.Tenant != "" && opts.OIDCIssuer == verification.GitHubOIDCIssuer { + c.Certificate.Issuer = fmt.Sprintf(verification.GitHubTenantOIDCIssuer, opts.Tenant) + } else { + // otherwise use the custom OIDC issuer provided as an option + c.Certificate.Issuer = opts.OIDCIssuer + } + + // set the SourceRepositoryDigest, SourceRepositoryRef, and BuildSignerDigest + // extensions if the options are provided + c.Certificate.BuildSignerDigest = opts.SignerDigest + c.Certificate.SourceRepositoryDigest = opts.SourceDigest + c.Certificate.SourceRepositoryRef = opts.SourceRef + + return c, nil +} + +func buildCertificateIdentityOption(c verification.EnforcementCriteria) (verify.PolicyOption, error) { + sanMatcher, err := verify.NewSANMatcher(c.SAN, c.SANRegex) + if err != nil { + return nil, err + } + + // Accept any issuer, we will verify the issuer as part of the extension verification + issuerMatcher, err := verify.NewIssuerMatcher("", ".*") + if err != nil { + return nil, err + } + + extensions := certificate.Extensions{ + RunnerEnvironment: c.Certificate.RunnerEnvironment, + } + + certId, err := verify.NewCertificateIdentity(sanMatcher, issuerMatcher, extensions) + if err != nil { + return nil, err + } + + return verify.WithCertificateIdentity(certId), nil +} + +func buildSigstoreVerifyPolicy(c verification.EnforcementCriteria, a artifact.DigestedArtifact) (verify.PolicyBuilder, error) { + artifactDigestPolicyOption, err := verification.BuildDigestPolicyOption(a) + if err != nil { + return verify.PolicyBuilder{}, err + } + + certIdOption, err := buildCertificateIdentityOption(c) + if err != nil { + return verify.PolicyBuilder{}, err + } + + policy := verify.NewPolicy(artifactDigestPolicyOption, certIdOption) + return policy, nil +} + +func validateSignerWorkflow(hostname, signerWorkflow string) (string, error) { + // we expect a provided workflow argument be in the format [HOST/]///path/to/workflow.yml + // if the provided workflow does not contain a host, set the host + match, err := regexp.MatchString(hostRegex, signerWorkflow) + if err != nil { + return "", err + } + + if match { + return fmt.Sprintf("^https://%s", signerWorkflow), nil + } + + // if the provided workflow did not match the expect format + // we move onto creating a signer workflow using the provided host name + if hostname == "" { + return "", errors.New("unknown signer workflow host") + } + + return fmt.Sprintf("^https://%s/%s", hostname, signerWorkflow), nil +} diff --git a/pkg/cmd/release/verify/verify.go b/pkg/cmd/release/verify/verify.go new file mode 100644 index 000000000..785b5750a --- /dev/null +++ b/pkg/cmd/release/verify/verify.go @@ -0,0 +1,285 @@ +package verify + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/cli/cli/v2/pkg/cmd/attestation/verification" + + "github.com/cli/cli/v2/pkg/cmd/attestation/artifact" + att_io "github.com/cli/cli/v2/pkg/cmd/attestation/io" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/tableprinter" + "github.com/cli/cli/v2/internal/text" + "github.com/cli/cli/v2/pkg/cmd/attestation/api" + "github.com/cli/cli/v2/pkg/cmd/release/shared" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/cli/cli/v2/pkg/markdown" + ghauth "github.com/cli/go-gh/v2/pkg/auth" + "github.com/spf13/cobra" +) + +type VerifyOptions struct { + HttpClient func() (*http.Client, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + Exporter cmdutil.Exporter + + TagName string +} + +func NewCmdVerify(f *cmdutil.Factory, runF func(*VerifyOptions) error) *cobra.Command { + opts := &VerifyOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + } + + cmd := &cobra.Command{ + Use: "verify []", + Short: "Verify information about a release", + Long: heredoc.Doc(` + Verify information about a GitHub Release. + + Without an explicit tag name argument, the latest release in the project + is shown. + `), + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + if len(args) > 0 { + opts.TagName = args[0] + } + + if runF != nil { + return runF(opts) + } + return verifyRun(opts) + }, + } + + cmdutil.AddJSONFlags(cmd, &opts.Exporter, shared.ReleaseFields) + + return cmd +} + +func verifyRun(opts *VerifyOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + + baseRepo, err := opts.BaseRepo() + if err != nil { + return err + } + + ctx := context.Background() + var release *shared.Release + + if opts.TagName == "" { + return cmdutil.FlagErrorf("tag name is required") + } else { + release, err = shared.FetchRelease(ctx, httpClient, baseRepo, opts.TagName) + if err != nil { + return err + } + } + + sha, err := shared.FetchRefSHA(ctx, httpClient, baseRepo, opts.TagName) + if err != nil { + return err + } + artifact := artifact.NewDigestedArtifactForRelease(opts.TagName, sha, "sha1") + + sha = "sha1:" + sha + + // Resolved v1.0.0 to sha1:824acc86dd86a745b3014bd5353b844959f3591e + fmt.Println("Resolved", opts.TagName, "to "+sha) + + // Fetch Attestation + PredicateType := "https://in-toto.io/attestation/release/v0.1" + limit := 10 + + Hostname, _ := ghauth.DefaultHost() + + logger := att_io.NewHandler(opts.IO) + + repo := baseRepo.RepoOwner() + "/" + baseRepo.RepoName() + attestOption := &Options{ + Repo: repo, + APIClient: api.NewLiveClient(httpClient, Hostname, logger), + Limit: limit, + Owner: baseRepo.RepoOwner(), + PredicateType: PredicateType, + } + attestations, logMsg, err := getAttestations(attestOption, sha) + + if err != nil { + if ok := errors.Is(err, api.ErrNoAttestationsFound); ok { + logger.Printf(logger.ColorScheme.Red("✗ No attestations found for subject %s\n"), sha) + return err + } + // Print the message signifying failure fetching attestations + logger.Println(logger.ColorScheme.Red(logMsg)) + return err + } + // Print the message signifying success fetching attestations + logger.Println(logMsg) + + // print information about the policy that will be enforced against attestations + logger.Println("\nThe following policy criteria will be enforced:") + ec, err := newEnforcementCriteria(attestOption) + + if err != nil { + logger.Println(logger.ColorScheme.Red("✗ Failed to build policy information")) + return err + } + logger.Println(ec.BuildPolicyInformation()) + + config := verification.SigstoreConfig{ + TrustedRoot: "", + Logger: logger, + NoPublicGood: true, + } + + sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(config) + if err != nil { + logger.Println(logger.ColorScheme.Red("✗ Failed to create Sigstore verifier")) + return err + } + verified, errMsg, err := verifyAttestations(*artifact, attestations, sigstoreVerifier, ec) + if err != nil { + logger.Println(logger.ColorScheme.Red(errMsg)) + return err + } + + logger.Printf("The following %s matched the policy criteria\n\n", text.Pluralize(len(verified), "attestation")) + + logger.Println(logger.ColorScheme.Green("✓ Verification succeeded!\n")) + + // Verify attestations + + opts.IO.DetectTerminalTheme() + if err := opts.IO.StartPager(); err != nil { + fmt.Fprintf(opts.IO.ErrOut, "error starting pager: %v\n", err) + } + defer opts.IO.StopPager() + + if opts.Exporter != nil { + return opts.Exporter.Write(opts.IO, release) + } + + if opts.IO.IsStdoutTTY() { + if err := renderVerifyTTY(opts.IO, release); err != nil { + return err + } + } else { + if err := renderVerifyPlain(opts.IO.Out, release); err != nil { + return err + } + } + + return nil +} + +func renderVerifyTTY(io *iostreams.IOStreams, release *shared.Release) error { + cs := io.ColorScheme() + w := io.Out + + fmt.Fprintf(w, "%s\n", cs.Bold(release.TagName)) + if release.IsDraft { + fmt.Fprintf(w, "%s • ", cs.Red("Draft")) + } else if release.IsPrerelease { + fmt.Fprintf(w, "%s • ", cs.Yellow("Pre-release")) + } + if release.IsDraft { + fmt.Fprintln(w, cs.Mutedf("%s created this %s", release.Author.Login, text.FuzzyAgo(time.Now(), release.CreatedAt))) + } else { + fmt.Fprintln(w, cs.Mutedf("%s released this %s", release.Author.Login, text.FuzzyAgo(time.Now(), *release.PublishedAt))) + } + + renderedDescription, err := markdown.Render(release.Body, + markdown.WithTheme(io.TerminalTheme()), + markdown.WithWrap(io.TerminalWidth())) + if err != nil { + return err + } + fmt.Fprintln(w, renderedDescription) + + if len(release.Assets) > 0 { + fmt.Fprintln(w, cs.Bold("Assets")) + //nolint:staticcheck // SA1019: Showing NAME|SIZE headers adds nothing to table. + table := tableprinter.New(io, tableprinter.NoHeader) + for _, a := range release.Assets { + table.AddField(a.Name) + table.AddField(humanFileSize(a.Size)) + table.EndRow() + } + err := table.Render() + if err != nil { + return err + } + fmt.Fprint(w, "\n") + } + + fmt.Fprintln(w, cs.Mutedf("View on GitHub: %s", release.URL)) + return nil +} + +func renderVerifyPlain(w io.Writer, release *shared.Release) error { + fmt.Fprintf(w, "title:\t%s\n", release.Name) + fmt.Fprintf(w, "tag:\t%s\n", release.TagName) + fmt.Fprintf(w, "draft:\t%v\n", release.IsDraft) + fmt.Fprintf(w, "prerelease:\t%v\n", release.IsPrerelease) + fmt.Fprintf(w, "author:\t%s\n", release.Author.Login) + fmt.Fprintf(w, "created:\t%s\n", release.CreatedAt.Format(time.RFC3339)) + if !release.IsDraft { + fmt.Fprintf(w, "published:\t%s\n", release.PublishedAt.Format(time.RFC3339)) + } + fmt.Fprintf(w, "url:\t%s\n", release.URL) + for _, a := range release.Assets { + fmt.Fprintf(w, "asset:\t%s\n", a.Name) + } + fmt.Fprint(w, "--\n") + fmt.Fprint(w, release.Body) + if !strings.HasSuffix(release.Body, "\n") { + fmt.Fprintf(w, "\n") + } + return nil +} + +func humanFileSize(s int64) string { + if s < 1024 { + return fmt.Sprintf("%d B", s) + } + + kb := float64(s) / 1024 + if kb < 1024 { + return fmt.Sprintf("%s KiB", floatToString(kb, 2)) + } + + mb := kb / 1024 + if mb < 1024 { + return fmt.Sprintf("%s MiB", floatToString(mb, 2)) + } + + gb := mb / 1024 + return fmt.Sprintf("%s GiB", floatToString(gb, 2)) +} + +// render float to fixed precision using truncation instead of rounding +func floatToString(f float64, p uint8) string { + fs := fmt.Sprintf("%#f%0*s", f, p, "") + idx := strings.IndexRune(fs, '.') + return fs[:idx+int(p)+1] +} From 510ce73d6efce407a2d011192052715d388bc075 Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Fri, 16 May 2025 14:22:45 -0400 Subject: [PATCH 02/31] wip --- pkg/cmd/attestation/verification/sigstore.go | 3 + pkg/cmd/release/verify/attestation.go | 12 +-- pkg/cmd/release/verify/verify.go | 80 +++++++++++++++++++- 3 files changed, 85 insertions(+), 10 deletions(-) diff --git a/pkg/cmd/attestation/verification/sigstore.go b/pkg/cmd/attestation/verification/sigstore.go index 190ea5c0f..14c8875d9 100644 --- a/pkg/cmd/attestation/verification/sigstore.go +++ b/pkg/cmd/attestation/verification/sigstore.go @@ -239,6 +239,9 @@ func (v *LiveSigstoreVerifier) verify(attestation *api.Attestation, policy verif result, err := verifier.Verify(attestation.Bundle, policy) // if verification fails, create the error and exit verification early if err != nil { + v.Logger.VerbosePrint(v.Logger.ColorScheme.Redf( + "Error is \"%s\"\n", err.Error(), + )) v.Logger.VerbosePrint(v.Logger.ColorScheme.Redf( "Failed to verify against issuer \"%s\" \n\n", issuer, )) diff --git a/pkg/cmd/release/verify/attestation.go b/pkg/cmd/release/verify/attestation.go index 9aa7461ae..8b4931f9a 100644 --- a/pkg/cmd/release/verify/attestation.go +++ b/pkg/cmd/release/verify/attestation.go @@ -48,11 +48,11 @@ func verifyAttestations(art artifact.DigestedArtifact, att []*api.Attestation, s } // Verify extensions - certExtVerified, err := verification.VerifyCertExtensions(sigstoreVerified, ec) - if err != nil { - logMsg := "✗ Policy verification failed" - return nil, logMsg, err - } + // certExtVerified, err := verification.VerifyCertExtensions(sigstoreVerified, ec) + // if err != nil { + // logMsg := "✗ Policy verification failed" + // return nil, logMsg, err + // } - return certExtVerified, "", nil + return sigstoreVerified, "", nil } diff --git a/pkg/cmd/release/verify/verify.go b/pkg/cmd/release/verify/verify.go index 785b5750a..3e0f739d5 100644 --- a/pkg/cmd/release/verify/verify.go +++ b/pkg/cmd/release/verify/verify.go @@ -9,7 +9,10 @@ import ( "strings" "time" + v1 "github.com/in-toto/attestation/go/v1" + "github.com/cli/cli/v2/pkg/cmd/attestation/verification" + "google.golang.org/protobuf/encoding/protojson" "github.com/cli/cli/v2/pkg/cmd/attestation/artifact" att_io "github.com/cli/cli/v2/pkg/cmd/attestation/io" @@ -136,15 +139,22 @@ func verifyRun(opts *VerifyOptions) error { // Print the message signifying success fetching attestations logger.Println(logMsg) + td, err := attestOption.APIClient.GetTrustDomain() + if err != nil { + logger.Println(logger.ColorScheme.Red("✗ Failed to get trust domain")) + return err + } + // print information about the policy that will be enforced against attestations - logger.Println("\nThe following policy criteria will be enforced:") + // logger.Println("\nThe following policy criteria will be enforced:") ec, err := newEnforcementCriteria(attestOption) + ec.SANRegex = "https://dotcom.releases.github.com" if err != nil { logger.Println(logger.ColorScheme.Red("✗ Failed to build policy information")) return err } - logger.Println(ec.BuildPolicyInformation()) + // logger.Println(ec.BuildPolicyInformation()) config := verification.SigstoreConfig{ TrustedRoot: "", @@ -152,12 +162,41 @@ func verifyRun(opts *VerifyOptions) error { NoPublicGood: true, } + config.TrustDomain = td + sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(config) if err != nil { logger.Println(logger.ColorScheme.Red("✗ Failed to create Sigstore verifier")) return err } - verified, errMsg, err := verifyAttestations(*artifact, attestations, sigstoreVerifier, ec) + + var filteredAttestations []*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 { + logger.Println(logger.ColorScheme.Red("✗ Failed to unmarshal statement")) + return err + } + expectedPURL := "pkg:github/" + attestOption.Repo + "@" + opts.TagName + purlValue := statementData.Predicate.GetFields()["purl"] + var purl string + if purlValue != nil { + purl = purlValue.GetStringValue() + } + + // fmt.Print("purlValue: ", expectedPURL, "\n") + // fmt.Print("purl: ", purl, "\n") + if purl == expectedPURL { + filteredAttestations = append(filteredAttestations, att) + } + } + + verified, errMsg, err := verifyAttestations(*artifact, filteredAttestations, sigstoreVerifier, ec) if err != nil { logger.Println(logger.ColorScheme.Red(errMsg)) return err @@ -167,6 +206,39 @@ func verifyRun(opts *VerifyOptions) error { logger.Println(logger.ColorScheme.Green("✓ Verification succeeded!\n")) + // Print verified attestations + for _, att := range verified { + + // • {"_type":"https://in-toto.io/Statement/v1", "subject":[{"name":"pkg:github/bdehamer/delme@v2.0.0", "digest":{"sha1":"c5e17a62e06a1d201570249c61fae531e9244e1b"}}, {"name":"bdehamer-attest-demo-attestation-6498970.sigstore.1.json", "digest":{"sha256":"b41c3c570a2f60272cb387a58f3e574c6f9da913f6281204b67a223e6ae56176"}}], "predicateType":"https://in-toto.io/attestation/release/v0.1", "predicate":{"ownerId":"398027", "purl":"pkg:github/bdehamer/delme@v2.0.0", "releaseId":"217656813", "repository":"bdehamer/delme", "repositoryId":"905988044", "tag":"v2.0.0"}} + statement := att.Attestation.Bundle.GetDsseEnvelope().Payload + + // cast statement to {"_type":"https://in-toto.io/Statement/v1", "subject":[{"name":"pkg:github/bdehamer/delme@v2.0.0", "digest":{"sha1":"c5e17a62e06a1d201570249c61fae531e9244e1b"}}, {"name":"bdehamer-attest-demo-attestation-6498970.sigstore.1.json", "digest":{"sha256":"b41c3c570a2f60272cb387a58f3e574c6f9da913f6281204b67a223e6ae56176"}}], "predicateType":"https://in-toto.io/attestation/release/v0.1", "predicate":{"ownerId":"398027", "purl":"pkg:github/bdehamer/delme@v2.0.0", "releaseId":"217656813", "repository":"bdehamer/delme", "repositoryId":"905988044", "tag":"v2.0.0"}} + + var statementData v1.Statement + err = protojson.Unmarshal([]byte(statement), &statementData) + if err != nil { + logger.Println(logger.ColorScheme.Red("✗ Failed to unmarshal statement")) + return err + } + + subjects := statementData.Subject + + for _, s := range subjects { + // // Print the subject name and digest + // logger.Printf("• %s\n", s.Name) + // for k, v := range s.Digest { + // // Print the digest algorithm and value + // logger.Printf(" - %s: %s\n", k, v) + // } + + // Print the whole subject + logger.Printf("%s\n", s.String()) + } + + // logger.Printf("• %s\n", att.Attestation.Bundle.GetDsseEnvelope().Payload) + + } + // Verify attestations opts.IO.DetectTerminalTheme() @@ -196,7 +268,7 @@ func renderVerifyTTY(io *iostreams.IOStreams, release *shared.Release) error { cs := io.ColorScheme() w := io.Out - fmt.Fprintf(w, "%s\n", cs.Bold(release.TagName)) + // fmt.Fprintf(w, "%s\n", cs.Bold(release.TagName)) if release.IsDraft { fmt.Fprintf(w, "%s • ", cs.Red("Draft")) } else if release.IsPrerelease { From 74c6a36c20cdd14a64599fa6f8e996e1b3b06bf4 Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Fri, 16 May 2025 14:59:04 -0400 Subject: [PATCH 03/31] remove comment --- pkg/cmd/release/verify/verify.go | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/pkg/cmd/release/verify/verify.go b/pkg/cmd/release/verify/verify.go index 3e0f739d5..e26b1d50c 100644 --- a/pkg/cmd/release/verify/verify.go +++ b/pkg/cmd/release/verify/verify.go @@ -154,7 +154,6 @@ func verifyRun(opts *VerifyOptions) error { logger.Println(logger.ColorScheme.Red("✗ Failed to build policy information")) return err } - // logger.Println(ec.BuildPolicyInformation()) config := verification.SigstoreConfig{ TrustedRoot: "", @@ -170,6 +169,7 @@ func verifyRun(opts *VerifyOptions) error { return err } + // Filter attestations by predicate PURL var filteredAttestations []*api.Attestation for _, att := range attestations { @@ -196,6 +196,7 @@ func verifyRun(opts *VerifyOptions) error { } } + // Verify attestations verified, errMsg, err := verifyAttestations(*artifact, filteredAttestations, sigstoreVerifier, ec) if err != nil { logger.Println(logger.ColorScheme.Red(errMsg)) @@ -208,12 +209,8 @@ func verifyRun(opts *VerifyOptions) error { // Print verified attestations for _, att := range verified { - - // • {"_type":"https://in-toto.io/Statement/v1", "subject":[{"name":"pkg:github/bdehamer/delme@v2.0.0", "digest":{"sha1":"c5e17a62e06a1d201570249c61fae531e9244e1b"}}, {"name":"bdehamer-attest-demo-attestation-6498970.sigstore.1.json", "digest":{"sha256":"b41c3c570a2f60272cb387a58f3e574c6f9da913f6281204b67a223e6ae56176"}}], "predicateType":"https://in-toto.io/attestation/release/v0.1", "predicate":{"ownerId":"398027", "purl":"pkg:github/bdehamer/delme@v2.0.0", "releaseId":"217656813", "repository":"bdehamer/delme", "repositoryId":"905988044", "tag":"v2.0.0"}} statement := att.Attestation.Bundle.GetDsseEnvelope().Payload - // cast statement to {"_type":"https://in-toto.io/Statement/v1", "subject":[{"name":"pkg:github/bdehamer/delme@v2.0.0", "digest":{"sha1":"c5e17a62e06a1d201570249c61fae531e9244e1b"}}, {"name":"bdehamer-attest-demo-attestation-6498970.sigstore.1.json", "digest":{"sha256":"b41c3c570a2f60272cb387a58f3e574c6f9da913f6281204b67a223e6ae56176"}}], "predicateType":"https://in-toto.io/attestation/release/v0.1", "predicate":{"ownerId":"398027", "purl":"pkg:github/bdehamer/delme@v2.0.0", "releaseId":"217656813", "repository":"bdehamer/delme", "repositoryId":"905988044", "tag":"v2.0.0"}} - var statementData v1.Statement err = protojson.Unmarshal([]byte(statement), &statementData) if err != nil { @@ -224,23 +221,10 @@ func verifyRun(opts *VerifyOptions) error { subjects := statementData.Subject for _, s := range subjects { - // // Print the subject name and digest - // logger.Printf("• %s\n", s.Name) - // for k, v := range s.Digest { - // // Print the digest algorithm and value - // logger.Printf(" - %s: %s\n", k, v) - // } - - // Print the whole subject logger.Printf("%s\n", s.String()) } - - // logger.Printf("• %s\n", att.Attestation.Bundle.GetDsseEnvelope().Payload) - } - // Verify attestations - opts.IO.DetectTerminalTheme() if err := opts.IO.StartPager(); err != nil { fmt.Fprintf(opts.IO.ErrOut, "error starting pager: %v\n", err) From 26b46f939deb42f62168b087265f88ba63479620 Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Tue, 20 May 2025 11:12:46 -0400 Subject: [PATCH 04/31] wip --- .../{verify => attestation}/attestation.go | 33 +- pkg/cmd/release/attestation/options.go | 92 ++++++ pkg/cmd/release/attestation/policy.go | 86 +++++ pkg/cmd/release/verify-asset/verify-asset.go | 308 ++++++++++++++++++ pkg/cmd/release/verify/options.go | 104 ------ pkg/cmd/release/verify/policy.go | 168 ---------- pkg/cmd/release/verify/verify.go | 269 +++++++-------- 7 files changed, 634 insertions(+), 426 deletions(-) rename pkg/cmd/release/{verify => attestation}/attestation.go (59%) create mode 100644 pkg/cmd/release/attestation/options.go create mode 100644 pkg/cmd/release/attestation/policy.go create mode 100644 pkg/cmd/release/verify-asset/verify-asset.go delete mode 100644 pkg/cmd/release/verify/options.go delete mode 100644 pkg/cmd/release/verify/policy.go diff --git a/pkg/cmd/release/verify/attestation.go b/pkg/cmd/release/attestation/attestation.go similarity index 59% rename from pkg/cmd/release/verify/attestation.go rename to pkg/cmd/release/attestation/attestation.go index 8b4931f9a..a8c654f46 100644 --- a/pkg/cmd/release/verify/attestation.go +++ b/pkg/cmd/release/attestation/attestation.go @@ -1,4 +1,4 @@ -package verify +package attestation import ( "errors" @@ -8,9 +8,13 @@ import ( "github.com/cli/cli/v2/pkg/cmd/attestation/api" "github.com/cli/cli/v2/pkg/cmd/attestation/artifact" "github.com/cli/cli/v2/pkg/cmd/attestation/verification" + + att_io "github.com/cli/cli/v2/pkg/cmd/attestation/io" + v1 "github.com/in-toto/attestation/go/v1" + "google.golang.org/protobuf/encoding/protojson" ) -func getAttestations(o *Options, sha string) ([]*api.Attestation, string, error) { +func GetAttestations(o *AttestOptions, sha string) ([]*api.Attestation, string, error) { if o.APIClient == nil { errMsg := "✗ No APIClient provided" return nil, errMsg, errors.New(errMsg) @@ -34,7 +38,7 @@ func getAttestations(o *Options, sha string) ([]*api.Attestation, string, error) return attestations, msg, nil } -func verifyAttestations(art artifact.DigestedArtifact, att []*api.Attestation, sgVerifier verification.SigstoreVerifier, ec verification.EnforcementCriteria) ([]*verification.AttestationProcessingResult, string, error) { +func VerifyAttestations(art artifact.DigestedArtifact, att []*api.Attestation, sgVerifier verification.SigstoreVerifier, ec verification.EnforcementCriteria) ([]*verification.AttestationProcessingResult, string, error) { sgPolicy, err := buildSigstoreVerifyPolicy(ec, art) if err != nil { logMsg := "✗ Failed to build Sigstore verification policy" @@ -56,3 +60,26 @@ func verifyAttestations(art artifact.DigestedArtifact, att []*api.Attestation, s return sigstoreVerified, "", nil } + +func FilterAttestationsByPURL(attestations []*api.Attestation, repo, tagName string, logger *att_io.Handler) []*api.Attestation { + var filtered []*api.Attestation + expectedPURL := "pkg:github/" + repo + "@" + tagName + for _, att := range attestations { + statement := att.Bundle.Bundle.GetDsseEnvelope().Payload + var statementData v1.Statement + err := protojson.Unmarshal([]byte(statement), &statementData) + if err != nil { + logger.Println(logger.ColorScheme.Red("✗ Failed to unmarshal statement")) + continue + } + purlValue := statementData.Predicate.GetFields()["purl"] + var purl string + if purlValue != nil { + purl = purlValue.GetStringValue() + } + if purl == expectedPURL { + filtered = append(filtered, att) + } + } + return filtered +} diff --git a/pkg/cmd/release/attestation/options.go b/pkg/cmd/release/attestation/options.go new file mode 100644 index 000000000..c567ff7e6 --- /dev/null +++ b/pkg/cmd/release/attestation/options.go @@ -0,0 +1,92 @@ +package attestation + +import ( + "fmt" + "net/http" + "strings" + + "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/ghinstance" + "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/oci" + "github.com/cli/cli/v2/pkg/cmd/attestation/io" + "github.com/cli/cli/v2/pkg/cmd/attestation/verification" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" +) + +type VerifyOptions struct { + HttpClient func() (*http.Client, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + Exporter cmdutil.Exporter + TagName string +} + +// AttestOptions captures the options for the verify command +type AttestOptions struct { + Config func() (gh.Config, error) + HttpClient *http.Client + IO *iostreams.IOStreams + BaseRepo ghrepo.Interface + Exporter cmdutil.Exporter + TagName string + TrustedRoot string + DigestAlgorithm string + Limit int + OIDCIssuer string + Owner string + PredicateType string + Repo string + SAN string + SANRegex string + SignerDigest string + SignerRepo string + SignerWorkflow string + SourceDigest string + SourceRef string + APIClient api.Client + Logger *io.Handler + OCIClient oci.Client + SigstoreVerifier verification.SigstoreVerifier + exporter cmdutil.Exporter + Hostname string + EC verification.EnforcementCriteria + // Tenant is only set when tenancy is used + Tenant string +} + +// AreFlagsValid checks that the provided flag combination is valid +// and returns an error otherwise +func (opts *AttestOptions) AreFlagsValid() error { + // If provided, check that the Repo option is in the expected format / + if opts.Repo != "" && !isProvidedRepoValid(opts.Repo) { + return fmt.Errorf("invalid value provided for repo: %s", opts.Repo) + } + + // If provided, check that the SignerRepo option is in the expected format / + if opts.SignerRepo != "" && !isProvidedRepoValid(opts.SignerRepo) { + return fmt.Errorf("invalid value provided for signer-repo: %s", opts.SignerRepo) + } + + // Check that limit is between 1 and 1000 + if opts.Limit < 1 || opts.Limit > 1000 { + return fmt.Errorf("limit %d not allowed, must be between 1 and 1000", opts.Limit) + } + + // Verify provided hostname + if opts.Hostname != "" { + if err := ghinstance.HostnameValidator(opts.Hostname); err != nil { + return fmt.Errorf("error parsing hostname: %w", err) + } + } + + return nil +} + +func isProvidedRepoValid(repo string) bool { + // we expect a provided repository argument be in the format / + splitRepo := strings.Split(repo, "/") + return len(splitRepo) == 2 +} diff --git a/pkg/cmd/release/attestation/policy.go b/pkg/cmd/release/attestation/policy.go new file mode 100644 index 000000000..f875acf08 --- /dev/null +++ b/pkg/cmd/release/attestation/policy.go @@ -0,0 +1,86 @@ +package attestation + +import ( + "fmt" + + att_io "github.com/cli/cli/v2/pkg/cmd/attestation/io" + "github.com/sigstore/sigstore-go/pkg/fulcio/certificate" + "github.com/sigstore/sigstore-go/pkg/verify" + + "github.com/cli/cli/v2/pkg/cmd/attestation/artifact" + "github.com/cli/cli/v2/pkg/cmd/attestation/verification" +) + +const hostRegex = `^[a-zA-Z0-9-]+\.[a-zA-Z0-9-]+.*$` + +func expandToGitHubURL(tenant, ownerOrRepo string) string { + if tenant == "" { + return fmt.Sprintf("https://github.com/%s", ownerOrRepo) + } + return fmt.Sprintf("https://%s.ghe.com/%s", tenant, ownerOrRepo) +} + +// TODO: revisit this policy +func expandToGitHubURLRegex(tenant, ownerOrRepo string) string { + url := expandToGitHubURL(tenant, ownerOrRepo) + return fmt.Sprintf("(?i)^%s/", url) +} + +// TODO: revist this policy +func NewEnforcementCriteria(opts *AttestOptions, logger *att_io.Handler) (verification.EnforcementCriteria, error) { + // initialize the enforcement criteria with the provided PredicateType and SAN + c := verification.EnforcementCriteria{ + PredicateType: opts.PredicateType, + // if the proxima is provided, the default uses the proxima-specific SAN + SAN: "https://dotcom.releases.github.com", + } + + // If the Repo option is provided, set the SourceRepositoryURI extension + if opts.Repo != "" { + c.Certificate.SourceRepositoryURI = expandToGitHubURL(opts.Tenant, opts.Repo) + } + + // Set the SourceRepositoryOwnerURI extension using owner and tenant if provided + c.Certificate.SourceRepositoryOwnerURI = expandToGitHubURL(opts.Tenant, opts.Owner) + + return c, nil +} + +func buildCertificateIdentityOption(c verification.EnforcementCriteria) (verify.PolicyOption, error) { + sanMatcher, err := verify.NewSANMatcher(c.SAN, c.SANRegex) + if err != nil { + return nil, err + } + + // Accept any issuer, we will verify the issuer as part of the extension verification + issuerMatcher, err := verify.NewIssuerMatcher("", ".*") + if err != nil { + return nil, err + } + + extensions := certificate.Extensions{ + RunnerEnvironment: c.Certificate.RunnerEnvironment, + } + + certId, err := verify.NewCertificateIdentity(sanMatcher, issuerMatcher, extensions) + if err != nil { + return nil, err + } + + return verify.WithCertificateIdentity(certId), nil +} + +func buildSigstoreVerifyPolicy(c verification.EnforcementCriteria, a artifact.DigestedArtifact) (verify.PolicyBuilder, error) { + artifactDigestPolicyOption, err := verification.BuildDigestPolicyOption(a) + if err != nil { + return verify.PolicyBuilder{}, err + } + + certIdOption, err := buildCertificateIdentityOption(c) + if err != nil { + return verify.PolicyBuilder{}, err + } + + policy := verify.NewPolicy(artifactDigestPolicyOption, certIdOption) + return policy, nil +} diff --git a/pkg/cmd/release/verify-asset/verify-asset.go b/pkg/cmd/release/verify-asset/verify-asset.go new file mode 100644 index 000000000..3b7d1e8e9 --- /dev/null +++ b/pkg/cmd/release/verify-asset/verify-asset.go @@ -0,0 +1,308 @@ +package verify_asset + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + "time" + + v1 "github.com/in-toto/attestation/go/v1" + "google.golang.org/protobuf/encoding/protojson" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/tableprinter" + "github.com/cli/cli/v2/internal/text" + "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/attestation" + "github.com/cli/cli/v2/pkg/cmd/release/shared" + + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/cli/cli/v2/pkg/markdown" + ghauth "github.com/cli/go-gh/v2/pkg/auth" + "github.com/spf13/cobra" +) + +func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.VerifyOptions) error) *cobra.Command { + opts := &attestation.VerifyOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + } + + cmd := &cobra.Command{ + Use: "verify-asset []", + Short: "Verify information about a release", + Long: heredoc.Doc(` + Verify information about a GitHub Release. + + Without an explicit tag name argument, the latest release in the project + is shown. + `), + Args: cobra.ExactArgs(1), + PreRunE: func(cmd *cobra.Command, args []string) error { + // Create a logger for use throughout the verify command + // opts.Logger = io.NewHandler(f.IOStreams) + + // // set the artifact path + // opts.ArtifactPath = args[0] + + // // Check that the given flag combination is valid + // if err := opts.AreFlagsValid(); err != nil { + // return err + // } + + // // Clean file path options + // opts.Clean() + + // if opts.TagName == "" { + // return cmdutil.FlagErrorf("tag name is required") + // } + + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + if len(args) > 0 { + opts.TagName = args[0] + } + + if runF != nil { + return runF(opts) + } + + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + + baseRepo, err := opts.BaseRepo() + if err != nil { + return err + } + + logger := att_io.NewHandler(opts.IO) + hostname, _ := ghauth.DefaultHost() + option := attestation.AttestOptions{ + Repo: baseRepo.RepoOwner() + "/" + baseRepo.RepoName(), + APIClient: api.NewLiveClient(httpClient, hostname, logger), + Limit: 10, + Owner: baseRepo.RepoOwner(), + PredicateType: "https://in-toto.io/attestation/release/v0.1", + Logger: logger, + } + + option.HttpClient = httpClient + option.BaseRepo = baseRepo + option.IO = opts.IO + option.TagName = opts.TagName + option.Exporter = opts.Exporter + + td, err := option.APIClient.GetTrustDomain() + if err != nil { + logger.Println(logger.ColorScheme.Red("✗ Failed to get trust domain")) + return err + } + + ec, err := attestation.NewEnforcementCriteria(&option, logger) + if err != nil { + logger.Println(logger.ColorScheme.Red("✗ Failed to build policy information")) + return err + } + + config := verification.SigstoreConfig{ + TrustedRoot: "", + Logger: logger, + NoPublicGood: true, + TrustDomain: td, + } + sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(config) + if err != nil { + logger.Println(logger.ColorScheme.Red("✗ Failed to create Sigstore verifier")) + return err + } + + option.SigstoreVerifier = sigstoreVerifier + option.EC = ec + + // output ec + return verifyRun(&option) + }, + } + + cmdutil.AddJSONFlags(cmd, &opts.Exporter, shared.ReleaseFields) + + return cmd +} + +func verifyRun(opts *attestation.AttestOptions) error { + ctx := context.Background() + logger := opts.Logger + + release, err := shared.FetchRelease(ctx, opts.HttpClient, opts.BaseRepo, opts.TagName) + if err != nil { + return err + } + + sha, err := shared.FetchRefSHA(ctx, opts.HttpClient, opts.BaseRepo, opts.TagName) + if err != nil { + return err + } + + artifact := artifact.NewDigestedArtifactForRelease(opts.TagName, sha, "sha1") + + // Attestation fetching + attestations, logMsg, err := attestation.GetAttestations(opts, artifact.DigestWithAlg()) + if err != nil { + if errors.Is(err, api.ErrNoAttestationsFound) { + logger.Printf(logger.ColorScheme.Red("✗ No attestations found for subject %s\n"), artifact.DigestWithAlg()) + return err + } + logger.Println(logger.ColorScheme.Red(logMsg)) + return err + } + + // Filter attestations by predicate PURL + filteredAttestations := attestation.FilterAttestationsByPURL(attestations, opts.Repo, opts.TagName, logger) + + // Verify attestations + verified, errMsg, err := attestation.VerifyAttestations(*artifact, filteredAttestations, opts.SigstoreVerifier, opts.EC) + + if err != nil { + logger.Println(logger.ColorScheme.Red(errMsg)) + return err + } + + logger.Printf("The following %s matched the policy criteria\n\n", text.Pluralize(len(verified), "attestation")) + logger.Println(logger.ColorScheme.Green("✓ Verification succeeded!\n")) + + printVerifiedSubjects(verified, logger) + + opts.IO.DetectTerminalTheme() + if err := opts.IO.StartPager(); err != nil { + fmt.Fprintf(opts.IO.ErrOut, "error starting pager: %v\n", err) + } + defer opts.IO.StopPager() + + if opts.Exporter != nil { + return opts.Exporter.Write(opts.IO, release) + } + + if opts.IO.IsStdoutTTY() { + return renderVerifyTTY(opts.IO, release) + } + return renderVerifyPlain(opts.IO.Out, release) +} + +func printVerifiedSubjects(verified []*verification.AttestationProcessingResult, logger *att_io.Handler) { + for _, att := range verified { + statement := att.Attestation.Bundle.GetDsseEnvelope().Payload + var statementData v1.Statement + err := protojson.Unmarshal([]byte(statement), &statementData) + if err != nil { + logger.Println(logger.ColorScheme.Red("✗ Failed to unmarshal statement")) + continue + } + for _, s := range statementData.Subject { + logger.Printf("%s\n", s.String()) + } + } +} + +func renderVerifyTTY(io *iostreams.IOStreams, release *shared.Release) error { + cs := io.ColorScheme() + w := io.Out + + fmt.Fprintf(w, "%s\n", cs.Bold(release.TagName)) + if release.IsDraft { + fmt.Fprintf(w, "%s • ", cs.Red("Draft")) + } else if release.IsPrerelease { + fmt.Fprintf(w, "%s • ", cs.Yellow("Pre-release")) + } + if release.IsDraft { + fmt.Fprintln(w, cs.Mutedf("%s created this %s", release.Author.Login, text.FuzzyAgo(time.Now(), release.CreatedAt))) + } else { + fmt.Fprintln(w, cs.Mutedf("%s released this %s", release.Author.Login, text.FuzzyAgo(time.Now(), *release.PublishedAt))) + } + + renderedDescription, err := markdown.Render(release.Body, + markdown.WithTheme(io.TerminalTheme()), + markdown.WithWrap(io.TerminalWidth())) + if err != nil { + return err + } + fmt.Fprintln(w, renderedDescription) + + if len(release.Assets) > 0 { + fmt.Fprintln(w, cs.Bold("Assets")) + //nolint:staticcheck // SA1019: Showing NAME|SIZE headers adds nothing to table. + table := tableprinter.New(io, tableprinter.NoHeader) + for _, a := range release.Assets { + table.AddField(a.Name) + table.AddField(humanFileSize(a.Size)) + table.EndRow() + } + err := table.Render() + if err != nil { + return err + } + fmt.Fprint(w, "\n") + } + + fmt.Fprintln(w, cs.Mutedf("View on GitHub: %s", release.URL)) + return nil +} + +func renderVerifyPlain(w io.Writer, release *shared.Release) error { + fmt.Fprintf(w, "title:\t%s\n", release.Name) + fmt.Fprintf(w, "tag:\t%s\n", release.TagName) + fmt.Fprintf(w, "draft:\t%v\n", release.IsDraft) + fmt.Fprintf(w, "prerelease:\t%v\n", release.IsPrerelease) + fmt.Fprintf(w, "author:\t%s\n", release.Author.Login) + fmt.Fprintf(w, "created:\t%s\n", release.CreatedAt.Format(time.RFC3339)) + if !release.IsDraft { + fmt.Fprintf(w, "published:\t%s\n", release.PublishedAt.Format(time.RFC3339)) + } + fmt.Fprintf(w, "url:\t%s\n", release.URL) + for _, a := range release.Assets { + fmt.Fprintf(w, "asset:\t%s\n", a.Name) + } + fmt.Fprint(w, "--\n") + fmt.Fprint(w, release.Body) + if !strings.HasSuffix(release.Body, "\n") { + fmt.Fprintf(w, "\n") + } + return nil +} + +func humanFileSize(s int64) string { + if s < 1024 { + return fmt.Sprintf("%d B", s) + } + + kb := float64(s) / 1024 + if kb < 1024 { + return fmt.Sprintf("%s KiB", floatToString(kb, 2)) + } + + mb := kb / 1024 + if mb < 1024 { + return fmt.Sprintf("%s MiB", floatToString(mb, 2)) + } + + gb := mb / 1024 + return fmt.Sprintf("%s GiB", floatToString(gb, 2)) +} + +// render float to fixed precision using truncation instead of rounding +func floatToString(f float64, p uint8) string { + fs := fmt.Sprintf("%#f%0*s", f, p, "") + idx := strings.IndexRune(fs, '.') + return fs[:idx+int(p)+1] +} diff --git a/pkg/cmd/release/verify/options.go b/pkg/cmd/release/verify/options.go deleted file mode 100644 index e47c4f4a8..000000000 --- a/pkg/cmd/release/verify/options.go +++ /dev/null @@ -1,104 +0,0 @@ -package verify - -import ( - "fmt" - "path/filepath" - "strings" - - "github.com/cli/cli/v2/internal/gh" - "github.com/cli/cli/v2/internal/ghinstance" - "github.com/cli/cli/v2/pkg/cmd/attestation/api" - "github.com/cli/cli/v2/pkg/cmd/attestation/artifact/oci" - "github.com/cli/cli/v2/pkg/cmd/attestation/io" - "github.com/cli/cli/v2/pkg/cmd/attestation/verification" - "github.com/cli/cli/v2/pkg/cmdutil" -) - -// Options captures the options for the verify command -type Options struct { - ArtifactPath string - BundlePath string - UseBundleFromRegistry bool - Config func() (gh.Config, error) - TrustedRoot string - DenySelfHostedRunner bool - DigestAlgorithm string - Limit int - NoPublicGood bool - OIDCIssuer string - Owner string - PredicateType string - Repo string - SAN string - SANRegex string - SignerDigest string - SignerRepo string - SignerWorkflow string - SourceDigest string - SourceRef string - APIClient api.Client - Logger *io.Handler - OCIClient oci.Client - SigstoreVerifier verification.SigstoreVerifier - exporter cmdutil.Exporter - Hostname string - // Tenant is only set when tenancy is used - Tenant string -} - -// Clean cleans the file path option values -func (opts *Options) Clean() { - if opts.BundlePath != "" { - opts.BundlePath = filepath.Clean(opts.BundlePath) - } -} - -// FetchAttestationsFromGitHubAPI returns true if the command should fetch attestations from the GitHub API -// It checks that a bundle path is not provided and that the "use bundle from registry" flag is not set -func (opts *Options) FetchAttestationsFromGitHubAPI() bool { - return opts.BundlePath == "" && !opts.UseBundleFromRegistry -} - -// AreFlagsValid checks that the provided flag combination is valid -// and returns an error otherwise -func (opts *Options) AreFlagsValid() error { - // If provided, check that the Repo option is in the expected format / - if opts.Repo != "" && !isProvidedRepoValid(opts.Repo) { - return fmt.Errorf("invalid value provided for repo: %s", opts.Repo) - } - - // If provided, check that the SignerRepo option is in the expected format / - if opts.SignerRepo != "" && !isProvidedRepoValid(opts.SignerRepo) { - return fmt.Errorf("invalid value provided for signer-repo: %s", opts.SignerRepo) - } - - // Check that limit is between 1 and 1000 - if opts.Limit < 1 || opts.Limit > 1000 { - return fmt.Errorf("limit %d not allowed, must be between 1 and 1000", opts.Limit) - } - - // Check that the bundle-from-oci flag is only used with OCI artifact paths - if opts.UseBundleFromRegistry && !strings.HasPrefix(opts.ArtifactPath, "oci://") { - return fmt.Errorf("bundle-from-oci flag can only be used with OCI artifact paths") - } - - // Check that both the bundle-from-oci and bundle-path flags are not used together - if opts.UseBundleFromRegistry && opts.BundlePath != "" { - return fmt.Errorf("bundle-from-oci flag cannot be used with bundle-path flag") - } - - // Verify provided hostname - if opts.Hostname != "" { - if err := ghinstance.HostnameValidator(opts.Hostname); err != nil { - return fmt.Errorf("error parsing hostname: %w", err) - } - } - - return nil -} - -func isProvidedRepoValid(repo string) bool { - // we expect a provided repository argument be in the format / - splitRepo := strings.Split(repo, "/") - return len(splitRepo) == 2 -} diff --git a/pkg/cmd/release/verify/policy.go b/pkg/cmd/release/verify/policy.go deleted file mode 100644 index 1d1595eca..000000000 --- a/pkg/cmd/release/verify/policy.go +++ /dev/null @@ -1,168 +0,0 @@ -package verify - -import ( - "errors" - "fmt" - "regexp" - "strings" - - "github.com/sigstore/sigstore-go/pkg/fulcio/certificate" - "github.com/sigstore/sigstore-go/pkg/verify" - - "github.com/cli/cli/v2/pkg/cmd/attestation/artifact" - "github.com/cli/cli/v2/pkg/cmd/attestation/verification" -) - -const hostRegex = `^[a-zA-Z0-9-]+\.[a-zA-Z0-9-]+.*$` - -func expandToGitHubURL(tenant, ownerOrRepo string) string { - if tenant == "" { - return fmt.Sprintf("https://github.com/%s", ownerOrRepo) - } - return fmt.Sprintf("https://%s.ghe.com/%s", tenant, ownerOrRepo) -} - -func expandToGitHubURLRegex(tenant, ownerOrRepo string) string { - url := expandToGitHubURL(tenant, ownerOrRepo) - return fmt.Sprintf("(?i)^%s/", url) -} - -func newEnforcementCriteria(opts *Options) (verification.EnforcementCriteria, error) { - // initialize the enforcement criteria with the provided PredicateType - c := verification.EnforcementCriteria{ - PredicateType: opts.PredicateType, - } - - // set the owner value by checking the repo and owner options - var owner string - if opts.Repo != "" { - // we expect the repo argument to be in the format / - splitRepo := strings.Split(opts.Repo, "/") - // if Repo is provided but owner is not, set the OWNER portion of the Repo value - // to Owner - owner = splitRepo[0] - } else { - // otherwise use the user provided owner value - owner = opts.Owner - } - - // Set the SANRegex and SAN values using the provided options - // First check if the opts.SANRegex or opts.SAN values are provided - if opts.SANRegex != "" || opts.SAN != "" { - c.SANRegex = opts.SANRegex - c.SAN = opts.SAN - } else if opts.SignerRepo != "" { - // next check if opts.SignerRepo was provided - signedRepoRegex := expandToGitHubURLRegex(opts.Tenant, opts.SignerRepo) - c.SANRegex = signedRepoRegex - } else if opts.SignerWorkflow != "" { - validatedWorkflowRegex, err := validateSignerWorkflow(opts.Hostname, opts.SignerWorkflow) - if err != nil { - return verification.EnforcementCriteria{}, err - } - c.SANRegex = validatedWorkflowRegex - } else if opts.Repo != "" { - // if the user has not provided the SAN, SANRegex, SignerRepo, or SignerWorkflow options - // then we default to the repo option - c.SANRegex = expandToGitHubURLRegex(opts.Tenant, opts.Repo) - } else { - // if opts.Repo was not provided, we fall back to the opts.Owner value - c.SANRegex = expandToGitHubURLRegex(opts.Tenant, owner) - } - - // if the DenySelfHostedRunner option is set to true, set the - // RunnerEnvironment extension to the GitHub hosted runner value - if opts.DenySelfHostedRunner { - c.Certificate.RunnerEnvironment = verification.GitHubRunner - } else { - // if Certificate.RunnerEnvironment value is set to the empty string - // through the second function argument, - // no certificate matching will happen on the RunnerEnvironment field - c.Certificate.RunnerEnvironment = "" - } - - // If the Repo option is provided, set the SourceRepositoryURI extension - if opts.Repo != "" { - c.Certificate.SourceRepositoryURI = expandToGitHubURL(opts.Tenant, opts.Repo) - } - - // Set the SourceRepositoryOwnerURI extension using owner and tenant if provided - c.Certificate.SourceRepositoryOwnerURI = expandToGitHubURL(opts.Tenant, owner) - - // if the tenant is provided and OIDC issuer provided matches the default - // use the tenant-specific issuer - if opts.Tenant != "" && opts.OIDCIssuer == verification.GitHubOIDCIssuer { - c.Certificate.Issuer = fmt.Sprintf(verification.GitHubTenantOIDCIssuer, opts.Tenant) - } else { - // otherwise use the custom OIDC issuer provided as an option - c.Certificate.Issuer = opts.OIDCIssuer - } - - // set the SourceRepositoryDigest, SourceRepositoryRef, and BuildSignerDigest - // extensions if the options are provided - c.Certificate.BuildSignerDigest = opts.SignerDigest - c.Certificate.SourceRepositoryDigest = opts.SourceDigest - c.Certificate.SourceRepositoryRef = opts.SourceRef - - return c, nil -} - -func buildCertificateIdentityOption(c verification.EnforcementCriteria) (verify.PolicyOption, error) { - sanMatcher, err := verify.NewSANMatcher(c.SAN, c.SANRegex) - if err != nil { - return nil, err - } - - // Accept any issuer, we will verify the issuer as part of the extension verification - issuerMatcher, err := verify.NewIssuerMatcher("", ".*") - if err != nil { - return nil, err - } - - extensions := certificate.Extensions{ - RunnerEnvironment: c.Certificate.RunnerEnvironment, - } - - certId, err := verify.NewCertificateIdentity(sanMatcher, issuerMatcher, extensions) - if err != nil { - return nil, err - } - - return verify.WithCertificateIdentity(certId), nil -} - -func buildSigstoreVerifyPolicy(c verification.EnforcementCriteria, a artifact.DigestedArtifact) (verify.PolicyBuilder, error) { - artifactDigestPolicyOption, err := verification.BuildDigestPolicyOption(a) - if err != nil { - return verify.PolicyBuilder{}, err - } - - certIdOption, err := buildCertificateIdentityOption(c) - if err != nil { - return verify.PolicyBuilder{}, err - } - - policy := verify.NewPolicy(artifactDigestPolicyOption, certIdOption) - return policy, nil -} - -func validateSignerWorkflow(hostname, signerWorkflow string) (string, error) { - // we expect a provided workflow argument be in the format [HOST/]///path/to/workflow.yml - // if the provided workflow does not contain a host, set the host - match, err := regexp.MatchString(hostRegex, signerWorkflow) - if err != nil { - return "", err - } - - if match { - return fmt.Sprintf("^https://%s", signerWorkflow), nil - } - - // if the provided workflow did not match the expect format - // we move onto creating a signer workflow using the provided host name - if hostname == "" { - return "", errors.New("unknown signer workflow host") - } - - return fmt.Sprintf("^https://%s/%s", hostname, signerWorkflow), nil -} diff --git a/pkg/cmd/release/verify/verify.go b/pkg/cmd/release/verify/verify.go index e26b1d50c..c739f226d 100644 --- a/pkg/cmd/release/verify/verify.go +++ b/pkg/cmd/release/verify/verify.go @@ -5,24 +5,22 @@ import ( "errors" "fmt" "io" - "net/http" "strings" "time" v1 "github.com/in-toto/attestation/go/v1" - - "github.com/cli/cli/v2/pkg/cmd/attestation/verification" "google.golang.org/protobuf/encoding/protojson" - "github.com/cli/cli/v2/pkg/cmd/attestation/artifact" - att_io "github.com/cli/cli/v2/pkg/cmd/attestation/io" - "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/tableprinter" "github.com/cli/cli/v2/internal/text" "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/attestation" "github.com/cli/cli/v2/pkg/cmd/release/shared" + "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/pkg/markdown" @@ -30,17 +28,8 @@ import ( "github.com/spf13/cobra" ) -type VerifyOptions struct { - HttpClient func() (*http.Client, error) - IO *iostreams.IOStreams - BaseRepo func() (ghrepo.Interface, error) - Exporter cmdutil.Exporter - - TagName string -} - -func NewCmdVerify(f *cmdutil.Factory, runF func(*VerifyOptions) error) *cobra.Command { - opts := &VerifyOptions{ +func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.VerifyOptions) error) *cobra.Command { + opts := &attestation.VerifyOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, } @@ -55,6 +44,27 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*VerifyOptions) error) *cobra.Co is shown. `), Args: cobra.MaximumNArgs(1), + PreRunE: func(cmd *cobra.Command, args []string) error { + // Create a logger for use throughout the verify command + // opts.Logger = io.NewHandler(f.IOStreams) + + // // set the artifact path + // opts.ArtifactPath = args[0] + + // // Check that the given flag combination is valid + // if err := opts.AreFlagsValid(); err != nil { + // return err + // } + + // // Clean file path options + // opts.Clean() + + // if opts.TagName == "" { + // return cmdutil.FlagErrorf("tag name is required") + // } + + return nil + }, RunE: func(cmd *cobra.Command, args []string) error { // support `-R, --repo` override opts.BaseRepo = f.BaseRepo @@ -66,7 +76,63 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*VerifyOptions) error) *cobra.Co if runF != nil { return runF(opts) } - return verifyRun(opts) + + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + + baseRepo, err := opts.BaseRepo() + if err != nil { + return err + } + + logger := att_io.NewHandler(opts.IO) + hostname, _ := ghauth.DefaultHost() + option := attestation.AttestOptions{ + Repo: baseRepo.RepoOwner() + "/" + baseRepo.RepoName(), + APIClient: api.NewLiveClient(httpClient, hostname, logger), + Limit: 10, + Owner: baseRepo.RepoOwner(), + PredicateType: "https://in-toto.io/attestation/release/v0.1", + Logger: logger, + } + + option.HttpClient = httpClient + option.BaseRepo = baseRepo + option.IO = opts.IO + option.TagName = opts.TagName + option.Exporter = opts.Exporter + + td, err := option.APIClient.GetTrustDomain() + if err != nil { + logger.Println(logger.ColorScheme.Red("✗ Failed to get trust domain")) + return err + } + + ec, err := attestation.NewEnforcementCriteria(&option, logger) + if err != nil { + logger.Println(logger.ColorScheme.Red("✗ Failed to build policy information")) + return err + } + + config := verification.SigstoreConfig{ + TrustedRoot: "", + Logger: logger, + NoPublicGood: true, + TrustDomain: td, + } + sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(config) + if err != nil { + logger.Println(logger.ColorScheme.Red("✗ Failed to create Sigstore verifier")) + return err + } + + option.SigstoreVerifier = sigstoreVerifier + option.EC = ec + + // output ec + return verifyRun(&option) }, } @@ -75,155 +141,48 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*VerifyOptions) error) *cobra.Co return cmd } -func verifyRun(opts *VerifyOptions) error { - httpClient, err := opts.HttpClient() - if err != nil { - return err - } - - baseRepo, err := opts.BaseRepo() - if err != nil { - return err - } - +func verifyRun(opts *attestation.AttestOptions) error { ctx := context.Background() - var release *shared.Release + logger := opts.Logger - if opts.TagName == "" { - return cmdutil.FlagErrorf("tag name is required") - } else { - release, err = shared.FetchRelease(ctx, httpClient, baseRepo, opts.TagName) - if err != nil { - return err - } - } - - sha, err := shared.FetchRefSHA(ctx, httpClient, baseRepo, opts.TagName) + release, err := shared.FetchRelease(ctx, opts.HttpClient, opts.BaseRepo, opts.TagName) if err != nil { return err } + + sha, err := shared.FetchRefSHA(ctx, opts.HttpClient, opts.BaseRepo, opts.TagName) + if err != nil { + return err + } + artifact := artifact.NewDigestedArtifactForRelease(opts.TagName, sha, "sha1") - sha = "sha1:" + sha - - // Resolved v1.0.0 to sha1:824acc86dd86a745b3014bd5353b844959f3591e - fmt.Println("Resolved", opts.TagName, "to "+sha) - - // Fetch Attestation - PredicateType := "https://in-toto.io/attestation/release/v0.1" - limit := 10 - - Hostname, _ := ghauth.DefaultHost() - - logger := att_io.NewHandler(opts.IO) - - repo := baseRepo.RepoOwner() + "/" + baseRepo.RepoName() - attestOption := &Options{ - Repo: repo, - APIClient: api.NewLiveClient(httpClient, Hostname, logger), - Limit: limit, - Owner: baseRepo.RepoOwner(), - PredicateType: PredicateType, - } - attestations, logMsg, err := getAttestations(attestOption, sha) - + // Attestation fetching + attestations, logMsg, err := attestation.GetAttestations(opts, artifact.DigestWithAlg()) if err != nil { - if ok := errors.Is(err, api.ErrNoAttestationsFound); ok { - logger.Printf(logger.ColorScheme.Red("✗ No attestations found for subject %s\n"), sha) + if errors.Is(err, api.ErrNoAttestationsFound) { + logger.Printf(logger.ColorScheme.Red("✗ No attestations found for subject %s\n"), artifact.DigestWithAlg()) return err } - // Print the message signifying failure fetching attestations logger.Println(logger.ColorScheme.Red(logMsg)) return err } - // Print the message signifying success fetching attestations - logger.Println(logMsg) - - td, err := attestOption.APIClient.GetTrustDomain() - if err != nil { - logger.Println(logger.ColorScheme.Red("✗ Failed to get trust domain")) - return err - } - - // print information about the policy that will be enforced against attestations - // logger.Println("\nThe following policy criteria will be enforced:") - ec, err := newEnforcementCriteria(attestOption) - ec.SANRegex = "https://dotcom.releases.github.com" - - if err != nil { - logger.Println(logger.ColorScheme.Red("✗ Failed to build policy information")) - return err - } - - config := verification.SigstoreConfig{ - TrustedRoot: "", - Logger: logger, - NoPublicGood: true, - } - - config.TrustDomain = td - - sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(config) - if err != nil { - logger.Println(logger.ColorScheme.Red("✗ Failed to create Sigstore verifier")) - return err - } // Filter attestations by predicate PURL - var filteredAttestations []*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 { - logger.Println(logger.ColorScheme.Red("✗ Failed to unmarshal statement")) - return err - } - expectedPURL := "pkg:github/" + attestOption.Repo + "@" + opts.TagName - purlValue := statementData.Predicate.GetFields()["purl"] - var purl string - if purlValue != nil { - purl = purlValue.GetStringValue() - } - - // fmt.Print("purlValue: ", expectedPURL, "\n") - // fmt.Print("purl: ", purl, "\n") - if purl == expectedPURL { - filteredAttestations = append(filteredAttestations, att) - } - } + filteredAttestations := attestation.FilterAttestationsByPURL(attestations, opts.Repo, opts.TagName, logger) // Verify attestations - verified, errMsg, err := verifyAttestations(*artifact, filteredAttestations, sigstoreVerifier, ec) + verified, errMsg, err := attestation.VerifyAttestations(*artifact, filteredAttestations, opts.SigstoreVerifier, opts.EC) + if err != nil { logger.Println(logger.ColorScheme.Red(errMsg)) return err } logger.Printf("The following %s matched the policy criteria\n\n", text.Pluralize(len(verified), "attestation")) - logger.Println(logger.ColorScheme.Green("✓ Verification succeeded!\n")) - // Print verified attestations - for _, att := range verified { - statement := att.Attestation.Bundle.GetDsseEnvelope().Payload - - var statementData v1.Statement - err = protojson.Unmarshal([]byte(statement), &statementData) - if err != nil { - logger.Println(logger.ColorScheme.Red("✗ Failed to unmarshal statement")) - return err - } - - subjects := statementData.Subject - - for _, s := range subjects { - logger.Printf("%s\n", s.String()) - } - } + printVerifiedSubjects(verified, logger) opts.IO.DetectTerminalTheme() if err := opts.IO.StartPager(); err != nil { @@ -236,23 +195,31 @@ func verifyRun(opts *VerifyOptions) error { } if opts.IO.IsStdoutTTY() { - if err := renderVerifyTTY(opts.IO, release); err != nil { - return err + return renderVerifyTTY(opts.IO, release) + } + return renderVerifyPlain(opts.IO.Out, release) +} + +func printVerifiedSubjects(verified []*verification.AttestationProcessingResult, logger *att_io.Handler) { + for _, att := range verified { + statement := att.Attestation.Bundle.GetDsseEnvelope().Payload + var statementData v1.Statement + err := protojson.Unmarshal([]byte(statement), &statementData) + if err != nil { + logger.Println(logger.ColorScheme.Red("✗ Failed to unmarshal statement")) + continue } - } else { - if err := renderVerifyPlain(opts.IO.Out, release); err != nil { - return err + for _, s := range statementData.Subject { + logger.Printf("%s\n", s.String()) } } - - return nil } func renderVerifyTTY(io *iostreams.IOStreams, release *shared.Release) error { cs := io.ColorScheme() w := io.Out - // fmt.Fprintf(w, "%s\n", cs.Bold(release.TagName)) + fmt.Fprintf(w, "%s\n", cs.Bold(release.TagName)) if release.IsDraft { fmt.Fprintf(w, "%s • ", cs.Red("Draft")) } else if release.IsPrerelease { From 3e5456827c0f2fe86442897665bb4a580b7f9d18 Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Tue, 20 May 2025 18:02:13 -0400 Subject: [PATCH 05/31] update the lng --- pkg/cmd/release/attestation/attestation.go | 26 ++ pkg/cmd/release/attestation/options.go | 14 +- pkg/cmd/release/attestation/policy.go | 8 - pkg/cmd/release/release.go | 3 + pkg/cmd/release/verify-asset/verify-asset.go | 220 ++++----------- pkg/cmd/release/verify/verify.go | 273 +++++-------------- 6 files changed, 160 insertions(+), 384 deletions(-) diff --git a/pkg/cmd/release/attestation/attestation.go b/pkg/cmd/release/attestation/attestation.go index a8c654f46..70760f8c6 100644 --- a/pkg/cmd/release/attestation/attestation.go +++ b/pkg/cmd/release/attestation/attestation.go @@ -83,3 +83,29 @@ func FilterAttestationsByPURL(attestations []*api.Attestation, repo, tagName str } return filtered } + +func FilterAttestationsByFileDigest(attestations []*api.Attestation, repo, tagName, fileDigest string, logger *att_io.Handler) []*api.Attestation { + 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 { + logger.Println(logger.ColorScheme.Red("✗ Failed to unmarshal statement")) + continue + } + subjects := statementData.Subject + for _, subject := range subjects { + digestMap := subject.GetDigest() + alg := "sha256" + + digest := digestMap[alg] + if digest == fileDigest { + filtered = append(filtered, att) + } + } + + } + return filtered +} diff --git a/pkg/cmd/release/attestation/options.go b/pkg/cmd/release/attestation/options.go index c567ff7e6..d4b3046ae 100644 --- a/pkg/cmd/release/attestation/options.go +++ b/pkg/cmd/release/attestation/options.go @@ -16,6 +16,17 @@ import ( "github.com/cli/cli/v2/pkg/iostreams" ) +type VerifyAssetOptions struct { + IO *iostreams.IOStreams + HttpClient func() (*http.Client, error) + + BaseRepo func() (ghrepo.Interface, error) + Exporter cmdutil.Exporter + + TagName string + FilePath string +} + type VerifyOptions struct { HttpClient func() (*http.Client, error) IO *iostreams.IOStreams @@ -54,7 +65,8 @@ type AttestOptions struct { Hostname string EC verification.EnforcementCriteria // Tenant is only set when tenancy is used - Tenant string + Tenant string + FilePath string } // AreFlagsValid checks that the provided flag combination is valid diff --git a/pkg/cmd/release/attestation/policy.go b/pkg/cmd/release/attestation/policy.go index f875acf08..7dfb88cfe 100644 --- a/pkg/cmd/release/attestation/policy.go +++ b/pkg/cmd/release/attestation/policy.go @@ -11,8 +11,6 @@ import ( "github.com/cli/cli/v2/pkg/cmd/attestation/verification" ) -const hostRegex = `^[a-zA-Z0-9-]+\.[a-zA-Z0-9-]+.*$` - func expandToGitHubURL(tenant, ownerOrRepo string) string { if tenant == "" { return fmt.Sprintf("https://github.com/%s", ownerOrRepo) @@ -20,12 +18,6 @@ func expandToGitHubURL(tenant, ownerOrRepo string) string { return fmt.Sprintf("https://%s.ghe.com/%s", tenant, ownerOrRepo) } -// TODO: revisit this policy -func expandToGitHubURLRegex(tenant, ownerOrRepo string) string { - url := expandToGitHubURL(tenant, ownerOrRepo) - return fmt.Sprintf("(?i)^%s/", url) -} - // TODO: revist this policy func NewEnforcementCriteria(opts *AttestOptions, logger *att_io.Handler) (verification.EnforcementCriteria, error) { // initialize the enforcement criteria with the provided PredicateType and SAN diff --git a/pkg/cmd/release/release.go b/pkg/cmd/release/release.go index 3e40b03e7..f25e8bd3a 100644 --- a/pkg/cmd/release/release.go +++ b/pkg/cmd/release/release.go @@ -9,6 +9,8 @@ import ( 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" @@ -36,6 +38,7 @@ func NewCmdRelease(f *cmdutil.Factory) *cobra.Command { cmdDelete.NewCmdDelete(f, nil), cmdDeleteAsset.NewCmdDeleteAsset(f, nil), cmdVerify.NewCmdVerify(f, nil), + cmdVerifyAsset.NewCmdVerifyAsset(f, nil), ) return cmd diff --git a/pkg/cmd/release/verify-asset/verify-asset.go b/pkg/cmd/release/verify-asset/verify-asset.go index 3b7d1e8e9..8df2f2a11 100644 --- a/pkg/cmd/release/verify-asset/verify-asset.go +++ b/pkg/cmd/release/verify-asset/verify-asset.go @@ -1,18 +1,12 @@ -package verify_asset +package verifyasset import ( "context" "errors" - "fmt" - "io" - "strings" - "time" + "path/filepath" - v1 "github.com/in-toto/attestation/go/v1" - "google.golang.org/protobuf/encoding/protojson" + ghauth "github.com/cli/go-gh/v2/pkg/auth" - "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/v2/internal/tableprinter" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmd/attestation/api" "github.com/cli/cli/v2/pkg/cmd/attestation/artifact" @@ -22,47 +16,20 @@ import ( "github.com/cli/cli/v2/pkg/cmd/release/shared" "github.com/cli/cli/v2/pkg/cmdutil" - "github.com/cli/cli/v2/pkg/iostreams" - "github.com/cli/cli/v2/pkg/markdown" - ghauth "github.com/cli/go-gh/v2/pkg/auth" "github.com/spf13/cobra" ) -func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.VerifyOptions) error) *cobra.Command { - opts := &attestation.VerifyOptions{ +func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.VerifyAssetOptions) error) *cobra.Command { + opts := &attestation.VerifyAssetOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, } cmd := &cobra.Command{ - Use: "verify-asset []", - Short: "Verify information about a release", - Long: heredoc.Doc(` - Verify information about a GitHub Release. - - Without an explicit tag name argument, the latest release in the project - is shown. - `), - Args: cobra.ExactArgs(1), + Use: "verify-asset ", + Short: "Verify that a given asset originated from a specific GitHub Release.", + Args: cobra.ExactArgs(2), PreRunE: func(cmd *cobra.Command, args []string) error { - // Create a logger for use throughout the verify command - // opts.Logger = io.NewHandler(f.IOStreams) - - // // set the artifact path - // opts.ArtifactPath = args[0] - - // // Check that the given flag combination is valid - // if err := opts.AreFlagsValid(); err != nil { - // return err - // } - - // // Clean file path options - // opts.Clean() - - // if opts.TagName == "" { - // return cmdutil.FlagErrorf("tag name is required") - // } - return nil }, RunE: func(cmd *cobra.Command, args []string) error { @@ -72,6 +39,9 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.VerifyOptions) erro if len(args) > 0 { opts.TagName = args[0] } + if len(args) > 1 { + opts.FilePath = args[1] + } if runF != nil { return runF(opts) @@ -103,6 +73,7 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.VerifyOptions) erro option.IO = opts.IO option.TagName = opts.TagName option.Exporter = opts.Exporter + option.FilePath = opts.FilePath td, err := option.APIClient.GetTrustDomain() if err != nil { @@ -132,177 +103,78 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.VerifyOptions) erro option.EC = ec // output ec - return verifyRun(&option) + return verifyAssetRun(&option) }, } cmdutil.AddJSONFlags(cmd, &opts.Exporter, shared.ReleaseFields) - return cmd } -func verifyRun(opts *attestation.AttestOptions) error { +func verifyAssetRun(opts *attestation.AttestOptions) error { ctx := context.Background() - logger := opts.Logger + fileName := getFileName(opts.FilePath) - release, err := shared.FetchRelease(ctx, opts.HttpClient, opts.BaseRepo, opts.TagName) + // calculate the digest of the file + fileDigest, err := artifact.NewDigestedArtifact(nil, opts.FilePath, "sha256") if err != nil { + opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to calculate file digest")) return err } + opts.Logger.Printf("Loaded digest %s for %s\n", fileDigest.DigestWithAlg(), fileName) + sha, err := shared.FetchRefSHA(ctx, opts.HttpClient, opts.BaseRepo, opts.TagName) if err != nil { return err } - - artifact := artifact.NewDigestedArtifactForRelease(opts.TagName, sha, "sha1") + releaseArtifact := artifact.NewDigestedArtifactForRelease(opts.TagName, sha, "sha1") + opts.Logger.Printf("Resolved %s to %s\n", opts.TagName, releaseArtifact.DigestWithAlg()) // Attestation fetching - attestations, logMsg, err := attestation.GetAttestations(opts, artifact.DigestWithAlg()) + attestations, logMsg, err := attestation.GetAttestations(opts, releaseArtifact.DigestWithAlg()) if err != nil { if errors.Is(err, api.ErrNoAttestationsFound) { - logger.Printf(logger.ColorScheme.Red("✗ No attestations found for subject %s\n"), artifact.DigestWithAlg()) + opts.Logger.Printf(opts.Logger.ColorScheme.Red("✗ No attestations found for subject %s\n"), releaseArtifact.DigestWithAlg()) return err } - logger.Println(logger.ColorScheme.Red(logMsg)) + opts.Logger.Println(opts.Logger.ColorScheme.Red(logMsg)) return err } // Filter attestations by predicate PURL - filteredAttestations := attestation.FilterAttestationsByPURL(attestations, opts.Repo, opts.TagName, logger) + filteredAttestations := attestation.FilterAttestationsByPURL(attestations, opts.Repo, opts.TagName, opts.Logger) + filteredAttestations = attestation.FilterAttestationsByFileDigest(filteredAttestations, opts.Repo, opts.TagName, fileDigest.Digest(), opts.Logger) + + opts.Logger.Printf("Loaded %s from GitHub API\n", text.Pluralize(len(filteredAttestations), "attestation")) // Verify attestations - verified, errMsg, err := attestation.VerifyAttestations(*artifact, filteredAttestations, opts.SigstoreVerifier, opts.EC) + verified, errMsg, err := attestation.VerifyAttestations(*releaseArtifact, filteredAttestations, opts.SigstoreVerifier, opts.EC) if err != nil { - logger.Println(logger.ColorScheme.Red(errMsg)) + opts.Logger.Println(opts.Logger.ColorScheme.Red(errMsg)) + + opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Verification failed")) + + // Release v1.0.0 does not contain bin-linux.tgz (sha256:0c2524c2b002fda89f8b766c7d3dd8e6ac1de183556728a83182c6137f19643d) + + opts.Logger.Printf(opts.Logger.ColorScheme.Red("Release %s does not contain %s (%s)\n"), opts.TagName, opts.FilePath, fileDigest.DigestWithAlg()) return err } - logger.Printf("The following %s matched the policy criteria\n\n", text.Pluralize(len(verified), "attestation")) - logger.Println(logger.ColorScheme.Green("✓ Verification succeeded!\n")) + opts.Logger.Printf("The following %s matched the policy criteria\n\n", text.Pluralize(len(verified), "attestation")) + opts.Logger.Println(opts.Logger.ColorScheme.Green("✓ Verification succeeded!\n")) - printVerifiedSubjects(verified, logger) + opts.Logger.Printf("Attestation found matching release %s (%s)\n", opts.TagName, releaseArtifact.DigestWithAlg()) - opts.IO.DetectTerminalTheme() - if err := opts.IO.StartPager(); err != nil { - fmt.Fprintf(opts.IO.ErrOut, "error starting pager: %v\n", err) - } - defer opts.IO.StopPager() + // bin-linux.tgz is present in release v1.0.0 + opts.Logger.Printf("%s is present in release %s\n", fileName, opts.TagName) - if opts.Exporter != nil { - return opts.Exporter.Write(opts.IO, release) - } - - if opts.IO.IsStdoutTTY() { - return renderVerifyTTY(opts.IO, release) - } - return renderVerifyPlain(opts.IO.Out, release) -} - -func printVerifiedSubjects(verified []*verification.AttestationProcessingResult, logger *att_io.Handler) { - for _, att := range verified { - statement := att.Attestation.Bundle.GetDsseEnvelope().Payload - var statementData v1.Statement - err := protojson.Unmarshal([]byte(statement), &statementData) - if err != nil { - logger.Println(logger.ColorScheme.Red("✗ Failed to unmarshal statement")) - continue - } - for _, s := range statementData.Subject { - logger.Printf("%s\n", s.String()) - } - } -} - -func renderVerifyTTY(io *iostreams.IOStreams, release *shared.Release) error { - cs := io.ColorScheme() - w := io.Out - - fmt.Fprintf(w, "%s\n", cs.Bold(release.TagName)) - if release.IsDraft { - fmt.Fprintf(w, "%s • ", cs.Red("Draft")) - } else if release.IsPrerelease { - fmt.Fprintf(w, "%s • ", cs.Yellow("Pre-release")) - } - if release.IsDraft { - fmt.Fprintln(w, cs.Mutedf("%s created this %s", release.Author.Login, text.FuzzyAgo(time.Now(), release.CreatedAt))) - } else { - fmt.Fprintln(w, cs.Mutedf("%s released this %s", release.Author.Login, text.FuzzyAgo(time.Now(), *release.PublishedAt))) - } - - renderedDescription, err := markdown.Render(release.Body, - markdown.WithTheme(io.TerminalTheme()), - markdown.WithWrap(io.TerminalWidth())) - if err != nil { - return err - } - fmt.Fprintln(w, renderedDescription) - - if len(release.Assets) > 0 { - fmt.Fprintln(w, cs.Bold("Assets")) - //nolint:staticcheck // SA1019: Showing NAME|SIZE headers adds nothing to table. - table := tableprinter.New(io, tableprinter.NoHeader) - for _, a := range release.Assets { - table.AddField(a.Name) - table.AddField(humanFileSize(a.Size)) - table.EndRow() - } - err := table.Render() - if err != nil { - return err - } - fmt.Fprint(w, "\n") - } - - fmt.Fprintln(w, cs.Mutedf("View on GitHub: %s", release.URL)) return nil } -func renderVerifyPlain(w io.Writer, release *shared.Release) error { - fmt.Fprintf(w, "title:\t%s\n", release.Name) - fmt.Fprintf(w, "tag:\t%s\n", release.TagName) - fmt.Fprintf(w, "draft:\t%v\n", release.IsDraft) - fmt.Fprintf(w, "prerelease:\t%v\n", release.IsPrerelease) - fmt.Fprintf(w, "author:\t%s\n", release.Author.Login) - fmt.Fprintf(w, "created:\t%s\n", release.CreatedAt.Format(time.RFC3339)) - if !release.IsDraft { - fmt.Fprintf(w, "published:\t%s\n", release.PublishedAt.Format(time.RFC3339)) - } - fmt.Fprintf(w, "url:\t%s\n", release.URL) - for _, a := range release.Assets { - fmt.Fprintf(w, "asset:\t%s\n", a.Name) - } - fmt.Fprint(w, "--\n") - fmt.Fprint(w, release.Body) - if !strings.HasSuffix(release.Body, "\n") { - fmt.Fprintf(w, "\n") - } - return nil -} - -func humanFileSize(s int64) string { - if s < 1024 { - return fmt.Sprintf("%d B", s) - } - - kb := float64(s) / 1024 - if kb < 1024 { - return fmt.Sprintf("%s KiB", floatToString(kb, 2)) - } - - mb := kb / 1024 - if mb < 1024 { - return fmt.Sprintf("%s MiB", floatToString(mb, 2)) - } - - gb := mb / 1024 - return fmt.Sprintf("%s GiB", floatToString(gb, 2)) -} - -// render float to fixed precision using truncation instead of rounding -func floatToString(f float64, p uint8) string { - fs := fmt.Sprintf("%#f%0*s", f, p, "") - idx := strings.IndexRune(fs, '.') - return fs[:idx+int(p)+1] +func getFileName(filePath string) string { + // Get the file name from the file path + _, fileName := filepath.Split(filePath) + return fileName } diff --git a/pkg/cmd/release/verify/verify.go b/pkg/cmd/release/verify/verify.go index c739f226d..4232cfca1 100644 --- a/pkg/cmd/release/verify/verify.go +++ b/pkg/cmd/release/verify/verify.go @@ -3,16 +3,10 @@ package verify import ( "context" "errors" - "fmt" - "io" - "strings" - "time" v1 "github.com/in-toto/attestation/go/v1" "google.golang.org/protobuf/encoding/protojson" - "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/v2/internal/tableprinter" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmd/attestation/api" "github.com/cli/cli/v2/pkg/cmd/attestation/artifact" @@ -22,117 +16,84 @@ import ( "github.com/cli/cli/v2/pkg/cmd/release/shared" "github.com/cli/cli/v2/pkg/cmdutil" - "github.com/cli/cli/v2/pkg/iostreams" - "github.com/cli/cli/v2/pkg/markdown" ghauth "github.com/cli/go-gh/v2/pkg/auth" "github.com/spf13/cobra" ) -func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.VerifyOptions) error) *cobra.Command { - opts := &attestation.VerifyOptions{ - IO: f.IOStreams, - HttpClient: f.HttpClient, - } +func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.AttestOptions) error) *cobra.Command { + opts := &attestation.AttestOptions{} cmd := &cobra.Command{ Use: "verify []", - Short: "Verify information about a release", - Long: heredoc.Doc(` - Verify information about a GitHub Release. - - Without an explicit tag name argument, the latest release in the project - is shown. - `), - Args: cobra.MaximumNArgs(1), + Short: "Verify the attestation for a GitHub Release.", + Args: cobra.ExactArgs(1), PreRunE: func(cmd *cobra.Command, args []string) error { - // Create a logger for use throughout the verify command - // opts.Logger = io.NewHandler(f.IOStreams) - - // // set the artifact path - // opts.ArtifactPath = args[0] - - // // Check that the given flag combination is valid - // if err := opts.AreFlagsValid(); err != nil { - // return err - // } - - // // Clean file path options - // opts.Clean() - - // if opts.TagName == "" { - // return cmdutil.FlagErrorf("tag name is required") - // } - - return nil - }, - RunE: func(cmd *cobra.Command, args []string) error { - // support `-R, --repo` override - opts.BaseRepo = f.BaseRepo - if len(args) > 0 { opts.TagName = args[0] } + httpClient, err := f.HttpClient() + if err != nil { + return err + } + + baseRepo, err := f.BaseRepo() + if err != nil { + return err + } + + logger := att_io.NewHandler(f.IOStreams) + hostname, _ := ghauth.DefaultHost() + + opts.Repo = baseRepo.RepoOwner() + "/" + baseRepo.RepoName() + opts.APIClient = api.NewLiveClient(httpClient, hostname, logger) + opts.Limit = 10 + opts.Owner = baseRepo.RepoOwner() + opts.PredicateType = "https://in-toto.io/attestation/release/v0.1" + opts.Logger = logger + + opts.HttpClient = httpClient + opts.BaseRepo = baseRepo + + opts.HttpClient = httpClient + + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { if runF != nil { return runF(opts) } + // - httpClient, err := opts.HttpClient() + td, err := opts.APIClient.GetTrustDomain() if err != nil { + opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to get trust domain")) return err } - baseRepo, err := opts.BaseRepo() + ec, err := attestation.NewEnforcementCriteria(opts, opts.Logger) if err != nil { - return err - } - - logger := att_io.NewHandler(opts.IO) - hostname, _ := ghauth.DefaultHost() - option := attestation.AttestOptions{ - Repo: baseRepo.RepoOwner() + "/" + baseRepo.RepoName(), - APIClient: api.NewLiveClient(httpClient, hostname, logger), - Limit: 10, - Owner: baseRepo.RepoOwner(), - PredicateType: "https://in-toto.io/attestation/release/v0.1", - Logger: logger, - } - - option.HttpClient = httpClient - option.BaseRepo = baseRepo - option.IO = opts.IO - option.TagName = opts.TagName - option.Exporter = opts.Exporter - - td, err := option.APIClient.GetTrustDomain() - if err != nil { - logger.Println(logger.ColorScheme.Red("✗ Failed to get trust domain")) - return err - } - - ec, err := attestation.NewEnforcementCriteria(&option, logger) - if err != nil { - logger.Println(logger.ColorScheme.Red("✗ Failed to build policy information")) + opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to build policy information")) return err } config := verification.SigstoreConfig{ TrustedRoot: "", - Logger: logger, + Logger: opts.Logger, NoPublicGood: true, TrustDomain: td, } sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(config) if err != nil { - logger.Println(logger.ColorScheme.Red("✗ Failed to create Sigstore verifier")) + opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to create Sigstore verifier")) return err } - option.SigstoreVerifier = sigstoreVerifier - option.EC = ec + opts.SigstoreVerifier = sigstoreVerifier + opts.EC = ec // output ec - return verifyRun(&option) + return verifyRun(opts) }, } @@ -143,12 +104,6 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.VerifyOptions) erro func verifyRun(opts *attestation.AttestOptions) error { ctx := context.Background() - logger := opts.Logger - - release, err := shared.FetchRelease(ctx, opts.HttpClient, opts.BaseRepo, opts.TagName) - if err != nil { - return err - } sha, err := shared.FetchRefSHA(ctx, opts.HttpClient, opts.BaseRepo, opts.TagName) if err != nil { @@ -156,48 +111,43 @@ func verifyRun(opts *attestation.AttestOptions) error { } artifact := artifact.NewDigestedArtifactForRelease(opts.TagName, sha, "sha1") + opts.Logger.Printf("Resolved %s to %s\n", opts.TagName, artifact.DigestWithAlg()) // Attestation fetching attestations, logMsg, err := attestation.GetAttestations(opts, artifact.DigestWithAlg()) if err != nil { if errors.Is(err, api.ErrNoAttestationsFound) { - logger.Printf(logger.ColorScheme.Red("✗ No attestations found for subject %s\n"), artifact.DigestWithAlg()) + opts.Logger.Printf(opts.Logger.ColorScheme.Red("✗ No attestations found for subject %s\n"), artifact.DigestWithAlg()) return err } - logger.Println(logger.ColorScheme.Red(logMsg)) + opts.Logger.Println(opts.Logger.ColorScheme.Red(logMsg)) return err } // Filter attestations by predicate PURL - filteredAttestations := attestation.FilterAttestationsByPURL(attestations, opts.Repo, opts.TagName, logger) + filteredAttestations := attestation.FilterAttestationsByPURL(attestations, opts.Repo, opts.TagName, opts.Logger) + + opts.Logger.Printf("Loaded %s from GitHub API\n", text.Pluralize(len(filteredAttestations), "attestation")) // Verify attestations verified, errMsg, err := attestation.VerifyAttestations(*artifact, filteredAttestations, opts.SigstoreVerifier, opts.EC) if err != nil { - logger.Println(logger.ColorScheme.Red(errMsg)) + opts.Logger.Println(opts.Logger.ColorScheme.Red(errMsg)) + + opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Verification failed")) + + opts.Logger.Printf(opts.Logger.ColorScheme.Red("✗ Failed to find an attestation for release %s in %s\n"), opts.TagName, opts.Repo) return err } - logger.Printf("The following %s matched the policy criteria\n\n", text.Pluralize(len(verified), "attestation")) - logger.Println(logger.ColorScheme.Green("✓ Verification succeeded!\n")) + opts.Logger.Printf("The following %s matched the policy criteria\n\n", text.Pluralize(len(verified), "attestation")) + opts.Logger.Println(opts.Logger.ColorScheme.Green("✓ Verification succeeded!\n")) - printVerifiedSubjects(verified, logger) + opts.Logger.Printf("Attestation found matching release %s (%s)\n", opts.TagName, artifact.Digest()) + printVerifiedSubjects(verified, opts.Logger) - opts.IO.DetectTerminalTheme() - if err := opts.IO.StartPager(); err != nil { - fmt.Fprintf(opts.IO.ErrOut, "error starting pager: %v\n", err) - } - defer opts.IO.StopPager() - - if opts.Exporter != nil { - return opts.Exporter.Write(opts.IO, release) - } - - if opts.IO.IsStdoutTTY() { - return renderVerifyTTY(opts.IO, release) - } - return renderVerifyPlain(opts.IO.Out, release) + return nil } func printVerifiedSubjects(verified []*verification.AttestationProcessingResult, logger *att_io.Handler) { @@ -210,99 +160,20 @@ func printVerifiedSubjects(verified []*verification.AttestationProcessingResult, continue } for _, s := range statementData.Subject { - logger.Printf("%s\n", s.String()) + name := s.Name + digest := s.Digest + + if name != "" { + // digest is map[string]string and i want to be key:value + // so i need to iterate over the map and print key:value + digestStr := "" + for key, value := range digest { + digestStr += key + ":" + value + } + // output should like this + // bin-linux.tgz sha256:0c2524c2b002fda89f8b766c7d3dd8e6ac1de183556728a83182c6137f19643d + logger.Println(" " + name + " " + digestStr) + } } } } - -func renderVerifyTTY(io *iostreams.IOStreams, release *shared.Release) error { - cs := io.ColorScheme() - w := io.Out - - fmt.Fprintf(w, "%s\n", cs.Bold(release.TagName)) - if release.IsDraft { - fmt.Fprintf(w, "%s • ", cs.Red("Draft")) - } else if release.IsPrerelease { - fmt.Fprintf(w, "%s • ", cs.Yellow("Pre-release")) - } - if release.IsDraft { - fmt.Fprintln(w, cs.Mutedf("%s created this %s", release.Author.Login, text.FuzzyAgo(time.Now(), release.CreatedAt))) - } else { - fmt.Fprintln(w, cs.Mutedf("%s released this %s", release.Author.Login, text.FuzzyAgo(time.Now(), *release.PublishedAt))) - } - - renderedDescription, err := markdown.Render(release.Body, - markdown.WithTheme(io.TerminalTheme()), - markdown.WithWrap(io.TerminalWidth())) - if err != nil { - return err - } - fmt.Fprintln(w, renderedDescription) - - if len(release.Assets) > 0 { - fmt.Fprintln(w, cs.Bold("Assets")) - //nolint:staticcheck // SA1019: Showing NAME|SIZE headers adds nothing to table. - table := tableprinter.New(io, tableprinter.NoHeader) - for _, a := range release.Assets { - table.AddField(a.Name) - table.AddField(humanFileSize(a.Size)) - table.EndRow() - } - err := table.Render() - if err != nil { - return err - } - fmt.Fprint(w, "\n") - } - - fmt.Fprintln(w, cs.Mutedf("View on GitHub: %s", release.URL)) - return nil -} - -func renderVerifyPlain(w io.Writer, release *shared.Release) error { - fmt.Fprintf(w, "title:\t%s\n", release.Name) - fmt.Fprintf(w, "tag:\t%s\n", release.TagName) - fmt.Fprintf(w, "draft:\t%v\n", release.IsDraft) - fmt.Fprintf(w, "prerelease:\t%v\n", release.IsPrerelease) - fmt.Fprintf(w, "author:\t%s\n", release.Author.Login) - fmt.Fprintf(w, "created:\t%s\n", release.CreatedAt.Format(time.RFC3339)) - if !release.IsDraft { - fmt.Fprintf(w, "published:\t%s\n", release.PublishedAt.Format(time.RFC3339)) - } - fmt.Fprintf(w, "url:\t%s\n", release.URL) - for _, a := range release.Assets { - fmt.Fprintf(w, "asset:\t%s\n", a.Name) - } - fmt.Fprint(w, "--\n") - fmt.Fprint(w, release.Body) - if !strings.HasSuffix(release.Body, "\n") { - fmt.Fprintf(w, "\n") - } - return nil -} - -func humanFileSize(s int64) string { - if s < 1024 { - return fmt.Sprintf("%d B", s) - } - - kb := float64(s) / 1024 - if kb < 1024 { - return fmt.Sprintf("%s KiB", floatToString(kb, 2)) - } - - mb := kb / 1024 - if mb < 1024 { - return fmt.Sprintf("%s MiB", floatToString(mb, 2)) - } - - gb := mb / 1024 - return fmt.Sprintf("%s GiB", floatToString(gb, 2)) -} - -// render float to fixed precision using truncation instead of rounding -func floatToString(f float64, p uint8) string { - fs := fmt.Sprintf("%#f%0*s", f, p, "") - idx := strings.IndexRune(fs, '.') - return fs[:idx+int(p)+1] -} From 0a6ce2bb74b54fb7779deeb84c729afac2c9cc64 Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Tue, 20 May 2025 18:35:40 -0400 Subject: [PATCH 06/31] clean up the code --- pkg/cmd/attestation/artifact/artifact.go | 3 +- pkg/cmd/attestation/verification/sigstore.go | 3 - pkg/cmd/release/attestation/attestation.go | 25 ++-- pkg/cmd/release/verify-asset/verify-asset.go | 117 +++++++++---------- pkg/cmd/release/verify/verify.go | 63 +++++----- 5 files changed, 93 insertions(+), 118 deletions(-) diff --git a/pkg/cmd/attestation/artifact/artifact.go b/pkg/cmd/attestation/artifact/artifact.go index 53f8d8aad..9d8125450 100644 --- a/pkg/cmd/attestation/artifact/artifact.go +++ b/pkg/cmd/attestation/artifact/artifact.go @@ -54,9 +54,8 @@ func normalizeReference(reference string, pathSeparator rune) (normalized string return filepath.Clean(reference), fileArtifactType, nil } -func NewDigestedArtifactForRelease(URL string, digest string, digestAlg string) (artifact *DigestedArtifact) { +func NewDigestedArtifactForRelease(digest string, digestAlg string) (artifact *DigestedArtifact) { return &DigestedArtifact{ - URL: URL, digest: digest, digestAlg: digestAlg, } diff --git a/pkg/cmd/attestation/verification/sigstore.go b/pkg/cmd/attestation/verification/sigstore.go index 14c8875d9..190ea5c0f 100644 --- a/pkg/cmd/attestation/verification/sigstore.go +++ b/pkg/cmd/attestation/verification/sigstore.go @@ -239,9 +239,6 @@ func (v *LiveSigstoreVerifier) verify(attestation *api.Attestation, policy verif result, err := verifier.Verify(attestation.Bundle, policy) // if verification fails, create the error and exit verification early if err != nil { - v.Logger.VerbosePrint(v.Logger.ColorScheme.Redf( - "Error is \"%s\"\n", err.Error(), - )) v.Logger.VerbosePrint(v.Logger.ColorScheme.Redf( "Failed to verify against issuer \"%s\" \n\n", issuer, )) diff --git a/pkg/cmd/release/attestation/attestation.go b/pkg/cmd/release/attestation/attestation.go index 70760f8c6..08e1398b8 100644 --- a/pkg/cmd/release/attestation/attestation.go +++ b/pkg/cmd/release/attestation/attestation.go @@ -9,7 +9,6 @@ import ( "github.com/cli/cli/v2/pkg/cmd/attestation/artifact" "github.com/cli/cli/v2/pkg/cmd/attestation/verification" - att_io "github.com/cli/cli/v2/pkg/cmd/attestation/io" v1 "github.com/in-toto/attestation/go/v1" "google.golang.org/protobuf/encoding/protojson" ) @@ -61,30 +60,25 @@ func VerifyAttestations(art artifact.DigestedArtifact, att []*api.Attestation, s return sigstoreVerified, "", nil } -func FilterAttestationsByPURL(attestations []*api.Attestation, repo, tagName string, logger *att_io.Handler) []*api.Attestation { +func FilterAttestationsByTag(attestations []*api.Attestation, tagName string) ([]*api.Attestation, error) { var filtered []*api.Attestation - expectedPURL := "pkg:github/" + repo + "@" + tagName for _, att := range attestations { statement := att.Bundle.Bundle.GetDsseEnvelope().Payload var statementData v1.Statement err := protojson.Unmarshal([]byte(statement), &statementData) if err != nil { - logger.Println(logger.ColorScheme.Red("✗ Failed to unmarshal statement")) - continue + return nil, fmt.Errorf("failed to unmarshal statement: %w", err) } - purlValue := statementData.Predicate.GetFields()["purl"] - var purl string - if purlValue != nil { - purl = purlValue.GetStringValue() - } - if purl == expectedPURL { + tagValue := statementData.Predicate.GetFields()["tag"].GetStringValue() + + if tagValue == tagName { filtered = append(filtered, att) } } - return filtered + return filtered, nil } -func FilterAttestationsByFileDigest(attestations []*api.Attestation, repo, tagName, fileDigest string, logger *att_io.Handler) []*api.Attestation { +func FilterAttestationsByFileDigest(attestations []*api.Attestation, repo, tagName, fileDigest string) ([]*api.Attestation, error) { var filtered []*api.Attestation for _, att := range attestations { statement := att.Bundle.Bundle.GetDsseEnvelope().Payload @@ -92,8 +86,7 @@ func FilterAttestationsByFileDigest(attestations []*api.Attestation, repo, tagNa err := protojson.Unmarshal([]byte(statement), &statementData) if err != nil { - logger.Println(logger.ColorScheme.Red("✗ Failed to unmarshal statement")) - continue + return nil, fmt.Errorf("failed to unmarshal statement: %w", err) } subjects := statementData.Subject for _, subject := range subjects { @@ -107,5 +100,5 @@ func FilterAttestationsByFileDigest(attestations []*api.Attestation, repo, tagNa } } - return filtered + return filtered, nil } diff --git a/pkg/cmd/release/verify-asset/verify-asset.go b/pkg/cmd/release/verify-asset/verify-asset.go index 8df2f2a11..666ad3f45 100644 --- a/pkg/cmd/release/verify-asset/verify-asset.go +++ b/pkg/cmd/release/verify-asset/verify-asset.go @@ -19,91 +19,76 @@ import ( "github.com/spf13/cobra" ) -func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.VerifyAssetOptions) error) *cobra.Command { - opts := &attestation.VerifyAssetOptions{ - IO: f.IOStreams, - HttpClient: f.HttpClient, - } +func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.AttestOptions) error) *cobra.Command { + opts := &attestation.AttestOptions{} cmd := &cobra.Command{ Use: "verify-asset ", Short: "Verify that a given asset originated from a specific GitHub Release.", Args: cobra.ExactArgs(2), PreRunE: func(cmd *cobra.Command, args []string) error { - return nil - }, - RunE: func(cmd *cobra.Command, args []string) error { - // support `-R, --repo` override - opts.BaseRepo = f.BaseRepo + opts.TagName = args[0] + opts.FilePath = args[1] - if len(args) > 0 { - opts.TagName = args[0] - } - if len(args) > 1 { - opts.FilePath = args[1] - } - - if runF != nil { - return runF(opts) - } - - httpClient, err := opts.HttpClient() + httpClient, err := f.HttpClient() if err != nil { return err } - - baseRepo, err := opts.BaseRepo() + baseRepo, err := f.BaseRepo() if err != nil { return err } - - logger := att_io.NewHandler(opts.IO) + logger := att_io.NewHandler(f.IOStreams) hostname, _ := ghauth.DefaultHost() - option := attestation.AttestOptions{ + + *opts = attestation.AttestOptions{ + TagName: opts.TagName, + FilePath: opts.FilePath, Repo: baseRepo.RepoOwner() + "/" + baseRepo.RepoName(), APIClient: api.NewLiveClient(httpClient, hostname, logger), Limit: 10, Owner: baseRepo.RepoOwner(), PredicateType: "https://in-toto.io/attestation/release/v0.1", Logger: logger, + HttpClient: httpClient, + BaseRepo: baseRepo, + IO: f.IOStreams, + Exporter: opts.Exporter, + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + if runF != nil { + return runF(opts) } - option.HttpClient = httpClient - option.BaseRepo = baseRepo - option.IO = opts.IO - option.TagName = opts.TagName - option.Exporter = opts.Exporter - option.FilePath = opts.FilePath - - td, err := option.APIClient.GetTrustDomain() + td, err := opts.APIClient.GetTrustDomain() if err != nil { - logger.Println(logger.ColorScheme.Red("✗ Failed to get trust domain")) + opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to get trust domain")) return err } - ec, err := attestation.NewEnforcementCriteria(&option, logger) + ec, err := attestation.NewEnforcementCriteria(opts, opts.Logger) if err != nil { - logger.Println(logger.ColorScheme.Red("✗ Failed to build policy information")) + opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to build policy information")) return err } config := verification.SigstoreConfig{ - TrustedRoot: "", - Logger: logger, + Logger: opts.Logger, NoPublicGood: true, TrustDomain: td, } sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(config) if err != nil { - logger.Println(logger.ColorScheme.Red("✗ Failed to create Sigstore verifier")) + opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to create Sigstore verifier")) return err } - option.SigstoreVerifier = sigstoreVerifier - option.EC = ec + opts.SigstoreVerifier = sigstoreVerifier + opts.EC = ec - // output ec - return verifyAssetRun(&option) + return verifyAssetRun(opts) }, } @@ -124,50 +109,56 @@ func verifyAssetRun(opts *attestation.AttestOptions) error { opts.Logger.Printf("Loaded digest %s for %s\n", fileDigest.DigestWithAlg(), fileName) - sha, err := shared.FetchRefSHA(ctx, opts.HttpClient, opts.BaseRepo, opts.TagName) + ref, err := shared.FetchRefSHA(ctx, opts.HttpClient, opts.BaseRepo, opts.TagName) if err != nil { return err } - releaseArtifact := artifact.NewDigestedArtifactForRelease(opts.TagName, sha, "sha1") - opts.Logger.Printf("Resolved %s to %s\n", opts.TagName, releaseArtifact.DigestWithAlg()) + releaseRefDigest := artifact.NewDigestedArtifactForRelease(ref, "sha1") + opts.Logger.Printf("Resolved %s to %s\n", opts.TagName, releaseRefDigest.DigestWithAlg()) // Attestation fetching - attestations, logMsg, err := attestation.GetAttestations(opts, releaseArtifact.DigestWithAlg()) + attestations, logMsg, err := attestation.GetAttestations(opts, releaseRefDigest.DigestWithAlg()) if err != nil { if errors.Is(err, api.ErrNoAttestationsFound) { - opts.Logger.Printf(opts.Logger.ColorScheme.Red("✗ No attestations found for subject %s\n"), releaseArtifact.DigestWithAlg()) + opts.Logger.Printf(opts.Logger.ColorScheme.Red("✗ No attestations found for subject %s\n"), releaseRefDigest.DigestWithAlg()) return err } opts.Logger.Println(opts.Logger.ColorScheme.Red(logMsg)) return err } - // Filter attestations by predicate PURL - filteredAttestations := attestation.FilterAttestationsByPURL(attestations, opts.Repo, opts.TagName, opts.Logger) - filteredAttestations = attestation.FilterAttestationsByFileDigest(filteredAttestations, opts.Repo, opts.TagName, fileDigest.Digest(), opts.Logger) + // Filter attestations by tag + filteredAttestations, err := attestation.FilterAttestationsByTag(attestations, opts.TagName) + if err != nil { + opts.Logger.Println(opts.Logger.ColorScheme.Red(err.Error())) + return err + } + + filteredAttestations, err = attestation.FilterAttestationsByFileDigest(filteredAttestations, opts.Repo, opts.TagName, fileDigest.Digest()) + if err != nil { + opts.Logger.Println(opts.Logger.ColorScheme.Red(err.Error())) + return err + } + + if len(filteredAttestations) == 0 { + opts.Logger.Printf(opts.Logger.ColorScheme.Red("✗ No attestations found for %s\n"), fileName) + return nil + } opts.Logger.Printf("Loaded %s from GitHub API\n", text.Pluralize(len(filteredAttestations), "attestation")) // Verify attestations - verified, errMsg, err := attestation.VerifyAttestations(*releaseArtifact, filteredAttestations, opts.SigstoreVerifier, opts.EC) + verified, errMsg, err := attestation.VerifyAttestations(*releaseRefDigest, filteredAttestations, opts.SigstoreVerifier, opts.EC) if err != nil { opts.Logger.Println(opts.Logger.ColorScheme.Red(errMsg)) - - opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Verification failed")) - - // Release v1.0.0 does not contain bin-linux.tgz (sha256:0c2524c2b002fda89f8b766c7d3dd8e6ac1de183556728a83182c6137f19643d) - opts.Logger.Printf(opts.Logger.ColorScheme.Red("Release %s does not contain %s (%s)\n"), opts.TagName, opts.FilePath, fileDigest.DigestWithAlg()) return err } opts.Logger.Printf("The following %s matched the policy criteria\n\n", text.Pluralize(len(verified), "attestation")) opts.Logger.Println(opts.Logger.ColorScheme.Green("✓ Verification succeeded!\n")) - - opts.Logger.Printf("Attestation found matching release %s (%s)\n", opts.TagName, releaseArtifact.DigestWithAlg()) - - // bin-linux.tgz is present in release v1.0.0 + opts.Logger.Printf("Attestation found matching release %s (%s)\n", opts.TagName, releaseRefDigest.DigestWithAlg()) opts.Logger.Printf("%s is present in release %s\n", fileName, opts.TagName) return nil diff --git a/pkg/cmd/release/verify/verify.go b/pkg/cmd/release/verify/verify.go index 4232cfca1..149125dc6 100644 --- a/pkg/cmd/release/verify/verify.go +++ b/pkg/cmd/release/verify/verify.go @@ -28,10 +28,12 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.AttestOptions) erro Short: "Verify the attestation for a GitHub Release.", Args: cobra.ExactArgs(1), PreRunE: func(cmd *cobra.Command, args []string) error { - if len(args) > 0 { - opts.TagName = args[0] + if len(args) < 1 { + return cmdutil.FlagErrorf("You must specify a tag") } + opts.TagName = args[0] + httpClient, err := f.HttpClient() if err != nil { return err @@ -41,29 +43,26 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.AttestOptions) erro if err != nil { return err } - logger := att_io.NewHandler(f.IOStreams) hostname, _ := ghauth.DefaultHost() - opts.Repo = baseRepo.RepoOwner() + "/" + baseRepo.RepoName() - opts.APIClient = api.NewLiveClient(httpClient, hostname, logger) - opts.Limit = 10 - opts.Owner = baseRepo.RepoOwner() - opts.PredicateType = "https://in-toto.io/attestation/release/v0.1" - opts.Logger = logger - - opts.HttpClient = httpClient - opts.BaseRepo = baseRepo - - opts.HttpClient = httpClient - + *opts = attestation.AttestOptions{ + TagName: opts.TagName, + Repo: baseRepo.RepoOwner() + "/" + baseRepo.RepoName(), + APIClient: api.NewLiveClient(httpClient, hostname, logger), + Limit: 10, + Owner: baseRepo.RepoOwner(), + PredicateType: "https://in-toto.io/attestation/release/v0.1", + Logger: logger, + HttpClient: httpClient, + BaseRepo: baseRepo, + } return nil }, RunE: func(cmd *cobra.Command, args []string) error { if runF != nil { return runF(opts) } - // td, err := opts.APIClient.GetTrustDomain() if err != nil { @@ -78,11 +77,11 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.AttestOptions) erro } config := verification.SigstoreConfig{ - TrustedRoot: "", Logger: opts.Logger, NoPublicGood: true, TrustDomain: td, } + sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(config) if err != nil { opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to create Sigstore verifier")) @@ -92,7 +91,6 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.AttestOptions) erro opts.SigstoreVerifier = sigstoreVerifier opts.EC = ec - // output ec return verifyRun(opts) }, } @@ -105,38 +103,39 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.AttestOptions) erro func verifyRun(opts *attestation.AttestOptions) error { ctx := context.Background() - sha, err := shared.FetchRefSHA(ctx, opts.HttpClient, opts.BaseRepo, opts.TagName) + ref, err := shared.FetchRefSHA(ctx, opts.HttpClient, opts.BaseRepo, opts.TagName) if err != nil { return err } - artifact := artifact.NewDigestedArtifactForRelease(opts.TagName, sha, "sha1") - opts.Logger.Printf("Resolved %s to %s\n", opts.TagName, artifact.DigestWithAlg()) + releaseRefDigest := artifact.NewDigestedArtifactForRelease(ref, "sha1") + opts.Logger.Printf("Resolved %s to %s\n", opts.TagName, releaseRefDigest.DigestWithAlg()) // Attestation fetching - attestations, logMsg, err := attestation.GetAttestations(opts, artifact.DigestWithAlg()) + attestations, logMsg, err := attestation.GetAttestations(opts, releaseRefDigest.DigestWithAlg()) if err != nil { if errors.Is(err, api.ErrNoAttestationsFound) { - opts.Logger.Printf(opts.Logger.ColorScheme.Red("✗ No attestations found for subject %s\n"), artifact.DigestWithAlg()) + opts.Logger.Printf(opts.Logger.ColorScheme.Red("✗ No attestations found for subject %s\n"), releaseRefDigest.DigestWithAlg()) return err } opts.Logger.Println(opts.Logger.ColorScheme.Red(logMsg)) return err } - // Filter attestations by predicate PURL - filteredAttestations := attestation.FilterAttestationsByPURL(attestations, opts.Repo, opts.TagName, opts.Logger) + // Filter attestations by predicate tag + filteredAttestations, err := attestation.FilterAttestationsByTag(attestations, opts.TagName) + if err != nil { + opts.Logger.Println(opts.Logger.ColorScheme.Red(err.Error())) + return err + } opts.Logger.Printf("Loaded %s from GitHub API\n", text.Pluralize(len(filteredAttestations), "attestation")) // Verify attestations - verified, errMsg, err := attestation.VerifyAttestations(*artifact, filteredAttestations, opts.SigstoreVerifier, opts.EC) + verified, errMsg, err := attestation.VerifyAttestations(*releaseRefDigest, filteredAttestations, opts.SigstoreVerifier, opts.EC) if err != nil { opts.Logger.Println(opts.Logger.ColorScheme.Red(errMsg)) - - opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Verification failed")) - opts.Logger.Printf(opts.Logger.ColorScheme.Red("✗ Failed to find an attestation for release %s in %s\n"), opts.TagName, opts.Repo) return err } @@ -144,7 +143,7 @@ func verifyRun(opts *attestation.AttestOptions) error { opts.Logger.Printf("The following %s matched the policy criteria\n\n", text.Pluralize(len(verified), "attestation")) opts.Logger.Println(opts.Logger.ColorScheme.Green("✓ Verification succeeded!\n")) - opts.Logger.Printf("Attestation found matching release %s (%s)\n", opts.TagName, artifact.Digest()) + opts.Logger.Printf("Attestation found matching release %s (%s)\n", opts.TagName, releaseRefDigest.Digest()) printVerifiedSubjects(verified, opts.Logger) return nil @@ -164,14 +163,10 @@ func printVerifiedSubjects(verified []*verification.AttestationProcessingResult, digest := s.Digest if name != "" { - // digest is map[string]string and i want to be key:value - // so i need to iterate over the map and print key:value digestStr := "" for key, value := range digest { digestStr += key + ":" + value } - // output should like this - // bin-linux.tgz sha256:0c2524c2b002fda89f8b766c7d3dd8e6ac1de183556728a83182c6137f19643d logger.Println(" " + name + " " + digestStr) } } From 7a7c7d6605520f8558002448394a81e3435318ad Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Tue, 20 May 2025 18:44:04 -0400 Subject: [PATCH 07/31] minor fix --- pkg/cmd/release/attestation/options.go | 20 +------------------- pkg/cmd/release/verify-asset/verify-asset.go | 10 +++++----- pkg/cmd/release/verify/verify.go | 5 +---- 3 files changed, 7 insertions(+), 28 deletions(-) diff --git a/pkg/cmd/release/attestation/options.go b/pkg/cmd/release/attestation/options.go index d4b3046ae..aadbc2f47 100644 --- a/pkg/cmd/release/attestation/options.go +++ b/pkg/cmd/release/attestation/options.go @@ -16,24 +16,7 @@ import ( "github.com/cli/cli/v2/pkg/iostreams" ) -type VerifyAssetOptions struct { - IO *iostreams.IOStreams - HttpClient func() (*http.Client, error) - - BaseRepo func() (ghrepo.Interface, error) - Exporter cmdutil.Exporter - - TagName string - FilePath string -} - -type VerifyOptions struct { - HttpClient func() (*http.Client, error) - IO *iostreams.IOStreams - BaseRepo func() (ghrepo.Interface, error) - Exporter cmdutil.Exporter - TagName string -} +const ReleasePredicateType = "https://in-toto.io/attestation/release/v0.1" // AttestOptions captures the options for the verify command type AttestOptions struct { @@ -61,7 +44,6 @@ type AttestOptions struct { Logger *io.Handler OCIClient oci.Client SigstoreVerifier verification.SigstoreVerifier - exporter cmdutil.Exporter Hostname string EC verification.EnforcementCriteria // Tenant is only set when tenancy is used diff --git a/pkg/cmd/release/verify-asset/verify-asset.go b/pkg/cmd/release/verify-asset/verify-asset.go index 666ad3f45..15263a2c4 100644 --- a/pkg/cmd/release/verify-asset/verify-asset.go +++ b/pkg/cmd/release/verify-asset/verify-asset.go @@ -27,6 +27,10 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.AttestOptions) Short: "Verify that a given asset originated from a specific GitHub Release.", Args: cobra.ExactArgs(2), PreRunE: func(cmd *cobra.Command, args []string) error { + if len(args) < 2 { + return cmdutil.FlagErrorf("You must specify a tag and a file path") + } + opts.TagName = args[0] opts.FilePath = args[1] @@ -48,12 +52,10 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.AttestOptions) APIClient: api.NewLiveClient(httpClient, hostname, logger), Limit: 10, Owner: baseRepo.RepoOwner(), - PredicateType: "https://in-toto.io/attestation/release/v0.1", + PredicateType: attestation.ReleasePredicateType, Logger: logger, HttpClient: httpClient, BaseRepo: baseRepo, - IO: f.IOStreams, - Exporter: opts.Exporter, } return nil }, @@ -91,8 +93,6 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.AttestOptions) return verifyAssetRun(opts) }, } - - cmdutil.AddJSONFlags(cmd, &opts.Exporter, shared.ReleaseFields) return cmd } diff --git a/pkg/cmd/release/verify/verify.go b/pkg/cmd/release/verify/verify.go index 149125dc6..8c835d1e6 100644 --- a/pkg/cmd/release/verify/verify.go +++ b/pkg/cmd/release/verify/verify.go @@ -52,7 +52,7 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.AttestOptions) erro APIClient: api.NewLiveClient(httpClient, hostname, logger), Limit: 10, Owner: baseRepo.RepoOwner(), - PredicateType: "https://in-toto.io/attestation/release/v0.1", + PredicateType: attestation.ReleasePredicateType, Logger: logger, HttpClient: httpClient, BaseRepo: baseRepo, @@ -94,9 +94,6 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.AttestOptions) erro return verifyRun(opts) }, } - - cmdutil.AddJSONFlags(cmd, &opts.Exporter, shared.ReleaseFields) - return cmd } From e9fbe9d8b8d6ab301b3fc10c2d17dd08bbf55e83 Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Wed, 21 May 2025 11:23:47 -0400 Subject: [PATCH 08/31] change verify-asset logic --- pkg/cmd/release/verify-asset/verify-asset.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/release/verify-asset/verify-asset.go b/pkg/cmd/release/verify-asset/verify-asset.go index 15263a2c4..1a74afdbf 100644 --- a/pkg/cmd/release/verify-asset/verify-asset.go +++ b/pkg/cmd/release/verify-asset/verify-asset.go @@ -141,7 +141,7 @@ func verifyAssetRun(opts *attestation.AttestOptions) error { } if len(filteredAttestations) == 0 { - opts.Logger.Printf(opts.Logger.ColorScheme.Red("✗ No attestations found for %s\n"), fileName) + opts.Logger.Printf(opts.Logger.ColorScheme.Red("Release %s does not contain %s (%s)\n"), opts.TagName, opts.FilePath, fileDigest.DigestWithAlg()) return nil } From ca0f9847db0c582404f14105eff5de9e92f0af26 Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Thu, 22 May 2025 12:31:45 -0400 Subject: [PATCH 09/31] add json format --- .github/CODEOWNERS | 4 +++ pkg/cmd/release/attestation/options.go | 12 +++++++-- pkg/cmd/release/verify-asset/verify-asset.go | 28 ++++++++++++++------ pkg/cmd/release/verify/verify.go | 12 +++++++++ 4 files changed, 46 insertions(+), 10 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 40683d917..5d39bf3af 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -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 diff --git a/pkg/cmd/release/attestation/options.go b/pkg/cmd/release/attestation/options.go index aadbc2f47..f0957c04b 100644 --- a/pkg/cmd/release/attestation/options.go +++ b/pkg/cmd/release/attestation/options.go @@ -3,6 +3,7 @@ package attestation import ( "fmt" "net/http" + "path/filepath" "strings" "github.com/cli/cli/v2/internal/gh" @@ -47,8 +48,15 @@ type AttestOptions struct { Hostname string EC verification.EnforcementCriteria // Tenant is only set when tenancy is used - Tenant string - FilePath string + Tenant string + AssetFilePath string +} + +// Clean cleans the file path option values +func (opts *AttestOptions) Clean() { + if opts.AssetFilePath != "" { + opts.AssetFilePath = filepath.Clean(opts.AssetFilePath) + } } // AreFlagsValid checks that the provided flag combination is valid diff --git a/pkg/cmd/release/verify-asset/verify-asset.go b/pkg/cmd/release/verify-asset/verify-asset.go index 1a74afdbf..585643c2d 100644 --- a/pkg/cmd/release/verify-asset/verify-asset.go +++ b/pkg/cmd/release/verify-asset/verify-asset.go @@ -31,8 +31,8 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.AttestOptions) return cmdutil.FlagErrorf("You must specify a tag and a file path") } - opts.TagName = args[0] - opts.FilePath = args[1] + tagName := args[0] + assetFilePath := args[1] httpClient, err := f.HttpClient() if err != nil { @@ -46,8 +46,8 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.AttestOptions) hostname, _ := ghauth.DefaultHost() *opts = attestation.AttestOptions{ - TagName: opts.TagName, - FilePath: opts.FilePath, + TagName: tagName, + AssetFilePath: assetFilePath, Repo: baseRepo.RepoOwner() + "/" + baseRepo.RepoName(), APIClient: api.NewLiveClient(httpClient, hostname, logger), Limit: 10, @@ -93,15 +93,17 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.AttestOptions) return verifyAssetRun(opts) }, } + cmdutil.AddFormatFlags(cmd, &opts.Exporter) + return cmd } func verifyAssetRun(opts *attestation.AttestOptions) error { ctx := context.Background() - fileName := getFileName(opts.FilePath) + fileName := getFileName(opts.AssetFilePath) // calculate the digest of the file - fileDigest, err := artifact.NewDigestedArtifact(nil, opts.FilePath, "sha256") + fileDigest, err := artifact.NewDigestedArtifact(nil, opts.AssetFilePath, "sha256") if err != nil { opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to calculate file digest")) return err @@ -141,7 +143,7 @@ func verifyAssetRun(opts *attestation.AttestOptions) error { } if len(filteredAttestations) == 0 { - opts.Logger.Printf(opts.Logger.ColorScheme.Red("Release %s does not contain %s (%s)\n"), opts.TagName, opts.FilePath, fileDigest.DigestWithAlg()) + opts.Logger.Printf(opts.Logger.ColorScheme.Red("Release %s does not contain %s (%s)\n"), opts.TagName, opts.AssetFilePath, fileDigest.DigestWithAlg()) return nil } @@ -152,10 +154,20 @@ func verifyAssetRun(opts *attestation.AttestOptions) error { if err != nil { opts.Logger.Println(opts.Logger.ColorScheme.Red(errMsg)) - opts.Logger.Printf(opts.Logger.ColorScheme.Red("Release %s does not contain %s (%s)\n"), opts.TagName, opts.FilePath, fileDigest.DigestWithAlg()) + opts.Logger.Printf(opts.Logger.ColorScheme.Red("Release %s does not contain %s (%s)\n"), opts.TagName, opts.AssetFilePath, fileDigest.DigestWithAlg()) return err } + // If an exporter is provided with the --json flag, write the results to the terminal in JSON format + if opts.Exporter != nil { + // print the results to the terminal as an array of JSON objects + if err = opts.Exporter.Write(opts.Logger.IO, verified); err != nil { + opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to write JSON output")) + return err + } + return nil + } + opts.Logger.Printf("The following %s matched the policy criteria\n\n", text.Pluralize(len(verified), "attestation")) opts.Logger.Println(opts.Logger.ColorScheme.Green("✓ Verification succeeded!\n")) opts.Logger.Printf("Attestation found matching release %s (%s)\n", opts.TagName, releaseRefDigest.DigestWithAlg()) diff --git a/pkg/cmd/release/verify/verify.go b/pkg/cmd/release/verify/verify.go index 8c835d1e6..e8c6621e5 100644 --- a/pkg/cmd/release/verify/verify.go +++ b/pkg/cmd/release/verify/verify.go @@ -94,6 +94,8 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.AttestOptions) erro return verifyRun(opts) }, } + cmdutil.AddFormatFlags(cmd, &opts.Exporter) + return cmd } @@ -137,6 +139,16 @@ func verifyRun(opts *attestation.AttestOptions) error { return err } + // If an exporter is provided with the --json flag, write the results to the terminal in JSON format + if opts.Exporter != nil { + // print the results to the terminal as an array of JSON objects + if err = opts.Exporter.Write(opts.Logger.IO, verified); err != nil { + opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to write JSON output")) + return err + } + return nil + } + opts.Logger.Printf("The following %s matched the policy criteria\n\n", text.Pluralize(len(verified), "attestation")) opts.Logger.Println(opts.Logger.ColorScheme.Green("✓ Verification succeeded!\n")) From 3108d99208273dad1b91813f7edffee409a00cd6 Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Fri, 23 May 2025 15:31:33 -0400 Subject: [PATCH 10/31] added the unit test --- .../test/data/release-attestation.json | 24 +++++++ pkg/cmd/release/attestation/attestation.go | 7 -- pkg/cmd/release/attestation/options.go | 7 -- pkg/cmd/release/attestation/options_test.go | 72 +++++++++++++++++++ pkg/cmd/release/attestation/policy.go | 6 +- pkg/cmd/release/attestation/policy_test.go | 71 ++++++++++++++++++ pkg/cmd/release/shared/fetch.go | 2 +- pkg/cmd/release/verify-asset/verify-asset.go | 30 +++++--- pkg/cmd/release/verify/verify.go | 29 +++++--- 9 files changed, 213 insertions(+), 35 deletions(-) create mode 100644 pkg/cmd/attestation/test/data/release-attestation.json create mode 100644 pkg/cmd/release/attestation/options_test.go create mode 100644 pkg/cmd/release/attestation/policy_test.go diff --git a/pkg/cmd/attestation/test/data/release-attestation.json b/pkg/cmd/attestation/test/data/release-attestation.json new file mode 100644 index 000000000..ae8dd1b56 --- /dev/null +++ b/pkg/cmd/attestation/test/data/release-attestation.json @@ -0,0 +1,24 @@ +{ + "mediaType": "application/vnd.dev.sigstore.bundle.v0.3+json", + "verificationMaterial": { + "timestampVerificationData": { + "rfc3161Timestamps": [ + { + "signedTimestamp": "MIIC0TADAgEAMIICyAYJKoZIhvcNAQcCoIICuTCCArUCAQMxDTALBglghkgBZQMEAgIwgbwGCyqGSIb3DQEJEAEEoIGsBIGpMIGmAgEBBgkrBgEEAYO/MAIwMTANBglghkgBZQMEAgEFAAQgGvFc6nUuLhnXfhM9p0DV91c5kHvafP1hs9BX8KYeeSYCFQDhjGrIIiaH/jkMdN6HUsErnUfrlRgPMjAyNTA1MTMyMzAzNTFaMAMCAQGgNqQ0MDIxFTATBgNVBAoTDEdpdEh1YiwgSW5jLjEZMBcGA1UEAxMQVFNBIFRpbWVzdGFtcGluZ6AAMYIB3jCCAdoCAQEwSjAyMRUwEwYDVQQKEwxHaXRIdWIsIEluYy4xGTAXBgNVBAMTEFRTQSBpbnRlcm1lZGlhdGUCFB+7MIjE5/rL4XA4fNDnmXHA04+wMAsGCWCGSAFlAwQCAqCCAQUwGgYJKoZIhvcNAQkDMQ0GCyqGSIb3DQEJEAEEMBwGCSqGSIb3DQEJBTEPFw0yNTA1MTMyMzAzNTFaMD8GCSqGSIb3DQEJBDEyBDDVh2oDCJy7ustugLKfVcUSNjo5M2MFMNKIU11sIQDCNOo5gbj9R97sCWXNnfmUztMwgYcGCyqGSIb3DQEJEAIvMXgwdjB0MHIEIHuISsKSyiJtlhGjT+RyS+tYQ7iwCMsMCTGmz2NK3D7DME4wNqQ0MDIxFTATBgNVBAoTDEdpdEh1YiwgSW5jLjEZMBcGA1UEAxMQVFNBIGludGVybWVkaWF0ZQIUH7swiMTn+svhcDh80OeZccDTj7AwCgYIKoZIzj0EAwMEZzBlAjAqp/fYVfQcU9aMcmTIZvb0cxk00OaVBYLzuiIvcRqkMdAJiz/gSxOWU0AQjEPskHUCMQCrUKlZR4shPZuMvY6CCUOhxxKq/6LUoccWNHyL6sGkHRXE7j9HETh4uLKzRwNDVVA=" + } + ] + }, + "certificate": { + "rawBytes": "MIICKjCCAbCgAwIBAgIUaa62dj98DUB+TpyvKtVaR4vGSM0wCgYIKoZIzj0EAwMwODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZGdWxjaW8gSW50ZXJtZWRpYXRlIGwxMB4XDTI1MDMxMDE1MDMwMloXDTI2MDMxMDE1MDMwMlowKjEVMBMGA1UEChMMR2l0SHViLCBJbmMuMREwDwYDVQQDEwhBdHRlc3RlcjBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABIMB7plPnZvBRlC2lvAocKTAqAPMJqstEqYk26e9vDJDC1yqoiHxZfPV4W/1RqUMZD1dFKm9t4RiSmm73/QnQKajgaUwgaIwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoGCCsGAQUFBwMDMAwGA1UdEwEB/wQCMAAwHQYDVR0OBBYEFOqaGpr5SbdYk5CQXsmmDZCBHR+XMB8GA1UdIwQYMBaAFMDhuFKkS08+3no4EQbPSY6hRZszMC0GA1UdEQQmMCSGImh0dHBzOi8vZG90Y29tLnJlbGVhc2VzLmdpdGh1Yi5jb20wCgYIKoZIzj0EAwMDaAAwZQIwWFdF6xcXazHVPHEAtd1SeaizLdY1erRl5hK+XlwhfpnasQHHZ9bdu4Zj8ARhW/AhAjEArujhmJGo7Fi4/Ek1RN8bufs6UhIQneQd/pxE8QdorwZkj2C8nf2EzrUYzlxKfktC" + } + }, + "dsseEnvelope": { + "payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEiLCJzdWJqZWN0IjpbeyJ1cmkiOiJwa2c6Z2l0aHViL2JkZWhhbWVyL2RlbG1lQHY1IiwiZGlnZXN0Ijp7InNoYTEiOiJjNWUxN2E2MmUwNmExZDIwMTU3MDI0OWM2MWZhZTUzMWU5MjQ0ZTFiIn19LHsibmFtZSI6ImEuemlwIiwiZGlnZXN0Ijp7InNoYTI1NiI6ImY3MTY1ODQ4ZjlmNWRkYzU3OGQ3YWRiZDFmNTY2YTM5NDE2OTM4NWM3M2JkODhiZjYwZGY3ZTc1OWRiOGUwOGQifX0seyJuYW1lIjoiYi56aXAiLCJkaWdlc3QiOnsic2hhMjU2IjoiOGI3ZWIxNTcyMzQ2NjkyZmZkM2FlMDEyNDhjNzBhMzQxYWUzYWE4YmUxZGY4YjEyMzQ2YjUwYWNiOTAwMjI4MiJ9fV0sInByZWRpY2F0ZVR5cGUiOiJodHRwczovL2luLXRvdG8uaW8vYXR0ZXN0YXRpb24vcmVsZWFzZS92MC4xIiwicHJlZGljYXRlIjp7Im93bmVySWQiOiIzOTgwMjciLCJwdXJsIjoicGtnOmdpdGh1Yi9iZGVoYW1lci9kZWxtZUB2NSIsInJlbGVhc2VJZCI6IjIxODQxOTIxNyIsInJlcG9zaXRvcnkiOiJiZGVoYW1lci9kZWxtZSIsInJlcG9zaXRvcnlJZCI6IjkwNTk4ODA0NCIsInRhZyI6InY1In19", + "payloadType": "application/vnd.in-toto+json", + "signatures": [ + { + "sig": "MEQCIH6LDUanQYOCPovZlIqI1cE49SiGJdexR65qsAZHohsZAiA9w3usgPWtgn5voB8bRvpJQtjEVqC5eMDh3mJEdyMcXw==" + } + ] + } +} \ No newline at end of file diff --git a/pkg/cmd/release/attestation/attestation.go b/pkg/cmd/release/attestation/attestation.go index 08e1398b8..bf2f39a7c 100644 --- a/pkg/cmd/release/attestation/attestation.go +++ b/pkg/cmd/release/attestation/attestation.go @@ -50,13 +50,6 @@ func VerifyAttestations(art artifact.DigestedArtifact, att []*api.Attestation, s return nil, logMsg, err } - // Verify extensions - // certExtVerified, err := verification.VerifyCertExtensions(sigstoreVerified, ec) - // if err != nil { - // logMsg := "✗ Policy verification failed" - // return nil, logMsg, err - // } - return sigstoreVerified, "", nil } diff --git a/pkg/cmd/release/attestation/options.go b/pkg/cmd/release/attestation/options.go index f0957c04b..9dd84647e 100644 --- a/pkg/cmd/release/attestation/options.go +++ b/pkg/cmd/release/attestation/options.go @@ -19,7 +19,6 @@ import ( const ReleasePredicateType = "https://in-toto.io/attestation/release/v0.1" -// AttestOptions captures the options for the verify command type AttestOptions struct { Config func() (gh.Config, error) HttpClient *http.Client @@ -67,17 +66,11 @@ func (opts *AttestOptions) AreFlagsValid() error { return fmt.Errorf("invalid value provided for repo: %s", opts.Repo) } - // If provided, check that the SignerRepo option is in the expected format / - if opts.SignerRepo != "" && !isProvidedRepoValid(opts.SignerRepo) { - return fmt.Errorf("invalid value provided for signer-repo: %s", opts.SignerRepo) - } - // Check that limit is between 1 and 1000 if opts.Limit < 1 || opts.Limit > 1000 { return fmt.Errorf("limit %d not allowed, must be between 1 and 1000", opts.Limit) } - // Verify provided hostname if opts.Hostname != "" { if err := ghinstance.HostnameValidator(opts.Hostname); err != nil { return fmt.Errorf("error parsing hostname: %w", err) diff --git a/pkg/cmd/release/attestation/options_test.go b/pkg/cmd/release/attestation/options_test.go new file mode 100644 index 000000000..00bba29a5 --- /dev/null +++ b/pkg/cmd/release/attestation/options_test.go @@ -0,0 +1,72 @@ +package attestation + +import ( + "errors" + "testing" +) + +func TestAttestOptions_Clean(t *testing.T) { + opts := &AttestOptions{ + AssetFilePath: "foo/bar/../baz.txt", + } + opts.Clean() + expected := "foo/baz.txt" + if opts.AssetFilePath != expected && opts.AssetFilePath != "./foo/baz.txt" { // OS differences + t.Errorf("expected AssetFilePath to be cleaned to %q, got %q", expected, opts.AssetFilePath) + } +} + +func TestAttestOptions_AreFlagsValid_Valid(t *testing.T) { + opts := &AttestOptions{ + Repo: "owner/repo", + SignerRepo: "signer/repo", + Limit: 10, + } + if err := opts.AreFlagsValid(); err != nil { + t.Errorf("expected no error, got %v", err) + } +} + +func TestAttestOptions_AreFlagsValid_InvalidRepo(t *testing.T) { + opts := &AttestOptions{ + Repo: "invalidrepo", + } + err := opts.AreFlagsValid() + if err == nil || !errors.Is(err, err) { + t.Errorf("expected error for invalid repo, got %v", err) + } +} + +func TestAttestOptions_AreFlagsValid_LimitTooLow(t *testing.T) { + opts := &AttestOptions{ + Repo: "owner/repo", + Limit: 0, + } + err := opts.AreFlagsValid() + if err == nil || !errors.Is(err, err) { + t.Errorf("expected error for limit too low, got %v", err) + } +} + +func TestAttestOptions_AreFlagsValid_LimitTooHigh(t *testing.T) { + opts := &AttestOptions{ + Repo: "owner/repo", + Limit: 1001, + } + err := opts.AreFlagsValid() + if err == nil || !errors.Is(err, err) { + t.Errorf("expected error for limit too high, got %v", err) + } +} + +func TestAttestOptions_AreFlagsValid_ValidHostname(t *testing.T) { + opts := &AttestOptions{ + Repo: "owner/repo", + Limit: 10, + Hostname: "github.com", + } + err := opts.AreFlagsValid() + if err != nil { + t.Errorf("expected no error for valid hostname, got %v", err) + } +} diff --git a/pkg/cmd/release/attestation/policy.go b/pkg/cmd/release/attestation/policy.go index 7dfb88cfe..d7bf0f096 100644 --- a/pkg/cmd/release/attestation/policy.go +++ b/pkg/cmd/release/attestation/policy.go @@ -3,7 +3,6 @@ package attestation import ( "fmt" - att_io "github.com/cli/cli/v2/pkg/cmd/attestation/io" "github.com/sigstore/sigstore-go/pkg/fulcio/certificate" "github.com/sigstore/sigstore-go/pkg/verify" @@ -18,12 +17,11 @@ func expandToGitHubURL(tenant, ownerOrRepo string) string { return fmt.Sprintf("https://%s.ghe.com/%s", tenant, ownerOrRepo) } -// TODO: revist this policy -func NewEnforcementCriteria(opts *AttestOptions, logger *att_io.Handler) (verification.EnforcementCriteria, error) { +func NewEnforcementCriteria(opts *AttestOptions) (verification.EnforcementCriteria, error) { // initialize the enforcement criteria with the provided PredicateType and SAN c := verification.EnforcementCriteria{ PredicateType: opts.PredicateType, - // if the proxima is provided, the default uses the proxima-specific SAN + // TODO: if the proxima is provided, the default uses the proxima-specific SAN SAN: "https://dotcom.releases.github.com", } diff --git a/pkg/cmd/release/attestation/policy_test.go b/pkg/cmd/release/attestation/policy_test.go new file mode 100644 index 000000000..57eab86b2 --- /dev/null +++ b/pkg/cmd/release/attestation/policy_test.go @@ -0,0 +1,71 @@ +package attestation + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNewEnforcementCriteria(t *testing.T) { + t.Run("check SAN", func(t *testing.T) { + opts := &AttestOptions{ + Owner: "foo", + Repo: "foo/bar", + PredicateType: "https://in-toto.io/attestation/release/v0.1", + } + + c, err := NewEnforcementCriteria(opts) + require.NoError(t, err) + require.Equal(t, "https://dotcom.releases.github.com", c.SAN) + require.Equal(t, "https://in-toto.io/attestation/release/v0.1", c.PredicateType) + }) + + t.Run("sets Extensions.SourceRepositoryURI using opts.Repo and opts.Tenant", func(t *testing.T) { + opts := &AttestOptions{ + Owner: "foo", + Repo: "foo/bar", + Tenant: "baz", + } + + c, err := NewEnforcementCriteria(opts) + require.NoError(t, err) + require.Equal(t, "https://baz.ghe.com/foo/bar", c.Certificate.SourceRepositoryURI) + }) + + t.Run("sets Extensions.SourceRepositoryURI using opts.Repo", func(t *testing.T) { + opts := &AttestOptions{ + Owner: "foo", + Repo: "foo/bar", + } + + c, err := NewEnforcementCriteria(opts) + require.NoError(t, err) + require.Equal(t, "https://github.com/foo/bar", c.Certificate.SourceRepositoryURI) + }) + + t.Run("sets Extensions.SourceRepositoryOwnerURI using opts.Owner and opts.Tenant", func(t *testing.T) { + opts := &AttestOptions{ + + Owner: "foo", + Repo: "foo/bar", + Tenant: "baz", + } + + c, err := NewEnforcementCriteria(opts) + require.NoError(t, err) + require.Equal(t, "https://baz.ghe.com/foo", c.Certificate.SourceRepositoryOwnerURI) + }) + + t.Run("sets Extensions.SourceRepositoryOwnerURI using opts.Owner", func(t *testing.T) { + opts := &AttestOptions{ + + Owner: "foo", + Repo: "foo/bar", + } + + c, err := NewEnforcementCriteria(opts) + require.NoError(t, err) + require.Equal(t, "https://github.com/foo", c.Certificate.SourceRepositoryOwnerURI) + }) + +} diff --git a/pkg/cmd/release/shared/fetch.go b/pkg/cmd/release/shared/fetch.go index 5fea30b7c..3daa1d3fc 100644 --- a/pkg/cmd/release/shared/fetch.go +++ b/pkg/cmd/release/shared/fetch.go @@ -132,7 +132,7 @@ type fetchResult struct { } 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) + path := fmt.Sprintf("repos/%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 diff --git a/pkg/cmd/release/verify-asset/verify-asset.go b/pkg/cmd/release/verify-asset/verify-asset.go index 585643c2d..ddefdf5be 100644 --- a/pkg/cmd/release/verify-asset/verify-asset.go +++ b/pkg/cmd/release/verify-asset/verify-asset.go @@ -5,6 +5,7 @@ import ( "errors" "path/filepath" + "github.com/cli/cli/v2/pkg/cmd/attestation/auth" ghauth "github.com/cli/go-gh/v2/pkg/auth" "github.com/cli/cli/v2/internal/text" @@ -23,9 +24,10 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.AttestOptions) opts := &attestation.AttestOptions{} cmd := &cobra.Command{ - Use: "verify-asset ", - Short: "Verify that a given asset originated from a specific GitHub Release.", - Args: cobra.ExactArgs(2), + Use: "verify-asset ", + Short: "Verify that a given asset originated from a specific GitHub Release.", + Hidden: true, + Args: cobra.ExactArgs(2), PreRunE: func(cmd *cobra.Command, args []string) error { if len(args) < 2 { return cmdutil.FlagErrorf("You must specify a tag and a file path") @@ -45,6 +47,11 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.AttestOptions) logger := att_io.NewHandler(f.IOStreams) hostname, _ := ghauth.DefaultHost() + err = auth.IsHostSupported(hostname) + if err != nil { + return err + } + *opts = attestation.AttestOptions{ TagName: tagName, AssetFilePath: assetFilePath, @@ -56,21 +63,24 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.AttestOptions) Logger: logger, HttpClient: httpClient, BaseRepo: baseRepo, + Hostname: hostname, } + + // Check that the given flag combination is valid + if err := opts.AreFlagsValid(); err != nil { + return err + } + return nil }, RunE: func(cmd *cobra.Command, args []string) error { - if runF != nil { - return runF(opts) - } - td, err := opts.APIClient.GetTrustDomain() if err != nil { opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to get trust domain")) return err } - ec, err := attestation.NewEnforcementCriteria(opts, opts.Logger) + ec, err := attestation.NewEnforcementCriteria(opts) if err != nil { opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to build policy information")) return err @@ -90,6 +100,10 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.AttestOptions) opts.SigstoreVerifier = sigstoreVerifier opts.EC = ec + if runF != nil { + return runF(opts) + } + return verifyAssetRun(opts) }, } diff --git a/pkg/cmd/release/verify/verify.go b/pkg/cmd/release/verify/verify.go index e8c6621e5..96c33c50b 100644 --- a/pkg/cmd/release/verify/verify.go +++ b/pkg/cmd/release/verify/verify.go @@ -10,6 +10,7 @@ import ( "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmd/attestation/api" "github.com/cli/cli/v2/pkg/cmd/attestation/artifact" + "github.com/cli/cli/v2/pkg/cmd/attestation/auth" 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/attestation" @@ -24,9 +25,10 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.AttestOptions) erro opts := &attestation.AttestOptions{} cmd := &cobra.Command{ - Use: "verify []", - Short: "Verify the attestation for a GitHub Release.", - Args: cobra.ExactArgs(1), + Use: "verify []", + Short: "Verify the attestation for a GitHub Release.", + Hidden: true, + Args: cobra.ExactArgs(1), PreRunE: func(cmd *cobra.Command, args []string) error { if len(args) < 1 { return cmdutil.FlagErrorf("You must specify a tag") @@ -46,6 +48,11 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.AttestOptions) erro logger := att_io.NewHandler(f.IOStreams) hostname, _ := ghauth.DefaultHost() + err = auth.IsHostSupported(hostname) + if err != nil { + return err + } + *opts = attestation.AttestOptions{ TagName: opts.TagName, Repo: baseRepo.RepoOwner() + "/" + baseRepo.RepoName(), @@ -56,21 +63,23 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.AttestOptions) erro Logger: logger, HttpClient: httpClient, BaseRepo: baseRepo, + Hostname: hostname, + } + + // Check that the given flag combination is valid + if err := opts.AreFlagsValid(); err != nil { + return err } return nil }, RunE: func(cmd *cobra.Command, args []string) error { - if runF != nil { - return runF(opts) - } - td, err := opts.APIClient.GetTrustDomain() if err != nil { opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to get trust domain")) return err } - ec, err := attestation.NewEnforcementCriteria(opts, opts.Logger) + ec, err := attestation.NewEnforcementCriteria(opts) if err != nil { opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to build policy information")) return err @@ -91,6 +100,10 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.AttestOptions) erro opts.SigstoreVerifier = sigstoreVerifier opts.EC = ec + if runF != nil { + return runF(opts) + } + return verifyRun(opts) }, } From 81f1017fa2e4633fdae6f4862d6eb1ab23d650b7 Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Fri, 23 May 2025 15:33:43 -0400 Subject: [PATCH 11/31] removed unused file --- .../test/data/release-attestation.json | 24 ------------------- 1 file changed, 24 deletions(-) delete mode 100644 pkg/cmd/attestation/test/data/release-attestation.json diff --git a/pkg/cmd/attestation/test/data/release-attestation.json b/pkg/cmd/attestation/test/data/release-attestation.json deleted file mode 100644 index ae8dd1b56..000000000 --- a/pkg/cmd/attestation/test/data/release-attestation.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "mediaType": "application/vnd.dev.sigstore.bundle.v0.3+json", - "verificationMaterial": { - "timestampVerificationData": { - "rfc3161Timestamps": [ - { - "signedTimestamp": "MIIC0TADAgEAMIICyAYJKoZIhvcNAQcCoIICuTCCArUCAQMxDTALBglghkgBZQMEAgIwgbwGCyqGSIb3DQEJEAEEoIGsBIGpMIGmAgEBBgkrBgEEAYO/MAIwMTANBglghkgBZQMEAgEFAAQgGvFc6nUuLhnXfhM9p0DV91c5kHvafP1hs9BX8KYeeSYCFQDhjGrIIiaH/jkMdN6HUsErnUfrlRgPMjAyNTA1MTMyMzAzNTFaMAMCAQGgNqQ0MDIxFTATBgNVBAoTDEdpdEh1YiwgSW5jLjEZMBcGA1UEAxMQVFNBIFRpbWVzdGFtcGluZ6AAMYIB3jCCAdoCAQEwSjAyMRUwEwYDVQQKEwxHaXRIdWIsIEluYy4xGTAXBgNVBAMTEFRTQSBpbnRlcm1lZGlhdGUCFB+7MIjE5/rL4XA4fNDnmXHA04+wMAsGCWCGSAFlAwQCAqCCAQUwGgYJKoZIhvcNAQkDMQ0GCyqGSIb3DQEJEAEEMBwGCSqGSIb3DQEJBTEPFw0yNTA1MTMyMzAzNTFaMD8GCSqGSIb3DQEJBDEyBDDVh2oDCJy7ustugLKfVcUSNjo5M2MFMNKIU11sIQDCNOo5gbj9R97sCWXNnfmUztMwgYcGCyqGSIb3DQEJEAIvMXgwdjB0MHIEIHuISsKSyiJtlhGjT+RyS+tYQ7iwCMsMCTGmz2NK3D7DME4wNqQ0MDIxFTATBgNVBAoTDEdpdEh1YiwgSW5jLjEZMBcGA1UEAxMQVFNBIGludGVybWVkaWF0ZQIUH7swiMTn+svhcDh80OeZccDTj7AwCgYIKoZIzj0EAwMEZzBlAjAqp/fYVfQcU9aMcmTIZvb0cxk00OaVBYLzuiIvcRqkMdAJiz/gSxOWU0AQjEPskHUCMQCrUKlZR4shPZuMvY6CCUOhxxKq/6LUoccWNHyL6sGkHRXE7j9HETh4uLKzRwNDVVA=" - } - ] - }, - "certificate": { - "rawBytes": "MIICKjCCAbCgAwIBAgIUaa62dj98DUB+TpyvKtVaR4vGSM0wCgYIKoZIzj0EAwMwODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZGdWxjaW8gSW50ZXJtZWRpYXRlIGwxMB4XDTI1MDMxMDE1MDMwMloXDTI2MDMxMDE1MDMwMlowKjEVMBMGA1UEChMMR2l0SHViLCBJbmMuMREwDwYDVQQDEwhBdHRlc3RlcjBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABIMB7plPnZvBRlC2lvAocKTAqAPMJqstEqYk26e9vDJDC1yqoiHxZfPV4W/1RqUMZD1dFKm9t4RiSmm73/QnQKajgaUwgaIwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoGCCsGAQUFBwMDMAwGA1UdEwEB/wQCMAAwHQYDVR0OBBYEFOqaGpr5SbdYk5CQXsmmDZCBHR+XMB8GA1UdIwQYMBaAFMDhuFKkS08+3no4EQbPSY6hRZszMC0GA1UdEQQmMCSGImh0dHBzOi8vZG90Y29tLnJlbGVhc2VzLmdpdGh1Yi5jb20wCgYIKoZIzj0EAwMDaAAwZQIwWFdF6xcXazHVPHEAtd1SeaizLdY1erRl5hK+XlwhfpnasQHHZ9bdu4Zj8ARhW/AhAjEArujhmJGo7Fi4/Ek1RN8bufs6UhIQneQd/pxE8QdorwZkj2C8nf2EzrUYzlxKfktC" - } - }, - "dsseEnvelope": { - "payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEiLCJzdWJqZWN0IjpbeyJ1cmkiOiJwa2c6Z2l0aHViL2JkZWhhbWVyL2RlbG1lQHY1IiwiZGlnZXN0Ijp7InNoYTEiOiJjNWUxN2E2MmUwNmExZDIwMTU3MDI0OWM2MWZhZTUzMWU5MjQ0ZTFiIn19LHsibmFtZSI6ImEuemlwIiwiZGlnZXN0Ijp7InNoYTI1NiI6ImY3MTY1ODQ4ZjlmNWRkYzU3OGQ3YWRiZDFmNTY2YTM5NDE2OTM4NWM3M2JkODhiZjYwZGY3ZTc1OWRiOGUwOGQifX0seyJuYW1lIjoiYi56aXAiLCJkaWdlc3QiOnsic2hhMjU2IjoiOGI3ZWIxNTcyMzQ2NjkyZmZkM2FlMDEyNDhjNzBhMzQxYWUzYWE4YmUxZGY4YjEyMzQ2YjUwYWNiOTAwMjI4MiJ9fV0sInByZWRpY2F0ZVR5cGUiOiJodHRwczovL2luLXRvdG8uaW8vYXR0ZXN0YXRpb24vcmVsZWFzZS92MC4xIiwicHJlZGljYXRlIjp7Im93bmVySWQiOiIzOTgwMjciLCJwdXJsIjoicGtnOmdpdGh1Yi9iZGVoYW1lci9kZWxtZUB2NSIsInJlbGVhc2VJZCI6IjIxODQxOTIxNyIsInJlcG9zaXRvcnkiOiJiZGVoYW1lci9kZWxtZSIsInJlcG9zaXRvcnlJZCI6IjkwNTk4ODA0NCIsInRhZyI6InY1In19", - "payloadType": "application/vnd.in-toto+json", - "signatures": [ - { - "sig": "MEQCIH6LDUanQYOCPovZlIqI1cE49SiGJdexR65qsAZHohsZAiA9w3usgPWtgn5voB8bRvpJQtjEVqC5eMDh3mJEdyMcXw==" - } - ] - } -} \ No newline at end of file From d0da9b16642706e60d89d330ddd410a9d688bcfb Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Fri, 23 May 2025 16:23:41 -0400 Subject: [PATCH 12/31] update Sprintf --- pkg/cmd/release/shared/fetch.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/release/shared/fetch.go b/pkg/cmd/release/shared/fetch.go index 3daa1d3fc..5fea30b7c 100644 --- a/pkg/cmd/release/shared/fetch.go +++ b/pkg/cmd/release/shared/fetch.go @@ -132,7 +132,7 @@ type fetchResult struct { } func FetchRefSHA(ctx context.Context, httpClient *http.Client, repo ghrepo.Interface, tagName string) (string, error) { - path := fmt.Sprintf("repos/%s/git/refs/tags/%s", repo.RepoOwner(), repo.RepoName(), tagName) + 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 From ab49b2abbc50c6c934f00a1bb56b26e44fffcc40 Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Tue, 27 May 2025 12:02:24 -0400 Subject: [PATCH 13/31] remove filepath test --- pkg/cmd/release/attestation/options_test.go | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/pkg/cmd/release/attestation/options_test.go b/pkg/cmd/release/attestation/options_test.go index 00bba29a5..89d260199 100644 --- a/pkg/cmd/release/attestation/options_test.go +++ b/pkg/cmd/release/attestation/options_test.go @@ -5,17 +5,6 @@ import ( "testing" ) -func TestAttestOptions_Clean(t *testing.T) { - opts := &AttestOptions{ - AssetFilePath: "foo/bar/../baz.txt", - } - opts.Clean() - expected := "foo/baz.txt" - if opts.AssetFilePath != expected && opts.AssetFilePath != "./foo/baz.txt" { // OS differences - t.Errorf("expected AssetFilePath to be cleaned to %q, got %q", expected, opts.AssetFilePath) - } -} - func TestAttestOptions_AreFlagsValid_Valid(t *testing.T) { opts := &AttestOptions{ Repo: "owner/repo", From 71c2361dfca1ba083eec3c5d24df5970bf379123 Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Fri, 30 May 2025 08:17:21 -0700 Subject: [PATCH 14/31] add unit test --- go.mod | 1 + go.sum | 6 + pkg/cmd/attestation/api/mock_client.go | 11 ++ pkg/cmd/attestation/test/data/a.zip | 28 ++++ pkg/cmd/attestation/test/data/data.go | 12 ++ .../test/data/github_release_bundle.json | 24 +++ pkg/cmd/release/shared/fetch.go | 8 + pkg/cmd/release/verify-asset/verify-asset.go | 28 +++- .../release/verify-asset/verify-asset_test.go | 158 ++++++++++++++++++ pkg/cmd/release/verify/verify.go | 16 +- pkg/cmd/release/verify/verify_test.go | 142 ++++++++++++++++ 11 files changed, 421 insertions(+), 13 deletions(-) create mode 100644 pkg/cmd/attestation/test/data/a.zip create mode 100644 pkg/cmd/attestation/test/data/github_release_bundle.json create mode 100644 pkg/cmd/release/verify-asset/verify-asset_test.go create mode 100644 pkg/cmd/release/verify/verify_test.go diff --git a/go.mod b/go.mod index f95c8a7c2..7ffaf3cc9 100644 --- a/go.mod +++ b/go.mod @@ -58,6 +58,7 @@ require ( google.golang.org/protobuf v1.36.6 gopkg.in/h2non/gock.v1 v1.1.2 gopkg.in/yaml.v3 v3.0.1 + gotest.tools/v3 v3.0.3 ) require ( diff --git a/go.sum b/go.sum index e0ecad6a7..564042eaf 100644 --- a/go.sum +++ b/go.sum @@ -243,6 +243,7 @@ github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/certificate-transparency-go v1.3.1 h1:akbcTfQg0iZlANZLn0L9xOeWtyCIdeoYhKrqi5iH3Go= github.com/google/certificate-transparency-go v1.3.1/go.mod h1:gg+UQlx6caKEDQ9EElFOujyxEQEfOiQzAt6782Bvi8k= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-containerregistry v0.20.3 h1:oNx7IdTI936V8CQRveCjaxOiegWwvM7kqkbXTpyiovI= @@ -410,6 +411,7 @@ github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNH github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -483,6 +485,7 @@ github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= @@ -560,6 +563,7 @@ golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8/go.mod h1:CQ1k9gNrJ50XIzaKCR golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= @@ -597,11 +601,13 @@ golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE= golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/api v0.227.0 h1:QvIHF9IuyG6d6ReE+BNd11kIB8hZvjN8Z5xY5t21zYc= google.golang.org/api v0.227.0/go.mod h1:EIpaG6MbTgQarWF5xJvX0eOJPK9n/5D4Bynb9j2HXvQ= google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb h1:ITgPrl429bc6+2ZraNSzMDk3I95nmQln2fuPstKwFDE= diff --git a/pkg/cmd/attestation/api/mock_client.go b/pkg/cmd/attestation/api/mock_client.go index b6062b39f..4b4f06eff 100644 --- a/pkg/cmd/attestation/api/mock_client.go +++ b/pkg/cmd/attestation/api/mock_client.go @@ -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) } diff --git a/pkg/cmd/attestation/test/data/a.zip b/pkg/cmd/attestation/test/data/a.zip new file mode 100644 index 000000000..f4595ef44 --- /dev/null +++ b/pkg/cmd/attestation/test/data/a.zip @@ -0,0 +1,28 @@ +a # frozen_string_literal: true + +source "https://rubygems.org" + +source "https://rubygems.pkg.github.com/github" do + gem "entitlements-aad-plugin", "~> 1.0" + gem "entitlements-app", "~> 1.2" + gem "entitlements-github-plugin", "~> 1.2" + gem "entitlements-gitrepo-auditor-plugin", "~> 1.0" + gem "entitlements-jit-github-plugin", "~> 1.0" + gem "entitlements-lib", "~> 0.2" + gem "entitlements-stafftools-plugin", "~> 1.0" +end + +group :development do + gem "base64", "~> 0.2.0" + gem "irb", "~> 1.15" + gem "pry", "~> 0.14" + gem "pry-byebug", "~> 3.9" + gem "pry-rescue", "~> 1.6" + gem "rspec", "~> 3.13" + gem "rubocop", "~> 1.71" + gem "rubocop-github", "~> 0.20.0" + gem "rubocop-performance" + gem "rubocop-rspec", "~> 3.4.0" + gem "simplecov", "~> 0.21" + gem "simplecov-erb", "~> 1.0.0" +end diff --git a/pkg/cmd/attestation/test/data/data.go b/pkg/cmd/attestation/test/data/data.go index ef3c35c20..223d6f22e 100644 --- a/pkg/cmd/attestation/test/data/data.go +++ b/pkg/cmd/attestation/test/data/data.go @@ -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 +} diff --git a/pkg/cmd/attestation/test/data/github_release_bundle.json b/pkg/cmd/attestation/test/data/github_release_bundle.json new file mode 100644 index 000000000..ae8dd1b56 --- /dev/null +++ b/pkg/cmd/attestation/test/data/github_release_bundle.json @@ -0,0 +1,24 @@ +{ + "mediaType": "application/vnd.dev.sigstore.bundle.v0.3+json", + "verificationMaterial": { + "timestampVerificationData": { + "rfc3161Timestamps": [ + { + "signedTimestamp": "MIIC0TADAgEAMIICyAYJKoZIhvcNAQcCoIICuTCCArUCAQMxDTALBglghkgBZQMEAgIwgbwGCyqGSIb3DQEJEAEEoIGsBIGpMIGmAgEBBgkrBgEEAYO/MAIwMTANBglghkgBZQMEAgEFAAQgGvFc6nUuLhnXfhM9p0DV91c5kHvafP1hs9BX8KYeeSYCFQDhjGrIIiaH/jkMdN6HUsErnUfrlRgPMjAyNTA1MTMyMzAzNTFaMAMCAQGgNqQ0MDIxFTATBgNVBAoTDEdpdEh1YiwgSW5jLjEZMBcGA1UEAxMQVFNBIFRpbWVzdGFtcGluZ6AAMYIB3jCCAdoCAQEwSjAyMRUwEwYDVQQKEwxHaXRIdWIsIEluYy4xGTAXBgNVBAMTEFRTQSBpbnRlcm1lZGlhdGUCFB+7MIjE5/rL4XA4fNDnmXHA04+wMAsGCWCGSAFlAwQCAqCCAQUwGgYJKoZIhvcNAQkDMQ0GCyqGSIb3DQEJEAEEMBwGCSqGSIb3DQEJBTEPFw0yNTA1MTMyMzAzNTFaMD8GCSqGSIb3DQEJBDEyBDDVh2oDCJy7ustugLKfVcUSNjo5M2MFMNKIU11sIQDCNOo5gbj9R97sCWXNnfmUztMwgYcGCyqGSIb3DQEJEAIvMXgwdjB0MHIEIHuISsKSyiJtlhGjT+RyS+tYQ7iwCMsMCTGmz2NK3D7DME4wNqQ0MDIxFTATBgNVBAoTDEdpdEh1YiwgSW5jLjEZMBcGA1UEAxMQVFNBIGludGVybWVkaWF0ZQIUH7swiMTn+svhcDh80OeZccDTj7AwCgYIKoZIzj0EAwMEZzBlAjAqp/fYVfQcU9aMcmTIZvb0cxk00OaVBYLzuiIvcRqkMdAJiz/gSxOWU0AQjEPskHUCMQCrUKlZR4shPZuMvY6CCUOhxxKq/6LUoccWNHyL6sGkHRXE7j9HETh4uLKzRwNDVVA=" + } + ] + }, + "certificate": { + "rawBytes": "MIICKjCCAbCgAwIBAgIUaa62dj98DUB+TpyvKtVaR4vGSM0wCgYIKoZIzj0EAwMwODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZGdWxjaW8gSW50ZXJtZWRpYXRlIGwxMB4XDTI1MDMxMDE1MDMwMloXDTI2MDMxMDE1MDMwMlowKjEVMBMGA1UEChMMR2l0SHViLCBJbmMuMREwDwYDVQQDEwhBdHRlc3RlcjBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABIMB7plPnZvBRlC2lvAocKTAqAPMJqstEqYk26e9vDJDC1yqoiHxZfPV4W/1RqUMZD1dFKm9t4RiSmm73/QnQKajgaUwgaIwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoGCCsGAQUFBwMDMAwGA1UdEwEB/wQCMAAwHQYDVR0OBBYEFOqaGpr5SbdYk5CQXsmmDZCBHR+XMB8GA1UdIwQYMBaAFMDhuFKkS08+3no4EQbPSY6hRZszMC0GA1UdEQQmMCSGImh0dHBzOi8vZG90Y29tLnJlbGVhc2VzLmdpdGh1Yi5jb20wCgYIKoZIzj0EAwMDaAAwZQIwWFdF6xcXazHVPHEAtd1SeaizLdY1erRl5hK+XlwhfpnasQHHZ9bdu4Zj8ARhW/AhAjEArujhmJGo7Fi4/Ek1RN8bufs6UhIQneQd/pxE8QdorwZkj2C8nf2EzrUYzlxKfktC" + } + }, + "dsseEnvelope": { + "payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEiLCJzdWJqZWN0IjpbeyJ1cmkiOiJwa2c6Z2l0aHViL2JkZWhhbWVyL2RlbG1lQHY1IiwiZGlnZXN0Ijp7InNoYTEiOiJjNWUxN2E2MmUwNmExZDIwMTU3MDI0OWM2MWZhZTUzMWU5MjQ0ZTFiIn19LHsibmFtZSI6ImEuemlwIiwiZGlnZXN0Ijp7InNoYTI1NiI6ImY3MTY1ODQ4ZjlmNWRkYzU3OGQ3YWRiZDFmNTY2YTM5NDE2OTM4NWM3M2JkODhiZjYwZGY3ZTc1OWRiOGUwOGQifX0seyJuYW1lIjoiYi56aXAiLCJkaWdlc3QiOnsic2hhMjU2IjoiOGI3ZWIxNTcyMzQ2NjkyZmZkM2FlMDEyNDhjNzBhMzQxYWUzYWE4YmUxZGY4YjEyMzQ2YjUwYWNiOTAwMjI4MiJ9fV0sInByZWRpY2F0ZVR5cGUiOiJodHRwczovL2luLXRvdG8uaW8vYXR0ZXN0YXRpb24vcmVsZWFzZS92MC4xIiwicHJlZGljYXRlIjp7Im93bmVySWQiOiIzOTgwMjciLCJwdXJsIjoicGtnOmdpdGh1Yi9iZGVoYW1lci9kZWxtZUB2NSIsInJlbGVhc2VJZCI6IjIxODQxOTIxNyIsInJlcG9zaXRvcnkiOiJiZGVoYW1lci9kZWxtZSIsInJlcG9zaXRvcnlJZCI6IjkwNTk4ODA0NCIsInRhZyI6InY1In19", + "payloadType": "application/vnd.in-toto+json", + "signatures": [ + { + "sig": "MEQCIH6LDUanQYOCPovZlIqI1cE49SiGJdexR65qsAZHohsZAiA9w3usgPWtgn5voB8bRvpJQtjEVqC5eMDh3mJEdyMcXw==" + } + ] + } +} \ No newline at end of file diff --git a/pkg/cmd/release/shared/fetch.go b/pkg/cmd/release/shared/fetch.go index 5fea30b7c..4e1be87e3 100644 --- a/pkg/cmd/release/shared/fetch.go +++ b/pkg/cmd/release/shared/fetch.go @@ -281,3 +281,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)), + ) +} diff --git a/pkg/cmd/release/verify-asset/verify-asset.go b/pkg/cmd/release/verify-asset/verify-asset.go index ddefdf5be..0c4443d04 100644 --- a/pkg/cmd/release/verify-asset/verify-asset.go +++ b/pkg/cmd/release/verify-asset/verify-asset.go @@ -27,14 +27,17 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.AttestOptions) Use: "verify-asset ", Short: "Verify that a given asset originated from a specific GitHub Release.", Hidden: true, - Args: cobra.ExactArgs(2), + Args: cobra.MaximumNArgs(2), PreRunE: func(cmd *cobra.Command, args []string) error { - if len(args) < 2 { - return cmdutil.FlagErrorf("You must specify a tag and a file path") - } - tagName := args[0] - assetFilePath := args[1] + 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") + } httpClient, err := f.HttpClient() if err != nil { @@ -53,8 +56,8 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.AttestOptions) } *opts = attestation.AttestOptions{ - TagName: tagName, - AssetFilePath: assetFilePath, + TagName: opts.TagName, + AssetFilePath: opts.AssetFilePath, Repo: baseRepo.RepoOwner() + "/" + baseRepo.RepoName(), APIClient: api.NewLiveClient(httpClient, hostname, logger), Limit: 10, @@ -114,6 +117,15 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.AttestOptions) func verifyAssetRun(opts *attestation.AttestOptions) error { ctx := context.Background() + + if opts.TagName == "" { + release, err := shared.FetchLatestRelease(ctx, opts.HttpClient, opts.BaseRepo) + if err != nil { + return err + } + opts.TagName = release.TagName + } + fileName := getFileName(opts.AssetFilePath) // calculate the digest of the file diff --git a/pkg/cmd/release/verify-asset/verify-asset_test.go b/pkg/cmd/release/verify-asset/verify-asset_test.go new file mode 100644 index 000000000..eb333fc06 --- /dev/null +++ b/pkg/cmd/release/verify-asset/verify-asset_test.go @@ -0,0 +1,158 @@ +package verifyasset + +import ( + "bytes" + "net/http" + "testing" + + "github.com/cli/cli/v2/pkg/cmd/attestation/api" + "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/attestation" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/cli/cli/v2/internal/ghrepo" + + "github.com/cli/cli/v2/pkg/cmd/release/shared" + "github.com/cli/cli/v2/pkg/httpmock" +) + +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/a.zip"}, + wantTag: "v1.2.3", + wantFile: "../../attestation/test/data/a.zip", + }, + { + name: "valid flag with no tag", + + args: []string{"../../attestation/test/data/a.zip"}, + wantFile: "../../attestation/test/data/a.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() + var testReg httpmock.Registry + var metaResp = api.MetaResponse{ + Domains: api.Domain{ + ArtifactAttestations: api.ArtifactAttestations{}, + }, + } + testReg.Register(httpmock.REST(http.MethodGet, "meta"), + httpmock.StatusJSONResponse(200, &metaResp)) + + f := &cmdutil.Factory{ + IOStreams: testIO, + HttpClient: func() (*http.Client, error) { + reg := &testReg + client := &http.Client{} + httpmock.ReplaceTripper(client, reg) + return client, nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("owner/repo") + }, + } + + var opts *attestation.AttestOptions + cmd := NewCmdVerifyAsset(f, func(o *attestation.AttestOptions) error { + opts = o + 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, opts.TagName) + assert.Equal(t, tt.wantFile, opts.AssetFilePath) + } + }) + } +} + +func Test_verifyAssetRun_Success(t *testing.T) { + ios, _, _, _ := iostreams.Test() + 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) + + opts := &attestation.AttestOptions{ + TagName: tagName, + AssetFilePath: "../../attestation/test/data/a.zip", + Repo: "owner/repo", + Owner: "owner", + Limit: 10, + Logger: io.NewHandler(ios), + APIClient: api.NewTestClient(), + SigstoreVerifier: verification.NewMockSigstoreVerifier(t), + HttpClient: &http.Client{Transport: fakeHTTP}, + BaseRepo: baseRepo, + } + + err = verifyAssetRun(opts) + require.NoError(t, err) +} + +func Test_verifyAssetRun_NoAttestation(t *testing.T) { + ios, _, _, _ := iostreams.Test() + opts := &attestation.AttestOptions{ + TagName: "v1.2.3", + AssetFilePath: "artifact.tgz", + Repo: "owner/repo", + Limit: 10, + Logger: io.NewHandler(ios), + IO: ios, + APIClient: api.NewTestClient(), + SigstoreVerifier: verification.NewMockSigstoreVerifier(t), + EC: verification.EnforcementCriteria{}, + } + + err := verifyAssetRun(opts) + require.Error(t, err, "failed to get open local artifact: open artifact.tgz: no such file or director") +} + +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) + }) + } +} diff --git a/pkg/cmd/release/verify/verify.go b/pkg/cmd/release/verify/verify.go index 96c33c50b..76a5cd773 100644 --- a/pkg/cmd/release/verify/verify.go +++ b/pkg/cmd/release/verify/verify.go @@ -28,14 +28,12 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.AttestOptions) erro Use: "verify []", Short: "Verify the attestation for a GitHub Release.", Hidden: true, - Args: cobra.ExactArgs(1), + Args: cobra.MaximumNArgs(1), PreRunE: func(cmd *cobra.Command, args []string) error { - if len(args) < 1 { - return cmdutil.FlagErrorf("You must specify a tag") + if len(args) > 0 { + opts.TagName = args[0] } - opts.TagName = args[0] - httpClient, err := f.HttpClient() if err != nil { return err @@ -115,6 +113,14 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.AttestOptions) erro func verifyRun(opts *attestation.AttestOptions) error { ctx := context.Background() + if opts.TagName == "" { + release, err := shared.FetchLatestRelease(ctx, opts.HttpClient, opts.BaseRepo) + if err != nil { + return err + } + opts.TagName = release.TagName + } + ref, err := shared.FetchRefSHA(ctx, opts.HttpClient, opts.BaseRepo, opts.TagName) if err != nil { return err diff --git a/pkg/cmd/release/verify/verify_test.go b/pkg/cmd/release/verify/verify_test.go new file mode 100644 index 000000000..71a282aa2 --- /dev/null +++ b/pkg/cmd/release/verify/verify_test.go @@ -0,0 +1,142 @@ +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/io" + "github.com/cli/cli/v2/pkg/cmd/attestation/verification" + "github.com/cli/cli/v2/pkg/cmd/release/attestation" + "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/require" + "gotest.tools/v3/assert" +) + +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() + var testReg httpmock.Registry + var metaResp = api.MetaResponse{ + Domains: api.Domain{ + ArtifactAttestations: api.ArtifactAttestations{}, + }, + } + testReg.Register(httpmock.REST(http.MethodGet, "meta"), + httpmock.StatusJSONResponse(200, &metaResp)) + + f := &cmdutil.Factory{ + IOStreams: testIO, + HttpClient: func() (*http.Client, error) { + reg := &testReg + client := &http.Client{} + httpmock.ReplaceTripper(client, reg) + return client, nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("owner/repo") + }, + } + + var opts *attestation.AttestOptions + cmd := NewCmdVerify(f, func(o *attestation.AttestOptions) error { + opts = o + 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, opts.TagName) + }) + } +} + +func Test_verifyRun_Success(t *testing.T) { + ios, _, _, _ := iostreams.Test() + 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) + + opts := &attestation.AttestOptions{ + TagName: tagName, + Repo: "owner/repo", + Owner: "owner", + Limit: 10, + Logger: io.NewHandler(ios), + APIClient: api.NewTestClient(), + SigstoreVerifier: verification.NewMockSigstoreVerifier(t), + HttpClient: &http.Client{Transport: fakeHTTP}, + BaseRepo: baseRepo, + } + + ec, err := attestation.NewEnforcementCriteria(opts) + require.NoError(t, err) + opts.EC = ec + + err = verifyRun(opts) + require.NoError(t, err) +} + +func Test_verifyRun_NoAttestation(t *testing.T) { + ios, _, _, _ := iostreams.Test() + 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) + + opts := &attestation.AttestOptions{ + TagName: tagName, + Repo: "owner/repo", + Owner: "owner", + Limit: 10, + Logger: io.NewHandler(ios), + APIClient: api.NewFailTestClient(), + SigstoreVerifier: verification.NewMockSigstoreVerifier(t), + HttpClient: &http.Client{Transport: fakeHTTP}, + BaseRepo: baseRepo, + } + + ec, err := attestation.NewEnforcementCriteria(opts) + require.NoError(t, err) + opts.EC = ec + + err = verifyRun(opts) + require.Error(t, err, "failed to fetch attestations from owner/repo") +} From 3b17318ee48dc59497f1703c5787262284e9d9f5 Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Fri, 30 May 2025 08:31:07 -0700 Subject: [PATCH 15/31] fix test --- .../test/data/{a.zip => github_release_artifact.zip} | 0 pkg/cmd/release/verify-asset/verify-asset.go | 10 ++++++---- pkg/cmd/release/verify-asset/verify-asset_test.go | 10 +++++----- pkg/cmd/release/verify/verify.go | 9 +++++---- 4 files changed, 16 insertions(+), 13 deletions(-) rename pkg/cmd/attestation/test/data/{a.zip => github_release_artifact.zip} (100%) diff --git a/pkg/cmd/attestation/test/data/a.zip b/pkg/cmd/attestation/test/data/github_release_artifact.zip similarity index 100% rename from pkg/cmd/attestation/test/data/a.zip rename to pkg/cmd/attestation/test/data/github_release_artifact.zip diff --git a/pkg/cmd/release/verify-asset/verify-asset.go b/pkg/cmd/release/verify-asset/verify-asset.go index 0c4443d04..c87fc8e65 100644 --- a/pkg/cmd/release/verify-asset/verify-asset.go +++ b/pkg/cmd/release/verify-asset/verify-asset.go @@ -89,11 +89,17 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.AttestOptions) return err } + // Avoid creating a Sigstore verifier if the runF function is provided for testing purposes + if runF != nil { + return runF(opts) + } + config := verification.SigstoreConfig{ Logger: opts.Logger, NoPublicGood: true, TrustDomain: td, } + sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(config) if err != nil { opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to create Sigstore verifier")) @@ -103,10 +109,6 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.AttestOptions) opts.SigstoreVerifier = sigstoreVerifier opts.EC = ec - if runF != nil { - return runF(opts) - } - return verifyAssetRun(opts) }, } diff --git a/pkg/cmd/release/verify-asset/verify-asset_test.go b/pkg/cmd/release/verify-asset/verify-asset_test.go index eb333fc06..784c43e1e 100644 --- a/pkg/cmd/release/verify-asset/verify-asset_test.go +++ b/pkg/cmd/release/verify-asset/verify-asset_test.go @@ -30,15 +30,15 @@ func TestNewCmdVerifyAsset_Args(t *testing.T) { }{ { name: "valid args", - args: []string{"v1.2.3", "../../attestation/test/data/a.zip"}, + args: []string{"v1.2.3", "../../attestation/test/data/github_release_artifact.zip"}, wantTag: "v1.2.3", - wantFile: "../../attestation/test/data/a.zip", + wantFile: "../../attestation/test/data/github_release_artifact.zip", }, { name: "valid flag with no tag", - args: []string{"../../attestation/test/data/a.zip"}, - wantFile: "../../attestation/test/data/a.zip", + args: []string{"../../attestation/test/data/github_release_artifact.zip"}, + wantFile: "../../attestation/test/data/github_release_artifact.zip", }, { name: "no args", @@ -107,7 +107,7 @@ func Test_verifyAssetRun_Success(t *testing.T) { opts := &attestation.AttestOptions{ TagName: tagName, - AssetFilePath: "../../attestation/test/data/a.zip", + AssetFilePath: "../../attestation/test/data/github_release_artifact.zip", Repo: "owner/repo", Owner: "owner", Limit: 10, diff --git a/pkg/cmd/release/verify/verify.go b/pkg/cmd/release/verify/verify.go index 76a5cd773..d58628725 100644 --- a/pkg/cmd/release/verify/verify.go +++ b/pkg/cmd/release/verify/verify.go @@ -83,6 +83,11 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.AttestOptions) erro return err } + // Avoid creating a Sigstore verifier if the runF function is provided for testing purposes + if runF != nil { + return runF(opts) + } + config := verification.SigstoreConfig{ Logger: opts.Logger, NoPublicGood: true, @@ -98,10 +103,6 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.AttestOptions) erro opts.SigstoreVerifier = sigstoreVerifier opts.EC = ec - if runF != nil { - return runF(opts) - } - return verifyRun(opts) }, } From 8e6ed6eb38b2c51213e45605b82e7fc0738beb62 Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Fri, 30 May 2025 09:30:05 -0700 Subject: [PATCH 16/31] improve test --- .../test/data/github_release_artifact.zip | 2 +- .../data/github_release_artifact_invalid.zip | 14 ++++ pkg/cmd/release/attestation/options.go | 11 --- pkg/cmd/release/attestation/options_test.go | 5 +- pkg/cmd/release/verify-asset/verify-asset.go | 4 +- .../release/verify-asset/verify-asset_test.go | 77 ++++++++++++++++++- pkg/cmd/release/verify/verify.go | 7 ++ pkg/cmd/release/verify/verify_test.go | 40 +++++++++- 8 files changed, 139 insertions(+), 21 deletions(-) create mode 100644 pkg/cmd/attestation/test/data/github_release_artifact_invalid.zip diff --git a/pkg/cmd/attestation/test/data/github_release_artifact.zip b/pkg/cmd/attestation/test/data/github_release_artifact.zip index f4595ef44..934302cd9 100644 --- a/pkg/cmd/attestation/test/data/github_release_artifact.zip +++ b/pkg/cmd/attestation/test/data/github_release_artifact.zip @@ -1,4 +1,4 @@ -a # frozen_string_literal: true +# frozen_string_literal: true source "https://rubygems.org" diff --git a/pkg/cmd/attestation/test/data/github_release_artifact_invalid.zip b/pkg/cmd/attestation/test/data/github_release_artifact_invalid.zip new file mode 100644 index 000000000..26b414dbc --- /dev/null +++ b/pkg/cmd/attestation/test/data/github_release_artifact_invalid.zip @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +source "https://rubygems.pkg.github.com/github" do + gem "entitlements-aad-plugin", "~> 1.0" + gem "entitlements-app", "~> 1.2" + gem "entitlements-github-plugin", "~> 1.2" + gem "entitlements-gitrepo-auditor-plugin", "~> 1.0" + gem "entitlements-jit-github-plugin", "~> 1.0" + gem "entitlements-lib", "~> 0.2" + gem "entitlements-stafftools-plugin", "~> 1.0" +end + diff --git a/pkg/cmd/release/attestation/options.go b/pkg/cmd/release/attestation/options.go index 9dd84647e..7140c4f33 100644 --- a/pkg/cmd/release/attestation/options.go +++ b/pkg/cmd/release/attestation/options.go @@ -10,7 +10,6 @@ import ( "github.com/cli/cli/v2/internal/ghinstance" "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/oci" "github.com/cli/cli/v2/pkg/cmd/attestation/io" "github.com/cli/cli/v2/pkg/cmd/attestation/verification" "github.com/cli/cli/v2/pkg/cmdutil" @@ -27,22 +26,12 @@ type AttestOptions struct { Exporter cmdutil.Exporter TagName string TrustedRoot string - DigestAlgorithm string Limit int - OIDCIssuer string Owner string PredicateType string Repo string - SAN string - SANRegex string - SignerDigest string - SignerRepo string - SignerWorkflow string - SourceDigest string - SourceRef string APIClient api.Client Logger *io.Handler - OCIClient oci.Client SigstoreVerifier verification.SigstoreVerifier Hostname string EC verification.EnforcementCriteria diff --git a/pkg/cmd/release/attestation/options_test.go b/pkg/cmd/release/attestation/options_test.go index 89d260199..125723b17 100644 --- a/pkg/cmd/release/attestation/options_test.go +++ b/pkg/cmd/release/attestation/options_test.go @@ -7,9 +7,8 @@ import ( func TestAttestOptions_AreFlagsValid_Valid(t *testing.T) { opts := &AttestOptions{ - Repo: "owner/repo", - SignerRepo: "signer/repo", - Limit: 10, + Repo: "owner/repo", + Limit: 10, } if err := opts.AreFlagsValid(); err != nil { t.Errorf("expected no error, got %v", err) diff --git a/pkg/cmd/release/verify-asset/verify-asset.go b/pkg/cmd/release/verify-asset/verify-asset.go index c87fc8e65..3845ada9b 100644 --- a/pkg/cmd/release/verify-asset/verify-asset.go +++ b/pkg/cmd/release/verify-asset/verify-asset.go @@ -3,6 +3,7 @@ package verifyasset import ( "context" "errors" + "fmt" "path/filepath" "github.com/cli/cli/v2/pkg/cmd/attestation/auth" @@ -95,6 +96,7 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.AttestOptions) } config := verification.SigstoreConfig{ + HttpClient: opts.HttpClient, Logger: opts.Logger, NoPublicGood: true, TrustDomain: td, @@ -172,7 +174,7 @@ func verifyAssetRun(opts *attestation.AttestOptions) error { if len(filteredAttestations) == 0 { opts.Logger.Printf(opts.Logger.ColorScheme.Red("Release %s does not contain %s (%s)\n"), opts.TagName, opts.AssetFilePath, fileDigest.DigestWithAlg()) - return nil + return fmt.Errorf("no attestations found for %s in release %s", fileName, opts.TagName) } opts.Logger.Printf("Loaded %s from GitHub API\n", text.Pluralize(len(filteredAttestations), "attestation")) diff --git a/pkg/cmd/release/verify-asset/verify-asset_test.go b/pkg/cmd/release/verify-asset/verify-asset_test.go index 784c43e1e..2a26dc6d3 100644 --- a/pkg/cmd/release/verify-asset/verify-asset_test.go +++ b/pkg/cmd/release/verify-asset/verify-asset_test.go @@ -95,7 +95,7 @@ func TestNewCmdVerifyAsset_Args(t *testing.T) { func Test_verifyAssetRun_Success(t *testing.T) { ios, _, _, _ := iostreams.Test() - tagName := "v1.2.3" + tagName := "v5" fakeHTTP := &httpmock.Registry{} defer fakeHTTP.Verify(t) @@ -114,12 +114,81 @@ func Test_verifyAssetRun_Success(t *testing.T) { Logger: io.NewHandler(ios), APIClient: api.NewTestClient(), SigstoreVerifier: verification.NewMockSigstoreVerifier(t), + PredicateType: attestation.ReleasePredicateType, + HttpClient: &http.Client{Transport: fakeHTTP}, + BaseRepo: baseRepo, + } + + ec, err := attestation.NewEnforcementCriteria(opts) + require.NoError(t, err) + opts.EC = ec + + err = verifyAssetRun(opts) + require.NoError(t, err) +} + +func Test_verifyAssetRun_Failed_With_Wrong_tag(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) + + opts := &attestation.AttestOptions{ + TagName: tagName, + AssetFilePath: "../../attestation/test/data/github_release_artifact.zip", + Repo: "owner/repo", + Owner: "owner", + Limit: 10, + Logger: io.NewHandler(ios), + APIClient: api.NewTestClient(), + SigstoreVerifier: verification.NewMockSigstoreVerifier(t), + PredicateType: attestation.ReleasePredicateType, + HttpClient: &http.Client{Transport: fakeHTTP}, + BaseRepo: baseRepo, + } + + ec, err := attestation.NewEnforcementCriteria(opts) + require.NoError(t, err) + opts.EC = ec + + err = verifyAssetRun(opts) + require.Error(t, err, "no attestations found for github_release_artifact.zip in release v1") +} + +func Test_verifyAssetRun_Failed_With_Invalid_Artifact(t *testing.T) { + ios, _, _, _ := iostreams.Test() + 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) + + opts := &attestation.AttestOptions{ + TagName: tagName, + AssetFilePath: "../../attestation/test/data/github_release_artifact_invalid.zip", + Repo: "owner/repo", + Owner: "owner", + Limit: 10, + Logger: io.NewHandler(ios), + APIClient: api.NewTestClient(), + SigstoreVerifier: verification.NewMockSigstoreVerifier(t), + PredicateType: attestation.ReleasePredicateType, HttpClient: &http.Client{Transport: fakeHTTP}, BaseRepo: baseRepo, } err = verifyAssetRun(opts) - require.NoError(t, err) + require.Error(t, err, "no attestations found for github_release_artifact_invalid.zip in release v1.2.3") } func Test_verifyAssetRun_NoAttestation(t *testing.T) { @@ -133,7 +202,9 @@ func Test_verifyAssetRun_NoAttestation(t *testing.T) { IO: ios, APIClient: api.NewTestClient(), SigstoreVerifier: verification.NewMockSigstoreVerifier(t), - EC: verification.EnforcementCriteria{}, + PredicateType: attestation.ReleasePredicateType, + + EC: verification.EnforcementCriteria{}, } err := verifyAssetRun(opts) diff --git a/pkg/cmd/release/verify/verify.go b/pkg/cmd/release/verify/verify.go index d58628725..c6579f825 100644 --- a/pkg/cmd/release/verify/verify.go +++ b/pkg/cmd/release/verify/verify.go @@ -3,6 +3,7 @@ package verify import ( "context" "errors" + "fmt" v1 "github.com/in-toto/attestation/go/v1" "google.golang.org/protobuf/encoding/protojson" @@ -89,6 +90,7 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.AttestOptions) erro } config := verification.SigstoreConfig{ + HttpClient: opts.HttpClient, Logger: opts.Logger, NoPublicGood: true, TrustDomain: td, @@ -148,6 +150,11 @@ func verifyRun(opts *attestation.AttestOptions) error { return err } + if len(filteredAttestations) == 0 { + opts.Logger.Printf(opts.Logger.ColorScheme.Red("✗ No attestations found for release %s in %s\n"), opts.TagName, opts.Repo) + return fmt.Errorf("no attestations found for release %s in %s", opts.TagName, opts.Repo) + } + opts.Logger.Printf("Loaded %s from GitHub API\n", text.Pluralize(len(filteredAttestations), "attestation")) // Verify attestations diff --git a/pkg/cmd/release/verify/verify_test.go b/pkg/cmd/release/verify/verify_test.go index 71a282aa2..53078f450 100644 --- a/pkg/cmd/release/verify/verify_test.go +++ b/pkg/cmd/release/verify/verify_test.go @@ -79,7 +79,7 @@ func TestNewCmdVerify_Args(t *testing.T) { func Test_verifyRun_Success(t *testing.T) { ios, _, _, _ := iostreams.Test() - tagName := "v1.2.3" + tagName := "v5" fakeHTTP := &httpmock.Registry{} defer fakeHTTP.Verify(t) @@ -99,6 +99,7 @@ func Test_verifyRun_Success(t *testing.T) { SigstoreVerifier: verification.NewMockSigstoreVerifier(t), HttpClient: &http.Client{Transport: fakeHTTP}, BaseRepo: baseRepo, + PredicateType: attestation.ReleasePredicateType, } ec, err := attestation.NewEnforcementCriteria(opts) @@ -109,7 +110,41 @@ func Test_verifyRun_Success(t *testing.T) { require.NoError(t, err) } -func Test_verifyRun_NoAttestation(t *testing.T) { +func Test_verifyRun_Failed_With_Invalid_Tag(t *testing.T) { + ios, _, _, _ := iostreams.Test() + 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) + + opts := &attestation.AttestOptions{ + TagName: tagName, + Repo: "owner/repo", + Owner: "owner", + Limit: 10, + Logger: io.NewHandler(ios), + APIClient: api.NewFailTestClient(), + SigstoreVerifier: verification.NewMockSigstoreVerifier(t), + PredicateType: attestation.ReleasePredicateType, + + HttpClient: &http.Client{Transport: fakeHTTP}, + BaseRepo: baseRepo, + } + + ec, err := attestation.NewEnforcementCriteria(opts) + require.NoError(t, err) + opts.EC = ec + + err = verifyRun(opts) + require.Error(t, err, "failed to fetch attestations from owner/repo") +} + +func Test_verifyRun_Failed_NoAttestation(t *testing.T) { ios, _, _, _ := iostreams.Test() tagName := "v1.2.3" @@ -131,6 +166,7 @@ func Test_verifyRun_NoAttestation(t *testing.T) { SigstoreVerifier: verification.NewMockSigstoreVerifier(t), HttpClient: &http.Client{Transport: fakeHTTP}, BaseRepo: baseRepo, + PredicateType: attestation.ReleasePredicateType, } ec, err := attestation.NewEnforcementCriteria(opts) From e00e1c414b1b1f5e1fb1be571158c1e228b7b986 Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Fri, 30 May 2025 09:46:46 -0700 Subject: [PATCH 17/31] clean the path --- pkg/cmd/release/verify-asset/verify-asset.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/cmd/release/verify-asset/verify-asset.go b/pkg/cmd/release/verify-asset/verify-asset.go index 3845ada9b..f7e651c6b 100644 --- a/pkg/cmd/release/verify-asset/verify-asset.go +++ b/pkg/cmd/release/verify-asset/verify-asset.go @@ -111,6 +111,8 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.AttestOptions) opts.SigstoreVerifier = sigstoreVerifier opts.EC = ec + opts.Clean() + return verifyAssetRun(opts) }, } From 6d90ad6a2db2e0bac4e4dcb6b1af3f5b5665c67d Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Fri, 30 May 2025 09:58:23 -0700 Subject: [PATCH 18/31] clean the path --- pkg/cmd/release/verify-asset/verify-asset_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/release/verify-asset/verify-asset_test.go b/pkg/cmd/release/verify-asset/verify-asset_test.go index 2a26dc6d3..81cf73551 100644 --- a/pkg/cmd/release/verify-asset/verify-asset_test.go +++ b/pkg/cmd/release/verify-asset/verify-asset_test.go @@ -122,7 +122,7 @@ func Test_verifyAssetRun_Success(t *testing.T) { ec, err := attestation.NewEnforcementCriteria(opts) require.NoError(t, err) opts.EC = ec - + opts.Clean() err = verifyAssetRun(opts) require.NoError(t, err) } From 2312cfb1460f68366cfd9cb92c1a97e720153cc7 Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Fri, 30 May 2025 10:07:18 -0700 Subject: [PATCH 19/31] clean the path --- pkg/cmd/release/verify-asset/verify-asset_test.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/release/verify-asset/verify-asset_test.go b/pkg/cmd/release/verify-asset/verify-asset_test.go index 81cf73551..1760d4a05 100644 --- a/pkg/cmd/release/verify-asset/verify-asset_test.go +++ b/pkg/cmd/release/verify-asset/verify-asset_test.go @@ -7,6 +7,7 @@ import ( "github.com/cli/cli/v2/pkg/cmd/attestation/api" "github.com/cli/cli/v2/pkg/cmd/attestation/io" + "github.com/cli/cli/v2/pkg/cmd/attestation/test" "github.com/cli/cli/v2/pkg/cmd/attestation/verification" "github.com/cli/cli/v2/pkg/cmd/release/attestation" "github.com/cli/cli/v2/pkg/cmdutil" @@ -107,7 +108,7 @@ func Test_verifyAssetRun_Success(t *testing.T) { opts := &attestation.AttestOptions{ TagName: tagName, - AssetFilePath: "../../attestation/test/data/github_release_artifact.zip", + AssetFilePath: test.NormalizeRelativePath("../../attestation/test/data/github_release_artifact.zip"), Repo: "owner/repo", Owner: "owner", Limit: 10, @@ -141,7 +142,7 @@ func Test_verifyAssetRun_Failed_With_Wrong_tag(t *testing.T) { opts := &attestation.AttestOptions{ TagName: tagName, - AssetFilePath: "../../attestation/test/data/github_release_artifact.zip", + AssetFilePath: test.NormalizeRelativePath("../../attestation/test/data/github_release_artifact.zip"), Repo: "owner/repo", Owner: "owner", Limit: 10, @@ -175,7 +176,7 @@ func Test_verifyAssetRun_Failed_With_Invalid_Artifact(t *testing.T) { opts := &attestation.AttestOptions{ TagName: tagName, - AssetFilePath: "../../attestation/test/data/github_release_artifact_invalid.zip", + AssetFilePath: test.NormalizeRelativePath("../../attestation/test/data/github_release_artifact.zip"), Repo: "owner/repo", Owner: "owner", Limit: 10, From 53cb90aecaf2397359bab99167c64975a96982f5 Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Fri, 30 May 2025 11:16:04 -0700 Subject: [PATCH 20/31] debug windows env --- .github/workflows/go.yml | 5 ++--- pkg/cmd/release/verify-asset/verify-asset_test.go | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 9b22701a7..903c35db0 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -9,7 +9,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest, macos-latest] + os: [windows-latest] runs-on: ${{ matrix.os }} steps: @@ -25,8 +25,7 @@ jobs: run: go mod download - name: Run unit and integration tests - run: go test -race -tags=integration ./... - + run: go test -v -race -tags=integration ./cmd/release - name: Build run: go build -v ./cmd/gh diff --git a/pkg/cmd/release/verify-asset/verify-asset_test.go b/pkg/cmd/release/verify-asset/verify-asset_test.go index 1760d4a05..c732bfd86 100644 --- a/pkg/cmd/release/verify-asset/verify-asset_test.go +++ b/pkg/cmd/release/verify-asset/verify-asset_test.go @@ -164,7 +164,7 @@ func Test_verifyAssetRun_Failed_With_Wrong_tag(t *testing.T) { func Test_verifyAssetRun_Failed_With_Invalid_Artifact(t *testing.T) { ios, _, _, _ := iostreams.Test() - tagName := "v1.2.3" + tagName := "v5" fakeHTTP := &httpmock.Registry{} defer fakeHTTP.Verify(t) From b423edff7b478377790f913d7ab244fe8617116f Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Fri, 30 May 2025 11:51:33 -0700 Subject: [PATCH 21/31] debug windows env --- .github/workflows/go.yml | 28 +------------------- pkg/cmd/release/verify-asset/verify-asset.go | 2 +- 2 files changed, 2 insertions(+), 28 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 903c35db0..ef171441b 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -25,32 +25,6 @@ jobs: run: go mod download - name: Run unit and integration tests - run: go test -v -race -tags=integration ./cmd/release + run: go test -v ./pkg/cmd/release/verify-asset/... - name: Build run: go build -v ./cmd/gh - - integration-tests: - env: - GH_TOKEN: ${{ github.token }} - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, windows-latest, macos-latest] - runs-on: ${{ matrix.os }} - - steps: - - name: Check out code - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: "go.mod" - - - name: Build executable - run: make - - - name: Run attestation command set integration tests - shell: bash - run: | - ./test/integration/attestation-cmd/run-all-tests.sh "${{ matrix.os }}" diff --git a/pkg/cmd/release/verify-asset/verify-asset.go b/pkg/cmd/release/verify-asset/verify-asset.go index f7e651c6b..e2b45f7f1 100644 --- a/pkg/cmd/release/verify-asset/verify-asset.go +++ b/pkg/cmd/release/verify-asset/verify-asset.go @@ -176,7 +176,7 @@ func verifyAssetRun(opts *attestation.AttestOptions) error { if len(filteredAttestations) == 0 { opts.Logger.Printf(opts.Logger.ColorScheme.Red("Release %s does not contain %s (%s)\n"), opts.TagName, opts.AssetFilePath, fileDigest.DigestWithAlg()) - return fmt.Errorf("no attestations found for %s in release %s", fileName, opts.TagName) + return fmt.Errorf("release %s does not contain %s (%s)", opts.TagName, opts.AssetFilePath, fileDigest.DigestWithAlg()) } opts.Logger.Printf("Loaded %s from GitHub API\n", text.Pluralize(len(filteredAttestations), "attestation")) From 4b1108734c1f4792e6e63eda71dcdc59ac393e16 Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Fri, 30 May 2025 12:18:23 -0700 Subject: [PATCH 22/31] debug windows env --- .github/workflows/go.yml | 2 +- pkg/cmd/attestation/artifact/file_test.go | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 pkg/cmd/attestation/artifact/file_test.go diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index ef171441b..8a4ead221 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -25,6 +25,6 @@ jobs: run: go mod download - name: Run unit and integration tests - run: go test -v ./pkg/cmd/release/verify-asset/... + run: go test -v ./pkg/cmd/release/verify-asset ./pkg/cmd/attestation/artifact - name: Build run: go build -v ./cmd/gh diff --git a/pkg/cmd/attestation/artifact/file_test.go b/pkg/cmd/attestation/artifact/file_test.go new file mode 100644 index 000000000..ed9e7049a --- /dev/null +++ b/pkg/cmd/attestation/artifact/file_test.go @@ -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 := "f7165848f9f5ddc578d7adbd1f566a394169385c73bd88bf60df7e759db8e08d" + + // 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) +} From 96db923f19a72d954647f4b3564a220e58e06fe5 Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Fri, 30 May 2025 12:33:40 -0700 Subject: [PATCH 23/31] revert the workflow --- .github/workflows/go.yml | 31 +++++++++++++++++++++++++-- pkg/cmd/release/verify/verify_test.go | 2 +- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 8a4ead221..4be1a55d2 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -9,7 +9,7 @@ jobs: strategy: fail-fast: false matrix: - os: [windows-latest] + os: [ubuntu-latest, windows-latest, macos-latest] runs-on: ${{ matrix.os }} steps: @@ -25,6 +25,33 @@ jobs: run: go mod download - name: Run unit and integration tests - run: go test -v ./pkg/cmd/release/verify-asset ./pkg/cmd/attestation/artifact + run: go test -race -tags=integration ./... + - name: Build run: go build -v ./cmd/gh + + integration-tests: + env: + GH_TOKEN: ${{ github.token }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: "go.mod" + + - name: Build executable + run: make + + - name: Run attestation command set integration tests + shell: bash + run: | + ./test/integration/attestation-cmd/run-all-tests.sh "${{ matrix.os }}" diff --git a/pkg/cmd/release/verify/verify_test.go b/pkg/cmd/release/verify/verify_test.go index 53078f450..22eaba54a 100644 --- a/pkg/cmd/release/verify/verify_test.go +++ b/pkg/cmd/release/verify/verify_test.go @@ -14,8 +14,8 @@ import ( "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" - "gotest.tools/v3/assert" ) func TestNewCmdVerify_Args(t *testing.T) { From df87b1559806c93910e579b1b0ac07cca8202f8f Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Fri, 30 May 2025 12:53:19 -0700 Subject: [PATCH 24/31] clean the code --- .github/workflows/go.yml | 2 +- go.mod | 1 - go.sum | 6 --- pkg/cmd/release/shared/fetch.go | 4 +- pkg/cmd/release/verify-asset/verify-asset.go | 41 +++++++++++--------- pkg/cmd/release/verify/verify.go | 36 +++++++++-------- 6 files changed, 45 insertions(+), 45 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 4be1a55d2..9b22701a7 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -29,7 +29,7 @@ jobs: - name: Build run: go build -v ./cmd/gh - + integration-tests: env: GH_TOKEN: ${{ github.token }} diff --git a/go.mod b/go.mod index bb50fba67..a4c973df1 100644 --- a/go.mod +++ b/go.mod @@ -60,7 +60,6 @@ require ( google.golang.org/protobuf v1.36.6 gopkg.in/h2non/gock.v1 v1.1.2 gopkg.in/yaml.v3 v3.0.1 - gotest.tools/v3 v3.0.3 ) require ( diff --git a/go.sum b/go.sum index 1835a69b2..718e0ca67 100644 --- a/go.sum +++ b/go.sum @@ -245,7 +245,6 @@ github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/certificate-transparency-go v1.3.1 h1:akbcTfQg0iZlANZLn0L9xOeWtyCIdeoYhKrqi5iH3Go= github.com/google/certificate-transparency-go v1.3.1/go.mod h1:gg+UQlx6caKEDQ9EElFOujyxEQEfOiQzAt6782Bvi8k= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-containerregistry v0.20.3 h1:oNx7IdTI936V8CQRveCjaxOiegWwvM7kqkbXTpyiovI= @@ -411,7 +410,6 @@ github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNH github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -485,7 +483,6 @@ github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= -github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= @@ -565,7 +562,6 @@ golang.org/x/exp v0.0.0-20240531132922-fd00a4e0eefc/go.mod h1:XtvwrStGgqGPLc4cjQ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= @@ -603,13 +599,11 @@ golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE= golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/api v0.230.0 h1:2u1hni3E+UXAXrONrrkfWpi/V6cyKVAbfGVeGtC3OxM= google.golang.org/api v0.230.0/go.mod h1:aqvtoMk7YkiXx+6U12arQFExiRV9D/ekvMCwCd/TksQ= google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb h1:ITgPrl429bc6+2ZraNSzMDk3I95nmQln2fuPstKwFDE= diff --git a/pkg/cmd/release/shared/fetch.go b/pkg/cmd/release/shared/fetch.go index 6d6d16473..322f33c17 100644 --- a/pkg/cmd/release/shared/fetch.go +++ b/pkg/cmd/release/shared/fetch.go @@ -146,7 +146,7 @@ func FetchRefSHA(ctx context.Context, httpClient *http.Client, repo ghrepo.Inter } defer resp.Body.Close() - if resp.StatusCode == 404 { + if resp.StatusCode == http.StatusNotFound { _, _ = io.Copy(io.Discard, resp.Body) // ErrRefNotFound return "", ErrReleaseNotFound @@ -248,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 { diff --git a/pkg/cmd/release/verify-asset/verify-asset.go b/pkg/cmd/release/verify-asset/verify-asset.go index e2b45f7f1..8890d8a0d 100644 --- a/pkg/cmd/release/verify-asset/verify-asset.go +++ b/pkg/cmd/release/verify-asset/verify-asset.go @@ -84,35 +84,23 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.AttestOptions) return err } + opts.TrustedRoot = td + ec, err := attestation.NewEnforcementCriteria(opts) if err != nil { opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to build policy information")) return err } + opts.EC = ec + + opts.Clean() + // Avoid creating a Sigstore verifier if the runF function is provided for testing purposes if runF != nil { return runF(opts) } - config := verification.SigstoreConfig{ - HttpClient: opts.HttpClient, - Logger: opts.Logger, - NoPublicGood: true, - TrustDomain: td, - } - - sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(config) - if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to create Sigstore verifier")) - return err - } - - opts.SigstoreVerifier = sigstoreVerifier - opts.EC = ec - - opts.Clean() - return verifyAssetRun(opts) }, } @@ -124,6 +112,23 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.AttestOptions) func verifyAssetRun(opts *attestation.AttestOptions) error { ctx := context.Background() + if opts.SigstoreVerifier == nil { + config := verification.SigstoreConfig{ + HttpClient: opts.HttpClient, + Logger: opts.Logger, + NoPublicGood: true, + TrustDomain: opts.TrustedRoot, + } + + sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(config) + if err != nil { + opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to create Sigstore verifier")) + return err + } + + opts.SigstoreVerifier = sigstoreVerifier + } + if opts.TagName == "" { release, err := shared.FetchLatestRelease(ctx, opts.HttpClient, opts.BaseRepo) if err != nil { diff --git a/pkg/cmd/release/verify/verify.go b/pkg/cmd/release/verify/verify.go index c6579f825..2b0fd8907 100644 --- a/pkg/cmd/release/verify/verify.go +++ b/pkg/cmd/release/verify/verify.go @@ -77,34 +77,19 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.AttestOptions) erro opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to get trust domain")) return err } + opts.TrustedRoot = td ec, err := attestation.NewEnforcementCriteria(opts) if err != nil { opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to build policy information")) return err } + opts.EC = ec // Avoid creating a Sigstore verifier if the runF function is provided for testing purposes if runF != nil { return runF(opts) } - - config := verification.SigstoreConfig{ - HttpClient: opts.HttpClient, - Logger: opts.Logger, - NoPublicGood: true, - TrustDomain: td, - } - - sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(config) - if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to create Sigstore verifier")) - return err - } - - opts.SigstoreVerifier = sigstoreVerifier - opts.EC = ec - return verifyRun(opts) }, } @@ -116,6 +101,23 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.AttestOptions) erro func verifyRun(opts *attestation.AttestOptions) error { ctx := context.Background() + if opts.SigstoreVerifier == nil { + config := verification.SigstoreConfig{ + HttpClient: opts.HttpClient, + Logger: opts.Logger, + NoPublicGood: true, + TrustDomain: opts.TrustedRoot, + } + + sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(config) + if err != nil { + opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to create Sigstore verifier")) + return err + } + + opts.SigstoreVerifier = sigstoreVerifier + } + if opts.TagName == "" { release, err := shared.FetchLatestRelease(ctx, opts.HttpClient, opts.BaseRepo) if err != nil { From 699ccc1a9488c07154350c0d9698eb9088a467e3 Mon Sep 17 00:00:00 2001 From: Brian DeHamer Date: Fri, 30 May 2025 13:19:41 -0700 Subject: [PATCH 25/31] empty commit From 56f887709751f4bb49e1d2b630fb2bb93cf8931c Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Fri, 30 May 2025 13:31:23 -0700 Subject: [PATCH 26/31] update the artifact and bundle for testing --- pkg/cmd/attestation/artifact/file_test.go | 2 +- .../test/data/github_release_artifact.zip | Bin 797 -> 169 bytes .../data/github_release_artifact_invalid.zip | Bin 427 -> 188 bytes .../test/data/github_release_bundle.json | 6 +++--- .../release/verify-asset/verify-asset_test.go | 10 +++++----- pkg/cmd/release/verify/verify_test.go | 2 +- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/pkg/cmd/attestation/artifact/file_test.go b/pkg/cmd/attestation/artifact/file_test.go index ed9e7049a..54768e93e 100644 --- a/pkg/cmd/attestation/artifact/file_test.go +++ b/pkg/cmd/attestation/artifact/file_test.go @@ -12,7 +12,7 @@ func Test_digestLocalFileArtifact_withRealZip(t *testing.T) { artifactPath := test.NormalizeRelativePath("../../attestation/test/data/github_release_artifact.zip") // Calculate expected digest using the same algorithm as the function under test - expectedDigest := "f7165848f9f5ddc578d7adbd1f566a394169385c73bd88bf60df7e759db8e08d" + expectedDigest := "e15b593c6ab8d7725a3cc82226ef816cac6bf9c70eed383bd459295cc65f5ec3" // Call the function under test artifact, err := digestLocalFileArtifact(artifactPath, "sha256") diff --git a/pkg/cmd/attestation/test/data/github_release_artifact.zip b/pkg/cmd/attestation/test/data/github_release_artifact.zip index 934302cd93449f12d999ab9fb7f4e28fb778bcf2..a4d222eb9e344b4038ce0a078f032f652aa823fe 100644 GIT binary patch literal 169 zcmWIWW@h1H0DunGxpHmUab_+LQgP!yV;!$Ppl^V zg!T{lt`BUgTsfF)}>=MvczU_d*!Uc zg0YLAacGlE9-7ND3au*ohz4+9$hmn z-dz&YF*645Ojmm;XPU&M>pCI&I8g%HswgaB4s2h+2|R}Da-%(sjvdivcVFEi`%J3)UDa~SuIEX pFaO-tE&@=E)abT^KmPmd&Bz^H6Fii78V(=gdnjNu(ogtx@^7uo@>&1@ diff --git a/pkg/cmd/attestation/test/data/github_release_artifact_invalid.zip b/pkg/cmd/attestation/test/data/github_release_artifact_invalid.zip index 26b414dbce27d457432613f45174596f7d158eaf..fcdda88fe075d6013f3426d3f40603862d314360 100644 GIT binary patch literal 188 zcmYdH3GilS;bH)R>dg21_eND!{9efHx}}NF^f> M`T=QI5Ql*a02C=HfdBvi literal 427 zcmaKmF%H5o3`O^x!U`)*3u6baQl->Q18E#N&Hw{v2dRjmYME@w@B6<4j7(3AT^9Dy zb-@d=4J`;4R4P;9D}tUxQf}*-OaE{eLWz{f{0{G-XV-zm| z=g-WQwL_IcaX#uJ=+^~KrqSDEN Date: Fri, 30 May 2025 13:43:07 -0700 Subject: [PATCH 27/31] moved to shared lib --- .../{attestation => shared}/attestation.go | 0 .../{attestation => shared}/options.go | 2 +- .../{attestation => shared}/options_test.go | 2 +- .../release/{attestation => shared}/policy.go | 2 +- .../{attestation => shared}/policy_test.go | 2 +- pkg/cmd/release/verify-asset/verify-asset.go | 21 ++++++++--------- .../release/verify-asset/verify-asset_test.go | 14 +++++------ pkg/cmd/release/verify/verify.go | 19 ++++++++------- pkg/cmd/release/verify/verify_test.go | 23 +++++++++---------- 9 files changed, 41 insertions(+), 44 deletions(-) rename pkg/cmd/release/{attestation => shared}/attestation.go (100%) rename pkg/cmd/release/{attestation => shared}/options.go (99%) rename pkg/cmd/release/{attestation => shared}/options_test.go (98%) rename pkg/cmd/release/{attestation => shared}/policy.go (99%) rename pkg/cmd/release/{attestation => shared}/policy_test.go (98%) diff --git a/pkg/cmd/release/attestation/attestation.go b/pkg/cmd/release/shared/attestation.go similarity index 100% rename from pkg/cmd/release/attestation/attestation.go rename to pkg/cmd/release/shared/attestation.go diff --git a/pkg/cmd/release/attestation/options.go b/pkg/cmd/release/shared/options.go similarity index 99% rename from pkg/cmd/release/attestation/options.go rename to pkg/cmd/release/shared/options.go index 7140c4f33..86e8ac78b 100644 --- a/pkg/cmd/release/attestation/options.go +++ b/pkg/cmd/release/shared/options.go @@ -1,4 +1,4 @@ -package attestation +package shared import ( "fmt" diff --git a/pkg/cmd/release/attestation/options_test.go b/pkg/cmd/release/shared/options_test.go similarity index 98% rename from pkg/cmd/release/attestation/options_test.go rename to pkg/cmd/release/shared/options_test.go index 125723b17..7a8fa73dc 100644 --- a/pkg/cmd/release/attestation/options_test.go +++ b/pkg/cmd/release/shared/options_test.go @@ -1,4 +1,4 @@ -package attestation +package shared import ( "errors" diff --git a/pkg/cmd/release/attestation/policy.go b/pkg/cmd/release/shared/policy.go similarity index 99% rename from pkg/cmd/release/attestation/policy.go rename to pkg/cmd/release/shared/policy.go index d7bf0f096..0e3bb322b 100644 --- a/pkg/cmd/release/attestation/policy.go +++ b/pkg/cmd/release/shared/policy.go @@ -1,4 +1,4 @@ -package attestation +package shared import ( "fmt" diff --git a/pkg/cmd/release/attestation/policy_test.go b/pkg/cmd/release/shared/policy_test.go similarity index 98% rename from pkg/cmd/release/attestation/policy_test.go rename to pkg/cmd/release/shared/policy_test.go index 57eab86b2..72cc53c2a 100644 --- a/pkg/cmd/release/attestation/policy_test.go +++ b/pkg/cmd/release/shared/policy_test.go @@ -1,4 +1,4 @@ -package attestation +package shared import ( "testing" diff --git a/pkg/cmd/release/verify-asset/verify-asset.go b/pkg/cmd/release/verify-asset/verify-asset.go index 8890d8a0d..4100d179e 100644 --- a/pkg/cmd/release/verify-asset/verify-asset.go +++ b/pkg/cmd/release/verify-asset/verify-asset.go @@ -14,15 +14,14 @@ import ( "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/attestation" "github.com/cli/cli/v2/pkg/cmd/release/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/spf13/cobra" ) -func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.AttestOptions) error) *cobra.Command { - opts := &attestation.AttestOptions{} +func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*shared.AttestOptions) error) *cobra.Command { + opts := &shared.AttestOptions{} cmd := &cobra.Command{ Use: "verify-asset ", @@ -56,14 +55,14 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.AttestOptions) return err } - *opts = attestation.AttestOptions{ + *opts = shared.AttestOptions{ TagName: opts.TagName, AssetFilePath: opts.AssetFilePath, Repo: baseRepo.RepoOwner() + "/" + baseRepo.RepoName(), APIClient: api.NewLiveClient(httpClient, hostname, logger), Limit: 10, Owner: baseRepo.RepoOwner(), - PredicateType: attestation.ReleasePredicateType, + PredicateType: shared.ReleasePredicateType, Logger: logger, HttpClient: httpClient, BaseRepo: baseRepo, @@ -86,7 +85,7 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.AttestOptions) opts.TrustedRoot = td - ec, err := attestation.NewEnforcementCriteria(opts) + ec, err := shared.NewEnforcementCriteria(opts) if err != nil { opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to build policy information")) return err @@ -109,7 +108,7 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.AttestOptions) return cmd } -func verifyAssetRun(opts *attestation.AttestOptions) error { +func verifyAssetRun(opts *shared.AttestOptions) error { ctx := context.Background() if opts.SigstoreVerifier == nil { @@ -156,7 +155,7 @@ func verifyAssetRun(opts *attestation.AttestOptions) error { opts.Logger.Printf("Resolved %s to %s\n", opts.TagName, releaseRefDigest.DigestWithAlg()) // Attestation fetching - attestations, logMsg, err := attestation.GetAttestations(opts, releaseRefDigest.DigestWithAlg()) + attestations, logMsg, err := shared.GetAttestations(opts, releaseRefDigest.DigestWithAlg()) if err != nil { if errors.Is(err, api.ErrNoAttestationsFound) { opts.Logger.Printf(opts.Logger.ColorScheme.Red("✗ No attestations found for subject %s\n"), releaseRefDigest.DigestWithAlg()) @@ -167,13 +166,13 @@ func verifyAssetRun(opts *attestation.AttestOptions) error { } // Filter attestations by tag - filteredAttestations, err := attestation.FilterAttestationsByTag(attestations, opts.TagName) + filteredAttestations, err := shared.FilterAttestationsByTag(attestations, opts.TagName) if err != nil { opts.Logger.Println(opts.Logger.ColorScheme.Red(err.Error())) return err } - filteredAttestations, err = attestation.FilterAttestationsByFileDigest(filteredAttestations, opts.Repo, opts.TagName, fileDigest.Digest()) + filteredAttestations, err = shared.FilterAttestationsByFileDigest(filteredAttestations, opts.Repo, opts.TagName, fileDigest.Digest()) if err != nil { opts.Logger.Println(opts.Logger.ColorScheme.Red(err.Error())) return err @@ -187,7 +186,7 @@ func verifyAssetRun(opts *attestation.AttestOptions) error { opts.Logger.Printf("Loaded %s from GitHub API\n", text.Pluralize(len(filteredAttestations), "attestation")) // Verify attestations - verified, errMsg, err := attestation.VerifyAttestations(*releaseRefDigest, filteredAttestations, opts.SigstoreVerifier, opts.EC) + verified, errMsg, err := shared.VerifyAttestations(*releaseRefDigest, filteredAttestations, opts.SigstoreVerifier, opts.EC) if err != nil { opts.Logger.Println(opts.Logger.ColorScheme.Red(errMsg)) diff --git a/pkg/cmd/release/verify-asset/verify-asset_test.go b/pkg/cmd/release/verify-asset/verify-asset_test.go index 0976807b1..a85c9066e 100644 --- a/pkg/cmd/release/verify-asset/verify-asset_test.go +++ b/pkg/cmd/release/verify-asset/verify-asset_test.go @@ -9,7 +9,7 @@ import ( "github.com/cli/cli/v2/pkg/cmd/attestation/io" "github.com/cli/cli/v2/pkg/cmd/attestation/test" "github.com/cli/cli/v2/pkg/cmd/attestation/verification" - "github.com/cli/cli/v2/pkg/cmd/release/attestation" + "github.com/cli/cli/v2/pkg/cmd/release/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/stretchr/testify/assert" @@ -17,7 +17,7 @@ import ( "github.com/cli/cli/v2/internal/ghrepo" - "github.com/cli/cli/v2/pkg/cmd/release/shared" + attestation "github.com/cli/cli/v2/pkg/cmd/release/shared" "github.com/cli/cli/v2/pkg/httpmock" ) @@ -72,8 +72,8 @@ func TestNewCmdVerifyAsset_Args(t *testing.T) { }, } - var opts *attestation.AttestOptions - cmd := NewCmdVerifyAsset(f, func(o *attestation.AttestOptions) error { + var opts *shared.AttestOptions + cmd := NewCmdVerifyAsset(f, func(o *shared.AttestOptions) error { opts = o return nil }) @@ -106,7 +106,7 @@ func Test_verifyAssetRun_Success(t *testing.T) { baseRepo, err := ghrepo.FromFullName("owner/repo") require.NoError(t, err) - opts := &attestation.AttestOptions{ + opts := &shared.AttestOptions{ TagName: tagName, AssetFilePath: test.NormalizeRelativePath("../../attestation/test/data/github_release_artifact.zip"), Repo: "owner/repo", @@ -115,12 +115,12 @@ func Test_verifyAssetRun_Success(t *testing.T) { Logger: io.NewHandler(ios), APIClient: api.NewTestClient(), SigstoreVerifier: verification.NewMockSigstoreVerifier(t), - PredicateType: attestation.ReleasePredicateType, + PredicateType: shared.ReleasePredicateType, HttpClient: &http.Client{Transport: fakeHTTP}, BaseRepo: baseRepo, } - ec, err := attestation.NewEnforcementCriteria(opts) + ec, err := shared.NewEnforcementCriteria(opts) require.NoError(t, err) opts.EC = ec opts.Clean() diff --git a/pkg/cmd/release/verify/verify.go b/pkg/cmd/release/verify/verify.go index 2b0fd8907..ff8f7147e 100644 --- a/pkg/cmd/release/verify/verify.go +++ b/pkg/cmd/release/verify/verify.go @@ -14,7 +14,6 @@ import ( "github.com/cli/cli/v2/pkg/cmd/attestation/auth" 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/attestation" "github.com/cli/cli/v2/pkg/cmd/release/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -22,8 +21,8 @@ import ( "github.com/spf13/cobra" ) -func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.AttestOptions) error) *cobra.Command { - opts := &attestation.AttestOptions{} +func NewCmdVerify(f *cmdutil.Factory, runF func(*shared.AttestOptions) error) *cobra.Command { + opts := &shared.AttestOptions{} cmd := &cobra.Command{ Use: "verify []", @@ -52,13 +51,13 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.AttestOptions) erro return err } - *opts = attestation.AttestOptions{ + *opts = shared.AttestOptions{ TagName: opts.TagName, Repo: baseRepo.RepoOwner() + "/" + baseRepo.RepoName(), APIClient: api.NewLiveClient(httpClient, hostname, logger), Limit: 10, Owner: baseRepo.RepoOwner(), - PredicateType: attestation.ReleasePredicateType, + PredicateType: shared.ReleasePredicateType, Logger: logger, HttpClient: httpClient, BaseRepo: baseRepo, @@ -79,7 +78,7 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.AttestOptions) erro } opts.TrustedRoot = td - ec, err := attestation.NewEnforcementCriteria(opts) + ec, err := shared.NewEnforcementCriteria(opts) if err != nil { opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to build policy information")) return err @@ -98,7 +97,7 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.AttestOptions) erro return cmd } -func verifyRun(opts *attestation.AttestOptions) error { +func verifyRun(opts *shared.AttestOptions) error { ctx := context.Background() if opts.SigstoreVerifier == nil { @@ -135,7 +134,7 @@ func verifyRun(opts *attestation.AttestOptions) error { opts.Logger.Printf("Resolved %s to %s\n", opts.TagName, releaseRefDigest.DigestWithAlg()) // Attestation fetching - attestations, logMsg, err := attestation.GetAttestations(opts, releaseRefDigest.DigestWithAlg()) + attestations, logMsg, err := shared.GetAttestations(opts, releaseRefDigest.DigestWithAlg()) if err != nil { if errors.Is(err, api.ErrNoAttestationsFound) { opts.Logger.Printf(opts.Logger.ColorScheme.Red("✗ No attestations found for subject %s\n"), releaseRefDigest.DigestWithAlg()) @@ -146,7 +145,7 @@ func verifyRun(opts *attestation.AttestOptions) error { } // Filter attestations by predicate tag - filteredAttestations, err := attestation.FilterAttestationsByTag(attestations, opts.TagName) + filteredAttestations, err := shared.FilterAttestationsByTag(attestations, opts.TagName) if err != nil { opts.Logger.Println(opts.Logger.ColorScheme.Red(err.Error())) return err @@ -160,7 +159,7 @@ func verifyRun(opts *attestation.AttestOptions) error { opts.Logger.Printf("Loaded %s from GitHub API\n", text.Pluralize(len(filteredAttestations), "attestation")) // Verify attestations - verified, errMsg, err := attestation.VerifyAttestations(*releaseRefDigest, filteredAttestations, opts.SigstoreVerifier, opts.EC) + verified, errMsg, err := shared.VerifyAttestations(*releaseRefDigest, filteredAttestations, opts.SigstoreVerifier, opts.EC) if err != nil { opts.Logger.Println(opts.Logger.ColorScheme.Red(errMsg)) diff --git a/pkg/cmd/release/verify/verify_test.go b/pkg/cmd/release/verify/verify_test.go index 9668a71ff..b0a1c7df5 100644 --- a/pkg/cmd/release/verify/verify_test.go +++ b/pkg/cmd/release/verify/verify_test.go @@ -9,7 +9,6 @@ import ( "github.com/cli/cli/v2/pkg/cmd/attestation/api" "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/attestation" "github.com/cli/cli/v2/pkg/cmd/release/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" @@ -61,8 +60,8 @@ func TestNewCmdVerify_Args(t *testing.T) { }, } - var opts *attestation.AttestOptions - cmd := NewCmdVerify(f, func(o *attestation.AttestOptions) error { + var opts *shared.AttestOptions + cmd := NewCmdVerify(f, func(o *shared.AttestOptions) error { opts = o return nil }) @@ -89,7 +88,7 @@ func Test_verifyRun_Success(t *testing.T) { baseRepo, err := ghrepo.FromFullName("owner/repo") require.NoError(t, err) - opts := &attestation.AttestOptions{ + opts := &shared.AttestOptions{ TagName: tagName, Repo: "owner/repo", Owner: "owner", @@ -99,10 +98,10 @@ func Test_verifyRun_Success(t *testing.T) { SigstoreVerifier: verification.NewMockSigstoreVerifier(t), HttpClient: &http.Client{Transport: fakeHTTP}, BaseRepo: baseRepo, - PredicateType: attestation.ReleasePredicateType, + PredicateType: shared.ReleasePredicateType, } - ec, err := attestation.NewEnforcementCriteria(opts) + ec, err := shared.NewEnforcementCriteria(opts) require.NoError(t, err) opts.EC = ec @@ -122,7 +121,7 @@ func Test_verifyRun_Failed_With_Invalid_Tag(t *testing.T) { baseRepo, err := ghrepo.FromFullName("owner/repo") require.NoError(t, err) - opts := &attestation.AttestOptions{ + opts := &shared.AttestOptions{ TagName: tagName, Repo: "owner/repo", Owner: "owner", @@ -130,13 +129,13 @@ func Test_verifyRun_Failed_With_Invalid_Tag(t *testing.T) { Logger: io.NewHandler(ios), APIClient: api.NewFailTestClient(), SigstoreVerifier: verification.NewMockSigstoreVerifier(t), - PredicateType: attestation.ReleasePredicateType, + PredicateType: shared.ReleasePredicateType, HttpClient: &http.Client{Transport: fakeHTTP}, BaseRepo: baseRepo, } - ec, err := attestation.NewEnforcementCriteria(opts) + ec, err := shared.NewEnforcementCriteria(opts) require.NoError(t, err) opts.EC = ec @@ -156,7 +155,7 @@ func Test_verifyRun_Failed_NoAttestation(t *testing.T) { baseRepo, err := ghrepo.FromFullName("owner/repo") require.NoError(t, err) - opts := &attestation.AttestOptions{ + opts := &shared.AttestOptions{ TagName: tagName, Repo: "owner/repo", Owner: "owner", @@ -166,10 +165,10 @@ func Test_verifyRun_Failed_NoAttestation(t *testing.T) { SigstoreVerifier: verification.NewMockSigstoreVerifier(t), HttpClient: &http.Client{Transport: fakeHTTP}, BaseRepo: baseRepo, - PredicateType: attestation.ReleasePredicateType, + PredicateType: shared.ReleasePredicateType, } - ec, err := attestation.NewEnforcementCriteria(opts) + ec, err := shared.NewEnforcementCriteria(opts) require.NoError(t, err) opts.EC = ec From 5048d586dcc56f2edd1014dce25dede8d5e4b866 Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Fri, 30 May 2025 13:46:54 -0700 Subject: [PATCH 28/31] moved to shared lib --- pkg/cmd/release/shared/attestation.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/release/shared/attestation.go b/pkg/cmd/release/shared/attestation.go index bf2f39a7c..2d859d531 100644 --- a/pkg/cmd/release/shared/attestation.go +++ b/pkg/cmd/release/shared/attestation.go @@ -1,4 +1,4 @@ -package attestation +package shared import ( "errors" From d7d9228609e555b5541088e56c8f25bbbe1177db Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Fri, 30 May 2025 14:08:59 -0700 Subject: [PATCH 29/31] use standardize color roles logic for the logging --- pkg/cmd/release/shared/attestation.go | 8 ++++---- pkg/cmd/release/verify-asset/verify-asset.go | 12 ++++++------ pkg/cmd/release/verify/verify.go | 16 ++++++++-------- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/pkg/cmd/release/shared/attestation.go b/pkg/cmd/release/shared/attestation.go index 2d859d531..a3aa3bea5 100644 --- a/pkg/cmd/release/shared/attestation.go +++ b/pkg/cmd/release/shared/attestation.go @@ -15,7 +15,7 @@ import ( func GetAttestations(o *AttestOptions, sha string) ([]*api.Attestation, string, error) { if o.APIClient == nil { - errMsg := "✗ No APIClient provided" + errMsg := "X No APIClient provided" return nil, errMsg, errors.New(errMsg) } @@ -29,7 +29,7 @@ func GetAttestations(o *AttestOptions, sha string) ([]*api.Attestation, string, attestations, err := o.APIClient.GetByDigest(params) if err != nil { - msg := "✗ Loading attestations from GitHub API failed" + msg := "X Loading attestations from GitHub API failed" return nil, msg, err } pluralAttestation := text.Pluralize(len(attestations), "attestation") @@ -40,13 +40,13 @@ func GetAttestations(o *AttestOptions, sha string) ([]*api.Attestation, string, func VerifyAttestations(art artifact.DigestedArtifact, att []*api.Attestation, sgVerifier verification.SigstoreVerifier, ec verification.EnforcementCriteria) ([]*verification.AttestationProcessingResult, string, error) { sgPolicy, err := buildSigstoreVerifyPolicy(ec, art) if err != nil { - logMsg := "✗ Failed to build Sigstore verification policy" + logMsg := "X Failed to build Sigstore verification policy" return nil, logMsg, err } sigstoreVerified, err := sgVerifier.Verify(att, sgPolicy) if err != nil { - logMsg := "✗ Sigstore verification failed" + logMsg := "X Sigstore verification failed" return nil, logMsg, err } diff --git a/pkg/cmd/release/verify-asset/verify-asset.go b/pkg/cmd/release/verify-asset/verify-asset.go index 4100d179e..260589d11 100644 --- a/pkg/cmd/release/verify-asset/verify-asset.go +++ b/pkg/cmd/release/verify-asset/verify-asset.go @@ -79,7 +79,7 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*shared.AttestOptions) erro RunE: func(cmd *cobra.Command, args []string) error { td, err := opts.APIClient.GetTrustDomain() if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to get trust domain")) + opts.Logger.Println(opts.Logger.ColorScheme.Red("X Failed to get trust domain")) return err } @@ -87,7 +87,7 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*shared.AttestOptions) erro ec, err := shared.NewEnforcementCriteria(opts) if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to build policy information")) + opts.Logger.Println(opts.Logger.ColorScheme.Red("X Failed to build policy information")) return err } @@ -121,7 +121,7 @@ func verifyAssetRun(opts *shared.AttestOptions) error { sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(config) if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to create Sigstore verifier")) + opts.Logger.Println(opts.Logger.ColorScheme.Red("X Failed to create Sigstore verifier")) return err } @@ -141,7 +141,7 @@ func verifyAssetRun(opts *shared.AttestOptions) error { // calculate the digest of the file fileDigest, err := artifact.NewDigestedArtifact(nil, opts.AssetFilePath, "sha256") if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to calculate file digest")) + opts.Logger.Println(opts.Logger.ColorScheme.Red("X Failed to calculate file digest")) return err } @@ -158,7 +158,7 @@ func verifyAssetRun(opts *shared.AttestOptions) error { attestations, logMsg, err := shared.GetAttestations(opts, releaseRefDigest.DigestWithAlg()) if err != nil { if errors.Is(err, api.ErrNoAttestationsFound) { - opts.Logger.Printf(opts.Logger.ColorScheme.Red("✗ No attestations found for subject %s\n"), releaseRefDigest.DigestWithAlg()) + opts.Logger.Printf(opts.Logger.ColorScheme.Red("X No attestations found for subject %s\n"), releaseRefDigest.DigestWithAlg()) return err } opts.Logger.Println(opts.Logger.ColorScheme.Red(logMsg)) @@ -198,7 +198,7 @@ func verifyAssetRun(opts *shared.AttestOptions) error { if opts.Exporter != nil { // print the results to the terminal as an array of JSON objects if err = opts.Exporter.Write(opts.Logger.IO, verified); err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to write JSON output")) + opts.Logger.Println(opts.Logger.ColorScheme.Red("X Failed to write JSON output")) return err } return nil diff --git a/pkg/cmd/release/verify/verify.go b/pkg/cmd/release/verify/verify.go index ff8f7147e..b8276f989 100644 --- a/pkg/cmd/release/verify/verify.go +++ b/pkg/cmd/release/verify/verify.go @@ -73,14 +73,14 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*shared.AttestOptions) error) *c RunE: func(cmd *cobra.Command, args []string) error { td, err := opts.APIClient.GetTrustDomain() if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to get trust domain")) + opts.Logger.Println(opts.Logger.ColorScheme.Red("X Failed to get trust domain")) return err } opts.TrustedRoot = td ec, err := shared.NewEnforcementCriteria(opts) if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to build policy information")) + opts.Logger.Println(opts.Logger.ColorScheme.Red("X Failed to build policy information")) return err } opts.EC = ec @@ -110,7 +110,7 @@ func verifyRun(opts *shared.AttestOptions) error { sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(config) if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to create Sigstore verifier")) + opts.Logger.Println(opts.Logger.ColorScheme.Red("X Failed to create Sigstore verifier")) return err } @@ -137,7 +137,7 @@ func verifyRun(opts *shared.AttestOptions) error { attestations, logMsg, err := shared.GetAttestations(opts, releaseRefDigest.DigestWithAlg()) if err != nil { if errors.Is(err, api.ErrNoAttestationsFound) { - opts.Logger.Printf(opts.Logger.ColorScheme.Red("✗ No attestations found for subject %s\n"), releaseRefDigest.DigestWithAlg()) + opts.Logger.Printf(opts.Logger.ColorScheme.Red("X No attestations found for subject %s\n"), releaseRefDigest.DigestWithAlg()) return err } opts.Logger.Println(opts.Logger.ColorScheme.Red(logMsg)) @@ -152,7 +152,7 @@ func verifyRun(opts *shared.AttestOptions) error { } if len(filteredAttestations) == 0 { - opts.Logger.Printf(opts.Logger.ColorScheme.Red("✗ No attestations found for release %s in %s\n"), opts.TagName, opts.Repo) + opts.Logger.Printf(opts.Logger.ColorScheme.Red("X No attestations found for release %s in %s\n"), opts.TagName, opts.Repo) return fmt.Errorf("no attestations found for release %s in %s", opts.TagName, opts.Repo) } @@ -163,7 +163,7 @@ func verifyRun(opts *shared.AttestOptions) error { if err != nil { opts.Logger.Println(opts.Logger.ColorScheme.Red(errMsg)) - opts.Logger.Printf(opts.Logger.ColorScheme.Red("✗ Failed to find an attestation for release %s in %s\n"), opts.TagName, opts.Repo) + opts.Logger.Printf(opts.Logger.ColorScheme.Red("X Failed to find an attestation for release %s in %s\n"), opts.TagName, opts.Repo) return err } @@ -171,7 +171,7 @@ func verifyRun(opts *shared.AttestOptions) error { if opts.Exporter != nil { // print the results to the terminal as an array of JSON objects if err = opts.Exporter.Write(opts.Logger.IO, verified); err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to write JSON output")) + opts.Logger.Println(opts.Logger.ColorScheme.Red("X Failed to write JSON output")) return err } return nil @@ -192,7 +192,7 @@ func printVerifiedSubjects(verified []*verification.AttestationProcessingResult, var statementData v1.Statement err := protojson.Unmarshal([]byte(statement), &statementData) if err != nil { - logger.Println(logger.ColorScheme.Red("✗ Failed to unmarshal statement")) + logger.Println(logger.ColorScheme.Red("X Failed to unmarshal statement")) continue } for _, s := range statementData.Subject { From 53cae592f616fbefac8672cfb97d2e1da6505b7d Mon Sep 17 00:00:00 2001 From: Brian DeHamer Date: Thu, 5 Jun 2025 10:05:46 -0700 Subject: [PATCH 30/31] refactor to simplify implementation Signed-off-by: Brian DeHamer --- pkg/cmd/attestation/artifact/file.go | 2 +- pkg/cmd/release/release.go | 1 - pkg/cmd/release/shared/attestation.go | 95 ++++--- pkg/cmd/release/shared/options.go | 76 ----- pkg/cmd/release/shared/options_test.go | 60 ---- pkg/cmd/release/shared/policy.go | 76 ----- pkg/cmd/release/shared/policy_test.go | 71 ----- pkg/cmd/release/verify-asset/verify-asset.go | 219 -------------- .../release/verify-asset/verify-asset_test.go | 230 --------------- pkg/cmd/release/verify-asset/verify_asset.go | 182 ++++++++++++ .../release/verify-asset/verify_asset_test.go | 267 ++++++++++++++++++ pkg/cmd/release/verify/verify.go | 242 ++++++++-------- pkg/cmd/release/verify/verify_test.go | 158 +++++------ 13 files changed, 705 insertions(+), 974 deletions(-) delete mode 100644 pkg/cmd/release/shared/options.go delete mode 100644 pkg/cmd/release/shared/options_test.go delete mode 100644 pkg/cmd/release/shared/policy.go delete mode 100644 pkg/cmd/release/shared/policy_test.go delete mode 100644 pkg/cmd/release/verify-asset/verify-asset.go delete mode 100644 pkg/cmd/release/verify-asset/verify-asset_test.go create mode 100644 pkg/cmd/release/verify-asset/verify_asset.go create mode 100644 pkg/cmd/release/verify-asset/verify_asset_test.go diff --git a/pkg/cmd/attestation/artifact/file.go b/pkg/cmd/attestation/artifact/file.go index 789a92a5d..237a9bbf7 100644 --- a/pkg/cmd/attestation/artifact/file.go +++ b/pkg/cmd/attestation/artifact/file.go @@ -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) diff --git a/pkg/cmd/release/release.go b/pkg/cmd/release/release.go index f25e8bd3a..f56042c81 100644 --- a/pkg/cmd/release/release.go +++ b/pkg/cmd/release/release.go @@ -10,7 +10,6 @@ import ( 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" diff --git a/pkg/cmd/release/shared/attestation.go b/pkg/cmd/release/shared/attestation.go index a3aa3bea5..4e0377fed 100644 --- a/pkg/cmd/release/shared/attestation.go +++ b/pkg/cmd/release/shared/attestation.go @@ -1,56 +1,58 @@ package shared import ( - "errors" "fmt" + "net/http" - "github.com/cli/cli/v2/internal/text" "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" ) -func GetAttestations(o *AttestOptions, sha string) ([]*api.Attestation, string, error) { - if o.APIClient == nil { - errMsg := "X No APIClient provided" - return nil, errMsg, errors.New(errMsg) - } +const ReleasePredicateType = "https://in-toto.io/attestation/release/v0.1" - params := api.FetchParams{ - Digest: sha, - Limit: o.Limit, - Owner: o.Owner, - PredicateType: o.PredicateType, - Repo: o.Repo, - } - - attestations, err := o.APIClient.GetByDigest(params) - if err != nil { - msg := "X Loading attestations from GitHub API failed" - return nil, msg, err - } - pluralAttestation := text.Pluralize(len(attestations), "attestation") - msg := fmt.Sprintf("Loaded %s from GitHub API", pluralAttestation) - return attestations, msg, nil +type Verifier interface { + // VerifyAttestation verifies the attestation for a given artifact + VerifyAttestation(art *artifact.DigestedArtifact, att *api.Attestation) (*verification.AttestationProcessingResult, error) } -func VerifyAttestations(art artifact.DigestedArtifact, att []*api.Attestation, sgVerifier verification.SigstoreVerifier, ec verification.EnforcementCriteria) ([]*verification.AttestationProcessingResult, string, error) { - sgPolicy, err := buildSigstoreVerifyPolicy(ec, art) +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 { - logMsg := "X Failed to build Sigstore verification policy" - return nil, logMsg, err + return nil, err } - sigstoreVerified, err := sgVerifier.Verify(att, sgPolicy) + verifier, err := verification.NewLiveSigstoreVerifier(verification.SigstoreConfig{ + HttpClient: v.HttpClient, + Logger: att_io.NewHandler(v.IO), + NoPublicGood: true, + TrustDomain: td, + }) if err != nil { - logMsg := "X Sigstore verification failed" - return nil, logMsg, err + return nil, err } - return sigstoreVerified, "", nil + 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) { @@ -71,7 +73,7 @@ func FilterAttestationsByTag(attestations []*api.Attestation, tagName string) ([ return filtered, nil } -func FilterAttestationsByFileDigest(attestations []*api.Attestation, repo, tagName, fileDigest string) ([]*api.Attestation, error) { +func FilterAttestationsByFileDigest(attestations []*api.Attestation, fileDigest string) ([]*api.Attestation, error) { var filtered []*api.Attestation for _, att := range attestations { statement := att.Bundle.Bundle.GetDsseEnvelope().Payload @@ -95,3 +97,32 @@ func FilterAttestationsByFileDigest(attestations []*api.Attestation, repo, tagNa } 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 +} diff --git a/pkg/cmd/release/shared/options.go b/pkg/cmd/release/shared/options.go deleted file mode 100644 index 86e8ac78b..000000000 --- a/pkg/cmd/release/shared/options.go +++ /dev/null @@ -1,76 +0,0 @@ -package shared - -import ( - "fmt" - "net/http" - "path/filepath" - "strings" - - "github.com/cli/cli/v2/internal/gh" - "github.com/cli/cli/v2/internal/ghinstance" - "github.com/cli/cli/v2/internal/ghrepo" - "github.com/cli/cli/v2/pkg/cmd/attestation/api" - "github.com/cli/cli/v2/pkg/cmd/attestation/io" - "github.com/cli/cli/v2/pkg/cmd/attestation/verification" - "github.com/cli/cli/v2/pkg/cmdutil" - "github.com/cli/cli/v2/pkg/iostreams" -) - -const ReleasePredicateType = "https://in-toto.io/attestation/release/v0.1" - -type AttestOptions struct { - Config func() (gh.Config, error) - HttpClient *http.Client - IO *iostreams.IOStreams - BaseRepo ghrepo.Interface - Exporter cmdutil.Exporter - TagName string - TrustedRoot string - Limit int - Owner string - PredicateType string - Repo string - APIClient api.Client - Logger *io.Handler - SigstoreVerifier verification.SigstoreVerifier - Hostname string - EC verification.EnforcementCriteria - // Tenant is only set when tenancy is used - Tenant string - AssetFilePath string -} - -// Clean cleans the file path option values -func (opts *AttestOptions) Clean() { - if opts.AssetFilePath != "" { - opts.AssetFilePath = filepath.Clean(opts.AssetFilePath) - } -} - -// AreFlagsValid checks that the provided flag combination is valid -// and returns an error otherwise -func (opts *AttestOptions) AreFlagsValid() error { - // If provided, check that the Repo option is in the expected format / - if opts.Repo != "" && !isProvidedRepoValid(opts.Repo) { - return fmt.Errorf("invalid value provided for repo: %s", opts.Repo) - } - - // Check that limit is between 1 and 1000 - if opts.Limit < 1 || opts.Limit > 1000 { - return fmt.Errorf("limit %d not allowed, must be between 1 and 1000", opts.Limit) - } - - if opts.Hostname != "" { - if err := ghinstance.HostnameValidator(opts.Hostname); err != nil { - return fmt.Errorf("error parsing hostname: %w", err) - } - } - - return nil -} - -func isProvidedRepoValid(repo string) bool { - // we expect a provided repository argument be in the format / - splitRepo := strings.Split(repo, "/") - return len(splitRepo) == 2 -} diff --git a/pkg/cmd/release/shared/options_test.go b/pkg/cmd/release/shared/options_test.go deleted file mode 100644 index 7a8fa73dc..000000000 --- a/pkg/cmd/release/shared/options_test.go +++ /dev/null @@ -1,60 +0,0 @@ -package shared - -import ( - "errors" - "testing" -) - -func TestAttestOptions_AreFlagsValid_Valid(t *testing.T) { - opts := &AttestOptions{ - Repo: "owner/repo", - Limit: 10, - } - if err := opts.AreFlagsValid(); err != nil { - t.Errorf("expected no error, got %v", err) - } -} - -func TestAttestOptions_AreFlagsValid_InvalidRepo(t *testing.T) { - opts := &AttestOptions{ - Repo: "invalidrepo", - } - err := opts.AreFlagsValid() - if err == nil || !errors.Is(err, err) { - t.Errorf("expected error for invalid repo, got %v", err) - } -} - -func TestAttestOptions_AreFlagsValid_LimitTooLow(t *testing.T) { - opts := &AttestOptions{ - Repo: "owner/repo", - Limit: 0, - } - err := opts.AreFlagsValid() - if err == nil || !errors.Is(err, err) { - t.Errorf("expected error for limit too low, got %v", err) - } -} - -func TestAttestOptions_AreFlagsValid_LimitTooHigh(t *testing.T) { - opts := &AttestOptions{ - Repo: "owner/repo", - Limit: 1001, - } - err := opts.AreFlagsValid() - if err == nil || !errors.Is(err, err) { - t.Errorf("expected error for limit too high, got %v", err) - } -} - -func TestAttestOptions_AreFlagsValid_ValidHostname(t *testing.T) { - opts := &AttestOptions{ - Repo: "owner/repo", - Limit: 10, - Hostname: "github.com", - } - err := opts.AreFlagsValid() - if err != nil { - t.Errorf("expected no error for valid hostname, got %v", err) - } -} diff --git a/pkg/cmd/release/shared/policy.go b/pkg/cmd/release/shared/policy.go deleted file mode 100644 index 0e3bb322b..000000000 --- a/pkg/cmd/release/shared/policy.go +++ /dev/null @@ -1,76 +0,0 @@ -package shared - -import ( - "fmt" - - "github.com/sigstore/sigstore-go/pkg/fulcio/certificate" - "github.com/sigstore/sigstore-go/pkg/verify" - - "github.com/cli/cli/v2/pkg/cmd/attestation/artifact" - "github.com/cli/cli/v2/pkg/cmd/attestation/verification" -) - -func expandToGitHubURL(tenant, ownerOrRepo string) string { - if tenant == "" { - return fmt.Sprintf("https://github.com/%s", ownerOrRepo) - } - return fmt.Sprintf("https://%s.ghe.com/%s", tenant, ownerOrRepo) -} - -func NewEnforcementCriteria(opts *AttestOptions) (verification.EnforcementCriteria, error) { - // initialize the enforcement criteria with the provided PredicateType and SAN - c := verification.EnforcementCriteria{ - PredicateType: opts.PredicateType, - // TODO: if the proxima is provided, the default uses the proxima-specific SAN - SAN: "https://dotcom.releases.github.com", - } - - // If the Repo option is provided, set the SourceRepositoryURI extension - if opts.Repo != "" { - c.Certificate.SourceRepositoryURI = expandToGitHubURL(opts.Tenant, opts.Repo) - } - - // Set the SourceRepositoryOwnerURI extension using owner and tenant if provided - c.Certificate.SourceRepositoryOwnerURI = expandToGitHubURL(opts.Tenant, opts.Owner) - - return c, nil -} - -func buildCertificateIdentityOption(c verification.EnforcementCriteria) (verify.PolicyOption, error) { - sanMatcher, err := verify.NewSANMatcher(c.SAN, c.SANRegex) - if err != nil { - return nil, err - } - - // Accept any issuer, we will verify the issuer as part of the extension verification - issuerMatcher, err := verify.NewIssuerMatcher("", ".*") - if err != nil { - return nil, err - } - - extensions := certificate.Extensions{ - RunnerEnvironment: c.Certificate.RunnerEnvironment, - } - - certId, err := verify.NewCertificateIdentity(sanMatcher, issuerMatcher, extensions) - if err != nil { - return nil, err - } - - return verify.WithCertificateIdentity(certId), nil -} - -func buildSigstoreVerifyPolicy(c verification.EnforcementCriteria, a artifact.DigestedArtifact) (verify.PolicyBuilder, error) { - artifactDigestPolicyOption, err := verification.BuildDigestPolicyOption(a) - if err != nil { - return verify.PolicyBuilder{}, err - } - - certIdOption, err := buildCertificateIdentityOption(c) - if err != nil { - return verify.PolicyBuilder{}, err - } - - policy := verify.NewPolicy(artifactDigestPolicyOption, certIdOption) - return policy, nil -} diff --git a/pkg/cmd/release/shared/policy_test.go b/pkg/cmd/release/shared/policy_test.go deleted file mode 100644 index 72cc53c2a..000000000 --- a/pkg/cmd/release/shared/policy_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package shared - -import ( - "testing" - - "github.com/stretchr/testify/require" -) - -func TestNewEnforcementCriteria(t *testing.T) { - t.Run("check SAN", func(t *testing.T) { - opts := &AttestOptions{ - Owner: "foo", - Repo: "foo/bar", - PredicateType: "https://in-toto.io/attestation/release/v0.1", - } - - c, err := NewEnforcementCriteria(opts) - require.NoError(t, err) - require.Equal(t, "https://dotcom.releases.github.com", c.SAN) - require.Equal(t, "https://in-toto.io/attestation/release/v0.1", c.PredicateType) - }) - - t.Run("sets Extensions.SourceRepositoryURI using opts.Repo and opts.Tenant", func(t *testing.T) { - opts := &AttestOptions{ - Owner: "foo", - Repo: "foo/bar", - Tenant: "baz", - } - - c, err := NewEnforcementCriteria(opts) - require.NoError(t, err) - require.Equal(t, "https://baz.ghe.com/foo/bar", c.Certificate.SourceRepositoryURI) - }) - - t.Run("sets Extensions.SourceRepositoryURI using opts.Repo", func(t *testing.T) { - opts := &AttestOptions{ - Owner: "foo", - Repo: "foo/bar", - } - - c, err := NewEnforcementCriteria(opts) - require.NoError(t, err) - require.Equal(t, "https://github.com/foo/bar", c.Certificate.SourceRepositoryURI) - }) - - t.Run("sets Extensions.SourceRepositoryOwnerURI using opts.Owner and opts.Tenant", func(t *testing.T) { - opts := &AttestOptions{ - - Owner: "foo", - Repo: "foo/bar", - Tenant: "baz", - } - - c, err := NewEnforcementCriteria(opts) - require.NoError(t, err) - require.Equal(t, "https://baz.ghe.com/foo", c.Certificate.SourceRepositoryOwnerURI) - }) - - t.Run("sets Extensions.SourceRepositoryOwnerURI using opts.Owner", func(t *testing.T) { - opts := &AttestOptions{ - - Owner: "foo", - Repo: "foo/bar", - } - - c, err := NewEnforcementCriteria(opts) - require.NoError(t, err) - require.Equal(t, "https://github.com/foo", c.Certificate.SourceRepositoryOwnerURI) - }) - -} diff --git a/pkg/cmd/release/verify-asset/verify-asset.go b/pkg/cmd/release/verify-asset/verify-asset.go deleted file mode 100644 index 260589d11..000000000 --- a/pkg/cmd/release/verify-asset/verify-asset.go +++ /dev/null @@ -1,219 +0,0 @@ -package verifyasset - -import ( - "context" - "errors" - "fmt" - "path/filepath" - - "github.com/cli/cli/v2/pkg/cmd/attestation/auth" - ghauth "github.com/cli/go-gh/v2/pkg/auth" - - "github.com/cli/cli/v2/internal/text" - "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/cmdutil" - "github.com/spf13/cobra" -) - -func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*shared.AttestOptions) error) *cobra.Command { - opts := &shared.AttestOptions{} - - cmd := &cobra.Command{ - Use: "verify-asset ", - Short: "Verify that a given asset originated from a specific GitHub Release.", - Hidden: true, - Args: cobra.MaximumNArgs(2), - PreRunE: 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") - } - - httpClient, err := f.HttpClient() - if err != nil { - return err - } - baseRepo, err := f.BaseRepo() - if err != nil { - return err - } - logger := att_io.NewHandler(f.IOStreams) - hostname, _ := ghauth.DefaultHost() - - err = auth.IsHostSupported(hostname) - if err != nil { - return err - } - - *opts = shared.AttestOptions{ - TagName: opts.TagName, - AssetFilePath: opts.AssetFilePath, - Repo: baseRepo.RepoOwner() + "/" + baseRepo.RepoName(), - APIClient: api.NewLiveClient(httpClient, hostname, logger), - Limit: 10, - Owner: baseRepo.RepoOwner(), - PredicateType: shared.ReleasePredicateType, - Logger: logger, - HttpClient: httpClient, - BaseRepo: baseRepo, - Hostname: hostname, - } - - // Check that the given flag combination is valid - if err := opts.AreFlagsValid(); err != nil { - return err - } - - return nil - }, - RunE: func(cmd *cobra.Command, args []string) error { - td, err := opts.APIClient.GetTrustDomain() - if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red("X Failed to get trust domain")) - return err - } - - opts.TrustedRoot = td - - ec, err := shared.NewEnforcementCriteria(opts) - if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red("X Failed to build policy information")) - return err - } - - opts.EC = ec - - opts.Clean() - - // Avoid creating a Sigstore verifier if the runF function is provided for testing purposes - if runF != nil { - return runF(opts) - } - - return verifyAssetRun(opts) - }, - } - cmdutil.AddFormatFlags(cmd, &opts.Exporter) - - return cmd -} - -func verifyAssetRun(opts *shared.AttestOptions) error { - ctx := context.Background() - - if opts.SigstoreVerifier == nil { - config := verification.SigstoreConfig{ - HttpClient: opts.HttpClient, - Logger: opts.Logger, - NoPublicGood: true, - TrustDomain: opts.TrustedRoot, - } - - sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(config) - if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red("X Failed to create Sigstore verifier")) - return err - } - - opts.SigstoreVerifier = sigstoreVerifier - } - - if opts.TagName == "" { - release, err := shared.FetchLatestRelease(ctx, opts.HttpClient, opts.BaseRepo) - if err != nil { - return err - } - opts.TagName = release.TagName - } - - fileName := getFileName(opts.AssetFilePath) - - // calculate the digest of the file - fileDigest, err := artifact.NewDigestedArtifact(nil, opts.AssetFilePath, "sha256") - if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red("X Failed to calculate file digest")) - return err - } - - opts.Logger.Printf("Loaded digest %s for %s\n", fileDigest.DigestWithAlg(), fileName) - - ref, err := shared.FetchRefSHA(ctx, opts.HttpClient, opts.BaseRepo, opts.TagName) - if err != nil { - return err - } - releaseRefDigest := artifact.NewDigestedArtifactForRelease(ref, "sha1") - opts.Logger.Printf("Resolved %s to %s\n", opts.TagName, releaseRefDigest.DigestWithAlg()) - - // Attestation fetching - attestations, logMsg, err := shared.GetAttestations(opts, releaseRefDigest.DigestWithAlg()) - if err != nil { - if errors.Is(err, api.ErrNoAttestationsFound) { - opts.Logger.Printf(opts.Logger.ColorScheme.Red("X No attestations found for subject %s\n"), releaseRefDigest.DigestWithAlg()) - return err - } - opts.Logger.Println(opts.Logger.ColorScheme.Red(logMsg)) - return err - } - - // Filter attestations by tag - filteredAttestations, err := shared.FilterAttestationsByTag(attestations, opts.TagName) - if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red(err.Error())) - return err - } - - filteredAttestations, err = shared.FilterAttestationsByFileDigest(filteredAttestations, opts.Repo, opts.TagName, fileDigest.Digest()) - if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red(err.Error())) - return err - } - - if len(filteredAttestations) == 0 { - opts.Logger.Printf(opts.Logger.ColorScheme.Red("Release %s does not contain %s (%s)\n"), opts.TagName, opts.AssetFilePath, fileDigest.DigestWithAlg()) - return fmt.Errorf("release %s does not contain %s (%s)", opts.TagName, opts.AssetFilePath, fileDigest.DigestWithAlg()) - } - - opts.Logger.Printf("Loaded %s from GitHub API\n", text.Pluralize(len(filteredAttestations), "attestation")) - - // Verify attestations - verified, errMsg, err := shared.VerifyAttestations(*releaseRefDigest, filteredAttestations, opts.SigstoreVerifier, opts.EC) - - if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red(errMsg)) - opts.Logger.Printf(opts.Logger.ColorScheme.Red("Release %s does not contain %s (%s)\n"), opts.TagName, opts.AssetFilePath, fileDigest.DigestWithAlg()) - return err - } - - // If an exporter is provided with the --json flag, write the results to the terminal in JSON format - if opts.Exporter != nil { - // print the results to the terminal as an array of JSON objects - if err = opts.Exporter.Write(opts.Logger.IO, verified); err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red("X Failed to write JSON output")) - return err - } - return nil - } - - opts.Logger.Printf("The following %s matched the policy criteria\n\n", text.Pluralize(len(verified), "attestation")) - opts.Logger.Println(opts.Logger.ColorScheme.Green("✓ Verification succeeded!\n")) - opts.Logger.Printf("Attestation found matching release %s (%s)\n", opts.TagName, releaseRefDigest.DigestWithAlg()) - opts.Logger.Printf("%s is present in release %s\n", fileName, opts.TagName) - - return nil -} - -func getFileName(filePath string) string { - // Get the file name from the file path - _, fileName := filepath.Split(filePath) - return fileName -} diff --git a/pkg/cmd/release/verify-asset/verify-asset_test.go b/pkg/cmd/release/verify-asset/verify-asset_test.go deleted file mode 100644 index a85c9066e..000000000 --- a/pkg/cmd/release/verify-asset/verify-asset_test.go +++ /dev/null @@ -1,230 +0,0 @@ -package verifyasset - -import ( - "bytes" - "net/http" - "testing" - - "github.com/cli/cli/v2/pkg/cmd/attestation/api" - "github.com/cli/cli/v2/pkg/cmd/attestation/io" - "github.com/cli/cli/v2/pkg/cmd/attestation/test" - "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/iostreams" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/cli/cli/v2/internal/ghrepo" - - attestation "github.com/cli/cli/v2/pkg/cmd/release/shared" - "github.com/cli/cli/v2/pkg/httpmock" -) - -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() - var testReg httpmock.Registry - var metaResp = api.MetaResponse{ - Domains: api.Domain{ - ArtifactAttestations: api.ArtifactAttestations{}, - }, - } - testReg.Register(httpmock.REST(http.MethodGet, "meta"), - httpmock.StatusJSONResponse(200, &metaResp)) - - f := &cmdutil.Factory{ - IOStreams: testIO, - HttpClient: func() (*http.Client, error) { - reg := &testReg - client := &http.Client{} - httpmock.ReplaceTripper(client, reg) - return client, nil - }, - BaseRepo: func() (ghrepo.Interface, error) { - return ghrepo.FromFullName("owner/repo") - }, - } - - var opts *shared.AttestOptions - cmd := NewCmdVerifyAsset(f, func(o *shared.AttestOptions) error { - opts = o - 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, opts.TagName) - assert.Equal(t, tt.wantFile, 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) - - opts := &shared.AttestOptions{ - TagName: tagName, - AssetFilePath: test.NormalizeRelativePath("../../attestation/test/data/github_release_artifact.zip"), - Repo: "owner/repo", - Owner: "owner", - Limit: 10, - Logger: io.NewHandler(ios), - APIClient: api.NewTestClient(), - SigstoreVerifier: verification.NewMockSigstoreVerifier(t), - PredicateType: shared.ReleasePredicateType, - HttpClient: &http.Client{Transport: fakeHTTP}, - BaseRepo: baseRepo, - } - - ec, err := shared.NewEnforcementCriteria(opts) - require.NoError(t, err) - opts.EC = ec - opts.Clean() - err = verifyAssetRun(opts) - require.NoError(t, err) -} - -func Test_verifyAssetRun_Failed_With_Invalid_tag(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) - - opts := &attestation.AttestOptions{ - TagName: tagName, - AssetFilePath: test.NormalizeRelativePath("../../attestation/test/data/github_release_artifact.zip"), - Repo: "owner/repo", - Owner: "owner", - Limit: 10, - Logger: io.NewHandler(ios), - APIClient: api.NewTestClient(), - SigstoreVerifier: verification.NewMockSigstoreVerifier(t), - PredicateType: attestation.ReleasePredicateType, - HttpClient: &http.Client{Transport: fakeHTTP}, - BaseRepo: baseRepo, - } - - ec, err := attestation.NewEnforcementCriteria(opts) - require.NoError(t, err) - opts.EC = ec - - err = verifyAssetRun(opts) - require.Error(t, err, "no attestations found for github_release_artifact.zip in release v1") -} - -func Test_verifyAssetRun_Failed_With_Invalid_Artifact(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) - - opts := &attestation.AttestOptions{ - TagName: tagName, - AssetFilePath: test.NormalizeRelativePath("../../attestation/test/data/github_release_artifact.zip"), - Repo: "owner/repo", - Owner: "owner", - Limit: 10, - Logger: io.NewHandler(ios), - APIClient: api.NewTestClient(), - SigstoreVerifier: verification.NewMockSigstoreVerifier(t), - PredicateType: attestation.ReleasePredicateType, - HttpClient: &http.Client{Transport: fakeHTTP}, - BaseRepo: baseRepo, - } - - err = verifyAssetRun(opts) - require.Error(t, err, "no attestations found for github_release_artifact_invalid.zip in release v1.2.3") -} - -func Test_verifyAssetRun_NoAttestation(t *testing.T) { - ios, _, _, _ := iostreams.Test() - opts := &attestation.AttestOptions{ - TagName: "v1.2.3", - AssetFilePath: "artifact.tgz", - Repo: "owner/repo", - Limit: 10, - Logger: io.NewHandler(ios), - IO: ios, - APIClient: api.NewTestClient(), - SigstoreVerifier: verification.NewMockSigstoreVerifier(t), - PredicateType: attestation.ReleasePredicateType, - - EC: verification.EnforcementCriteria{}, - } - - err := verifyAssetRun(opts) - require.Error(t, err, "failed to get open local artifact: open artifact.tgz: no such file or director") -} - -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) - }) - } -} diff --git a/pkg/cmd/release/verify-asset/verify_asset.go b/pkg/cmd/release/verify-asset/verify_asset.go new file mode 100644 index 000000000..ddafbb265 --- /dev/null +++ b/pkg/cmd/release/verify-asset/verify_asset.go @@ -0,0 +1,182 @@ +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/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.", + Hidden: true, + Args: cobra.MaximumNArgs(2), + 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 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 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 +} diff --git a/pkg/cmd/release/verify-asset/verify_asset_test.go b/pkg/cmd/release/verify-asset/verify_asset_test.go new file mode 100644 index 000000000..732de9fd2 --- /dev/null +++ b/pkg/cmd/release/verify-asset/verify_asset_test.go @@ -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) + }) + } +} diff --git a/pkg/cmd/release/verify/verify.go b/pkg/cmd/release/verify/verify.go index b8276f989..95708fa5a 100644 --- a/pkg/cmd/release/verify/verify.go +++ b/pkg/cmd/release/verify/verify.go @@ -2,94 +2,86 @@ package verify import ( "context" - "errors" "fmt" + "net/http" v1 "github.com/in-toto/attestation/go/v1" "google.golang.org/protobuf/encoding/protojson" - "github.com/cli/cli/v2/internal/text" + "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" - "github.com/cli/cli/v2/pkg/cmd/attestation/auth" 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" - ghauth "github.com/cli/go-gh/v2/pkg/auth" "github.com/spf13/cobra" ) -func NewCmdVerify(f *cmdutil.Factory, runF func(*shared.AttestOptions) error) *cobra.Command { - opts := &shared.AttestOptions{} +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 []", Short: "Verify the attestation for a GitHub Release.", Hidden: true, Args: cobra.MaximumNArgs(1), - PreRunE: func(cmd *cobra.Command, args []string) error { + + 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 } - baseRepo, err := f.BaseRepo() - if err != nil { - return err - } - logger := att_io.NewHandler(f.IOStreams) - hostname, _ := ghauth.DefaultHost() + io := f.IOStreams + attClient := api.NewLiveClient(httpClient, baseRepo.RepoHost(), att_io.NewHandler(io)) - err = auth.IsHostSupported(hostname) - if err != nil { - return err + attVerifier := &shared.AttestationVerifier{ + AttClient: attClient, + HttpClient: httpClient, + IO: io, } - *opts = shared.AttestOptions{ - TagName: opts.TagName, - Repo: baseRepo.RepoOwner() + "/" + baseRepo.RepoName(), - APIClient: api.NewLiveClient(httpClient, hostname, logger), - Limit: 10, - Owner: baseRepo.RepoOwner(), - PredicateType: shared.ReleasePredicateType, - Logger: logger, - HttpClient: httpClient, - BaseRepo: baseRepo, - Hostname: hostname, + config := &VerifyConfig{ + Opts: opts, + HttpClient: httpClient, + AttClient: attClient, + AttVerifier: attVerifier, + IO: io, } - // Check that the given flag combination is valid - if err := opts.AreFlagsValid(); err != nil { - return err - } - return nil - }, - RunE: func(cmd *cobra.Command, args []string) error { - td, err := opts.APIClient.GetTrustDomain() - if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red("X Failed to get trust domain")) - return err - } - opts.TrustedRoot = td - - ec, err := shared.NewEnforcementCriteria(opts) - if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red("X Failed to build policy information")) - return err - } - opts.EC = ec - - // Avoid creating a Sigstore verifier if the runF function is provided for testing purposes if runF != nil { - return runF(opts) + return runF(config) } - return verifyRun(opts) + return verifyRun(config) }, } cmdutil.AddFormatFlags(cmd, &opts.Exporter) @@ -97,115 +89,119 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*shared.AttestOptions) error) *c return cmd } -func verifyRun(opts *shared.AttestOptions) error { +func verifyRun(config *VerifyConfig) error { ctx := context.Background() + opts := config.Opts + baseRepo := opts.BaseRepo + tagName := opts.TagName - if opts.SigstoreVerifier == nil { - config := verification.SigstoreConfig{ - HttpClient: opts.HttpClient, - Logger: opts.Logger, - NoPublicGood: true, - TrustDomain: opts.TrustedRoot, - } - - sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(config) - if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red("X Failed to create Sigstore verifier")) - return err - } - - opts.SigstoreVerifier = sigstoreVerifier - } - - if opts.TagName == "" { - release, err := shared.FetchLatestRelease(ctx, opts.HttpClient, opts.BaseRepo) + if tagName == "" { + release, err := shared.FetchLatestRelease(ctx, config.HttpClient, baseRepo) if err != nil { return err } - opts.TagName = release.TagName + tagName = release.TagName } - ref, err := shared.FetchRefSHA(ctx, opts.HttpClient, opts.BaseRepo, opts.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") - opts.Logger.Printf("Resolved %s to %s\n", opts.TagName, releaseRefDigest.DigestWithAlg()) - // Attestation fetching - attestations, logMsg, err := shared.GetAttestations(opts, releaseRefDigest.DigestWithAlg()) + // 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 { - if errors.Is(err, api.ErrNoAttestationsFound) { - opts.Logger.Printf(opts.Logger.ColorScheme.Red("X No attestations found for subject %s\n"), releaseRefDigest.DigestWithAlg()) - return err - } - opts.Logger.Println(opts.Logger.ColorScheme.Red(logMsg)) - return err + return fmt.Errorf("no attestations for tag %s (%s)", tagName, releaseRefDigest.DigestWithAlg()) } - // Filter attestations by predicate tag - filteredAttestations, err := shared.FilterAttestationsByTag(attestations, opts.TagName) + // Filter attestations by tag name + filteredAttestations, err := shared.FilterAttestationsByTag(attestations, tagName) if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red(err.Error())) - return err + return fmt.Errorf("error parsing attestations for tag %s: %w", tagName, err) } if len(filteredAttestations) == 0 { - opts.Logger.Printf(opts.Logger.ColorScheme.Red("X No attestations found for release %s in %s\n"), opts.TagName, opts.Repo) - return fmt.Errorf("no attestations found for release %s in %s", opts.TagName, opts.Repo) + return fmt.Errorf("no attestations found for release %s in %s", tagName, baseRepo.RepoName()) } - opts.Logger.Printf("Loaded %s from GitHub API\n", text.Pluralize(len(filteredAttestations), "attestation")) - - // Verify attestations - verified, errMsg, err := shared.VerifyAttestations(*releaseRefDigest, filteredAttestations, opts.SigstoreVerifier, opts.EC) + 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 { - opts.Logger.Println(opts.Logger.ColorScheme.Red(errMsg)) - opts.Logger.Printf(opts.Logger.ColorScheme.Red("X Failed to find an attestation for release %s in %s\n"), opts.TagName, opts.Repo) - return err + 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 { - // print the results to the terminal as an array of JSON objects - if err = opts.Exporter.Write(opts.Logger.IO, verified); err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red("X Failed to write JSON output")) - return err - } - return nil + return opts.Exporter.Write(config.IO, verified) } - opts.Logger.Printf("The following %s matched the policy criteria\n\n", text.Pluralize(len(verified), "attestation")) - opts.Logger.Println(opts.Logger.ColorScheme.Green("✓ Verification succeeded!\n")) + 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) - opts.Logger.Printf("Attestation found matching release %s (%s)\n", opts.TagName, releaseRefDigest.Digest()) - printVerifiedSubjects(verified, opts.Logger) + if err := printVerifiedSubjects(io, verified); err != nil { + return err + } return nil } -func printVerifiedSubjects(verified []*verification.AttestationProcessingResult, logger *att_io.Handler) { - for _, att := range verified { - statement := att.Attestation.Bundle.GetDsseEnvelope().Payload - var statementData v1.Statement - err := protojson.Unmarshal([]byte(statement), &statementData) - if err != nil { - logger.Println(logger.ColorScheme.Red("X Failed to unmarshal statement")) - continue - } - for _, s := range statementData.Subject { - name := s.Name - digest := s.Digest +func printVerifiedSubjects(io *iostreams.IOStreams, att *verification.AttestationProcessingResult) error { + cs := io.ColorScheme() + w := io.Out - if name != "" { - digestStr := "" - for key, value := range digest { - digestStr += key + ":" + value - } - logger.Println(" " + name + " " + digestStr) + 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 } diff --git a/pkg/cmd/release/verify/verify_test.go b/pkg/cmd/release/verify/verify_test.go index b0a1c7df5..40009fc7d 100644 --- a/pkg/cmd/release/verify/verify_test.go +++ b/pkg/cmd/release/verify/verify_test.go @@ -7,7 +7,7 @@ import ( "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/cmd/attestation/api" - "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/cmd/release/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -38,40 +38,30 @@ func TestNewCmdVerify_Args(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { testIO, _, _, _ := iostreams.Test() - var testReg httpmock.Registry - var metaResp = api.MetaResponse{ - Domains: api.Domain{ - ArtifactAttestations: api.ArtifactAttestations{}, - }, - } - testReg.Register(httpmock.REST(http.MethodGet, "meta"), - httpmock.StatusJSONResponse(200, &metaResp)) - f := &cmdutil.Factory{ IOStreams: testIO, HttpClient: func() (*http.Client, error) { - reg := &testReg - client := &http.Client{} - httpmock.ReplaceTripper(client, reg) - return client, nil + return nil, nil }, BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.FromFullName("owner/repo") }, } - var opts *shared.AttestOptions - cmd := NewCmdVerify(f, func(o *shared.AttestOptions) error { - opts = o + 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, opts.TagName) + assert.Equal(t, tt.wantTag, cfg.Opts.TagName) }) } } @@ -82,35 +72,72 @@ func Test_verifyRun_Success(t *testing.T) { 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) - opts := &shared.AttestOptions{ - TagName: tagName, - Repo: "owner/repo", - Owner: "owner", - Limit: 10, - Logger: io.NewHandler(ios), - APIClient: api.NewTestClient(), - SigstoreVerifier: verification.NewMockSigstoreVerifier(t), - HttpClient: &http.Client{Transport: fakeHTTP}, - BaseRepo: baseRepo, - PredicateType: shared.ReleasePredicateType, + result := &verification.AttestationProcessingResult{ + Attestation: &api.Attestation{ + Bundle: data.GitHubReleaseBundle(t), + BundleURL: "https://example.com", + }, + VerificationResult: nil, } - ec, err := shared.NewEnforcementCriteria(opts) - require.NoError(t, err) - opts.EC = ec + 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(opts) + err = verifyRun(cfg) require.NoError(t, err) } -func Test_verifyRun_Failed_With_Invalid_Tag(t *testing.T) { +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{} @@ -121,57 +148,18 @@ func Test_verifyRun_Failed_With_Invalid_Tag(t *testing.T) { baseRepo, err := ghrepo.FromFullName("owner/repo") require.NoError(t, err) - opts := &shared.AttestOptions{ - TagName: tagName, - Repo: "owner/repo", - Owner: "owner", - Limit: 10, - Logger: io.NewHandler(ios), - APIClient: api.NewFailTestClient(), - SigstoreVerifier: verification.NewMockSigstoreVerifier(t), - PredicateType: shared.ReleasePredicateType, - - HttpClient: &http.Client{Transport: fakeHTTP}, - BaseRepo: baseRepo, + cfg := &VerifyConfig{ + Opts: &VerifyOptions{ + TagName: tagName, + BaseRepo: baseRepo, + Exporter: nil, + }, + IO: ios, + HttpClient: &http.Client{Transport: fakeHTTP}, + AttClient: api.NewTestClient(), + AttVerifier: nil, } - ec, err := shared.NewEnforcementCriteria(opts) - require.NoError(t, err) - opts.EC = ec - - err = verifyRun(opts) - require.Error(t, err, "failed to fetch attestations from owner/repo") -} - -func Test_verifyRun_Failed_NoAttestation(t *testing.T) { - ios, _, _, _ := iostreams.Test() - 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) - - opts := &shared.AttestOptions{ - TagName: tagName, - Repo: "owner/repo", - Owner: "owner", - Limit: 10, - Logger: io.NewHandler(ios), - APIClient: api.NewFailTestClient(), - SigstoreVerifier: verification.NewMockSigstoreVerifier(t), - HttpClient: &http.Client{Transport: fakeHTTP}, - BaseRepo: baseRepo, - PredicateType: shared.ReleasePredicateType, - } - - ec, err := shared.NewEnforcementCriteria(opts) - require.NoError(t, err) - opts.EC = ec - - err = verifyRun(opts) - require.Error(t, err, "failed to fetch attestations from owner/repo") + err = verifyRun(cfg) + require.ErrorContains(t, err, "no attestations found for release v1.2.3") } From 169b909015a9ef103c9714e0283e5db346ee3a15 Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Thu, 19 Jun 2025 10:18:23 -0400 Subject: [PATCH 31/31] improve the description for gh release verify cmd --- pkg/cmd/release/verify-asset/verify_asset.go | 37 +++++++++++++++++--- pkg/cmd/release/verify/verify.go | 30 ++++++++++++++-- 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/pkg/cmd/release/verify-asset/verify_asset.go b/pkg/cmd/release/verify-asset/verify_asset.go index ddafbb265..2b66f3502 100644 --- a/pkg/cmd/release/verify-asset/verify_asset.go +++ b/pkg/cmd/release/verify-asset/verify_asset.go @@ -14,6 +14,7 @@ import ( 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" ) @@ -37,10 +38,34 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*VerifyAssetConfig) error) opts := &VerifyAssetOptions{} cmd := &cobra.Command{ - Use: "verify-asset ", - Short: "Verify that a given asset originated from a specific GitHub Release.", + 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] @@ -122,13 +147,17 @@ func verifyAssetRun(config *VerifyAssetConfig) error { releaseRefDigest := artifact.NewDigestedArtifactForRelease(ref, "sha1") - // Find attestaitons for the release tag SHA + // 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(), - Limit: 10, + // 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()) diff --git a/pkg/cmd/release/verify/verify.go b/pkg/cmd/release/verify/verify.go index 95708fa5a..8c04fe682 100644 --- a/pkg/cmd/release/verify/verify.go +++ b/pkg/cmd/release/verify/verify.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" + "github.com/MakeNowJust/heredoc" v1 "github.com/in-toto/attestation/go/v1" "google.golang.org/protobuf/encoding/protojson" @@ -43,7 +44,28 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(config *VerifyConfig) error) *co 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] @@ -111,13 +133,17 @@ func verifyRun(config *VerifyConfig) error { releaseRefDigest := artifact.NewDigestedArtifactForRelease(ref, "sha1") - // Find attestaitons for the release tag SHA + // 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(), - Limit: 10, + // 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())