add inspect cmd

Signed-off-by: Meredith Lancaster <malancas@github.com>
This commit is contained in:
Meredith Lancaster 2024-03-04 13:55:05 -07:00
parent cbd57deb11
commit 884fe225d1
7 changed files with 519 additions and 0 deletions

View file

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

View file

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

View file

@ -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 [<file path> | oci://<OCI image URI>]",
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://<my-OCI-image-URI>%[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 <my-artifact> --bundle <path-to-bundle>
# Inspect a local artifact bundle and print the results in JSON format
$ gh attestation inspect <my-artifact> --bundle <path-to-bundle> --json-result
# Inspect an OCI image bundle and print the results in table format
$ gh attestation inspect oci://<my-OCI-image> --bundle <path-to-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
}

View file

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

View file

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

View file

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

View file

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