diff --git a/cmd/validate/definition.go b/cmd/validate/definition.go index 5bc29077f..3cd7ed6c2 100644 --- a/cmd/validate/definition.go +++ b/cmd/validate/definition.go @@ -81,6 +81,7 @@ func validateDefinitionCmd(validate definitionValidationFn) *cobra.Command { --data git::https://github.com/enterprise-contract/ec-policies//example/data `), + Deprecated: "please use \"ec validate input\" instead.", RunE: func(cmd *cobra.Command, args []string) error { var allErrors error report := definition.NewReport() diff --git a/cmd/validate/definition_test.go b/cmd/validate/definition_test.go index 37e6c02d4..bc8bda0ca 100644 --- a/cmd/validate/definition_test.go +++ b/cmd/validate/definition_test.go @@ -22,6 +22,8 @@ import ( "bytes" "context" "errors" + "fmt" + "strings" "testing" hd "github.com/MakeNowJust/heredoc" @@ -73,7 +75,7 @@ func TestValidateDefinitionFileCommandOutput(t *testing.T) { ], "success": true, "ec-version": "development" - }`, out.String()) + }`, strings.Split(out.String(), "\n")[1]) } func TestValidateDefinitionFilePolicySources(t *testing.T) { @@ -134,22 +136,22 @@ func TestDefinitionFileOutputFormats(t *testing.T) { }{ { name: "default output", - expectedStdout: testJSONText, + expectedStdout: fmt.Sprintf("Command \"definition\" is deprecated, please use \"ec validate input\" instead.\n%s", testJSONText), }, { name: "json stdout", output: []string{"--output", "json"}, - expectedStdout: testJSONText, + expectedStdout: fmt.Sprintf("Command \"definition\" is deprecated, please use \"ec validate input\" instead.\n%s", testJSONText), }, { name: "yaml stdout", output: []string{"--output", "yaml"}, - expectedStdout: testYAMLTest, + expectedStdout: fmt.Sprintf("Command \"definition\" is deprecated, please use \"ec validate input\" instead.\n%s", testYAMLTest), }, { name: "json and yaml to file", output: []string{"--output", "json=out.json", "--output", "yaml=out.yaml"}, - expectedStdout: "", + expectedStdout: "Command \"definition\" is deprecated, please use \"ec validate input\" instead.\n", expectedFiles: map[string]string{ "out.json": testJSONText, "out.yaml": testYAMLTest, @@ -215,7 +217,7 @@ func TestValidateDefinitionFileCommandErrors(t *testing.T) { err := cmd.Execute() assert.Error(t, err, "2 errors occurred:\n\t* /path/file1.yaml\n\t* /path/file2.yaml\n") - assert.Equal(t, "", out.String()) + assert.Equal(t, "Command \"definition\" is deprecated, please use \"ec validate input\" instead.\n", out.String()) } func TestStrictOutput(t *testing.T) { diff --git a/cmd/validate/image.go b/cmd/validate/image.go index 48074acf8..cb33b6a92 100644 --- a/cmd/validate/image.go +++ b/cmd/validate/image.go @@ -27,8 +27,6 @@ import ( "github.com/hashicorp/go-multierror" app "github.com/redhat-appstudio/application-api/api/v1alpha1" "github.com/sigstore/cosign/v2/pkg/cosign" - log "github.com/sirupsen/logrus" - "github.com/spf13/afero" "github.com/spf13/cobra" "github.com/enterprise-contract/ec-cli/internal/applicationsnapshot" @@ -36,8 +34,8 @@ import ( "github.com/enterprise-contract/ec-cli/internal/format" "github.com/enterprise-contract/ec-cli/internal/output" "github.com/enterprise-contract/ec-cli/internal/policy" - "github.com/enterprise-contract/ec-cli/internal/policy/source" "github.com/enterprise-contract/ec-cli/internal/utils" + validate_utils "github.com/enterprise-contract/ec-cli/internal/validate" ) type imageValidationFunc func(context.Context, app.SnapshotComponent, policy.Policy, bool) (*output.Output, error) @@ -190,50 +188,12 @@ func validateImageCmd(validate imageValidationFunc) *cobra.Command { data.spec = s } - // Check if policyConfiguration is a git url, if so, try to download a config file from git - if source.SourceIsGit(data.policyConfiguration) { - log.Debugf("Fetching policy config from git url %s", data.policyConfiguration) - - // Create a temporary dir to download the config. It will be different to the - // workdir used later for downloading policy sources, but it won't matter - // because this dir is not used again once the config file has been read. - fs := utils.FS(ctx) - tmpDir, err := utils.CreateWorkDir(fs) - if err != nil { - allErrors = multierror.Append(allErrors, err) - return - } - defer utils.CleanupWorkDir(fs, tmpDir) - - // Git download and find a suitable config file - configFile, err := source.GitConfigDownload(cmd.Context(), tmpDir, data.policyConfiguration) - if err != nil { - allErrors = multierror.Append(allErrors, err) - return - } - - // Changing data.policyConfiguration to the name of the newly downloaded - // file means we can use the code below to load the config - data.policyConfiguration = configFile - } - - // Check if policyConfiguration is a file path, if so, we read it into the var data.policyConfiguration - if utils.HasJsonOrYamlExt(data.policyConfiguration) { - fs := utils.FS(ctx) - policyBytes, err := afero.ReadFile(fs, data.policyConfiguration) - if err != nil { - allErrors = multierror.Append(allErrors, err) - return - } - // Check for empty file as that would cause a false "success" - if len(policyBytes) == 0 { - err := fmt.Errorf("file %s is empty", data.policyConfiguration) - allErrors = multierror.Append(allErrors, err) - return - } - - data.policyConfiguration = string(policyBytes) + policyConfiguration, err := validate_utils.GetPolicyConfig(ctx, data.policyConfiguration) + if err != nil { + allErrors = multierror.Append(allErrors, err) + return } + data.policyConfiguration = policyConfiguration if p, err := policy.NewPolicy(cmd.Context(), policy.Options{ EffectiveTime: data.effectiveTime, @@ -349,7 +309,6 @@ func validateImageCmd(validate imageValidationFunc) *cobra.Command { } if data.strict && !report.Success { - // TODO: replace this with proper message and exit code 1. return errors.New("success criteria not met") } diff --git a/cmd/validate/input.go b/cmd/validate/input.go new file mode 100644 index 000000000..5aded97fa --- /dev/null +++ b/cmd/validate/input.go @@ -0,0 +1,227 @@ +// Copyright The Enterprise Contract Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package validate + +import ( + "context" + "errors" + "fmt" + "sort" + "sync" + + hd "github.com/MakeNowJust/heredoc" + "github.com/hashicorp/go-multierror" + "github.com/spf13/cobra" + + "github.com/enterprise-contract/ec-cli/internal/evaluator" + "github.com/enterprise-contract/ec-cli/internal/format" + "github.com/enterprise-contract/ec-cli/internal/input" + "github.com/enterprise-contract/ec-cli/internal/output" + "github.com/enterprise-contract/ec-cli/internal/policy" + "github.com/enterprise-contract/ec-cli/internal/utils" + validate_utils "github.com/enterprise-contract/ec-cli/internal/validate" +) + +type InputValidationFunc func(context.Context, string, policy.Policy, bool) (*output.Output, error) + +func validateInputCmd(validate InputValidationFunc) *cobra.Command { + var data = struct { + effectiveTime string + filePaths []string + info bool + namespaces []string + output []string + policy policy.Policy + policyConfiguration string + strict bool + }{ + strict: true, + } + cmd := &cobra.Command{ + Use: "input", + Short: "Validate arbitrary JSON or yaml file input conformance with the Enterprise Contract", + Long: hd.Doc(` + Validate conformance of arbitrary JSON or yaml file input with the Enterprise Contract + + For each file, validation is performed to determine if the file conforms to rego policies + defined in the the EnterpriseContractPolicy. + `), + Example: hd.Doc(` + Use an EnterpriseContractPolicy spec from a local YAML file to validate a single file + ec validate input --file /path/to/file.json --policy my-policy.yaml + + Use an EnterpriseContractPolicy spec from a local YAML file to validate multiple files + The file flag can be repeated for multiple input files. + ec validate input --file /path/to/file.yaml --file /path/to/file2.yaml --policy my-policy.yaml + + Use an EnterpriseContractPolicy spec from a local YAML file to validate multiple files + The file flag can take a comma separated series of files. + ec validate input --file="/path/to/file.json,/path/to/file2.json" --policy my-policy.yaml + + Use a git url for the policy configuration. In the first example there should be a '.ec/policy.yaml' + or a 'policy.yaml' inside a directory called 'default' in the top level of the git repo. In the second + example there should be a '.ec/policy.yaml' or a 'policy.yaml' file in the top level + of the git repo. For git repos not hosted on 'github.com' or 'gitlab.com', prefix the url with + 'git::'. For the policy configuration files you can use json instead of yaml if you prefer. + + ec validate input --file /path/to/file.json --policy github.com/user/repo//default?ref=main + + ec validate input --file /path/to/file.yaml --policy github.com/user/repo + +`), + PreRunE: func(cmd *cobra.Command, args []string) (allErrors error) { + ctx := cmd.Context() + + policyConfiguration, err := validate_utils.GetPolicyConfig(ctx, data.policyConfiguration) + if err != nil { + allErrors = multierror.Append(allErrors, err) + return + } + data.policyConfiguration = policyConfiguration + + if p, err := policy.NewInputPolicy(cmd.Context(), data.policyConfiguration, data.effectiveTime); err != nil { + allErrors = multierror.Append(allErrors, err) + } else { + data.policy = p + } + return + }, + RunE: func(cmd *cobra.Command, args []string) error { + + type result struct { + err error + input input.Input + data []evaluator.Data + policyInput []byte + } + + ch := make(chan result, len(data.filePaths)) + + var lock sync.WaitGroup + + for _, f := range data.filePaths { + lock.Add(1) + go func(fpath string) { + defer lock.Done() + + ctx := cmd.Context() + out, err := validate(ctx, fpath, data.policy, data.info) + res := result{ + err: err, + input: input.Input{ + FilePath: fpath, + Success: err == nil, + }, + } + // Skip on err to not panic. Error is return on routine completion. + if err == nil { + res.input.Violations = out.Violations() + showSuccesses, _ := cmd.Flags().GetBool("show-successes") + res.input.Warnings = out.Warnings() + + successes := out.Successes() + res.input.SuccessCount = len(successes) + if showSuccesses { + res.input.Successes = successes + } + res.data = out.Data + } + res.input.Success = err == nil && len(res.input.Violations) == 0 + ch <- res + }(f) + } + + lock.Wait() + close(ch) + + var inputs []input.Input + var manyData [][]evaluator.Data + var manyPolicyInput [][]byte + var allErrors error = nil + + for r := range ch { + if r.err != nil { + e := fmt.Errorf("error validating file %s: %w", r.input.FilePath, r.err) + allErrors = multierror.Append(allErrors, e) + } else { + inputs = append(inputs, r.input) + manyData = append(manyData, r.data) + manyPolicyInput = append(manyPolicyInput, r.policyInput) + } + } + if allErrors != nil { + return allErrors + } + + // Ensure some consistency in output. + sort.Slice(inputs, func(i, j int) bool { + return inputs[i].FilePath > inputs[j].FilePath + }) + + report, err := input.NewReport(inputs, data.policy, manyData, manyPolicyInput) + if err != nil { + return err + } + + p := format.NewTargetParser(input.JSON, cmd.OutOrStdout(), utils.FS(cmd.Context())) + if err := report.WriteAll(data.output, p); err != nil { + return err + } + + if data.strict && !report.Success { + return errors.New("success criteria not met") + } + + return nil + }, + } + + cmd.Flags().StringSliceVarP(&data.filePaths, "file", "f", data.filePaths, "path to input YAML/JSON file (required)") + + cmd.Flags().StringVarP(&data.policyConfiguration, "policy", "p", data.policyConfiguration, hd.Doc(` + Policy configuration as: + * file (policy.yaml) + * git reference (github.com/user/repo//default?ref=main), or + * inline JSON ('{sources: {...}, configuration: {...}}')")`)) + + cmd.Flags().StringSliceVarP(&data.output, "output", "o", data.output, hd.Doc(` + Write output to a file in a specific format, e.g. yaml=/tmp/output.yaml. Use empty string + path for stdout, e.g. yaml. May be used multiple times. Possible formats are json and yaml`)) + + cmd.Flags().BoolVarP(&data.strict, "strict", "s", data.strict, + "Return non-zero status on non-successful validation") + + cmd.Flags().StringVar(&data.effectiveTime, "effective-time", policy.Now, hd.Doc(` + Run policy checks with the provided time. Useful for testing rules with + effective dates in the future. The value can be "now" (default) - for + current time, or a RFC3339 formatted value, e.g. 2022-11-18T00:00:00Z.`)) + + cmd.Flags().BoolVar(&data.info, "info", data.info, hd.Doc(` + Include additional information on the failures. For instance for policy + violations, include the title and the description of the failed policy + rule.`)) + + if err := cmd.MarkFlagRequired("file"); err != nil { + panic(err) + } + + if err := cmd.MarkFlagRequired("policy"); err != nil { + panic(err) + } + + return cmd +} diff --git a/cmd/validate/input_test.go b/cmd/validate/input_test.go new file mode 100644 index 000000000..937c02d25 --- /dev/null +++ b/cmd/validate/input_test.go @@ -0,0 +1,19 @@ +// Copyright The Enterprise Contract Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +//go:build unit + +package validate diff --git a/cmd/validate/validate.go b/cmd/validate/validate.go index 749022495..643902d4e 100644 --- a/cmd/validate/validate.go +++ b/cmd/validate/validate.go @@ -21,6 +21,7 @@ import ( "github.com/enterprise-contract/ec-cli/internal/definition" "github.com/enterprise-contract/ec-cli/internal/image" + "github.com/enterprise-contract/ec-cli/internal/input" ) var ValidateCmd *cobra.Command @@ -32,6 +33,7 @@ func init() { func init() { ValidateCmd.AddCommand(validateImageCmd(image.ValidateImage)) ValidateCmd.AddCommand(validateDefinitionCmd(definition.ValidateDefinition)) + ValidateCmd.AddCommand(validateInputCmd(input.ValidateInput)) } func NewValidateCmd() *cobra.Command { diff --git a/features/__snapshots__/task_validate_definition.snap b/features/__snapshots__/task_validate_definition.snap index 0cc230616..9a4bfb1fc 100755 --- a/features/__snapshots__/task_validate_definition.snap +++ b/features/__snapshots__/task_validate_definition.snap @@ -1,17 +1,7 @@ [Verifying a simple task definition:validate - 1] -{ - "definitions": [ - { - "filename": "${TEMP}/definition-file-${RANDOM}", - "violations": [], - "warnings": [], - "successes": [] - } - ], - "success": true, - "ec-version": "${EC_VERSION}" -} +Command "definition" is deprecated, please use "ec validate input" instead. +{"definitions":[{"filename":"${TEMP}/definition-file-${RANDOM}","violations":[],"warnings":[],"successes":[]}],"success":true,"ec-version":"${EC_VERSION}"} --- [Verifying a simple task definition:results - 1] diff --git a/features/__snapshots__/validate_definition.snap b/features/__snapshots__/validate_definition.snap index 645e689a1..15390df3d 100755 --- a/features/__snapshots__/validate_definition.snap +++ b/features/__snapshots__/validate_definition.snap @@ -15,6 +15,7 @@ --- [:stderr - 1] +Command "definition" is deprecated, please use "ec validate input" instead. --- @@ -43,5 +44,6 @@ --- [Showing successes:stderr - 1] +Command "definition" is deprecated, please use "ec validate input" instead. --- diff --git a/features/__snapshots__/validate_image.snap b/features/__snapshots__/validate_image.snap index d8804aed3..577fc1fca 100755 --- a/features/__snapshots__/validate_image.snap +++ b/features/__snapshots__/validate_image.snap @@ -1152,7 +1152,7 @@ Error: success criteria not met [happy day with missing git config:stderr - 1] Error: 1 error occurred: - * No suitable config file found at git::https://${GITHOST}/git/happy-config.git + * no suitable config file found at git::https://${GITHOST}/git/happy-config.git diff --git a/go.mod b/go.mod index cbee8c734..f1b827b52 100644 --- a/go.mod +++ b/go.mod @@ -34,13 +34,12 @@ require ( github.com/tektoncd/pipeline v0.51.0 golang.org/x/exp v0.0.0-20231006140011-7918f672742d golang.org/x/net v0.18.0 - k8s.io/api v0.28.3 + gopkg.in/yaml.v2 v2.4.0 k8s.io/apiextensions-apiserver v0.28.3 k8s.io/apimachinery v0.28.4 k8s.io/client-go v0.28.3 k8s.io/klog/v2 v2.110.1 k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00 - sigs.k8s.io/controller-runtime v0.16.3 sigs.k8s.io/yaml v1.4.0 ) @@ -315,13 +314,14 @@ require ( gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/square/go-jose.v2 v2.6.0 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/api v0.28.3 // indirect k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect knative.dev/pkg v0.0.0-20230718152110-aef227e72ead // indirect muzzammil.xyz/jsonc v1.0.0 // indirect olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 // indirect oras.land/oras-go/v2 v2.3.1 // indirect + sigs.k8s.io/controller-runtime v0.16.3 // indirect sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect sigs.k8s.io/release-utils v0.7.6 // indirect sigs.k8s.io/structured-merge-diff/v4 v4.3.0 // indirect diff --git a/internal/evaluation_target/application_snapshot_image/application_snapshot_image.go b/internal/evaluation_target/application_snapshot_image/application_snapshot_image.go index f13822765..ed75734d4 100644 --- a/internal/evaluation_target/application_snapshot_image/application_snapshot_image.go +++ b/internal/evaluation_target/application_snapshot_image/application_snapshot_image.go @@ -26,7 +26,6 @@ import ( "path" "time" - ecc "github.com/enterprise-contract/enterprise-contract-controller/api/v1alpha1" "github.com/google/go-containerregistry/pkg/authn" "github.com/google/go-containerregistry/pkg/name" "github.com/google/go-containerregistry/pkg/v1/remote" @@ -112,7 +111,7 @@ func NewApplicationSnapshotImage(ctx context.Context, component app.SnapshotComp for _, sourceGroup := range p.Spec().Sources { // Todo: Make each fetch run concurrently log.Debugf("Fetching policy source group '%s'", sourceGroup.Name) - policySources, err := fetchPolicySources(sourceGroup) + policySources, err := source.FetchPolicySources(sourceGroup) if err != nil { log.Debugf("Failed to fetch policy source group '%s'!", sourceGroup.Name) return nil, err @@ -134,28 +133,6 @@ func NewApplicationSnapshotImage(ctx context.Context, component app.SnapshotComp return a, nil } -// fetchPolicySources returns an array of policy sources -func fetchPolicySources(s ecc.Source) ([]source.PolicySource, error) { - policySources := make([]source.PolicySource, 0, len(s.Policy)+len(s.Data)) - - for _, policySourceUrl := range s.Policy { - url := source.PolicyUrl{Url: policySourceUrl, Kind: "policy"} - policySources = append(policySources, &url) - } - - for _, dataSourceUrl := range s.Data { - url := source.PolicyUrl{Url: dataSourceUrl, Kind: "data"} - policySources = append(policySources, &url) - } - - if s.RuleData != nil { - data := append(append([]byte(`{"rule_data__configuration__":`), s.RuleData.Raw...), '}') - policySources = append(policySources, source.InlineData(data)) - } - - return policySources, nil -} - // ValidateImageAccess executes the remote.Head method on the ApplicationSnapshotImage image ref func (a *ApplicationSnapshotImage) ValidateImageAccess(ctx context.Context) error { opts := createRemoteOptions(ctx) diff --git a/internal/evaluation_target/input/input.go b/internal/evaluation_target/input/input.go new file mode 100644 index 000000000..510fff1ad --- /dev/null +++ b/internal/evaluation_target/input/input.go @@ -0,0 +1,67 @@ +// Copyright The Enterprise Contract Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package input + +import ( + "context" + + log "github.com/sirupsen/logrus" + + "github.com/enterprise-contract/ec-cli/internal/evaluator" + "github.com/enterprise-contract/ec-cli/internal/policy" + "github.com/enterprise-contract/ec-cli/internal/policy/source" +) + +var newConftestEvaluator = evaluator.NewConftestEvaluator + +// Input represents the structure needed to evaluate a generic file input +type Input struct { + Paths []string + Evaluator evaluator.Evaluator +} + +// NewInput returns a Input struct with FPath and evaluator ready to use +func NewInput(ctx context.Context, paths []string, p policy.Policy) (*Input, error) { + i := &Input{ + Paths: paths, + } + + for _, sourceGroup := range p.Spec().Sources { + // Todo: Make each fetch run concurrently + policySources, err := source.FetchPolicySources(sourceGroup) + + if err != nil { + log.Debugf("Failed to fetch policy source group '%s'!", sourceGroup.Name) + return nil, err + } + + for _, policySource := range policySources { + log.Debugf("policySource: %#v", policySource) + } + + c, err := newConftestEvaluator(ctx, policySources, p, sourceGroup) + if err != nil { + log.Debug("Failed to initialize the conftest evaluator!") + return nil, err + } + + log.Debug("Conftest evaluator initialized") + i.Evaluator = c + + } + return i, nil +} diff --git a/internal/input/report.go b/internal/input/report.go new file mode 100644 index 000000000..2abf80ca1 --- /dev/null +++ b/internal/input/report.go @@ -0,0 +1,204 @@ +// Copyright The Enterprise Contract Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.` +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package input + +import ( + "bytes" + "encoding/json" + "fmt" + "time" + + ecc "github.com/enterprise-contract/enterprise-contract-controller/api/v1alpha1" + "github.com/hashicorp/go-multierror" + "gopkg.in/yaml.v2" + + "github.com/enterprise-contract/ec-cli/internal/evaluator" + "github.com/enterprise-contract/ec-cli/internal/format" + "github.com/enterprise-contract/ec-cli/internal/policy" + "github.com/enterprise-contract/ec-cli/internal/version" +) + +type Input struct { + FilePath string + Violations []evaluator.Result + Warnings []evaluator.Result + Successes []evaluator.Result + Success bool + SuccessCount int +} + +type Report struct { + Success bool `json:"success"` + created time.Time + FilePaths []Input `json:"filepaths"` + Policy ecc.EnterpriseContractPolicySpec `json:"policy"` + EcVersion string `json:"ec-version"` + Data any `json:"-"` + EffectiveTime time.Time `json:"effective-time"` + PolicyInput [][]byte `json:"-"` +} + +type summary struct { + FilePaths []inputSummary `json:"filepaths"` + Success bool `json:"success"` + Key string `json:"key"` +} + +type inputSummary struct { + FilePath string `json:"name"` + Success bool `json:"success"` + Violations map[string][]string `json:"violations"` + Warnings map[string][]string `json:"warnings"` + Successes map[string][]string `json:"successes"` + TotalViolations int `json:"total_violations"` + TotalWarnings int `json:"total_warnings"` + TotalSuccesses int `json:"total_successes"` +} + +// TestReport represents the standardized TEST_OUTPUT format. +// The `Namespace` attribute is required for the appstudio results API. However, +// it is always an empty string from the ec-cli as a way to indicate all +// namespaces were used. +type TestReport struct { + Timestamp string `json:"timestamp"` + Namespace string `json:"namespace"` + Successes int `json:"successes"` + Failures int `json:"failures"` + Warnings int `json:"warnings"` + Result string `json:"result"` + Note string `json:"note,omitempty"` +} + +// Possible formats the report can be written as. +const ( + JSON = "json" + YAML = "yaml" + Summary = "summary" +) + +// WriteReport returns a new instance of Report representing the state of +// the filepaths provided. +func NewReport(inputs []Input, policy policy.Policy, data any, policyInput [][]byte) (Report, error) { + success := true + + // Set the report success, remains true if all the files were successfully validated + for _, fpath := range inputs { + if !fpath.Success { + success = false + break + } + } + + info, _ := version.ComputeInfo() + + return Report{ + Success: success, + created: time.Now().UTC(), + FilePaths: inputs, + Policy: policy.Spec(), + EcVersion: info.Version, + Data: data, + EffectiveTime: policy.EffectiveTime().UTC(), + PolicyInput: policyInput, + }, nil +} + +// WriteAll writes the report to all the given targets. +func (r Report) WriteAll(targets []string, p format.TargetParser) (allErrors error) { + if len(targets) == 0 { + targets = append(targets, JSON) + } + for _, targetName := range targets { + target := p.Parse(targetName) + + data, err := r.toFormat(target.Format) + if err != nil { + allErrors = multierror.Append(allErrors, err) + } + + if !bytes.HasSuffix(data, []byte{'\n'}) { + data = append(data, "\n"...) + } + + if _, err := target.Write(data); err != nil { + allErrors = multierror.Append(allErrors, err) + } + } + return +} + +// toFormat converts the report into the given format. +func (r *Report) toFormat(format string) (data []byte, err error) { + switch format { + case JSON: + data, err = json.Marshal(r) + case YAML: + data, err = yaml.Marshal(r) + case Summary: + data, err = json.Marshal(r.toSummary()) + default: + return nil, fmt.Errorf("%q is not a valid report format", format) + } + return +} + +// toSummary returns a condensed version of the report. +func (r *Report) toSummary() summary { + pr := summary{} + for _, cmp := range r.FilePaths { + c := inputSummary{ + FilePath: cmp.FilePath, + TotalViolations: len(cmp.Violations), + TotalWarnings: len(cmp.Warnings), + + // Because cmp.Successes does not get populated unless the --show-successes + // flag was set, cmp.SuccessCount is used here instead of len(cmp.Successes) + TotalSuccesses: cmp.SuccessCount, + + Success: cmp.Success, + Violations: condensedMsg(cmp.Violations), + Warnings: condensedMsg(cmp.Warnings), + Successes: condensedMsg(cmp.Successes), + } + pr.FilePaths = append(pr.FilePaths, c) + } + return pr +} + +// condensedMsg reduces repetitive error messages. +func condensedMsg(results []evaluator.Result) map[string][]string { + maxErr := 1 + shortNames := make(map[string][]string) + count := make(map[string]int) + for _, v := range results { + code, isPresent := v.Metadata["code"] + // we don't want to keep count of the empty string + if isPresent { + code := fmt.Sprintf("%v", code) + if count[code] < maxErr { + shortNames[code] = append(shortNames[code], v.Message) + } + count[code] = count[code] + 1 + } + } + for k := range shortNames { + if count[k] > maxErr { + shortNames[k] = append(shortNames[k], fmt.Sprintf("There are %v more %q messages", count[k]-1, k)) + } + } + return shortNames +} diff --git a/internal/input/report_test.go b/internal/input/report_test.go new file mode 100644 index 000000000..3e9d7aa2c --- /dev/null +++ b/internal/input/report_test.go @@ -0,0 +1,329 @@ +// Copyright The Enterprise Contract Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +//go:build unit + +package input + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/enterprise-contract/ec-cli/internal/evaluator" + "github.com/enterprise-contract/ec-cli/internal/policy" + "github.com/enterprise-contract/ec-cli/internal/utils" +) + +func Test_ReportJson(t *testing.T) { + filePaths := []string{"/path/to/file1.yaml", "/path/to/file2.yaml", "/path/to/file3.yaml"} + inputs := testInputsFor(filePaths) + ctx := context.Background() + testPolicy := createTestPolicy(t, ctx) + report, err := NewReport(inputs, testPolicy, "data here", nil) + assert.NoError(t, err) + + testEffectiveTime := testPolicy.EffectiveTime().UTC().Format(time.RFC3339Nano) + + expected := fmt.Sprintf(` + { + "success": false, + "filepaths": [ + { + "FilePath": "/path/to/file1.yaml", + "Violations": [ + { + "msg": "violation1" + } + ], + "Warnings": [ + { + "msg": "warning1" + } + ], + "Successes": [ + { + "msg": "success1" + } + ], + "Success": false, + "SuccessCount": 0 + }, + { + "FilePath": "/path/to/file2.yaml", + "Violations": [ + { + "msg": "violation2" + } + ], + "Warnings": null, + "Successes": null, + "Success": false, + "SuccessCount": 0 + }, + { + "FilePath": "/path/to/file3.yaml", + "Violations": null, + "Warnings": null, + "Successes": [ + { + "msg": "success3" + } + ], + "Success": true, + "SuccessCount": 0 + } + ], + "policy": { + "name": "Default", + "description": "Stuff and things", + "sources": [ + { + "name": "Default", + "policy": [ + "github.com/org/repo//policy" + ], + "data": [ + "github.com/org/repo//data" + ], + "config": { + "include": [ + "basic" + ] + } + } + ] + }, + "ec-version": "development", + "effective-time": %q + } + `, testEffectiveTime) + + reportJson, err := report.toFormat(JSON) + assert.NoError(t, err) + assert.JSONEq(t, expected, string(reportJson)) + assert.False(t, report.Success) +} + +func Test_ReportYaml(t *testing.T) { + filePaths := []string{"/path/to/file1.yaml", "/path/to/file2.yaml", "/path/to/file3.yaml"} + inputs := testInputsFor(filePaths) + ctx := context.Background() + testPolicy := createTestPolicy(t, ctx) + report, err := NewReport(inputs, testPolicy, "data here", nil) + assert.NoError(t, err) + + testEffectiveTime := testPolicy.EffectiveTime().UTC().Format(time.RFC3339Nano) + + expected := fmt.Sprintf(` +success: false +filepaths: +- filepath: /path/to/file1.yaml + violations: + - message: violation1 + metadata: {} + outputs: [] + warnings: + - message: warning1 + metadata: {} + outputs: [] + successes: + - message: success1 + metadata: {} + outputs: [] + success: false + successcount: 0 +- filepath: /path/to/file2.yaml + violations: + - message: violation2 + metadata: {} + outputs: [] + warnings: [] + successes: [] + success: false + successcount: 0 +- filepath: /path/to/file3.yaml + violations: [] + warnings: [] + successes: + - message: success3 + metadata: {} + outputs: [] + success: true + successcount: 0 +policy: + name: Default + description: Stuff and things + sources: + - name: Default + policy: + - github.com/org/repo//policy + data: + - github.com/org/repo//data + ruledata: null + config: + exclude: [] + include: + - basic + volatileconfig: null + configuration: null + rekorurl: "" + publickey: "" + identity: null +ecversion: development +data: data here +effectivetime: %s +policyinput: [] +`, testEffectiveTime) + + reportYaml, err := report.toFormat(YAML) + assert.NoError(t, err) + assert.YAMLEq(t, expected, string(reportYaml)) + assert.False(t, report.Success) +} + +func Test_ReportSummary(t *testing.T) { + tests := []struct { + name string + input []Input + want summary + }{ + { + name: "testing one violation and warning", + input: []Input{{ + FilePath: "/path/to/file1.yaml", + Violations: []evaluator.Result{ + { + Message: "short report", + Metadata: map[string]interface{}{ + "code": "short_name", + }, + }, + }, + Warnings: []evaluator.Result{ + { + Message: "short report", + Metadata: map[string]interface{}{ + "code": "short_name", + }, + }, + }, + Success: false, + }}, + want: summary{ + FilePaths: []inputSummary{ + { + FilePath: "/path/to/file1.yaml", + Violations: map[string][]string{ + "short_name": {"short report"}, + }, + Warnings: map[string][]string{ + "short_name": {"short report"}, + }, + Successes: map[string][]string{}, + TotalViolations: 1, + TotalSuccesses: 0, + TotalWarnings: 1, + Success: false, + }, + }, + Success: false, + }, + }, + } + + for _, tc := range tests { + t.Run(fmt.Sprintf("NewReport=%s", tc.name), func(t *testing.T) { + ctx := context.Background() + report, err := NewReport(tc.input, createTestPolicy(t, ctx), "data here", nil) + // report, err := NewReport(tc.snapshot, []Component{tc.input}, createTestPolicy(t, ctx), "data here", nil) + assert.NoError(t, err) + fmt.Println("\n\nExpected:\n", tc.want, "\n\nActual:\n", report.toSummary()) + assert.Equal(t, tc.want, report.toSummary()) + }) + } + +} + +func testInputsFor(filePaths []string) []Input { + inputs := []Input{ + { + FilePath: filePaths[0], + Violations: []evaluator.Result{ + { + Message: "violation1", + }, + }, + Warnings: []evaluator.Result{ + { + Message: "warning1", + }, + }, + Successes: []evaluator.Result{ + { + Message: "success1", + }, + }, + Success: false, + }, + { + FilePath: filePaths[1], + Violations: []evaluator.Result{ + { + Message: "violation2", + }, + }, + Success: false, + }, + { + FilePath: filePaths[2], + Successes: []evaluator.Result{ + { + Message: "success3", + }, + }, + Success: true, + }, + } + return inputs +} + +func createTestPolicy(t *testing.T, ctx context.Context) policy.Policy { + utils.SetTestRekorPublicKey(t) + + policyConfiguration := ` +name: Default +description: >- + Stuff and things +sources: + - name: Default + policy: + - github.com/org/repo//policy + data: + - github.com/org/repo//data + config: + include: + - "basic" + exclude: + [] +` + p, err := policy.NewInputPolicy(ctx, policyConfiguration, "now") + assert.NoError(t, err) + return p +} diff --git a/internal/input/validate.go b/internal/input/validate.go new file mode 100644 index 000000000..229d8089e --- /dev/null +++ b/internal/input/validate.go @@ -0,0 +1,135 @@ +// Copyright The Enterprise Contract Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.` +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package input + +import ( + "context" + "fmt" + "path/filepath" + + log "github.com/sirupsen/logrus" + "github.com/spf13/afero" + + "github.com/enterprise-contract/ec-cli/internal/evaluation_target/input" + "github.com/enterprise-contract/ec-cli/internal/output" + "github.com/enterprise-contract/ec-cli/internal/policy" + "github.com/enterprise-contract/ec-cli/internal/utils" +) + +var inputFile = input.NewInput + +func ValidateInput(ctx context.Context, fpath string, policy policy.Policy, detailed bool) (*output.Output, error) { + + log.Debugf("Current input filePath: %q", fpath) + inputFiles, err := detectInput(ctx, fpath) + if err != nil { + return nil, err + } + + p, err := inputFile(ctx, inputFiles, policy) + + if err != nil { + log.Debug("Failed to create input!") + return nil, err + } + + results, _, err := p.Evaluator.Evaluate(ctx, inputFiles) + if err != nil { + log.Debug("Problem running conftest policy check!") + return nil, err + } + + log.Debug("Conftest policy check complete") + return &output.Output{PolicyCheck: results, Detailed: detailed}, nil +} + +// detect if a file or directory was passed. if a directory, gather all files in it +// the order is file lookup, json lookup then yaml +func detectInput(ctx context.Context, fpath string) ([]string, error) { + if utils.IsJson(fpath) { + log.Debug("valid JSON found for definition file") + return inputFromString(ctx, fpath) + } + log.Debug("unable to detect input as JSON") + + // this is narrowed down to map[string]interface{} + // since a provided filename that does not exist could be considered valid yaml + if utils.IsYamlMap(fpath) { + log.Debug("valid YAML map found for definition file") + return inputFromString(ctx, fpath) + } + log.Debug("unable to detect input as YAML") + + fileExists, err := utils.IsFile(ctx, fpath) + if err != nil { + return nil, err + } + + if fileExists { + return fileLookup(ctx, fpath) + } + log.Debugf("unable to detect a file at path %v", fpath) + + return nil, fmt.Errorf("unable to parse the provided input file: %v", fpath) +} + +// if a single file is provided, return it +// if the file is a directory, return the files inside the directory +func fileLookup(ctx context.Context, path string) ([]string, error) { + fs := utils.FS(ctx) + var defFiles []string + + file, err := fs.Open(path) + if err != nil { + return nil, err + } + + defer file.Close() + + dir, err := afero.IsDir(fs, path) + if err != nil { + return nil, err + } + + if dir { + files, err := afero.ReadDir(fs, path) + if err != nil { + return nil, err + } + // a directory was provided, but contained no files + if len(files) == 0 { + return nil, fmt.Errorf("the directory %v contained no files", path) + } + + for _, f := range files { + defFiles = append(defFiles, filepath.Join(path, f.Name())) + } + } else { + defFiles = append(defFiles, path) + } + + return defFiles, nil +} + +// write the input file if a json or yaml string is provided +func inputFromString(ctx context.Context, data string) ([]string, error) { + data, err := utils.WriteTempFile(ctx, data, "input-file-") + if err != nil { + return nil, err + } + return []string{data}, nil +} diff --git a/internal/input/validate_test.go b/internal/input/validate_test.go new file mode 100644 index 000000000..16732b7ac --- /dev/null +++ b/internal/input/validate_test.go @@ -0,0 +1,158 @@ +// Copyright The Enterprise Contract Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.` +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +//go:build unit + +package input + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "testing" + + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + + "github.com/enterprise-contract/ec-cli/internal/evaluation_target/input" + "github.com/enterprise-contract/ec-cli/internal/evaluator" + "github.com/enterprise-contract/ec-cli/internal/output" + "github.com/enterprise-contract/ec-cli/internal/policy" + "github.com/enterprise-contract/ec-cli/internal/utils" +) + +type mockEvaluator struct{} +type badMockEvaluator struct{} + +func (e mockEvaluator) Evaluate(ctx context.Context, inputs []string) ([]evaluator.Outcome, evaluator.Data, error) { + return []evaluator.Outcome{}, nil, nil +} + +func (e mockEvaluator) Destroy() { +} + +func (e mockEvaluator) CapabilitiesPath() string { + return "" +} + +func (b badMockEvaluator) Evaluate(ctx context.Context, inputs []string) ([]evaluator.Outcome, evaluator.Data, error) { + return nil, nil, errors.New("Evaluator error") +} + +func (e badMockEvaluator) Destroy() { +} + +func (e badMockEvaluator) CapabilitiesPath() string { + return "" +} + +func mockNewPipelineDefinitionFile(ctx context.Context, fpath []string, policy policy.Policy) (*input.Input, error) { + return &input.Input{ + Evaluator: mockEvaluator{}, + }, nil +} + +func badMockNewPipelineDefinitionFile(ctx context.Context, fpath []string, policy policy.Policy) (*input.Input, error) { + return &input.Input{ + Evaluator: badMockEvaluator{}, + }, nil +} + +func Test_ValidatePipeline(t *testing.T) { + emptyDir := "/empty" + nonEmptyDir := "/nonEmpty" + validFile := filepath.Join(nonEmptyDir, "file.json") + badPath := "bad" + + tests := []struct { + name string + fpath string + err error + output *output.Output + defFunc func(ctx context.Context, fpath []string, policy policy.Policy) (*input.Input, error) + }{ + { + name: "validation succeeds", + fpath: validFile, + err: nil, + output: &output.Output{PolicyCheck: []evaluator.Outcome{}}, + defFunc: mockNewPipelineDefinitionFile, + }, + { + name: "validation fails on empty directory", + fpath: emptyDir, + err: fmt.Errorf("the directory %v contained no files", emptyDir), + output: nil, + defFunc: mockNewPipelineDefinitionFile, + }, + { + name: "validation fails on bad path", + fpath: badPath, + err: fmt.Errorf("unable to parse the provided input file: %v", badPath), + output: nil, + defFunc: mockNewPipelineDefinitionFile, + }, + { + name: "valid file, but evaluator fails", + fpath: validFile, + err: errors.New("Evaluator error"), + output: nil, + defFunc: badMockNewPipelineDefinitionFile, + }, + { + name: "validation succeeds with json input", + fpath: "{\"json\": 1}", + err: nil, + output: &output.Output{PolicyCheck: []evaluator.Outcome{}}, + defFunc: mockNewPipelineDefinitionFile, + }, + { + name: "validation succeeds with yaml input", + fpath: "kind: task", + err: nil, + output: &output.Output{PolicyCheck: []evaluator.Outcome{}}, + defFunc: mockNewPipelineDefinitionFile, + }, + { + name: "validation fails with only an array of strings as yaml", + fpath: "- test1\n- test2", + err: fmt.Errorf("unable to parse the provided input file: %v", "- test1\n- test2"), + output: nil, + defFunc: mockNewPipelineDefinitionFile, + }, + } + + appFS := afero.NewMemMapFs() + errEmptyDir := appFS.MkdirAll(emptyDir, 0777) + assert.NoError(t, errEmptyDir) + errDir := appFS.MkdirAll(nonEmptyDir, 0777) + assert.NoError(t, errDir) + errFile := afero.WriteFile(appFS, validFile, []byte("data"), 0777) + assert.NoError(t, errFile) + ctx := utils.WithFS(context.Background(), appFS) + policy, err := policy.NewInputPolicy(ctx, "", "2023-01-01T00:00:00.00Z") + assert.NoError(t, err) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + inputFile = tt.defFunc + output, err := ValidateInput(ctx, tt.fpath, policy, false) + assert.Equal(t, tt.err, err) + assert.Equal(t, tt.output, output) + }) + } +} diff --git a/internal/policy/policy.go b/internal/policy/policy.go index 481f04937..cc4f27b0e 100644 --- a/internal/policy/policy.go +++ b/internal/policy/policy.go @@ -147,6 +147,31 @@ func NewInertPolicy(ctx context.Context, policyRef string) (Policy, error) { return &p, nil } +// NewInputPolicy constructs and returns a new instance of Policy that doesn't +// perform strict checks on the consistency of the policy, but can evaluate based on +// provided effectiveTime +// +// The policyRef parameter is expected to be either a YAML/JSON-encoded instance of +// EnterpriseContractPolicySpec, or reference to the location of the EnterpriseContractPolicy +// resource in Kubernetes using the format: [namespace/]name +// +// If policyRef is blank, an empty EnterpriseContractPolicySpec is used. +func NewInputPolicy(ctx context.Context, policyRef string, effectiveTime string) (Policy, error) { + if efn, err := parseEffectiveTime(effectiveTime); err == nil { + p := policy{ + choosenTime: effectiveTime, + checkOpts: &cosign.CheckOpts{}, + } + if err := p.loadPolicy(ctx, policyRef); err != nil { + return nil, err + } + p.effectiveTime = efn + return &p, nil + } else { + return nil, err + } +} + // NewPolicy construct and return a new instance of Policy. // // The policyRef parameter is expected to be either a JSON-encoded instance of @@ -229,9 +254,10 @@ func (p *policy) loadPolicy(ctx context.Context, policyRef string) error { if err := yaml.Unmarshal([]byte(policyRef), &ecp); err == nil && ecp.APIVersion != "" { p.EnterpriseContractPolicySpec = ecp.Spec } else { - log.Debugf("Problem parsing EnterpriseContractPolicy from %q", policyRef) + log.Debugf("Unable to parse EnterpriseContractPolicy from %q", policyRef) + log.Debug("Attempting to parse as EnterpriseContractPolicySpec") if err := yaml.Unmarshal([]byte(policyRef), &p.EnterpriseContractPolicySpec); err != nil { - log.Debugf("Problem parsing EnterpriseContractPolicySpec from %q", policyRef) + log.Debugf("Unable to parse EnterpriseContractPolicySpec from %q", policyRef) return fmt.Errorf("unable to parse EnterpriseContractPolicySpec: %w", err) } } diff --git a/internal/policy/source/git_config.go b/internal/policy/source/git_config.go index e1c1a23dc..070547d83 100644 --- a/internal/policy/source/git_config.go +++ b/internal/policy/source/git_config.go @@ -57,7 +57,7 @@ func GitConfigDownload(ctx context.Context, tmpDir, src string) (string, error) configFile, err := choosePolicyFile(ctx, configDir) if err != nil { // A more useful error message: - return "", fmt.Errorf("No suitable config file found at %s", c.Url) + return "", fmt.Errorf("no suitable config file found at %s", c.Url) } log.Debugf("Chose file %s to use for the policy config", configFile) return configFile, nil @@ -90,5 +90,5 @@ func choosePolicyFile(ctx context.Context, configDir string) (string, error) { } } } - return "", fmt.Errorf("No suitable config file found in %s", configDir) + return "", fmt.Errorf("no suitable config file found in %s", configDir) } diff --git a/internal/policy/source/git_config_test.go b/internal/policy/source/git_config_test.go index 55f324aa2..1dce3de72 100644 --- a/internal/policy/source/git_config_test.go +++ b/internal/policy/source/git_config_test.go @@ -40,7 +40,7 @@ func TestChoosePolicyFile(t *testing.T) { name: "No files", files: []string{}, wantErr: true, - errText: "No suitable config file found", + errText: "no suitable config file found", }, { name: "One policy.json file", diff --git a/internal/policy/source/source.go b/internal/policy/source/source.go index dc434b362..e96d506a6 100644 --- a/internal/policy/source/source.go +++ b/internal/policy/source/source.go @@ -30,6 +30,7 @@ import ( "path" "time" + ecc "github.com/enterprise-contract/enterprise-contract-controller/api/v1alpha1" log "github.com/sirupsen/logrus" "github.com/enterprise-contract/ec-cli/internal/downloader" @@ -126,3 +127,25 @@ func (s inlineData) PolicyUrl() string { func (s inlineData) Subdir() string { return "data" } + +// FetchPolicySources returns an array of policy sources +func FetchPolicySources(s ecc.Source) ([]PolicySource, error) { + policySources := make([]PolicySource, 0, len(s.Policy)+len(s.Data)) + + for _, policySourceUrl := range s.Policy { + url := PolicyUrl{Url: policySourceUrl, Kind: "policy"} + policySources = append(policySources, &url) + } + + for _, dataSourceUrl := range s.Data { + url := PolicyUrl{Url: dataSourceUrl, Kind: "data"} + policySources = append(policySources, &url) + } + + if s.RuleData != nil { + data := append(append([]byte(`{"rule_data__configuration__":`), s.RuleData.Raw...), '}') + policySources = append(policySources, InlineData(data)) + } + + return policySources, nil +} diff --git a/internal/policy/source/source_test.go b/internal/policy/source/source_test.go index afcc41940..ba5d98ac0 100644 --- a/internal/policy/source/source_test.go +++ b/internal/policy/source/source_test.go @@ -26,8 +26,10 @@ import ( "regexp" "testing" + ecc "github.com/enterprise-contract/enterprise-contract-controller/api/v1alpha1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" + extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" ) func usingDownloader(ctx context.Context, m *mockDownloader) context.Context { @@ -115,3 +117,57 @@ func TestInlineDataSource(t *testing.T) { assert.Equal(t, "data:application/json;base64,c29tZSBkYXRh", s.PolicyUrl()) } + +func TestFetchPolicySources(t *testing.T) { + // var ruleData = &extv1.JSON{Raw: []byte("foo")} + tests := []struct { + name string + source ecc.Source + expected []PolicySource + err error + }{ + { + name: "fetches policy configs", + source: ecc.Source{ + Name: "policy1", + Policy: []string{"github.com/org/repo1//policy/", "github.com/org/repo2//policy/", "github.com/org/repo3//policy/"}, + Data: []string{"github.com/org/repo1//data/", "github.com/org/repo2//data/", "github.com/org/repo3//data/"}, + }, + expected: []PolicySource{ + &PolicyUrl{Url: "github.com/org/repo1//policy/", Kind: "policy"}, + &PolicyUrl{Url: "github.com/org/repo2//policy/", Kind: "policy"}, + &PolicyUrl{Url: "github.com/org/repo3//policy/", Kind: "policy"}, + &PolicyUrl{Url: "github.com/org/repo1//data/", Kind: "data"}, + &PolicyUrl{Url: "github.com/org/repo2//data/", Kind: "data"}, + &PolicyUrl{Url: "github.com/org/repo3//data/", Kind: "data"}, + }, + err: nil, + }, + { + name: "handles rule data", + source: ecc.Source{ + Name: "policy2", + Policy: []string{"github.com/org/repo1//policy/"}, + Data: []string{"github.com/org/repo1//data/"}, + RuleData: &extv1.JSON{Raw: []byte(`"foo":"bar"`)}, + }, + expected: []PolicySource{ + &PolicyUrl{Url: "github.com/org/repo1//policy/", Kind: "policy"}, + &PolicyUrl{Url: "github.com/org/repo1//data/", Kind: "data"}, + inlineData{source: []byte("{\"rule_data__configuration__\":\"foo\":\"bar\"}")}, + }, + err: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sources, err := FetchPolicySources(tt.source) + if tt.err == nil { + assert.NoError(t, err, "FetchPolicySources returned an error") + } else { + assert.EqualError(t, err, tt.err.Error()) + } + assert.Equal(t, sources, tt.expected) + }) + } +} diff --git a/internal/report/utils/helpers.go b/internal/report/utils/helpers.go new file mode 100644 index 000000000..d2ce090b8 --- /dev/null +++ b/internal/report/utils/helpers.go @@ -0,0 +1,47 @@ +// Copyright The Enterprise Contract Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.` +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package utils + +import ( + "fmt" + + "github.com/enterprise-contract/ec-cli/internal/evaluator" +) + +// condensedMsg reduces repetitive error messages. +func CondensedMsg(results []evaluator.Result) map[string][]string { + maxErr := 1 + shortNames := make(map[string][]string) + count := make(map[string]int) + for _, v := range results { + code, isPresent := v.Metadata["code"] + // we don't want to keep count of the empty string + if isPresent { + code := fmt.Sprintf("%v", code) + if count[code] < maxErr { + shortNames[code] = append(shortNames[code], v.Message) + } + count[code] = count[code] + 1 + } + } + for k := range shortNames { + if count[k] > maxErr { + shortNames[k] = append(shortNames[k], fmt.Sprintf("There are %v more %q messages", count[k]-1, k)) + } + } + return shortNames +} diff --git a/internal/validate/helpers.go b/internal/validate/helpers.go new file mode 100644 index 000000000..6bf943e5b --- /dev/null +++ b/internal/validate/helpers.go @@ -0,0 +1,76 @@ +// Copyright The Enterprise Contract Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.` +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package validate + +import ( + "context" + "fmt" + + log "github.com/sirupsen/logrus" + "github.com/spf13/afero" + + "github.com/enterprise-contract/ec-cli/internal/policy/source" + "github.com/enterprise-contract/ec-cli/internal/utils" +) + +// Determine policyConfig +func GetPolicyConfig(ctx context.Context, policyConfiguration string) (string, error) { + // Check if policyConfiguration is a git url. If so, try to download a config file from git. + // If successful we write that into the data.policyConfiguration var. + if source.SourceIsGit(policyConfiguration) { + log.Debugf("Fetching policy config from git url %s", policyConfiguration) + + // Create a temporary dir to download the config. This is separate from the workDir usd + // later for downloading policy sources, but it doesn't matter because this dir is not + // used again once the config file has been read. + fs := utils.FS(ctx) + tmpDir, err := utils.CreateWorkDir(fs) + if err != nil { + return "", err + } + defer utils.CleanupWorkDir(fs, tmpDir) + + // Git download and find a suitable config file + configFile, err := source.GitConfigDownload(ctx, tmpDir, policyConfiguration) + if err != nil { + return "", err + } + + // Changing data.policyConfiguration to the name of the newly downloaded file means we can + // use the code below to load the config + policyConfiguration = configFile + } + + // Check if policyConfig is a file path. If so, try to read it. If successful we write + // that into the data.policyConfiguration var. + if utils.HasJsonOrYamlExt(policyConfiguration) { + fs := utils.FS(ctx) + policyBytes, err := afero.ReadFile(fs, policyConfiguration) + if err != nil { + return "", err + } + // Check for empty file as that would cause a false "success" + if len(policyBytes) == 0 { + err := fmt.Errorf("file %s is empty", policyConfiguration) + return "", err + } + log.Debugf("Loaded %s as policyConfiguration", policyConfiguration) + policyConfiguration = string(policyBytes) + } + return policyConfiguration, nil + +}