diff --git a/cmd/kosli/attest.go b/cmd/kosli/attest.go index 09167bf90..1632455bf 100644 --- a/cmd/kosli/attest.go +++ b/cmd/kosli/attest.go @@ -26,6 +26,7 @@ func newAttestCmd(out io.Writer) *cobra.Command { newAttestSonarCmd(out), newAttestCustomCmd(out), newAttestOverrideCmd(out), + newAttestDecisionCmd(out), ) return cmd } diff --git a/cmd/kosli/attestDecision.go b/cmd/kosli/attestDecision.go new file mode 100644 index 000000000..59c4d07e8 --- /dev/null +++ b/cmd/kosli/attestDecision.go @@ -0,0 +1,187 @@ +package main + +import ( + "fmt" + "io" + "net/http" + "net/url" + "os" + + "github.com/kosli-dev/cli/internal/requests" + "github.com/spf13/cobra" +) + +type DecisionAttestationData struct { + Compliant bool `json:"is_compliant"` +} + +type DecisionAttestationPayload struct { + *CommonAttestationPayload + TypeName string `json:"type_name"` + Control string `json:"control"` + AttestationData DecisionAttestationData `json:"attestation_data"` +} + +type attestDecisionOptions struct { + *CommonAttestationOptions + payload DecisionAttestationPayload +} + +const attestDecisionShortDesc = `[BETA] Record a compliance decision against a control in a Kosli trail. ` + +const attestDecisionLongDesc = attestDecisionShortDesc + ` +Use this command to record the outcome of evaluating a control as part of your delivery +pipeline — whether it was satisfied or not — attached to a specific trail with an optional artifact. +This decision is the evidence that a governance requirement was assessed. +` + attestationBindingDesc + ` + +` + commitDescription + +const attestDecisionExample = ` +# record a compliant decision against a trail: +kosli attest decision \ + --name yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --control RCTL-043 \ + --compliant=true \ + --api-token yourAPIToken \ + --org yourOrgName + +# record a non-compliant decision against a trail: +kosli attest decision \ + --name yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --control RCTL-043 \ + --compliant=false \ + --api-token yourAPIToken \ + --org yourOrgName + +# record a decision linked to a specific artifact (by fingerprint): +kosli attest decision \ + --name yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --control RCTL-043 \ + --compliant=true \ + --fingerprint yourArtifactFingerprint \ + --api-token yourAPIToken \ + --org yourOrgName + +# record a decision with an evidence attachment: +kosli attest decision \ + --name yourAttestationName \ + --flow yourFlowName \ + --trail yourTrailName \ + --control RCTL-043 \ + --compliant=true \ + --attachments eval-report.json \ + --api-token yourAPIToken \ + --org yourOrgName +` + +func newAttestDecisionCmd(out io.Writer) *cobra.Command { + o := &attestDecisionOptions{ + CommonAttestationOptions: &CommonAttestationOptions{ + fingerprintOptions: &fingerprintOptions{}, + }, + payload: DecisionAttestationPayload{ + CommonAttestationPayload: &CommonAttestationPayload{}, + TypeName: "decision", + }, + } + cmd := &cobra.Command{ + Use: "decision [IMAGE-NAME | FILE-PATH | DIR-PATH]", + Short: attestDecisionShortDesc, + Long: attestDecisionLongDesc, + Example: attestDecisionExample, + Hidden: true, + PreRunE: func(cmd *cobra.Command, args []string) error { + err := CustomMaximumNArgs(1, args) + if err != nil { + return err + } + + err = RequireGlobalFlags(global, []string{"Org", "ApiToken"}) + if err != nil { + return ErrorBeforePrintingUsage(cmd, err.Error()) + } + + if !cmd.Flags().Changed("compliant") { + return fmt.Errorf(`required flag(s) "compliant" not set`) + } + + err = MuXRequiredFlags(cmd, []string{"fingerprint", "artifact-type"}, false) + if err != nil { + return err + } + + err = ValidateSliceValues(o.redactedCommitInfo, allowedCommitRedactionValues) + if err != nil { + return fmt.Errorf("%s for --redact-commit-info", err.Error()) + } + + err = ValidateAttestationArtifactArg(args, o.fingerprintOptions.artifactType, o.payload.ArtifactFingerprint) + if err != nil { + return ErrorBeforePrintingUsage(cmd, err.Error()) + } + + return ValidateRegistryFlags(cmd, o.fingerprintOptions) + }, + + RunE: func(cmd *cobra.Command, args []string) error { + o.repoURLExplicit = cmd.Flags().Changed("repo-url") + return o.run(args) + }, + } + + ci := WhichCI() + addAttestationFlags(cmd, o.CommonAttestationOptions, o.payload.CommonAttestationPayload, ci) + cmd.Flags().StringVar(&o.payload.Control, "control", "", attestationDecisionControlFlag) + cmd.Flags().BoolVarP(&o.payload.AttestationData.Compliant, "compliant", "C", false, attestationCompliantFlag) + + err := RequireFlags(cmd, []string{"flow", "trail", "name", "control"}) + if err != nil { + logger.Error("failed to configure required flags: %v", err) + } + + return cmd +} + +func (o *attestDecisionOptions) run(args []string) error { + url, err := url.JoinPath(global.Host, "api/v2/attestations", global.Org, o.flowName, "trail", o.trailName, "system") + if err != nil { + return err + } + + err = o.CommonAttestationOptions.run(args, o.payload.CommonAttestationPayload) + if err != nil { + return err + } + + form, cleanupNeeded, evidencePath, err := prepareAttestationForm(o.payload, o.attachments) + if err != nil { + return err + } + if cleanupNeeded { + defer func() { + if err := os.Remove(evidencePath); err != nil { + logger.Warn("failed to remove evidence file: %v", err) + } + }() + } + + reqParams := &requests.RequestParams{ + Method: http.MethodPost, + URL: url, + Form: form, + DryRun: global.DryRun, + Token: global.ApiToken, + } + _, err = kosliClient.Do(reqParams) + if err == nil && !global.DryRun { + logger.Info("decision attestation '%s' is reported to trail: %s", o.payload.AttestationName, o.trailName) + } + return wrapAttestationError(err) +} diff --git a/cmd/kosli/attestDecision_test.go b/cmd/kosli/attestDecision_test.go new file mode 100644 index 000000000..5cc55e83b --- /dev/null +++ b/cmd/kosli/attestDecision_test.go @@ -0,0 +1,132 @@ +package main + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/suite" +) + +type AttestDecisionCommandTestSuite struct { + flowName string + trailName string + artifactFingerprint string + suite.Suite + defaultKosliArguments string +} + +func (suite *AttestDecisionCommandTestSuite) SetupTest() { + suite.flowName = "attest-decision" + suite.trailName = "test-123" + suite.artifactFingerprint = "7509e5bda0c762d2bac7f90d758b5b2263fa01ccbc542ab5e3df163be08e6ca9" + global = &GlobalOpts{ + ApiToken: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6ImNkNzg4OTg5In0.e8i_lA_QrEhFncb05Xw6E_tkCHU9QfcY4OLTVUCHffY", + Org: "docs-cmd-test-user", + Host: "http://localhost:8001", + } + suite.defaultKosliArguments = fmt.Sprintf(" --flow %s --trail %s --repo-root ../.. --host %s --org %s --api-token %s", suite.flowName, suite.trailName, global.Host, global.Org, global.ApiToken) + CreateControl(global.Org, "RCTL-043", "Test Control", suite.T()) + CreateFlowWithTemplate(suite.flowName, "testdata/valid_template.yml", suite.T()) + BeginTrail(suite.trailName, suite.flowName, "", suite.T()) + CreateArtifactOnTrail(suite.flowName, suite.trailName, "cli", suite.artifactFingerprint, "file1", suite.T()) +} + +func (suite *AttestDecisionCommandTestSuite) TestAttestDecisionCmd() { + tests := []cmdTestCase{ + { + wantError: true, + name: "fails when more arguments are provided", + cmd: fmt.Sprintf("attest decision foo bar %s --control RCTL-043 --compliant=true", suite.defaultKosliArguments), + golden: "Error: accepts at most 1 arg(s), received 2 [foo bar]\n", + }, + { + wantError: true, + name: "fails when missing --name flag", + cmd: fmt.Sprintf("attest decision %s --control RCTL-043 --compliant=true", suite.defaultKosliArguments), + golden: "Error: required flag(s) \"name\" not set\n", + }, + { + wantError: true, + name: "fails when missing --control flag", + cmd: fmt.Sprintf("attest decision --name foo %s --compliant=true", suite.defaultKosliArguments), + golden: "Error: required flag(s) \"control\" not set\n", + }, + { + wantError: true, + name: "fails when --compliant is not set", + cmd: fmt.Sprintf("attest decision --name foo --control RCTL-043 %s", suite.defaultKosliArguments), + golden: "Error: required flag(s) \"compliant\" not set\n", + }, + { + wantError: true, + name: "fails when both --fingerprint and --artifact-type are set", + cmd: fmt.Sprintf("attest decision testdata/file1 --fingerprint xxxx --artifact-type file --name foo --control RCTL-043 --compliant=true %s", suite.defaultKosliArguments), + golden: "Error: only one of --fingerprint, --artifact-type is allowed\n", + }, + { + wantError: true, + name: "fails when --fingerprint is not a valid SHA256", + cmd: fmt.Sprintf("attest decision --name foo --fingerprint xxxx --control RCTL-043 --compliant=true %s", suite.defaultKosliArguments), + golden: "Error: xxxx is not a valid SHA256 fingerprint. It should match the pattern ^([a-f0-9]{64})$\nUsage: kosli attest decision [IMAGE-NAME | FILE-PATH | DIR-PATH] [flags]\n", + }, + { + wantError: true, + name: "fails when --name is passed as empty string", + cmd: fmt.Sprintf("attest decision --name \"\" --control RCTL-043 --compliant=true %s", suite.defaultKosliArguments), + golden: "Error: flag '--name' is required, but empty string was provided\n", + }, + { + name: "can record a compliant decision against a trail", + cmd: fmt.Sprintf("attest decision --name foo --control RCTL-043 --compliant=true %s", suite.defaultKosliArguments), + golden: "decision attestation 'foo' is reported to trail: test-123\n", + }, + { + name: "can record a non-compliant decision against a trail", + cmd: fmt.Sprintf("attest decision --name foo --control RCTL-043 --compliant=false %s", suite.defaultKosliArguments), + golden: "decision attestation 'foo' is reported to trail: test-123\n", + }, + { + name: "can record a decision linked to a specific artifact by fingerprint", + cmd: fmt.Sprintf("attest decision --fingerprint 7509e5bda0c762d2bac7f90d758b5b2263fa01ccbc542ab5e3df163be08e6ca9 --name foo --control RCTL-043 --compliant=true %s", suite.defaultKosliArguments), + golden: "decision attestation 'foo' is reported to trail: test-123\n", + }, + { + name: "can record a decision with an attachment", + cmd: fmt.Sprintf("attest decision --name foo --control RCTL-043 --compliant=true --attachments testdata/file1 %s", suite.defaultKosliArguments), + golden: "decision attestation 'foo' is reported to trail: test-123\n", + }, + { + name: "can record a decision with description", + cmd: fmt.Sprintf("attest decision --name foo --control RCTL-043 --compliant=true --description 'evaluation passed' %s", suite.defaultKosliArguments), + golden: "decision attestation 'foo' is reported to trail: test-123\n", + }, + { + name: "can record a decision with annotations", + cmd: fmt.Sprintf("attest decision --name foo --control RCTL-043 --compliant=true --annotate key=value %s", suite.defaultKosliArguments), + golden: "decision attestation 'foo' is reported to trail: test-123\n", + }, + { + name: "can record a decision with user data", + cmd: fmt.Sprintf("attest decision --name foo --control RCTL-043 --compliant=true --user-data testdata/person-type-data-example.json %s", suite.defaultKosliArguments), + golden: "decision attestation 'foo' is reported to trail: test-123\n", + }, + { + wantError: true, + name: "fails when annotation key is invalid", + cmd: fmt.Sprintf("attest decision --name foo --control RCTL-043 --compliant=true --annotate foo.bar=baz %s", suite.defaultKosliArguments), + golden: "Error: --annotate flag should be in the format key=value. Invalid key: 'foo.bar'. Key can only contain [A-Za-z0-9_]\n", + }, + { + wantError: true, + name: "fails when --name has invalid dot format", + cmd: fmt.Sprintf("attest decision --name .foo --control RCTL-043 --compliant=true %s", suite.defaultKosliArguments), + golden: "Error: failed to parse attestation name: invalid attestation name format: .foo\n", + }, + } + + runTestCmd(suite.T(), tests) +} + +func TestAttestDecisionCommandTestSuite(t *testing.T) { + suite.Run(t, new(AttestDecisionCommandTestSuite)) +} diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index 81012b22a..0bd1b4177 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -237,6 +237,7 @@ The ^.kosli_ignore^ will be treated as part of the artifact like any other file, attestationOverrideReasonFlag = "The reason for overriding the attestation." newComplianceStatusFlag = "The new compliance status to set on the attestation. A boolean flag https://docs.kosli.com/faq/#boolean-flags" originalAttestationTypeFlag = "The original attestation type being overridden (e.g. generic, snyk, junit, sonar, jira, pull_request, custom)." + attestationDecisionControlFlag = "The control identifier being evaluated (e.g. RCTL-043)." excludeScalingFlag = "[optional] Exclude scaling events for snapshots. Snapshots with scaling changes will not result in new environment records." includeScalingFlag = "[optional] Include scaling events for snapshots. Snapshots with scaling changes will result in new environment records." includedEnvironments = "[optional] Comma separated list of environments to include in logical environment" diff --git a/cmd/kosli/testHelpers.go b/cmd/kosli/testHelpers.go index 8d96ed8c9..8d5ccabc6 100644 --- a/cmd/kosli/testHelpers.go +++ b/cmd/kosli/testHelpers.go @@ -6,6 +6,8 @@ import ( "encoding/json" "fmt" "io" + "net/http" + "net/url" "os" "path/filepath" "regexp" @@ -14,6 +16,7 @@ import ( "testing" "github.com/kosli-dev/cli/internal/gitview" + "github.com/kosli-dev/cli/internal/requests" shellwords "github.com/mattn/go-shellwords" "github.com/pkg/errors" "github.com/spf13/cobra" @@ -518,6 +521,22 @@ func UnSetEnvVars(envVars map[string]string, t *testing.T) { } } +// CreateControl creates a control in the org via the API. +func CreateControl(org, identifier, name string, t *testing.T) { + t.Helper() + u, err := url.JoinPath(global.Host, "api/v2/controls", org) + require.NoError(t, err, "control URL should be constructed without error") + + reqParams := &requests.RequestParams{ + Method: http.MethodPost, + URL: u, + Payload: map[string]string{"identifier": identifier, "name": name}, + Token: global.ApiToken, + } + _, err = kosliClient.Do(reqParams) + require.NoError(t, err, "control should be created without error") +} + // CreatePolicy creates a policy on the server func CreatePolicy(org, policyName string, t *testing.T) { t.Helper()