Skip to content

Commit

Permalink
feat(report): initial lula report (#599)
Browse files Browse the repository at this point in the history
* initial command

* initial command

* initial report - still wip

* still wip, need to pull controls from catalog and profile to do math with

* added report doc

* refactored to test main function, added test and test data

* update tests

* re run doc generation

* fix e2e test

* removed failed case, its covered in unit test for the function itself

* add compose to handleComponentDefinittion

* update e2e test file to contain validations

* extra space?

* chore: empty commit to re-run CI

* update report structure

* update go fmt

* still wip need to fix to work with new compose

* updated compose calls

* update to table function and I think fixed e2e tests to match

* fix(report): pair with andy on consolidating logic

* remove golden files and clean up test

* updated lint/gosec errors

* dropped un-needed test and fix oscal types

* fix tests

---------

Co-authored-by: Brandt Keller <[email protected]>
Co-authored-by: Brandt Keller <[email protected]>
  • Loading branch information
3 people authored Dec 19, 2024
1 parent d23cedf commit 27e9f25
Show file tree
Hide file tree
Showing 8 changed files with 628 additions and 0 deletions.
1 change: 1 addition & 0 deletions docs/cli-commands/lula.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 46 additions & 0 deletions docs/cli-commands/lula_report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
title: lula report
description: Lula CLI command reference for <code>lula report</code>.
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

49 changes: 49 additions & 0 deletions src/cmd/report/report.go
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 2 additions & 0 deletions src/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -63,6 +64,7 @@ func init() {
validate.ValidateCommand(),
evaluate.EvaluateCommand(),
generate.GenerateCommand(),
report.ReportCommand(),
console.ConsoleCommand(),
dev.DevCommand(),
}
Expand Down
48 changes: 48 additions & 0 deletions src/internal/reporting/helpers.go
Original file line number Diff line number Diff line change
@@ -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
}
195 changes: 195 additions & 0 deletions src/internal/reporting/reporting.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading

0 comments on commit 27e9f25

Please sign in to comment.