start moving over verify cmd and general verification code

Signed-off-by: Meredith Lancaster <malancas@github.com>
This commit is contained in:
Meredith Lancaster 2024-03-01 15:58:42 -07:00
parent 2ccc34afdc
commit a082815d81
10 changed files with 1067 additions and 9 deletions

6
go.mod
View file

@ -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

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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 <OWNER>/<REPO>
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 <OWNER>/<REPO>
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)
}

View file

@ -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())
})
}

View file

@ -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
}

View file

@ -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 <artifact-path-or-url>",
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://<my-OCI-image-URI>)
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 <my-artifact> --repo <repo-name>
# Verify a local artifact with the organization name
$ gh attestation verify <my-artifact> --owner <owner>
# Verify an OCI image using a local trusted metadata bundle
$ gh attestation verify oci://<my-OCI-image> --owner <owner> --bundle <path-to-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 <owner>/<repo>")
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
}