From 23a45b696a3c24653ad2001dc4b883f40e9685c1 Mon Sep 17 00:00:00 2001 From: "Cole (Mike) Winberry" <86802655+mike-winberry@users.noreply.github.com> Date: Fri, 12 Jul 2024 13:48:23 -0700 Subject: [PATCH] feat(common): json schema linting for common validation(s) (#473) * feat(validation): add initiall validation json schema * feat(common): create schema folder in common with schema functionality and tests for the validation schema * feat(schema): add the validate method to run validation given a schema and a model * feat(schema): schema tests passing, still need to fix other repo validations * fix(schema): fix missing top level field not properly returning top level validation error * chore(tests): fix validations used by tests, TODO: replace remote link with valid validation * feat(schemas): worked more defined schema for domain * feat(schemas): fix validation schema and test files relating * fix(validation): while the documentation says name required if field and namespace required if name, none of our test validations follow this rule * feat(schemas): validation schema provider and domain specs linked to their type enum chore(makefile): add test-unit to makefile chore(adr): update validation artifact format (resource-rule) required fields to match current usage and functionality chore: update domains and provider types with comments from adr, docs, and schema * feat(schemas): add back in the resource-rule constraints * fix(adr): revert validation-artifact-format * fix(schemas): resource-rule no longer flags name as required when field is null or empty * test(compose_test): uncomment remote validations and test case * fix(component_test): uncomment test case * docs: fix missing optional comment in kubernetes-domain.md * fix(validation-composition): reintroduced remote components to component-definition * test(unit): fix composition compoent-definition-local-and-remote to have the remote validations reintroduced * docs(common): add annotation about schema linting to UnmarshalYaml func * refactor(schemas): removed the duplicative ExtractErrors method in favor of updated go-oscal implementation * docs(reference): update the Validation reference readme * fix(schemas): validation schema fixed create-resources, semver, and kubernetes-spec constraints refactor(common): created linting method for validation, update ToLulaValidation to run lint, remove linting from validation.UnmarshalYaml * docs: update reference/README.md * chore: remove lula.schema.json * feat(lint): add composition to lint that can be disabled using the -c flag * chore(docs): add schema-updates.md * refactor(cmd): rm composition from tools lint chore(docs): update docs to reflect changes * feat(cmd): add dev validate command. --------- Co-authored-by: Brandt Keller <43887158+brandtkeller@users.noreply.github.com> --- .github/pull_request_template.md | 1 + Makefile | 6 +- docs/cli-commands/dev/lint.md | 30 + docs/cli-commands/tools/compose.md | 4 +- docs/cli-commands/tools/lint.md | 32 + .../schema-updates.md | 3 + docs/reference/README.md | 97 ++- docs/reference/domains/kubernetes-domain.md | 4 +- lula.schema.json | 205 ------ src/cmd/dev/lint.go | 127 ++++ src/cmd/tools/compose.go | 51 +- src/cmd/tools/lint.go | 14 +- src/pkg/common/composition/composition.go | 39 ++ .../common/composition/composition_test.go | 39 ++ src/pkg/common/network/network.go | 15 +- src/pkg/common/network/network_test.go | 28 +- src/pkg/common/oscal/multi-validate.go | 4 +- src/pkg/common/schemas/schema.go | 120 ++++ src/pkg/common/schemas/schema_test.go | 90 +++ src/pkg/common/schemas/validation.json | 593 ++++++++++++++++++ src/pkg/common/types.go | 33 +- .../validation-result/validation-result.go | 42 ++ .../validation-result_test.go | 85 +++ src/pkg/domains/api/types.go | 3 + src/pkg/providers/opa/types.go | 10 +- src/test/e2e/dev_lint_test.go | 60 ++ .../scenarios/api-field/oscal-component.yaml | 3 + .../create-resources/validation.yaml | 140 ++--- .../dev-get-resources/validation.yaml | 12 +- .../dev-lint/invalid.opa.validation.yaml | 26 + .../scenarios/dev-lint/multi.validation.yaml | 59 ++ .../scenarios/dev-lint/opa.validation.yaml | 27 + .../dev-lint/validation.kyverno.yaml | 31 + .../dev-validate/validation.kyverno.yaml | 28 +- .../scenarios/dev-validate/validation.yaml | 16 +- .../component-definition.yaml | 14 +- .../remote-validations/multi-validations.yaml | 2 - .../validation.kyverno.yaml | 4 +- .../remote-validations/validation.opa.yaml | 5 +- .../component-definition.yaml | 2 +- .../multi-validations.yaml | 2 - .../validation.kyverno.yaml | 2 +- .../validation.opa.yaml | 2 +- .../e2e/scenarios/wait-field/validation.yaml | 45 +- .../common/oscal/valid-back-matter-map.yaml | 81 +-- .../unit/common/oscal/valid-component.yaml | 81 +-- .../common/validation/multi.validation.yaml | 59 ++ .../common/validation/opa.validation.yaml | 27 + .../common/validation/validation.kyverno.yaml | 31 + 49 files changed, 1960 insertions(+), 474 deletions(-) create mode 100644 docs/cli-commands/dev/lint.md create mode 100644 docs/cli-commands/tools/lint.md create mode 100644 docs/community-and-contribution/schema-updates.md delete mode 100644 lula.schema.json create mode 100644 src/cmd/dev/lint.go create mode 100644 src/pkg/common/schemas/schema.go create mode 100644 src/pkg/common/schemas/schema_test.go create mode 100644 src/pkg/common/schemas/validation.json create mode 100644 src/pkg/common/validation-result/validation-result.go create mode 100644 src/pkg/common/validation-result/validation-result_test.go create mode 100644 src/test/e2e/dev_lint_test.go create mode 100644 src/test/e2e/scenarios/dev-lint/invalid.opa.validation.yaml create mode 100644 src/test/e2e/scenarios/dev-lint/multi.validation.yaml create mode 100644 src/test/e2e/scenarios/dev-lint/opa.validation.yaml create mode 100644 src/test/e2e/scenarios/dev-lint/validation.kyverno.yaml create mode 100644 src/test/unit/common/validation/multi.validation.yaml create mode 100644 src/test/unit/common/validation/opa.validation.yaml create mode 100644 src/test/unit/common/validation/validation.kyverno.yaml diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 4f1a26cd..678b6e65 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -17,4 +17,5 @@ Relates to # ## Checklist before merging - [ ] Test, docs, adr added or updated as needed +- [ ] [Schema Updates](https://github.com/defenseunicorns/lula/blob/main/docs/community-and-contribution/schema-updates.md) applied - [ ] [Contributor Guide Steps](https://github.com/defenseunicorns/lula/blob/main/CONTRIBUTING.md) followed \ No newline at end of file diff --git a/Makefile b/Makefile index 23b9b725..69a2bbd1 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,7 @@ CLI_VERSION ?= $(if $(shell git describe --tags),$(shell git describe --tags),"u # Go CLI options PKG := ./... +UNIT_PKG := $(shell go list ./... | grep -v 'e2e') TAGS := TESTS := . TESTFLAGS := -race -v @@ -63,6 +64,10 @@ $(BINDIR)/$(BINNAME): $(SRC) test: go clean -testcache && go test $(GOFLAGS) -run $(TESTS) $(PKG) $(TESTFLAGS) +.PHONY: test-unit +test-unit: # Run tests excluding those in the e2e folder. + go clean -testcache && go test $(GOFLAGS) -run $(TESTS) $(UNIT_PKG) $(TESTFLAGS) + .PHONY: test-e2e test-e2e: cd src/test/e2e && go clean -testcache && go test $(GOFLAGS) -run $(TESTS) $(PKG) $(TESTFLAGS) @@ -74,4 +79,3 @@ test-cmd: .PHONY: install install: ## Install binary to $INSTALL_PATH. @install "$(BINDIR)/$(BINNAME)" "$(INSTALL_PATH)/$(BINNAME)" - diff --git a/docs/cli-commands/dev/lint.md b/docs/cli-commands/dev/lint.md new file mode 100644 index 00000000..aa433014 --- /dev/null +++ b/docs/cli-commands/dev/lint.md @@ -0,0 +1,30 @@ +# Lint Command + +The `lula dev lint` command is used to validate validation files against the schema. It can validate both local files and URLs. + +## Usage + +```bash +lula dev lint -f [-r ] +``` + +## Options + +- `-f, --input-files`: The paths to the validation files (comma-separated). +- `-r, --result-file`: The path to the result file. If not specified, the validation results will be printed to the console. + +## Examples + +To lint existing validation files: +```bash +lula dev lint -f ./validation-file1.yaml,./validation-file2.yaml,https://example.com/validation-file3.yaml +``` + +To specify a result file: +```bash +lula dev lint -f ./validation-file1.yaml,./validation-file2.yaml -r validation-results.json +``` + +## Notes + +The validation results will be written to the specified result file. If there is at least one validation result that is not valid, the command will exit with a fatal error listing the files that failed linting. diff --git a/docs/cli-commands/tools/compose.md b/docs/cli-commands/tools/compose.md index 3564873e..4637bd05 100644 --- a/docs/cli-commands/tools/compose.md +++ b/docs/cli-commands/tools/compose.md @@ -1,6 +1,6 @@ # Compose Command -The `compose` command is used to compose an OSCAL component definition. It is used to compose remote validations within a component definition in order to resolve any references for portability. +The `lula tools compose` command is used to compose an OSCAL component definition. It is used to compose remote validations within a component definition in order to resolve any references for portability. ## Usage @@ -11,7 +11,7 @@ lula tools compose -f -o ## Options - `-f, --input-file`: The path to the target OSCAL component definition. -- `-o, --output-file`: The path to the output file. If not specified, the output file will be the original filename with `-composed` appended. +- `-o, --output-file`: The path to the output file. If not specified, the output file will be the original filename with `-composed` appended (ie. `oscal-component.yaml` will be composed to `oscal-component-composed.yaml`). ## Examples diff --git a/docs/cli-commands/tools/lint.md b/docs/cli-commands/tools/lint.md new file mode 100644 index 00000000..613d5716 --- /dev/null +++ b/docs/cli-commands/tools/lint.md @@ -0,0 +1,32 @@ +# Lint Command + +The `lula tools lint` command is used to validate OSCAL files against the OSCAL schema. It can validate both composed and non-composed OSCAL models. +> **Note**: the `lint` command does not compose the OSCAL model. +> If you want to validate a composed OSCAL model, you should use the [`lula tools compose`](../compose/README.md) command first. + +## Usage + +```bash +lula tools lint -f [-r ] +``` + +## Options + +- `-f, --input-files`: The paths to the tar get OSCAL files (comma-separated). +- `-r, --result-file`: The path to the result file. If not specified, the validation results will be printed to the console. + +## Examples + +To lint existing OSCAL files: +```bash +lula tools lint -f ./oscal-component1.yaml,./oscal-component2.yaml +``` + +To specify a result file: +```bash +lula tools lint -f ./oscal-component1.yaml,./oscal-component2.yaml -r validation-results.json +``` + +## Notes + +If no input files are specified, an error will be returned. The validation results will be written to the specified result file. If no result file is specified, the validation results will be printed to the console. If there is at least one validation result that is not valid, the command will exit with a fatal error listing the files that failed linting. \ No newline at end of file diff --git a/docs/community-and-contribution/schema-updates.md b/docs/community-and-contribution/schema-updates.md new file mode 100644 index 00000000..1b7c0d95 --- /dev/null +++ b/docs/community-and-contribution/schema-updates.md @@ -0,0 +1,3 @@ +# Schema Updates + +Any changes type changes effecting one of the schemas in `src/pkg/common/schemas` should be reflected in the relevant `types.go` file and vice versa. This will ensure that the schema is kept in sync with the Go type definitions. diff --git a/docs/reference/README.md b/docs/reference/README.md index cf89fcc7..de0b207f 100644 --- a/docs/reference/README.md +++ b/docs/reference/README.md @@ -1 +1,96 @@ -# Validation Reference \ No newline at end of file +# Validation Reference + +### Validation Struct + +The `Validation` struct is a data structure used for ingesting validation data. It contains the following fields: + +- `LulaVersion` (string): Optional field to maintain backward compatibility. +- `Metadata` (*Metadata): Optional metadata containing the name and UUID of the validation. +- `Provider` (*Provider): Required field specifying the provider and its corresponding specification. +- `Domain` (*Domain): Required field specifying the domain and its corresponding specification. + +#### Metadata Struct + +The `Metadata` struct contains the following fields: + +- `Name` (string): Optional short description to use in the output of validations. +- `UUID` (string): Optional UUID of the validation. + +#### Domain Struct + +The `Domain` struct contains the following fields: + +- `Type` (string): Required field specifying the type of domain (enum: `kubernetes`, `api`). +- `KubernetesSpec` (*KubernetesSpec): Optional specification for a Kubernetes domain, required if type is `kubernetes`. +- `ApiSpec` (*ApiSpec): Optional specification for an API domain, required if type is `api`. + +#### Provider Struct + +The `Provider` struct contains the following fields: + +- `Type` (string): Required field specifying the type of provider (enum: `opa`, `kyverno`). +- `OpaSpec` (*OpaSpec): Optional specification for an OPA provider. +- `KyvernoSpec` (*KyvernoSpec): Optional specification for a Kyverno provider. + +### Example YAML Document + +The following is an example of a YAML document for a validation artifact: +```yaml +lula-version: ">=v0.2.0" +metadata: + name: Validate pods with label foo=bar + uuid: 123e4567-e89b-12d3-a456-426655440000 +domain: + type: kubernetes + kubernetes-spec: + resources: + - name: podsvt + resource-rule: + version: v1 + resource: pods + namespaces: [validation-test] +provider: + type: opa + opa-spec: + rego: | + package validate + + import future.keywords.every + + validate { + every pod in input.podsvt { + podLabel := pod.metadata.labels.foo + podLabel == "bar" + } + } +``` +## Linting +Linting is done by Lula when a `Validation` object is converted to a `LulaValidation` for evaluation. + +The `common.Validation.Lint` method is a convenience method to lint a `Validation` object. It performs the following step: + +1. **Marshalling**: The method marshals the `Validation` object into a YAML byte array using the `common.Validation.MarshalYaml` function. +2. **Linting**: The method runs linting against the marshalled `Validation` object. This is done using the `schemas.Validate` function, which ensures that the YAML data conforms to the expected [schema](../../src/pkg/common/schemas/validation.json). + +___ +The `schemas.Validate` function is responsible for validating the provided data against a specified JSON schema using [github.com/santhosh-tekuri/jsonschema/v5](https://github.com/santhosh-tekuri/jsonschema). The process involves the following steps: + +1. **Coercion to JSON Map**: The provided data, which can be either an interface or a byte array, is coerced into a JSON map using the `model.CoerceToJsonMap` function. +2. **Schema Retrieval**: The function retrieves the JSON schema specified by the `schema` parameter using the `GetSchema` function. +3. **Schema Compilation**: The retrieved schema is compiled into a format that can be used for validation using the `jsonschema.CompileString` function. +4. **Validation**: The coerced JSON map is validated against the compiled schema. If the validation fails, the function extracts the specific errors and returns them as a formatted string. + +## VS Code intellisense: +1. Ensure that the [YAML (Red Hat)](https://marketplace.visualstudio.com/items?itemName=redhat.vscode-yaml) extension is installed. +2. Add the following to your settings.json: +```json +"yaml.schemas": { + "${PATH_TO_LULA}/lula/src/pkg/common/schemas/validation.json": "*validation*.yaml" +}, +``` + + +> **Note:** +> - `${PATH_TO_LULA}` should be replaced with your path. +> - `*validation*.yaml` may be changed to match your project's validation file naming conventions. +> - can also be limited to project or workspace settings if desired \ No newline at end of file diff --git a/docs/reference/domains/kubernetes-domain.md b/docs/reference/domains/kubernetes-domain.md index 3efe9851..97eaaa43 100644 --- a/docs/reference/domains/kubernetes-domain.md +++ b/docs/reference/domains/kubernetes-domain.md @@ -17,10 +17,10 @@ domain: - name: podsvt # Required - Identifier to be read by the policy resource-rule: # Required - resource selection criteria, at least one resource rule is required name: # Optional - Used to retrieve a specific resource in a single namespace - group: # Required - empty or "" for core group + group: # Optional - empty or "" for core group version: v1 # Required - Version of resource resource: pods # Required - Resource type (API-recognized type, not Kind) - namespaces: [validation-test] # Required - Namespaces to validate the above resources in. Empty or "" for all namespace pr non-namespaced resources + namespaces: [validation-test] # Optional - Namespaces to validate the above resources in. Empty or "" for all namespace pr non-namespaced resources field: # Optional - Field to grab in a resource if it is in an unusable type, e.g., string json data. Must specify named resource to use. jsonpath: # Required - Jsonpath specifier of where to find the field from the top level object type: # Optional - Accepts "json" or "yaml". Default is "json". diff --git a/lula.schema.json b/lula.schema.json deleted file mode 100644 index 0bbe9ec2..00000000 --- a/lula.schema.json +++ /dev/null @@ -1,205 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "object", - "properties": { - "lula-version": { - "type": "string", - "description": "Optional (use to maintain backward compatibility)" - }, - "metadata": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Optional (short description to use in output of validations could be useful)" - } - }, - "additionalProperties": false - }, - "domain": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["kubernetes", "api"], - "description": "Required" - }, - "kubernetes-spec": { - "type": "object", - "properties": { - "resources": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { "type": "string" }, - "resource-rule": { - "type": "object", - "properties": { - "name": { "type": "string" }, - "group": { "type": "string" }, - "version": { "type": "string" }, - "resource": { "type": "string" }, - "namespaces": { - "type": "array", - "items": { "type": "string" } - }, - "field": { - "type": "object", - "properties": { - "jsonpath": { "type": "string" }, - "type": { "type": "string" }, - "base64": { "type": "boolean" } - }, - "required": ["jsonpath"] - } - }, - "required": ["version", "resource"] - } - }, - "required": ["name", "resource-rule"] - } - }, - "wait": { - "type": "object", - "properties": { - "condition": { "type": "string" }, - "kind": { "type": "string" }, - "namespace": { "type": "string" }, - "timeout": { "type": "string" } - } - } - }, - "additionalProperties": false - }, - "api-spec": { - "type": "object", - "properties": { - "requests": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { "type": "string" }, - "url": { "type": "string" } - }, - "required": ["name", "url"] - } - } - }, - "additionalProperties": false - } - }, - "required": ["type"] - }, - "provider": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["opa", "kyverno"], - "description": "Required" - }, - "opa-spec": { - "type": "object", - "properties": { - "rego": { - "type": "string", - "description": "Required" - }, - "output": { - "type": "object", - "properties": { - "validation": { "type": "string" }, - "observations": { - "type": "array", - "items": { "type": "string" } - } - } - } - }, - "additionalProperties": false - }, - "kyverno-spec": { - "type": "object", - "properties": { - "policy": { - "type": "object", - "properties": { - "apiVersion": { - "type": "string", - "pattern": "^json\\.kyverno\\.io/v1alpha1$" - }, - "kind": { - "type": "string", - "enum": ["ValidatingPolicy"] - }, - "metadata": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - }, - "required": ["name"] - }, - "spec": { - "type": "object", - "properties": { - "rules": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "assert": { - "type": "object", - "properties": { - "all": { - "type": "array", - "items": { - "type": "object", - "properties": { - "check": { - "type": "object", - "additionalProperties": true - } - }, - "required": ["check"] - } - } - }, - "required": ["all"] - } - }, - "required": ["name", "assert"] - } - } - }, - "required": ["rules"] - } - }, - "required": ["apiVersion", "kind", "metadata", "spec"] - } - }, - "output":{ - "type": "object", - "properties": { - "validation": { "type": "string" }, - "observations": { - "type": "array", - "items": { "type": "string" } - } - } - }, - "required": ["policy"] - } - }, - "required": ["type"] - } - }, - "additionalProperties": false, - "required": ["domain", "provider"] - } \ No newline at end of file diff --git a/src/cmd/dev/lint.go b/src/cmd/dev/lint.go new file mode 100644 index 00000000..096c9187 --- /dev/null +++ b/src/cmd/dev/lint.go @@ -0,0 +1,127 @@ +package dev + +import ( + "fmt" + "strings" + + oscalValidation "github.com/defenseunicorns/go-oscal/src/pkg/validation" + "github.com/defenseunicorns/lula/src/config" + "github.com/defenseunicorns/lula/src/pkg/common" + "github.com/defenseunicorns/lula/src/pkg/common/network" + validationResult "github.com/defenseunicorns/lula/src/pkg/common/validation-result" + "github.com/defenseunicorns/lula/src/pkg/message" + "github.com/spf13/cobra" +) + +type LintFlags struct { + InputFiles []string // -f --input-files + ResultFile string // -r --result-file +} + +var lintOpts = &LintFlags{} + +var lintHelp = ` +To lint existing validation files: + lula dev lint -f ,, [-r ] +` + +var lintCmd = &cobra.Command{ + Use: "lint", + Short: "Lint validation files against schema", + PersistentPreRun: func(cmd *cobra.Command, args []string) { + config.SkipLogFile = true + }, + Long: "Validate validation files are properly configured against the schema, file paths can be local or URLs (https://)", + Example: lintHelp, + Run: func(cmd *cobra.Command, args []string) { + if len(lintOpts.InputFiles) == 0 { + message.Fatalf(nil, "No input files specified") + } + + validationResults := DevLintCommand(lintOpts.InputFiles) + + // If result file is specified, write the validation results to the file + if lintOpts.ResultFile != "" { + // If there is only one validation result, write it to the file + if len(validationResults) == 1 { + oscalValidation.WriteValidationResult(validationResults[0], lintOpts.ResultFile) + } else { + // If there are multiple validation results, write them to the file + oscalValidation.WriteValidationResults(validationResults, lintOpts.ResultFile) + } + } + + // If there is at least one validation result that is not valid, exit with a fatal error + failedFiles := []string{} + for _, result := range validationResults { + if !result.Valid { + failedFiles = append(failedFiles, result.Metadata.DocumentPath) + } + } + if len(failedFiles) > 0 { + message.Fatal(nil, fmt.Sprintf("The following files failed linting: %s", strings.Join(failedFiles, ", "))) + } + }, +} + +func DevLintCommand(inputFiles []string) []oscalValidation.ValidationResult { + var validationResults []oscalValidation.ValidationResult + + for _, inputFile := range inputFiles { + var result oscalValidation.ValidationResult + spinner := message.NewProgressSpinner("Linting %s", inputFile) + + // handleFail is a helper function to handle the case where the validation fails from + // a non-schema error + handleFail := func(err error) { + result = validationResult.NewNonSchemaValidationError(err, "validation") + validationResults = append(validationResults, result) + message.WarnErrf(validationResult.GetNonSchemaError(result), "Failed to lint %s, %s", inputFile, validationResult.GetNonSchemaError(result).Error()) + spinner.Stop() + } + + defer spinner.Stop() + + validationBytes, err := network.Fetch(inputFile) + if err != nil { + handleFail(err) + break + } + + validations, err := common.ReadValidationsFromYaml(validationBytes) + if err != nil { + handleFail(err) + break + } + + allValid := true + // Lint each validation in the file + for _, validation := range validations { + result = validation.Lint() + result.Metadata.DocumentPath = inputFile + validationResults = append(validationResults, result) + + // If any of the validations fail, set allValid to false + if !result.Valid { + allValid = false + } + } + + if allValid { + message.Infof("Successfully linted %s", inputFile) + spinner.Success() + } else { + message.Warnf("Validation failed for %s", inputFile) + spinner.Stop() + } + } + return validationResults +} + +func init() { + + devCmd.AddCommand(lintCmd) + + lintCmd.Flags().StringSliceVarP(&lintOpts.InputFiles, "input-files", "f", []string{}, "the paths to validation files (comma-separated)") + lintCmd.Flags().StringVarP(&lintOpts.ResultFile, "result-file", "r", "", "the path to write the validation result") +} diff --git a/src/cmd/tools/compose.go b/src/cmd/tools/compose.go index 78fbca48..3320ff08 100644 --- a/src/cmd/tools/compose.go +++ b/src/cmd/tools/compose.go @@ -9,9 +9,8 @@ import ( "strings" "github.com/defenseunicorns/go-oscal/src/pkg/files" - "github.com/defenseunicorns/lula/src/pkg/common" + oscalTypes_1_1_2 "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-2" "github.com/defenseunicorns/lula/src/pkg/common/composition" - "github.com/defenseunicorns/lula/src/pkg/common/oscal" "github.com/defenseunicorns/lula/src/pkg/message" "github.com/spf13/cobra" "gopkg.in/yaml.v3" @@ -39,14 +38,26 @@ func init() { Long: "Lula Composition of an OSCAL component definition. Used to compose remote validations within a component definition in order to resolve any references for portability.", Example: composeHelp, Run: func(cmd *cobra.Command, args []string) { + composeSpinner := message.NewProgressSpinner("Composing %s", composeOpts.InputFile) + defer composeSpinner.Stop() + if composeOpts.InputFile == "" { message.Fatal(errors.New("flag input-file is not set"), "Please specify an input file with the -f flag") } - err := Compose(composeOpts.InputFile, composeOpts.OutputFile) + + outputFile := composeOpts.OutputFile + if outputFile == "" { + outputFile = GetDefaultOutputFile(composeOpts.InputFile) + } + + err := Compose(composeOpts.InputFile, outputFile) if err != nil { message.Fatalf(err, "Composition error: %s", err) } + + message.Infof("Composed OSCAL Component Definition to: %s", outputFile) + composeSpinner.Success() }, } @@ -56,47 +67,44 @@ func init() { composeCmd.Flags().StringVarP(&composeOpts.OutputFile, "output-file", "o", "", "the path to the output file. If not specified, the output file will be the original filename with `-composed` appended") } +// Compose composes an OSCAL model from a file path func Compose(inputFile, outputFile string) error { _, err := os.Stat(inputFile) if os.IsNotExist(err) { return fmt.Errorf("input file: %v does not exist - unable to compose document", inputFile) } - data, err := os.ReadFile(inputFile) - if err != nil { - return err - } - - // Change Cwd to the directory of the component definition - dirPath := filepath.Dir(inputFile) - message.Infof("changing cwd to %s", dirPath) - resetCwd, err := common.SetCwdToFileDir(dirPath) + // Compose the OSCAL model + model, err := composition.ComposeFromPath(inputFile) if err != nil { return err } - model, err := oscal.NewOscalModel(data) + // Write the composed OSCAL model to a file + err = WriteComposedOscalModel(model, outputFile, inputFile) if err != nil { return err } - err = composition.ComposeComponentDefinitions(model.ComponentDefinition) - if err != nil { - return err - } + return nil +} - // Reset Cwd to original before outputting - resetCwd() +// GetDefaultOutputFile returns the default output file name +func GetDefaultOutputFile(inputFile string) string { + return strings.TrimSuffix(inputFile, filepath.Ext(inputFile)) + "-composed" + filepath.Ext(inputFile) +} +// WriteComposedOscalModel writes the composed OSCAL model to a file +func WriteComposedOscalModel(model *oscalTypes_1_1_2.OscalCompleteSchema, outputFile string, inputFile string) (err error) { var b bytes.Buffer - // Format the output + yamlEncoder := yaml.NewEncoder(&b) yamlEncoder.SetIndent(2) yamlEncoder.Encode(model) outputFileName := outputFile if outputFileName == "" { - outputFileName = strings.TrimSuffix(inputFile, filepath.Ext(inputFile)) + "-composed" + filepath.Ext(inputFile) + outputFileName = GetDefaultOutputFile(inputFile) } message.Infof("Writing Composed OSCAL Component Definition to: %s", outputFileName) @@ -105,6 +113,5 @@ func Compose(inputFile, outputFile string) error { if err != nil { return err } - return nil } diff --git a/src/cmd/tools/lint.go b/src/cmd/tools/lint.go index 5f40ef0a..efa5ab2d 100644 --- a/src/cmd/tools/lint.go +++ b/src/cmd/tools/lint.go @@ -5,7 +5,7 @@ import ( "fmt" "strings" - "github.com/defenseunicorns/go-oscal/src/pkg/validation" + oscalValidation "github.com/defenseunicorns/go-oscal/src/pkg/validation" "github.com/defenseunicorns/lula/src/config" "github.com/defenseunicorns/lula/src/pkg/message" "github.com/spf13/cobra" @@ -20,7 +20,8 @@ var opts = &flags{} var lintHelp = ` To lint existing OSCAL files: - lula tools lint -f ,, + lula tools lint -f ,, [-r ] + ` func init() { @@ -33,16 +34,17 @@ func init() { Long: "Validate OSCAL documents are properly configured against the OSCAL schema", Example: lintHelp, Run: func(cmd *cobra.Command, args []string) { - var validationResults []validation.ValidationResult + var validationResults []oscalValidation.ValidationResult if len(opts.InputFiles) == 0 { message.Fatalf(nil, "No input files specified") } for _, inputFile := range opts.InputFiles { + spinner := message.NewProgressSpinner("Linting %s", inputFile) defer spinner.Stop() - validationResp, err := validation.ValidationCommand(inputFile) + validationResp, err := oscalValidation.ValidationCommand(inputFile) // fatal for non-validation errors if err != nil { message.Fatalf(err, "Failed to lint %s: %s", inputFile, err) @@ -77,10 +79,10 @@ func init() { if opts.ResultFile != "" { // If there is only one validation result, write it to the file if len(validationResults) == 1 { - validation.WriteValidationResult(validationResults[0], opts.ResultFile) + oscalValidation.WriteValidationResult(validationResults[0], opts.ResultFile) } else { // If there are multiple validation results, write them to the file - validation.WriteValidationResults(validationResults, opts.ResultFile) + oscalValidation.WriteValidationResults(validationResults, opts.ResultFile) } } diff --git a/src/pkg/common/composition/composition.go b/src/pkg/common/composition/composition.go index 5f3584a2..18a5d90f 100644 --- a/src/pkg/common/composition/composition.go +++ b/src/pkg/common/composition/composition.go @@ -4,6 +4,8 @@ import ( "bytes" "fmt" "io" + "os" + "path/filepath" "github.com/defenseunicorns/go-oscal/src/pkg/uuid" "github.com/defenseunicorns/go-oscal/src/pkg/versioning" @@ -15,6 +17,38 @@ import ( k8syaml "k8s.io/apimachinery/pkg/util/yaml" ) +// ComposeFromPath composes an OSCAL model from a file path +func ComposeFromPath(inputFile string) (model *oscalTypes_1_1_2.OscalCompleteSchema, err error) { + data, err := os.ReadFile(inputFile) + if err != nil { + return nil, err + } + + // Change Cwd to the directory of the component definition + // This is needed to resolve relative paths in the remote validations + dirPath := filepath.Dir(inputFile) + message.Infof("changing cwd to %s", dirPath) + resetCwd, err := common.SetCwdToFileDir(dirPath) + if err != nil { + return nil, err + } + + model, err = oscal.NewOscalModel(data) + if err != nil { + return nil, err + } + + err = ComposeComponentDefinitions(model.ComponentDefinition) + if err != nil { + return nil, err + } + + // Reset Cwd to original before outputting + resetCwd() + return model, nil +} + +// ComposeComponentDefinitions composes an OSCAL component definition by adding the remote resources to the back matter and updating with back matter links. func ComposeComponentDefinitions(compDef *oscalTypes_1_1_2.ComponentDefinition) error { if compDef == nil { return fmt.Errorf("component definition is nil") @@ -141,6 +175,11 @@ func ComposeComponentValidations(compDef *oscalTypes_1_1_2.ComponentDefinition) return nil } +// CreateTempDir creates a temporary directory to store the composed OSCAL models +func CreateTempDir() (string, error) { + return os.MkdirTemp("", "lula-composed-*") +} + // ReadComponentDefinitionsFromYaml reads a yaml file of validations to an array of validations func readComponentDefinitionsFromYaml(componentDefinitionBytes []byte) (componentDefinitionsArray []*oscalTypes_1_1_2.ComponentDefinition, err error) { decoder := k8syaml.NewYAMLOrJSONDecoder(bytes.NewReader(componentDefinitionBytes), 4096) diff --git a/src/pkg/common/composition/composition_test.go b/src/pkg/common/composition/composition_test.go index c5faf8dc..1b924c45 100644 --- a/src/pkg/common/composition/composition_test.go +++ b/src/pkg/common/composition/composition_test.go @@ -19,6 +19,45 @@ const ( compDefMultiImport = "../../../test/unit/common/composition/component-definition-import-multi-compdef.yaml" ) +func TestComposeFromPath(t *testing.T) { + t.Run("No imports, local validations", func(t *testing.T) { + model, err := composition.ComposeFromPath(allLocal) + if err != nil { + t.Fatalf("Error composing component definitions: %v", err) + } + if model == nil { + t.Error("expected the model to be composed") + } + }) + + t.Run("No imports, remote validations", func(t *testing.T) { + model, err := composition.ComposeFromPath(allRemote) + if err != nil { + t.Fatalf("Error composing component definitions: %v", err) + } + if model == nil { + t.Error("expected the model to be composed") + } + }) + + t.Run("Errors when file does not exist", func(t *testing.T) { + _, err := composition.ComposeFromPath("nonexistent") + if err == nil { + t.Error("expected an error") + } + }) + + t.Run("Resolves relative paths", func(t *testing.T) { + model, err := composition.ComposeFromPath(localAndRemote) + if err != nil { + t.Fatalf("Error composing component definitions: %v", err) + } + if model == nil { + t.Error("expected the model to be composed") + } + }) +} + func TestComposeComponentDefinitions(t *testing.T) { t.Run("No imports, local validations", func(t *testing.T) { og := getComponentDef(allLocal, t) diff --git a/src/pkg/common/network/network.go b/src/pkg/common/network/network.go index c11da49b..400f407e 100644 --- a/src/pkg/common/network/network.go +++ b/src/pkg/common/network/network.go @@ -23,9 +23,18 @@ var HttpClient = &http.Client{ // parseUrl parses a URL string into a url.URL object. func parseUrl(inputURL string) (*url.URL, error) { - parsedUrl, err := url.ParseRequestURI(inputURL) - if err != nil || parsedUrl.Scheme == "" || (parsedUrl.Scheme != "file" && parsedUrl.Host == "") { - return nil, errors.New("invalid URL") + if inputURL == "" { + return nil, errors.New("empty URL") + } + parsedUrl, err := url.Parse(inputURL) + if err != nil { + return nil, err + } + if parsedUrl.Scheme == "" { + return parseUrl(fmt.Sprintf("file:%s", inputURL)) + } + if parsedUrl.Scheme != "file" && parsedUrl.Host == "" { + return nil, errors.New("invalid URL, must be a file path, http(s) URL, or a valid URL with a host") } return parsedUrl, nil } diff --git a/src/pkg/common/network/network_test.go b/src/pkg/common/network/network_test.go index c5d22417..5c3ed966 100644 --- a/src/pkg/common/network/network_test.go +++ b/src/pkg/common/network/network_test.go @@ -23,11 +23,29 @@ func TestParseUrl(t *testing.T) { wantChecksum: false, }, { - name: "invalid url", - input: "backmatter/resources", + name: "Invalid URL scheme", + input: "ht@tp://example.com", wantErr: true, wantChecksum: false, }, + { + name: "Empty URL", + input: "", + wantErr: true, + wantChecksum: false, + }, + { + name: "URL with spaces", + input: "http://example .com", + wantErr: true, + wantChecksum: false, + }, + { + name: "Adds file if no scheme", + input: "path/to/file", + wantErr: false, + wantChecksum: false, + }, { name: "File url", input: "file://../../../../test/e2e/scenarios/remote-validations/validation.opa.yaml", @@ -36,7 +54,7 @@ func TestParseUrl(t *testing.T) { }, { name: "With Checksum", - input: "file://../../../../test/e2e/scenarios/remote-validations/validation.opa.yaml@e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + input: "file://../../../../test/e2e/scenarios/remote-validations/validation.opa.yaml@394f5efa7aa5c3163a631d0f2640efe836af07c77fa7b27749f00819dd869058", wantErr: false, wantChecksum: true, }, @@ -81,7 +99,7 @@ func TestFetch(t *testing.T) { }, { name: "File with checksum SHA-256", - url: "file://../../../../test/e2e/scenarios/remote-validations/validation.opa.yaml@9d09d88105eae1e0349b157c5ba98c4c4fb322e9e15e397ba794beabd5f05d44", + url: "file://../../../../test/e2e/scenarios/remote-validations/validation.opa.yaml@0f97afb4d95cc9b4d7962960d6f8c988c851b9ce84cda441cce2b232e787ae24", wantErr: false, }, { @@ -183,7 +201,7 @@ func TestParseChecksum(t *testing.T) { }, { name: "Invalid URL", - inputURL: "invalid", + inputURL: "", expectedURL: nil, expectedChecksum: "", wantErr: true, diff --git a/src/pkg/common/oscal/multi-validate.go b/src/pkg/common/oscal/multi-validate.go index 4fb554ed..95cccf02 100644 --- a/src/pkg/common/oscal/multi-validate.go +++ b/src/pkg/common/oscal/multi-validate.go @@ -4,7 +4,7 @@ import ( "errors" "github.com/defenseunicorns/go-oscal/src/pkg/model" - "github.com/defenseunicorns/go-oscal/src/pkg/validation" + oscalValidation "github.com/defenseunicorns/go-oscal/src/pkg/validation" ) func multiModelValidate(data []byte) (err error) { @@ -20,7 +20,7 @@ func multiModelValidate(data []byte) (err error) { for key, value := range jsonMap { jsonModel := make(map[string]interface{}) jsonModel[key] = value - validator, err := validation.NewValidator(jsonModel) + validator, err := oscalValidation.NewValidator(jsonModel) if err != nil { return err } diff --git a/src/pkg/common/schemas/schema.go b/src/pkg/common/schemas/schema.go new file mode 100644 index 00000000..dc9bfaeb --- /dev/null +++ b/src/pkg/common/schemas/schema.go @@ -0,0 +1,120 @@ +package schemas + +import ( + "embed" + "fmt" + "io/fs" + "strings" + "time" + + "github.com/defenseunicorns/go-oscal/src/pkg/model" + oscalValidation "github.com/defenseunicorns/go-oscal/src/pkg/validation" + validationResult "github.com/defenseunicorns/lula/src/pkg/common/validation-result" + "github.com/santhosh-tekuri/jsonschema/v5" +) + +//go:embed *.json +var Schemas embed.FS + +const ( + SCHEMA_SUFFIX = ".json" +) + +func PrefixSchema(path string) string { + if !strings.HasSuffix(path, SCHEMA_SUFFIX) { + path = path + SCHEMA_SUFFIX + } + return path +} + +// HasSchema checks if a schema exists in the schemas directory +func HasSchema(path string) bool { + path = PrefixSchema(path) + _, err := Schemas.Open(path) + return err == nil +} + +// ListSchemas returns a list of schema names +func ListSchemas() ([]string, error) { + files, err := ToMap() + if err != nil { + return nil, err + } + keys := make([]string, 0, len(files)) + for k := range files { + keys = append(keys, k) + } + return keys, nil +} + +// ToMap returns a map of schema names to schemas +func ToMap() (fileMap map[string]fs.DirEntry, err error) { + files, err := Schemas.ReadDir(".") + if err != nil { + return nil, err + } + fileMap = make(map[string]fs.DirEntry) + for _, file := range files { + name := file.Name() + isDir := file.IsDir() + if isDir || !strings.HasSuffix(name, SCHEMA_SUFFIX) { + continue + } + fileMap[name] = file + } + return fileMap, nil +} + +// GetSchema returns a schema from the schemas directory +func GetSchema(path string) ([]byte, error) { + path = PrefixSchema(path) + if !HasSchema(path) { + return nil, fmt.Errorf("schema not found") + } + return Schemas.ReadFile(path) +} + +func Validate(schema string, data model.InterfaceOrBytes) oscalValidation.ValidationResult { + + jsonMap, err := model.CoerceToJsonMap(data) + if err != nil { + return validationResult.NewNonSchemaValidationError(err, "validation") + } + + schemaBytes, err := GetSchema(schema) + if err != nil { + return validationResult.NewNonSchemaValidationError(err, "validation") + } + + sch, err := jsonschema.CompileString(schema, string(schemaBytes)) + if err != nil { + return validationResult.NewNonSchemaValidationError(err, "validation") + } + + err = sch.Validate(jsonMap) + if err != nil { + // If the error is not a validation error, return the error + validationErr, ok := err.(*jsonschema.ValidationError) + if !ok { + return validationResult.NewNonSchemaValidationError(err, "validation") + } + + // Extract the specific errors from the schema error + // Return the errors as a string + basicOutput := validationErr.BasicOutput() + basicErrors := oscalValidation.ExtractErrors(jsonMap, basicOutput) + return oscalValidation.ValidationResult{ + Valid: false, + TimeStamp: time.Now(), + Errors: basicErrors, + } + } + return oscalValidation.ValidationResult{ + Valid: true, + TimeStamp: time.Now(), + Errors: []oscalValidation.ValidatorError{}, + Metadata: oscalValidation.ValidationResultMetadata{ + DocumentType: "validation", + }, + } +} diff --git a/src/pkg/common/schemas/schema_test.go b/src/pkg/common/schemas/schema_test.go new file mode 100644 index 00000000..7d31ad07 --- /dev/null +++ b/src/pkg/common/schemas/schema_test.go @@ -0,0 +1,90 @@ +package schemas_test + +import ( + "io/fs" + "os" + "testing" + + "github.com/defenseunicorns/lula/src/pkg/common/schemas" + validationResult "github.com/defenseunicorns/lula/src/pkg/common/validation-result" +) + +func TestToMap(t *testing.T) { + t.Parallel() // Enable parallel execution of tests + t.Run("Should return a map with all the schemas", func(t *testing.T) { + t.Parallel() // Enable parallel execution of subtests + fileMap, err := schemas.ToMap() + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if len(fileMap) == 0 { + t.Errorf("Expected map to have some files, got %d", len(fileMap)) + } + + // Check if all files have the correct JSON schema suffix and are not directories + for fileName, fileInfo := range fileMap { + if fileInfo.IsDir() { + t.Errorf("Expected file but got directory for %s", fileName) + } + if fs.ValidPath(fileName) && !schemas.HasSchema(fileName) { + t.Errorf("File %s does not have the correct schema suffix", fileName) + } + } + }) +} + +func TestHasSchema(t *testing.T) { + t.Parallel() // Enable parallel execution of tests + t.Run("Should detect schema suffix correctly", func(t *testing.T) { + t.Parallel() // Enable parallel execution of subtests + validSchema := "validation.json" + invalidSchema := "validation.txt" + + if !schemas.HasSchema(validSchema) { + t.Errorf("Expected true for %s, got false", validSchema) + } + if schemas.HasSchema(invalidSchema) { + t.Errorf("Expected false for %s, got true", invalidSchema) + } + }) +} + +func TestListSchemas(t *testing.T) { + t.Parallel() // Enable parallel execution of tests + t.Run("Should list all schemas", func(t *testing.T) { + t.Parallel() // Enable parallel execution of subtests + schemasList, err := schemas.ListSchemas() + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if len(schemasList) == 0 { + t.Errorf("Expected non-empty schema list, got empty") + } + }) +} + +func TestValidate(t *testing.T) { + t.Parallel() // Enable parallel execution of tests + validationPath := "../../../test/unit/common/validation/opa.validation.yaml" + validationData, err := os.ReadFile(validationPath) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + t.Run("Should validate a schema", func(t *testing.T) { + t.Parallel() // Enable parallel execution of subtests + schema := "validation" + result := schemas.Validate(schema, validationData) + if validationResult.GetNonSchemaError(result) != nil { + t.Errorf("expected result to be valid, got %v", result) + } + }) + + t.Run("Should return an ValidationResult if the schema is missing required properties", func(t *testing.T) { + t.Parallel() // Enable parallel execution of subtests + schema := "validation" + result := schemas.Validate(schema, []byte("{\n\t\"name\": \"test\"\n}")) + if result.Valid == true { + t.Errorf("expected result to be invalid, got %v", result) + } + }) +} diff --git a/src/pkg/common/schemas/validation.json b/src/pkg/common/schemas/validation.json new file mode 100644 index 00000000..0f676325 --- /dev/null +++ b/src/pkg/common/schemas/validation.json @@ -0,0 +1,593 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Validation", + "type": "object", + "properties": { + "lula-version": { + "$ref": "#/definitions/semver", + "description": "Optional (use to maintain backward compatibility)" + }, + "metadata": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Optional (short description to use in output of validations could be useful)" + }, + "uuid": { + "$ref": "#/definitions/uuid" + } + } + }, + "domain": { + "$ref": "#/definitions/domain" + }, + "provider": { + "$ref": "#/definitions/provider" + } + }, + "definitions": { + "semver": { + "type": "string", + "description": "Semantic versioning string following the pattern major.minor.patch with optional pre-release and build metadata or an empty string.", + "pattern": "^$|^(?:[><=]*\\s*|~|\\^)?v?([0-9]+)\\.([0-9]+)\\.([0-9]+)(?:-([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?$" + }, + "uuid": { + "type": "string", + "format": "uuid", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" + }, + "domain": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "kubernetes", + "api" + ], + "description": "The type of domain (Required)" + }, + "kubernetes-spec": { + "$ref": "#/definitions/kubernetes-spec" + }, + "api-spec": { + "$ref": "#/definitions/api-spec" + } + }, + "allOf": [ + { + "required": [ + "type" + ] + }, + { + "if": { + "properties": { + "type": { + "const": "kubernetes" + } + } + }, + "then": { + "required": [ + "kubernetes-spec" + ] + } + }, + { + "if": { + "properties": { + "type": { + "const": "api" + } + } + }, + "then": { + "required": [ + "api-spec" + ] + } + } + ] + }, + "kubernetes-spec": { + "type": "object", + "properties": { + "resources": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/resource" + } + }, + "create-resources": { + "type": [ + "array", + "null" + ], + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Required - Identifier to be read by the policy" + }, + "namespace": { + "type": "string", + "description": "Optional - Namespace to be created if applicable (no need to specify if ns exists OR resource is non-namespaced)" + }, + "manifest": { + "type": "string", + "description": "Optional - Manifest string for resource(s) to create; Only optional if file is not specified" + }, + "file": { + "type": "string", + "description": "Optional - File name where resource(s) to create are stored; Only optional if manifest is not specified" + } + }, + "required": [ + "name" + ], + "allOf": [ + { + "if": { + "properties": { + "manifest": { + "const": null + } + } + }, + "then": { + "required": [ + "file" + ] + } + }, + { + "if": { + "properties": { + "file": { + "const": null + } + } + }, + "then": { + "required": [ + "manifest" + ] + } + } + ] + } + }, + "wait": { + "type": "object", + "properties": { + "condition": { + "type": "string", + "description": "Condition to wait for ie. 'Ready'" + }, + "jsonpath": { + "type": "string", + "description": "Jsonpath specifier of where to find the condition from the top level object" + }, + "kind": { + "type": "string", + "description": "Kind of resource to wait for" + }, + "namespace": { + "type": "string", + "description": "Namespace to wait for the resource in" + }, + "timeout": { + "type": "string", + "description": "Timeout for the wait" + } + } + } + }, + "anyOf": [ + { + "required": [ + "resources" + ] + }, + { + "required": [ + "create-resources" + ] + } + ] + }, + "resource": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Identifier to be read by the policy" + }, + "resource-rule": { + "$ref": "#/definitions/resource-rule" + } + }, + "required": [ + "name", + "resource-rule" + ] + }, + "resource-rule": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Used to retrieve a specific resource in a single namespace required if field is specified" + }, + "group": { + "type": "string", + "description": "Empty or \"\" for core group" + }, + "version": { + "type": "string", + "description": "Version of resource" + }, + "resource": { + "type": "string", + "description": "Resource type (API-recognized type, not Kind)" + }, + "namespaces": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Namespaces to validate the above resources in. Empty or \"\" for all namespace or non-namespaced resources. Required if name is specified" + }, + "field": { + "$ref": "#/definitions/field" + } + }, + "allOf": [ + { + "required": [ + "version", + "resource" + ] + }, + { + "if": { + "not": { + "properties": { + "field": { + "const": [ + null, + {} + ] + } + } + } + }, + "then": { + "required": [ + "name" + ] + } + }, + { + "if": { + "properties": { + "name": { + "type": "string" + } + } + }, + "then": { + "required": [ + "namespaces" + ] + } + } + ], + "description": "Resource selection criteria, at least one resource rule is required" + }, + "field": { + "type": "object", + "properties": { + "jsonpath": { + "type": "string", + "description": "Jsonpath specifier of where to find the field from the top level object" + }, + "type": { + "type": "string", + "enum": [ + "json", + "yaml" + ], + "default": "json", + "description": "Accepts \"json\" or \"yaml\". Default is \"json\"." + }, + "base64": { + "type": "boolean", + "description": "Boolean whether field is base64 encoded" + } + }, + "required": [ + "jsonpath" + ], + "description": "Field to grab in a resource if it is in an unusable type, e.g., string json data. Must specify named resource to use." + }, + "api-spec": { + "type": "object", + "properties": { + "requests": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + } + } + } + } + }, + "provider": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "opa", + "kyverno" + ], + "description": "Required" + }, + "opa-spec": { + "$ref": "#/definitions/opaSpec" + }, + "kyverno-spec": { + "$ref": "#/definitions/kyvernoSpec" + } + }, + "allOf": [ + { + "required": [ + "type" + ] + }, + { + "if": { + "properties": { + "type": { + "const": "opa" + } + } + }, + "then": { + "required": [ + "opa-spec" + ] + } + }, + { + "if": { + "properties": { + "type": { + "const": "kyverno" + } + } + }, + "then": { + "required": [ + "kyverno-spec" + ] + } + } + ] + }, + "opaSpec": { + "type": "object", + "properties": { + "rego": { + "type": "string", + "pattern": ".*\\S\\s\\n.*" + }, + "output": { + "type": "object", + "properties": { + "validation": { + "type": "string" + }, + "observations": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "required": [ + "rego" + ] + }, + "kyvernoSpec": { + "type": "object", + "properties": { + "policy": { + "type": "object", + "properties": { + "metadata": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "namespace": { + "type": "string" + }, + "labels": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "annotations": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, + "spec": { + "$ref": "#/definitions/validatingPolicySpec" + } + }, + "required": [ + "metadata", + "spec" + ] + }, + "output": { + "type": "object", + "properties": { + "validation": { + "type": "string" + }, + "observations": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "validation" + ] + } + }, + "required": [ + "policy" + ] + }, + "validatingPolicySpec": { + "type": "object", + "properties": { + "rules": { + "type": "array", + "items": { + "$ref": "#/definitions/validatingRule" + } + } + }, + "required": [ + "rules" + ] + }, + "validatingRule": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 63 + }, + "context": { + "type": "array", + "items": { + "$ref": "#/definitions/contextEntry" + } + }, + "match": { + "$ref": "#/definitions/match" + }, + "exclude": { + "$ref": "#/definitions/match" + }, + "identifier": { + "type": "string" + }, + "assert": { + "$ref": "#/definitions/assert" + } + }, + "required": [ + "name", + "assert" + ] + }, + "contextEntry": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "variable": { + "type": "object" + } + }, + "required": [ + "name" + ] + }, + "match": { + "type": "object", + "properties": { + "any": { + "type": "array", + "items": { + "type": "object" + } + }, + "all": { + "type": "array", + "items": { + "type": "object" + } + } + } + }, + "assert": { + "type": "object", + "properties": { + "any": { + "type": "array", + "items": { + "$ref": "#/definitions/assertion" + } + }, + "all": { + "type": "array", + "items": { + "$ref": "#/definitions/assertion" + } + } + } + }, + "assertion": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "check": { + "type": "object" + } + }, + "required": [ + "check" + ] + } + }, + "required": [ + "domain", + "provider" + ], + "additionalProperties": false +} \ No newline at end of file diff --git a/src/pkg/common/types.go b/src/pkg/common/types.go index 22232329..eb002aad 100644 --- a/src/pkg/common/types.go +++ b/src/pkg/common/types.go @@ -6,8 +6,11 @@ import ( "strings" "github.com/defenseunicorns/go-oscal/src/pkg/uuid" + oscalValidation "github.com/defenseunicorns/go-oscal/src/pkg/validation" oscalTypes_1_1_2 "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-2" "github.com/defenseunicorns/lula/src/config" + "github.com/defenseunicorns/lula/src/pkg/common/schemas" + validationResult "github.com/defenseunicorns/lula/src/pkg/common/validation-result" "github.com/defenseunicorns/lula/src/pkg/domains/api" kube "github.com/defenseunicorns/lula/src/pkg/domains/kubernetes" "github.com/defenseunicorns/lula/src/pkg/providers/kyverno" @@ -51,16 +54,20 @@ func (v *Validation) ToResource() (resource *oscalTypes_1_1_2.Resource, err erro return resource, nil } -// TODO: Perhaps extend this structure with other needed information, such as UUID or type of validation if workflow is needed +// Metadata is a structure that contains the name and uuid of a validation type Metadata struct { Name string `json:"name" yaml:"name"` UUID string `json:"uuid,omitempty" yaml:"uuid,omitempty"` } +// Domain is a structure that contains the domain type and the corresponding spec type Domain struct { - Type string `json:"type" yaml:"type"` + // Type is the type of domain: enum: kubernetes, api + Type string `json:"type" yaml:"type"` + // KubernetesSpec is the specification for a Kubernetes domain, required if type is kubernetes KubernetesSpec *kube.KubernetesSpec `json:"kubernetes-spec,omitempty" yaml:"kubernetes-spec,omitempty"` - ApiSpec *api.ApiSpec `json:"api-spec,omitempty" yaml:"api-spec,omitempty"` + // ApiSpec is the specification for an API domain, required if type is api + ApiSpec *api.ApiSpec `json:"api-spec,omitempty" yaml:"api-spec,omitempty"` } type Provider struct { @@ -69,6 +76,15 @@ type Provider struct { KyvernoSpec *kyverno.KyvernoSpec `json:"kyverno-spec,omitempty" yaml:"kyverno-spec,omitempty"` } +// Lint is a convenience method to lint a Validation object +func (validation *Validation) Lint() oscalValidation.ValidationResult { + validationBytes, err := validation.MarshalYaml() + if err != nil { + return validationResult.NewNonSchemaValidationError(err, "validation") + } + return schemas.Validate("validation", validationBytes) +} + // ToLulaValidation converts a Validation object to a LulaValidation object func (validation *Validation) ToLulaValidation() (lulaValidation types.LulaValidation, err error) { // Do version checking here to establish if the version is correct/acceptable @@ -79,11 +95,12 @@ func (validation *Validation) ToLulaValidation() (lulaValidation types.LulaValid versionConstraint = validation.LulaVersion } - if validation.Domain == nil { - return lulaValidation, fmt.Errorf("required domain is nil") - } - if validation.Provider == nil { - return lulaValidation, fmt.Errorf("required provider is nil") + lintResult := validation.Lint() + // If the validation is not valid, return the error + if validationResult.IsNonSchemaValidationError(lintResult) { + return lulaValidation, validationResult.GetNonSchemaError(lintResult) + } else if !lintResult.Valid { + return lulaValidation, fmt.Errorf("validation failed: %v", lintResult.Errors) } validVersion, versionErr := IsVersionValid(versionConstraint, currentVersion) diff --git a/src/pkg/common/validation-result/validation-result.go b/src/pkg/common/validation-result/validation-result.go new file mode 100644 index 00000000..860640d4 --- /dev/null +++ b/src/pkg/common/validation-result/validation-result.go @@ -0,0 +1,42 @@ +package validationResult + +import ( + "errors" + "time" + + oscalValidation "github.com/defenseunicorns/go-oscal/src/pkg/validation" +) + +// NON_SCHEMA_ERROR_ABSOLUTE_KEYWORD_LOCATION is the absolute keyword location for non-schema errors +const NON_SCHEMA_ERROR_ABSOLUTE_KEYWORD_LOCATION = "non-schema-error" + +// NewNonSchemaValidationError creates a system validation error +func NewNonSchemaValidationError(err error, documentType string) oscalValidation.ValidationResult { + return oscalValidation.ValidationResult{ + Valid: false, + TimeStamp: time.Now(), + Errors: []oscalValidation.ValidatorError{ + { + Error: err.Error(), + AbsoluteKeywordLocation: NON_SCHEMA_ERROR_ABSOLUTE_KEYWORD_LOCATION, + }, + }, + Metadata: oscalValidation.ValidationResultMetadata{ + DocumentType: documentType, + }, + } +} + +// IsNonSchemaValidationError checks if the result is a system validation error +func IsNonSchemaValidationError(result oscalValidation.ValidationResult) bool { + return len(result.Errors) == 1 && result.Errors[0].AbsoluteKeywordLocation == NON_SCHEMA_ERROR_ABSOLUTE_KEYWORD_LOCATION +} + +// GetNonSchemaError extracts the system validation error +// If the result is not a system validation error or if there are no errors, return nil +func GetNonSchemaError(result oscalValidation.ValidationResult) error { + if !IsNonSchemaValidationError(result) { + return nil + } + return errors.New(result.Errors[0].Error) +} diff --git a/src/pkg/common/validation-result/validation-result_test.go b/src/pkg/common/validation-result/validation-result_test.go new file mode 100644 index 00000000..f66ad470 --- /dev/null +++ b/src/pkg/common/validation-result/validation-result_test.go @@ -0,0 +1,85 @@ +package validationResult_test + +import ( + "errors" + "testing" + "time" + + oscalValidation "github.com/defenseunicorns/go-oscal/src/pkg/validation" + validationResult "github.com/defenseunicorns/lula/src/pkg/common/validation-result" +) + +func TestNewNonSchemaValidationError(t *testing.T) { + err := errors.New("test error") + documentType := "testDocument" + result := validationResult.NewNonSchemaValidationError(err, documentType) + + if result.Valid { + t.Errorf("Expected Valid to be false, got true") + } + if len(result.Errors) != 1 { + t.Errorf("Expected 1 error, got %d", len(result.Errors)) + } + if result.Errors[0].Error != "test error" { + t.Errorf("Expected error message 'test error', got '%s'", result.Errors[0].Error) + } + if result.Errors[0].AbsoluteKeywordLocation != validationResult.NON_SCHEMA_ERROR_ABSOLUTE_KEYWORD_LOCATION { + t.Errorf("Expected AbsoluteKeywordLocation '%s', got '%s'", validationResult.NON_SCHEMA_ERROR_ABSOLUTE_KEYWORD_LOCATION, result.Errors[0].AbsoluteKeywordLocation) + } + if result.Metadata.DocumentType != documentType { + t.Errorf("Expected DocumentType '%s', got '%s'", documentType, result.Metadata.DocumentType) + } + if time.Since(result.TimeStamp) > time.Second { + t.Errorf("TimeStamp is too old") + } +} + +func TestIsNonSchemaValidationError(t *testing.T) { + err := errors.New("test error") + documentType := "testDocument" + result := validationResult.NewNonSchemaValidationError(err, documentType) + + if !validationResult.IsNonSchemaValidationError(result) { + t.Errorf("Expected IsNonSchemaValidationError to be true, got false") + } + + // Test with a different error location + result.Errors[0].AbsoluteKeywordLocation = "different-location" + if validationResult.IsNonSchemaValidationError(result) { + t.Errorf("Expected IsNonSchemaValidationError to be false, got true") + } + + // Test with multiple errors + result.Errors = append(result.Errors, oscalValidation.ValidatorError{Error: "another error"}) + if validationResult.IsNonSchemaValidationError(result) { + t.Errorf("Expected IsNonSchemaValidationError to be false, got true") + } +} + +func TestGetNonSchemaError(t *testing.T) { + err := errors.New("test error") + documentType := "testDocument" + result := validationResult.NewNonSchemaValidationError(err, documentType) + + extractedErr := validationResult.GetNonSchemaError(result) + if extractedErr == nil { + t.Errorf("Expected non-nil error, got nil") + } + if extractedErr.Error() != "test error" { + t.Errorf("Expected error message 'test error', got '%s'", extractedErr.Error()) + } + + // Test with a different error location + result.Errors[0].AbsoluteKeywordLocation = "different-location" + extractedErr = validationResult.GetNonSchemaError(result) + if extractedErr != nil { + t.Errorf("Expected nil error, got '%s'", extractedErr.Error()) + } + + // Test with multiple errors + result.Errors = append(result.Errors, oscalValidation.ValidatorError{Error: "another error"}) + extractedErr = validationResult.GetNonSchemaError(result) + if extractedErr != nil { + t.Errorf("Expected nil error, got '%s'", extractedErr.Error()) + } +} diff --git a/src/pkg/domains/api/types.go b/src/pkg/domains/api/types.go index 63d6621b..92644fa5 100644 --- a/src/pkg/domains/api/types.go +++ b/src/pkg/domains/api/types.go @@ -4,6 +4,7 @@ import ( "github.com/defenseunicorns/lula/src/types" ) +// ApiDomain is a domain that is defined by a list of API requests type ApiDomain struct { // Spec is the specification of the API requests Spec *ApiSpec `json:"spec,omitempty" yaml:"spec,omitempty"` @@ -18,10 +19,12 @@ func (a ApiDomain) IsExecutable() bool { return false } +// ApiSpec contains a list of API requests type ApiSpec struct { Requests []Request `mapstructure:"requests" json:"requests" yaml:"requests"` } +// Request is a single API request type Request struct { Name string `json:"name" yaml:"name"` URL string `json:"url" yaml:"url"` diff --git a/src/pkg/providers/opa/types.go b/src/pkg/providers/opa/types.go index d0add3b1..72f531cd 100644 --- a/src/pkg/providers/opa/types.go +++ b/src/pkg/providers/opa/types.go @@ -22,12 +22,18 @@ func (o OpaProvider) Evaluate(resources types.DomainResources) (types.Result, er return results, nil } +// OpaSpec is the specification of the OPA policy, required if the provider type is opa type OpaSpec struct { - Rego string `json:"rego" yaml:"rego"` + // Required: Rego is the OPA policy + Rego string `json:"rego" yaml:"rego"` + // Optional: Output is the output of the OPA policy Output *OpaOutput `json:"output,omitempty" yaml:"output,omitempty"` } +// OpaOutput Defines the output structure for OPA validation results, including validation status and additional observations. type OpaOutput struct { - Validation string `json:"validation" yaml:"validation"` + // optional: Specifies the JSON path to a boolean value indicating the validation result. + Validation string `json:"validation" yaml:"validation"` + // optional: any additional observations to include (fields must resolve to strings) Observations []string `json:"observations" yaml:"observations"` } diff --git a/src/test/e2e/dev_lint_test.go b/src/test/e2e/dev_lint_test.go new file mode 100644 index 00000000..19646c16 --- /dev/null +++ b/src/test/e2e/dev_lint_test.go @@ -0,0 +1,60 @@ +package test + +import ( + "testing" + + "github.com/defenseunicorns/lula/src/cmd/dev" +) + +func TestLintCommand(t *testing.T) { + + // Define the test cases + testCases := []struct { + name string + inputFiles []string + valid []bool + }{ + { + name: "Valid multi validation file", + inputFiles: []string{"../../test/e2e/scenarios/dev-lint/multi.validation.yaml"}, + valid: []bool{true, true}, + }, + { + name: "Valid OPA validation file", + inputFiles: []string{"../../test/e2e/scenarios/dev-lint/opa.validation.yaml"}, + valid: []bool{true}, + }, + { + name: "Valid Kyverno validation file", + inputFiles: []string{"../../test/e2e/scenarios/dev-lint/validation.kyverno.yaml"}, + valid: []bool{true}, + }, + { + name: "Invalid OPA validation file", + inputFiles: []string{"../../test/e2e/scenarios/dev-lint/invalid.opa.validation.yaml"}, + valid: []bool{false}, + }, + { + name: "Multiple files", + inputFiles: []string{"../../test/e2e/scenarios/dev-lint/validation.kyverno.yaml", "../../test/e2e/scenarios/dev-lint/invalid.opa.validation.yaml"}, + valid: []bool{true, false}, + }, + { + name: "Remote validation file", + inputFiles: []string{"https://raw.githubusercontent.com/defenseunicorns/lula/main/src/test/e2e/scenarios/dev-validate/validation.kyverno.yaml"}, + valid: []bool{true}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + validationResults := dev.DevLintCommand(tc.inputFiles) + for i, result := range validationResults { + if result.Valid != tc.valid[i] { + t.Errorf("Expected valid to be %v, but got %v", tc.valid[i], result.Valid) + } + } + }) + } + +} diff --git a/src/test/e2e/scenarios/api-field/oscal-component.yaml b/src/test/e2e/scenarios/api-field/oscal-component.yaml index a034215a..65eb5c31 100644 --- a/src/test/e2e/scenarios/api-field/oscal-component.yaml +++ b/src/test/e2e/scenarios/api-field/oscal-component.yaml @@ -43,6 +43,9 @@ component-definition: resources: - uuid: C30E849E-C262-42DF-8C84-EA1B62A6AD90 description: >- + metadata: + name: test pass + uuid: 88AB3470-B96B-4D7C-BC36-02BF9563C46C domain: type: api api-spec: diff --git a/src/test/e2e/scenarios/create-resources/validation.yaml b/src/test/e2e/scenarios/create-resources/validation.yaml index 2b05d0cc..407043c1 100644 --- a/src/test/e2e/scenarios/create-resources/validation.yaml +++ b/src/test/e2e/scenarios/create-resources/validation.yaml @@ -1,80 +1,80 @@ -domain: +domain: type: kubernetes kubernetes-spec: create-resources: - - name: successPods - namespace: validation-test - manifest: | - apiVersion: v1 - kind: Pod - metadata: - name: success-1 - namespace: validation-test - spec: - containers: - - name: test-container - image: nginx - --- - apiVersion: v1 - kind: Pod - metadata: - name: success-2 - namespace: validation-test - spec: - containers: - - name: test-container - image: nginx - - name: failPods - namespace: secure-ns - manifest: | - apiVersion: v1 - kind: Pod - metadata: - name: fail-1 - namespace: secure-ns - spec: - containers: - - name: test-container - image: nginx + - name: successPods + namespace: validation-test + manifest: | + apiVersion: v1 + kind: Pod + metadata: + name: success-1 + namespace: validation-test + spec: + containers: + - name: test-container + image: nginx + --- + apiVersion: v1 + kind: Pod + metadata: + name: success-2 + namespace: validation-test + spec: + containers: + - name: test-container + image: nginx + - name: failPods + namespace: secure-ns + manifest: | + apiVersion: v1 + kind: Pod + metadata: + name: fail-1 + namespace: secure-ns + spec: + containers: + - name: test-container + image: nginx + securityContext: + privileged: true + --- + apiVersion: v1 + kind: Pod + metadata: + name: fail-2 + namespace: secure-ns + spec: + containers: + - name: test-container + image: nginx securityContext: - privileged: true - --- - apiVersion: v1 - kind: Pod - metadata: - name: fail-2 - namespace: secure-ns - spec: - containers: - - name: test-container - image: nginx - securityContext: - runAsUser: 0 - - name: netpolTestJob - namespace: another-ns - manifest: | - apiVersion: batch/v1 - kind: Job - metadata: - name: test-job - namespace: another-ns - spec: - template: - spec: - containers: - - name: test-container - image: nginx - command: ["curl", "http://fake-service:80"] - restartPolicy: Never - - name: remotePod - namespace: validation-test - file: 'file:../pod-label/pod.pass.yaml' -provider: + runAsUser: 0 + - name: netpolTestJob + namespace: another-ns + manifest: | + apiVersion: batch/v1 + kind: Job + metadata: + name: test-job + namespace: another-ns + spec: + template: + spec: + containers: + - name: test-container + image: nginx + command: ["curl", "http://fake-service:80"] + restartPolicy: Never + - name: remotePod + namespace: validation-test + file: "file:../pod-label/pod.pass.yaml" +provider: type: opa opa-spec: rego: | package validate - + default validate = false validate { check_success_pods @@ -104,4 +104,4 @@ provider: check_remote_pod { remote_pod_names := { pod.metadata.name | pod := input.remotePod[_]; pod.kind == "Pod" } count({"test-pod-label"}-remote_pod_names) == 0 - } \ No newline at end of file + } diff --git a/src/test/e2e/scenarios/dev-get-resources/validation.yaml b/src/test/e2e/scenarios/dev-get-resources/validation.yaml index 81cc68b2..73f795e4 100644 --- a/src/test/e2e/scenarios/dev-get-resources/validation.yaml +++ b/src/test/e2e/scenarios/dev-get-resources/validation.yaml @@ -1,4 +1,8 @@ -domain: +lula-version: ">= v0.2.0" +metadata: + name: test validation + uuid: 7f4b12a9-3b8e-4f0a-8a5c-1f2b5b2c9e4d +domain: type: kubernetes kubernetes-spec: resources: @@ -13,10 +17,10 @@ domain: version: v1 resource: configmaps namespaces: [validation-test] -provider: +provider: type: opa opa-spec: rego: | package validate - - default validate = false \ No newline at end of file + + default validate = false diff --git a/src/test/e2e/scenarios/dev-lint/invalid.opa.validation.yaml b/src/test/e2e/scenarios/dev-lint/invalid.opa.validation.yaml new file mode 100644 index 00000000..ff2b8e9d --- /dev/null +++ b/src/test/e2e/scenarios/dev-lint/invalid.opa.validation.yaml @@ -0,0 +1,26 @@ +lula-version: ">=v0.2.0" +metadata: + name: Validate pods with label foo=bar + uuid: 123e4567-e89b-12d3-a456-426655440000 +domain: + type: kubernetes + kubernetes-spec: + resources: + - name: podsvt + resource-rule: + version: v1 + resource: pods + namespaces: [validation-test] +provider: + opa-spec: + rego: | + package validate + + import future.keywords.every + + validate { + every pod in input.podsvt { + podLabel := pod.metadata.labels.foo + podLabel == "bar" + } + } diff --git a/src/test/e2e/scenarios/dev-lint/multi.validation.yaml b/src/test/e2e/scenarios/dev-lint/multi.validation.yaml new file mode 100644 index 00000000..d9461f46 --- /dev/null +++ b/src/test/e2e/scenarios/dev-lint/multi.validation.yaml @@ -0,0 +1,59 @@ +lula-version: ">=v0.2.0" +metadata: + name: Validate pods with label foo=bar + uuid: 123e4567-e89b-12d3-a456-426655440000 +domain: + type: kubernetes + kubernetes-spec: + resources: + - name: podsvt + resource-rule: + version: v1 + resource: pods + namespaces: [validation-test] +provider: + type: opa + opa-spec: + rego: | + package validate + + import future.keywords.every + + validate { + every pod in input.podsvt { + podLabel := pod.metadata.labels.foo + podLabel == "bar" + } + } +--- +lula-version: ">= v0.1.0" +metadata: + name: Kyverno validate pods with label foo=bar + uuid: 123e4567-e89b-12d3-a456-426614174000 +domain: + type: kubernetes + kubernetes-spec: + resources: + - name: podsvt # Identifier for use in the rego below + resource-rule: # Mandatory, resource selection criteria, at least one resource rule is required + version: v1 # Version of resource + resource: pods # Resource type + namespaces: [validation-test] # Namespaces to validate the above resources in. Empty or "" for all namespaces or non-namespaced resources +provider: + type: kyverno + kyverno-spec: + policy: + apiVersion: json.kyverno.io/v1alpha1 + kind: ValidatingPolicy + metadata: + name: labels + spec: + rules: + - name: foo-label-exists + assert: + all: + - check: + ~.podsvt: + metadata: + labels: + foo: bar diff --git a/src/test/e2e/scenarios/dev-lint/opa.validation.yaml b/src/test/e2e/scenarios/dev-lint/opa.validation.yaml new file mode 100644 index 00000000..a46af206 --- /dev/null +++ b/src/test/e2e/scenarios/dev-lint/opa.validation.yaml @@ -0,0 +1,27 @@ +lula-version: ">=v0.2.0" +metadata: + name: Validate pods with label foo=bar + uuid: 123e4567-e89b-12d3-a456-426655440000 +domain: + type: kubernetes + kubernetes-spec: + resources: + - name: podsvt + resource-rule: + version: v1 + resource: pods + namespaces: [validation-test] +provider: + type: opa + opa-spec: + rego: | + package validate + + import future.keywords.every + + validate { + every pod in input.podsvt { + podLabel := pod.metadata.labels.foo + podLabel == "bar" + } + } diff --git a/src/test/e2e/scenarios/dev-lint/validation.kyverno.yaml b/src/test/e2e/scenarios/dev-lint/validation.kyverno.yaml new file mode 100644 index 00000000..e0e6de7c --- /dev/null +++ b/src/test/e2e/scenarios/dev-lint/validation.kyverno.yaml @@ -0,0 +1,31 @@ +lula-version: ">= v0.1.0" +metadata: + name: Kyverno validate pods with label foo=bar + uuid: 123e4567-e89b-12d3-a456-426614174000 +domain: + type: kubernetes + kubernetes-spec: + resources: + - name: podsvt # Identifier for use in the rego below + resource-rule: # Mandatory, resource selection criteria, at least one resource rule is required + version: v1 # Version of resource + resource: pods # Resource type + namespaces: [validation-test] # Namespaces to validate the above resources in. Empty or "" for all namespaces or non-namespaced resources +provider: + type: kyverno + kyverno-spec: + policy: + apiVersion: json.kyverno.io/v1alpha1 + kind: ValidatingPolicy + metadata: + name: labels + spec: + rules: + - name: foo-label-exists + assert: + all: + - check: + ~.podsvt: + metadata: + labels: + foo: bar diff --git a/src/test/e2e/scenarios/dev-validate/validation.kyverno.yaml b/src/test/e2e/scenarios/dev-validate/validation.kyverno.yaml index 332641c1..e0e6de7c 100644 --- a/src/test/e2e/scenarios/dev-validate/validation.kyverno.yaml +++ b/src/test/e2e/scenarios/dev-validate/validation.kyverno.yaml @@ -1,16 +1,16 @@ lula-version: ">= v0.1.0" metadata: name: Kyverno validate pods with label foo=bar + uuid: 123e4567-e89b-12d3-a456-426614174000 domain: type: kubernetes kubernetes-spec: resources: - - name: podsvt # Identifier for use in the rego below - resource-rule: # Mandatory, resource selection criteria, at least one resource rule is required - group: # empty or "" for core group - version: v1 # Version of resource - resource: pods # Resource type - namespaces: [validation-test] # Namespaces to validate the above resources in. Empty or "" for all namespaces or non-namespaced resources + - name: podsvt # Identifier for use in the rego below + resource-rule: # Mandatory, resource selection criteria, at least one resource rule is required + version: v1 # Version of resource + resource: pods # Resource type + namespaces: [validation-test] # Namespaces to validate the above resources in. Empty or "" for all namespaces or non-namespaced resources provider: type: kyverno kyverno-spec: @@ -21,11 +21,11 @@ provider: name: labels spec: rules: - - name: foo-label-exists - assert: - all: - - check: - ~.podsvt: - metadata: - labels: - foo: bar + - name: foo-label-exists + assert: + all: + - check: + ~.podsvt: + metadata: + labels: + foo: bar diff --git a/src/test/e2e/scenarios/dev-validate/validation.yaml b/src/test/e2e/scenarios/dev-validate/validation.yaml index 00d5e269..6f4d2c39 100644 --- a/src/test/e2e/scenarios/dev-validate/validation.yaml +++ b/src/test/e2e/scenarios/dev-validate/validation.yaml @@ -1,17 +1,17 @@ lula-version: ">= v0.1.0" metadata: name: Validate pods with label foo=bar + uuid: 7f4b12a9-3b8f-4a8e-9f6e-8c8f506c851e domain: type: kubernetes kubernetes-spec: resources: - - name: podsvt - resource-rule: - group: - version: v1 - resource: pods - namespaces: [validation-test] -provider: + - name: podsvt + resource-rule: + version: v1 + resource: pods + namespaces: [validation-test] +provider: type: opa opa-spec: rego: | @@ -24,4 +24,4 @@ provider: podLabel := pod.metadata.labels.foo podLabel == "bar" } - } \ No newline at end of file + } diff --git a/src/test/e2e/scenarios/remote-validations/component-definition.yaml b/src/test/e2e/scenarios/remote-validations/component-definition.yaml index d39dcf2a..89e6d109 100644 --- a/src/test/e2e/scenarios/remote-validations/component-definition.yaml +++ b/src/test/e2e/scenarios/remote-validations/component-definition.yaml @@ -6,14 +6,14 @@ component-definition: - control-id: ID-1 description: This control validates that the demo-pod pod in the validation-test namespace contains the required pod label foo=bar in order to establish compliance. links: - # remote opa validation - - href: https://raw.githubusercontent.com/defenseunicorns/lula/main/src/test/e2e/scenarios/dev-validate/validation.yaml - rel: lula - # remote kyverno validation - - href: https://raw.githubusercontent.com/defenseunicorns/lula/main/src/test/e2e/scenarios/dev-validate/validation.kyverno.yaml - rel: lula + # # remote opa validation + # - href: https://raw.githubusercontent.com/defenseunicorns/lula/main/src/test/e2e/scenarios/dev-validate/validation.yaml + # rel: lula + # # remote kyverno validation + # - href: https://raw.githubusercontent.com/defenseunicorns/lula/main/src/test/e2e/scenarios/dev-validate/validation.kyverno.yaml + # rel: lula # single validation w/ checksum - - href: file://./validation.opa.yaml@9d09d88105eae1e0349b157c5ba98c4c4fb322e9e15e397ba794beabd5f05d44 + - href: file://./validation.opa.yaml@394f5efa7aa5c3163a631d0f2640efe836af07c77fa7b27749f00819dd869058 rel: lula # Single validation from multi-validations.yaml - href: file://./multi-validations.yaml diff --git a/src/test/e2e/scenarios/remote-validations/multi-validations.yaml b/src/test/e2e/scenarios/remote-validations/multi-validations.yaml index f7ed1b20..3561bbf8 100644 --- a/src/test/e2e/scenarios/remote-validations/multi-validations.yaml +++ b/src/test/e2e/scenarios/remote-validations/multi-validations.yaml @@ -8,7 +8,6 @@ domain: resources: - name: podsvt resource-rule: - group: version: v1 resource: pods namespaces: [validation-test] @@ -37,7 +36,6 @@ domain: resources: - name: podsvt # Identifier for use in the rego below resource-rule: # Mandatory, resource selection criteria, at least one resource rule is required - group: # empty or "" for core group version: v1 # Version of resource resource: pods # Resource type namespaces: [validation-test] # Namespaces to validate the above resources in. Empty or "" for all namespaces or non-namespaced resources diff --git a/src/test/e2e/scenarios/remote-validations/validation.kyverno.yaml b/src/test/e2e/scenarios/remote-validations/validation.kyverno.yaml index 275f3720..cf2225aa 100644 --- a/src/test/e2e/scenarios/remote-validations/validation.kyverno.yaml +++ b/src/test/e2e/scenarios/remote-validations/validation.kyverno.yaml @@ -1,13 +1,13 @@ -lula-version: ">= v0.2.0" +lula-version: ">=v0.2.0" metadata: name: Kyverno validate pods with label foo=bar + uuid: 14e02734-1626-429f-a1ef-49ce11edbe21 domain: type: kubernetes kubernetes-spec: resources: - name: podsvt # Identifier for use in the rego below resource-rule: # Mandatory, resource selection criteria, at least one resource rule is required - group: # empty or "" for core group version: v1 # Version of resource resource: pods # Resource type namespaces: [validation-test] # Namespaces to validate the above resources in. Empty or "" for all namespaces or non-namespaced resources diff --git a/src/test/e2e/scenarios/remote-validations/validation.opa.yaml b/src/test/e2e/scenarios/remote-validations/validation.opa.yaml index dd72d709..97264312 100644 --- a/src/test/e2e/scenarios/remote-validations/validation.opa.yaml +++ b/src/test/e2e/scenarios/remote-validations/validation.opa.yaml @@ -1,13 +1,14 @@ -lula-version: ">= v0.2.0" +lula-version: ">=v0.2.0" metadata: name: Validate pods with label foo=bar + uuid: 7f4c3b2a-1c3d-4a2b-8b64-3b1f76a8e36f domain: type: kubernetes kubernetes-spec: resources: - name: podsvt resource-rule: - group: + name: podsvt version: v1 resource: pods namespaces: [validation-test] diff --git a/src/test/e2e/scenarios/validation-composition/component-definition.yaml b/src/test/e2e/scenarios/validation-composition/component-definition.yaml index d39dcf2a..79e91b40 100644 --- a/src/test/e2e/scenarios/validation-composition/component-definition.yaml +++ b/src/test/e2e/scenarios/validation-composition/component-definition.yaml @@ -13,7 +13,7 @@ component-definition: - href: https://raw.githubusercontent.com/defenseunicorns/lula/main/src/test/e2e/scenarios/dev-validate/validation.kyverno.yaml rel: lula # single validation w/ checksum - - href: file://./validation.opa.yaml@9d09d88105eae1e0349b157c5ba98c4c4fb322e9e15e397ba794beabd5f05d44 + - href: file://./validation.opa.yaml@169b2ffb1e682c713381538abac7caff04a9271f8758af17ad68f7ed30a07b38 rel: lula # Single validation from multi-validations.yaml - href: file://./multi-validations.yaml diff --git a/src/test/e2e/scenarios/validation-composition/multi-validations.yaml b/src/test/e2e/scenarios/validation-composition/multi-validations.yaml index f7ed1b20..3561bbf8 100644 --- a/src/test/e2e/scenarios/validation-composition/multi-validations.yaml +++ b/src/test/e2e/scenarios/validation-composition/multi-validations.yaml @@ -8,7 +8,6 @@ domain: resources: - name: podsvt resource-rule: - group: version: v1 resource: pods namespaces: [validation-test] @@ -37,7 +36,6 @@ domain: resources: - name: podsvt # Identifier for use in the rego below resource-rule: # Mandatory, resource selection criteria, at least one resource rule is required - group: # empty or "" for core group version: v1 # Version of resource resource: pods # Resource type namespaces: [validation-test] # Namespaces to validate the above resources in. Empty or "" for all namespaces or non-namespaced resources diff --git a/src/test/e2e/scenarios/validation-composition/validation.kyverno.yaml b/src/test/e2e/scenarios/validation-composition/validation.kyverno.yaml index 275f3720..0115229b 100644 --- a/src/test/e2e/scenarios/validation-composition/validation.kyverno.yaml +++ b/src/test/e2e/scenarios/validation-composition/validation.kyverno.yaml @@ -1,13 +1,13 @@ lula-version: ">= v0.2.0" metadata: name: Kyverno validate pods with label foo=bar + uuid: 14e02734-1626-429f-a1ef-49ce11edbe21 domain: type: kubernetes kubernetes-spec: resources: - name: podsvt # Identifier for use in the rego below resource-rule: # Mandatory, resource selection criteria, at least one resource rule is required - group: # empty or "" for core group version: v1 # Version of resource resource: pods # Resource type namespaces: [validation-test] # Namespaces to validate the above resources in. Empty or "" for all namespaces or non-namespaced resources diff --git a/src/test/e2e/scenarios/validation-composition/validation.opa.yaml b/src/test/e2e/scenarios/validation-composition/validation.opa.yaml index dd72d709..8e4d95aa 100644 --- a/src/test/e2e/scenarios/validation-composition/validation.opa.yaml +++ b/src/test/e2e/scenarios/validation-composition/validation.opa.yaml @@ -1,13 +1,13 @@ lula-version: ">= v0.2.0" metadata: name: Validate pods with label foo=bar + uuid: 7f4c3b2a-1c3d-4a2b-8b64-3b1f76a8e36f domain: type: kubernetes kubernetes-spec: resources: - name: podsvt resource-rule: - group: version: v1 resource: pods namespaces: [validation-test] diff --git a/src/test/e2e/scenarios/wait-field/validation.yaml b/src/test/e2e/scenarios/wait-field/validation.yaml index 7e04b29b..c69f08b4 100644 --- a/src/test/e2e/scenarios/wait-field/validation.yaml +++ b/src/test/e2e/scenarios/wait-field/validation.yaml @@ -1,29 +1,28 @@ -target: - domain: - type: kubernetes - kubernetes-spec: - resources: +domain: + type: kubernetes + kubernetes-spec: + resources: - name: podsvt resource-rule: + resource: pods version: v1 - kind: pods namespaces: [validation-test] - wait: - condition: Ready - kind: pod/test-pod-wait - namespace: validation-test - timeout: 30s - provider: - type: opa - opa-spec: - rego: | - package validate + wait: + condition: Ready + kind: pod/test-pod-wait + namespace: validation-test + timeout: 30s +provider: + type: opa + opa-spec: + rego: | + package validate - import future.keywords.every + import future.keywords.every - validate { - every pod in input.podsvt { - podLabel := pod.metadata.labels.foo - podLabel == "bar" - } - } \ No newline at end of file + validate { + every pod in input.podsvt { + podLabel := pod.metadata.labels.foo + podLabel == "bar" + } + } diff --git a/src/test/unit/common/oscal/valid-back-matter-map.yaml b/src/test/unit/common/oscal/valid-back-matter-map.yaml index 93e17d5d..918e6582 100644 --- a/src/test/unit/common/oscal/valid-back-matter-map.yaml +++ b/src/test/unit/common/oscal/valid-back-matter-map.yaml @@ -1,46 +1,49 @@ 88AB3470-B96B-4D7C-BC36-02BF9563C46C: >- - domain: + metadata: + name: Validate pods with label foo=bar + uuid: 88AB3470-B96B-4D7C-BC36-02BF9563C46C + domain: type: kubernetes kubernetes-spec: resources: - - name: jsoncm - resource-rule: - name: configmap-json - version: v1 - resource: configmaps - namespaces: [validation-test] - field: - jsonpath: .data.person.json - type: yaml - - name: yamlcm - resource-rule: - name: configmap-yaml - version: v1 - resource: configmaps - namespaces: [validation-test] - field: - jsonpath: .data.app-config.yaml - type: yaml - - name: secret - resource-rule: - name: example-secret - version: v1 - resource: secrets - namespaces: [validation-test] - field: - jsonpath: .data.auth - type: yaml - base64: true - - name: pod - resource-rule: - name: example-pod - version: v1 - resource: pods - namespaces: [validation-test] - field: - jsonpath: .metadata.annotations.annotation.io/simple - type: json - provider: + - name: jsoncm + resource-rule: + name: configmap-json + version: v1 + resource: configmaps + namespaces: [validation-test] + field: + jsonpath: .data.person.json + type: yaml + - name: yamlcm + resource-rule: + name: configmap-yaml + version: v1 + resource: configmaps + namespaces: [validation-test] + field: + jsonpath: .data.app-config.yaml + type: yaml + - name: secret + resource-rule: + name: example-secret + version: v1 + resource: secrets + namespaces: [validation-test] + field: + jsonpath: .data.auth + type: yaml + base64: true + - name: pod + resource-rule: + name: example-pod + version: v1 + resource: pods + namespaces: [validation-test] + field: + jsonpath: .metadata.annotations.annotation.io/simple + type: json + provider: type: opa opa-spec: rego: | diff --git a/src/test/unit/common/oscal/valid-component.yaml b/src/test/unit/common/oscal/valid-component.yaml index a43cfe71..514abd16 100644 --- a/src/test/unit/common/oscal/valid-component.yaml +++ b/src/test/unit/common/oscal/valid-component.yaml @@ -45,48 +45,51 @@ component-definition: remarks: >- Get data for all resources fields specified description: >- - domain: + metadata: + name: Validate pods with label foo=bar + uuid: 88AB3470-B96B-4D7C-BC36-02BF9563C46C + domain: type: kubernetes kubernetes-spec: resources: - - name: jsoncm - resource-rule: - name: configmap-json - version: v1 - resource: configmaps - namespaces: [validation-test] - field: - jsonpath: .data.person.json - type: yaml - - name: yamlcm - resource-rule: - name: configmap-yaml - version: v1 - resource: configmaps - namespaces: [validation-test] - field: - jsonpath: .data.app-config.yaml - type: yaml - - name: secret - resource-rule: - name: example-secret - version: v1 - resource: secrets - namespaces: [validation-test] - field: - jsonpath: .data.auth - type: yaml - base64: true - - name: pod - resource-rule: - name: example-pod - version: v1 - resource: pods - namespaces: [validation-test] - field: - jsonpath: .metadata.annotations.annotation.io/simple - type: json - provider: + - name: jsoncm + resource-rule: + name: configmap-json + version: v1 + resource: configmaps + namespaces: [validation-test] + field: + jsonpath: .data.person.json + type: yaml + - name: yamlcm + resource-rule: + name: configmap-yaml + version: v1 + resource: configmaps + namespaces: [validation-test] + field: + jsonpath: .data.app-config.yaml + type: yaml + - name: secret + resource-rule: + name: example-secret + version: v1 + resource: secrets + namespaces: [validation-test] + field: + jsonpath: .data.auth + type: yaml + base64: true + - name: pod + resource-rule: + name: example-pod + version: v1 + resource: pods + namespaces: [validation-test] + field: + jsonpath: .metadata.annotations.annotation.io/simple + type: json + provider: type: opa opa-spec: rego: | diff --git a/src/test/unit/common/validation/multi.validation.yaml b/src/test/unit/common/validation/multi.validation.yaml new file mode 100644 index 00000000..d9461f46 --- /dev/null +++ b/src/test/unit/common/validation/multi.validation.yaml @@ -0,0 +1,59 @@ +lula-version: ">=v0.2.0" +metadata: + name: Validate pods with label foo=bar + uuid: 123e4567-e89b-12d3-a456-426655440000 +domain: + type: kubernetes + kubernetes-spec: + resources: + - name: podsvt + resource-rule: + version: v1 + resource: pods + namespaces: [validation-test] +provider: + type: opa + opa-spec: + rego: | + package validate + + import future.keywords.every + + validate { + every pod in input.podsvt { + podLabel := pod.metadata.labels.foo + podLabel == "bar" + } + } +--- +lula-version: ">= v0.1.0" +metadata: + name: Kyverno validate pods with label foo=bar + uuid: 123e4567-e89b-12d3-a456-426614174000 +domain: + type: kubernetes + kubernetes-spec: + resources: + - name: podsvt # Identifier for use in the rego below + resource-rule: # Mandatory, resource selection criteria, at least one resource rule is required + version: v1 # Version of resource + resource: pods # Resource type + namespaces: [validation-test] # Namespaces to validate the above resources in. Empty or "" for all namespaces or non-namespaced resources +provider: + type: kyverno + kyverno-spec: + policy: + apiVersion: json.kyverno.io/v1alpha1 + kind: ValidatingPolicy + metadata: + name: labels + spec: + rules: + - name: foo-label-exists + assert: + all: + - check: + ~.podsvt: + metadata: + labels: + foo: bar diff --git a/src/test/unit/common/validation/opa.validation.yaml b/src/test/unit/common/validation/opa.validation.yaml new file mode 100644 index 00000000..a46af206 --- /dev/null +++ b/src/test/unit/common/validation/opa.validation.yaml @@ -0,0 +1,27 @@ +lula-version: ">=v0.2.0" +metadata: + name: Validate pods with label foo=bar + uuid: 123e4567-e89b-12d3-a456-426655440000 +domain: + type: kubernetes + kubernetes-spec: + resources: + - name: podsvt + resource-rule: + version: v1 + resource: pods + namespaces: [validation-test] +provider: + type: opa + opa-spec: + rego: | + package validate + + import future.keywords.every + + validate { + every pod in input.podsvt { + podLabel := pod.metadata.labels.foo + podLabel == "bar" + } + } diff --git a/src/test/unit/common/validation/validation.kyverno.yaml b/src/test/unit/common/validation/validation.kyverno.yaml new file mode 100644 index 00000000..e0e6de7c --- /dev/null +++ b/src/test/unit/common/validation/validation.kyverno.yaml @@ -0,0 +1,31 @@ +lula-version: ">= v0.1.0" +metadata: + name: Kyverno validate pods with label foo=bar + uuid: 123e4567-e89b-12d3-a456-426614174000 +domain: + type: kubernetes + kubernetes-spec: + resources: + - name: podsvt # Identifier for use in the rego below + resource-rule: # Mandatory, resource selection criteria, at least one resource rule is required + version: v1 # Version of resource + resource: pods # Resource type + namespaces: [validation-test] # Namespaces to validate the above resources in. Empty or "" for all namespaces or non-namespaced resources +provider: + type: kyverno + kyverno-spec: + policy: + apiVersion: json.kyverno.io/v1alpha1 + kind: ValidatingPolicy + metadata: + name: labels + spec: + rules: + - name: foo-label-exists + assert: + all: + - check: + ~.podsvt: + metadata: + labels: + foo: bar