Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: initial lula report #599

Merged
merged 37 commits into from
Dec 19, 2024
Merged
Show file tree
Hide file tree
Changes from 32 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
95568b1
initial command
CloudBeard Aug 5, 2024
3f7bb0c
initial command
CloudBeard Aug 9, 2024
d1543fb
initial report - still wip
CloudBeard Aug 12, 2024
0ea6bde
still wip, need to pull controls from catalog and profile to do math …
CloudBeard Aug 14, 2024
8027410
added report doc
CloudBeard Aug 19, 2024
b10e5ff
refactored to test main function, added test and test data
CloudBeard Sep 18, 2024
bba7581
Merge branch 'main' into initial-lula-report
CloudBeard Sep 18, 2024
e793cd1
Merge branch 'main' into initial-lula-report
CloudBeard Sep 24, 2024
3227f7b
update tests
CloudBeard Sep 25, 2024
6296154
re run doc generation
CloudBeard Sep 25, 2024
d707143
fix e2e test
CloudBeard Sep 27, 2024
9371ff8
removed failed case, its covered in unit test for the function itself
CloudBeard Sep 27, 2024
ee25f9f
add compose to handleComponentDefinittion
CloudBeard Sep 30, 2024
9d58163
update e2e test file to contain validations
CloudBeard Sep 30, 2024
ddd7fa8
Merge branch 'main' into initial-lula-report
CloudBeard Sep 30, 2024
60827db
extra space?
CloudBeard Sep 30, 2024
b4430e5
chore: empty commit to re-run CI
CloudBeard Sep 30, 2024
1862398
update report structure
CloudBeard Oct 2, 2024
07e630a
update go fmt
CloudBeard Oct 3, 2024
79a034b
Merge branch 'main' of https://github.com/defenseunicorns/lula into i…
CloudBeard Oct 8, 2024
09592ac
still wip need to fix to work with new compose
CloudBeard Oct 8, 2024
c363e45
updated compose calls
CloudBeard Oct 10, 2024
df8a3cd
update to table function and I think fixed e2e tests to match
CloudBeard Oct 10, 2024
baf8f91
Merge branch 'main' into initial-lula-report
CloudBeard Oct 10, 2024
5698e54
Merge branch 'main' into initial-lula-report
CloudBeard Oct 11, 2024
6047d8c
Merge branch 'main' into initial-lula-report
CloudBeard Nov 22, 2024
30ce424
fix(report): pair with andy on consolidating logic
brandtkeller Nov 23, 2024
6e92274
remove golden files and clean up test
CloudBeard Dec 4, 2024
a37ef5a
Merge branch 'main' into initial-lula-report
CloudBeard Dec 4, 2024
9829b37
updated lint/gosec errors
CloudBeard Dec 4, 2024
c704cc9
Merge branch 'main' into initial-lula-report
CloudBeard Dec 6, 2024
2f19489
dropped un-needed test and fix oscal types
CloudBeard Dec 6, 2024
5202d0d
Merge branch 'main' into initial-lula-report
CloudBeard Dec 13, 2024
9e15079
fix tests
CloudBeard Dec 13, 2024
5ca9232
Merge branch 'main' into initial-lula-report
CloudBeard Dec 13, 2024
0f18665
Merge branch 'main' into initial-lula-report
CloudBeard Dec 15, 2024
88939a1
Merge branch 'main' into initial-lula-report
brandtkeller Dec 18, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
brandtkeller marked this conversation as resolved.
Show resolved Hide resolved

import (
"fmt"

"github.com/defenseunicorns/lula/src/internal/reporting"
"github.com/defenseunicorns/lula/src/pkg/message"
"github.com/spf13/cobra"
)

var reportHelp = `
brandtkeller marked this conversation as resolved.
Show resolved Hide resolved
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
Loading