diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 00000000..f645bcd1 --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +golang 1.25.6 diff --git a/adapters/v1/backend.go b/adapters/v1/backend.go index dc3fc4cf..1a4888a8 100644 --- a/adapters/v1/backend.go +++ b/adapters/v1/backend.go @@ -16,6 +16,7 @@ import ( cs "github.com/armosec/armoapi-go/containerscan" v1 "github.com/armosec/armoapi-go/containerscan/v1" "github.com/armosec/armoapi-go/identifiers" + "github.com/armosec/armoapi-go/scanfailure" "github.com/armosec/utils-go/httputils" pkgcautils "github.com/armosec/utils-k8s-go/armometadata" wlidpkg "github.com/armosec/utils-k8s-go/wlid" @@ -27,35 +28,36 @@ import ( "github.com/kubescape/k8s-interface/k8sinterface" "github.com/kubescape/kubevuln/core/domain" "github.com/kubescape/kubevuln/core/ports" - "github.com/armosec/armoapi-go/scanfailure" "go.opentelemetry.io/otel" ) type BackendAdapter struct { - eventReceiverRestURL string - apiServerRestURL string - clusterConfig pkgcautils.ClusterConfig - getCVEExceptionsFunc func(string, string, *identifiers.PortalDesignator, map[string]string) ([]armotypes.VulnerabilityExceptionPolicy, error) - httpPostFunc func(httputils.IHttpClient, string, map[string]string, []byte, time.Duration) (*http.Response, error) - sendStatusFunc func(*backendClientV1.BaseReportSender, string, bool) - accessKey string + eventReceiverRestURL string + apiServerRestURL string + clusterConfig pkgcautils.ClusterConfig + getCVEExceptionsFunc func(string, string, *identifiers.PortalDesignator, map[string]string) ([]armotypes.VulnerabilityExceptionPolicy, error) + httpPostFunc func(httputils.IHttpClient, string, map[string]string, []byte, time.Duration) (*http.Response, error) + sendStatusFunc func(*backendClientV1.BaseReportSender, string, bool) + accessKey string + securityExceptionRepo ports.SecurityExceptionRepository } var _ ports.Platform = (*BackendAdapter)(nil) -func NewBackendAdapter(accountID, apiServerRestURL, eventReceiverRestURL, accessKey string) *BackendAdapter { +func NewBackendAdapter(accountID, apiServerRestURL, eventReceiverRestURL, accessKey string, seRepo ports.SecurityExceptionRepository) *BackendAdapter { return &BackendAdapter{ clusterConfig: pkgcautils.ClusterConfig{ AccountID: accountID, }, - eventReceiverRestURL: eventReceiverRestURL, - apiServerRestURL: apiServerRestURL, - getCVEExceptionsFunc: backendClientV1.GetCVEExceptionByDesignator, - httpPostFunc: httputils.HttpPostWithRetry, + eventReceiverRestURL: eventReceiverRestURL, + apiServerRestURL: apiServerRestURL, + getCVEExceptionsFunc: backendClientV1.GetCVEExceptionByDesignator, + httpPostFunc: httputils.HttpPostWithRetry, sendStatusFunc: func(sender *backendClientV1.BaseReportSender, status string, sendReport bool) { sender.SendStatus(status, sendReport) // TODO - update this function to use from kubescape/backend }, - accessKey: accessKey, + accessKey: accessKey, + securityExceptionRepo: seRepo, } } @@ -102,6 +104,17 @@ func (a *BackendAdapter) GetCVEExceptions(ctx context.Context) (domain.CVEExcept if err != nil { return nil, err } + + // Merge CRD-based exceptions + namespace := wlidpkg.GetNamespaceFromWlid(workload.Wlid) + seList, cseList, err := a.securityExceptionRepo.GetSecurityExceptions(ctx, namespace) + 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) + vulnExceptionList = append(vulnExceptionList, crdPolicies...) + } + return vulnExceptionList, nil } diff --git a/adapters/v1/backend_test.go b/adapters/v1/backend_test.go index 4337dbe5..61b25918 100644 --- a/adapters/v1/backend_test.go +++ b/adapters/v1/backend_test.go @@ -26,8 +26,11 @@ import ( beClientV1 "github.com/kubescape/backend/pkg/client/v1" sysreport "github.com/kubescape/backend/pkg/server/v1/systemreports" "github.com/kubescape/kubevuln/core/domain" + sev1beta1 "github.com/kubescape/kubevuln/pkg/securityexception/v1beta1" + "github.com/kubescape/kubevuln/repositories" "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) func TestBackendAdapter_GetCVEExceptions(t *testing.T) { @@ -71,8 +74,9 @@ func TestBackendAdapter_GetCVEExceptions(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { a := &BackendAdapter{ - clusterConfig: tt.fields.clusterConfig, - getCVEExceptionsFunc: tt.fields.getCVEExceptionsFunc, + clusterConfig: tt.fields.clusterConfig, + getCVEExceptionsFunc: tt.fields.getCVEExceptionsFunc, + securityExceptionRepo: &repositories.NoOpSecurityExceptionRepository{}, } ctx := context.TODO() if tt.workload { @@ -190,7 +194,8 @@ func TestBackendAdapter_SubmitCVE(t *testing.T) { getCVEExceptionsFunc: func(s, a string, designator *identifiers.PortalDesignator, headers map[string]string) ([]armotypes.VulnerabilityExceptionPolicy, error) { return tt.exceptions, nil }, - httpPostFunc: httpPostFunc, + httpPostFunc: httpPostFunc, + securityExceptionRepo: &repositories.NoOpSecurityExceptionRepository{}, } ctx := context.TODO() ctx = context.WithValue(ctx, domain.TimestampKey{}, time.Now().Unix()) @@ -270,10 +275,11 @@ func TestNewBackendAdapter(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := NewBackendAdapter(tt.args.accountID, tt.args.apiServerRestURL, tt.args.eventReceiverRestURL, "") + got := NewBackendAdapter(tt.args.accountID, tt.args.apiServerRestURL, tt.args.eventReceiverRestURL, "", &repositories.NoOpSecurityExceptionRepository{}) // need to nil functions to compare got.httpPostFunc = nil got.getCVEExceptionsFunc = nil + got.securityExceptionRepo = nil assert.NotEqual(t, got, tt.want) }) } @@ -494,3 +500,53 @@ func TestBackendAdapter_ReportScanFailure_NilError(t *testing.T) { assert.Equal(t, scanfailure.ReasonSBOMIncomplete, capturedReport.FailureReason) assert.Empty(t, capturedReport.Error, "Error field should be empty when scanErr is nil") } + +type mockSecurityExceptionRepo struct { + exceptions []sev1beta1.SecurityException + clusterExceptions []sev1beta1.ClusterSecurityException + err error +} + +func (m *mockSecurityExceptionRepo) GetSecurityExceptions(_ context.Context, _ string) ([]sev1beta1.SecurityException, []sev1beta1.ClusterSecurityException, error) { + return m.exceptions, m.clusterExceptions, m.err +} + +func TestGetCVEExceptions_MergesCRDExceptions(t *testing.T) { + cloudPolicies := []armotypes.VulnerabilityExceptionPolicy{ + { + PolicyType: "vulnerabilityExceptionPolicy", + VulnerabilityPolicies: []armotypes.VulnerabilityPolicy{{Name: "CVE-CLOUD-1"}}, + }, + } + + mockRepo := &mockSecurityExceptionRepo{ + exceptions: []sev1beta1.SecurityException{ + { + ObjectMeta: metav1.ObjectMeta{Namespace: "default"}, + Spec: sev1beta1.SecurityExceptionSpec{ + Vulnerabilities: []sev1beta1.VulnerabilityException{ + {Vulnerability: sev1beta1.VulnerabilityRef{ID: "CVE-CRD-1"}}, + }, + }, + }, + }, + } + + a := &BackendAdapter{ + clusterConfig: armometadata.ClusterConfig{AccountID: "test-account"}, + getCVEExceptionsFunc: func(string, string, *identifiers.PortalDesignator, map[string]string) ([]armotypes.VulnerabilityExceptionPolicy, error) { + return cloudPolicies, nil + }, + securityExceptionRepo: mockRepo, + } + + ctx := context.WithValue(context.Background(), domain.WorkloadKey{}, domain.ScanCommand{ + Wlid: "wlid://cluster-test/namespace-default/deployment-myapp", + }) + + exceptions, err := a.GetCVEExceptions(ctx) + require.NoError(t, err) + assert.Len(t, exceptions, 2, "should merge cloud + CRD exceptions") + assert.Equal(t, "CVE-CLOUD-1", exceptions[0].VulnerabilityPolicies[0].Name) + assert.Equal(t, "CVE-CRD-1", exceptions[1].VulnerabilityPolicies[0].Name) +} diff --git a/adapters/v1/securityexception.go b/adapters/v1/securityexception.go new file mode 100644 index 00000000..d0903583 --- /dev/null +++ b/adapters/v1/securityexception.go @@ -0,0 +1,112 @@ +package v1 + +import ( + "time" + + "github.com/armosec/armoapi-go/armotypes" + "github.com/armosec/armoapi-go/identifiers" + sev1beta1 "github.com/kubescape/kubevuln/pkg/securityexception/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// 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 { + var policies []armotypes.VulnerabilityExceptionPolicy + + now := time.Now() + + for i := range exceptions { + se := &exceptions[i] + if isExpired(se.Spec.ExpiresAt, now) { + continue + } + namespace := se.Namespace + for _, vuln := range se.Spec.Vulnerabilities { + p := buildPolicy(se.Spec, vuln, namespace) + policies = append(policies, p) + } + } + + for i := range clusterExceptions { + cse := &clusterExceptions[i] + if isExpired(cse.Spec.ExpiresAt, now) { + continue + } + for _, vuln := range cse.Spec.Vulnerabilities { + p := buildPolicy(cse.Spec, vuln, "") + policies = append(policies, p) + } + } + + return policies +} + +func isExpired(expiresAt *metav1.Time, now time.Time) bool { + return expiresAt != nil && expiresAt.Time.Before(now) +} + +func buildPolicy(spec sev1beta1.SecurityExceptionSpec, vuln sev1beta1.VulnerabilityException, namespace string) armotypes.VulnerabilityExceptionPolicy { + p := armotypes.VulnerabilityExceptionPolicy{ + PolicyType: "vulnerabilityExceptionPolicy", + Actions: []armotypes.VulnerabilityExceptionPolicyActions{armotypes.Ignore}, + VulnerabilityPolicies: []armotypes.VulnerabilityPolicy{ + {Name: vuln.Vulnerability.ID}, + }, + Reason: spec.Reason, + } + + if spec.ExpiresAt != nil { + t := spec.ExpiresAt.Time + p.ExpirationDate = &t + } + + if vuln.ExpiredOnFix { + b := true + p.ExpiredOnFix = &b + } + + p.Designatores = buildDesignators(spec.Match.Resources, namespace) + + return p +} + +func buildDesignators(resources []sev1beta1.ResourceMatch, namespace string) []identifiers.PortalDesignator { + if len(resources) == 0 { + // No specific resource match — create a namespace-only designator + if namespace != "" { + return []identifiers.PortalDesignator{ + { + DesignatorType: identifiers.DesignatorAttributes, + Attributes: map[string]string{ + "namespace": namespace, + }, + }, + } + } + return nil + } + + designators := make([]identifiers.PortalDesignator, 0, len(resources)) + for _, r := range resources { + attrs := map[string]string{} + if namespace != "" { + attrs["namespace"] = namespace + } + if r.Kind != "" { + attrs["kind"] = r.Kind + } + if r.Name != "" { + attrs["name"] = r.Name + } + if len(attrs) == 0 { + continue + } + designators = append(designators, identifiers.PortalDesignator{ + DesignatorType: identifiers.DesignatorAttributes, + Attributes: attrs, + }) + } + return designators +} diff --git a/adapters/v1/securityexception_test.go b/adapters/v1/securityexception_test.go new file mode 100644 index 00000000..755f4722 --- /dev/null +++ b/adapters/v1/securityexception_test.go @@ -0,0 +1,183 @@ +package v1 + +import ( + "testing" + "time" + + sev1beta1 "github.com/kubescape/kubevuln/pkg/securityexception/v1beta1" + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestConvertVulnerabilityExceptions(t *testing.T) { + exceptions := []sev1beta1.SecurityException{ + { + ObjectMeta: metav1.ObjectMeta{Namespace: "default"}, + Spec: sev1beta1.SecurityExceptionSpec{ + Reason: "accepted risk", + Vulnerabilities: []sev1beta1.VulnerabilityException{ + { + Vulnerability: sev1beta1.VulnerabilityRef{ID: "CVE-2021-44228"}, + }, + }, + }, + }, + } + clusterExceptions := []sev1beta1.ClusterSecurityException{ + { + Spec: sev1beta1.SecurityExceptionSpec{ + Reason: "cluster-wide", + Vulnerabilities: []sev1beta1.VulnerabilityException{ + { + Vulnerability: sev1beta1.VulnerabilityRef{ID: "CVE-2022-12345"}, + }, + }, + }, + }, + } + + policies := convertToVulnerabilityExceptionPolicies(exceptions, clusterExceptions) + + assert.Len(t, policies, 2) + + // Namespaced exception + assert.Equal(t, "vulnerabilityExceptionPolicy", policies[0].PolicyType) + assert.Equal(t, "CVE-2021-44228", policies[0].VulnerabilityPolicies[0].Name) + assert.Equal(t, "accepted risk", policies[0].Reason) + assert.Len(t, policies[0].Actions, 1) + assert.Equal(t, "ignore", string(policies[0].Actions[0])) + // Should have namespace-only designator + assert.Len(t, policies[0].Designatores, 1) + assert.Equal(t, "default", policies[0].Designatores[0].Attributes["namespace"]) + + // Cluster exception + assert.Equal(t, "CVE-2022-12345", policies[1].VulnerabilityPolicies[0].Name) + assert.Equal(t, "cluster-wide", policies[1].Reason) + // No namespace, no resources => nil designators + assert.Nil(t, policies[1].Designatores) +} + +func TestConvertExpiredOnFix(t *testing.T) { + tests := []struct { + name string + expiredOnFix bool + wantNil bool + wantValue bool + }{ + { + name: "true sets pointer to true", + expiredOnFix: true, + wantNil: false, + wantValue: true, + }, + { + name: "false leaves pointer nil", + expiredOnFix: false, + wantNil: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + exceptions := []sev1beta1.SecurityException{ + { + ObjectMeta: metav1.ObjectMeta{Namespace: "ns"}, + Spec: sev1beta1.SecurityExceptionSpec{ + Vulnerabilities: []sev1beta1.VulnerabilityException{ + { + Vulnerability: sev1beta1.VulnerabilityRef{ID: "CVE-2023-0001"}, + ExpiredOnFix: tt.expiredOnFix, + }, + }, + }, + }, + } + + policies := convertToVulnerabilityExceptionPolicies(exceptions, nil) + assert.Len(t, policies, 1) + + if tt.wantNil { + assert.Nil(t, policies[0].ExpiredOnFix) + } else { + assert.NotNil(t, policies[0].ExpiredOnFix) + assert.Equal(t, tt.wantValue, *policies[0].ExpiredOnFix) + } + }) + } +} + +func TestConvertSkipsExpired(t *testing.T) { + past := metav1.NewTime(time.Now().Add(-1 * time.Hour)) + future := metav1.NewTime(time.Now().Add(1 * time.Hour)) + + exceptions := []sev1beta1.SecurityException{ + { + ObjectMeta: metav1.ObjectMeta{Namespace: "ns"}, + Spec: sev1beta1.SecurityExceptionSpec{ + ExpiresAt: &past, + Vulnerabilities: []sev1beta1.VulnerabilityException{ + {Vulnerability: sev1beta1.VulnerabilityRef{ID: "CVE-EXPIRED"}}, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Namespace: "ns"}, + Spec: sev1beta1.SecurityExceptionSpec{ + ExpiresAt: &future, + Vulnerabilities: []sev1beta1.VulnerabilityException{ + {Vulnerability: sev1beta1.VulnerabilityRef{ID: "CVE-VALID"}}, + }, + }, + }, + } + + clusterExceptions := []sev1beta1.ClusterSecurityException{ + { + Spec: sev1beta1.SecurityExceptionSpec{ + ExpiresAt: &past, + Vulnerabilities: []sev1beta1.VulnerabilityException{ + {Vulnerability: sev1beta1.VulnerabilityRef{ID: "CVE-CLUSTER-EXPIRED"}}, + }, + }, + }, + } + + policies := convertToVulnerabilityExceptionPolicies(exceptions, clusterExceptions) + + assert.Len(t, policies, 1) + assert.Equal(t, "CVE-VALID", policies[0].VulnerabilityPolicies[0].Name) +} + +func TestConvertMatchResources(t *testing.T) { + exceptions := []sev1beta1.SecurityException{ + { + ObjectMeta: metav1.ObjectMeta{Namespace: "production"}, + Spec: sev1beta1.SecurityExceptionSpec{ + Match: sev1beta1.ExceptionMatch{ + Resources: []sev1beta1.ResourceMatch{ + {Kind: "Deployment", Name: "my-app"}, + {Kind: "StatefulSet", Name: "my-db"}, + }, + }, + Vulnerabilities: []sev1beta1.VulnerabilityException{ + {Vulnerability: sev1beta1.VulnerabilityRef{ID: "CVE-2023-9999"}}, + }, + }, + }, + } + + policies := convertToVulnerabilityExceptionPolicies(exceptions, nil) + + assert.Len(t, policies, 1) + assert.Len(t, policies[0].Designatores, 2) + + d0 := policies[0].Designatores[0] + assert.Equal(t, "production", d0.Attributes["namespace"]) + assert.Equal(t, "Deployment", d0.Attributes["kind"]) + assert.Equal(t, "my-app", d0.Attributes["name"]) + + d1 := policies[0].Designatores[1] + assert.Equal(t, "production", d1.Attributes["namespace"]) + assert.Equal(t, "StatefulSet", d1.Attributes["kind"]) + assert.Equal(t, "my-db", d1.Attributes["name"]) +} diff --git a/cmd/http/main.go b/cmd/http/main.go index 08b19aef..a2ef72ff 100644 --- a/cmd/http/main.go +++ b/cmd/http/main.go @@ -94,7 +94,15 @@ func main() { 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())) - platform = v1.NewBackendAdapter(credentials.Account, backendServices.GetApiServerUrl(), backendServices.GetReportReceiverHttpUrl(), credentials.AccessKey) + 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 } relevancyProvider := v1.NewContainerProfileAdapter(storage) service := services.NewScanService(sbomAdapter, storage, cveAdapter, storage, platform, relevancyProvider, c.Storage, c.VexGeneration, !c.NodeSbomGeneration, c.StoreFilteredSbom, c.PartialRelevancy) diff --git a/core/ports/repositories.go b/core/ports/repositories.go index 5fc72265..d06b597e 100644 --- a/core/ports/repositories.go +++ b/core/ports/repositories.go @@ -4,6 +4,7 @@ import ( "context" "github.com/kubescape/kubevuln/core/domain" + sev1beta1 "github.com/kubescape/kubevuln/pkg/securityexception/v1beta1" "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" ) @@ -25,3 +26,8 @@ type SBOMRepository interface { GetSBOM(ctx context.Context, name, SBOMCreatorVersion string) (domain.SBOM, error) StoreSBOM(ctx context.Context, sbom domain.SBOM, isFiltered bool) error } + +// SecurityExceptionRepository reads SecurityException CRDs from the cluster +type SecurityExceptionRepository interface { + GetSecurityExceptions(ctx context.Context, namespace string) ([]sev1beta1.SecurityException, []sev1beta1.ClusterSecurityException, error) +} diff --git a/pkg/securityexception/v1beta1/types.go b/pkg/securityexception/v1beta1/types.go new file mode 100644 index 00000000..6b717e6a --- /dev/null +++ b/pkg/securityexception/v1beta1/types.go @@ -0,0 +1,101 @@ +package v1beta1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// SecurityException defines a namespaced exception for vulnerability and posture findings. +type SecurityException struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec SecurityExceptionSpec `json:"spec,omitempty"` +} + +// SecurityExceptionList is a list of SecurityException resources. +type SecurityExceptionList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + + Items []SecurityException `json:"items"` +} + +// ClusterSecurityException defines a cluster-scoped exception for vulnerability and posture findings. +type ClusterSecurityException struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec SecurityExceptionSpec `json:"spec,omitempty"` +} + +// ClusterSecurityExceptionList is a list of ClusterSecurityException resources. +type ClusterSecurityExceptionList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + + Items []ClusterSecurityException `json:"items"` +} + +// VulnerabilityStatus is the VEX status of a vulnerability exception. +type VulnerabilityStatus string + +const ( + VulnerabilityStatusNotAffected VulnerabilityStatus = "not_affected" + VulnerabilityStatusFixed VulnerabilityStatus = "fixed" + VulnerabilityStatusUnderInvestigation VulnerabilityStatus = "under_investigation" +) + +// PostureAction is the action to take for a posture exception. +type PostureAction string + +const ( + PostureActionIgnore PostureAction = "ignore" + PostureActionAlertOnly PostureAction = "alert_only" +) + +// SecurityExceptionSpec defines the desired state of a SecurityException. +type SecurityExceptionSpec struct { + Author string `json:"author,omitempty"` + Reason string `json:"reason,omitempty"` + ExpiresAt *metav1.Time `json:"expiresAt,omitempty"` + Match ExceptionMatch `json:"match,omitempty"` + Vulnerabilities []VulnerabilityException `json:"vulnerabilities,omitempty"` + Posture []PostureException `json:"posture,omitempty"` +} + +// ExceptionMatch defines which workloads the exception applies to. +type ExceptionMatch struct { + NamespaceSelector *metav1.LabelSelector `json:"namespaceSelector,omitempty"` + ObjectSelector *metav1.LabelSelector `json:"objectSelector,omitempty"` + Resources []ResourceMatch `json:"resources,omitempty"` + Images []string `json:"images,omitempty"` +} + +// ResourceMatch identifies a workload by kind and optional name. +type ResourceMatch struct { + APIGroup string `json:"apiGroup,omitempty"` + Kind string `json:"kind"` + Name string `json:"name,omitempty"` +} + +// VulnerabilityException defines an exception for a specific CVE. +type VulnerabilityException struct { + Vulnerability VulnerabilityRef `json:"vulnerability"` + Status VulnerabilityStatus `json:"status"` + Justification string `json:"justification,omitempty"` + ImpactStatement string `json:"impactStatement,omitempty"` + ExpiredOnFix bool `json:"expiredOnFix,omitempty"` +} + +// VulnerabilityRef identifies a vulnerability by CVE ID. +type VulnerabilityRef struct { + ID string `json:"id"` + Aliases []string `json:"aliases,omitempty"` +} + +// PostureException defines an exception for a posture control. +type PostureException struct { + ControlID string `json:"controlID"` + FrameworkName string `json:"frameworkName,omitempty"` + Action PostureAction `json:"action"` +} diff --git a/repositories/apiserver.go b/repositories/apiserver.go index 170b2cb0..a2ff54df 100644 --- a/repositories/apiserver.go +++ b/repositories/apiserver.go @@ -17,6 +17,7 @@ import ( "github.com/kubescape/k8s-interface/k8sinterface" "github.com/kubescape/kubevuln/core/domain" "github.com/kubescape/kubevuln/core/ports" + sev1beta1 "github.com/kubescape/kubevuln/pkg/securityexception/v1beta1" "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" "github.com/kubescape/storage/pkg/generated/clientset/versioned" "github.com/kubescape/storage/pkg/generated/clientset/versioned/fake" @@ -26,6 +27,10 @@ import ( "golang.org/x/mod/semver" "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + fakedynamic "k8s.io/client-go/dynamic/fake" "k8s.io/client-go/util/retry" ) @@ -37,15 +42,31 @@ const ( // APIServerStore implements both CVERepository and SBOMRepository with in-cluster storage (apiserver) to be used for production type APIServerStore struct { StorageClient spdxv1beta1.SpdxV1beta1Interface + DynamicClient dynamic.Interface Namespace string } +var ( + securityExceptionGVR = schema.GroupVersionResource{ + Group: "kubescape.io", + Version: "v1beta1", + Resource: "securityexceptions", + } + clusterSecurityExceptionGVR = schema.GroupVersionResource{ + Group: "kubescape.io", + Version: "v1beta1", + Resource: "clustersecurityexceptions", + } +) + var _ ports.ContainerProfileRepository = (*APIServerStore)(nil) var _ ports.CVERepository = (*APIServerStore)(nil) var _ ports.SBOMRepository = (*APIServerStore)(nil) +var _ ports.SecurityExceptionRepository = (*APIServerStore)(nil) + // NewAPIServerStorage initializes the APIServerStore struct func NewAPIServerStorage(namespace string) (*APIServerStore, error) { @@ -53,15 +74,22 @@ func NewAPIServerStorage(namespace string) (*APIServerStore, error) { if config == nil { return nil, fmt.Errorf("failed to get k8s config") } - // force GRPC - config.AcceptContentTypes = "application/vnd.kubernetes.protobuf" - config.ContentType = "application/vnd.kubernetes.protobuf" - clientset, err := versioned.NewForConfig(config) + // Typed client for storage API (uses protobuf) + protoConfig := *config + protoConfig.AcceptContentTypes = "application/vnd.kubernetes.protobuf" + protoConfig.ContentType = "application/vnd.kubernetes.protobuf" + clientset, err := versioned.NewForConfig(&protoConfig) + if err != nil { + return nil, err + } + // Dynamic client for CRDs (uses JSON) + dynClient, err := dynamic.NewForConfig(config) if err != nil { return nil, err } return &APIServerStore{ StorageClient: clientset.SpdxV1beta1(), + DynamicClient: dynClient, Namespace: namespace, }, nil } @@ -69,10 +97,50 @@ func NewAPIServerStorage(namespace string) (*APIServerStore, error) { func NewFakeAPIServerStorage(namespace string) *APIServerStore { return &APIServerStore{ StorageClient: fake.NewSimpleClientset().SpdxV1beta1(), + DynamicClient: fakedynamic.NewSimpleDynamicClient(runtime.NewScheme()), Namespace: namespace, } } +func (a *APIServerStore) GetSecurityExceptions(ctx context.Context, namespace string) ([]sev1beta1.SecurityException, []sev1beta1.ClusterSecurityException, error) { + var exceptions []sev1beta1.SecurityException + + // Only list namespaced exceptions when namespace is provided + if namespace != "" { + seList, err := a.DynamicClient.Resource(securityExceptionGVR).Namespace(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + logger.L().Ctx(ctx).Warning("failed to list SecurityExceptions", helpers.Error(err), helpers.String("namespace", namespace)) + } else { + for i := range seList.Items { + var se sev1beta1.SecurityException + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(seList.Items[i].Object, &se); err != nil { + logger.L().Ctx(ctx).Warning("failed to convert SecurityException", helpers.Error(err)) + continue + } + exceptions = append(exceptions, se) + } + } + } + + // List cluster-scoped exceptions + var clusterExceptions []sev1beta1.ClusterSecurityException + cseList, err := a.DynamicClient.Resource(clusterSecurityExceptionGVR).List(ctx, metav1.ListOptions{}) + if err != nil { + logger.L().Ctx(ctx).Warning("failed to list ClusterSecurityExceptions", helpers.Error(err)) + } else { + for i := range cseList.Items { + var cse sev1beta1.ClusterSecurityException + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(cseList.Items[i].Object, &cse); err != nil { + logger.L().Ctx(ctx).Warning("failed to convert ClusterSecurityException", helpers.Error(err)) + continue + } + clusterExceptions = append(clusterExceptions, cse) + } + } + + return exceptions, clusterExceptions, nil +} + func (a *APIServerStore) GetContainerProfile(ctx context.Context, namespace string, name string) (v1beta1.ContainerProfile, error) { _, span := otel.Tracer("").Start(ctx, "APIServerStore.GetContainerProfile") defer span.End() diff --git a/repositories/noop_security_exception.go b/repositories/noop_security_exception.go new file mode 100644 index 00000000..231100ec --- /dev/null +++ b/repositories/noop_security_exception.go @@ -0,0 +1,15 @@ +package repositories + +import ( + "context" + + "github.com/kubescape/kubevuln/pkg/securityexception/v1beta1" +) + +// NoOpSecurityExceptionRepository returns empty results for environments +// where SecurityException CRDs are not available (e.g., local/test mode). +type NoOpSecurityExceptionRepository struct{} + +func (n *NoOpSecurityExceptionRepository) GetSecurityExceptions(_ context.Context, _ string) ([]v1beta1.SecurityException, []v1beta1.ClusterSecurityException, error) { + return nil, nil, nil +}