Skip to content
38 changes: 33 additions & 5 deletions adapters/mockplatform.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,59 @@ package adapters
import (
"context"

"github.com/armosec/armoapi-go/scanfailure"
wlidpkg "github.com/armosec/utils-k8s-go/wlid"
"github.com/kubescape/go-logger"
"github.com/kubescape/go-logger/helpers"
v1 "github.com/kubescape/kubevuln/adapters/v1"
"github.com/kubescape/kubevuln/core/domain"
"github.com/kubescape/kubevuln/core/ports"
"github.com/armosec/armoapi-go/scanfailure"
"go.opentelemetry.io/otel"
)

// MockPlatform implements a mocked Platform to be used for tests
type MockPlatform struct {
wantEmptyReport bool
wantEmptyReport bool
securityExceptionRepo ports.SecurityExceptionRepository
}

var _ ports.Platform = (*MockPlatform)(nil)

// NewMockPlatform initializes the MockPlatform struct
func NewMockPlatform(wantEmptyReport bool) *MockPlatform {
func NewMockPlatform(wantEmptyReport bool, seRepo ports.SecurityExceptionRepository) *MockPlatform {
logger.L().Info("keepLocal config is true, statuses and scan reports won't be sent to Armo cloud")
return &MockPlatform{
wantEmptyReport: wantEmptyReport,
wantEmptyReport: wantEmptyReport,
securityExceptionRepo: seRepo,
}
}

// GetCVEExceptions returns an empty CVEExceptions
// GetCVEExceptions returns CRD-based SecurityException policies
func (m MockPlatform) GetCVEExceptions(ctx context.Context) (domain.CVEExceptions, error) {
_, span := otel.Tracer("").Start(ctx, "MockPlatform.GetCVEExceptions")
defer span.End()

if m.securityExceptionRepo == nil {
return domain.CVEExceptions{}, nil
}

workload, ok := ctx.Value(domain.WorkloadKey{}).(domain.ScanCommand)
if !ok {
return domain.CVEExceptions{}, nil
}

namespace := wlidpkg.GetNamespaceFromWlid(workload.Wlid)
seList, cseList, err := m.securityExceptionRepo.GetSecurityExceptions(ctx, namespace)
if err != nil {
logger.L().Ctx(ctx).Warning("failed to get CRD security exceptions", helpers.Error(err))
return domain.CVEExceptions{}, nil
}

if len(seList) > 0 || len(cseList) > 0 {
policies := v1.ConvertToVulnerabilityExceptionPolicies(seList, cseList)
return policies, nil
}

return domain.CVEExceptions{}, nil
}

Expand Down Expand Up @@ -57,3 +84,4 @@ func (m MockPlatform) SubmitCVE(ctx context.Context, _ domain.CVEManifest, _ dom
defer span.End()
return nil
}

6 changes: 3 additions & 3 deletions adapters/mockplatform_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,21 @@ import (
)

func TestMockPlatform_GetCVEExceptions(t *testing.T) {
m := NewMockPlatform(true)
m := NewMockPlatform(true, nil)
_, err := m.GetCVEExceptions(context.Background())
assert.NoError(t, err)
}

func TestMockPlatform_SendStatus(t *testing.T) {
m := NewMockPlatform(true)
m := NewMockPlatform(true, nil)
ctx := context.TODO()
ctx = context.WithValue(ctx, domain.WorkloadKey{}, domain.ScanCommand{})
err := m.SendStatus(ctx, domain.Done)
assert.NoError(t, err)
}

func TestMockPlatform_SubmitCVE(t *testing.T) {
m := NewMockPlatform(true)
m := NewMockPlatform(true, nil)
ctx := context.TODO()
err := m.SubmitCVE(ctx, domain.CVEManifest{}, domain.CVEManifest{})
assert.NoError(t, err)
Expand Down
2 changes: 1 addition & 1 deletion adapters/v1/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ func (a *BackendAdapter) GetCVEExceptions(ctx context.Context) (domain.CVEExcept
if err != nil {
logger.L().Ctx(ctx).Warning("failed to get CRD security exceptions", helpers.Error(err))
} else if len(seList) > 0 || len(cseList) > 0 {
crdPolicies := convertToVulnerabilityExceptionPolicies(seList, cseList)
crdPolicies := ConvertToVulnerabilityExceptionPolicies(seList, cseList)
vulnExceptionList = append(vulnExceptionList, crdPolicies...)
}

Expand Down
43 changes: 41 additions & 2 deletions adapters/v1/securityexception.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@ import (

"github.com/armosec/armoapi-go/armotypes"
"github.com/armosec/armoapi-go/identifiers"
"github.com/kubescape/kubevuln/core/domain"
sev1beta1 "github.com/kubescape/kubevuln/pkg/securityexception/v1beta1"
"github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

// convertToVulnerabilityExceptionPolicies converts SecurityException and
// ConvertToVulnerabilityExceptionPolicies converts SecurityException and
// ClusterSecurityException CRDs into armotypes.VulnerabilityExceptionPolicy
// slices compatible with the existing exception pipeline.
func convertToVulnerabilityExceptionPolicies(exceptions []sev1beta1.SecurityException, clusterExceptions []sev1beta1.ClusterSecurityException) []armotypes.VulnerabilityExceptionPolicy {
func ConvertToVulnerabilityExceptionPolicies(exceptions []sev1beta1.SecurityException, clusterExceptions []sev1beta1.ClusterSecurityException) []armotypes.VulnerabilityExceptionPolicy {
var policies []armotypes.VulnerabilityExceptionPolicy

now := time.Now()
Expand Down Expand Up @@ -72,6 +74,43 @@ func buildPolicy(spec sev1beta1.SecurityExceptionSpec, vuln sev1beta1.Vulnerabil
return p
}

// hasIgnoreAction returns true if any of the matched policies contain the Ignore action.
func hasIgnoreAction(policies []armotypes.VulnerabilityExceptionPolicy) bool {
for _, p := range policies {
for _, a := range p.Actions {
if a == armotypes.Ignore {
return true
}
}
}
return false
}

// ApplySecurityExceptions moves CVEs covered by exception policies from
// doc.Matches to doc.IgnoredMatches with applied ignore rules.
func ApplySecurityExceptions(doc *v1beta1.GrypeDocument, exceptions domain.CVEExceptions) {
if doc == nil || len(exceptions) == 0 {
return
}

var remaining []v1beta1.Match
for _, m := range doc.Matches {
isFixed := m.Vulnerability.Fix.State == "fixed"
matched := getCVEExceptionMatchCVENameFromList(exceptions, m.Vulnerability.ID, isFixed)
if len(matched) > 0 && hasIgnoreAction(matched) {
doc.IgnoredMatches = append(doc.IgnoredMatches, v1beta1.IgnoredMatch{
Match: m,
AppliedIgnoreRules: []v1beta1.IgnoreRule{
{Vulnerability: m.Vulnerability.ID},
},
})
} else {
remaining = append(remaining, m)
}
Comment thread
matthyx marked this conversation as resolved.
}
doc.Matches = remaining
}

func buildDesignators(resources []sev1beta1.ResourceMatch, namespace string) []identifiers.PortalDesignator {
if len(resources) == 0 {
// No specific resource match — create a namespace-only designator
Expand Down
81 changes: 77 additions & 4 deletions adapters/v1/securityexception_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import (
"testing"
"time"

"github.com/armosec/armoapi-go/armotypes"
"github.com/kubescape/kubevuln/core/domain"
sev1beta1 "github.com/kubescape/kubevuln/pkg/securityexception/v1beta1"
"github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1"
"github.com/stretchr/testify/assert"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
Expand Down Expand Up @@ -36,7 +39,7 @@ func TestConvertVulnerabilityExceptions(t *testing.T) {
},
}

policies := convertToVulnerabilityExceptionPolicies(exceptions, clusterExceptions)
policies := ConvertToVulnerabilityExceptionPolicies(exceptions, clusterExceptions)

assert.Len(t, policies, 2)

Expand Down Expand Up @@ -93,7 +96,7 @@ func TestConvertExpiredOnFix(t *testing.T) {
},
}

policies := convertToVulnerabilityExceptionPolicies(exceptions, nil)
policies := ConvertToVulnerabilityExceptionPolicies(exceptions, nil)
assert.Len(t, policies, 1)

if tt.wantNil {
Expand Down Expand Up @@ -142,7 +145,7 @@ func TestConvertSkipsExpired(t *testing.T) {
},
}

policies := convertToVulnerabilityExceptionPolicies(exceptions, clusterExceptions)
policies := ConvertToVulnerabilityExceptionPolicies(exceptions, clusterExceptions)

assert.Len(t, policies, 1)
assert.Equal(t, "CVE-VALID", policies[0].VulnerabilityPolicies[0].Name)
Expand All @@ -166,7 +169,7 @@ func TestConvertMatchResources(t *testing.T) {
},
}

policies := convertToVulnerabilityExceptionPolicies(exceptions, nil)
policies := ConvertToVulnerabilityExceptionPolicies(exceptions, nil)

assert.Len(t, policies, 1)
assert.Len(t, policies[0].Designatores, 2)
Expand All @@ -181,3 +184,73 @@ func TestConvertMatchResources(t *testing.T) {
assert.Equal(t, "StatefulSet", d1.Attributes["kind"])
assert.Equal(t, "my-db", d1.Attributes["name"])
}

func TestApplySecurityExceptions_MovesToIgnored(t *testing.T) {
doc := &v1beta1.GrypeDocument{
Matches: []v1beta1.Match{
{Vulnerability: v1beta1.Vulnerability{VulnerabilityMetadata: v1beta1.VulnerabilityMetadata{ID: "CVE-2021-44228"}}},
{Vulnerability: v1beta1.Vulnerability{VulnerabilityMetadata: v1beta1.VulnerabilityMetadata{ID: "CVE-2023-9999"}}},
},
}

exceptions := domain.CVEExceptions{
{
PolicyType: "vulnerabilityExceptionPolicy",
Actions: []armotypes.VulnerabilityExceptionPolicyActions{armotypes.Ignore},
VulnerabilityPolicies: []armotypes.VulnerabilityPolicy{{Name: "CVE-2021-44228"}},
},
}

ApplySecurityExceptions(doc, exceptions)

assert.Len(t, doc.Matches, 1, "one match should remain")
assert.Equal(t, "CVE-2023-9999", doc.Matches[0].Vulnerability.ID)

assert.Len(t, doc.IgnoredMatches, 1, "one match should be ignored")
assert.Equal(t, "CVE-2021-44228", doc.IgnoredMatches[0].Vulnerability.ID)
assert.Len(t, doc.IgnoredMatches[0].AppliedIgnoreRules, 1)
assert.Equal(t, "CVE-2021-44228", doc.IgnoredMatches[0].AppliedIgnoreRules[0].Vulnerability)
}

func TestApplySecurityExceptions_ExpiredOnFix(t *testing.T) {
expiredOnFix := true
doc := &v1beta1.GrypeDocument{
Matches: []v1beta1.Match{
{Vulnerability: v1beta1.Vulnerability{
VulnerabilityMetadata: v1beta1.VulnerabilityMetadata{ID: "CVE-2021-44228"},
Fix: v1beta1.Fix{State: "fixed", Versions: []string{"2.17.0"}},
}},
},
}

exceptions := domain.CVEExceptions{
{
PolicyType: "vulnerabilityExceptionPolicy",
Actions: []armotypes.VulnerabilityExceptionPolicyActions{armotypes.Ignore},
VulnerabilityPolicies: []armotypes.VulnerabilityPolicy{{Name: "CVE-2021-44228"}},
ExpiredOnFix: &expiredOnFix,
},
}

ApplySecurityExceptions(doc, exceptions)

// Fix available + expiredOnFix = exception skipped, CVE stays in Matches
assert.Len(t, doc.Matches, 1, "CVE with fix should remain in Matches when expiredOnFix is set")
assert.Len(t, doc.IgnoredMatches, 0, "nothing should be ignored when fix is available and expiredOnFix is set")
}
Comment thread
matthyx marked this conversation as resolved.

func TestApplySecurityExceptions_NilDoc(t *testing.T) {
ApplySecurityExceptions(nil, domain.CVEExceptions{})
}

func TestApplySecurityExceptions_NoExceptions(t *testing.T) {
doc := &v1beta1.GrypeDocument{
Matches: []v1beta1.Match{
{Vulnerability: v1beta1.Vulnerability{VulnerabilityMetadata: v1beta1.VulnerabilityMetadata{ID: "CVE-2021-44228"}}},
},
}

ApplySecurityExceptions(doc, nil)

assert.Len(t, doc.Matches, 1, "no filtering when no exceptions")
}
19 changes: 11 additions & 8 deletions cmd/http/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,22 +85,25 @@ func main() {
sbomAdapter = v1.NewSyftAdapter(c.ScanTimeout, c.MaxImageSize, c.MaxSBOMSize, c.ScanEmbeddedSboms)
}
cveAdapter := v1.NewGrypeAdapter(c.ListingURL, c.UseDefaultMatchers)

// SecurityException CRD integration (works in both local and cloud modes)
var seRepo ports.SecurityExceptionRepository
if storage != nil {
seRepo = storage
logger.L().Info("SecurityException CRD integration enabled")
} else {
seRepo = &repositories.NoOpSecurityExceptionRepository{}
}

var platform ports.Platform
if c.KeepLocal {
platform = adapters.NewMockPlatform(true)
platform = adapters.NewMockPlatform(true, seRepo)
} else {
backendServices, err := config.LoadBackendServicesConfig("/etc/config")
if err != nil {
logger.L().Ctx(ctx).Fatal("load services error", helpers.Error(err))
}
logger.L().Info("loaded backend services", helpers.String("ApiServerUrl", backendServices.GetApiServerUrl()), helpers.String("ReportReceiverHttpUrl", backendServices.GetReportReceiverHttpUrl()))
var seRepo ports.SecurityExceptionRepository
if storage != nil {
seRepo = storage
logger.L().Info("SecurityException CRD integration enabled")
} else {
seRepo = &repositories.NoOpSecurityExceptionRepository{}
}
backendAdapter := v1.NewBackendAdapter(credentials.Account, backendServices.GetApiServerUrl(), backendServices.GetReportReceiverHttpUrl(), credentials.AccessKey, seRepo)
platform = backendAdapter
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/http/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ func TestScan(t *testing.T) {
repository := repositories.NewFakeAPIServerStorage("kubescape")
sbomAdapter := adapters.NewMockSBOMAdapter(false, false, false)
cveAdapter := adapters.NewMockCVEAdapter()
platform := adapters.NewMockPlatform(true)
platform := adapters.NewMockPlatform(true, nil)
relevancyProvider := adapters.NewMockRelevancyAdapter()
service := services.NewScanService(sbomAdapter, repository, cveAdapter, repository, platform, relevancyProvider, test.storage, false, true, false, false)
controller := controllers.NewHTTPController(service, 2)
Expand Down
Loading
Loading