diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/ap.go b/pkg/rulemanager/cel/libraries/applicationprofile/ap.go index ce86d7ab8..99ab11831 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/ap.go +++ b/pkg/rulemanager/cel/libraries/applicationprofile/ap.go @@ -130,6 +130,28 @@ func (l *apLibrary) Declarations() map[string][]cel.FunctionOpt { }), ), }, + // ap.was_path_opened_with_suffix and ap.was_path_opened_with_prefix + // — rule-author contract (CodeRabbit upstream PR #807 finding #7): + // + // These helpers answer "did any RECORDED concrete path open match + // this suffix/prefix?". When the profile-projection cache is in + // pass-through mode (no rule declared an Opens-projection slice, + // so cp.Opens.All == true), wildcard patterns in cp.Opens.Patterns + // are NOT scanned via string-level HasSuffix/HasPrefix because the + // pattern text contains '*' / '⋯' tokens whose string shape doesn't + // safely answer suffix/prefix questions (see open.go comment). + // Concrete-only Values are scanned. + // + // False-negative gap: if a profile entry is `/var/log/pods/*/foo.log`, + // the runtime path `/var/log/pods/web-7d6f/volumes/foo.log` actually + // matches this pattern, but `was_path_opened_with_suffix("/foo.log")` + // returns FALSE because the pattern text doesn't end in `/foo.log` + // literally. Rule authors who need wildcard-aware coverage should + // either: (a) declare an Opens projection slice in the rule's + // ProfileDataRequired (then SuffixHits/PrefixHits become authoritative + // and the projector pre-computes the hit map for wildcard entries), + // or (b) use ap.was_path_opened(path) which DOES run dynamic-segment + // matching over Patterns via CompareDynamic. "ap.was_path_opened_with_suffix": { cel.Overload( "ap_was_path_opened_with_suffix", []*cel.Type{cel.StringType, cel.StringType}, cel.BoolType, diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/open.go b/pkg/rulemanager/cel/libraries/applicationprofile/open.go index ec0a8310c..520ee3ae7 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/open.go +++ b/pkg/rulemanager/cel/libraries/applicationprofile/open.go @@ -46,6 +46,13 @@ func (l *apLibrary) wasPathOpened(containerID, path ref.Val) ref.Val { return types.Bool(false) } +// wasPathOpenedWithFlags answers whether the projected ApplicationProfile +// contains an open-entry whose path matches the given path. The flags +// argument is parsed and validated for shape but is not used for matching +// in v1 — the OpenFlagsByPath projection slice is out of scope for v1 +// (composite-key projection would balloon the cache footprint). When the +// flags-projection slice is added in a future spec revision, this helper +// becomes the path-AND-flag matcher and v1 callers continue to work. func (l *apLibrary) wasPathOpenedWithFlags(containerID, path, flags ref.Val) ref.Val { if l.objectCache == nil { return types.NewErr("objectCache is nil") @@ -105,14 +112,32 @@ func (l *apLibrary) wasPathOpenedWithSuffix(containerID, suffix ref.Val) ref.Val } if cp.Opens.All { - // All entries retained — scan to check for the suffix. + // All entries retained (no rule declared SuffixHits-style + // projection). Scan concrete entries in Values first — exact + // strings.HasSuffix is correct for those. for openPath := range cp.Opens.Values { if strings.HasSuffix(openPath, suffixStr) { return types.Bool(true) } } - for _, openPath := range cp.Opens.Patterns { - if strings.HasSuffix(openPath, suffixStr) { + // Patterns hold dynamic entries (containing `*` / `⋯`). We + // can't run strings.HasSuffix on the raw pattern text — a + // pattern like "/var/log/pods/*/volumes/..." has wildcard + // tokens that don't textually end with "foo.log" even though + // its concrete realisations might. Matthias upstream PR #811 + // review: a NARROWER fallback is the answer here — split off + // the pattern's concrete tail (the literal text after the + // last wildcard segment) and only check HasSuffix against + // that. If the pattern ends in a wildcard segment, the tail + // is empty and concrete realisations could match ANY suffix — + // be permissive (return true) to avoid the false-negative on + // rules that omit profileDataRequired.opens. + for _, openPattern := range cp.Opens.Patterns { + tail := patternConcreteSuffix(openPattern) + if tail == "" { + return types.Bool(true) + } + if strings.HasSuffix(tail, suffixStr) { return types.Bool(true) } } @@ -149,14 +174,24 @@ func (l *apLibrary) wasPathOpenedWithPrefix(containerID, prefix ref.Val) ref.Val } if cp.Opens.All { - // All entries retained — scan to check for the prefix. + // All entries retained. Scan concrete entries in Values first — + // exact strings.HasPrefix is correct for those. for openPath := range cp.Opens.Values { if strings.HasPrefix(openPath, prefixStr) { return types.Bool(true) } } - for _, openPath := range cp.Opens.Patterns { - if strings.HasPrefix(openPath, prefixStr) { + // Patterns: same narrower-fallback strategy as the suffix path. + // Split off the pattern's concrete head (the literal text + // BEFORE the first wildcard segment). If the pattern starts + // with a wildcard, concrete realisations could match ANY + // prefix — be permissive. Matthias upstream PR #811 review. + for _, openPattern := range cp.Opens.Patterns { + head := patternConcretePrefix(openPattern) + if head == "" { + return types.Bool(true) + } + if strings.HasPrefix(head, prefixStr) { return types.Bool(true) } } @@ -173,3 +208,77 @@ func (l *apLibrary) wasPathOpenedWithPrefix(containerID, prefix ref.Val) ref.Val return types.Bool(hit) } +// patternConcreteSuffix returns the literal text at the tail of a +// wildcard-bearing path pattern, dropped to start after the LAST +// wildcard segment's trailing `/`. Returns the input unchanged when +// no wildcard segments are present, or "" when the pattern ends in +// a wildcard segment (concrete realisations could match any suffix). +// +// Examples: +// +// "/var/log/⋯/foo.log" → "foo.log" (last wildcard `⋯`, concrete tail follows) +// "/var/log/pods/*" → "" (trailing wildcard, permissive caller) +// "/var/log/foo.log" → "/var/log/foo.log" (no wildcards, whole pattern) +// "*" → "" (lone wildcard) +// +// Matthias upstream PR #811 review. +func patternConcreteSuffix(p string) string { + lastWildEnd := -1 + i := 0 + for i < len(p) { + segStart := i + for i < len(p) && p[i] != '/' { + i++ + } + seg := p[segStart:i] + if seg == "*" || seg == dynamicpathdetector.DynamicIdentifier { + lastWildEnd = i + } + if i < len(p) { + i++ // skip `/` + } + } + if lastWildEnd < 0 { + return p + } + if lastWildEnd >= len(p) { + return "" + } + // lastWildEnd points at the `/` after the wildcard segment. Keep + // the slash so callers querying with leading-slash suffixes match + // correctly (every concrete realisation has that slash too). + return p[lastWildEnd:] +} + +// patternConcretePrefix is the mirror of patternConcreteSuffix — +// returns the literal text at the HEAD of the pattern up to (but not +// including) the first wildcard segment. Returns the input unchanged +// when no wildcard segments are present, or "" when the pattern starts +// with a wildcard segment. +// +// Matthias upstream PR #811 review. +func patternConcretePrefix(p string) string { + i := 0 + for i < len(p) { + segStart := i + for i < len(p) && p[i] != '/' { + i++ + } + seg := p[segStart:i] + if seg == "*" || seg == dynamicpathdetector.DynamicIdentifier { + if segStart == 0 { + return "" + } + // segStart is at the wildcard segment; the byte BEFORE it + // is the `/` separator. Keep the slash in the returned + // prefix so callers querying with trailing-slash prefixes + // match (every concrete realisation has that slash too). + return p[:segStart] + } + if i < len(p) { + i++ // skip `/` + } + } + return p +} + diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/open_bench_test.go b/pkg/rulemanager/cel/libraries/applicationprofile/open_bench_test.go new file mode 100644 index 000000000..29b740e0f --- /dev/null +++ b/pkg/rulemanager/cel/libraries/applicationprofile/open_bench_test.go @@ -0,0 +1,129 @@ +package applicationprofile + +import ( + "strconv" + "testing" + + "github.com/google/cel-go/common/types" + "github.com/kubescape/node-agent/pkg/objectcache" +) + +// BenchmarkWasPathOpenedWithSuffix_AllMode exercises the pass-through +// (Opens.All == true) suffix path under three representative profile +// shapes: +// +// - values_only: 50 concrete entries, no Patterns +// - patterns_concrete: 50 concrete entries + 10 Patterns whose tail +// is literal (the typical /var/log/⋯/foo.log shape) +// - patterns_wildcard: 50 concrete entries + 10 Patterns ending in a +// wildcard segment (the permissive-arm shape) +// +// Captures Matthias's upstream PR #811 contract numbers for the PR +// description. +func BenchmarkWasPathOpenedWithSuffix_AllMode(b *testing.B) { + shapes := []struct { + name string + values int + patterns []string + }{ + {"values_only", 50, nil}, + {"patterns_concrete", 50, []string{ + "/var/log/⋯/access.log", "/var/log/⋯/error.log", "/opt/⋯/server.log", + "/etc/⋯/audit.log", "/var/run/⋯/state.log", "/srv/⋯/app.log", + "/var/cache/⋯/tmp.log", "/usr/share/⋯/data.log", "/home/⋯/user.log", + "/proc/⋯/status.log", + }}, + {"patterns_wildcard", 50, []string{ + "/var/log/pods/*", "/var/log/containers/*", "/etc/cron.d/*", + "/opt/⋯", "/srv/*", "/var/run/*", + "/usr/local/⋯", "/home/⋯", "/tmp/⋯", "/run/⋯", + }}, + } + for _, sh := range shapes { + b.Run(sh.name, func(b *testing.B) { + values := make(map[string]struct{}, sh.values) + for i := 0; i < sh.values; i++ { + values["/usr/lib/x86_64-linux-gnu/libcrypto.so."+strconv.Itoa(i)] = struct{}{} + } + pcp := &objectcache.ProjectedContainerProfile{ + Opens: objectcache.ProjectedField{ + All: true, + Values: values, + Patterns: sh.patterns, + }, + } + lib := &apLibrary{objectCache: &mockObjectCacheForPattern{pcp: pcp}} + suffix := types.String(".log") + cid := types.String("bench-cid") + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = lib.wasPathOpenedWithSuffix(cid, suffix) + } + }) + } +} + +// BenchmarkWasPathOpenedWithPrefix_AllMode mirrors the suffix bench +// for the prefix path. +func BenchmarkWasPathOpenedWithPrefix_AllMode(b *testing.B) { + shapes := []struct { + name string + values int + patterns []string + }{ + {"values_only", 50, nil}, + {"patterns_concrete", 50, []string{ + "/var/log/⋯/access.log", "/var/log/⋯/error.log", "/opt/⋯/server.log", + "/etc/⋯/audit.log", "/var/run/⋯/state.log", + }}, + {"patterns_wildcard", 50, []string{ + "*/run", "*/log", "*/cache", + "⋯", "*", + }}, + } + for _, sh := range shapes { + b.Run(sh.name, func(b *testing.B) { + values := make(map[string]struct{}, sh.values) + for i := 0; i < sh.values; i++ { + values["/usr/lib/x86_64-linux-gnu/libcrypto.so."+strconv.Itoa(i)] = struct{}{} + } + pcp := &objectcache.ProjectedContainerProfile{ + Opens: objectcache.ProjectedField{ + All: true, + Values: values, + Patterns: sh.patterns, + }, + } + lib := &apLibrary{objectCache: &mockObjectCacheForPattern{pcp: pcp}} + prefix := types.String("/var/") + cid := types.String("bench-cid") + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = lib.wasPathOpenedWithPrefix(cid, prefix) + } + }) + } +} + +// BenchmarkPatternConcreteSuffix isolates the helper to confirm zero +// allocation regardless of pattern shape. +func BenchmarkPatternConcreteSuffix(b *testing.B) { + cases := []string{ + "/var/log/⋯/foo.log", + "/var/log/pods/*", + "/var/log/foo.log", + "*", + "/var/⋯/log/⋯/foo.log", + } + for _, c := range cases { + b.Run(c, func(b *testing.B) { + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = patternConcreteSuffix(c) + } + }) + } +} diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/open_test.go b/pkg/rulemanager/cel/libraries/applicationprofile/open_test.go index bf407611e..c0cf6f6e6 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/open_test.go +++ b/pkg/rulemanager/cel/libraries/applicationprofile/open_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/google/cel-go/cel" + "github.com/google/cel-go/common/types" "github.com/goradd/maps" "github.com/kubescape/node-agent/pkg/config" "github.com/kubescape/node-agent/pkg/objectcache" @@ -12,134 +13,202 @@ import ( "github.com/stretchr/testify/assert" ) -func TestOpenInProfile(t *testing.T) { - objCache := objectcachev1.RuleObjectCacheMock{ - ContainerIDToSharedData: maps.NewSafeMap[string, *objectcache.WatchedContainerData](), +// TestWasPathOpenedWithSuffix_PatternsNotScanned pins the contract from +// the CodeRabbit PR #43 review on open.go:79 (Major). Wildcard-shaped +// entries in cp.Opens.Patterns MUST NOT contribute to suffix/prefix +// answers — their literal text answers the wrong question. A retained +// pattern "/var/log/pods/*/volumes/...." doesn't END with "foo.log" +// even though the concrete open it stands in for might. Only concrete +// paths in cp.Opens.Values are valid sources of suffix/prefix truth in +// pass-through (Opens.All=true) mode. +// +// In projection-active mode (Opens.All=false), the rule manager +// precomputes Opens.SuffixHits / PrefixHits from the spec, which is +// the correct mechanism — those are exercised in +// TestOpenWithSuffixInProfile / TestOpenWithPrefixInProfile. +// +// These tests pin Matthias's upstream PR #811 review contract: +// +// Patterns ARE scanned when Opens.All == true, but with a NARROWER +// fallback than text-level strings.HasSuffix/HasPrefix: +// +// * Pattern with a concrete tail/head (text after/before the last/first +// wildcard segment): match via HasSuffix/HasPrefix on the concrete +// piece — every realisation has that text textually. +// * Pattern ending/starting with a wildcard segment: be PERMISSIVE +// (return true). The concrete realisations could match ANY +// suffix/prefix; refusing would silently regress rules that omit +// profileDataRequired.opens (Matthias's "we still need a narrower +// fallback here instead of ignoring Patterns entirely"). +// +// Pre-PR-#811 (CR's HasSuffix-on-Patterns concern) the matcher SKIPPED +// Patterns entirely. That made wildcard-only profiles silently fail +// suffix/prefix queries — the regression Matthias's review reverses. +func TestWasPathOpenedWithSuffix_PatternsScannedWithConcreteTail(t *testing.T) { + pcp := &objectcache.ProjectedContainerProfile{ + Opens: objectcache.ProjectedField{ + All: true, + Values: map[string]struct{}{"/var/log/concrete.log": {}}, + Patterns: []string{"/var/log/⋯/foo.log"}, + }, + } + objCache := &mockObjectCacheForPattern{pcp: pcp} + lib := &apLibrary{objectCache: objCache} + + // 1) Concrete in Values: returns true via Values scan. + got := lib.wasPathOpenedWithSuffix(types.String("test-cid"), types.String(".log")) + if b, _ := got.Value().(bool); !b { + t.Fatalf("suffix '.log' against concrete /var/log/concrete.log: expected true, got %v", got) + } + + // 2) Strip Values; only the wildcard Pattern remains. The pattern's + // concrete tail (text after the last wildcard segment) is + // "/foo.log" which DOES end with ".log" → expect true. + pcp.Opens.Values = map[string]struct{}{} + got = lib.wasPathOpenedWithSuffix(types.String("test-cid"), types.String(".log")) + if b, _ := got.Value().(bool); !b { + t.Errorf("suffix '.log' against pattern /var/log/⋯/foo.log: "+ + "expected true (concrete tail '/foo.log' has suffix '.log'), got %v", got) + } + + // 3) Same pattern, query suffix that DOESN'T match the concrete tail. + got = lib.wasPathOpenedWithSuffix(types.String("test-cid"), types.String(".txt")) + if b, _ := got.Value().(bool); b { + t.Errorf("suffix '.txt' against pattern /var/log/⋯/foo.log: "+ + "expected false (concrete tail '/foo.log' doesn't have suffix '.txt'), got %v", got) } +} - objCache.SetSharedContainerData("test-container-id", &objectcache.WatchedContainerData{ - ContainerType: objectcache.Container, - ContainerInfos: map[objectcache.ContainerType][]objectcache.ContainerInfo{ - objectcache.Container: { - { - Name: "test-container", - }, - }, +// TestWasPathOpenedWithSuffix_PatternWildcardTail_Permissive pins the +// permissive arm of Matthias's contract: a pattern ending in a wildcard +// segment can match ANY suffix because its concrete realisations are +// unconstrained at the tail. +func TestWasPathOpenedWithSuffix_PatternWildcardTail_Permissive(t *testing.T) { + pcp := &objectcache.ProjectedContainerProfile{ + Opens: objectcache.ProjectedField{ + All: true, + Values: map[string]struct{}{}, + Patterns: []string{"/var/log/pods/*"}, }, - }) - - profile := &v1beta1.ApplicationProfile{} - profile.Spec.Containers = append(profile.Spec.Containers, v1beta1.ApplicationProfileContainer{ - Name: "test-container", - Opens: []v1beta1.OpenCalls{ - { - Path: "/etc/passwd", - Flags: []string{"O_RDONLY"}, - }, - { - Path: "/tmp/test.txt", - Flags: []string{"O_WRONLY", "O_CREAT"}, - }, - }, - }) - objCache.SetApplicationProfile(profile) + } + objCache := &mockObjectCacheForPattern{pcp: pcp} + lib := &apLibrary{objectCache: objCache} - env, err := cel.NewEnv( - cel.Variable("containerID", cel.StringType), - cel.Variable("path", cel.StringType), - AP(&objCache, config.Config{}), - ) - if err != nil { - t.Fatalf("failed to create env: %v", err) + for _, suffix := range []string{".log", "/foo.log", "kube-system"} { + got := lib.wasPathOpenedWithSuffix(types.String("test-cid"), types.String(suffix)) + if b, _ := got.Value().(bool); !b { + t.Errorf("suffix %q against pattern /var/log/pods/*: "+ + "expected true (permissive — wildcard tail), got %v", suffix, got) + } } +} - testCases := []struct { - name string - containerID string - path string - expectedResult bool - }{ - { - name: "Path exists in profile", - containerID: "test-container-id", - path: "/etc/passwd", - expectedResult: true, - }, - { - name: "Path does not exist in profile", - containerID: "test-container-id", - path: "/etc/nonexistent", - expectedResult: false, - }, - { - name: "Another path exists in profile", - containerID: "test-container-id", - path: "/tmp/test.txt", - expectedResult: true, +// TestWasPathOpenedWithPrefix_PatternsScannedWithConcreteHead mirrors +// the suffix test for the prefix path. +func TestWasPathOpenedWithPrefix_PatternsScannedWithConcreteHead(t *testing.T) { + pcp := &objectcache.ProjectedContainerProfile{ + Opens: objectcache.ProjectedField{ + All: true, + Values: map[string]struct{}{"/var/concrete/foo": {}}, + Patterns: []string{"/var/⋯/log/foo"}, }, } + objCache := &mockObjectCacheForPattern{pcp: pcp} + lib := &apLibrary{objectCache: objCache} - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - ast, issues := env.Compile(`ap.was_path_opened(containerID, path)`) - if issues != nil { - t.Fatalf("failed to compile expression: %v", issues.Err()) - } - - program, err := env.Program(ast) - if err != nil { - t.Fatalf("failed to create program: %v", err) - } + got := lib.wasPathOpenedWithPrefix(types.String("test-cid"), types.String("/var/")) + if b, _ := got.Value().(bool); !b { + t.Fatalf("prefix '/var/' against concrete /var/concrete/foo: expected true, got %v", got) + } - result, _, err := program.Eval(map[string]interface{}{ - "containerID": tc.containerID, - "path": tc.path, - }) - if err != nil { - t.Fatalf("failed to eval program: %v", err) - } + // Strip Values; the pattern's concrete head is "/var/" which DOES + // start with the queried prefix "/var/" → expect true. + pcp.Opens.Values = map[string]struct{}{} + got = lib.wasPathOpenedWithPrefix(types.String("test-cid"), types.String("/var/")) + if b, _ := got.Value().(bool); !b { + t.Errorf("prefix '/var/' against pattern /var/⋯/log/foo: "+ + "expected true (concrete head '/var/' starts with '/var/'), got %v", got) + } - actualResult := result.Value().(bool) - assert.Equal(t, tc.expectedResult, actualResult, "ap.was_path_opened result should match expected value") - }) + // Query prefix that doesn't match the concrete head. + got = lib.wasPathOpenedWithPrefix(types.String("test-cid"), types.String("/etc/")) + if b, _ := got.Value().(bool); b { + t.Errorf("prefix '/etc/' against pattern /var/⋯/log/foo: "+ + "expected false (concrete head '/var/' doesn't start with '/etc/'), got %v", got) } } -func TestOpenNoProfile(t *testing.T) { - objCache := objectcachev1.RuleObjectCacheMock{} - - env, err := cel.NewEnv( - cel.Variable("containerID", cel.StringType), - cel.Variable("path", cel.StringType), - AP(&objCache, config.Config{}), - ) - if err != nil { - t.Fatalf("failed to create env: %v", err) +// TestWasPathOpenedWithPrefix_PatternWildcardHead_Permissive pins the +// permissive arm of the prefix path. +func TestWasPathOpenedWithPrefix_PatternWildcardHead_Permissive(t *testing.T) { + pcp := &objectcache.ProjectedContainerProfile{ + Opens: objectcache.ProjectedField{ + All: true, + Values: map[string]struct{}{}, + Patterns: []string{"*/run"}, + }, } + objCache := &mockObjectCacheForPattern{pcp: pcp} + lib := &apLibrary{objectCache: objCache} - ast, issues := env.Compile(`ap.was_path_opened(containerID, path)`) - if issues != nil { - t.Fatalf("failed to compile expression: %v", issues.Err()) + for _, prefix := range []string{"/var/lib", "/etc", "/anything"} { + got := lib.wasPathOpenedWithPrefix(types.String("test-cid"), types.String(prefix)) + if b, _ := got.Value().(bool); !b { + t.Errorf("prefix %q against pattern */run: "+ + "expected true (permissive — wildcard head), got %v", prefix, got) + } } +} - program, err := env.Program(ast) - if err != nil { - t.Fatalf("failed to create program: %v", err) +// TestPatternConcreteSuffix_AndPrefix pins the helper-level contract +// for the narrower-fallback splitters. Standalone test on the helpers +// so failures localise to the splitter logic rather than the matcher. +func TestPatternConcreteSuffix_AndPrefix(t *testing.T) { + cases := []struct { + name, in, wantSuffix, wantPrefix string + }{ + {"no_wildcards", "/var/log/foo.log", "/var/log/foo.log", "/var/log/foo.log"}, + {"trailing_star", "/var/log/pods/*", "", "/var/log/pods/"}, + {"leading_star", "*/run", "/run", ""}, + {"mid_ellipsis", "/var/⋯/log", "/log", "/var/"}, + {"both_mid", "/var/⋯/log/⋯/foo.log", "/foo.log", "/var/"}, + {"lone_star", "*", "", ""}, + {"lone_ellipsis", "⋯", "", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := patternConcreteSuffix(tc.in); got != tc.wantSuffix { + t.Errorf("patternConcreteSuffix(%q) = %q, want %q", tc.in, got, tc.wantSuffix) + } + if got := patternConcretePrefix(tc.in); got != tc.wantPrefix { + t.Errorf("patternConcretePrefix(%q) = %q, want %q", tc.in, got, tc.wantPrefix) + } + }) } +} - result, _, err := program.Eval(map[string]interface{}{ - "containerID": "test-container-id", - "path": "/etc/passwd", - }) - if err != nil { - t.Fatalf("failed to eval program: %v", err) - } +// mockObjectCacheForPattern returns a fixed ProjectedContainerProfile +// for any containerID; used only by the suffix/prefix pattern tests +// above to bypass the full RuleObjectCacheMock setup. +type mockObjectCacheForPattern struct { + objectcache.ObjectCache + pcp *objectcache.ProjectedContainerProfile +} - actualResult := result.Value().(bool) - assert.False(t, actualResult, "ap.was_path_opened should return false when no profile is available") +func (m *mockObjectCacheForPattern) ContainerProfileCache() objectcache.ContainerProfileCache { + return &mockCPCForPattern{pcp: m.pcp} } -func TestOpenWithFlagsInProfile(t *testing.T) { +type mockCPCForPattern struct { + objectcache.ContainerProfileCache + pcp *objectcache.ProjectedContainerProfile +} + +func (m *mockCPCForPattern) GetProjectedContainerProfile(_ string) *objectcache.ProjectedContainerProfile { + return m.pcp +} + +func TestOpenInProfile(t *testing.T) { objCache := objectcachev1.RuleObjectCacheMock{ ContainerIDToSharedData: maps.NewSafeMap[string, *objectcache.WatchedContainerData](), } @@ -167,10 +236,6 @@ func TestOpenWithFlagsInProfile(t *testing.T) { Path: "/tmp/test.txt", Flags: []string{"O_WRONLY", "O_CREAT"}, }, - { - Path: "/var/log/app.log", - Flags: []string{"O_RDWR", "O_APPEND"}, - }, }, }) objCache.SetApplicationProfile(profile) @@ -178,7 +243,6 @@ func TestOpenWithFlagsInProfile(t *testing.T) { env, err := cel.NewEnv( cel.Variable("containerID", cel.StringType), cel.Variable("path", cel.StringType), - cel.Variable("flags", cel.ListType(cel.StringType)), AP(&objCache, config.Config{}), ) if err != nil { @@ -189,64 +253,31 @@ func TestOpenWithFlagsInProfile(t *testing.T) { name string containerID string path string - flags []string expectedResult bool }{ { - name: "Path and flags match exactly", - containerID: "test-container-id", - path: "/etc/passwd", - flags: []string{"O_RDONLY"}, - expectedResult: true, - }, - { - // v1 degradation: flags projection is out of scope; path-only matching. - name: "Path matches but flags don't match", + name: "Path exists in profile", containerID: "test-container-id", path: "/etc/passwd", - flags: []string{"O_WRONLY"}, expectedResult: true, }, { - name: "Path doesn't exist", + name: "Path does not exist in profile", containerID: "test-container-id", path: "/etc/nonexistent", - flags: []string{"O_RDONLY"}, expectedResult: false, }, { - name: "Multiple flags match", - containerID: "test-container-id", - path: "/tmp/test.txt", - flags: []string{"O_WRONLY", "O_CREAT"}, - expectedResult: true, - }, - { - name: "Multiple flags in different order", - containerID: "test-container-id", - path: "/tmp/test.txt", - flags: []string{"O_CREAT", "O_WRONLY"}, - expectedResult: true, - }, - { - name: "Partial flags match", + name: "Another path exists in profile", containerID: "test-container-id", path: "/tmp/test.txt", - flags: []string{"O_WRONLY"}, - expectedResult: true, - }, - { - name: "Empty flags list", - containerID: "test-container-id", - path: "/etc/passwd", - flags: []string{}, expectedResult: true, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - ast, issues := env.Compile(`ap.was_path_opened_with_flags(containerID, path, flags)`) + ast, issues := env.Compile(`ap.was_path_opened(containerID, path)`) if issues != nil { t.Fatalf("failed to compile expression: %v", issues.Err()) } @@ -259,32 +290,30 @@ func TestOpenWithFlagsInProfile(t *testing.T) { result, _, err := program.Eval(map[string]interface{}{ "containerID": tc.containerID, "path": tc.path, - "flags": tc.flags, }) if err != nil { t.Fatalf("failed to eval program: %v", err) } actualResult := result.Value().(bool) - assert.Equal(t, tc.expectedResult, actualResult, "ap.was_path_opened_with_flags result should match expected value") + assert.Equal(t, tc.expectedResult, actualResult, "ap.was_path_opened result should match expected value") }) } } -func TestOpenWithFlagsNoProfile(t *testing.T) { +func TestOpenNoProfile(t *testing.T) { objCache := objectcachev1.RuleObjectCacheMock{} env, err := cel.NewEnv( cel.Variable("containerID", cel.StringType), cel.Variable("path", cel.StringType), - cel.Variable("flags", cel.ListType(cel.StringType)), AP(&objCache, config.Config{}), ) if err != nil { t.Fatalf("failed to create env: %v", err) } - ast, issues := env.Compile(`ap.was_path_opened_with_flags(containerID, path, flags)`) + ast, issues := env.Compile(`ap.was_path_opened(containerID, path)`) if issues != nil { t.Fatalf("failed to compile expression: %v", issues.Err()) } @@ -297,40 +326,13 @@ func TestOpenWithFlagsNoProfile(t *testing.T) { result, _, err := program.Eval(map[string]interface{}{ "containerID": "test-container-id", "path": "/etc/passwd", - "flags": []string{"O_RDONLY"}, }) if err != nil { t.Fatalf("failed to eval program: %v", err) } actualResult := result.Value().(bool) - assert.False(t, actualResult, "ap.was_path_opened_with_flags should return false when no profile is available") -} - -func TestOpenWithFlagsCompilation(t *testing.T) { - objCache := objectcachev1.RuleObjectCacheMock{} - - env, err := cel.NewEnv( - cel.Variable("containerID", cel.StringType), - cel.Variable("path", cel.StringType), - cel.Variable("flags", cel.ListType(cel.StringType)), - AP(&objCache, config.Config{}), - ) - if err != nil { - t.Fatalf("failed to create env: %v", err) - } - - // Test that the function compiles correctly - ast, issues := env.Compile(`ap.was_path_opened_with_flags(containerID, path, flags)`) - if issues != nil { - t.Fatalf("failed to compile expression: %v", issues.Err()) - } - - // Test that we can create a program - _, err = env.Program(ast) - if err != nil { - t.Fatalf("failed to create program: %v", err) - } + assert.False(t, actualResult, "ap.was_path_opened should return false when no profile is available") } func TestOpenCompilation(t *testing.T) { diff --git a/tests/component_test.go b/tests/component_test.go index fcdb760bf..b44bd89c1 100644 --- a/tests/component_test.go +++ b/tests/component_test.go @@ -1569,3 +1569,652 @@ 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 { + 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 + } + + // 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 + } + } + } + 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"}}, + }) + 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"}}, + }, + 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) + } + } + + // 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)) + }) +} + +// Test_28_UserDefinedNetworkNeighborhood exercises user-defined AP + NN. +// Each subtest gets its own namespace to avoid alert cross-contamination. +// +// The NN allows only fusioncore.ai (162.0.217.171) on TCP/80. +// R0005 requires real resolvable domains (not NXDOMAIN), because trace_dns +// drops DNS responses with 0 answers. +func Test_33_AnalyzeOpensWildcardAnchoring(t *testing.T) { + start := time.Now() + defer tearDownTest(t, start) + + const ruleName = "Files Access Anomalies in container" + const profileName = "nginx-regex-profile" + + 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_33 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] %-50s profile=%-25s file=%-30s %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 AP with a single Opens + // entry (plus a couple of always-needed paths nginx hits at startup), + // then deploys nginx with the user-defined-profile label pointing at + // it and waits for the pod + cache load. + deployWithProfile := func(t *testing.T, profilePath string) *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: []v1beta1.OpenCalls{ + {Path: profilePath, Flags: []string{"O_RDONLY"}}, + // Dynamic linker fires this on every exec — keep + // it whitelisted so it doesn't drown out the + // signal we actually care about. + {Path: "/etc/ld.so.cache", Flags: []string{"O_RDONLY", "O_CLOEXEC"}}, + }, + }, + }, + }, + } + + 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) + + 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) + // 11 subtests deploy a fresh pod sequentially, so each later subtest + // races against an increasingly loaded kind cluster — the upstream + // CP cache reconciler, alertmanager, and prometheus all chew CPU at + // boot. 80s timed out intermittently; 180s gives headroom without + // pushing the total test runtime into a different regime. + require.NoError(t, wl.WaitForReady(180), "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 + } + + // catAndAlerts execs `cat ` (ignoring cat's own exit error — + // catting a directory or a non-readable file still triggers the + // open() syscall the eBPF tracer captures), then polls for alerts. + catAndAlerts := func(t *testing.T, wl *testutils.TestWorkload, filePath string) []testutils.Alert { + t.Helper() + stdout, stderr, _ := wl.ExecIntoPod([]string{"cat", filePath}, "nginx") + t.Logf("cat %q → stdout=%q stderr=%q", filePath, stdout, stderr) + + var alerts []testutils.Alert + require.Eventually(t, func() bool { + a, err := testutils.GetAlerts(wl.Namespace) + if err != nil { + return false + } + alerts = a + return true + }, 60*time.Second, 5*time.Second, "alerts must be retrievable from ns %s", wl.Namespace) + // Settle so any late R0002 alert lands before we count. + time.Sleep(10 * time.Second) + alerts, err := testutils.GetAlerts(wl.Namespace) + require.NoError(t, err, "get alerts from ns %s", wl.Namespace) + return alerts + } + + // hasR0002 returns true if any R0002 alert fired for `cat` in the + // nginx container. + hasR0002 := 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 + } + + tests := []struct { + name string + profilePath string + filePath string + expectAlert bool + why string // contract pinned by this case + }{ + // ─── Trailing-`*` anchoring (the security fix) ────────────── + // + // IMPORTANT: R0002's CEL ruleExpression has a strict prefix + // filter (event.path.startsWith('/etc/'), startsWith('/var/log/'), + // etc. — all with trailing slash). Bare `/etc` and `/var/log` + // don't match those prefixes, so the rule never evaluates on + // them and the matcher's anchoring contract stays invisible at + // runtime. Probe one level deeper instead — `/etc/ssl` IS under + // the `/etc/` monitored prefix, so R0002 CAN see whether a + // `/etc/ssl/*` profile entry matches the bare `/etc/ssl` parent. + { + name: "trailing_star_matches_immediate_child", + profilePath: "/etc/*", + filePath: "/etc/hosts", + expectAlert: false, + why: "/etc/* matches a one-segment child under /etc", + }, + { + name: "trailing_star_matches_deep_child", + profilePath: "/etc/*", + filePath: "/etc/ssl/openssl.cnf", + expectAlert: false, + why: "/etc/* matches a multi-segment path under /etc (mid-path zero-or-more)", + }, + { + name: "trailing_star_does_not_match_bare_parent_under_monitored_prefix", + profilePath: "/etc/ssl/*", + filePath: "/etc/ssl", + expectAlert: true, + why: "/etc/ssl/* must NOT match the bare /etc/ssl directory itself — pins the security fix at a path R0002's prefix filter can observe", + }, + { + name: "deep_prefix_trailing_star_does_not_match_parent", + profilePath: "/etc/ssl/certs/*", + filePath: "/etc/ssl/certs", + expectAlert: true, + why: "Same anchoring rule, deeper: /etc/ssl/certs/* does NOT match /etc/ssl/certs", + }, + + // ─── DynamicIdentifier (⋯) exactly-one ────────────────────── + { + name: "ellipsis_requires_one_segment_not_zero", + profilePath: "/etc/passwd/" + dynamicpathdetector.DynamicIdentifier, + filePath: "/etc/passwd", + expectAlert: true, + why: "⋯ consumes EXACTLY ONE segment; /etc/passwd/⋯ requires one more, /etc/passwd alone has zero past — must fire R0002", + }, + + // ─── Mixed ⋯/* combinations ───────────────────────────────── + { + name: "ellipsis_then_trailing_star_matches_two_segment_tail", + profilePath: "/proc/" + dynamicpathdetector.DynamicIdentifier + "/*", + filePath: "/proc/1/status", + expectAlert: false, + why: "/proc/⋯/* matches /proc/1/status (⋯ consumes 1, * consumes ≥1)", + }, + { + name: "ellipsis_then_trailing_star_matches_three_segment_tail", + profilePath: "/proc/" + dynamicpathdetector.DynamicIdentifier + "/*", + filePath: "/proc/1/task/1", + expectAlert: false, + why: "/proc/⋯/* matches deeper paths (⋯ consumes 1, * consumes ≥1 covering rest)", + }, + + // ─── Multiple trailing wildcards ──────────────────────────── + { + name: "double_trailing_matches_one_child", + profilePath: "/etc/*/*", + filePath: "/etc/ssl", + expectAlert: false, + why: "/etc/*/* matches /etc/ssh (mid-* consumes zero, trailing-* consumes one)", + }, + { + name: "double_trailing_matches_deep_child", + profilePath: "/etc/*/*", + filePath: "/etc/ssl/openssl.cnf", + expectAlert: false, + why: "/etc/*/* matches /etc/ssl/openssl.cnf (mid-* consumes one, trailing-* consumes one)", + }, + { + name: "double_trailing_does_not_match_parent_under_monitored_prefix", + profilePath: "/etc/ssl/*/*", + filePath: "/etc/ssl", + expectAlert: true, + why: "/etc/ssl/*/* requires at least one segment past /etc/ssl; bare /etc/ssl must NOT match (probed under /etc/ so R0002 sees it)", + }, + + // ─── splitPath trailing-slash normalisation ───────────────── + { + name: "trailing_slash_in_profile_normalises_to_literal", + profilePath: "/etc/passwd/", + filePath: "/etc/passwd", + expectAlert: false, + why: "Profile `/etc/passwd/` is normalised to `/etc/passwd`; matches the literal at runtime", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Logf("contract: %s", tc.why) + wl := deployWithProfile(t, tc.profilePath) + alerts := catAndAlerts(t, wl, tc.filePath) + got := hasR0002(alerts) + + detail := fmt.Sprintf("got %d alerts total; R0002 fired = %v", len(alerts), got) + passed := got == tc.expectAlert + if !passed { + if tc.expectAlert { + t.Errorf("expected R0002 alert: profile %q must NOT match %q (%s); but no alert fired", + tc.profilePath, tc.filePath, tc.why) + } else { + t.Errorf("expected NO R0002 alert: profile %q should match %q (%s); but alert fired", + tc.profilePath, tc.filePath, tc.why) + } + } + addResult(tc.name, tc.profilePath, tc.filePath, tc.expectAlert, passed, detail) + }) + } +} diff --git a/tests/resources/curl-user-profile-wildcards-deployment.yaml b/tests/resources/curl-user-profile-wildcards-deployment.yaml new file mode 100644 index 000000000..7b2e4ab7d --- /dev/null +++ b/tests/resources/curl-user-profile-wildcards-deployment.yaml @@ -0,0 +1,21 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: curl-fusioncore + name: curl-fusioncore-deployment +spec: + selector: + matchLabels: + app: curl-fusioncore + replicas: 1 + template: + metadata: + labels: + app: curl-fusioncore + kubescape.io/user-defined-profile: fusioncore-profile-wildcards + spec: + containers: + - name: curl + image: docker.io/curlimages/curl@sha256:08e466006f0860e54fc299378de998935333e0e130a15f6f98482e9f8dab3058 + command: ["sleep", "infinity"] diff --git a/tests/resources/nginx-user-profile-deployment.yaml b/tests/resources/nginx-user-profile-deployment.yaml new file mode 100644 index 000000000..218f95654 --- /dev/null +++ b/tests/resources/nginx-user-profile-deployment.yaml @@ -0,0 +1,22 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: nginx + name: nginx-deployment +spec: + selector: + matchLabels: + app: nginx + replicas: 1 + template: + metadata: + labels: + app: nginx + kubescape.io/user-defined-profile: nginx-regex-profile + spec: + containers: + - name: nginx + image: nginx:1.14.2 + ports: + - containerPort: 80