Skip to content
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
28 changes: 28 additions & 0 deletions cmd/grype/cli/commands/report_filter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package commands

import (
"slices"

"github.com/anchore/grype/grype/presenter/models"
"github.com/anchore/grype/grype/vulnerability"
)

func filterDocumentByMinSeverity(doc *models.Document, minSeverity *vulnerability.Severity) {
if minSeverity == nil {
return
}

doc.Matches = slices.DeleteFunc(doc.Matches, func(m models.Match) bool {
return vulnerability.ParseSeverity(m.Vulnerability.Severity) < *minSeverity
})
if doc.Matches == nil {
doc.Matches = make([]models.Match, 0)
}

doc.IgnoredMatches = slices.DeleteFunc(doc.IgnoredMatches, func(m models.IgnoredMatch) bool {
return vulnerability.ParseSeverity(m.Vulnerability.Severity) < *minSeverity
})
if doc.IgnoredMatches == nil {
doc.IgnoredMatches = make([]models.IgnoredMatch, 0)
}
}
166 changes: 166 additions & 0 deletions cmd/grype/cli/commands/report_filter_output_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
package commands

import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/anchore/clio"
"github.com/anchore/grype/grype/presenter/models"
"github.com/anchore/grype/grype/vulnerability"
grypeFormat "github.com/anchore/grype/internal/format"
syftPkg "github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/sbom"
syftSource "github.com/anchore/syft/syft/source"
)

func TestMinSeverityFilteredDocumentIsSharedByAllOutputs(t *testing.T) {
doc := newReportFilterTestDocument(t, []string{"Unknown", "Low", "High", "Critical"})
filterDocumentByMinSeverity(&doc, severityPointer(vulnerability.HighSeverity))

outputs := writeReportOutputs(t, doc)

for format, output := range outputs {
t.Run(format, func(t *testing.T) {
assert.NotContains(t, output, "CVE-0-unknown")
assert.NotContains(t, output, "CVE-1-low")
assert.Contains(t, output, "CVE-2-high")
assert.Contains(t, output, "CVE-3-critical")
})
}

assert.Contains(t, outputs["table"], "suppressed by VEX")
assert.Contains(t, outputs["template"], "ignored:CVE-2-high")
assert.Contains(t, outputs["template"], "ignored:CVE-3-critical")
assertValidJSONReportMetadata(t, outputs["json"])
assertValidSARIFStructure(t, outputs["sarif"])
assertValidCycloneDXMetadata(t, outputs["cyclonedx-json"])
}

func TestMinSeverityFullyFilteredDocumentProducesValidEmptyOutputs(t *testing.T) {
doc := newReportFilterTestDocument(t, []string{"Unknown", "Low"})
filterDocumentByMinSeverity(&doc, severityPointer(vulnerability.CriticalSeverity))

outputs := writeReportOutputs(t, doc)

assert.Contains(t, outputs["table"], "No vulnerabilities found")
assert.Empty(t, strings.TrimSpace(outputs["template"]))

var jsonReport map[string]any
require.NoError(t, json.Unmarshal([]byte(outputs["json"]), &jsonReport))
matches, ok := jsonReport["matches"].([]any)
require.True(t, ok, "JSON matches should be an array")
assert.Empty(t, matches)
assert.NotNil(t, jsonReport["source"])
assert.NotNil(t, jsonReport["descriptor"])

var sarifReport map[string]any
require.NoError(t, json.Unmarshal([]byte(outputs["sarif"]), &sarifReport))
runs, ok := sarifReport["runs"].([]any)
require.True(t, ok)
require.Len(t, runs, 1)
run := runs[0].(map[string]any)
assert.NotNil(t, run["tool"])
results, ok := run["results"].([]any)
require.True(t, ok)
assert.Empty(t, results)

var cyclonedxReport map[string]any
require.NoError(t, json.Unmarshal([]byte(outputs["cyclonedx-json"]), &cyclonedxReport))
assert.NotNil(t, cyclonedxReport["metadata"])
if rawVulnerabilities, exists := cyclonedxReport["vulnerabilities"]; exists {
vulnerabilities, ok := rawVulnerabilities.([]any)
require.True(t, ok)
assert.Empty(t, vulnerabilities)
}
}

func writeReportOutputs(t *testing.T, doc models.Document) map[string]string {
t.Helper()

dir := t.TempDir()
templatePath := filepath.Join(dir, "report.tmpl")
templateContents := "{{range .Matches}}match:{{.Vulnerability.ID}}\n{{end}}{{range .IgnoredMatches}}ignored:{{.Vulnerability.ID}}\n{{end}}"
require.NoError(t, os.WriteFile(templatePath, []byte(templateContents), 0600))

formats := []string{"table", "json", "sarif", "template", "cyclonedx-json"}
paths := make(map[string]string, len(formats))
outputOptions := make([]string, 0, len(formats))
for _, format := range formats {
path := filepath.Join(dir, strings.ReplaceAll(format, "-", "_")+".out")
paths[format] = path
outputOptions = append(outputOptions, format+"="+path)
}

writer, err := grypeFormat.MakeScanResultWriter(outputOptions, "", grypeFormat.PresentationConfig{
TemplateFilePath: templatePath,
ShowSuppressed: true,
})
require.NoError(t, err)

sourceDescription := syftSource.Description{
Name: "test-source",
Version: "1.0.0",
Metadata: syftSource.DirectoryMetadata{Path: "/test/source"},
}
reportPackage := syftPkg.Package{
Name: "component",
Version: "1.0.0",
Type: syftPkg.NpmPkg,
PURL: "pkg:npm/component@1.0.0",
}
reportPackage.SetID()
reportSBOM := &sbom.SBOM{
Artifacts: sbom.Artifacts{
Packages: syftPkg.NewCollection(reportPackage),
},
Source: sourceDescription,
}
require.NoError(t, writer.Write(models.PresenterConfig{
ID: clio.Identification{Name: "grype", Version: "test"},
Document: doc,
SBOM: reportSBOM,
}))

outputs := make(map[string]string, len(paths))
for format, path := range paths {
contents, err := os.ReadFile(path)
require.NoError(t, err)
outputs[format] = string(contents)
}
return outputs
}

func assertValidJSONReportMetadata(t *testing.T, output string) {
t.Helper()
var report map[string]any
require.NoError(t, json.Unmarshal([]byte(output), &report))
assert.NotNil(t, report["source"])
assert.NotNil(t, report["distro"])
assert.NotNil(t, report["descriptor"])
assert.NotNil(t, report["alertsByPackage"])
}

func assertValidSARIFStructure(t *testing.T, output string) {
t.Helper()
var report map[string]any
require.NoError(t, json.Unmarshal([]byte(output), &report))
runs, ok := report["runs"].([]any)
require.True(t, ok)
require.Len(t, runs, 1)
run := runs[0].(map[string]any)
assert.NotNil(t, run["tool"])
}

func assertValidCycloneDXMetadata(t *testing.T, output string) {
t.Helper()
var report map[string]any
require.NoError(t, json.Unmarshal([]byte(output), &report))
assert.NotNil(t, report["metadata"])
assert.NotNil(t, report["components"])
}
186 changes: 186 additions & 0 deletions cmd/grype/cli/commands/report_filter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
package commands

import (
"fmt"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/anchore/clio"
"github.com/anchore/grype/grype/distro"
"github.com/anchore/grype/grype/match"
"github.com/anchore/grype/grype/pkg"
"github.com/anchore/grype/grype/presenter/models"
"github.com/anchore/grype/grype/vulnerability"
syftSource "github.com/anchore/syft/syft/source"
)

func TestFilterDocumentByMinSeverity(t *testing.T) {
severities := []string{"Unknown", "Negligible", "Low", "Medium", "High", "Critical"}
tests := []struct {
name string
min *vulnerability.Severity
expected []string
}{
{
name: "unset leaves the document unchanged",
expected: severities,
},
{
name: "negligible is inclusive and excludes unknown",
min: severityPointer(vulnerability.NegligibleSeverity),
expected: []string{"Negligible", "Low", "Medium", "High", "Critical"},
},
{
name: "low is inclusive",
min: severityPointer(vulnerability.LowSeverity),
expected: []string{"Low", "Medium", "High", "Critical"},
},
{
name: "medium is inclusive",
min: severityPointer(vulnerability.MediumSeverity),
expected: []string{"Medium", "High", "Critical"},
},
{
name: "high is inclusive",
min: severityPointer(vulnerability.HighSeverity),
expected: []string{"High", "Critical"},
},
{
name: "critical is inclusive",
min: severityPointer(vulnerability.CriticalSeverity),
expected: []string{"Critical"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
doc := newReportFilterTestDocument(t, severities)
metadataBefore := documentWithoutVulnerabilities(doc)

filterDocumentByMinSeverity(&doc, tt.min)

assert.Equal(t, tt.expected, matchSeverities(doc.Matches))
assert.Equal(t, tt.expected, ignoredMatchSeverities(doc.IgnoredMatches))
assert.Equal(t, metadataBefore, documentWithoutVulnerabilities(doc))

for _, ignored := range doc.IgnoredMatches {
require.Len(t, ignored.AppliedIgnoreRules, 1)
assert.Equal(t, "preserve this rule", ignored.AppliedIgnoreRules[0].Reason)
}
})
}
}

func TestFilterDocumentByMinSeverityFullyFiltered(t *testing.T) {
doc := newReportFilterTestDocument(t, []string{"Unknown", "Low"})
metadataBefore := documentWithoutVulnerabilities(doc)

filterDocumentByMinSeverity(&doc, severityPointer(vulnerability.CriticalSeverity))

assert.Empty(t, doc.Matches)
assert.NotNil(t, doc.Matches)
assert.Empty(t, doc.IgnoredMatches)
assert.NotNil(t, doc.IgnoredMatches)
assert.Equal(t, metadataBefore, documentWithoutVulnerabilities(doc))
}

func newReportFilterTestDocument(t *testing.T, severities []string) models.Document {
t.Helper()

d := &distro.Distro{
Type: "ubuntu",
Version: "22.04",
IDLike: []string{"debian"},
}
p := pkg.Package{
ID: "package-id",
Name: "package",
Version: "1.0.0",
Distro: d,
}
ctx := pkg.Context{
Source: &syftSource.Description{
Name: "test-source",
Version: "1.0.0",
Metadata: syftSource.DirectoryMetadata{Path: "/test/source"},
},
Distro: d,
}
doc, err := models.NewDocument(
clio.Identification{Name: "grype", Version: "test"},
[]pkg.Package{p},
ctx,
match.NewMatches(),
nil,
nil,
map[string]any{"configuration": "preserve"},
map[string]any{"database": "preserve"},
models.SortByPackage,
false,
&models.DistroAlertData{EOLDistroPackages: []pkg.Package{p}},
)
require.NoError(t, err)

for idx, severity := range severities {
m := reportModelMatch(idx, severity)
doc.Matches = append(doc.Matches, m)
doc.IgnoredMatches = append(doc.IgnoredMatches, models.IgnoredMatch{
Match: m,
AppliedIgnoreRules: []models.IgnoreRule{
{
Reason: "preserve this rule",
Namespace: "vex",
VexStatus: "not_affected",
},
},
})
}

return doc
}

func reportModelMatch(idx int, severity string) models.Match {
id := fmt.Sprintf("CVE-%d-%s", idx, strings.ToLower(severity))
return models.Match{
Vulnerability: models.Vulnerability{
VulnerabilityMetadata: models.VulnerabilityMetadata{
ID: id,
Severity: severity,
},
},
Artifact: models.Package{
ID: fmt.Sprintf("package-%d", idx),
Name: fmt.Sprintf("package-%d", idx),
Version: "1.0.0",
},
}
}

func matchSeverities(matches []models.Match) []string {
result := make([]string, 0, len(matches))
for _, m := range matches {
result = append(result, m.Vulnerability.Severity)
}
return result
}

func ignoredMatchSeverities(matches []models.IgnoredMatch) []string {
result := make([]string, 0, len(matches))
for _, m := range matches {
result = append(result, m.Vulnerability.Severity)
}
return result
}

func documentWithoutVulnerabilities(doc models.Document) models.Document {
doc.Matches = nil
doc.IgnoredMatches = nil
return doc
}

func severityPointer(severity vulnerability.Severity) *vulnerability.Severity {
return &severity
}
1 change: 1 addition & 0 deletions cmd/grype/cli/commands/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@ func runGrype(ctx context.Context, app clio.Application, opts *options.Grype, us
if err != nil {
return fmt.Errorf("failed to create document: %w", err)
}
filterDocumentByMinSeverity(&model, opts.MinSeverityThreshold())

if err = writer.Write(models.PresenterConfig{
ID: app.ID(),
Expand Down
Loading