Skip to content
Merged
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
2 changes: 2 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

---
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/scan.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion internal/output/human.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}

Expand Down
68 changes: 68 additions & 0 deletions internal/scan/exploitability.go
Original file line number Diff line number Diff line change
@@ -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:<level>" (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)
}
100 changes: 100 additions & 0 deletions internal/scan/exploitability_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
77 changes: 0 additions & 77 deletions internal/scan/image/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
21 changes: 1 addition & 20 deletions internal/scan/image/image.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down
24 changes: 12 additions & 12 deletions internal/scan/image/image_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
}),
}

Expand Down Expand Up @@ -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"},
}),
}

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

Expand Down
Loading
Loading