Skip to content
Open
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
378 changes: 378 additions & 0 deletions tests/component_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"slices"
"sort"
"strconv"
"strings"
"testing"
"time"

Expand Down Expand Up @@ -1569,3 +1570,380 @@ func Test_24_ProcessTreeDepthTest(t *testing.T) {

t.Logf("Found alerts for the process tree depth: %v", alerts)
}

func Test_27_ApplicationProfileOpens(t *testing.T) {
start := time.Now()
defer tearDownTest(t, start)

const ruleName = "Files Access Anomalies in container"
const profileName = "nginx-regex-profile"

// --- result tracking for end-of-test summary ---
type subtestResult struct {
name string
profilePath string
filePath string
expectAlert bool
passed bool
detail string
}
var results []subtestResult
addResult := func(name, profilePath, filePath string, expectAlert, passed bool, detail string) {
results = append(results, subtestResult{name, profilePath, filePath, expectAlert, passed, detail})
}
defer func() {
t.Log("\n========== Test_27 Summary ==========")
anyFailed := false
for _, r := range results {
status := "PASS"
if !r.passed {
status = "FAIL"
anyFailed = true
}
expect := "expect alert"
if !r.expectAlert {
expect = "expect NO alert"
}
t.Logf(" [%s] %-35s profile=%-25s file=%-25s %s", status, r.name, r.profilePath, r.filePath, expect)
if !r.passed {
t.Logf(" -> %s", r.detail)
}
}
if !anyFailed {
t.Log(" All subtests passed.")
}
t.Log("======================================")
}()

// deployWithProfile creates a user-defined ApplicationProfile with the
// given Opens list, polls until it is retrievable from storage, then
// deploys nginx with the kubescape.io/user-defined-profile label
// pointing at it, and waits for the pod to be ready.
deployWithProfile := func(t *testing.T, opens []v1beta1.OpenCalls) *testutils.TestWorkload {
t.Helper()
ns := testutils.NewRandomNamespace()

profile := &v1beta1.ApplicationProfile{
ObjectMeta: metav1.ObjectMeta{
Name: profileName,
Namespace: ns.Name,
},
Spec: v1beta1.ApplicationProfileSpec{
Architectures: []string{"amd64"},
Containers: []v1beta1.ApplicationProfileContainer{
{
Name: "nginx",
Execs: []v1beta1.ExecCalls{
{Path: "/bin/cat", Args: []string{"/bin/cat"}},
},
Opens: opens,
},
},
},
}

k8sClient := k8sinterface.NewKubernetesApi()
storageClient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig)
_, err := storageClient.ApplicationProfiles(ns.Name).Create(
context.Background(), profile, metav1.CreateOptions{})
require.NoError(t, err, "create user-defined profile %q in ns %s", profileName, ns.Name)

// Poll until the profile is retrievable from storage before deploying.
// Node-agent does a single fetch on container start with no retry.
require.Eventually(t, func() bool {
_, apErr := storageClient.ApplicationProfiles(ns.Name).Get(
context.Background(), profileName, v1.GetOptions{})
return apErr == nil
}, 30*time.Second, 1*time.Second, "AP must be retrievable from storage before deploying the pod")

wl, err := testutils.NewTestWorkload(ns.Name,
path.Join(utils.CurrentDir(), "resources/nginx-user-profile-deployment.yaml"))
require.NoError(t, err, "create workload in ns %s", ns.Name)
require.NoError(t, wl.WaitForReady(80), "workload not ready in ns %s", ns.Name)

// Wait for node-agent to load the user-defined profile into cache.
time.Sleep(10 * time.Second)
return wl
}

// triggerAndGetAlerts execs cat on the given path, then polls for alerts
// up to 60s to avoid race conditions with alert propagation.
triggerAndGetAlerts := func(t *testing.T, wl *testutils.TestWorkload, filePath string) []testutils.Alert {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This helper is shared by both the "expect alert" and "expect no alert" cases, but right now the polling only waits for GetAlerts() to become reachable. In practice that can return immediately with an empty list, so the positive cases still depend on the fixed 10 * time.Second sleep below.

I think this is less flaky if triggerAndGetAlerts takes an expectAlert bool and polls for the actual condition in the positive cases:

require.Eventually(t, func() bool {
	alerts, err = testutils.GetAlerts(wl.Namespace)
	return err == nil && hasAlert(alerts)
}, 60*time.Second, 5*time.Second, "expected R0002 alert in ns %s", wl.Namespace)

For the no-alert cases, keep the stabilization sleep/final fetch path so late alerts are still caught.

t.Helper()
stdout, stderr, err := wl.ExecIntoPod([]string{"cat", filePath}, "nginx")
if err != nil {
t.Errorf("exec 'cat %s' in container nginx failed: %v (stdout=%q stderr=%q)", filePath, err, stdout, stderr)
}
// Poll for alerts — they may take time to propagate through
// eBPF → node-agent → alertmanager.
var alerts []testutils.Alert
require.Eventually(t, func() bool {
alerts, err = testutils.GetAlerts(wl.Namespace)
return err == nil
}, 60*time.Second, 5*time.Second, "alerts must be retrievable from ns %s", wl.Namespace)
// Give extra time for all alerts to arrive after first successful fetch.
time.Sleep(10 * time.Second)
alerts, err = testutils.GetAlerts(wl.Namespace)
require.NoError(t, err, "get alerts from ns %s", wl.Namespace)
return alerts
}
Comment on lines +1671 to +1689
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

triggerAndGetAlerts polling does not actually wait for alerts — risk of flaky positive cases.

The Eventually condition only requires GetAlerts to return without error. Since testutils.GetAlerts just performs an HTTP fetch from Alertmanager and decodes the response, it returns err == nil on the very first iteration (even with an empty alert list). So Eventually exits immediately, and the only real wait before the final fetch is the fixed time.Sleep(10 * time.Second) on line 1685. The advertised 60s budget is never used.

For the "expect alert" subtests (exact_path_mismatch, ellipsis_rejects_multi_segment), this can produce false negatives if eBPF → node-agent → Alertmanager propagation takes longer than 10s on a slower CI runner.

Consider polling on the actual condition (presence of the target alert), and only fall back to the timeout when expecting absence:

♻️ Proposed predicate-based polling
-	triggerAndGetAlerts := func(t *testing.T, wl *testutils.TestWorkload, filePath string) []testutils.Alert {
+	// triggerAndGetAlerts execs cat and waits up to 60s, returning early once
+	// the expected alert state is observed. For expectAlert=true it returns as
+	// soon as the R0002 alert is seen; for expectAlert=false it waits the full
+	// stabilization window so late alerts are not missed.
+	triggerAndGetAlerts := func(t *testing.T, wl *testutils.TestWorkload, filePath string, expectAlert bool) []testutils.Alert {
 		t.Helper()
 		stdout, stderr, err := wl.ExecIntoPod([]string{"cat", filePath}, "nginx")
 		if err != nil {
 			t.Errorf("exec 'cat %s' in container nginx failed: %v (stdout=%q stderr=%q)", filePath, err, stdout, stderr)
 		}
-		// Poll for alerts — they may take time to propagate through
-		// eBPF → node-agent → alertmanager.
 		var alerts []testutils.Alert
-		require.Eventually(t, func() bool {
-			alerts, err = testutils.GetAlerts(wl.Namespace)
-			return err == nil
-		}, 60*time.Second, 5*time.Second, "alerts must be retrievable from ns %s", wl.Namespace)
-		// Give extra time for all alerts to arrive after first successful fetch.
-		time.Sleep(10 * time.Second)
-		alerts, err = testutils.GetAlerts(wl.Namespace)
-		require.NoError(t, err, "get alerts from ns %s", wl.Namespace)
+		deadline := time.Now().Add(60 * time.Second)
+		for time.Now().Before(deadline) {
+			alerts, err = testutils.GetAlerts(wl.Namespace)
+			require.NoError(t, err, "get alerts from ns %s", wl.Namespace)
+			if expectAlert && hasAlert(alerts) {
+				return alerts
+			}
+			time.Sleep(2 * time.Second)
+		}
 		return alerts
 	}

If you keep the current shape, please at least replace the no-op Eventually with a single sleep so the intent is clear, or accept that propagation-related flakiness may surface on slow runners.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/component_test.go` around lines 1671 - 1689, The polling currently only
waits for testutils.GetAlerts to return without error, which can succeed
immediately with an empty list; update triggerAndGetAlerts so the
require.Eventually predicate checks for actual alerts (e.g., call
testutils.GetAlerts into alerts and return err==nil && len(alerts) > 0) instead
of just err==nil so the 60s budget is actually used for propagation; keep a
final GetAlerts and NoError as now. If some tests expect no alerts, split or add
a separate helper that polls for absence (err==nil && len(alerts) == 0) or use a
single sleep there. Ensure you update references in triggerAndGetAlerts and keep
function names testutils.GetAlerts and require.Eventually unchanged.


// hasAlert checks whether an R0002 alert exists for comm=cat, container=nginx.
hasAlert := func(alerts []testutils.Alert) bool {
for _, a := range alerts {
if a.Labels["rule_name"] == ruleName &&
a.Labels["comm"] == "cat" &&
a.Labels["container_name"] == "nginx" {
return true
}
}
return false
}

// ---------------------------------------------------------------
// 1a. Recorded (auto-learned) profile must use absolute paths.
// There must be no "." in the Opens paths.
// ---------------------------------------------------------------
t.Run("recorded_profile_absolute_paths", func(t *testing.T) {
ns := testutils.NewRandomNamespace()
wl, err := testutils.NewTestWorkload(ns.Name,
path.Join(utils.CurrentDir(), "resources/nginx-deployment.yaml"))
require.NoError(t, err)
require.NoError(t, wl.WaitForReady(80))
require.NoError(t, wl.WaitForApplicationProfileCompletion(80))

profile, err := wl.GetApplicationProfile()
require.NoError(t, err, "get application profile")

passed := true
for _, container := range profile.Spec.Containers {
for _, open := range container.Opens {
if !strings.HasPrefix(open.Path, "/") {
t.Errorf("recorded path must be absolute: got %q (container %s)", open.Path, container.Name)
passed = false
}
if open.Path == "." {
t.Errorf("recorded path must not be relative dot: got %q (container %s)", open.Path, container.Name)
passed = false
}
}
}
Comment on lines +1718 to +1730
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

recorded_profile_absolute_paths passes vacuously if Opens is empty.

If for any reason the auto-learned profile contains no Opens entries (e.g. timing or a learning regression), the nested loops never execute and the subtest reports PASS without verifying anything. Add a positive cardinality assertion so a regression that drops Opens entirely is also caught.

🛡️ Proposed assertion
 		passed := true
+		totalOpens := 0
 		for _, container := range profile.Spec.Containers {
+			totalOpens += len(container.Opens)
 			for _, open := range container.Opens {
@@
 			}
 		}
+		if totalOpens == 0 {
+			t.Errorf("recorded profile has no Opens entries — cannot validate absolute paths")
+			passed = false
+		}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
passed := true
for _, container := range profile.Spec.Containers {
for _, open := range container.Opens {
if !strings.HasPrefix(open.Path, "/") {
t.Errorf("recorded path must be absolute: got %q (container %s)", open.Path, container.Name)
passed = false
}
if open.Path == "." {
t.Errorf("recorded path must not be relative dot: got %q (container %s)", open.Path, container.Name)
passed = false
}
}
}
passed := true
totalOpens := 0
for _, container := range profile.Spec.Containers {
totalOpens += len(container.Opens)
for _, open := range container.Opens {
if !strings.HasPrefix(open.Path, "/") {
t.Errorf("recorded path must be absolute: got %q (container %s)", open.Path, container.Name)
passed = false
}
if open.Path == "." {
t.Errorf("recorded path must not be relative dot: got %q (container %s)", open.Path, container.Name)
passed = false
}
}
}
if totalOpens == 0 {
t.Errorf("recorded profile has no Opens entries — cannot validate absolute paths")
passed = false
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/component_test.go` around lines 1718 - 1730, The test currently
iterates over profile.Spec.Containers and container.Opens but will pass
vacuously if no Opens exist; add a positive cardinality assertion before the
loops: compute totalOpens by summing len(container.Opens) for each container and
call t.Fatalf or t.Errorf if totalOpens == 0 to fail the test, then proceed with
the existing checks (use profile, profile.Spec.Containers, container.Opens and
t.Errorf/t.Fatalf as appropriate) so a regression that drops all Opens is
detected.

detail := ""
if !passed {
detail = "found non-absolute or '.' paths in recorded profile"
}
addResult("recorded_profile_absolute_paths", "(auto-learned)", "(nginx startup)", false, passed, detail)
})

// ---------------------------------------------------------------
// 1b. User-defined profile wildcard tests.
// Each sub-test deploys nginx in its own namespace with a
// different Opens pattern and verifies R0002 behaviour.
// ---------------------------------------------------------------

// 1b-1: Exact path — profile has the exact file => no alert.
t.Run("exact_path_match", func(t *testing.T) {
profilePath := "/etc/nginx/nginx.conf"
filePath := "/etc/nginx/nginx.conf"
wl := deployWithProfile(t, []v1beta1.OpenCalls{
{Path: profilePath, Flags: []string{"O_RDONLY"}},
{Path: "/etc/ld.so.cache", Flags: []string{"O_RDONLY", "O_CLOEXEC"}}, // dynamic linker opens this on every exec
})
alerts := triggerAndGetAlerts(t, wl, filePath)
got := hasAlert(alerts)
if got {
t.Errorf("expected NO R0002 alert: profile allows %q, opened %q, but alert fired", profilePath, filePath)
}
addResult("exact_path_match", profilePath, filePath, false, !got,
fmt.Sprintf("got %d alerts, expected none for cat", len(alerts)))
})

// 1b-2: Exact path — profile has a DIFFERENT file => alert.
t.Run("exact_path_mismatch", func(t *testing.T) {
profilePath := "/etc/nginx/nginx.conf"
filePath := "/etc/hostname"
wl := deployWithProfile(t, []v1beta1.OpenCalls{
{Path: profilePath, Flags: []string{"O_RDONLY"}},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cat also opens /etc/ld.so.cache on exec. Since this case is looking for any R0002 from cat, omitting the loader cache here can make the subtest pass for the wrong reason even if /etc/hostname matching regresses.

Suggested change
{Path: profilePath, Flags: []string{"O_RDONLY"}},
{Path: profilePath, Flags: []string{"O_RDONLY"}},
{Path: "/etc/ld.so.cache", Flags: []string{"O_RDONLY", "O_CLOEXEC"}}, // dynamic linker opens this on every exec

})
alerts := triggerAndGetAlerts(t, wl, filePath)
got := hasAlert(alerts)
if !got {
t.Errorf("expected R0002 alert: profile only allows %q, opened %q, but no alert", profilePath, filePath)
}
addResult("exact_path_mismatch", profilePath, filePath, true, got,
fmt.Sprintf("got %d alerts, expected at least one for cat", len(alerts)))
})

// 1b-3: Ellipsis ⋯ matches single segment — /etc/⋯ covers /etc/hostname.
t.Run("ellipsis_single_segment_match", func(t *testing.T) {
profilePath := "/etc/" + dynamicpathdetector.DynamicIdentifier
filePath := "/etc/hostname"
wl := deployWithProfile(t, []v1beta1.OpenCalls{
{Path: profilePath, Flags: []string{"O_RDONLY"}},
})
alerts := triggerAndGetAlerts(t, wl, filePath)
got := hasAlert(alerts)
if got {
t.Errorf("expected NO R0002 alert: profile %q should match %q (single segment), but alert fired", profilePath, filePath)
}
addResult("ellipsis_single_segment_match", profilePath, filePath, false, !got,
fmt.Sprintf("got %d alerts, expected none for cat", len(alerts)))
})

// 1b-4: Ellipsis ⋯ rejects multi-segment — /etc/⋯ does NOT cover
// /etc/nginx/nginx.conf (two segments past /etc/).
t.Run("ellipsis_rejects_multi_segment", func(t *testing.T) {
profilePath := "/etc/" + dynamicpathdetector.DynamicIdentifier
filePath := "/etc/nginx/nginx.conf"
wl := deployWithProfile(t, []v1beta1.OpenCalls{
{Path: profilePath, Flags: []string{"O_RDONLY"}},
})
alerts := triggerAndGetAlerts(t, wl, filePath)
got := hasAlert(alerts)
if !got {
t.Errorf("expected R0002 alert: profile %q should NOT match %q (two segments), but no alert", profilePath, filePath)
}
addResult("ellipsis_rejects_multi_segment", profilePath, filePath, true, got,
fmt.Sprintf("got %d alerts, expected at least one for cat", len(alerts)))
})

// 1b-5: Wildcard * matches any depth — /etc/* covers /etc/nginx/nginx.conf.
t.Run("wildcard_matches_deep_path", func(t *testing.T) {
profilePath := "/etc/*"
filePath := "/etc/nginx/nginx.conf"
wl := deployWithProfile(t, []v1beta1.OpenCalls{
{Path: profilePath, Flags: []string{"O_RDONLY"}},
})
alerts := triggerAndGetAlerts(t, wl, filePath)
got := hasAlert(alerts)
if got {
t.Errorf("expected NO R0002 alert: profile %q should match %q (wildcard), but alert fired", profilePath, filePath)
}
addResult("wildcard_matches_deep_path", profilePath, filePath, false, !got,
fmt.Sprintf("got %d alerts, expected none for cat", len(alerts)))
})

// ---------------------------------------------------------------
// 1c. Deploy known-application-profile-wildcards.yaml (curl image)
// and verify that files under wildcard-covered opens paths
// produce no R0002 alert.
// ---------------------------------------------------------------
t.Run("wildcard_yaml_profile_allowed_opens", func(t *testing.T) {
ns := testutils.NewRandomNamespace()
wildcardProfileName := "fusioncore-profile-wildcards"

// Create the profile matching known-application-profile-wildcards.yaml.
profile := &v1beta1.ApplicationProfile{
ObjectMeta: metav1.ObjectMeta{
Name: wildcardProfileName,
Namespace: ns.Name,
},
Spec: v1beta1.ApplicationProfileSpec{
Architectures: []string{"amd64"},
Containers: []v1beta1.ApplicationProfileContainer{
{
Name: "curl",
ImageID: "docker.io/curlimages/curl@sha256:08e466006f0860e54fc299378de998935333e0e130a15f6f98482e9f8dab3058",
ImageTag: "docker.io/curlimages/curl:8.5.0",
Capabilities: []string{
"CAP_CHOWN", "CAP_DAC_OVERRIDE", "CAP_DAC_READ_SEARCH",
"CAP_SETGID", "CAP_SETPCAP", "CAP_SETUID", "CAP_SYS_ADMIN",
},
Execs: []v1beta1.ExecCalls{
{Path: "/bin/sleep", Args: []string{"/bin/sleep", "infinity"}},
{Path: "/bin/cat", Args: []string{"/bin/cat"}},
{Path: "/usr/bin/curl", Args: []string{"/usr/bin/curl", "-sm2", "fusioncore.ai"}},
},
Opens: []v1beta1.OpenCalls{
{Path: "/etc/*", Flags: []string{"O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"}},
{Path: "/etc/ssl/openssl.cnf", Flags: []string{"O_RDONLY", "O_LARGEFILE"}},
{Path: "/home/*", Flags: []string{"O_RDONLY", "O_LARGEFILE"}},
{Path: "/lib/*", Flags: []string{"O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"}},
{Path: "/usr/lib/*", Flags: []string{"O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"}},
{Path: "/usr/local/lib/*", Flags: []string{"O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"}},
{Path: "/proc/*/cgroup", Flags: []string{"O_RDONLY", "O_CLOEXEC"}},
{Path: "/proc/*/kernel/cap_last_cap", Flags: []string{"O_RDONLY", "O_CLOEXEC"}},
{Path: "/proc/*/mountinfo", Flags: []string{"O_RDONLY", "O_CLOEXEC"}},
{Path: "/proc/*/task/*/fd", Flags: []string{"O_RDONLY", "O_DIRECTORY", "O_CLOEXEC"}},
{Path: "/sys/fs/cgroup/cpu.max", Flags: []string{"O_RDONLY", "O_CLOEXEC"}},
{Path: "/sys/kernel/mm/transparent_hugepage/hpage_pmd_size", Flags: []string{"O_RDONLY"}},
{Path: "/7/setgroups", Flags: []string{"O_RDONLY", "O_CLOEXEC"}},
{Path: "/runc", Flags: []string{"O_RDONLY", "O_CLOEXEC"}},
},
Comment on lines +1857 to +1872
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find the source-of-truth wildcard profile YAML referenced in the comment and
# print its Opens entries so we can confirm the Go literal mirrors it exactly.
fd -t f -i 'known-application-profile-wildcards' --exec sh -c 'echo "===== {} ====="; cat {}'

Repository: kubescape/node-agent

Length of output: 46


🏁 Script executed:

# Read the context around line 1827 to see the actual comment
head -1872 tests/component_test.go | tail -50 | cat -n

Repository: kubescape/node-agent

Length of output: 3123


🏁 Script executed:

# Search for YAML files with "profile" or "wildcard" in the name using broader patterns
fd -t f '\.yaml' | grep -i 'profile\|wildcard'

Repository: kubescape/node-agent

Length of output: 209


🏁 Script executed:

# Also search for any mentions of these specific paths in the codebase
rg '7/setgroups|/runc' --type go -B 2 -A 2

Repository: kubescape/node-agent

Length of output: 563


🏁 Script executed:

# Check the curl user profile wildcards deployment YAML
cat tests/resources/curl-user-profile-wildcards-deployment.yaml

Repository: kubescape/node-agent

Length of output: 597


🏁 Script executed:

# Search for any comments or documentation mentioning /7/setgroups or /runc
rg '/7/setgroups|/runc' --type yaml

Repository: kubescape/node-agent

Length of output: 46


🏁 Script executed:

# Search for any TODO, FIXME, or similar comments near these paths
rg 'TODO|FIXME|HACK|BUG|XXX' tests/component_test.go | head -20

Repository: kubescape/node-agent

Length of output: 327


Remove or correct the suspicious Opens paths /7/setgroups and /runc.

These paths appear to be errors. The comment claims the profile "matches known-application-profile-wildcards.yaml", but that file does not exist in the repository. Additionally, neither path appears in any YAML profile files — they only exist in this Go test. The path /7/setgroups looks like a truncated /proc/<pid>/setgroups, and /runc appears incomplete. Without a documented source reference, these are dead matchers that don't exercise real wildcard logic.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/component_test.go` around lines 1857 - 1872, In tests/component_test.go
inside the Opens array (the OpenCalls entries), remove or correct the two
suspicious Path entries: replace {Path: "/7/setgroups", ...} with {Path:
"/proc/*/setgroups", Flags: []string{"O_RDONLY", "O_CLOEXEC"}} and either remove
the {Path: "/runc", ...} entry or replace it with a real path (e.g. {Path:
"/run/runc", Flags: []string{"O_RDONLY", "O_CLOEXEC"}}) so the test uses valid
wildcarded /proc and runtime paths (look for the Opens slice in the test to
update these entries).

Syscalls: []string{
"arch_prctl", "bind", "brk", "capget", "capset", "chdir",
"clone", "close", "close_range", "connect", "epoll_ctl",
"epoll_pwait", "execve", "exit", "exit_group", "faccessat2",
"fchown", "fcntl", "fstat", "fstatfs", "futex", "getcwd",
"getdents64", "getegid", "geteuid", "getgid", "getpeername",
"getppid", "getsockname", "getsockopt", "gettid", "getuid",
"ioctl", "membarrier", "mmap", "mprotect", "munmap",
"nanosleep", "newfstatat", "open", "openat", "openat2",
"pipe", "poll", "prctl", "read", "recvfrom", "recvmsg",
"rt_sigaction", "rt_sigprocmask", "rt_sigreturn", "sendto",
"set_tid_address", "setgid", "setgroups", "setsockopt",
"setuid", "sigaltstack", "socket", "statx", "tkill",
"unknown", "write", "writev",
},
},
},
},
}

k8sClient := k8sinterface.NewKubernetesApi()
storageClient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig)
_, err := storageClient.ApplicationProfiles(ns.Name).Create(
context.Background(), profile, metav1.CreateOptions{})
require.NoError(t, err, "create wildcard profile %q in ns %s", wildcardProfileName, ns.Name)

// Poll until the profile is retrievable from storage before deploying.
require.Eventually(t, func() bool {
_, apErr := storageClient.ApplicationProfiles(ns.Name).Get(
context.Background(), wildcardProfileName, v1.GetOptions{})
return apErr == nil
}, 30*time.Second, 1*time.Second, "AP must be retrievable before deploying the pod")

wl, err := testutils.NewTestWorkload(ns.Name,
path.Join(utils.CurrentDir(), "resources/curl-user-profile-wildcards-deployment.yaml"))
require.NoError(t, err, "create curl workload in ns %s", ns.Name)
require.NoError(t, wl.WaitForReady(80), "curl workload not ready in ns %s", ns.Name)

// Wait for node-agent to load the user-defined profile into cache.
time.Sleep(10 * time.Second)

// Cat files that are covered by the wildcard opens.
allowedFiles := []string{
"/etc/hosts", // covered by /etc/*
"/etc/resolv.conf", // covered by /etc/*
"/etc/ssl/openssl.cnf", // exact match
}
for _, f := range allowedFiles {
stdout, stderr, err := wl.ExecIntoPod([]string{"cat", f}, "curl")
if err != nil {
t.Logf("exec 'cat %s' failed: %v (stdout=%q stderr=%q)", f, err, stdout, stderr)
}
Comment on lines +1921 to +1924
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If one of these cat commands fails, the subtest still passes as long as no alert arrives, which makes this branch vacuous. I'd fail fast here so the "allowed opens" assertion only runs after the opens were actually exercised.

Suggested change
stdout, stderr, err := wl.ExecIntoPod([]string{"cat", f}, "curl")
if err != nil {
t.Logf("exec 'cat %s' failed: %v (stdout=%q stderr=%q)", f, err, stdout, stderr)
}
stdout, stderr, err := wl.ExecIntoPod([]string{"cat", f}, "curl")
require.NoErrorf(t, err, "exec 'cat %s' in container curl failed (stdout=%q stderr=%q)", f, stdout, stderr)

}

// Poll for alerts to propagate.
time.Sleep(15 * time.Second)
alerts, err := testutils.GetAlerts(wl.Namespace)
require.NoError(t, err, "get alerts from ns %s", wl.Namespace)

var r0002Fired bool
for _, a := range alerts {
if a.Labels["rule_name"] == ruleName &&
a.Labels["comm"] == "cat" &&
a.Labels["container_name"] == "curl" {
r0002Fired = true
break
}
}
if r0002Fired {
t.Errorf("expected NO R0002 for files covered by wildcard opens, but alert fired")
}
addResult("wildcard_yaml_profile_allowed_opens",
"/etc/*, /etc/ssl/openssl.cnf", "/etc/hosts, /etc/resolv.conf, /etc/ssl/openssl.cnf",
false, !r0002Fired,
fmt.Sprintf("got R0002=%v, expected none for wildcard-covered files", r0002Fired))
})
}
Loading