diff --git a/docs/cli-commands/lula.md b/docs/cli-commands/lula.md index 91fca6a7..7a182036 100644 --- a/docs/cli-commands/lula.md +++ b/docs/cli-commands/lula.md @@ -25,6 +25,7 @@ Real Time Risk Transparency through automated validation * [lula dev](./lula_dev.md) - Collection of dev commands to make dev life easier * [lula evaluate](./lula_evaluate.md) - evaluate two results of a Security Assessment Results * [lula generate](./lula_generate.md) - Generate a specified compliance artifact template +* [lula report](./lula_report.md) - Build a compliance report * [lula tools](./lula_tools.md) - Collection of additional commands to make OSCAL easier * [lula validate](./lula_validate.md) - validate an OSCAL component definition * [lula version](./lula_version.md) - Shows the current version of the Lula binary diff --git a/docs/cli-commands/lula_report.md b/docs/cli-commands/lula_report.md new file mode 100644 index 00000000..3a2193db --- /dev/null +++ b/docs/cli-commands/lula_report.md @@ -0,0 +1,46 @@ +--- +title: lula report +description: Lula CLI command reference for lula report. +type: docs +--- +## lula report + +Build a compliance report + +``` +lula report [flags] +``` + +### Examples + +``` + +To create a new report: +lula report -f oscal-component-definition.yaml + +To create a new report in json format: +lula report -f oscal-component-definition.yaml --file-format json + +To create a new report in yaml format: +lula report -f oscal-component-definition.yaml --file-format yaml + +``` + +### Options + +``` + --file-format string File format of the report (default "table") + -h, --help help for report + -f, --input-file string Path to an OSCAL file +``` + +### Options inherited from parent commands + +``` + -l, --log-level string Log level when running Lula. Valid options are: warn, info, debug, trace (default "info") +``` + +### SEE ALSO + +* [lula](./lula.md) - Risk Management as Code + diff --git a/src/cmd/report/report.go b/src/cmd/report/report.go new file mode 100644 index 00000000..4ed70638 --- /dev/null +++ b/src/cmd/report/report.go @@ -0,0 +1,49 @@ +package report + +import ( + "fmt" + + "github.com/defenseunicorns/lula/src/internal/reporting" + "github.com/defenseunicorns/lula/src/pkg/message" + "github.com/spf13/cobra" +) + +var reportHelp = ` +To create a new report: +lula report -f oscal-component-definition.yaml + +To create a new report in json format: +lula report -f oscal-component-definition.yaml --file-format json + +To create a new report in yaml format: +lula report -f oscal-component-definition.yaml --file-format yaml +` + +func ReportCommand() *cobra.Command { + var ( + inputFile string + fileFormat string + ) + + cmd := &cobra.Command{ + Use: "report", + Short: "Build a compliance report", + Example: reportHelp, // reuse your existing help text + RunE: func(cmd *cobra.Command, args []string) error { + err := reporting.GenerateReport(inputFile, fileFormat) + if err != nil { + return fmt.Errorf("error generating report: %w", err) + } + return nil + }, + } + + cmd.Flags().StringVarP(&inputFile, "input-file", "f", "", "Path to an OSCAL file") + cmd.Flags().StringVar(&fileFormat, "file-format", "table", "File format of the report") + err := cmd.MarkFlagRequired("input-file") + if err != nil { + message.Fatal(err, "error initializing report command flags") + } + + return cmd +} diff --git a/src/cmd/root.go b/src/cmd/root.go index 785536cb..8ef58d3d 100644 --- a/src/cmd/root.go +++ b/src/cmd/root.go @@ -12,6 +12,7 @@ import ( "github.com/defenseunicorns/lula/src/cmd/dev" "github.com/defenseunicorns/lula/src/cmd/evaluate" "github.com/defenseunicorns/lula/src/cmd/generate" + "github.com/defenseunicorns/lula/src/cmd/report" "github.com/defenseunicorns/lula/src/cmd/tools" "github.com/defenseunicorns/lula/src/cmd/validate" "github.com/defenseunicorns/lula/src/cmd/version" @@ -63,6 +64,7 @@ func init() { validate.ValidateCommand(), evaluate.EvaluateCommand(), generate.GenerateCommand(), + report.ReportCommand(), console.ConsoleCommand(), dev.DevCommand(), } diff --git a/src/internal/reporting/helpers.go b/src/internal/reporting/helpers.go new file mode 100644 index 00000000..ca5ece97 --- /dev/null +++ b/src/internal/reporting/helpers.go @@ -0,0 +1,48 @@ +package reporting + +import ( + oscalTypes "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-3" + "github.com/defenseunicorns/lula/src/pkg/common/oscal" +) + +// Split the default controlMap into framework and source maps for further processing +func SplitControlMap(controlMap map[string][]oscalTypes.ControlImplementationSet) (sourceMap map[string]map[string]int, frameworkMap map[string]map[string]int) { + sourceMap = make(map[string]map[string]int) + frameworkMap = make(map[string]map[string]int) + + for key, implementations := range controlMap { + for _, controlImplementation := range implementations { + status, framework := oscal.GetProp("framework", oscal.LULA_NAMESPACE, controlImplementation.Props) + if status { + // if these are the same - we need to de-duplicate + if key == framework { + if _, exists := frameworkMap[framework]; !exists { + frameworkMap[framework] = make(map[string]int) + } + for _, implementedReq := range controlImplementation.ImplementedRequirements { + controlID := implementedReq.ControlId + frameworkMap[framework][controlID]++ + } + } else { + if _, exists := sourceMap[key]; !exists { + sourceMap[key] = make(map[string]int) + } + for _, implementedReq := range controlImplementation.ImplementedRequirements { + controlID := implementedReq.ControlId + sourceMap[key][controlID]++ + } + } + } else { + if _, exists := sourceMap[key]; !exists { + sourceMap[key] = make(map[string]int) + } + for _, implementedReq := range controlImplementation.ImplementedRequirements { + controlID := implementedReq.ControlId + sourceMap[key][controlID]++ + } + } + } + } + + return sourceMap, frameworkMap +} diff --git a/src/internal/reporting/reporting.go b/src/internal/reporting/reporting.go new file mode 100644 index 00000000..49c4f030 --- /dev/null +++ b/src/internal/reporting/reporting.go @@ -0,0 +1,195 @@ +package reporting + +import ( + "context" + "encoding/json" + "fmt" + + oscalTypes "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-3" + "github.com/defenseunicorns/lula/src/cmd/common" + "github.com/defenseunicorns/lula/src/pkg/common/composition" + "github.com/defenseunicorns/lula/src/pkg/common/network" + "github.com/defenseunicorns/lula/src/pkg/common/oscal" + "github.com/defenseunicorns/lula/src/pkg/message" + "gopkg.in/yaml.v3" +) + +type ReportData struct { + ComponentDefinition *ComponentDefinitionReportData `json:"componentDefinition,omitempty" yaml:"componentDefinition,omitempty"` +} + +type ComponentDefinitionReportData struct { + Title string `json:"title" yaml:"title"` + ControlIDBySource map[string]int `json:"control ID mapped" yaml:"control ID mapped"` + ControlIDByFramework map[string]int `json:"controlIDFramework" yaml:"controlIDFramework"` +} + +// Runs the logic of report generation +func GenerateReport(inputFile string, fileFormat string) error { + spinner := message.NewProgressSpinner("Fetching or reading file %s", inputFile) + + getOSCALModelsFile, err := network.Fetch(inputFile) + if err != nil { + return fmt.Errorf("failed to get OSCAL file: %v", err) + } + + spinner.Success() + + spinner = message.NewProgressSpinner("Reading OSCAL model from file") + oscalModel, err := oscal.NewOscalModel(getOSCALModelsFile) + if err != nil { + return fmt.Errorf("failed to read OSCAL Model data: %v", err) + } + spinner.Success() + + // Set up the composer + composer, err := composition.New( + composition.WithRenderSettings("all", true), + composition.WithTemplateRenderer("all", common.TemplateConstants, common.TemplateVariables, []string{}), + ) + if err != nil { + return fmt.Errorf("error creating new composer: %v", err) + } + + err = handleOSCALModel(oscalModel, fileFormat, composer) + if err != nil { + return err + } + + return nil +} + +// Processes an OSCAL Model based on the model type +func handleOSCALModel(oscalModel *oscalTypes.OscalModels, format string, composer *composition.Composer) error { + // Start a new spinner for the report generation process + spinner := message.NewProgressSpinner("Determining OSCAL model type") + modelType, err := oscal.GetOscalModel(oscalModel) + if err != nil { + return fmt.Errorf("unable to determine OSCAL model type: %v", err) + } + + switch modelType { + case "catalog", "profile", "assessment-plan", "assessment-results", "system-security-plan", "poam": + // If the model type is not supported, stop the spinner with a warning + return fmt.Errorf("reporting does not create reports for %s at this time", modelType) + + case "component": + spinner.Updatef("Composing Component Definition") + err := composer.ComposeComponentDefinitions(context.Background(), oscalModel.ComponentDefinition, "") + if err != nil { + return fmt.Errorf("failed to compose component definitions: %v", err) + } + + spinner.Updatef("Processing Component Definition") + // Process the component-definition model + err = handleComponentDefinition(oscalModel.ComponentDefinition, format) + if err != nil { + // If an error occurs, stop the spinner and display the error + return err + } + + default: + // For unknown model types, stop the spinner with a failure + return fmt.Errorf("unknown OSCAL model type: %s", modelType) + } + + spinner.Success() + message.Info(fmt.Sprintf("Successfully processed OSCAL model: %s", modelType)) + return nil +} + +// Handler for Component Definition OSCAL files to create the report +func handleComponentDefinition(componentDefinition *oscalTypes.ComponentDefinition, format string) error { + + controlMap := oscal.FilterControlImplementations(componentDefinition) + extractedData := ExtractControlIDs(controlMap) + extractedData.Title = componentDefinition.Metadata.Title + + report := ReportData{ + ComponentDefinition: extractedData, + } + + message.Info("Generating report...") + return PrintReport(report, format) +} + +// Gets the unique Control IDs from each source and framework in the OSCAL Component Definition +func ExtractControlIDs(controlMap map[string][]oscalTypes.ControlImplementationSet) *ComponentDefinitionReportData { + sourceMap, frameworkMap := SplitControlMap(controlMap) + + sourceControlIDs := make(map[string]int) + for source, controlMap := range sourceMap { + total := 0 + for _, count := range controlMap { + total += count + } + sourceControlIDs[source] = total + } + + aggregatedFrameworkCounts := make(map[string]int) + for framework, controlCounts := range frameworkMap { + total := 0 + for _, count := range controlCounts { + total += count + } + aggregatedFrameworkCounts[framework] = total + } + + return &ComponentDefinitionReportData{ + ControlIDBySource: sourceControlIDs, + ControlIDByFramework: aggregatedFrameworkCounts, + } +} + +func PrintReport(data ReportData, format string) error { + if format == "table" { + // Use the Table function to print a formatted table + message.Infof("Title: %s", data.ComponentDefinition.Title) + + // Prepare headers and data for Control ID By Source table + sourceHeaders := []string{"Control Source", "Number of Controls"} + sourceData := make([][]string, 0, len(data.ComponentDefinition.ControlIDBySource)) + for source, count := range data.ComponentDefinition.ControlIDBySource { + sourceData = append(sourceData, []string{source, fmt.Sprintf("%d", count)}) + } + // Print Control ID By Source using the Table function + if err := message.Table(sourceHeaders, sourceData, []int{70, 30}); err != nil { + // Handle the error, e.g., log or return + return err + } + + // Prepare headers and data for Control ID By Framework table + frameworkHeaders := []string{"Framework", "Number of Controls"} + frameworkData := make([][]string, 0, len(data.ComponentDefinition.ControlIDByFramework)) + for framework, count := range data.ComponentDefinition.ControlIDByFramework { + frameworkData = append(frameworkData, []string{framework, fmt.Sprintf("%d", count)}) + } + // Print Control ID By Framework using the Table function + if err := message.Table(frameworkHeaders, frameworkData, []int{70, 30}); err != nil { + // Handle the error, e.g., log or return + return err + } + + } else { + var err error + var fileData []byte + + if format == "yaml" { + message.Info("Generating report in YAML format...") + fileData, err = yaml.Marshal(data) + if err != nil { + message.Fatal(err, "Failed to marshal data to YAML") + } + } else { + message.Info("Generating report in JSON format...") + fileData, err = json.MarshalIndent(data, "", " ") + if err != nil { + message.Fatal(err, "Failed to marshal data to JSON") + } + } + + message.Info(string(fileData)) + } + + return nil +} diff --git a/src/internal/reporting/reporting_test.go b/src/internal/reporting/reporting_test.go new file mode 100644 index 00000000..792ced1f --- /dev/null +++ b/src/internal/reporting/reporting_test.go @@ -0,0 +1,231 @@ +package reporting + +import ( + "testing" + + oscalTypes "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-3" + "github.com/defenseunicorns/lula/src/pkg/common/composition" + "github.com/defenseunicorns/lula/src/pkg/message" +) + +// Mock functions for each OSCAL model + +func MockAssessmentPlan() *oscalTypes.AssessmentPlan { + return &oscalTypes.AssessmentPlan{ + UUID: "mock-assessment-plan-uuid", + Metadata: oscalTypes.Metadata{ + Title: "Mock Assessment Plan", + Version: "1.0", + }, + } +} + +func MockAssessmentResults() *oscalTypes.AssessmentResults { + return &oscalTypes.AssessmentResults{ + UUID: "mock-assessment-results-uuid", + Metadata: oscalTypes.Metadata{ + Title: "Mock Assessment Results", + Version: "1.0", + }, + } +} + +func MockCatalog() *oscalTypes.Catalog { + return &oscalTypes.Catalog{ + UUID: "mock-catalog-uuid", + Metadata: oscalTypes.Metadata{ + Title: "Mock Catalog", + Version: "1.0", + }, + } +} + +func MockComponentDefinition() *oscalTypes.ComponentDefinition { + return &oscalTypes.ComponentDefinition{ + UUID: "mock-component-definition-uuid", + Metadata: oscalTypes.Metadata{ + Title: "Mock Component Definition", + Version: "1.0", + }, + Components: &[]oscalTypes.DefinedComponent{ + { + UUID: "7c02500a-6e33-44e0-82ee-fba0f5ea0cae", + Description: "Mock Component Description A", + Title: "Component A", + Type: "software", + ControlImplementations: &[]oscalTypes.ControlImplementationSet{ + { + Description: "Control Implementation Description", + ImplementedRequirements: []oscalTypes.ImplementedRequirementControlImplementation{ + { + ControlId: "ac-1", + Description: "", + Remarks: "STATEMENT: Implementation details for ac-1.", + UUID: "67dd59c4-0340-4aed-a49d-002815b50157", + }, + }, + Source: "https://raw.githubusercontent.com/usnistgov/oscal-content/main/nist.gov/SP800-53/rev4/yaml/NIST_SP-800-53_rev4_HIGH-baseline-resolved-profile_catalog.yaml", + UUID: "0631b5b8-e51a-577b-8a43-2d3d0bd9ced8", + Props: &[]oscalTypes.Property{ + { + Name: "framework", + Ns: "https://docs.lula.dev/ns", + Value: "rev4", + }, + }, + }, + }, + }, + { + UUID: "4cb1810c-d0d8-404e-b346-5a12c9629ed5", + Description: "Mock Component Description B", + Title: "Component B", + Type: "software", + ControlImplementations: &[]oscalTypes.ControlImplementationSet{ + { + Description: "Control Implementation Description", + ImplementedRequirements: []oscalTypes.ImplementedRequirementControlImplementation{ + { + ControlId: "ac-1", + Description: "", + Remarks: "STATEMENT: Implementation details for ac-1.", + UUID: "857121b1-2992-412c-b34a-504ead86e117", + }, + }, + Source: "https://raw.githubusercontent.com/usnistgov/oscal-content/main/nist.gov/SP800-53/rev5/yaml/NIST_SP-800-53_rev5_HIGH-baseline-resolved-profile_catalog.yaml", + UUID: "b1723ecd-a15a-5daf-a8e0-a7dd20a19abf", + Props: &[]oscalTypes.Property{ + { + Name: "framework", + Ns: "https://docs.lula.dev/ns", + Value: "rev5", + }, + }, + }, + }, + }, + }, + } +} + +func MockPoam() *oscalTypes.PlanOfActionAndMilestones { + return &oscalTypes.PlanOfActionAndMilestones{ + UUID: "mock-poam-uuid", + Metadata: oscalTypes.Metadata{ + Title: "Mock POAM", + Version: "1.0", + }, + } +} + +func MockProfile() *oscalTypes.Profile { + return &oscalTypes.Profile{ + UUID: "mock-profile-uuid", + Metadata: oscalTypes.Metadata{ + Title: "Mock Profile", + Version: "1.0", + }, + } +} + +func MockSystemSecurityPlan() *oscalTypes.SystemSecurityPlan { + return &oscalTypes.SystemSecurityPlan{ + UUID: "mock-system-security-plan-uuid", + Metadata: oscalTypes.Metadata{ + Title: "Mock System Security Plan", + Version: "1.0", + }, + } +} + +func MockOscalModels() *oscalTypes.OscalCompleteSchema { + return &oscalTypes.OscalCompleteSchema{ + AssessmentPlan: MockAssessmentPlan(), + AssessmentResults: MockAssessmentResults(), + Catalog: MockCatalog(), + ComponentDefinition: MockComponentDefinition(), + PlanOfActionAndMilestones: MockPoam(), + Profile: MockProfile(), + SystemSecurityPlan: MockSystemSecurityPlan(), + } +} + +// Test function for handleOSCALModel +func TestHandleOSCALModel(t *testing.T) { + // Disable the spinner for this test function to work properly + message.NoProgress = true + + // Define the test cases + testCases := []struct { + name string + oscalModel *oscalTypes.OscalCompleteSchema + fileFormat string + expectErr bool + }{ + { + name: "Component Definition Model", + oscalModel: &oscalTypes.OscalCompleteSchema{ComponentDefinition: MockComponentDefinition()}, + fileFormat: "table", + expectErr: false, + }, + { + name: "Catalog Model", + oscalModel: &oscalTypes.OscalCompleteSchema{Catalog: MockCatalog()}, + fileFormat: "table", + expectErr: true, + }, + { + name: "Assessment Plan Model", + oscalModel: &oscalTypes.OscalCompleteSchema{AssessmentPlan: MockAssessmentPlan()}, + fileFormat: "table", + expectErr: true, + }, + { + name: "Assessment Results Model", + oscalModel: &oscalTypes.OscalCompleteSchema{AssessmentResults: MockAssessmentResults()}, + fileFormat: "table", + expectErr: true, + }, + { + name: "POAM Model", + oscalModel: &oscalTypes.OscalCompleteSchema{PlanOfActionAndMilestones: MockPoam()}, + fileFormat: "table", + expectErr: true, + }, + { + name: "Profile Model", + oscalModel: &oscalTypes.OscalCompleteSchema{Profile: MockProfile()}, + fileFormat: "table", + expectErr: true, + }, + { + name: "System Security Plan Model", + oscalModel: &oscalTypes.OscalCompleteSchema{SystemSecurityPlan: MockSystemSecurityPlan()}, + fileFormat: "table", + expectErr: true, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + // Initialize CompositionContext + compCtx, err := composition.New() + if err != nil { + t.Fatalf("failed to create composition context: %v", err) + } + + // Call handleOSCALModel with compCtx + err = handleOSCALModel(tc.oscalModel, tc.fileFormat, compCtx) + if tc.expectErr { + if err == nil { + t.Errorf("expected an error but got none for test case: %s", tc.name) + } + } else { + if err != nil { + t.Errorf("did not expect an error but got one for test case: %s, error: %v", tc.name, err) + } + } + }) + } +} diff --git a/src/test/e2e/cmd/cmd_report_test.go b/src/test/e2e/cmd/cmd_report_test.go new file mode 100644 index 00000000..657a501c --- /dev/null +++ b/src/test/e2e/cmd/cmd_report_test.go @@ -0,0 +1,56 @@ +package cmd_test + +import ( + "testing" + + "github.com/defenseunicorns/lula/src/cmd/report" + "github.com/defenseunicorns/lula/src/pkg/message" + "github.com/stretchr/testify/require" +) + +func TestLulaReportValidComponent(t *testing.T) { + + message.NoProgress = true + + // Helper function to test against golden files + test := func(t *testing.T, goldenFileName string, args ...string) error { + t.Helper() + rootCmd := report.ReportCommand() + return runCmdTest(t, rootCmd, args...) + } + + t.Run("Valid YAML Report", func(t *testing.T) { + err := test(t, "report_valid-multi-component-validations-yaml", + "-f", "../../unit/common/oscal/valid-multi-component-validations.yaml", "--file-format", "yaml") + require.NoError(t, err) + }) + + t.Run("Valid JSON Report", func(t *testing.T) { + err := test(t, "report_valid-multi-component-validations-json", + "-f", "../../unit/common/oscal/valid-multi-component-validations.yaml", "--file-format", "json") + require.NoError(t, err) + }) + + t.Run("Valid TABLE Report", func(t *testing.T) { + err := test(t, "report_valid-multi-component-validations", + "-f", "../../unit/common/oscal/valid-multi-component-validations.yaml", "--file-format", "table") + require.NoError(t, err) + }) + + t.Run("Unsupported OSCAL Report Model", func(t *testing.T) { + err := test(t, "report_valid-multi-component-validations", + "-f", "../../unit/common/oscal/catalog.yaml", "--file-format", "table") + require.Error(t, err) + }) + + t.Run("invalid - file does not exist", func(t *testing.T) { + err := test(t, "report_valid-multi-component-validations", + "-f", "file-does-not-exist.yaml", "--file-format", "table") + require.Error(t, err) + }) + + t.Run("Help Output", func(t *testing.T) { + err := test(t, "report_help", "--help") + require.NoError(t, err) + }) +}