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

Draft
wants to merge 25 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 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
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

45 changes: 45 additions & 0 deletions src/cmd/report/report.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package report
brandtkeller marked this conversation as resolved.
Show resolved Hide resolved

import (
"fmt"

"github.com/defenseunicorns/lula/src/internal/reporting"
"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 running 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")
cmd.MarkFlagRequired("input-file")

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(),
}

Expand Down
68 changes: 68 additions & 0 deletions src/internal/reporting/helpers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package reporting

import (
"os"

oscalTypes_1_1_2 "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-2"
"github.com/defenseunicorns/lula/src/pkg/common/network"
"github.com/defenseunicorns/lula/src/pkg/message"
)

// Helper to determine if the controlMap source is a URL
func isURL(str string) bool {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given the comment below I imagine this will be removed - but I do think this has some gaps worth addressing if it were to be kept.

IE the return statement does not indicate a value that aligns to the function intent.

_, err := network.ParseUrl(str)
return err == nil
}

func fetchOrReadFile(source string) ([]byte, error) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

network.Fetch() will return file bytes for local files on the filesystem or remote files on network. I don't believe this functionality is needed unless you have an edge case you're solving for.

if isURL(source) {
spinner := message.NewProgressSpinner("Fetching data from URL: %s", source)
defer spinner.Stop()
data, err := network.Fetch(source)
if err != nil {
spinner.Fatalf(err, "failed to fetch data from URL")
}
spinner.Success()
return data, nil
}
spinner := message.NewProgressSpinner("Reading file: %s", source)
defer spinner.Stop()
data, err := os.ReadFile(source)
if err != nil {
spinner.Fatalf(err, "failed to read file")
}
spinner.Success()
return data, nil
}

// Split the default controlMap into framework and source maps for further processing
func SplitControlMap(controlMap map[string][]oscalTypes_1_1_2.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 {
if isURL(key) {
if _, exists := sourceMap[key]; !exists {
sourceMap[key] = make(map[string]int)
}
for _, controlImplementation := range implementations {
for _, implementedReq := range controlImplementation.ImplementedRequirements {
controlID := implementedReq.ControlId
sourceMap[key][controlID]++
}
}
} else {
if _, exists := frameworkMap[key]; !exists {
frameworkMap[key] = make(map[string]int)
}
for _, controlImplementation := range implementations {
for _, implementedReq := range controlImplementation.ImplementedRequirements {
controlID := implementedReq.ControlId
frameworkMap[key][controlID]++
}
}
}
}

return sourceMap, frameworkMap
}
196 changes: 196 additions & 0 deletions src/internal/reporting/reporting.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
package reporting

import (
"context"
"encoding/json"
"fmt"

oscalTypes_1_1_2 "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-2"
"github.com/defenseunicorns/lula/src/pkg/common/composition"
"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 := fetchOrReadFile(inputFile)
if err != nil {
spinner.Fatalf(fmt.Errorf("failed to get OSCAL file: %v", err), "failed to get OSCAL file")
}
spinner.Success()

spinner = message.NewProgressSpinner("Reading OSCAL model from file")
oscalModel, err := oscal.NewOscalModel(getOSCALModelsFile)
if err != nil {
spinner.Fatalf(fmt.Errorf("failed to read OSCAL Model data: %v", err), "failed to read OSCAL Model")
}
spinner.Success()

// Initialize CompositionContext
compCtx, err := composition.New()
if err != nil {
return fmt.Errorf("failed to create composition context: %v", err)
}

err = handleOSCALModel(oscalModel, fileFormat, compCtx)
if err != nil {
return err
}

return nil
}

// Processes an OSCAL Model based on the model type
func handleOSCALModel(oscalModel *oscalTypes_1_1_2.OscalModels, format string, compCtx *composition.CompositionContext) 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 {
spinner.Fatalf(fmt.Errorf("unable to determine OSCAL model type: %v", err), "unable to determine OSCAL model type")
return 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
spinner.Warnf("reporting does not create reports for %s at this time", modelType)
return fmt.Errorf("reporting does not create reports for %s at this time", modelType)

case "component":
spinner.Updatef("Composing Component Definition")
err := compCtx.ComposeComponentDefinitions(context.Background(), oscalModel.ComponentDefinition, "")
if err != nil {
spinner.Fatalf(fmt.Errorf("failed to compose component definitions: %v", err), "failed to compose component definitions")
return err
}

spinner.Updatef("Processing Component Definition")
// Process the component-definition model
err = handleComponentDefinition(oscalModel.ComponentDefinition, format, compCtx)
if err != nil {
// If an error occurs, stop the spinner and display the error
spinner.Fatalf(err, "failed to process component-definition model")
return err
}

default:
// For unknown model types, stop the spinner with a failure
spinner.Fatalf(fmt.Errorf("unknown OSCAL model type: %s", modelType), "failed to process OSCAL file")
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_1_1_2.ComponentDefinition, format string, compCtx *composition.CompositionContext) error {
spinner := message.NewProgressSpinner("composing component definitions")

err := compCtx.ComposeComponentDefinitions(context.Background(), componentDefinition, "")
if err != nil {
spinner.Fatalf(fmt.Errorf("failed to compose component definitions: %v", err), "failed to compose component definitions")
return err
}

spinner.Success() // Mark the spinner as successful before moving forward

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_1_1_2.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
message.Table(sourceHeaders, sourceData, []int{70, 30})

// 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
message.Table(frameworkHeaders, frameworkData, []int{70, 30})

} 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