From a082815d8118b0463353e839ec12af764987cd6f Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Fri, 1 Mar 2024 15:58:42 -0700 Subject: [PATCH] start moving over verify cmd and general verification code Signed-off-by: Meredith Lancaster --- go.mod | 6 +- pkg/cmd/attestation/attestation.go | 25 +- pkg/cmd/attestation/verification/policy.go | 78 ++++++ .../attestation/verification/policy_test.go | 27 +++ pkg/cmd/attestation/verification/sigstore.go | 199 +++++++++++++++ .../attestation/verification/sigstore_test.go | 129 ++++++++++ pkg/cmd/attestation/verify/options.go | 156 ++++++++++++ pkg/cmd/attestation/verify/options_test.go | 229 ++++++++++++++++++ pkg/cmd/attestation/verify/policy.go | 22 ++ pkg/cmd/attestation/verify/verify.go | 205 ++++++++++++++++ 10 files changed, 1067 insertions(+), 9 deletions(-) create mode 100644 pkg/cmd/attestation/verification/policy.go create mode 100644 pkg/cmd/attestation/verification/policy_test.go create mode 100644 pkg/cmd/attestation/verification/sigstore.go create mode 100644 pkg/cmd/attestation/verification/sigstore_test.go create mode 100644 pkg/cmd/attestation/verify/options.go create mode 100644 pkg/cmd/attestation/verify/options_test.go create mode 100644 pkg/cmd/attestation/verify/policy.go create mode 100644 pkg/cmd/attestation/verify/verify.go diff --git a/go.mod b/go.mod index b5849a76a..9ce568348 100644 --- a/go.mod +++ b/go.mod @@ -14,9 +14,11 @@ require ( github.com/cli/safeexec v1.0.1 github.com/cpuguy83/go-md2man/v2 v2.0.3 github.com/creack/pty v1.1.21 + github.com/distribution/reference v0.5.0 github.com/gabriel-vasile/mimetype v1.4.3 github.com/gdamore/tcell/v2 v2.5.4 - github.com/github/gh-attestation v0.3.1-0.20240213221736-bd06290fa8ee + github.com/in-toto/in-toto-golang v0.9.0 + github.com/google/go-containerregistry v0.19.0 github.com/google/go-cmp v0.6.0 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/gorilla/websocket v1.5.0 @@ -33,6 +35,8 @@ require ( github.com/opentracing/opentracing-go v1.2.0 github.com/rivo/tview v0.0.0-20221029100920-c4a7e501810d github.com/shurcooL/githubv4 v0.0.0-20230704064427-599ae7bbf278 + github.com/sigstore/protobuf-specs v0.3.0 + github.com/sigstore/sigstore-go v0.2.1-0.20240222221148-8bd2a8139edc github.com/spf13/cobra v1.8.0 github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.8.4 diff --git a/pkg/cmd/attestation/attestation.go b/pkg/cmd/attestation/attestation.go index 978a5327b..7441d6105 100644 --- a/pkg/cmd/attestation/attestation.go +++ b/pkg/cmd/attestation/attestation.go @@ -1,21 +1,30 @@ package attestation import ( - "github.com/github/gh-attestation/cmd/app" - + "github.com/cli/cli/v2/pkg/cmd/attestation/verify" "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" ) -func NewCmdAttestation(io *iostreams.IOStreams, version, buildDate string) *cobra.Command { +func NewCmdAttestation(f *cmdutil.Factory) *cobra.Command { root := &cobra.Command{ - Use: "attestation", - Short: "attestations", + Use: "attestation [subcommand]", + Short: "Work with attestations.", + Aliases: []string{"at"}, + Long: heredoc.Docf(` + Work with attestations that represent trusted metadata about artifacts and images. + + The %[1]sattestation%[1]s command and all subcommands support the following account types: + * Free tier + * Pro tier + * Team tier + * GHEC + * GHEC EMU + `, "`"), } - root.AddCommand(app.NewDownloadCmd(io, version, buildDate)) - root.AddCommand(app.NewVerifyCmd(io, version, buildDate)) - root.AddCommand(app.NewTUFRootVerifyCmd(io)) + root.AddCommand(verify.NewVerifyCmd(f)) return root } diff --git a/pkg/cmd/attestation/verification/policy.go b/pkg/cmd/attestation/verification/policy.go new file mode 100644 index 000000000..99c138a37 --- /dev/null +++ b/pkg/cmd/attestation/verification/policy.go @@ -0,0 +1,78 @@ +package verify + +import ( + "encoding/hex" + "fmt" + + "github.com/sigstore/sigstore-go/pkg/fulcio/certificate" + "github.com/sigstore/sigstore-go/pkg/verify" +) + +const ( + GitHubOIDCIssuer = "https://token.actions.githubusercontent.com" + SLSAPredicateType = "https://slsa.dev/provenance/v1" + // represents the GitHub hosted runner in the certificate RunnerEnvironment extension + GitHubRunner = "github-hosted" +) + +func buildSANMatcher(opts *VerifyOpts) (verify.SubjectAlternativeNameMatcher, error) { + if opts.SAN != "" || opts.SANRegex != "" { + sanMatcher, err := verify.NewSANMatcher(opts.SAN, "", opts.SANRegex) + if err != nil { + return verify.SubjectAlternativeNameMatcher{}, err + } + return sanMatcher, nil + } + return verify.SubjectAlternativeNameMatcher{}, nil +} + +func buildCertificateIdentityOption(opts *VerifyOpts, runnerEnv string) (verify.PolicyOption, error) { + sanMatcher, err := buildSANMatcher(opts) + if err != nil { + return nil, err + } + + extensions := certificate.Extensions{ + Issuer: opts.OIDCIssuer, + SourceRepositoryOwnerURI: fmt.Sprintf("https://github.com/%s", opts.Owner), + RunnerEnvironment: runnerEnv, + } + certId, err := verify.NewCertificateIdentity(sanMatcher, extensions) + if err != nil { + return nil, err + } + + return verify.WithCertificateIdentity(certId), nil +} + +func buildVerifyCertIdOption(opts *VerifyOpts) (verify.PolicyOption, error) { + if opts.DenySelfHostedRunner { + withGHRunner, err := buildCertificateIdentityOption(opts, GitHubRunner) + if err != nil { + return nil, err + } + + return withGHRunner, nil + } + + // if Extensions.RunnerEnvironment value is set to the empty string + // through the second function argument, + // no certificate matching will happen on the RunnerEnvironment field + withAnyRunner, err := buildCertificateIdentityOption(opts, "") + if err != nil { + return nil, err + } + + return withAnyRunner, nil +} + +// BuildDigestPolicyOption builds a verify.ArtifactPolicyOption +// from the given artifact digest and digest algorithm +func BuildDigestPolicyOption(artifactDigest, digestAlgorithm string) (verify.ArtifactPolicyOption, error) { + // sigstore-go expects the artifact digest to be decoded from hex + decoded, err := hex.DecodeString(artifactDigest) + if err != nil { + return nil, err + } + return verify.WithArtifactDigest(digestAlgorithm, decoded), nil +} diff --git a/pkg/cmd/attestation/verification/policy_test.go b/pkg/cmd/attestation/verification/policy_test.go new file mode 100644 index 000000000..8cff361a8 --- /dev/null +++ b/pkg/cmd/attestation/verification/policy_test.go @@ -0,0 +1,27 @@ +package verify + +import ( + "testing" + + "github.com/github/gh-attestation/pkg/artifact" + "github.com/github/gh-attestation/pkg/github" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildPolicy(t *testing.T) { + opts := &VerifyOpts{ + ArtifactPath: "../../test/data/public-good/sigstore-js-2.1.0.tgz", + BundlePath: "../../test/data/public-good/sigstore-js-2.1.0-bundle.json", + GitHubClient: github.NewTestClient(), + OIDCIssuer: GitHubOIDCIssuer, + SAN: "fake san", + Owner: "github", + } + + artifact, err := artifact.NewDigestedArtifact(opts.OCIClient, opts.ArtifactPath, opts.DigestAlgorithm) + require.NoError(t, err) + + _, err = buildVerifyPolicy(opts, artifact.Digest()) + assert.NoError(t, err) +} diff --git a/pkg/cmd/attestation/verification/sigstore.go b/pkg/cmd/attestation/verification/sigstore.go new file mode 100644 index 000000000..8c6d13434 --- /dev/null +++ b/pkg/cmd/attestation/verification/sigstore.go @@ -0,0 +1,199 @@ +package verify + +import ( + "fmt" + + "github.com/github/gh-attestation/pkg/github" + "github.com/github/gh-attestation/pkg/output" + + "github.com/sigstore/sigstore-go/pkg/bundle" + "github.com/sigstore/sigstore-go/pkg/root" + "github.com/sigstore/sigstore-go/pkg/tuf" + "github.com/sigstore/sigstore-go/pkg/verify" +) + +const ( + PublicGoodIssuerOrg = "sigstore.dev" + GitHubIssuerOrg = "GitHub, Inc." +) + +type SigstoreConfig struct { + CustomTrustedRoot string + Logger *output.Logger + NoPublicGood bool +} + +type SigstoreVerifier struct { + ghVerifier *verify.SignedEntityVerifier + publicGoodVerifier *verify.SignedEntityVerifier + customVerifier *verify.SignedEntityVerifier + policy verify.PolicyBuilder + onlyVerifyWithGithub bool + Logger *output.Logger +} + +// NewSigstoreVerifier creates a new SigstoreVerifier struct +// that is used to verify artifacts and attestations against the +// Public Good, GitHub, or a custom trusted root. +func NewSigstoreVerifier(config SigstoreConfig, policy verify.PolicyBuilder) (*SigstoreVerifier, error) { + customVerifier, err := newCustomVerifier(config.CustomTrustedRoot) + if err != nil { + return nil, fmt.Errorf("failed to create custom verifier: %w", err) + } + + publicGoodVerifier, err := newPublicGoodVerifier() + if err != nil { + return nil, fmt.Errorf("failed to create Public Good Sigstore verifier: %w", err) + } + + ghVerifier, err := newGitHubVerifier() + if err != nil { + return nil, fmt.Errorf("failed to create GitHub Sigstore verifier: %w", err) + } + + return &SigstoreVerifier{ + ghVerifier: ghVerifier, + publicGoodVerifier: publicGoodVerifier, + customVerifier: customVerifier, + Logger: config.Logger, + policy: policy, + onlyVerifyWithGithub: config.NoPublicGood, + }, nil +} + +func (v *SigstoreVerifier) chooseVerifier(b *bundle.ProtobufBundle) (*verify.SignedEntityVerifier, string, error) { + verifyContent, err := b.VerificationContent() + if err != nil { + return nil, "", fmt.Errorf("failed to get bundle verification content: %w", err) + } + leafCert, ok := verifyContent.HasCertificate() + if !ok { + return nil, "", fmt.Errorf("leaf cert not found") + } + if len(leafCert.Issuer.Organization) != 1 { + return nil, "", fmt.Errorf("expected the leaf certificate issuer to only have one organization") + } + issuer := leafCert.Issuer.Organization[0] + + // if user provided a custom trusted root file path, use the custom verifier + if v.customVerifier != nil { + return v.customVerifier, issuer, nil + } + + if v.onlyVerifyWithGithub { + return v.ghVerifier, issuer, nil + } + + if leafCert.Issuer.Organization[0] == PublicGoodIssuerOrg { + return v.publicGoodVerifier, issuer, nil + } else if leafCert.Issuer.Organization[0] == GitHubIssuerOrg { + return v.ghVerifier, issuer, nil + } + return nil, "", fmt.Errorf("leaf certificate issuer is not recognized") +} + +func (v *SigstoreVerifier) Verify(attestations []*github.Attestation) *SigstoreResults { + // initialize the processing results before attempting to verify + // with multiple verifiers + results := make([]*AttestationProcessingResult, len(attestations)) + for i, att := range attestations { + apr := &AttestationProcessingResult{ + Attestation: att, + } + results[i] = apr + } + + for i, apr := range results { + v.Logger.VerbosePrintf("Verifying attestation #%d against the configured Sigstore trust roots\n", i+1) + + // determine which verifier should attempt verification against the bundle + verifier, issuer, err := v.chooseVerifier(apr.Attestation.Bundle) + if err != nil { + return &SigstoreResults{ + Error: fmt.Errorf("failed to find recognized issuer from bundle content: %w", err), + } + } + + v.Logger.VerbosePrintf("Attempting verification against issuer \"%s\"...\n", issuer) + // attempt to verify the attestation + result, err := verifier.Verify(apr.Attestation.Bundle, v.policy) + // if verification fails, create the error and exit verification early + if err != nil { + v.Logger.VerbosePrint(v.Logger.ColorScheme.Redf( + "Failed to verify against issuer \"%s\" \n\n", issuer, + )) + + return &SigstoreResults{ + Error: fmt.Errorf("verifying with issuer \"%s\": %w", issuer, err), + } + } + + // if verification is successful, add the result + // to the AttestationProcessingResult entry + v.Logger.VerbosePrint(v.Logger.ColorScheme.Greenf( + "SUCCESS - attestation signature verified with \"%s\"\n", issuer, + )) + apr.VerificationResult = result + } + + return &SigstoreResults{ + VerifyResults: results, + } +} + +func newCustomVerifier(trustedRootFilePath string) (*verify.SignedEntityVerifier, error) { + if trustedRootFilePath == "" { + return nil, nil + } + + trustedRoot, err := root.NewTrustedRootFromPath(trustedRootFilePath) + if err != nil { + return nil, fmt.Errorf("failed to create trusted root from file %s: %w", trustedRootFilePath, err) + } + + gv, err := verify.NewSignedEntityVerifier(trustedRoot, verify.WithSignedTimestamps(1)) + if err != nil { + return nil, fmt.Errorf("failed to create custom verifier: %w", err) + } + + return gv, nil +} + +func newGitHubVerifier() (*verify.SignedEntityVerifier, error) { + opts, err := GitHubTUFOptions() + if err != nil { + return nil, err + } + client, err := tuf.New(opts) + if err != nil { + return nil, fmt.Errorf("failed to create TUF client: %w", err) + } + trustedRoot, err := root.GetTrustedRoot(client) + if err != nil { + return nil, err + } + gv, err := verify.NewSignedEntityVerifier(trustedRoot, verify.WithSignedTimestamps(1)) + if err != nil { + return nil, fmt.Errorf("failed to create GitHub verifier: %w", err) + } + + return gv, nil +} + +func newPublicGoodVerifier() (*verify.SignedEntityVerifier, error) { + client, err := tuf.DefaultClient() + if err != nil { + return nil, fmt.Errorf("failed to create TUF client: %w", err) + } + trustedRoot, err := root.GetTrustedRoot(client) + if err != nil { + return nil, fmt.Errorf("failed to get trusted root: %w", err) + } + + sv, err := verify.NewSignedEntityVerifier(trustedRoot, verify.WithSignedCertificateTimestamps(1), verify.WithTransparencyLog(1), verify.WithObserverTimestamps(1)) + if err != nil { + return nil, fmt.Errorf("failed to create Public Good verifier: %w", err) + } + + return sv, nil +} diff --git a/pkg/cmd/attestation/verification/sigstore_test.go b/pkg/cmd/attestation/verification/sigstore_test.go new file mode 100644 index 000000000..edf31eb6e --- /dev/null +++ b/pkg/cmd/attestation/verification/sigstore_test.go @@ -0,0 +1,129 @@ +package verify + +import ( + "testing" + + "github.com/github/gh-attestation/pkg/artifact" + "github.com/github/gh-attestation/pkg/artifact/oci" + "github.com/github/gh-attestation/pkg/github" + "github.com/github/gh-attestation/pkg/output" + "github.com/github/gh-attestation/test" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var logger = output.NewDefaultLogger() + +var publicGoodOpts = Options{ + ArtifactPath: test.NormalizeRelativePath("../../test/data/public-good/sigstore-js-2.1.0.tgz"), + BundlePath: test.NormalizeRelativePath("../../test/data/public-good/sigstore-js-2.1.0-bundle.json"), + DigestAlgorithm: "sha512", + GitHubClient: github.NewTestClient(), + Logger: logger, + OIDCIssuer: GitHubOIDCIssuer, + Owner: "sigstore", + Limit: 30, + OCIClient: oci.NewMockClient(), + SANRegex: "^https://github.com/sigstore/", +} + +func TestVerify_PublicGoodSuccess(t *testing.T) { + t.Skip() + res := test.SuppressAndRestoreOutput() + defer res() + + artifact, err := artifact.NewDigestedArtifact(publicGoodOpts.OCIClient, publicGoodOpts.ArtifactPath, publicGoodOpts.DigestAlgorithm) + require.NoError(t, err) + + attestations, err := getAttestations(&publicGoodOpts, artifact.Digest()) + require.NoError(t, err) + + policy, err := buildVerifyPolicy(&publicGoodOpts, artifact.Digest()) + require.NoError(t, err) + + config := SigstoreConfig{ + CustomTrustedRoot: publicGoodOpts.CustomTrustedRoot, + Logger: publicGoodOpts.Logger, + NoPublicGood: publicGoodOpts.NoPublicGood, + } + + v, err := NewSigstoreVerifier(config, policy) + require.NoError(t, err) + + resp := v.Verify(attestations) + assert.Nil(t, resp.Error) + + verifyResults := resp.VerifyResults + assert.Len(t, verifyResults, 1) + + result := verifyResults[0] + assert.NotNil(t, result.VerificationResult) + assert.Equal(t, attestations[0], result.Attestation) +} + +func TestVerify_PublicGoodFail_WithNoPublicGoodFlag(t *testing.T) { + t.Skip() + res := test.SuppressAndRestoreOutput() + defer res() + + opts := publicGoodOpts + opts.NoPublicGood = true + + err := opts.AreFlagsValid() + require.NoError(t, err) + + artifact, err := artifact.NewDigestedArtifact(opts.OCIClient, opts.ArtifactPath, opts.DigestAlgorithm) + require.NoError(t, err) + + attestations, err := getAttestations(&opts, artifact.Digest()) + require.NoError(t, err) + + policy, err := buildVerifyPolicy(&publicGoodOpts, artifact.Digest()) + require.NoError(t, err) + + config := SigstoreConfig{ + CustomTrustedRoot: opts.CustomTrustedRoot, + Logger: opts.Logger, + NoPublicGood: opts.NoPublicGood, + } + + v, err := NewSigstoreVerifier(config, policy) + require.NoError(t, err) + + resp := v.Verify(attestations) + assert.Nil(t, resp.VerifyResults) + assert.ErrorContains(t, resp.Error, "verifying with issuer \"sigstore.dev\"") +} + +func TestVerify_Failure(t *testing.T) { + res := test.SuppressAndRestoreOutput() + defer res() + + opts := publicGoodOpts + opts.DigestAlgorithm = "sha256" + + err := opts.AreFlagsValid() + require.NoError(t, err) + + artifact, err := artifact.NewDigestedArtifact(opts.OCIClient, opts.ArtifactPath, opts.DigestAlgorithm) + require.NoError(t, err) + + attestations, err := getAttestations(&opts, artifact.Digest()) + require.NoError(t, err) + + policy, err := buildVerifyPolicy(&opts, artifact.Digest()) + require.NoError(t, err) + + config := SigstoreConfig{ + CustomTrustedRoot: opts.CustomTrustedRoot, + Logger: opts.Logger, + NoPublicGood: opts.NoPublicGood, + } + + v, err := NewSigstoreVerifier(config, policy) + require.NoError(t, err) + + resp := v.Verify(attestations) + assert.NotNil(t, resp.Error) + assert.Nil(t, resp.VerifyResults) +} diff --git a/pkg/cmd/attestation/verify/options.go b/pkg/cmd/attestation/verify/options.go new file mode 100644 index 000000000..2af3a9d6b --- /dev/null +++ b/pkg/cmd/attestation/verify/options.go @@ -0,0 +1,156 @@ +package verify + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/github/gh-attestation/pkg/artifact/digest" + "github.com/github/gh-attestation/pkg/artifact/oci" + "github.com/github/gh-attestation/pkg/github" + "github.com/github/gh-attestation/pkg/output" +) + +const ( + // OfflineMode is used when the user provides a bundle path + OfflineMode = "offline" + // OnlineMode is used when the user does not provide a bundle path + // An owner or repo scope is used to fetch attestations from GitHub + OnlineMode = "online" +) + +// Options captures the options for the verify command +type Options struct { + ArtifactPath string + BundlePath string + CustomTrustedRoot string + DenySelfHostedRunner bool + DigestAlgorithm string + JsonResult bool + NoPublicGood bool + OIDCIssuer string + Owner string + Quiet bool + Repo string + SAN string + SANRegex string + Verbose bool + GitHubClient github.Client + Logger *output.Logger + Limit int + OCIClient oci.Client +} + +// Mode returns a string indicating either online or offline mode +func (opts *Options) Mode() string { + if opts.BundlePath == "" { + return OnlineMode + } + return OfflineMode +} + +// ConfigureGitHubClient configures a live GitHub client if the tool +// is running in online mode +func (opts *Options) ConfigureGitHubClient(version, date string) error { + if opts.Mode() == OfflineMode { + return nil + } + + client, err := github.NewLiveClient(version, date) + if err != nil { + return err + } + opts.GitHubClient = client + return nil +} + +// ConfigureOCIClient configures an OCI client +func (opts *Options) ConfigureOCIClient() { + opts.OCIClient = oci.NewLiveClient() +} + +// ConfigureLogger configures a logger using configuration provided +// through the options +func (opts *Options) ConfigureLogger() { + opts.Logger = output.NewLogger(opts.Quiet, opts.Verbose) +} + +// Clean cleans the file path option values +func (opts *Options) Clean() { + if opts.BundlePath != "" { + opts.BundlePath = filepath.Clean(opts.BundlePath) + } +} + +func (opts *Options) SetPolicyFlags() { + // check that Repo is in the expected format if provided + 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 + opts.Owner = splitRepo[0] + + if opts.SAN == "" && opts.SANRegex == "" { + opts.SANRegex = expandToGitHubURL(opts.Repo) + } + return + } + if opts.SAN == "" && opts.SANRegex == "" { + opts.SANRegex = expandToGitHubURL(opts.Owner) + } +} + +// AreFlagsValid checks that the provided flag combination is valid +// and returns an error otherwise +func (opts *Options) AreFlagsValid() error { + // either BundlePath or Repo must be set to configure offline or online mode + if opts.BundlePath == "" && opts.Repo == "" && opts.Owner == "" { + return fmt.Errorf("either bundle or repo or owner must be provided") + } + + // DigestAlgorithm must not be empty + if opts.DigestAlgorithm == "" { + return fmt.Errorf("digest-alg cannot be empty") + } + + if !digest.IsValidDigestAlgorithm(opts.DigestAlgorithm) { + return fmt.Errorf("invalid digtest algorithm '%s' provided in digest-alg", opts.DigestAlgorithm) + } + + // OIDCIssuer must not be empty + if opts.OIDCIssuer == "" { + return fmt.Errorf("cert-oidc-issuer cannot be empty") + } + + // either Owner or Repo must be supplied + if opts.Owner == "" && opts.Repo == "" { + return fmt.Errorf("owner or repo must be provided") + } + + // SAN or SAN regex are mutually exclusive, only one can be provided + if opts.SAN != "" && opts.SANRegex != "" { + return fmt.Errorf("cert-identity and cert-identity-regex cannot both be provided") + } + + // check that Repo is in the expected format if provided + if opts.Repo != "" { + // we expect the repo argument to be in the format / + splitRepo := strings.Split(opts.Repo, "/") + if len(splitRepo) != 2 { + 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) + } + + return nil +} + +func expandToGitHubURL(ownerOrRepo string) string { + return fmt.Sprintf("^https://github.com/%s/", ownerOrRepo) +} diff --git a/pkg/cmd/attestation/verify/options_test.go b/pkg/cmd/attestation/verify/options_test.go new file mode 100644 index 000000000..ee947f7b6 --- /dev/null +++ b/pkg/cmd/attestation/verify/options_test.go @@ -0,0 +1,229 @@ +package verify + +import ( + "testing" + + "github.com/github/gh-attestation/test" + + "github.com/stretchr/testify/assert" +) + +var ( + publicGoodArtifactPath = test.NormalizeRelativePath("../../test/data/public-good/sigstore-js-2.1.0.tgz") + publicGoodBundlePath = test.NormalizeRelativePath("../../test/data/public-good/sigstore-js-2.1.0-bundle.json") +) + +func TestAreFlagsValid(t *testing.T) { + t.Run("missing BundlePath, Repo, and Owner", func(t *testing.T) { + opts := Options{ + ArtifactPath: publicGoodArtifactPath, + DigestAlgorithm: "sha512", + OIDCIssuer: "some issuer", + } + + err := opts.AreFlagsValid() + assert.Error(t, err) + assert.ErrorContains(t, err, "either bundle or repo or owner must be provided") + }) + + t.Run("missing DigestAlgorithm", func(t *testing.T) { + opts := Options{ + ArtifactPath: publicGoodArtifactPath, + BundlePath: publicGoodBundlePath, + OIDCIssuer: "some issuer", + Owner: "sigstore", + } + + err := opts.AreFlagsValid() + assert.Error(t, err) + assert.ErrorContains(t, err, "digest-alg cannot be empty") + }) + + t.Run("missing Owner and Repo", func(t *testing.T) { + opts := Options{ + ArtifactPath: publicGoodArtifactPath, + BundlePath: publicGoodBundlePath, + DigestAlgorithm: "sha512", + OIDCIssuer: "some issuer", + } + + err := opts.AreFlagsValid() + assert.Error(t, err) + assert.ErrorContains(t, err, "owner or repo must be provided") + }) + + t.Run("has both SAN and SANRegex", func(t *testing.T) { + opts := Options{ + ArtifactPath: publicGoodArtifactPath, + BundlePath: publicGoodBundlePath, + DigestAlgorithm: "sha512", + OIDCIssuer: "some issuer", + Owner: "sigstore", + SAN: "some san", + SANRegex: "^some san regex$", + } + + err := opts.AreFlagsValid() + assert.Error(t, err) + assert.ErrorContains(t, err, "cert-identity and cert-identity-regex cannot both be provided") + }) + + t.Run("has invalid Repo value", func(t *testing.T) { + opts := Options{ + ArtifactPath: publicGoodArtifactPath, + DigestAlgorithm: "sha512", + OIDCIssuer: "some issuer", + Repo: "sigstoresigstore-js", + } + + err := opts.AreFlagsValid() + assert.Error(t, err) + assert.ErrorContains(t, err, "invalid value provided for repo") + }) + + t.Run("missing OIDCIssuer", func(t *testing.T) { + opts := Options{ + ArtifactPath: publicGoodArtifactPath, + BundlePath: publicGoodBundlePath, + DigestAlgorithm: "sha512", + Owner: "sigstore", + } + + err := opts.AreFlagsValid() + assert.Error(t, err) + assert.ErrorContains(t, err, "cert-oidc-issuer cannot be empty") + }) + + t.Run("invalid limit < 0", func(t *testing.T) { + opts := Options{ + ArtifactPath: publicGoodArtifactPath, + BundlePath: publicGoodBundlePath, + DigestAlgorithm: "sha512", + Owner: "sigstore", + OIDCIssuer: "some issuer", + Limit: 0, + } + + err := opts.AreFlagsValid() + assert.Error(t, err) + assert.ErrorContains(t, err, "limit 0 not allowed, must be between 1 and 1000") + }) + + t.Run("invalid limit > 1000", func(t *testing.T) { + opts := Options{ + ArtifactPath: publicGoodArtifactPath, + BundlePath: publicGoodBundlePath, + DigestAlgorithm: "sha512", + Owner: "sigstore", + OIDCIssuer: "some issuer", + Limit: 1001, + } + + err := opts.AreFlagsValid() + assert.Error(t, err) + assert.ErrorContains(t, err, "limit 1001 not allowed, must be between 1 and 1000") + }) +} + +func TestSetPolicyFlags(t *testing.T) { + t.Run("sets Owner and SANRegex when Repo is provided", func(t *testing.T) { + opts := Options{ + ArtifactPath: publicGoodArtifactPath, + DigestAlgorithm: "sha512", + OIDCIssuer: "some issuer", + Repo: "sigstore/sigstore-js", + } + + opts.SetPolicyFlags() + assert.Equal(t, "sigstore", opts.Owner) + assert.Equal(t, "sigstore/sigstore-js", opts.Repo) + assert.Equal(t, "^https://github.com/sigstore/sigstore-js/", opts.SANRegex) + }) + + t.Run("does not set SANRegex when SANRegex and Repo are provided", func(t *testing.T) { + opts := Options{ + ArtifactPath: publicGoodArtifactPath, + DigestAlgorithm: "sha512", + OIDCIssuer: "some issuer", + Repo: "sigstore/sigstore-js", + SANRegex: "^https://github/foo", + } + + opts.SetPolicyFlags() + assert.Equal(t, "sigstore", opts.Owner) + assert.Equal(t, "sigstore/sigstore-js", opts.Repo) + assert.Equal(t, "^https://github/foo", opts.SANRegex) + }) + + t.Run("sets SANRegex when Owner is provided", func(t *testing.T) { + opts := Options{ + ArtifactPath: publicGoodArtifactPath, + BundlePath: publicGoodBundlePath, + DigestAlgorithm: "sha512", + OIDCIssuer: "some issuer", + Owner: "sigstore", + } + + opts.SetPolicyFlags() + assert.Equal(t, "sigstore", opts.Owner) + assert.Equal(t, "^https://github.com/sigstore/", opts.SANRegex) + }) + + t.Run("does not set SANRegex when SANRegex and Owner are provided", func(t *testing.T) { + opts := Options{ + ArtifactPath: publicGoodArtifactPath, + BundlePath: publicGoodBundlePath, + DigestAlgorithm: "sha512", + OIDCIssuer: "some issuer", + Owner: "sigstore", + SANRegex: "^https://github/foo", + } + + opts.SetPolicyFlags() + assert.Equal(t, "sigstore", opts.Owner) + assert.Equal(t, "^https://github/foo", opts.SANRegex) + }) +} + +func TestClean(t *testing.T) { + t.Skip() + validBundlePath := "foo/attestation.json" + opts := &Options{ + BundlePath: validBundlePath, + } + + opts.Clean() + assert.Equal(t, validBundlePath, opts.BundlePath) +} + +func TestMode(t *testing.T) { + t.Run("run in offline mode when bundle is provided", func(t *testing.T) { + opts := Options{ + ArtifactPath: publicGoodArtifactPath, + BundlePath: publicGoodBundlePath, + } + + assert.Equal(t, OfflineMode, opts.Mode()) + }) + + t.Run("run in offline mode when bundle and repo are provided", func(t *testing.T) { + opts := &Options{ + ArtifactPath: publicGoodArtifactPath, + BundlePath: publicGoodBundlePath, + DigestAlgorithm: "sha512", + Repo: "sigstore/sigstore-js", + } + + assert.Equal(t, OfflineMode, opts.Mode()) + }) + + t.Run("run in online mode when repo are provided", func(t *testing.T) { + opts := &Options{ + ArtifactPath: publicGoodArtifactPath, + DigestAlgorithm: "sha512", + Repo: "sigstore/sigstore-js", + } + + assert.Equal(t, OnlineMode, opts.Mode()) + }) +} diff --git a/pkg/cmd/attestation/verify/policy.go b/pkg/cmd/attestation/verify/policy.go new file mode 100644 index 000000000..4a1daabfa --- /dev/null +++ b/pkg/cmd/attestation/verify/policy.go @@ -0,0 +1,22 @@ +package verify + +import ( + "github.com/sigstore/sigstore-go/pkg/verify" + + "github.com/cli/cli/v2/pkg/cmd/attestation/verification" +) + +func buildVerifyPolicy(opts *Options, artifactDigest string) (verify.PolicyBuilder, error) { + artifactDigestPolicyOption, err := verification.BuildDigestPolicyOption(artifactDigest, opts.DigestAlgorithm) + if err != nil { + return verify.PolicyBuilder{}, err + } + + certIdOption, err := buildVerifyCertIdOption(opts) + if err != nil { + return verify.PolicyBuilder{}, err + } + + policy := verify.NewPolicy(artifactDigestPolicyOption, certIdOption) + return policy, nil +} diff --git a/pkg/cmd/attestation/verify/verify.go b/pkg/cmd/attestation/verify/verify.go new file mode 100644 index 000000000..a4662df6b --- /dev/null +++ b/pkg/cmd/attestation/verify/verify.go @@ -0,0 +1,205 @@ +package verify + +import ( + "os" + + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/pkg/cmd/attestation/pkg/github" + + "github.com/MakeNowJust/heredoc" + "github.com/spf13/cobra" +) + +func NewVerifyCmd(f *cmdutil.Factory) *cobra.Command { + opts := &verify.Options{} + verifyCmd := &cobra.Command{ + Use: "verify ", + Args: cobra.ExactArgs(1), + Short: "Cryptographically verify an artifact", + Long: heredoc.Docf(` + Cryptographically verify the authenticity of an artifact using the + associated trusted metadata. + + The command accepts either: + * a relative path to a local artifact + * a container image URI (e.g. oci://) + + Note that you must already be authenticated with a container registry + if you provide an OCI image URI as the artifact. + + The command also requires you provide either the %[1]s--owner%[1]s + or %[1]s--repo%[1]s flag. + The value of the %[1]s--owner%[1]s flag should be the name of the GitHub organization + that the artifact is associated with. + The value of the %[1]s--repo%[1]s flag should be the name of the GitHub repository that + the artifact is associated with. + + By default, the command will verify the artifact against trusted metadata stored remotely. + If you would like to verify the artifact against local metadata, + you can provide a path to the local trusted metadata bundle file with the + %[1]s--bundle%[1]s flag. + + By default, the command will use the SHA-256 hash algorithm to create the artifact digest + used for verification. + You can specify the SHA-512 algorithm instead using the %[1]s--digest-alg%[1]s flag. + + If the %[1]s--json-result%[1]s flag is provided, the command will print the verification + results as JSON. + `, "`"), + Example: heredoc.Doc(` + # Verify a local artifact with the repository name + $ gh attestation verify --repo + + # Verify a local artifact with the organization name + $ gh attestation verify --owner + + # Verify an OCI image using a local trusted metadata bundle + $ gh attestation verify oci:// --owner --bundle + `), + // PreRunE is used to validate flags before the command is run + // If an error is returned, its message will be printed to the terminal + // along with information about how use the command + PreRunE: func(cmd *cobra.Command, args []string) error { + // Create a logger for use throughout the verify command + opts.ConfigureLogger() + + // Configure the live OCI client + opts.ConfigureOCIClient() + + // 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() + + // set policy flags based on what has been provided + opts.SetPolicyFlags() + + return nil + }, + // Use Run instead of RunE because if an error is returned by RunVerify + // when RunE is used, the command usage will be printed + // We only want to print the error, not usage + Run: func(cmd *cobra.Command, args []string) { + // Configure the GitHub API client + apiClient := api.NewClientFromHTTP(httpClient) + + if err := runVerify(opts); err != nil { + opts.Logger.Println(opts.Logger.ColorScheme.Redf("Failed to verify the artifact: %s", err.Error())) + os.Exit(1) + } + }, + } + + // general flags + verifyCmd.Flags().StringVarP(&opts.BundlePath, "bundle", "b", "", "Path to bundle on disk, either a single bundle in a JSON file or a JSON lines file with multiple bundles") + verifyCmd.Flags().StringVarP(&opts.DigestAlgorithm, "digest-alg", "d", "sha256", "The algorithm used to compute a digest of the artifact (sha256 or sha512)") + verifyCmd.Flags().StringVarP(&opts.Owner, "owner", "o", "", "GitHub organization to scope attestation lookup by") + verifyCmd.Flags().StringVarP(&opts.Repo, "repo", "R", "", "Repository name in the format /") + verifyCmd.MarkFlagsMutuallyExclusive("owner", "repo") + verifyCmd.Flags().BoolVarP(&opts.NoPublicGood, "no-public-good", "", false, "Only verify attestations signed with GitHub's Sigstore instance") + verifyCmd.Flags().BoolVarP(&opts.JsonResult, "json-result", "j", false, "Output verification result as JSON lines") + verifyCmd.Flags().BoolVarP(&opts.Quiet, "quiet", "q", false, "If set to true, the CLI will not print any diagnostic logging.") + verifyCmd.Flags().BoolVarP(&opts.Verbose, "verbose", "v", false, "If set to true, the CLI will print verbose diagnostic logging.") + verifyCmd.MarkFlagsMutuallyExclusive("quiet", "verbose") + verifyCmd.Flags().StringVarP(&opts.CustomTrustedRoot, "custom-trusted-root", "", "", "Path to a custom trustedroot.json file to use for verification") + verifyCmd.Flags().IntVarP(&opts.Limit, "limit", "L", github.DefaultLimit, "Maximum number of attestations to fetch") + // policy enforcement flags + verifyCmd.Flags().BoolVarP(&opts.DenySelfHostedRunner, "deny-self-hosted-runners", "", false, "Fail verification for attestations generated on self-hosted runners.") + verifyCmd.Flags().StringVarP(&opts.SAN, "cert-identity", "", "", "Enforce that the certificate's subject alternative name matches the provided value exactly") + verifyCmd.Flags().StringVarP(&opts.SANRegex, "cert-identity-regex", "i", "", "Enforce that the certificate's subject alternative name matches the provided regex") + verifyCmd.MarkFlagsMutuallyExclusive("cert-identity", "cert-identity-regex") + verifyCmd.Flags().StringVarP(&opts.OIDCIssuer, "cert-oidc-issuer", "", verify.GitHubOIDCIssuer, "Issuer of the OIDC token") + + return verifyCmd +} + +func runVerify(opts *Options) error { + artifact, err := artifact.NewDigestedArtifact(opts.OCIClient, opts.ArtifactPath, opts.DigestAlgorithm) + if err != nil { + return fmt.Errorf("failed to digest artifact: %s", err) + } + + opts.Logger.Printf("Verifying attestations for the artifact found at %s\n", artifact.URL) + + attestations, err := getAttestations(opts, artifact.DigestWithAlg()) + if err != nil { + if ok := errors.Is(err, github.ErrNoAttestations{}); ok { + return fmt.Errorf("no attestations found for subject: %s", artifact.DigestWithAlg()) + } + return fmt.Errorf("failed to fetch attestations for subject: %s", artifact.DigestWithAlg()) + } + + policy, err := buildVerifyPolicy(opts, artifact.Digest()) + if err != nil { + return fmt.Errorf("failed to build policy: %w", err) + } + + config := SigstoreConfig{ + CustomTrustedRoot: opts.CustomTrustedRoot, + Logger: opts.Logger, + NoPublicGood: opts.NoPublicGood, + } + + sv, err := NewSigstoreVerifier(config, policy) + if err != nil { + return err + } + + sigstoreRes := sv.Verify(attestations) + if sigstoreRes.Error != nil { + return fmt.Errorf("at least one attestation failed to verify against Sigstore: %w", sigstoreRes.Error) + } + + opts.Logger.VerbosePrint(opts.Logger.ColorScheme.Green( + "Successfully verified all attestations against Sigstore!\n\n", + )) + + // Try verifying the attestation's predicate type against the expect SLSA predicate type + if err = verifySLSAPredicateType(opts.Logger, sigstoreRes.VerifyResults); err != nil { + return fmt.Errorf("at least one attestation failed to verify predicate type verification: %w", err) + } + + opts.Logger.VerbosePrint(opts.Logger.ColorScheme.Green("Successfully verified the SLSA predicate type of all attestations!\n")) + + opts.Logger.Println(opts.Logger.ColorScheme.Green("All attestations have been successfully verified!")) + + if opts.JsonResult { + verificationResults := sigstoreRes.VerifyResults + // print each result as JSON line + + jsonResults := make([]string, len(verificationResults)) + for i, verificationResult := range verificationResults { + jsonBytes, err := json.Marshal(verificationResult) + if err != nil { + return fmt.Errorf("failed to create JSON output") + } + + jsonResults[i] = string(jsonBytes) + } + + rows := make([][]string, 1) + rows[0] = jsonResults + opts.Logger.PrintTableToStdOut(nil, rows) + } + + // All attestations passed verification and policy evaluation + return nil +} + +func verifySLSAPredicateType(logger *output.Logger, apr []*AttestationProcessingResult) error { + logger.VerbosePrintf("Evaluating attestations have valid SLSA predicate type...\n") + + for _, result := range apr { + if result.VerificationResult.Statement.PredicateType != SLSAPredicateType { + return ErrNoMatchingSLSAPredicate + } + } + + return nil +}