From 23c3caf49ce2dffe7a8b37e958a2230e2ae3b1c2 Mon Sep 17 00:00:00 2001 From: "Harsh K. Singh" Date: Thu, 8 Oct 2026 04:32:50 +0530 Subject: [PATCH 1/3] Apply control exclusions to operator scans Combine installation settings with request exclusions at the shared request builder. Store only request settings in recurring jobs so later configuration changes take effect when those jobs run. Signed-off-by: Harsh K. Singh --- README.md | 24 +++++ config/config.go | 23 +++++ config/control_exclusions_test.go | 50 ++++++++++ mainhandler/agent_runtime_requests_test.go | 15 ++- mainhandler/control_exclusions_test.go | 109 +++++++++++++++++++++ mainhandler/kubescapehandler.go | 2 +- mainhandler/kubescapehandlerhelper.go | 24 ++++- mainhandler/kubescapehandlerhelper_test.go | 18 ++-- 8 files changed, 247 insertions(+), 18 deletions(-) create mode 100644 config/control_exclusions_test.go create mode 100644 mainhandler/control_exclusions_test.go diff --git a/README.md b/README.md index 34dde705..957d0079 100644 --- a/README.md +++ b/README.md @@ -312,9 +312,33 @@ The Operator reads configuration from `/etc/config/`. When running locally, set | `includeNamespaces` | `""` | Comma-separated string or array of exact namespaces to include | | `includeNamespacesRegex` | `""` | Comma-separated string or array of RE2 regex patterns for namespaces to include | | `excludeNamespaces` | `"kube-system,kubescape"` | Comma-separated string or array of exact namespaces to exclude | +| `excludeControls` | `[]` | Control IDs or legacy CIS section numbers omitted from operator posture scans | | `excludeNamespacesRegex` | `""` | Comma-separated string or array of RE2 regex patterns for namespaces to exclude | | `namespaceFilterConfigMapName` | `""` | Optional ConfigMap name in the operator namespace for live namespace filtering | +### Exclude controls from posture scans + +Set `excludeControls` in the operator's `config.json` to a list of control IDs +(or legacy CIS section numbers): + +```json +{"excludeControls": ["C-0069", "C-0070"]} +``` + +The operator forwards these settings for explicit, scheduled, and continuous +scans, including resource deletion scans. Individual `scanV1` requests can add +`excludeControls`; the installation and request lists are combined. An empty +request list does not clear installation settings. Whitespace is trimmed and +duplicates are removed without regard to case, including scheduled replays. +Stored jobs retain only request-specific exclusions; installation settings are +applied when they run. Restart the operator after changing its config file. +Blank identifiers and values other than a list of strings are rejected. + +A scanner supporting this field applies the existing `--exclude-controls` +filter before evaluating controls and computing compliance scores. Excluding a +control narrows the assessment scope; it does not assert that the check passed. +Omitting the installation setting leaves existing behavior unchanged. + ### Namespace Filtering (Exact & Regex) The Operator supports filtering namespaces using exact names (`includeNamespaces`, `excludeNamespaces`) and RE2 regular expressions (`includeNamespacesRegex`, `excludeNamespacesRegex`): diff --git a/config/config.go b/config/config.go index 0b46cc82..2feeb388 100644 --- a/config/config.go +++ b/config/config.go @@ -1,6 +1,7 @@ package config import ( + "encoding/json" "fmt" "os" "regexp" @@ -131,6 +132,7 @@ func LoadCapabilitiesConfig(path string) (CapabilitiesConfig, error) { } type Config struct { + ExcludeControls []string `mapstructure:"excludeControls"` NamespaceFilterConfigMapName string `mapstructure:"namespaceFilterConfigMapName"` Namespace string `mapstructure:"namespace"` RestAPIPort string `mapstructure:"port"` @@ -183,6 +185,7 @@ type IConfig interface { // DefaultFrameworks returns install-time posture frameworks from clusterData. // Empty means callers should keep their legacy fallback (e.g. "all" or the native trio). DefaultFrameworks() []string + ExcludeControls() []string } // OperatorConfig implements IConfig @@ -303,6 +306,10 @@ func (c *OperatorConfig) ClusterName() string { return c.clusterConfig.ClusterName } +func (c *OperatorConfig) ExcludeControls() []string { + return slices.Clone(c.serviceConfig.ExcludeControls) +} + func (c *OperatorConfig) DefaultFrameworks() []string { out := make([]string, 0, len(c.clusterConfig.DefaultFrameworks)) for _, f := range c.clusterConfig.DefaultFrameworks { @@ -436,6 +443,22 @@ func LoadConfig(path string) (Config, error) { return Config{}, err } + // Viper weakly converts scalars and numbers; decode this list strictly. + if raw := viper.Get("excludeControls"); raw != nil { + data, err := json.Marshal(raw) + if err != nil { + return Config{}, fmt.Errorf("invalid excludeControls: %w", err) + } + if err := json.Unmarshal(data, &c.ExcludeControls); err != nil { + return Config{}, fmt.Errorf("invalid excludeControls: %w", err) + } + } + for _, control := range c.ExcludeControls { + if strings.TrimSpace(control) == "" { + return Config{}, fmt.Errorf("excludeControls contains an empty control identifier") + } + } + if _, err := compileRegexes(c.IncludeNamespacesRegex); err != nil { return Config{}, fmt.Errorf("invalid includeNamespacesRegex: %w", err) } diff --git a/config/control_exclusions_test.go b/config/control_exclusions_test.go new file mode 100644 index 00000000..59944a6a --- /dev/null +++ b/config/control_exclusions_test.go @@ -0,0 +1,50 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + + utilsmetadata "github.com/armosec/utils-k8s-go/armometadata" + "github.com/spf13/viper" + "github.com/stretchr/testify/require" +) + +func TestLoadControlExclusions(t *testing.T) { + for _, tt := range []struct { + name, input string + want []string + wantError bool + }{ + {"absent", `{}`, nil, false}, + {"empty", `{"excludeControls":[]}`, []string{}, false}, + {"null", `{"excludeControls":null}`, nil, false}, + {"list", `{"excludeControls":["C-0069","C-0070"]}`, []string{"C-0069", "C-0070"}, false}, + {"scalar", `{"excludeControls":"C-0069"}`, nil, true}, + {"number", `{"excludeControls":[42]}`, nil, true}, + {"blank", `{"excludeControls":[" "]}`, nil, true}, + {"null entry", `{"excludeControls":[null]}`, nil, true}, + } { + t.Run(tt.name, func(t *testing.T) { + viper.Reset() + t.Cleanup(viper.Reset) + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "config.json"), []byte(tt.input), 0600)) + got, err := LoadConfig(dir) + if tt.wantError { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, tt.want, got.ExcludeControls) + cfg, err := NewOperatorConfig(CapabilitiesConfig{}, utilsmetadata.ClusterConfig{}, nil, got) + require.NoError(t, err) + exclusions := cfg.ExcludeControls() + require.Equal(t, tt.want, exclusions) + if len(exclusions) > 0 { + exclusions[0] = "changed" + require.Equal(t, tt.want, cfg.ExcludeControls()) + } + }) + } +} diff --git a/mainhandler/agent_runtime_requests_test.go b/mainhandler/agent_runtime_requests_test.go index 722a63e1..127ca093 100644 --- a/mainhandler/agent_runtime_requests_test.go +++ b/mainhandler/agent_runtime_requests_test.go @@ -36,6 +36,7 @@ func TestAgentRuntimeFrameworkRequestSurvivesScheduledTrigger(t *testing.T) { "includeNamespaces": []string{"agents"}, "excludedNamespaces": []string{"kube-system"}, "hostScanner": false, + "excludeControls": []string{"C-0070"}, "keepLocal": true, "useCachedArtifacts": true, "exceptions": []interface{}{map[string]interface{}{"name": "agent-runtime-exception"}}, @@ -47,11 +48,12 @@ func TestAgentRuntimeFrameworkRequestSurvivesScheduledTrigger(t *testing.T) { defaults = []string{"nsa"} } args := map[string]interface{}{utils.KubescapeScanV1: payload} - requested, err := getKubescapeV1ScanRequest(args, defaults) + requested, err := getKubescapeV1ScanRequest(args, defaults, []string{"C-0069"}) require.NoError(t, err) scheduled, err := getKubescapeRequest(args, defaults) require.NoError(t, err) - require.Equal(t, requested, scheduled) + require.Equal(t, []string{"C-0069", "C-0070"}, requested.ExcludeControls) + require.Equal(t, []string{"C-0070"}, scheduled.ExcludeControls) assert.Equal(t, []string{"AgentRuntimeHardening"}, scheduled.TargetNames) assert.Equal(t, utilsapisv1.KindFramework, scheduled.TargetType) assert.Equal(t, []string{"agents"}, scheduled.IncludeNamespaces) @@ -98,7 +100,7 @@ func TestAgentRuntimeFrameworkRequestSurvivesScheduledTrigger(t *testing.T) { cfg, err := config.NewOperatorConfig( config.CapabilitiesConfig{Components: config.Components{Kubescape: config.Component{Enabled: true}, KubescapeScheduler: config.Component{Enabled: true}}}, utilsmetadata.ClusterConfig{KubescapeURL: strings.TrimPrefix(scanner.URL, "http://"), InstallationData: armotypes.InstallationData{DefaultFrameworks: defaults}}, - &beUtils.Credentials{}, config.Config{Namespace: "kubescape"}, + &beUtils.Credentials{}, config.Config{Namespace: "kubescape", ExcludeControls: []string{"C-0069"}}, ) require.NoError(t, err) args["jobParams"] = apis.CronJobParams{CronTabSchedule: "0 3 * * *"} @@ -124,9 +126,12 @@ func TestAgentRuntimeFrameworkRequestSurvivesScheduledTrigger(t *testing.T) { require.NoError(t, json.Unmarshal([]byte(configMap.Data["request-body.json"]), &commands)) require.Len(t, commands.Commands, 1) assert.Equal(t, apis.TypeRunKubescape, commands.Commands[0].CommandName) - replayed, err := getKubescapeV1ScanRequest(commands.Commands[0].Args, []string{"mitre"}) + replayed, err := getKubescapeV1ScanRequest(commands.Commands[0].Args, []string{"mitre"}, []string{"C-0069"}) require.NoError(t, err) - assert.Equal(t, scheduled, replayed, "the stored trigger must preserve the complete supported request") + assert.Equal(t, requested, replayed, "scheduled execution must reapply installation settings") + changed, err := getKubescapeV1ScanRequest(commands.Commands[0].Args, defaults, nil) + require.NoError(t, err) + assert.Equal(t, scheduled, changed, "removing installation exclusions must take effect on existing jobs") assert.Equal(t, "0 3 * * *", job.Spec.Schedule) assert.Equal(t, "AgentRuntimeHardening", job.Spec.JobTemplate.Spec.Template.Annotations["armo.framework"]) diff --git a/mainhandler/control_exclusions_test.go b/mainhandler/control_exclusions_test.go new file mode 100644 index 00000000..f7f1fff6 --- /dev/null +++ b/mainhandler/control_exclusions_test.go @@ -0,0 +1,109 @@ +package mainhandler + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/armosec/armoapi-go/apis" + utilsmetadata "github.com/armosec/utils-k8s-go/armometadata" + "github.com/kubescape/operator/config" + "github.com/kubescape/operator/continuousscanning" + "github.com/kubescape/operator/utils" + storagefake "github.com/kubescape/storage/pkg/generated/clientset/versioned/fake" + "github.com/panjf2000/ants/v2" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/watch" +) + +func TestOperatorControlExclusions(t *testing.T) { + args := map[string]interface{}{utils.KubescapeScanV1: map[string]interface{}{ + "targetType": "framework", "targetNames": []string{"nsa"}, + "excludeControls": []string{"C-0070"}, + }} + request, err := getKubescapeV1ScanRequest(args, nil, nil) + require.NoError(t, err) + body, err := json.Marshal(request) + require.NoError(t, err) + var fields map[string]json.RawMessage + require.NoError(t, json.Unmarshal(body, &fields)) + require.JSONEq(t, `["C-0070"]`, string(fields["excludeControls"])) +} + +func TestOperatorControlExclusionsDefaultsAndReplay(t *testing.T) { + defaults := []string{" C-0069 "} + tests := []struct { + name string + payload interface{} + want []string + wantError bool + }{ + {"absent", map[string]interface{}{}, []string{"C-0069"}, false}, + {"empty", map[string]interface{}{"excludeControls": []string{}}, []string{"C-0069"}, false}, + {"null", map[string]interface{}{"excludeControls": nil}, []string{"C-0069"}, false}, + {"union", map[string]interface{}{"excludeControls": []string{"c-0069", " C-0070 "}}, []string{"C-0069", "C-0070"}, false}, + {"blank", map[string]interface{}{"excludeControls": []string{" "}}, nil, true}, + {"wrong type", map[string]interface{}{"excludeControls": "C-0070"}, nil, true}, + {"null entry", map[string]interface{}{"excludeControls": []interface{}{nil}}, nil, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + args := map[string]interface{}{utils.KubescapeScanV1: tt.payload} + request, err := getKubescapeV1ScanRequest(args, nil, defaults) + if tt.wantError { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, tt.want, request.ExcludeControls) + data, err := wrapRequestWithCommand(request) + require.NoError(t, err) + var commands apis.Commands + require.NoError(t, json.Unmarshal(data, &commands)) + replayed, err := getKubescapeV1ScanRequest(commands.Commands[0].Args, nil, defaults) + require.NoError(t, err) + require.Equal(t, request, replayed) + request.ExcludeControls[0] = "changed" + require.Equal(t, []string{" C-0069 "}, defaults) + }) + } + _, err := getKubescapeV1ScanRequest(map[string]interface{}{utils.KubescapeScanV1: map[string]interface{}{}}, nil, []string{" "}) + require.Error(t, err) +} + +func TestContinuousControlExclusions(t *testing.T) { + cfg, err := config.NewOperatorConfig(config.CapabilitiesConfig{}, utilsmetadata.ClusterConfig{ClusterName: "test"}, nil, config.Config{ExcludeControls: []string{"C-0069"}}) + require.NoError(t, err) + jobs := make(chan utils.Job, 1) + pool, err := ants.NewPoolWithFunc(1, func(value interface{}) { jobs <- value.(utils.Job) }) + require.NoError(t, err) + defer pool.Release() + object := &unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": "v1", "kind": "Pod", "metadata": map[string]interface{}{"name": "example", "namespace": "default"}, + "spec": map[string]interface{}{"containers": []interface{}{map[string]interface{}{"name": "app", "image": "nginx"}}}, + }} + for _, eventType := range []watch.EventType{watch.Added, watch.Modified, watch.Deleted} { + t.Run(string(eventType), func(t *testing.T) { + handler := continuousscanning.NewTriggeringHandler(pool, cfg) + if eventType == watch.Deleted { + handler = continuousscanning.NewDeletedCleanerHandler(pool, cfg, storagefake.NewSimpleClientset()) + } + require.NoError(t, handler.Handle(context.Background(), watch.Event{Type: eventType, Object: object})) + select { + case job := <-jobs: + request, err := getKubescapeV1ScanRequest(job.Obj().Command.Args, cfg.DefaultFrameworks(), cfg.ExcludeControls()) + require.NoError(t, err) + require.Equal(t, []string{"C-0069"}, request.ExcludeControls) + require.NotNil(t, request.ScanObject) + require.NotNil(t, request.IsDeletedScanObject) + require.Equal(t, eventType == watch.Deleted, *request.IsDeletedScanObject) + require.NotNil(t, request.HostScanner) + require.False(t, *request.HostScanner) + case <-time.After(5 * time.Second): + t.Fatal("continuous event did not create a scan command") + } + }) + } +} diff --git a/mainhandler/kubescapehandler.go b/mainhandler/kubescapehandler.go index e67d4ed0..ad750ff1 100644 --- a/mainhandler/kubescapehandler.go +++ b/mainhandler/kubescapehandler.go @@ -162,7 +162,7 @@ func (actionHandler *ActionHandler) kubescapeScan(ctx context.Context) error { return errors.New("kubescape is not enabled") } - request, err := getKubescapeV1ScanRequest(actionHandler.sessionObj.Command.Args, actionHandler.config.DefaultFrameworks()) + request, err := getKubescapeV1ScanRequest(actionHandler.sessionObj.Command.Args, actionHandler.config.DefaultFrameworks(), actionHandler.config.ExcludeControls()) if err != nil { return err } diff --git a/mainhandler/kubescapehandlerhelper.go b/mainhandler/kubescapehandlerhelper.go index 10b59bd9..f9130963 100644 --- a/mainhandler/kubescapehandlerhelper.go +++ b/mainhandler/kubescapehandlerhelper.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "net/url" + "slices" "strings" "github.com/armosec/armoapi-go/apis" @@ -20,7 +21,6 @@ import ( v1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/utils/strings/slices" ) const ( @@ -53,7 +53,7 @@ func getKubescapeV1ScanStatusURL(config config.IConfig, scanID string) *url.URL return &ksURL } -func getKubescapeV1ScanRequest(args map[string]interface{}, defaultFrameworks []string) (*utilsmetav1.PostScanRequest, error) { +func getKubescapeV1ScanRequest(args map[string]interface{}, defaultFrameworks, excludedControls []string) (*utilsmetav1.PostScanRequest, error) { scanV1, ok := args[utils.KubescapeScanV1] if !ok { @@ -70,6 +70,23 @@ func getKubescapeV1ScanRequest(args map[string]interface{}, defaultFrameworks [] return nil, fmt.Errorf("failed to convert request to v1/scan object, reason: %s", err.Error()) } + // Merge installation and request settings without mutating config or growing scheduled requests. + controls := append(slices.Clone(excludedControls), postScanRequest.ExcludeControls...) + postScanRequest.ExcludeControls = nil + seen := make(map[string]struct{}, len(controls)) + for _, control := range controls { + control = strings.TrimSpace(control) + if control == "" { + return nil, fmt.Errorf("excludeControls contains an empty control identifier") + } + key := strings.ToLower(control) + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + postScanRequest.ExcludeControls = append(postScanRequest.ExcludeControls, control) + } + // Drop blank entries so targetNames: [""] is treated as "no explicit target". names := make([]string, 0, len(postScanRequest.TargetNames)) for _, n := range postScanRequest.TargetNames { @@ -108,7 +125,8 @@ func readKubescapeV1ScanResponse(resp *http.Response) (*utilsmetav1.Response, er } func getKubescapeRequest(args map[string]interface{}, defaultFrameworks []string) (*utilsmetav1.PostScanRequest, error) { - postScanRequest, err := getKubescapeV1ScanRequest(args, defaultFrameworks) + // Keep installation settings out of stored jobs; apply current settings at execution. + postScanRequest, err := getKubescapeV1ScanRequest(args, defaultFrameworks, nil) if err != nil { return postScanRequest, err } diff --git a/mainhandler/kubescapehandlerhelper_test.go b/mainhandler/kubescapehandlerhelper_test.go index 9bebec40..b43942e9 100644 --- a/mainhandler/kubescapehandlerhelper_test.go +++ b/mainhandler/kubescapehandlerhelper_test.go @@ -29,7 +29,7 @@ func TestGetKubescapeV1ScanRequest(t *testing.T) { }, }, } - req, err := getKubescapeV1ScanRequest(actionHandler.sessionObj.Command.Args, nil) + req, err := getKubescapeV1ScanRequest(actionHandler.sessionObj.Command.Args, nil, nil) assert.NoError(t, err) assert.NotNil(t, 0, req) } @@ -39,7 +39,7 @@ func TestGetKubescapeV1ScanRequest(t *testing.T) { Command: &apis.Command{Args: map[string]interface{}{utils.KubescapeScanV1: map[string]interface{}{"format": "json"}}}, }, } - req, err := getKubescapeV1ScanRequest(actionHandler.sessionObj.Command.Args, nil) + req, err := getKubescapeV1ScanRequest(actionHandler.sessionObj.Command.Args, nil, nil) assert.NoError(t, err) assert.Equal(t, "json", req.Format) } @@ -49,7 +49,7 @@ func TestGetKubescapeV1ScanRequest(t *testing.T) { Command: &apis.Command{Args: map[string]interface{}{utils.KubescapeScanV1: map[string]interface{}{}}}, }, } - req, err := getKubescapeV1ScanRequest(actionHandler.sessionObj.Command.Args, nil) + req, err := getKubescapeV1ScanRequest(actionHandler.sessionObj.Command.Args, nil, nil) assert.NoError(t, err) assert.Equal(t, "all", req.TargetNames[0]) assert.Equal(t, utilsapisv1.KindFramework, req.TargetType) @@ -61,7 +61,7 @@ func TestGetKubescapeV1ScanRequest(t *testing.T) { Command: &apis.Command{Args: map[string]interface{}{utils.KubescapeScanV1: map[string]interface{}{"targetType": utilsapisv1.KindFramework, "targetNames": []string{""}}}}, }, } - req, err := getKubescapeV1ScanRequest(actionHandler.sessionObj.Command.Args, nil) + req, err := getKubescapeV1ScanRequest(actionHandler.sessionObj.Command.Args, nil, nil) assert.NoError(t, err) assert.Equal(t, "all", req.TargetNames[0]) assert.Equal(t, utilsapisv1.KindFramework, req.TargetType) @@ -76,7 +76,7 @@ func TestGetKubescapeV1ScanRequest(t *testing.T) { }}}, }, } - req, err := getKubescapeV1ScanRequest(actionHandler.sessionObj.Command.Args, []string{"cis-eks-t1.2.0", "nsa"}) + req, err := getKubescapeV1ScanRequest(actionHandler.sessionObj.Command.Args, []string{"cis-eks-t1.2.0", "nsa"}, nil) assert.NoError(t, err) assert.Equal(t, []string{"cis-eks-t1.2.0", "nsa"}, req.TargetNames) assert.Equal(t, utilsapisv1.KindFramework, req.TargetType) @@ -87,7 +87,7 @@ func TestGetKubescapeV1ScanRequest(t *testing.T) { Command: &apis.Command{Args: map[string]interface{}{utils.KubescapeScanV1: map[string]interface{}{}}}, }, } - req, err := getKubescapeV1ScanRequest(actionHandler.sessionObj.Command.Args, []string{"cis-aks-t1.2.0"}) + req, err := getKubescapeV1ScanRequest(actionHandler.sessionObj.Command.Args, []string{"cis-aks-t1.2.0"}, nil) assert.NoError(t, err) assert.Equal(t, []string{"cis-aks-t1.2.0"}, req.TargetNames) assert.Equal(t, utilsapisv1.KindFramework, req.TargetType) @@ -101,7 +101,7 @@ func TestGetKubescapeV1ScanRequest(t *testing.T) { }}}, }, } - req, err := getKubescapeV1ScanRequest(actionHandler.sessionObj.Command.Args, []string{"cis-eks-t1.2.0"}) + req, err := getKubescapeV1ScanRequest(actionHandler.sessionObj.Command.Args, []string{"cis-eks-t1.2.0"}, nil) assert.NoError(t, err) assert.Equal(t, []string{"cis-eks-t1.2.0"}, req.TargetNames) assert.Equal(t, utilsapisv1.KindFramework, req.TargetType) @@ -116,7 +116,7 @@ func TestGetKubescapeV1ScanRequest(t *testing.T) { }}}, }, } - req, err := getKubescapeV1ScanRequest(actionHandler.sessionObj.Command.Args, []string{"cis-eks-t1.2.0"}) + req, err := getKubescapeV1ScanRequest(actionHandler.sessionObj.Command.Args, []string{"cis-eks-t1.2.0"}, nil) assert.NoError(t, err) assert.Equal(t, []string{"nsa"}, req.TargetNames) } @@ -130,7 +130,7 @@ func TestGetKubescapeV1ScanRequest(t *testing.T) { }}}, }, } - req, err := getKubescapeV1ScanRequest(actionHandler.sessionObj.Command.Args, []string{"cis-eks-t1.2.0"}) + req, err := getKubescapeV1ScanRequest(actionHandler.sessionObj.Command.Args, []string{"cis-eks-t1.2.0"}, nil) assert.NoError(t, err) assert.Empty(t, req.TargetNames) assert.Equal(t, utilsapisv1.KindControl, req.TargetType) From 885a15d6f9594e94193176c88f4f177ef9d24366 Mon Sep 17 00:00:00 2001 From: "Harsh K. Singh" Date: Sun, 11 Oct 2026 22:19:59 +0530 Subject: [PATCH 2/3] Test exclusion forwarding across scheduled configuration changes Replay stored commands through the real HTTP dispatch path and confirm malformed exclusion lists never reach the scanner. Signed-off-by: Harsh K. Singh --- .../control_exclusions_dispatch_test.go | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 mainhandler/control_exclusions_dispatch_test.go diff --git a/mainhandler/control_exclusions_dispatch_test.go b/mainhandler/control_exclusions_dispatch_test.go new file mode 100644 index 00000000..ebe39fad --- /dev/null +++ b/mainhandler/control_exclusions_dispatch_test.go @@ -0,0 +1,94 @@ +package mainhandler + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/armosec/armoapi-go/apis" + "github.com/armosec/armoapi-go/armotypes" + utilsmetadata "github.com/armosec/utils-k8s-go/armometadata" + beUtils "github.com/kubescape/backend/pkg/utils" + utilsmetav1 "github.com/kubescape/opa-utils/httpserver/meta/v1" + "github.com/kubescape/operator/config" + "github.com/kubescape/operator/utils" + "github.com/stretchr/testify/require" +) + +func TestControlExclusionsScheduledReplayAndInvalidRequestsAtDispatch(t *testing.T) { + var dispatched atomic.Int32 + received := make(chan utilsmetav1.PostScanRequest, 8) + scanner := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + dispatched.Add(1) + var request utilsmetav1.PostScanRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Errorf("decode scan: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + received <- request + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"independent-scan","type":"success"}`)) + })) + defer scanner.Close() + previousClient := KubescapeHttpClient + KubescapeHttpClient = scanner.Client() + t.Cleanup(func() { KubescapeHttpClient = previousClient }) + handler := func(args map[string]interface{}, globals []string) *ActionHandler { + t.Helper() + cfg, err := config.NewOperatorConfig( + config.CapabilitiesConfig{Components: config.Components{Kubescape: config.Component{Enabled: true}}}, + utilsmetadata.ClusterConfig{KubescapeURL: strings.TrimPrefix(scanner.URL, "http://"), InstallationData: armotypes.InstallationData{DefaultFrameworks: []string{"nsa"}}}, + &beUtils.Credentials{}, config.Config{Namespace: "kubescape", ExcludeControls: globals}, + ) + require.NoError(t, err) + return &ActionHandler{config: cfg, sessionObj: &utils.SessionObj{Command: &apis.Command{Args: args}}} + } + + // Use the same serialization path as scheduled jobs, then replay its stored command. + args := map[string]interface{}{utils.KubescapeScanV1: map[string]interface{}{"excludeControls": []string{"C-0070"}, "hostScanner": false}} + scheduled, err := getKubescapeRequest(args, []string{"nsa"}) + require.NoError(t, err) + stored, err := wrapRequestWithCommand(scheduled) + require.NoError(t, err) + var commands apis.Commands + require.NoError(t, json.Unmarshal(stored, &commands)) + require.Len(t, commands.Commands, 1) + for _, tt := range []struct { + name string + globals, want []string + }{ + {"original installation", []string{"C-0069"}, []string{"C-0069", "C-0070"}}, + {"changed installation", []string{"C-0071"}, []string{"C-0071", "C-0070"}}, + {"removed installation exclusions", nil, []string{"C-0070"}}, + } { + t.Run(tt.name, func(t *testing.T) { + before := dispatched.Load() + require.NoError(t, handler(commands.Commands[0].Args, tt.globals).kubescapeScan(context.Background())) + require.Equal(t, before+1, dispatched.Load()) + select { + case sent := <-received: + require.Equal(t, tt.want, sent.ExcludeControls, "stored schedule must use current installation settings at actual dispatch") + require.Equal(t, []string{"nsa"}, sent.TargetNames) + require.NotNil(t, sent.HostScanner) + require.False(t, *sent.HostScanner) + default: + t.Fatal("successful dispatch did not reach the scanner") + } + }) + } + for _, value := range []interface{}{"C-0069", []interface{}{42}, []interface{}{nil}, []string{""}, []string{" "}} { + data, err := json.Marshal(value) + require.NoError(t, err) + t.Run("invalid "+string(data), func(t *testing.T) { + before := dispatched.Load() + args := map[string]interface{}{utils.KubescapeScanV1: map[string]interface{}{"excludeControls": value}} + require.Error(t, handler(args, []string{"C-0069"}).kubescapeScan(context.Background())) + require.Equal(t, before, dispatched.Load(), "bad exclusions must not trigger an HTTP scan") + }) + } +} From c63d85949550ec64463a29dd873ffabc6d693069 Mon Sep 17 00:00:00 2001 From: "Harsh K. Singh" Date: Mon, 12 Oct 2026 16:45:15 +0000 Subject: [PATCH 3/3] Use the released scan request field and document config compatibility Signed-off-by: Harsh K. Singh --- README.md | 4 ++++ go.mod | 2 +- go.sum | 2 ++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 957d0079..bf2611ea 100644 --- a/README.md +++ b/README.md @@ -339,6 +339,10 @@ filter before evaluating controls and computing compliance scores. Excluding a control narrows the assessment scope; it does not assert that the check passed. Omitting the installation setting leaves existing behavior unchanged. +Compatibility: external implementations of `config.IConfig` must also provide +`ExcludeControls() []string`. Returning `nil` or an empty slice keeps scans +without installation-level exclusions. + ### Namespace Filtering (Exact & Regex) The Operator supports filtering namespaces using exact names (`includeNamespaces`, `excludeNamespaces`) and RE2 regular expressions (`includeNamespacesRegex`, `excludeNamespacesRegex`): diff --git a/go.mod b/go.mod index 78edaa65..4c818279 100644 --- a/go.mod +++ b/go.mod @@ -26,7 +26,7 @@ require ( github.com/kubescape/k8s-interface v0.0.214 github.com/kubescape/kubescape-network-scanner v0.0.15 github.com/kubescape/node-agent v0.3.38 - github.com/kubescape/opa-utils v0.0.285 + github.com/kubescape/opa-utils v0.0.315 github.com/kubescape/storage v0.0.301 github.com/panjf2000/ants/v2 v2.11.3 github.com/spf13/viper v1.21.0 diff --git a/go.sum b/go.sum index c37f9729..265eeefd 100644 --- a/go.sum +++ b/go.sum @@ -710,6 +710,8 @@ github.com/kubescape/node-agent v0.3.38 h1:eILySwFZ/jCzZH5sYvSu0oPmabJpth2yN3EJU github.com/kubescape/node-agent v0.3.38/go.mod h1:t2ufPpsMwuBKLWbTeLdHeJcjRUlr2WrBLHWQP5TafpI= github.com/kubescape/opa-utils v0.0.285 h1:iOCwwyqsIz++jqfZHD1WsvHpyNGcUzaLapzrG24aV64= github.com/kubescape/opa-utils v0.0.285/go.mod h1:fgL/Bykj2+HYQc81Mj2HkWrs5tBwIlb+/yEkKfA54fw= +github.com/kubescape/opa-utils v0.0.315 h1:43b6beRDo74nMztOFUBLWV0Vr2T0Ugap7/goU3JPe0Q= +github.com/kubescape/opa-utils v0.0.315/go.mod h1:dWNcjmiTwGYlNmclXvDvgH7UlLdpDvmEc9T7ACTqLo0= github.com/kubescape/rbac-utils v0.0.21-0.20230806101615-07e36f555520 h1:SqlwF8G+oFazeYmZQKoPczLEflBQpwpHCU8DoLLyfj8= github.com/kubescape/rbac-utils v0.0.21-0.20230806101615-07e36f555520/go.mod h1:wuxMUSDzGUyWd25IJfBzEJ/Udmw2Vy7npj+MV3u3GrU= github.com/kubescape/regolibrary v1.0.317-0.20240320124840-1d84ac7186ea h1:hLUe+1bdhiBD7xM/jliQozVd1NLYn1afQLxl5trQdPk=