diff --git a/cmd/kosli/get.go b/cmd/kosli/get.go index 6608aba1f..8ce43bafc 100644 --- a/cmd/kosli/get.go +++ b/cmd/kosli/get.go @@ -26,6 +26,7 @@ func newGetCmd(out io.Writer) *cobra.Command { newGetTrailCmd(out), newGetPolicyCmd(out), newGetAttestationTypeCmd(out), + newGetAttestationCmd(out), ) return cmd } diff --git a/cmd/kosli/getAttestation.go b/cmd/kosli/getAttestation.go new file mode 100644 index 000000000..061fffc1e --- /dev/null +++ b/cmd/kosli/getAttestation.go @@ -0,0 +1,184 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/kosli-dev/cli/internal/output" + "github.com/kosli-dev/cli/internal/requests" + "github.com/spf13/cobra" +) + +const getAttestationShortDesc = `Get attestation by name from a specified trail or artifact. ` + +const getAttestationLongDesc = getAttestationShortDesc + ` +You can get an attestation from a trail or artifact using its name. The attestation name should be given +WITHOUT dot-notation. + +To get an attestation from a trail, specify the trail name using the --trail flag. +To get an attestation from an artifact, specify the artifact fingerprint using the --fingerprint flag. + +In both cases the flow must also be specified using the --flow flag. + +If there are multiple attestations with the same name on the trail or artifact, a list of all will be returned. +` + +const getAttestationExample = ` +# get an attestation from a trail (requires the --trail flag) +kosli get attestation attestationName \ + --flow flowName \ + --trail trailName + +# get an attestation from an artifact +kosli get attestation attestationName \ + --flow flowName \ + --fingerprint fingerprint +` + +type getAttestationOptions struct { + output string + flow string + trail string + fingerprint string +} + +type Attestation struct { + Name string `json:"attestation_name"` + Type string `json:"attestation_type"` + Compliance bool `json:"is_compliant"` + ArtifactFingerprint string `json:"artifact_fingerprint,omitempty"` + CreatedAt float64 `json:"created_at"` + GitCommitInfo *GitCommitInfo `json:"git_commit_info,omitempty"` + HtmlUrl string `json:"html_url"` +} + +type GitCommitInfo struct { + Sha1 string `json:"sha1"` + Author string `json:"author"` + Message string `json:"message"` + Branch string `json:"branch"` + Url string `json:"url,omitempty"` + Timestamp float64 `json:"timestamp"` +} + +func newGetAttestationCmd(out io.Writer) *cobra.Command { + o := new(getAttestationOptions) + cmd := &cobra.Command{ + Use: "attestation ATTESTATION-NAME", + Short: getAttestationShortDesc, + Long: getAttestationLongDesc, + Example: getAttestationExample, + Args: cobra.ExactArgs(1), + PreRunE: func(cmd *cobra.Command, args []string) error { + err := RequireGlobalFlags(global, []string{"Org", "ApiToken"}) + if err != nil { + return ErrorBeforePrintingUsage(cmd, err.Error()) + } + err = MuXRequiredFlags(cmd, []string{"trail", "fingerprint"}, true) + if err != nil { + return err + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + return o.run(out, args) + }, + } + + cmd.Flags().StringVarP(&o.output, "output", "o", "table", outputFlag) + cmd.Flags().StringVarP(&o.flow, "flow", "f", "", flowNameFlag) + cmd.Flags().StringVarP(&o.trail, "trail", "t", "", getAttestationTrailFlag) + cmd.Flags().StringVarP(&o.fingerprint, "fingerprint", "F", "", getAttestationFingerprintFlag) + + err := RequireFlags(cmd, []string{"flow"}) + if err != nil { + logger.Error("failed to configure required flags: %v", err) + } + + return cmd +} + +func (o *getAttestationOptions) run(out io.Writer, args []string) error { + var url string + baseUrl := fmt.Sprintf("%s/api/v2/attestations/%s/%s", global.Host, global.Org, o.flow) + if o.trail != "" { + url = fmt.Sprintf("%s/trail/%s", baseUrl, o.trail) + } + + if o.fingerprint != "" { + url = fmt.Sprintf("%s/artifact/%s", baseUrl, o.fingerprint) + } + + url = fmt.Sprintf("%s/%s", url, args[0]) + + reqParams := &requests.RequestParams{ + Method: http.MethodGet, + URL: url, + Token: global.ApiToken, + } + + response, err := kosliClient.Do(reqParams) + if err != nil { + return err + } + + return output.FormattedPrint(response.Body, o.output, out, 0, + map[string]output.FormatOutputFunc{ + "table": printAttestationsAsTable, + "json": output.PrintJson, + }) +} + +func printAttestationsAsTable(raw string, out io.Writer, pageNumber int) error { + var attestations []Attestation + err := json.Unmarshal([]byte(raw), &attestations) + if err != nil { + return err + } + + if len(attestations) == 0 { + logger.Info("No attestations found.") + return nil + } + + separator := "" + for _, attestation := range attestations { + rows := []string{} + rows = append(rows, fmt.Sprintf("Name:\t%s", attestation.Name)) + rows = append(rows, fmt.Sprintf("Type:\t%s", attestation.Type)) + rows = append(rows, fmt.Sprintf("Compliance:\t%t", attestation.Compliance)) + + createdAt, err := formattedTimestamp(attestation.CreatedAt, false) + if err != nil { + return err + } + rows = append(rows, fmt.Sprintf("Created at:\t%s", createdAt)) + + if attestation.ArtifactFingerprint != "" { + rows = append(rows, fmt.Sprintf("Artifact fingerprint:\t%s", attestation.ArtifactFingerprint)) + } + if attestation.GitCommitInfo != nil { + rows = append(rows, "Git Commit Info:") + rows = append(rows, fmt.Sprintf(" Sha1:\t%s", attestation.GitCommitInfo.Sha1)) + rows = append(rows, fmt.Sprintf(" Author:\t%s", attestation.GitCommitInfo.Author)) + rows = append(rows, fmt.Sprintf(" Branch:\t%s", attestation.GitCommitInfo.Branch)) + rows = append(rows, fmt.Sprintf(" Commit URL:\t%s", attestation.GitCommitInfo.Url)) + timestamp, err := formattedTimestamp(attestation.GitCommitInfo.Timestamp, false) + if err != nil { + return err + } + rows = append(rows, fmt.Sprintf(" Timestamp:\t%s", timestamp)) + } + + if attestation.HtmlUrl != "" { + rows = append(rows, fmt.Sprintf("Attestation URL:\t%s", attestation.HtmlUrl)) + } + + fmt.Print(separator) + separator = "\n" + tabFormattedPrint(out, []string{}, rows) + } + return nil +} diff --git a/cmd/kosli/getAttestation_test.go b/cmd/kosli/getAttestation_test.go new file mode 100644 index 000000000..84b6f4b10 --- /dev/null +++ b/cmd/kosli/getAttestation_test.go @@ -0,0 +1,120 @@ +package main + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +// Define the suite, and absorb the built-in basic suite +// functionality from testify - including a T() method which +// returns the current testing context +type GetAttestationCommandTestSuite struct { + suite.Suite + defaultKosliArguments string + flowName string + artifactName string + artifactPath string + fingerprint string + trailName string +} + +func (suite *GetAttestationCommandTestSuite) SetupTest() { + suite.flowName = "get-attestation" + suite.artifactName = "arti" + suite.artifactPath = "testdata/folder1/hello.txt" + suite.trailName = "cli-build-1" + global = &GlobalOpts{ + ApiToken: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6ImNkNzg4OTg5In0.e8i_lA_QrEhFncb05Xw6E_tkCHU9QfcY4OLTVUCHffY", + Org: "docs-cmd-test-user-shared", + Host: "http://localhost:8001", + } + suite.defaultKosliArguments = fmt.Sprintf(" --host %s --org %s --api-token %s", global.Host, global.Org, global.ApiToken) + + CreateFlowWithTemplate(suite.flowName, "testdata/valid_template.yml", suite.Suite.T()) + BeginTrail(suite.trailName, suite.flowName, "", suite.Suite.T()) + fingerprintOptions := &fingerprintOptions{ + artifactType: "file", + } + var err error + suite.fingerprint, err = GetSha256Digest(suite.artifactPath, fingerprintOptions, logger) + require.NoError(suite.Suite.T(), err) + CreateArtifactOnTrail(suite.flowName, suite.trailName, "cli", suite.fingerprint, suite.artifactName, suite.Suite.T()) + CreateGenericArtifactAttestation(suite.flowName, suite.trailName, suite.fingerprint, "first-artifact-attestation", suite.Suite.T()) + CreateGenericTrailAttestation(suite.flowName, suite.trailName, "first-trail-attestation", suite.Suite.T()) + CreateGenericArtifactAttestation(suite.flowName, suite.trailName, suite.fingerprint, "second-artifact-attestation", suite.Suite.T()) + CreateGenericTrailAttestation(suite.flowName, suite.trailName, "second-trail-attestation", suite.Suite.T()) +} + +func (suite *GetAttestationCommandTestSuite) TestGetAttestationCmd() { + tests := []cmdTestCase{ + { + wantError: false, + name: "if no attestation found, say so", + cmd: fmt.Sprintf(`get attestation non-existent-attestation --flow %s --trail %s %s`, suite.flowName, suite.trailName, suite.defaultKosliArguments), + golden: "No attestations found.\n", + }, + { + wantError: false, + name: "if no attestation found return empty list in json format", + cmd: fmt.Sprintf(`get attestation non-existent-attestation --flow %s --trail %s %s --output json`, suite.flowName, suite.trailName, suite.defaultKosliArguments), + golden: "[]\n", + }, + { + wantError: true, + name: "providing more than one argument fails", + cmd: fmt.Sprintf(`get attestation first-attestation second-attestation --flow %s --trail %s %s`, suite.flowName, suite.trailName, suite.defaultKosliArguments), + golden: "Error: accepts 1 arg(s), received 2\n", + }, + { + wantError: true, + name: "missing --flow fails", + cmd: fmt.Sprintf(`get attestation first-artifact-attestation --trail %s %s`, suite.trailName, suite.defaultKosliArguments), + golden: "Error: required flag(s) \"flow\" not set\n", + }, + { + wantError: true, + name: "missing --api-token fails", + cmd: fmt.Sprintf(`get attestation first-artifact-attestation --flow %s --org orgX`, suite.flowName), + golden: "Error: --api-token is not set\nUsage: kosli get attestation ATTESTATION-NAME [flags]\n", + }, + { + name: "getting an existing trail attestation works", + cmd: fmt.Sprintf(`get attestation first-trail-attestation --flow %s --trail %s %s`, suite.flowName, suite.trailName, suite.defaultKosliArguments), + }, + { + name: "getting an existing trail attestation with --output json works", + cmd: fmt.Sprintf(`get attestation first-trail-attestation --flow %s --trail %s --output json %s`, suite.flowName, suite.trailName, suite.defaultKosliArguments), + }, + { + name: "getting an existing artifact attestation works", + cmd: fmt.Sprintf(`get attestation first-artifact-attestation --flow %s --fingerprint %s %s`, suite.flowName, suite.fingerprint, suite.defaultKosliArguments), + }, + { + name: "getting an existing artifact attestation with --output json works", + cmd: fmt.Sprintf(`get attestation first-artifact-attestation --flow %s --fingerprint %s --output json %s`, suite.flowName, suite.fingerprint, suite.defaultKosliArguments), + }, + { + wantError: true, + name: "missing both trail and fingerprint fails", + cmd: fmt.Sprintf(`get attestation first-artifact-attestation --flow %s %s`, suite.flowName, suite.defaultKosliArguments), + golden: "Error: at least one of --trail, --fingerprint is required\n", + }, + { + wantError: true, + name: "providing both trail and fingerprint fails", + cmd: fmt.Sprintf(`get attestation first-artifact-attestation --flow %s --trail %s --fingerprint %s %s`, suite.flowName, suite.trailName, suite.fingerprint, suite.defaultKosliArguments), + golden: "Error: only one of --trail, --fingerprint is allowed\n", + }, + } + + runTestCmd(suite.Suite.T(), tests) +} + +// In order for 'go test' to run this suite, we need to create +// a normal test function and pass our suite to suite.Run +func TestGetAttestationCommandTestSuite(t *testing.T) { + suite.Run(t, new(GetAttestationCommandTestSuite)) +} diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index fc7d21cc9..28af62cbb 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -248,6 +248,8 @@ The ^.kosli_ignore^ will be treated as part of the artifact like any other file, attestationTypeJqFlag = "[optional] The attestation type evaluation JQ rules." envNameFlag = "The Kosli environment name to assert the artifact against." pathsWatchFlag = "[optional] Watch the filesystem for changes and report snapshots of artifacts running in specific filesystem paths to Kosli." + getAttestationFingerprintFlag = "[conditional] The fingerprint of the artifact for the attestation. Cannot be used together with --trail." + getAttestationTrailFlag = "[conditional] The name of the Kosli trailfor the attestation. Cannot be used together with --fingerprint." ) var global *GlobalOpts diff --git a/cmd/kosli/testHelpers.go b/cmd/kosli/testHelpers.go index 682a85c61..62f72f7aa 100644 --- a/cmd/kosli/testHelpers.go +++ b/cmd/kosli/testHelpers.go @@ -9,6 +9,7 @@ import ( "strings" "testing" + "github.com/kosli-dev/cli/internal/gitview" shellwords "github.com/mattn/go-shellwords" "github.com/pkg/errors" "github.com/spf13/cobra" @@ -415,3 +416,50 @@ func CreatePolicy(org, policyName string, t *testing.T) { err := o.run([]string{policyName, "testdata/policy-files/test-policy.yml"}) require.NoError(t, err, "policy should be created without error") } + +func CreateGenericArtifactAttestation(flowName, trailName, fingerprint, attestationName string, t *testing.T) { + t.Helper() + o := &attestGenericOptions{ + CommonAttestationOptions: &CommonAttestationOptions{ + flowName: flowName, + trailName: trailName, + fingerprintOptions: &fingerprintOptions{}, + attestationNameTemplate: attestationName, + }, + payload: GenericAttestationPayload{ + CommonAttestationPayload: &CommonAttestationPayload{ + ArtifactFingerprint: fingerprint, + }, + Compliant: true, + }, + } + err := o.run([]string{}) + require.NoError(t, err, "generic artifact attestation should be created without error") +} + +func CreateGenericTrailAttestation(flowName, trailName, attestationName string, t *testing.T) { + t.Helper() + + o := &attestGenericOptions{ + CommonAttestationOptions: &CommonAttestationOptions{ + flowName: flowName, + trailName: trailName, + fingerprintOptions: &fingerprintOptions{}, + attestationNameTemplate: attestationName, + }, + payload: GenericAttestationPayload{ + CommonAttestationPayload: &CommonAttestationPayload{ + Commit: &gitview.BasicCommitInfo{ + Sha1: "0fc1ba9876f91b215679f3649b8668085d820ab5", + Message: "test commit", + Author: "test author", + Timestamp: 1234567890, + Branch: "test branch", + }, + }, + Compliant: true, + }, + } + err := o.run([]string{}) + require.NoError(t, err, "generic artifact attestation should be created without error") +}