From 59f7cd1e098dcd470023fa05be81d5a6545f70e3 Mon Sep 17 00:00:00 2001 From: matthyx Date: Fri, 26 Jun 2026 12:20:49 +0200 Subject: [PATCH 1/4] test(rules): sync default-rules.yaml from rulelibrary; add R0040, Test_32, lint guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Syncs the bundled tests/chart/templates/node-agent/default-rules.yaml from kubescape/rulelibrary's rules-crd.yaml (the source of truth after rulelibrary#39): - adds R0040 "Unexpected process arguments" (3-arg get_exec_path overload + ap.was_executed_with_args, both in node-agent v0.3.147) - the migrated exec rules (R0001/R0007/R1001/R1004) use the backward-compatible exepath-first ternary rather than the 3-arg overload - adds R2000/R2001 and picks up the rule renames already in rulelibrary - reconciles default-rule-binding.yaml: enables R0040 and updates the renamed rule names (R1000/R1003/R1015/R2001) so every binding still resolves Adds Test_32_UnexpectedProcessArguments (+ fixtures) and registers it in the component-tests matrix. Test_32 uses dynamicpathdetector.WildcardIdentifier (the zero-or-more exec-args sentinel in the pinned storage v0.0.278), matching the existing exec_test.go convention. The ExecArgsWildcard ("⋯⋯") form and the "*"->literal flip arrived in storage v0.0.287 and are a separate coordinated storage bump. Adds pkg/rulemanager/cel/libraries/parse/default_rules_yaml_lint_test.go to guard the bundled rules: no UNGUARDED 2-arg get_exec_path (the exepath-first ternary or the 3-arg form is required), no string(event.args), and R1000 detects /dev/shm via argv[0]. All three pass against the synced bundle. Replaces node-agent#805 (its engine landed separately via #843). Signed-off-by: matthyx Co-Authored-By: Claude Opus 4.8 Docs-exempt: test-only + rule-bundle sync; node-agent engine/behavior unchanged (R0040 engine shipped in #843, rule bodies are sourced from kubescape/rulelibrary). --- .github/workflows/component-tests.yaml | 3 +- .../parse/default_rules_yaml_lint_test.go | 146 +++++ .../node-agent/default-rule-binding.yaml | 9 +- .../templates/node-agent/default-rules.yaml | 125 ++++- tests/component_test.go | 501 +++++++++++++++++- .../curl-exec-arg-wildcards-deployment.yaml | 29 + .../resources/exec-arg-wildcards-profile.yaml | 74 +++ 7 files changed, 856 insertions(+), 31 deletions(-) create mode 100644 pkg/rulemanager/cel/libraries/parse/default_rules_yaml_lint_test.go create mode 100644 tests/resources/curl-exec-arg-wildcards-deployment.yaml create mode 100644 tests/resources/exec-arg-wildcards-profile.yaml diff --git a/.github/workflows/component-tests.yaml b/.github/workflows/component-tests.yaml index 867b66f80d..c92220bc3c 100644 --- a/.github/workflows/component-tests.yaml +++ b/.github/workflows/component-tests.yaml @@ -72,7 +72,8 @@ jobs: Test_22_AlertOnPartialNetworkProfileTest, Test_23_RuleCooldownTest, Test_24_ProcessTreeDepthTest, - Test_27_ApplicationProfileOpens + Test_27_ApplicationProfileOpens, + Test_32_UnexpectedProcessArguments ] steps: - name: Checkout code diff --git a/pkg/rulemanager/cel/libraries/parse/default_rules_yaml_lint_test.go b/pkg/rulemanager/cel/libraries/parse/default_rules_yaml_lint_test.go new file mode 100644 index 0000000000..7776883eb7 --- /dev/null +++ b/pkg/rulemanager/cel/libraries/parse/default_rules_yaml_lint_test.go @@ -0,0 +1,146 @@ +package parse + +import ( + "bytes" + "os" + "path/filepath" + "regexp" + "runtime" + "strings" + "testing" +) + +func defaultRulesYAMLPath(t *testing.T) string { + t.Helper() + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + repoRoot := filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", "..", "..", "..", "..")) + return filepath.Join(repoRoot, "tests", "chart", "templates", "node-agent", "default-rules.yaml") +} + +// guardedExecPath matches the exepath-first ternary that wraps the 2-arg +// resolver — the backward-compatible form the bundled rules use: +// +// event.exepath != "" ? event.exepath : parse.get_exec_path(event.args, event.comm) +// +// Both YAML renderings are tolerated: literal "" (block scalars) and the +// escaped \"\" (double-quoted scalars). +var guardedExecPath = regexp.MustCompile(`event\.exepath\s*!=\s*\\?"\\?"\s*\?\s*event\.exepath\s*:\s*parse\.get_exec_path\(\s*event\.args\s*,\s*event\.comm\s*\)`) + +// twoArgExecPath matches a 2-arg parse.get_exec_path(event.args, event.comm). +// It does NOT match the 3-arg form, because there event.comm is followed by +// ", event.exepath" rather than ")". +var twoArgExecPath = regexp.MustCompile(`parse\.get_exec_path\(\s*event\.args\s*,\s*event\.comm\s*\)`) + +// TestDefaultRulesYAML_NoUnguardedTwoArgGetExecPath pins exec-path resolution +// in the bundled rules to an exepath-first form. A *bare* 2-arg +// parse.get_exec_path(args, comm) re-introduces the fork-shell mismatch: the +// rule side evaluates to a bare comm (e.g. "sh") while the recording side +// stores the kernel-resolved exepath (e.g. "/bin/sh"), silently breaking +// ap.was_executed lookups for execve patterns like `sh -c …`. +// +// Two forms are allowed and must be used instead: +// - the 3-arg parse.get_exec_path(event.args, event.comm, event.exepath) +// overload (node-agent v0.3.147+), used by R0040; and +// - the backward-compatible inline ternary +// event.exepath != "" ? event.exepath : parse.get_exec_path(event.args, event.comm), +// used by R0001/R0007/R1001/R1004 so they also run on agents predating the +// 3-arg overload. +// +// The check blanks out the guarded ternary occurrences (length-preserving so +// reported line numbers stay accurate), then flags any 2-arg call that remains. +func TestDefaultRulesYAML_NoUnguardedTwoArgGetExecPath(t *testing.T) { + data, err := os.ReadFile(defaultRulesYAMLPath(t)) + if err != nil { + t.Fatalf("read default-rules.yaml: %v", err) + } + + cleaned := guardedExecPath.ReplaceAllFunc(data, func(m []byte) []byte { + return bytes.Repeat([]byte(" "), len(m)) + }) + + if locs := twoArgExecPath.FindAllIndex(cleaned, -1); len(locs) > 0 { + lines := lineNumbers(data, locs) + t.Errorf("found %d unguarded 2-arg parse.get_exec_path() call(s) at line(s) %v; "+ + "use the 3-arg form parse.get_exec_path(event.args, event.comm, event.exepath) or the "+ + "exepath-first ternary event.exepath != \"\" ? event.exepath : parse.get_exec_path(event.args, event.comm)", + len(locs), lines) + } +} + +// TestDefaultRulesYAML_NoStringOnArgsList guards against CEL expressions that +// wrap the list-typed event.args field in string(). CEL's string() has no +// list overload, so e.g. string(event.args) compiles-then-fails at rule-eval +// time. When that expression is a rule's message or uniqueId, the rule manager +// drops the whole alert (rule_manager.go: getUniqueIdAndMessage error → the +// event is skipped) and spams error logs on every matching event — which broke +// R0040 delivery and tripped Test_02/Test_32. Render list fields with +// event.args.map(a, string(a)).join(" ") instead (precedent: R0006 uses +// event.flags.join(",")). +func TestDefaultRulesYAML_NoStringOnArgsList(t *testing.T) { + data, err := os.ReadFile(defaultRulesYAMLPath(t)) + if err != nil { + t.Fatalf("read default-rules.yaml: %v", err) + } + + stringOnArgs := regexp.MustCompile(`string\(\s*event\.args\s*\)`) + if locs := stringOnArgs.FindAllIndex(data, -1); len(locs) > 0 { + lines := lineNumbers(data, locs) + t.Errorf("found %d string(event.args) call(s) at line(s) %v; CEL string() has no list overload — "+ + "render with event.args.map(a, string(a)).join(\" \")", len(locs), lines) + } +} + +// TestDefaultRulesYAML_R1000DetectsDevShmViaArgv guards R1000 ("Process +// Executed from /dev/shm") against silently losing its /dev/shm detection. +// R1000 must inspect argv[0] (event.args[0]) and event.exepath / event.cwd +// directly — NOT route through parse.get_exec_path, because the 3-arg resolver +// prefers the kernel-resolved exepath (e.g. /bin/busybox for a busybox-symlinked +// applet) over the as-invoked argv[0] (/dev/shm/ls). Routing /dev/shm detection +// through get_exec_path therefore resolves the path away from /dev/shm and the +// rule never fires (regressed Test_02). +func TestDefaultRulesYAML_R1000DetectsDevShmViaArgv(t *testing.T) { + data, err := os.ReadFile(defaultRulesYAMLPath(t)) + if err != nil { + t.Fatalf("read default-rules.yaml: %v", err) + } + + // Isolate the R1000 rule block: from its id line to the next rule's id line. + text := string(data) + start := strings.Index(text, `id: "R1000"`) + if start < 0 { + t.Fatal(`R1000 rule not found in default-rules.yaml`) + } + rest := text[start+len(`id: "R1000"`):] + end := strings.Index(rest, `id: "R`) + if end < 0 { + end = len(rest) + } + block := rest[:end] + + if strings.Contains(block, "get_exec_path") { + t.Errorf("R1000 routes /dev/shm detection through parse.get_exec_path; the resolver " + + "prefers exepath over argv[0], so /dev/shm/ resolves away from /dev/shm and " + + "the rule never fires. Inspect event.args[0] / event.exepath / event.cwd directly.") + } + if !strings.Contains(block, "event.args[0]") { + t.Errorf("R1000 must inspect argv[0] (event.args[0]) so an exec invoked as /dev/shm/ " + + "is detected even when exepath resolves to the symlink target (e.g. /bin/busybox)") + } +} + +func lineNumbers(data []byte, locs [][]int) []int { + out := make([]int, 0, len(locs)) + for _, loc := range locs { + line := 1 + for i := 0; i < loc[0] && i < len(data); i++ { + if data[i] == '\n' { + line++ + } + } + out = append(out, line) + } + return out +} diff --git a/tests/chart/templates/node-agent/default-rule-binding.yaml b/tests/chart/templates/node-agent/default-rule-binding.yaml index 26367de97f..3d8f7847b4 100644 --- a/tests/chart/templates/node-agent/default-rule-binding.yaml +++ b/tests/chart/templates/node-agent/default-rule-binding.yaml @@ -15,16 +15,17 @@ spec: - "kubeconfig" rules: - ruleName: "Unexpected process launched" + - ruleName: "Unexpected process arguments" - ruleName: "Files Access Anomalies in container" - ruleName: "Syscalls Anomalies in container" - ruleName: "Linux Capabilities Anomalies in container" - ruleName: "DNS Anomalies in container" - ruleName: "Unexpected service account token access" - ruleName: "Workload uses Kubernetes API unexpectedly" - - ruleName: "Process executed from malicious source" + - ruleName: "Process Executed from /dev/shm" - ruleName: "Process tries to load a kernel module" - ruleName: "Drifted process executed" - - ruleName: "Disallowed ssh connection" + - ruleName: "SSH Connection to Unexpected Destination on Non-Standard Port" - ruleName: "Fileless execution detected" - ruleName: "Crypto miner launched" - ruleName: "Process executed from mount" @@ -36,7 +37,7 @@ spec: - ruleName: "Unexpected Sensitive File Access" - ruleName: "Hard link created over sensitive file" - ruleName: "Exec to pod" - - ruleName: "Port forward" + - ruleName: "Port forward to pod" - ruleName: "Unexpected Egress Network Traffic" - - ruleName: "Malicious Ptrace Usage" + - ruleName: "Unexpected Ptrace Syscall Usage" - ruleName: "Unexpected io_uring Operation Detected" diff --git a/tests/chart/templates/node-agent/default-rules.yaml b/tests/chart/templates/node-agent/default-rules.yaml index 0a4fe1d87f..e8e519cfb1 100644 --- a/tests/chart/templates/node-agent/default-rules.yaml +++ b/tests/chart/templates/node-agent/default-rules.yaml @@ -18,7 +18,7 @@ spec: uniqueId: "event.comm + '_' + event.exepath" ruleExpression: - eventType: "exec" - expression: "!ap.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm))" + expression: "!ap.was_executed(event.containerId, (event.exepath != \"\" ? event.exepath : parse.get_exec_path(event.args, event.comm)))" profileDependency: 0 profileDataRequired: execs: all @@ -29,12 +29,13 @@ spec: mitreTechnique: "T1059" tags: - "context:kubernetes" + - "context:container" - "anomaly" - "process" - "exec" - "applicationprofile" - name: "Files Access Anomalies in container" - enabled: true + enabled: false id: "R0002" description: "Detects unexpected file access that is not in the baseline" expressions: @@ -83,6 +84,7 @@ spec: mitreTechnique: "T1005" tags: - "context:kubernetes" + - "context:container" - "anomaly" - "file" - "open" @@ -107,6 +109,7 @@ spec: mitreTechnique: "T1059" tags: - "context:kubernetes" + - "context:container" - "anomaly" - "syscall" - "applicationprofile" @@ -130,6 +133,7 @@ spec: mitreTechnique: "T1059" tags: - "context:kubernetes" + - "context:container" - "anomaly" - "capabilities" - "applicationprofile" @@ -153,6 +157,7 @@ spec: mitreTechnique: "T1071.004" tags: - "context:kubernetes" + - "context:container" - "dns" - "anomaly" - "networkprofile" @@ -166,7 +171,7 @@ spec: ruleExpression: - eventType: "open" expression: > - ((event.path.startsWith('/run/secrets/kubernetes.io/serviceaccount') && event.path.endsWith('/token')) || + ((event.path.startsWith('/run/secrets/kubernetes.io/serviceaccount') && event.path.endsWith('/token')) || (event.path.startsWith('/var/run/secrets/kubernetes.io/serviceaccount') && event.path.endsWith('/token')) || (event.path.startsWith('/run/secrets/eks.amazonaws.com/serviceaccount') && event.path.endsWith('/token')) || (event.path.startsWith('/var/run/secrets/eks.amazonaws.com/serviceaccount') && event.path.endsWith('/token'))) && @@ -198,7 +203,7 @@ spec: uniqueId: "eventType == 'exec' ? 'exec_' + event.comm : 'network_' + event.dstAddr" ruleExpression: - eventType: "exec" - expression: "(event.comm == 'kubectl' || event.exepath.endsWith('/kubectl')) && !ap.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm))" + expression: "(event.comm == 'kubectl' || event.exepath.endsWith('/kubectl')) && !ap.was_executed(event.containerId, (event.exepath != \"\" ? event.exepath : parse.get_exec_path(event.args, event.comm)))" - eventType: "network" expression: "event.pktType == 'OUTGOING' && k8s.is_api_server_address(event.dstAddr) && !nn.was_address_in_egress(event.containerId, event.dstAddr)" profileDependency: 0 @@ -226,7 +231,7 @@ spec: ruleExpression: - eventType: "open" expression: > - event.path.startsWith('/proc/') && + event.path.startsWith('/proc/') && event.path.endsWith('/environ') && !ap.was_path_opened_with_suffix(event.containerId, '/environ') state: @@ -243,6 +248,7 @@ spec: mitreTechnique: "T1552.001" tags: - "context:kubernetes" + - "context:container" - "anomaly" - "procfs" - "environment" @@ -293,6 +299,7 @@ spec: mitreTechnique: "T1005" tags: - "context:kubernetes" + - "context:container" - "context:host" - "files" - "anomaly" @@ -317,23 +324,49 @@ spec: mitreTechnique: "T1041" tags: - "context:kubernetes" + - "context:container" - "whitelisted" - "network" - "anomaly" - "networkprofile" - - name: "Process executed from malicious source" + - name: "Unexpected process arguments" + enabled: true + id: "R0040" + description: "Detects an exec event whose path IS in the application profile but whose argv vector does not match any recorded argv pattern for that path. Consumes ap.was_executed_with_args, which walks the ExecsByPath projection surface and delegates argv comparison to dynamicpathdetector.MatchExecArgs (storage). Stays silent when the path is unknown (R0001 covers that case) and when the argv vector matches any recorded pattern (including the trailing zero-or-more form and the single-arg form); a '*' in a recorded arg is a literal character, not a wildcard." + expressions: + message: "'Unexpected process arguments: ' + event.comm + ' with PID ' + string(event.pid) + ' argv=' + event.args.map(a, string(a)).join(' ')" + uniqueId: "event.comm + '_' + event.exepath + '_' + event.args.map(a, string(a)).join(' ')" + ruleExpression: + - eventType: "exec" + expression: "ap.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm, event.exepath)) && !ap.was_executed_with_args(event.containerId, parse.get_exec_path(event.args, event.comm, event.exepath), event.args)" + profileDependency: 0 + profileDataRequired: + execs: all + severity: 3 + supportPolicy: false + isTriggerAlert: true + mitreTactic: "TA0002" + mitreTechnique: "T1059" + tags: + - "context:kubernetes" + - "context:container" + - "anomaly" + - "process" + - "exec" + - "applicationprofile" + - name: "Process Executed from /dev/shm" enabled: true id: "R1000" - description: "Detecting exec calls that are from malicious source like: /dev/shm" + description: "Detecting exec calls whose executable path or working directory is under /dev/shm, a world-writable memory-backed (tmpfs) directory." expressions: - message: "'Execution from malicious source: ' + event.exepath + ' in directory ' + event.cwd" + message: "'Process executed from /dev/shm: ' + event.exepath + ' in directory ' + event.cwd" uniqueId: "event.comm + '_' + event.exepath + '_' + event.pcomm" ruleExpression: - eventType: "exec" expression: > (event.exepath == '/dev/shm' || event.exepath.startsWith('/dev/shm/')) || - (event.cwd == '/dev/shm' || event.cwd.startsWith('/dev/shm/') || - (parse.get_exec_path(event.args, event.comm).startsWith('/dev/shm/'))) + (event.cwd == '/dev/shm' || event.cwd.startsWith('/dev/shm/')) || + (event.args.size() > 0 && (event.args[0] == '/dev/shm' || event.args[0].startsWith('/dev/shm/'))) profileDependency: 2 severity: 8 supportPolicy: false @@ -358,7 +391,7 @@ spec: expression: > (event.upperlayer == true || event.pupperlayer == true) && - !ap.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm)) + !ap.was_executed(event.containerId, (event.exepath != "" ? event.exepath : parse.get_exec_path(event.args, event.comm))) profileDependency: 1 profileDataRequired: execs: all @@ -369,6 +402,7 @@ spec: mitreTechnique: "T1036" tags: - "context:kubernetes" + - "context:container" - "exec" - "malicious" - "binary" @@ -397,12 +431,12 @@ spec: - "kernel" - "module" - "load" - - name: "Disallowed ssh connection" + - name: "SSH Connection to Unexpected Destination on Non-Standard Port" enabled: false id: "R1003" - description: "Detecting ssh connection to disallowed port" + description: "Detecting an SSH connection to a non-standard port where the destination address is not in the container's learned egress baseline." expressions: - message: "'Malicious SSH connection attempt to ' + event.dstIp + ':' + string(dyn(event.dstPort))" + message: "'SSH connection to unexpected destination on non-standard port: ' + event.dstIp + ':' + string(dyn(event.dstPort))" uniqueId: "event.comm + '_' + event.dstIp + '_' + string(dyn(event.dstPort))" ruleExpression: - eventType: "ssh" @@ -417,6 +451,7 @@ spec: mitreTechnique: "T1021.001" tags: - "context:kubernetes" + - "context:container" - "ssh" - "connection" - "port" @@ -431,7 +466,7 @@ spec: uniqueId: "event.comm" ruleExpression: - eventType: "exec" - expression: "!ap.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm)) && k8s.get_container_mount_paths(event.namespace, event.podName, event.containerName).exists(mount, event.exepath.startsWith(mount) || parse.get_exec_path(event.args, event.comm).startsWith(mount))" + expression: "!ap.was_executed(event.containerId, (event.exepath != \"\" ? event.exepath : parse.get_exec_path(event.args, event.comm))) && k8s.get_container_mount_paths(event.namespace, event.podName, event.containerName).exists(mount, event.exepath.startsWith(mount) || (event.exepath != \"\" ? event.exepath : parse.get_exec_path(event.args, event.comm)).startsWith(mount))" profileDependency: 1 profileDataRequired: execs: all @@ -442,6 +477,7 @@ spec: mitreTechnique: "T1059" tags: - "context:kubernetes" + - "context:container" - "exec" - "mount" - "applicationprofile" @@ -450,7 +486,7 @@ spec: id: "R1005" description: "Detecting Fileless Execution" expressions: - message: '''Fileless execution detected: exec call "'' + event.comm + ''" is from a malicious source''' + message: '''Fileless execution detected: exec call "'' + event.comm + ''" runs from a memory-backed source (memfd / /proc/self/fd)''' uniqueId: "event.comm + '_' + event.exepath + '_' + event.pcomm" ruleExpression: - eventType: "exec" @@ -467,10 +503,10 @@ spec: - "fileless" - "execution" - "malicious" - - name: "Process tries to escape container" + - name: "Unexpected unshare Syscall in Container" enabled: true id: "R1006" - description: "Detecting Unshare System Call usage, which can be used to escape container." + description: "Detecting use of the unshare system call (a namespace-manipulation capability that can be used to escape a container) by a non-runc process, where it was not seen in the container's application-profile baseline." expressions: message: "'Unshare system call (unshare) was called by process (' + event.comm + ')'" uniqueId: "event.comm + '_' + 'unshare'" @@ -488,6 +524,7 @@ spec: mitreTechnique: "T1611" tags: - "context:kubernetes" + - "context:container" - "unshare" - "escape" - "unshare" @@ -511,6 +548,7 @@ spec: mitreTechnique: "T1496" tags: - "context:kubernetes" + - "context:container" - "crypto" - "miners" - "malicious" @@ -594,10 +632,10 @@ spec: - "anomaly" - "symlink" - "applicationprofile" - - name: "ld_preload hooks technique detected" + - name: "ld_preload Mechanism Use or ld.so.preload Modification" enabled: false id: "R1011" - description: "Detecting ld_preload hook techniques." + description: "Detecting use of the LD_PRELOAD/LD_LIBRARY_PATH dynamic-linker hook mechanism, or an unexpected write to /etc/ld.so.preload relative to the container's application-profile baseline." expressions: message: "eventType == 'exec' ? 'Process (' + event.comm + ') is using a dynamic linker hook: ' + process.get_ld_hook_var(event.pid) : 'The dynamic linker configuration file (' + event.path + ') was modified by process (' + event.comm + ')'" uniqueId: "eventType == 'exec' ? 'exec_' + event.comm : 'open_' + event.path" @@ -645,12 +683,12 @@ spec: - "files" - "malicious" - "applicationprofile" - - name: "Malicious Ptrace Usage" + - name: "Unexpected Ptrace Syscall Usage" enabled: true id: "R1015" - description: "Detecting potentially malicious ptrace usage." + description: "Detecting use of the ptrace syscall that was not seen in the container's application-profile baseline." expressions: - message: "'Malicious ptrace usage detected from: ' + event.comm" + message: "'Unexpected ptrace syscall usage from: ' + event.comm" uniqueId: "event.exepath + '_' + event.comm" ruleExpression: - eventType: "ptrace" @@ -686,6 +724,47 @@ spec: mitreTechnique: "T1218" tags: - "context:kubernetes" + - "context:container" - "syscalls" - "io_uring" - "applicationprofile" + - name: "Exec to pod" + enabled: true + id: "R2000" + description: "Detects exec operations on pods via the Kubernetes admission webhook (PodExecOptions CONNECT)" + expressions: + message: "'Exec to pod: ' + event.Name + ' in namespace ' + event.Namespace + ' by ' + event.UserInfo.Username" + uniqueId: "event.Namespace + '/' + event.Name" + ruleExpression: + - eventType: "k8s-admission" + expression: 'event.Kind == "PodExecOptions"' + profileDependency: 2 + severity: 8 + supportPolicy: false + isTriggerAlert: true + mitreTactic: "TA0002" + mitreTechnique: "T1609" + tags: + - "context:kubernetes" + - "admission" + - "exec" + - name: "Port forward to pod" + enabled: true + id: "R2001" + description: "Detects port-forward operations on pods via the Kubernetes admission webhook (PodPortForwardOptions CONNECT)" + expressions: + message: "'Port forward to pod: ' + event.Name + ' in namespace ' + event.Namespace + ' by ' + event.UserInfo.Username" + uniqueId: "event.Namespace + '/' + event.Name" + ruleExpression: + - eventType: "k8s-admission" + expression: 'event.Kind == "PodPortForwardOptions"' + profileDependency: 2 + severity: 5 + supportPolicy: false + isTriggerAlert: true + mitreTactic: "TA0011" + mitreTechnique: "T1090" + tags: + - "context:kubernetes" + - "admission" + - "network" diff --git a/tests/component_test.go b/tests/component_test.go index 8024838ad8..1ed591dd15 100644 --- a/tests/component_test.go +++ b/tests/component_test.go @@ -1961,9 +1961,6 @@ func Test_27_ApplicationProfileOpens(t *testing.T) { }) } - - - func Test_33_AnalyzeOpensWildcardAnchoring(t *testing.T) { start := time.Now() defer tearDownTest(t, start) @@ -2230,3 +2227,501 @@ func Test_33_AnalyzeOpensWildcardAnchoring(t *testing.T) { }) } } + +func Test_32_UnexpectedProcessArguments(t *testing.T) { + start := time.Now() + defer tearDownTest(t, start) + + const overlayName = "curl-32-overlay" + + setup := func(t *testing.T) (*testutils.TestWorkload, int) { + t.Helper() + ns := testutils.NewRandomNamespace() + k8sClient := k8sinterface.NewKubernetesApi() + storageClient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) + + ap := &v1beta1.ApplicationProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: overlayName, + Namespace: ns.Name, + }, + Spec: v1beta1.ApplicationProfileSpec{ + Containers: []v1beta1.ApplicationProfileContainer{ + { + Name: "curl", + Execs: []v1beta1.ExecCalls{ + // Profile shape: Path AND Args[0] both use the + // absolute-path symlink form (/bin/sh, + // /usr/bin/nslookup, ...). With the symlink- + // faithful precedence in parse.get_exec_path + // (fix 9a6eb359), the rule queries the + // symlink-as-invoked path that the kernel + // preserves in argv[0]. Recording-side + // resolveExecPath uses the same precedence so + // auto-learned profiles get the same key. + // + // Storage's CompareExecArgs is a strict + // positional compare — no special argv[0] + // normalisation — so Args[0] MUST be the same + // string as runtime argv[0]. For + // kubectl-exec'd processes that's the absolute + // path the caller invoked. + // + // pod startup: sleep + {Path: "/bin/sleep", Args: []string{"/bin/sleep", dynamicpathdetector.WildcardIdentifier}}, + // sh -c + {Path: "/bin/sh", Args: []string{"/bin/sh", "-c", dynamicpathdetector.WildcardIdentifier}}, + // echo hello + {Path: "/bin/echo", Args: []string{"/bin/echo", "hello", dynamicpathdetector.WildcardIdentifier}}, + // curl -s + {Path: "/usr/bin/curl", Args: []string{"/usr/bin/curl", "-s", dynamicpathdetector.DynamicIdentifier}}, + // curl -s file:///etc/hosts file:///etc/hostname + // — a ⋯ in a NON-trailing position: it matches exactly + // one arg, and the LITERAL args after it must still + // anchor. (file:// URLs are used as the post-⋯ literals + // so curl reads local files and exits 0.) + {Path: "/usr/bin/curl", Args: []string{"/usr/bin/curl", "-s", dynamicpathdetector.DynamicIdentifier, "file:///etc/hosts", "file:///etc/hostname"}}, + // Busybox-symlink mirror entries. The curl image's + // /bin/{sleep,sh,echo} are symlinks to /bin/busybox, + // so the kernel's resolved /proc//exe — what + // IG captures as event.exepath — is /bin/busybox. + // parse.get_exec_path(args, comm, exepath) returns + // exepath first, so ap.was_executed queries arrive + // at the rule keyed on /bin/busybox, not the + // symlink form. Without a matching profile entry + // keyed on /bin/busybox, R0001 fires before R0040 + // ever evaluates and the test trips its R0001 + // precondition. The symlink-form entries above are + // retained for environments where exepath resolves + // to the as-invoked path (non-symlinked utilities; + // fexecve / argv[0] fallback in resolveExecPath). + {Path: "/bin/busybox", Args: []string{"/bin/sleep", dynamicpathdetector.WildcardIdentifier}}, + {Path: "/bin/busybox", Args: []string{"/bin/sh", "-c", dynamicpathdetector.WildcardIdentifier}}, + {Path: "/bin/busybox", Args: []string{"/bin/echo", "hello", dynamicpathdetector.WildcardIdentifier}}, + // Literal "*" arg: echo invoked with a GENUINE literal "*" + // (e.g. an unexpanded glob), recorded verbatim. Under the + // symbol contract a "*" in argv is DATA, not a wildcard, so + // this entry matches ONLY `echo star *` and must NOT broaden + // to `echo star `. CT-level mirror of storage's + // TestAP_LiteralStarVsDynamic. (busybox + symlink forms.) + {Path: "/bin/echo", Args: []string{"/bin/echo", "star", "*"}}, + {Path: "/bin/busybox", Args: []string{"/bin/echo", "star", "*"}}, + }, + Syscalls: []string{"socket", "connect", "sendto", "recvfrom", "read", "write", "close", "openat", "mmap", "mprotect", "munmap", "fcntl", "ioctl", "poll", "epoll_create1", "epoll_ctl", "epoll_wait", "bind", "listen", "accept4", "getsockopt", "setsockopt", "getsockname", "getpid", "fstat", "rt_sigaction", "rt_sigprocmask", "writev", "execve"}, + }, + }, + }, + } + _, err := storageClient.ApplicationProfiles(ns.Name).Create( + context.Background(), ap, metav1.CreateOptions{}) + require.NoError(t, err, "create AP") + + // User-supplied SBOB pattern (mirrors Test_28): the pod carries BOTH + // kubescape.io/user-defined-profile and kubescape.io/user-defined-network. + // Node-agent uses the single overlay name as the lookup key for BOTH + // the user ApplicationProfile and the user NetworkNeighborhood, so the + // NN must exist under the same name and be created before the pod. + // User-authored objects carry managed-by=User + a terminal + // status/completion and the workload-binding labels. + nn := &v1beta1.NetworkNeighborhood{ + ObjectMeta: metav1.ObjectMeta{ + Name: overlayName, + Namespace: ns.Name, + Annotations: map[string]string{ + helpersv1.ManagedByMetadataKey: helpersv1.ManagedByUserValue, + helpersv1.StatusMetadataKey: helpersv1.Completed, + helpersv1.CompletionMetadataKey: helpersv1.Full, + }, + Labels: map[string]string{ + helpersv1.ApiGroupMetadataKey: "apps", + helpersv1.ApiVersionMetadataKey: "v1", + helpersv1.RelatedKindMetadataKey: "Deployment", + helpersv1.RelatedNameMetadataKey: "curl-32", + helpersv1.RelatedNamespaceMetadataKey: ns.Name, + }, + }, + Spec: v1beta1.NetworkNeighborhoodSpec{ + LabelSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "curl-32"}, + }, + Containers: []v1beta1.NetworkNeighborhoodContainer{ + {Name: "curl"}, + }, + }, + } + _, err = storageClient.NetworkNeighborhoods(ns.Name).Create( + context.Background(), nn, metav1.CreateOptions{}) + require.NoError(t, err, "create NN") + + require.Eventually(t, func() bool { + _, apErr := storageClient.ApplicationProfiles(ns.Name).Get( + context.Background(), overlayName, v1.GetOptions{}) + _, nnErr := storageClient.NetworkNeighborhoods(ns.Name).Get( + context.Background(), overlayName, v1.GetOptions{}) + return apErr == nil && nnErr == nil + }, 30*time.Second, 1*time.Second, "AP+NN must be in storage before pod deploy") + + wl, err := testutils.NewTestWorkload(ns.Name, + path.Join(utils.CurrentDir(), "resources/curl-exec-arg-wildcards-deployment.yaml")) + require.NoError(t, err) + require.NoError(t, wl.WaitForReady(80)) + + // Deterministic profile-load gate (replaces a fixed sleep that raced the + // asynchronous overlay load). node-agent must observe the pod, resolve + // the kubescape.io/user-defined-profile annotation to UserAPRef, fetch + // the user AP and build the projection before the argv-comparison rule + // (R0040) can evaluate at all; until then refreshOneEntry reports the CP + // "not-available" and R0040 is suppressed — which makes every POSITIVE + // subtest pass VACUOUSLY (no profile -> no R0040 -> ==0) and every + // NEGATIVE subtest time out. The fixed 30s sleep did not reliably cover + // that window (observed: all negatives failing on a slow load). + // + // The canary is a deterministic argv MISMATCH: [echo, ] matches + // neither [echo, hello, ⋯⋯] nor [echo, star, *], so once the overlay is + // projected it MUST fire R0040. R0040's cooldown key is uniqueId = + // comm+exepath+argv, so this distinct argv never suppresses a subtest's + // own R0040. We retry until it fires, then return the post-gate R0040 + // count as a baseline so subtests assert on the DELTA, not absolutes — + // closing the vacuous-positive hole. + countR0040 := func(alerts []testutils.Alert) int { + n := 0 + for _, a := range alerts { + if a.Labels["rule_id"] == "R0040" { + n++ + } + } + return n + } + require.Eventually(t, func() bool { + if _, _, err := wl.ExecIntoPod([]string{"echo", "__profile_probe__"}, "curl"); err != nil { + return false + } + alerts, _ := testutils.GetAlerts(ns.Name) + return countR0040(alerts) > 0 + }, 180*time.Second, 10*time.Second, + "user overlay must project (canary R0040 must fire) before subtests run") + // settle so all in-flight canary alerts are counted into the baseline + time.Sleep(10 * time.Second) + alerts, _ := testutils.GetAlerts(ns.Name) + return wl, countR0040(alerts) + } + + countByRule := func(alerts []testutils.Alert, ruleID string) int { + n := 0 + for _, a := range alerts { + if a.Labels["rule_id"] == ruleID { + n++ + } + } + return n + } + + waitAlerts := func(t *testing.T, ns string) []testutils.Alert { + t.Helper() + var alerts []testutils.Alert + var err error + require.Eventually(t, func() bool { + alerts, err = testutils.GetAlerts(ns) + return err == nil + }, 60*time.Second, 5*time.Second, "must be able to fetch alerts") + // settle time for any in-flight alerts + time.Sleep(10 * time.Second) + alerts, _ = testutils.GetAlerts(ns) + return alerts + } + + logAlerts := func(t *testing.T, alerts []testutils.Alert) { + t.Helper() + for i, a := range alerts { + t.Logf(" [%d] %s(%s) comm=%s container=%s", + i, a.Labels["rule_name"], a.Labels["rule_id"], + a.Labels["comm"], a.Labels["container_name"]) + } + } + + // R0001 silence is a precondition for every subtest below: it means + // parse.get_exec_path resolved to the profile's Path key, so R0040 + // gets to evaluate its argv comparison cleanly. A non-zero R0001 for + // the test binary's comm means the recording / capture / resolution + // chain dropped event.exepath — that's a separate bug (track it in + // the recording side, not in R0040), and asserting it here fails the + // subtest on the right axis instead of polluting the R0040 signal. + assertR0001Silent := func(t *testing.T, alerts []testutils.Alert, comm string) { + t.Helper() + n := 0 + for _, a := range alerts { + if a.Labels["rule_id"] == "R0001" && a.Labels["comm"] == comm { + n++ + } + } + require.Zero(t, n, + "R0001 precondition: path resolution failed for comm=%q. "+ + "parse.get_exec_path either didn't receive event.exepath or "+ + "profile Path doesn't match its return value. Fix capture-side "+ + "exepath before reading R0040 results from this subtest.", comm) + } + + // ----------------------------------------------------------------- + // 32a. sh -c — argv [sh, -c, "echo hi"] matches + // profile [sh, -c, ⋯⋯]. R0040 must NOT fire. + // ----------------------------------------------------------------- + t.Run("sh_dash_c_matches_wildcard_trailing", func(t *testing.T) { + wl, base := setup(t) + // Warm the cache: retry the exec until it runs cleanly so the user + // overlay is loaded, then settle and assert R0040 stays silent + // (mirrors Test_28 no-alert idiom). A matching argv must not alert. + require.Eventually(t, func() bool { + _, _, err := wl.ExecIntoPod([]string{"sh", "-c", "echo hi"}, "curl") + return err == nil + }, 60*time.Second, 5*time.Second, "exec must run") + time.Sleep(20 * time.Second) + alerts := waitAlerts(t, wl.Namespace) + t.Logf("=== %d alerts ===", len(alerts)) + logAlerts(t, alerts) + assertR0001Silent(t, alerts, "sh") + assert.Equal(t, base, countByRule(alerts, "R0040"), + "sh -c matches profile [sh, -c, ⋯⋯]: R0040 must stay silent") + }) + + // ----------------------------------------------------------------- + // 32b. sh -x -c — argv [sh, -x, -c, "echo hi"] does NOT match + // profile [sh, -c, ⋯⋯] (literal anchor `-c` at position 1 mismatches + // `-x`). Path /bin/sh (or /bin/busybox) IS in profile so R0001 + // stays silent. R0040 must fire. + // + // Earlier shape `sh -x "echo hi"` exited 2 (busybox sh tried to + // open "echo hi" as a script file) — kubectl exec returned an + // error and require.NoError tripped before R0040 could be read. + // Adding -c keeps sh's invocation valid while preserving the + // argv-shape mismatch that exercises R0040. + // ----------------------------------------------------------------- + t.Run("sh_dash_x_mismatches_R0040", func(t *testing.T) { + wl, base := setup(t) + // Retry the trigger until node-agent has loaded the user overlay + // into the ContainerProfileCache and R0040 fires. The overlay loads + // asynchronously, so a single exec can race the load and the + // profile-dependent rule is suppressed (mirrors Test_28). The + // command is idempotent, so re-exec is side-effect-free. + var alerts []testutils.Alert + require.Eventually(t, func() bool { + _, _, err := wl.ExecIntoPod([]string{"sh", "-x", "-c", "echo hi"}, "curl") + if err != nil { + return false + } + alerts = waitAlerts(t, wl.Namespace) + return countByRule(alerts, "R0040") > base + }, 120*time.Second, 10*time.Second, "sh -x mismatches profile [sh, -c, ⋯⋯]: R0040 must fire") + t.Logf("=== %d alerts ===", len(alerts)) + logAlerts(t, alerts) + assertR0001Silent(t, alerts, "sh") + require.Greater(t, countByRule(alerts, "R0040"), base, + "sh -x mismatches profile [sh, -c, ⋯⋯]: R0040 must fire") + }) + + // ----------------------------------------------------------------- + // 32c. echo hello — argv [echo, hello, world, from, test] + // matches profile [echo, hello, ⋯⋯]. R0040 must NOT fire. + // ----------------------------------------------------------------- + t.Run("echo_hello_matches_wildcard_trailing", func(t *testing.T) { + wl, base := setup(t) + // Warm the cache: retry the exec until it runs cleanly so the user + // overlay is loaded, then settle and assert R0040 stays silent + // (mirrors Test_28 no-alert idiom). A matching argv must not alert. + require.Eventually(t, func() bool { + _, _, err := wl.ExecIntoPod([]string{"echo", "hello", "world", "from", "test"}, "curl") + return err == nil + }, 60*time.Second, 5*time.Second, "exec must run") + time.Sleep(20 * time.Second) + alerts := waitAlerts(t, wl.Namespace) + t.Logf("=== %d alerts ===", len(alerts)) + logAlerts(t, alerts) + assertR0001Silent(t, alerts, "echo") + assert.Equal(t, base, countByRule(alerts, "R0040"), + "echo hello matches profile [echo, hello, ⋯⋯]: R0040 must stay silent") + }) + + // ----------------------------------------------------------------- + // 32d. echo goodbye — argv [echo, goodbye, world] does + // NOT match profile [echo, hello, ⋯⋯] (literal anchor `hello` + // mismatch). R0040 must fire. + // ----------------------------------------------------------------- + t.Run("echo_goodbye_mismatches_R0040", func(t *testing.T) { + wl, base := setup(t) + // Retry the trigger until node-agent has loaded the user overlay + // into the ContainerProfileCache and R0040 fires. The overlay loads + // asynchronously, so a single exec can race the load and the + // profile-dependent rule is suppressed (mirrors Test_28). The + // command is idempotent, so re-exec is side-effect-free. + var alerts []testutils.Alert + require.Eventually(t, func() bool { + _, _, err := wl.ExecIntoPod([]string{"echo", "goodbye", "world"}, "curl") + if err != nil { + return false + } + alerts = waitAlerts(t, wl.Namespace) + return countByRule(alerts, "R0040") > base + }, 120*time.Second, 10*time.Second, "echo goodbye mismatches profile [echo, hello, ⋯⋯] (literal anchor): R0040 must fire") + t.Logf("=== %d alerts ===", len(alerts)) + logAlerts(t, alerts) + assertR0001Silent(t, alerts, "echo") + require.Greater(t, countByRule(alerts, "R0040"), base, + "echo goodbye mismatches profile [echo, hello, ⋯⋯] (literal anchor): R0040 must fire") + }) + + // ----------------------------------------------------------------- + // 32e. curl -s — the NON-symlinked binary (curl is a real + // binary in curlimages/curl, not a busybox applet) with an + // ELLIPSIS profile: [curl, -s, ⋯]. ⋯ matches EXACTLY ONE arg, so + // `curl -s ` matches → R0040 silent. + // + // A file:// URL is used so curl reads a local file and exits 0 + // regardless of cluster egress — the test pins argv matching, not + // network reachability. + // ----------------------------------------------------------------- + t.Run("curl_dash_s_one_url_matches_ellipsis", func(t *testing.T) { + wl, base := setup(t) + // Warm the cache: retry the exec until it runs cleanly so the user + // overlay is loaded, then settle and assert R0040 stays silent + // (mirrors Test_28 no-alert idiom). A matching argv must not alert. + require.Eventually(t, func() bool { + _, _, err := wl.ExecIntoPod([]string{"curl", "-s", "file:///etc/hostname"}, "curl") + return err == nil + }, 60*time.Second, 5*time.Second, "exec must run") + time.Sleep(20 * time.Second) + alerts := waitAlerts(t, wl.Namespace) + t.Logf("=== %d alerts ===", len(alerts)) + logAlerts(t, alerts) + assertR0001Silent(t, alerts, "curl") + assert.Equal(t, base, countByRule(alerts, "R0040"), + "curl -s matches profile [curl, -s, dyn]: R0040 must stay silent") + }) + + // ----------------------------------------------------------------- + // 32f. curl -s — argv [curl, -s, url1, url2] does NOT match + // profile [curl, -s, ⋯] because ⋯ consumes EXACTLY ONE arg, not + // two. R0040 must fire. Pins the ⋯ (DynamicIdentifier) arity on + // the non-symlinked path. Both file:// URLs are readable so curl + // still exits 0. + // ----------------------------------------------------------------- + t.Run("curl_dash_s_two_urls_mismatches_R0040", func(t *testing.T) { + wl, base := setup(t) + // Retry the trigger until node-agent has loaded the user overlay + // into the ContainerProfileCache and R0040 fires. The overlay loads + // asynchronously, so a single exec can race the load and the + // profile-dependent rule is suppressed (mirrors Test_28). The + // command is idempotent, so re-exec is side-effect-free. + var alerts []testutils.Alert + require.Eventually(t, func() bool { + _, _, err := wl.ExecIntoPod([]string{"curl", "-s", "file:///etc/hostname", "file:///etc/hosts"}, "curl") + if err != nil { + return false + } + alerts = waitAlerts(t, wl.Namespace) + return countByRule(alerts, "R0040") > base + }, 120*time.Second, 10*time.Second, "curl -s exceeds the single-arg dyn token in profile [curl, -s, dyn]: R0040 must fire") + t.Logf("=== %d alerts ===", len(alerts)) + logAlerts(t, alerts) + assertR0001Silent(t, alerts, "curl") + require.Greater(t, countByRule(alerts, "R0040"), base, + "curl -s exceeds the single-arg dyn token in profile [curl, -s, dyn]: R0040 must fire") + }) + + // ----------------------------------------------------------------- + // 32g. echo star — argv [echo, star, boom] does NOT match + // profile [echo, star, *] because the profile's "*" is a LITERAL + // character, not a wildcard. The path IS in profile (R0001 silent) + // but the argv mismatches at position 2 → R0040 must fire. This is + // the core symbol-contract guard: a recorded literal "*" must NOT + // broaden to an arbitrary arg (the over-broadening that blocked the + // merge). Mirrors storage's TestAP_LiteralStarVsDynamic. + // ----------------------------------------------------------------- + t.Run("echo_literal_star_does_not_broaden_R0040", func(t *testing.T) { + wl, base := setup(t) + var alerts []testutils.Alert + require.Eventually(t, func() bool { + _, _, err := wl.ExecIntoPod([]string{"echo", "star", "boom"}, "curl") + if err != nil { + return false + } + alerts = waitAlerts(t, wl.Namespace) + return countByRule(alerts, "R0040") > base + }, 120*time.Second, 10*time.Second, "echo star boom mismatches profile [echo, star, *] (literal star, no broaden): R0040 must fire") + t.Logf("=== %d alerts ===", len(alerts)) + logAlerts(t, alerts) + assertR0001Silent(t, alerts, "echo") + require.Greater(t, countByRule(alerts, "R0040"), base, + "echo star boom mismatches profile [echo, star, *] (literal star, no broaden): R0040 must fire") + }) + + // ----------------------------------------------------------------- + // 32h. echo star "*" — argv [echo, star, *] (a genuine literal "*" + // argument, passed unexpanded via exec, no shell) DOES match + // profile [echo, star, *] exactly. R0040 must stay silent. Pins the + // other half of the literal-"*" contract: data matches its own + // value verbatim. + // ----------------------------------------------------------------- + t.Run("echo_literal_star_matches_itself", func(t *testing.T) { + wl, base := setup(t) + require.Eventually(t, func() bool { + _, _, err := wl.ExecIntoPod([]string{"echo", "star", "*"}, "curl") + return err == nil + }, 60*time.Second, 5*time.Second, "exec must run") + time.Sleep(20 * time.Second) + alerts := waitAlerts(t, wl.Namespace) + t.Logf("=== %d alerts ===", len(alerts)) + logAlerts(t, alerts) + assertR0001Silent(t, alerts, "echo") + assert.Equal(t, base, countByRule(alerts, "R0040"), + "echo star * matches profile [echo, star, *] (literal): R0040 must stay silent") + }) + + // ----------------------------------------------------------------- + // 32i. curl -s file:///etc/hosts file:///etc/hostname — + // argv [curl, -s, , file:///etc/hosts, file:///etc/hostname] + // matches profile [curl, -s, ⋯, file:///etc/hosts, + // file:///etc/hostname]. The ⋯ sits MID-VECTOR: it consumes exactly + // the one arg, and the two LITERAL args after it anchor. All + // three URLs are readable file:// paths so curl exits 0. R0040 must + // stay silent. + // ----------------------------------------------------------------- + t.Run("curl_dash_s_mid_ellipsis_then_literals_matches", func(t *testing.T) { + wl, base := setup(t) + require.Eventually(t, func() bool { + _, _, err := wl.ExecIntoPod([]string{"curl", "-s", "file:///etc/group", "file:///etc/hosts", "file:///etc/hostname"}, "curl") + return err == nil + }, 60*time.Second, 5*time.Second, "exec must run") + time.Sleep(20 * time.Second) + alerts := waitAlerts(t, wl.Namespace) + t.Logf("=== %d alerts ===", len(alerts)) + logAlerts(t, alerts) + assertR0001Silent(t, alerts, "curl") + assert.Equal(t, base, countByRule(alerts, "R0040"), + "curl -s file:///etc/hosts file:///etc/hostname matches profile [curl, -s, ⋯, , ]: R0040 must stay silent") + }) + + // ----------------------------------------------------------------- + // 32j. curl -s file:///etc/hosts file:///etc/group — the LAST + // literal mismatches the profile's anchor (profile ends + // file:///etc/hostname, runtime ends file:///etc/group). The ⋯ and + // the first literal still match, so this pins that literals AFTER a + // mid-vector ⋯ are enforced — a mismatch there fires R0040. All URLs + // are readable so curl exits 0; only the argv shape differs. + // ----------------------------------------------------------------- + t.Run("curl_dash_s_mid_ellipsis_trailing_literal_mismatch_R0040", func(t *testing.T) { + wl, base := setup(t) + var alerts []testutils.Alert + require.Eventually(t, func() bool { + _, _, err := wl.ExecIntoPod([]string{"curl", "-s", "file:///etc/group", "file:///etc/hosts", "file:///etc/group"}, "curl") + if err != nil { + return false + } + alerts = waitAlerts(t, wl.Namespace) + return countByRule(alerts, "R0040") > base + }, 120*time.Second, 10*time.Second, "curl trailing literal mismatches profile [curl, -s, ⋯, , file:///etc/hostname]: R0040 must fire") + t.Logf("=== %d alerts ===", len(alerts)) + logAlerts(t, alerts) + assertR0001Silent(t, alerts, "curl") + require.Greater(t, countByRule(alerts, "R0040"), base, + "curl trailing literal mismatches profile [curl, -s, ⋯, , file:///etc/hostname]: R0040 must fire") + }) +} diff --git a/tests/resources/curl-exec-arg-wildcards-deployment.yaml b/tests/resources/curl-exec-arg-wildcards-deployment.yaml new file mode 100644 index 0000000000..cdb2ddf96b --- /dev/null +++ b/tests/resources/curl-exec-arg-wildcards-deployment.yaml @@ -0,0 +1,29 @@ +## Curl pod for Test_32_UnexpectedProcessArguments. +## +## Carries the unified user-defined-profile label used by upstream's +## ContainerProfileCache (kubescape/node-agent#788). The label value +## must match the name of BOTH the user ApplicationProfile and (when +## present) the user NetworkNeighborhood. The test creates only the AP +## with that name; both the user AP and the user NN use it. +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: curl-32 + name: curl-32 +spec: + selector: + matchLabels: + app: curl-32 + replicas: 1 + template: + metadata: + labels: + app: curl-32 + kubescape.io/user-defined-profile: curl-32-overlay + kubescape.io/user-defined-network: curl-32-overlay + spec: + containers: + - name: curl + image: docker.io/curlimages/curl@sha256:08e466006f0860e54fc299378de998935333e0e130a15f6f98482e9f8dab3058 + command: ["sleep", "infinity"] diff --git a/tests/resources/exec-arg-wildcards-profile.yaml b/tests/resources/exec-arg-wildcards-profile.yaml new file mode 100644 index 0000000000..97dba6e92d --- /dev/null +++ b/tests/resources/exec-arg-wildcards-profile.yaml @@ -0,0 +1,74 @@ +# User ApplicationProfile fixture for exec-arg wildcard CTs (Test_32 family). +# +# Encodes the exec-arg wildcard contract enforced by storage's +# dynamicpathdetector.MatchExecArgs (see compare_exec_args.go). The tokens are +# dedicated, collision-free sentinels — a "*" in argv is a LITERAL character, +# NEVER a wildcard: +# +# "⋯" (U+22EF) matches exactly ONE whole arg, or one embedded segment. +# "⋯⋯" (U+22EF x2) matches ZERO-OR-MORE whole trailing args (positional). +# "*" a literal "*" — matches only itself, does NOT broaden. +# +# YAML note: "*" MUST be quoted ("*") — bare * is a YAML alias indicator. The +# ⋯ runes are plain UTF-8 and need no quoting but are quoted here for clarity. +# +# This is the YAML equivalent of Test_32_UnexpectedProcessArguments's inline AP. +# Apply with the workload label kubescape.io/user-defined-profile=curl-32-overlay +# (see resources/curl-exec-arg-wildcards-deployment.yaml). Substitute {namespace} +# for the test namespace, or set it explicitly. +apiVersion: spdx.softwarecomposition.kubescape.io/v1beta1 +kind: ApplicationProfile +metadata: + name: curl-32-overlay + namespace: "{namespace}" + resourceVersion: "1" + annotations: + kubescape.io/managed-by: User +spec: + architectures: ["amd64"] + containers: + - name: curl + imageID: "" + imageTag: "" + capabilities: [] + opens: [] + syscalls: [] + endpoints: [] + execs: + # sleep — ⋯⋯ absorbs zero-or-more trailing args. + - path: /bin/sleep + args: ["/bin/sleep", "⋯⋯"] + # sh -c — literal anchor "-c", then ⋯⋯. + - path: /bin/sh + args: ["/bin/sh", "-c", "⋯⋯"] + # echo hello — literal anchor "hello", then ⋯⋯. + - path: /bin/echo + args: ["/bin/echo", "hello", "⋯⋯"] + # curl -s — ⋯ matches one arg, not two. + - path: /usr/bin/curl + args: ["/usr/bin/curl", "-s", "⋯"] + # curl -s — ⋯ MID-VECTOR: one arg, then the literal + # args after it must anchor (a mismatch on a trailing literal fires R0040). + - path: /usr/bin/curl + args: ["/usr/bin/curl", "-s", "⋯", "file:///etc/hosts", "file:///etc/hostname"] + # echo star — "*" is data: matches only `echo star *`, + # must NOT broaden to `echo star ` (the threat-model guard). + - path: /bin/echo + args: ["/bin/echo", "star", "*"] + # Busybox-symlink mirror entries: the curl image's /bin/{sleep,sh,echo} + # are symlinks to /bin/busybox, so the kernel exepath the rule queries is + # /bin/busybox. Without these, R0001 fires before R0040 can evaluate. + - path: /bin/busybox + args: ["/bin/sleep", "⋯⋯"] + - path: /bin/busybox + args: ["/bin/sh", "-c", "⋯⋯"] + - path: /bin/busybox + args: ["/bin/echo", "hello", "⋯⋯"] + - path: /bin/busybox + args: ["/bin/echo", "star", "*"] + seccompProfile: + spec: + defaultAction: "" + initContainers: [] + ephemeralContainers: [] +status: {} From 5918747f7d44f2627f2ab0ae5c1c4debbe4afde4 Mon Sep 17 00:00:00 2001 From: matthyx Date: Fri, 26 Jun 2026 13:06:26 +0200 Subject: [PATCH 2/4] test: fix Test_02 rule rename; skip storage-version-dependent Test_32 case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Test_02_AllAlertsFromMaliciousApp: update the R1000 expectation key from the old "Process executed from malicious source" to the synced name "Process Executed from /dev/shm" (rule renamed in rulelibrary). - Test_32/echo_literal_star_does_not_broaden_R0040: t.Skip — it asserts the "*"-is-literal semantics introduced in storage v0.0.287, but main pins v0.0.278 where "*" (WildcardIdentifier) is a zero-or-more wildcard, so a literal "*" does broaden. Re-enable with the coordinated storage bump. The other 9 Test_32 subtests pass. Test_27 (R0002 was disabled by the wholesale bundle sync) is still pending a decision on whether to keep R0002 enabled in node-agent's bundle. Docs-exempt: test-only fix, no node-agent behavioral/API change. Signed-off-by: matthyx Co-Authored-By: Claude Opus 4.8 --- tests/component_test.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/component_test.go b/tests/component_test.go index 1ed591dd15..bf943db8b8 100644 --- a/tests/component_test.go +++ b/tests/component_test.go @@ -148,7 +148,7 @@ func Test_02_AllAlertsFromMaliciousApp(t *testing.T) { "Syscalls Anomalies in container": false, "Linux Capabilities Anomalies in container": false, "Workload uses Kubernetes API unexpectedly": false, - "Process executed from malicious source": false, + "Process Executed from /dev/shm": false, "Process tries to load a kernel module": false, "Drifted process executed": false, "Process executed from mount": false, @@ -164,7 +164,7 @@ func Test_02_AllAlertsFromMaliciousApp(t *testing.T) { "Syscalls Anomalies in container": {true}, "Linux Capabilities Anomalies in container": {true}, "Workload uses Kubernetes API unexpectedly": {true}, - "Process executed from malicious source": {false}, + "Process Executed from /dev/shm": {false}, "Process tries to load a kernel module": {false}, "Drifted process executed": {true}, "Process executed from mount": {true}, @@ -2636,6 +2636,13 @@ func Test_32_UnexpectedProcessArguments(t *testing.T) { // merge). Mirrors storage's TestAP_LiteralStarVsDynamic. // ----------------------------------------------------------------- t.Run("echo_literal_star_does_not_broaden_R0040", func(t *testing.T) { + // Pinned storage (v0.0.278) treats "*" (WildcardIdentifier) as a + // zero-or-more exec-args wildcard, so a literal "*" arg DOES broaden + // the match. The "*"-is-literal semantics this case asserts arrived in + // storage v0.0.287 (ExecArgsWildcard "⋯⋯"). Re-enable together with the + // coordinated storage v0.0.278 -> v0.0.287 bump (which also rewrites + // exec_test.go's "*" cases). + t.Skip("requires storage v0.0.287 '*'-is-literal semantics; main pins v0.0.278 where '*' is a wildcard") wl, base := setup(t) var alerts []testutils.Alert require.Eventually(t, func() bool { From bea17c2b82758a28da33cbfaa47e223d28fe13b1 Mon Sep 17 00:00:00 2001 From: matthyx Date: Fri, 26 Jun 2026 13:21:15 +0200 Subject: [PATCH 3/4] test(rules): bump storage v0.0.287 (ExecArgsWildcard); enable R0002 in Test_27 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #3 (the original #805 intent): bump kubescape/storage v0.0.278 -> v0.0.287, which introduces ExecArgsWildcard ("⋯⋯") as the zero-or-more exec-args wildcard and makes "*" a literal in exec args. Migrate exec_test.go's "*"-as-wildcard profile entries to dynamicpathdetector.ExecArgsWildcard, revert Test_32 from the v0.0.278 WildcardIdentifier stopgap back to ExecArgsWildcard, and un-skip echo_literal_star_does_not_broaden_R0040 (it now passes — "*" is literal). Engine code is unchanged: it routes through dynamicpathdetector.MatchExecArgs (stable signature); only test fixtures supply the sentinel. #2: Test_27_ApplicationProfileOpens enables R0002 ("Files Access Anomalies"), which ships disabled in the synced bundle, for its own cluster only — by applying tests/resources/r0002-files-access-enabled.yaml (an override Rules CRD with R0002 enabled). The rules-watcher filters disabled rules before its per-ID merge, so it uses the enabled override; default-rules.yaml is untouched. Component-test matrix jobs run on isolated clusters. (Test_33 also exercises R0002 but is not in the matrix.) Unit tests (applicationprofile, parse, objectcache) pass under v0.0.287; the component tests compile. Docs-exempt: dependency bump + test updates; the exec-args wildcard sentinel is documented in storage and the rulelibrary R0040 rule, not in node-agent docs. Signed-off-by: matthyx Co-Authored-By: Claude Opus 4.8 --- go.mod | 4 +- go.sum | 8 +-- .../libraries/applicationprofile/exec_test.go | 14 +++-- tests/component_test.go | 32 ++++++----- .../resources/r0002-files-access-enabled.yaml | 57 +++++++++++++++++++ 5 files changed, 91 insertions(+), 24 deletions(-) create mode 100644 tests/resources/r0002-files-access-enabled.yaml diff --git a/go.mod b/go.mod index b3fde07433..3f1a87345c 100644 --- a/go.mod +++ b/go.mod @@ -34,8 +34,8 @@ require ( github.com/joncrlsn/dque v0.0.0-20241024143830-7723fd131a64 github.com/kubescape/backend v0.0.39 github.com/kubescape/go-logger v0.0.32 - github.com/kubescape/k8s-interface v0.0.213 - github.com/kubescape/storage v0.0.278 + github.com/kubescape/k8s-interface v0.0.214 + github.com/kubescape/storage v0.0.287 github.com/kubescape/workerpool v0.0.0-20250526074519-0e4a4e7f44cf github.com/moby/sys/mountinfo v0.7.2 github.com/oleiade/lane/v2 v2.0.0 diff --git a/go.sum b/go.sum index dc20737c95..d82f34163d 100644 --- a/go.sum +++ b/go.sum @@ -883,10 +883,10 @@ github.com/kubescape/backend v0.0.39 h1:B1QRfKCSFlzuE+jWOnk/l7EpH71/Q3n14KKq0QSn github.com/kubescape/backend v0.0.39/go.mod h1:cMEGP8cXUZgY89YU4GRBGIla9HZW7grZsUtlCwvZgAE= github.com/kubescape/go-logger v0.0.32 h1:4mI+XJOV8VFCMewrEE9VIFEIOhzXokYT3nFpNfXf4fM= github.com/kubescape/go-logger v0.0.32/go.mod h1:Alj7JBQ8/WCxbXe8Ura6ZheSRK45E0p21M3xeqedX90= -github.com/kubescape/k8s-interface v0.0.213 h1:JaEVzgE5qwQ3rEjQ8tBMp48YX4yveitLfYNaCIk8j/A= -github.com/kubescape/k8s-interface v0.0.213/go.mod h1:WNYUG93aZ5kDmuaRKFLtVhp18Yc6EfaHdD1gLYtVTN4= -github.com/kubescape/storage v0.0.278 h1:/pOtKul443yb2Fzg/4MFq29oOaFoJg1okQaCGbcEVOk= -github.com/kubescape/storage v0.0.278/go.mod h1:FpV6tCrYXlp2kKWza4yr7zf2Y1q7IGgx871ndN7SMNo= +github.com/kubescape/k8s-interface v0.0.214 h1:j7KP0/5VvYOoQdBGV2+gRM3qnR8PWLAGF8RM/k/DmJ0= +github.com/kubescape/k8s-interface v0.0.214/go.mod h1:WNYUG93aZ5kDmuaRKFLtVhp18Yc6EfaHdD1gLYtVTN4= +github.com/kubescape/storage v0.0.287 h1:3POQZ4xiGTstVVGpHO94rMPQhK7v079vBBr4C/EuePM= +github.com/kubescape/storage v0.0.287/go.mod h1:ARiTDaeDWLqEcOIbH+zz4dwdMEVxubfu5X5ehdDOqPc= github.com/kubescape/syft v1.32.0-ks.2 h1:xdUksUmKEyyVKsTfJDYW8Z5HawVJtelsUolPOsWtDx0= github.com/kubescape/syft v1.32.0-ks.2/go.mod h1:E6Kd4iBM2ljUOUQvSt7hVK6vBwaHkMXwcvBZmGMSY5o= github.com/kubescape/workerpool v0.0.0-20250526074519-0e4a4e7f44cf h1:hI0jVwrB6fT4GJWvuUjzObfci1CUknrZdRHfnRVtKM0= diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/exec_test.go b/pkg/rulemanager/cel/libraries/applicationprofile/exec_test.go index 625559e67c..944ac23ddc 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/exec_test.go +++ b/pkg/rulemanager/cel/libraries/applicationprofile/exec_test.go @@ -9,6 +9,7 @@ import ( "github.com/kubescape/node-agent/pkg/objectcache" objectcachev1 "github.com/kubescape/node-agent/pkg/objectcache/v1" "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + "github.com/kubescape/storage/pkg/registry/file/dynamicpathdetector" "github.com/stretchr/testify/assert" ) @@ -309,8 +310,11 @@ func TestExecWithArgsNoProfile(t *testing.T) { // TestExecWithArgsWildcardInProfile exercises wildcard tokens inside a // user-defined ApplicationProfile's exec arg vector: // -// "⋯" (DynamicIdentifier) — matches exactly one argument position. -// "*" (WildcardIdentifier) — matches zero or more consecutive args. +// "⋯" (DynamicIdentifier) — matches exactly one argument position. +// "⋯⋯" (ExecArgsWildcard) — matches zero or more consecutive args. +// +// As of storage v0.0.287 the exec-args zero-or-more wildcard is "⋯⋯" +// (ExecArgsWildcard); "*" is a literal character in exec args. // // The runtime exec arg vector is matched against the profile via // dynamicpathdetector.CompareExecArgs (added in @@ -344,7 +348,7 @@ func TestExecWithArgsWildcardInProfile(t *testing.T) { // sh -c with any trailing payload (zero or more args). { Path: "/bin/sh", - Args: []string{"-c", "*"}, + Args: []string{"-c", dynamicpathdetector.ExecArgsWildcard}, }, // ls -l in any directory — single trailing position. { @@ -354,7 +358,7 @@ func TestExecWithArgsWildcardInProfile(t *testing.T) { // echo with any number of greeting words after a literal anchor. { Path: "/bin/echo", - Args: []string{"hello", "*"}, + Args: []string{"hello", dynamicpathdetector.ExecArgsWildcard}, }, }, }) @@ -385,7 +389,7 @@ func TestExecWithArgsWildcardInProfile(t *testing.T) { // sh -c with arbitrary trailing payload {"sh -c with single command", "/bin/sh", []string{"-c", "echo hi"}, true}, {"sh -c with multi-token command", "/bin/sh", []string{"-c", "while", "true;", "do", "sleep", "1;", "done"}, true}, - {"sh -c with no trailing args (* matches zero)", "/bin/sh", []string{"-c"}, true}, + {"sh -c with no trailing args (⋯⋯ matches zero)", "/bin/sh", []string{"-c"}, true}, {"sh -x — wrong flag", "/bin/sh", []string{"-x", "echo hi"}, false}, // ls -l in any directory diff --git a/tests/component_test.go b/tests/component_test.go index bf943db8b8..cb776697d9 100644 --- a/tests/component_test.go +++ b/tests/component_test.go @@ -1588,6 +1588,19 @@ func Test_27_ApplicationProfileOpens(t *testing.T) { start := time.Now() defer tearDownTest(t, start) + // R0002 ("Files Access Anomalies in container") ships disabled in the + // bundle (rulelibrary default). Enable it for THIS test's cluster only by + // applying an override Rules CRD — without touching default-rules.yaml. The + // rules-watcher filters disabled rules before its per-ID merge, so it skips + // the disabled bundle copy and uses this enabled one. Each component-test + // matrix job runs on its own cluster, so this stays isolated. + r0002Override := path.Join(utils.CurrentDir(), "resources/r0002-files-access-enabled.yaml") + require.Equal(t, 0, testutils.RunCommand("kubectl", "apply", "--validate=false", "-f", r0002Override), + "enable R0002 override") + defer testutils.RunCommand("kubectl", "delete", "--ignore-not-found", "-f", r0002Override) + // allow the rules-watcher to pick up the newly enabled rule + time.Sleep(10 * time.Second) + const ruleName = "Files Access Anomalies in container" const profileName = "nginx-regex-profile" @@ -2268,11 +2281,11 @@ func Test_32_UnexpectedProcessArguments(t *testing.T) { // path the caller invoked. // // pod startup: sleep - {Path: "/bin/sleep", Args: []string{"/bin/sleep", dynamicpathdetector.WildcardIdentifier}}, + {Path: "/bin/sleep", Args: []string{"/bin/sleep", dynamicpathdetector.ExecArgsWildcard}}, // sh -c - {Path: "/bin/sh", Args: []string{"/bin/sh", "-c", dynamicpathdetector.WildcardIdentifier}}, + {Path: "/bin/sh", Args: []string{"/bin/sh", "-c", dynamicpathdetector.ExecArgsWildcard}}, // echo hello - {Path: "/bin/echo", Args: []string{"/bin/echo", "hello", dynamicpathdetector.WildcardIdentifier}}, + {Path: "/bin/echo", Args: []string{"/bin/echo", "hello", dynamicpathdetector.ExecArgsWildcard}}, // curl -s {Path: "/usr/bin/curl", Args: []string{"/usr/bin/curl", "-s", dynamicpathdetector.DynamicIdentifier}}, // curl -s file:///etc/hosts file:///etc/hostname @@ -2295,9 +2308,9 @@ func Test_32_UnexpectedProcessArguments(t *testing.T) { // retained for environments where exepath resolves // to the as-invoked path (non-symlinked utilities; // fexecve / argv[0] fallback in resolveExecPath). - {Path: "/bin/busybox", Args: []string{"/bin/sleep", dynamicpathdetector.WildcardIdentifier}}, - {Path: "/bin/busybox", Args: []string{"/bin/sh", "-c", dynamicpathdetector.WildcardIdentifier}}, - {Path: "/bin/busybox", Args: []string{"/bin/echo", "hello", dynamicpathdetector.WildcardIdentifier}}, + {Path: "/bin/busybox", Args: []string{"/bin/sleep", dynamicpathdetector.ExecArgsWildcard}}, + {Path: "/bin/busybox", Args: []string{"/bin/sh", "-c", dynamicpathdetector.ExecArgsWildcard}}, + {Path: "/bin/busybox", Args: []string{"/bin/echo", "hello", dynamicpathdetector.ExecArgsWildcard}}, // Literal "*" arg: echo invoked with a GENUINE literal "*" // (e.g. an unexpanded glob), recorded verbatim. Under the // symbol contract a "*" in argv is DATA, not a wildcard, so @@ -2636,13 +2649,6 @@ func Test_32_UnexpectedProcessArguments(t *testing.T) { // merge). Mirrors storage's TestAP_LiteralStarVsDynamic. // ----------------------------------------------------------------- t.Run("echo_literal_star_does_not_broaden_R0040", func(t *testing.T) { - // Pinned storage (v0.0.278) treats "*" (WildcardIdentifier) as a - // zero-or-more exec-args wildcard, so a literal "*" arg DOES broaden - // the match. The "*"-is-literal semantics this case asserts arrived in - // storage v0.0.287 (ExecArgsWildcard "⋯⋯"). Re-enable together with the - // coordinated storage v0.0.278 -> v0.0.287 bump (which also rewrites - // exec_test.go's "*" cases). - t.Skip("requires storage v0.0.287 '*'-is-literal semantics; main pins v0.0.278 where '*' is a wildcard") wl, base := setup(t) var alerts []testutils.Alert require.Eventually(t, func() bool { diff --git a/tests/resources/r0002-files-access-enabled.yaml b/tests/resources/r0002-files-access-enabled.yaml new file mode 100644 index 0000000000..4793922a46 --- /dev/null +++ b/tests/resources/r0002-files-access-enabled.yaml @@ -0,0 +1,57 @@ +apiVersion: kubescape.io/v1 +kind: Rules +metadata: + name: r0002-files-access-enabled + namespace: kubescape + labels: + app: kubescape +spec: + rules: + - name: Files Access Anomalies in container + enabled: true + id: R0002 + description: Detects unexpected file access that is not in the baseline + expressions: + message: '''Unexpected file access detected: '' + event.comm + '' with PID '' + + string(event.pid) + '' to '' + event.path' + uniqueId: event.comm + '_' + event.path + ruleExpression: + - eventType: open + expression: "(event.path.startsWith('/etc/') || event.path.startsWith('/var/log/')\ + \ || event.path.startsWith('/var/run/') || event.path.startsWith('/run/')\ + \ || event.path.startsWith('/var/spool/cron/') || event.path.startsWith('/var/www/')\ + \ || event.path.startsWith('/var/lib/') || event.path.startsWith('/opt/')\ + \ || event.path.startsWith('/usr/local/') || event.path.startsWith('/app/')\ + \ || event.path == '/.dockerenv' || event.path == '/proc/self/environ')\ + \ && !(event.path.startsWith('/run/secrets/kubernetes.io/serviceaccount')\ + \ ||\n event.path.startsWith('/var/run/secrets/kubernetes.io/serviceaccount')\ + \ ||\n event.path.startsWith('/tmp'))\n&& !ap.was_path_opened(event.containerId,\ + \ event.path)\n" + profileDependency: 0 + profileDataRequired: + opens: + - prefix: /etc/ + - prefix: /var/log/ + - prefix: /var/run/ + - prefix: /run/ + - prefix: /var/spool/cron/ + - prefix: /var/www/ + - prefix: /var/lib/ + - prefix: /opt/ + - prefix: /usr/local/ + - prefix: /app/ + - exact: /.dockerenv + - exact: /proc/self/environ + severity: 1 + supportPolicy: false + isTriggerAlert: false + mitreTactic: TA0009 + mitreTechnique: T1005 + tags: + - context:kubernetes + - context:container + - anomaly + - file + - open + - applicationprofile + From 104b834163741e69d385b090a69fedf3a269a3f7 Mon Sep 17 00:00:00 2001 From: matthyx Date: Fri, 26 Jun 2026 13:23:35 +0200 Subject: [PATCH 4/4] test: also enable R0002 in Test_02; share the enable helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test_02_AllAlertsFromMaliciousApp expects "Files Access Anomalies in container" (R0002) to fire, but R0002 ships disabled in the synced bundle — so last commit's rename fix alone left Test_02 red on R0002. Extract enableR0002ForTest (apply the override Rules CRD, return a cleanup func) and use it from both Test_02 and Test_27, replacing Test_27's inline copy. Docs-exempt: test-only change. Signed-off-by: matthyx Co-Authored-By: Claude Opus 4.8 --- tests/component_test.go | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/tests/component_test.go b/tests/component_test.go index cb776697d9..04fe03cf9a 100644 --- a/tests/component_test.go +++ b/tests/component_test.go @@ -115,9 +115,28 @@ func Test_01_BasicAlertTest(t *testing.T) { testutils.AssertNetworkNeighborhoodNotContains(t, nn, "nginx", []string{"ebpf.io."}, []string{}) } +// enableR0002ForTest applies an override Rules CRD that enables R0002 ("Files +// Access Anomalies in container") — which ships disabled in the bundle +// (rulelibrary default) — for the calling test's cluster only, without +// modifying tests/chart/templates/node-agent/default-rules.yaml. The +// rules-watcher filters disabled rules before its per-ID merge, so it uses this +// enabled copy in place of the disabled bundle one. Each component-test matrix +// job runs on its own cluster, so this stays isolated. Use as: +// +// defer enableR0002ForTest(t)() +func enableR0002ForTest(t *testing.T) func() { + t.Helper() + override := path.Join(utils.CurrentDir(), "resources/r0002-files-access-enabled.yaml") + require.Equal(t, 0, testutils.RunCommand("kubectl", "apply", "--validate=false", "-f", override), "enable R0002 override") + // allow the rules-watcher to pick up the newly enabled rule + time.Sleep(10 * time.Second) + return func() { testutils.RunCommand("kubectl", "delete", "--ignore-not-found", "-f", override) } +} + func Test_02_AllAlertsFromMaliciousApp(t *testing.T) { start := time.Now() defer tearDownTest(t, start) + defer enableR0002ForTest(t)() // Create a random namespace ns := testutils.NewRandomNamespace() @@ -1587,19 +1606,7 @@ func Test_24_ProcessTreeDepthTest(t *testing.T) { func Test_27_ApplicationProfileOpens(t *testing.T) { start := time.Now() defer tearDownTest(t, start) - - // R0002 ("Files Access Anomalies in container") ships disabled in the - // bundle (rulelibrary default). Enable it for THIS test's cluster only by - // applying an override Rules CRD — without touching default-rules.yaml. The - // rules-watcher filters disabled rules before its per-ID merge, so it skips - // the disabled bundle copy and uses this enabled one. Each component-test - // matrix job runs on its own cluster, so this stays isolated. - r0002Override := path.Join(utils.CurrentDir(), "resources/r0002-files-access-enabled.yaml") - require.Equal(t, 0, testutils.RunCommand("kubectl", "apply", "--validate=false", "-f", r0002Override), - "enable R0002 override") - defer testutils.RunCommand("kubectl", "delete", "--ignore-not-found", "-f", r0002Override) - // allow the rules-watcher to pick up the newly enabled rule - time.Sleep(10 * time.Second) + defer enableR0002ForTest(t)() const ruleName = "Files Access Anomalies in container" const profileName = "nginx-regex-profile"