From 8bb42b02f6da2670830f11a1d2e1e5367c2b7d09 Mon Sep 17 00:00:00 2001 From: "Cole (Mike) Winberry" <86802655+mike-winberry@users.noreply.github.com> Date: Thu, 25 Apr 2024 13:47:31 -0700 Subject: [PATCH] feat!: #367 compiling external/remote validations (#384) * refactor(common.go): ValidationFromString now uses `validationData.UnmarshalYaml` * refactor(validation-store): extracted constants and helpers from validation-store to common as they are used in wider areas * refactor(validate): replace lula link check with common.IsLulaLink * feat(common): Validation now has ToResource method * tests(common): add tests for Validation.ToResource() * feat(compilation): Added tests and functionality for the validation compilation command TODO: create the actual command * feat(compile): create compile command * docs: create compile command documentation * feat(tools): move the compile command under the tools cmd * feat(compilation): now updates the timestamp for the component definition * chore: renamecompile command and all associations to use the compose verbiage --- docs/commands/tools/compose.md | 30 ++++ src/cmd/tools/compose.go | 110 ++++++++++++++ src/cmd/tools/compose_test.go | 52 +++++++ src/cmd/validate/validate.go | 4 +- src/pkg/common/common.go | 28 +++- src/pkg/common/common_test.go | 60 ++++++++ src/pkg/common/composition/composition.go | 73 +++++++++ .../common/composition/composition_test.go | 96 ++++++++++++ src/pkg/common/composition/resource-store.go | 143 ++++++++++++++++++ src/pkg/common/oscal/complete-schema.go | 15 ++ src/pkg/common/types.go | 19 +++ .../validation-store/validation-store.go | 30 +--- .../component-definition.yaml | 51 +++++++ .../multi-validations.yaml | 61 ++++++++ .../validation-composition/pod.pass.yaml | 12 ++ .../validation.kyverno.yaml | 31 ++++ .../validation.opa.yaml | 27 ++++ src/test/e2e/validation_composition_test.go | 108 +++++++++++++ .../component-definition-all-local.yaml | 68 +++++++++ ...component-definition-local-and-remote.yaml | 74 +++++++++ 20 files changed, 1062 insertions(+), 30 deletions(-) create mode 100644 docs/commands/tools/compose.md create mode 100644 src/cmd/tools/compose.go create mode 100644 src/cmd/tools/compose_test.go create mode 100644 src/pkg/common/composition/composition.go create mode 100644 src/pkg/common/composition/composition_test.go create mode 100644 src/pkg/common/composition/resource-store.go create mode 100644 src/pkg/common/oscal/complete-schema.go create mode 100644 src/test/e2e/scenarios/validation-composition/component-definition.yaml create mode 100644 src/test/e2e/scenarios/validation-composition/multi-validations.yaml create mode 100644 src/test/e2e/scenarios/validation-composition/pod.pass.yaml create mode 100644 src/test/e2e/scenarios/validation-composition/validation.kyverno.yaml create mode 100644 src/test/e2e/scenarios/validation-composition/validation.opa.yaml create mode 100644 src/test/e2e/validation_composition_test.go create mode 100644 src/test/unit/common/compilation/component-definition-all-local.yaml create mode 100644 src/test/unit/common/compilation/component-definition-local-and-remote.yaml diff --git a/docs/commands/tools/compose.md b/docs/commands/tools/compose.md new file mode 100644 index 00000000..3564873e --- /dev/null +++ b/docs/commands/tools/compose.md @@ -0,0 +1,30 @@ +# 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. + +## Usage + +```bash +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. + +## Examples + +To compose an OSCAL Model: +```bash +lula tools compose -f ./oscal-component.yaml +``` + +To indicate a specific output file: +```bash +lula tools compose -f ./oscal-component.yaml -o composed-oscal-component.yaml +``` + +## Notes + +If the input file does not exist, an error will be returned. The composed OSCAL Component Definition will be written to the specified output file. If no output file is specified, the composed definition will be written to a file with the original filename and `-composed` appended. diff --git a/src/cmd/tools/compose.go b/src/cmd/tools/compose.go new file mode 100644 index 00000000..69453ea8 --- /dev/null +++ b/src/cmd/tools/compose.go @@ -0,0 +1,110 @@ +package tools + +import ( + "bytes" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + gooscalUtils "github.com/defenseunicorns/go-oscal/src/pkg/utils" + "github.com/defenseunicorns/lula/src/pkg/common" + "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" +) + +type composeFlags struct { + InputFile string // -f --input-file + OutputFile string // -o --output-file +} + +var composeOpts = &composeFlags{} + +var composeHelp = ` +To compose an OSCAL Model: + lula tools compose -f ./oscal-component.yaml + +To indicate a specific output file: + lula tools compose -f ./oscal-component.yaml -o composed-oscal-component.yaml +` + +func init() { + composeCmd := &cobra.Command{ + Use: "compose", + Short: "compose an OSCAL component definition", + 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) { + 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) + if err != nil { + message.Fatalf(err, "Composition error: %s", err) + } + }, + } + + toolsCmd.AddCommand(composeCmd) + + composeCmd.Flags().StringVarP(&composeOpts.InputFile, "input-file", "f", "", "the path to the target OSCAL component definition") + 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") +} + +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) + if err != nil { + return err + } + + model, err := oscal.NewOscalModel(data) + if err != nil { + return err + } + + err = composition.ComposeComponentValidations(model.ComponentDefinition) + if err != nil { + return err + } + + // Reset Cwd to original before outputting + resetCwd() + + 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) + } + + message.Infof("Writing Composed OSCAL Component Definition to: %s", outputFileName) + + err = gooscalUtils.WriteOutput(b.Bytes(), outputFileName) + if err != nil { + return err + } + + return nil +} diff --git a/src/cmd/tools/compose_test.go b/src/cmd/tools/compose_test.go new file mode 100644 index 00000000..6e0b7fcc --- /dev/null +++ b/src/cmd/tools/compose_test.go @@ -0,0 +1,52 @@ +package tools_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/defenseunicorns/lula/src/cmd/tools" + "github.com/defenseunicorns/lula/src/pkg/common/oscal" +) + +var ( + validInputFile = "../../test/unit/common/compilation/component-definition-local-and-remote.yaml" + invalidInputFile = "../../test/unit/common/valid-api-spec.yaml" +) + +func TestComposeComponentDefinition(t *testing.T) { + t.Parallel() + tempDir := t.TempDir() + outputFile := filepath.Join(tempDir, "output.yaml") + + t.Run("composes valid component definition", func(t *testing.T) { + err := tools.Compose(validInputFile, outputFile) + if err != nil { + t.Fatalf("error composing component definition: %s", err) + } + + compiledBytes, err := os.ReadFile(outputFile) + if err != nil { + t.Fatalf("error reading composed component definition: %s", err) + } + compiledModel, err := oscal.NewOscalModel(compiledBytes) + if err != nil { + t.Fatalf("error creating oscal model from composed component definition: %s", err) + } + + if compiledModel.ComponentDefinition.BackMatter.Resources == nil { + t.Fatal("composed component definition is nil") + } + + if len(*compiledModel.ComponentDefinition.BackMatter.Resources) <= 1 { + t.Fatalf("expected 2 resources, got %d", len(*compiledModel.ComponentDefinition.BackMatter.Resources)) + } + }) + + t.Run("invalid component definition throws error", func(t *testing.T) { + err := tools.Compose(invalidInputFile, outputFile) + if err == nil { + t.Fatal("expected error composing invalid component definition") + } + }) +} diff --git a/src/cmd/validate/validate.go b/src/cmd/validate/validate.go index 7514ab02..3c3fd197 100644 --- a/src/cmd/validate/validate.go +++ b/src/cmd/validate/validate.go @@ -6,7 +6,6 @@ import ( "fmt" "os" "path/filepath" - "strings" "time" "github.com/defenseunicorns/go-oscal/src/pkg/uuid" @@ -185,8 +184,7 @@ func ValidateOnCompDef(compDef oscalTypes_1_1_2.ComponentDefinition) (map[string if implementedRequirement.Links != nil { for _, link := range *implementedRequirement.Links { // TODO: potentially use rel to determine the type of validation (Validation Types discussion) - rel := strings.Split(link.Rel, ".") - if link.Text == "Lula Validation" || rel[0] == "lula" { + if common.IsLulaLink(link) { ids, err := validationStore.AddFromLink(link) if err != nil { return map[string]oscalTypes_1_1_2.Finding{}, []oscalTypes_1_1_2.Observation{}, err diff --git a/src/pkg/common/common.go b/src/pkg/common/common.go index 9be2f4fc..2f100eec 100644 --- a/src/pkg/common/common.go +++ b/src/pkg/common/common.go @@ -5,7 +5,9 @@ import ( "fmt" "os" "path/filepath" + "strings" + oscalTypes_1_1_2 "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-2" "github.com/defenseunicorns/lula/src/pkg/domains/api" kube "github.com/defenseunicorns/lula/src/pkg/domains/kubernetes" "github.com/defenseunicorns/lula/src/pkg/message" @@ -13,10 +15,30 @@ import ( "github.com/defenseunicorns/lula/src/pkg/providers/opa" "github.com/defenseunicorns/lula/src/types" goversion "github.com/hashicorp/go-version" +) - "sigs.k8s.io/yaml" +const ( + UUID_PREFIX = "#" + WILDCARD = "*" + YAML_DELIMITER = "---" ) +// TrimIdPrefix trims the id prefix from the given id +func TrimIdPrefix(id string) string { + return strings.TrimPrefix(id, UUID_PREFIX) +} + +// AddIdPrefix adds the id prefix to the given id +func AddIdPrefix(id string) string { + return fmt.Sprintf("%s%s", UUID_PREFIX, id) +} + +// IsLulaLink checks if the link is a lula link +func IsLulaLink(link oscalTypes_1_1_2.Link) bool { + rel := strings.Split(link.Rel, ".") + return link.Text == "Lula Validation" || rel[0] == "lula" +} + // ReadFileToBytes reads a file to bytes func ReadFileToBytes(path string) ([]byte, error) { var data []byte @@ -135,10 +157,8 @@ func ValidationFromString(raw string) (validation types.LulaValidation, err erro } var validationData Validation - - err = yaml.Unmarshal([]byte(raw), &validationData) + err = validationData.UnmarshalYaml([]byte(raw)) if err != nil { - message.Fatalf(err, "error unmarshalling yaml: %s", err.Error()) return validation, err } diff --git a/src/pkg/common/common_test.go b/src/pkg/common/common_test.go index 0b5f6a06..112350ff 100644 --- a/src/pkg/common/common_test.go +++ b/src/pkg/common/common_test.go @@ -273,3 +273,63 @@ func TestSwitchCwd(t *testing.T) { }) } } + +func TestValidationToResource(t *testing.T) { + t.Parallel() + t.Run("It populates a resource from a validation", func(t *testing.T) { + t.Parallel() + validation := &common.Validation{ + Metadata: common.Metadata{ + UUID: "1234", + Name: "Test Validation", + }, + Provider: common.Provider{ + Type: "test", + }, + Domain: common.Domain{ + Type: "test", + }, + } + + resource, err := validation.ToResource() + if err != nil { + t.Errorf("ToResource() error = %v", err) + } + + if resource.Title != validation.Metadata.Name { + t.Errorf("ToResource() title = %v, want %v", resource.Title, validation.Metadata.Name) + } + + if resource.UUID != validation.Metadata.UUID { + t.Errorf("ToResource() UUID = %v, want %v", resource.UUID, validation.Metadata.UUID) + } + + if resource.Description == "" { + t.Errorf("ToResource() description = %v, want %v", resource.Description, "") + } + }) + + t.Run("It adds a UUID if one does not exist", func(t *testing.T) { + t.Parallel() + validation := &common.Validation{ + Metadata: common.Metadata{ + Name: "Test Validation", + }, + Provider: common.Provider{ + Type: "test", + }, + Domain: common.Domain{ + Type: "test", + }, + } + + resource, err := validation.ToResource() + if err != nil { + t.Errorf("ToResource() error = %v", err) + } + + if resource.UUID == validation.Metadata.UUID { + t.Errorf("ToResource() description = \"\", want a valid UUID") + } + }) +} diff --git a/src/pkg/common/composition/composition.go b/src/pkg/common/composition/composition.go new file mode 100644 index 00000000..ba0b4bd4 --- /dev/null +++ b/src/pkg/common/composition/composition.go @@ -0,0 +1,73 @@ +package composition + +import ( + "fmt" + + gooscalUtils "github.com/defenseunicorns/go-oscal/src/pkg/utils" + oscalTypes_1_1_2 "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-2" + "github.com/defenseunicorns/lula/src/pkg/common" +) + +// ComposeComponentValidations compiles the component validations by adding the remote resources to the back matter and updating with back matter links. +func ComposeComponentValidations(compDef *oscalTypes_1_1_2.ComponentDefinition) error { + + if compDef == nil { + return fmt.Errorf("component definition is nil") + } + + resourceMap := NewResourceStoreFromBackMatter(compDef.BackMatter) + + if *compDef.Components == nil { + return fmt.Errorf("no components found in component definition") + } + + for componentIndex, component := range *compDef.Components { + // If there are no control-implementations, skip to the next component + controlImplementations := *component.ControlImplementations + if controlImplementations == nil { + continue + } + for controlImplementationIndex, controlImplementation := range controlImplementations { + for implementedRequirementIndex, implementedRequirement := range controlImplementation.ImplementedRequirements { + if implementedRequirement.Links != nil { + compiledLinks := []oscalTypes_1_1_2.Link{} + + for _, link := range *implementedRequirement.Links { + if common.IsLulaLink(link) { + ids, err := resourceMap.AddFromLink(link) + if err != nil { + return err + } + for _, id := range ids { + link := oscalTypes_1_1_2.Link{ + Rel: link.Rel, + Href: common.AddIdPrefix(id), + Text: link.Text, + } + compiledLinks = append(compiledLinks, link) + } + } else { + compiledLinks = append(compiledLinks, link) + } + } + (*component.ControlImplementations)[controlImplementationIndex].ImplementedRequirements[implementedRequirementIndex].Links = &compiledLinks + (*compDef.Components)[componentIndex] = component + } + } + } + } + allFetched := resourceMap.AllFetched() + if compDef.BackMatter != nil && compDef.BackMatter.Resources != nil { + existingResources := *compDef.BackMatter.Resources + existingResources = append(existingResources, allFetched...) + compDef.BackMatter.Resources = &existingResources + } else { + compDef.BackMatter = &oscalTypes_1_1_2.BackMatter{ + Resources: &allFetched, + } + } + + compDef.Metadata.LastModified = gooscalUtils.GetTimestamp() + + return nil +} diff --git a/src/pkg/common/composition/composition_test.go b/src/pkg/common/composition/composition_test.go new file mode 100644 index 00000000..8086b547 --- /dev/null +++ b/src/pkg/common/composition/composition_test.go @@ -0,0 +1,96 @@ +package composition_test + +import ( + "os" + "reflect" + "testing" + + oscalTypes_1_1_2 "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-2" + "github.com/defenseunicorns/lula/src/pkg/common" + "github.com/defenseunicorns/lula/src/pkg/common/composition" + "gopkg.in/yaml.v3" +) + +const ( + allRemote = "../../../test/e2e/scenarios/validation-composition/component-definition.yaml" + allLocal = "../../../test/unit/common/compilation/component-definition-all-local.yaml" + localAndRemote = "../../../test/unit/common/compilation/component-definition-local-and-remote.yaml" +) + +func TestCompileComponentValidations(t *testing.T) { + + t.Run("all local", func(t *testing.T) { + og := getComponentDef(allLocal, t) + compDef := getComponentDef(allLocal, t) + reset, err := common.SetCwdToFileDir(allLocal) + defer reset() + if err != nil { + t.Fatalf("Error setting cwd to file dir: %v", err) + } + err = composition.ComposeComponentValidations(compDef) + if err != nil { + t.Fatalf("Error compiling component validations: %v", err) + } + + // Only the last-modified timestamp should be different + if !reflect.DeepEqual(*og.BackMatter, *compDef.BackMatter) { + t.Error("expected the back matter to be unchanged") + } + }) + + t.Run("all remote", func(t *testing.T) { + og := getComponentDef(allRemote, t) + compDef := getComponentDef(allRemote, t) + reset, err := common.SetCwdToFileDir(allRemote) + defer reset() + if err != nil { + t.Fatalf("Error setting cwd to file dir: %v", err) + } + err = composition.ComposeComponentValidations(compDef) + if err != nil { + t.Fatalf("Error compiling component validations: %v", err) + } + if reflect.DeepEqual(*og, *compDef) { + t.Error("expected the component definition to be changed") + } + + if compDef.BackMatter == nil { + t.Error("expected the component definition to have back matter") + } + + if og.Metadata.LastModified == compDef.Metadata.LastModified { + t.Error("expected the component definition to have a different last modified timestamp") + } + }) + + t.Run("local and remote", func(t *testing.T) { + og := getComponentDef(localAndRemote, t) + compDef := getComponentDef(localAndRemote, t) + reset, err := common.SetCwdToFileDir(localAndRemote) + defer reset() + if err != nil { + t.Fatalf("Error setting cwd to file dir: %v", err) + } + err = composition.ComposeComponentValidations(compDef) + if err != nil { + t.Fatalf("Error compiling component validations: %v", err) + } + + if reflect.DeepEqual(*og, *compDef) { + t.Error("expected the component definition to be changed") + } + }) +} + +func getComponentDef(path string, t *testing.T) *oscalTypes_1_1_2.ComponentDefinition { + compDef, err := os.ReadFile(path) + if err != nil { + t.Fatalf("Error reading component definition file: %v", err) + } + + var oscalModel oscalTypes_1_1_2.OscalModels + if err := yaml.Unmarshal(compDef, &oscalModel); err != nil { + t.Fatalf("Error unmarshalling component definition: %v", err) + } + return oscalModel.ComponentDefinition +} diff --git a/src/pkg/common/composition/resource-store.go b/src/pkg/common/composition/resource-store.go new file mode 100644 index 00000000..f150e5fe --- /dev/null +++ b/src/pkg/common/composition/resource-store.go @@ -0,0 +1,143 @@ +package composition + +import ( + "bytes" + "fmt" + + oscalTypes_1_1_2 "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-2" + "github.com/defenseunicorns/lula/src/pkg/common" + "github.com/defenseunicorns/lula/src/pkg/common/network" +) + +type ResourceStore struct { + existing map[string]*oscalTypes_1_1_2.Resource + fetched map[string]*oscalTypes_1_1_2.Resource + hrefIdMap map[string][]string +} + +func NewResourceStoreFromBackMatter(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), + } + + if backMatter != nil && *backMatter.Resources != nil { + for _, resource := range *backMatter.Resources { + store.AddExisting(&resource) + } + } + + return store +} + +// AddExisting adds a resource to the store that is already in the back matter. +func (s *ResourceStore) AddExisting(resource *oscalTypes_1_1_2.Resource) { + s.existing[resource.UUID] = resource +} + +// GetExisting returns the resource with the given ID, if it exists. +func (s *ResourceStore) GetExisting(id string) (*oscalTypes_1_1_2.Resource, bool) { + resource, ok := s.existing[id] + return resource, ok +} + +// AddFetched adds a resource to the store that was fetched from a remote source. +func (s *ResourceStore) AddFetched(resource *oscalTypes_1_1_2.Resource) { + s.fetched[resource.UUID] = resource +} + +// GetFetched returns the resource that was fetched from a remote source with the given ID, if it exists. +func (s *ResourceStore) GetFetched(id string) (*oscalTypes_1_1_2.Resource, bool) { + resource, ok := s.fetched[id] + return resource, ok +} + +// AllFetched returns all the resources that were fetched from a remote source. +func (s *ResourceStore) AllFetched() []oscalTypes_1_1_2.Resource { + resources := make([]oscalTypes_1_1_2.Resource, 0, len(s.fetched)) + for _, resource := range s.fetched { + resources = append(resources, *resource) + } + return resources +} + +// SetHrefIds sets the resource ids for a given href +func (s *ResourceStore) SetHrefIds(href string, ids []string) { + s.hrefIdMap[href] = ids +} + +// GetHrefIds gets the resource ids for a given href +func (s *ResourceStore) GetHrefIds(href string) (ids []string, err error) { + if ids, ok := s.hrefIdMap[href]; ok { + return ids, nil + } + return nil, fmt.Errorf("href #%s not found", href) +} + +// Get returns the resource with the given ID, if it exists. +func (s *ResourceStore) Get(id string) (*oscalTypes_1_1_2.Resource, bool) { + resource, inExisting := s.GetExisting(id) + if inExisting { + return resource, true + } + + resource, inFetched := s.GetFetched(id) + return resource, inFetched +} + +// Has returns true if the resource store has a resource with the given ID. +func (s *ResourceStore) Has(id string) bool { + _, inExisting := s.existing[id] + _, inFetched := s.fetched[id] + return inExisting || inFetched +} + +func (s *ResourceStore) AddFromLink(link oscalTypes_1_1_2.Link) (ids []string, err error) { + id := common.TrimIdPrefix(link.Href) + + if link.ResourceFragment != common.WILDCARD && link.ResourceFragment != "" { + id = common.TrimIdPrefix(link.ResourceFragment) + } + + if s.Has(id) { + return []string{id}, err + } + + if ids, err = s.GetHrefIds(id); err == nil { + return ids, err + } + + return s.fetchFromRemoteLink(link) +} + +func (s *ResourceStore) fetchFromRemoteLink(link oscalTypes_1_1_2.Link) (ids []string, err error) { + wantedId := common.TrimIdPrefix(link.ResourceFragment) + + validationBytes, err := network.Fetch(link.Href) + if err != nil { + return nil, err + } + + validationBytesArr := bytes.Split(validationBytes, []byte(common.YAML_DELIMITER)) + isSingleValidation := len(validationBytesArr) == 1 + + for _, validationBytes := range validationBytesArr { + var validation common.Validation + if err = validation.UnmarshalYaml(validationBytes); err != nil { + return nil, err + } + + resource, err := validation.ToResource() + if err != nil { + return nil, err + } + + s.AddFetched(resource) + + if wantedId == resource.UUID || wantedId == common.WILDCARD || isSingleValidation { + ids = append(ids, resource.UUID) + } + } + + return ids, err +} diff --git a/src/pkg/common/oscal/complete-schema.go b/src/pkg/common/oscal/complete-schema.go new file mode 100644 index 00000000..306bc7dc --- /dev/null +++ b/src/pkg/common/oscal/complete-schema.go @@ -0,0 +1,15 @@ +package oscal + +import ( + oscalTypes_1_1_2 "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-2" + "sigs.k8s.io/yaml" +) + +func NewOscalModel(data []byte) (*oscalTypes_1_1_2.OscalModels, error) { + oscalModel := oscalTypes_1_1_2.OscalModels{} + err := yaml.Unmarshal(data, &oscalModel) + if err != nil { + return nil, err + } + return &oscalModel, nil +} diff --git a/src/pkg/common/types.go b/src/pkg/common/types.go index 018dd68a..9b0dc784 100644 --- a/src/pkg/common/types.go +++ b/src/pkg/common/types.go @@ -5,6 +5,8 @@ import ( "fmt" "strings" + "github.com/defenseunicorns/go-oscal/src/pkg/uuid" + 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/domains/api" kube "github.com/defenseunicorns/lula/src/pkg/domains/kubernetes" @@ -32,6 +34,23 @@ func (v *Validation) MarshalYaml() ([]byte, error) { return yaml.Marshal(v) } +// ToResource converts a Validation object to a Resource object +func (v *Validation) ToResource() (resource *oscalTypes_1_1_2.Resource, err error) { + resource = &oscalTypes_1_1_2.Resource{} + resource.Title = v.Metadata.Name + if v.Metadata.UUID != "" { + resource.UUID = v.Metadata.UUID + } else { + resource.UUID = uuid.NewUUID() + } + validationBytes, err := v.MarshalYaml() + if err != nil { + return nil, err + } + resource.Description = string(validationBytes) + return resource, nil +} + // TODO: Perhaps extend this structure with other needed information, such as UUID or type of validation if workflow is needed type Metadata struct { Name string `json:"name" yaml:"name"` diff --git a/src/pkg/common/validation-store/validation-store.go b/src/pkg/common/validation-store/validation-store.go index 0742f228..6664a8ea 100644 --- a/src/pkg/common/validation-store/validation-store.go +++ b/src/pkg/common/validation-store/validation-store.go @@ -3,7 +3,6 @@ package validationstore import ( "bytes" "fmt" - "strings" "github.com/defenseunicorns/go-oscal/src/pkg/uuid" oscalTypes_1_1_2 "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-2" @@ -13,10 +12,6 @@ import ( "github.com/defenseunicorns/lula/src/types" ) -const UUID_PREFIX = "#" -const WILDCARD = "*" -const YAML_DELIMITER = "---" - type ValidationStore struct { backMatterMap map[string]string validationMap types.LulaValidationMap @@ -59,7 +54,7 @@ func (v *ValidationStore) AddValidation(validation *common.Validation) (id strin // GetLulaValidation gets the LulaValidation from the store func (v *ValidationStore) GetLulaValidation(id string) (validation *types.LulaValidation, err error) { - trimmedId := TrimIdPrefix(id) + trimmedId := common.TrimIdPrefix(id) if validation, ok := v.validationMap[trimmedId]; ok { return &validation, nil @@ -95,8 +90,8 @@ func (v *ValidationStore) AddFromLink(link oscalTypes_1_1_2.Link) (ids []string, id := link.Href // If the resource fragment is not a wildcard, trim the prefix from the resource fragment - if link.ResourceFragment != WILDCARD && link.ResourceFragment != "" { - id = TrimIdPrefix(link.ResourceFragment) + if link.ResourceFragment != common.WILDCARD && link.ResourceFragment != "" { + id = common.TrimIdPrefix(link.ResourceFragment) } // If the id is a uuid and the lula validation exists, return the id @@ -109,25 +104,19 @@ func (v *ValidationStore) AddFromLink(link oscalTypes_1_1_2.Link) (ids []string, return ids, nil } - // If the id is a url and has not been fetched before, fetch and add to the store - ids, err = v.fetchFromRemoteLink(link) - if err != nil { - return ids, err - } - - return ids, nil + return v.fetchFromRemoteLink(link) } // fetchFromRemoteLink adds a validation from a remote source func (v *ValidationStore) fetchFromRemoteLink(link oscalTypes_1_1_2.Link) (ids []string, err error) { - wantedId := TrimIdPrefix(link.ResourceFragment) + wantedId := common.TrimIdPrefix(link.ResourceFragment) validationBytes, err := network.Fetch(link.Href) if err != nil { return ids, err } - validationBytesArr := bytes.Split(validationBytes, []byte(YAML_DELIMITER)) + validationBytesArr := bytes.Split(validationBytes, []byte(common.YAML_DELIMITER)) isSingleValidation := len(validationBytesArr) == 1 for _, validationBytes := range validationBytesArr { @@ -147,7 +136,7 @@ func (v *ValidationStore) fetchFromRemoteLink(link oscalTypes_1_1_2.Link) (ids [ } // If the wanted id is the id, the id is a wildcard, or there is only one validation, add the id to the ids - if wantedId == id || wantedId == WILDCARD || isSingleValidation { + if wantedId == id || wantedId == common.WILDCARD || isSingleValidation { ids = append(ids, id) } } @@ -160,8 +149,3 @@ func (v *ValidationStore) fetchFromRemoteLink(link oscalTypes_1_1_2.Link) (ids [ return ids, nil } - -// TrimIdPrefix trims the id prefix from the given id -func TrimIdPrefix(id string) string { - return strings.TrimPrefix(id, UUID_PREFIX) -} diff --git a/src/test/e2e/scenarios/validation-composition/component-definition.yaml b/src/test/e2e/scenarios/validation-composition/component-definition.yaml new file mode 100644 index 00000000..d39dcf2a --- /dev/null +++ b/src/test/e2e/scenarios/validation-composition/component-definition.yaml @@ -0,0 +1,51 @@ +component-definition: + components: + - control-implementations: + - description: Validate generic security requirements + implemented-requirements: + - 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 + # single validation w/ checksum + - href: file://./validation.opa.yaml@9d09d88105eae1e0349b157c5ba98c4c4fb322e9e15e397ba794beabd5f05d44 + rel: lula + # Single validation from multi-validations.yaml + - href: file://./multi-validations.yaml + rel: lula + resource-fragment: "9d09b4fc-1a82-4434-9fbe-392935347a84" + # All validations from multi-validations.yaml + - href: file:multi-validations.yaml + rel: lula + resource-fragment: "*" + uuid: 42C2FFDC-5F05-44DF-A67F-EEC8660AEFFD + source: https://raw.githubusercontent.com/usnistgov/oscal-content/master/nist.gov/SP800-53/rev5/json/NIST_SP-800-53_rev5_catalog.json + uuid: A584FEDC-8CEA-4B0C-9F07-85C2C4AE751A + description: | + Lula - the Compliance Validator + purpose: Validate compliance controls + responsible-roles: + - party-uuids: + - C18F4A9F-A402-415B-8D13-B51739D689FF + role-id: provider + title: lula + type: software + uuid: A9D5204C-7E5B-4C43-BD49-34DF759B9F04 + metadata: + last-modified: 2024-04-03T09:56:20.719564-07:00 + oscal-version: 1.1.2 + parties: + - links: + - href: https://github.com/defenseunicorns/lula + rel: website + name: Lula Development + type: organization + uuid: C18F4A9F-A402-415B-8D13-B51739D689FF + title: Lula Demo + version: "20220913" + uuid: E6A291A4-2BC8-43A0-B4B2-FD67CAAE1F8F diff --git a/src/test/e2e/scenarios/validation-composition/multi-validations.yaml b/src/test/e2e/scenarios/validation-composition/multi-validations.yaml new file mode 100644 index 00000000..f7ed1b20 --- /dev/null +++ b/src/test/e2e/scenarios/validation-composition/multi-validations.yaml @@ -0,0 +1,61 @@ +lula-version: ">= v0.2.0" +metadata: + name: Validate pods with label foo=bar + uuid: 9d09b4fc-1a82-4434-9fbe-392935347a84 +domain: + type: kubernetes + kubernetes-spec: + resources: + - name: podsvt + resource-rule: + group: + 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.2.0" +metadata: + name: Kyverno validate pods with label foo=bar + uuid: 88ea1e4c-c1a6-4e55-87c0-ba17c1b7c288 +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 +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/validation-composition/pod.pass.yaml b/src/test/e2e/scenarios/validation-composition/pod.pass.yaml new file mode 100644 index 00000000..65d23306 --- /dev/null +++ b/src/test/e2e/scenarios/validation-composition/pod.pass.yaml @@ -0,0 +1,12 @@ +--- +apiVersion: v1 +kind: Pod +metadata: + name: validation-composition-pod + namespace: validation-test + labels: + foo: bar +spec: + containers: + - image: nginx + name: pods-simple-container diff --git a/src/test/e2e/scenarios/validation-composition/validation.kyverno.yaml b/src/test/e2e/scenarios/validation-composition/validation.kyverno.yaml new file mode 100644 index 00000000..275f3720 --- /dev/null +++ b/src/test/e2e/scenarios/validation-composition/validation.kyverno.yaml @@ -0,0 +1,31 @@ +lula-version: ">= v0.2.0" +metadata: + name: Kyverno validate pods with label foo=bar +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 +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/validation-composition/validation.opa.yaml b/src/test/e2e/scenarios/validation-composition/validation.opa.yaml new file mode 100644 index 00000000..dd72d709 --- /dev/null +++ b/src/test/e2e/scenarios/validation-composition/validation.opa.yaml @@ -0,0 +1,27 @@ +lula-version: ">= v0.2.0" +metadata: + name: Validate pods with label foo=bar +domain: + type: kubernetes + kubernetes-spec: + resources: + - name: podsvt + resource-rule: + group: + 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/validation_composition_test.go b/src/test/e2e/validation_composition_test.go new file mode 100644 index 00000000..0e4a1a83 --- /dev/null +++ b/src/test/e2e/validation_composition_test.go @@ -0,0 +1,108 @@ +package test + +import ( + "context" + "os" + "testing" + "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" + "github.com/defenseunicorns/lula/src/pkg/common/composition" + "github.com/defenseunicorns/lula/src/test/util" + "gopkg.in/yaml.v3" + 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" +) + +type contextKey string + +const validationCompositionPodKey contextKey = "validation-composition-pod" + +func TestValidationComposition(t *testing.T) { + featureValidationComposition := features.New("Check validation composition"). + Setup(func(ctx context.Context, t *testing.T, config *envconf.Config) context.Context { + // Create the pod + pod, err := util.GetPod("./scenarios/validation-composition/pod.pass.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, validationCompositionPodKey, pod) + + return ctx + }). + Assess("Validate local composition file", func(ctx context.Context, t *testing.T, config *envconf.Config) context.Context { + compDefPath := "./scenarios/validation-composition/component-definition.yaml" + compDefBytes, err := os.ReadFile(compDefPath) + if err != nil { + t.Error(err) + } + + findings, observations, err := validate.ValidateOnPath(compDefPath) + if err != nil { + t.Errorf("Error validating component definition: %v", err) + } + expectedFindings := len(findings) + expectedObservations := len(observations) + + if expectedFindings == 0 { + t.Errorf("Expected to find findings") + } + + if expectedObservations == 0 { + t.Errorf("Expected to find observations") + } + + var oscalModel oscalTypes_1_1_2.OscalCompleteSchema + err = yaml.Unmarshal(compDefBytes, &oscalModel) + if err != nil { + t.Error(err) + } + reset, err := common.SetCwdToFileDir(compDefPath) + if err != nil { + t.Fatalf("Error setting cwd to file dir: %v", err) + } + defer reset() + + err = composition.ComposeComponentValidations(oscalModel.ComponentDefinition) + if err != nil { + t.Error(err) + } + + findings, observations, err = validate.ValidateOnCompDef(*oscalModel.ComponentDefinition) + if err != nil { + t.Error(err) + } + + if len(findings) != expectedFindings { + t.Errorf("Expected %d findings, got %d", expectedFindings, len(findings)) + } + + if len(observations) != expectedObservations { + t.Errorf("Expected %d observations, got %d", expectedObservations, len(observations)) + } + return ctx + }). + Teardown(func(ctx context.Context, t *testing.T, config *envconf.Config) context.Context { + + // Delete the pod + pod := ctx.Value(validationCompositionPodKey).(*corev1.Pod) + if err := config.Client().Resources().Delete(ctx, pod); err != nil { + t.Fatal(err) + } + return ctx + }).Feature() + + testEnv.Test(t, featureValidationComposition) +} diff --git a/src/test/unit/common/compilation/component-definition-all-local.yaml b/src/test/unit/common/compilation/component-definition-all-local.yaml new file mode 100644 index 00000000..16509c34 --- /dev/null +++ b/src/test/unit/common/compilation/component-definition-all-local.yaml @@ -0,0 +1,68 @@ +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.1 # This version should remain one version behind latest version for `lula dev upgrade` demo + parties: + # Should be consistent across all of the packages, but where is ground truth? + - 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: software + title: lula + description: | + Lula - the Compliance Validator + purpose: Validate compliance controls + responsible-roles: + - role-id: provider + party-uuids: + - C18F4A9F-A402-415B-8D13-B51739D689FF # matches parties entry for Defense Unicorns + 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 that the demo-pod pod in the validation-test namespace contains the required pod label foo=bar in order to establish compliance. + links: + - href: "#a7377430-2328-4dc4-a9e2-b3f31dc1dff9" + rel: lula + back-matter: + resources: + - uuid: a7377430-2328-4dc4-a9e2-b3f31dc1dff9 + rlinks: + - href: lula.dev + description: >- + domain: + type: kubernetes + kubernetes-spec: + resources: + - name: podsvt + resource-rule: + group: + 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/compilation/component-definition-local-and-remote.yaml b/src/test/unit/common/compilation/component-definition-local-and-remote.yaml new file mode 100644 index 00000000..64ca88c0 --- /dev/null +++ b/src/test/unit/common/compilation/component-definition-local-and-remote.yaml @@ -0,0 +1,74 @@ +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.1 # This version should remain one version behind latest version for `lula dev upgrade` demo + parties: + # Should be consistent across all of the packages, but where is ground truth? + - 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: software + title: lula + description: | + Lula - the Compliance Validator + purpose: Validate compliance controls + responsible-roles: + - role-id: provider + party-uuids: + - C18F4A9F-A402-415B-8D13-B51739D689FF # matches parties entry for Defense Unicorns + 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 that the demo-pod pod in the validation-test namespace contains the required pod label foo=bar in order to establish compliance. + links: + - href: "#a7377430-2328-4dc4-a9e2-b3f31dc1dff9" + 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 + back-matter: + resources: + - uuid: a7377430-2328-4dc4-a9e2-b3f31dc1dff9 + rlinks: + - href: lula.dev + description: >- + domain: + type: kubernetes + kubernetes-spec: + resources: + - name: podsvt + resource-rule: + group: + 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" + } + }