-
Notifications
You must be signed in to change notification settings - Fork 7
feat: add kosli attest decision command (hidden/BETA) #912
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7109d92
9158617
63e962a
da49273
779fe0b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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") | ||
|
pbeckham marked this conversation as resolved.
|
||
| 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) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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)) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
Comment on lines
521
to
540
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor: the other helpers like If the server's control endpoint is idempotent on re-creation, this is fine. Otherwise, consider either checking for existence first, or ignoring "already exists" errors. Worth verifying against the server. |
||
| func CreatePolicy(org, policyName string, t *testing.T) { | ||
| t.Helper() | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.