Skip to content

Commit

Permalink
feat(validate): template oscal during runtime (#708)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
Co-authored-by: Brandt Keller <[email protected]>
  • Loading branch information
3 people authored Oct 11, 2024
1 parent 6839d20 commit 3f5a110
Show file tree
Hide file tree
Showing 28 changed files with 915 additions and 345 deletions.
1 change: 1 addition & 0 deletions docs/cli-commands/lula_validate.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
6 changes: 3 additions & 3 deletions src/cmd/tools/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
315 changes: 93 additions & 222 deletions src/cmd/validate/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}

/*
Expand All @@ -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)
}
Loading

0 comments on commit 3f5a110

Please sign in to comment.