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
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,9 +312,37 @@ 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.

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`):
Expand Down
23 changes: 23 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package config

import (
"encoding/json"
"fmt"
"os"
"regexp"
Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand Down
50 changes: 50 additions & 0 deletions config/control_exclusions_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
})
}
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
15 changes: 10 additions & 5 deletions mainhandler/agent_runtime_requests_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}},
Expand All @@ -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)
Expand Down Expand Up @@ -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 * * *"}
Expand All @@ -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"])
Expand Down
94 changes: 94 additions & 0 deletions mainhandler/control_exclusions_dispatch_test.go
Original file line number Diff line number Diff line change
@@ -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")
})
}
}
Loading
Loading