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)
+ })
+}