From 3f5a110ecf692d99e1511ac82b737d82764321c2 Mon Sep 17 00:00:00 2001 From: Megan Wolf <97549300+meganwolf0@users.noreply.github.com> Date: Fri, 11 Oct 2024 18:56:06 -0400 Subject: [PATCH] feat(validate): template oscal during runtime (#708) * feat(template): sample sensitive config * feat(template): updated funcs, tests * fix: go mod tidy * fix: small refactor on template render * docs: updated config/template docs * fix: bump timeout in tui tests * fix: small refactor for extending tmpl * fix: bumped timeout * f * f * feat(compose): initial templating * feat(compose): updated functions * feat(compose): updating tests * fix: network things, tests * fix: validate * feat(compose): tests for compose with templating * fix: scrub runner timestamp format * fix: input/output filepaths * fix: removed duplicative/non useful path cmds * feat(validation): add support for templating * fix: compose and template test updates * fix(validate): e2e tests * fix: merge oopsie * fix: test cleanup, renaming * fix: ValidationContext->Validator, CompositionContext->Composer * fix: updated error contains text * chore(docs): doc generation for validation command * fix(ci): empty commit * fix(e2e): update wait and read test --------- Co-authored-by: Brandt Keller Co-authored-by: Brandt Keller <43887158+brandtkeller@users.noreply.github.com> --- docs/cli-commands/lula_validate.md | 1 + src/cmd/tools/compose.go | 6 +- src/cmd/validate/validate.go | 315 ++++++------------ src/pkg/common/composition/composition.go | 32 +- src/pkg/common/composition/options.go | 24 +- src/pkg/common/composition/resource-store.go | 10 +- src/pkg/common/oscal/complete-schema.go | 53 +-- src/pkg/common/validation/options.go | 48 +++ src/pkg/common/validation/validation.go | 181 ++++++++++ src/test/e2e/api_validation_test.go | 16 +- src/test/e2e/cmd/lula-config.yaml | 3 +- .../e2e/cmd/testdata/validate/help.golden | 28 ++ src/test/e2e/cmd/tools_compose_test.go | 2 +- src/test/e2e/cmd/validate_test.go | 76 +++++ .../e2e/composition_component_def_test.go | 16 +- src/test/e2e/create_resource_data_test.go | 41 ++- src/test/e2e/file_validation_test.go | 44 ++- .../e2e/multi_resource_validation_test.go | 9 +- src/test/e2e/outputs_test.go | 9 +- src/test/e2e/pod_validation_test.go | 36 +- src/test/e2e/pod_wait_test.go | 9 +- src/test/e2e/remote_validation_test.go | 9 +- src/test/e2e/resource_data_test.go | 9 +- .../component-definition.tmpl.yaml | 38 +++ .../scenarios/template-validation/pod.yaml | 12 + .../template-validation/validation.tmpl.yaml | 25 ++ src/test/e2e/template_validation_test.go | 193 +++++++++++ src/test/e2e/validation_composition_test.go | 15 +- 28 files changed, 915 insertions(+), 345 deletions(-) create mode 100644 src/pkg/common/validation/options.go create mode 100644 src/pkg/common/validation/validation.go create mode 100644 src/test/e2e/cmd/testdata/validate/help.golden create mode 100644 src/test/e2e/cmd/validate_test.go create mode 100644 src/test/e2e/scenarios/template-validation/component-definition.tmpl.yaml create mode 100644 src/test/e2e/scenarios/template-validation/pod.yaml create mode 100644 src/test/e2e/scenarios/template-validation/validation.tmpl.yaml create mode 100644 src/test/e2e/template_validation_test.go diff --git a/docs/cli-commands/lula_validate.md b/docs/cli-commands/lula_validate.md index 52c52680..33093560 100644 --- a/docs/cli-commands/lula_validate.md +++ b/docs/cli-commands/lula_validate.md @@ -41,6 +41,7 @@ To run validations non-interactively (no execution) --non-interactive run the command non-interactively -o, --output-file string the path to write assessment results. Creates a new file or appends to existing files --save-resources saves the resources to 'resources' directory at assessment-results level + -s, --set strings set a value in the template data -t, --target string the specific control implementations or framework to validate against ``` diff --git a/src/cmd/tools/compose.go b/src/cmd/tools/compose.go index 79a68981..503174f5 100644 --- a/src/cmd/tools/compose.go +++ b/src/cmd/tools/compose.go @@ -64,12 +64,12 @@ func ComposeCommand() *cobra.Command { } // Compose the OSCAL model - compositionCtx, err := composition.New(opts...) + composer, err := composition.New(opts...) if err != nil { - return fmt.Errorf("error creating composition context: %v", err) + return fmt.Errorf("error creating new composer: %v", err) } - model, err := compositionCtx.ComposeFromPath(cmd.Context(), inputFile) + model, err := composer.ComposeFromPath(cmd.Context(), inputFile) if err != nil { return fmt.Errorf("error composing model from path: %v", err) } diff --git a/src/cmd/validate/validate.go b/src/cmd/validate/validate.go index ef9bd297..4722a0c4 100644 --- a/src/cmd/validate/validate.go +++ b/src/cmd/validate/validate.go @@ -2,34 +2,19 @@ package validate import ( "context" + "errors" "fmt" - "os" "path/filepath" - "github.com/defenseunicorns/go-oscal/src/pkg/files" oscalTypes_1_1_2 "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-2" "github.com/defenseunicorns/lula/src/cmd/common" "github.com/defenseunicorns/lula/src/pkg/common/composition" "github.com/defenseunicorns/lula/src/pkg/common/oscal" - requirementstore "github.com/defenseunicorns/lula/src/pkg/common/requirement-store" - validationstore "github.com/defenseunicorns/lula/src/pkg/common/validation-store" - "github.com/defenseunicorns/lula/src/pkg/message" + "github.com/defenseunicorns/lula/src/pkg/common/validation" "github.com/defenseunicorns/lula/src/types" "github.com/spf13/cobra" ) -type flags struct { - OutputFile string // -o --output-file - InputFile string // -f --input-file - Target string // -t --target -} - -var opts = &flags{} -var ConfirmExecution bool // --confirm-execution -var RunNonInteractively bool // --non-interactive -var SaveResources bool // --save-resources -var ResourcesDir string - var validateHelp = ` To validate on a cluster: lula validate -f ./oscal-component.yaml @@ -43,63 +28,107 @@ To run validations non-interactively (no execution) lula dev validate -f ./oscal-component.yaml --non-interactive ` -var validateCmd = &cobra.Command{ - Use: "validate", - Short: "validate an OSCAL component definition", - Long: "Lula Validation of an OSCAL component definition", - Example: validateHelp, - Run: func(cmd *cobra.Command, componentDefinitionPath []string) { - outputFile := opts.OutputFile - if outputFile == "" { - outputFile = getDefaultOutputFile(opts.InputFile) - } +var ( + ErrValidating = errors.New("error validating") + ErrInvalidOut = errors.New("error invalid OSCAL model at output") + ErrWritingComponent = errors.New("error writing component to file") + ErrCreatingVCtx = errors.New("error creating validation context") + ErrCreatingCCtx = errors.New("error creating composition context") +) + +func ValidateCommand() *cobra.Command { + v := common.GetViper() + + var ( + outputFile string + inputFile string + target string + setOpts []string + confirmExecution bool + runNonInteractively bool + saveResources bool + ) + + cmd := &cobra.Command{ + Use: "validate", + Short: "validate an OSCAL component definition", + Long: "Lula Validation of an OSCAL component definition", + Example: validateHelp, + RunE: func(cmd *cobra.Command, args []string) error { + + // If no output file is specified, get the default output file + if outputFile == "" { + outputFile = getDefaultOutputFile(inputFile) + } + + // Check if output file contains a valid OSCAL model + _, err := oscal.ValidOSCALModelAtPath(outputFile) + if err != nil { + return fmt.Errorf("invalid OSCAL model at output: %v", err) + } + + // Set up the composer + composer, err := composition.New( + composition.WithModelFromLocalPath(inputFile), + composition.WithRenderSettings("all", true), + composition.WithTemplateRenderer("all", common.TemplateConstants, common.TemplateVariables, setOpts), + ) + if err != nil { + return fmt.Errorf("error creating new composer: %v", err) + } - // Check if output file contains a valid OSCAL model - _, err := oscal.ValidOSCALModelAtPath(outputFile) - if err != nil { - message.Fatalf(err, "Output file %s is not a valid OSCAL model: %v", outputFile, err) - } + // Set up the validator + validator, err := validation.New( + validation.WithComposition(composer, inputFile), + validation.WithResourcesDir(saveResources, filepath.Dir(outputFile)), + validation.WithAllowExecution(confirmExecution, runNonInteractively), + ) + if err != nil { + return fmt.Errorf("error creating new validator: %v", err) + } - if SaveResources { - ResourcesDir = filepath.Join(filepath.Dir(outputFile)) - } + ctx := context.WithValue(cmd.Context(), types.LulaValidationWorkDir, filepath.Dir(inputFile)) + assessmentResults, err := validator.ValidateOnPath(ctx, inputFile, target) + if err != nil { + return fmt.Errorf("error validating on path: %v", err) + } - if err := files.IsJsonOrYaml(opts.InputFile); err != nil { - message.Fatalf(err, "Invalid file extension: %s, requires .json or .yaml", opts.InputFile) - } + if assessmentResults == nil { + return fmt.Errorf("assessment results are nil") + } - ctx := context.WithValue(cmd.Context(), types.LulaValidationWorkDir, filepath.Dir(opts.InputFile)) - assessment, err := ValidateOnPath(ctx, opts.InputFile, opts.Target) - if err != nil { - message.Fatalf(err, "Validation error: %s", err) - } + var model = oscalTypes_1_1_2.OscalModels{ + AssessmentResults: assessmentResults, + } - var model = oscalTypes_1_1_2.OscalModels{ - AssessmentResults: assessment, - } + // Write the assessment results to file + err = oscal.WriteOscalModel(outputFile, &model) + if err != nil { + return fmt.Errorf("error writing component to file: %v", err) + } - // Write the assessment results to file - err = oscal.WriteOscalModel(outputFile, &model) - if err != nil { - message.Fatalf(err, "error writing component to file") - } - }, -} + return nil + }, + } -func init() { - v := common.InitViper() + cmd.Flags().StringVarP(&outputFile, "output-file", "o", "", "the path to write assessment results. Creates a new file or appends to existing files") + cmd.Flags().StringVarP(&inputFile, "input-file", "f", "", "the path to the target OSCAL component definition") + cmd.MarkFlagRequired("input-file") + cmd.Flags().StringVarP(&target, "target", "t", v.GetString(common.VTarget), "the specific control implementations or framework to validate against") + cmd.Flags().BoolVar(&confirmExecution, "confirm-execution", false, "confirm execution scripts run as part of the validation") + cmd.Flags().BoolVar(&runNonInteractively, "non-interactive", false, "run the command non-interactively") + cmd.Flags().BoolVar(&saveResources, "save-resources", false, "saves the resources to 'resources' directory at assessment-results level") + cmd.Flags().StringSliceVarP(&setOpts, "set", "s", []string{}, "set a value in the template data") - validateCmd.Flags().StringVarP(&opts.OutputFile, "output-file", "o", "", "the path to write assessment results. Creates a new file or appends to existing files") - validateCmd.Flags().StringVarP(&opts.InputFile, "input-file", "f", "", "the path to the target OSCAL component definition") - validateCmd.MarkFlagRequired("input-file") - validateCmd.Flags().StringVarP(&opts.Target, "target", "t", v.GetString(common.VTarget), "the specific control implementations or framework to validate against") - validateCmd.Flags().BoolVar(&ConfirmExecution, "confirm-execution", false, "confirm execution scripts run as part of the validation") - validateCmd.Flags().BoolVar(&RunNonInteractively, "non-interactive", false, "run the command non-interactively") - validateCmd.Flags().BoolVar(&SaveResources, "save-resources", false, "saves the resources to 'resources' directory at assessment-results level") + return cmd } -func ValidateCommand() *cobra.Command { - return validateCmd +// getDefaultOutputFile returns the default output file name +func getDefaultOutputFile(inputFile string) string { + dirPath := filepath.Dir(inputFile) + filename := "assessment-results" + filepath.Ext(inputFile) + + return filepath.Join(dirPath, filename) } /* @@ -124,161 +153,3 @@ func ValidateCommand() *cobra.Command { As such, building a ReportObject to collect and retain the relational information could be preferred */ - -// ValidateOnPath takes 1 -> N paths to OSCAL component-definition files -// It will then read those files to perform validation and return an ResultObject -func ValidateOnPath(ctx context.Context, path string, target string) (assessmentResult *oscalTypes_1_1_2.AssessmentResults, err error) { - - _, err = os.Stat(path) - if os.IsNotExist(err) { - return assessmentResult, fmt.Errorf("path: %v does not exist - unable to digest document", path) - } - - compositionCtx, err := composition.New(composition.WithModelFromLocalPath(path)) - if err != nil { - return nil, fmt.Errorf("error creating composition context: %v", err) - } - - oscalModel, err := compositionCtx.ComposeFromPath(ctx, path) - if err != nil { - return nil, fmt.Errorf("error composing model: %v", err) - } - - if oscalModel.ComponentDefinition == nil { - return assessmentResult, fmt.Errorf("component definition is nil") - } - - results, err := ValidateOnCompDef(ctx, oscalModel.ComponentDefinition, target) - if err != nil { - return assessmentResult, err - } - - assessmentResult, err = oscal.GenerateAssessmentResults(results) - if err != nil { - return assessmentResult, err - } - - return assessmentResult, nil - -} - -// ValidateOnCompDef takes a single ComponentDefinition object -// It will perform a validation and return a slice of results that can be written to an assessment-results object -func ValidateOnCompDef(ctx context.Context, compDef *oscalTypes_1_1_2.ComponentDefinition, target string) (results []oscalTypes_1_1_2.Result, err error) { - if compDef == nil { - return results, fmt.Errorf("cannot validate a component definition that is nil") - } - - if *compDef.Components == nil { - return results, fmt.Errorf("no components found in component definition") - } - - // Create a validation store from the back-matter if it exists - validationStore := validationstore.NewValidationStoreFromBackMatter(*compDef.BackMatter) - - // Create a map of control implementations from the component definition - // This combines all same source/framework control implementations into an []Control-Implementation - controlImplementations := oscal.FilterControlImplementations(compDef) - - if len(controlImplementations) == 0 { - return results, fmt.Errorf("no control implementations found in component definition") - } - - // target one specific controlImplementation - // this could be either a framework or source property - // this will only produce a single result - if target != "" { - if controlImplementation, ok := controlImplementations[target]; ok { - findings, observations, err := ValidateOnControlImplementations(ctx, &controlImplementation, validationStore, target) - if err != nil { - return results, err - } - result, err := oscal.CreateResult(findings, observations) - if err != nil { - return results, err - } - // add/update the source to the result props - make source = framework or omit? - oscal.UpdateProps("target", oscal.LULA_NAMESPACE, target, result.Props) - results = append(results, result) - } else { - return results, fmt.Errorf("target %s not found", target) - } - } else { - // default behavior - create a result for each unique source/framework - // loop over the controlImplementations map & validate - // we lose context of source if not contained within the loop - for source, controlImplementation := range controlImplementations { - findings, observations, err := ValidateOnControlImplementations(ctx, &controlImplementation, validationStore, source) - if err != nil { - return results, err - } - result, err := oscal.CreateResult(findings, observations) - if err != nil { - return results, err - } - // add/update the source to the result props - oscal.UpdateProps("target", oscal.LULA_NAMESPACE, source, result.Props) - results = append(results, result) - } - } - - return results, nil - -} - -func ValidateOnControlImplementations(ctx context.Context, controlImplementations *[]oscalTypes_1_1_2.ControlImplementationSet, validationStore *validationstore.ValidationStore, target string) (map[string]oscalTypes_1_1_2.Finding, []oscalTypes_1_1_2.Observation, error) { - - // Create requirement store for all implemented requirements - requirementStore := requirementstore.NewRequirementStore(controlImplementations) - message.Title("\nšŸ” Collecting Requirements and Validations for Target: ", target) - requirementStore.ResolveLulaValidations(validationStore) - reqtStats := requirementStore.GetStats(validationStore) - message.Infof("Found %d Implemented Requirements", reqtStats.TotalRequirements) - message.Infof("Found %d runnable Lula Validations", reqtStats.TotalValidations) - - // Check if validations perform execution actions - if reqtStats.ExecutableValidations { - message.Warnf(reqtStats.ExecutableValidationsMsg) - if !ConfirmExecution { - if !RunNonInteractively { - ConfirmExecution = message.PromptForConfirmation(nil) - } - if !ConfirmExecution { - // Break or just skip those those validations? - message.Infof("Validations requiring execution will not be run") - // message.Fatalf(errors.New("execution not confirmed"), "Exiting validation") - } - } - } - - // Run Lula validations and generate observations & findings - message.Title("\nšŸ“ Running Validations", "") - observations := validationStore.RunValidations(ctx, ConfirmExecution, SaveResources, ResourcesDir) - message.Title("\nšŸ’” Findings", "") - findings := requirementStore.GenerateFindings(validationStore) - - // Print findings here to prevent repetition of findings in the output - header := []string{"Control ID", "Status"} - rows := make([][]string, 0) - columnSize := []int{20, 25} - - for id, finding := range findings { - rows = append(rows, []string{ - id, finding.Target.Status.State, - }) - } - - if len(rows) != 0 { - message.Table(header, rows, columnSize) - } - - return findings, observations, nil -} - -// getDefaultOutputFile returns the default output file name and checks if the file already exists -func getDefaultOutputFile(inputFile string) string { - dirPath := filepath.Dir(inputFile) - filename := "assessment-results" + filepath.Ext(inputFile) - - return filepath.Join(dirPath, filename) -} diff --git a/src/pkg/common/composition/composition.go b/src/pkg/common/composition/composition.go index 19a37085..9615ee5c 100644 --- a/src/pkg/common/composition/composition.go +++ b/src/pkg/common/composition/composition.go @@ -20,7 +20,7 @@ import ( type RenderedContent string -type CompositionContext struct { +type Composer struct { modelDir string templateRenderer *template.TemplateRenderer renderTemplate bool @@ -28,28 +28,28 @@ type CompositionContext struct { renderType template.RenderType } -func New(opts ...Option) (*CompositionContext, error) { - var compositionCtx CompositionContext +func New(opts ...Option) (*Composer, error) { + var composer Composer for _, opt := range opts { - if err := opt(&compositionCtx); err != nil { + if err := opt(&composer); err != nil { return nil, err } } - return &compositionCtx, nil + return &composer, nil } // ComposeFromPath composes an OSCAL model from a file path -func (cc *CompositionContext) ComposeFromPath(ctx context.Context, path string) (model *oscalTypes_1_1_2.OscalCompleteSchema, err error) { +func (c *Composer) ComposeFromPath(ctx context.Context, path string) (model *oscalTypes_1_1_2.OscalCompleteSchema, err error) { data, err := os.ReadFile(path) if err != nil { return nil, err } // Template if renderTemplate is true -> Only renders the local data (e.g., what is in the file) - if cc.renderTemplate { - data, err = cc.templateRenderer.Render(string(data), cc.renderType) + if c.renderTemplate { + data, err = c.templateRenderer.Render(string(data), c.renderType) if err != nil { return nil, err } @@ -60,7 +60,7 @@ func (cc *CompositionContext) ComposeFromPath(ctx context.Context, path string) return nil, err } - err = cc.ComposeComponentDefinitions(ctx, model.ComponentDefinition, cc.modelDir) + err = c.ComposeComponentDefinitions(ctx, model.ComponentDefinition, c.modelDir) if err != nil { return nil, err } @@ -69,13 +69,13 @@ func (cc *CompositionContext) ComposeFromPath(ctx context.Context, path string) } // ComposeComponentDefinitions composes an OSCAL component definition by adding the remote resources to the back matter and updating with back matter links. -func (cc *CompositionContext) ComposeComponentDefinitions(ctx context.Context, compDef *oscalTypes_1_1_2.ComponentDefinition, baseDir string) error { +func (c *Composer) ComposeComponentDefinitions(ctx context.Context, compDef *oscalTypes_1_1_2.ComponentDefinition, baseDir string) error { if compDef == nil { return fmt.Errorf("component definition is nil") } // Compose the component validations - err := cc.ComposeComponentValidations(ctx, compDef, baseDir) + err := c.ComposeComponentValidations(ctx, compDef, baseDir) if err != nil { return err } @@ -100,8 +100,8 @@ func (cc *CompositionContext) ComposeComponentDefinitions(ctx context.Context, c } // template here if renderTemplate is true - if cc.renderTemplate { - response, err = cc.templateRenderer.Render(string(response), cc.renderType) + if c.renderTemplate { + response, err = c.templateRenderer.Render(string(response), c.renderType) if err != nil { return err } @@ -116,7 +116,7 @@ func (cc *CompositionContext) ComposeComponentDefinitions(ctx context.Context, c for _, importDef := range componentDefs { // Reconcile the base directory from the import component definition href importDir := network.GetLocalFileDir(importComponentDef.Href, baseDir) - err = cc.ComposeComponentDefinitions(ctx, importDef, importDir) + err = c.ComposeComponentDefinitions(ctx, importDef, importDir) if err != nil { return err } @@ -137,13 +137,13 @@ func (cc *CompositionContext) ComposeComponentDefinitions(ctx context.Context, c } // ComposeComponentValidations compiles the component validations by adding the remote resources to the back matter and updating with back matter links. -func (cc *CompositionContext) ComposeComponentValidations(ctx context.Context, compDef *oscalTypes_1_1_2.ComponentDefinition, baseDir string) error { +func (c *Composer) ComposeComponentValidations(ctx context.Context, compDef *oscalTypes_1_1_2.ComponentDefinition, baseDir string) error { if compDef == nil { return fmt.Errorf("component definition is nil") } - resourceMap := NewResourceStoreFromBackMatter(cc, compDef.BackMatter) + resourceMap := NewResourceStoreFromBackMatter(c, compDef.BackMatter) // If there are no components, there is nothing to do if compDef.Components == nil { diff --git a/src/pkg/common/composition/options.go b/src/pkg/common/composition/options.go index e9d75c9f..d34e44ac 100644 --- a/src/pkg/common/composition/options.go +++ b/src/pkg/common/composition/options.go @@ -10,11 +10,11 @@ import ( "github.com/defenseunicorns/lula/src/pkg/message" ) -type Option func(*CompositionContext) error +type Option func(*Composer) error // TODO: add remote option? func WithModelFromLocalPath(path string) Option { - return func(ctx *CompositionContext) error { + return func(c *Composer) error { _, err := os.Stat(path) if os.IsNotExist(err) { return fmt.Errorf("input-file: %v does not exist - unable to digest document", path) @@ -24,24 +24,24 @@ func WithModelFromLocalPath(path string) Option { if err != nil { return fmt.Errorf("error getting absolute path: %v", err) } - ctx.modelDir = filepath.Dir(absPath) + c.modelDir = filepath.Dir(absPath) return nil } } func WithRenderSettings(renderTypeString string, renderValidations bool) Option { - return func(ctx *CompositionContext) error { + return func(c *Composer) error { if renderTypeString == "" { - ctx.renderTemplate = false - ctx.renderValidations = false + c.renderTemplate = false + c.renderValidations = false if renderValidations { message.Warn("`render` not specified, `render-validations` will be ignored") } return nil } - ctx.renderTemplate = true - ctx.renderValidations = renderValidations + c.renderTemplate = true + c.renderValidations = renderValidations // Get the template render type renderType, err := template.ParseRenderType(renderTypeString) @@ -49,16 +49,16 @@ func WithRenderSettings(renderTypeString string, renderValidations bool) Option message.Warnf("invalid render type, defaulting to non-sensitive: %v", err) renderType = template.NONSENSITIVE } - ctx.renderType = renderType + c.renderType = renderType return nil } } func WithTemplateRenderer(renderTypeString string, constants map[string]interface{}, variables []template.VariableConfig, setOpts []string) Option { - return func(ctx *CompositionContext) error { + return func(c *Composer) error { if renderTypeString == "" { - ctx.renderTemplate = false + c.renderTemplate = false if len(setOpts) > 0 { message.Warn("`render` not specified, the --set options will be ignored") } @@ -81,7 +81,7 @@ func WithTemplateRenderer(renderTypeString string, constants map[string]interfac // need to update the template with the templateString... tr := template.NewTemplateRenderer(templateData) - ctx.templateRenderer = tr + c.templateRenderer = tr return nil } diff --git a/src/pkg/common/composition/resource-store.go b/src/pkg/common/composition/resource-store.go index 02a050d7..6da0a579 100644 --- a/src/pkg/common/composition/resource-store.go +++ b/src/pkg/common/composition/resource-store.go @@ -13,16 +13,16 @@ type ResourceStore struct { existing map[string]*oscalTypes_1_1_2.Resource fetched map[string]*oscalTypes_1_1_2.Resource hrefIdMap map[string][]string - cctx *CompositionContext + composer *Composer } // NewResourceStoreFromBackMatter creates a new resource store from the back matter of a component definition. -func NewResourceStoreFromBackMatter(cctx *CompositionContext, backMatter *oscalTypes_1_1_2.BackMatter) *ResourceStore { +func NewResourceStoreFromBackMatter(composer *Composer, backMatter *oscalTypes_1_1_2.BackMatter) *ResourceStore { store := &ResourceStore{ existing: make(map[string]*oscalTypes_1_1_2.Resource), fetched: make(map[string]*oscalTypes_1_1_2.Resource), hrefIdMap: make(map[string][]string), - cctx: cctx, + composer: composer, } if backMatter != nil && *backMatter.Resources != nil { @@ -128,8 +128,8 @@ func (s *ResourceStore) fetchFromRemoteLink(link *oscalTypes_1_1_2.Link, baseDir } // template here if renderValidations is true - if s.cctx.renderValidations { - validationBytes, err = s.cctx.templateRenderer.Render(string(validationBytes), s.cctx.renderType) + if s.composer.renderValidations { + validationBytes, err = s.composer.templateRenderer.Render(string(validationBytes), s.composer.renderType) if err != nil { return nil, err } diff --git a/src/pkg/common/oscal/complete-schema.go b/src/pkg/common/oscal/complete-schema.go index 792e0d37..f661f245 100644 --- a/src/pkg/common/oscal/complete-schema.go +++ b/src/pkg/common/oscal/complete-schema.go @@ -83,19 +83,12 @@ func WriteOscalModel(filePath string, model *oscalTypes_1_1_2.OscalModels) error MakeAssessmentResultsDeterministic(model.AssessmentResults) } - var b bytes.Buffer - - if filepath.Ext(filePath) == ".json" { - jsonEncoder := json.NewEncoder(&b) - jsonEncoder.SetIndent("", " ") - jsonEncoder.Encode(model) - } else { - yamlEncoder := yamlV3.NewEncoder(&b) - yamlEncoder.SetIndent(2) - yamlEncoder.Encode(model) + b, err := ConvertOSCALToBytes(model, filepath.Ext(filePath)) + if err != nil { + return fmt.Errorf("error converting OSCAL model to bytes: %v", err) } - err = files.WriteOutput(b.Bytes(), filePath) + err = files.WriteOutput(b, filePath) if err != nil { return err } @@ -126,19 +119,14 @@ func OverwriteOscalModel(filePath string, model *oscalTypes_1_1_2.OscalModels) e if model.AssessmentResults != nil { MakeAssessmentResultsDeterministic(model.AssessmentResults) } - var b bytes.Buffer - if filepath.Ext(filePath) == ".json" { - jsonEncoder := json.NewEncoder(&b) - jsonEncoder.SetIndent("", " ") - jsonEncoder.Encode(model) - } else { - yamlEncoder := yamlV3.NewEncoder(&b) - yamlEncoder.SetIndent(2) - yamlEncoder.Encode(model) + b, err := ConvertOSCALToBytes(model, filepath.Ext(filePath)) + if err != nil { + return fmt.Errorf("error converting OSCAL model to bytes: %v", err) } - if err := files.WriteOutput(b.Bytes(), filePath); err != nil { + err = files.WriteOutput(b, filePath) + if err != nil { return err } @@ -273,6 +261,29 @@ func InjectIntoOSCALModel(target *oscalTypes_1_1_2.OscalModels, values map[strin return newModel, nil } +// ConvertOSCALToBytes returns a byte slice representation of an OSCAL model +func ConvertOSCALToBytes(model *oscalTypes_1_1_2.OscalModels, fileExt string) ([]byte, error) { + var b bytes.Buffer + + if fileExt == ".json" { + jsonEncoder := json.NewEncoder(&b) + jsonEncoder.SetIndent("", " ") + err := jsonEncoder.Encode(model) + if err != nil { + return nil, err + } + } else { + yamlEncoder := yamlV3.NewEncoder(&b) + yamlEncoder.SetIndent(2) + err := yamlEncoder.Encode(model) + if err != nil { + return nil, err + } + } + + return b.Bytes(), nil +} + // convertOscalModelToMap converts an OSCAL model to a map[string]interface{} func convertOscalModelToMap(model oscalTypes_1_1_2.OscalModels) (map[string]interface{}, error) { var modelMap map[string]interface{} diff --git a/src/pkg/common/validation/options.go b/src/pkg/common/validation/options.go new file mode 100644 index 00000000..41d9f29a --- /dev/null +++ b/src/pkg/common/validation/options.go @@ -0,0 +1,48 @@ +package validation + +import ( + "fmt" + + "github.com/defenseunicorns/lula/src/pkg/common/composition" + "github.com/defenseunicorns/lula/src/pkg/message" +) + +type Option func(*Validator) error + +func WithComposition(composer *composition.Composer, path string) Option { + return func(v *Validator) error { + var err error + if composer == nil { + composer, err = composition.New(composition.WithModelFromLocalPath(path)) + if err != nil { + return fmt.Errorf("error creating composition context: %v", err) + } + } + v.composer = composer + return nil + } +} + +func WithAllowExecution(confirmExecution, runNonInteractively bool) Option { + return func(v *Validator) error { + if !confirmExecution { + if !runNonInteractively { + v.requestExecutionConfirmation = true + } else { + message.Infof("Validations requiring execution will NOT be run") + } + } else { + v.runExecutableValidations = true + } + return nil + } +} + +func WithResourcesDir(saveResources bool, rootDir string) Option { + return func(v *Validator) error { + if saveResources { + v.resourcesDir = rootDir + } + return nil + } +} diff --git a/src/pkg/common/validation/validation.go b/src/pkg/common/validation/validation.go new file mode 100644 index 00000000..104a3446 --- /dev/null +++ b/src/pkg/common/validation/validation.go @@ -0,0 +1,181 @@ +package validation + +import ( + "context" + "fmt" + "os" + + 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" + requirementstore "github.com/defenseunicorns/lula/src/pkg/common/requirement-store" + validationstore "github.com/defenseunicorns/lula/src/pkg/common/validation-store" + "github.com/defenseunicorns/lula/src/pkg/message" +) + +type Validator struct { + composer *composition.Composer + requestExecutionConfirmation bool + runExecutableValidations bool + resourcesDir string +} + +func New(opts ...Option) (*Validator, error) { + var validator Validator + + for _, opt := range opts { + if err := opt(&validator); err != nil { + return nil, err + } + } + + return &validator, nil +} + +func (v *Validator) ValidateOnPath(ctx context.Context, path, target string) (assessmentResult *oscalTypes_1_1_2.AssessmentResults, err error) { + var oscalModel *oscalTypes_1_1_2.OscalCompleteSchema + if v.composer == nil { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("error getting path: %v", err) + } + oscalModel, err = oscal.NewOscalModel(data) + if err != nil { + return nil, fmt.Errorf("error creating oscal model from path: %v", err) + } + } else { + oscalModel, err = v.composer.ComposeFromPath(ctx, path) + if err != nil { + return nil, fmt.Errorf("error composing model: %v", err) + } + } + + if oscalModel.ComponentDefinition == nil { + return assessmentResult, fmt.Errorf("component definition is nil") + } + + results, err := v.ValidateOnCompDef(ctx, oscalModel.ComponentDefinition, target) + if err != nil { + return assessmentResult, err + } + + assessmentResult, err = oscal.GenerateAssessmentResults(results) + if err != nil { + return assessmentResult, err + } + + return assessmentResult, nil +} + +func (v *Validator) ValidateOnCompDef(ctx context.Context, compDef *oscalTypes_1_1_2.ComponentDefinition, target string) (results []oscalTypes_1_1_2.Result, err error) { + // TODO: Should we execute the validation even if there are no comp-def/components, e.g., create an empty assessment-results object? + + if compDef == nil { + return nil, fmt.Errorf("cannot validate a component definition that is nil") + } + + if *compDef.Components == nil { + return nil, fmt.Errorf("no components found in component definition") + } + + // Create a validation store from the back-matter if it exists + validationStore := validationstore.NewValidationStoreFromBackMatter(*compDef.BackMatter) + + // Create a map of control implementations from the component definition + // This combines all same source/framework control implementations into an []Control-Implementation + controlImplementations := oscal.FilterControlImplementations(compDef) + + if len(controlImplementations) == 0 { + return nil, fmt.Errorf("no control implementations found in component definition") + } + + // Get results of validation execution + results = make([]oscalTypes_1_1_2.Result, 0) + if target != "" { + if controlImplementation, ok := controlImplementations[target]; ok { + findings, observations, err := v.ValidateOnControlImplementations(ctx, &controlImplementation, validationStore, target) + if err != nil { + return nil, err + } + result, err := oscal.CreateResult(findings, observations) + if err != nil { + return nil, err + } + // add/update the source to the result props - make source = framework or omit? + oscal.UpdateProps("target", oscal.LULA_NAMESPACE, target, result.Props) + results = append(results, result) + } else { + return nil, fmt.Errorf("target %s not found", target) + } + } else { + // default behavior - create a result for each unique source/framework + // loop over the controlImplementations map & validate + // we lose context of source if not contained within the loop + for source, controlImplementation := range controlImplementations { + findings, observations, err := v.ValidateOnControlImplementations(ctx, &controlImplementation, validationStore, source) + if err != nil { + return nil, err + } + result, err := oscal.CreateResult(findings, observations) + if err != nil { + return nil, err + } + // add/update the source to the result props + oscal.UpdateProps("target", oscal.LULA_NAMESPACE, source, result.Props) + results = append(results, result) + } + } + + return results, nil +} + +func (v *Validator) ValidateOnControlImplementations(ctx context.Context, controlImplementations *[]oscalTypes_1_1_2.ControlImplementationSet, validationStore *validationstore.ValidationStore, target string) (map[string]oscalTypes_1_1_2.Finding, []oscalTypes_1_1_2.Observation, error) { + // Create requirement store for all implemented requirements + requirementStore := requirementstore.NewRequirementStore(controlImplementations) + message.Title("\nšŸ” Collecting Requirements and Validations for Target: ", target) + requirementStore.ResolveLulaValidations(validationStore) + reqtStats := requirementStore.GetStats(validationStore) + message.Infof("Found %d Implemented Requirements", reqtStats.TotalRequirements) + message.Infof("Found %d runnable Lula Validations", reqtStats.TotalValidations) + + // Check if validations perform execution actions + if reqtStats.ExecutableValidations { + if !v.runExecutableValidations && v.requestExecutionConfirmation { + confirmExecution := message.PromptForConfirmation(nil) + if !confirmExecution { + message.Infof("Validations requiring execution will NOT be run") + } else { + v.runExecutableValidations = true + } + } + } + + // Set values for saving resources + saveResources := false + if v.resourcesDir != "" { + saveResources = true + } + + // Run Lula validations and generate observations & findings + message.Title("\nšŸ“ Running Validations", "") + observations := validationStore.RunValidations(ctx, v.runExecutableValidations, saveResources, v.resourcesDir) + message.Title("\nšŸ’” Findings", "") + findings := requirementStore.GenerateFindings(validationStore) + + // Print findings here to prevent repetition of findings in the output + header := []string{"Control ID", "Status"} + rows := make([][]string, 0) + columnSize := []int{20, 25} + + for id, finding := range findings { + rows = append(rows, []string{ + id, finding.Target.Status.State, + }) + } + + if len(rows) != 0 { + message.Table(header, rows, columnSize) + } + + return findings, observations, nil +} diff --git a/src/test/e2e/api_validation_test.go b/src/test/e2e/api_validation_test.go index e55db58b..1b43e4c9 100644 --- a/src/test/e2e/api_validation_test.go +++ b/src/test/e2e/api_validation_test.go @@ -3,7 +3,7 @@ package test import ( "context" - "github.com/defenseunicorns/lula/src/cmd/validate" + "github.com/defenseunicorns/lula/src/pkg/common/validation" "github.com/defenseunicorns/lula/src/pkg/message" "github.com/defenseunicorns/lula/src/test/util" corev1 "k8s.io/api/core/v1" @@ -55,7 +55,12 @@ func TestApiValidation(t *testing.T) { oscalPath := "./scenarios/api-field/oscal-component.yaml" message.NoProgress = true - assessment, err := validate.ValidateOnPath(context.Background(), oscalPath, "") + validator, err := validation.New() + if err != nil { + t.Errorf("error creating validation context: %v", err) + } + + assessment, err := validator.ValidateOnPath(context.Background(), oscalPath, "") if err != nil { t.Fatal(err) } @@ -139,7 +144,12 @@ func TestApiValidation(t *testing.T) { oscalPath := "./scenarios/api-field/oscal-component.yaml" message.NoProgress = true - assessment, err := validate.ValidateOnPath(context.Background(), oscalPath, "") + validator, err := validation.New() + if err != nil { + t.Errorf("error creating validation context: %v", err) + } + + assessment, err := validator.ValidateOnPath(context.Background(), oscalPath, "") if err != nil { t.Fatal(err) } diff --git a/src/test/e2e/cmd/lula-config.yaml b/src/test/e2e/cmd/lula-config.yaml index 2506d95b..6a795794 100644 --- a/src/test/e2e/cmd/lula-config.yaml +++ b/src/test/e2e/cmd/lula-config.yaml @@ -17,5 +17,4 @@ variables: - key: some_env_var default: this-should-be-overridden -log_level: info -target: il5 +log_level: info \ No newline at end of file diff --git a/src/test/e2e/cmd/testdata/validate/help.golden b/src/test/e2e/cmd/testdata/validate/help.golden new file mode 100644 index 00000000..ab75d5e3 --- /dev/null +++ b/src/test/e2e/cmd/testdata/validate/help.golden @@ -0,0 +1,28 @@ +Lula Validation of an OSCAL component definition + +Usage: + validate [flags] + +Examples: + +To validate on a cluster: + lula validate -f ./oscal-component.yaml +To indicate a specific Assessment Results file to create or append to: + lula validate -f ./oscal-component.yaml -o assessment-results.yaml +To target a specific control-implementation source / standard/ framework + lula validate -f ./oscal-component.yaml -t critical +To run validations and automatically confirm execution + lula dev validate -f ./oscal-component.yaml --confirm-execution +To run validations non-interactively (no execution) + lula dev validate -f ./oscal-component.yaml --non-interactive + + +Flags: + --confirm-execution confirm execution scripts run as part of the validation + -h, --help help for validate + -f, --input-file string the path to the target OSCAL component definition + --non-interactive run the command non-interactively + -o, --output-file string the path to write assessment results. Creates a new file or appends to existing files + --save-resources saves the resources to 'resources' directory at assessment-results level + -s, --set strings set a value in the template data + -t, --target string the specific control implementations or framework to validate against diff --git a/src/test/e2e/cmd/tools_compose_test.go b/src/test/e2e/cmd/tools_compose_test.go index 2186059c..44935ce6 100644 --- a/src/test/e2e/cmd/tools_compose_test.go +++ b/src/test/e2e/cmd/tools_compose_test.go @@ -111,7 +111,7 @@ func TestToolsComposeCommand(t *testing.T) { t.Run("Test Compose - invalid file error", func(t *testing.T) { err := test(t, "-f", "not-a-file.yaml") - require.ErrorContains(t, err, "error creating composition context") + require.ErrorContains(t, err, "error creating new composer") }) t.Run("Test Compose - invalid file schema error", func(t *testing.T) { diff --git a/src/test/e2e/cmd/validate_test.go b/src/test/e2e/cmd/validate_test.go new file mode 100644 index 00000000..0c34681d --- /dev/null +++ b/src/test/e2e/cmd/validate_test.go @@ -0,0 +1,76 @@ +package cmd_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/defenseunicorns/lula/src/cmd/validate" + "github.com/defenseunicorns/lula/src/pkg/common/oscal" + "github.com/defenseunicorns/lula/src/pkg/message" +) + +const ( + validInputFile = "../../unit/common/oscal/valid-component.yaml" + invalidOutputFile = "../../unit/common/validation/validation.opa.yaml" +) + +func TestValidateCommand(t *testing.T) { + + message.NoProgress = true + + test := func(t *testing.T, args ...string) error { + rootCmd := validate.ValidateCommand() + + return runCmdTest(t, rootCmd, args...) + } + + testAgainstGolden := func(t *testing.T, goldenFileName string, args ...string) error { + rootCmd := validate.ValidateCommand() + + return runCmdTestWithGolden(t, "validate/", goldenFileName, rootCmd, args...) + } + + t.Run("Validate command", func(t *testing.T) { + tempDir := t.TempDir() + outputFile := filepath.Join(tempDir, "output.yaml") + + err := test(t, "-f", validInputFile, "-o", outputFile) + + require.NoError(t, err) + + // Check that the output file is valid OSCAL + compiledBytes, err := os.ReadFile(outputFile) + require.NoErrorf(t, err, "error reading assessment results file: %v", err) + + compiledModel, err := oscal.NewOscalModel(compiledBytes) + require.NoErrorf(t, err, "error creating oscal model from assessment results: %v", err) + + require.NotNilf(t, compiledModel.AssessmentResults, "assessment results is nil") + + require.Equalf(t, 1, len(compiledModel.AssessmentResults.Results), "expected 1 result, got %d", len(compiledModel.AssessmentResults.Results)) + }) + + t.Run("Validate with invalid input file - error", func(t *testing.T) { + err := test(t, "-f", "invalid-file.yaml") + require.ErrorContains(t, err, "error creating new composer") + }) + + t.Run("Validate with invalid output file - error", func(t *testing.T) { + err := test(t, "-f", validInputFile, "-o", invalidOutputFile) + require.ErrorContains(t, err, "invalid OSCAL model at output") + }) + + t.Run("Validate with invalid target - error", func(t *testing.T) { + err := test(t, "-f", validInputFile, "-t", "invalid-target") + require.ErrorContains(t, err, "error validating on path") + }) + + t.Run("Test help", func(t *testing.T) { + err := testAgainstGolden(t, "help", "--help") + require.NoError(t, err) + }) + +} diff --git a/src/test/e2e/composition_component_def_test.go b/src/test/e2e/composition_component_def_test.go index f471b6e7..8a1c9602 100644 --- a/src/test/e2e/composition_component_def_test.go +++ b/src/test/e2e/composition_component_def_test.go @@ -5,8 +5,8 @@ import ( "testing" "time" - "github.com/defenseunicorns/lula/src/cmd/validate" "github.com/defenseunicorns/lula/src/pkg/common/composition" + "github.com/defenseunicorns/lula/src/pkg/common/validation" "github.com/defenseunicorns/lula/src/test/util" corev1 "k8s.io/api/core/v1" "sigs.k8s.io/e2e-framework/klient/wait" @@ -41,8 +41,12 @@ func TestComponentDefinitionComposition(t *testing.T) { Assess("Validate local composition file", func(ctx context.Context, t *testing.T, config *envconf.Config) context.Context { compDefPath := "../../test/unit/common/composition/component-definition-import-multi-compdef.yaml" - // Validate results using ValidateOnPath - assessment, err := validate.ValidateOnPath(context.Background(), compDefPath, "") + validator, err := validation.New(validation.WithComposition(nil, compDefPath)) + if err != nil { + t.Errorf("error creating validation context: %v", err) + } + + assessment, err := validator.ValidateOnPath(context.Background(), compDefPath, "") if err != nil { t.Errorf("Error validating component definition: %v", err) } @@ -87,12 +91,12 @@ func TestComponentDefinitionComposition(t *testing.T) { } // Compare validation results to a composed component definition - compositionCtx, err := composition.New(composition.WithModelFromLocalPath(compDefPath)) + composer, err := composition.New(composition.WithModelFromLocalPath(compDefPath)) if err != nil { t.Errorf("error creating composition context: %v", err) } - oscalModel, err := compositionCtx.ComposeFromPath(ctx, compDefPath) + oscalModel, err := composer.ComposeFromPath(ctx, compDefPath) if err != nil { t.Error(err) } @@ -101,7 +105,7 @@ func TestComponentDefinitionComposition(t *testing.T) { t.Errorf("component definition is nil") } - composeResults, err := validate.ValidateOnCompDef(ctx, oscalModel.ComponentDefinition, "") + composeResults, err := validator.ValidateOnCompDef(context.Background(), oscalModel.ComponentDefinition, "") if err != nil { t.Error(err) } diff --git a/src/test/e2e/create_resource_data_test.go b/src/test/e2e/create_resource_data_test.go index 43e7d77d..a522294b 100644 --- a/src/test/e2e/create_resource_data_test.go +++ b/src/test/e2e/create_resource_data_test.go @@ -4,7 +4,7 @@ import ( "context" "testing" - "github.com/defenseunicorns/lula/src/cmd/validate" + "github.com/defenseunicorns/lula/src/pkg/common/validation" "github.com/defenseunicorns/lula/src/pkg/message" appsv1 "k8s.io/api/apps/v1" batchv1 "k8s.io/api/batch/v1" @@ -36,12 +36,15 @@ func TestCreateResourceDataValidation(t *testing.T) { oscalPath := "./scenarios/create-resources/oscal-component.yaml" message.NoProgress = true - // TODO: fix this nonsense - validate.ConfirmExecution = true - validate.RunNonInteractively = true - validate.SaveResources = false + validator, err := validation.New( + validation.WithComposition(nil, oscalPath), + validation.WithAllowExecution(true, true), + ) + if err != nil { + t.Errorf("error creating validation context: %v", err) + } - assessment, err := validate.ValidateOnPath(context.Background(), oscalPath, "") + assessment, err := validator.ValidateOnPath(context.Background(), oscalPath, "") if err != nil { t.Fatal(err) } @@ -92,12 +95,15 @@ func TestCreateResourceDataValidation(t *testing.T) { oscalPath := "./scenarios/create-resources/oscal-component-wait-read.yaml" message.NoProgress = true - // TODO: fix this nonsense - validate.ConfirmExecution = true - validate.RunNonInteractively = true - validate.SaveResources = false + validator, err := validation.New( + validation.WithComposition(nil, oscalPath), + validation.WithAllowExecution(true, true), + ) + if err != nil { + t.Errorf("error creating validation context: %v", err) + } - assessment, err := validate.ValidateOnPath(context.Background(), oscalPath, "") + assessment, err := validator.ValidateOnPath(context.Background(), oscalPath, "") if err != nil { t.Fatal(err) } @@ -155,10 +161,15 @@ func TestDeniedCreateResources(t *testing.T) { oscalPath := "./scenarios/create-resources/oscal-component-denied.yaml" message.NoProgress = true - // Check that validation fails - validate.ConfirmExecution = false - validate.RunNonInteractively = true - assessment, err := validate.ValidateOnPath(context.Background(), oscalPath, "") + validator, err := validation.New( + validation.WithComposition(nil, oscalPath), + validation.WithAllowExecution(false, true), + ) + if err != nil { + t.Errorf("error creating validation context: %v", err) + } + + assessment, err := validator.ValidateOnPath(context.Background(), oscalPath, "") if err != nil { t.Fatal(err) } diff --git a/src/test/e2e/file_validation_test.go b/src/test/e2e/file_validation_test.go index d8e78ae3..654ed68b 100644 --- a/src/test/e2e/file_validation_test.go +++ b/src/test/e2e/file_validation_test.go @@ -5,7 +5,7 @@ import ( "fmt" "testing" - "github.com/defenseunicorns/lula/src/cmd/validate" + "github.com/defenseunicorns/lula/src/pkg/common/validation" "github.com/defenseunicorns/lula/src/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -19,7 +19,10 @@ func TestFileValidation(t *testing.T) { t.Run("success - opa", func(t *testing.T) { ctx := context.WithValue(context.Background(), types.LulaValidationWorkDir, passDir) - assessment, err := validate.ValidateOnPath(ctx, passDir+oscalFile, "") + validator, err := validation.New() + require.NoError(t, err) + + assessment, err := validator.ValidateOnPath(ctx, passDir+oscalFile, "") if err != nil { t.Fatal(err) } @@ -40,7 +43,10 @@ func TestFileValidation(t *testing.T) { }) t.Run("success - kyverno", func(t *testing.T) { ctx := context.WithValue(context.Background(), types.LulaValidationWorkDir, passDir) - assessment, err := validate.ValidateOnPath(ctx, passDir+kyvernoFile, "") + validator, err := validation.New() + require.NoError(t, err) + + assessment, err := validator.ValidateOnPath(ctx, passDir+kyvernoFile, "") assert.NoError(t, err) assert.NotEmpty(t, assessment.Results, "Expected greater than zero results") @@ -54,7 +60,11 @@ func TestFileValidation(t *testing.T) { }) t.Run("success - arbitrary file contexnts", func(t *testing.T) { ctx := context.WithValue(context.Background(), types.LulaValidationWorkDir, passDir) - assessment, err := validate.ValidateOnPath(ctx, passDir+"/component-definition-string-file.yaml", "") + validator, err := validation.New() + if err != nil { + t.Errorf("error creating validator: %v", err) + } + assessment, err := validator.ValidateOnPath(ctx, passDir+"/component-definition-string-file.yaml", "") assert.NoError(t, err) assert.NotEmpty(t, assessment.Results, "Expected greater than zero results") @@ -68,7 +78,10 @@ func TestFileValidation(t *testing.T) { }) t.Run("fail - opa", func(t *testing.T) { ctx := context.WithValue(context.Background(), types.LulaValidationWorkDir, failDir) - assessment, err := validate.ValidateOnPath(ctx, failDir+oscalFile, "") + validator, err := validation.New() + require.NoError(t, err) + + assessment, err := validator.ValidateOnPath(ctx, failDir+oscalFile, "") assert.NoError(t, err) assert.NotEmpty(t, assessment.Results, "Expected greater than zero results") @@ -82,9 +95,16 @@ func TestFileValidation(t *testing.T) { }) t.Run("fail - kyverno", func(t *testing.T) { ctx := context.WithValue(context.Background(), types.LulaValidationWorkDir, failDir) - assessment, err := validate.ValidateOnPath(ctx, failDir+kyvernoFile, "") - assert.NoError(t, err) - assert.NotEmpty(t, assessment.Results, "Expected greater than zero results") + validator, err := validation.New() + require.NoError(t, err) + assessment, err := validator.ValidateOnPath(ctx, failDir+kyvernoFile, "") + if err != nil { + t.Fatal(err) + } + + if len(assessment.Results) == 0 { + t.Fatal("Expected greater than zero results") + } result := assessment.Results[0] assert.NotNil(t, result, "Expected findings to be not nil") @@ -97,7 +117,11 @@ func TestFileValidation(t *testing.T) { t.Run("invalid input", func(t *testing.T) { ctx := context.WithValue(context.Background(), types.LulaValidationWorkDir, "scenarios/file-validations/invalid") - _, err := validate.ValidateOnPath(ctx, "scenarios/file-validations/invalid/oscal-component.yaml", "") - require.Error(t, err) + validator, err := validation.New() + require.NoError(t, err) + _, err = validator.ValidateOnPath(ctx, "scenarios/file-validations/invalid/oscal-component.yaml", "") + if err == nil { + t.Fatal("expected error, got success") + } }) } diff --git a/src/test/e2e/multi_resource_validation_test.go b/src/test/e2e/multi_resource_validation_test.go index 173ad984..33b98067 100644 --- a/src/test/e2e/multi_resource_validation_test.go +++ b/src/test/e2e/multi_resource_validation_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - "github.com/defenseunicorns/lula/src/cmd/validate" + "github.com/defenseunicorns/lula/src/pkg/common/validation" "github.com/defenseunicorns/lula/src/pkg/message" "github.com/defenseunicorns/lula/src/test/util" corev1 "k8s.io/api/core/v1" @@ -103,7 +103,12 @@ func TestMultiResourceValidation(t *testing.T) { oscalPath := "./scenarios/multi-resource/oscal-component.yaml" message.NoProgress = true - assessment, err := validate.ValidateOnPath(context.Background(), oscalPath, "") + validator, err := validation.New() + if err != nil { + t.Errorf("error creating validation context: %v", err) + } + + assessment, err := validator.ValidateOnPath(context.Background(), oscalPath, "") if err != nil { t.Fatal(err) } diff --git a/src/test/e2e/outputs_test.go b/src/test/e2e/outputs_test.go index 45570d2f..abe4cefb 100644 --- a/src/test/e2e/outputs_test.go +++ b/src/test/e2e/outputs_test.go @@ -7,8 +7,8 @@ import ( "testing" "time" - "github.com/defenseunicorns/lula/src/cmd/validate" "github.com/defenseunicorns/lula/src/pkg/common/oscal" + "github.com/defenseunicorns/lula/src/pkg/common/validation" validationstore "github.com/defenseunicorns/lula/src/pkg/common/validation-store" "github.com/defenseunicorns/lula/src/pkg/message" "github.com/defenseunicorns/lula/src/test/util" @@ -57,7 +57,12 @@ func TestOutputs(t *testing.T) { components := *compDef.Components validationStore := validationstore.NewValidationStoreFromBackMatter(*compDef.BackMatter) - findingMap, observations, err := validate.ValidateOnControlImplementations(ctx, components[0].ControlImplementations, validationStore, "") + validator, err := validation.New() + if err != nil { + t.Errorf("error creating validation context: %v", err) + } + + findingMap, observations, err := validator.ValidateOnControlImplementations(context.Background(), components[0].ControlImplementations, validationStore, "") if err != nil { t.Fatal(err) } diff --git a/src/test/e2e/pod_validation_test.go b/src/test/e2e/pod_validation_test.go index 6f6cc2cd..8f8902a4 100644 --- a/src/test/e2e/pod_validation_test.go +++ b/src/test/e2e/pod_validation_test.go @@ -11,13 +11,13 @@ import ( "github.com/defenseunicorns/go-oscal/src/pkg/files" "github.com/defenseunicorns/go-oscal/src/pkg/revision" - "github.com/defenseunicorns/go-oscal/src/pkg/validation" + oscalValidation "github.com/defenseunicorns/go-oscal/src/pkg/validation" "github.com/defenseunicorns/go-oscal/src/pkg/versioning" oscalTypes_1_1_2 "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-2" - "github.com/defenseunicorns/lula/src/cmd/validate" "github.com/defenseunicorns/lula/src/pkg/common" "github.com/defenseunicorns/lula/src/pkg/common/network" "github.com/defenseunicorns/lula/src/pkg/common/oscal" + "github.com/defenseunicorns/lula/src/pkg/common/validation" "github.com/defenseunicorns/lula/src/pkg/message" "github.com/defenseunicorns/lula/src/test/util" "github.com/defenseunicorns/lula/src/types" @@ -207,7 +207,6 @@ func TestPodLabelValidation(t *testing.T) { func validatePodLabelPass(ctx context.Context, t *testing.T, config *envconf.Config, oscalPath string) context.Context { message.NoProgress = true - validate.SaveResources = false tempDir := t.TempDir() @@ -228,9 +227,14 @@ func validatePodLabelPass(ctx context.Context, t *testing.T, config *envconf.Con } message.Infof("Successfully upgraded %s to %s with OSCAL version %s %s\n", oscalPath, revisionOptions.OutputFile, revisionResponse.Reviser.GetSchemaVersion(), revisionResponse.Reviser.GetModelType()) - assessment, err := validate.ValidateOnPath(context.Background(), oscalPath, "") + validator, err := validation.New() if err != nil { - t.Fatalf("Failed to validate oscal file: %s", oscalPath) + t.Errorf("error creating validation context: %v", err) + } + + assessment, err := validator.ValidateOnPath(context.Background(), revisionOptions.OutputFile, "") + if err != nil { + t.Fatalf("Failed to validate oscal file: %s", revisionOptions.OutputFile) } if len(assessment.Results) == 0 { @@ -306,7 +310,7 @@ func validatePodLabelPass(ctx context.Context, t *testing.T, config *envconf.Con t.Fatal("Failed to prepend results to existing report") } - validatorResponse, err := validation.ValidationCommand("sar-test.yaml") + validatorResponse, err := oscalValidation.ValidationCommand("sar-test.yaml") if err != nil || validatorResponse.JsonSchemaError != nil { t.Fatal("File failed linting") } @@ -317,11 +321,13 @@ func validatePodLabelPass(ctx context.Context, t *testing.T, config *envconf.Con func validatePodLabelFail(ctx context.Context, t *testing.T, oscalPath string) (*[]oscalTypes_1_1_2.Finding, *[]oscalTypes_1_1_2.Observation) { message.NoProgress = true - validate.ConfirmExecution = false - validate.RunNonInteractively = true - validate.SaveResources = false - assessment, err := validate.ValidateOnPath(context.Background(), oscalPath, "") + validator, err := validation.New(validation.WithAllowExecution(false, true)) + if err != nil { + t.Errorf("error creating validation context: %v", err) + } + + assessment, err := validator.ValidateOnPath(context.Background(), oscalPath, "") if err != nil { t.Fatalf("Failed to validate oscal file: %s", oscalPath) } @@ -362,12 +368,14 @@ func generateObservationRemarksMap(observations []oscalTypes_1_1_2.Observation) func validateSaveResources(ctx context.Context, t *testing.T, oscalPath string) context.Context { message.NoProgress = true - validate.SaveResources = true tempDir := t.TempDir() - validate.ResourcesDir = tempDir - // Validate on path - assessment, err := validate.ValidateOnPath(context.Background(), oscalPath, "") + validator, err := validation.New(validation.WithResourcesDir(true, tempDir)) + if err != nil { + t.Errorf("error creating validation context: %v", err) + } + + assessment, err := validator.ValidateOnPath(context.Background(), oscalPath, "") if err != nil { t.Fatalf("Failed to validate oscal file: %s", oscalPath) } diff --git a/src/test/e2e/pod_wait_test.go b/src/test/e2e/pod_wait_test.go index 8b183d69..13a3d522 100644 --- a/src/test/e2e/pod_wait_test.go +++ b/src/test/e2e/pod_wait_test.go @@ -4,7 +4,7 @@ import ( "context" "testing" - "github.com/defenseunicorns/lula/src/cmd/validate" + "github.com/defenseunicorns/lula/src/pkg/common/validation" "github.com/defenseunicorns/lula/src/pkg/message" "github.com/defenseunicorns/lula/src/test/util" corev1 "k8s.io/api/core/v1" @@ -32,7 +32,12 @@ func TestPodWaitValidation(t *testing.T) { oscalPath := "./scenarios/wait-field/oscal-component.yaml" message.NoProgress = true - assessment, err := validate.ValidateOnPath(context.Background(), oscalPath, "") + validator, err := validation.New() + if err != nil { + t.Errorf("error creating validation context: %v", err) + } + + assessment, err := validator.ValidateOnPath(context.Background(), oscalPath, "") if err != nil { t.Fatal(err) } diff --git a/src/test/e2e/remote_validation_test.go b/src/test/e2e/remote_validation_test.go index 3bf36f7c..cc8c9444 100644 --- a/src/test/e2e/remote_validation_test.go +++ b/src/test/e2e/remote_validation_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - "github.com/defenseunicorns/lula/src/cmd/validate" + "github.com/defenseunicorns/lula/src/pkg/common/validation" "github.com/defenseunicorns/lula/src/test/util" corev1 "k8s.io/api/core/v1" "sigs.k8s.io/e2e-framework/klient/wait" @@ -36,7 +36,12 @@ func TestRemoteValidation(t *testing.T) { Assess("Validate local validation file", func(ctx context.Context, t *testing.T, config *envconf.Config) context.Context { oscalPath := "./scenarios/remote-validations/component-definition.yaml" - assessment, err := validate.ValidateOnPath(context.Background(), oscalPath, "") + validator, err := validation.New(validation.WithComposition(nil, oscalPath)) + if err != nil { + t.Errorf("error creating validation context: %v", err) + } + + assessment, err := validator.ValidateOnPath(context.Background(), oscalPath, "") if err != nil { t.Fatal(err) } diff --git a/src/test/e2e/resource_data_test.go b/src/test/e2e/resource_data_test.go index 3547164d..13f4ae7b 100644 --- a/src/test/e2e/resource_data_test.go +++ b/src/test/e2e/resource_data_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - "github.com/defenseunicorns/lula/src/cmd/validate" + "github.com/defenseunicorns/lula/src/pkg/common/validation" "github.com/defenseunicorns/lula/src/pkg/message" "github.com/defenseunicorns/lula/src/test/util" corev1 "k8s.io/api/core/v1" @@ -71,7 +71,12 @@ func TestResourceDataValidation(t *testing.T) { oscalPath := "./scenarios/resource-data/oscal-component.yaml" message.NoProgress = true - assessment, err := validate.ValidateOnPath(context.Background(), oscalPath, "") + validator, err := validation.New() + if err != nil { + t.Errorf("error creating validation context: %v", err) + } + + assessment, err := validator.ValidateOnPath(context.Background(), oscalPath, "") if err != nil { t.Fatal(err) } diff --git a/src/test/e2e/scenarios/template-validation/component-definition.tmpl.yaml b/src/test/e2e/scenarios/template-validation/component-definition.tmpl.yaml new file mode 100644 index 00000000..eec9e028 --- /dev/null +++ b/src/test/e2e/scenarios/template-validation/component-definition.tmpl.yaml @@ -0,0 +1,38 @@ +component-definition: + uuid: E6A291A4-2BC8-43A0-B4B2-FD67CAAE1F8F + metadata: + title: Lula Demo + last-modified: "2022-09-13T12:00:00Z" + version: "20220913" + oscal-version: 1.1.2 + parties: + - uuid: C18F4A9F-A402-415B-8D13-B51739D689FF + type: organization + name: Lula Development + links: + - href: https://github.com/defenseunicorns/lula + rel: website + components: + - uuid: A9D5204C-7E5B-4C43-BD49-34DF759B9F04 + type: {{ .const.type }} + title: {{ .const.title }} + description: | + Lula - the Compliance Validator + purpose: Validate compliance controls + responsible-roles: + - role-id: provider + party-uuids: + - C18F4A9F-A402-415B-8D13-B51739D689FF + control-implementations: + - uuid: A584FEDC-8CEA-4B0C-9F07-85C2C4AE751A + source: https://raw.githubusercontent.com/usnistgov/oscal-content/master/nist.gov/SP800-53/rev5/json/NIST_SP-800-53_rev5_catalog.json + description: Validate generic security requirements + implemented-requirements: + - uuid: 42C2FFDC-5F05-44DF-A67F-EEC8660AEFFD + control-id: ID-1 + description: >- + This control validates templating of the validation + links: + - href: "./validation.tmpl.yaml" + text: local path template validation + rel: lula \ No newline at end of file diff --git a/src/test/e2e/scenarios/template-validation/pod.yaml b/src/test/e2e/scenarios/template-validation/pod.yaml new file mode 100644 index 00000000..61953dc4 --- /dev/null +++ b/src/test/e2e/scenarios/template-validation/pod.yaml @@ -0,0 +1,12 @@ +--- +apiVersion: v1 +kind: Pod +metadata: + name: test-pod-label + namespace: validation-test + labels: + foo: bar +spec: + containers: + - image: nginx + name: nginx \ No newline at end of file diff --git a/src/test/e2e/scenarios/template-validation/validation.tmpl.yaml b/src/test/e2e/scenarios/template-validation/validation.tmpl.yaml new file mode 100644 index 00000000..a948b531 --- /dev/null +++ b/src/test/e2e/scenarios/template-validation/validation.tmpl.yaml @@ -0,0 +1,25 @@ +metadata: + name: Test validation with templating + uuid: a3a9d2cd-f15a-442f-83c1-a33ce3f56122 +domain: + type: kubernetes + kubernetes-spec: + resources: + - name: podvt + resource-rule: + name: {{ .const.resources.name }} + version: v1 + resource: pods + namespaces: [{{ .const.resources.namespace }}] +provider: + type: opa + opa-spec: + rego: | + package validate + + default validate := false + + validate { + input.podvt.metadata.labels.foo == "{{ .var.pod_label }}" + input.podvt.spec.containers[_].name == "{{ .var.container_name }}" + } \ No newline at end of file diff --git a/src/test/e2e/template_validation_test.go b/src/test/e2e/template_validation_test.go new file mode 100644 index 00000000..2e830af7 --- /dev/null +++ b/src/test/e2e/template_validation_test.go @@ -0,0 +1,193 @@ +package test + +import ( + "context" + "os" + "testing" + "time" + + "github.com/defenseunicorns/lula/src/internal/template" + "github.com/defenseunicorns/lula/src/pkg/common/composition" + "github.com/defenseunicorns/lula/src/pkg/common/validation" + "github.com/defenseunicorns/lula/src/pkg/message" + "github.com/defenseunicorns/lula/src/test/util" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/e2e-framework/klient/wait" + "sigs.k8s.io/e2e-framework/klient/wait/conditions" + "sigs.k8s.io/e2e-framework/pkg/envconf" + "sigs.k8s.io/e2e-framework/pkg/features" +) + +// Write validation template tests... +// To test +// 1. Templated comp-def +// check pass and fail?... + +func TestTemplateValidation(t *testing.T) { + featureTemplateValidation := features.New("Check Template Validation"). + Setup(func(ctx context.Context, t *testing.T, config *envconf.Config) context.Context { + // Create the pod + pod, err := util.GetPod("./scenarios/template-validation/pod.yaml") + if err != nil { + t.Fatal(err) + } + if err = config.Client().Resources().Create(ctx, pod); err != nil { + t.Fatal(err) + } + err = wait.For(conditions.New(config.Client().Resources()).PodConditionMatch(pod, corev1.PodReady, corev1.ConditionTrue), wait.WithTimeout(time.Minute*5)) + if err != nil { + t.Fatal(err) + } + ctx = context.WithValue(ctx, "pod-template-validation", pod) + + return ctx + }). + Assess("Template to Pass", func(ctx context.Context, t *testing.T, config *envconf.Config) context.Context { + oscalPath := "./scenarios/template-validation/component-definition.tmpl.yaml" + // Set up the composition context + composer, err := composition.New( + composition.WithModelFromLocalPath(oscalPath), + composition.WithRenderSettings("all", true), + composition.WithTemplateRenderer("all", map[string]interface{}{ + "type": interface{}("software"), + "title": interface{}("lula"), + "resources": interface{}(map[string]interface{}{ + "name": interface{}("test-pod-label"), + "namespace": interface{}("validation-test"), + }), + }, []template.VariableConfig{ + { + Key: "pod_label", + Default: "bar", + Sensitive: false, + }, + { + Key: "container_name", + Default: "nginx", + Sensitive: true, + }, + }, []string{}), + ) + if err != nil { + t.Errorf("error creating composition context: %v", err) + } + + ctx = validateFindingsSatisfied(ctx, t, oscalPath, validation.WithComposition(composer, oscalPath)) + + return ctx + }). + Assess("Template to Pass with env vars", func(ctx context.Context, t *testing.T, config *envconf.Config) context.Context { + oscalPath := "./scenarios/template-validation/component-definition.tmpl.yaml" + + // Add env vars + os.Setenv("LULA_VAR_POD_LABEL", "bar") + os.Setenv("LULA_VAR_CONTAINER_NAME", "nginx") + defer os.Unsetenv("LULA_VAR_POD_LABEL") + defer os.Unsetenv("LULA_VAR_CONTAINER_NAME") + + // Set up the composition context - no default variable values now + composer, err := composition.New( + composition.WithModelFromLocalPath(oscalPath), + composition.WithRenderSettings("all", true), + composition.WithTemplateRenderer("all", map[string]interface{}{ + "type": interface{}("software"), + "title": interface{}("lula"), + "resources": interface{}(map[string]interface{}{ + "name": interface{}("test-pod-label"), + "namespace": interface{}("validation-test"), + }), + }, []template.VariableConfig{ + { + Key: "pod_label", + Sensitive: false, + }, + { + Key: "container_name", + Sensitive: true, + }, + }, []string{}), + ) + if err != nil { + t.Errorf("error creating composition context: %v", err) + } + + ctx = validateFindingsSatisfied(ctx, t, oscalPath, validation.WithComposition(composer, oscalPath)) + + return ctx + }). + Assess("Template to Pass with overrides", func(ctx context.Context, t *testing.T, config *envconf.Config) context.Context { + oscalPath := "./scenarios/template-validation/component-definition.tmpl.yaml" + // Set up the composition context - bad resource name and no pod_label var - in overrides + composer, err := composition.New( + composition.WithModelFromLocalPath(oscalPath), + composition.WithRenderSettings("all", true), + composition.WithTemplateRenderer("all", map[string]interface{}{ + "type": interface{}("software"), + "title": interface{}("lula"), + "resources": interface{}(map[string]interface{}{ + "name": interface{}("bad-pod-name"), + "namespace": interface{}("validation-test"), + }), + }, []template.VariableConfig{ + { + Key: "pod_label", + Sensitive: false, + }, + { + Key: "container_name", + Default: "nginx", + Sensitive: true, + }, + }, []string{".var.pod_label=bar", ".const.resources.name=test-pod-label"}), + ) + if err != nil { + t.Errorf("error creating composition context: %v", err) + } + + ctx = validateFindingsSatisfied(ctx, t, oscalPath, validation.WithComposition(composer, oscalPath)) + + return ctx + }). + Teardown(func(ctx context.Context, t *testing.T, config *envconf.Config) context.Context { + pod := ctx.Value("pod-template-validation").(*corev1.Pod) + if err := config.Client().Resources().Delete(ctx, pod); err != nil { + t.Fatal(err) + } + return ctx + }).Feature() + + testEnv.Test(t, featureTemplateValidation) +} + +func validateFindingsSatisfied(ctx context.Context, t *testing.T, oscalPath string, opts ...validation.Option) context.Context { + message.NoProgress = true + + validator, err := validation.New(opts...) + if err != nil { + t.Errorf("error creating validation context: %v", err) + } + + assessment, err := validator.ValidateOnPath(context.Background(), oscalPath, "") + if err != nil { + t.Fatalf("Failed to validate oscal file: %s", oscalPath) + } + + if len(assessment.Results) == 0 { + t.Fatal("Expected greater than zero results") + } + + result := assessment.Results[0] + + if result.Findings == nil { + t.Fatal("Expected findings to be not nil") + } + + for _, finding := range *result.Findings { + state := finding.Target.Status.State + if state != "satisfied" { + t.Fatal("State should be satisfied, but got :", state) + } + } + + return ctx +} diff --git a/src/test/e2e/validation_composition_test.go b/src/test/e2e/validation_composition_test.go index 25f6d5b7..9d88fb58 100644 --- a/src/test/e2e/validation_composition_test.go +++ b/src/test/e2e/validation_composition_test.go @@ -8,9 +8,9 @@ import ( "time" oscalTypes_1_1_2 "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-2" - "github.com/defenseunicorns/lula/src/cmd/validate" "github.com/defenseunicorns/lula/src/pkg/common/composition" "github.com/defenseunicorns/lula/src/pkg/common/oscal" + "github.com/defenseunicorns/lula/src/pkg/common/validation" validationstore "github.com/defenseunicorns/lula/src/pkg/common/validation-store" "github.com/defenseunicorns/lula/src/test/util" "gopkg.in/yaml.v3" @@ -71,7 +71,12 @@ func validateComposition(ctx context.Context, t *testing.T, oscalPath, expectedF t.Error(err) } - assessment, err := validate.ValidateOnPath(context.Background(), oscalPath, "") + validator, err := validation.New(validation.WithComposition(nil, oscalPath)) + if err != nil { + t.Errorf("error creating validation context: %v", err) + } + + assessment, err := validator.ValidateOnPath(ctx, oscalPath, "") if err != nil { t.Fatal(err) } @@ -101,13 +106,13 @@ func validateComposition(ctx context.Context, t *testing.T, oscalPath, expectedF compDef := oscalModel.ComponentDefinition - compositionCtx, err := composition.New(composition.WithModelFromLocalPath(oscalPath)) + composer, err := composition.New(composition.WithModelFromLocalPath(oscalPath)) if err != nil { t.Errorf("error creating composition context: %v", err) } baseDir := filepath.Dir(oscalPath) - err = compositionCtx.ComposeComponentValidations(ctx, compDef, baseDir) + err = composer.ComposeComponentValidations(ctx, compDef, baseDir) if err != nil { t.Error(err) } @@ -117,7 +122,7 @@ func validateComposition(ctx context.Context, t *testing.T, oscalPath, expectedF // Create a validation store from the back-matter if it exists validationStore := validationstore.NewValidationStoreFromBackMatter(*compDef.BackMatter) - findingMap, observations, err := validate.ValidateOnControlImplementations(ctx, components[0].ControlImplementations, validationStore, "") + findingMap, observations, err := validator.ValidateOnControlImplementations(ctx, components[0].ControlImplementations, validationStore, "") if err != nil { t.Fatalf("Error with validateOnControlImplementations: %v", err) }