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: support markdown as a renderer output format #222

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,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:
Expand Down
2 changes: 1 addition & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,5 +59,5 @@ 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)")
}
166 changes: 166 additions & 0 deletions renderers/md/md.go
Original file line number Diff line number Diff line change
@@ -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, converts Markdown to HTML, 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 }}**<br>{{ end }}
Severity: **{{ .Severity }}**<br>
{{ if ne (len .Epss.Percentile) 0 }} EPSS: {{ .Epss.Percentile }}<br>{{ end }}
[Reference Documentation]({{ .Reference }})

{{ .Description }}

<hr>

{{ end }}

{{ end }}
{{ end }}

<sub>Powered by the [DevOps Kung Fu Mafia](https://github.com/devops-kung-fu)</sub>
`
return template.Must(template.New(output).Parse(content))
}
73 changes: 73 additions & 0 deletions renderers/md/md_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
3 changes: 3 additions & 0 deletions renderers/rendererfactory.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
Expand Down
5 changes: 5 additions & 0 deletions renderers/rendererfactory_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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)
}