diff --git a/README.md b/README.md index de19f3c..c68d909 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,16 @@ Example command: bomber scan bad-bom.json --output=json > filename.json ``` +### Markdown Output + +`bomber` also supports output in Markdown format. This is very similar to the HTML output, but offloads styling to the Markdown renderer, such as GitHub. The output is saved to a file in the format "YYYY-MM-DD-HH-MM-SS-bomber-results.md". + +Example command: + +```bash +bomber scan bad-bom.json --output=md +``` + ## Ignoring Vulnerabilities If needed, you can use the `--ignore-file` flag to load a list of CVEs to ignore in the vulnerability output. This list needs to be in a specific format where each CVE to ignore is entered on a separate line similar to the following: diff --git a/cmd/root.go b/cmd/root.go index 925b573..45ec830 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -56,7 +56,7 @@ func Execute() { func init() { rootCmd.PersistentFlags().BoolVar(&debug, "debug", false, "displays debug level log messages.") - rootCmd.PersistentFlags().StringVar(&output, "output", "stdout", "how bomber should output findings (json, html, ai, stdout)") + rootCmd.PersistentFlags().StringVar(&output, "output", "stdout", "how bomber should output findings (json, html, ai, md, stdout)") } func checkForNewVersion(currentVersion string) { diff --git a/renderers/md/md.go b/renderers/md/md.go new file mode 100644 index 0000000..8e41b45 --- /dev/null +++ b/renderers/md/md.go @@ -0,0 +1,166 @@ +package md + +import ( + "fmt" + "log" + "math" + "path/filepath" + "strconv" + "strings" + "text/template" + "time" + + "github.com/devops-kung-fu/common/util" + "github.com/spf13/afero" + + "github.com/devops-kung-fu/bomber/models" +) + +// Renderer contains methods to render results to a Markdown file +type Renderer struct{} + +// Render renders results to a Markdown file +func (Renderer) Render(results models.Results) error { + var afs *afero.Afero + + if results.Meta.Provider == "test" { + afs = &afero.Afero{Fs: afero.NewMemMapFs()} + } else { + afs = &afero.Afero{Fs: afero.NewOsFs()} + } + + filename := generateFilename() + util.PrintInfo("Writing filename:", filename) + + err := writeTemplate(afs, filename, results) + + return err +} + +// generateFilename generates a unique filename based on the current timestamp +// in the format "2006-01-02 15:04:05" and replaces certain characters to +// create a valid filename. The resulting filename is a combination of the +// timestamp and a fixed suffix. +func generateFilename() string { + t := time.Now() + r := strings.NewReplacer("-", "", " ", "-", ":", "-") + return filepath.Join(".", fmt.Sprintf("%s-bomber-results.md", r.Replace(t.Format("2006-01-02 15:04:05")))) +} + +// writeTemplate writes the results to a file with the specified filename, +// using the given Afero filesystem interface. It creates the file, processes +// percentiles in the results and writes the templated results to the file. +// It also sets file permissions to 0777. +func writeTemplate(afs *afero.Afero, filename string, results models.Results) error { + processPercentiles(results) + + file, err := afs.Create(filename) + if err != nil { + log.Println(err) + return err + } + + template := genTemplate("output") + err = template.ExecuteTemplate(file, "output", results) + if err != nil { + log.Println(err) + return err + } + + err = afs.Fs.Chmod(filename, 0777) + + return err +} + +// processPercentiles calculates and updates the percentile values for +// vulnerabilities in the given results. It converts the percentile from +// a decimal to a percentage and updates the results in place. +func processPercentiles(results models.Results) { + for i, p := range results.Packages { + for vi, v := range p.Vulnerabilities { + per, err := strconv.ParseFloat(v.Epss.Percentile, 64) + if err != nil { + log.Println(err) + } else { + percentage := math.Round(per * 100) + if percentage > 0 { + results.Packages[i].Vulnerabilities[vi].Epss.Percentile = fmt.Sprintf("%d%%", uint64(percentage)) + } else { + results.Packages[i].Vulnerabilities[vi].Epss.Percentile = "N/A" + } + } + } + } +} + +func genTemplate(output string) (t *template.Template) { + + content := ` +![IMG](https://raw.githubusercontent.com/devops-kung-fu/bomber/main/img/bomber-readme-logo.png) + +The following results were detected by `+ "`{{.Meta.Generator}} {{.Meta.Version}}`" + ` on {{.Meta.Date}} using the {{.Meta.Provider}} provider. +{{ if ne (len .Packages) 0 }} + +Vulnerabilities displayed may differ from provider to provider. This list may not contain all possible vulnerabilities. Please try the other providers that ` + "`bomber`" + ` supports (osv, ossindex, snyk). There is no guarantee that the next time you scan for vulnerabilities that there won't be more, or less of them. Threats are continuous. + +EPSS Percentage indicates the % chance that the vulnerability will be exploited. This value will assist in prioritizing remediation. For more information on EPSS, refer to [https://www.first.org/epss/](https://www.first.org/epss/) +{{ else }} +No vulnerabilities found! +{{ end }} + +{{ if ne (len .Files) 0 }} +## Scanned Files + +{{ range .Files }}**{{ .Name }}** (sha256:{{ .SHA256 }}){{ end }} +{{end}} +{{ if ne (len .Licenses) 0 }} +## Licenses + +The following licenses were found by ` + "`bomber`" + `: +{{ range $license := .Licenses }} +- {{ $license }}{{ end }} +{{ else }} +**No license information detected.** +{{ end }} + +{{ if ne (len .Packages) 0 }} +## Vulnerability Summary + +{{ if ne (len .Meta.SeverityFilter) 0 }} +Only showing vulnerabilities with a severity of ***{{ .Meta.SeverityFilter }}*** or higher. + +{{ end }} +| Severity | Count | +| --- | --- |{{ if gt .Summary.Critical 0 }} +| Critical | {{ .Summary.Critical }} |{{ end }}{{ if gt .Summary.High 0 }} +| High | {{ .Summary.High }} |{{ end }}{{ if gt .Summary.Moderate 0 }} +| Moderate | {{ .Summary.Moderate }} |{{ end }}{{ if gt .Summary.Low 0 }} +| Low | {{ .Summary.Low }} |{{ end }}{{ if gt .Summary.Unspecified 0 }} +| Unspecified | {{ .Summary.Unspecified }} |{{ end }} + +## Vulnerability Details + +{{ range .Packages }} +### {{ .Purl }} +{{if .Description }}{{ .Description }}{{ end }} +#### Vulnerabilities + +{{ range .Vulnerabilities }} +{{ if .Title }}Title: **{{ .Title }}**
{{ end }} +Severity: **{{ .Severity }}**
+{{ if ne (len .Epss.Percentile) 0 }} EPSS: {{ .Epss.Percentile }}
{{ end }} +[Reference Documentation]({{ .Reference }}) + +{{ .Description }} + +
+ +{{ end }} + +{{ end }} +{{ end }} + +Powered by the [DevOps Kung Fu Mafia](https://github.com/devops-kung-fu) +` + return template.Must(template.New(output).Parse(content)) +} diff --git a/renderers/md/md_test.go b/renderers/md/md_test.go new file mode 100644 index 0000000..c7a72ec --- /dev/null +++ b/renderers/md/md_test.go @@ -0,0 +1,73 @@ +package md + +import ( + "fmt" + "os" + "testing" + + "github.com/devops-kung-fu/common/util" + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + + "github.com/devops-kung-fu/bomber/models" +) + +func Test_writeTemplate(t *testing.T) { + afs := &afero.Afero{Fs: afero.NewMemMapFs()} + + err := writeTemplate(afs, "test.md", models.NewResults([]models.Package{}, models.Summary{}, []models.ScannedFile{}, []string{"GPL"}, "0.0.0", "test", "low")) + assert.NoError(t, err) + + b, err := afs.ReadFile("test.md") + assert.NotNil(t, b) + assert.NoError(t, err) + + info, err := afs.Stat("test.md") + assert.NoError(t, err) + assert.Equal(t, os.FileMode(0777), info.Mode().Perm()) +} + +func Test_genTemplate(t *testing.T) { + template := genTemplate("test") + + assert.NotNil(t, template) + assert.Len(t, template.Root.Nodes, 17) +} + +func TestRenderer_Render(t *testing.T) { + output := util.CaptureOutput(func() { + renderer := Renderer{} + err := renderer.Render(models.NewResults([]models.Package{}, models.Summary{}, []models.ScannedFile{}, []string{"GPL"}, "0.0.0", "test", "")) + if err != nil { + fmt.Println(err) + } + }) + assert.NotNil(t, output) +} + +func Test_processPercentiles(t *testing.T) { + // Create a sample Results struct for testing + results := models.Results{ + Packages: []models.Package{ + { + Vulnerabilities: []models.Vulnerability{ + { + Epss: models.EpssScore{Percentile: "0.75"}, + }, + { + Epss: models.EpssScore{Percentile: "invalid"}, // Simulate an invalid percentile + }, + { + Epss: models.EpssScore{Percentile: "0"}, // Simulate a zero percentile + }, + }, + }, + }, + } + + processPercentiles(results) + + assert.Equal(t, "75%", results.Packages[0].Vulnerabilities[0].Epss.Percentile, "Expected 75% percentile") + assert.Equal(t, "invalid", results.Packages[0].Vulnerabilities[1].Epss.Percentile, "Expected invalid for invalid percentile") + assert.Equal(t, "N/A", results.Packages[0].Vulnerabilities[2].Epss.Percentile, "Expected N/A for zero percentile") +} diff --git a/renderers/rendererfactory.go b/renderers/rendererfactory.go index b93498d..9fd0711 100644 --- a/renderers/rendererfactory.go +++ b/renderers/rendererfactory.go @@ -9,6 +9,7 @@ import ( "github.com/devops-kung-fu/bomber/renderers/html" "github.com/devops-kung-fu/bomber/renderers/json" "github.com/devops-kung-fu/bomber/renderers/stdout" + "github.com/devops-kung-fu/bomber/renderers/md" ) // NewRenderer will return a Renderer interface for the requested output @@ -22,6 +23,8 @@ func NewRenderer(output string) (renderer models.Renderer, err error) { renderer = html.Renderer{} case "ai": renderer = ai.Renderer{} + case "md": + renderer = md.Renderer{} default: err = fmt.Errorf("%s is not a valid output type", output) } diff --git a/renderers/rendererfactory_test.go b/renderers/rendererfactory_test.go index 6b27450..01407b7 100644 --- a/renderers/rendererfactory_test.go +++ b/renderers/rendererfactory_test.go @@ -8,6 +8,7 @@ import ( "github.com/devops-kung-fu/bomber/renderers/ai" "github.com/devops-kung-fu/bomber/renderers/html" "github.com/devops-kung-fu/bomber/renderers/json" + "github.com/devops-kung-fu/bomber/renderers/md" "github.com/devops-kung-fu/bomber/renderers/stdout" ) @@ -28,6 +29,10 @@ func TestNewRenderer(t *testing.T) { assert.NoError(t, err) assert.IsType(t, ai.Renderer{}, renderer) + renderer, err = NewRenderer("md") + assert.NoError(t, err) + assert.IsType(t, md.Renderer{}, renderer) + _, err = NewRenderer("test") assert.Error(t, err) }