diff --git a/pkg/cmd/attestation/inspect/bundle.go b/pkg/cmd/attestation/inspect/bundle.go new file mode 100644 index 000000000..235d2c0f5 --- /dev/null +++ b/pkg/cmd/attestation/inspect/bundle.go @@ -0,0 +1,134 @@ +package inspect + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/cli/cli/v2/pkg/cmd/attestation/api" + "github.com/cli/cli/v2/pkg/cmd/attestation/verification" +) + +type workflow struct { + Repository string `json:"repository"` +} + +type externalParameters struct { + Workflow workflow `json:"workflow"` +} + +type githubInfo struct { + RepositoryID string `json:"repository_id"` + RepositoryOwnerId string `json:"repository_owner_id"` +} + +type internalParameters struct { + GitHub githubInfo `json:"github"` +} + +type buildDefinition struct { + ExternalParameters externalParameters `json:"externalParameters"` + InternalParameters internalParameters `json:"internalParameters"` +} + +type metadata struct { + InvocationID string `json:"invocationId"` +} + +type runDetails struct { + Metadata metadata `json:"metadata"` +} + +// Predicate captures the predicate of a given attestation +type Predicate struct { + BuildDefinition buildDefinition `json:"buildDefinition"` + RunDetails runDetails `json:"runDetails"` +} + +// AttestationDetail captures attestation source details +// that will be returned by the inspect command +type AttestationDetail struct { + OrgName string `json:"orgName"` + OrgID string `json:"orgId"` + RepositoryName string `json:"repositoryName"` + RepositoryID string `json:"repositoryId"` + WorkflowID string `json:"workflowId"` +} + +// AttestationDetail#Slice returns the fields as string slice in the following order: +// RepositoryName, RepositoryID, OrgName, OrgID, WorkflowID +func (d AttestationDetail) Slice() []string { + return []string{d.RepositoryName, d.RepositoryID, d.OrgName, d.OrgID, d.WorkflowID} +} + +func getOrgAndRepo(repoURL string) (string, string, error) { + after, found := strings.CutPrefix(repoURL, "https://github.com/") + if !found { + return "", "", fmt.Errorf("failed to get org and repo from %s", repoURL) + } + + parts := strings.Split(after, "/") + return parts[0], parts[1], nil +} + +func getAttestationDetail(attr api.Attestation) (AttestationDetail, error) { + envelope, err := attr.Bundle.Envelope() + if err != nil { + return AttestationDetail{}, fmt.Errorf("failed to get envelope from bundle: %w", err) + } + + statement, err := envelope.EnvelopeContent().Statement() + if err != nil { + return AttestationDetail{}, fmt.Errorf("failed to get statement from envelope: %w", err) + } + + var predicate Predicate + predicateJson, err := json.Marshal(statement.Predicate) + if err != nil { + return AttestationDetail{}, fmt.Errorf("failed to marshal predicate: %w", err) + } + + err = json.Unmarshal(predicateJson, &predicate) + if err != nil { + return AttestationDetail{}, fmt.Errorf("failed to unmarshal predicate: %w", err) + } + + org, repo, err := getOrgAndRepo(predicate.BuildDefinition.ExternalParameters.Workflow.Repository) + if err != nil { + return AttestationDetail{}, fmt.Errorf("failed to parse attestation content: %w", err) + } + + return AttestationDetail{ + OrgName: org, + OrgID: predicate.BuildDefinition.InternalParameters.GitHub.RepositoryOwnerId, + RepositoryName: repo, + RepositoryID: predicate.BuildDefinition.InternalParameters.GitHub.RepositoryID, + WorkflowID: predicate.RunDetails.Metadata.InvocationID, + }, nil +} + +func getDetailsAsSlice(results []*verification.AttestationProcessingResult) ([][]string, error) { + details := make([][]string, len(results)) + + for i, result := range results { + detail, err := getAttestationDetail(*result.Attestation) + if err != nil { + return nil, fmt.Errorf("failed to get attestation detail: %w", err) + } + details[i] = detail.Slice() + } + return details, nil +} + +func getAttestationDetails(results []*verification.AttestationProcessingResult) ([]AttestationDetail, error) { + details := make([]AttestationDetail, len(results)) + + for i, result := range results { + detail, err := getAttestationDetail(*result.Attestation) + if err != nil { + return nil, fmt.Errorf("failed to get attestation detail: %w", err) + } + details[i] = detail + } + return details, nil +} diff --git a/pkg/cmd/attestation/inspect/bundle_test.go b/pkg/cmd/attestation/inspect/bundle_test.go new file mode 100644 index 000000000..4ef85f4e7 --- /dev/null +++ b/pkg/cmd/attestation/inspect/bundle_test.go @@ -0,0 +1,47 @@ +package inspect + +import ( + "testing" + + "github.com/cli/cli/v2/pkg/cmd/attestation/verification" + "github.com/cli/cli/v2/pkg/cmd/attestation/test" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetOrgAndRepo(t *testing.T) { + t.Run("with valid source URL", func(t *testing.T) { + sourceURL := "https://github.com/github/gh-attestation" + org, repo, err := getOrgAndRepo(sourceURL) + assert.Nil(t, err) + assert.Equal(t, "github", org) + assert.Equal(t, "gh-attestation", repo) + }) + + t.Run("with invalid source URL", func(t *testing.T) { + sourceURL := "hub.com/github/gh-attestation" + org, repo, err := getOrgAndRepo(sourceURL) + assert.Error(t, err) + assert.Zero(t, org) + assert.Zero(t, repo) + }) +} + +func TestGetAttestationDetail(t *testing.T) { + bundlePath := test.NormalizeRelativePath("../test/data/sigstore-js-2.1.0-bundle.json") + + attestations, err := verification.GetLocalAttestations(bundlePath) + require.Len(t, attestations, 1) + require.NoError(t, err) + + attestation := attestations[0] + detail, err := getAttestationDetail(*attestation) + assert.NoError(t, err) + + assert.Equal(t, "sigstore", detail.OrgName) + assert.Equal(t, "71096353", detail.OrgID) + assert.Equal(t, "sigstore-js", detail.RepositoryName) + assert.Equal(t, "495574555", detail.RepositoryID) + assert.Equal(t, "https://github.com/sigstore/sigstore-js/actions/runs/6014488666/attempts/1", detail.WorkflowID) +} diff --git a/pkg/cmd/attestation/inspect/inspect.go b/pkg/cmd/attestation/inspect/inspect.go new file mode 100644 index 000000000..68a925f07 --- /dev/null +++ b/pkg/cmd/attestation/inspect/inspect.go @@ -0,0 +1,154 @@ +package inspect + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/cli/cli/v2/pkg/cmd/attestation/artifact" + "github.com/cli/cli/v2/pkg/cmd/attestation/verification" + + "github.com/MakeNowJust/heredoc" + "github.com/spf13/cobra" +) + +func NewInspectCmd() *cobra.Command { + opts := &Options{} + inspectCmd := &cobra.Command{ + Use: "inspect [ | oci://]", + Args: cobra.ExactArgs(1), + Short: "Inspect an artifact's trusted metadata bundle", + Long: heredoc.Docf(` + Inspect downloaded trusted metadata associated with a given artifact. + + The command accepts either: + * a relative path to a local artifact + * a container image URI (e.g. %[1]soci://%[1]s) + + 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 the path a local trusted metadata bundle with + the %[1]s--bundle%[1]s flag. + You can download a trusted metadata bundle using the %[1]sdownload%[1]s command. + + By default, the command will print information about the bundle in a table format. + If the %[1]s--json-result%[1]s flag is provided, the command will print the + information in JSON format. + `, "`"), + Example: heredoc.Doc(` + # Inspect a local artifact bundle and print the results in table format + $ gh attestation inspect --bundle + + # Inspect a local artifact bundle and print the results in JSON format + $ gh attestation inspect --bundle --json-result + + # Inspect an OCI image bundle and print the results in table format + $ gh attestation inspect oci:// --bundle + `), + PreRunE: func(cmd *cobra.Command, args []string) error { + // Create a logger for use throughout the inspect command + opts.ConfigureLogger() + + // 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() + + return nil + }, + // Use Run instead of RunE because if an error is returned by RunInspect + // 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) { + if err := RunInspect(opts); err != nil { + opts.Logger.Println(opts.Logger.ColorScheme.Redf("Failed to inspect the artifact and bundle: %s", err.Error())) + os.Exit(1) + } + }, + } + + inspectCmd.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") + inspectCmd.MarkFlagRequired("bundle") //nolint:errcheck + inspectCmd.Flags().StringVarP(&opts.DigestAlgorithm, "digest-alg", "d", "sha256", "The algorithm used to compute a digest of the artifact (sha256 or sha512)") + inspectCmd.Flags().BoolVarP(&opts.JsonResult, "json-result", "j", false, "Output inspect result as JSON lines") + + return inspectCmd +} + + +func RunInspect(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\n", artifact.URL) + + attestations, err := verification.GetLocalAttestations(opts.BundlePath) + if err != nil { + return fmt.Errorf("failed to read attestations for subject: %s", artifact.DigestWithAlg()) + } + + config := verification.SigstoreConfig{ + Logger: opts.Logger, + } + + policy, err := buildPolicy(*artifact) + if err != nil { + return fmt.Errorf("failed to build policy: %w", err) + } + + sigstore, err := verification.NewSigstoreVerifier(config, policy) + if err != nil { + return err + } + + res := sigstore.Verify(attestations) + if res.Error != nil { + return fmt.Errorf("at least one attestation failed to verify against Sigstore: %w", res.Error) + } + + opts.Logger.VerbosePrint(opts.Logger.ColorScheme.Green( + "Successfully verified all attestations against Sigstore!\n\n", + )) + + if opts.JsonResult { + details, err := getAttestationDetails(res.VerifyResults) + if err != nil { + return fmt.Errorf("failed to get attestation detail: %w", err) + } + + jsonResults := make([]string, len(details)) + for i, detail := range details { + jsonBytes, err := json.Marshal(detail) + 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) + + return nil + } + + details, err := getDetailsAsSlice(res.VerifyResults) + if err != nil { + return fmt.Errorf("failed to parse attestation details: %w", err) + } + + headerRow := []string{"Repo Name", "Repo ID", "Org Name", "Org ID", "Workflow ID"} + opts.Logger.PrintTableToStdOut(headerRow, details) + + return nil +} diff --git a/pkg/cmd/attestation/inspect/inspect_test.go b/pkg/cmd/attestation/inspect/inspect_test.go new file mode 100644 index 000000000..3b94bbc0d --- /dev/null +++ b/pkg/cmd/attestation/inspect/inspect_test.go @@ -0,0 +1,66 @@ +package inspect + +import ( + "testing" + + "github.com/cli/cli/v2/pkg/cmd/attestation/logger" + "github.com/cli/cli/v2/pkg/cmd/attestation/test" + + "github.com/stretchr/testify/assert" +) + +const ( + SigstoreSanValue = "https://github.com/sigstore/sigstore-js/.github/workflows/release.yml@refs/heads/main" + SigstoreSanRegex = "^https://github.com/sigstore/sigstore-js/" +) + +var ( + artifactPath = test.NormalizeRelativePath("../test/data/sigstore-js-2.1.0.tgz") + bundlePath = test.NormalizeRelativePath("../test/data/sigstore-js-2.1.0-bundle.json") +) + +func TestRunInspect(t *testing.T) { + res := test.SuppressAndRestoreOutput() + defer res() + + logger := logger.NewDefaultLogger() + + opts := Options{ + ArtifactPath: artifactPath, + BundlePath: bundlePath, + DigestAlgorithm: "sha512", + Logger: logger, + } + + t.Run("with valid artifact and bundle", func(t *testing.T) { + assert.Nil(t, RunInspect(&opts)) + }) + + t.Run("with missing artifact path", func(t *testing.T) { + customOpts := opts + customOpts.ArtifactPath = "../test/data/non-existent-artifact.zip" + assert.Error(t, RunInspect(&customOpts)) + }) + + t.Run("with missing bundle path", func(t *testing.T) { + customOpts := opts + customOpts.BundlePath = "../test/data/non-existent-sigstoreBundle.json" + assert.Error(t, RunInspect(&customOpts)) + }) + + t.Run("with invalid signature", func(t *testing.T) { + customOpts := opts + customOpts.BundlePath = "../test/data/sigstoreBundle-invalid-signature.json" + + err := RunInspect(&customOpts) + assert.Error(t, err) + assert.ErrorContains(t, err, "at least one attestation failed to verify") + assert.ErrorContains(t, err, "verifying with issuer \"sigstore.dev\"") + }) + + t.Run("with valid artifact and JSON lines file containing multiple bundles", func(t *testing.T) { + customOpts := opts + customOpts.BundlePath = "../test/data/sigstore-js-2.1.0_with_2_bundles.jsonl" + assert.Nil(t, RunInspect(&customOpts)) + }) +} diff --git a/pkg/cmd/attestation/inspect/options.go b/pkg/cmd/attestation/inspect/options.go new file mode 100644 index 000000000..ce8c132f8 --- /dev/null +++ b/pkg/cmd/attestation/inspect/options.go @@ -0,0 +1,52 @@ +package inspect + +import ( + "fmt" + "path/filepath" + + "github.com/cli/cli/v2/pkg/cmd/attestation/artifact/digest" + "github.com/cli/cli/v2/pkg/cmd/attestation/artifact/oci" + "github.com/cli/cli/v2/pkg/cmd/attestation/logger" +) + +// Options captures the options for the inspect command +type Options struct { + ArtifactPath string + BundlePath string + DigestAlgorithm string + JsonResult bool + Verbose bool + Logger *logger.Logger + OCIClient oci.Client +} + +// Clean cleans the file path option values +func (opts *Options) Clean() { + opts.BundlePath = filepath.Clean(opts.BundlePath) +} + +// ConfigureLogger configures a logger using configuration provided +// through the options +func (opts *Options) ConfigureLogger() { + opts.Logger = logger.NewLogger(false, opts.Verbose) +} + +// 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 == "" { + return fmt.Errorf("bundle 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 digest algorithm '%s' provided in digest-alg", opts.DigestAlgorithm) + } + + return nil +} diff --git a/pkg/cmd/attestation/inspect/options_test.go b/pkg/cmd/attestation/inspect/options_test.go new file mode 100644 index 000000000..988eda5c7 --- /dev/null +++ b/pkg/cmd/attestation/inspect/options_test.go @@ -0,0 +1,48 @@ +package inspect + +import ( + "testing" + + "github.com/cli/cli/v2/pkg/cmd/attestation/test" + + "github.com/stretchr/testify/assert" +) + +func TestAreFlagsValid(t *testing.T) { + 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") + + t.Run("missing BundlePath", func(t *testing.T) { + opts := Options{ + ArtifactPath: artifactPath, + DigestAlgorithm: "sha512", + } + + err := opts.AreFlagsValid() + assert.Error(t, err) + assert.ErrorContains(t, err, "bundle must be provided") + }) + + t.Run("missing DigestAlgorithm", func(t *testing.T) { + opts := Options{ + ArtifactPath: artifactPath, + BundlePath: bundlePath, + } + + err := opts.AreFlagsValid() + assert.Error(t, err) + assert.ErrorContains(t, err, "digest-alg cannot be empty") + }) + + t.Run("invalid DigestAlgorithm", func(t *testing.T) { + opts := Options{ + ArtifactPath: artifactPath, + BundlePath: bundlePath, + DigestAlgorithm: "sha1", + } + + err := opts.AreFlagsValid() + assert.Error(t, err) + assert.ErrorContains(t, err, "invalid digest algorithm 'sha1' provided in digest-alg") + }) +} diff --git a/pkg/cmd/attestation/inspect/policy.go b/pkg/cmd/attestation/inspect/policy.go new file mode 100644 index 000000000..49313d35a --- /dev/null +++ b/pkg/cmd/attestation/inspect/policy.go @@ -0,0 +1,18 @@ +package inspect + +import ( + "github.com/cli/cli/v2/pkg/cmd/attestation/artifact" + "github.com/cli/cli/v2/pkg/cmd/attestation/verification" + + sigstoreVerify "github.com/sigstore/sigstore-go/pkg/verify" +) + +func buildPolicy(a artifact.DigestedArtifact) (sigstoreVerify.PolicyBuilder, error) { + artifactDigestPolicyOption, err := verification.BuildDigestPolicyOption(a) + if err != nil { + return sigstoreVerify.PolicyBuilder{}, err + } + + policy := sigstoreVerify.NewPolicy(artifactDigestPolicyOption, sigstoreVerify.WithoutIdentitiesUnsafe()) + return policy, nil +}