From a64e0aee739ce8109c758d50f08601993b1c9958 Mon Sep 17 00:00:00 2001 From: entlein Date: Sat, 16 May 2026 13:16:38 +0200 Subject: [PATCH 01/17] feat(projection): add ExecsByPath composite-key surface to ProjectedContainerProfile Signed-off-by: entlein --- .../containerprofilecache/projection_apply.go | 28 +++++++++++ .../projection_apply_test.go | 50 +++++++++++++++++++ pkg/objectcache/projection_types.go | 9 ++++ 3 files changed, 87 insertions(+) diff --git a/pkg/objectcache/containerprofilecache/projection_apply.go b/pkg/objectcache/containerprofilecache/projection_apply.go index 135464188..ebe62df26 100644 --- a/pkg/objectcache/containerprofilecache/projection_apply.go +++ b/pkg/objectcache/containerprofilecache/projection_apply.go @@ -49,6 +49,7 @@ func Apply(spec *objectcache.RuleProjectionSpec, cp *v1beta1.ContainerProfile, c execsPaths := extractExecsPaths(cp) pcp.Execs = projectField(s.Execs, execsPaths, true) + pcp.ExecsByPath = extractExecsByPath(cp) endpointPaths := extractEndpointPaths(cp) pcp.Endpoints = projectField(s.Endpoints, endpointPaths, true) @@ -166,6 +167,33 @@ func extractExecsPaths(cp *v1beta1.ContainerProfile) []string { return paths } +// extractExecsByPath builds the path → args map used by exec-args +// matchers (e.g. dynamicpathdetector.CompareExecArgs in node-agent#807). +// Multiple ExecCalls entries with the same Path collapse to the last +// seen. nil-Args entries are stored as empty slices; downstream +// matchers distinguish "absent key" (path not in the profile at all) +// from "present with empty slice" (path captured but ran with no args). +// +// Args slices are CLONED rather than aliased — Apply is contract-bound +// to be a pure transform, and an alias would let consumers mutate the +// source profile by editing the projected map. +func extractExecsByPath(cp *v1beta1.ContainerProfile) map[string][]string { + if len(cp.Spec.Execs) == 0 { + return nil + } + m := make(map[string][]string, len(cp.Spec.Execs)) + for _, e := range cp.Spec.Execs { + if e.Args == nil { + m[e.Path] = []string{} + continue + } + cloned := make([]string, len(e.Args)) + copy(cloned, e.Args) + m[e.Path] = cloned + } + return m +} + func extractEndpointPaths(cp *v1beta1.ContainerProfile) []string { endpoints := make([]string, len(cp.Spec.Endpoints)) for i, e := range cp.Spec.Endpoints { diff --git a/pkg/objectcache/containerprofilecache/projection_apply_test.go b/pkg/objectcache/containerprofilecache/projection_apply_test.go index 15b63cf3c..a0308d3d6 100644 --- a/pkg/objectcache/containerprofilecache/projection_apply_test.go +++ b/pkg/objectcache/containerprofilecache/projection_apply_test.go @@ -410,3 +410,53 @@ func TestApply_ExactFilter_NoMatchYieldsNilValues(t *testing.T) { require.NotNil(t, pcp) assert.Nil(t, pcp.Opens.Values, "Values should be nil when no entries match the filter") } + +// TestApply_ExecsByPath_PopulatesFromSpec pins the projection of +// per-Path Args from cp.Spec.Execs into ProjectedContainerProfile.ExecsByPath. +// Three cases combined in one CP fixture to keep the gate compact: +// - Path with a populated Args slice — projected as a CLONED slice +// - Path with nil Args — projected as an empty (non-nil) slice +// - Two ExecCalls with the same Path — last write wins +// The cloned-slice invariant is checked by mutating the projected slice +// and asserting the source is unchanged. +func TestApply_ExecsByPath_PopulatesFromSpec(t *testing.T) { + cp := &v1beta1.ContainerProfile{ + Spec: v1beta1.ContainerProfileSpec{ + Execs: []v1beta1.ExecCalls{ + {Path: "/bin/sh", Args: []string{"-c", "echo hi"}}, + {Path: "/bin/echo", Args: nil}, + {Path: "/bin/sh", Args: []string{"-x", "later"}}, + }, + }, + } + pcp := Apply(&objectcache.RuleProjectionSpec{}, cp, nil) + require.NotNil(t, pcp) + require.NotNil(t, pcp.ExecsByPath, "ExecsByPath must be populated") + + // last write wins for duplicate Path + assert.Equal(t, []string{"-x", "later"}, pcp.ExecsByPath["/bin/sh"], + "duplicate Path: last ExecCalls entry should win") + + // nil Args → empty (non-nil) slice + got, present := pcp.ExecsByPath["/bin/echo"] + require.True(t, present, "/bin/echo must be present even with nil Args") + require.NotNil(t, got, "/bin/echo Args nil source must project as non-nil empty slice") + assert.Empty(t, got, "/bin/echo nil-Args must project as empty slice") + + // CLONED-slice invariant: mutating the projection must not affect + // the source ContainerProfile spec. + sourceCopy := append([]string{}, cp.Spec.Execs[2].Args...) // current "/bin/sh" + pcp.ExecsByPath["/bin/sh"][0] = "MUTATED" + assert.Equal(t, sourceCopy, cp.Spec.Execs[2].Args, + "mutating the projected slice must not propagate to the source profile (cloned, not aliased)") +} + +// TestApply_ExecsByPath_NilWhenSpecEmpty pins the contract that an +// empty Execs list yields a nil ExecsByPath (not an allocated empty +// map). Matches the project-wide convention of nil-for-empty. +func TestApply_ExecsByPath_NilWhenSpecEmpty(t *testing.T) { + cp := &v1beta1.ContainerProfile{Spec: v1beta1.ContainerProfileSpec{}} + pcp := Apply(&objectcache.RuleProjectionSpec{}, cp, nil) + require.NotNil(t, pcp) + assert.Nil(t, pcp.ExecsByPath, "empty Spec.Execs must project to nil ExecsByPath") +} diff --git a/pkg/objectcache/projection_types.go b/pkg/objectcache/projection_types.go index ed55d671b..c9d8c3a6b 100644 --- a/pkg/objectcache/projection_types.go +++ b/pkg/objectcache/projection_types.go @@ -54,6 +54,15 @@ type ProjectedContainerProfile struct { IngressDomains ProjectedField IngressAddresses ProjectedField + // ExecsByPath carries the per-Path Args slice from cp.Spec.Execs so + // downstream consumers (e.g. dynamicpathdetector.CompareExecArgs used + // by R0040 in node-agent#807) can run wildcard-aware argv matching + // against the projected profile. Keyed by Exec.Path (same key used + // in Execs.Values / Execs.Patterns). Projection-v1 dropped argv + // matching as "future work"; this field re-adds the storage surface + // without re-introducing the matcher itself. + ExecsByPath map[string][]string + SpecHash string SyncChecksum string PolicyByRuleId map[string]v1beta1.RulePolicy From a881579c7940c6fa9162c6f74edcd1c04f906669 Mon Sep 17 00:00:00 2001 From: entlein Date: Fri, 15 May 2026 22:32:52 +0200 Subject: [PATCH 02/17] path-wildcards: anchored trailing-* + per-endpoint port + R0040 args Signed-off-by: entlein --- .../cel/libraries/applicationprofile/ap.go | 22 -- .../cel/libraries/applicationprofile/exec.go | 40 ++- .../libraries/applicationprofile/exec_test.go | 134 ++++++++- .../applicationprofile/integration_test.go | 2 +- .../cel/libraries/applicationprofile/open.go | 69 ++--- .../libraries/applicationprofile/open_test.go | 284 ++++++------------ 6 files changed, 272 insertions(+), 279 deletions(-) diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/ap.go b/pkg/rulemanager/cel/libraries/applicationprofile/ap.go index ce86d7ab8..fabf311c2 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/ap.go +++ b/pkg/rulemanager/cel/libraries/applicationprofile/ap.go @@ -111,25 +111,6 @@ func (l *apLibrary) Declarations() map[string][]cel.FunctionOpt { }), ), }, - "ap.was_path_opened_with_flags": { - cel.Overload( - "ap_was_path_opened_with_flags", []*cel.Type{cel.StringType, cel.StringType, cel.ListType(cel.StringType)}, cel.BoolType, - cel.FunctionBinding(func(values ...ref.Val) ref.Val { - if len(values) != 3 { - return types.NewErr("expected 3 arguments, got %d", len(values)) - } - if l.detailedMetrics && l.metrics != nil { - l.metrics.IncHelperCall("ap.was_path_opened_with_flags") - } - wrapperFunc := func(args ...ref.Val) ref.Val { - return l.wasPathOpenedWithFlags(args[0], args[1], args[2]) - } - cachedFunc := l.functionCache.WithCache(wrapperFunc, "ap.was_path_opened_with_flags", cache.HashForContainerProfile(l.objectCache)) - result := cachedFunc(values[0], values[1], values[2]) - return cache.ConvertProfileNotAvailableErrToBool(result, false) - }), - ), - }, "ap.was_path_opened_with_suffix": { cel.Overload( "ap_was_path_opened_with_suffix", []*cel.Type{cel.StringType, cel.StringType}, cel.BoolType, @@ -354,9 +335,6 @@ func (e *apCostEstimator) EstimateCallCost(function, overloadID string, target * case "ap.was_path_opened": // Cache lookup + O(n) linear search + dynamic path comparison cost = 25 - case "ap.was_path_opened_with_flags": - // Cache lookup + O(n) search + dynamic path comparison + O(f*p) flag comparison - cost = 40 case "ap.was_path_opened_with_suffix": // Cache lookup + O(n) linear search + O(n*len(suffix)) string suffix checks cost = 20 diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/exec.go b/pkg/rulemanager/cel/libraries/applicationprofile/exec.go index b69a69c0e..5f5736922 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/exec.go +++ b/pkg/rulemanager/cel/libraries/applicationprofile/exec.go @@ -70,12 +70,11 @@ func (l *apLibrary) wasExecutedWithArgs(containerID, path, args ref.Val) ref.Val return types.MaybeNoSuchOverloadErr(path) } - // v1 limitation for rule authors: wasExecutedWithArgs is currently equivalent - // to wasExecuted — the args list is validated but not matched against. Any - // execution of the given path returns true regardless of its arguments. Full - // argument matching (ExecArgsByPath) will be added in a future version. - _ = args - if _, err := celparse.ParseList[string](args); err != nil { + // Parse the runtime args list from CEL. Empty list is valid ("exec'd + // with no args") and matches a profile entry whose Args is also empty + // or absent (empty profile Args = "no argv constraint"). + runtimeArgs, err := celparse.ParseList[string](args) + if err != nil { return types.NewErr("failed to parse args: %v", err) } @@ -84,20 +83,37 @@ func (l *apLibrary) wasExecutedWithArgs(containerID, path, args ref.Val) ref.Val return types.Bool(true) } - cp, _, err := profilehelper.GetProjectedContainerProfile(l.objectCache, containerIDStr) - if err != nil { + cp, _, perr := profilehelper.GetProjectedContainerProfile(l.objectCache, containerIDStr) + if perr != nil { // Return a special error that will NOT be cached, allowing retry when profile becomes available. // The caller should convert this to false after the cache layer. - return cache.NewProfileNotAvailableErr("%v", err) + return cache.NewProfileNotAvailableErr("%v", perr) } + // Exact path match: walk the profile's Args for that path via + // CompareExecArgs (handles ⋯ single-arg and * zero-or-more tokens). if _, ok := cp.Execs.Values[pathStr]; ok { - return types.Bool(true) + if profileArgs, ok := cp.ExecsByPath[pathStr]; ok { + if dynamicpathdetector.CompareExecArgs(profileArgs, runtimeArgs) { + return types.Bool(true) + } + } else { + // No ExecsByPath entry for this path — back-compat: treat as + // "no argv constraint", match. + return types.Bool(true) + } } - // Check Patterns (dynamic-segment entries). + // Pattern path match: dynamic-segment paths in cp.Execs.Patterns. + // Args matching mirrors the exact-path case. for _, execPath := range cp.Execs.Patterns { if dynamicpathdetector.CompareDynamic(execPath, pathStr) { - return types.Bool(true) + if profileArgs, ok := cp.ExecsByPath[execPath]; ok { + if dynamicpathdetector.CompareExecArgs(profileArgs, runtimeArgs) { + return types.Bool(true) + } + } else { + return types.Bool(true) + } } } diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/exec_test.go b/pkg/rulemanager/cel/libraries/applicationprofile/exec_test.go index 085e2215f..625559e67 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/exec_test.go +++ b/pkg/rulemanager/cel/libraries/applicationprofile/exec_test.go @@ -200,12 +200,14 @@ func TestExecWithArgsInProfile(t *testing.T) { expectedResult: true, }, { - // v1 degradation: args projection is out of scope; path-only matching. + // Args are anchored — wrong arg mismatch must reject the exec. + // Fork restores CompareExecArgs matching that upstream + // projection-v1 had temporarily dropped. name: "Path matches but args don't match", containerID: "test-container-id", path: "/bin/ls", args: []string{"-la", "/home"}, - expectedResult: true, + expectedResult: false, }, { name: "Path doesn't exist", @@ -229,12 +231,15 @@ func TestExecWithArgsInProfile(t *testing.T) { expectedResult: true, }, { - // v1 degradation: args projection is out of scope; path-only matching. + // /bin/ls in the profile has Args: ["-la", "/tmp"]. An empty + // runtime args list cannot satisfy a 2-arg anchored profile. + // (Empty profile Args = "no argv constraint" still matches via + // the back-compat branch; that's a separate case.) name: "Empty args list", containerID: "test-container-id", path: "/bin/ls", args: []string{}, - expectedResult: true, + expectedResult: false, }, } @@ -301,6 +306,127 @@ func TestExecWithArgsNoProfile(t *testing.T) { assert.False(t, actualResult, "ap.was_executed_with_args should return false when no profile is available") } +// 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. +// +// The runtime exec arg vector is matched against the profile via +// dynamicpathdetector.CompareExecArgs (added in +// k8sstormcenter/storage#23 — the matcher that this CEL function now +// routes through instead of slices.Compare). +func TestExecWithArgsWildcardInProfile(t *testing.T) { + objCache := objectcachev1.RuleObjectCacheMock{ + ContainerIDToSharedData: maps.NewSafeMap[string, *objectcache.WatchedContainerData](), + } + + objCache.SetSharedContainerData("test-container-id", &objectcache.WatchedContainerData{ + ContainerType: objectcache.Container, + ContainerInfos: map[objectcache.ContainerType][]objectcache.ContainerInfo{ + objectcache.Container: { + { + Name: "test-container", + }, + }, + }, + }) + + profile := &v1beta1.ApplicationProfile{} + profile.Spec.Containers = append(profile.Spec.Containers, v1beta1.ApplicationProfileContainer{ + Name: "test-container", + Execs: []v1beta1.ExecCalls{ + // curl any URL: --user must be literal, value is one position. + { + Path: "/usr/bin/curl", + Args: []string{"--user", "⋯"}, + }, + // sh -c with any trailing payload (zero or more args). + { + Path: "/bin/sh", + Args: []string{"-c", "*"}, + }, + // ls -l in any directory — single trailing position. + { + Path: "/bin/ls", + Args: []string{"-l", "⋯"}, + }, + // echo with any number of greeting words after a literal anchor. + { + Path: "/bin/echo", + Args: []string{"hello", "*"}, + }, + }, + }) + objCache.SetApplicationProfile(profile) + + env, err := cel.NewEnv( + cel.Variable("containerID", cel.StringType), + cel.Variable("path", cel.StringType), + cel.Variable("args", cel.ListType(cel.StringType)), + AP(&objCache, config.Config{}), + ) + if err != nil { + t.Fatalf("failed to create env: %v", err) + } + + testCases := []struct { + name string + path string + args []string + expectedResult bool + }{ + // curl with --user, dynamic value + {"curl --user alice — ⋯ matches one arg", "/usr/bin/curl", []string{"--user", "alice"}, true}, + {"curl --user alice bob — extra arg, ⋯ rejects", "/usr/bin/curl", []string{"--user", "alice", "bob"}, false}, + {"curl --user — missing value, ⋯ requires one arg", "/usr/bin/curl", []string{"--user"}, false}, + {"curl --pass alice — literal mismatch", "/usr/bin/curl", []string{"--pass", "alice"}, false}, + + // 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 -x — wrong flag", "/bin/sh", []string{"-x", "echo hi"}, false}, + + // ls -l in any directory + {"ls -l /var/log", "/bin/ls", []string{"-l", "/var/log"}, true}, + {"ls -l with no directory (⋯ requires one)", "/bin/ls", []string{"-l"}, false}, + + // echo hello * + {"echo hello world from test", "/bin/echo", []string{"hello", "world", "from", "test"}, true}, + {"echo hello (no trailing args)", "/bin/echo", []string{"hello"}, true}, + {"echo goodbye world — wrong literal anchor", "/bin/echo", []string{"goodbye", "world"}, false}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ast, issues := env.Compile(`ap.was_executed_with_args(containerID, path, args)`) + 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) + } + + result, _, err := program.Eval(map[string]interface{}{ + "containerID": "test-container-id", + "path": tc.path, + "args": tc.args, + }) + if err != nil { + t.Fatalf("failed to eval program: %v", err) + } + + actualResult := result.Value().(bool) + assert.Equal(t, tc.expectedResult, actualResult, + "runtime args %v vs profile (one of curl/sh/ls/echo overlay): got %v want %v", + tc.args, actualResult, tc.expectedResult) + }) + } +} + func TestExecWithArgsCompilation(t *testing.T) { objCache := objectcachev1.RuleObjectCacheMock{} diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/integration_test.go b/pkg/rulemanager/cel/libraries/applicationprofile/integration_test.go index 885ace3f4..46784e7b8 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/integration_test.go +++ b/pkg/rulemanager/cel/libraries/applicationprofile/integration_test.go @@ -86,7 +86,7 @@ func TestIntegrationWithAllFunctions(t *testing.T) { }, { name: "Check file access pattern", - expression: `ap.was_path_opened_with_flags(containerID, "/etc/passwd", ["O_RDONLY"])`, + expression: `ap.was_path_opened(containerID, "/etc/passwd")`, expectedResult: true, }, { diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/open.go b/pkg/rulemanager/cel/libraries/applicationprofile/open.go index ec0a8310c..fccf19a10 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/open.go +++ b/pkg/rulemanager/cel/libraries/applicationprofile/open.go @@ -6,7 +6,6 @@ import ( "github.com/google/cel-go/common/types" "github.com/google/cel-go/common/types/ref" "github.com/kubescape/node-agent/pkg/rulemanager/cel/libraries/cache" - "github.com/kubescape/node-agent/pkg/rulemanager/cel/libraries/celparse" "github.com/kubescape/node-agent/pkg/rulemanager/profilehelper" "github.com/kubescape/storage/pkg/registry/file/dynamicpathdetector" ) @@ -46,45 +45,6 @@ func (l *apLibrary) wasPathOpened(containerID, path ref.Val) ref.Val { return types.Bool(false) } -func (l *apLibrary) wasPathOpenedWithFlags(containerID, path, flags ref.Val) ref.Val { - if l.objectCache == nil { - return types.NewErr("objectCache is nil") - } - - containerIDStr, ok := containerID.Value().(string) - if !ok { - return types.MaybeNoSuchOverloadErr(containerID) - } - - pathStr, ok := path.Value().(string) - if !ok { - return types.MaybeNoSuchOverloadErr(path) - } - - // flags projection (OpenFlagsByPath) is out of scope for v1; degrade to path-only matching. - if _, err := celparse.ParseList[string](flags); err != nil { - return types.NewErr("failed to parse flags: %v", err) - } - - cp, _, err := profilehelper.GetProjectedContainerProfile(l.objectCache, containerIDStr) - if err != nil { - return cache.NewProfileNotAvailableErr("%v", err) - } - - for openPath := range cp.Opens.Values { - if dynamicpathdetector.CompareDynamic(openPath, pathStr) { - return types.Bool(true) - } - } - for _, openPath := range cp.Opens.Patterns { - if dynamicpathdetector.CompareDynamic(openPath, pathStr) { - return types.Bool(true) - } - } - - return types.Bool(false) -} - func (l *apLibrary) wasPathOpenedWithSuffix(containerID, suffix ref.Val) ref.Val { if l.objectCache == nil { return types.NewErr("objectCache is nil") @@ -105,17 +65,21 @@ 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 ONLY concrete entries in Values — Patterns + // contain wildcard tokens ('*' / '⋯') whose text doesn't safely + // answer suffix questions. CodeRabbit PR #43 open.go:79: a + // retained Pattern like "/var/log/pods/*/volumes/..." doesn't + // end with the concrete suffix "foo.log", but the concrete open + // it stands in for might — strings.HasSuffix on the pattern + // text returns false and produces a false negative. Patterns + // are inherently wildcard-shaped; concrete-path semantics live + // in Values (and in SuffixHits when projection is active). 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) { - return types.Bool(true) - } - } return types.Bool(false) } // Projection applied — SuffixHits is authoritative; absent key = undeclared. @@ -149,17 +113,18 @@ 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 ONLY Values (concrete paths). + // Patterns contain wildcard tokens whose text doesn't safely + // answer prefix questions; a pattern starting with "/var/⋯/log" + // matches concrete paths starting with "/var/anything/log" but + // strings.HasPrefix against the pattern text returns false for + // "/var/foo/log...". Same fix as wasPathOpenedWithSuffix above. + // CodeRabbit PR #43 open.go:79 (Also applies to 111-123). 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) { - return types.Bool(true) - } - } return types.Bool(false) } // Projection applied — PrefixHits is authoritative; absent key = undeclared. diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/open_test.go b/pkg/rulemanager/cel/libraries/applicationprofile/open_test.go index bf407611e..9fce787ae 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,108 @@ 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. +// +// This test exercises the pass-through path directly by setting a +// ProjectedContainerProfile where Opens.All=true, Values contains a +// concrete path with the queried suffix, and Patterns contains a +// wildcard-pattern that ALSO appears to satisfy strings.HasSuffix +// against the queried suffix. The pattern must be ignored. +func TestWasPathOpenedWithSuffix_PatternsNotScanned(t *testing.T) { + // Pass-through pcp (Opens.All=true): + // Values: ["/var/log/concrete.log"] — concrete, ends with ".log" + // Patterns: ["/var/log/⋯/foo.log"] — wildcard, ALSO ends with ".log" + // Querying suffix=".log" should match Values; we then strip + // concrete.log from Values and assert suffix doesn't match + // through Patterns alone. + 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) With concrete in Values: returns true. + 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. Suffix '.log' + // text-matches the pattern but the pattern is wildcardised — the + // correct answer is false (no concrete observation supports it). + 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 ONLY wildcard pattern /var/log/⋯/foo.log: "+ + "expected false (patterns must not be scanned), got %v", got) } +} - objCache.SetSharedContainerData("test-container-id", &objectcache.WatchedContainerData{ - ContainerType: objectcache.Container, - ContainerInfos: map[objectcache.ContainerType][]objectcache.ContainerInfo{ - objectcache.Container: { - { - Name: "test-container", - }, - }, +// TestWasPathOpenedWithPrefix_PatternsNotScanned mirrors the suffix +// test for the prefix path. Same rabbit finding (open.go:79 Also +// applies to: 111-123). +func TestWasPathOpenedWithPrefix_PatternsNotScanned(t *testing.T) { + pcp := &objectcache.ProjectedContainerProfile{ + Opens: objectcache.ProjectedField{ + All: true, + Values: map[string]struct{}{"/var/concrete/foo": {}}, + Patterns: []string{"/var/⋯/log/foo"}, }, - }) - - 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) - - 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) } + objCache := &mockObjectCacheForPattern{pcp: pcp} + lib := &apLibrary{objectCache: objCache} - 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, - }, + 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) } - 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) - } - - result, _, err := program.Eval(map[string]interface{}{ - "containerID": tc.containerID, - "path": tc.path, - }) - 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 result should match expected value") - }) + 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 ONLY wildcard pattern /var/⋯/log/foo: "+ + "expected false (patterns must not be scanned), 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) - } - - ast, issues := env.Compile(`ap.was_path_opened(containerID, path)`) - if issues != nil { - t.Fatalf("failed to compile expression: %v", issues.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 +} - program, err := env.Program(ast) - if err != nil { - t.Fatalf("failed to create program: %v", err) - } +func (m *mockObjectCacheForPattern) ContainerProfileCache() objectcache.ContainerProfileCache { + return &mockCPCForPattern{pcp: m.pcp} +} - 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) - } +type mockCPCForPattern struct { + objectcache.ContainerProfileCache + 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 *mockCPCForPattern) GetProjectedContainerProfile(_ string) *objectcache.ProjectedContainerProfile { + return m.pcp } -func TestOpenWithFlagsInProfile(t *testing.T) { +func TestOpenInProfile(t *testing.T) { objCache := objectcachev1.RuleObjectCacheMock{ ContainerIDToSharedData: maps.NewSafeMap[string, *objectcache.WatchedContainerData](), } @@ -167,10 +142,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 +149,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 +159,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 +196,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 +232,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) { From 314cf7fe2c793502042145ed76ac7730033d7378 Mon Sep 17 00:00:00 2001 From: entlein Date: Fri, 15 May 2026 22:56:36 +0200 Subject: [PATCH 03/17] restoring ap_was_path_opened_with_flags Signed-off-by: entlein --- .../cel/libraries/applicationprofile/ap.go | 22 +++++++++ .../applicationprofile/integration_test.go | 2 +- .../cel/libraries/applicationprofile/open.go | 47 +++++++++++++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/ap.go b/pkg/rulemanager/cel/libraries/applicationprofile/ap.go index fabf311c2..ce86d7ab8 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/ap.go +++ b/pkg/rulemanager/cel/libraries/applicationprofile/ap.go @@ -111,6 +111,25 @@ func (l *apLibrary) Declarations() map[string][]cel.FunctionOpt { }), ), }, + "ap.was_path_opened_with_flags": { + cel.Overload( + "ap_was_path_opened_with_flags", []*cel.Type{cel.StringType, cel.StringType, cel.ListType(cel.StringType)}, cel.BoolType, + cel.FunctionBinding(func(values ...ref.Val) ref.Val { + if len(values) != 3 { + return types.NewErr("expected 3 arguments, got %d", len(values)) + } + if l.detailedMetrics && l.metrics != nil { + l.metrics.IncHelperCall("ap.was_path_opened_with_flags") + } + wrapperFunc := func(args ...ref.Val) ref.Val { + return l.wasPathOpenedWithFlags(args[0], args[1], args[2]) + } + cachedFunc := l.functionCache.WithCache(wrapperFunc, "ap.was_path_opened_with_flags", cache.HashForContainerProfile(l.objectCache)) + result := cachedFunc(values[0], values[1], values[2]) + return cache.ConvertProfileNotAvailableErrToBool(result, false) + }), + ), + }, "ap.was_path_opened_with_suffix": { cel.Overload( "ap_was_path_opened_with_suffix", []*cel.Type{cel.StringType, cel.StringType}, cel.BoolType, @@ -335,6 +354,9 @@ func (e *apCostEstimator) EstimateCallCost(function, overloadID string, target * case "ap.was_path_opened": // Cache lookup + O(n) linear search + dynamic path comparison cost = 25 + case "ap.was_path_opened_with_flags": + // Cache lookup + O(n) search + dynamic path comparison + O(f*p) flag comparison + cost = 40 case "ap.was_path_opened_with_suffix": // Cache lookup + O(n) linear search + O(n*len(suffix)) string suffix checks cost = 20 diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/integration_test.go b/pkg/rulemanager/cel/libraries/applicationprofile/integration_test.go index 46784e7b8..885ace3f4 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/integration_test.go +++ b/pkg/rulemanager/cel/libraries/applicationprofile/integration_test.go @@ -86,7 +86,7 @@ func TestIntegrationWithAllFunctions(t *testing.T) { }, { name: "Check file access pattern", - expression: `ap.was_path_opened(containerID, "/etc/passwd")`, + expression: `ap.was_path_opened_with_flags(containerID, "/etc/passwd", ["O_RDONLY"])`, expectedResult: true, }, { diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/open.go b/pkg/rulemanager/cel/libraries/applicationprofile/open.go index fccf19a10..62a4abedf 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/open.go +++ b/pkg/rulemanager/cel/libraries/applicationprofile/open.go @@ -6,6 +6,7 @@ import ( "github.com/google/cel-go/common/types" "github.com/google/cel-go/common/types/ref" "github.com/kubescape/node-agent/pkg/rulemanager/cel/libraries/cache" + "github.com/kubescape/node-agent/pkg/rulemanager/cel/libraries/celparse" "github.com/kubescape/node-agent/pkg/rulemanager/profilehelper" "github.com/kubescape/storage/pkg/registry/file/dynamicpathdetector" ) @@ -45,6 +46,52 @@ 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") + } + + containerIDStr, ok := containerID.Value().(string) + if !ok { + return types.MaybeNoSuchOverloadErr(containerID) + } + + pathStr, ok := path.Value().(string) + if !ok { + return types.MaybeNoSuchOverloadErr(path) + } + + // flags projection (OpenFlagsByPath) is out of scope for v1; degrade to path-only matching. + if _, err := celparse.ParseList[string](flags); err != nil { + return types.NewErr("failed to parse flags: %v", err) + } + + cp, _, err := profilehelper.GetProjectedContainerProfile(l.objectCache, containerIDStr) + if err != nil { + return cache.NewProfileNotAvailableErr("%v", err) + } + + for openPath := range cp.Opens.Values { + if dynamicpathdetector.CompareDynamic(openPath, pathStr) { + return types.Bool(true) + } + } + for _, openPath := range cp.Opens.Patterns { + if dynamicpathdetector.CompareDynamic(openPath, pathStr) { + return types.Bool(true) + } + } + + return types.Bool(false) +} + func (l *apLibrary) wasPathOpenedWithSuffix(containerID, suffix ref.Val) ref.Val { if l.objectCache == nil { return types.NewErr("objectCache is nil") From c16f729c26112cf5b320395c2d2c2f926c07ab58 Mon Sep 17 00:00:00 2001 From: entlein Date: Sat, 16 May 2026 13:19:07 +0200 Subject: [PATCH 04/17] apply rabbit feedback: align R0040 args consumer with rc1 final state Signed-off-by: entlein --- .../cel/libraries/applicationprofile/exec.go | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/exec.go b/pkg/rulemanager/cel/libraries/applicationprofile/exec.go index 5f5736922..10e8d4c97 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/exec.go +++ b/pkg/rulemanager/cel/libraries/applicationprofile/exec.go @@ -92,14 +92,40 @@ func (l *apLibrary) wasExecutedWithArgs(containerID, path, args ref.Val) ref.Val // Exact path match: walk the profile's Args for that path via // CompareExecArgs (handles ⋯ single-arg and * zero-or-more tokens). + // + // ExecsByPath absent-vs-empty asymmetry — CodeRabbit upstream PR + // #807 finding #8. Three states to distinguish: + // + // 1. Path absent from cp.Execs.Values: + // Profile doesn't allow this exec at all → fall through to + // the pattern-match loop, then to false. + // + // 2. Path in Values, ABSENT from ExecsByPath (map lookup ok=false): + // Legacy / pre-args-projection profiles. Treated as + // "no argv constraint" — back-compat MATCH any args. + // This is the intentional fallback for profiles compiled + // against older storage versions that didn't populate the + // composite ExecsByPath surface. + // + // 3. Path in Values, PRESENT in ExecsByPath with an EMPTY arg + // list ([]): + // Profile explicitly captured "this path ran with no args". + // CompareExecArgs matches only when runtimeArgs is also + // empty. NOT a back-compat fallback — a deliberately tight + // constraint authored by the profile producer. + // + // The distinction matters for rule-author intuition: producing a + // signed profile that lists `{Path: /usr/bin/foo, Args: []}` is a + // CONSTRAINT, not a wildcard. Authors who want "any args" must + // omit the ExecsByPath entry (rare) or use an explicit `*` + // wildcard token in Args. if _, ok := cp.Execs.Values[pathStr]; ok { if profileArgs, ok := cp.ExecsByPath[pathStr]; ok { if dynamicpathdetector.CompareExecArgs(profileArgs, runtimeArgs) { return types.Bool(true) } } else { - // No ExecsByPath entry for this path — back-compat: treat as - // "no argv constraint", match. + // State 2: ExecsByPath absent → back-compat "no argv constraint". return types.Bool(true) } } From 7b6361467f8eed000aa7f8099d54897ae2033c69 Mon Sep 17 00:00:00 2001 From: Entlein Date: Wed, 27 May 2026 20:34:36 +0200 Subject: [PATCH 05/17] build(go.mod): replace kubescape/storage with sister execs branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR (R0040 args consumer) references dynamicpathdetector.CompareExecArgs, which ships in k8sstormcenter/storage's upstream-pr/sbob-execs branch (the storage sibling of kubescape/storage#322), not in any released kubescape/storage tag. The PR's existing go.mod pin (v0.0.258) does not contain it. Adds a replace directive to the sister branch's current tip (5e39d001 — feat(apis): add ExecCalls.ArgsRequired + MatchExecArgs to express 'no args'). Removed when the sister storage PR kubescape/storage#322 merges and a release ships CompareExecArgs. Companion to the stacking-on-NA-#812 cherry-pick (provides cp.ExecsByPath) in the previous commits on this branch. Resolves the matthyx 'does not build' blocker on PR #807 (2026-05-27). Signed-off-by: entlein --- go.mod | 2 ++ go.sum | 2 ++ 2 files changed, 4 insertions(+) diff --git a/go.mod b/go.mod index 68ee6ab37..827e71035 100644 --- a/go.mod +++ b/go.mod @@ -479,3 +479,5 @@ replace github.com/inspektor-gadget/inspektor-gadget => github.com/matthyx/inspe replace github.com/cilium/ebpf => github.com/matthyx/ebpf v0.0.0-20260421101317-8a32d06def6c replace github.com/anchore/syft => github.com/kubescape/syft v1.32.0-ks.2 + +replace github.com/kubescape/storage => github.com/k8sstormcenter/storage v0.0.240-0.20260527160734-5e39d0018391 diff --git a/go.sum b/go.sum index 5cbde2056..c1109c8de 100644 --- a/go.sum +++ b/go.sum @@ -855,6 +855,8 @@ github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHm github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/k8sstormcenter/storage v0.0.240-0.20260527160734-5e39d0018391 h1:IIDExlszvZR7ZrEFo4d9awPoIu9arywDIPDng5m527g= +github.com/k8sstormcenter/storage v0.0.240-0.20260527160734-5e39d0018391/go.mod h1:FpV6tCrYXlp2kKWza4yr7zf2Y1q7IGgx871ndN7SMNo= github.com/kastenhq/goversion v0.0.0-20230811215019-93b2f8823953 h1:WdAeg/imY2JFPc/9CST4bZ80nNJbiBFCAdSZCSgrS5Y= github.com/kastenhq/goversion v0.0.0-20230811215019-93b2f8823953/go.mod h1:6o+UrvuZWc4UTyBhQf0LGjW9Ld7qJxLz/OqvSOWWlEc= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= From c1ea27f40823a3db90f3d921f46389dfcfe49a81 Mon Sep 17 00:00:00 2001 From: entlein Date: Wed, 27 May 2026 22:50:12 +0200 Subject: [PATCH 06/17] test: add Test_32_UnexpectedProcessArguments + fixtures Signed-off-by: entlein --- tests/component_test.go | 3634 +++++++++++++++++ .../curl-exec-arg-wildcards-deployment.yaml | 28 + 2 files changed, 3662 insertions(+) create mode 100644 tests/resources/curl-exec-arg-wildcards-deployment.yaml diff --git a/tests/component_test.go b/tests/component_test.go index fcdb760bf..d2766f638 100644 --- a/tests/component_test.go +++ b/tests/component_test.go @@ -1569,3 +1569,3637 @@ func Test_24_ProcessTreeDepthTest(t *testing.T) { t.Logf("Found alerts for the process tree depth: %v", alerts) } +//go:build component + +package tests + +import ( + "context" + "encoding/json" + "fmt" + "path" + "reflect" + "runtime" + "slices" + "sort" + "strconv" + "strings" + "testing" + "time" + + "github.com/kubescape/go-logger" + "github.com/kubescape/go-logger/helpers" + helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers" + "github.com/kubescape/k8s-interface/k8sinterface" + "github.com/kubescape/node-agent/pkg/signature" + "github.com/kubescape/node-agent/pkg/signature/profiles" + "github.com/kubescape/node-agent/pkg/utils" + "github.com/kubescape/node-agent/tests/testutils" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + spdxv1beta1client "github.com/kubescape/storage/pkg/generated/clientset/versioned/typed/softwarecomposition/v1beta1" + "github.com/kubescape/storage/pkg/registry/file/dynamicpathdetector" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" +) + +func tearDownTest(t *testing.T, startTime time.Time) { + end := time.Now() + + t.Log("Waiting 30 seconds for Prometheus to scrape the data") + time.Sleep(30 * time.Second) + + err := testutils.PlotNodeAgentPrometheusCPUUsage(t.Name(), startTime, end) + require.NoError(t, err, "Error plotting CPU usage") + + _, err = testutils.PlotNodeAgentPrometheusMemoryUsage(t.Name(), startTime, end) + require.NoError(t, err, "Error plotting memory usage") + + testutils.PrintAppLogs(t, "node-agent") + testutils.PrintAppLogs(t, "malicious-app") + testutils.PrintAppLogs(t, "endpoint-traffic") +} + +func Test_01_BasicAlertTest(t *testing.T) { + start := time.Now() + defer tearDownTest(t, start) + + ns := testutils.NewRandomNamespace() + wl, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/deployment-multiple-containers.yaml")) + require.NoError(t, err, "Error creating workload") + require.NoError(t, wl.WaitForReady(80)) + + time.Sleep(10 * time.Second) + + // process launched from nginx container + _, _, err = wl.ExecIntoPod([]string{"ls", "-l"}, "nginx") + + // network activity from server container + _, _, err = wl.ExecIntoPod([]string{"wget", "ebpf.io", "-T", "2", "-t", "1"}, "server") + + // network activity from nginx container + _, _, err = wl.ExecIntoPod([]string{"curl", "kubernetes.io", "-m", "2"}, "nginx") + + err = wl.WaitForApplicationProfileCompletion(80) + require.NoError(t, err, "Error waiting for application profile to be completed") + err = wl.WaitForNetworkNeighborhoodCompletion(80) + require.NoError(t, err, "Error waiting for network neighborhood to be completed") + + time.Sleep(30 * time.Second) + + appProfile, _ := wl.GetApplicationProfile() + appProfileJson, _ := json.Marshal(appProfile) + + networkNeighborhood, _ := wl.GetNetworkNeighborhood() + networkNeighborhoodJson, _ := json.Marshal(networkNeighborhood) + + t.Logf("network neighborhood: %v", string(networkNeighborhoodJson)) + + t.Logf("application profile: %v", string(appProfileJson)) + + _, _, err = wl.ExecIntoPod([]string{"ls", "-l"}, "nginx") // no alert expected + _, _, err = wl.ExecIntoPod([]string{"ls", "-l"}, "server") // alert expected + _, _, err = wl.ExecIntoPod([]string{"wget", "ebpf.io", "-T", "2", "-t", "1"}, "server") // no alert expected + _, _, err = wl.ExecIntoPod([]string{"curl", "ebpf.io", "-m", "2"}, "nginx") // alert expected + + // Wait for the alert to be signaled + time.Sleep(30 * time.Second) + + alerts, err := testutils.GetAlerts(wl.Namespace) + require.NoError(t, err, "Error getting alerts") + + testutils.AssertContains(t, alerts, "Unexpected process launched", "ls", "server", []bool{true}) + testutils.AssertNotContains(t, alerts, "Unexpected process launched", "ls", "nginx", []bool{true}) + + testutils.AssertContains(t, alerts, "DNS Anomalies in container", "curl", "nginx", []bool{true}) + testutils.AssertNotContains(t, alerts, "DNS Anomalies in container", "wget", "server", []bool{true}) + + // Verify UID fields are populated in alerts + testutils.AssertUIDFieldsPopulated(t, alerts, wl.Namespace) + + // check network neighborhood + nn, _ := wl.GetNetworkNeighborhood() + testutils.AssertNetworkNeighborhoodContains(t, nn, "nginx", []string{"kubernetes.io."}, []string{}) + testutils.AssertNetworkNeighborhoodNotContains(t, nn, "server", []string{"kubernetes.io."}, []string{}) + + testutils.AssertNetworkNeighborhoodContains(t, nn, "server", []string{"ebpf.io."}, []string{}) + testutils.AssertNetworkNeighborhoodNotContains(t, nn, "nginx", []string{"ebpf.io."}, []string{}) +} + +func Test_02_AllAlertsFromMaliciousApp(t *testing.T) { + start := time.Now() + defer tearDownTest(t, start) + + // Create a random namespace + ns := testutils.NewRandomNamespace() + + // Create a workload + wl, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/malicious-job.yaml")) + require.NoError(t, err, "Error creating workload") + + // Wait for the workload to be ready + err = wl.WaitForReady(80) + require.NoError(t, err, "Error waiting for workload to be ready") + + // Wait for the application profile to be created and completed + err = wl.WaitForApplicationProfileCompletion(150) + require.NoError(t, err, "Error waiting for application profile to be completed") + + // Wait for the alerts to be generated + time.Sleep(2 * time.Minute) + + // Get all the alerts for the namespace + alerts, err := testutils.GetAlerts(wl.Namespace) + require.NoError(t, err, "Error getting alerts") + + // Validate that all alerts are signaled + expectedAlerts := map[string]bool{ + "Unexpected process launched": false, + "Files Access Anomalies in container": false, + "Syscalls Anomalies in container": false, + "Linux Capabilities Anomalies in container": false, + "Workload uses Kubernetes API unexpectedly": false, + "Process executed from malicious source": false, + "Process tries to load a kernel module": false, + "Drifted process executed": false, + "Process executed from mount": false, + "Unexpected service account token access": false, + "DNS Anomalies in container": false, + "Crypto Mining Related Port Communication": false, + "Crypto Mining Domain Communication": false, + } + + expectedFailOnProfile := map[string][]bool{ + "Unexpected process launched": {true}, + "Files Access Anomalies in container": {true}, + "Syscalls Anomalies in container": {true}, + "Linux Capabilities Anomalies in container": {true}, + "Workload uses Kubernetes API unexpectedly": {true}, + "Process executed from malicious source": {false}, + "Process tries to load a kernel module": {false}, + "Drifted process executed": {true}, + "Process executed from mount": {true}, + "Unexpected service account token access": {true}, + "DNS Anomalies in container": {true}, + "Crypto Mining Related Port Communication": {true}, + "Crypto Mining Domain Communication": {false}, + } + + for _, alert := range alerts { + ruleName, ruleOk := alert.Labels["rule_name"] + failOnProfile, failOnProfileOk := alert.Labels["fail_on_profile"] + failOnProfileBool, err := strconv.ParseBool(failOnProfile) + require.NoError(t, err, "Error parsing fail_on_profile") + if ruleOk && failOnProfileOk { + if _, exists := expectedAlerts[ruleName]; exists && slices.Contains(expectedFailOnProfile[ruleName], failOnProfileBool) { + expectedAlerts[ruleName] = true + } + } + } + + for ruleName, signaled := range expectedAlerts { + assert.Truef(t, signaled, "Expected alert '%s' was not signaled", ruleName) + } +} + +func Test_03_BasicLoadActivities(t *testing.T) { + start := time.Now() + defer tearDownTest(t, start) + + // Create a random namespace + ns := testutils.NewRandomNamespace() + + // Create a workload + wl, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/nginx-deployment.yaml")) + require.NoError(t, err, "Error creating workload") + + // Wait for the workload to be ready + err = wl.WaitForReady(80) + require.NoError(t, err, "Error waiting for workload to be ready") + + // Wait for the application profile to be created and completed + err = wl.WaitForApplicationProfileCompletion(80) + require.NoError(t, err, "Error waiting for application profile to be completed") + + // Create loader + loader, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/locust-deployment.yaml")) + require.NoError(t, err) + err = loader.WaitForReady(80) + require.NoError(t, err, "Error waiting for workload to be ready") + + loadStart := time.Now() + + // Create a load of 5 minutes + time.Sleep(5 * time.Minute) + + loadEnd := time.Now() + + // Get CPU usage of Node Agent pods + podToCpuUsage, err := testutils.GetNodeAgentAverageCPUUsage(loadStart, loadEnd) + require.NoError(t, err, "Error getting CPU usage") + + require.NotEqual(t, 0, podToCpuUsage, "No CPU usage data found") + + for pod, cpuUsage := range podToCpuUsage { + assert.LessOrEqual(t, cpuUsage, 0.4, "CPU usage of Node Agent is too high. CPU usage is %f, Pod: %s", cpuUsage, pod) + } +} + +func Test_04_MemoryLeak(t *testing.T) { + start := time.Now() + defer tearDownTest(t, start) + + // Create a random namespace + ns := testutils.NewRandomNamespace() + + // Create 2 workloads + wlPaths := []string{ + "resources/locust-deployment.yaml", + "resources/nginx-deployment.yaml", + } + var workloads []testutils.TestWorkload + for _, p := range wlPaths { + wl, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), p)) + require.NoError(t, err, "Error creating deployment") + workloads = append(workloads, *wl) + } + for _, wl := range workloads { + err := wl.WaitForReady(80) + require.NoError(t, err, "Error waiting for workload to be ready") + err = wl.WaitForApplicationProfileCompletion(80) + require.NoError(t, err, "Error waiting for application profile to be completed") + } + + // Wait for 60 seconds for the GC to run, so the memory leak can be detected + time.Sleep(60 * time.Second) + + metrics, err := testutils.PlotNodeAgentPrometheusMemoryUsage("memleak_basic", start, time.Now()) + require.NoError(t, err, "Error plotting memory usage") + + require.NotEqual(t, 0, metrics, "No memory usage data found") + + for _, metric := range metrics { + podName := metric.Name + firstValue := metric.Values[0] + lastValue := metric.Values[len(metric.Values)-1] + + // Validate that there is no memory leak, but tolerate 100Mb memory leak + tolerateMb := 100 + assert.LessOrEqual(t, lastValue, firstValue+float64(tolerateMb*1024*1024), "Memory leak detected in node-agent pod (%s). Memory usage at the end of the test is %f and at the beginning of the test is %f", podName, lastValue, firstValue) + } +} + +func Test_05_MemoryLeak_10K_Alerts(t *testing.T) { + start := time.Now() + defer tearDownTest(t, start) + + // Create a random namespace + ns := testutils.NewRandomNamespace() + + // Create nginx workload + nginx, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/nginx-deployment.yaml")) + require.NoError(t, err, "Error creating workload") + err = nginx.WaitForReady(80) + require.NoError(t, err, "Error waiting for workload to be ready") + + err = nginx.WaitForApplicationProfileCompletion(80) + require.NoError(t, err, "Error waiting for application profile to be completed") + + // wait for 300 seconds for the GC to run, so the memory leak can be detected + t.Log("Waiting 300 seconds to have a baseline memory usage") + time.Sleep(300 * time.Second) + + //Exec into the nginx pod and create a file in the /tmp directory in a loop + startLoad := time.Now() + for i := 0; i < 100; i++ { + _, _, err := nginx.ExecIntoPod([]string{"bash", "-c", "for i in {1..100}; do touch /tmp/nginx-test-$i; done"}, "") + require.NoError(t, err, "Error executing remote command") + if i%5 == 0 { + t.Logf("Created file %d times", (i+1)*100) + } + } + + // wait for 300 seconds for the GC to run, so the memory leak can be detected + t.Log("Waiting 300 seconds to GC to run") + time.Sleep(300 * time.Second) + + metrics, err := testutils.PlotNodeAgentPrometheusMemoryUsage("memleak_10k_alerts", startLoad, time.Now()) + require.NoError(t, err, "Error plotting memory usage") + + require.NotEqual(t, 0, metrics, "No memory usage data found") + + for _, metric := range metrics { + podName := metric.Name + firstValue := metric.Values[0] + lastValue := metric.Values[len(metric.Values)-1] + + // Validate that there is no memory leak, but tolerate 40mb memory leak + tolerateMb := 40 + assert.LessOrEqual(t, lastValue, firstValue+float64(tolerateMb*1024*1024), "Memory leak detected in node-agent pod (%s). Memory usage at the end of the test is %f and at the beginning of the test is %f", podName, lastValue, firstValue) + } +} + +func Test_06_KillProcessInTheMiddle(t *testing.T) { + start := time.Now() + defer tearDownTest(t, start) + + // Create a random namespace + ns := testutils.NewRandomNamespace() + // Create nginx deployment + nginx, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/nginx-deployment.yaml")) + require.NoError(t, err, "Error creating workload") + err = nginx.WaitForReady(80) + require.NoError(t, err, "Error waiting for workload to be ready") + + // Give time for the nginx application profile to be ready + require.NoError(t, nginx.WaitForApplicationProfile(80, "ready")) + + // Exec into the nginx pod and kill the process + _, _, err = nginx.ExecIntoPod([]string{"bash", "-c", "kill -9 1"}, "") + require.NoError(t, err, "Error executing remote command") + + // Wait for the application profile to be 'completed' + err = nginx.WaitForApplicationProfileCompletion(20) + require.NoError(t, err, "Error waiting for application profile to be completed") +} + +func Test_07_RuleBindingApplyTest(t *testing.T) { + ruleBindingPath := func(name string) string { + return path.Join(utils.CurrentDir(), "resources/rulebindings", name) + } + + // valid + exitCode := testutils.RunCommand("kubectl", "apply", "--validate=false", "-f", ruleBindingPath("all-valid.yaml")) + assert.Equal(t, 0, exitCode, "Error applying valid rule binding") + exitCode = testutils.RunCommand("kubectl", "delete", "-f", ruleBindingPath("all-valid.yaml")) + require.Equal(t, 0, exitCode, "Error deleting valid rule binding") + + // duplicate fields + file := ruleBindingPath("dup-fields-name-tag.yaml") + exitCode = testutils.RunCommand("kubectl", "apply", "--validate=false", "-f", file) + assert.NotEqualf(t, 0, exitCode, "Expected error when applying rule binding '%s'", file) + + file = ruleBindingPath("dup-fields-name-id.yaml") + exitCode = testutils.RunCommand("kubectl", "apply", "--validate=false", "-f", file) + assert.NotEqualf(t, 0, exitCode, "Expected error when applying rule binding '%s'", file) + + file = ruleBindingPath("dup-fields-id-tag.yaml") + exitCode = testutils.RunCommand("kubectl", "apply", "--validate=false", "-f", file) + assert.NotEqualf(t, 0, exitCode, "Expected error when applying rule binding '%s'", file) +} + +func Test_08_ApplicationProfilePatching(t *testing.T) { + k8sClient := k8sinterface.NewKubernetesApi() + storageclient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) + + t.Log("Creating namespace") + ns := testutils.NewRandomNamespace() + + name := "replicaset-checkoutservice-59596bf8d8" + applicationProfile := &v1beta1.ApplicationProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: map[string]string{ + "kubescape.io/instance-template-hash": "59596bf8d8", + "kubescape.io/workload-api-group": "apps", + "kubescape.io/workload-api-version": "v1", + "kubescape.io/workload-kind": "Deployment", + "kubescape.io/workload-name": "checkoutservice", + "kubescape.io/workload-namespace": "node-agent-test-veum", + "kubescape.io/workload-resource-version": "667544", + }, + Annotations: map[string]string{ + "kubescape.io/completion": "complete", + "kubescape.io/status": "initializing", + }, + }, + Spec: v1beta1.ApplicationProfileSpec{ + Containers: []v1beta1.ApplicationProfileContainer{ + { + Name: "server", + Syscalls: []string{ + "capget", "capset", "chdir", "close", "epoll_ctl", "faccessat2", + "fcntl", "fstat", "fstatfs", "futex", "getdents64", "getppid", + "nanosleep", "newfstatat", "openat", "prctl", "read", "setgid", + "setgroups", "setuid", "write", + }, + }, + }, + }, + Status: v1beta1.ApplicationProfileStatus{}, + } + + _, err := storageclient.ApplicationProfiles(ns.Name).Create(context.TODO(), applicationProfile, metav1.CreateOptions{}) + require.NoError(t, err) + + // patch the application profile + patchOperations := []utils.PatchOperation{ + {Op: "replace", Path: "/spec/containers/0/capabilities", Value: []string{"NET_ADMIN"}}, + {Op: "add", Path: "/spec/containers/0/capabilities/-", Value: "SETGID"}, + {Op: "add", Path: "/spec/containers/0/capabilities/-", Value: "SETPCAP"}, + {Op: "add", Path: "/spec/containers/0/capabilities/-", Value: "SETUID"}, + {Op: "add", Path: "/spec/containers/0/capabilities/-", Value: "SYS_ADMIN"}, + {Op: "add", Path: "/spec/containers/0/syscalls/-", Value: "accept4"}, + {Op: "add", Path: "/spec/containers/0/syscalls/-", Value: "arch_prctl"}, + {Op: "add", Path: "/spec/containers/0/syscalls/-", Value: "bind"}, + {Op: "replace", Path: "/spec/containers/0/execs", Value: []map[string]interface{}{{ + "path": "/checkoutservice", + "args": []string{"/checkoutservice"}, + }}}, + {Op: "add", Path: "/spec/containers/0/execs/-", Value: map[string]interface{}{ + "path": "/bin/grpc_health_probe", + "args": []string{"/bin/grpc_health_probe", "-addr=:5050"}, + }}, + {Op: "replace", Path: "/metadata/annotations/kubescape.io~1status", Value: "ready"}, + {Op: "replace", Path: "/metadata/annotations/kubescape.io~1completion", Value: "complete"}, + } + + patch, err := json.Marshal(patchOperations) + require.NoError(t, err) + + // TODO use Storage abstraction? + _, err = storageclient.ApplicationProfiles(ns.Name).Patch(context.Background(), name, types.JSONPatchType, patch, v1.PatchOptions{}) + + assert.NoError(t, err) +} + +func Test_09_FalsePositiveTest(t *testing.T) { + start := time.Now() + defer tearDownTest(t, start) + + testutils.IncreaseNodeAgentSniffingTime("10m") + + time.Sleep(5 * time.Second) + + t.Log("Creating namespace") + ns := testutils.NewRandomNamespace() + + t.Log("Creating services") + _, err := testutils.CreateWorkloadsInPath(ns.Name, path.Join(utils.CurrentDir(), "resources/hipster_shop/services")) + require.NoError(t, err, "Error creating services") + + t.Log("Creating deployments") + deployments, err := testutils.CreateWorkloadsInPath(ns.Name, path.Join(utils.CurrentDir(), "resources/hipster_shop/deployments")) + require.NoError(t, err, "Error creating deployments") + + t.Log("Waiting for all workloads to be ready") + for _, wl := range deployments { + err = wl.WaitForReady(80) + require.NoError(t, err, "Error waiting for workload to be ready") + } + t.Log("All workloads are ready") + + t.Log("Waiting for all application profiles to be completed") + for _, wl := range deployments { + err = wl.WaitForApplicationProfileCompletion(80) + require.NoError(t, err, "Error waiting for application profile to be completed") + } + + // wait for 1 minute for the alerts to be generated + time.Sleep(1 * time.Minute) + + require.NoError(t, err, "Error getting pods with restarts") + + alerts, err := testutils.GetAlerts(ns.Name) + require.NoError(t, err, "Error getting alerts") + + // Some rules are structurally noisy on real apps and can't reasonably + // reach zero alerts under an auto-learned baseline: + // + // - R0003 (Syscalls Anomalies): the baseline can never capture + // every syscall a real workload will eventually make (rare + // error paths, late-startup allocations, GC, async I/O). Bob + // chart ships R0003 disabled by default. + // - R0006 (Unexpected service account token access): every pod + // with a service-account legitimately reads + // /var/run/secrets/kubernetes.io/serviceaccount/token to + // authenticate to the K8s API. Hipster-shop services (and the + // prometheus / alertmanager infra the test framework deploys) + // all do this on startup and on every API call. + // + // Test_09's contract is "no FPs on benign workloads under EXEC / + // OPEN / NETWORK / SIGNED-PROFILE rules" — the noisy syscall- and + // SA-token rules are evaluated on their own merits elsewhere (e.g. + // Test_10's 10b subtest pins R0003 firing when the AP declares NO + // syscalls). Filter both out here. + noisyRules := map[string]string{ + "R0003": "Syscalls Anomalies", + "R0006": "SA token access", + } + filtered := alerts[:0] + excluded := map[string]int{} + for _, a := range alerts { + if _, isNoisy := noisyRules[a.Labels["rule_id"]]; isNoisy { + excluded[a.Labels["rule_id"]]++ + continue + } + filtered = append(filtered, a) + } + for ruleID, count := range excluded { + t.Logf("excluded %d %s (%s) alerts from FP gate — structurally noisy on real apps", count, ruleID, noisyRules[ruleID]) + } + if len(filtered) > 0 { + for i, a := range filtered { + t.Logf("unexpected FP[%d]: rule_id=%s rule_name=%s comm=%s container=%s", i, a.Labels["rule_id"], a.Labels["rule_name"], a.Labels["comm"], a.Labels["container_name"]) + } + } + assert.Equal(t, 0, len(filtered), "Expected no non-noisy alerts to be generated, but got %d (excluding %v)", len(filtered), excluded) +} + +// Test_10_CryptoMinerDetection tests crypto-miner detection from two angles: +// - malware_scan: ClamAV file-scanning detects xmrig binary signature +// - empty_profile_rules: empty user-defined AP means every exec/DNS is anomalous, +// so rule-based detection fires immediately without a learning period +func Test_10_MalwareDetectionTest(t *testing.T) { + start := time.Now() + defer tearDownTest(t, start) + + // --------------------------------------------------------------- + // 10a. Malware file-scanning (ClamAV signature match) + // --------------------------------------------------------------- + t.Run("malware_scan", func(t *testing.T) { + ns := testutils.NewRandomNamespace() + + t.Log("Deploy container with malware") + exitCode := testutils.RunCommand("kubectl", "run", "-n", ns.Name, "malware-cryptominer", "--image=quay.io/petr_ruzicka/malware-cryptominer-container:2.0.2") + require.Equalf(t, 0, exitCode, "expected no error when deploying malware container") + + exitCode = testutils.RunCommand("kubectl", "wait", "--for=condition=Ready", "pod", "malware-cryptominer", "-n", ns.Name, "--timeout=300s") + require.Equalf(t, 0, exitCode, "expected no error when waiting for pod to be ready") + + // Wait for application profile to be completed. + time.Sleep(3 * time.Minute) + + _, _, err := testutils.ExecIntoPod("malware-cryptominer", ns.Name, []string{"ls", "-l", "/usr/share/nginx/html/xmrig"}, "") + require.NoErrorf(t, err, "expected no error when executing command in malware container") + + _, _, err = testutils.ExecIntoPod("malware-cryptominer", ns.Name, []string{"/usr/share/nginx/html/xmrig/xmrig"}, "") + + time.Sleep(20 * time.Second) + + alerts, err := testutils.GetMalwareAlerts(ns.Name) + require.NoError(t, err, "Error getting alerts") + + expectedMalwares := []string{ + "Multios.Coinminer.Miner-6781728-2.UNOFFICIAL", + } + + malwaresDetected := map[string]bool{} + for _, alert := range alerts { + podName, podNameOk := alert.Labels["pod_name"] + malwareName, malwareNameOk := alert.Labels["malware_name"] + if podNameOk && malwareNameOk { + if podName == "malware-cryptominer" && slices.Contains(expectedMalwares, malwareName) { + malwaresDetected[malwareName] = true + } + } + } + + assert.Equal(t, len(expectedMalwares), len(malwaresDetected), + "Expected %d malwares to be detected, but got %d", len(expectedMalwares), len(malwaresDetected)) + }) + + // --------------------------------------------------------------- + // 10b. Behavioral rule detection with empty user-defined AP. + // The miner starts immediately; because the AP declares nothing, + // every exec, DNS lookup, and network connection is anomalous. + // + // Expected rules: + // R0001: Unexpected process launched (every exec) + // R0003: Syscalls Anomalies (empty syscall list) + // + // Rules that MAY fire depending on network conditions: + // R0005: DNS Anomalies (requires DNS responses with answers; + // trace_dns drops NXDOMAIN, so behind a firewall these + // won't arrive) + // R1008: Crypto Mining Domain Communication (same DNS dependency) + // R1009: Crypto Mining Related Port Communication (requires TCP + // connectivity to mining pool ports 3333/45700) + // R1007: Crypto miner launched via randomx (amd64 only) + // + // Race condition note: the node-agent fetches the user-defined AP + // from storage asynchronously after detecting the container. Events + // arriving before the fetch completes see profileExists=false, + // causing Required rules (R0001 etc.) to be skipped. The miner's + // initial exec happens during this window — so we must exec into + // the pod AFTER the profile is cached to generate observable exec + // events. + // --------------------------------------------------------------- + t.Run("empty_profile_rules", func(t *testing.T) { + ns := testutils.NewRandomNamespace() + k8sClient := k8sinterface.NewKubernetesApi() + storageClient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) + + // Create an ApplicationProfile with an empty container entry for k8s-miner. + // The container name must match the pod's container so + // GetContainerFromApplicationProfile finds it. With no execs, syscalls, + // opens, or capabilities listed, every operation is anomalous. + ap := &v1beta1.ApplicationProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "crypto2", + Namespace: ns.Name, + }, + Spec: v1beta1.ApplicationProfileSpec{ + Containers: []v1beta1.ApplicationProfileContainer{ + {Name: "k8s-miner"}, + }, + }, + } + + _, err := storageClient.ApplicationProfiles(ns.Name).Create( + context.Background(), ap, metav1.CreateOptions{}) + require.NoError(t, err, "create empty AP in storage") + + require.Eventually(t, func() bool { + _, getErr := storageClient.ApplicationProfiles(ns.Name).Get( + context.Background(), "crypto2", v1.GetOptions{}) + return getErr == nil + }, 30*time.Second, 1*time.Second, "empty AP must be stored") + + // Deploy crypto miner with user-defined profile label. + wl, err := testutils.NewTestWorkload(ns.Name, + path.Join(utils.CurrentDir(), "resources/crypto-miner-deployment.yaml")) + require.NoError(t, err) + require.NoError(t, wl.WaitForReady(80)) + t.Log("Crypto miner pod is ready") + + // Wait for node-agent to fetch the user-defined AP from storage and + // cache it. The miner's initial execve races with this fetch, so + // R0001 is skipped for that event. Syscalls keep flowing, so R0003 + // fires once the profile is cached. + time.Sleep(20 * time.Second) + + // Exec into the pod to generate post-profile-load events: + // exec event → R0001 (cat not in empty AP) + // open event → R0002 (/etc/hostname starts with /etc/) + stdout, stderr, execErr := wl.ExecIntoPod([]string{"cat", "/etc/hostname"}, "k8s-miner") + t.Logf("exec cat /etc/hostname: err=%v stdout=%q stderr=%q", execErr, stdout, stderr) + + // Collect alerts — R0001 must appear from the exec above. + var alerts []testutils.Alert + require.Eventually(t, func() bool { + alerts, err = testutils.GetAlerts(ns.Name) + if err != nil || len(alerts) == 0 { + return false + } + for _, a := range alerts { + if a.Labels["rule_id"] == "R0001" { + return true + } + } + return false + }, 120*time.Second, 10*time.Second, "expected R0001 alert from exec with empty AP") + + time.Sleep(15 * time.Second) + alerts, _ = testutils.GetAlerts(ns.Name) + + t.Logf("=== %d alerts ===", len(alerts)) + 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"]) + } + + rulesSeen := map[string]bool{} + for _, a := range alerts { + rulesSeen[a.Labels["rule_id"]] = true + } + + // These rules must fire with an empty AP — every operation is anomalous. + assert.True(t, rulesSeen["R0001"], + "R0001 (Unexpected process launched) must fire — cat exec not in empty AP") + assert.True(t, rulesSeen["R0002"], + "R0002 (Files Access Anomalies) must fire — /etc/hostname not in empty AP opens") + assert.True(t, rulesSeen["R0003"], + "R0003 (Syscalls Anomalies) must fire — miner syscalls not in empty AP") + assert.True(t, rulesSeen["R0004"], + "R0004 (Linux Capabilities Anomalies) must fire — capabilities not in empty AP") + + // DNS/network rules depend on the miner resolving pool domains and + // establishing TCP connections. In sandboxed/firewalled environments + // these won't fire: trace_dns drops NXDOMAIN, and TCP to mining + // ports is blocked. Log what fired for visibility. + for _, entry := range []struct { + id, desc string + }{ + {"R0005", "DNS Anomalies"}, + {"R1007", "Crypto miner launched via randomx"}, + {"R1008", "Crypto Mining Domain Communication"}, + {"R1009", "Crypto Mining Related Port Communication"}, + } { + if rulesSeen[entry.id] { + t.Logf("%s (%s) fired", entry.id, entry.desc) + } + } + }) + + // --------------------------------------------------------------- + // 10c. RandomX detection (R1007) via xmrig benchmark mode. + // Uses --bench 1M which runs RandomX hashing without a pool + // connection, reliably triggering the x86 FPU tracepoint + // that the randomx eBPF gadget monitors. + // x86_64 (amd64) only — the gadget is disabled on arm64. + // --------------------------------------------------------------- + t.Run("randomx_bench", func(t *testing.T) { + if runtime.GOARCH != "amd64" { + t.Skip("randomx tracer is x86_64 only") + } + + ns := testutils.NewRandomNamespace() + + wl, err := testutils.NewTestWorkload(ns.Name, + path.Join(utils.CurrentDir(), "resources/crypto-miner-deployment.yaml")) + require.NoError(t, err) + require.NoError(t, wl.WaitForReady(80)) + t.Log("xmrig benchmark pod is ready, waiting for RandomX FPU events...") + + // xmrig needs ~5s to init the RandomX dataset, then starts hashing. + // The eBPF gadget needs 5 FPU events within 5s to fire. + // Give it 30s total. + var alerts []testutils.Alert + require.Eventually(t, func() bool { + alerts, err = testutils.GetAlerts(ns.Name) + if err != nil || len(alerts) == 0 { + return false + } + for _, a := range alerts { + if a.Labels["rule_id"] == "R1007" { + return true + } + } + return false + }, 120*time.Second, 10*time.Second, "expected R1007 (RandomX crypto miner) from xmrig --bench") + + alerts, _ = testutils.GetAlerts(ns.Name) + t.Logf("=== %d alerts ===", len(alerts)) + 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"]) + } + + rulesSeen := map[string]bool{} + for _, a := range alerts { + rulesSeen[a.Labels["rule_id"]] = true + } + + assert.True(t, rulesSeen["R1007"], + "R1007 (Crypto miner launched via randomx) must fire — xmrig benchmark runs RandomX hashing") + }) +} + +func Test_11_EndpointTest(t *testing.T) { + threshold := 101 + ns := testutils.NewRandomNamespace() + + endpointTraffic, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/endpoint-traffic.yaml")) + require.NoError(t, err, "Error creating workload") + err = endpointTraffic.WaitForReady(80) + require.NoError(t, err, "Error waiting for workload to be ready") + + require.NoError(t, endpointTraffic.WaitForApplicationProfile(80, "ready")) + + // Merge methods + _, _, err = endpointTraffic.ExecIntoPod([]string{"wget", "http://127.0.0.1:80"}, "") + require.NoError(t, err) + _, _, err = endpointTraffic.ExecIntoPod([]string{"wget", "http://127.0.0.1:80", "-O", "/dev/null", "--post-data", "test-data"}, "") // avoid index.html already exists error + + // Merge dynamic + for i := 0; i < threshold; i++ { + _, _, err = endpointTraffic.ExecIntoPod([]string{"wget", fmt.Sprintf("http://127.0.0.1:80/users/%d", i)}, "") + } + + // Wait for dedup cache entries to expire (~2s TTL) so the next requests + // with different headers are not deduplicated before reaching the profile. + time.Sleep(3 * time.Second) + + // Merge headers + _, _, err = endpointTraffic.ExecIntoPod([]string{"wget", "http://127.0.0.1:80/users/99", "--header", "Connection:1234r"}, "") + _, _, err = endpointTraffic.ExecIntoPod([]string{"wget", "http://127.0.0.1:80/users/12", "--header", "Connection:ziz"}, "") + + err = endpointTraffic.WaitForApplicationProfileCompletion(80) + require.NoError(t, err, "Error waiting for application profile to be completed") + + applicationProfile, err := endpointTraffic.GetApplicationProfile() + require.NoError(t, err, "Error getting application profile") + + headers := map[string][]string{"Connection": {"close"}, "Host": {"127.0.0.1:80"}} + rawJSON, err := json.Marshal(headers) + require.NoError(t, err) + + endpoint2 := v1beta1.HTTPEndpoint{ + Endpoint: ":80/", + Methods: []string{"GET", "POST"}, + Internal: false, + Direction: "inbound", + Headers: rawJSON, + } + + headers = map[string][]string{"Host": {"127.0.0.1:80"}, "Connection": {"1234r", "close", "ziz"}} + rawJSON, err = json.Marshal(headers) + require.NoError(t, err) + + endpoint1 := v1beta1.HTTPEndpoint{ + Endpoint: ":80/users/" + dynamicpathdetector.DynamicIdentifier, + Methods: []string{"GET"}, + Internal: false, + Direction: "inbound", + Headers: rawJSON, + } + + savedEndpoints := applicationProfile.Spec.Containers[0].Endpoints + + for i := range savedEndpoints { + + headers := savedEndpoints[i].Headers + var headersMap map[string][]string + err := json.Unmarshal(headers, &headersMap) + require.NoError(t, err, "Error unmarshalling headers") + + if headersMap["Connection"] != nil { + sort.Strings(headersMap["Connection"]) + rawJSON, err = json.Marshal(headersMap) + require.NoError(t, err) + savedEndpoints[i].Headers = rawJSON + } + } + + expectedEndpoints := []v1beta1.HTTPEndpoint{endpoint1, endpoint2} + for _, expectedEndpoint := range expectedEndpoints { + found := false + for _, savedEndpoint := range savedEndpoints { + e := savedEndpoint + sort.Strings(e.Methods) + sort.Strings(expectedEndpoint.Methods) + if reflect.DeepEqual(e, expectedEndpoint) { + found = true + break + } + } + assert.Truef(t, found, "Expected endpoint %v not found in the application profile", expectedEndpoint) + } +} + +func Test_12_MergingProfilesTest(t *testing.T) { + start := time.Now() + defer tearDownTest(t, start) + + // PHASE 1: Setup workload and initial profile + ns := testutils.NewRandomNamespace() + wl, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/deployment-multiple-containers.yaml")) + require.NoError(t, err, "Failed to create workload") + require.NoError(t, wl.WaitForReady(80), "Workload failed to be ready") + // require.NoError(t, wl.WaitForApplicationProfile(80, "ready"), "Application profile not ready") + time.Sleep(10 * time.Second) + + // Generate initial profile data + _, _, err = wl.ExecIntoPod([]string{"ls", "-l"}, "nginx") + require.NoError(t, err, "Failed to exec into nginx container") + _, _, err = wl.ExecIntoPod([]string{"wget", "ebpf.io", "-T", "2", "-t", "1"}, "server") + require.NoError(t, err, "Failed to exec into server container") + + require.NoError(t, wl.WaitForApplicationProfileCompletion(160), "Profile failed to complete") + time.Sleep(10 * time.Second) // Allow profile processing + + // Log initial profile state + initialProfile, err := wl.GetApplicationProfile() + require.NoError(t, err, "Failed to get initial profile") + initialProfileJSON, _ := json.Marshal(initialProfile) + t.Logf("Initial application profile:\n%s", string(initialProfileJSON)) + + // PHASE 2: Verify initial alerts + t.Log("Testing initial alert generation...") + _, _, err = wl.ExecIntoPod([]string{"ls", "-l"}, "nginx") // Expected: no alert + _, _, err = wl.ExecIntoPod([]string{"ls", "-l"}, "server") // Expected: alert + // time.Sleep(2 * time.Minute) // Wait for alert generation + time.Sleep(30 * time.Second) // Wait for alert generation + + initialAlerts, err := testutils.GetAlerts(wl.Namespace) + require.NoError(t, err, "Failed to get initial alerts") + + // Record initial alert count + initialAlertCount := 0 + for _, alert := range initialAlerts { + if ruleName, ok := alert.Labels["rule_name"]; ok && ruleName == "Unexpected process launched" { + initialAlertCount++ + } + } + + testutils.AssertContains(t, initialAlerts, "Unexpected process launched", "ls", "server", []bool{true}) + testutils.AssertNotContains(t, initialAlerts, "Unexpected process launched", "ls", "nginx", []bool{true, false}) + + // PHASE 3: Apply user-managed profile + t.Log("Applying user-managed profile...") + // Create the user-managed profile + userProfile := &v1beta1.ApplicationProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("ug-%s", initialProfile.Name), + Namespace: initialProfile.Namespace, + Annotations: map[string]string{ + "kubescape.io/managed-by": "User", + }, + }, + Spec: v1beta1.ApplicationProfileSpec{ + Architectures: []string{"amd64"}, + Containers: []v1beta1.ApplicationProfileContainer{ + { + Name: "nginx", + Execs: []v1beta1.ExecCalls{ + { + Path: "/usr/bin/ls", + Args: []string{"/usr/bin/ls", "-l"}, + }, + }, + SeccompProfile: v1beta1.SingleSeccompProfile{ + Spec: v1beta1.SingleSeccompProfileSpec{ + DefaultAction: "", + }, + }, + }, + { + Name: "server", + Execs: []v1beta1.ExecCalls{ + { + Path: "/bin/ls", + Args: []string{"/bin/ls", "-l"}, + }, + { + Path: "/bin/grpc_health_probe", + Args: []string{"-addr=:9555"}, + }, + }, + SeccompProfile: v1beta1.SingleSeccompProfile{ + Spec: v1beta1.SingleSeccompProfileSpec{ + DefaultAction: "", + }, + }, + }, + }, + }, + } + + // Log the profile we're about to create + userProfileJSON, err := json.MarshalIndent(userProfile, "", " ") + require.NoError(t, err, "Failed to marshal user profile") + t.Logf("Creating user profile:\n%s", string(userProfileJSON)) + + // Get k8s client + k8sClient := k8sinterface.NewKubernetesApi() + + // Create the user-managed profile + storageClient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) + _, err = storageClient.ApplicationProfiles(ns.Name).Create(context.Background(), userProfile, metav1.CreateOptions{}) + require.NoError(t, err, "Failed to create user profile") + + // PHASE 4: Verify merged profile behavior + t.Log("Verifying merged profile behavior...") + time.Sleep(1 * time.Minute) // Allow merge to complete + + // Test merged profile behavior + _, _, err = wl.ExecIntoPod([]string{"ls", "-l"}, "nginx") // Expected: no alert + _, _, err = wl.ExecIntoPod([]string{"ls", "-l"}, "server") // Expected: no alert (user profile should suppress alert) + time.Sleep(1 * time.Minute) // Wait for potential alerts + + // Verify alert counts + finalAlerts, err := testutils.GetAlerts(wl.Namespace) + require.NoError(t, err, "Failed to get final alerts") + + // Only count new alerts (after the initial count) + newAlertCount := 0 + for _, alert := range finalAlerts { + if ruleName, ok := alert.Labels["rule_name"]; ok && ruleName == "Unexpected process launched" { + newAlertCount++ + } + } + + t.Logf("Alert counts - Initial: %d, Final: %d", initialAlertCount, newAlertCount) + + if newAlertCount > initialAlertCount { + t.Logf("Full alert details:") + for _, alert := range finalAlerts { + if ruleName, ok := alert.Labels["rule_name"]; ok && ruleName == "Unexpected process launched" { + t.Logf("Alert: %+v", alert) + } + } + t.Errorf("New alerts were generated after merge (Initial: %d, Final: %d)", initialAlertCount, newAlertCount) + } + + // The new cache doesn't listen to patches + // PHASE 5: Check PATCH (removing the ls command from the user profile of the server container and triggering an alert) + // t.Log("Patching user profile to remove ls command from server container...") + // patchOperations := []utils.PatchOperation{ + // {Op: "remove", Path: "/spec/containers/1/execs/0"}, + // } + + // patch, err := json.Marshal(patchOperations) + // require.NoError(t, err, "Failed to marshal patch operations") + + // _, err = storageClient.ApplicationProfiles(ns.Name).Patch(context.Background(), userProfile.Name, types.JSONPatchType, patch, metav1.PatchOptions{}) + // require.NoError(t, err, "Failed to patch user profile") + + // // Verify patched profile behavior + // time.Sleep(15 * time.Second) // Allow merge to complete + + // // Log the profile that was patched + // patchedProfile, err := wl.GetApplicationProfile() + // require.NoError(t, err, "Failed to get patched profile") + // t.Logf("Patched application profile:\n%v", patchedProfile) + + // // Test patched profile behavior + // wl.ExecIntoPod([]string{"ls", "-l"}, "nginx") // Expected: no alert + // wl.ExecIntoPod([]string{"ls", "-l"}, "server") // Expected: alert (ls command removed from user profile) + // time.Sleep(10 * time.Second) // Wait for potential alerts + + // // Verify alert counts + // finalAlerts, err = testutils.GetAlerts(wl.Namespace) + // require.NoError(t, err, "Failed to get final alerts") + + // // Only count new alerts (after the initial count) + // newAlertCount = 0 + // for _, alert := range finalAlerts { + // if ruleName, ok := alert.Labels["rule_name"]; ok && ruleName == "Unexpected process launched" { + // newAlertCount++ + // } + // } + + // t.Logf("Alert counts - Initial: %d, Final: %d", initialAlertCount, newAlertCount) + + // if newAlertCount <= initialAlertCount { + // t.Logf("Full alert details:") + // for _, alert := range finalAlerts { + // if ruleName, ok := alert.Labels["rule_name"]; ok && ruleName == "Unexpected process launched" { + // t.Logf("Alert: %+v", alert) + // } + // } + // t.Errorf("New alerts were not generated after patch (Initial: %d, Final: %d)", initialAlertCount, newAlertCount) + // } +} + +func Test_13_MergingNetworkNeighborhoodTest(t *testing.T) { + start := time.Now() + defer tearDownTest(t, start) + + // PHASE 1: Setup workload and initial network neighborhood + ns := testutils.NewRandomNamespace() + wl, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/deployment-multiple-containers.yaml")) + require.NoError(t, err, "Failed to create workload") + require.NoError(t, wl.WaitForReady(80), "Workload failed to be ready") + require.NoError(t, wl.WaitForNetworkNeighborhood(80, "ready"), "Network neighborhood not ready") + + // Generate initial network data + _, _, err = wl.ExecIntoPod([]string{"wget", "ebpf.io", "-T", "2", "-t", "1"}, "server") + require.NoError(t, err, "Failed to exec wget in server container") + _, _, err = wl.ExecIntoPod([]string{"curl", "kubernetes.io", "-m", "2"}, "nginx") + require.NoError(t, err, "Failed to exec curl in nginx container") + + require.NoError(t, wl.WaitForNetworkNeighborhoodCompletion(80), "Network neighborhood failed to complete") + time.Sleep(10 * time.Second) // Allow network neighborhood processing + + // Log initial network neighborhood state + initialNN, err := wl.GetNetworkNeighborhood() + require.NoError(t, err, "Failed to get initial network neighborhood") + initialNNJSON, _ := json.Marshal(initialNN) + t.Logf("Initial network neighborhood:\n%s", string(initialNNJSON)) + + // PHASE 2: Verify initial alerts + t.Log("Testing initial alert generation...") + _, _, err = wl.ExecIntoPod([]string{"wget", "ebpf.io", "-T", "2", "-t", "1"}, "server") // Expected: no alert (original rule) + _, _, err = wl.ExecIntoPod([]string{"wget", "httpforever.com", "-T", "2", "-t", "1"}, "server") // Expected: alert (not allowed) + _, _, err = wl.ExecIntoPod([]string{"wget", "httpforever.com", "-T", "2", "-t", "1"}, "server") // Expected: alert (not allowed) + _, _, err = wl.ExecIntoPod([]string{"wget", "httpforever.com", "-T", "2", "-t", "1"}, "server") // Expected: alert (not allowed) + _, _, err = wl.ExecIntoPod([]string{"curl", "kubernetes.io", "-m", "2"}, "nginx") // Expected: no alert (original rule) + _, _, err = wl.ExecIntoPod([]string{"curl", "github.com", "-m", "2"}, "nginx") // Expected: alert (not allowed) + time.Sleep(30 * time.Second) // Wait for alert generation + + initialAlerts, err := testutils.GetAlerts(wl.Namespace) + require.NoError(t, err, "Failed to get initial alerts") + + // Record initial alert count + initialAlertCount := 0 + for _, alert := range initialAlerts { + if ruleName, ok := alert.Labels["rule_name"]; ok && ruleName == "DNS Anomalies in container" && alert.Labels["container_name"] == "server" { + initialAlertCount++ + } + } + + // Verify initial alerts + testutils.AssertContains(t, initialAlerts, "DNS Anomalies in container", "wget", "server", []bool{true}) + testutils.AssertContains(t, initialAlerts, "DNS Anomalies in container", "curl", "nginx", []bool{true}) + + // PHASE 3: Apply user-managed network neighborhood + t.Log("Applying user-managed network neighborhood...") + userNN := &v1beta1.NetworkNeighborhood{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("ug-%s", initialNN.Name), + Namespace: initialNN.Namespace, + Annotations: map[string]string{ + "kubescape.io/managed-by": "User", + }, + }, + Spec: v1beta1.NetworkNeighborhoodSpec{ + LabelSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{ + "app": "multiple-containers-app", + }, + }, + Containers: []v1beta1.NetworkNeighborhoodContainer{ + { + Name: "nginx", + Egress: []v1beta1.NetworkNeighbor{ + { + Identifier: "nginx-github", + Type: "external", + DNSNames: []string{"github.com."}, + Ports: []v1beta1.NetworkPort{ + { + Name: "TCP-80", + Protocol: "TCP", + Port: ptr.To(int32(80)), + }, + { + Name: "TCP-443", + Protocol: "TCP", + Port: ptr.To(int32(443)), + }, + }, + }, + }, + }, + { + Name: "server", + Egress: []v1beta1.NetworkNeighbor{ + { + Identifier: "server-example", + Type: "external", + DNSNames: []string{"info.cern.ch."}, + Ports: []v1beta1.NetworkPort{ + { + Name: "TCP-80", + Protocol: "TCP", + Port: ptr.To(int32(80)), + }, + { + Name: "TCP-443", + Protocol: "TCP", + Port: ptr.To(int32(443)), + }, + }, + }, + }, + }, + }, + }, + } + + // Create user-managed network neighborhood + k8sClient := k8sinterface.NewKubernetesApi() + storageClient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) + _, err = storageClient.NetworkNeighborhoods(ns.Name).Create(context.Background(), userNN, metav1.CreateOptions{}) + require.NoError(t, err, "Failed to create user network neighborhood") + + // PHASE 4: Verify merged behavior (no new alerts) + t.Log("Verifying merged network neighborhood behavior...") + time.Sleep(60 * time.Second) // Allow merge to complete + + _, _, err = wl.ExecIntoPod([]string{"wget", "ebpf.io", "-T", "2", "-t", "1"}, "server") // Expected: no alert (original) + // Try multiple times to ensure alert is removed + _, _, err = wl.ExecIntoPod([]string{"wget", "info.cern.ch", "-T", "2", "-t", "1"}, "server") // Expected: no alert (user added) + _, _, err = wl.ExecIntoPod([]string{"wget", "info.cern.ch", "-T", "2", "-t", "1"}, "server") // Expected: no alert (user added) + _, _, err = wl.ExecIntoPod([]string{"wget", "info.cern.ch", "-T", "2", "-t", "1"}, "server") // Expected: no alert (user added) + _, _, err = wl.ExecIntoPod([]string{"wget", "info.cern.ch", "-T", "2", "-t", "1"}, "server") // Expected: no alert (user added) + _, _, err = wl.ExecIntoPod([]string{"curl", "kubernetes.io", "-m", "2"}, "nginx") // Expected: no alert (original) + _, _, err = wl.ExecIntoPod([]string{"curl", "github.com", "-m", "2"}, "nginx") // Expected: no alert (user added) + time.Sleep(30 * time.Second) // Wait for potential alerts + + mergedAlerts, err := testutils.GetAlerts(wl.Namespace) + require.NoError(t, err, "Failed to get alerts after merge") + + // Count new alerts after merge + newAlertCount := 0 + for _, alert := range mergedAlerts { + if ruleName, ok := alert.Labels["rule_name"]; ok && ruleName == "DNS Anomalies in container" && alert.Labels["container_name"] == "server" { + newAlertCount++ + } + } + + t.Logf("Alert counts - Initial: %d, After merge: %d", initialAlertCount, newAlertCount) + + if newAlertCount > initialAlertCount { + t.Logf("Full alert details:") + for _, alert := range mergedAlerts { + if ruleName, ok := alert.Labels["rule_name"]; ok && ruleName == "DNS Anomalies in container" && alert.Labels["container_name"] == "server" { + t.Logf("Alert: %+v", alert) + } + } + t.Errorf("New alerts were generated after merge (Initial: %d, After merge: %d)", initialAlertCount, newAlertCount) + } + + // PHASE 5: Remove permission via patch and verify alerts return + t.Log("Patching user network neighborhood to remove info.cern.ch from server container...") + patchOperations := []utils.PatchOperation{ + {Op: "remove", Path: "/spec/containers/1/egress/0"}, + } + + patch, err := json.Marshal(patchOperations) + require.NoError(t, err, "Failed to marshal patch operations") + + _, err = storageClient.NetworkNeighborhoods(ns.Name).Patch(context.Background(), userNN.Name, types.JSONPatchType, patch, metav1.PatchOptions{}) + require.NoError(t, err, "Failed to patch user network neighborhood") + + time.Sleep(60 * time.Second) // Allow merge to complete + + // Test alerts after patch + _, _, err = wl.ExecIntoPod([]string{"wget", "ebpf.io", "-T", "2", "-t", "1"}, "server") // Expected: no alert + // Try multiple times to ensure alert is removed + _, _, err = wl.ExecIntoPod([]string{"wget", "info.cern.ch", "-T", "2", "-t", "1"}, "server") // Expected: alert (removed) + _, _, err = wl.ExecIntoPod([]string{"wget", "info.cern.ch", "-T", "2", "-t", "1"}, "server") // Expected: alert (removed) + _, _, err = wl.ExecIntoPod([]string{"wget", "info.cern.ch", "-T", "2", "-t", "1"}, "server") // Expected: alert (removed) + _, _, err = wl.ExecIntoPod([]string{"wget", "info.cern.ch", "-T", "2", "-t", "1"}, "server") // Expected: alert (removed) + _, _, err = wl.ExecIntoPod([]string{"wget", "info.cern.ch", "-T", "2", "-t", "1"}, "server") // Expected: alert (removed) + _, _, err = wl.ExecIntoPod([]string{"curl", "kubernetes.io", "-m", "2"}, "nginx") // Expected: no alert + _, _, err = wl.ExecIntoPod([]string{"curl", "github.com", "-m", "2"}, "nginx") // Expected: no alert + time.Sleep(30 * time.Second) // Wait for alerts + + finalAlerts, err := testutils.GetAlerts(wl.Namespace) + require.NoError(t, err, "Failed to get final alerts") + + // Count final alerts + finalAlertCount := 0 + for _, alert := range finalAlerts { + if ruleName, ok := alert.Labels["rule_name"]; ok && ruleName == "DNS Anomalies in container" && alert.Labels["container_name"] == "server" { + finalAlertCount++ + } + } + + t.Logf("Alert counts - Initial: %d, Final: %d", initialAlertCount, finalAlertCount) + + if finalAlertCount <= initialAlertCount { + t.Logf("Full alert details:") + for _, alert := range finalAlerts { + if ruleName, ok := alert.Labels["rule_name"]; ok && ruleName == "DNS Anomalies in container" && alert.Labels["container_name"] == "server" { + t.Logf("Alert: %+v", alert) + } + } + t.Errorf("New alerts were not generated after patch (Initial: %d, Final: %d)", initialAlertCount, finalAlertCount) + } +} + +func Test_14_RulePoliciesTest(t *testing.T) { + ns := testutils.NewRandomNamespace() + + endpointTraffic, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/endpoint-traffic.yaml")) + if err != nil { + t.Errorf("Error creating workload: %v", err) + } + err = endpointTraffic.WaitForReady(80) + if err != nil { + t.Errorf("Error waiting for workload to be ready: %v", err) + } + + // Wait for application profile to be ready + assert.NoError(t, endpointTraffic.WaitForApplicationProfile(80, "ready")) + time.Sleep(10 * time.Second) + + // Add to rule policy symlink + _, _, err = endpointTraffic.ExecIntoPod([]string{"ln", "-s", "/etc/shadow", "/tmp/a"}, "") + assert.NoError(t, err) + + _, _, err = endpointTraffic.ExecIntoPod([]string{"rm", "/tmp/a"}, "") + assert.NoError(t, err) + + // Not add to rule policy + _, _, err = endpointTraffic.ExecIntoPod([]string{"ln", "/bin/sh", "/tmp/a"}, "") + assert.NoError(t, err) + + _, _, err = endpointTraffic.ExecIntoPod([]string{"rm", "/tmp/a"}, "") + assert.NoError(t, err) + + err = endpointTraffic.WaitForApplicationProfileCompletion(80) + if err != nil { + t.Errorf("Error waiting for application profile to be completed: %v", err) + } + + applicationProfile, err := endpointTraffic.GetApplicationProfile() + if err != nil { + t.Errorf("Error getting application profile: %v", err) + } + + symlinkPolicy := applicationProfile.Spec.Containers[0].PolicyByRuleId["R1010"] + assert.Equal(t, []string{"ln"}, symlinkPolicy.AllowedProcesses) + + hardlinkPolicy := applicationProfile.Spec.Containers[0].PolicyByRuleId["R1012"] + assert.Len(t, hardlinkPolicy.AllowedProcesses, 0) + + fmt.Println("After completed....") + + // wait for cache + time.Sleep(40 * time.Second) + + // generate hardlink alert + _, _, err = endpointTraffic.ExecIntoPod([]string{"ln", "/etc/shadow", "/tmp/a"}, "") + _, _, err = endpointTraffic.ExecIntoPod([]string{"rm", "/tmp/a"}, "") + assert.NoError(t, err) + + // not generate alert + _, _, err = endpointTraffic.ExecIntoPod([]string{"ln", "-s", "/etc/shadow", "/tmp/a"}, "") + _, _, err = endpointTraffic.ExecIntoPod([]string{"rm", "/tmp/a"}, "") + assert.NoError(t, err) + + // Wait for the alert to be signaled + time.Sleep(30 * time.Second) + + alerts, err := testutils.GetAlerts(endpointTraffic.Namespace) + if err != nil { + t.Errorf("Error getting alerts: %v", err) + } + + testutils.AssertContains(t, alerts, "Hard link created over sensitive file", "ln", "endpoint-traffic", []bool{true}) + testutils.AssertNotContains(t, alerts, "Soft link created over sensitive file", "ln", "endpoint-traffic", []bool{true}) + + // Also check for learning mode + testutils.AssertContains(t, alerts, "Soft link created over sensitive file", "ln", "endpoint-traffic", []bool{false}) + testutils.AssertNotContains(t, alerts, "Hard link created over sensitive file", "ln", "endpoint-traffic", []bool{false}) + +} + +func Test_15_CompletedApCannotBecomeReadyAgain(t *testing.T) { + k8sClient := k8sinterface.NewKubernetesApi() + storageclient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) + + ns := testutils.NewRandomNamespace() + defer func() { + _ = k8sClient.KubernetesClient.CoreV1().Namespaces().Delete(context.Background(), ns.Name, v1.DeleteOptions{}) + }() + + // create an application profile with completed status + name := "test" + ap1, err := storageclient.ApplicationProfiles(ns.Name).Create(context.TODO(), &v1beta1.ApplicationProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Annotations: map[string]string{ + helpersv1.CompletionMetadataKey: helpersv1.Full, + helpersv1.StatusMetadataKey: helpersv1.Completed, + }, + }, + }, v1.CreateOptions{}) + require.NoError(t, err) + require.Equal(t, helpersv1.Completed, ap1.Annotations[helpersv1.StatusMetadataKey]) + + // patch the application profile with ready status + patchOperations := []utils.PatchOperation{ + { + Op: "replace", + Path: "/metadata/annotations/" + utils.EscapeJSONPointerElement(helpersv1.StatusMetadataKey), + Value: helpersv1.Learning, + }, + } + patch, err := json.Marshal(patchOperations) + require.NoError(t, err) + ap2, err := storageclient.ApplicationProfiles(ns.Name).Patch(context.Background(), name, types.JSONPatchType, patch, v1.PatchOptions{}) + assert.NoError(t, err) // patch should succeed + assert.Equal(t, helpersv1.Completed, ap2.Annotations[helpersv1.StatusMetadataKey]) // but the status should not change +} + +func Test_16_ApNotStuckOnRestart(t *testing.T) { + ns := testutils.NewRandomNamespace() + + wl, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/nginx-deployment.yaml")) + require.NoError(t, err, "Error creating workload") + + require.NoError(t, wl.WaitForReady(80)) + + time.Sleep(30 * time.Second) + + _, _, _ = wl.ExecIntoPod([]string{"service", "nginx", "stop"}, "") // suppose to get error + // wl, err = testutils.NewTestWorkloadFromK8sIdentifiers(ns.Name, wl.UnstructuredObj.GroupVersionKind().Kind, "nginx-deployment") + // require.NoError(t, err, "Error re-fetching workload after stop") + // require.NoError(t, wl.WaitForReady(80)) + // require.NoError(t, wl.WaitForApplicationProfileCompletion(160)) + + time.Sleep(160 * time.Second) + + // Wait for cache to be updated + time.Sleep(15 * time.Second) + + _, _, err = wl.ExecIntoPod([]string{"ls", "-l"}, "") + require.NoError(t, err) + + // Wait for the alert to be generated + time.Sleep(30 * time.Second) + + alerts, err := testutils.GetAlerts(wl.Namespace) + require.NoError(t, err, "Error getting alerts") + + testutils.AssertContains(t, alerts, "Unexpected process launched", "ls", "nginx", []bool{true}) +} + +func Test_17_ApCompletedToPartialUpdateTest(t *testing.T) { + ns := testutils.NewRandomNamespace() + + wl, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/nginx-deployment.yaml")) + require.NoError(t, err, "Error creating workload") + + time.Sleep(30 * time.Second) + require.NoError(t, wl.WaitForReady(80)) + require.NoError(t, wl.WaitForNetworkNeighborhood(80, "ready")) + + err = testutils.RestartDaemonSet("kubescape", "node-agent") + require.NoError(t, err, "Error restarting daemonset") + + require.NoError(t, wl.WaitForApplicationProfileCompletion(160)) + require.NoError(t, wl.WaitForNetworkNeighborhoodCompletion(160)) + + time.Sleep(30 * time.Second) + + _, _, err = wl.ExecIntoPod([]string{"sh", "-c", "cat /run/secrets/kubernetes.io/serviceaccount/token >/dev/null"}, "") + require.NoError(t, err) + + time.Sleep(30 * time.Second) + + alerts, err := testutils.GetAlerts(wl.Namespace) + require.NoError(t, err, "Error getting alerts") + + testutils.AssertContains(t, alerts, "Unexpected service account token access", "cat", "nginx", []bool{true}) +} + +func Test_18_ShortLivedJobTest(t *testing.T) { + ns := testutils.NewRandomNamespace() + + // Create a short-lived job + wl, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/echo-job.yaml")) + require.NoError(t, err, "Error creating workload") + + // Application profile should be created and completed + err = wl.WaitForApplicationProfileCompletion(80) + require.NoError(t, err, "Error waiting for application profile to be completed") +} + +func Test_19_AlertOnPartialProfileTest(t *testing.T) { + start := time.Now() + defer tearDownTest(t, start) + + ns := testutils.NewRandomNamespace() + + // Create a workload + wl, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/nginx-deployment.yaml")) + require.NoError(t, err, "Error creating workload") + + // Wait for the workload to be ready + err = wl.WaitForReady(80) + require.NoError(t, err, "Error waiting for workload to be ready") + + // Restart the daemonset + err = testutils.RestartDaemonSet("kubescape", "node-agent") + require.NoError(t, err, "Error restarting daemonset") + + // Wait for the application profile to be completed + err = wl.WaitForApplicationProfileCompletion(160) + require.NoError(t, err, "Error waiting for application profile to be completed") + + profile, err := wl.GetApplicationProfile() + require.NoError(t, err, "Error getting application profile") + + require.Equal(t, helpersv1.Partial, profile.Annotations[helpersv1.CompletionMetadataKey]) + + // Wait for cache to be updated + time.Sleep(15 * time.Second) + + // Generate an alert by executing a command + _, _, err = wl.ExecIntoPod([]string{"ls", "-l"}, "") + require.NoError(t, err, "Error executing command in pod") + // Wait for the alert to be generated + time.Sleep(15 * time.Second) + alerts, err := testutils.GetAlerts(ns.Name) + require.NoError(t, err, "Error getting alerts") + testutils.AssertContains(t, alerts, "Unexpected process launched", "ls", "nginx", []bool{true}) +} + +func Test_20_AlertOnPartialThenLearnProcessTest(t *testing.T) { + start := time.Now() + defer tearDownTest(t, start) + + ns := testutils.NewRandomNamespace() + + // Create a workload + wl, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/nginx-deployment.yaml")) + require.NoError(t, err, "Error creating workload") + + // Wait for the workload to be ready + err = wl.WaitForReady(80) + require.NoError(t, err, "Error waiting for workload to be ready") + + // Restart the daemonset + err = testutils.RestartDaemonSet("kubescape", "node-agent") + require.NoError(t, err, "Error restarting daemonset") + + // Wait for the application profile to be completed (partial) + err = wl.WaitForApplicationProfileCompletion(160) + require.NoError(t, err, "Error waiting for application profile to be completed") + + // Wait for cache to be updated + time.Sleep(15 * time.Second) + + // Generate an alert by executing a command (should trigger alert on partial profile) + _, _, err = wl.ExecIntoPod([]string{"ls", "-l"}, "") + require.NoError(t, err, "Error executing command in pod") + + // Wait for the alert to be generated + time.Sleep(15 * time.Second) + alerts, err := testutils.GetAlerts(ns.Name) + require.NoError(t, err, "Error getting alerts") + testutils.AssertContains(t, alerts, "Unexpected process launched", "ls", "nginx", []bool{true}) + + profile, err := wl.GetApplicationProfile() + require.NoError(t, err, "Error getting application profile") + + // Restart the deployment to reset the profile learning + err = testutils.RestartDeployment(ns.Name, wl.WorkloadObj.GetName()) + require.NoError(t, err, "Error restarting deployment") + + wl, err = testutils.NewTestWorkloadFromK8sIdentifiers(ns.Name, wl.UnstructuredObj.GroupVersionKind().Kind, "nginx-deployment") + require.NoError(t, err, "Error re-fetching workload after restart") + + // Wait for the workload to be ready after restart + err = wl.WaitForReady(80) + require.NoError(t, err, "Error waiting for workload to be ready after restart") + + // Execute the same command during learning phase (should be learned in profile) + _, _, err = wl.ExecIntoPod([]string{"ls", "-l"}, "") + require.NoError(t, err, "Error executing command in pod during learning") + + // Wait for the application profile to be completed (with ls command learned) + err = wl.WaitForApplicationProfileCompletionWithBlacklist(160, []string{profile.Name}) + require.NoError(t, err, "Error waiting for application profile to be completed after learning") + + // Wait for cache to be updated + time.Sleep(15 * time.Second) + + // Execute the same command again - should NOT trigger an alert now + _, _, err = wl.ExecIntoPod([]string{"ls", "-l"}, "") + require.NoError(t, err, "Error executing command in pod after learning") + + // Wait to see if any alert is generated + time.Sleep(15 * time.Second) + alertsAfter, err := testutils.GetAlerts(ns.Name) + require.NoError(t, err, "Error getting alerts after learning") + + // Should not contain new alert for ls command after learning + count := 0 + for _, alert := range alertsAfter { + if alert.Labels["rule_name"] == "Unexpected process launched" && alert.Labels["container_name"] == "nginx" && alert.Labels["process_name"] == "ls" { + count++ + } + } + if count > 1 { + t.Errorf("Unexpected alerts found after learning: %d", count) + } +} + +func Test_21_AlertOnPartialThenLearnNetworkTest(t *testing.T) { + start := time.Now() + defer tearDownTest(t, start) + + ns := testutils.NewRandomNamespace() + + // Create a workload using deployment-multiple-containers.yaml (same as Test_22) + wl, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/deployment-multiple-containers.yaml")) + require.NoError(t, err, "Error creating workload") + + // Wait for the workload to be ready + err = wl.WaitForReady(80) + require.NoError(t, err, "Error waiting for workload to be ready") + + // Restart the daemonset + err = testutils.RestartDaemonSet("kubescape", "node-agent") + require.NoError(t, err, "Error restarting daemonset") + + // Wait for the network neighborhood to be completed (partial) + err = wl.WaitForNetworkNeighborhoodCompletion(160) + require.NoError(t, err, "Error waiting for network neighborhood to be completed") + + // Wait for cache to be updated + time.Sleep(15 * time.Second) + + // Generate an alert by making a network request (should trigger alert on partial profile) + // Using curl with timeout and targeting nginx container (same as Test_22) + _, _, err = wl.ExecIntoPod([]string{"curl", "google.com", "-m", "5"}, "nginx") + require.NoError(t, err, "Error executing network command in pod") + + // Wait for the alert to be generated + time.Sleep(15 * time.Second) + alerts, err := testutils.GetAlerts(ns.Name) + require.NoError(t, err, "Error getting alerts") + testutils.AssertContains(t, alerts, "DNS Anomalies in container", "curl", "nginx", []bool{true}) + + nn, err := wl.GetNetworkNeighborhood() + require.NoError(t, err, "Error getting network neighborhood") + + // Restart the deployment to reset the profile learning + err = testutils.RestartDeployment(ns.Name, wl.WorkloadObj.GetName()) + require.NoError(t, err, "Error restarting deployment") + + // Print we restarted the deployment + logger.L().Info("restarted deployment", helpers.String("name", wl.WorkloadObj.GetName()), helpers.String("namespace", wl.WorkloadObj.GetNamespace())) + + // Sleep to allow the restart to complete + time.Sleep(30 * time.Second) + + wl, err = testutils.NewTestWorkloadFromK8sIdentifiers(ns.Name, wl.UnstructuredObj.GroupVersionKind().Kind, "multiple-containers-deployment") + require.NoError(t, err, "Error re-fetching workload after restart") + + // Wait for the workload to be ready after restart + err = wl.WaitForReady(80) + require.NoError(t, err, "Error waiting for workload to be ready after restart") + + // Execute the same network command during learning phase (should be learned in profile) + _, _, err = wl.ExecIntoPod([]string{"curl", "google.com", "-m", "5"}, "nginx") + require.NoError(t, err, "Error executing network command in pod during learning") + + // Print the workload details we are using + logger.L().Info("workload details", helpers.String("name", wl.WorkloadObj.GetName()), helpers.String("namespace", wl.WorkloadObj.GetNamespace())) + // Print the metadata of the workload + logger.L().Info("workload metadata", helpers.Interface("metadata", wl.WorkloadObj.GetAnnotations()), helpers.Interface("labels", wl.WorkloadObj.GetLabels())) + + // Wait for the network neighborhood to be completed (with curl command learned) + err = wl.WaitForNetworkNeighborhoodCompletionWithBlacklist(160, []string{nn.Name}) + require.NoError(t, err, "Error waiting for network neighborhood to be completed after learning") + + // Wait for cache to be updated + time.Sleep(15 * time.Second) + + // Execute the same network command again - should NOT trigger an alert now + _, _, err = wl.ExecIntoPod([]string{"curl", "google.com", "-m", "5"}, "nginx") + require.NoError(t, err, "Error executing network command in pod after learning") + + // Wait to see if any alert is generated + time.Sleep(15 * time.Second) + alertsAfter, err := testutils.GetAlerts(ns.Name) + require.NoError(t, err, "Error getting alerts after learning") + + // Should not contain new alert for curl command after learning + count := 0 + for _, alert := range alertsAfter { + if alert.Labels["rule_name"] == "DNS Anomalies in container" && alert.Labels["container_name"] == "nginx" && alert.Labels["process_name"] == "curl" { + count++ + } + } + if count > 1 { + t.Errorf("Unexpected alerts found after learning: %d", count) + } +} + +func Test_22_AlertOnPartialNetworkProfileTest(t *testing.T) { + start := time.Now() + defer tearDownTest(t, start) + + ns := testutils.NewRandomNamespace() + + // Create a workload + wl, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/deployment-multiple-containers.yaml")) + require.NoError(t, err, "Error creating workload") + + // Wait for the workload to be ready + err = wl.WaitForReady(80) + require.NoError(t, err, "Error waiting for workload to be ready") + + // Restart the daemonset + err = testutils.RestartDaemonSet("kubescape", "node-agent") + require.NoError(t, err, "Failed to restart daemonset") + + // Wait for the network neighborhood to be completed + err = wl.WaitForNetworkNeighborhoodCompletion(160) + require.NoError(t, err, "Error waiting for network neighborhood to be completed") + + // Wait for cache to be updated + time.Sleep(15 * time.Second) + + // Generate an alert by making an unexpected network request + _, _, err = wl.ExecIntoPod([]string{"curl", "google.com", "-m", "5"}, "nginx") + require.NoError(t, err, "Error executing network command in pod") + + // Wait for the alert to be generated + time.Sleep(15 * time.Second) + alerts, err := testutils.GetAlerts(ns.Name) + require.NoError(t, err, "Error getting alerts") + testutils.AssertContains(t, alerts, "DNS Anomalies in container", "curl", "nginx", []bool{true}) +} + +func Test_23_RuleCooldownTest(t *testing.T) { + start := time.Now() + defer tearDownTest(t, start) + + ns := testutils.NewRandomNamespace() + + wl, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/nginx-deployment.yaml")) + require.NoError(t, err, "Error creating workload") + + require.NoError(t, wl.WaitForApplicationProfileCompletion(80)) + + // Wait for cache + time.Sleep(30 * time.Second) + + // Run the same process 20 times + for i := 0; i < 20; i++ { + _, _, err = wl.ExecIntoPod([]string{"ls", "-l"}, "") + require.NoError(t, err) + time.Sleep(1 * time.Second) + } + + // Wait for alerts to be processed + time.Sleep(30 * time.Second) + + // Get all alerts + alerts, err := testutils.GetAlerts(wl.Namespace) + require.NoError(t, err, "Error getting alerts") + + // Count alerts for "Unexpected process launched" rule + alertCount := 0 + for _, alert := range alerts { + if ruleName, ok := alert.Labels["rule_name"]; ok && ruleName == "Unexpected process launched" { + alertCount++ + } + } + + // We should get exactly 10 alerts (cooldown threshold) even though we ran the process 20 times + assert.Equal(t, 10, alertCount, "Expected exactly 10 alerts due to cooldown threshold, got %d", alertCount) + + // Verify the specific alert details + testutils.AssertContains(t, alerts, "Unexpected process launched", "ls", "nginx", []bool{true}) +} + +func Test_24_ProcessTreeDepthTest(t *testing.T) { + start := time.Now() + defer tearDownTest(t, start) + + ns := testutils.NewRandomNamespace() + + endpointTraffic, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/tree.yaml")) + require.NoError(t, err, "Error creating workload") + + err = endpointTraffic.WaitForReady(80) + require.NoError(t, err, "Error waiting for workload to be ready") + + err = endpointTraffic.WaitForApplicationProfileCompletion(80) + require.NoError(t, err, "Error waiting for application profile to be completed") + + // wait for cache + time.Sleep(30 * time.Second) + + // Add to rule policy symlink + buf, _, err := endpointTraffic.ExecIntoPod([]string{"/bin/sh", "-c", "python3 /root/python_spawner.py 10"}, "") + require.NoError(t, err) + + t.Logf("Output: %s", buf) + + t.Logf("Waiting for the alert to be signaled") + + // Wait for the alert to be signaled + time.Sleep(2 * time.Minute) + + alerts, err := testutils.GetAlerts(endpointTraffic.Namespace) + require.NoError(t, err, "Error getting alerts") + + found := false + + for _, alert := range alerts { + if alert.Labels["rule_name"] == "Unexpected process launched" { + if alert.Labels["processtree_depth"] == "10" { + found = true + break + } + } + } + + assert.Truef(t, found, "Expected to find an alert for the process tree depth") + + t.Logf("Found alerts for the process tree depth: %v", alerts) +} + +// Test_27_ApplicationProfileOpens tests that the dynamic path matching in +// application profiles works correctly for both recorded (auto-learned) +// profiles and user-defined profiles. +// +// Path matching symbols: +// +// ⋯ (U+22EF DynamicIdentifier) — matches exactly ONE path segment +// * (WildcardIdentifier) — matches ZERO or more path segments +// 0 (in endpoints) — wildcard port (any port) +// +// R0002 "Files Access Anomalies in container" fires when a file is opened +// under a monitored prefix (/etc/, /var/log/, …) and the path was NOT +// recorded in the application profile. +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_28_UserDefinedNetworkNeighborhood(t *testing.T) { + start := time.Now() + defer tearDownTest(t, start) + + // setup creates a namespace with user-defined AP + NN + pod. + // The NN allows only fusioncore.ai (162.0.217.171) on TCP/80. + setup := func(t *testing.T) *testutils.TestWorkload { + t.Helper() + ns := testutils.NewRandomNamespace() + k8sClient := k8sinterface.NewKubernetesApi() + storageClient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) + + // Upstream ContainerProfileCache (kubescape/node-agent#788) reads ONE + // pod label `kubescape.io/user-defined-profile=` and uses + // as the lookup key for BOTH the user AP and the user NN. + // AP and NN MUST therefore share that single name. + const overlayName = "curl-28-overlay" + + ap := &v1beta1.ApplicationProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: overlayName, + Namespace: ns.Name, + }, + Spec: v1beta1.ApplicationProfileSpec{ + Containers: []v1beta1.ApplicationProfileContainer{ + { + Name: "curl", + Execs: []v1beta1.ExecCalls{ + {Path: "/bin/sleep"}, + {Path: "/usr/bin/curl"}, + {Path: "/usr/bin/nslookup"}, + {Path: "/usr/bin/wget"}, + }, + 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"}, + }, + }, + }, + } + _, err := storageClient.ApplicationProfiles(ns.Name).Create( + context.Background(), ap, metav1.CreateOptions{}) + require.NoError(t, err, "create AP") + + 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-28", + helpersv1.RelatedNamespaceMetadataKey: ns.Name, + }, + }, + Spec: v1beta1.NetworkNeighborhoodSpec{ + LabelSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "curl-28"}, + }, + Containers: []v1beta1.NetworkNeighborhoodContainer{ + { + Name: "curl", + Egress: []v1beta1.NetworkNeighbor{ + { + Identifier: "fusioncore-egress", + Type: "external", + DNS: "fusioncore.ai.", + DNSNames: []string{"fusioncore.ai."}, + IPAddress: "162.0.217.171", + Ports: []v1beta1.NetworkPort{ + {Name: "TCP-80", Protocol: "TCP", Port: ptr.To(int32(80))}, + }, + }, + }, + }, + }, + }, + } + _, 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/nginx-user-defined-deployment.yaml")) + require.NoError(t, err) + require.NoError(t, wl.WaitForReady(80)) + // Cache-load latency on the upstream ContainerProfileCache is bursty + // — 15s is enough on a quiet runner but not on a loaded one. The + // failure mode is alert metadata `errorMessage:"waiting for profile + // update"`, which means the rule manager evaluated against an + // unloaded NN and fired R0005/R0011 spuriously. 30s covers the + // observed worst-case in CI without pushing total test time too + // far. Real fix would be to poll a cache-loaded signal, but no + // such signal is exposed today. + time.Sleep(30 * time.Second) + return wl + } + + 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") + // Extra settle time for remaining 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"]) + } + } + + // --------------------------------------------------------------- + // 28a. Allowed traffic — fusioncore.ai is in the NN. + // No R0005 (DNS) and no R0011 (egress) expected. + // --------------------------------------------------------------- + t.Run("allowed_fusioncore_no_alert", func(t *testing.T) { + wl := setup(t) + + // DNS lookup via nslookup (domain in NN). + stdout, stderr, err := wl.ExecIntoPod([]string{"nslookup", "fusioncore.ai"}, "curl") + t.Logf("nslookup fusioncore.ai → err=%v stdout=%q stderr=%q", err, stdout, stderr) + + // HTTP via curl (domain + IP in NN). + stdout, stderr, err = wl.ExecIntoPod([]string{"curl", "-sm5", "http://fusioncore.ai"}, "curl") + t.Logf("curl fusioncore.ai → err=%v stdout=%q stderr=%q", err, stdout, stderr) + + alerts := waitAlerts(t, wl.Namespace) + t.Logf("=== %d alerts ===", len(alerts)) + logAlerts(t, alerts) + + assert.Equal(t, 0, countByRule(alerts, "R0005"), + "fusioncore.ai is in NN — should NOT fire R0005") + assert.Equal(t, 0, countByRule(alerts, "R0011"), + "fusioncore.ai IP is in NN — should NOT fire R0011") + }) + + // --------------------------------------------------------------- + // 28b. Unknown domains — domains NOT in the NN → R0005. + // Uses both nslookup (pure DNS) and curl (DNS + TCP). + // --------------------------------------------------------------- + t.Run("unknown_domain_R0005", func(t *testing.T) { + wl := setup(t) + + // nslookup generates a DNS query without any TCP connection. + wl.ExecIntoPod([]string{"nslookup", "google.com"}, "curl") + // curl resolves + connects. + wl.ExecIntoPod([]string{"curl", "-sm5", "http://ebpf.io"}, "curl") + wl.ExecIntoPod([]string{"curl", "-sm5", "http://cloudflare.com"}, "curl") + + alerts := waitAlerts(t, wl.Namespace) + t.Logf("=== %d alerts ===", len(alerts)) + logAlerts(t, alerts) + + require.Greater(t, countByRule(alerts, "R0005"), 0, + "unknown domains must fire R0005") + }) + + // --------------------------------------------------------------- + // 28c. Unknown IPs — raw IP egress NOT in the NN → R0011. + // --------------------------------------------------------------- + t.Run("unknown_ip_R0011", func(t *testing.T) { + wl := setup(t) + + wl.ExecIntoPod([]string{"curl", "-sm5", "http://8.8.8.8"}, "curl") + wl.ExecIntoPod([]string{"curl", "-sm5", "http://1.1.1.1"}, "curl") + + alerts := waitAlerts(t, wl.Namespace) + t.Logf("=== %d alerts ===", len(alerts)) + logAlerts(t, alerts) + + require.Greater(t, countByRule(alerts, "R0011"), 0, + "IPs not in NN must fire R0011") + }) + + // --------------------------------------------------------------- + // 28d. MITM — DNS spoofing simulation. + // fusioncore.ai is an allowed domain but the IP is spoofed. + // + // Step 1: nslookup fusioncore.ai (legitimate DNS, no alert). + // Step 2: curl --resolve fusioncore.ai:80:8.8.4.4 + // Simulates a DNS MITM returning a different IP. + // The domain is allowed but the connection goes to + // 8.8.4.4 (not 162.0.217.171) → R0011. + // --------------------------------------------------------------- + t.Run("mitm_spoofed_ip_R0011", func(t *testing.T) { + wl := setup(t) + + // Step 1: Legitimate DNS lookup — no alert expected. + wl.ExecIntoPod([]string{"nslookup", "fusioncore.ai"}, "curl") + + // Step 2: MITM — domain resolves to spoofed IP 8.8.4.4. + // curl --resolve skips DNS and connects directly to the + // spoofed IP, simulating what happens after DNS poisoning. + stdout, stderr, err := wl.ExecIntoPod( + []string{"curl", "-sm5", "--resolve", "fusioncore.ai:80:8.8.4.4", "http://fusioncore.ai"}, "curl") + t.Logf("curl MITM → err=%v stdout=%q stderr=%q", err, stdout, stderr) + + alerts := waitAlerts(t, wl.Namespace) + t.Logf("=== %d alerts ===", len(alerts)) + logAlerts(t, alerts) + + require.Greater(t, countByRule(alerts, "R0011"), 0, + "MITM: fusioncore.ai allowed but spoofed IP 8.8.4.4 must fire R0011") + }) + + // --------------------------------------------------------------- + // 28e. MITM — real CoreDNS poisoning via template plugin. + // Poisons CoreDNS so fusioncore.ai resolves to 8.8.4.4 + // instead of the legitimate 162.0.217.171. + // + // nslookup triggers the poisoned DNS response. + // R0005 does NOT fire: fusioncore.ai is in the NN egress + // list and BusyBox nslookup does NOT do PTR reverse-lookups. + // R0011 does NOT fire: no TCP egress (DNS is UDP to cluster + // DNS which is a private IP filtered by is_private_ip). + // + // This documents a detection gap: pure DNS MITM (without + // subsequent TCP to the spoofed IP) is invisible to both + // R0005 and R0011 when the domain is already whitelisted. + // + // NOTE: this subtest MUST run last — it modifies the + // cluster-wide CoreDNS configmap. + // --------------------------------------------------------------- + t.Run("mitm_coredns_poisoning", func(t *testing.T) { + wl := setup(t) + ctx := context.Background() + k8sClient := k8sinterface.NewKubernetesApi() + + // ── Back up original CoreDNS Corefile ── + cm, err := k8sClient.KubernetesClient.CoreV1(). + ConfigMaps("kube-system").Get(ctx, "coredns", metav1.GetOptions{}) + require.NoError(t, err, "get coredns configmap") + originalCorefile := cm.Data["Corefile"] + + restartAndWaitCoreDNS := func() { + deploy, err := k8sClient.KubernetesClient.AppsV1(). + Deployments("kube-system").Get(ctx, "coredns", metav1.GetOptions{}) + require.NoError(t, err, "get coredns deployment") + if deploy.Spec.Template.ObjectMeta.Annotations == nil { + deploy.Spec.Template.ObjectMeta.Annotations = make(map[string]string) + } + deploy.Spec.Template.ObjectMeta.Annotations["kubectl.kubernetes.io/restartedAt"] = time.Now().Format(time.RFC3339) + _, err = k8sClient.KubernetesClient.AppsV1(). + Deployments("kube-system").Update(ctx, deploy, metav1.UpdateOptions{}) + require.NoError(t, err, "restart coredns") + + require.Eventually(t, func() bool { + d, err := k8sClient.KubernetesClient.AppsV1(). + Deployments("kube-system").Get(ctx, "coredns", metav1.GetOptions{}) + if err != nil || d.Spec.Replicas == nil { + return false + } + return d.Status.ReadyReplicas == *d.Spec.Replicas && + d.Status.UpdatedReplicas == *d.Spec.Replicas + }, 60*time.Second, 2*time.Second, "coredns must become ready") + } + + // ── Restore CoreDNS on cleanup (best-effort) ── + t.Cleanup(func() { + t.Log("cleanup: restoring CoreDNS Corefile") + cm, err := k8sClient.KubernetesClient.CoreV1(). + ConfigMaps("kube-system").Get(ctx, "coredns", metav1.GetOptions{}) + if err != nil { + t.Logf("cleanup: get coredns cm: %v", err) + return + } + cm.Data["Corefile"] = originalCorefile + if _, err := k8sClient.KubernetesClient.CoreV1(). + ConfigMaps("kube-system").Update(ctx, cm, metav1.UpdateOptions{}); err != nil { + t.Logf("cleanup: update coredns cm: %v", err) + return + } + deploy, err := k8sClient.KubernetesClient.AppsV1(). + Deployments("kube-system").Get(ctx, "coredns", metav1.GetOptions{}) + if err != nil { + t.Logf("cleanup: get coredns deploy: %v", err) + return + } + if deploy.Spec.Template.ObjectMeta.Annotations == nil { + deploy.Spec.Template.ObjectMeta.Annotations = make(map[string]string) + } + deploy.Spec.Template.ObjectMeta.Annotations["kubectl.kubernetes.io/restartedAt"] = time.Now().Format(time.RFC3339) + if _, err := k8sClient.KubernetesClient.AppsV1(). + Deployments("kube-system").Update(ctx, deploy, metav1.UpdateOptions{}); err != nil { + t.Logf("cleanup: restart coredns: %v", err) + } + }) + + // ── Poison CoreDNS: fusioncore.ai → 8.8.4.4 ── + poisoned := strings.Replace(originalCorefile, + "forward .", + "template IN A fusioncore.ai {\n answer \"fusioncore.ai. 60 IN A 8.8.4.4\"\n fallthrough\n }\n forward .", + 1) + require.NotEqual(t, originalCorefile, poisoned, "template injection must modify Corefile") + + cm.Data["Corefile"] = poisoned + _, err = k8sClient.KubernetesClient.CoreV1(). + ConfigMaps("kube-system").Update(ctx, cm, metav1.UpdateOptions{}) + require.NoError(t, err, "apply poisoned Corefile") + restartAndWaitCoreDNS() + + // Verify poisoned DNS returns the spoofed IP. + require.Eventually(t, func() bool { + stdout, _, _ := wl.ExecIntoPod([]string{"nslookup", "fusioncore.ai"}, "curl") + return strings.Contains(stdout, "8.8.4.4") + }, 30*time.Second, 3*time.Second, "poisoned CoreDNS must return 8.8.4.4 for fusioncore.ai") + + // ── Trigger alerts ── + // nslookup does DNS only (no TCP egress). + // BusyBox nslookup does NOT do PTR reverse-lookups on result IPs. + stdout, stderr, err := wl.ExecIntoPod([]string{"nslookup", "fusioncore.ai"}, "curl") + t.Logf("nslookup (poisoned) → err=%v stdout=%q stderr=%q", err, stdout, stderr) + + alerts := waitAlerts(t, wl.Namespace) + t.Logf("=== %d alerts ===", len(alerts)) + logAlerts(t, alerts) + + // R0005 does NOT fire: fusioncore.ai is already in the NN + // egress list, and BusyBox nslookup does NOT perform PTR + // reverse-lookups on result IPs, so no unknown domain is queried. + assert.Equal(t, 0, countByRule(alerts, "R0005"), + "DNS MITM: domain is in NN and no PTR lookup — R0005 should not fire") + + // R0011 does NOT fire: nslookup generates only DNS (UDP) + // traffic to the cluster DNS service, which is a private IP + // excluded by is_private_ip(). + assert.Equal(t, 0, countByRule(alerts, "R0011"), + "DNS MITM: nslookup has no TCP egress — R0011 should not fire") + }) + + // --------------------------------------------------------------- + // 28f. MITM — CoreDNS poisoning with TCP egress. + // Same CoreDNS poisoning as 28e, but now fusioncore.ai + // resolves to 128.130.194.56 (a routable IP that accepts + // TCP on port 80). curl generates a real TCP connection + // to the spoofed IP. + // + // Expected: + // R0005 = 0 — domain is in NN, no PTR reverse-lookup. + // R0011 fires — TCP egress to 128.130.194.56 which is + // NOT in the NN (NN only has 162.0.217.171). + // + // NOTE: runs after 28e; modifies cluster-wide CoreDNS. + // --------------------------------------------------------------- + t.Run("mitm_coredns_poisoning_tcp", func(t *testing.T) { + wl := setup(t) + ctx := context.Background() + k8sClient := k8sinterface.NewKubernetesApi() + + // ── Back up original CoreDNS Corefile ── + cm, err := k8sClient.KubernetesClient.CoreV1(). + ConfigMaps("kube-system").Get(ctx, "coredns", metav1.GetOptions{}) + require.NoError(t, err, "get coredns configmap") + originalCorefile := cm.Data["Corefile"] + + restartAndWaitCoreDNS := func() { + deploy, err := k8sClient.KubernetesClient.AppsV1(). + Deployments("kube-system").Get(ctx, "coredns", metav1.GetOptions{}) + require.NoError(t, err, "get coredns deployment") + if deploy.Spec.Template.ObjectMeta.Annotations == nil { + deploy.Spec.Template.ObjectMeta.Annotations = make(map[string]string) + } + deploy.Spec.Template.ObjectMeta.Annotations["kubectl.kubernetes.io/restartedAt"] = time.Now().Format(time.RFC3339) + _, err = k8sClient.KubernetesClient.AppsV1(). + Deployments("kube-system").Update(ctx, deploy, metav1.UpdateOptions{}) + require.NoError(t, err, "restart coredns") + + require.Eventually(t, func() bool { + d, err := k8sClient.KubernetesClient.AppsV1(). + Deployments("kube-system").Get(ctx, "coredns", metav1.GetOptions{}) + if err != nil || d.Spec.Replicas == nil { + return false + } + return d.Status.ReadyReplicas == *d.Spec.Replicas && + d.Status.UpdatedReplicas == *d.Spec.Replicas + }, 60*time.Second, 2*time.Second, "coredns must become ready") + } + + // ── Restore CoreDNS on cleanup (best-effort) ── + t.Cleanup(func() { + t.Log("cleanup: restoring CoreDNS Corefile") + cm, err := k8sClient.KubernetesClient.CoreV1(). + ConfigMaps("kube-system").Get(ctx, "coredns", metav1.GetOptions{}) + if err != nil { + t.Logf("cleanup: get coredns cm: %v", err) + return + } + cm.Data["Corefile"] = originalCorefile + if _, err := k8sClient.KubernetesClient.CoreV1(). + ConfigMaps("kube-system").Update(ctx, cm, metav1.UpdateOptions{}); err != nil { + t.Logf("cleanup: update coredns cm: %v", err) + return + } + deploy, err := k8sClient.KubernetesClient.AppsV1(). + Deployments("kube-system").Get(ctx, "coredns", metav1.GetOptions{}) + if err != nil { + t.Logf("cleanup: get coredns deploy: %v", err) + return + } + if deploy.Spec.Template.ObjectMeta.Annotations == nil { + deploy.Spec.Template.ObjectMeta.Annotations = make(map[string]string) + } + deploy.Spec.Template.ObjectMeta.Annotations["kubectl.kubernetes.io/restartedAt"] = time.Now().Format(time.RFC3339) + if _, err := k8sClient.KubernetesClient.AppsV1(). + Deployments("kube-system").Update(ctx, deploy, metav1.UpdateOptions{}); err != nil { + t.Logf("cleanup: restart coredns: %v", err) + } + }) + + // ── Poison CoreDNS: fusioncore.ai → 128.130.194.56 ── + poisoned := strings.Replace(originalCorefile, + "forward .", + "template IN A fusioncore.ai {\n answer \"fusioncore.ai. 60 IN A 128.130.194.56\"\n fallthrough\n }\n forward .", + 1) + require.NotEqual(t, originalCorefile, poisoned, "template injection must modify Corefile") + + cm.Data["Corefile"] = poisoned + _, err = k8sClient.KubernetesClient.CoreV1(). + ConfigMaps("kube-system").Update(ctx, cm, metav1.UpdateOptions{}) + require.NoError(t, err, "apply poisoned Corefile") + restartAndWaitCoreDNS() + + // Verify poisoned DNS returns the spoofed IP. + require.Eventually(t, func() bool { + stdout, _, _ := wl.ExecIntoPod([]string{"nslookup", "fusioncore.ai"}, "curl") + return strings.Contains(stdout, "128.130.194.56") + }, 30*time.Second, 3*time.Second, "poisoned CoreDNS must return 128.130.194.56 for fusioncore.ai") + + // ── Trigger alerts ── + // curl resolves fusioncore.ai → 128.130.194.56 (poisoned) + // then opens a TCP connection to 128.130.194.56:80. + stdout, stderr, err := wl.ExecIntoPod( + []string{"curl", "-sm5", "http://fusioncore.ai"}, "curl") + t.Logf("curl (poisoned DNS) → err=%v stdout=%q stderr=%q", err, stdout, stderr) + + alerts := waitAlerts(t, wl.Namespace) + t.Logf("=== %d alerts ===", len(alerts)) + logAlerts(t, alerts) + + // R0005 does NOT fire: fusioncore.ai is already in the NN + // egress list, and curl (like BusyBox nslookup) does NOT + // perform PTR reverse-lookups on resolved IPs. + assert.Equal(t, 0, countByRule(alerts, "R0005"), + "DNS MITM: domain is in NN and no PTR lookup — R0005 should not fire") + + // R0011 fires: TCP egress to 128.130.194.56 which is NOT + // in the NN (NN only allows 162.0.217.171). + require.Greater(t, countByRule(alerts, "R0011"), 0, + "DNS MITM: TCP to spoofed IP 128.130.194.56 must fire R0011") + }) +} + +// Test_29_SignedApplicationProfile verifies that a cryptographically signed +// ApplicationProfile can be pushed to storage, loaded by node-agent, and +// used for anomaly detection just like any other user-defined profile. +// +// The test signs an AP with key-based ECDSA (no OIDC/Sigstore needed), +// pushes it to storage, verifies the signature survives the round-trip, +// deploys a pod referencing the signed profile, and asserts that executing +// a binary NOT in the profile fires R0001 (Unexpected process launched). +func Test_29_SignedApplicationProfile(t *testing.T) { + start := time.Now() + defer tearDownTest(t, start) + + ns := testutils.NewRandomNamespace() + k8sClient := k8sinterface.NewKubernetesApi() + storageClient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) + + // ── 1. Build the ApplicationProfile ── + // Use nil (not empty slices) for unused fields — storage normalizes + // []string{} → nil on save, which changes the content hash. + // Matching the storage representation ensures the signature survives + // the round-trip (same approach as cluster_flow_test.go). + ap := &v1beta1.ApplicationProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "signed-ap", + Namespace: ns.Name, + }, + Spec: v1beta1.ApplicationProfileSpec{ + Containers: []v1beta1.ApplicationProfileContainer{ + { + Name: "curl", + Execs: []v1beta1.ExecCalls{ + {Path: "/bin/sleep"}, + {Path: "/usr/bin/curl"}, + }, + Syscalls: []string{"close", "connect", "openat", "read", "socket", "write"}, + }, + }, + }, + } + + // ── 2. Sign the AP (key-based, no OIDC) ── + adapter := profiles.NewApplicationProfileAdapter(ap) + err := signature.SignObjectDisableKeyless(adapter) + require.NoError(t, err, "sign AP") + require.True(t, signature.IsSigned(adapter), "AP must be signed") + + // Verify signature locally. + require.NoError(t, signature.VerifyObjectAllowUntrusted(adapter), + "signature must verify immediately after signing") + + sig, err := signature.GetObjectSignature(adapter) + require.NoError(t, err, "extract signature") + require.NotEmpty(t, sig.Signature, "signature bytes must not be empty") + require.NotEmpty(t, sig.Certificate, "certificate must not be empty") + t.Logf("AP signed: issuer=%s identity=%s sigLen=%d", sig.Issuer, sig.Identity, len(sig.Signature)) + + // ── 3. Push signed AP to storage ── + // Create preserves annotations (including signature.*). + _, err = storageClient.ApplicationProfiles(ns.Name).Create( + context.Background(), ap, metav1.CreateOptions{}) + require.NoError(t, err, "create signed AP in storage") + + // ── 4. Verify signature survives the storage round-trip ── + require.Eventually(t, func() bool { + stored, getErr := storageClient.ApplicationProfiles(ns.Name).Get( + context.Background(), "signed-ap", v1.GetOptions{}) + if getErr != nil { + return false + } + return signature.IsSigned(profiles.NewApplicationProfileAdapter(stored)) + }, 30*time.Second, 1*time.Second, "stored AP must retain signature annotations") + + storedAP, err := storageClient.ApplicationProfiles(ns.Name).Get( + context.Background(), "signed-ap", v1.GetOptions{}) + require.NoError(t, err) + storedAdapter := profiles.NewApplicationProfileAdapter(storedAP) + err = signature.VerifyObjectAllowUntrusted(storedAdapter) + require.NoError(t, err, "stored AP signature must still verify after round-trip") + t.Log("Signature round-trip verification passed") + + // ── 6. Deploy pod referencing the signed profile ── + wl, err := testutils.NewTestWorkload(ns.Name, + path.Join(utils.CurrentDir(), "resources/curl-signed-deployment.yaml")) + require.NoError(t, err) + require.NoError(t, wl.WaitForReady(80)) + time.Sleep(15 * time.Second) // let node-agent load the profile + + // ── 7. Exec an allowed binary — should NOT fire R0001 ── + stdout, stderr, execErr := wl.ExecIntoPod([]string{"curl", "-sm5", "http://ebpf.io"}, "curl") + t.Logf("curl (allowed) → err=%v stdout=%q stderr=%q", execErr, stdout, stderr) + + // ── 8. Exec an anomalous binary — should fire R0001 ── + // The user-defined profile may not be cached yet when the first exec runs. + // Re-exec nslookup on each poll so the eBPF event is generated after + // the profile is loaded (same race as the crypto miner test). + stdout, stderr, execErr = wl.ExecIntoPod([]string{"nslookup", "ebpf.io"}, "curl") + t.Logf("nslookup (anomalous) → err=%v stdout=%q stderr=%q", execErr, stdout, stderr) + + // ── 9. Wait for R0001 alert ── + var alerts []testutils.Alert + require.Eventually(t, func() bool { + // Re-exec on each poll to ensure the event arrives after the profile is cached. + wl.ExecIntoPod([]string{"nslookup", "ebpf.io"}, "curl") + + alerts, err = testutils.GetAlerts(ns.Name) + if err != nil || len(alerts) == 0 { + return false + } + for _, a := range alerts { + if a.Labels["rule_id"] == "R0001" { + return true + } + } + return false + }, 120*time.Second, 10*time.Second, "nslookup is not in signed AP — must fire R0001") + + // Extra settle time. + time.Sleep(10 * time.Second) + alerts, _ = testutils.GetAlerts(ns.Name) + + t.Logf("=== %d alerts ===", len(alerts)) + 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 must have fired for the anomalous exec. + r0001Count := 0 + for _, a := range alerts { + if a.Labels["rule_id"] == "R0001" { + r0001Count++ + } + } + require.Greater(t, r0001Count, 0, "nslookup not in signed AP must fire R0001") +} + +// Test_30_TamperedSignedProfiles verifies that cryptographic signature +// verification detects tampering of both ApplicationProfile and +// NetworkNeighborhood objects. +// +// Current state of enforcement (as of merge): +// - enableSignatureVerification defaults to false +// - When enabled: tampered profiles are silently SKIPPED (not loaded) +// - No R-number rule fires on signature verification failure +// - User-defined NNs in addContainer() are NOT verified (known gap) +// - System fails open: no profile → no anomaly baseline → no detection +// +// This test proves: +// - The crypto layer detects tampering (sign → tamper → verify fails) +// - Without enforcement, tampered profiles are loaded and used +func Test_30_TamperedSignedProfiles(t *testing.T) { + start := time.Now() + defer tearDownTest(t, start) + + // --------------------------------------------------------------- + // 30a. Tamper detection at the crypto layer — AP and NN. + // Sign both objects, tamper their specs, verify fails. + // --------------------------------------------------------------- + t.Run("tamper_invalidates_signature", func(t *testing.T) { + // ── ApplicationProfile ── + ap := &v1beta1.ApplicationProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "tamper-test-ap", + Namespace: "tamper-test-ns", + }, + Spec: v1beta1.ApplicationProfileSpec{ + Containers: []v1beta1.ApplicationProfileContainer{ + { + Name: "app", + Execs: []v1beta1.ExecCalls{ + {Path: "/bin/sleep"}, + {Path: "/usr/bin/curl"}, + }, + Syscalls: []string{"read", "write", "close"}, + }, + }, + }, + } + + apAdapter := profiles.NewApplicationProfileAdapter(ap) + require.NoError(t, signature.SignObjectDisableKeyless(apAdapter), "sign AP") + require.True(t, signature.IsSigned(apAdapter)) + require.NoError(t, signature.VerifyObjectAllowUntrusted(apAdapter), "untampered AP must verify") + + // Tamper: attacker adds nslookup to whitelist + ap.Spec.Containers[0].Execs = append(ap.Spec.Containers[0].Execs, + v1beta1.ExecCalls{Path: "/usr/bin/nslookup"}) + + tamperedAPAdapter := profiles.NewApplicationProfileAdapter(ap) + err := signature.VerifyObjectAllowUntrusted(tamperedAPAdapter) + require.Error(t, err, "tampered AP must fail verification") + t.Logf("AP tamper detected: %v", err) + + // ── NetworkNeighborhood ── + nn := &v1beta1.NetworkNeighborhood{ + ObjectMeta: metav1.ObjectMeta{ + Name: "tamper-test-nn", + Namespace: "tamper-test-ns", + Annotations: map[string]string{ + helpersv1.ManagedByMetadataKey: helpersv1.ManagedByUserValue, + helpersv1.StatusMetadataKey: helpersv1.Completed, + helpersv1.CompletionMetadataKey: helpersv1.Full, + }, + Labels: map[string]string{ + helpersv1.RelatedKindMetadataKey: "Deployment", + helpersv1.RelatedNameMetadataKey: "tamper-test", + }, + }, + Spec: v1beta1.NetworkNeighborhoodSpec{ + LabelSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "tamper-test"}, + }, + Containers: []v1beta1.NetworkNeighborhoodContainer{ + { + Name: "app", + Egress: []v1beta1.NetworkNeighbor{ + { + Identifier: "allowed-egress", + Type: "external", + DNS: "fusioncore.ai.", + DNSNames: []string{"fusioncore.ai."}, + IPAddress: "162.0.217.171", + Ports: []v1beta1.NetworkPort{ + {Name: "TCP-80", Protocol: "TCP", Port: ptr.To(int32(80))}, + }, + }, + }, + }, + }, + }, + } + + nnAdapter := profiles.NewNetworkNeighborhoodAdapter(nn) + require.NoError(t, signature.SignObjectDisableKeyless(nnAdapter), "sign NN") + require.True(t, signature.IsSigned(nnAdapter)) + require.NoError(t, signature.VerifyObjectAllowUntrusted(nnAdapter), "untampered NN must verify") + + // Tamper: attacker adds a C2 domain to the egress whitelist + nn.Spec.Containers[0].Egress = append(nn.Spec.Containers[0].Egress, + v1beta1.NetworkNeighbor{ + Identifier: "c2-backdoor", + Type: "external", + DNS: "evil-c2.example.com.", + DNSNames: []string{"evil-c2.example.com."}, + IPAddress: "6.6.6.6", + Ports: []v1beta1.NetworkPort{ + {Name: "TCP-443", Protocol: "TCP", Port: ptr.To(int32(443))}, + }, + }) + + tamperedNNAdapter := profiles.NewNetworkNeighborhoodAdapter(nn) + err = signature.VerifyObjectAllowUntrusted(tamperedNNAdapter) + require.Error(t, err, "tampered NN must fail verification") + t.Logf("NN tamper detected: %v", err) + }) + + // --------------------------------------------------------------- + // 30b. Tampered AP is still loaded when enforcement is off. + // + // enableSignatureVerification defaults to false. + // The tampered profile is pushed to storage and node-agent + // loads it without checking the signature. Anomaly detection + // uses the tampered baseline → the attacker's added exec + // path (nslookup) is whitelisted. + // + // With enableSignatureVerification=true, the tampered profile + // would be rejected and the pod would have no baseline. + // --------------------------------------------------------------- + t.Run("tampered_profile_loaded_without_enforcement", func(t *testing.T) { + ns := testutils.NewRandomNamespace() + k8sClient := k8sinterface.NewKubernetesApi() + storageClient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) + + // Build AP: only sleep + curl allowed. + // Use nil for unused fields (storage normalizes empty slices to nil). + ap := &v1beta1.ApplicationProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "signed-ap", + Namespace: ns.Name, + }, + Spec: v1beta1.ApplicationProfileSpec{ + Containers: []v1beta1.ApplicationProfileContainer{ + { + Name: "curl", + Execs: []v1beta1.ExecCalls{ + {Path: "/bin/sleep"}, + {Path: "/usr/bin/curl"}, + }, + Syscalls: []string{"close", "connect", "openat", "read", "socket", "write"}, + }, + }, + }, + } + + // Sign the AP. + apAdapter := profiles.NewApplicationProfileAdapter(ap) + require.NoError(t, signature.SignObjectDisableKeyless(apAdapter)) + require.NoError(t, signature.VerifyObjectAllowUntrusted(apAdapter), "pre-tamper verification") + + // Tamper: attacker adds nslookup to the whitelist. + ap.Spec.Containers[0].Execs = append(ap.Spec.Containers[0].Execs, + v1beta1.ExecCalls{Path: "/usr/bin/nslookup"}) + + // Signature is now invalid. + tamperedAdapter := profiles.NewApplicationProfileAdapter(ap) + require.Error(t, signature.VerifyObjectAllowUntrusted(tamperedAdapter), + "tampered AP must fail verification") + + // Push tampered AP to storage (signature annotations are stale). + _, err := storageClient.ApplicationProfiles(ns.Name).Create( + context.Background(), ap, metav1.CreateOptions{}) + require.NoError(t, err, "push tampered AP to storage") + + // Verify stored AP has stale signature. + require.Eventually(t, func() bool { + stored, getErr := storageClient.ApplicationProfiles(ns.Name).Get( + context.Background(), "signed-ap", v1.GetOptions{}) + if getErr != nil { + return false + } + storedAdapter := profiles.NewApplicationProfileAdapter(stored) + // Signature annotation exists but verification should fail. + if !signature.IsSigned(storedAdapter) { + return false + } + return signature.VerifyObjectAllowUntrusted(storedAdapter) != nil + }, 30*time.Second, 1*time.Second, "stored AP must have stale signature that fails verification") + t.Log("Stored AP has invalid signature (tamper detected at crypto layer)") + + // Deploy pod referencing the tampered profile. + wl, err := testutils.NewTestWorkload(ns.Name, + path.Join(utils.CurrentDir(), "resources/curl-signed-deployment.yaml")) + require.NoError(t, err) + require.NoError(t, wl.WaitForReady(80)) + + // Drive the unexpected exec inside Eventually so cache-load latency + // is absorbed by retries instead of a blind sleep. Same pattern as + // Test_29 (signed AP, anomalous exec) — without it, the first exec + // can land before the CP cache projects the user-defined AP, the + // rule manager evaluates against an empty baseline, and R0001 never + // fires within the polling window. + // + // wget is NOT in the AP (even after the attacker added nslookup), so + // once the cache loads, every wget exec produces an R0001 alert. + var alerts []testutils.Alert + require.Eventually(t, func() bool { + wl.ExecIntoPod([]string{"wget", "-qO-", "--timeout=2", "http://ebpf.io"}, "curl") + alerts, err = testutils.GetAlerts(ns.Name) + if err != nil { + return false + } + for _, a := range alerts { + if a.Labels["rule_id"] == "R0001" && a.Labels["comm"] == "wget" { + return true + } + } + return false + }, 120*time.Second, 10*time.Second, + "wget not in tampered AP must fire R0001 — proves tampered profile was loaded (enforcement off)") + + // Settle so any pending alerts flush, then dump for diagnostics. + time.Sleep(10 * time.Second) + alerts, _ = testutils.GetAlerts(ns.Name) + t.Logf("=== %d alerts ===", len(alerts)) + 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"]) + } + + // With enableSignatureVerification=true: + // - The tampered AP would be rejected (verifyUserApplicationProfile returns false) + // - The pod would have no baseline → no anomaly rules fire for wget + // - System fails OPEN (attacker evades detection by tampering the profile) + // - NOTE: user-defined NNs are not yet gated on the same flag (known gap) + // R1016 ("Signed profile tampered") fires regardless of the flag — that + // path is handled by Test_31. + t.Log("With enableSignatureVerification=true, the tampered profile would be silently rejected.") + }) +} + +// Test_31_TamperDetectionAlert verifies that R1016 "Signed profile tampered" +// fires when a previously signed ApplicationProfile or NetworkNeighborhood +// has been tampered with (signature annotations stale relative to the +// resource bytes). +// +// Coverage: +// 31a — tampered AP fires R1016 (the original scenario; regression-pinned +// after upstream PR #788's cache rewrite re-wired alert emission). +// 31b — untampered signed AP does NOT fire R1016 (negative; signature +// verifies cleanly so no alert). +// 31c — unsigned AP does NOT fire R1016 (signing is opt-in; not-signed +// is not the same as tampered). +// 31d — tampered NN fires R1016 via the parallel NN code path (different +// storage call, same emission contract). +// +// All four subtests share signSignedAP / signSignedNN helpers; each subtest +// uses its own namespace + its own AP/NN name to avoid alert cross-talk +// between scenarios. +// +// R1016 fires regardless of cfg.EnableSignatureVerification: the alert is +// always emitted on tamper; the flag only gates whether the cache also +// rejects the load. +func Test_31_TamperDetectionAlert(t *testing.T) { + start := time.Now() + defer tearDownTest(t, start) + + k8sClient := k8sinterface.NewKubernetesApi() + storageClient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) + + // signSignedAP returns a signed ApplicationProfile in nsName under name. + // + // IMPORTANT: storage's PreSave normalises spec content (DeflateSortString + // sorts+dedupes Syscalls/Capabilities/Architectures, DeflateStringer + // dedupes Execs, AnalyzeOpens/Endpoints/UnifyIdentifiedCallStacks + // rewrite their respective slices, GetContent injects empty + // PolicyByRuleId maps, and K8s itself may default fields). Signing + // locally and then pushing to storage makes the SIGNED hash mismatch + // the POST-STORE content hash that node-agent's tamper check sees, + // firing R1016 on an untampered profile. + // + // Sign-after-roundtrip eliminates every drift source at once: push + // the AP unsigned, read back the storage-normalised form, sign THAT, + // and let the caller push the signed version (deployAndWait does an + // Update-or-Create, so the second push goes through the same + // idempotent deflate and produces the same content hash). + signSignedAP := func(t *testing.T, nsName, name string) *v1beta1.ApplicationProfile { + t.Helper() + // Pre-sort syscalls so the first roundtrip is a no-op for that field + // — keeps the assertion that "deflate is idempotent on already-sorted + // content" honest. + syscalls := []string{"close", "connect", "openat", "read", "socket", "write"} + ap := &v1beta1.ApplicationProfile{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: nsName}, + Spec: v1beta1.ApplicationProfileSpec{ + Containers: []v1beta1.ApplicationProfileContainer{ + { + Name: "curl", + Execs: []v1beta1.ExecCalls{ + {Path: "/bin/sleep"}, + {Path: "/usr/bin/curl"}, + }, + Syscalls: syscalls, + }, + }, + }, + } + + // Round-trip 1: push unsigned, read back the normalised form. + _, err := storageClient.ApplicationProfiles(nsName).Create( + context.Background(), ap, metav1.CreateOptions{}) + require.NoError(t, err, "create unsigned AP for normalisation") + var stored *v1beta1.ApplicationProfile + require.Eventually(t, func() bool { + s, gerr := storageClient.ApplicationProfiles(nsName).Get( + context.Background(), name, v1.GetOptions{}) + if gerr != nil { + return false + } + stored = s + return true + }, 30*time.Second, 1*time.Second, "AP must be retrievable after unsigned create") + + // Sign the storage-normalised content. Now the hash in the signature + // annotation matches what node-agent will see when it loads the AP. + require.NoError(t, + signature.SignObjectDisableKeyless(profiles.NewApplicationProfileAdapter(stored)), + "sign storage-normalised AP") + + // Delete the unsigned in-storage copy so the caller's deployAndWait + // Create succeeds without an AlreadyExists conflict. Storage will + // re-deflate the signed AP on the second push; since that content + // is already normalised, deflate is a no-op and the hash stays + // stable. + require.NoError(t, + storageClient.ApplicationProfiles(nsName).Delete( + context.Background(), name, metav1.DeleteOptions{}), + "delete unsigned AP before caller re-pushes signed version") + // Strip server-managed metadata so the Create call doesn't see a + // stale resourceVersion / uid / creationTimestamp. + stored.ObjectMeta.ResourceVersion = "" + stored.ObjectMeta.UID = "" + stored.ObjectMeta.CreationTimestamp = v1.Time{} + stored.ObjectMeta.Generation = 0 + return stored + } + + signSignedNN := func(t *testing.T, nsName, name string) *v1beta1.NetworkNeighborhood { + t.Helper() + nn := &v1beta1.NetworkNeighborhood{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: nsName}, + Spec: v1beta1.NetworkNeighborhoodSpec{ + LabelSelector: metav1.LabelSelector{MatchLabels: map[string]string{"app": "curl-signed"}}, + Containers: []v1beta1.NetworkNeighborhoodContainer{ + {Name: "curl"}, + }, + }, + } + require.NoError(t, signature.SignObjectDisableKeyless(profiles.NewNetworkNeighborhoodAdapter(nn)), "sign NN") + return nn + } + + // deployAndWait pushes the AP (and optionally NN) into storage, then + // deploys curl-signed-deployment.yaml and waits for it to come up. The + // deployment YAML uses kubescape.io/user-defined-profile=signed-ap as + // its label, so AP+NN names must equal "signed-ap" for the upstream + // CP cache to pick them up. + deployAndWait := func(t *testing.T, ns testutils.TestNamespace, ap *v1beta1.ApplicationProfile, nn *v1beta1.NetworkNeighborhood) *testutils.TestWorkload { + t.Helper() + if ap != nil { + _, err := storageClient.ApplicationProfiles(ns.Name).Create( + context.Background(), ap, metav1.CreateOptions{}) + require.NoError(t, err, "push AP to storage") + } + if nn != nil { + _, err := storageClient.NetworkNeighborhoods(ns.Name).Create( + context.Background(), nn, metav1.CreateOptions{}) + require.NoError(t, err, "push NN to storage") + } + require.Eventually(t, func() bool { + if ap != nil { + if _, err := storageClient.ApplicationProfiles(ns.Name).Get( + context.Background(), ap.Name, v1.GetOptions{}); err != nil { + return false + } + } + if nn != nil { + if _, err := storageClient.NetworkNeighborhoods(ns.Name).Get( + context.Background(), nn.Name, v1.GetOptions{}); err != nil { + return false + } + } + return true + }, 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-signed-deployment.yaml")) + require.NoError(t, err) + require.NoError(t, wl.WaitForReady(80)) + return wl + } + + countR1016 := func(t *testing.T, nsName string, settle time.Duration) int { + t.Helper() + // Allow node-agent to load the profile and for any alert to flush. + time.Sleep(settle) + alerts, err := testutils.GetAlerts(nsName) + if err != nil { + t.Logf("GetAlerts error: %v", err) + return 0 + } + n := 0 + for _, a := range alerts { + if a.Labels["rule_id"] == "R1016" { + n++ + assert.Equal(t, "Signed profile tampered", a.Labels["rule_name"], + "R1016 alert must have correct rule name") + assert.Equal(t, nsName, a.Labels["namespace"], + "R1016 alert must have correct namespace") + } + } + t.Logf("[%s] R1016 count = %d (out of %d alerts)", nsName, n, len(alerts)) + return n + } + + // ----------------------------------------------------------------- + // 31a — tampered AP fires R1016 + // ----------------------------------------------------------------- + t.Run("tampered_user_defined_AP_fires_R1016", func(t *testing.T) { + ns := testutils.NewRandomNamespace() + ap := signSignedAP(t, ns.Name, "signed-ap") + // Tamper after signing: append an unauthorized exec entry. The + // signature annotations stay (stale). + ap.Spec.Containers[0].Execs = append(ap.Spec.Containers[0].Execs, + v1beta1.ExecCalls{Path: "/usr/bin/nslookup"}) + require.Error(t, + signature.VerifyObjectAllowUntrusted(profiles.NewApplicationProfileAdapter(ap)), + "tampered AP must fail verification") + + _ = deployAndWait(t, ns, ap, nil) + + require.Eventually(t, func() bool { + alerts, _ := testutils.GetAlerts(ns.Name) + for _, a := range alerts { + if a.Labels["rule_id"] == "R1016" { + return true + } + } + return false + }, 120*time.Second, 5*time.Second, "tampered AP must produce R1016") + + require.Greater(t, countR1016(t, ns.Name, 5*time.Second), 0) + }) + + // ----------------------------------------------------------------- + // 31b — untampered signed AP must NOT fire R1016 + // ----------------------------------------------------------------- + t.Run("untampered_signed_AP_no_R1016", func(t *testing.T) { + ns := testutils.NewRandomNamespace() + ap := signSignedAP(t, ns.Name, "signed-ap") + // Don't tamper. Signature verifies cleanly. + require.NoError(t, + signature.VerifyObjectAllowUntrusted(profiles.NewApplicationProfileAdapter(ap)), + "untampered signed AP must verify") + + _ = deployAndWait(t, ns, ap, nil) + // Wait for cache load to happen (cache picks it up within ~15s). + assert.Equal(t, 0, countR1016(t, ns.Name, 30*time.Second), + "untampered signed AP must NOT fire R1016") + }) + + // ----------------------------------------------------------------- + // 31c — unsigned AP must NOT fire R1016 (signing is opt-in) + // ----------------------------------------------------------------- + t.Run("unsigned_AP_no_R1016", func(t *testing.T) { + ns := testutils.NewRandomNamespace() + ap := &v1beta1.ApplicationProfile{ + ObjectMeta: metav1.ObjectMeta{Name: "signed-ap", Namespace: ns.Name}, + Spec: v1beta1.ApplicationProfileSpec{ + Containers: []v1beta1.ApplicationProfileContainer{ + { + Name: "curl", + Execs: []v1beta1.ExecCalls{ + {Path: "/bin/sleep"}, + }, + Syscalls: []string{"socket"}, + }, + }, + }, + } + require.False(t, + signature.IsSigned(profiles.NewApplicationProfileAdapter(ap)), + "unsigned AP must not have signature annotations") + + _ = deployAndWait(t, ns, ap, nil) + assert.Equal(t, 0, countR1016(t, ns.Name, 30*time.Second), + "unsigned AP must NOT fire R1016 — not-signed is not the same as tampered") + }) + + // ----------------------------------------------------------------- + // 31d — tampered NN fires R1016 via the NN code path + // ----------------------------------------------------------------- + t.Run("tampered_user_defined_NN_fires_R1016", func(t *testing.T) { + ns := testutils.NewRandomNamespace() + // Untampered AP (matched on name to the pod label) so the AP path + // stays silent and we know any R1016 came from the NN path. + ap := signSignedAP(t, ns.Name, "signed-ap") + nn := signSignedNN(t, ns.Name, "signed-ap") + // Tamper the NN: add a container the original signature didn't cover. + nn.Spec.Containers = append(nn.Spec.Containers, + v1beta1.NetworkNeighborhoodContainer{Name: "drift"}) + require.Error(t, + signature.VerifyObjectAllowUntrusted(profiles.NewNetworkNeighborhoodAdapter(nn)), + "tampered NN must fail verification") + + _ = deployAndWait(t, ns, ap, nn) + + require.Eventually(t, func() bool { + alerts, _ := testutils.GetAlerts(ns.Name) + for _, a := range alerts { + if a.Labels["rule_id"] == "R1016" { + return true + } + } + return false + }, 120*time.Second, 5*time.Second, "tampered NN must produce R1016") + + require.Greater(t, countR1016(t, ns.Name, 5*time.Second), 0) + }) + +} + +// --------------------------------------------------------------------------- +// Test_32_UnexpectedProcessArguments — component test for the wildcard-aware +// exec-argument matching (R0040). Each subtest gets its own namespace so +// alerts don't cross-contaminate. +// +// AP overlay declares 4 allowed exec patterns for the curl pod. Profile +// shape: +// - Path = full kernel-resolved exec path (used by parse.get_exec_path +// + ap.was_executed for path-level matching) +// - Args[0] = ABSOLUTE invoking path (e.g. "/bin/sh"). Matches runtime +// argv[0] as captured by eBPF after the symlink-faithful +// precedence fix (parse.get_exec_path / resolveExecPath +// prefer absolute argv[0] over kernel exepath when argv[0] +// starts with "/"). Recording side records the same form +// via the matching precedence in +// pkg/containerprofilemanager/v1/event_reporting.go:: +// resolveExecPath, so profile.Args[0] agrees with what +// CompareExecArgs compares against at rule-eval time. See +// pkg/rulemanager/cel/libraries/parse/parse.go for the +// live precedence definition. +// +// /bin/sleep [/bin/sleep, *] — pod startup, must stay silent +// /bin/sh [/bin/sh, -c, *] — sh -c +// /bin/echo [/bin/echo, hello, *] — echo hello +// /usr/bin/curl [/usr/bin/curl, -s, ⋯] — curl -s +// +// Profile loaded into the new ContainerProfileCache via the unified +// kubescape.io/user-defined-profile= label. The exec.go CEL function +// routes ap.was_executed_with_args through dynamicpathdetector.CompareExecArgs +// — see storage/pkg/registry/file/dynamicpathdetector/tests/ +// compare_exec_args_test.go::TestCompareExecArgs_Argv0BareName for the +// matcher-level contract these subtests rest on. +// +// R0040 ("Unexpected process arguments") fires when: +// - the exec'd path IS in the profile (R0001 silent), AND +// - the runtime arg vector does NOT match any profile entry's pattern. +// +// Each subtest asserts R0001 silence as a PRECONDITION (path resolution +// works), THEN asserts presence/absence of R0040. If R0001 fires, the +// failure points at the recording-side exepath capture (event.exepath +// empty AND argv[0] not absolute → parse.get_exec_path falls back to +// bare comm → profile +// Path lookup misses), not at R0040 logic. Separating the two axes +// stops Test_32 from flaking on unrelated capture-layer gaps. +// --------------------------------------------------------------------------- +func Test_32_UnexpectedProcessArguments(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 { + 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}}, + }, + 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") + + require.Eventually(t, func() bool { + _, apErr := storageClient.ApplicationProfiles(ns.Name).Get( + context.Background(), overlayName, v1.GetOptions{}) + return apErr == nil + }, 30*time.Second, 1*time.Second, "AP 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)) + // let node-agent load the user AP into the CP cache + time.Sleep(15 * time.Second) + return wl + } + + 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 := setup(t) + stdout, stderr, err := wl.ExecIntoPod([]string{"sh", "-c", "echo hi"}, "curl") + t.Logf("sh -c 'echo hi' → err=%v stdout=%q stderr=%q", err, stdout, stderr) + + alerts := waitAlerts(t, wl.Namespace) + t.Logf("=== %d alerts ===", len(alerts)) + logAlerts(t, alerts) + + assertR0001Silent(t, alerts, "sh") + assert.Equal(t, 0, countByRule(alerts, "R0040"), + "sh -c matches profile [sh, -c, *] — R0040 must stay silent") + }) + + // ----------------------------------------------------------------- + // 32b. sh -x — argv [sh, -x, "echo hi"] does NOT match + // profile [sh, -c, *] (literal anchor `-c` mismatch). Path + // /bin/sh IS in profile so R0001 stays silent. R0040 must fire. + // ----------------------------------------------------------------- + t.Run("sh_dash_x_mismatches_R0040", func(t *testing.T) { + wl := setup(t) + stdout, stderr, err := wl.ExecIntoPod([]string{"sh", "-x", "echo hi"}, "curl") + t.Logf("sh -x 'echo hi' → err=%v stdout=%q stderr=%q", err, stdout, stderr) + + alerts := waitAlerts(t, wl.Namespace) + t.Logf("=== %d alerts ===", len(alerts)) + logAlerts(t, alerts) + + assertR0001Silent(t, alerts, "sh") + require.Greater(t, countByRule(alerts, "R0040"), 0, + "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 := setup(t) + stdout, stderr, err := wl.ExecIntoPod([]string{"echo", "hello", "world", "from", "test"}, "curl") + t.Logf("echo hello world from test → err=%v stdout=%q stderr=%q", err, stdout, stderr) + + alerts := waitAlerts(t, wl.Namespace) + t.Logf("=== %d alerts ===", len(alerts)) + logAlerts(t, alerts) + + assertR0001Silent(t, alerts, "echo") + assert.Equal(t, 0, 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 := setup(t) + stdout, stderr, err := wl.ExecIntoPod([]string{"echo", "goodbye", "world"}, "curl") + t.Logf("echo goodbye world → err=%v stdout=%q stderr=%q", err, stdout, stderr) + + alerts := waitAlerts(t, wl.Namespace) + t.Logf("=== %d alerts ===", len(alerts)) + logAlerts(t, alerts) + + assertR0001Silent(t, alerts, "echo") + require.Greater(t, countByRule(alerts, "R0040"), 0, + "echo goodbye mismatches profile [echo, hello, *] (literal anchor) → R0040 must fire") + }) +} + +// Test_33_AnalyzeOpensWildcardAnchoring pins the wildcard-matching +// contract that storage-side CompareDynamic enforces, end-to-end through +// R0002 ("Files Access Anomalies in container"). +// +// Each subtest spins up a fresh nginx pod with a user-defined AP that +// carries ONE Opens entry, then `cat`s a target path that probes a +// boundary case from the storage-side analyzer fixes (kubescape/storage +// PR #316 review by matthyx + entlein): +// +// - Anchored trailing `*` matches one OR MORE remaining segments — +// never zero. So `/etc/*` matches `/etc/passwd` but NOT the bare +// `/etc` directory. Without this rule, R0002 silently allowed +// access to the parent of any profiled directory. +// - DynamicIdentifier (⋯) consumes EXACTLY ONE segment. +// - Mid-path `*` consumes ZERO or more, so `/etc/*/*` still matches +// `/etc/ssh` (inner `*` consumed zero, trailing `*` consumed one). +// - splitPath normalises trailing slashes on both dynamic and +// regular paths so `/etc/passwd/` is treated as `/etc/passwd`. +// - Mixed `⋯/*` patterns: ⋯ pins one segment, `*` consumes the rest +// (with one-or-more semantics). +// +// Component-level pin sits ON TOP of the unit tests in storage's +// pkg/registry/file/dynamicpathdetector/tests/coverage_test.go. +// Both layers must agree — if the unit suite drifts away from these +// runtime expectations, R0002 has either a false-positive or a +// false-negative bug. 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 000000000..2f06f8bae --- /dev/null +++ b/tests/resources/curl-exec-arg-wildcards-deployment.yaml @@ -0,0 +1,28 @@ +## 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; the NN side is intentionally absent. +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 + spec: + containers: + - name: curl + image: docker.io/curlimages/curl@sha256:08e466006f0860e54fc299378de998935333e0e130a15f6f98482e9f8dab3058 + command: ["sleep", "infinity"] From 1f111a2d2ac2c38fd8095c4233b22c4ba6a479e2 Mon Sep 17 00:00:00 2001 From: entlein Date: Wed, 27 May 2026 23:23:35 +0200 Subject: [PATCH 07/17] test: add Test_32_UnexpectedProcessArguments + fixtures Signed-off-by: entlein --- tests/component_test.go | 228 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 228 insertions(+) diff --git a/tests/component_test.go b/tests/component_test.go index d2766f638..c95ee423d 100644 --- a/tests/component_test.go +++ b/tests/component_test.go @@ -5203,3 +5203,231 @@ func Test_32_UnexpectedProcessArguments(t *testing.T) { // Both layers must agree — if the unit suite drifts away from these // runtime expectations, R0002 has either a false-positive or a // false-negative bug. +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 { + 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}}, + }, + 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") + + require.Eventually(t, func() bool { + _, apErr := storageClient.ApplicationProfiles(ns.Name).Get( + context.Background(), overlayName, v1.GetOptions{}) + return apErr == nil + }, 30*time.Second, 1*time.Second, "AP 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)) + // let node-agent load the user AP into the CP cache + time.Sleep(15 * time.Second) + return wl + } + + 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 := setup(t) + stdout, stderr, err := wl.ExecIntoPod([]string{"sh", "-c", "echo hi"}, "curl") + t.Logf("sh -c 'echo hi' → err=%v stdout=%q stderr=%q", err, stdout, stderr) + + alerts := waitAlerts(t, wl.Namespace) + t.Logf("=== %d alerts ===", len(alerts)) + logAlerts(t, alerts) + + assertR0001Silent(t, alerts, "sh") + assert.Equal(t, 0, countByRule(alerts, "R0040"), + "sh -c matches profile [sh, -c, *] — R0040 must stay silent") + }) + + // ----------------------------------------------------------------- + // 32b. sh -x — argv [sh, -x, "echo hi"] does NOT match + // profile [sh, -c, *] (literal anchor `-c` mismatch). Path + // /bin/sh IS in profile so R0001 stays silent. R0040 must fire. + // ----------------------------------------------------------------- + t.Run("sh_dash_x_mismatches_R0040", func(t *testing.T) { + wl := setup(t) + stdout, stderr, err := wl.ExecIntoPod([]string{"sh", "-x", "echo hi"}, "curl") + t.Logf("sh -x 'echo hi' → err=%v stdout=%q stderr=%q", err, stdout, stderr) + + alerts := waitAlerts(t, wl.Namespace) + t.Logf("=== %d alerts ===", len(alerts)) + logAlerts(t, alerts) + + assertR0001Silent(t, alerts, "sh") + require.Greater(t, countByRule(alerts, "R0040"), 0, + "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 := setup(t) + stdout, stderr, err := wl.ExecIntoPod([]string{"echo", "hello", "world", "from", "test"}, "curl") + t.Logf("echo hello world from test → err=%v stdout=%q stderr=%q", err, stdout, stderr) + + alerts := waitAlerts(t, wl.Namespace) + t.Logf("=== %d alerts ===", len(alerts)) + logAlerts(t, alerts) + + assertR0001Silent(t, alerts, "echo") + assert.Equal(t, 0, 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 := setup(t) + stdout, stderr, err := wl.ExecIntoPod([]string{"echo", "goodbye", "world"}, "curl") + t.Logf("echo goodbye world → err=%v stdout=%q stderr=%q", err, stdout, stderr) + + alerts := waitAlerts(t, wl.Namespace) + t.Logf("=== %d alerts ===", len(alerts)) + logAlerts(t, alerts) + + assertR0001Silent(t, alerts, "echo") + require.Greater(t, countByRule(alerts, "R0040"), 0, + "echo goodbye mismatches profile [echo, hello, *] (literal anchor) → R0040 must fire") + }) +} + +// Test_33_AnalyzeOpensWildcardAnchoring pins the wildcard-matching +// contract that storage-side CompareDynamic enforces, end-to-end through +// R0002 ("Files Access Anomalies in container"). +// +// Each subtest spins up a fresh nginx pod with a user-defined AP that +// carries ONE Opens entry, then `cat`s a target path that probes a +// boundary case from the storage-side analyzer fixes (kubescape/storage +// PR #316 review by matthyx + entlein): +// +// - Anchored trailing `*` matches one OR MORE remaining segments — +// never zero. So `/etc/*` matches `/etc/passwd` but NOT the bare +// `/etc` directory. Without this rule, R0002 silently allowed +// access to the parent of any profiled directory. +// - DynamicIdentifier (⋯) consumes EXACTLY ONE segment. +// - Mid-path `*` consumes ZERO or more, so `/etc/*/*` still matches +// `/etc/ssh` (inner `*` consumed zero, trailing `*` consumed one). +// - splitPath normalises trailing slashes on both dynamic and +// regular paths so `/etc/passwd/` is treated as `/etc/passwd`. +// - Mixed `⋯/*` patterns: ⋯ pins one segment, `*` consumes the rest +// (with one-or-more semantics). +// +// Component-level pin sits ON TOP of the unit tests in storage's +// pkg/registry/file/dynamicpathdetector/tests/coverage_test.go. +// Both layers must agree — if the unit suite drifts away from these +// runtime expectations, R0002 has either a false-positive or a +// false-negative bug. From 5e2f667b3e24452edc8dee10c01469d64f52a654 Mon Sep 17 00:00:00 2001 From: Entlein Date: Thu, 28 May 2026 11:56:27 +0200 Subject: [PATCH 08/17] build(go.mod): drop sister-branch replace; pin kubescape/storage v0.0.278 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Storage PR kubescape/storage#322 (CompareExecArgs + MatchExecArgs + ExecCalls.ArgsRequired) merged to kubescape/storage main and a release was cut (v0.0.278, 2026-05-28). The k8sstormcenter sister-branch replace is no longer needed — pinning the real tag. Signed-off-by: entlein --- go.mod | 60 ++++++++++++------------ go.sum | 146 ++++++++++++++++++++++++++++++++------------------------- 2 files changed, 112 insertions(+), 94 deletions(-) diff --git a/go.mod b/go.mod index 827e71035..f9e8102d3 100644 --- a/go.mod +++ b/go.mod @@ -5,9 +5,9 @@ go 1.25.8 require ( github.com/DmitriyVTitov/size v1.5.0 github.com/Masterminds/semver/v3 v3.4.0 - github.com/anchore/syft v1.32.0 + github.com/anchore/syft v1.42.3 github.com/aquilax/truncate v1.0.0 - github.com/armosec/armoapi-go v0.0.694 + github.com/armosec/armoapi-go v0.0.696 github.com/armosec/utils-k8s-go v0.0.35 github.com/cenkalti/backoff v2.2.1+incompatible github.com/cenkalti/backoff/v4 v4.3.0 @@ -24,7 +24,7 @@ require ( github.com/facette/natsort v0.0.0-20181210072756-2cd4dd1e2dcb github.com/go-openapi/strfmt v0.23.0 github.com/google/cel-go v0.26.1 - github.com/google/go-containerregistry v0.20.7 + github.com/google/go-containerregistry v0.21.2 github.com/google/uuid v1.6.0 github.com/goradd/maps v1.3.0 github.com/grafana/pyroscope-go v1.2.2 @@ -35,7 +35,7 @@ require ( 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.258 + github.com/kubescape/storage v0.0.278 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 @@ -47,7 +47,7 @@ require ( github.com/prometheus/alertmanager v0.27.0 github.com/prometheus/client_golang v1.23.2 github.com/prometheus/procfs v0.20.1 - github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af + github.com/sirupsen/logrus v1.9.4 github.com/spf13/afero v1.15.0 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 @@ -57,6 +57,7 @@ require ( go.opentelemetry.io/otel v1.43.0 go.opentelemetry.io/otel/exporters/prometheus v0.65.0 go.opentelemetry.io/otel/log v0.19.0 + go.opentelemetry.io/otel/log/logtest v0.19.0 go.opentelemetry.io/otel/metric v1.43.0 go.opentelemetry.io/otel/sdk v1.43.0 go.opentelemetry.io/otel/sdk/metric v1.43.0 @@ -77,7 +78,7 @@ require ( k8s.io/cri-api v0.35.0 k8s.io/kubectl v0.34.1 k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 - modernc.org/sqlite v1.38.2 + modernc.org/sqlite v1.46.1 oras.land/oras-go/v2 v2.6.0 sigs.k8s.io/yaml v1.6.0 ) @@ -107,9 +108,9 @@ require ( github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/sprig/v3 v3.3.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/Microsoft/hcsshim v0.13.0 // indirect + github.com/Microsoft/hcsshim v0.14.0-rc.1 // indirect github.com/OneOfOne/xxhash v1.2.8 // indirect - github.com/ProtonMail/go-crypto v1.3.0 // indirect + github.com/ProtonMail/go-crypto v1.4.0 // indirect github.com/STARRY-S/zip v0.2.3 // indirect github.com/SergJa/jsonhash v0.0.0-20210531165746-fc45f346aa74 // indirect github.com/acobaugh/osrelease v0.1.0 // indirect @@ -129,7 +130,7 @@ require ( github.com/anchore/go-sync v0.0.0-20250714163430-add63db73ad1 // indirect github.com/anchore/go-version v1.2.2-0.20210903204242-51efa5b487c4 // indirect github.com/anchore/packageurl-go v0.1.1-0.20250220190351-d62adb6e1115 // indirect - github.com/anchore/stereoscope v0.1.9 // indirect + github.com/anchore/stereoscope v0.1.22 // indirect github.com/andybalholm/brotli v1.2.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect @@ -167,7 +168,7 @@ require ( github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb // indirect github.com/blang/semver v3.5.1+incompatible // indirect github.com/blang/semver/v4 v4.0.0 // indirect - github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect + github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect github.com/bodgit/plumbing v1.3.0 // indirect github.com/bodgit/sevenzip v1.6.1 // indirect github.com/bodgit/windows v1.0.1 // indirect @@ -182,17 +183,19 @@ require ( github.com/cloudflare/cbpfc v0.0.0-20240920015331-ff978e94500b // indirect github.com/cloudflare/circl v1.6.3 // indirect github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect - github.com/containerd/cgroups/v3 v3.0.5 // indirect + github.com/containerd/cgroups/v3 v3.1.2 // indirect github.com/containerd/containerd v1.7.32 // indirect - github.com/containerd/containerd/api v1.9.0 // indirect + github.com/containerd/containerd/api v1.10.0 // indirect + github.com/containerd/containerd/v2 v2.2.1 // indirect github.com/containerd/continuity v0.4.5 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/fifo v1.1.0 // indirect github.com/containerd/log v0.1.0 // indirect - github.com/containerd/nri v0.9.0 // indirect - github.com/containerd/platforms v0.2.1 // indirect - github.com/containerd/stargz-snapshotter/estargz v0.18.1 // indirect + github.com/containerd/nri v0.11.0 // indirect + github.com/containerd/platforms v1.0.0-rc.2 // indirect + github.com/containerd/plugin v1.0.0 // indirect + github.com/containerd/stargz-snapshotter/estargz v0.18.2 // indirect github.com/containerd/ttrpc v1.2.7 // indirect github.com/containerd/typeurl/v2 v2.2.3 // indirect github.com/containers/common v0.64.2 // indirect @@ -202,16 +205,16 @@ require ( github.com/deitch/magic v0.0.0-20240306090643-c67ab88f10cb // indirect github.com/diskfs/go-diskfs v1.7.0 // indirect github.com/distribution/reference v0.6.0 // indirect - github.com/docker/cli v29.2.0+incompatible // indirect + github.com/docker/cli v29.3.0+incompatible // indirect github.com/docker/distribution v2.8.3+incompatible // indirect github.com/docker/docker v28.5.2+incompatible // indirect - github.com/docker/docker-credential-helpers v0.9.3 // indirect + github.com/docker/docker-credential-helpers v0.9.5 // indirect github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-events v0.0.0-20250114142523-c867878c5e32 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect github.com/elliotchance/phpserialize v1.4.0 // indirect - github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect @@ -223,9 +226,9 @@ require ( github.com/francoispqt/gojay v1.2.13 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect - github.com/gabriel-vasile/mimetype v1.4.10 // indirect + github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/gammazero/deque v1.0.0 // indirect - github.com/github/go-spdx/v2 v2.3.3 // indirect + github.com/github/go-spdx/v2 v2.4.0 // indirect github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect github.com/go-errors/errors v1.5.1 // indirect github.com/go-fonts/liberation v0.3.2 // indirect @@ -248,12 +251,12 @@ require ( github.com/go-openapi/validate v0.24.0 // indirect github.com/go-pdf/fpdf v0.9.0 // indirect github.com/go-restruct/restruct v1.2.0-alpha // indirect - github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/goccy/go-yaml v1.18.0 // indirect github.com/godbus/dbus/v5 v5.2.0 // indirect github.com/gofrs/flock v0.13.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/gohugoio/hashstructure v0.5.0 // indirect + github.com/gohugoio/hashstructure v0.6.0 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect @@ -327,7 +330,7 @@ require ( github.com/muesli/termenv v0.16.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect - github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncw/directio v1.0.5 // indirect github.com/nix-community/go-nix v0.0.0-20250101154619-4bdde671e0a1 // indirect github.com/notaryproject/notation-core-go v1.3.0 // indirect @@ -342,7 +345,7 @@ require ( github.com/olekukonko/tablewriter v1.0.9 // indirect github.com/olvrng/ujson v1.1.0 // indirect github.com/opcoder0/capabilities v0.0.0-20221222060822-17fd73bffd2a // indirect - github.com/opencontainers/runtime-spec v1.2.1 // indirect + github.com/opencontainers/runtime-spec v1.3.0 // indirect github.com/opencontainers/selinux v1.13.1 // indirect github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect github.com/packetcap/go-pcap v0.0.0-20250723190045-d00b185f30b7 // indirect @@ -391,7 +394,7 @@ require ( github.com/stretchr/objx v0.5.2 // indirect github.com/stripe/stripe-go/v74 v74.30.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect - github.com/sylabs/sif/v2 v2.22.0 // indirect + github.com/sylabs/sif/v2 v2.24.0 // indirect github.com/sylabs/squashfs v1.0.6 // indirect github.com/therootcompany/xz v1.0.1 // indirect github.com/ulikunitz/xz v0.5.15 // indirect @@ -405,7 +408,7 @@ require ( github.com/vishvananda/netlink v1.3.1 // indirect github.com/vishvananda/netns v0.0.5 // indirect github.com/wagoodman/go-partybus v0.0.0-20230516145632-8ccac152c651 // indirect - github.com/wagoodman/go-progress v0.0.0-20230925121702-07e42b3cdba0 // indirect + github.com/wagoodman/go-progress v0.0.0-20260303201901-10176f79b2c0 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect @@ -429,7 +432,6 @@ require ( go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 // indirect go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 // indirect - go.opentelemetry.io/otel/log/logtest v0.19.0 // indirect go.opentelemetry.io/otel/sdk/log v0.19.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/zap v1.27.1 // indirect @@ -462,7 +464,7 @@ require ( k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect k8s.io/kubelet v0.35.0 // indirect - modernc.org/libc v1.66.3 // indirect + modernc.org/libc v1.67.6 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect sigs.k8s.io/controller-runtime v0.21.0 // indirect @@ -479,5 +481,3 @@ replace github.com/inspektor-gadget/inspektor-gadget => github.com/matthyx/inspe replace github.com/cilium/ebpf => github.com/matthyx/ebpf v0.0.0-20260421101317-8a32d06def6c replace github.com/anchore/syft => github.com/kubescape/syft v1.32.0-ks.2 - -replace github.com/kubescape/storage => github.com/k8sstormcenter/storage v0.0.240-0.20260527160734-5e39d0018391 diff --git a/go.sum b/go.sum index c1109c8de..a91178585 100644 --- a/go.sum +++ b/go.sum @@ -121,13 +121,13 @@ github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSC github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/Microsoft/hcsshim v0.13.0 h1:/BcXOiS6Qi7N9XqUcv27vkIuVOkBEcWstd2pMlWSeaA= -github.com/Microsoft/hcsshim v0.13.0/go.mod h1:9KWJ/8DgU+QzYGupX4tzMhRQE8h6w90lH6HAaclpEok= +github.com/Microsoft/hcsshim v0.14.0-rc.1 h1:qAPXKwGOkVn8LlqgBN8GS0bxZ83hOJpcjxzmlQKxKsQ= +github.com/Microsoft/hcsshim v0.14.0-rc.1/go.mod h1:hTKFGbnDtQb1wHiOWv4v0eN+7boSWAHyK/tNAaYZL0c= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/OneOfOne/xxhash v1.2.8 h1:31czK/TI9sNkxIKfaUfGlU47BAxQ0ztGgd9vPyqimf8= github.com/OneOfOne/xxhash v1.2.8/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q= -github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= -github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= +github.com/ProtonMail/go-crypto v1.4.0 h1:Zq/pbM3F5DFgJiMouxEdSVY44MVoQNEKp5d5QxIQceQ= +github.com/ProtonMail/go-crypto v1.4.0/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= github.com/STARRY-S/zip v0.2.3 h1:luE4dMvRPDOWQdeDdUxUoZkzUIpTccdKdhHHsQJ1fm4= github.com/STARRY-S/zip v0.2.3/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk= github.com/SergJa/jsonhash v0.0.0-20210531165746-fc45f346aa74 h1:zZX7V5abnOB0VTEFnwYxwbuot0GCZUjQZQpjHKnG1Kk= @@ -177,8 +177,8 @@ github.com/anchore/go-version v1.2.2-0.20210903204242-51efa5b487c4 h1:rmZG77uXgE github.com/anchore/go-version v1.2.2-0.20210903204242-51efa5b487c4/go.mod h1:Bkc+JYWjMCF8OyZ340IMSIi2Ebf3uwByOk6ho4wne1E= github.com/anchore/packageurl-go v0.1.1-0.20250220190351-d62adb6e1115 h1:ZyRCmiEjnoGJZ1+Ah0ZZ/mKKqNhGcUZBl0s7PTTDzvY= github.com/anchore/packageurl-go v0.1.1-0.20250220190351-d62adb6e1115/go.mod h1:KoYIv7tdP5+CC9VGkeZV4/vGCKsY55VvoG+5dadg4YI= -github.com/anchore/stereoscope v0.1.9 h1:Nhvk8g6PRx9ubaJU4asAhD3fGcY5HKXZCDGkxI2e0sI= -github.com/anchore/stereoscope v0.1.9/go.mod h1:YkrCtDgz7A+w6Ggd0yxU9q58CerqQFwYARS+F2RvLQQ= +github.com/anchore/stereoscope v0.1.22 h1:L807G/kk0WZzOCGuRGF7knxMKzwW2PGdbPVRystryd8= +github.com/anchore/stereoscope v0.1.22/go.mod h1:FikPtAb/WnbqwgLHAvQA9O+fWez0K4pbjxzghz++iy4= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= @@ -203,8 +203,8 @@ github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= -github.com/armosec/armoapi-go v0.0.694 h1:LDScWAzikv7mJdDhO+VM0DfNoMhQbhA6do6LWXHRIQs= -github.com/armosec/armoapi-go v0.0.694/go.mod h1:9jAH0g8ZsryhiBDd/aNMX4+n10bGwTx/doWCyyjSxts= +github.com/armosec/armoapi-go v0.0.696 h1:+0Ll7y4oWNaKEO47qbGDFIQLxkSJeKYzylS0FwI84XE= +github.com/armosec/armoapi-go v0.0.696/go.mod h1:9jAH0g8ZsryhiBDd/aNMX4+n10bGwTx/doWCyyjSxts= github.com/armosec/gojay v1.2.17 h1:VSkLBQzD1c2V+FMtlGFKqWXNsdNvIKygTKJI9ysY8eM= github.com/armosec/gojay v1.2.17/go.mod h1:vuvX3DlY0nbVrJ0qCklSS733AWMoQboq3cFyuQW9ybc= github.com/armosec/utils-go v0.0.58 h1:g9RnRkxZAmzTfPe2ruMo2OXSYLwVSegQSkSavOfmaIE= @@ -276,8 +276,8 @@ github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdn github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= -github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= -github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= +github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU= github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs= github.com/bodgit/sevenzip v1.6.1 h1:kikg2pUMYC9ljU7W9SaqHXhym5HyKm8/M/jd31fYan4= @@ -351,12 +351,14 @@ github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/T github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE= github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4= -github.com/containerd/cgroups/v3 v3.0.5 h1:44na7Ud+VwyE7LIoJ8JTNQOa549a8543BmzaJHo6Bzo= -github.com/containerd/cgroups/v3 v3.0.5/go.mod h1:SA5DLYnXO8pTGYiAHXz94qvLQTKfVM5GEVisn4jpins= +github.com/containerd/cgroups/v3 v3.1.2 h1:OSosXMtkhI6Qove637tg1XgK4q+DhR0mX8Wi8EhrHa4= +github.com/containerd/cgroups/v3 v3.1.2/go.mod h1:PKZ2AcWmSBsY/tJUVhtS/rluX0b1uq1GmPO1ElCmbOw= github.com/containerd/containerd v1.7.32 h1:S54xuVcPxeLaYgaRABtpJ2VyVUVsy0IGf7qHBs+sbY8= github.com/containerd/containerd v1.7.32/go.mod h1:jdwD6s/BhV4XVJGrvtziNPVA+83n66TwptVaPKprq4E= -github.com/containerd/containerd/api v1.9.0 h1:HZ/licowTRazus+wt9fM6r/9BQO7S0vD5lMcWspGIg0= -github.com/containerd/containerd/api v1.9.0/go.mod h1:GhghKFmTR3hNtyznBoQ0EMWr9ju5AqHjcZPsSpTKutI= +github.com/containerd/containerd/api v1.10.0 h1:5n0oHYVBwN4VhoX9fFykCV9dF1/BvAXeg2F8W6UYq1o= +github.com/containerd/containerd/api v1.10.0/go.mod h1:NBm1OAk8ZL+LG8R0ceObGxT5hbUYj7CzTmR3xh0DlMM= +github.com/containerd/containerd/v2 v2.2.1 h1:TpyxcY4AL5A+07dxETevunVS5zxqzuq7ZqJXknM11yk= +github.com/containerd/containerd/v2 v2.2.1/go.mod h1:NR70yW1iDxe84F2iFWbR9xfAN0N2F0NcjTi1OVth4nU= github.com/containerd/continuity v0.4.5 h1:ZRoN1sXq9u7V6QoHMcVWGhOwDFqZ4B9i5H6un1Wh0x4= github.com/containerd/continuity v0.4.5/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= @@ -367,12 +369,14 @@ github.com/containerd/fifo v1.1.0 h1:4I2mbh5stb1u6ycIABlBw9zgtlK8viPI9QkQNRQEEmY github.com/containerd/fifo v1.1.0/go.mod h1:bmC4NWMbXlt2EZ0Hc7Fx7QzTFxgPID13eH0Qu+MAb2o= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= -github.com/containerd/nri v0.9.0 h1:jribDJs/oQ95vLO4Yn19HKFYriZGWKiG6nKWjl9Y/x4= -github.com/containerd/nri v0.9.0/go.mod h1:sDRoMy5U4YolsWthg7TjTffAwPb6LEr//83O+D3xVU4= -github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= -github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= -github.com/containerd/stargz-snapshotter/estargz v0.18.1 h1:cy2/lpgBXDA3cDKSyEfNOFMA/c10O1axL69EU7iirO8= -github.com/containerd/stargz-snapshotter/estargz v0.18.1/go.mod h1:ALIEqa7B6oVDsrF37GkGN20SuvG/pIMm7FwP7ZmRb0Q= +github.com/containerd/nri v0.11.0 h1:26mcQwNG58AZn0YkOrlJQ0yxQVmyZooflnVWJTqQrqQ= +github.com/containerd/nri v0.11.0/go.mod h1:bjGTLdUA58WgghKHg8azFMGXr05n1wDHrt3NSVBHiGI= +github.com/containerd/platforms v1.0.0-rc.2 h1:0SPgaNZPVWGEi4grZdV8VRYQn78y+nm6acgLGv/QzE4= +github.com/containerd/platforms v1.0.0-rc.2/go.mod h1:J71L7B+aiM5SdIEqmd9wp6THLVRzJGXfNuWCZCllLA4= +github.com/containerd/plugin v1.0.0 h1:c8Kf1TNl6+e2TtMHZt+39yAPDbouRH9WAToRjex483Y= +github.com/containerd/plugin v1.0.0/go.mod h1:hQfJe5nmWfImiqT1q8Si3jLv3ynMUIBB47bQ+KexvO8= +github.com/containerd/stargz-snapshotter/estargz v0.18.2 h1:yXkZFYIzz3eoLwlTUZKz2iQ4MrckBxJjkmD16ynUTrw= +github.com/containerd/stargz-snapshotter/estargz v0.18.2/go.mod h1:XyVU5tcJ3PRpkA9XS2T5us6Eg35yM0214Y+wvrZTBrY= github.com/containerd/ttrpc v1.2.7 h1:qIrroQvuOL9HQ1X6KHe2ohc7p+HP/0VE6XPU7elJRqQ= github.com/containerd/ttrpc v1.2.7/go.mod h1:YCXHsb32f+Sq5/72xHubdiJRQY9inL4a4ZQrAbN1q9o= github.com/containerd/typeurl/v2 v2.2.3 h1:yNA/94zxWdvYACdYO8zofhrTVuQY73fFU1y++dYSw40= @@ -414,14 +418,14 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= -github.com/docker/cli v29.2.0+incompatible h1:9oBd9+YM7rxjZLfyMGxjraKBKE4/nVyvVfN4qNl9XRM= -github.com/docker/cli v29.2.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/cli v29.3.0+incompatible h1:z3iWveU7h19Pqx7alZES8j+IeFQZ1lhTwb2F+V9SVvk= +github.com/docker/cli v29.3.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= -github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= -github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= +github.com/docker/docker-credential-helpers v0.9.5 h1:EFNN8DHvaiK8zVqFA2DT6BjXE0GzfLOZ38ggPTKePkY= +github.com/docker/docker-credential-helpers v0.9.5/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c= github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= github.com/docker/go-events v0.0.0-20250114142523-c867878c5e32 h1:EHZfspsnLAz8Hzccd67D5abwLiqoqym2jz/jOS39mCk= @@ -442,8 +446,8 @@ github.com/elliotchance/phpserialize v1.4.0 h1:cAp/9+KSnEbUC8oYCE32n2n84BeW8HOY3 github.com/elliotchance/phpserialize v1.4.0/go.mod h1:gt7XX9+ETUcLXbtTKEuyrqW3lcLUAeS/AnGZ2e49TZs= github.com/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab h1:h1UgjJdAAhj+uPL68n7XASS6bU+07ZX1WJvVS2eyoeY= github.com/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab/go.mod h1:GLo/8fDswSAniFG+BFIaiSPcK610jyzgEhWYPQwuQdw= -github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= -github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= +github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= @@ -497,13 +501,17 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= -github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= -github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gammazero/deque v1.0.0 h1:LTmimT8H7bXkkCy6gZX7zNLtkbz4NdS2z8LZuor3j34= github.com/gammazero/deque v1.0.0/go.mod h1:iflpYvtGfM3U8S8j+sZEKIak3SAKYpA5/SQewgfXDKo= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/github/go-spdx/v2 v2.3.3 h1:QI7evnHWEfWkT54eJwkoV/f3a0xD3gLlnVmT5wQG6LE= -github.com/github/go-spdx/v2 v2.3.3/go.mod h1:2ZxKsOhvBp+OYBDlsGnUMcchLeo2mrpEBn2L1C+U3IQ= +github.com/github/go-spdx/v2 v2.4.0 h1:+4IwVwJJbm3rzvrQ6P1nI9BDMcy3la4RchRy5uehV/M= +github.com/github/go-spdx/v2 v2.4.0/go.mod h1:/5rwgS0txhGtRdUZwc02bTglzg6HK3FfuEbECKlK2Sg= +github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= +github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= +github.com/gkampitakis/go-snaps v0.5.20 h1:FGKonEeQPJ12t7RQj6cTPa881fl5c8HYarMLv5vP7sg= +github.com/gkampitakis/go-snaps v0.5.20/go.mod h1:gC3YqxQTPyIXvQrw/Vpt3a8VqR1MO8sVpZFWN4DGwNs= github.com/glebarez/go-sqlite v1.20.3 h1:89BkqGOXR9oRmG58ZrzgoY/Fhy5x0M+/WV48U5zVrZ4= github.com/glebarez/go-sqlite v1.20.3/go.mod h1:u3N6D/wftiAzIOJtZl6BmedqxmmkDfH3q+ihjqxC9u0= github.com/gliderlabs/ssh v0.1.1/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= @@ -580,8 +588,8 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= -github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= -github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= @@ -596,8 +604,8 @@ github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8 github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/gohugoio/hashstructure v0.5.0 h1:G2fjSBU36RdwEJBWJ+919ERvOVqAg9tfcYp47K9swqg= -github.com/gohugoio/hashstructure v0.5.0/go.mod h1:Ser0TniXuu/eauYmrwM4o64EBvySxNzITEOLlm4igec= +github.com/gohugoio/hashstructure v0.6.0 h1:7wMB/2CfXoThFYhdWRGv3u3rUM761Cq29CxUW+NltUg= +github.com/gohugoio/hashstructure v0.6.0/go.mod h1:lapVLk9XidheHG1IQ4ZSbyYrXcaILU1ZEP/+vno5rBQ= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= @@ -667,8 +675,8 @@ github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-containerregistry v0.20.7 h1:24VGNpS0IwrOZ2ms2P1QE3Xa5X9p4phx0aUgzYzHW6I= -github.com/google/go-containerregistry v0.20.7/go.mod h1:Lx5LCZQjLH1QBaMPeGwsME9biPeo1lPx6lbGj/UmzgM= +github.com/google/go-containerregistry v0.21.2 h1:vYaMU4nU55JJGFC9JR/s8NZcTjbE9DBBbvusTW9NeS0= +github.com/google/go-containerregistry v0.21.2/go.mod h1:ctO5aCaewH4AK1AumSF5DPW+0+R+d2FmylMJdp5G7p0= github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -855,8 +863,6 @@ github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHm github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= -github.com/k8sstormcenter/storage v0.0.240-0.20260527160734-5e39d0018391 h1:IIDExlszvZR7ZrEFo4d9awPoIu9arywDIPDng5m527g= -github.com/k8sstormcenter/storage v0.0.240-0.20260527160734-5e39d0018391/go.mod h1:FpV6tCrYXlp2kKWza4yr7zf2Y1q7IGgx871ndN7SMNo= github.com/kastenhq/goversion v0.0.0-20230811215019-93b2f8823953 h1:WdAeg/imY2JFPc/9CST4bZ80nNJbiBFCAdSZCSgrS5Y= github.com/kastenhq/goversion v0.0.0-20230811215019-93b2f8823953/go.mod h1:6o+UrvuZWc4UTyBhQf0LGjW9Ld7qJxLz/OqvSOWWlEc= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= @@ -891,8 +897,8 @@ github.com/kubescape/go-logger v0.0.32 h1:4mI+XJOV8VFCMewrEE9VIFEIOhzXokYT3nFpNf 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.258 h1:0mL0z3dAmtP1qup7VgoEgwLgbBSROu5oOusBAPeMmus= -github.com/kubescape/storage v0.0.258/go.mod h1:VHs+xQzvZKE2lJDN8rR1sFmTa43N6XJAcatZ249gviU= +github.com/kubescape/storage v0.0.278 h1:/pOtKul443yb2Fzg/4MFq29oOaFoJg1okQaCGbcEVOk= +github.com/kubescape/storage v0.0.278/go.mod h1:FpV6tCrYXlp2kKWza4yr7zf2Y1q7IGgx871ndN7SMNo= 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= @@ -917,6 +923,8 @@ github.com/mailru/easyjson v0.0.0-20190312143242-1de009706dbe/go.mod h1:C1wdFJiN github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= +github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/matthyx/ebpf v0.0.0-20260421101317-8a32d06def6c h1:ZCCeIMu86h4NhF0UfSm9Kdy1AHVWPogk86MdQD6OvPM= github.com/matthyx/ebpf v0.0.0-20260421101317-8a32d06def6c/go.mod h1:pzLjFymM+uZPLk/IXZUL63xdx5VXEo+enTzxkZXdycw= github.com/matthyx/inspektor-gadget v0.0.0-20260513134836-aa8a4c2613db h1:li+4y/XuMY5X4ICzp4cGdFE5eQzYae6KRAkIUsZkeFE= @@ -1035,8 +1043,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8m github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= -github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= -github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncw/directio v1.0.5 h1:JSUBhdjEvVaJvOoyPAbcW0fnd0tvRXD76wEfZ1KcQz4= github.com/ncw/directio v1.0.5/go.mod h1:rX/pKEYkOXBGOggmcyJeJGloCkleSvphPx2eV3t6ROk= github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo= @@ -1083,10 +1091,10 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= -github.com/opencontainers/runtime-spec v1.2.1 h1:S4k4ryNgEpxW1dzyqffOmhI1BHYcjzU8lpJfSlR0xww= -github.com/opencontainers/runtime-spec v1.2.1/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= -github.com/opencontainers/runtime-tools v0.9.1-0.20250523060157-0ea5ed0382a2 h1:2xZEHOdeQBV6PW8ZtimN863bIOl7OCW/X10K0cnxKeA= -github.com/opencontainers/runtime-tools v0.9.1-0.20250523060157-0ea5ed0382a2/go.mod h1:MXdPzqAA8pHC58USHqNCSjyLnRQ6D+NjbpP+02Z1U/0= +github.com/opencontainers/runtime-spec v1.3.0 h1:YZupQUdctfhpZy3TM39nN9Ika5CBWT5diQ8ibYCRkxg= +github.com/opencontainers/runtime-spec v1.3.0/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= +github.com/opencontainers/runtime-tools v0.9.1-0.20251114084447-edf4cb3d2116 h1:tAKu3NkKWZYpqBSOJKwTxT1wIGueiF7gcmcNgr5pNTY= +github.com/opencontainers/runtime-tools v0.9.1-0.20251114084447-edf4cb3d2116/go.mod h1:DKDEfzxvRkoQ6n9TGhxQgg2IM1lY4aM0eaQP4e3oElw= github.com/opencontainers/selinux v1.13.1 h1:A8nNeceYngH9Ow++M+VVEwJVpdFmrlxsN22F+ISDCJE= github.com/opencontainers/selinux v1.13.1/go.mod h1:S10WXZ/osk2kWOYKy1x2f/eXF5ZHJoUs8UU/2caNRbg= github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:FfH+VrHHk6Lxt9HdVS0PXzSXFyS2NbZKXv33FYPol0A= @@ -1198,8 +1206,8 @@ github.com/sassoftware/go-rpmutils v0.4.0/go.mod h1:3goNWi7PGAT3/dlql2lv3+MSN5jN github.com/scylladb/go-set v1.0.3-0.20200225121959-cc7b2070d91e h1:7q6NSFZDeGfvvtIRwBrU/aegEYJYmvev0cHAwo17zZQ= github.com/scylladb/go-set v1.0.3-0.20200225121959-cc7b2070d91e/go.mod h1:DkpGd78rljTxKAnTDPFqXSGxvETQnJyuSOQwsHycqfs= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= -github.com/sebdah/goldie/v2 v2.7.1 h1:PkBHymaYdtvEkZV7TmyqKxdmn5/Vcj+8TpATWZjnG5E= -github.com/sebdah/goldie/v2 v2.7.1/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI= +github.com/sebdah/goldie/v2 v2.8.0 h1:dZb9wR8q5++oplmEiJT+U/5KyotVD+HNGCAc5gNr8rc= +github.com/sebdah/goldie/v2 v2.8.0/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI= github.com/seccomp/libseccomp-golang v0.11.0 h1:SDkcBRqGLP+sezmMACkxO1EfgbghxIxnRKfd6mHUEis= github.com/seccomp/libseccomp-golang v0.11.0/go.mod h1:5m1Lk8E9OwgZTTVz4bBOer7JuazaBa+xTkM895tDiWc= github.com/secure-systems-lab/go-securesystemslib v0.10.0 h1:l+H5ErcW0PAehBNrBxoGv1jjNpGYdZ9RcheFkB2WI14= @@ -1242,8 +1250,8 @@ github.com/sigstore/sigstore v1.10.4/go.mod h1:tDiyrdOref3q6qJxm2G+JHghqfmvifB7h github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af h1:Sp5TG9f7K39yfB+If0vjp97vuT74F72r8hfRpP8jLU0= -github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik= @@ -1304,8 +1312,8 @@ github.com/stripe/stripe-go/v74 v74.30.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaB github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/sylabs/sif/v2 v2.22.0 h1:Y+xXufp4RdgZe02SR3nWEg7S6q4tPWN237WHYzkDSKA= -github.com/sylabs/sif/v2 v2.22.0/go.mod h1:W1XhWTmG1KcG7j5a3KSYdMcUIFvbs240w/MMVW627hs= +github.com/sylabs/sif/v2 v2.24.0 h1:1wB5uMDUQYjk8AckTySaDcP9YnpMb1LyDRr1Jt9A10w= +github.com/sylabs/sif/v2 v2.24.0/go.mod h1:DbXWqWZ1hdLSU+K9ipdds5AmZeHWsyxCOj/oQakBa88= github.com/sylabs/squashfs v1.0.6 h1:PvJcDzxr+vIm2kH56mEMbaOzvGu79gK7P7IX+R7BDZI= github.com/sylabs/squashfs v1.0.6/go.mod h1:DlDeUawVXLWAsSRa085Eo0ZenGzAB32JdAUFaB0LZfE= github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA= @@ -1313,6 +1321,14 @@ github.com/terminalstatic/go-xsd-validate v0.1.6 h1:TenYeQ3eY631qNi1/cTmLH/s2slH github.com/terminalstatic/go-xsd-validate v0.1.6/go.mod h1:18lsvYFofBflqCrvo1umpABZ99+GneNTw2kEEc8UPJw= github.com/therootcompany/xz v1.0.1 h1:CmOtsn1CbtmyYiusbfmhmkpAAETj0wBIH6kCYaX+xzw= github.com/therootcompany/xz v1.0.1/go.mod h1:3K3UH1yCKgBneZYhuQUvJ9HPD19UEXEI0BWbMn8qNMY= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= @@ -1339,8 +1355,8 @@ github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zd github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/wagoodman/go-partybus v0.0.0-20230516145632-8ccac152c651 h1:jIVmlAFIqV3d+DOxazTR9v+zgj8+VYuQBzPgBZvWBHA= github.com/wagoodman/go-partybus v0.0.0-20230516145632-8ccac152c651/go.mod h1:b26F2tHLqaoRQf8DywqzVaV1MQ9yvjb0OMcNl7Nxu20= -github.com/wagoodman/go-progress v0.0.0-20230925121702-07e42b3cdba0 h1:0KGbf+0SMg+UFy4e1A/CPVvXn21f1qtWdeJwxZFoQG8= -github.com/wagoodman/go-progress v0.0.0-20230925121702-07e42b3cdba0/go.mod h1:jLXFoL31zFaHKAAyZUh+sxiTDFe1L1ZHrcK2T1itVKA= +github.com/wagoodman/go-progress v0.0.0-20260303201901-10176f79b2c0 h1:EHsPe0Q0ANoLOZff1dBLAyeWLTA4sbPTpGI+2zb0FnM= +github.com/wagoodman/go-progress v0.0.0-20260303201901-10176f79b2c0/go.mod h1:g/D9uEUFp5YLyciwCpVsSOZOm56hfv4rzGJod6MlqIM= github.com/weaveworks/procspy v0.0.0-20150706124340-cb970aa190c3 h1:UC4iN/yCDCObTBhKzo34/R2U6qptTPmqbzG6UiQVMUQ= github.com/weaveworks/procspy v0.0.0-20150706124340-cb970aa190c3/go.mod h1:cJTfuBcxkdbj8Mabk4PPdaf0AXv9TYEJmkFxKcWxYY4= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= @@ -2143,18 +2159,20 @@ k8s.io/kubelet v0.35.0 h1:8cgJHCBCKLYuuQ7/Pxb/qWbJfX1LXIw7790ce9xHq7c= k8s.io/kubelet v0.35.0/go.mod h1:ciRzAXn7C4z5iB7FhG1L2CGPPXLTVCABDlbXt/Zz8YA= k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 h1:kBawHLSnx/mYHmRnNUf9d4CpjREbeZuxoSGOX/J+aYM= k8s.io/utils v0.0.0-20260319190234-28399d86e0b5/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= -modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM= -modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= -modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= -modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= -modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM= -modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= +modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= +modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ= -modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8= +modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= +modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= @@ -2163,8 +2181,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek= -modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= +modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= +modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= From e8a4605c0adc77bb5526cbc2432c93a75668b373 Mon Sep 17 00:00:00 2001 From: Entlein Date: Thu, 28 May 2026 14:20:59 +0200 Subject: [PATCH 09/17] fix(tests): de-duplicate Test_32 / file-header / package decl Previous Test_32 add appended the full umbrella component_test.go on top of the legitimate one, producing two //go:build directives, two package clauses, two import blocks and a triplicated Test_32 body. gofmt -e rejected the file at lines 1574, 1576, 4978, 5206. Truncates to the last legitimate line and re-appends only the Test_32 function from the umbrella branch. File parses clean, single instance of each top-level element. Resolves matthyx blocker (1) on PR #807 (2026-05-28). Signed-off-by: entlein --- tests/component_test.go | 3634 --------------------------------------- 1 file changed, 3634 deletions(-) diff --git a/tests/component_test.go b/tests/component_test.go index c95ee423d..b282c6b9c 100644 --- a/tests/component_test.go +++ b/tests/component_test.go @@ -1569,3640 +1569,6 @@ func Test_24_ProcessTreeDepthTest(t *testing.T) { t.Logf("Found alerts for the process tree depth: %v", alerts) } -//go:build component - -package tests - -import ( - "context" - "encoding/json" - "fmt" - "path" - "reflect" - "runtime" - "slices" - "sort" - "strconv" - "strings" - "testing" - "time" - - "github.com/kubescape/go-logger" - "github.com/kubescape/go-logger/helpers" - helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers" - "github.com/kubescape/k8s-interface/k8sinterface" - "github.com/kubescape/node-agent/pkg/signature" - "github.com/kubescape/node-agent/pkg/signature/profiles" - "github.com/kubescape/node-agent/pkg/utils" - "github.com/kubescape/node-agent/tests/testutils" - "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" - spdxv1beta1client "github.com/kubescape/storage/pkg/generated/clientset/versioned/typed/softwarecomposition/v1beta1" - "github.com/kubescape/storage/pkg/registry/file/dynamicpathdetector" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - "k8s.io/utils/ptr" -) - -func tearDownTest(t *testing.T, startTime time.Time) { - end := time.Now() - - t.Log("Waiting 30 seconds for Prometheus to scrape the data") - time.Sleep(30 * time.Second) - - err := testutils.PlotNodeAgentPrometheusCPUUsage(t.Name(), startTime, end) - require.NoError(t, err, "Error plotting CPU usage") - - _, err = testutils.PlotNodeAgentPrometheusMemoryUsage(t.Name(), startTime, end) - require.NoError(t, err, "Error plotting memory usage") - - testutils.PrintAppLogs(t, "node-agent") - testutils.PrintAppLogs(t, "malicious-app") - testutils.PrintAppLogs(t, "endpoint-traffic") -} - -func Test_01_BasicAlertTest(t *testing.T) { - start := time.Now() - defer tearDownTest(t, start) - - ns := testutils.NewRandomNamespace() - wl, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/deployment-multiple-containers.yaml")) - require.NoError(t, err, "Error creating workload") - require.NoError(t, wl.WaitForReady(80)) - - time.Sleep(10 * time.Second) - - // process launched from nginx container - _, _, err = wl.ExecIntoPod([]string{"ls", "-l"}, "nginx") - - // network activity from server container - _, _, err = wl.ExecIntoPod([]string{"wget", "ebpf.io", "-T", "2", "-t", "1"}, "server") - - // network activity from nginx container - _, _, err = wl.ExecIntoPod([]string{"curl", "kubernetes.io", "-m", "2"}, "nginx") - - err = wl.WaitForApplicationProfileCompletion(80) - require.NoError(t, err, "Error waiting for application profile to be completed") - err = wl.WaitForNetworkNeighborhoodCompletion(80) - require.NoError(t, err, "Error waiting for network neighborhood to be completed") - - time.Sleep(30 * time.Second) - - appProfile, _ := wl.GetApplicationProfile() - appProfileJson, _ := json.Marshal(appProfile) - - networkNeighborhood, _ := wl.GetNetworkNeighborhood() - networkNeighborhoodJson, _ := json.Marshal(networkNeighborhood) - - t.Logf("network neighborhood: %v", string(networkNeighborhoodJson)) - - t.Logf("application profile: %v", string(appProfileJson)) - - _, _, err = wl.ExecIntoPod([]string{"ls", "-l"}, "nginx") // no alert expected - _, _, err = wl.ExecIntoPod([]string{"ls", "-l"}, "server") // alert expected - _, _, err = wl.ExecIntoPod([]string{"wget", "ebpf.io", "-T", "2", "-t", "1"}, "server") // no alert expected - _, _, err = wl.ExecIntoPod([]string{"curl", "ebpf.io", "-m", "2"}, "nginx") // alert expected - - // Wait for the alert to be signaled - time.Sleep(30 * time.Second) - - alerts, err := testutils.GetAlerts(wl.Namespace) - require.NoError(t, err, "Error getting alerts") - - testutils.AssertContains(t, alerts, "Unexpected process launched", "ls", "server", []bool{true}) - testutils.AssertNotContains(t, alerts, "Unexpected process launched", "ls", "nginx", []bool{true}) - - testutils.AssertContains(t, alerts, "DNS Anomalies in container", "curl", "nginx", []bool{true}) - testutils.AssertNotContains(t, alerts, "DNS Anomalies in container", "wget", "server", []bool{true}) - - // Verify UID fields are populated in alerts - testutils.AssertUIDFieldsPopulated(t, alerts, wl.Namespace) - - // check network neighborhood - nn, _ := wl.GetNetworkNeighborhood() - testutils.AssertNetworkNeighborhoodContains(t, nn, "nginx", []string{"kubernetes.io."}, []string{}) - testutils.AssertNetworkNeighborhoodNotContains(t, nn, "server", []string{"kubernetes.io."}, []string{}) - - testutils.AssertNetworkNeighborhoodContains(t, nn, "server", []string{"ebpf.io."}, []string{}) - testutils.AssertNetworkNeighborhoodNotContains(t, nn, "nginx", []string{"ebpf.io."}, []string{}) -} - -func Test_02_AllAlertsFromMaliciousApp(t *testing.T) { - start := time.Now() - defer tearDownTest(t, start) - - // Create a random namespace - ns := testutils.NewRandomNamespace() - - // Create a workload - wl, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/malicious-job.yaml")) - require.NoError(t, err, "Error creating workload") - - // Wait for the workload to be ready - err = wl.WaitForReady(80) - require.NoError(t, err, "Error waiting for workload to be ready") - - // Wait for the application profile to be created and completed - err = wl.WaitForApplicationProfileCompletion(150) - require.NoError(t, err, "Error waiting for application profile to be completed") - - // Wait for the alerts to be generated - time.Sleep(2 * time.Minute) - - // Get all the alerts for the namespace - alerts, err := testutils.GetAlerts(wl.Namespace) - require.NoError(t, err, "Error getting alerts") - - // Validate that all alerts are signaled - expectedAlerts := map[string]bool{ - "Unexpected process launched": false, - "Files Access Anomalies in container": false, - "Syscalls Anomalies in container": false, - "Linux Capabilities Anomalies in container": false, - "Workload uses Kubernetes API unexpectedly": false, - "Process executed from malicious source": false, - "Process tries to load a kernel module": false, - "Drifted process executed": false, - "Process executed from mount": false, - "Unexpected service account token access": false, - "DNS Anomalies in container": false, - "Crypto Mining Related Port Communication": false, - "Crypto Mining Domain Communication": false, - } - - expectedFailOnProfile := map[string][]bool{ - "Unexpected process launched": {true}, - "Files Access Anomalies in container": {true}, - "Syscalls Anomalies in container": {true}, - "Linux Capabilities Anomalies in container": {true}, - "Workload uses Kubernetes API unexpectedly": {true}, - "Process executed from malicious source": {false}, - "Process tries to load a kernel module": {false}, - "Drifted process executed": {true}, - "Process executed from mount": {true}, - "Unexpected service account token access": {true}, - "DNS Anomalies in container": {true}, - "Crypto Mining Related Port Communication": {true}, - "Crypto Mining Domain Communication": {false}, - } - - for _, alert := range alerts { - ruleName, ruleOk := alert.Labels["rule_name"] - failOnProfile, failOnProfileOk := alert.Labels["fail_on_profile"] - failOnProfileBool, err := strconv.ParseBool(failOnProfile) - require.NoError(t, err, "Error parsing fail_on_profile") - if ruleOk && failOnProfileOk { - if _, exists := expectedAlerts[ruleName]; exists && slices.Contains(expectedFailOnProfile[ruleName], failOnProfileBool) { - expectedAlerts[ruleName] = true - } - } - } - - for ruleName, signaled := range expectedAlerts { - assert.Truef(t, signaled, "Expected alert '%s' was not signaled", ruleName) - } -} - -func Test_03_BasicLoadActivities(t *testing.T) { - start := time.Now() - defer tearDownTest(t, start) - - // Create a random namespace - ns := testutils.NewRandomNamespace() - - // Create a workload - wl, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/nginx-deployment.yaml")) - require.NoError(t, err, "Error creating workload") - - // Wait for the workload to be ready - err = wl.WaitForReady(80) - require.NoError(t, err, "Error waiting for workload to be ready") - - // Wait for the application profile to be created and completed - err = wl.WaitForApplicationProfileCompletion(80) - require.NoError(t, err, "Error waiting for application profile to be completed") - - // Create loader - loader, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/locust-deployment.yaml")) - require.NoError(t, err) - err = loader.WaitForReady(80) - require.NoError(t, err, "Error waiting for workload to be ready") - - loadStart := time.Now() - - // Create a load of 5 minutes - time.Sleep(5 * time.Minute) - - loadEnd := time.Now() - - // Get CPU usage of Node Agent pods - podToCpuUsage, err := testutils.GetNodeAgentAverageCPUUsage(loadStart, loadEnd) - require.NoError(t, err, "Error getting CPU usage") - - require.NotEqual(t, 0, podToCpuUsage, "No CPU usage data found") - - for pod, cpuUsage := range podToCpuUsage { - assert.LessOrEqual(t, cpuUsage, 0.4, "CPU usage of Node Agent is too high. CPU usage is %f, Pod: %s", cpuUsage, pod) - } -} - -func Test_04_MemoryLeak(t *testing.T) { - start := time.Now() - defer tearDownTest(t, start) - - // Create a random namespace - ns := testutils.NewRandomNamespace() - - // Create 2 workloads - wlPaths := []string{ - "resources/locust-deployment.yaml", - "resources/nginx-deployment.yaml", - } - var workloads []testutils.TestWorkload - for _, p := range wlPaths { - wl, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), p)) - require.NoError(t, err, "Error creating deployment") - workloads = append(workloads, *wl) - } - for _, wl := range workloads { - err := wl.WaitForReady(80) - require.NoError(t, err, "Error waiting for workload to be ready") - err = wl.WaitForApplicationProfileCompletion(80) - require.NoError(t, err, "Error waiting for application profile to be completed") - } - - // Wait for 60 seconds for the GC to run, so the memory leak can be detected - time.Sleep(60 * time.Second) - - metrics, err := testutils.PlotNodeAgentPrometheusMemoryUsage("memleak_basic", start, time.Now()) - require.NoError(t, err, "Error plotting memory usage") - - require.NotEqual(t, 0, metrics, "No memory usage data found") - - for _, metric := range metrics { - podName := metric.Name - firstValue := metric.Values[0] - lastValue := metric.Values[len(metric.Values)-1] - - // Validate that there is no memory leak, but tolerate 100Mb memory leak - tolerateMb := 100 - assert.LessOrEqual(t, lastValue, firstValue+float64(tolerateMb*1024*1024), "Memory leak detected in node-agent pod (%s). Memory usage at the end of the test is %f and at the beginning of the test is %f", podName, lastValue, firstValue) - } -} - -func Test_05_MemoryLeak_10K_Alerts(t *testing.T) { - start := time.Now() - defer tearDownTest(t, start) - - // Create a random namespace - ns := testutils.NewRandomNamespace() - - // Create nginx workload - nginx, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/nginx-deployment.yaml")) - require.NoError(t, err, "Error creating workload") - err = nginx.WaitForReady(80) - require.NoError(t, err, "Error waiting for workload to be ready") - - err = nginx.WaitForApplicationProfileCompletion(80) - require.NoError(t, err, "Error waiting for application profile to be completed") - - // wait for 300 seconds for the GC to run, so the memory leak can be detected - t.Log("Waiting 300 seconds to have a baseline memory usage") - time.Sleep(300 * time.Second) - - //Exec into the nginx pod and create a file in the /tmp directory in a loop - startLoad := time.Now() - for i := 0; i < 100; i++ { - _, _, err := nginx.ExecIntoPod([]string{"bash", "-c", "for i in {1..100}; do touch /tmp/nginx-test-$i; done"}, "") - require.NoError(t, err, "Error executing remote command") - if i%5 == 0 { - t.Logf("Created file %d times", (i+1)*100) - } - } - - // wait for 300 seconds for the GC to run, so the memory leak can be detected - t.Log("Waiting 300 seconds to GC to run") - time.Sleep(300 * time.Second) - - metrics, err := testutils.PlotNodeAgentPrometheusMemoryUsage("memleak_10k_alerts", startLoad, time.Now()) - require.NoError(t, err, "Error plotting memory usage") - - require.NotEqual(t, 0, metrics, "No memory usage data found") - - for _, metric := range metrics { - podName := metric.Name - firstValue := metric.Values[0] - lastValue := metric.Values[len(metric.Values)-1] - - // Validate that there is no memory leak, but tolerate 40mb memory leak - tolerateMb := 40 - assert.LessOrEqual(t, lastValue, firstValue+float64(tolerateMb*1024*1024), "Memory leak detected in node-agent pod (%s). Memory usage at the end of the test is %f and at the beginning of the test is %f", podName, lastValue, firstValue) - } -} - -func Test_06_KillProcessInTheMiddle(t *testing.T) { - start := time.Now() - defer tearDownTest(t, start) - - // Create a random namespace - ns := testutils.NewRandomNamespace() - // Create nginx deployment - nginx, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/nginx-deployment.yaml")) - require.NoError(t, err, "Error creating workload") - err = nginx.WaitForReady(80) - require.NoError(t, err, "Error waiting for workload to be ready") - - // Give time for the nginx application profile to be ready - require.NoError(t, nginx.WaitForApplicationProfile(80, "ready")) - - // Exec into the nginx pod and kill the process - _, _, err = nginx.ExecIntoPod([]string{"bash", "-c", "kill -9 1"}, "") - require.NoError(t, err, "Error executing remote command") - - // Wait for the application profile to be 'completed' - err = nginx.WaitForApplicationProfileCompletion(20) - require.NoError(t, err, "Error waiting for application profile to be completed") -} - -func Test_07_RuleBindingApplyTest(t *testing.T) { - ruleBindingPath := func(name string) string { - return path.Join(utils.CurrentDir(), "resources/rulebindings", name) - } - - // valid - exitCode := testutils.RunCommand("kubectl", "apply", "--validate=false", "-f", ruleBindingPath("all-valid.yaml")) - assert.Equal(t, 0, exitCode, "Error applying valid rule binding") - exitCode = testutils.RunCommand("kubectl", "delete", "-f", ruleBindingPath("all-valid.yaml")) - require.Equal(t, 0, exitCode, "Error deleting valid rule binding") - - // duplicate fields - file := ruleBindingPath("dup-fields-name-tag.yaml") - exitCode = testutils.RunCommand("kubectl", "apply", "--validate=false", "-f", file) - assert.NotEqualf(t, 0, exitCode, "Expected error when applying rule binding '%s'", file) - - file = ruleBindingPath("dup-fields-name-id.yaml") - exitCode = testutils.RunCommand("kubectl", "apply", "--validate=false", "-f", file) - assert.NotEqualf(t, 0, exitCode, "Expected error when applying rule binding '%s'", file) - - file = ruleBindingPath("dup-fields-id-tag.yaml") - exitCode = testutils.RunCommand("kubectl", "apply", "--validate=false", "-f", file) - assert.NotEqualf(t, 0, exitCode, "Expected error when applying rule binding '%s'", file) -} - -func Test_08_ApplicationProfilePatching(t *testing.T) { - k8sClient := k8sinterface.NewKubernetesApi() - storageclient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) - - t.Log("Creating namespace") - ns := testutils.NewRandomNamespace() - - name := "replicaset-checkoutservice-59596bf8d8" - applicationProfile := &v1beta1.ApplicationProfile{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, - Labels: map[string]string{ - "kubescape.io/instance-template-hash": "59596bf8d8", - "kubescape.io/workload-api-group": "apps", - "kubescape.io/workload-api-version": "v1", - "kubescape.io/workload-kind": "Deployment", - "kubescape.io/workload-name": "checkoutservice", - "kubescape.io/workload-namespace": "node-agent-test-veum", - "kubescape.io/workload-resource-version": "667544", - }, - Annotations: map[string]string{ - "kubescape.io/completion": "complete", - "kubescape.io/status": "initializing", - }, - }, - Spec: v1beta1.ApplicationProfileSpec{ - Containers: []v1beta1.ApplicationProfileContainer{ - { - Name: "server", - Syscalls: []string{ - "capget", "capset", "chdir", "close", "epoll_ctl", "faccessat2", - "fcntl", "fstat", "fstatfs", "futex", "getdents64", "getppid", - "nanosleep", "newfstatat", "openat", "prctl", "read", "setgid", - "setgroups", "setuid", "write", - }, - }, - }, - }, - Status: v1beta1.ApplicationProfileStatus{}, - } - - _, err := storageclient.ApplicationProfiles(ns.Name).Create(context.TODO(), applicationProfile, metav1.CreateOptions{}) - require.NoError(t, err) - - // patch the application profile - patchOperations := []utils.PatchOperation{ - {Op: "replace", Path: "/spec/containers/0/capabilities", Value: []string{"NET_ADMIN"}}, - {Op: "add", Path: "/spec/containers/0/capabilities/-", Value: "SETGID"}, - {Op: "add", Path: "/spec/containers/0/capabilities/-", Value: "SETPCAP"}, - {Op: "add", Path: "/spec/containers/0/capabilities/-", Value: "SETUID"}, - {Op: "add", Path: "/spec/containers/0/capabilities/-", Value: "SYS_ADMIN"}, - {Op: "add", Path: "/spec/containers/0/syscalls/-", Value: "accept4"}, - {Op: "add", Path: "/spec/containers/0/syscalls/-", Value: "arch_prctl"}, - {Op: "add", Path: "/spec/containers/0/syscalls/-", Value: "bind"}, - {Op: "replace", Path: "/spec/containers/0/execs", Value: []map[string]interface{}{{ - "path": "/checkoutservice", - "args": []string{"/checkoutservice"}, - }}}, - {Op: "add", Path: "/spec/containers/0/execs/-", Value: map[string]interface{}{ - "path": "/bin/grpc_health_probe", - "args": []string{"/bin/grpc_health_probe", "-addr=:5050"}, - }}, - {Op: "replace", Path: "/metadata/annotations/kubescape.io~1status", Value: "ready"}, - {Op: "replace", Path: "/metadata/annotations/kubescape.io~1completion", Value: "complete"}, - } - - patch, err := json.Marshal(patchOperations) - require.NoError(t, err) - - // TODO use Storage abstraction? - _, err = storageclient.ApplicationProfiles(ns.Name).Patch(context.Background(), name, types.JSONPatchType, patch, v1.PatchOptions{}) - - assert.NoError(t, err) -} - -func Test_09_FalsePositiveTest(t *testing.T) { - start := time.Now() - defer tearDownTest(t, start) - - testutils.IncreaseNodeAgentSniffingTime("10m") - - time.Sleep(5 * time.Second) - - t.Log("Creating namespace") - ns := testutils.NewRandomNamespace() - - t.Log("Creating services") - _, err := testutils.CreateWorkloadsInPath(ns.Name, path.Join(utils.CurrentDir(), "resources/hipster_shop/services")) - require.NoError(t, err, "Error creating services") - - t.Log("Creating deployments") - deployments, err := testutils.CreateWorkloadsInPath(ns.Name, path.Join(utils.CurrentDir(), "resources/hipster_shop/deployments")) - require.NoError(t, err, "Error creating deployments") - - t.Log("Waiting for all workloads to be ready") - for _, wl := range deployments { - err = wl.WaitForReady(80) - require.NoError(t, err, "Error waiting for workload to be ready") - } - t.Log("All workloads are ready") - - t.Log("Waiting for all application profiles to be completed") - for _, wl := range deployments { - err = wl.WaitForApplicationProfileCompletion(80) - require.NoError(t, err, "Error waiting for application profile to be completed") - } - - // wait for 1 minute for the alerts to be generated - time.Sleep(1 * time.Minute) - - require.NoError(t, err, "Error getting pods with restarts") - - alerts, err := testutils.GetAlerts(ns.Name) - require.NoError(t, err, "Error getting alerts") - - // Some rules are structurally noisy on real apps and can't reasonably - // reach zero alerts under an auto-learned baseline: - // - // - R0003 (Syscalls Anomalies): the baseline can never capture - // every syscall a real workload will eventually make (rare - // error paths, late-startup allocations, GC, async I/O). Bob - // chart ships R0003 disabled by default. - // - R0006 (Unexpected service account token access): every pod - // with a service-account legitimately reads - // /var/run/secrets/kubernetes.io/serviceaccount/token to - // authenticate to the K8s API. Hipster-shop services (and the - // prometheus / alertmanager infra the test framework deploys) - // all do this on startup and on every API call. - // - // Test_09's contract is "no FPs on benign workloads under EXEC / - // OPEN / NETWORK / SIGNED-PROFILE rules" — the noisy syscall- and - // SA-token rules are evaluated on their own merits elsewhere (e.g. - // Test_10's 10b subtest pins R0003 firing when the AP declares NO - // syscalls). Filter both out here. - noisyRules := map[string]string{ - "R0003": "Syscalls Anomalies", - "R0006": "SA token access", - } - filtered := alerts[:0] - excluded := map[string]int{} - for _, a := range alerts { - if _, isNoisy := noisyRules[a.Labels["rule_id"]]; isNoisy { - excluded[a.Labels["rule_id"]]++ - continue - } - filtered = append(filtered, a) - } - for ruleID, count := range excluded { - t.Logf("excluded %d %s (%s) alerts from FP gate — structurally noisy on real apps", count, ruleID, noisyRules[ruleID]) - } - if len(filtered) > 0 { - for i, a := range filtered { - t.Logf("unexpected FP[%d]: rule_id=%s rule_name=%s comm=%s container=%s", i, a.Labels["rule_id"], a.Labels["rule_name"], a.Labels["comm"], a.Labels["container_name"]) - } - } - assert.Equal(t, 0, len(filtered), "Expected no non-noisy alerts to be generated, but got %d (excluding %v)", len(filtered), excluded) -} - -// Test_10_CryptoMinerDetection tests crypto-miner detection from two angles: -// - malware_scan: ClamAV file-scanning detects xmrig binary signature -// - empty_profile_rules: empty user-defined AP means every exec/DNS is anomalous, -// so rule-based detection fires immediately without a learning period -func Test_10_MalwareDetectionTest(t *testing.T) { - start := time.Now() - defer tearDownTest(t, start) - - // --------------------------------------------------------------- - // 10a. Malware file-scanning (ClamAV signature match) - // --------------------------------------------------------------- - t.Run("malware_scan", func(t *testing.T) { - ns := testutils.NewRandomNamespace() - - t.Log("Deploy container with malware") - exitCode := testutils.RunCommand("kubectl", "run", "-n", ns.Name, "malware-cryptominer", "--image=quay.io/petr_ruzicka/malware-cryptominer-container:2.0.2") - require.Equalf(t, 0, exitCode, "expected no error when deploying malware container") - - exitCode = testutils.RunCommand("kubectl", "wait", "--for=condition=Ready", "pod", "malware-cryptominer", "-n", ns.Name, "--timeout=300s") - require.Equalf(t, 0, exitCode, "expected no error when waiting for pod to be ready") - - // Wait for application profile to be completed. - time.Sleep(3 * time.Minute) - - _, _, err := testutils.ExecIntoPod("malware-cryptominer", ns.Name, []string{"ls", "-l", "/usr/share/nginx/html/xmrig"}, "") - require.NoErrorf(t, err, "expected no error when executing command in malware container") - - _, _, err = testutils.ExecIntoPod("malware-cryptominer", ns.Name, []string{"/usr/share/nginx/html/xmrig/xmrig"}, "") - - time.Sleep(20 * time.Second) - - alerts, err := testutils.GetMalwareAlerts(ns.Name) - require.NoError(t, err, "Error getting alerts") - - expectedMalwares := []string{ - "Multios.Coinminer.Miner-6781728-2.UNOFFICIAL", - } - - malwaresDetected := map[string]bool{} - for _, alert := range alerts { - podName, podNameOk := alert.Labels["pod_name"] - malwareName, malwareNameOk := alert.Labels["malware_name"] - if podNameOk && malwareNameOk { - if podName == "malware-cryptominer" && slices.Contains(expectedMalwares, malwareName) { - malwaresDetected[malwareName] = true - } - } - } - - assert.Equal(t, len(expectedMalwares), len(malwaresDetected), - "Expected %d malwares to be detected, but got %d", len(expectedMalwares), len(malwaresDetected)) - }) - - // --------------------------------------------------------------- - // 10b. Behavioral rule detection with empty user-defined AP. - // The miner starts immediately; because the AP declares nothing, - // every exec, DNS lookup, and network connection is anomalous. - // - // Expected rules: - // R0001: Unexpected process launched (every exec) - // R0003: Syscalls Anomalies (empty syscall list) - // - // Rules that MAY fire depending on network conditions: - // R0005: DNS Anomalies (requires DNS responses with answers; - // trace_dns drops NXDOMAIN, so behind a firewall these - // won't arrive) - // R1008: Crypto Mining Domain Communication (same DNS dependency) - // R1009: Crypto Mining Related Port Communication (requires TCP - // connectivity to mining pool ports 3333/45700) - // R1007: Crypto miner launched via randomx (amd64 only) - // - // Race condition note: the node-agent fetches the user-defined AP - // from storage asynchronously after detecting the container. Events - // arriving before the fetch completes see profileExists=false, - // causing Required rules (R0001 etc.) to be skipped. The miner's - // initial exec happens during this window — so we must exec into - // the pod AFTER the profile is cached to generate observable exec - // events. - // --------------------------------------------------------------- - t.Run("empty_profile_rules", func(t *testing.T) { - ns := testutils.NewRandomNamespace() - k8sClient := k8sinterface.NewKubernetesApi() - storageClient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) - - // Create an ApplicationProfile with an empty container entry for k8s-miner. - // The container name must match the pod's container so - // GetContainerFromApplicationProfile finds it. With no execs, syscalls, - // opens, or capabilities listed, every operation is anomalous. - ap := &v1beta1.ApplicationProfile{ - ObjectMeta: metav1.ObjectMeta{ - Name: "crypto2", - Namespace: ns.Name, - }, - Spec: v1beta1.ApplicationProfileSpec{ - Containers: []v1beta1.ApplicationProfileContainer{ - {Name: "k8s-miner"}, - }, - }, - } - - _, err := storageClient.ApplicationProfiles(ns.Name).Create( - context.Background(), ap, metav1.CreateOptions{}) - require.NoError(t, err, "create empty AP in storage") - - require.Eventually(t, func() bool { - _, getErr := storageClient.ApplicationProfiles(ns.Name).Get( - context.Background(), "crypto2", v1.GetOptions{}) - return getErr == nil - }, 30*time.Second, 1*time.Second, "empty AP must be stored") - - // Deploy crypto miner with user-defined profile label. - wl, err := testutils.NewTestWorkload(ns.Name, - path.Join(utils.CurrentDir(), "resources/crypto-miner-deployment.yaml")) - require.NoError(t, err) - require.NoError(t, wl.WaitForReady(80)) - t.Log("Crypto miner pod is ready") - - // Wait for node-agent to fetch the user-defined AP from storage and - // cache it. The miner's initial execve races with this fetch, so - // R0001 is skipped for that event. Syscalls keep flowing, so R0003 - // fires once the profile is cached. - time.Sleep(20 * time.Second) - - // Exec into the pod to generate post-profile-load events: - // exec event → R0001 (cat not in empty AP) - // open event → R0002 (/etc/hostname starts with /etc/) - stdout, stderr, execErr := wl.ExecIntoPod([]string{"cat", "/etc/hostname"}, "k8s-miner") - t.Logf("exec cat /etc/hostname: err=%v stdout=%q stderr=%q", execErr, stdout, stderr) - - // Collect alerts — R0001 must appear from the exec above. - var alerts []testutils.Alert - require.Eventually(t, func() bool { - alerts, err = testutils.GetAlerts(ns.Name) - if err != nil || len(alerts) == 0 { - return false - } - for _, a := range alerts { - if a.Labels["rule_id"] == "R0001" { - return true - } - } - return false - }, 120*time.Second, 10*time.Second, "expected R0001 alert from exec with empty AP") - - time.Sleep(15 * time.Second) - alerts, _ = testutils.GetAlerts(ns.Name) - - t.Logf("=== %d alerts ===", len(alerts)) - 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"]) - } - - rulesSeen := map[string]bool{} - for _, a := range alerts { - rulesSeen[a.Labels["rule_id"]] = true - } - - // These rules must fire with an empty AP — every operation is anomalous. - assert.True(t, rulesSeen["R0001"], - "R0001 (Unexpected process launched) must fire — cat exec not in empty AP") - assert.True(t, rulesSeen["R0002"], - "R0002 (Files Access Anomalies) must fire — /etc/hostname not in empty AP opens") - assert.True(t, rulesSeen["R0003"], - "R0003 (Syscalls Anomalies) must fire — miner syscalls not in empty AP") - assert.True(t, rulesSeen["R0004"], - "R0004 (Linux Capabilities Anomalies) must fire — capabilities not in empty AP") - - // DNS/network rules depend on the miner resolving pool domains and - // establishing TCP connections. In sandboxed/firewalled environments - // these won't fire: trace_dns drops NXDOMAIN, and TCP to mining - // ports is blocked. Log what fired for visibility. - for _, entry := range []struct { - id, desc string - }{ - {"R0005", "DNS Anomalies"}, - {"R1007", "Crypto miner launched via randomx"}, - {"R1008", "Crypto Mining Domain Communication"}, - {"R1009", "Crypto Mining Related Port Communication"}, - } { - if rulesSeen[entry.id] { - t.Logf("%s (%s) fired", entry.id, entry.desc) - } - } - }) - - // --------------------------------------------------------------- - // 10c. RandomX detection (R1007) via xmrig benchmark mode. - // Uses --bench 1M which runs RandomX hashing without a pool - // connection, reliably triggering the x86 FPU tracepoint - // that the randomx eBPF gadget monitors. - // x86_64 (amd64) only — the gadget is disabled on arm64. - // --------------------------------------------------------------- - t.Run("randomx_bench", func(t *testing.T) { - if runtime.GOARCH != "amd64" { - t.Skip("randomx tracer is x86_64 only") - } - - ns := testutils.NewRandomNamespace() - - wl, err := testutils.NewTestWorkload(ns.Name, - path.Join(utils.CurrentDir(), "resources/crypto-miner-deployment.yaml")) - require.NoError(t, err) - require.NoError(t, wl.WaitForReady(80)) - t.Log("xmrig benchmark pod is ready, waiting for RandomX FPU events...") - - // xmrig needs ~5s to init the RandomX dataset, then starts hashing. - // The eBPF gadget needs 5 FPU events within 5s to fire. - // Give it 30s total. - var alerts []testutils.Alert - require.Eventually(t, func() bool { - alerts, err = testutils.GetAlerts(ns.Name) - if err != nil || len(alerts) == 0 { - return false - } - for _, a := range alerts { - if a.Labels["rule_id"] == "R1007" { - return true - } - } - return false - }, 120*time.Second, 10*time.Second, "expected R1007 (RandomX crypto miner) from xmrig --bench") - - alerts, _ = testutils.GetAlerts(ns.Name) - t.Logf("=== %d alerts ===", len(alerts)) - 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"]) - } - - rulesSeen := map[string]bool{} - for _, a := range alerts { - rulesSeen[a.Labels["rule_id"]] = true - } - - assert.True(t, rulesSeen["R1007"], - "R1007 (Crypto miner launched via randomx) must fire — xmrig benchmark runs RandomX hashing") - }) -} - -func Test_11_EndpointTest(t *testing.T) { - threshold := 101 - ns := testutils.NewRandomNamespace() - - endpointTraffic, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/endpoint-traffic.yaml")) - require.NoError(t, err, "Error creating workload") - err = endpointTraffic.WaitForReady(80) - require.NoError(t, err, "Error waiting for workload to be ready") - - require.NoError(t, endpointTraffic.WaitForApplicationProfile(80, "ready")) - - // Merge methods - _, _, err = endpointTraffic.ExecIntoPod([]string{"wget", "http://127.0.0.1:80"}, "") - require.NoError(t, err) - _, _, err = endpointTraffic.ExecIntoPod([]string{"wget", "http://127.0.0.1:80", "-O", "/dev/null", "--post-data", "test-data"}, "") // avoid index.html already exists error - - // Merge dynamic - for i := 0; i < threshold; i++ { - _, _, err = endpointTraffic.ExecIntoPod([]string{"wget", fmt.Sprintf("http://127.0.0.1:80/users/%d", i)}, "") - } - - // Wait for dedup cache entries to expire (~2s TTL) so the next requests - // with different headers are not deduplicated before reaching the profile. - time.Sleep(3 * time.Second) - - // Merge headers - _, _, err = endpointTraffic.ExecIntoPod([]string{"wget", "http://127.0.0.1:80/users/99", "--header", "Connection:1234r"}, "") - _, _, err = endpointTraffic.ExecIntoPod([]string{"wget", "http://127.0.0.1:80/users/12", "--header", "Connection:ziz"}, "") - - err = endpointTraffic.WaitForApplicationProfileCompletion(80) - require.NoError(t, err, "Error waiting for application profile to be completed") - - applicationProfile, err := endpointTraffic.GetApplicationProfile() - require.NoError(t, err, "Error getting application profile") - - headers := map[string][]string{"Connection": {"close"}, "Host": {"127.0.0.1:80"}} - rawJSON, err := json.Marshal(headers) - require.NoError(t, err) - - endpoint2 := v1beta1.HTTPEndpoint{ - Endpoint: ":80/", - Methods: []string{"GET", "POST"}, - Internal: false, - Direction: "inbound", - Headers: rawJSON, - } - - headers = map[string][]string{"Host": {"127.0.0.1:80"}, "Connection": {"1234r", "close", "ziz"}} - rawJSON, err = json.Marshal(headers) - require.NoError(t, err) - - endpoint1 := v1beta1.HTTPEndpoint{ - Endpoint: ":80/users/" + dynamicpathdetector.DynamicIdentifier, - Methods: []string{"GET"}, - Internal: false, - Direction: "inbound", - Headers: rawJSON, - } - - savedEndpoints := applicationProfile.Spec.Containers[0].Endpoints - - for i := range savedEndpoints { - - headers := savedEndpoints[i].Headers - var headersMap map[string][]string - err := json.Unmarshal(headers, &headersMap) - require.NoError(t, err, "Error unmarshalling headers") - - if headersMap["Connection"] != nil { - sort.Strings(headersMap["Connection"]) - rawJSON, err = json.Marshal(headersMap) - require.NoError(t, err) - savedEndpoints[i].Headers = rawJSON - } - } - - expectedEndpoints := []v1beta1.HTTPEndpoint{endpoint1, endpoint2} - for _, expectedEndpoint := range expectedEndpoints { - found := false - for _, savedEndpoint := range savedEndpoints { - e := savedEndpoint - sort.Strings(e.Methods) - sort.Strings(expectedEndpoint.Methods) - if reflect.DeepEqual(e, expectedEndpoint) { - found = true - break - } - } - assert.Truef(t, found, "Expected endpoint %v not found in the application profile", expectedEndpoint) - } -} - -func Test_12_MergingProfilesTest(t *testing.T) { - start := time.Now() - defer tearDownTest(t, start) - - // PHASE 1: Setup workload and initial profile - ns := testutils.NewRandomNamespace() - wl, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/deployment-multiple-containers.yaml")) - require.NoError(t, err, "Failed to create workload") - require.NoError(t, wl.WaitForReady(80), "Workload failed to be ready") - // require.NoError(t, wl.WaitForApplicationProfile(80, "ready"), "Application profile not ready") - time.Sleep(10 * time.Second) - - // Generate initial profile data - _, _, err = wl.ExecIntoPod([]string{"ls", "-l"}, "nginx") - require.NoError(t, err, "Failed to exec into nginx container") - _, _, err = wl.ExecIntoPod([]string{"wget", "ebpf.io", "-T", "2", "-t", "1"}, "server") - require.NoError(t, err, "Failed to exec into server container") - - require.NoError(t, wl.WaitForApplicationProfileCompletion(160), "Profile failed to complete") - time.Sleep(10 * time.Second) // Allow profile processing - - // Log initial profile state - initialProfile, err := wl.GetApplicationProfile() - require.NoError(t, err, "Failed to get initial profile") - initialProfileJSON, _ := json.Marshal(initialProfile) - t.Logf("Initial application profile:\n%s", string(initialProfileJSON)) - - // PHASE 2: Verify initial alerts - t.Log("Testing initial alert generation...") - _, _, err = wl.ExecIntoPod([]string{"ls", "-l"}, "nginx") // Expected: no alert - _, _, err = wl.ExecIntoPod([]string{"ls", "-l"}, "server") // Expected: alert - // time.Sleep(2 * time.Minute) // Wait for alert generation - time.Sleep(30 * time.Second) // Wait for alert generation - - initialAlerts, err := testutils.GetAlerts(wl.Namespace) - require.NoError(t, err, "Failed to get initial alerts") - - // Record initial alert count - initialAlertCount := 0 - for _, alert := range initialAlerts { - if ruleName, ok := alert.Labels["rule_name"]; ok && ruleName == "Unexpected process launched" { - initialAlertCount++ - } - } - - testutils.AssertContains(t, initialAlerts, "Unexpected process launched", "ls", "server", []bool{true}) - testutils.AssertNotContains(t, initialAlerts, "Unexpected process launched", "ls", "nginx", []bool{true, false}) - - // PHASE 3: Apply user-managed profile - t.Log("Applying user-managed profile...") - // Create the user-managed profile - userProfile := &v1beta1.ApplicationProfile{ - ObjectMeta: metav1.ObjectMeta{ - Name: fmt.Sprintf("ug-%s", initialProfile.Name), - Namespace: initialProfile.Namespace, - Annotations: map[string]string{ - "kubescape.io/managed-by": "User", - }, - }, - Spec: v1beta1.ApplicationProfileSpec{ - Architectures: []string{"amd64"}, - Containers: []v1beta1.ApplicationProfileContainer{ - { - Name: "nginx", - Execs: []v1beta1.ExecCalls{ - { - Path: "/usr/bin/ls", - Args: []string{"/usr/bin/ls", "-l"}, - }, - }, - SeccompProfile: v1beta1.SingleSeccompProfile{ - Spec: v1beta1.SingleSeccompProfileSpec{ - DefaultAction: "", - }, - }, - }, - { - Name: "server", - Execs: []v1beta1.ExecCalls{ - { - Path: "/bin/ls", - Args: []string{"/bin/ls", "-l"}, - }, - { - Path: "/bin/grpc_health_probe", - Args: []string{"-addr=:9555"}, - }, - }, - SeccompProfile: v1beta1.SingleSeccompProfile{ - Spec: v1beta1.SingleSeccompProfileSpec{ - DefaultAction: "", - }, - }, - }, - }, - }, - } - - // Log the profile we're about to create - userProfileJSON, err := json.MarshalIndent(userProfile, "", " ") - require.NoError(t, err, "Failed to marshal user profile") - t.Logf("Creating user profile:\n%s", string(userProfileJSON)) - - // Get k8s client - k8sClient := k8sinterface.NewKubernetesApi() - - // Create the user-managed profile - storageClient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) - _, err = storageClient.ApplicationProfiles(ns.Name).Create(context.Background(), userProfile, metav1.CreateOptions{}) - require.NoError(t, err, "Failed to create user profile") - - // PHASE 4: Verify merged profile behavior - t.Log("Verifying merged profile behavior...") - time.Sleep(1 * time.Minute) // Allow merge to complete - - // Test merged profile behavior - _, _, err = wl.ExecIntoPod([]string{"ls", "-l"}, "nginx") // Expected: no alert - _, _, err = wl.ExecIntoPod([]string{"ls", "-l"}, "server") // Expected: no alert (user profile should suppress alert) - time.Sleep(1 * time.Minute) // Wait for potential alerts - - // Verify alert counts - finalAlerts, err := testutils.GetAlerts(wl.Namespace) - require.NoError(t, err, "Failed to get final alerts") - - // Only count new alerts (after the initial count) - newAlertCount := 0 - for _, alert := range finalAlerts { - if ruleName, ok := alert.Labels["rule_name"]; ok && ruleName == "Unexpected process launched" { - newAlertCount++ - } - } - - t.Logf("Alert counts - Initial: %d, Final: %d", initialAlertCount, newAlertCount) - - if newAlertCount > initialAlertCount { - t.Logf("Full alert details:") - for _, alert := range finalAlerts { - if ruleName, ok := alert.Labels["rule_name"]; ok && ruleName == "Unexpected process launched" { - t.Logf("Alert: %+v", alert) - } - } - t.Errorf("New alerts were generated after merge (Initial: %d, Final: %d)", initialAlertCount, newAlertCount) - } - - // The new cache doesn't listen to patches - // PHASE 5: Check PATCH (removing the ls command from the user profile of the server container and triggering an alert) - // t.Log("Patching user profile to remove ls command from server container...") - // patchOperations := []utils.PatchOperation{ - // {Op: "remove", Path: "/spec/containers/1/execs/0"}, - // } - - // patch, err := json.Marshal(patchOperations) - // require.NoError(t, err, "Failed to marshal patch operations") - - // _, err = storageClient.ApplicationProfiles(ns.Name).Patch(context.Background(), userProfile.Name, types.JSONPatchType, patch, metav1.PatchOptions{}) - // require.NoError(t, err, "Failed to patch user profile") - - // // Verify patched profile behavior - // time.Sleep(15 * time.Second) // Allow merge to complete - - // // Log the profile that was patched - // patchedProfile, err := wl.GetApplicationProfile() - // require.NoError(t, err, "Failed to get patched profile") - // t.Logf("Patched application profile:\n%v", patchedProfile) - - // // Test patched profile behavior - // wl.ExecIntoPod([]string{"ls", "-l"}, "nginx") // Expected: no alert - // wl.ExecIntoPod([]string{"ls", "-l"}, "server") // Expected: alert (ls command removed from user profile) - // time.Sleep(10 * time.Second) // Wait for potential alerts - - // // Verify alert counts - // finalAlerts, err = testutils.GetAlerts(wl.Namespace) - // require.NoError(t, err, "Failed to get final alerts") - - // // Only count new alerts (after the initial count) - // newAlertCount = 0 - // for _, alert := range finalAlerts { - // if ruleName, ok := alert.Labels["rule_name"]; ok && ruleName == "Unexpected process launched" { - // newAlertCount++ - // } - // } - - // t.Logf("Alert counts - Initial: %d, Final: %d", initialAlertCount, newAlertCount) - - // if newAlertCount <= initialAlertCount { - // t.Logf("Full alert details:") - // for _, alert := range finalAlerts { - // if ruleName, ok := alert.Labels["rule_name"]; ok && ruleName == "Unexpected process launched" { - // t.Logf("Alert: %+v", alert) - // } - // } - // t.Errorf("New alerts were not generated after patch (Initial: %d, Final: %d)", initialAlertCount, newAlertCount) - // } -} - -func Test_13_MergingNetworkNeighborhoodTest(t *testing.T) { - start := time.Now() - defer tearDownTest(t, start) - - // PHASE 1: Setup workload and initial network neighborhood - ns := testutils.NewRandomNamespace() - wl, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/deployment-multiple-containers.yaml")) - require.NoError(t, err, "Failed to create workload") - require.NoError(t, wl.WaitForReady(80), "Workload failed to be ready") - require.NoError(t, wl.WaitForNetworkNeighborhood(80, "ready"), "Network neighborhood not ready") - - // Generate initial network data - _, _, err = wl.ExecIntoPod([]string{"wget", "ebpf.io", "-T", "2", "-t", "1"}, "server") - require.NoError(t, err, "Failed to exec wget in server container") - _, _, err = wl.ExecIntoPod([]string{"curl", "kubernetes.io", "-m", "2"}, "nginx") - require.NoError(t, err, "Failed to exec curl in nginx container") - - require.NoError(t, wl.WaitForNetworkNeighborhoodCompletion(80), "Network neighborhood failed to complete") - time.Sleep(10 * time.Second) // Allow network neighborhood processing - - // Log initial network neighborhood state - initialNN, err := wl.GetNetworkNeighborhood() - require.NoError(t, err, "Failed to get initial network neighborhood") - initialNNJSON, _ := json.Marshal(initialNN) - t.Logf("Initial network neighborhood:\n%s", string(initialNNJSON)) - - // PHASE 2: Verify initial alerts - t.Log("Testing initial alert generation...") - _, _, err = wl.ExecIntoPod([]string{"wget", "ebpf.io", "-T", "2", "-t", "1"}, "server") // Expected: no alert (original rule) - _, _, err = wl.ExecIntoPod([]string{"wget", "httpforever.com", "-T", "2", "-t", "1"}, "server") // Expected: alert (not allowed) - _, _, err = wl.ExecIntoPod([]string{"wget", "httpforever.com", "-T", "2", "-t", "1"}, "server") // Expected: alert (not allowed) - _, _, err = wl.ExecIntoPod([]string{"wget", "httpforever.com", "-T", "2", "-t", "1"}, "server") // Expected: alert (not allowed) - _, _, err = wl.ExecIntoPod([]string{"curl", "kubernetes.io", "-m", "2"}, "nginx") // Expected: no alert (original rule) - _, _, err = wl.ExecIntoPod([]string{"curl", "github.com", "-m", "2"}, "nginx") // Expected: alert (not allowed) - time.Sleep(30 * time.Second) // Wait for alert generation - - initialAlerts, err := testutils.GetAlerts(wl.Namespace) - require.NoError(t, err, "Failed to get initial alerts") - - // Record initial alert count - initialAlertCount := 0 - for _, alert := range initialAlerts { - if ruleName, ok := alert.Labels["rule_name"]; ok && ruleName == "DNS Anomalies in container" && alert.Labels["container_name"] == "server" { - initialAlertCount++ - } - } - - // Verify initial alerts - testutils.AssertContains(t, initialAlerts, "DNS Anomalies in container", "wget", "server", []bool{true}) - testutils.AssertContains(t, initialAlerts, "DNS Anomalies in container", "curl", "nginx", []bool{true}) - - // PHASE 3: Apply user-managed network neighborhood - t.Log("Applying user-managed network neighborhood...") - userNN := &v1beta1.NetworkNeighborhood{ - ObjectMeta: metav1.ObjectMeta{ - Name: fmt.Sprintf("ug-%s", initialNN.Name), - Namespace: initialNN.Namespace, - Annotations: map[string]string{ - "kubescape.io/managed-by": "User", - }, - }, - Spec: v1beta1.NetworkNeighborhoodSpec{ - LabelSelector: metav1.LabelSelector{ - MatchLabels: map[string]string{ - "app": "multiple-containers-app", - }, - }, - Containers: []v1beta1.NetworkNeighborhoodContainer{ - { - Name: "nginx", - Egress: []v1beta1.NetworkNeighbor{ - { - Identifier: "nginx-github", - Type: "external", - DNSNames: []string{"github.com."}, - Ports: []v1beta1.NetworkPort{ - { - Name: "TCP-80", - Protocol: "TCP", - Port: ptr.To(int32(80)), - }, - { - Name: "TCP-443", - Protocol: "TCP", - Port: ptr.To(int32(443)), - }, - }, - }, - }, - }, - { - Name: "server", - Egress: []v1beta1.NetworkNeighbor{ - { - Identifier: "server-example", - Type: "external", - DNSNames: []string{"info.cern.ch."}, - Ports: []v1beta1.NetworkPort{ - { - Name: "TCP-80", - Protocol: "TCP", - Port: ptr.To(int32(80)), - }, - { - Name: "TCP-443", - Protocol: "TCP", - Port: ptr.To(int32(443)), - }, - }, - }, - }, - }, - }, - }, - } - - // Create user-managed network neighborhood - k8sClient := k8sinterface.NewKubernetesApi() - storageClient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) - _, err = storageClient.NetworkNeighborhoods(ns.Name).Create(context.Background(), userNN, metav1.CreateOptions{}) - require.NoError(t, err, "Failed to create user network neighborhood") - - // PHASE 4: Verify merged behavior (no new alerts) - t.Log("Verifying merged network neighborhood behavior...") - time.Sleep(60 * time.Second) // Allow merge to complete - - _, _, err = wl.ExecIntoPod([]string{"wget", "ebpf.io", "-T", "2", "-t", "1"}, "server") // Expected: no alert (original) - // Try multiple times to ensure alert is removed - _, _, err = wl.ExecIntoPod([]string{"wget", "info.cern.ch", "-T", "2", "-t", "1"}, "server") // Expected: no alert (user added) - _, _, err = wl.ExecIntoPod([]string{"wget", "info.cern.ch", "-T", "2", "-t", "1"}, "server") // Expected: no alert (user added) - _, _, err = wl.ExecIntoPod([]string{"wget", "info.cern.ch", "-T", "2", "-t", "1"}, "server") // Expected: no alert (user added) - _, _, err = wl.ExecIntoPod([]string{"wget", "info.cern.ch", "-T", "2", "-t", "1"}, "server") // Expected: no alert (user added) - _, _, err = wl.ExecIntoPod([]string{"curl", "kubernetes.io", "-m", "2"}, "nginx") // Expected: no alert (original) - _, _, err = wl.ExecIntoPod([]string{"curl", "github.com", "-m", "2"}, "nginx") // Expected: no alert (user added) - time.Sleep(30 * time.Second) // Wait for potential alerts - - mergedAlerts, err := testutils.GetAlerts(wl.Namespace) - require.NoError(t, err, "Failed to get alerts after merge") - - // Count new alerts after merge - newAlertCount := 0 - for _, alert := range mergedAlerts { - if ruleName, ok := alert.Labels["rule_name"]; ok && ruleName == "DNS Anomalies in container" && alert.Labels["container_name"] == "server" { - newAlertCount++ - } - } - - t.Logf("Alert counts - Initial: %d, After merge: %d", initialAlertCount, newAlertCount) - - if newAlertCount > initialAlertCount { - t.Logf("Full alert details:") - for _, alert := range mergedAlerts { - if ruleName, ok := alert.Labels["rule_name"]; ok && ruleName == "DNS Anomalies in container" && alert.Labels["container_name"] == "server" { - t.Logf("Alert: %+v", alert) - } - } - t.Errorf("New alerts were generated after merge (Initial: %d, After merge: %d)", initialAlertCount, newAlertCount) - } - - // PHASE 5: Remove permission via patch and verify alerts return - t.Log("Patching user network neighborhood to remove info.cern.ch from server container...") - patchOperations := []utils.PatchOperation{ - {Op: "remove", Path: "/spec/containers/1/egress/0"}, - } - - patch, err := json.Marshal(patchOperations) - require.NoError(t, err, "Failed to marshal patch operations") - - _, err = storageClient.NetworkNeighborhoods(ns.Name).Patch(context.Background(), userNN.Name, types.JSONPatchType, patch, metav1.PatchOptions{}) - require.NoError(t, err, "Failed to patch user network neighborhood") - - time.Sleep(60 * time.Second) // Allow merge to complete - - // Test alerts after patch - _, _, err = wl.ExecIntoPod([]string{"wget", "ebpf.io", "-T", "2", "-t", "1"}, "server") // Expected: no alert - // Try multiple times to ensure alert is removed - _, _, err = wl.ExecIntoPod([]string{"wget", "info.cern.ch", "-T", "2", "-t", "1"}, "server") // Expected: alert (removed) - _, _, err = wl.ExecIntoPod([]string{"wget", "info.cern.ch", "-T", "2", "-t", "1"}, "server") // Expected: alert (removed) - _, _, err = wl.ExecIntoPod([]string{"wget", "info.cern.ch", "-T", "2", "-t", "1"}, "server") // Expected: alert (removed) - _, _, err = wl.ExecIntoPod([]string{"wget", "info.cern.ch", "-T", "2", "-t", "1"}, "server") // Expected: alert (removed) - _, _, err = wl.ExecIntoPod([]string{"wget", "info.cern.ch", "-T", "2", "-t", "1"}, "server") // Expected: alert (removed) - _, _, err = wl.ExecIntoPod([]string{"curl", "kubernetes.io", "-m", "2"}, "nginx") // Expected: no alert - _, _, err = wl.ExecIntoPod([]string{"curl", "github.com", "-m", "2"}, "nginx") // Expected: no alert - time.Sleep(30 * time.Second) // Wait for alerts - - finalAlerts, err := testutils.GetAlerts(wl.Namespace) - require.NoError(t, err, "Failed to get final alerts") - - // Count final alerts - finalAlertCount := 0 - for _, alert := range finalAlerts { - if ruleName, ok := alert.Labels["rule_name"]; ok && ruleName == "DNS Anomalies in container" && alert.Labels["container_name"] == "server" { - finalAlertCount++ - } - } - - t.Logf("Alert counts - Initial: %d, Final: %d", initialAlertCount, finalAlertCount) - - if finalAlertCount <= initialAlertCount { - t.Logf("Full alert details:") - for _, alert := range finalAlerts { - if ruleName, ok := alert.Labels["rule_name"]; ok && ruleName == "DNS Anomalies in container" && alert.Labels["container_name"] == "server" { - t.Logf("Alert: %+v", alert) - } - } - t.Errorf("New alerts were not generated after patch (Initial: %d, Final: %d)", initialAlertCount, finalAlertCount) - } -} - -func Test_14_RulePoliciesTest(t *testing.T) { - ns := testutils.NewRandomNamespace() - - endpointTraffic, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/endpoint-traffic.yaml")) - if err != nil { - t.Errorf("Error creating workload: %v", err) - } - err = endpointTraffic.WaitForReady(80) - if err != nil { - t.Errorf("Error waiting for workload to be ready: %v", err) - } - - // Wait for application profile to be ready - assert.NoError(t, endpointTraffic.WaitForApplicationProfile(80, "ready")) - time.Sleep(10 * time.Second) - - // Add to rule policy symlink - _, _, err = endpointTraffic.ExecIntoPod([]string{"ln", "-s", "/etc/shadow", "/tmp/a"}, "") - assert.NoError(t, err) - - _, _, err = endpointTraffic.ExecIntoPod([]string{"rm", "/tmp/a"}, "") - assert.NoError(t, err) - - // Not add to rule policy - _, _, err = endpointTraffic.ExecIntoPod([]string{"ln", "/bin/sh", "/tmp/a"}, "") - assert.NoError(t, err) - - _, _, err = endpointTraffic.ExecIntoPod([]string{"rm", "/tmp/a"}, "") - assert.NoError(t, err) - - err = endpointTraffic.WaitForApplicationProfileCompletion(80) - if err != nil { - t.Errorf("Error waiting for application profile to be completed: %v", err) - } - - applicationProfile, err := endpointTraffic.GetApplicationProfile() - if err != nil { - t.Errorf("Error getting application profile: %v", err) - } - - symlinkPolicy := applicationProfile.Spec.Containers[0].PolicyByRuleId["R1010"] - assert.Equal(t, []string{"ln"}, symlinkPolicy.AllowedProcesses) - - hardlinkPolicy := applicationProfile.Spec.Containers[0].PolicyByRuleId["R1012"] - assert.Len(t, hardlinkPolicy.AllowedProcesses, 0) - - fmt.Println("After completed....") - - // wait for cache - time.Sleep(40 * time.Second) - - // generate hardlink alert - _, _, err = endpointTraffic.ExecIntoPod([]string{"ln", "/etc/shadow", "/tmp/a"}, "") - _, _, err = endpointTraffic.ExecIntoPod([]string{"rm", "/tmp/a"}, "") - assert.NoError(t, err) - - // not generate alert - _, _, err = endpointTraffic.ExecIntoPod([]string{"ln", "-s", "/etc/shadow", "/tmp/a"}, "") - _, _, err = endpointTraffic.ExecIntoPod([]string{"rm", "/tmp/a"}, "") - assert.NoError(t, err) - - // Wait for the alert to be signaled - time.Sleep(30 * time.Second) - - alerts, err := testutils.GetAlerts(endpointTraffic.Namespace) - if err != nil { - t.Errorf("Error getting alerts: %v", err) - } - - testutils.AssertContains(t, alerts, "Hard link created over sensitive file", "ln", "endpoint-traffic", []bool{true}) - testutils.AssertNotContains(t, alerts, "Soft link created over sensitive file", "ln", "endpoint-traffic", []bool{true}) - - // Also check for learning mode - testutils.AssertContains(t, alerts, "Soft link created over sensitive file", "ln", "endpoint-traffic", []bool{false}) - testutils.AssertNotContains(t, alerts, "Hard link created over sensitive file", "ln", "endpoint-traffic", []bool{false}) - -} - -func Test_15_CompletedApCannotBecomeReadyAgain(t *testing.T) { - k8sClient := k8sinterface.NewKubernetesApi() - storageclient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) - - ns := testutils.NewRandomNamespace() - defer func() { - _ = k8sClient.KubernetesClient.CoreV1().Namespaces().Delete(context.Background(), ns.Name, v1.DeleteOptions{}) - }() - - // create an application profile with completed status - name := "test" - ap1, err := storageclient.ApplicationProfiles(ns.Name).Create(context.TODO(), &v1beta1.ApplicationProfile{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, - Annotations: map[string]string{ - helpersv1.CompletionMetadataKey: helpersv1.Full, - helpersv1.StatusMetadataKey: helpersv1.Completed, - }, - }, - }, v1.CreateOptions{}) - require.NoError(t, err) - require.Equal(t, helpersv1.Completed, ap1.Annotations[helpersv1.StatusMetadataKey]) - - // patch the application profile with ready status - patchOperations := []utils.PatchOperation{ - { - Op: "replace", - Path: "/metadata/annotations/" + utils.EscapeJSONPointerElement(helpersv1.StatusMetadataKey), - Value: helpersv1.Learning, - }, - } - patch, err := json.Marshal(patchOperations) - require.NoError(t, err) - ap2, err := storageclient.ApplicationProfiles(ns.Name).Patch(context.Background(), name, types.JSONPatchType, patch, v1.PatchOptions{}) - assert.NoError(t, err) // patch should succeed - assert.Equal(t, helpersv1.Completed, ap2.Annotations[helpersv1.StatusMetadataKey]) // but the status should not change -} - -func Test_16_ApNotStuckOnRestart(t *testing.T) { - ns := testutils.NewRandomNamespace() - - wl, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/nginx-deployment.yaml")) - require.NoError(t, err, "Error creating workload") - - require.NoError(t, wl.WaitForReady(80)) - - time.Sleep(30 * time.Second) - - _, _, _ = wl.ExecIntoPod([]string{"service", "nginx", "stop"}, "") // suppose to get error - // wl, err = testutils.NewTestWorkloadFromK8sIdentifiers(ns.Name, wl.UnstructuredObj.GroupVersionKind().Kind, "nginx-deployment") - // require.NoError(t, err, "Error re-fetching workload after stop") - // require.NoError(t, wl.WaitForReady(80)) - // require.NoError(t, wl.WaitForApplicationProfileCompletion(160)) - - time.Sleep(160 * time.Second) - - // Wait for cache to be updated - time.Sleep(15 * time.Second) - - _, _, err = wl.ExecIntoPod([]string{"ls", "-l"}, "") - require.NoError(t, err) - - // Wait for the alert to be generated - time.Sleep(30 * time.Second) - - alerts, err := testutils.GetAlerts(wl.Namespace) - require.NoError(t, err, "Error getting alerts") - - testutils.AssertContains(t, alerts, "Unexpected process launched", "ls", "nginx", []bool{true}) -} - -func Test_17_ApCompletedToPartialUpdateTest(t *testing.T) { - ns := testutils.NewRandomNamespace() - - wl, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/nginx-deployment.yaml")) - require.NoError(t, err, "Error creating workload") - - time.Sleep(30 * time.Second) - require.NoError(t, wl.WaitForReady(80)) - require.NoError(t, wl.WaitForNetworkNeighborhood(80, "ready")) - - err = testutils.RestartDaemonSet("kubescape", "node-agent") - require.NoError(t, err, "Error restarting daemonset") - - require.NoError(t, wl.WaitForApplicationProfileCompletion(160)) - require.NoError(t, wl.WaitForNetworkNeighborhoodCompletion(160)) - - time.Sleep(30 * time.Second) - - _, _, err = wl.ExecIntoPod([]string{"sh", "-c", "cat /run/secrets/kubernetes.io/serviceaccount/token >/dev/null"}, "") - require.NoError(t, err) - - time.Sleep(30 * time.Second) - - alerts, err := testutils.GetAlerts(wl.Namespace) - require.NoError(t, err, "Error getting alerts") - - testutils.AssertContains(t, alerts, "Unexpected service account token access", "cat", "nginx", []bool{true}) -} - -func Test_18_ShortLivedJobTest(t *testing.T) { - ns := testutils.NewRandomNamespace() - - // Create a short-lived job - wl, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/echo-job.yaml")) - require.NoError(t, err, "Error creating workload") - - // Application profile should be created and completed - err = wl.WaitForApplicationProfileCompletion(80) - require.NoError(t, err, "Error waiting for application profile to be completed") -} - -func Test_19_AlertOnPartialProfileTest(t *testing.T) { - start := time.Now() - defer tearDownTest(t, start) - - ns := testutils.NewRandomNamespace() - - // Create a workload - wl, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/nginx-deployment.yaml")) - require.NoError(t, err, "Error creating workload") - - // Wait for the workload to be ready - err = wl.WaitForReady(80) - require.NoError(t, err, "Error waiting for workload to be ready") - - // Restart the daemonset - err = testutils.RestartDaemonSet("kubescape", "node-agent") - require.NoError(t, err, "Error restarting daemonset") - - // Wait for the application profile to be completed - err = wl.WaitForApplicationProfileCompletion(160) - require.NoError(t, err, "Error waiting for application profile to be completed") - - profile, err := wl.GetApplicationProfile() - require.NoError(t, err, "Error getting application profile") - - require.Equal(t, helpersv1.Partial, profile.Annotations[helpersv1.CompletionMetadataKey]) - - // Wait for cache to be updated - time.Sleep(15 * time.Second) - - // Generate an alert by executing a command - _, _, err = wl.ExecIntoPod([]string{"ls", "-l"}, "") - require.NoError(t, err, "Error executing command in pod") - // Wait for the alert to be generated - time.Sleep(15 * time.Second) - alerts, err := testutils.GetAlerts(ns.Name) - require.NoError(t, err, "Error getting alerts") - testutils.AssertContains(t, alerts, "Unexpected process launched", "ls", "nginx", []bool{true}) -} - -func Test_20_AlertOnPartialThenLearnProcessTest(t *testing.T) { - start := time.Now() - defer tearDownTest(t, start) - - ns := testutils.NewRandomNamespace() - - // Create a workload - wl, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/nginx-deployment.yaml")) - require.NoError(t, err, "Error creating workload") - - // Wait for the workload to be ready - err = wl.WaitForReady(80) - require.NoError(t, err, "Error waiting for workload to be ready") - - // Restart the daemonset - err = testutils.RestartDaemonSet("kubescape", "node-agent") - require.NoError(t, err, "Error restarting daemonset") - - // Wait for the application profile to be completed (partial) - err = wl.WaitForApplicationProfileCompletion(160) - require.NoError(t, err, "Error waiting for application profile to be completed") - - // Wait for cache to be updated - time.Sleep(15 * time.Second) - - // Generate an alert by executing a command (should trigger alert on partial profile) - _, _, err = wl.ExecIntoPod([]string{"ls", "-l"}, "") - require.NoError(t, err, "Error executing command in pod") - - // Wait for the alert to be generated - time.Sleep(15 * time.Second) - alerts, err := testutils.GetAlerts(ns.Name) - require.NoError(t, err, "Error getting alerts") - testutils.AssertContains(t, alerts, "Unexpected process launched", "ls", "nginx", []bool{true}) - - profile, err := wl.GetApplicationProfile() - require.NoError(t, err, "Error getting application profile") - - // Restart the deployment to reset the profile learning - err = testutils.RestartDeployment(ns.Name, wl.WorkloadObj.GetName()) - require.NoError(t, err, "Error restarting deployment") - - wl, err = testutils.NewTestWorkloadFromK8sIdentifiers(ns.Name, wl.UnstructuredObj.GroupVersionKind().Kind, "nginx-deployment") - require.NoError(t, err, "Error re-fetching workload after restart") - - // Wait for the workload to be ready after restart - err = wl.WaitForReady(80) - require.NoError(t, err, "Error waiting for workload to be ready after restart") - - // Execute the same command during learning phase (should be learned in profile) - _, _, err = wl.ExecIntoPod([]string{"ls", "-l"}, "") - require.NoError(t, err, "Error executing command in pod during learning") - - // Wait for the application profile to be completed (with ls command learned) - err = wl.WaitForApplicationProfileCompletionWithBlacklist(160, []string{profile.Name}) - require.NoError(t, err, "Error waiting for application profile to be completed after learning") - - // Wait for cache to be updated - time.Sleep(15 * time.Second) - - // Execute the same command again - should NOT trigger an alert now - _, _, err = wl.ExecIntoPod([]string{"ls", "-l"}, "") - require.NoError(t, err, "Error executing command in pod after learning") - - // Wait to see if any alert is generated - time.Sleep(15 * time.Second) - alertsAfter, err := testutils.GetAlerts(ns.Name) - require.NoError(t, err, "Error getting alerts after learning") - - // Should not contain new alert for ls command after learning - count := 0 - for _, alert := range alertsAfter { - if alert.Labels["rule_name"] == "Unexpected process launched" && alert.Labels["container_name"] == "nginx" && alert.Labels["process_name"] == "ls" { - count++ - } - } - if count > 1 { - t.Errorf("Unexpected alerts found after learning: %d", count) - } -} - -func Test_21_AlertOnPartialThenLearnNetworkTest(t *testing.T) { - start := time.Now() - defer tearDownTest(t, start) - - ns := testutils.NewRandomNamespace() - - // Create a workload using deployment-multiple-containers.yaml (same as Test_22) - wl, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/deployment-multiple-containers.yaml")) - require.NoError(t, err, "Error creating workload") - - // Wait for the workload to be ready - err = wl.WaitForReady(80) - require.NoError(t, err, "Error waiting for workload to be ready") - - // Restart the daemonset - err = testutils.RestartDaemonSet("kubescape", "node-agent") - require.NoError(t, err, "Error restarting daemonset") - - // Wait for the network neighborhood to be completed (partial) - err = wl.WaitForNetworkNeighborhoodCompletion(160) - require.NoError(t, err, "Error waiting for network neighborhood to be completed") - - // Wait for cache to be updated - time.Sleep(15 * time.Second) - - // Generate an alert by making a network request (should trigger alert on partial profile) - // Using curl with timeout and targeting nginx container (same as Test_22) - _, _, err = wl.ExecIntoPod([]string{"curl", "google.com", "-m", "5"}, "nginx") - require.NoError(t, err, "Error executing network command in pod") - - // Wait for the alert to be generated - time.Sleep(15 * time.Second) - alerts, err := testutils.GetAlerts(ns.Name) - require.NoError(t, err, "Error getting alerts") - testutils.AssertContains(t, alerts, "DNS Anomalies in container", "curl", "nginx", []bool{true}) - - nn, err := wl.GetNetworkNeighborhood() - require.NoError(t, err, "Error getting network neighborhood") - - // Restart the deployment to reset the profile learning - err = testutils.RestartDeployment(ns.Name, wl.WorkloadObj.GetName()) - require.NoError(t, err, "Error restarting deployment") - - // Print we restarted the deployment - logger.L().Info("restarted deployment", helpers.String("name", wl.WorkloadObj.GetName()), helpers.String("namespace", wl.WorkloadObj.GetNamespace())) - - // Sleep to allow the restart to complete - time.Sleep(30 * time.Second) - - wl, err = testutils.NewTestWorkloadFromK8sIdentifiers(ns.Name, wl.UnstructuredObj.GroupVersionKind().Kind, "multiple-containers-deployment") - require.NoError(t, err, "Error re-fetching workload after restart") - - // Wait for the workload to be ready after restart - err = wl.WaitForReady(80) - require.NoError(t, err, "Error waiting for workload to be ready after restart") - - // Execute the same network command during learning phase (should be learned in profile) - _, _, err = wl.ExecIntoPod([]string{"curl", "google.com", "-m", "5"}, "nginx") - require.NoError(t, err, "Error executing network command in pod during learning") - - // Print the workload details we are using - logger.L().Info("workload details", helpers.String("name", wl.WorkloadObj.GetName()), helpers.String("namespace", wl.WorkloadObj.GetNamespace())) - // Print the metadata of the workload - logger.L().Info("workload metadata", helpers.Interface("metadata", wl.WorkloadObj.GetAnnotations()), helpers.Interface("labels", wl.WorkloadObj.GetLabels())) - - // Wait for the network neighborhood to be completed (with curl command learned) - err = wl.WaitForNetworkNeighborhoodCompletionWithBlacklist(160, []string{nn.Name}) - require.NoError(t, err, "Error waiting for network neighborhood to be completed after learning") - - // Wait for cache to be updated - time.Sleep(15 * time.Second) - - // Execute the same network command again - should NOT trigger an alert now - _, _, err = wl.ExecIntoPod([]string{"curl", "google.com", "-m", "5"}, "nginx") - require.NoError(t, err, "Error executing network command in pod after learning") - - // Wait to see if any alert is generated - time.Sleep(15 * time.Second) - alertsAfter, err := testutils.GetAlerts(ns.Name) - require.NoError(t, err, "Error getting alerts after learning") - - // Should not contain new alert for curl command after learning - count := 0 - for _, alert := range alertsAfter { - if alert.Labels["rule_name"] == "DNS Anomalies in container" && alert.Labels["container_name"] == "nginx" && alert.Labels["process_name"] == "curl" { - count++ - } - } - if count > 1 { - t.Errorf("Unexpected alerts found after learning: %d", count) - } -} - -func Test_22_AlertOnPartialNetworkProfileTest(t *testing.T) { - start := time.Now() - defer tearDownTest(t, start) - - ns := testutils.NewRandomNamespace() - - // Create a workload - wl, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/deployment-multiple-containers.yaml")) - require.NoError(t, err, "Error creating workload") - - // Wait for the workload to be ready - err = wl.WaitForReady(80) - require.NoError(t, err, "Error waiting for workload to be ready") - - // Restart the daemonset - err = testutils.RestartDaemonSet("kubescape", "node-agent") - require.NoError(t, err, "Failed to restart daemonset") - - // Wait for the network neighborhood to be completed - err = wl.WaitForNetworkNeighborhoodCompletion(160) - require.NoError(t, err, "Error waiting for network neighborhood to be completed") - - // Wait for cache to be updated - time.Sleep(15 * time.Second) - - // Generate an alert by making an unexpected network request - _, _, err = wl.ExecIntoPod([]string{"curl", "google.com", "-m", "5"}, "nginx") - require.NoError(t, err, "Error executing network command in pod") - - // Wait for the alert to be generated - time.Sleep(15 * time.Second) - alerts, err := testutils.GetAlerts(ns.Name) - require.NoError(t, err, "Error getting alerts") - testutils.AssertContains(t, alerts, "DNS Anomalies in container", "curl", "nginx", []bool{true}) -} - -func Test_23_RuleCooldownTest(t *testing.T) { - start := time.Now() - defer tearDownTest(t, start) - - ns := testutils.NewRandomNamespace() - - wl, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/nginx-deployment.yaml")) - require.NoError(t, err, "Error creating workload") - - require.NoError(t, wl.WaitForApplicationProfileCompletion(80)) - - // Wait for cache - time.Sleep(30 * time.Second) - - // Run the same process 20 times - for i := 0; i < 20; i++ { - _, _, err = wl.ExecIntoPod([]string{"ls", "-l"}, "") - require.NoError(t, err) - time.Sleep(1 * time.Second) - } - - // Wait for alerts to be processed - time.Sleep(30 * time.Second) - - // Get all alerts - alerts, err := testutils.GetAlerts(wl.Namespace) - require.NoError(t, err, "Error getting alerts") - - // Count alerts for "Unexpected process launched" rule - alertCount := 0 - for _, alert := range alerts { - if ruleName, ok := alert.Labels["rule_name"]; ok && ruleName == "Unexpected process launched" { - alertCount++ - } - } - - // We should get exactly 10 alerts (cooldown threshold) even though we ran the process 20 times - assert.Equal(t, 10, alertCount, "Expected exactly 10 alerts due to cooldown threshold, got %d", alertCount) - - // Verify the specific alert details - testutils.AssertContains(t, alerts, "Unexpected process launched", "ls", "nginx", []bool{true}) -} - -func Test_24_ProcessTreeDepthTest(t *testing.T) { - start := time.Now() - defer tearDownTest(t, start) - - ns := testutils.NewRandomNamespace() - - endpointTraffic, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/tree.yaml")) - require.NoError(t, err, "Error creating workload") - - err = endpointTraffic.WaitForReady(80) - require.NoError(t, err, "Error waiting for workload to be ready") - - err = endpointTraffic.WaitForApplicationProfileCompletion(80) - require.NoError(t, err, "Error waiting for application profile to be completed") - - // wait for cache - time.Sleep(30 * time.Second) - - // Add to rule policy symlink - buf, _, err := endpointTraffic.ExecIntoPod([]string{"/bin/sh", "-c", "python3 /root/python_spawner.py 10"}, "") - require.NoError(t, err) - - t.Logf("Output: %s", buf) - - t.Logf("Waiting for the alert to be signaled") - - // Wait for the alert to be signaled - time.Sleep(2 * time.Minute) - - alerts, err := testutils.GetAlerts(endpointTraffic.Namespace) - require.NoError(t, err, "Error getting alerts") - - found := false - - for _, alert := range alerts { - if alert.Labels["rule_name"] == "Unexpected process launched" { - if alert.Labels["processtree_depth"] == "10" { - found = true - break - } - } - } - - assert.Truef(t, found, "Expected to find an alert for the process tree depth") - - t.Logf("Found alerts for the process tree depth: %v", alerts) -} - -// Test_27_ApplicationProfileOpens tests that the dynamic path matching in -// application profiles works correctly for both recorded (auto-learned) -// profiles and user-defined profiles. -// -// Path matching symbols: -// -// ⋯ (U+22EF DynamicIdentifier) — matches exactly ONE path segment -// * (WildcardIdentifier) — matches ZERO or more path segments -// 0 (in endpoints) — wildcard port (any port) -// -// R0002 "Files Access Anomalies in container" fires when a file is opened -// under a monitored prefix (/etc/, /var/log/, …) and the path was NOT -// recorded in the application profile. -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_28_UserDefinedNetworkNeighborhood(t *testing.T) { - start := time.Now() - defer tearDownTest(t, start) - - // setup creates a namespace with user-defined AP + NN + pod. - // The NN allows only fusioncore.ai (162.0.217.171) on TCP/80. - setup := func(t *testing.T) *testutils.TestWorkload { - t.Helper() - ns := testutils.NewRandomNamespace() - k8sClient := k8sinterface.NewKubernetesApi() - storageClient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) - - // Upstream ContainerProfileCache (kubescape/node-agent#788) reads ONE - // pod label `kubescape.io/user-defined-profile=` and uses - // as the lookup key for BOTH the user AP and the user NN. - // AP and NN MUST therefore share that single name. - const overlayName = "curl-28-overlay" - - ap := &v1beta1.ApplicationProfile{ - ObjectMeta: metav1.ObjectMeta{ - Name: overlayName, - Namespace: ns.Name, - }, - Spec: v1beta1.ApplicationProfileSpec{ - Containers: []v1beta1.ApplicationProfileContainer{ - { - Name: "curl", - Execs: []v1beta1.ExecCalls{ - {Path: "/bin/sleep"}, - {Path: "/usr/bin/curl"}, - {Path: "/usr/bin/nslookup"}, - {Path: "/usr/bin/wget"}, - }, - 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"}, - }, - }, - }, - } - _, err := storageClient.ApplicationProfiles(ns.Name).Create( - context.Background(), ap, metav1.CreateOptions{}) - require.NoError(t, err, "create AP") - - 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-28", - helpersv1.RelatedNamespaceMetadataKey: ns.Name, - }, - }, - Spec: v1beta1.NetworkNeighborhoodSpec{ - LabelSelector: metav1.LabelSelector{ - MatchLabels: map[string]string{"app": "curl-28"}, - }, - Containers: []v1beta1.NetworkNeighborhoodContainer{ - { - Name: "curl", - Egress: []v1beta1.NetworkNeighbor{ - { - Identifier: "fusioncore-egress", - Type: "external", - DNS: "fusioncore.ai.", - DNSNames: []string{"fusioncore.ai."}, - IPAddress: "162.0.217.171", - Ports: []v1beta1.NetworkPort{ - {Name: "TCP-80", Protocol: "TCP", Port: ptr.To(int32(80))}, - }, - }, - }, - }, - }, - }, - } - _, 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/nginx-user-defined-deployment.yaml")) - require.NoError(t, err) - require.NoError(t, wl.WaitForReady(80)) - // Cache-load latency on the upstream ContainerProfileCache is bursty - // — 15s is enough on a quiet runner but not on a loaded one. The - // failure mode is alert metadata `errorMessage:"waiting for profile - // update"`, which means the rule manager evaluated against an - // unloaded NN and fired R0005/R0011 spuriously. 30s covers the - // observed worst-case in CI without pushing total test time too - // far. Real fix would be to poll a cache-loaded signal, but no - // such signal is exposed today. - time.Sleep(30 * time.Second) - return wl - } - - 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") - // Extra settle time for remaining 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"]) - } - } - - // --------------------------------------------------------------- - // 28a. Allowed traffic — fusioncore.ai is in the NN. - // No R0005 (DNS) and no R0011 (egress) expected. - // --------------------------------------------------------------- - t.Run("allowed_fusioncore_no_alert", func(t *testing.T) { - wl := setup(t) - - // DNS lookup via nslookup (domain in NN). - stdout, stderr, err := wl.ExecIntoPod([]string{"nslookup", "fusioncore.ai"}, "curl") - t.Logf("nslookup fusioncore.ai → err=%v stdout=%q stderr=%q", err, stdout, stderr) - - // HTTP via curl (domain + IP in NN). - stdout, stderr, err = wl.ExecIntoPod([]string{"curl", "-sm5", "http://fusioncore.ai"}, "curl") - t.Logf("curl fusioncore.ai → err=%v stdout=%q stderr=%q", err, stdout, stderr) - - alerts := waitAlerts(t, wl.Namespace) - t.Logf("=== %d alerts ===", len(alerts)) - logAlerts(t, alerts) - - assert.Equal(t, 0, countByRule(alerts, "R0005"), - "fusioncore.ai is in NN — should NOT fire R0005") - assert.Equal(t, 0, countByRule(alerts, "R0011"), - "fusioncore.ai IP is in NN — should NOT fire R0011") - }) - - // --------------------------------------------------------------- - // 28b. Unknown domains — domains NOT in the NN → R0005. - // Uses both nslookup (pure DNS) and curl (DNS + TCP). - // --------------------------------------------------------------- - t.Run("unknown_domain_R0005", func(t *testing.T) { - wl := setup(t) - - // nslookup generates a DNS query without any TCP connection. - wl.ExecIntoPod([]string{"nslookup", "google.com"}, "curl") - // curl resolves + connects. - wl.ExecIntoPod([]string{"curl", "-sm5", "http://ebpf.io"}, "curl") - wl.ExecIntoPod([]string{"curl", "-sm5", "http://cloudflare.com"}, "curl") - - alerts := waitAlerts(t, wl.Namespace) - t.Logf("=== %d alerts ===", len(alerts)) - logAlerts(t, alerts) - - require.Greater(t, countByRule(alerts, "R0005"), 0, - "unknown domains must fire R0005") - }) - - // --------------------------------------------------------------- - // 28c. Unknown IPs — raw IP egress NOT in the NN → R0011. - // --------------------------------------------------------------- - t.Run("unknown_ip_R0011", func(t *testing.T) { - wl := setup(t) - - wl.ExecIntoPod([]string{"curl", "-sm5", "http://8.8.8.8"}, "curl") - wl.ExecIntoPod([]string{"curl", "-sm5", "http://1.1.1.1"}, "curl") - - alerts := waitAlerts(t, wl.Namespace) - t.Logf("=== %d alerts ===", len(alerts)) - logAlerts(t, alerts) - - require.Greater(t, countByRule(alerts, "R0011"), 0, - "IPs not in NN must fire R0011") - }) - - // --------------------------------------------------------------- - // 28d. MITM — DNS spoofing simulation. - // fusioncore.ai is an allowed domain but the IP is spoofed. - // - // Step 1: nslookup fusioncore.ai (legitimate DNS, no alert). - // Step 2: curl --resolve fusioncore.ai:80:8.8.4.4 - // Simulates a DNS MITM returning a different IP. - // The domain is allowed but the connection goes to - // 8.8.4.4 (not 162.0.217.171) → R0011. - // --------------------------------------------------------------- - t.Run("mitm_spoofed_ip_R0011", func(t *testing.T) { - wl := setup(t) - - // Step 1: Legitimate DNS lookup — no alert expected. - wl.ExecIntoPod([]string{"nslookup", "fusioncore.ai"}, "curl") - - // Step 2: MITM — domain resolves to spoofed IP 8.8.4.4. - // curl --resolve skips DNS and connects directly to the - // spoofed IP, simulating what happens after DNS poisoning. - stdout, stderr, err := wl.ExecIntoPod( - []string{"curl", "-sm5", "--resolve", "fusioncore.ai:80:8.8.4.4", "http://fusioncore.ai"}, "curl") - t.Logf("curl MITM → err=%v stdout=%q stderr=%q", err, stdout, stderr) - - alerts := waitAlerts(t, wl.Namespace) - t.Logf("=== %d alerts ===", len(alerts)) - logAlerts(t, alerts) - - require.Greater(t, countByRule(alerts, "R0011"), 0, - "MITM: fusioncore.ai allowed but spoofed IP 8.8.4.4 must fire R0011") - }) - - // --------------------------------------------------------------- - // 28e. MITM — real CoreDNS poisoning via template plugin. - // Poisons CoreDNS so fusioncore.ai resolves to 8.8.4.4 - // instead of the legitimate 162.0.217.171. - // - // nslookup triggers the poisoned DNS response. - // R0005 does NOT fire: fusioncore.ai is in the NN egress - // list and BusyBox nslookup does NOT do PTR reverse-lookups. - // R0011 does NOT fire: no TCP egress (DNS is UDP to cluster - // DNS which is a private IP filtered by is_private_ip). - // - // This documents a detection gap: pure DNS MITM (without - // subsequent TCP to the spoofed IP) is invisible to both - // R0005 and R0011 when the domain is already whitelisted. - // - // NOTE: this subtest MUST run last — it modifies the - // cluster-wide CoreDNS configmap. - // --------------------------------------------------------------- - t.Run("mitm_coredns_poisoning", func(t *testing.T) { - wl := setup(t) - ctx := context.Background() - k8sClient := k8sinterface.NewKubernetesApi() - - // ── Back up original CoreDNS Corefile ── - cm, err := k8sClient.KubernetesClient.CoreV1(). - ConfigMaps("kube-system").Get(ctx, "coredns", metav1.GetOptions{}) - require.NoError(t, err, "get coredns configmap") - originalCorefile := cm.Data["Corefile"] - - restartAndWaitCoreDNS := func() { - deploy, err := k8sClient.KubernetesClient.AppsV1(). - Deployments("kube-system").Get(ctx, "coredns", metav1.GetOptions{}) - require.NoError(t, err, "get coredns deployment") - if deploy.Spec.Template.ObjectMeta.Annotations == nil { - deploy.Spec.Template.ObjectMeta.Annotations = make(map[string]string) - } - deploy.Spec.Template.ObjectMeta.Annotations["kubectl.kubernetes.io/restartedAt"] = time.Now().Format(time.RFC3339) - _, err = k8sClient.KubernetesClient.AppsV1(). - Deployments("kube-system").Update(ctx, deploy, metav1.UpdateOptions{}) - require.NoError(t, err, "restart coredns") - - require.Eventually(t, func() bool { - d, err := k8sClient.KubernetesClient.AppsV1(). - Deployments("kube-system").Get(ctx, "coredns", metav1.GetOptions{}) - if err != nil || d.Spec.Replicas == nil { - return false - } - return d.Status.ReadyReplicas == *d.Spec.Replicas && - d.Status.UpdatedReplicas == *d.Spec.Replicas - }, 60*time.Second, 2*time.Second, "coredns must become ready") - } - - // ── Restore CoreDNS on cleanup (best-effort) ── - t.Cleanup(func() { - t.Log("cleanup: restoring CoreDNS Corefile") - cm, err := k8sClient.KubernetesClient.CoreV1(). - ConfigMaps("kube-system").Get(ctx, "coredns", metav1.GetOptions{}) - if err != nil { - t.Logf("cleanup: get coredns cm: %v", err) - return - } - cm.Data["Corefile"] = originalCorefile - if _, err := k8sClient.KubernetesClient.CoreV1(). - ConfigMaps("kube-system").Update(ctx, cm, metav1.UpdateOptions{}); err != nil { - t.Logf("cleanup: update coredns cm: %v", err) - return - } - deploy, err := k8sClient.KubernetesClient.AppsV1(). - Deployments("kube-system").Get(ctx, "coredns", metav1.GetOptions{}) - if err != nil { - t.Logf("cleanup: get coredns deploy: %v", err) - return - } - if deploy.Spec.Template.ObjectMeta.Annotations == nil { - deploy.Spec.Template.ObjectMeta.Annotations = make(map[string]string) - } - deploy.Spec.Template.ObjectMeta.Annotations["kubectl.kubernetes.io/restartedAt"] = time.Now().Format(time.RFC3339) - if _, err := k8sClient.KubernetesClient.AppsV1(). - Deployments("kube-system").Update(ctx, deploy, metav1.UpdateOptions{}); err != nil { - t.Logf("cleanup: restart coredns: %v", err) - } - }) - - // ── Poison CoreDNS: fusioncore.ai → 8.8.4.4 ── - poisoned := strings.Replace(originalCorefile, - "forward .", - "template IN A fusioncore.ai {\n answer \"fusioncore.ai. 60 IN A 8.8.4.4\"\n fallthrough\n }\n forward .", - 1) - require.NotEqual(t, originalCorefile, poisoned, "template injection must modify Corefile") - - cm.Data["Corefile"] = poisoned - _, err = k8sClient.KubernetesClient.CoreV1(). - ConfigMaps("kube-system").Update(ctx, cm, metav1.UpdateOptions{}) - require.NoError(t, err, "apply poisoned Corefile") - restartAndWaitCoreDNS() - - // Verify poisoned DNS returns the spoofed IP. - require.Eventually(t, func() bool { - stdout, _, _ := wl.ExecIntoPod([]string{"nslookup", "fusioncore.ai"}, "curl") - return strings.Contains(stdout, "8.8.4.4") - }, 30*time.Second, 3*time.Second, "poisoned CoreDNS must return 8.8.4.4 for fusioncore.ai") - - // ── Trigger alerts ── - // nslookup does DNS only (no TCP egress). - // BusyBox nslookup does NOT do PTR reverse-lookups on result IPs. - stdout, stderr, err := wl.ExecIntoPod([]string{"nslookup", "fusioncore.ai"}, "curl") - t.Logf("nslookup (poisoned) → err=%v stdout=%q stderr=%q", err, stdout, stderr) - - alerts := waitAlerts(t, wl.Namespace) - t.Logf("=== %d alerts ===", len(alerts)) - logAlerts(t, alerts) - - // R0005 does NOT fire: fusioncore.ai is already in the NN - // egress list, and BusyBox nslookup does NOT perform PTR - // reverse-lookups on result IPs, so no unknown domain is queried. - assert.Equal(t, 0, countByRule(alerts, "R0005"), - "DNS MITM: domain is in NN and no PTR lookup — R0005 should not fire") - - // R0011 does NOT fire: nslookup generates only DNS (UDP) - // traffic to the cluster DNS service, which is a private IP - // excluded by is_private_ip(). - assert.Equal(t, 0, countByRule(alerts, "R0011"), - "DNS MITM: nslookup has no TCP egress — R0011 should not fire") - }) - - // --------------------------------------------------------------- - // 28f. MITM — CoreDNS poisoning with TCP egress. - // Same CoreDNS poisoning as 28e, but now fusioncore.ai - // resolves to 128.130.194.56 (a routable IP that accepts - // TCP on port 80). curl generates a real TCP connection - // to the spoofed IP. - // - // Expected: - // R0005 = 0 — domain is in NN, no PTR reverse-lookup. - // R0011 fires — TCP egress to 128.130.194.56 which is - // NOT in the NN (NN only has 162.0.217.171). - // - // NOTE: runs after 28e; modifies cluster-wide CoreDNS. - // --------------------------------------------------------------- - t.Run("mitm_coredns_poisoning_tcp", func(t *testing.T) { - wl := setup(t) - ctx := context.Background() - k8sClient := k8sinterface.NewKubernetesApi() - - // ── Back up original CoreDNS Corefile ── - cm, err := k8sClient.KubernetesClient.CoreV1(). - ConfigMaps("kube-system").Get(ctx, "coredns", metav1.GetOptions{}) - require.NoError(t, err, "get coredns configmap") - originalCorefile := cm.Data["Corefile"] - - restartAndWaitCoreDNS := func() { - deploy, err := k8sClient.KubernetesClient.AppsV1(). - Deployments("kube-system").Get(ctx, "coredns", metav1.GetOptions{}) - require.NoError(t, err, "get coredns deployment") - if deploy.Spec.Template.ObjectMeta.Annotations == nil { - deploy.Spec.Template.ObjectMeta.Annotations = make(map[string]string) - } - deploy.Spec.Template.ObjectMeta.Annotations["kubectl.kubernetes.io/restartedAt"] = time.Now().Format(time.RFC3339) - _, err = k8sClient.KubernetesClient.AppsV1(). - Deployments("kube-system").Update(ctx, deploy, metav1.UpdateOptions{}) - require.NoError(t, err, "restart coredns") - - require.Eventually(t, func() bool { - d, err := k8sClient.KubernetesClient.AppsV1(). - Deployments("kube-system").Get(ctx, "coredns", metav1.GetOptions{}) - if err != nil || d.Spec.Replicas == nil { - return false - } - return d.Status.ReadyReplicas == *d.Spec.Replicas && - d.Status.UpdatedReplicas == *d.Spec.Replicas - }, 60*time.Second, 2*time.Second, "coredns must become ready") - } - - // ── Restore CoreDNS on cleanup (best-effort) ── - t.Cleanup(func() { - t.Log("cleanup: restoring CoreDNS Corefile") - cm, err := k8sClient.KubernetesClient.CoreV1(). - ConfigMaps("kube-system").Get(ctx, "coredns", metav1.GetOptions{}) - if err != nil { - t.Logf("cleanup: get coredns cm: %v", err) - return - } - cm.Data["Corefile"] = originalCorefile - if _, err := k8sClient.KubernetesClient.CoreV1(). - ConfigMaps("kube-system").Update(ctx, cm, metav1.UpdateOptions{}); err != nil { - t.Logf("cleanup: update coredns cm: %v", err) - return - } - deploy, err := k8sClient.KubernetesClient.AppsV1(). - Deployments("kube-system").Get(ctx, "coredns", metav1.GetOptions{}) - if err != nil { - t.Logf("cleanup: get coredns deploy: %v", err) - return - } - if deploy.Spec.Template.ObjectMeta.Annotations == nil { - deploy.Spec.Template.ObjectMeta.Annotations = make(map[string]string) - } - deploy.Spec.Template.ObjectMeta.Annotations["kubectl.kubernetes.io/restartedAt"] = time.Now().Format(time.RFC3339) - if _, err := k8sClient.KubernetesClient.AppsV1(). - Deployments("kube-system").Update(ctx, deploy, metav1.UpdateOptions{}); err != nil { - t.Logf("cleanup: restart coredns: %v", err) - } - }) - - // ── Poison CoreDNS: fusioncore.ai → 128.130.194.56 ── - poisoned := strings.Replace(originalCorefile, - "forward .", - "template IN A fusioncore.ai {\n answer \"fusioncore.ai. 60 IN A 128.130.194.56\"\n fallthrough\n }\n forward .", - 1) - require.NotEqual(t, originalCorefile, poisoned, "template injection must modify Corefile") - - cm.Data["Corefile"] = poisoned - _, err = k8sClient.KubernetesClient.CoreV1(). - ConfigMaps("kube-system").Update(ctx, cm, metav1.UpdateOptions{}) - require.NoError(t, err, "apply poisoned Corefile") - restartAndWaitCoreDNS() - - // Verify poisoned DNS returns the spoofed IP. - require.Eventually(t, func() bool { - stdout, _, _ := wl.ExecIntoPod([]string{"nslookup", "fusioncore.ai"}, "curl") - return strings.Contains(stdout, "128.130.194.56") - }, 30*time.Second, 3*time.Second, "poisoned CoreDNS must return 128.130.194.56 for fusioncore.ai") - - // ── Trigger alerts ── - // curl resolves fusioncore.ai → 128.130.194.56 (poisoned) - // then opens a TCP connection to 128.130.194.56:80. - stdout, stderr, err := wl.ExecIntoPod( - []string{"curl", "-sm5", "http://fusioncore.ai"}, "curl") - t.Logf("curl (poisoned DNS) → err=%v stdout=%q stderr=%q", err, stdout, stderr) - - alerts := waitAlerts(t, wl.Namespace) - t.Logf("=== %d alerts ===", len(alerts)) - logAlerts(t, alerts) - - // R0005 does NOT fire: fusioncore.ai is already in the NN - // egress list, and curl (like BusyBox nslookup) does NOT - // perform PTR reverse-lookups on resolved IPs. - assert.Equal(t, 0, countByRule(alerts, "R0005"), - "DNS MITM: domain is in NN and no PTR lookup — R0005 should not fire") - - // R0011 fires: TCP egress to 128.130.194.56 which is NOT - // in the NN (NN only allows 162.0.217.171). - require.Greater(t, countByRule(alerts, "R0011"), 0, - "DNS MITM: TCP to spoofed IP 128.130.194.56 must fire R0011") - }) -} - -// Test_29_SignedApplicationProfile verifies that a cryptographically signed -// ApplicationProfile can be pushed to storage, loaded by node-agent, and -// used for anomaly detection just like any other user-defined profile. -// -// The test signs an AP with key-based ECDSA (no OIDC/Sigstore needed), -// pushes it to storage, verifies the signature survives the round-trip, -// deploys a pod referencing the signed profile, and asserts that executing -// a binary NOT in the profile fires R0001 (Unexpected process launched). -func Test_29_SignedApplicationProfile(t *testing.T) { - start := time.Now() - defer tearDownTest(t, start) - - ns := testutils.NewRandomNamespace() - k8sClient := k8sinterface.NewKubernetesApi() - storageClient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) - - // ── 1. Build the ApplicationProfile ── - // Use nil (not empty slices) for unused fields — storage normalizes - // []string{} → nil on save, which changes the content hash. - // Matching the storage representation ensures the signature survives - // the round-trip (same approach as cluster_flow_test.go). - ap := &v1beta1.ApplicationProfile{ - ObjectMeta: metav1.ObjectMeta{ - Name: "signed-ap", - Namespace: ns.Name, - }, - Spec: v1beta1.ApplicationProfileSpec{ - Containers: []v1beta1.ApplicationProfileContainer{ - { - Name: "curl", - Execs: []v1beta1.ExecCalls{ - {Path: "/bin/sleep"}, - {Path: "/usr/bin/curl"}, - }, - Syscalls: []string{"close", "connect", "openat", "read", "socket", "write"}, - }, - }, - }, - } - - // ── 2. Sign the AP (key-based, no OIDC) ── - adapter := profiles.NewApplicationProfileAdapter(ap) - err := signature.SignObjectDisableKeyless(adapter) - require.NoError(t, err, "sign AP") - require.True(t, signature.IsSigned(adapter), "AP must be signed") - - // Verify signature locally. - require.NoError(t, signature.VerifyObjectAllowUntrusted(adapter), - "signature must verify immediately after signing") - - sig, err := signature.GetObjectSignature(adapter) - require.NoError(t, err, "extract signature") - require.NotEmpty(t, sig.Signature, "signature bytes must not be empty") - require.NotEmpty(t, sig.Certificate, "certificate must not be empty") - t.Logf("AP signed: issuer=%s identity=%s sigLen=%d", sig.Issuer, sig.Identity, len(sig.Signature)) - - // ── 3. Push signed AP to storage ── - // Create preserves annotations (including signature.*). - _, err = storageClient.ApplicationProfiles(ns.Name).Create( - context.Background(), ap, metav1.CreateOptions{}) - require.NoError(t, err, "create signed AP in storage") - - // ── 4. Verify signature survives the storage round-trip ── - require.Eventually(t, func() bool { - stored, getErr := storageClient.ApplicationProfiles(ns.Name).Get( - context.Background(), "signed-ap", v1.GetOptions{}) - if getErr != nil { - return false - } - return signature.IsSigned(profiles.NewApplicationProfileAdapter(stored)) - }, 30*time.Second, 1*time.Second, "stored AP must retain signature annotations") - - storedAP, err := storageClient.ApplicationProfiles(ns.Name).Get( - context.Background(), "signed-ap", v1.GetOptions{}) - require.NoError(t, err) - storedAdapter := profiles.NewApplicationProfileAdapter(storedAP) - err = signature.VerifyObjectAllowUntrusted(storedAdapter) - require.NoError(t, err, "stored AP signature must still verify after round-trip") - t.Log("Signature round-trip verification passed") - - // ── 6. Deploy pod referencing the signed profile ── - wl, err := testutils.NewTestWorkload(ns.Name, - path.Join(utils.CurrentDir(), "resources/curl-signed-deployment.yaml")) - require.NoError(t, err) - require.NoError(t, wl.WaitForReady(80)) - time.Sleep(15 * time.Second) // let node-agent load the profile - - // ── 7. Exec an allowed binary — should NOT fire R0001 ── - stdout, stderr, execErr := wl.ExecIntoPod([]string{"curl", "-sm5", "http://ebpf.io"}, "curl") - t.Logf("curl (allowed) → err=%v stdout=%q stderr=%q", execErr, stdout, stderr) - - // ── 8. Exec an anomalous binary — should fire R0001 ── - // The user-defined profile may not be cached yet when the first exec runs. - // Re-exec nslookup on each poll so the eBPF event is generated after - // the profile is loaded (same race as the crypto miner test). - stdout, stderr, execErr = wl.ExecIntoPod([]string{"nslookup", "ebpf.io"}, "curl") - t.Logf("nslookup (anomalous) → err=%v stdout=%q stderr=%q", execErr, stdout, stderr) - - // ── 9. Wait for R0001 alert ── - var alerts []testutils.Alert - require.Eventually(t, func() bool { - // Re-exec on each poll to ensure the event arrives after the profile is cached. - wl.ExecIntoPod([]string{"nslookup", "ebpf.io"}, "curl") - - alerts, err = testutils.GetAlerts(ns.Name) - if err != nil || len(alerts) == 0 { - return false - } - for _, a := range alerts { - if a.Labels["rule_id"] == "R0001" { - return true - } - } - return false - }, 120*time.Second, 10*time.Second, "nslookup is not in signed AP — must fire R0001") - - // Extra settle time. - time.Sleep(10 * time.Second) - alerts, _ = testutils.GetAlerts(ns.Name) - - t.Logf("=== %d alerts ===", len(alerts)) - 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 must have fired for the anomalous exec. - r0001Count := 0 - for _, a := range alerts { - if a.Labels["rule_id"] == "R0001" { - r0001Count++ - } - } - require.Greater(t, r0001Count, 0, "nslookup not in signed AP must fire R0001") -} - -// Test_30_TamperedSignedProfiles verifies that cryptographic signature -// verification detects tampering of both ApplicationProfile and -// NetworkNeighborhood objects. -// -// Current state of enforcement (as of merge): -// - enableSignatureVerification defaults to false -// - When enabled: tampered profiles are silently SKIPPED (not loaded) -// - No R-number rule fires on signature verification failure -// - User-defined NNs in addContainer() are NOT verified (known gap) -// - System fails open: no profile → no anomaly baseline → no detection -// -// This test proves: -// - The crypto layer detects tampering (sign → tamper → verify fails) -// - Without enforcement, tampered profiles are loaded and used -func Test_30_TamperedSignedProfiles(t *testing.T) { - start := time.Now() - defer tearDownTest(t, start) - - // --------------------------------------------------------------- - // 30a. Tamper detection at the crypto layer — AP and NN. - // Sign both objects, tamper their specs, verify fails. - // --------------------------------------------------------------- - t.Run("tamper_invalidates_signature", func(t *testing.T) { - // ── ApplicationProfile ── - ap := &v1beta1.ApplicationProfile{ - ObjectMeta: metav1.ObjectMeta{ - Name: "tamper-test-ap", - Namespace: "tamper-test-ns", - }, - Spec: v1beta1.ApplicationProfileSpec{ - Containers: []v1beta1.ApplicationProfileContainer{ - { - Name: "app", - Execs: []v1beta1.ExecCalls{ - {Path: "/bin/sleep"}, - {Path: "/usr/bin/curl"}, - }, - Syscalls: []string{"read", "write", "close"}, - }, - }, - }, - } - - apAdapter := profiles.NewApplicationProfileAdapter(ap) - require.NoError(t, signature.SignObjectDisableKeyless(apAdapter), "sign AP") - require.True(t, signature.IsSigned(apAdapter)) - require.NoError(t, signature.VerifyObjectAllowUntrusted(apAdapter), "untampered AP must verify") - - // Tamper: attacker adds nslookup to whitelist - ap.Spec.Containers[0].Execs = append(ap.Spec.Containers[0].Execs, - v1beta1.ExecCalls{Path: "/usr/bin/nslookup"}) - - tamperedAPAdapter := profiles.NewApplicationProfileAdapter(ap) - err := signature.VerifyObjectAllowUntrusted(tamperedAPAdapter) - require.Error(t, err, "tampered AP must fail verification") - t.Logf("AP tamper detected: %v", err) - - // ── NetworkNeighborhood ── - nn := &v1beta1.NetworkNeighborhood{ - ObjectMeta: metav1.ObjectMeta{ - Name: "tamper-test-nn", - Namespace: "tamper-test-ns", - Annotations: map[string]string{ - helpersv1.ManagedByMetadataKey: helpersv1.ManagedByUserValue, - helpersv1.StatusMetadataKey: helpersv1.Completed, - helpersv1.CompletionMetadataKey: helpersv1.Full, - }, - Labels: map[string]string{ - helpersv1.RelatedKindMetadataKey: "Deployment", - helpersv1.RelatedNameMetadataKey: "tamper-test", - }, - }, - Spec: v1beta1.NetworkNeighborhoodSpec{ - LabelSelector: metav1.LabelSelector{ - MatchLabels: map[string]string{"app": "tamper-test"}, - }, - Containers: []v1beta1.NetworkNeighborhoodContainer{ - { - Name: "app", - Egress: []v1beta1.NetworkNeighbor{ - { - Identifier: "allowed-egress", - Type: "external", - DNS: "fusioncore.ai.", - DNSNames: []string{"fusioncore.ai."}, - IPAddress: "162.0.217.171", - Ports: []v1beta1.NetworkPort{ - {Name: "TCP-80", Protocol: "TCP", Port: ptr.To(int32(80))}, - }, - }, - }, - }, - }, - }, - } - - nnAdapter := profiles.NewNetworkNeighborhoodAdapter(nn) - require.NoError(t, signature.SignObjectDisableKeyless(nnAdapter), "sign NN") - require.True(t, signature.IsSigned(nnAdapter)) - require.NoError(t, signature.VerifyObjectAllowUntrusted(nnAdapter), "untampered NN must verify") - - // Tamper: attacker adds a C2 domain to the egress whitelist - nn.Spec.Containers[0].Egress = append(nn.Spec.Containers[0].Egress, - v1beta1.NetworkNeighbor{ - Identifier: "c2-backdoor", - Type: "external", - DNS: "evil-c2.example.com.", - DNSNames: []string{"evil-c2.example.com."}, - IPAddress: "6.6.6.6", - Ports: []v1beta1.NetworkPort{ - {Name: "TCP-443", Protocol: "TCP", Port: ptr.To(int32(443))}, - }, - }) - - tamperedNNAdapter := profiles.NewNetworkNeighborhoodAdapter(nn) - err = signature.VerifyObjectAllowUntrusted(tamperedNNAdapter) - require.Error(t, err, "tampered NN must fail verification") - t.Logf("NN tamper detected: %v", err) - }) - - // --------------------------------------------------------------- - // 30b. Tampered AP is still loaded when enforcement is off. - // - // enableSignatureVerification defaults to false. - // The tampered profile is pushed to storage and node-agent - // loads it without checking the signature. Anomaly detection - // uses the tampered baseline → the attacker's added exec - // path (nslookup) is whitelisted. - // - // With enableSignatureVerification=true, the tampered profile - // would be rejected and the pod would have no baseline. - // --------------------------------------------------------------- - t.Run("tampered_profile_loaded_without_enforcement", func(t *testing.T) { - ns := testutils.NewRandomNamespace() - k8sClient := k8sinterface.NewKubernetesApi() - storageClient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) - - // Build AP: only sleep + curl allowed. - // Use nil for unused fields (storage normalizes empty slices to nil). - ap := &v1beta1.ApplicationProfile{ - ObjectMeta: metav1.ObjectMeta{ - Name: "signed-ap", - Namespace: ns.Name, - }, - Spec: v1beta1.ApplicationProfileSpec{ - Containers: []v1beta1.ApplicationProfileContainer{ - { - Name: "curl", - Execs: []v1beta1.ExecCalls{ - {Path: "/bin/sleep"}, - {Path: "/usr/bin/curl"}, - }, - Syscalls: []string{"close", "connect", "openat", "read", "socket", "write"}, - }, - }, - }, - } - - // Sign the AP. - apAdapter := profiles.NewApplicationProfileAdapter(ap) - require.NoError(t, signature.SignObjectDisableKeyless(apAdapter)) - require.NoError(t, signature.VerifyObjectAllowUntrusted(apAdapter), "pre-tamper verification") - - // Tamper: attacker adds nslookup to the whitelist. - ap.Spec.Containers[0].Execs = append(ap.Spec.Containers[0].Execs, - v1beta1.ExecCalls{Path: "/usr/bin/nslookup"}) - - // Signature is now invalid. - tamperedAdapter := profiles.NewApplicationProfileAdapter(ap) - require.Error(t, signature.VerifyObjectAllowUntrusted(tamperedAdapter), - "tampered AP must fail verification") - - // Push tampered AP to storage (signature annotations are stale). - _, err := storageClient.ApplicationProfiles(ns.Name).Create( - context.Background(), ap, metav1.CreateOptions{}) - require.NoError(t, err, "push tampered AP to storage") - - // Verify stored AP has stale signature. - require.Eventually(t, func() bool { - stored, getErr := storageClient.ApplicationProfiles(ns.Name).Get( - context.Background(), "signed-ap", v1.GetOptions{}) - if getErr != nil { - return false - } - storedAdapter := profiles.NewApplicationProfileAdapter(stored) - // Signature annotation exists but verification should fail. - if !signature.IsSigned(storedAdapter) { - return false - } - return signature.VerifyObjectAllowUntrusted(storedAdapter) != nil - }, 30*time.Second, 1*time.Second, "stored AP must have stale signature that fails verification") - t.Log("Stored AP has invalid signature (tamper detected at crypto layer)") - - // Deploy pod referencing the tampered profile. - wl, err := testutils.NewTestWorkload(ns.Name, - path.Join(utils.CurrentDir(), "resources/curl-signed-deployment.yaml")) - require.NoError(t, err) - require.NoError(t, wl.WaitForReady(80)) - - // Drive the unexpected exec inside Eventually so cache-load latency - // is absorbed by retries instead of a blind sleep. Same pattern as - // Test_29 (signed AP, anomalous exec) — without it, the first exec - // can land before the CP cache projects the user-defined AP, the - // rule manager evaluates against an empty baseline, and R0001 never - // fires within the polling window. - // - // wget is NOT in the AP (even after the attacker added nslookup), so - // once the cache loads, every wget exec produces an R0001 alert. - var alerts []testutils.Alert - require.Eventually(t, func() bool { - wl.ExecIntoPod([]string{"wget", "-qO-", "--timeout=2", "http://ebpf.io"}, "curl") - alerts, err = testutils.GetAlerts(ns.Name) - if err != nil { - return false - } - for _, a := range alerts { - if a.Labels["rule_id"] == "R0001" && a.Labels["comm"] == "wget" { - return true - } - } - return false - }, 120*time.Second, 10*time.Second, - "wget not in tampered AP must fire R0001 — proves tampered profile was loaded (enforcement off)") - - // Settle so any pending alerts flush, then dump for diagnostics. - time.Sleep(10 * time.Second) - alerts, _ = testutils.GetAlerts(ns.Name) - t.Logf("=== %d alerts ===", len(alerts)) - 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"]) - } - - // With enableSignatureVerification=true: - // - The tampered AP would be rejected (verifyUserApplicationProfile returns false) - // - The pod would have no baseline → no anomaly rules fire for wget - // - System fails OPEN (attacker evades detection by tampering the profile) - // - NOTE: user-defined NNs are not yet gated on the same flag (known gap) - // R1016 ("Signed profile tampered") fires regardless of the flag — that - // path is handled by Test_31. - t.Log("With enableSignatureVerification=true, the tampered profile would be silently rejected.") - }) -} - -// Test_31_TamperDetectionAlert verifies that R1016 "Signed profile tampered" -// fires when a previously signed ApplicationProfile or NetworkNeighborhood -// has been tampered with (signature annotations stale relative to the -// resource bytes). -// -// Coverage: -// 31a — tampered AP fires R1016 (the original scenario; regression-pinned -// after upstream PR #788's cache rewrite re-wired alert emission). -// 31b — untampered signed AP does NOT fire R1016 (negative; signature -// verifies cleanly so no alert). -// 31c — unsigned AP does NOT fire R1016 (signing is opt-in; not-signed -// is not the same as tampered). -// 31d — tampered NN fires R1016 via the parallel NN code path (different -// storage call, same emission contract). -// -// All four subtests share signSignedAP / signSignedNN helpers; each subtest -// uses its own namespace + its own AP/NN name to avoid alert cross-talk -// between scenarios. -// -// R1016 fires regardless of cfg.EnableSignatureVerification: the alert is -// always emitted on tamper; the flag only gates whether the cache also -// rejects the load. -func Test_31_TamperDetectionAlert(t *testing.T) { - start := time.Now() - defer tearDownTest(t, start) - - k8sClient := k8sinterface.NewKubernetesApi() - storageClient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) - - // signSignedAP returns a signed ApplicationProfile in nsName under name. - // - // IMPORTANT: storage's PreSave normalises spec content (DeflateSortString - // sorts+dedupes Syscalls/Capabilities/Architectures, DeflateStringer - // dedupes Execs, AnalyzeOpens/Endpoints/UnifyIdentifiedCallStacks - // rewrite their respective slices, GetContent injects empty - // PolicyByRuleId maps, and K8s itself may default fields). Signing - // locally and then pushing to storage makes the SIGNED hash mismatch - // the POST-STORE content hash that node-agent's tamper check sees, - // firing R1016 on an untampered profile. - // - // Sign-after-roundtrip eliminates every drift source at once: push - // the AP unsigned, read back the storage-normalised form, sign THAT, - // and let the caller push the signed version (deployAndWait does an - // Update-or-Create, so the second push goes through the same - // idempotent deflate and produces the same content hash). - signSignedAP := func(t *testing.T, nsName, name string) *v1beta1.ApplicationProfile { - t.Helper() - // Pre-sort syscalls so the first roundtrip is a no-op for that field - // — keeps the assertion that "deflate is idempotent on already-sorted - // content" honest. - syscalls := []string{"close", "connect", "openat", "read", "socket", "write"} - ap := &v1beta1.ApplicationProfile{ - ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: nsName}, - Spec: v1beta1.ApplicationProfileSpec{ - Containers: []v1beta1.ApplicationProfileContainer{ - { - Name: "curl", - Execs: []v1beta1.ExecCalls{ - {Path: "/bin/sleep"}, - {Path: "/usr/bin/curl"}, - }, - Syscalls: syscalls, - }, - }, - }, - } - - // Round-trip 1: push unsigned, read back the normalised form. - _, err := storageClient.ApplicationProfiles(nsName).Create( - context.Background(), ap, metav1.CreateOptions{}) - require.NoError(t, err, "create unsigned AP for normalisation") - var stored *v1beta1.ApplicationProfile - require.Eventually(t, func() bool { - s, gerr := storageClient.ApplicationProfiles(nsName).Get( - context.Background(), name, v1.GetOptions{}) - if gerr != nil { - return false - } - stored = s - return true - }, 30*time.Second, 1*time.Second, "AP must be retrievable after unsigned create") - - // Sign the storage-normalised content. Now the hash in the signature - // annotation matches what node-agent will see when it loads the AP. - require.NoError(t, - signature.SignObjectDisableKeyless(profiles.NewApplicationProfileAdapter(stored)), - "sign storage-normalised AP") - - // Delete the unsigned in-storage copy so the caller's deployAndWait - // Create succeeds without an AlreadyExists conflict. Storage will - // re-deflate the signed AP on the second push; since that content - // is already normalised, deflate is a no-op and the hash stays - // stable. - require.NoError(t, - storageClient.ApplicationProfiles(nsName).Delete( - context.Background(), name, metav1.DeleteOptions{}), - "delete unsigned AP before caller re-pushes signed version") - // Strip server-managed metadata so the Create call doesn't see a - // stale resourceVersion / uid / creationTimestamp. - stored.ObjectMeta.ResourceVersion = "" - stored.ObjectMeta.UID = "" - stored.ObjectMeta.CreationTimestamp = v1.Time{} - stored.ObjectMeta.Generation = 0 - return stored - } - - signSignedNN := func(t *testing.T, nsName, name string) *v1beta1.NetworkNeighborhood { - t.Helper() - nn := &v1beta1.NetworkNeighborhood{ - ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: nsName}, - Spec: v1beta1.NetworkNeighborhoodSpec{ - LabelSelector: metav1.LabelSelector{MatchLabels: map[string]string{"app": "curl-signed"}}, - Containers: []v1beta1.NetworkNeighborhoodContainer{ - {Name: "curl"}, - }, - }, - } - require.NoError(t, signature.SignObjectDisableKeyless(profiles.NewNetworkNeighborhoodAdapter(nn)), "sign NN") - return nn - } - - // deployAndWait pushes the AP (and optionally NN) into storage, then - // deploys curl-signed-deployment.yaml and waits for it to come up. The - // deployment YAML uses kubescape.io/user-defined-profile=signed-ap as - // its label, so AP+NN names must equal "signed-ap" for the upstream - // CP cache to pick them up. - deployAndWait := func(t *testing.T, ns testutils.TestNamespace, ap *v1beta1.ApplicationProfile, nn *v1beta1.NetworkNeighborhood) *testutils.TestWorkload { - t.Helper() - if ap != nil { - _, err := storageClient.ApplicationProfiles(ns.Name).Create( - context.Background(), ap, metav1.CreateOptions{}) - require.NoError(t, err, "push AP to storage") - } - if nn != nil { - _, err := storageClient.NetworkNeighborhoods(ns.Name).Create( - context.Background(), nn, metav1.CreateOptions{}) - require.NoError(t, err, "push NN to storage") - } - require.Eventually(t, func() bool { - if ap != nil { - if _, err := storageClient.ApplicationProfiles(ns.Name).Get( - context.Background(), ap.Name, v1.GetOptions{}); err != nil { - return false - } - } - if nn != nil { - if _, err := storageClient.NetworkNeighborhoods(ns.Name).Get( - context.Background(), nn.Name, v1.GetOptions{}); err != nil { - return false - } - } - return true - }, 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-signed-deployment.yaml")) - require.NoError(t, err) - require.NoError(t, wl.WaitForReady(80)) - return wl - } - - countR1016 := func(t *testing.T, nsName string, settle time.Duration) int { - t.Helper() - // Allow node-agent to load the profile and for any alert to flush. - time.Sleep(settle) - alerts, err := testutils.GetAlerts(nsName) - if err != nil { - t.Logf("GetAlerts error: %v", err) - return 0 - } - n := 0 - for _, a := range alerts { - if a.Labels["rule_id"] == "R1016" { - n++ - assert.Equal(t, "Signed profile tampered", a.Labels["rule_name"], - "R1016 alert must have correct rule name") - assert.Equal(t, nsName, a.Labels["namespace"], - "R1016 alert must have correct namespace") - } - } - t.Logf("[%s] R1016 count = %d (out of %d alerts)", nsName, n, len(alerts)) - return n - } - - // ----------------------------------------------------------------- - // 31a — tampered AP fires R1016 - // ----------------------------------------------------------------- - t.Run("tampered_user_defined_AP_fires_R1016", func(t *testing.T) { - ns := testutils.NewRandomNamespace() - ap := signSignedAP(t, ns.Name, "signed-ap") - // Tamper after signing: append an unauthorized exec entry. The - // signature annotations stay (stale). - ap.Spec.Containers[0].Execs = append(ap.Spec.Containers[0].Execs, - v1beta1.ExecCalls{Path: "/usr/bin/nslookup"}) - require.Error(t, - signature.VerifyObjectAllowUntrusted(profiles.NewApplicationProfileAdapter(ap)), - "tampered AP must fail verification") - - _ = deployAndWait(t, ns, ap, nil) - - require.Eventually(t, func() bool { - alerts, _ := testutils.GetAlerts(ns.Name) - for _, a := range alerts { - if a.Labels["rule_id"] == "R1016" { - return true - } - } - return false - }, 120*time.Second, 5*time.Second, "tampered AP must produce R1016") - - require.Greater(t, countR1016(t, ns.Name, 5*time.Second), 0) - }) - - // ----------------------------------------------------------------- - // 31b — untampered signed AP must NOT fire R1016 - // ----------------------------------------------------------------- - t.Run("untampered_signed_AP_no_R1016", func(t *testing.T) { - ns := testutils.NewRandomNamespace() - ap := signSignedAP(t, ns.Name, "signed-ap") - // Don't tamper. Signature verifies cleanly. - require.NoError(t, - signature.VerifyObjectAllowUntrusted(profiles.NewApplicationProfileAdapter(ap)), - "untampered signed AP must verify") - - _ = deployAndWait(t, ns, ap, nil) - // Wait for cache load to happen (cache picks it up within ~15s). - assert.Equal(t, 0, countR1016(t, ns.Name, 30*time.Second), - "untampered signed AP must NOT fire R1016") - }) - - // ----------------------------------------------------------------- - // 31c — unsigned AP must NOT fire R1016 (signing is opt-in) - // ----------------------------------------------------------------- - t.Run("unsigned_AP_no_R1016", func(t *testing.T) { - ns := testutils.NewRandomNamespace() - ap := &v1beta1.ApplicationProfile{ - ObjectMeta: metav1.ObjectMeta{Name: "signed-ap", Namespace: ns.Name}, - Spec: v1beta1.ApplicationProfileSpec{ - Containers: []v1beta1.ApplicationProfileContainer{ - { - Name: "curl", - Execs: []v1beta1.ExecCalls{ - {Path: "/bin/sleep"}, - }, - Syscalls: []string{"socket"}, - }, - }, - }, - } - require.False(t, - signature.IsSigned(profiles.NewApplicationProfileAdapter(ap)), - "unsigned AP must not have signature annotations") - - _ = deployAndWait(t, ns, ap, nil) - assert.Equal(t, 0, countR1016(t, ns.Name, 30*time.Second), - "unsigned AP must NOT fire R1016 — not-signed is not the same as tampered") - }) - - // ----------------------------------------------------------------- - // 31d — tampered NN fires R1016 via the NN code path - // ----------------------------------------------------------------- - t.Run("tampered_user_defined_NN_fires_R1016", func(t *testing.T) { - ns := testutils.NewRandomNamespace() - // Untampered AP (matched on name to the pod label) so the AP path - // stays silent and we know any R1016 came from the NN path. - ap := signSignedAP(t, ns.Name, "signed-ap") - nn := signSignedNN(t, ns.Name, "signed-ap") - // Tamper the NN: add a container the original signature didn't cover. - nn.Spec.Containers = append(nn.Spec.Containers, - v1beta1.NetworkNeighborhoodContainer{Name: "drift"}) - require.Error(t, - signature.VerifyObjectAllowUntrusted(profiles.NewNetworkNeighborhoodAdapter(nn)), - "tampered NN must fail verification") - - _ = deployAndWait(t, ns, ap, nn) - - require.Eventually(t, func() bool { - alerts, _ := testutils.GetAlerts(ns.Name) - for _, a := range alerts { - if a.Labels["rule_id"] == "R1016" { - return true - } - } - return false - }, 120*time.Second, 5*time.Second, "tampered NN must produce R1016") - - require.Greater(t, countR1016(t, ns.Name, 5*time.Second), 0) - }) - -} - -// --------------------------------------------------------------------------- -// Test_32_UnexpectedProcessArguments — component test for the wildcard-aware -// exec-argument matching (R0040). Each subtest gets its own namespace so -// alerts don't cross-contaminate. -// -// AP overlay declares 4 allowed exec patterns for the curl pod. Profile -// shape: -// - Path = full kernel-resolved exec path (used by parse.get_exec_path -// + ap.was_executed for path-level matching) -// - Args[0] = ABSOLUTE invoking path (e.g. "/bin/sh"). Matches runtime -// argv[0] as captured by eBPF after the symlink-faithful -// precedence fix (parse.get_exec_path / resolveExecPath -// prefer absolute argv[0] over kernel exepath when argv[0] -// starts with "/"). Recording side records the same form -// via the matching precedence in -// pkg/containerprofilemanager/v1/event_reporting.go:: -// resolveExecPath, so profile.Args[0] agrees with what -// CompareExecArgs compares against at rule-eval time. See -// pkg/rulemanager/cel/libraries/parse/parse.go for the -// live precedence definition. -// -// /bin/sleep [/bin/sleep, *] — pod startup, must stay silent -// /bin/sh [/bin/sh, -c, *] — sh -c -// /bin/echo [/bin/echo, hello, *] — echo hello -// /usr/bin/curl [/usr/bin/curl, -s, ⋯] — curl -s -// -// Profile loaded into the new ContainerProfileCache via the unified -// kubescape.io/user-defined-profile= label. The exec.go CEL function -// routes ap.was_executed_with_args through dynamicpathdetector.CompareExecArgs -// — see storage/pkg/registry/file/dynamicpathdetector/tests/ -// compare_exec_args_test.go::TestCompareExecArgs_Argv0BareName for the -// matcher-level contract these subtests rest on. -// -// R0040 ("Unexpected process arguments") fires when: -// - the exec'd path IS in the profile (R0001 silent), AND -// - the runtime arg vector does NOT match any profile entry's pattern. -// -// Each subtest asserts R0001 silence as a PRECONDITION (path resolution -// works), THEN asserts presence/absence of R0040. If R0001 fires, the -// failure points at the recording-side exepath capture (event.exepath -// empty AND argv[0] not absolute → parse.get_exec_path falls back to -// bare comm → profile -// Path lookup misses), not at R0040 logic. Separating the two axes -// stops Test_32 from flaking on unrelated capture-layer gaps. -// --------------------------------------------------------------------------- -func Test_32_UnexpectedProcessArguments(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 { - 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}}, - }, - 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") - - require.Eventually(t, func() bool { - _, apErr := storageClient.ApplicationProfiles(ns.Name).Get( - context.Background(), overlayName, v1.GetOptions{}) - return apErr == nil - }, 30*time.Second, 1*time.Second, "AP 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)) - // let node-agent load the user AP into the CP cache - time.Sleep(15 * time.Second) - return wl - } - - 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 := setup(t) - stdout, stderr, err := wl.ExecIntoPod([]string{"sh", "-c", "echo hi"}, "curl") - t.Logf("sh -c 'echo hi' → err=%v stdout=%q stderr=%q", err, stdout, stderr) - - alerts := waitAlerts(t, wl.Namespace) - t.Logf("=== %d alerts ===", len(alerts)) - logAlerts(t, alerts) - - assertR0001Silent(t, alerts, "sh") - assert.Equal(t, 0, countByRule(alerts, "R0040"), - "sh -c matches profile [sh, -c, *] — R0040 must stay silent") - }) - - // ----------------------------------------------------------------- - // 32b. sh -x — argv [sh, -x, "echo hi"] does NOT match - // profile [sh, -c, *] (literal anchor `-c` mismatch). Path - // /bin/sh IS in profile so R0001 stays silent. R0040 must fire. - // ----------------------------------------------------------------- - t.Run("sh_dash_x_mismatches_R0040", func(t *testing.T) { - wl := setup(t) - stdout, stderr, err := wl.ExecIntoPod([]string{"sh", "-x", "echo hi"}, "curl") - t.Logf("sh -x 'echo hi' → err=%v stdout=%q stderr=%q", err, stdout, stderr) - - alerts := waitAlerts(t, wl.Namespace) - t.Logf("=== %d alerts ===", len(alerts)) - logAlerts(t, alerts) - - assertR0001Silent(t, alerts, "sh") - require.Greater(t, countByRule(alerts, "R0040"), 0, - "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 := setup(t) - stdout, stderr, err := wl.ExecIntoPod([]string{"echo", "hello", "world", "from", "test"}, "curl") - t.Logf("echo hello world from test → err=%v stdout=%q stderr=%q", err, stdout, stderr) - - alerts := waitAlerts(t, wl.Namespace) - t.Logf("=== %d alerts ===", len(alerts)) - logAlerts(t, alerts) - - assertR0001Silent(t, alerts, "echo") - assert.Equal(t, 0, 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 := setup(t) - stdout, stderr, err := wl.ExecIntoPod([]string{"echo", "goodbye", "world"}, "curl") - t.Logf("echo goodbye world → err=%v stdout=%q stderr=%q", err, stdout, stderr) - - alerts := waitAlerts(t, wl.Namespace) - t.Logf("=== %d alerts ===", len(alerts)) - logAlerts(t, alerts) - - assertR0001Silent(t, alerts, "echo") - require.Greater(t, countByRule(alerts, "R0040"), 0, - "echo goodbye mismatches profile [echo, hello, *] (literal anchor) → R0040 must fire") - }) -} - -// Test_33_AnalyzeOpensWildcardAnchoring pins the wildcard-matching -// contract that storage-side CompareDynamic enforces, end-to-end through -// R0002 ("Files Access Anomalies in container"). -// -// Each subtest spins up a fresh nginx pod with a user-defined AP that -// carries ONE Opens entry, then `cat`s a target path that probes a -// boundary case from the storage-side analyzer fixes (kubescape/storage -// PR #316 review by matthyx + entlein): -// -// - Anchored trailing `*` matches one OR MORE remaining segments — -// never zero. So `/etc/*` matches `/etc/passwd` but NOT the bare -// `/etc` directory. Without this rule, R0002 silently allowed -// access to the parent of any profiled directory. -// - DynamicIdentifier (⋯) consumes EXACTLY ONE segment. -// - Mid-path `*` consumes ZERO or more, so `/etc/*/*` still matches -// `/etc/ssh` (inner `*` consumed zero, trailing `*` consumed one). -// - splitPath normalises trailing slashes on both dynamic and -// regular paths so `/etc/passwd/` is treated as `/etc/passwd`. -// - Mixed `⋯/*` patterns: ⋯ pins one segment, `*` consumes the rest -// (with one-or-more semantics). -// -// Component-level pin sits ON TOP of the unit tests in storage's -// pkg/registry/file/dynamicpathdetector/tests/coverage_test.go. -// Both layers must agree — if the unit suite drifts away from these -// runtime expectations, R0002 has either a false-positive or a -// false-negative bug. func Test_32_UnexpectedProcessArguments(t *testing.T) { start := time.Now() defer tearDownTest(t, start) From 0d1b11dd0b6ee34eb32ad200ae184c9825613d09 Mon Sep 17 00:00:00 2001 From: Entlein Date: Thu, 28 May 2026 14:23:14 +0200 Subject: [PATCH 10/17] build(go.mod): tidy + pin runtime-spec v1.2.1 for -mod=readonly clean MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Storage v0.0.278 bump pulled in a transitive dep set that drove runtime-spec up to v1.3.0, which is incompatible with containerd v1.7.32's spec_opts.go (cannot use int64 as *int64). Upstream NA main runs on runtime-spec v1.2.1; pin it the same way here via replace so 'go test -mod=readonly' is clean (matthyx blocker 2 on PR #807, 2026-05-28). Also runs full mod tidy now that v0.0.278 has shipped — populates the transitive go.sum entries that were missing under the previous sister-branch replace, so CI's -mod=readonly accepts the module graph. Signed-off-by: entlein --- go.mod | 2 ++ go.sum | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index f9e8102d3..1b53d7931 100644 --- a/go.mod +++ b/go.mod @@ -481,3 +481,5 @@ replace github.com/inspektor-gadget/inspektor-gadget => github.com/matthyx/inspe replace github.com/cilium/ebpf => github.com/matthyx/ebpf v0.0.0-20260421101317-8a32d06def6c replace github.com/anchore/syft => github.com/kubescape/syft v1.32.0-ks.2 + +replace github.com/opencontainers/runtime-spec => github.com/opencontainers/runtime-spec v1.2.1 diff --git a/go.sum b/go.sum index a91178585..815469270 100644 --- a/go.sum +++ b/go.sum @@ -1091,8 +1091,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= -github.com/opencontainers/runtime-spec v1.3.0 h1:YZupQUdctfhpZy3TM39nN9Ika5CBWT5diQ8ibYCRkxg= -github.com/opencontainers/runtime-spec v1.3.0/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= +github.com/opencontainers/runtime-spec v1.2.1 h1:S4k4ryNgEpxW1dzyqffOmhI1BHYcjzU8lpJfSlR0xww= +github.com/opencontainers/runtime-spec v1.2.1/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= github.com/opencontainers/runtime-tools v0.9.1-0.20251114084447-edf4cb3d2116 h1:tAKu3NkKWZYpqBSOJKwTxT1wIGueiF7gcmcNgr5pNTY= github.com/opencontainers/runtime-tools v0.9.1-0.20251114084447-edf4cb3d2116/go.mod h1:DKDEfzxvRkoQ6n9TGhxQgg2IM1lY4aM0eaQP4e3oElw= github.com/opencontainers/selinux v1.13.1 h1:A8nNeceYngH9Ow++M+VVEwJVpdFmrlxsN22F+ISDCJE= From 6d493d315e5382140e3eda8c9ba6e2a7ce44f381 Mon Sep 17 00:00:00 2001 From: Entlein Date: Thu, 28 May 2026 14:29:38 +0200 Subject: [PATCH 11/17] fix(projection): ExecsByPath becomes path -> []argv-vectors Previous shape (map[string][]string) collapsed duplicate Path entries to the last seen, breaking merged profiles where mergeApplicationProfile legitimately appends multiple ExecCalls per path with distinct argv shapes. ap.was_executed_with_args silently rejected valid executions that matched any but the final entry (matthyx blocker 3 on PR #807, 2026-05-28). extractExecsByPath now appends each Args slice to a per-path list. wasExecutedWithArgs iterates the list and returns true when any vector matches via CompareExecArgs. The 'absent key means no argv constraint' back-compat semantic is unchanged. Cloned-slice invariant preserved. Updates TestApply_ExecsByPath_PopulatesFromSpec to assert the appended-list shape and the second-entry mutation invariant. Signed-off-by: entlein --- .../containerprofilecache/projection_apply.go | 33 +++++++++++-------- .../projection_apply_test.go | 19 ++++++----- pkg/objectcache/projection_types.go | 14 +++++--- .../cel/libraries/applicationprofile/exec.go | 19 +++++++---- 4 files changed, 52 insertions(+), 33 deletions(-) diff --git a/pkg/objectcache/containerprofilecache/projection_apply.go b/pkg/objectcache/containerprofilecache/projection_apply.go index ebe62df26..9f94c4715 100644 --- a/pkg/objectcache/containerprofilecache/projection_apply.go +++ b/pkg/objectcache/containerprofilecache/projection_apply.go @@ -167,29 +167,36 @@ func extractExecsPaths(cp *v1beta1.ContainerProfile) []string { return paths } -// extractExecsByPath builds the path → args map used by exec-args -// matchers (e.g. dynamicpathdetector.CompareExecArgs in node-agent#807). -// Multiple ExecCalls entries with the same Path collapse to the last -// seen. nil-Args entries are stored as empty slices; downstream -// matchers distinguish "absent key" (path not in the profile at all) -// from "present with empty slice" (path captured but ran with no args). +// extractExecsByPath builds the path → []argv-vectors map used by +// exec-args matchers (e.g. dynamicpathdetector.CompareExecArgs in +// node-agent#807). Multiple ExecCalls entries with the same Path +// APPEND to the per-path list — overlay merge in +// mergeApplicationProfile (storage) legitimately produces several +// ExecCalls per path, each with a distinct argv shape, and the +// consumer must accept any of them (matthyx review on PR #807, +// 2026-05-28). +// +// nil-Args entries are stored as empty-but-non-nil slices so the +// downstream matcher distinguishes "present with empty args" (a +// deliberate must-be-empty constraint) from "absent" (no key). // // Args slices are CLONED rather than aliased — Apply is contract-bound // to be a pure transform, and an alias would let consumers mutate the // source profile by editing the projected map. -func extractExecsByPath(cp *v1beta1.ContainerProfile) map[string][]string { +func extractExecsByPath(cp *v1beta1.ContainerProfile) map[string][][]string { if len(cp.Spec.Execs) == 0 { return nil } - m := make(map[string][]string, len(cp.Spec.Execs)) + m := make(map[string][][]string, len(cp.Spec.Execs)) for _, e := range cp.Spec.Execs { + var entry []string if e.Args == nil { - m[e.Path] = []string{} - continue + entry = []string{} + } else { + entry = make([]string, len(e.Args)) + copy(entry, e.Args) } - cloned := make([]string, len(e.Args)) - copy(cloned, e.Args) - m[e.Path] = cloned + m[e.Path] = append(m[e.Path], entry) } return m } diff --git a/pkg/objectcache/containerprofilecache/projection_apply_test.go b/pkg/objectcache/containerprofilecache/projection_apply_test.go index a0308d3d6..13b0d2818 100644 --- a/pkg/objectcache/containerprofilecache/projection_apply_test.go +++ b/pkg/objectcache/containerprofilecache/projection_apply_test.go @@ -433,20 +433,23 @@ func TestApply_ExecsByPath_PopulatesFromSpec(t *testing.T) { require.NotNil(t, pcp) require.NotNil(t, pcp.ExecsByPath, "ExecsByPath must be populated") - // last write wins for duplicate Path - assert.Equal(t, []string{"-x", "later"}, pcp.ExecsByPath["/bin/sh"], - "duplicate Path: last ExecCalls entry should win") + // Duplicate Path: BOTH ExecCalls entries appended in source order — + // merged profiles can carry multiple argv variants for the same + // executable path (matthyx review on PR #807, 2026-05-28). + assert.Equal(t, [][]string{{"-c", "echo hi"}, {"-x", "later"}}, pcp.ExecsByPath["/bin/sh"], + "duplicate Path: every ExecCalls entry must be appended, not collapsed") - // nil Args → empty (non-nil) slice + // nil Args → list containing one empty-but-non-nil slice got, present := pcp.ExecsByPath["/bin/echo"] require.True(t, present, "/bin/echo must be present even with nil Args") - require.NotNil(t, got, "/bin/echo Args nil source must project as non-nil empty slice") - assert.Empty(t, got, "/bin/echo nil-Args must project as empty slice") + require.Len(t, got, 1, "/bin/echo with one nil-Args ExecCalls must project as a one-element list") + require.NotNil(t, got[0], "/bin/echo Args nil source must project as non-nil empty slice") + assert.Empty(t, got[0], "/bin/echo nil-Args must project as empty slice") // CLONED-slice invariant: mutating the projection must not affect // the source ContainerProfile spec. - sourceCopy := append([]string{}, cp.Spec.Execs[2].Args...) // current "/bin/sh" - pcp.ExecsByPath["/bin/sh"][0] = "MUTATED" + sourceCopy := append([]string{}, cp.Spec.Execs[2].Args...) // current "/bin/sh" second entry + pcp.ExecsByPath["/bin/sh"][1][0] = "MUTATED" assert.Equal(t, sourceCopy, cp.Spec.Execs[2].Args, "mutating the projected slice must not propagate to the source profile (cloned, not aliased)") } diff --git a/pkg/objectcache/projection_types.go b/pkg/objectcache/projection_types.go index c9d8c3a6b..8d452c7a3 100644 --- a/pkg/objectcache/projection_types.go +++ b/pkg/objectcache/projection_types.go @@ -54,14 +54,18 @@ type ProjectedContainerProfile struct { IngressDomains ProjectedField IngressAddresses ProjectedField - // ExecsByPath carries the per-Path Args slice from cp.Spec.Execs so + // ExecsByPath carries the per-Path Args slices from cp.Spec.Execs so // downstream consumers (e.g. dynamicpathdetector.CompareExecArgs used // by R0040 in node-agent#807) can run wildcard-aware argv matching // against the projected profile. Keyed by Exec.Path (same key used - // in Execs.Values / Execs.Patterns). Projection-v1 dropped argv - // matching as "future work"; this field re-adds the storage surface - // without re-introducing the matcher itself. - ExecsByPath map[string][]string + // in Execs.Values / Execs.Patterns); the value is a LIST of argv + // vectors because a merged profile can contain multiple ExecCalls + // entries with the same Path and different argv shapes — overlay + // merge appends rather than replaces (mergeApplicationProfile in + // storage). A consumer matches if ANY argv vector in the list + // matches the runtime args. Empty/absent value means "no argv + // constraint" (back-compat for pre-projection profiles). + ExecsByPath map[string][][]string SpecHash string SyncChecksum string diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/exec.go b/pkg/rulemanager/cel/libraries/applicationprofile/exec.go index 10e8d4c97..c6ac58eaf 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/exec.go +++ b/pkg/rulemanager/cel/libraries/applicationprofile/exec.go @@ -120,9 +120,11 @@ func (l *apLibrary) wasExecutedWithArgs(containerID, path, args ref.Val) ref.Val // omit the ExecsByPath entry (rare) or use an explicit `*` // wildcard token in Args. if _, ok := cp.Execs.Values[pathStr]; ok { - if profileArgs, ok := cp.ExecsByPath[pathStr]; ok { - if dynamicpathdetector.CompareExecArgs(profileArgs, runtimeArgs) { - return types.Bool(true) + if vectors, ok := cp.ExecsByPath[pathStr]; ok { + for _, profileArgs := range vectors { + if dynamicpathdetector.CompareExecArgs(profileArgs, runtimeArgs) { + return types.Bool(true) + } } } else { // State 2: ExecsByPath absent → back-compat "no argv constraint". @@ -130,12 +132,15 @@ func (l *apLibrary) wasExecutedWithArgs(containerID, path, args ref.Val) ref.Val } } // Pattern path match: dynamic-segment paths in cp.Execs.Patterns. - // Args matching mirrors the exact-path case. + // Args matching mirrors the exact-path case — match against any + // argv vector recorded for that pattern key. for _, execPath := range cp.Execs.Patterns { if dynamicpathdetector.CompareDynamic(execPath, pathStr) { - if profileArgs, ok := cp.ExecsByPath[execPath]; ok { - if dynamicpathdetector.CompareExecArgs(profileArgs, runtimeArgs) { - return types.Bool(true) + if vectors, ok := cp.ExecsByPath[execPath]; ok { + for _, profileArgs := range vectors { + if dynamicpathdetector.CompareExecArgs(profileArgs, runtimeArgs) { + return types.Bool(true) + } } } else { return types.Bool(true) From 6d6eaef9c6f88636df5872cb3b2005c6fe77ea9e Mon Sep 17 00:00:00 2001 From: Entlein Date: Thu, 28 May 2026 14:30:40 +0200 Subject: [PATCH 12/17] test(mock): populate ExecsByPath in RuleObjectCacheMock projection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors containerprofilecache.Apply's extractExecsByPath shape so exec-args unit tests exercise the real wildcard matcher instead of hitting the absent-key 'no argv constraint' back-compat fallback. Same per-Path append + nil→empty-slice rules. Fixes the four TestExecWithArgsWildcardInProfile cases that were silently returning true (back-compat) and getting marked as failing expected:false (sh -x wrong flag, ls -l no directory, echo goodbye world wrong literal anchor, curl --pass alice wrong literal). Signed-off-by: entlein --- pkg/objectcache/v1/mock.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/pkg/objectcache/v1/mock.go b/pkg/objectcache/v1/mock.go index c618e2450..cc780da55 100644 --- a/pkg/objectcache/v1/mock.go +++ b/pkg/objectcache/v1/mock.go @@ -153,8 +153,19 @@ func (r *RuleObjectCacheMock) GetProjectedContainerProfile(containerID string) * if (!specInstalled || spec.Execs.InUse) && len(cp.Spec.Execs) > 0 { pcp.Execs.All = true pcp.Execs.Values = make(map[string]struct{}, len(cp.Spec.Execs)) + pcp.ExecsByPath = make(map[string][][]string, len(cp.Spec.Execs)) for _, e := range cp.Spec.Execs { pcp.Execs.Values[e.Path] = struct{}{} + // Mirror containerprofilecache.Apply's extractExecsByPath: + // append each ExecCalls entry as its own argv vector, + // nil-Args projects to a non-nil empty slice. + var entry []string + if e.Args != nil { + entry = append([]string(nil), e.Args...) + } else { + entry = []string{} + } + pcp.ExecsByPath[e.Path] = append(pcp.ExecsByPath[e.Path], entry) } } From f53816ff6325c406631416a940abaa00a99a8272 Mon Sep 17 00:00:00 2001 From: entlein Date: Thu, 28 May 2026 15:15:31 +0200 Subject: [PATCH 13/17] test 27 is for PR 807, test 32 is for PR 805, now swapping them , also go mod sync with main Signed-off-by: entlein --- .github/workflows/component-tests.yaml | 3 +- .../libraries/applicationprofile/open_test.go | 194 +++++++ tests/component_test.go | 517 ++++++++++++------ .../curl-exec-arg-wildcards-deployment.yaml | 28 - ...url-user-profile-wildcards-deployment.yaml | 21 + .../nginx-user-profile-deployment.yaml | 22 + 6 files changed, 580 insertions(+), 205 deletions(-) delete mode 100644 tests/resources/curl-exec-arg-wildcards-deployment.yaml create mode 100644 tests/resources/curl-user-profile-wildcards-deployment.yaml create mode 100644 tests/resources/nginx-user-profile-deployment.yaml diff --git a/.github/workflows/component-tests.yaml b/.github/workflows/component-tests.yaml index 86612b805..867b66f80 100644 --- a/.github/workflows/component-tests.yaml +++ b/.github/workflows/component-tests.yaml @@ -71,7 +71,8 @@ jobs: Test_21_AlertOnPartialThenLearnNetworkTest, Test_22_AlertOnPartialNetworkProfileTest, Test_23_RuleCooldownTest, - Test_24_ProcessTreeDepthTest + Test_24_ProcessTreeDepthTest, + Test_27_ApplicationProfileOpens ] steps: - name: Checkout code diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/open_test.go b/pkg/rulemanager/cel/libraries/applicationprofile/open_test.go index 9fce787ae..6e72428f0 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/open_test.go +++ b/pkg/rulemanager/cel/libraries/applicationprofile/open_test.go @@ -645,3 +645,197 @@ func TestOpenWithPrefixCompilation(t *testing.T) { t.Fatalf("failed to create program: %v", err) } } +func TestOpenWithFlagsInProfile(t *testing.T) { + objCache := objectcachev1.RuleObjectCacheMock{ + ContainerIDToSharedData: maps.NewSafeMap[string, *objectcache.WatchedContainerData](), + } + + objCache.SetSharedContainerData("test-container-id", &objectcache.WatchedContainerData{ + ContainerType: objectcache.Container, + ContainerInfos: map[objectcache.ContainerType][]objectcache.ContainerInfo{ + objectcache.Container: { + { + Name: "test-container", + }, + }, + }, + }) + + 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"}, + }, + { + Path: "/var/log/app.log", + Flags: []string{"O_RDWR", "O_APPEND"}, + }, + }, + }) + objCache.SetApplicationProfile(profile) + + 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) + } + + testCases := []struct { + 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", + containerID: "test-container-id", + path: "/etc/passwd", + flags: []string{"O_WRONLY"}, + expectedResult: true, + }, + { + name: "Path doesn't exist", + 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", + 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)`) + 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) + } + + 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") + }) + } +} + +func TestOpenWithFlagsNoProfile(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)`) + 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) + } + + 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) + } +} + diff --git a/tests/component_test.go b/tests/component_test.go index b282c6b9c..4a87d112a 100644 --- a/tests/component_test.go +++ b/tests/component_test.go @@ -1569,231 +1569,396 @@ func Test_24_ProcessTreeDepthTest(t *testing.T) { t.Logf("Found alerts for the process tree depth: %v", alerts) } -func Test_32_UnexpectedProcessArguments(t *testing.T) { + +// Test_27_ApplicationProfileOpens tests that the dynamic path matching in +// application profiles works correctly for both recorded (auto-learned) +// profiles and user-defined profiles. +// +// Path matching symbols: +// +// ⋯ (U+22EF DynamicIdentifier) — matches exactly ONE path segment +// * (WildcardIdentifier) — matches ZERO or more path segments +// 0 (in endpoints) — wildcard port (any port) +// +// R0002 "Files Access Anomalies in container" fires when a file is opened +// under a monitored prefix (/etc/, /var/log/, …) and the path was NOT +// recorded in the application profile. +func Test_27_ApplicationProfileOpens(t *testing.T) { start := time.Now() defer tearDownTest(t, start) - const overlayName = "curl-32-overlay" + 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("======================================") + }() - setup := func(t *testing.T) *testutils.TestWorkload { + // 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() - k8sClient := k8sinterface.NewKubernetesApi() - storageClient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) - ap := &v1beta1.ApplicationProfile{ + profile := &v1beta1.ApplicationProfile{ ObjectMeta: metav1.ObjectMeta{ - Name: overlayName, + Name: profileName, Namespace: ns.Name, }, Spec: v1beta1.ApplicationProfileSpec{ + Architectures: []string{"amd64"}, Containers: []v1beta1.ApplicationProfileContainer{ { - Name: "curl", + Name: "nginx", 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}}, + {Path: "/bin/cat", Args: []string{"/bin/cat"}}, }, - 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"}, + Opens: opens, }, }, }, } + + k8sClient := k8sinterface.NewKubernetesApi() + storageClient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) _, err := storageClient.ApplicationProfiles(ns.Name).Create( - context.Background(), ap, metav1.CreateOptions{}) - require.NoError(t, err, "create AP") + 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(), overlayName, v1.GetOptions{}) + context.Background(), profileName, v1.GetOptions{}) return apErr == nil - }, 30*time.Second, 1*time.Second, "AP must be in storage before pod deploy") + }, 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/curl-exec-arg-wildcards-deployment.yaml")) - require.NoError(t, err) - require.NoError(t, wl.WaitForReady(80)) - // let node-agent load the user AP into the CP cache - time.Sleep(15 * time.Second) - return wl - } + 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) - countByRule := func(alerts []testutils.Alert, ruleID string) int { - n := 0 - for _, a := range alerts { - if a.Labels["rule_id"] == ruleID { - n++ - } - } - return n + // Wait for node-agent to load the user-defined profile into cache. + time.Sleep(10 * time.Second) + return wl } - waitAlerts := func(t *testing.T, ns string) []testutils.Alert { + // 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 - var err error require.Eventually(t, func() bool { - alerts, err = testutils.GetAlerts(ns) + alerts, err = testutils.GetAlerts(wl.Namespace) return err == nil - }, 60*time.Second, 5*time.Second, "must be able to fetch alerts") - // settle time for any in-flight alerts + }, 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, _ = testutils.GetAlerts(ns) + alerts, err = testutils.GetAlerts(wl.Namespace) + require.NoError(t, err, "get alerts from ns %s", wl.Namespace) 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"]) + // 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 } - // 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++ + // --------------------------------------------------------------- + // 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 + } } } - 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) - } + 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))) + }) - // ----------------------------------------------------------------- - // 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 := setup(t) - stdout, stderr, err := wl.ExecIntoPod([]string{"sh", "-c", "echo hi"}, "curl") - t.Logf("sh -c 'echo hi' → err=%v stdout=%q stderr=%q", err, stdout, stderr) - - alerts := waitAlerts(t, wl.Namespace) - t.Logf("=== %d alerts ===", len(alerts)) - logAlerts(t, alerts) - - assertR0001Silent(t, alerts, "sh") - assert.Equal(t, 0, countByRule(alerts, "R0040"), - "sh -c matches profile [sh, -c, *] — R0040 must stay silent") + // 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))) }) - // ----------------------------------------------------------------- - // 32b. sh -x — argv [sh, -x, "echo hi"] does NOT match - // profile [sh, -c, *] (literal anchor `-c` mismatch). Path - // /bin/sh IS in profile so R0001 stays silent. R0040 must fire. - // ----------------------------------------------------------------- - t.Run("sh_dash_x_mismatches_R0040", func(t *testing.T) { - wl := setup(t) - stdout, stderr, err := wl.ExecIntoPod([]string{"sh", "-x", "echo hi"}, "curl") - t.Logf("sh -x 'echo hi' → err=%v stdout=%q stderr=%q", err, stdout, stderr) - - alerts := waitAlerts(t, wl.Namespace) - t.Logf("=== %d alerts ===", len(alerts)) - logAlerts(t, alerts) - - assertR0001Silent(t, alerts, "sh") - require.Greater(t, countByRule(alerts, "R0040"), 0, - "sh -x mismatches profile [sh, -c, *] → R0040 must fire") + // 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))) }) - // ----------------------------------------------------------------- - // 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 := setup(t) - stdout, stderr, err := wl.ExecIntoPod([]string{"echo", "hello", "world", "from", "test"}, "curl") - t.Logf("echo hello world from test → err=%v stdout=%q stderr=%q", err, stdout, stderr) - - alerts := waitAlerts(t, wl.Namespace) - t.Logf("=== %d alerts ===", len(alerts)) - logAlerts(t, alerts) - - assertR0001Silent(t, alerts, "echo") - assert.Equal(t, 0, countByRule(alerts, "R0040"), - "echo hello matches profile [echo, hello, *] — R0040 must stay silent") + // 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))) }) - // ----------------------------------------------------------------- - // 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 := setup(t) - stdout, stderr, err := wl.ExecIntoPod([]string{"echo", "goodbye", "world"}, "curl") - t.Logf("echo goodbye world → err=%v stdout=%q stderr=%q", err, stdout, stderr) - - alerts := waitAlerts(t, wl.Namespace) - t.Logf("=== %d alerts ===", len(alerts)) - logAlerts(t, alerts) - - assertR0001Silent(t, alerts, "echo") - require.Greater(t, countByRule(alerts, "R0040"), 0, - "echo goodbye mismatches profile [echo, hello, *] (literal anchor) → R0040 must fire") + // --------------------------------------------------------------- + // 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_33_AnalyzeOpensWildcardAnchoring pins the wildcard-matching -// contract that storage-side CompareDynamic enforces, end-to-end through -// R0002 ("Files Access Anomalies in container"). -// -// Each subtest spins up a fresh nginx pod with a user-defined AP that -// carries ONE Opens entry, then `cat`s a target path that probes a -// boundary case from the storage-side analyzer fixes (kubescape/storage -// PR #316 review by matthyx + entlein): -// -// - Anchored trailing `*` matches one OR MORE remaining segments — -// never zero. So `/etc/*` matches `/etc/passwd` but NOT the bare -// `/etc` directory. Without this rule, R0002 silently allowed -// access to the parent of any profiled directory. -// - DynamicIdentifier (⋯) consumes EXACTLY ONE segment. -// - Mid-path `*` consumes ZERO or more, so `/etc/*/*` still matches -// `/etc/ssh` (inner `*` consumed zero, trailing `*` consumed one). -// - splitPath normalises trailing slashes on both dynamic and -// regular paths so `/etc/passwd/` is treated as `/etc/passwd`. -// - Mixed `⋯/*` patterns: ⋯ pins one segment, `*` consumes the rest -// (with one-or-more semantics). -// -// Component-level pin sits ON TOP of the unit tests in storage's -// pkg/registry/file/dynamicpathdetector/tests/coverage_test.go. -// Both layers must agree — if the unit suite drifts away from these -// runtime expectations, R0002 has either a false-positive or a -// false-negative bug. + + diff --git a/tests/resources/curl-exec-arg-wildcards-deployment.yaml b/tests/resources/curl-exec-arg-wildcards-deployment.yaml deleted file mode 100644 index 2f06f8bae..000000000 --- a/tests/resources/curl-exec-arg-wildcards-deployment.yaml +++ /dev/null @@ -1,28 +0,0 @@ -## 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; the NN side is intentionally absent. -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 - spec: - containers: - - name: curl - image: docker.io/curlimages/curl@sha256:08e466006f0860e54fc299378de998935333e0e130a15f6f98482e9f8dab3058 - command: ["sleep", "infinity"] 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 From 46d46b1cc24e20f74e19e55c5fcb15f89ef966b2 Mon Sep 17 00:00:00 2001 From: entlein Date: Thu, 28 May 2026 15:54:14 +0200 Subject: [PATCH 14/17] restoring earlier go dependencies Signed-off-by: entlein --- go.mod | 22 +++++++++------------- go.sum | 44 ++++++++++++++++++++------------------------ 2 files changed, 29 insertions(+), 37 deletions(-) diff --git a/go.mod b/go.mod index 1b53d7931..3e4394e8e 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,7 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 github.com/cilium/ebpf v0.20.0 github.com/crewjam/rfc5424 v0.1.0 - github.com/cyphar/filepath-securejoin v0.6.1 + github.com/cyphar/filepath-securejoin v0.6.0 github.com/deckarep/golang-set/v2 v2.8.0 github.com/dghubble/trie v0.1.0 github.com/distribution/distribution v2.8.2+incompatible @@ -184,7 +184,7 @@ require ( github.com/cloudflare/circl v1.6.3 // indirect github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect github.com/containerd/cgroups/v3 v3.1.2 // indirect - github.com/containerd/containerd v1.7.32 // indirect + github.com/containerd/containerd v1.7.30 // indirect github.com/containerd/containerd/api v1.10.0 // indirect github.com/containerd/containerd/v2 v2.2.1 // indirect github.com/containerd/continuity v0.4.5 // indirect @@ -233,8 +233,8 @@ require ( github.com/go-errors/errors v1.5.1 // indirect github.com/go-fonts/liberation v0.3.2 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect - github.com/go-git/go-billy/v5 v5.9.0 // indirect - github.com/go-git/go-git/v5 v5.19.1 // indirect + github.com/go-git/go-billy/v5 v5.8.0 // indirect + github.com/go-git/go-git/v5 v5.18.0 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-latex/latex v0.0.0-20231108140139-5c1ce85aa4ea // indirect github.com/go-ldap/ldap/v3 v3.4.10 // indirect @@ -283,8 +283,7 @@ require ( github.com/hashicorp/hcl/v2 v2.24.0 // indirect github.com/huandu/xstrings v1.5.0 // indirect github.com/iancoleman/strcase v0.3.0 // indirect - github.com/in-toto/attestation v1.1.2 // indirect - github.com/in-toto/in-toto-golang v0.11.0 // indirect + github.com/in-toto/in-toto-golang v0.9.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/jinzhu/copier v0.4.0 // indirect @@ -295,7 +294,6 @@ require ( github.com/kastenhq/goversion v0.0.0-20230811215019-93b2f8823953 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/klauspost/compress v1.18.5 // indirect - github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/pgzip v1.2.6 // indirect github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect @@ -355,7 +353,7 @@ require ( github.com/peterbourgon/diskv v2.0.1+incompatible // indirect github.com/petermattis/goid v0.0.0-20241211131331-93ee7e083c43 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect - github.com/pjbgf/sha1cd v0.6.0 // indirect + github.com/pjbgf/sha1cd v0.4.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pkg/profile v1.7.0 // indirect github.com/pkg/xattr v0.4.12 // indirect @@ -375,7 +373,7 @@ require ( github.com/sassoftware/go-rpmutils v0.4.0 // indirect github.com/scylladb/go-set v1.0.3-0.20200225121959-cc7b2070d91e // indirect github.com/seccomp/libseccomp-golang v0.11.0 // indirect - github.com/secure-systems-lab/go-securesystemslib v0.10.0 // indirect + github.com/secure-systems-lab/go-securesystemslib v0.9.1 // indirect github.com/sergi/go-diff v1.4.0 // indirect github.com/shibumi/go-pathspec v1.3.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect @@ -440,7 +438,7 @@ require ( go4.org v0.0.0-20230225012048-214862532bf5 // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect golang.org/x/crypto v0.52.0 // indirect - golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect + golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/image v0.38.0 // indirect golang.org/x/mod v0.35.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect @@ -476,10 +474,8 @@ require ( zombiezen.com/go/sqlite v1.4.0 // indirect ) -replace github.com/inspektor-gadget/inspektor-gadget => github.com/matthyx/inspektor-gadget v0.0.0-20260513134836-aa8a4c2613db +replace github.com/inspektor-gadget/inspektor-gadget => github.com/matthyx/inspektor-gadget v0.0.0-20260421100818-fd383d3d7db4 replace github.com/cilium/ebpf => github.com/matthyx/ebpf v0.0.0-20260421101317-8a32d06def6c replace github.com/anchore/syft => github.com/kubescape/syft v1.32.0-ks.2 - -replace github.com/opencontainers/runtime-spec => github.com/opencontainers/runtime-spec v1.2.1 diff --git a/go.sum b/go.sum index 815469270..959b9a2a3 100644 --- a/go.sum +++ b/go.sum @@ -353,8 +353,8 @@ github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUo github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4= github.com/containerd/cgroups/v3 v3.1.2 h1:OSosXMtkhI6Qove637tg1XgK4q+DhR0mX8Wi8EhrHa4= github.com/containerd/cgroups/v3 v3.1.2/go.mod h1:PKZ2AcWmSBsY/tJUVhtS/rluX0b1uq1GmPO1ElCmbOw= -github.com/containerd/containerd v1.7.32 h1:S54xuVcPxeLaYgaRABtpJ2VyVUVsy0IGf7qHBs+sbY8= -github.com/containerd/containerd v1.7.32/go.mod h1:jdwD6s/BhV4XVJGrvtziNPVA+83n66TwptVaPKprq4E= +github.com/containerd/containerd v1.7.30 h1:/2vezDpLDVGGmkUXmlNPLCCNKHJ5BbC5tJB5JNzQhqE= +github.com/containerd/containerd v1.7.30/go.mod h1:fek494vwJClULlTpExsmOyKCMUAbuVjlFsJQc4/j44M= github.com/containerd/containerd/api v1.10.0 h1:5n0oHYVBwN4VhoX9fFykCV9dF1/BvAXeg2F8W6UYq1o= github.com/containerd/containerd/api v1.10.0/go.mod h1:NBm1OAk8ZL+LG8R0ceObGxT5hbUYj7CzTmR3xh0DlMM= github.com/containerd/containerd/v2 v2.2.1 h1:TpyxcY4AL5A+07dxETevunVS5zxqzuq7ZqJXknM11yk= @@ -397,8 +397,8 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/crewjam/rfc5424 v0.1.0 h1:MSeXJm22oKovLzWj44AHwaItjIMUMugYGkEzfa831H8= github.com/crewjam/rfc5424 v0.1.0/go.mod h1:RCi9M3xHVOeerf6ULZzqv2xOGRO/zYaVUeRyPnBW3gQ= -github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= -github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= +github.com/cyphar/filepath-securejoin v0.6.0 h1:BtGB77njd6SVO6VztOHfPxKitJvd/VPT+OFBFMOi1Is= +github.com/cyphar/filepath-securejoin v0.6.0/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -530,12 +530,12 @@ github.com/go-fonts/liberation v0.3.2 h1:XuwG0vGHFBPRRI8Qwbi5tIvR3cku9LUfZGq/Ar1 github.com/go-fonts/liberation v0.3.2/go.mod h1:N0QsDLVUQPy3UYg9XAc3Uh3UDMp2Z7M1o4+X98dXkmI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= -github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA= -github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw= +github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0= +github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= -github.com/go-git/go-git/v5 v5.19.1 h1:nX27AnaU43/K5bKktKwgBmR9lawoYVe1Ckg0rgzzN00= -github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ= +github.com/go-git/go-git/v5 v5.18.0 h1:O831KI+0PR51hM2kep6T8k+w0/LIAD490gvqMCvL5hM= +github.com/go-git/go-git/v5 v5.18.0/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= @@ -807,10 +807,8 @@ github.com/ianlancetaylor/demangle v0.0.0-20210905161508-09a460cdf81d/go.mod h1: github.com/ianlancetaylor/demangle v0.0.0-20230524184225-eabc099b10ab/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= github.com/iceber/iouring-go v0.0.0-20230403020409-002cfd2e2a90 h1:xrtfZokN++5kencK33hn2Kx3Uj8tGnjMEhdt6FMvHD0= github.com/iceber/iouring-go v0.0.0-20230403020409-002cfd2e2a90/go.mod h1:LEzdaZarZ5aqROlLIwJ4P7h3+4o71008fSy6wpaEB+s= -github.com/in-toto/attestation v1.1.2 h1:MBFn6lsMq6dptQZJBhalXTcWMb/aJy3V+GX3VYj/V1E= -github.com/in-toto/attestation v1.1.2/go.mod h1:gYFddHMZj3DiQ0b62ltNi1Vj5rC879bTmBbrv9CRHpM= -github.com/in-toto/in-toto-golang v0.11.0 h1:nfidMYBFx+E0lnmX5KUnN2Pdm8zdNKal1ayjJuzzRoA= -github.com/in-toto/in-toto-golang v0.11.0/go.mod h1:u3PjTnwFKjp5a1YCcw8SJg0G+tMeKfVoWsWeFMDCMtw= +github.com/in-toto/in-toto-golang v0.9.0 h1:tHny7ac4KgtsfrG6ybU8gVOZux2H8jN05AXJ9EBM1XU= +github.com/in-toto/in-toto-golang v0.9.0/go.mod h1:xsBVrVsHNsB61++S6Dy2vWosKhuA3lUTQd+eF9HdeMo= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= @@ -875,8 +873,6 @@ github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0 github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= -github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= -github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= @@ -927,8 +923,8 @@ github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/matthyx/ebpf v0.0.0-20260421101317-8a32d06def6c h1:ZCCeIMu86h4NhF0UfSm9Kdy1AHVWPogk86MdQD6OvPM= github.com/matthyx/ebpf v0.0.0-20260421101317-8a32d06def6c/go.mod h1:pzLjFymM+uZPLk/IXZUL63xdx5VXEo+enTzxkZXdycw= -github.com/matthyx/inspektor-gadget v0.0.0-20260513134836-aa8a4c2613db h1:li+4y/XuMY5X4ICzp4cGdFE5eQzYae6KRAkIUsZkeFE= -github.com/matthyx/inspektor-gadget v0.0.0-20260513134836-aa8a4c2613db/go.mod h1:V4TgEmWo37K72pQvC7XuRQssysrxIIkrNX4TtEkgiE0= +github.com/matthyx/inspektor-gadget v0.0.0-20260421100818-fd383d3d7db4 h1:+10X5NKBH8AOfLSqKqet2pyMvduv4gHImvYHVohyB/I= +github.com/matthyx/inspektor-gadget v0.0.0-20260421100818-fd383d3d7db4/go.mod h1:V4TgEmWo37K72pQvC7XuRQssysrxIIkrNX4TtEkgiE0= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= @@ -1091,8 +1087,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= -github.com/opencontainers/runtime-spec v1.2.1 h1:S4k4ryNgEpxW1dzyqffOmhI1BHYcjzU8lpJfSlR0xww= -github.com/opencontainers/runtime-spec v1.2.1/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= +github.com/opencontainers/runtime-spec v1.3.0 h1:YZupQUdctfhpZy3TM39nN9Ika5CBWT5diQ8ibYCRkxg= +github.com/opencontainers/runtime-spec v1.3.0/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= github.com/opencontainers/runtime-tools v0.9.1-0.20251114084447-edf4cb3d2116 h1:tAKu3NkKWZYpqBSOJKwTxT1wIGueiF7gcmcNgr5pNTY= github.com/opencontainers/runtime-tools v0.9.1-0.20251114084447-edf4cb3d2116/go.mod h1:DKDEfzxvRkoQ6n9TGhxQgg2IM1lY4aM0eaQP4e3oElw= github.com/opencontainers/selinux v1.13.1 h1:A8nNeceYngH9Ow++M+VVEwJVpdFmrlxsN22F+ISDCJE= @@ -1123,8 +1119,8 @@ github.com/picatz/xcel v0.0.0-20260226001349-6958ffac5706 h1:xfPEUCHSHcjpu4WxgtC github.com/picatz/xcel v0.0.0-20260226001349-6958ffac5706/go.mod h1:bFTXcuU+280rICoGMpVTk/06XNfgvfeplhjWWoLKPys= github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= -github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= -github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= +github.com/pjbgf/sha1cd v0.4.0 h1:NXzbL1RvjTUi6kgYZCX3fPwwl27Q1LJndxtUDVfJGRY= +github.com/pjbgf/sha1cd v0.4.0/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -1210,8 +1206,8 @@ github.com/sebdah/goldie/v2 v2.8.0 h1:dZb9wR8q5++oplmEiJT+U/5KyotVD+HNGCAc5gNr8r github.com/sebdah/goldie/v2 v2.8.0/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI= github.com/seccomp/libseccomp-golang v0.11.0 h1:SDkcBRqGLP+sezmMACkxO1EfgbghxIxnRKfd6mHUEis= github.com/seccomp/libseccomp-golang v0.11.0/go.mod h1:5m1Lk8E9OwgZTTVz4bBOer7JuazaBa+xTkM895tDiWc= -github.com/secure-systems-lab/go-securesystemslib v0.10.0 h1:l+H5ErcW0PAehBNrBxoGv1jjNpGYdZ9RcheFkB2WI14= -github.com/secure-systems-lab/go-securesystemslib v0.10.0/go.mod h1:MRKONWmRoFzPNQ9USRF9i1mc7MvAVvF1LlW8X5VWDvk= +github.com/secure-systems-lab/go-securesystemslib v0.9.1 h1:nZZaNz4DiERIQguNy0cL5qTdn9lR8XKHf4RUyG1Sx3g= +github.com/secure-systems-lab/go-securesystemslib v0.9.1/go.mod h1:np53YzT0zXGMv6x4iEWc9Z59uR+x+ndLwCLqPYpLXVU= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= @@ -1518,8 +1514,8 @@ golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u0 golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= golang.org/x/exp v0.0.0-20230224173230-c95f2b4c22f2/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= -golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= -golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE= From 9ca93ba7efd74f68b721f3c228880c79fa9c03d9 Mon Sep 17 00:00:00 2001 From: entlein Date: Thu, 28 May 2026 16:06:39 +0200 Subject: [PATCH 15/17] go.mod dependencies Signed-off-by: entlein --- go.mod | 6 ++++-- go.sum | 26 ++++---------------------- 2 files changed, 8 insertions(+), 24 deletions(-) diff --git a/go.mod b/go.mod index 3e4394e8e..b8ab3d7cd 100644 --- a/go.mod +++ b/go.mod @@ -186,7 +186,6 @@ require ( github.com/containerd/cgroups/v3 v3.1.2 // indirect github.com/containerd/containerd v1.7.30 // indirect github.com/containerd/containerd/api v1.10.0 // indirect - github.com/containerd/containerd/v2 v2.2.1 // indirect github.com/containerd/continuity v0.4.5 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect @@ -194,7 +193,6 @@ require ( github.com/containerd/log v0.1.0 // indirect github.com/containerd/nri v0.11.0 // indirect github.com/containerd/platforms v1.0.0-rc.2 // indirect - github.com/containerd/plugin v1.0.0 // indirect github.com/containerd/stargz-snapshotter/estargz v0.18.2 // indirect github.com/containerd/ttrpc v1.2.7 // indirect github.com/containerd/typeurl/v2 v2.2.3 // indirect @@ -479,3 +477,7 @@ replace github.com/inspektor-gadget/inspektor-gadget => github.com/matthyx/inspe replace github.com/cilium/ebpf => github.com/matthyx/ebpf v0.0.0-20260421101317-8a32d06def6c replace github.com/anchore/syft => github.com/kubescape/syft v1.32.0-ks.2 + +replace github.com/anchore/stereoscope => github.com/anchore/stereoscope v0.1.9 + +replace github.com/opencontainers/runtime-spec => github.com/opencontainers/runtime-spec v1.2.1 diff --git a/go.sum b/go.sum index 959b9a2a3..b0c18027b 100644 --- a/go.sum +++ b/go.sum @@ -177,8 +177,8 @@ github.com/anchore/go-version v1.2.2-0.20210903204242-51efa5b487c4 h1:rmZG77uXgE github.com/anchore/go-version v1.2.2-0.20210903204242-51efa5b487c4/go.mod h1:Bkc+JYWjMCF8OyZ340IMSIi2Ebf3uwByOk6ho4wne1E= github.com/anchore/packageurl-go v0.1.1-0.20250220190351-d62adb6e1115 h1:ZyRCmiEjnoGJZ1+Ah0ZZ/mKKqNhGcUZBl0s7PTTDzvY= github.com/anchore/packageurl-go v0.1.1-0.20250220190351-d62adb6e1115/go.mod h1:KoYIv7tdP5+CC9VGkeZV4/vGCKsY55VvoG+5dadg4YI= -github.com/anchore/stereoscope v0.1.22 h1:L807G/kk0WZzOCGuRGF7knxMKzwW2PGdbPVRystryd8= -github.com/anchore/stereoscope v0.1.22/go.mod h1:FikPtAb/WnbqwgLHAvQA9O+fWez0K4pbjxzghz++iy4= +github.com/anchore/stereoscope v0.1.9 h1:Nhvk8g6PRx9ubaJU4asAhD3fGcY5HKXZCDGkxI2e0sI= +github.com/anchore/stereoscope v0.1.9/go.mod h1:YkrCtDgz7A+w6Ggd0yxU9q58CerqQFwYARS+F2RvLQQ= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= @@ -357,8 +357,6 @@ github.com/containerd/containerd v1.7.30 h1:/2vezDpLDVGGmkUXmlNPLCCNKHJ5BbC5tJB5 github.com/containerd/containerd v1.7.30/go.mod h1:fek494vwJClULlTpExsmOyKCMUAbuVjlFsJQc4/j44M= github.com/containerd/containerd/api v1.10.0 h1:5n0oHYVBwN4VhoX9fFykCV9dF1/BvAXeg2F8W6UYq1o= github.com/containerd/containerd/api v1.10.0/go.mod h1:NBm1OAk8ZL+LG8R0ceObGxT5hbUYj7CzTmR3xh0DlMM= -github.com/containerd/containerd/v2 v2.2.1 h1:TpyxcY4AL5A+07dxETevunVS5zxqzuq7ZqJXknM11yk= -github.com/containerd/containerd/v2 v2.2.1/go.mod h1:NR70yW1iDxe84F2iFWbR9xfAN0N2F0NcjTi1OVth4nU= github.com/containerd/continuity v0.4.5 h1:ZRoN1sXq9u7V6QoHMcVWGhOwDFqZ4B9i5H6un1Wh0x4= github.com/containerd/continuity v0.4.5/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= @@ -373,8 +371,6 @@ github.com/containerd/nri v0.11.0 h1:26mcQwNG58AZn0YkOrlJQ0yxQVmyZooflnVWJTqQrqQ github.com/containerd/nri v0.11.0/go.mod h1:bjGTLdUA58WgghKHg8azFMGXr05n1wDHrt3NSVBHiGI= github.com/containerd/platforms v1.0.0-rc.2 h1:0SPgaNZPVWGEi4grZdV8VRYQn78y+nm6acgLGv/QzE4= github.com/containerd/platforms v1.0.0-rc.2/go.mod h1:J71L7B+aiM5SdIEqmd9wp6THLVRzJGXfNuWCZCllLA4= -github.com/containerd/plugin v1.0.0 h1:c8Kf1TNl6+e2TtMHZt+39yAPDbouRH9WAToRjex483Y= -github.com/containerd/plugin v1.0.0/go.mod h1:hQfJe5nmWfImiqT1q8Si3jLv3ynMUIBB47bQ+KexvO8= github.com/containerd/stargz-snapshotter/estargz v0.18.2 h1:yXkZFYIzz3eoLwlTUZKz2iQ4MrckBxJjkmD16ynUTrw= github.com/containerd/stargz-snapshotter/estargz v0.18.2/go.mod h1:XyVU5tcJ3PRpkA9XS2T5us6Eg35yM0214Y+wvrZTBrY= github.com/containerd/ttrpc v1.2.7 h1:qIrroQvuOL9HQ1X6KHe2ohc7p+HP/0VE6XPU7elJRqQ= @@ -508,10 +504,6 @@ github.com/gammazero/deque v1.0.0/go.mod h1:iflpYvtGfM3U8S8j+sZEKIak3SAKYpA5/SQe github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/github/go-spdx/v2 v2.4.0 h1:+4IwVwJJbm3rzvrQ6P1nI9BDMcy3la4RchRy5uehV/M= github.com/github/go-spdx/v2 v2.4.0/go.mod h1:/5rwgS0txhGtRdUZwc02bTglzg6HK3FfuEbECKlK2Sg= -github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= -github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= -github.com/gkampitakis/go-snaps v0.5.20 h1:FGKonEeQPJ12t7RQj6cTPa881fl5c8HYarMLv5vP7sg= -github.com/gkampitakis/go-snaps v0.5.20/go.mod h1:gC3YqxQTPyIXvQrw/Vpt3a8VqR1MO8sVpZFWN4DGwNs= github.com/glebarez/go-sqlite v1.20.3 h1:89BkqGOXR9oRmG58ZrzgoY/Fhy5x0M+/WV48U5zVrZ4= github.com/glebarez/go-sqlite v1.20.3/go.mod h1:u3N6D/wftiAzIOJtZl6BmedqxmmkDfH3q+ihjqxC9u0= github.com/gliderlabs/ssh v0.1.1/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= @@ -919,8 +911,6 @@ github.com/mailru/easyjson v0.0.0-20190312143242-1de009706dbe/go.mod h1:C1wdFJiN github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= -github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= -github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/matthyx/ebpf v0.0.0-20260421101317-8a32d06def6c h1:ZCCeIMu86h4NhF0UfSm9Kdy1AHVWPogk86MdQD6OvPM= github.com/matthyx/ebpf v0.0.0-20260421101317-8a32d06def6c/go.mod h1:pzLjFymM+uZPLk/IXZUL63xdx5VXEo+enTzxkZXdycw= github.com/matthyx/inspektor-gadget v0.0.0-20260421100818-fd383d3d7db4 h1:+10X5NKBH8AOfLSqKqet2pyMvduv4gHImvYHVohyB/I= @@ -1087,8 +1077,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= -github.com/opencontainers/runtime-spec v1.3.0 h1:YZupQUdctfhpZy3TM39nN9Ika5CBWT5diQ8ibYCRkxg= -github.com/opencontainers/runtime-spec v1.3.0/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= +github.com/opencontainers/runtime-spec v1.2.1 h1:S4k4ryNgEpxW1dzyqffOmhI1BHYcjzU8lpJfSlR0xww= +github.com/opencontainers/runtime-spec v1.2.1/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= github.com/opencontainers/runtime-tools v0.9.1-0.20251114084447-edf4cb3d2116 h1:tAKu3NkKWZYpqBSOJKwTxT1wIGueiF7gcmcNgr5pNTY= github.com/opencontainers/runtime-tools v0.9.1-0.20251114084447-edf4cb3d2116/go.mod h1:DKDEfzxvRkoQ6n9TGhxQgg2IM1lY4aM0eaQP4e3oElw= github.com/opencontainers/selinux v1.13.1 h1:A8nNeceYngH9Ow++M+VVEwJVpdFmrlxsN22F+ISDCJE= @@ -1317,14 +1307,6 @@ github.com/terminalstatic/go-xsd-validate v0.1.6 h1:TenYeQ3eY631qNi1/cTmLH/s2slH github.com/terminalstatic/go-xsd-validate v0.1.6/go.mod h1:18lsvYFofBflqCrvo1umpABZ99+GneNTw2kEEc8UPJw= github.com/therootcompany/xz v1.0.1 h1:CmOtsn1CbtmyYiusbfmhmkpAAETj0wBIH6kCYaX+xzw= github.com/therootcompany/xz v1.0.1/go.mod h1:3K3UH1yCKgBneZYhuQUvJ9HPD19UEXEI0BWbMn8qNMY= -github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= -github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= -github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= -github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= -github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= -github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= -github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= From 80c3dbffca7bff62b0c7fc3186c4dfb66540ebf5 Mon Sep 17 00:00:00 2001 From: entlein Date: Fri, 29 May 2026 10:01:06 +0200 Subject: [PATCH 16/17] adding strings in import Signed-off-by: entlein --- tests/component_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/component_test.go b/tests/component_test.go index 4a87d112a..1c025da63 100644 --- a/tests/component_test.go +++ b/tests/component_test.go @@ -11,6 +11,7 @@ import ( "slices" "sort" "strconv" + "strings" "testing" "time" From c5dd143c9ee3f6d04fa1afe4627cf99174aef886 Mon Sep 17 00:00:00 2001 From: entlein Date: Fri, 29 May 2026 11:04:30 +0200 Subject: [PATCH 17/17] rebase attempt 1 Signed-off-by: entlein --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index b8ab3d7cd..b3fde0743 100644 --- a/go.mod +++ b/go.mod @@ -472,7 +472,7 @@ require ( zombiezen.com/go/sqlite v1.4.0 // indirect ) -replace github.com/inspektor-gadget/inspektor-gadget => github.com/matthyx/inspektor-gadget v0.0.0-20260421100818-fd383d3d7db4 +replace github.com/inspektor-gadget/inspektor-gadget => github.com/matthyx/inspektor-gadget v0.0.0-20260513134836-aa8a4c2613db replace github.com/cilium/ebpf => github.com/matthyx/ebpf v0.0.0-20260421101317-8a32d06def6c diff --git a/go.sum b/go.sum index b0c18027b..dc20737c9 100644 --- a/go.sum +++ b/go.sum @@ -913,8 +913,8 @@ github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4 github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/matthyx/ebpf v0.0.0-20260421101317-8a32d06def6c h1:ZCCeIMu86h4NhF0UfSm9Kdy1AHVWPogk86MdQD6OvPM= github.com/matthyx/ebpf v0.0.0-20260421101317-8a32d06def6c/go.mod h1:pzLjFymM+uZPLk/IXZUL63xdx5VXEo+enTzxkZXdycw= -github.com/matthyx/inspektor-gadget v0.0.0-20260421100818-fd383d3d7db4 h1:+10X5NKBH8AOfLSqKqet2pyMvduv4gHImvYHVohyB/I= -github.com/matthyx/inspektor-gadget v0.0.0-20260421100818-fd383d3d7db4/go.mod h1:V4TgEmWo37K72pQvC7XuRQssysrxIIkrNX4TtEkgiE0= +github.com/matthyx/inspektor-gadget v0.0.0-20260513134836-aa8a4c2613db h1:li+4y/XuMY5X4ICzp4cGdFE5eQzYae6KRAkIUsZkeFE= +github.com/matthyx/inspektor-gadget v0.0.0-20260513134836-aa8a4c2613db/go.mod h1:V4TgEmWo37K72pQvC7XuRQssysrxIIkrNX4TtEkgiE0= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=