add inspect cmd
Signed-off-by: Meredith Lancaster <malancas@github.com>
This commit is contained in:
parent
cbd57deb11
commit
884fe225d1
7 changed files with 519 additions and 0 deletions
134
pkg/cmd/attestation/inspect/bundle.go
Normal file
134
pkg/cmd/attestation/inspect/bundle.go
Normal 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
|
||||
}
|
||||
47
pkg/cmd/attestation/inspect/bundle_test.go
Normal file
47
pkg/cmd/attestation/inspect/bundle_test.go
Normal 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)
|
||||
}
|
||||
154
pkg/cmd/attestation/inspect/inspect.go
Normal file
154
pkg/cmd/attestation/inspect/inspect.go
Normal 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
|
||||
}
|
||||
66
pkg/cmd/attestation/inspect/inspect_test.go
Normal file
66
pkg/cmd/attestation/inspect/inspect_test.go
Normal 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))
|
||||
})
|
||||
}
|
||||
52
pkg/cmd/attestation/inspect/options.go
Normal file
52
pkg/cmd/attestation/inspect/options.go
Normal 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
|
||||
}
|
||||
48
pkg/cmd/attestation/inspect/options_test.go
Normal file
48
pkg/cmd/attestation/inspect/options_test.go
Normal 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")
|
||||
})
|
||||
}
|
||||
18
pkg/cmd/attestation/inspect/policy.go
Normal file
18
pkg/cmd/attestation/inspect/policy.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue