diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 52fed7f7..2e6925f5 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -17,6 +17,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- `scan`: the `--include-non-exploitable` filter now correctly hides low/medium exploitability findings. The backend's exploitability label schema changed from a boolean (`Exploitable: true/false`) to a graded one (`Exploitability Level: low/medium/high`), which left the old filter matching nothing — the flag was effectively a no-op. Findings graded low or medium are now hidden by default, while high-exploitability and ungraded findings (SCA, container CVEs, false positives) are always shown. Pass `--include-non-exploitable` to restore the previous behavior of showing every finding (PPSC-1015) + ### Security --- diff --git a/internal/cmd/scan.go b/internal/cmd/scan.go index cd8a0388..a933a670 100644 --- a/internal/cmd/scan.go +++ b/internal/cmd/scan.go @@ -93,7 +93,7 @@ func init() { scanCmd.PersistentFlags().BoolVar(&includeTests, "include-tests", false, "Include test files in the scan (test files are excluded by default)") scanCmd.PersistentFlags().IntVar(&scanTimeout, "scan-timeout", 60, "Maximum time in minutes to wait for scan analysis to complete") scanCmd.PersistentFlags().IntVar(&uploadTimeout, "upload-timeout", 10, "Maximum time in minutes to wait for artifact upload to complete") - scanCmd.PersistentFlags().BoolVar(&includeNonExploitable, "include-non-exploitable", false, "Show non-exploitable findings (hidden by default)") + scanCmd.PersistentFlags().BoolVar(&includeNonExploitable, "include-non-exploitable", false, "Show low/medium exploitability findings (hidden by default; high and ungraded findings always shown)") scanCmd.PersistentFlags().StringVar(&groupBy, "group-by", "none", "Group findings by: none, cwe, severity, file") scanCmd.PersistentFlags().BoolVar(&generateSBOM, "sbom", false, "Generate Software Bill of Materials (SBOM) in CycloneDX format") scanCmd.PersistentFlags().BoolVar(&generateVEX, "vex", false, "Generate Vulnerability Exploitability eXchange (VEX) document") diff --git a/internal/output/human.go b/internal/output/human.go index 0dc62ef7..0c79d49a 100644 --- a/internal/output/human.go +++ b/internal/output/human.go @@ -818,7 +818,7 @@ func renderSummaryDashboard(w io.Writer, result *model.ScanResult) error { // Filtered count if any if result.Summary.FilteredNonExploitable > 0 { - filtered := s.MutedText.Render(fmt.Sprintf("(%d filtered as non-exploitable)", result.Summary.FilteredNonExploitable)) + filtered := s.MutedText.Render(fmt.Sprintf("(%d filtered as low/medium exploitability)", result.Summary.FilteredNonExploitable)) content.WriteString(filtered + "\n") } diff --git a/internal/scan/exploitability.go b/internal/scan/exploitability.go new file mode 100644 index 00000000..0a55b2b5 --- /dev/null +++ b/internal/scan/exploitability.go @@ -0,0 +1,68 @@ +package scan + +import ( + "strings" + + "github.com/ArmisSecurity/armis-cli/internal/model" +) + +// Exploitability label schema emitted by the backend. +// +// The backend grades code-scanner findings by exposure and attaches a label: +// +// description: "Exploitability Level" +// value: "armis_appsec:exploitability:" (low | medium | high) +// score: 5 (low) | 10 (medium) | 20 (high) +// +// Crucially, a label is emitted ONLY when a finding was actually graded. SCA/ +// container findings, findings without validation, false positives, and +// not-exposed findings get NO exploitability label. The absence of a label +// therefore means "not graded" — never "non-exploitable". +const ( + exploitabilityLabelDesc = "exploitability level" + exploitabilityPrefix = "armis_appsec:exploitability:" +) + +// hiddenExploitabilityLevels are the graded levels suppressed by default. +// We hide explicitly down-ranked findings (low + medium exposure) while always +// showing high and anything the backend did not grade. +var hiddenExploitabilityLevels = map[string]struct{}{ + "low": {}, + "medium": {}, +} + +// ShouldFilterByExploitability reports whether a finding should be hidden by +// default because the backend graded it as low or medium exploitability. +// +// It is a denylist on an explicit signal: it returns true ONLY when an +// "Exploitability Level" label is present AND its level is in the hidden set. +// Findings with no exploitability label (SCA, container, ungraded, false +// positives) always return false and are never filtered. This is overridden by +// the --include-non-exploitable flag. +func ShouldFilterByExploitability(labels []model.Label) bool { + for _, label := range labels { + if strings.ToLower(strings.TrimSpace(label.Description)) != exploitabilityLabelDesc { + continue + } + + level := exploitabilityLevel(label.Value) + if _, hidden := hiddenExploitabilityLevels[level]; hidden { + return true + } + } + + return false +} + +// exploitabilityLevel extracts the bare level from a namespaced label value +// such as "armis_appsec:exploitability:high". It tolerates the namespace prefix +// being absent and returns a lowercased, trimmed level (e.g. "high"). +func exploitabilityLevel(value string) string { + v := strings.ToLower(strings.TrimSpace(value)) + v = strings.TrimPrefix(v, exploitabilityPrefix) + // Fall back to the last segment in case the prefix differs slightly. + if idx := strings.LastIndex(v, ":"); idx != -1 { + v = v[idx+1:] + } + return strings.TrimSpace(v) +} diff --git a/internal/scan/exploitability_test.go b/internal/scan/exploitability_test.go new file mode 100644 index 00000000..8dc2b0c3 --- /dev/null +++ b/internal/scan/exploitability_test.go @@ -0,0 +1,100 @@ +package scan + +import ( + "testing" + + "github.com/ArmisSecurity/armis-cli/internal/model" +) + +func TestShouldFilterByExploitability(t *testing.T) { + // Labels co-occurring with the exploitability label on a real finding, so + // tests reflect the actual backend payload rather than the label in isolation. + scannerCode := model.Label{Description: "Scanner Code", Value: "armis_appsec:scanner_code:38295677"} + classification := model.Label{ + Description: "Security Finding Classification", + Value: "armis_appsec:security_finding_classification:code_vulnerability", + } + + tests := []struct { + name string + labels []model.Label + expected bool + }{ + { + name: "low exploitability is filtered", + labels: []model.Label{ + classification, + scannerCode, + {Description: "Exploitability Level", Value: "armis_appsec:exploitability:low"}, + }, + expected: true, + }, + { + name: "medium exploitability is filtered", + labels: []model.Label{ + classification, + scannerCode, + {Description: "Exploitability Level", Value: "armis_appsec:exploitability:medium"}, + }, + expected: true, + }, + { + name: "high exploitability is kept", + labels: []model.Label{ + classification, + scannerCode, + {Description: "Exploitability Level", Value: "armis_appsec:exploitability:high"}, + }, + expected: false, + }, + { + name: "no exploitability label is never filtered (ungraded finding)", + labels: []model.Label{ + classification, + scannerCode, + }, + expected: false, + }, + { + name: "empty labels are never filtered", + labels: []model.Label{}, + expected: false, + }, + { + name: "case-insensitive description and value", + labels: []model.Label{ + {Description: "exploitability level", Value: "ARMIS_APPSEC:EXPLOITABILITY:MEDIUM"}, + }, + expected: true, + }, + { + name: "level without namespace prefix still parsed", + labels: []model.Label{ + {Description: "Exploitability Level", Value: "low"}, + }, + expected: true, + }, + { + name: "unknown level is not filtered", + labels: []model.Label{ + {Description: "Exploitability Level", Value: "armis_appsec:exploitability:unknown"}, + }, + expected: false, + }, + { + name: "whitespace is tolerated", + labels: []model.Label{ + {Description: " Exploitability Level ", Value: " armis_appsec:exploitability:low "}, + }, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ShouldFilterByExploitability(tt.labels); got != tt.expected { + t.Errorf("ShouldFilterByExploitability() = %v, want %v", got, tt.expected) + } + }) + } +} diff --git a/internal/scan/image/helpers_test.go b/internal/scan/image/helpers_test.go index 31705ddb..d6f4fbd7 100644 --- a/internal/scan/image/helpers_test.go +++ b/internal/scan/image/helpers_test.go @@ -65,83 +65,6 @@ func TestCleanDescription(t *testing.T) { } } -func TestShouldFilterByExploitability(t *testing.T) { - tests := []struct { - name string - labels []model.Label - expected bool - }{ - { - name: "scanner code match and not exploitable", - labels: []model.Label{ - {Description: "scanner code", Value: "38295677"}, - {Description: "exploitable", Value: "false"}, - }, - expected: true, - }, - { - name: "scanner code match and exploitable 0", - labels: []model.Label{ - {Description: "scanner code", Value: "38295677"}, - {Description: "exploitable", Value: "0"}, - }, - expected: true, - }, - { - name: "scanner code match but exploitable", - labels: []model.Label{ - {Description: "scanner code", Value: "38295677"}, - {Description: "exploitable", Value: "true"}, - }, - expected: false, - }, - { - name: "no scanner code match", - labels: []model.Label{ - {Description: "scanner code", Value: "12345"}, - {Description: "exploitable", Value: "false"}, - }, - expected: false, - }, - { - name: "case insensitive", - labels: []model.Label{ - {Description: "Scanner Code", Value: "38295677"}, - {Description: "Exploitable", Value: "FALSE"}, - }, - expected: true, - }, - { - name: "empty labels", - labels: []model.Label{}, - expected: false, - }, - { - name: "only scanner code", - labels: []model.Label{ - {Description: "scanner code", Value: "38295677"}, - }, - expected: false, - }, - { - name: "only exploitable", - labels: []model.Label{ - {Description: "exploitable", Value: "false"}, - }, - expected: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := shouldFilterByExploitability(tt.labels) - if result != tt.expected { - t.Errorf("shouldFilterByExploitability() = %v, want %v", result, tt.expected) - } - }) - } -} - func TestIsEmptyFinding(t *testing.T) { longDesc := "Long description" diff --git a/internal/scan/image/image.go b/internal/scan/image/image.go index 490cf51a..719433cd 100644 --- a/internal/scan/image/image.go +++ b/internal/scan/image/image.go @@ -393,7 +393,7 @@ func convertNormalizedFindings(normalizedFindings []model.NormalizedFinding, deb continue } - if !includeNonExploitable && shouldFilterByExploitability(nf.NormalizedTask.Labels) { + if !includeNonExploitable && scan.ShouldFilterByExploitability(nf.NormalizedTask.Labels) { filteredCount++ continue } @@ -507,25 +507,6 @@ func convertNormalizedFindings(normalizedFindings []model.NormalizedFinding, deb return findings, filteredCount } -func shouldFilterByExploitability(labels []model.Label) bool { - var scannerCodeMatch bool - var exploitableFalse bool - - for _, label := range labels { - desc := strings.ToLower(strings.TrimSpace(label.Description)) - value := strings.ToLower(strings.TrimSpace(label.Value)) - - if desc == "scanner code" && value == "38295677" { - scannerCodeMatch = true - } - if desc == "exploitable" && (value == "false" || value == "0") { - exploitableFalse = true - } - } - - return scannerCodeMatch && exploitableFalse -} - func cleanDescription(desc string) string { lines := strings.Split(desc, "\n") var cleaned []string diff --git a/internal/scan/image/image_test.go b/internal/scan/image/image_test.go index 17e973f0..caf32a96 100644 --- a/internal/scan/image/image_test.go +++ b/internal/scan/image/image_test.go @@ -78,12 +78,12 @@ func TestBuildScanResult(t *testing.T) { } }) - t.Run("tracks filtered non-exploitable count", func(t *testing.T) { + t.Run("tracks filtered low/medium exploitability count", func(t *testing.T) { findings := []model.NormalizedFinding{ testhelpers.CreateNormalizedFinding(testFindingID, "HIGH", "vulnerability", []string{"CVE-2023-1234"}, nil), - testhelpers.CreateNormalizedFindingWithLabels("finding-2", "MEDIUM", "sca", nil, []model.Label{ - {Description: "scanner code", Value: "38295677"}, - {Description: "exploitable", Value: "false"}, + testhelpers.CreateNormalizedFindingWithLabels("finding-2", "MEDIUM", "vulnerability", nil, []model.Label{ + {Description: "Scanner Code", Value: "armis_appsec:scanner_code:38295677"}, + {Description: "Exploitability Level", Value: "armis_appsec:exploitability:low"}, }), } @@ -126,12 +126,12 @@ func TestConvertNormalizedFindings(t *testing.T) { } }) - t.Run("filters non-exploitable findings when flag is false", func(t *testing.T) { + t.Run("filters low exploitability findings when flag is false", func(t *testing.T) { input := []model.NormalizedFinding{ testhelpers.CreateNormalizedFinding(testFindingID, "HIGH", "vulnerability", []string{"CVE-2023-1234"}, nil), - testhelpers.CreateNormalizedFindingWithLabels("finding-2", "MEDIUM", "sca", nil, []model.Label{ - {Description: "scanner code", Value: "38295677"}, - {Description: "exploitable", Value: "false"}, + testhelpers.CreateNormalizedFindingWithLabels("finding-2", "MEDIUM", "vulnerability", nil, []model.Label{ + {Description: "Scanner Code", Value: "armis_appsec:scanner_code:38295677"}, + {Description: "Exploitability Level", Value: "armis_appsec:exploitability:low"}, }), } @@ -145,12 +145,12 @@ func TestConvertNormalizedFindings(t *testing.T) { } }) - t.Run("keeps non-exploitable findings when flag is true", func(t *testing.T) { + t.Run("keeps low exploitability findings when flag is true", func(t *testing.T) { input := []model.NormalizedFinding{ testhelpers.CreateNormalizedFinding(testFindingID, "HIGH", "vulnerability", []string{"CVE-2023-1234"}, nil), - testhelpers.CreateNormalizedFindingWithLabels("finding-2", "MEDIUM", "sca", nil, []model.Label{ - {Description: "scanner code", Value: "38295677"}, - {Description: "exploitable", Value: "false"}, + testhelpers.CreateNormalizedFindingWithLabels("finding-2", "MEDIUM", "vulnerability", nil, []model.Label{ + {Description: "Scanner Code", Value: "armis_appsec:scanner_code:38295677"}, + {Description: "Exploitability Level", Value: "armis_appsec:exploitability:low"}, }), } diff --git a/internal/scan/repo/helpers_test.go b/internal/scan/repo/helpers_test.go index a7bb89fe..3a913c4c 100644 --- a/internal/scan/repo/helpers_test.go +++ b/internal/scan/repo/helpers_test.go @@ -188,69 +188,6 @@ func TestCleanDescription(t *testing.T) { } } -func TestShouldFilterByExploitability(t *testing.T) { - tests := []struct { - name string - labels []model.Label - expected bool - }{ - { - name: "scanner code match and not exploitable", - labels: []model.Label{ - {Description: "scanner code", Value: "38295677"}, - {Description: "exploitable", Value: "false"}, - }, - expected: true, - }, - { - name: "scanner code match and exploitable 0", - labels: []model.Label{ - {Description: "scanner code", Value: "38295677"}, - {Description: "exploitable", Value: "0"}, - }, - expected: true, - }, - { - name: "scanner code match but exploitable", - labels: []model.Label{ - {Description: "scanner code", Value: "38295677"}, - {Description: "exploitable", Value: "true"}, - }, - expected: false, - }, - { - name: "no scanner code match", - labels: []model.Label{ - {Description: "scanner code", Value: "12345"}, - {Description: "exploitable", Value: "false"}, - }, - expected: false, - }, - { - name: "case insensitive", - labels: []model.Label{ - {Description: "Scanner Code", Value: "38295677"}, - {Description: "Exploitable", Value: "FALSE"}, - }, - expected: true, - }, - { - name: "empty labels", - labels: []model.Label{}, - expected: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := shouldFilterByExploitability(tt.labels) - if result != tt.expected { - t.Errorf("shouldFilterByExploitability() = %v, want %v", result, tt.expected) - } - }) - } -} - func TestIsEmptyFinding(t *testing.T) { longDesc := "Long description" diff --git a/internal/scan/repo/repo.go b/internal/scan/repo/repo.go index c143e55a..1246e0c0 100644 --- a/internal/scan/repo/repo.go +++ b/internal/scan/repo/repo.go @@ -674,7 +674,7 @@ func convertNormalizedFindings(normalizedFindings []model.NormalizedFinding, deb continue } - if !includeNonExploitable && shouldFilterByExploitability(nf.NormalizedTask.Labels) { + if !includeNonExploitable && scan.ShouldFilterByExploitability(nf.NormalizedTask.Labels) { filteredCount++ continue } @@ -788,25 +788,6 @@ func convertNormalizedFindings(normalizedFindings []model.NormalizedFinding, deb return findings, filteredCount } -func shouldFilterByExploitability(labels []model.Label) bool { - var scannerCodeMatch bool - var exploitableFalse bool - - for _, label := range labels { - desc := strings.ToLower(strings.TrimSpace(label.Description)) - value := strings.ToLower(strings.TrimSpace(label.Value)) - - if desc == "scanner code" && value == "38295677" { - scannerCodeMatch = true - } - if desc == "exploitable" && (value == "false" || value == "0") { - exploitableFalse = true - } - } - - return scannerCodeMatch && exploitableFalse -} - func cleanDescription(desc string) string { lines := strings.Split(desc, "\n") var cleaned []string diff --git a/internal/scan/repo/repo_test.go b/internal/scan/repo/repo_test.go index c6c6f3db..d1f4aa4b 100644 --- a/internal/scan/repo/repo_test.go +++ b/internal/scan/repo/repo_test.go @@ -81,12 +81,12 @@ func TestBuildScanResult(t *testing.T) { } }) - t.Run("tracks filtered non-exploitable count", func(t *testing.T) { + t.Run("tracks filtered low/medium exploitability count", func(t *testing.T) { findings := []model.NormalizedFinding{ testhelpers.CreateNormalizedFinding("finding-1", "HIGH", "vulnerability", []string{"CVE-2023-1234"}, nil), - testhelpers.CreateNormalizedFindingWithLabels("finding-2", "MEDIUM", "sca", nil, []model.Label{ - {Description: "scanner code", Value: "38295677"}, - {Description: "exploitable", Value: "false"}, + testhelpers.CreateNormalizedFindingWithLabels("finding-2", "MEDIUM", "vulnerability", nil, []model.Label{ + {Description: "Scanner Code", Value: "armis_appsec:scanner_code:38295677"}, + {Description: "Exploitability Level", Value: "armis_appsec:exploitability:low"}, }), } @@ -129,12 +129,12 @@ func TestConvertNormalizedFindings(t *testing.T) { } }) - t.Run("filters non-exploitable findings when flag is false", func(t *testing.T) { + t.Run("filters low exploitability findings when flag is false", func(t *testing.T) { input := []model.NormalizedFinding{ testhelpers.CreateNormalizedFinding("finding-1", "HIGH", "vulnerability", []string{"CVE-2023-1234"}, nil), - testhelpers.CreateNormalizedFindingWithLabels("finding-2", "MEDIUM", "sca", nil, []model.Label{ - {Description: "scanner code", Value: "38295677"}, - {Description: "exploitable", Value: "false"}, + testhelpers.CreateNormalizedFindingWithLabels("finding-2", "MEDIUM", "vulnerability", nil, []model.Label{ + {Description: "Scanner Code", Value: "armis_appsec:scanner_code:38295677"}, + {Description: "Exploitability Level", Value: "armis_appsec:exploitability:low"}, }), } @@ -148,12 +148,12 @@ func TestConvertNormalizedFindings(t *testing.T) { } }) - t.Run("keeps non-exploitable findings when flag is true", func(t *testing.T) { + t.Run("keeps low exploitability findings when flag is true", func(t *testing.T) { input := []model.NormalizedFinding{ testhelpers.CreateNormalizedFinding("finding-1", "HIGH", "vulnerability", []string{"CVE-2023-1234"}, nil), - testhelpers.CreateNormalizedFindingWithLabels("finding-2", "MEDIUM", "sca", nil, []model.Label{ - {Description: "scanner code", Value: "38295677"}, - {Description: "exploitable", Value: "false"}, + testhelpers.CreateNormalizedFindingWithLabels("finding-2", "MEDIUM", "vulnerability", nil, []model.Label{ + {Description: "Scanner Code", Value: "armis_appsec:scanner_code:38295677"}, + {Description: "Exploitability Level", Value: "armis_appsec:exploitability:low"}, }), }