From bc22631d50908c7fc849214bb45673dc1f1fbe1d Mon Sep 17 00:00:00 2001 From: entlein Date: Fri, 15 May 2026 22:06:09 +0200 Subject: [PATCH 01/17] allowing wildcards in exec args Signed-off-by: entlein --- .../v1/event_reporting.go | 21 +++ .../v1/event_reporting_test.go | 20 +++ pkg/rulemanager/cel/libraries/parse/parse.go | 57 +++++++- .../cel/libraries/parse/parselib.go | 11 ++ .../cel/libraries/parse/parsing_test.go | 125 ++++++++++++++++++ 5 files changed, 233 insertions(+), 1 deletion(-) diff --git a/pkg/containerprofilemanager/v1/event_reporting.go b/pkg/containerprofilemanager/v1/event_reporting.go index 077875fe1a..5a80952e30 100644 --- a/pkg/containerprofilemanager/v1/event_reporting.go +++ b/pkg/containerprofilemanager/v1/event_reporting.go @@ -41,7 +41,28 @@ func (cpm *ContainerProfileManager) ReportCapability(containerID, capability str // invocation pattern), while the rule-side resolver falls back to comm — // leaving the AP entry unreachable to ap.was_executed and producing spurious // "Unexpected process launched" alerts. +// resolveExecPath chooses the canonical recorded path for an exec event. +// Precedence (kept symmetric with the rule-side +// pkg/rulemanager/cel/libraries/parse/parse.go::getExecPathWithExePath +// — divergence here would let runtime queries miss profile entries that +// were recorded under a different key): +// +// 1. argv[0] when it's an absolute path (`/...`) — symlink-faithful. +// In busybox-based images every utility (sh, echo, nslookup, ...) +// is a symlink to /bin/busybox. The kernel-resolved exepath is +// /bin/busybox, but argv[0] preserves the symlink form a user +// invoked. Users author profile.Path with the symlink form, so +// we record the same. +// 2. exepath when argv[0] is bare or empty — kernel-authoritative +// wins. Preserves argv[0]-spoofing protection: an attacker passing +// argv[0]="sshd" while exec'ing /usr/bin/curl gets resolved to the +// real exepath rather than the bare lie. +// 3. argv[0] when bare and exepath empty (fexecve / AT_EMPTY_PATH). +// 4. comm as last resort. func resolveExecPath(exepath, comm string, args []string) string { + if len(args) > 0 && len(args[0]) > 0 && args[0][0] == '/' { + return args[0] + } if exepath != "" { return exepath } diff --git a/pkg/containerprofilemanager/v1/event_reporting_test.go b/pkg/containerprofilemanager/v1/event_reporting_test.go index ee38683d53..ae4509df0a 100644 --- a/pkg/containerprofilemanager/v1/event_reporting_test.go +++ b/pkg/containerprofilemanager/v1/event_reporting_test.go @@ -45,6 +45,26 @@ func TestResolveExecPath(t *testing.T) { args: []string{"sshd", "-i"}, want: "/usr/bin/curl", }, + { + // Busybox symlink: kernel resolves /bin/sh → /bin/busybox and + // reports exepath=/bin/busybox, but argv[0] preserves the + // symlink-as-invoked form (/bin/sh). User-authored profiles + // list /bin/sh (matching how people think). Recording side + // MUST record /bin/sh so rule-side parse.get_exec_path's + // matching precedence (same convention) finds the entry. + name: "busybox symlink — argv[0] absolute /bin/sh, exepath /bin/busybox", + exepath: "/bin/busybox", + comm: "sh", + args: []string{"/bin/sh", "-c", "echo hi"}, + want: "/bin/sh", + }, + { + name: "busybox symlink — argv[0] /usr/bin/nslookup, exepath /bin/busybox", + exepath: "/bin/busybox", + comm: "nslookup", + args: []string{"/usr/bin/nslookup", "example.com"}, + want: "/usr/bin/nslookup", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/rulemanager/cel/libraries/parse/parse.go b/pkg/rulemanager/cel/libraries/parse/parse.go index ba82f982f6..b1fc0c56d4 100644 --- a/pkg/rulemanager/cel/libraries/parse/parse.go +++ b/pkg/rulemanager/cel/libraries/parse/parse.go @@ -17,7 +17,10 @@ func (l *parseLibrary) getExecPath(args ref.Val, comm ref.Val) ref.Val { return types.MaybeNoSuchOverloadErr(comm) } - // Implement the logic from GetExecPathFromEvent + // 2-arg overload — back-compat. Resolves args[0] → comm. + // Callers that have event.exepath SHOULD use the 3-arg overload below + // to stay symmetric with the recording side's resolveExecPath in + // pkg/containerprofilemanager/v1/event_reporting.go. if len(argsList) > 0 { if argsList[0] != "" { return types.String(argsList[0]) @@ -25,3 +28,55 @@ func (l *parseLibrary) getExecPath(args ref.Val, comm ref.Val) ref.Val { } return types.String(commStr) } + +// getExecPathWithExePath is the 3-arg overload that resolves the exec +// path with symlink-faithful precedence: +// +// 1. argv[0] when it's an absolute path (`/...`) — preserves symlink +// identity as invoked (e.g. busybox-based images where /bin/sh, +// /usr/bin/nslookup, /bin/echo are all symlinks to /bin/busybox; +// argv[0] carries the symlink form, exepath carries the kernel- +// resolved target). User-authored profiles list the symlink form, +// and the recording side (resolveExecPath in +// pkg/containerprofilemanager/v1/event_reporting.go) uses the same +// precedence so profile.Path matches what rules query. +// +// 2. exepath when argv[0] is bare (e.g. "sh", "curl") or empty — the +// kernel-authoritative path is the right tiebreaker here, and +// preserves the existing argv[0]-spoofing protection: an attacker +// passing a misleading bare argv[0] (e.g. argv[0]="sshd" while +// actually exec'ing /usr/bin/curl) gets resolved to the real +// exepath, not the bare lie. The "absolute path → trust argv[0]" +// rule is safe because the kernel only exposes an absolute argv[0] +// when execve was called with that exact path (modulo symlinks +// that the kernel itself follows transparently). +// +// 3. argv[0] when bare AND exepath empty (fexecve / AT_EMPTY_PATH). +// +// 4. comm as final fallback. +// +// This closes the spurious-R0001 gap on busybox-based containers AND +// the prior fork-shell case where event.exepath was the only source. +func (l *parseLibrary) getExecPathWithExePath(args ref.Val, comm ref.Val, exepath ref.Val) ref.Val { + exepathStr, ok := exepath.Value().(string) + if !ok { + return types.MaybeNoSuchOverloadErr(exepath) + } + + argsList, err := celparse.ParseList[string](args) + if err == nil && len(argsList) > 0 { + argv0 := argsList[0] + // Tier 1: absolute argv[0] wins. Symlink-faithful. + if len(argv0) > 0 && argv0[0] == '/' { + return types.String(argv0) + } + } + + // Tier 2: kernel-authoritative exepath when argv[0] is bare/empty. + if exepathStr != "" { + return types.String(exepathStr) + } + + // Tiers 3+4: defer to 2-arg fallback (argv[0]-bare → comm). + return l.getExecPath(args, comm) +} diff --git a/pkg/rulemanager/cel/libraries/parse/parselib.go b/pkg/rulemanager/cel/libraries/parse/parselib.go index 57b05be451..758492d542 100644 --- a/pkg/rulemanager/cel/libraries/parse/parselib.go +++ b/pkg/rulemanager/cel/libraries/parse/parselib.go @@ -47,6 +47,17 @@ func (l *parseLibrary) Declarations() map[string][]cel.FunctionOpt { return l.getExecPath(values[0], values[1]) }), ), + cel.Overload( + "parse_get_exec_path_with_exepath", + []*cel.Type{cel.ListType(cel.StringType), cel.StringType, cel.StringType}, + cel.StringType, + cel.FunctionBinding(func(values ...ref.Val) ref.Val { + if len(values) != 3 { + return types.NewErr("expected 3 arguments, got %d", len(values)) + } + return l.getExecPathWithExePath(values[0], values[1], values[2]) + }), + ), }, } } diff --git a/pkg/rulemanager/cel/libraries/parse/parsing_test.go b/pkg/rulemanager/cel/libraries/parse/parsing_test.go index 5677c8b56f..b756aaea51 100644 --- a/pkg/rulemanager/cel/libraries/parse/parsing_test.go +++ b/pkg/rulemanager/cel/libraries/parse/parsing_test.go @@ -135,3 +135,128 @@ func TestParseLibraryErrorCases(t *testing.T) { }) } } + +// TestGetExecPath_SymmetryWithRecordingSide pins the contract that the +// rule-side resolver MUST agree with pkg/containerprofilemanager/v1/ +// event_reporting.go:resolveExecPath. That recording function uses +// 1. exepath (kernel-authoritative) +// 2. argv[0] when non-empty +// 3. comm +// in that precedence order — so the path stored in the ApplicationProfile +// is whatever the kernel reports. +// +// If the rule side ignores exepath, the profile entry written under +// "/bin/sh" becomes unreachable when the runtime queries with the rule's +// resolved path "sh" (argv[0]), and R0001 fires spuriously on benign +// shell invocations — exactly the regression bobctl tune was hitting on +// merge/upstream-profile-rearch. +// +// These cases mirror TestResolveExecPath in pkg/containerprofilemanager/v1/ +// event_reporting_test.go. They use a 3-arg overload of parse.get_exec_path +// that accepts (args, comm, exepath). +func TestGetExecPath_SymmetryWithRecordingSide(t *testing.T) { + env, err := cel.NewEnv( + cel.Variable("event", cel.AnyType), + Parse(config.Config{}), + ) + if err != nil { + t.Fatalf("failed to create env: %v", err) + } + + tests := []struct { + name string + expr string + expected string + }{ + { + name: "exepath present (canonical exec)", + expr: "parse.get_exec_path(['/usr/sbin/unix_chkpwd', 'root'], 'unix_chkpwd', '/usr/sbin/unix_chkpwd')", + expected: "/usr/sbin/unix_chkpwd", + }, + { + name: "exepath disagrees with argv[0] — exepath wins (argv[0] spoofing)", + // kernel says /usr/bin/curl, argv[0] says sshd. Profile recorded by + // resolveExecPath has "/usr/bin/curl" — rule MUST query the same. + expr: "parse.get_exec_path(['sshd', '-i'], 'curl', '/usr/bin/curl')", + expected: "/usr/bin/curl", + }, + { + name: "exepath empty (fexecve / AT_EMPTY_PATH) — fall back to argv[0]", + expr: "parse.get_exec_path(['unix_chkpwd', 'root'], 'unix_chkpwd', '')", + expected: "unix_chkpwd", + }, + { + name: "exepath + argv[0] empty — fall back to comm", + expr: "parse.get_exec_path(['', 'root'], 'unix_chkpwd', '')", + expected: "unix_chkpwd", + }, + { + name: "fork-shell case — kernel /bin/sh, argv[0] sh, comm sh", + expr: "parse.get_exec_path(['sh', '-c', 'echo'], 'sh', '/bin/sh')", + expected: "/bin/sh", + }, + { + // Busybox-style symlink case: the user runs `/bin/sh` which is + // a symlink to `/bin/busybox`. Inspektor Gadget's eBPF tracer + // reports exepath as the kernel-resolved binary (`/bin/busybox`) + // while argv[0] preserves the symlink-as-invoked form + // (`/bin/sh`). User-authored profiles list the symlink form + // (which is what people think of), and the recording side's + // resolveExecPath records the same form when argv[0] is + // absolute. Rule-side resolution MUST match so ap.was_executed + // finds the profile entry on busybox-based images. + // + // Precedence: absolute-argv[0] > exepath > bare-argv[0] > comm. + // argv[0] being absolute is the signal that the symlink form + // is intentional and present at exec time; bare argv[0] is + // just a shell convention and the kernel-authoritative exepath + // should win (preserving the existing argv[0]-spoofing + // protection where attackers pass a misleading bare argv[0]). + name: "busybox symlink — argv[0] /bin/sh absolute, exepath /bin/busybox", + expr: "parse.get_exec_path(['/bin/sh', '-c', 'echo hi'], 'sh', '/bin/busybox')", + expected: "/bin/sh", + }, + { + name: "busybox symlink — nslookup absolute, exepath /bin/busybox", + expr: "parse.get_exec_path(['/usr/bin/nslookup', 'example.com'], 'nslookup', '/bin/busybox')", + expected: "/usr/bin/nslookup", + }, + { + // Negative case: argv[0] bare → exepath still wins. This + // preserves the argv[0] spoofing protection in the test above + // ("argv[0] spoofing"), where a bare argv[0]='sshd' was being + // rejected in favour of the kernel-authoritative exepath. + name: "bare argv[0] keeps spoof protection — exepath wins", + expr: "parse.get_exec_path(['sshd', '-i'], 'curl', '/usr/bin/curl')", + expected: "/usr/bin/curl", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ast, issues := env.Compile(tt.expr) + 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{}{ + "event": map[string]interface{}{ + "args": []string{}, + "comm": "test", + "exepath": "", + }, + }) + if err != nil { + t.Fatalf("failed to eval program: %v", err) + } + actual, ok := result.Value().(string) + if !ok { + t.Fatalf("expected string result, got %T", result.Value()) + } + assert.Equal(t, tt.expected, actual, "result should match expected value") + }) + } +} From 0ab2559fbaa04b6dfb4af12c33b80ec01c54d52c Mon Sep 17 00:00:00 2001 From: entlein Date: Wed, 27 May 2026 16:01:17 +0200 Subject: [PATCH 02/17] fix(recorder,rule): revert argv[0]-spoof Tier 1; exepath wins always MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveExecPath and getExecPathWithExePath both gained an "if argv[0] is absolute, trust argv[0] over exepath" tier in fd9e262d. The justification (busybox-image symlink fidelity — record /bin/sh instead of /bin/busybox) does not survive the argv[0] spoofing case: `exec -a /bin/sh sleep 2` yields cmdline=/bin/sh while /proc//exe stays /usr/bin/sleep, so the recorded identity is whatever an attacker chose, not what actually ran. ap.was_executed lookups for allowed paths then pass for arbitrary binaries. Reverts both functions to the v0.3.113 precedence: 1. exepath (kernel-authoritative) 2. argv[0] non-empty when exepath empty (fexecve / AT_EMPTY_PATH) 3. comm Busybox-image profiles record /bin/busybox (kernel-resolved) — the v0.3.113 behaviour. The symlink-faithful tier never shipped in a tagged release; only profiles built against fd9e262d depended on it, all internal. Adds explicit absolute-argv[0]-spoof regression tests on both sides (resolveExecPath and getExecPathWithExePath) pinning that `args=["/bin/sh", …], exepath="/usr/bin/sleep"` resolves to `/usr/bin/sleep`. Updated the busybox tests on both sides to reflect the kernel-authoritative semantics. Addresses matthyx review on event_reporting.go:63 (2026-05-27). Signed-off-by: entlein --- .../v1/event_reporting.go | 42 ++-- .../v1/event_reporting_test.go | 32 ++- pkg/rulemanager/cel/cel.go | 17 +- pkg/rulemanager/cel/expression_rewrite.go | 61 +++++ .../cel/expression_rewrite_test.go | 219 ++++++++++++++++++ .../parse/default_rules_yaml_lint_test.go | 51 ++++ pkg/rulemanager/cel/libraries/parse/parse.go | 54 ++--- .../cel/libraries/parse/parsing_test.go | 50 ++-- .../templates/node-agent/default-rules.yaml | 10 +- 9 files changed, 434 insertions(+), 102 deletions(-) create mode 100644 pkg/rulemanager/cel/expression_rewrite.go create mode 100644 pkg/rulemanager/cel/expression_rewrite_test.go create mode 100644 pkg/rulemanager/cel/libraries/parse/default_rules_yaml_lint_test.go diff --git a/pkg/containerprofilemanager/v1/event_reporting.go b/pkg/containerprofilemanager/v1/event_reporting.go index 5a80952e30..e262c5326a 100644 --- a/pkg/containerprofilemanager/v1/event_reporting.go +++ b/pkg/containerprofilemanager/v1/event_reporting.go @@ -32,37 +32,31 @@ func (cpm *ContainerProfileManager) ReportCapability(containerID, capability str cpm.logEventError(err, "capability", containerID) } -// resolveExecPath derives the path to record for an exec event. It is kept -// symmetric with the rule-side resolver in -// pkg/rulemanager/cel/libraries/parse/parse.go (parse.get_exec_path): prefer -// the kernel-authoritative exepath, then argv[0] when non-empty, then comm. -// Using args[0] unconditionally produces an empty Path when the syscall has -// an empty pathname (fexecve / execveat AT_EMPTY_PATH — the libpam helper -// invocation pattern), while the rule-side resolver falls back to comm — -// leaving the AP entry unreachable to ap.was_executed and producing spurious -// "Unexpected process launched" alerts. // resolveExecPath chooses the canonical recorded path for an exec event. +// // Precedence (kept symmetric with the rule-side // pkg/rulemanager/cel/libraries/parse/parse.go::getExecPathWithExePath // — divergence here would let runtime queries miss profile entries that // were recorded under a different key): // -// 1. argv[0] when it's an absolute path (`/...`) — symlink-faithful. -// In busybox-based images every utility (sh, echo, nslookup, ...) -// is a symlink to /bin/busybox. The kernel-resolved exepath is -// /bin/busybox, but argv[0] preserves the symlink form a user -// invoked. Users author profile.Path with the symlink form, so -// we record the same. -// 2. exepath when argv[0] is bare or empty — kernel-authoritative -// wins. Preserves argv[0]-spoofing protection: an attacker passing -// argv[0]="sshd" while exec'ing /usr/bin/curl gets resolved to the -// real exepath rather than the bare lie. -// 3. argv[0] when bare and exepath empty (fexecve / AT_EMPTY_PATH). -// 4. comm as last resort. +// 1. exepath — kernel-authoritative; the only spoof-resistant source. +// argv[0] is user-controllable (man 3 exec: argv[0] is convention, +// not enforced) even when absolute, so it cannot be the recorded +// identity for security-relevant lookups. Repro: `exec -a /bin/sh +// sleep 2` yields cmdline=/bin/sh while /proc//exe stays +// /usr/bin/sleep — recording argv[0] would let a process masquerade +// as any allowed absolute path. +// 2. argv[0] when non-empty AND exepath empty (fexecve / execveat +// AT_EMPTY_PATH — the libpam helper invocation pattern). +// 3. comm as last resort. +// +// Busybox-image consequence: utilities like /bin/sh, /usr/bin/nslookup +// are symlinks to /bin/busybox. exepath resolves to /bin/busybox, so +// that's the recorded identity. User-authored profiles for busybox +// images must list /bin/busybox (not the symlink form) — the prior +// "absolute argv[0] wins" tier was an earlier experimental design that +// re-opened argv[0] spoofing and has been reverted. func resolveExecPath(exepath, comm string, args []string) string { - if len(args) > 0 && len(args[0]) > 0 && args[0][0] == '/' { - return args[0] - } if exepath != "" { return exepath } diff --git a/pkg/containerprofilemanager/v1/event_reporting_test.go b/pkg/containerprofilemanager/v1/event_reporting_test.go index ae4509df0a..0bfedd2a08 100644 --- a/pkg/containerprofilemanager/v1/event_reporting_test.go +++ b/pkg/containerprofilemanager/v1/event_reporting_test.go @@ -47,23 +47,37 @@ func TestResolveExecPath(t *testing.T) { }, { // Busybox symlink: kernel resolves /bin/sh → /bin/busybox and - // reports exepath=/bin/busybox, but argv[0] preserves the - // symlink-as-invoked form (/bin/sh). User-authored profiles - // list /bin/sh (matching how people think). Recording side - // MUST record /bin/sh so rule-side parse.get_exec_path's - // matching precedence (same convention) finds the entry. - name: "busybox symlink — argv[0] absolute /bin/sh, exepath /bin/busybox", + // reports exepath=/bin/busybox. The recorded identity is the + // kernel-resolved exepath, not the user-controllable argv[0] + // symlink form. User-authored profiles for busybox images + // must therefore list /bin/busybox. Trusting absolute argv[0] + // here would re-open the argv[0] spoofing hole pinned by the + // "absolute argv[0] spoof" case below. + name: "busybox symlink — exepath /bin/busybox wins over argv[0]=/bin/sh", exepath: "/bin/busybox", comm: "sh", args: []string{"/bin/sh", "-c", "echo hi"}, - want: "/bin/sh", + want: "/bin/busybox", }, { - name: "busybox symlink — argv[0] /usr/bin/nslookup, exepath /bin/busybox", + name: "busybox symlink — exepath /bin/busybox wins over argv[0]=/usr/bin/nslookup", exepath: "/bin/busybox", comm: "nslookup", args: []string{"/usr/bin/nslookup", "example.com"}, - want: "/usr/bin/nslookup", + want: "/bin/busybox", + }, + { + // `exec -a /bin/sh sleep 2` — attacker spoofs argv[0] to an + // allowed absolute path while running a different binary. + // The recorder MUST anchor on kernel-authoritative exepath + // so the recorded identity reflects the real executable, + // not the spoofed argv[0]. Regression pin for the parse.go + // matthyx blocker on PR #805 (2026-05-27). + name: "absolute argv[0] spoof — exec -a /bin/sh sleep", + exepath: "/usr/bin/sleep", + comm: "sleep", + args: []string{"/bin/sh", "2"}, + want: "/usr/bin/sleep", }, } for _, tt := range tests { diff --git a/pkg/rulemanager/cel/cel.go b/pkg/rulemanager/cel/cel.go index b064323df9..a5a999bbe8 100644 --- a/pkg/rulemanager/cel/cel.go +++ b/pkg/rulemanager/cel/cel.go @@ -106,17 +106,26 @@ func (c *CEL) registerExpression(expression string) error { return nil } - ast, issues := c.env.Compile(expression) + // Auto-rewrite deprecated helper call sites (e.g. 2-arg get_exec_path + // → 3-arg) for operators who upgrade the node-agent binary without + // updating their RuleBindings. Each rewrite surfaces a notice; this + // runs once per unique expression because of the cache check above. + compileExpr, notices := rewriteDeprecatedHelpers(expression) + for _, n := range notices { + logger.L().Warning("CEL expression: deprecated form", helpers.String("notice", n)) + } + + ast, issues := c.env.Compile(compileExpr) if issues != nil { // Cache nil to prevent repeated compilation attempts for invalid expressions c.programCache[expression] = nil - logger.L().Warning("CEL expression disabled: failed to compile", helpers.String("expression", expression), helpers.Error(issues.Err())) + logger.L().Warning("CEL expression disabled: failed to compile", helpers.String("expression", compileExpr), helpers.Error(issues.Err())) return fmt.Errorf("failed to compile expression: %s", issues.Err()) } optAst, optIssues := c.staticOptimizer.Optimize(c.env, ast) if optIssues != nil && optIssues.Err() != nil { - logger.L().Warning("CEL static optimization failed, falling back to unoptimized AST", helpers.String("expression", expression), helpers.Error(optIssues.Err())) + logger.L().Warning("CEL static optimization failed, falling back to unoptimized AST", helpers.String("expression", compileExpr), helpers.Error(optIssues.Err())) } else { ast = optAst } @@ -125,7 +134,7 @@ func (c *CEL) registerExpression(expression string) error { if err != nil { // Cache nil to prevent repeated program creation attempts c.programCache[expression] = nil - logger.L().Warning("CEL expression disabled: failed to create program", helpers.String("expression", expression), helpers.Error(err)) + logger.L().Warning("CEL expression disabled: failed to create program", helpers.String("expression", compileExpr), helpers.Error(err)) return fmt.Errorf("failed to create program: %s", err) } diff --git a/pkg/rulemanager/cel/expression_rewrite.go b/pkg/rulemanager/cel/expression_rewrite.go new file mode 100644 index 0000000000..af7b21be18 --- /dev/null +++ b/pkg/rulemanager/cel/expression_rewrite.go @@ -0,0 +1,61 @@ +package cel + +import ( + "fmt" + "regexp" +) + +// twoArgEventGetExecPath matches the canonical 2-arg call against the event +// variable — safely auto-upgradable to 3-arg by passing event.exepath as the +// kernel-authoritative resolution source. +var twoArgEventGetExecPath = regexp.MustCompile(`\bparse\.get_exec_path\(\s*event\.args\s*,\s*event\.comm\s*\)`) + +// anyTwoArgGetExecPath matches any 2-arg call site. After the canonical +// rewrite has run, anything still matching is non-canonical (caller used +// identifiers other than event.args / event.comm, or literal values) and +// cannot be safely upgraded without operator intent. The pattern +// `[^,)]+` halts at the first comma, so a 3-arg call does not match. +var anyTwoArgGetExecPath = regexp.MustCompile(`\bparse\.get_exec_path\([^,)]+,\s*[^,)]+\)`) + +// rewriteDeprecatedHelpers upgrades known-deprecated helper call sites in a +// CEL expression to their safer current form. Returns the rewritten +// expression plus operator-visible notices describing each rewrite or +// remaining concern. +// +// Today this handles ONE rewrite: the 2-arg parse.get_exec_path against +// the event variable. The 2-arg overload returns argv[0] (or comm) which +// is user-controllable — `exec -a /bin/sh sleep 2` yields cmdline=/bin/sh +// while /proc//exe is /usr/bin/sleep, so any ap.was_executed-style +// lookup against the 2-arg result is bypassable. The 3-arg form anchors +// on event.exepath (kernel-authoritative) and falls back to argv[0]/comm +// only for fexecve / AT_EMPTY_PATH cases. +// +// Auto-rewriting is intentionally limited to expressions that name the +// event variable explicitly — those are guaranteed to be evaluated against +// an exec event that carries event.exepath, so the rewrite is contract- +// preserving. Non-canonical 2-arg calls (custom identifiers or literals) +// cannot be auto-upgraded; they surface a notice so operators can migrate +// manually. +func rewriteDeprecatedHelpers(expression string) (string, []string) { + var notices []string + + if twoArgEventGetExecPath.MatchString(expression) { + original := expression + expression = twoArgEventGetExecPath.ReplaceAllString(expression, + "parse.get_exec_path(event.args, event.comm, event.exepath)") + notices = append(notices, fmt.Sprintf( + "auto-rewrote 2-arg parse.get_exec_path → 3-arg with event.exepath fallback; the 2-arg form trusts argv[0] which is user-controllable and bypassable via `exec -a `. Update RuleBindings to call the 3-arg form explicitly. Original expression: %q", + original)) + } + + // After auto-rewrite the canonical pattern is gone. Anything still + // matching the 2-arg shape is non-canonical — flag it for operator + // attention but do not modify. + for _, m := range anyTwoArgGetExecPath.FindAllString(expression, -1) { + notices = append(notices, fmt.Sprintf( + "2-arg parse.get_exec_path with non-event arguments cannot be auto-upgraded and remains argv[0]-spoofable: %s", + m)) + } + + return expression, notices +} diff --git a/pkg/rulemanager/cel/expression_rewrite_test.go b/pkg/rulemanager/cel/expression_rewrite_test.go new file mode 100644 index 0000000000..4b63955a7d --- /dev/null +++ b/pkg/rulemanager/cel/expression_rewrite_test.go @@ -0,0 +1,219 @@ +package cel + +import ( + "strings" + "testing" + + "github.com/google/cel-go/cel" + "github.com/kubescape/node-agent/pkg/config" + "github.com/kubescape/node-agent/pkg/rulemanager/cel/libraries/parse" +) + +func TestRewriteDeprecatedHelpers(t *testing.T) { + tests := []struct { + name string + input string + wantOutput string + wantNoticeCount int + wantNoticeContains string + }{ + { + name: "canonical 2-arg rewritten to 3-arg", + input: `!ap.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm))`, + wantOutput: `!ap.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm, event.exepath))`, + wantNoticeCount: 1, + wantNoticeContains: "auto-rewrote", + }, + { + name: "3-arg form already — unchanged, no notice", + input: `!ap.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm, event.exepath))`, + wantOutput: `!ap.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm, event.exepath))`, + wantNoticeCount: 0, + }, + { + name: "whitespace inside the call tolerated", + input: `parse.get_exec_path( event.args , event.comm )`, + wantOutput: `parse.get_exec_path(event.args, event.comm, event.exepath)`, + wantNoticeCount: 1, + }, + { + name: "multiple canonical 2-arg calls in one expression — all rewritten, single notice", + input: `parse.get_exec_path(event.args, event.comm) + parse.get_exec_path(event.args, event.comm)`, + wantOutput: `parse.get_exec_path(event.args, event.comm, event.exepath) + parse.get_exec_path(event.args, event.comm, event.exepath)`, + wantNoticeCount: 1, + }, + { + name: "non-event 2-arg — cannot auto-upgrade, notice emitted", + input: `parse.get_exec_path(myArgs, myComm)`, + wantOutput: `parse.get_exec_path(myArgs, myComm)`, + wantNoticeCount: 1, + wantNoticeContains: "cannot be auto-upgraded", + }, + { + name: "literal-args 2-arg — cannot auto-upgrade", + input: `parse.get_exec_path(['/bin/ls'], 'ls')`, + wantOutput: `parse.get_exec_path(['/bin/ls'], 'ls')`, + wantNoticeCount: 1, + wantNoticeContains: "cannot be auto-upgraded", + }, + { + name: "mixed canonical + non-canonical — canonical rewritten, both flagged", + input: `parse.get_exec_path(event.args, event.comm) && parse.get_exec_path(otherArgs, otherComm)`, + wantOutput: `parse.get_exec_path(event.args, event.comm, event.exepath) && parse.get_exec_path(otherArgs, otherComm)`, + wantNoticeCount: 2, + }, + { + name: "no get_exec_path at all — unchanged", + input: `event.containerId == 'foo'`, + wantOutput: `event.containerId == 'foo'`, + wantNoticeCount: 0, + }, + { + name: "default-rules.yaml line 434 form (chained call, multiple sites)", + input: `!ap.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm)) && k8s.get_container_mount_paths(ns, pod, c).exists(mount, event.exepath.startsWith(mount) || parse.get_exec_path(event.args, event.comm).startsWith(mount))`, + wantOutput: `!ap.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm, event.exepath)) && k8s.get_container_mount_paths(ns, pod, c).exists(mount, event.exepath.startsWith(mount) || parse.get_exec_path(event.args, event.comm, event.exepath).startsWith(mount))`, + wantNoticeCount: 1, + }, + { + name: "method-call chain on get_exec_path result", + input: `parse.get_exec_path(event.args, event.comm).startsWith('/dev/shm/')`, + wantOutput: `parse.get_exec_path(event.args, event.comm, event.exepath).startsWith('/dev/shm/')`, + wantNoticeCount: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, notices := rewriteDeprecatedHelpers(tt.input) + if got != tt.wantOutput { + t.Errorf("rewrite output mismatch\n input: %s\n got: %s\n expected: %s", tt.input, got, tt.wantOutput) + } + if len(notices) != tt.wantNoticeCount { + t.Errorf("notice count: got %d, want %d\n notices: %v", len(notices), tt.wantNoticeCount, notices) + } + if tt.wantNoticeContains != "" { + found := false + for _, n := range notices { + if strings.Contains(n, tt.wantNoticeContains) { + found = true + break + } + } + if !found { + t.Errorf("expected at least one notice containing %q; got: %v", tt.wantNoticeContains, notices) + } + } + }) + } +} + +// TestRewriteIntegration_2ArgExpressionResolvesViaExepath proves the +// end-to-end SF-A guarantee: a RuleBinding shipped with the older 2-arg +// `parse.get_exec_path(event.args, event.comm)` form, when run through +// rewriteDeprecatedHelpers and compiled+evaluated by CEL, yields +// kernel-authoritative `event.exepath` — i.e. the spoof case `exec -a +// /bin/sh sleep` resolves to /usr/bin/sleep instead of /bin/sh, exactly +// as the 3-arg form would. +// +// This is the contract operators rely on when they upgrade the +// node-agent binary without touching their RuleBindings. +func TestRewriteIntegration_2ArgExpressionResolvesViaExepath(t *testing.T) { + env, err := cel.NewEnv( + cel.Variable("event", cel.AnyType), + parse.Parse(config.Config{}), + ) + if err != nil { + t.Fatalf("failed to build CEL env: %v", err) + } + + cases := []struct { + name string + oldExpr string // 2-arg form as written in legacy RuleBindings + event map[string]any + expected string + }{ + { + name: "absolute argv[0] spoof — exec -a /bin/sh sleep", + oldExpr: `parse.get_exec_path(event.args, event.comm)`, + event: map[string]any{ + "args": []string{"/bin/sh", "2"}, + "comm": "sleep", + "exepath": "/usr/bin/sleep", + }, + expected: "/usr/bin/sleep", + }, + { + name: "bare argv[0] spoof — argv0=sshd, real exe curl", + oldExpr: `parse.get_exec_path(event.args, event.comm)`, + event: map[string]any{ + "args": []string{"sshd", "-i"}, + "comm": "curl", + "exepath": "/usr/bin/curl", + }, + expected: "/usr/bin/curl", + }, + { + name: "shell-typed `ls` (bare argv[0]) — fork-shell case", + oldExpr: `parse.get_exec_path(event.args, event.comm)`, + event: map[string]any{ + "args": []string{"ls", "-la"}, + "comm": "ls", + "exepath": "/bin/ls", + }, + expected: "/bin/ls", + }, + { + name: "busybox — exepath /bin/busybox wins over argv[0]=/bin/sh", + oldExpr: `parse.get_exec_path(event.args, event.comm)`, + event: map[string]any{ + "args": []string{"/bin/sh", "-c", "echo hi"}, + "comm": "sh", + "exepath": "/bin/busybox", + }, + expected: "/bin/busybox", + }, + { + name: "fexecve (libpam) — exepath empty, falls back to argv[0]", + oldExpr: `parse.get_exec_path(event.args, event.comm)`, + event: map[string]any{ + "args": []string{"unix_chkpwd", "root"}, + "comm": "unix_chkpwd", + "exepath": "", + }, + expected: "unix_chkpwd", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // 1. Auto-rewrite, as registerExpression does. + rewritten, notices := rewriteDeprecatedHelpers(tc.oldExpr) + if len(notices) == 0 { + t.Fatalf("expected at least one rewrite notice for input %q", tc.oldExpr) + } + + // 2. Compile + run the REWRITTEN expression through the real + // CEL pipeline (same env the rule manager uses). + ast, issues := env.Compile(rewritten) + if issues != nil && issues.Err() != nil { + t.Fatalf("rewritten expression failed to compile: %v\n rewritten: %s", issues.Err(), rewritten) + } + program, err := env.Program(ast) + if err != nil { + t.Fatalf("program creation failed: %v", err) + } + result, _, err := program.Eval(map[string]any{"event": tc.event}) + if err != nil { + t.Fatalf("eval failed: %v", err) + } + got, ok := result.Value().(string) + if !ok { + t.Fatalf("expected string result, got %T (%v)", result.Value(), result.Value()) + } + if got != tc.expected { + t.Errorf("integrated rewrite+eval mismatch\n legacy expression: %s\n rewritten: %s\n event: %v\n got: %q\n want: %q", + tc.oldExpr, rewritten, tc.event, got, tc.expected) + } + }) + } +} diff --git a/pkg/rulemanager/cel/libraries/parse/default_rules_yaml_lint_test.go b/pkg/rulemanager/cel/libraries/parse/default_rules_yaml_lint_test.go new file mode 100644 index 0000000000..f16ea7a3de --- /dev/null +++ b/pkg/rulemanager/cel/libraries/parse/default_rules_yaml_lint_test.go @@ -0,0 +1,51 @@ +package parse + +import ( + "os" + "path/filepath" + "regexp" + "runtime" + "testing" +) + +// TestDefaultRulesYAML_NoTwoArgGetExecPath pins the migration of bundled +// rule expressions to the 3-arg parse.get_exec_path(args, comm, exepath). +// Any 2-arg call site re-introduces the fork-shell mismatch where the rule +// side evaluates to a bare comm (e.g. "sh") while the recording side stores +// the resolved exepath (e.g. "/bin/sh") — silently breaking ap.was_executed +// lookups for execve patterns like `sh -c …` whose argv[0] is just "sh". +func TestDefaultRulesYAML_NoTwoArgGetExecPath(t *testing.T) { + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + repoRoot := filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", "..", "..", "..", "..")) + yamlPath := filepath.Join(repoRoot, "tests", "chart", "templates", "node-agent", "default-rules.yaml") + + data, err := os.ReadFile(yamlPath) + if err != nil { + t.Fatalf("read %s: %v", yamlPath, err) + } + + // Match parse.get_exec_path( args , comm ) — i.e. exactly two arguments, + // no third event.exepath. Whitespace between args is tolerated. + twoArg := regexp.MustCompile(`parse\.get_exec_path\(\s*event\.args\s*,\s*event\.comm\s*\)`) + if locs := twoArg.FindAllIndex(data, -1); len(locs) > 0 { + lines := lineNumbers(data, locs) + t.Errorf("found %d 2-arg parse.get_exec_path() call(s) at line(s) %v; migrate to parse.get_exec_path(event.args, event.comm, event.exepath)", len(locs), lines) + } +} + +func lineNumbers(data []byte, locs [][]int) []int { + out := make([]int, 0, len(locs)) + for _, loc := range locs { + line := 1 + for i := 0; i < loc[0] && i < len(data); i++ { + if data[i] == '\n' { + line++ + } + } + out = append(out, line) + } + return out +} diff --git a/pkg/rulemanager/cel/libraries/parse/parse.go b/pkg/rulemanager/cel/libraries/parse/parse.go index b1fc0c56d4..be0608a848 100644 --- a/pkg/rulemanager/cel/libraries/parse/parse.go +++ b/pkg/rulemanager/cel/libraries/parse/parse.go @@ -29,54 +29,38 @@ func (l *parseLibrary) getExecPath(args ref.Val, comm ref.Val) ref.Val { return types.String(commStr) } -// getExecPathWithExePath is the 3-arg overload that resolves the exec -// path with symlink-faithful precedence: +// getExecPathWithExePath resolves the exec path symmetrically with the +// recording side's resolveExecPath in +// pkg/containerprofilemanager/v1/event_reporting.go. Precedence: // -// 1. argv[0] when it's an absolute path (`/...`) — preserves symlink -// identity as invoked (e.g. busybox-based images where /bin/sh, -// /usr/bin/nslookup, /bin/echo are all symlinks to /bin/busybox; -// argv[0] carries the symlink form, exepath carries the kernel- -// resolved target). User-authored profiles list the symlink form, -// and the recording side (resolveExecPath in -// pkg/containerprofilemanager/v1/event_reporting.go) uses the same -// precedence so profile.Path matches what rules query. +// 1. exepath — kernel-authoritative; the only spoof-resistant source. +// argv[0] is user-controllable (man 3 exec) even when absolute, so +// a process can lie about its identity via `exec -a /bin/sh sleep` +// while /proc//exe stays /usr/bin/sleep. Trusting absolute +// argv[0] would let that lie pass an ap.was_executed check for +// /bin/sh. // -// 2. exepath when argv[0] is bare (e.g. "sh", "curl") or empty — the -// kernel-authoritative path is the right tiebreaker here, and -// preserves the existing argv[0]-spoofing protection: an attacker -// passing a misleading bare argv[0] (e.g. argv[0]="sshd" while -// actually exec'ing /usr/bin/curl) gets resolved to the real -// exepath, not the bare lie. The "absolute path → trust argv[0]" -// rule is safe because the kernel only exposes an absolute argv[0] -// when execve was called with that exact path (modulo symlinks -// that the kernel itself follows transparently). +// 2. argv[0] when non-empty AND exepath empty (fexecve / execveat +// AT_EMPTY_PATH — the libpam helper invocation pattern; defers to +// the 2-arg fallback below). // -// 3. argv[0] when bare AND exepath empty (fexecve / AT_EMPTY_PATH). +// 3. comm as final fallback (also via 2-arg). // -// 4. comm as final fallback. -// -// This closes the spurious-R0001 gap on busybox-based containers AND -// the prior fork-shell case where event.exepath was the only source. +// This closes the fork-shell mismatch — `sh -c …` records /bin/sh on +// the recording side and the rule side now queries /bin/sh too — while +// preserving the kernel-authoritative argv[0]-spoofing protection that +// the prior absolute-argv[0]-wins tier had eroded. func (l *parseLibrary) getExecPathWithExePath(args ref.Val, comm ref.Val, exepath ref.Val) ref.Val { exepathStr, ok := exepath.Value().(string) if !ok { return types.MaybeNoSuchOverloadErr(exepath) } - argsList, err := celparse.ParseList[string](args) - if err == nil && len(argsList) > 0 { - argv0 := argsList[0] - // Tier 1: absolute argv[0] wins. Symlink-faithful. - if len(argv0) > 0 && argv0[0] == '/' { - return types.String(argv0) - } - } - - // Tier 2: kernel-authoritative exepath when argv[0] is bare/empty. if exepathStr != "" { return types.String(exepathStr) } - // Tiers 3+4: defer to 2-arg fallback (argv[0]-bare → comm). + // exepath empty (fexecve / AT_EMPTY_PATH) — fall back to argv[0], + // then comm, via the 2-arg path. return l.getExecPath(args, comm) } diff --git a/pkg/rulemanager/cel/libraries/parse/parsing_test.go b/pkg/rulemanager/cel/libraries/parse/parsing_test.go index b756aaea51..b91bc1d904 100644 --- a/pkg/rulemanager/cel/libraries/parse/parsing_test.go +++ b/pkg/rulemanager/cel/libraries/parse/parsing_test.go @@ -196,40 +196,40 @@ func TestGetExecPath_SymmetryWithRecordingSide(t *testing.T) { expected: "/bin/sh", }, { - // Busybox-style symlink case: the user runs `/bin/sh` which is - // a symlink to `/bin/busybox`. Inspektor Gadget's eBPF tracer - // reports exepath as the kernel-resolved binary (`/bin/busybox`) - // while argv[0] preserves the symlink-as-invoked form - // (`/bin/sh`). User-authored profiles list the symlink form - // (which is what people think of), and the recording side's - // resolveExecPath records the same form when argv[0] is - // absolute. Rule-side resolution MUST match so ap.was_executed - // finds the profile entry on busybox-based images. - // - // Precedence: absolute-argv[0] > exepath > bare-argv[0] > comm. - // argv[0] being absolute is the signal that the symlink form - // is intentional and present at exec time; bare argv[0] is - // just a shell convention and the kernel-authoritative exepath - // should win (preserving the existing argv[0]-spoofing - // protection where attackers pass a misleading bare argv[0]). - name: "busybox symlink — argv[0] /bin/sh absolute, exepath /bin/busybox", + // Busybox-style symlink case: the user runs `/bin/sh` which + // is a symlink to `/bin/busybox`. The kernel-resolved exepath + // is `/bin/busybox`. The rule side queries the same identity + // the recording side stored — exepath — so user-authored + // profiles on busybox images must list `/bin/busybox`. Trusting + // the absolute argv[0] here would let `exec -a /bin/sh sleep` + // pass an ap.was_executed check for `/bin/sh`. + name: "busybox symlink — exepath /bin/busybox wins over argv[0]=/bin/sh", expr: "parse.get_exec_path(['/bin/sh', '-c', 'echo hi'], 'sh', '/bin/busybox')", - expected: "/bin/sh", + expected: "/bin/busybox", }, { - name: "busybox symlink — nslookup absolute, exepath /bin/busybox", + name: "busybox symlink — exepath /bin/busybox wins over argv[0]=/usr/bin/nslookup", expr: "parse.get_exec_path(['/usr/bin/nslookup', 'example.com'], 'nslookup', '/bin/busybox')", - expected: "/usr/bin/nslookup", + expected: "/bin/busybox", }, { - // Negative case: argv[0] bare → exepath still wins. This - // preserves the argv[0] spoofing protection in the test above - // ("argv[0] spoofing"), where a bare argv[0]='sshd' was being - // rejected in favour of the kernel-authoritative exepath. - name: "bare argv[0] keeps spoof protection — exepath wins", + // Bare-argv[0] spoof — exepath still wins. + name: "bare argv[0] spoof — exepath wins", expr: "parse.get_exec_path(['sshd', '-i'], 'curl', '/usr/bin/curl')", expected: "/usr/bin/curl", }, + { + // Absolute argv[0] spoof — `exec -a /bin/sh sleep 2`. + // Process lies about its identity via an allowed absolute + // argv[0]; the real exe is /usr/bin/sleep. Rule-side resolver + // MUST anchor on kernel-authoritative exepath, mirroring + // resolveExecPath so ap.was_executed lookups reflect the + // real binary. Regression pin for matthyx blocker on PR #805 + // (2026-05-27). + name: "absolute argv[0] spoof — exec -a /bin/sh sleep", + expr: "parse.get_exec_path(['/bin/sh', '2'], 'sleep', '/usr/bin/sleep')", + expected: "/usr/bin/sleep", + }, } for _, tt := range tests { diff --git a/tests/chart/templates/node-agent/default-rules.yaml b/tests/chart/templates/node-agent/default-rules.yaml index 0a4fe1d87f..f6b1f1b040 100644 --- a/tests/chart/templates/node-agent/default-rules.yaml +++ b/tests/chart/templates/node-agent/default-rules.yaml @@ -18,7 +18,7 @@ spec: uniqueId: "event.comm + '_' + event.exepath" ruleExpression: - eventType: "exec" - expression: "!ap.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm))" + expression: "!ap.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm, event.exepath))" profileDependency: 0 profileDataRequired: execs: all @@ -198,7 +198,7 @@ spec: uniqueId: "eventType == 'exec' ? 'exec_' + event.comm : 'network_' + event.dstAddr" ruleExpression: - eventType: "exec" - expression: "(event.comm == 'kubectl' || event.exepath.endsWith('/kubectl')) && !ap.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm))" + expression: "(event.comm == 'kubectl' || event.exepath.endsWith('/kubectl')) && !ap.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm, event.exepath))" - eventType: "network" expression: "event.pktType == 'OUTGOING' && k8s.is_api_server_address(event.dstAddr) && !nn.was_address_in_egress(event.containerId, event.dstAddr)" profileDependency: 0 @@ -333,7 +333,7 @@ spec: expression: > (event.exepath == '/dev/shm' || event.exepath.startsWith('/dev/shm/')) || (event.cwd == '/dev/shm' || event.cwd.startsWith('/dev/shm/') || - (parse.get_exec_path(event.args, event.comm).startsWith('/dev/shm/'))) + (parse.get_exec_path(event.args, event.comm, event.exepath).startsWith('/dev/shm/'))) profileDependency: 2 severity: 8 supportPolicy: false @@ -358,7 +358,7 @@ spec: expression: > (event.upperlayer == true || event.pupperlayer == true) && - !ap.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm)) + !ap.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm, event.exepath)) profileDependency: 1 profileDataRequired: execs: all @@ -431,7 +431,7 @@ spec: uniqueId: "event.comm" ruleExpression: - eventType: "exec" - expression: "!ap.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm)) && k8s.get_container_mount_paths(event.namespace, event.podName, event.containerName).exists(mount, event.exepath.startsWith(mount) || parse.get_exec_path(event.args, event.comm).startsWith(mount))" + expression: "!ap.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm, event.exepath)) && k8s.get_container_mount_paths(event.namespace, event.podName, event.containerName).exists(mount, event.exepath.startsWith(mount) || parse.get_exec_path(event.args, event.comm, event.exepath).startsWith(mount))" profileDependency: 1 profileDataRequired: execs: all From 487c21cf5a88e95bfd133d43472dcf5e255bdf70 Mon Sep 17 00:00:00 2001 From: entlein Date: Wed, 27 May 2026 17:57:38 +0200 Subject: [PATCH 03/17] addressing the rabbit Signed-off-by: entlein --- pkg/rulemanager/cel/expression_rewrite.go | 28 +++++++++++++++---- .../cel/libraries/parse/parsing_test.go | 5 ++-- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/pkg/rulemanager/cel/expression_rewrite.go b/pkg/rulemanager/cel/expression_rewrite.go index af7b21be18..048d5597c6 100644 --- a/pkg/rulemanager/cel/expression_rewrite.go +++ b/pkg/rulemanager/cel/expression_rewrite.go @@ -17,10 +17,10 @@ var twoArgEventGetExecPath = regexp.MustCompile(`\bparse\.get_exec_path\(\s*even // `[^,)]+` halts at the first comma, so a 3-arg call does not match. var anyTwoArgGetExecPath = regexp.MustCompile(`\bparse\.get_exec_path\([^,)]+,\s*[^,)]+\)`) -// rewriteDeprecatedHelpers upgrades known-deprecated helper call sites in a -// CEL expression to their safer current form. Returns the rewritten -// expression plus operator-visible notices describing each rewrite or -// remaining concern. +// rewriteDeprecatedHelpers is a compatibility shim that auto-upgrades +// deprecated 2-arg parse.get_exec_path calls to the safer 3-arg form. +// Returns the rewritten expression plus operator-visible notices +// describing each rewrite or remaining concern. // // Today this handles ONE rewrite: the 2-arg parse.get_exec_path against // the event variable. The 2-arg overload returns argv[0] (or comm) which @@ -36,6 +36,24 @@ var anyTwoArgGetExecPath = regexp.MustCompile(`\bparse\.get_exec_path\([^,)]+,\s // preserving. Non-canonical 2-arg calls (custom identifiers or literals) // cannot be auto-upgraded; they surface a notice so operators can migrate // manually. +// +// IMPLEMENTATION NOTE: this is intentionally a text-level regex rewrite, +// not an AST transform. The theoretical risk of mutating a string +// literal that happens to contain the exact 2-arg call pattern (e.g. +// `event.message == "parse.get_exec_path(event.args, event.comm)"`) is +// accepted as negligible: CEL security-rule expressions do not embed +// the helper's call form as a literal, and the alternative (CEL parser +// dependency at this layer + AST walker + serializer) is disproportionate +// to the risk. If a future need surfaces a real-world false-rewrite, +// upgrade to AST-based transformation then. +// +// DEPRECATION NOTICE: the 2-arg parse.get_exec_path overload is +// deprecated. This shim auto-upgrades calls at registration time so +// operators who upgrade the node-agent binary without updating their +// RuleBindings retain the kernel-authoritative behaviour. The shim and +// the 2-arg overload itself will be removed in a future major version; +// migrate RuleBindings to call the 3-arg form explicitly to avoid the +// removal cliff. func rewriteDeprecatedHelpers(expression string) (string, []string) { var notices []string @@ -44,7 +62,7 @@ func rewriteDeprecatedHelpers(expression string) (string, []string) { expression = twoArgEventGetExecPath.ReplaceAllString(expression, "parse.get_exec_path(event.args, event.comm, event.exepath)") notices = append(notices, fmt.Sprintf( - "auto-rewrote 2-arg parse.get_exec_path → 3-arg with event.exepath fallback; the 2-arg form trusts argv[0] which is user-controllable and bypassable via `exec -a `. Update RuleBindings to call the 3-arg form explicitly. Original expression: %q", + "[DEPRECATED, to be removed in a future major release] auto-rewrote 2-arg parse.get_exec_path → 3-arg with event.exepath fallback; the 2-arg form trusts argv[0] which is user-controllable and bypassable via `exec -a `. Update RuleBindings to call the 3-arg form explicitly. Original expression: %q", original)) } diff --git a/pkg/rulemanager/cel/libraries/parse/parsing_test.go b/pkg/rulemanager/cel/libraries/parse/parsing_test.go index b91bc1d904..51b213b718 100644 --- a/pkg/rulemanager/cel/libraries/parse/parsing_test.go +++ b/pkg/rulemanager/cel/libraries/parse/parsing_test.go @@ -140,10 +140,11 @@ func TestParseLibraryErrorCases(t *testing.T) { // rule-side resolver MUST agree with pkg/containerprofilemanager/v1/ // event_reporting.go:resolveExecPath. That recording function uses // 1. exepath (kernel-authoritative) -// 2. argv[0] when non-empty +// 2. argv[0] when non-empty AND exepath empty (fexecve / AT_EMPTY_PATH) // 3. comm // in that precedence order — so the path stored in the ApplicationProfile -// is whatever the kernel reports. +// is whatever the kernel reports, and argv[0] is only consulted when the +// kernel itself could not report an exepath. // // If the rule side ignores exepath, the profile entry written under // "/bin/sh" becomes unreachable when the runtime queries with the rule's From 8b611317abe149a24b7ce635e30b46faf6853e2f Mon Sep 17 00:00:00 2001 From: Entlein Date: Thu, 28 May 2026 14:03:51 +0200 Subject: [PATCH 04/17] build(go.mod): force kubescape/storage v0.0.258 via self-replace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prevents Go MVS from picking up a higher storage version pulled in transitively by other deps. PR #805 (parse.get_exec_path 3-arg overload, event_reporting spoof revert, CEL auto-rewrite shim) does not reference any post-v0.0.258 storage symbol, so v0.0.258 is the floor — and now also the ceiling. Signed-off-by: entlein --- go.mod | 2 ++ 1 file changed, 2 insertions(+) diff --git a/go.mod b/go.mod index 68ee6ab378..9b77eff129 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/kubescape/storage v0.0.258 From c74ba23e88100b6170fa66c85deed5bccc348c5d Mon Sep 17 00:00:00 2001 From: entlein Date: Thu, 28 May 2026 14:39:35 +0200 Subject: [PATCH 05/17] pinning indirect kubescape to 278 , adding component test 27 Signed-off-by: entlein --- .github/workflows/component-tests.yaml | 3 +- go.mod | 60 +++++------ go.sum | 144 ++++++++++++++----------- 3 files changed, 114 insertions(+), 93 deletions(-) diff --git a/.github/workflows/component-tests.yaml b/.github/workflows/component-tests.yaml index 86612b8053..069d1f4ab1 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 # for PRs 807 this is the only component test to add ] steps: - name: Checkout code diff --git a/go.mod b/go.mod index 9b77eff129..f9e8102d3b 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/kubescape/storage v0.0.258 diff --git a/go.sum b/go.sum index 5cbde20563..a91178585d 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= @@ -889,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= @@ -915,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= @@ -1033,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= @@ -1081,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= @@ -1196,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= @@ -1240,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= @@ -1302,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= @@ -1311,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= @@ -1337,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= @@ -2141,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= @@ -2161,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 1321e5549def1a6db025103fa21be0770be7318f Mon Sep 17 00:00:00 2001 From: entlein Date: Thu, 28 May 2026 15:48:43 +0200 Subject: [PATCH 06/17] swapping test 27 (out) and 32(in) Signed-off-by: entlein --- .github/workflows/component-tests.yaml | 2 +- tests/component_test.go | 247 ++++++++++++++++++ .../curl-exec-arg-wildcards-deployment.yaml | 28 ++ 3 files changed, 276 insertions(+), 1 deletion(-) create mode 100644 tests/resources/curl-exec-arg-wildcards-deployment.yaml diff --git a/.github/workflows/component-tests.yaml b/.github/workflows/component-tests.yaml index 069d1f4ab1..4077ba98b5 100644 --- a/.github/workflows/component-tests.yaml +++ b/.github/workflows/component-tests.yaml @@ -72,7 +72,7 @@ jobs: Test_22_AlertOnPartialNetworkProfileTest, Test_23_RuleCooldownTest, Test_24_ProcessTreeDepthTest, - Test_27_ApplicationProfileOpens # for PRs 807 this is the only component test to add + Test_32_UnexpectedProcessArguments ] steps: - name: Checkout code diff --git a/tests/component_test.go b/tests/component_test.go index fcdb760bfb..d3e70357f6 100644 --- a/tests/component_test.go +++ b/tests/component_test.go @@ -1569,3 +1569,250 @@ func Test_24_ProcessTreeDepthTest(t *testing.T) { t.Logf("Found alerts for the process tree depth: %v", alerts) } + +// --------------------------------------------------------------------------- +// 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) { + 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") + }) +} diff --git a/tests/resources/curl-exec-arg-wildcards-deployment.yaml b/tests/resources/curl-exec-arg-wildcards-deployment.yaml new file mode 100644 index 0000000000..2f06f8baef --- /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 ed4a0eafc823c8bc69273d3fb3280e3242895966 Mon Sep 17 00:00:00 2001 From: entlein Date: Thu, 28 May 2026 16:34:00 +0200 Subject: [PATCH 07/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 f9e8102d3b..32f7128662 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.32 // 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 @@ -481,3 +479,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 a91178585d..271a7c2cc6 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.32 h1:S54xuVcPxeLaYgaRABtpJ2VyVUVsy0IGf7qH github.com/containerd/containerd v1.7.32/go.mod h1:jdwD6s/BhV4XVJGrvtziNPVA+83n66TwptVaPKprq4E= 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= @@ -923,8 +915,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-20260513134836-aa8a4c2613db h1:li+4y/XuMY5X4ICzp4cGdFE5eQzYae6KRAkIUsZkeFE= @@ -1091,8 +1081,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= @@ -1321,14 +1311,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 d66998276565ea5fbbcdf32f380be5eccd00e5dc Mon Sep 17 00:00:00 2001 From: entlein Date: Fri, 29 May 2026 09:59:18 +0200 Subject: [PATCH 08/17] add .NoError assert Signed-off-by: entlein --- tests/component_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/component_test.go b/tests/component_test.go index d3e70357f6..c7cd0413f5 100644 --- a/tests/component_test.go +++ b/tests/component_test.go @@ -1750,6 +1750,7 @@ func Test_32_UnexpectedProcessArguments(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) + require.NoError(t, err) alerts := waitAlerts(t, wl.Namespace) t.Logf("=== %d alerts ===", len(alerts)) @@ -1769,6 +1770,7 @@ func Test_32_UnexpectedProcessArguments(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) + require.NoError(t, err) alerts := waitAlerts(t, wl.Namespace) t.Logf("=== %d alerts ===", len(alerts)) @@ -1787,6 +1789,7 @@ func Test_32_UnexpectedProcessArguments(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) + require.NoError(t, err) alerts := waitAlerts(t, wl.Namespace) t.Logf("=== %d alerts ===", len(alerts)) @@ -1806,6 +1809,7 @@ func Test_32_UnexpectedProcessArguments(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) + require.NoError(t, err) alerts := waitAlerts(t, wl.Namespace) t.Logf("=== %d alerts ===", len(alerts)) From b4f4caa9f8639837d88b4df9b33a5cc542e2df57 Mon Sep 17 00:00:00 2001 From: entlein Date: Fri, 29 May 2026 12:06:49 +0200 Subject: [PATCH 09/17] CT 32 triage root cause of failure; Signed-off-by: entlein --- .../containerprofilecache/reconciler.go | 31 +++++-- .../containerprofilecache/reconciler_test.go | 90 +++++++++++++++++++ 2 files changed, 114 insertions(+), 7 deletions(-) diff --git a/pkg/objectcache/containerprofilecache/reconciler.go b/pkg/objectcache/containerprofilecache/reconciler.go index 0af5c8ee49..73c0269500 100644 --- a/pkg/objectcache/containerprofilecache/reconciler.go +++ b/pkg/objectcache/containerprofilecache/reconciler.go @@ -366,35 +366,52 @@ func (c *ContainerProfileCacheImpl) refreshOneEntry(ctx context.Context, id stri userManagedNN = nil } } + // Determine the user-defined overlay name. Prefer the ref already stored + // on the entry (set when addContainer saw the label at container-start). + // If absent, re-read the current pod labels: IG enrichment can populate + // the `kubescape.io/user-defined-profile` label AFTER addContainer's + // initial fire, and without re-checking here a container whose label + // propagated late would never get its overlay merged. Test_32 race: + // curl-32-overlay AP exists in storage, but the cached entry was built + // before the label was visible, so was_executed misses /bin/sh and + // R0001 fires. + var overlayName, overlayNS string + if e.UserAPRef != nil { + overlayName = e.UserAPRef.Name + overlayNS = e.UserAPRef.Namespace + } else if pod := c.k8sObjectCache.GetPod(e.Namespace, e.PodName); pod != nil { + if v, ok := pod.Labels[helpersv1.UserDefinedProfileMetadataKey]; ok && v != "" { + overlayName = v + overlayNS = e.Namespace + } + } var userAP *v1beta1.ApplicationProfile var userNN *v1beta1.NetworkNeighborhood - if e.UserAPRef != nil { + if overlayName != "" { var userAPErr error _ = c.refreshRPC(ctx, func(rctx context.Context) error { - userAP, userAPErr = c.storageClient.GetApplicationProfile(rctx, e.UserAPRef.Namespace, e.UserAPRef.Name) + userAP, userAPErr = c.storageClient.GetApplicationProfile(rctx, overlayNS, overlayName) return userAPErr }) if userAPErr != nil && e.UserAPRV != "" { logger.L().Debug("refreshOneEntry: user-defined AP fetch failed; keeping cached entry", helpers.String("containerID", id), - helpers.String("name", e.UserAPRef.Name), + helpers.String("name", overlayName), helpers.Error(userAPErr)) return } if userAPErr != nil { userAP = nil } - } - if e.UserNNRef != nil { var userNNErr error _ = c.refreshRPC(ctx, func(rctx context.Context) error { - userNN, userNNErr = c.storageClient.GetNetworkNeighborhood(rctx, e.UserNNRef.Namespace, e.UserNNRef.Name) + userNN, userNNErr = c.storageClient.GetNetworkNeighborhood(rctx, overlayNS, overlayName) return userNNErr }) if userNNErr != nil && e.UserNNRV != "" { logger.L().Debug("refreshOneEntry: user-defined NN fetch failed; keeping cached entry", helpers.String("containerID", id), - helpers.String("name", e.UserNNRef.Name), + helpers.String("name", overlayName), helpers.Error(userNNErr)) return } diff --git a/pkg/objectcache/containerprofilecache/reconciler_test.go b/pkg/objectcache/containerprofilecache/reconciler_test.go index e76c384d6a..039224cb23 100644 --- a/pkg/objectcache/containerprofilecache/reconciler_test.go +++ b/pkg/objectcache/containerprofilecache/reconciler_test.go @@ -1399,3 +1399,93 @@ func TestSpecChange_TriggersReprojection(t *testing.T) { assert.Contains(t, after.Capabilities.Values, "SYS_PTRACE", "after spec change → SYS_PTRACE projected") assert.Contains(t, after.Capabilities.Values, "NET_ADMIN", "after spec change → NET_ADMIN projected") } + +// TestRefreshPicksUpLabelAppearingAfterAdd pins the fix for the user-defined- +// profile late-label race. When addContainer fires before the IG enrichment +// has populated the kubescape.io/user-defined-profile label on the container +// event, the initial entry is built with UserAPRef=nil. Without the fix, the +// reconciler's per-tick refresh would forever skip the user overlay fetch +// (guarded by `if e.UserAPRef != nil`), even after the label later appears in +// the k8s pod cache. The fix has refreshOneEntry re-read the pod labels and +// pick up a newly-visible overlay reference. Surface symptom this guards +// against: Test_32_UnexpectedProcessArguments seeing R0001 fire because +// /bin/sh isn't in the cached projection's Execs. +func TestRefreshPicksUpLabelAppearingAfterAdd(t *testing.T) { + cp := &v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "base-cp", Namespace: "default", ResourceVersion: "1", + Annotations: map[string]string{helpersv1.StatusMetadataKey: helpersv1.Completed}, + }, + // Base CP has NO exec entries — only the late-arriving overlay can + // satisfy was_executed(/bin/sh). + } + overlay := &v1beta1.ApplicationProfile{ + ObjectMeta: metav1.ObjectMeta{Name: "curl-overlay", Namespace: "default", ResourceVersion: "42"}, + Spec: v1beta1.ApplicationProfileSpec{ + Containers: []v1beta1.ApplicationProfileContainer{{ + Name: "curl", + Execs: []v1beta1.ExecCalls{ + {Path: "/bin/sh", Args: []string{"/bin/sh", "-c"}}, + }, + }}, + }, + } + client := &countingProfileClient{cp: cp, ap: overlay} + k8s := newControllableK8sCache() + c := newReconcilerCache(t, client, k8s, newCountingMetrics()) + + // Project execs (so the projection's Execs.Values is populated rather + // than collapsed by the projection-spec gating). + c.SetProjectionSpec(objectcache.RuleProjectionSpec{ + Execs: objectcache.FieldSpec{InUse: true, All: true}, + }) + + const id = "container-late-label" + const podName = "curl-32-abcdef" + const ns = "default" + + // Seed an entry as if addContainer ran BEFORE the label was visible: no + // UserAPRef, no UserAPRV. Mirrors what tryPopulateEntry's buildEntry + // would have produced if container.K8s.PodLabels was empty at the time. + entry := newEntry(cp, "curl", podName, ns, "uid-1") + entry.WorkloadName = "curl-32" + c.entries.Set(id, entry) + + // Pre-fix verification: the cached projection has no execs (base CP is + // empty), so was_executed-style lookups would miss /bin/sh. + before := c.GetProjectedContainerProfile(id) + require.NotNil(t, before) + assert.NotContains(t, before.Execs.Values, "/bin/sh", + "pre-fix sanity: base-only entry doesn't see /bin/sh") + require.Nil(t, entry.UserAPRef, "pre-fix sanity: entry has no overlay ref yet") + + // Label propagates: pod appears in the k8s cache WITH the + // user-defined-profile label pointing at curl-overlay. + k8s.setPod(ns, podName, &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: podName, Namespace: ns, UID: types.UID("uid-1"), + Labels: map[string]string{ + helpersv1.UserDefinedProfileMetadataKey: "curl-overlay", + }, + }, + }) + + // Run one refresh tick. + c.containerLocks.WithLock(id, func() { + current, _ := c.entries.Load(id) + c.refreshOneEntry(context.Background(), id, current) + }) + + // Post-fix: refresh re-read pod labels, fetched the overlay, merged it + // into the projection, and recorded UserAPRef on the new entry so future + // ticks fast-path through it. + after := c.GetProjectedContainerProfile(id) + require.NotNil(t, after) + assert.Contains(t, after.Execs.Values, "/bin/sh", + "overlay execs must merge into projection after label appears") + newEntry, ok := c.entries.Load(id) + require.True(t, ok, "entry still present after refresh") + require.NotNil(t, newEntry.UserAPRef, "UserAPRef recorded so subsequent ticks skip the GetPod re-read") + assert.Equal(t, "curl-overlay", newEntry.UserAPRef.Name) + assert.Equal(t, ns, newEntry.UserAPRef.Namespace) +} From 71a4084416cc613bfbbd85c945a7fda98a5139e0 Mon Sep 17 00:00:00 2001 From: entlein Date: Fri, 29 May 2026 13:10:55 +0200 Subject: [PATCH 10/17] CT 32 resolve busybox by walking the link Signed-off-by: entlein --- tests/component_test.go | 34 +++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/tests/component_test.go b/tests/component_test.go index c7cd0413f5..82eb432fc9 100644 --- a/tests/component_test.go +++ b/tests/component_test.go @@ -1662,6 +1662,23 @@ func Test_32_UnexpectedProcessArguments(t *testing.T) { {Path: "/bin/echo", Args: []string{"/bin/echo", "hello", dynamicpathdetector.WildcardIdentifier}}, // curl -s {Path: "/usr/bin/curl", Args: []string{"/usr/bin/curl", "-s", dynamicpathdetector.DynamicIdentifier}}, + // Busybox-symlink mirror entries. The curl image's + // /bin/{sleep,sh,echo} are symlinks to /bin/busybox, + // so the kernel's resolved /proc//exe — what + // IG captures as event.exepath — is /bin/busybox. + // parse.get_exec_path(args, comm, exepath) returns + // exepath first, so ap.was_executed queries arrive + // at the rule keyed on /bin/busybox, not the + // symlink form. Without a matching profile entry + // keyed on /bin/busybox, R0001 fires before R0040 + // ever evaluates and the test trips its R0001 + // precondition. The symlink-form entries above are + // retained for environments where exepath resolves + // to the as-invoked path (non-symlinked utilities; + // fexecve / argv[0] fallback in resolveExecPath). + {Path: "/bin/busybox", Args: []string{"/bin/sleep", dynamicpathdetector.WildcardIdentifier}}, + {Path: "/bin/busybox", Args: []string{"/bin/sh", "-c", dynamicpathdetector.WildcardIdentifier}}, + {Path: "/bin/busybox", Args: []string{"/bin/echo", "hello", dynamicpathdetector.WildcardIdentifier}}, }, 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"}, }, @@ -1762,14 +1779,21 @@ func Test_32_UnexpectedProcessArguments(t *testing.T) { }) // ----------------------------------------------------------------- - // 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. + // 32b. sh -x -c — argv [sh, -x, -c, "echo hi"] does NOT match + // profile [sh, -c, *] (literal anchor `-c` at position 1 mismatches + // `-x`). Path /bin/sh (or /bin/busybox) IS in profile so R0001 + // stays silent. R0040 must fire. + // + // Earlier shape `sh -x "echo hi"` exited 2 (busybox sh tried to + // open "echo hi" as a script file) — kubectl exec returned an + // error and require.NoError tripped before R0040 could be read. + // Adding -c keeps sh's invocation valid while preserving the + // argv-shape mismatch that exercises R0040. // ----------------------------------------------------------------- t.Run("sh_dash_x_mismatches_R0040", func(t *testing.T) { wl := 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) + stdout, stderr, err := wl.ExecIntoPod([]string{"sh", "-x", "-c", "echo hi"}, "curl") + t.Logf("sh -x -c 'echo hi' → err=%v stdout=%q stderr=%q", err, stdout, stderr) require.NoError(t, err) alerts := waitAlerts(t, wl.Namespace) From 9d792d0368e2ff4592f7db5919bc817ff95971fe Mon Sep 17 00:00:00 2001 From: entlein Date: Fri, 29 May 2026 13:37:45 +0200 Subject: [PATCH 11/17] CT32 maybe we should have added the new rule we re trying to detect. just maybe Signed-off-by: entlein --- .../templates/node-agent/default-rules.yaml | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/chart/templates/node-agent/default-rules.yaml b/tests/chart/templates/node-agent/default-rules.yaml index f6b1f1b040..530981bbbe 100644 --- a/tests/chart/templates/node-agent/default-rules.yaml +++ b/tests/chart/templates/node-agent/default-rules.yaml @@ -689,3 +689,27 @@ spec: - "syscalls" - "io_uring" - "applicationprofile" + - name: "Unexpected process arguments" + enabled: true + id: "R0040" + description: "Detects an exec event whose path IS in the profile but whose argv vector does not match any recorded argv pattern for that path. Consumes ap.was_executed_with_args, which walks the ExecsByPath projection surface added by node-agent#807 and delegates argv comparison to dynamicpathdetector.CompareExecArgs (storage). Stays silent when the path is unknown (R0001 covers that case) and when the argv vector matches any recorded pattern (including the trailing-wildcard form `[…, *]`)." + expressions: + message: "'Unexpected process arguments: ' + event.comm + ' with PID ' + string(event.pid) + ' argv=' + string(event.args)" + uniqueId: "event.comm + '_' + event.exepath + '_' + string(event.args)" + ruleExpression: + - eventType: "exec" + expression: "ap.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm, event.exepath)) && !ap.was_executed_with_args(event.containerId, parse.get_exec_path(event.args, event.comm, event.exepath), event.args)" + profileDependency: 0 + profileDataRequired: + execs: all + severity: 3 + supportPolicy: false + isTriggerAlert: true + mitreTactic: "TA0002" + mitreTechnique: "T1059" + tags: + - "context:kubernetes" + - "anomaly" + - "process" + - "exec" + - "applicationprofile" From b94913b3c6a3b2ae5bd33edaee206c2821544fc7 Mon Sep 17 00:00:00 2001 From: entlein Date: Fri, 29 May 2026 19:22:33 +0200 Subject: [PATCH 12/17] this new path resolution has a few side-effects Signed-off-by: entlein --- .../containerprofilecache/projection_apply.go | 34 +++++ pkg/objectcache/projection_types.go | 12 ++ pkg/objectcache/v1/mock.go | 9 ++ .../cel/libraries/applicationprofile/exec.go | 59 ++++++-- .../libraries/applicationprofile/exec_test.go | 137 +++++++++++++++++- 5 files changed, 235 insertions(+), 16 deletions(-) diff --git a/pkg/objectcache/containerprofilecache/projection_apply.go b/pkg/objectcache/containerprofilecache/projection_apply.go index 1354641886..0284885326 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,39 @@ func extractExecsPaths(cp *v1beta1.ContainerProfile) []string { return paths } +// extractExecsByPath builds the path → []argv-vectors map used by +// argv-aware matchers (ap.was_executed_with_args via +// dynamicpathdetector.CompareExecArgs in storage). Multiple ExecCalls +// entries with the same Path APPEND to the per-path list: overlay merge +// in projectUserProfiles legitimately produces several ExecCalls per +// path, each with a distinct argv shape, and the consumer must accept +// any of them. +// +// 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 { + if len(cp.Spec.Execs) == 0 { + return nil + } + m := make(map[string][][]string, len(cp.Spec.Execs)) + for _, e := range cp.Spec.Execs { + var entry []string + if e.Args == nil { + entry = []string{} + } else { + entry = make([]string, len(e.Args)) + copy(entry, e.Args) + } + m[e.Path] = append(m[e.Path], entry) + } + 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/projection_types.go b/pkg/objectcache/projection_types.go index ed55d671b6..267c22f4ef 100644 --- a/pkg/objectcache/projection_types.go +++ b/pkg/objectcache/projection_types.go @@ -54,6 +54,18 @@ type ProjectedContainerProfile struct { IngressDomains ProjectedField IngressAddresses ProjectedField + // ExecsByPath carries the per-Path Args slices from cp.Spec.Execs so + // downstream consumers (ap.was_executed_with_args + R0040) can run + // argv-vector matching against the projected profile. Keyed by + // Exec.Path (same key used in Execs.Values / Execs.Patterns); the + // value is a LIST of argv vectors because a merged profile can carry + // multiple ExecCalls entries with the same Path and different argv + // shapes — overlay merge appends rather than replaces, and the + // consumer matches if ANY vector matches the runtime args. + // Empty/absent key means "no argv constraint" (back-compat for + // pre-projection profiles). + ExecsByPath map[string][][]string + SpecHash string SyncChecksum string PolicyByRuleId map[string]v1beta1.RulePolicy diff --git a/pkg/objectcache/v1/mock.go b/pkg/objectcache/v1/mock.go index c618e24506..017d6e5072 100644 --- a/pkg/objectcache/v1/mock.go +++ b/pkg/objectcache/v1/mock.go @@ -153,8 +153,17 @@ 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{}{} + var entry []string + if e.Args == nil { + entry = []string{} + } else { + entry = make([]string, len(e.Args)) + copy(entry, e.Args) + } + pcp.ExecsByPath[e.Path] = append(pcp.ExecsByPath[e.Path], entry) } } diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/exec.go b/pkg/rulemanager/cel/libraries/applicationprofile/exec.go index b69a69c0ea..e748ef1f10 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,56 @@ 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. ExecsByPath absent-vs-empty asymmetry: three states. + // + // 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: + // Walk each profile argv vector with CompareExecArgs (handles + // DynamicIdentifier "⋯" and WildcardIdentifier "*"). Match if + // ANY vector matches. if _, ok := cp.Execs.Values[pathStr]; ok { - 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". + 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 — match against any + // argv vector recorded for that pattern key. for _, execPath := range cp.Execs.Patterns { if dynamicpathdetector.CompareDynamic(execPath, pathStr) { - 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) + } } } diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/exec_test.go b/pkg/rulemanager/cel/libraries/applicationprofile/exec_test.go index 085e2215fc..d89cbb22d8 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/exec_test.go +++ b/pkg/rulemanager/cel/libraries/applicationprofile/exec_test.go @@ -9,6 +9,7 @@ import ( "github.com/kubescape/node-agent/pkg/objectcache" objectcachev1 "github.com/kubescape/node-agent/pkg/objectcache/v1" "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + "github.com/kubescape/storage/pkg/registry/file/dynamicpathdetector" "github.com/stretchr/testify/assert" ) @@ -200,12 +201,16 @@ func TestExecWithArgsInProfile(t *testing.T) { expectedResult: true, }, { - // v1 degradation: args projection is out of scope; path-only matching. + // Profile entry has Args=["-la", "/tmp"]; runtime args differ at + // position 1 (/home vs /tmp). CompareExecArgs walks the literal + // segments, so the mismatch surfaces and the match fails. This + // is the contract R0040 ("Unexpected process arguments") relies + // on: path-allowed exec with mismatching argv must NOT match. 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 +234,17 @@ func TestExecWithArgsInProfile(t *testing.T) { expectedResult: true, }, { - // v1 degradation: args projection is out of scope; path-only matching. + // Profile entry has Args=["-la", "/tmp"] (non-empty); runtime + // args is empty. matchExecArgsStrict treats this as anchored: + // the profile demands at least the literal "-la" but there's + // no runtime arg to consume. No match. (If the profile entry + // also wanted to allow the no-args case, it would carry a + // second ExecCalls entry for the same Path with empty Args.) name: "Empty args list", containerID: "test-container-id", path: "/bin/ls", args: []string{}, - expectedResult: true, + expectedResult: false, }, } @@ -326,3 +336,122 @@ func TestExecWithArgsCompilation(t *testing.T) { t.Fatalf("failed to create program: %v", err) } } + +// TestExecWithArgsBusyboxMultiVector pins the Test_32 component-test contract +// at the unit level. In busybox-symlink containers the kernel-resolved +// /proc//exe (and therefore event.exepath) is /bin/busybox for every +// applet exec, so parse.get_exec_path routes was_executed_with_args queries +// to /bin/busybox regardless of which symlink was invoked. The profile +// therefore carries multiple ExecCalls entries with the SAME Path +// (/bin/busybox) and DIFFERENT Args vectors — one per allowed argv shape. +// The matcher must walk every vector and accept if ANY matches. +// +// Failure modes this guards against: +// - The wasExecutedWithArgs stub that returned true for any path-in-profile +// match regardless of args (the bug that suppressed R0040 entirely on +// #805's first 3 CT runs). +// - extractExecsByPath overwriting prior entries when Paths collide (the +// "last-write-wins" concern CodeRabbit/matthyx raised on PR #807). +// +// Test_32 has 4 subtests; this pins the contract for each: +// sh_dash_c_matches_wildcard_trailing — argv matches profile [sh, -c, *]. +// sh_dash_x_mismatches_R0040 — argv mismatches at literal anchor. +// echo_hello_matches_wildcard_trailing — argv matches profile [echo, hello, *]. +// echo_goodbye_mismatches_R0040 — argv mismatches at literal "hello". +func TestExecWithArgsBusyboxMultiVector(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{ + // Three ExecCalls share Path=/bin/busybox with distinct argv + // shapes. The projection layer appends them all into + // ExecsByPath["/bin/busybox"]; the matcher walks every + // vector and accepts if ANY matches. + {Path: "/bin/busybox", Args: []string{"/bin/sleep", dynamicpathdetector.WildcardIdentifier}}, + {Path: "/bin/busybox", Args: []string{"/bin/sh", "-c", dynamicpathdetector.WildcardIdentifier}}, + {Path: "/bin/busybox", Args: []string{"/bin/echo", "hello", dynamicpathdetector.WildcardIdentifier}}, + }, + }) + 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 + args []string + expectedResult bool // true = silent (matches), false = R0040 must fire + }{ + { + name: "sh_dash_c matches [sh,-c,*]", + args: []string{"/bin/sh", "-c", "echo hi"}, + expectedResult: true, + }, + { + name: "sh_dash_x_dash_c mismatches at position 1 (-c vs -x)", + args: []string{"/bin/sh", "-x", "-c", "echo hi"}, + expectedResult: false, + }, + { + name: "echo_hello matches [echo,hello,*]", + args: []string{"/bin/echo", "hello", "world", "from", "test"}, + expectedResult: true, + }, + { + name: "echo_goodbye mismatches at position 1 (hello vs goodbye)", + args: []string{"/bin/echo", "goodbye", "world"}, + expectedResult: false, + }, + { + name: "sleep matches [sleep,*] wildcard", + args: []string{"/bin/sleep", "infinity"}, + expectedResult: true, + }, + { + name: "unknown applet mismatches all three vectors", + args: []string{"/bin/cat", "/etc/passwd"}, + expectedResult: 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()) + } + prog, err := env.Program(ast) + if err != nil { + t.Fatalf("failed to create program: %v", err) + } + result, _, err := prog.Eval(map[string]any{ + "containerID": "test-container-id", + "path": "/bin/busybox", + "args": tc.args, + }) + if err != nil { + t.Fatalf("failed to evaluate expression: %v", err) + } + assert.Equal(t, tc.expectedResult, result.Value().(bool), + "ap.was_executed_with_args(/bin/busybox, %v) — must return %v so R0040's `!was_executed_with_args` produces the right alert decision", + tc.args, tc.expectedResult) + }) + } +} From c904f58edee16bb8f2f204c2310857dac57be792 Mon Sep 17 00:00:00 2001 From: entlein Date: Sat, 30 May 2026 13:17:58 +0200 Subject: [PATCH 13/17] addressing the side effects in NA, we should prob address them in storage , lets do a benchmark and get this green before pivoting abck to storage Signed-off-by: entlein --- .../test32_realpipeline_test.go | 241 ++++++++++++++ pkg/objectcache/v1/mock.go | 15 +- .../applicationprofile/argvmatch_test.go | 310 ++++++++++++++++++ .../cel/libraries/applicationprofile/exec.go | 104 +++++- .../libraries/applicationprofile/exec_test.go | 105 ++++++ tests/component_test.go | 80 ++++- 6 files changed, 835 insertions(+), 20 deletions(-) create mode 100644 pkg/objectcache/containerprofilecache/test32_realpipeline_test.go create mode 100644 pkg/rulemanager/cel/libraries/applicationprofile/argvmatch_test.go diff --git a/pkg/objectcache/containerprofilecache/test32_realpipeline_test.go b/pkg/objectcache/containerprofilecache/test32_realpipeline_test.go new file mode 100644 index 0000000000..a00fdd3586 --- /dev/null +++ b/pkg/objectcache/containerprofilecache/test32_realpipeline_test.go @@ -0,0 +1,241 @@ +package containerprofilecache + +import ( + "context" + "testing" + + helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers" + "github.com/kubescape/node-agent/pkg/objectcache" + "github.com/kubescape/storage/pkg/apis/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" +) + +// TestTest32_RealProjectionPipeline drives the REAL ContainerProfileCache +// projection — base CP (404 / synthetic) + the curl-32-overlay user AP, +// exactly as Test_32 deploys it — through addContainer → tryPopulateEntry → +// projectUserProfiles → Apply → extractExecsByPath, with NO mock shortcut. +// +// It then dumps the materialised ExecsByPath and applies the same +// CompareExecArgs walk that wasExecutedWithArgs performs, so we can see the +// GROUND TRUTH the production rule evaluator would see — answering whether +// R0040 silence comes from the projection (missing/empty vector) or from +// somewhere else (capture-side args). +func TestTest32_RealProjectionPipeline(t *testing.T) { + const wild = dynamicpathdetector.WildcardIdentifier + + // curl-32-overlay user AP. Container name MUST be "nginx" to match the + // InstanceID that primeSharedData/eventContainer build. The argv shapes + // mirror Test_32's profile. + userAP := &v1beta1.ApplicationProfile{ + ObjectMeta: metav1.ObjectMeta{Name: "curl-32-overlay", Namespace: "default", ResourceVersion: "1"}, + Spec: v1beta1.ApplicationProfileSpec{ + Containers: []v1beta1.ApplicationProfileContainer{{ + Name: "nginx", + Execs: []v1beta1.ExecCalls{ + {Path: "/bin/busybox", Args: []string{"/bin/sleep", wild}}, + {Path: "/bin/busybox", Args: []string{"/bin/sh", "-c", wild}}, + {Path: "/bin/busybox", Args: []string{"/bin/echo", "hello", wild}}, + }, + }}, + }, + } + + // Base CP 404 → cache synthesises an empty base, then overlays the user AP. + client := &fakeProfileClient{cp: nil, cpErr: assertErrNotFound("no-base"), ap: userAP} + c, k8s := newTestCache(t, client) + c.SetProjectionSpec(objectcache.RuleProjectionSpec{ + Execs: objectcache.FieldSpec{InUse: true, All: true}, + Hash: "test32-real", + }) + + id := "container-test32" + primeSharedData(t, k8s, id, "wlid://cluster-a/namespace-default/deployment-nginx") + ct := eventContainer(id) + ct.K8s.PodLabels = map[string]string{helpersv1.UserDefinedProfileMetadataKey: "curl-32-overlay"} + require.NoError(t, c.addContainer(ct, context.Background())) + + pcp := c.GetProjectedContainerProfile(id) + require.NotNil(t, pcp, "real projection must produce an entry") + + // GROUND TRUTH dump. + t.Logf("Execs.Values keys: %v", keysOf(pcp.Execs.Values)) + t.Logf("Execs.Patterns: %v", pcp.Execs.Patterns) + t.Logf("ExecsByPath[/bin/busybox] = %#v", pcp.ExecsByPath["/bin/busybox"]) + t.Logf("ExecsByPath full = %#v", pcp.ExecsByPath) + + // Replicate the exact matcher walk from wasExecutedWithArgs (post-fix: + // empty vector matches only empty runtime; non-empty via CompareExecArgs). + match := func(pathStr string, runtimeArgs []string) bool { + if _, ok := pcp.Execs.Values[pathStr]; ok { + if vectors, ok := pcp.ExecsByPath[pathStr]; ok { + for _, pv := range vectors { + if len(pv) == 0 { + if len(runtimeArgs) == 0 { + return true + } + continue + } + if dynamicpathdetector.CompareExecArgs(pv, runtimeArgs) { + return true + } + } + return false + } + return true // State 2: back-compat no-constraint + } + return false + } + + // /bin/busybox must be present in Values (R0001 stays silent — path known). + _, busyboxKnown := pcp.Execs.Values["/bin/busybox"] + require.True(t, busyboxKnown, "path /bin/busybox must be in Execs.Values (R0001 precondition)") + require.NotNil(t, pcp.ExecsByPath["/bin/busybox"], "ExecsByPath must carry the busybox vectors (else back-compat returns true and R0040 never fires)") + + cases := []struct { + name string + args []string + want bool // was_executed_with_args result; false => R0040 fires + }{ + {"sh -c matches", []string{"/bin/sh", "-c", "echo hi"}, true}, + {"sh -x -c mismatches", []string{"/bin/sh", "-x", "-c", "echo hi"}, false}, + {"echo hello matches", []string{"/bin/echo", "hello", "world", "from", "test"}, true}, + {"echo goodbye mismatches", []string{"/bin/echo", "goodbye", "world"}, false}, + } + for _, tc := range cases { + got := match("/bin/busybox", tc.args) + assert.Equalf(t, tc.want, got, + "real pipeline: was_executed_with_args(/bin/busybox, %v) = %v, want %v", tc.args, got, tc.want) + } +} + +func keysOf(m map[string]struct{}) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} + +// TestTest32_BaseCPBareVectorPoisonsR0040 reproduces the ACTUAL Test_32 +// production failure through the real projection pipeline. +// +// In production the curl-32 pod's `sleep infinity` startup exec is recorded +// into the consolidated base ContainerProfile as a /bin/busybox entry with +// empty/no args (the recorder stores Path + the observed argv; for a bare +// applet exec the args surface collapses to empty). That base CP is then +// overlaid with the user-defined curl-32-overlay AP, so the MERGED profile +// carries BOTH the bare /bin/busybox vector AND the three constrained ones. +// +// dynamicpathdetector.CompareExecArgs treats the empty bare vector as "no +// argv constraint" → returns true for ANY runtime args. When the matcher +// ORs across vectors, that one empty vector makes was_executed_with_args +// return true for every exec → R0040 never fires. This is the silence +// entlein observed on echo_goodbye / sh_dash_x while echo_hello / sh_dash_c +// (which legitimately match) stayed green. +// +// The test drives the REAL projectUserProfiles → Apply merge, then shows the +// raw-CompareExecArgs walk (deployed behaviour) returns the WRONG answer and +// the argvVectorMatches walk (the fix) returns the RIGHT answer. +func TestTest32_BaseCPBareVectorPoisonsR0040(t *testing.T) { + const wild = dynamicpathdetector.WildcardIdentifier + + // Base consolidated CP: the recorded /bin/busybox startup exec, bare args. + baseCP := &v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "replicaset-curl-32-curl", Namespace: "default", ResourceVersion: "5", + Annotations: map[string]string{ + helpersv1.CompletionMetadataKey: helpersv1.Full, + helpersv1.StatusMetadataKey: helpersv1.Completed, + }, + }, + Spec: v1beta1.ContainerProfileSpec{ + Execs: []v1beta1.ExecCalls{ + {Path: "/bin/busybox", Args: nil}, // bare recorder entry — the poison + }, + }, + } + userAP := &v1beta1.ApplicationProfile{ + ObjectMeta: metav1.ObjectMeta{Name: "curl-32-overlay", Namespace: "default", ResourceVersion: "1"}, + Spec: v1beta1.ApplicationProfileSpec{ + Containers: []v1beta1.ApplicationProfileContainer{{ + Name: "nginx", + Execs: []v1beta1.ExecCalls{ + {Path: "/bin/busybox", Args: []string{"/bin/sleep", wild}}, + {Path: "/bin/busybox", Args: []string{"/bin/sh", "-c", wild}}, + {Path: "/bin/busybox", Args: []string{"/bin/echo", "hello", wild}}, + }, + }}, + }, + } + + client := &fakeProfileClient{cp: baseCP, ap: userAP} + c, k8s := newTestCache(t, client) + c.SetProjectionSpec(objectcache.RuleProjectionSpec{ + Execs: objectcache.FieldSpec{InUse: true, All: true}, + Hash: "test32-poison", + }) + + id := "container-test32-poison" + primeSharedData(t, k8s, id, "wlid://cluster-a/namespace-default/deployment-nginx") + ct := eventContainer(id) + ct.K8s.PodLabels = map[string]string{helpersv1.UserDefinedProfileMetadataKey: "curl-32-overlay"} + require.NoError(t, c.addContainer(ct, context.Background())) + + pcp := c.GetProjectedContainerProfile(id) + require.NotNil(t, pcp) + vectors := pcp.ExecsByPath["/bin/busybox"] + t.Logf("MERGED ExecsByPath[/bin/busybox] = %#v", vectors) + + // Confirm the merged profile really does carry a bare (empty) vector + // alongside the constrained ones — the production-faithful shape. + hasBare := false + for _, v := range vectors { + if len(v) == 0 { + hasBare = true + } + } + require.True(t, hasBare, "merged profile must contain the bare /bin/busybox vector (the poison)") + require.GreaterOrEqual(t, len(vectors), 4, "bare + 3 constrained vectors") + + rawWalk := func(runtimeArgs []string) bool { // deployed b94913b3 behaviour + for _, pv := range vectors { + if dynamicpathdetector.CompareExecArgs(pv, runtimeArgs) { + return true + } + } + return false + } + fixedWalk := func(runtimeArgs []string) bool { // argvVectorMatches behaviour + for _, pv := range vectors { + if len(pv) == 0 { + if len(runtimeArgs) == 0 { + return true + } + continue + } + if dynamicpathdetector.CompareExecArgs(pv, runtimeArgs) { + return true + } + } + return false + } + + echoGoodbye := []string{"/bin/echo", "goodbye", "world"} + shDashX := []string{"/bin/sh", "-x", "-c", "echo hi"} + + // Deployed behaviour: the empty vector poisons the OR → returns true → + // R0040 stays silent. This is the bug. + assert.True(t, rawWalk(echoGoodbye), "DEPLOYED: empty vector wrongly matches echo goodbye (reproduces R0040 silence)") + assert.True(t, rawWalk(shDashX), "DEPLOYED: empty vector wrongly matches sh -x -c (reproduces R0040 silence)") + + // Fixed behaviour: empty vector matches only empty runtime → mismatches + // fall through → returns false → R0040 fires. + assert.False(t, fixedWalk(echoGoodbye), "FIXED: echo goodbye no longer matches → R0040 fires") + assert.False(t, fixedWalk(shDashX), "FIXED: sh -x -c no longer matches → R0040 fires") + // Legitimate matches still hold under the fix. + assert.True(t, fixedWalk([]string{"/bin/echo", "hello", "world"}), "FIXED: echo hello still matches") + assert.True(t, fixedWalk([]string{"/bin/sh", "-c", "echo hi"}), "FIXED: sh -c still matches") +} diff --git a/pkg/objectcache/v1/mock.go b/pkg/objectcache/v1/mock.go index 017d6e5072..8051c99a77 100644 --- a/pkg/objectcache/v1/mock.go +++ b/pkg/objectcache/v1/mock.go @@ -3,6 +3,7 @@ package objectcache import ( "context" "errors" + "strings" "sync" corev1 "k8s.io/api/core/v1" @@ -13,6 +14,7 @@ import ( "github.com/kubescape/node-agent/pkg/objectcache/callstackcache" "github.com/kubescape/node-agent/pkg/watcher" "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + "github.com/kubescape/storage/pkg/registry/file/dynamicpathdetector" "k8s.io/apimachinery/pkg/runtime" ) @@ -155,7 +157,18 @@ func (r *RuleObjectCacheMock) GetProjectedContainerProfile(containerID string) * 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 the real projectField routing: a path with a dynamic + // segment ("⋯") goes to Patterns (matched via CompareDynamic); + // a regular path goes to Values (exact lookup). ExecsByPath is + // keyed by the literal e.Path either way (same as the real + // extractExecsByPath). Without this split a ⋯-path (e.g. the + // versioned postgres binary) would land in Values and never + // reach the pattern branch of wasExecutedWithArgs. + if strings.Contains(e.Path, dynamicpathdetector.DynamicIdentifier) { + pcp.Execs.Patterns = append(pcp.Execs.Patterns, e.Path) + } else { + pcp.Execs.Values[e.Path] = struct{}{} + } var entry []string if e.Args == nil { entry = []string{} diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/argvmatch_test.go b/pkg/rulemanager/cel/libraries/applicationprofile/argvmatch_test.go new file mode 100644 index 0000000000..3ffa4a2f3f --- /dev/null +++ b/pkg/rulemanager/cel/libraries/applicationprofile/argvmatch_test.go @@ -0,0 +1,310 @@ +package applicationprofile + +import ( + "testing" + + "github.com/google/cel-go/cel" + "github.com/goradd/maps" + "github.com/kubescape/node-agent/pkg/config" + "github.com/kubescape/node-agent/pkg/objectcache" + objectcachev1 "github.com/kubescape/node-agent/pkg/objectcache/v1" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + "github.com/kubescape/storage/pkg/registry/file/dynamicpathdetector" + "github.com/stretchr/testify/assert" +) + +// TestArgvVectorMatches exhaustively pins the per-vector argv matching used by +// ap.was_executed_with_args (and therefore R0040). It exercises argvVectorMatches +// directly — the single-vector comparator — across every token shape the +// profile can carry: +// +// literal — exact string equality +// "⋯" (Dynamic) — exactly ONE arg, any value +// "*" (Wildcard) — ZERO or more args +// embedded "⋯" — a path with a dynamic segment INSIDE one arg +// (the postgres / versioned-binary case) +// +// The "want" column is the contract: true => exec is known-with-these-args +// (R0040 silent); false => argv mismatch (R0040 fires). +func TestArgvVectorMatches(t *testing.T) { + const wild = dynamicpathdetector.WildcardIdentifier // "*" + const ell = dynamicpathdetector.DynamicIdentifier // "⋯" + + cases := []struct { + name string + profile []string + runtime []string + want bool + }{ + // ---- symlinked busybox applet: NO args ---- + {"no-args: empty profile matches empty runtime", []string{}, []string{}, true}, + {"no-args: empty profile rejects non-empty runtime", []string{}, []string{"/bin/echo", "x"}, false}, + + // ---- symlinked busybox applet: literal args only ---- + {"literal: exact match", []string{"/bin/cp", "-r"}, []string{"/bin/cp", "-r"}, true}, + {"literal: value mismatch", []string{"/bin/cp", "-r"}, []string{"/bin/cp", "-f"}, false}, + {"literal: runtime too long (anchored)", []string{"/bin/cp", "-r"}, []string{"/bin/cp", "-r", "x"}, false}, + {"literal: runtime too short (anchored)", []string{"/bin/cp", "-r"}, []string{"/bin/cp"}, false}, + + // ---- with WILDCARD "*" (zero or more) ---- + {"wildcard: trailing * absorbs many", []string{"/bin/echo", "hello", wild}, []string{"/bin/echo", "hello", "a", "b", "c"}, true}, + {"wildcard: trailing * absorbs zero", []string{"/bin/echo", "hello", wild}, []string{"/bin/echo", "hello"}, true}, + {"wildcard: literal anchor before * mismatches", []string{"/bin/echo", "hello", wild}, []string{"/bin/echo", "goodbye", "x"}, false}, + {"wildcard: leading * absorbs prefix", []string{wild, "--end"}, []string{"a", "b", "--end"}, true}, + + // ---- with ELLIPSIS "⋯" (exactly one) ---- + {"ellipsis: matches exactly one arg", []string{"/bin/sh", "-c", ell}, []string{"/bin/sh", "-c", "echo hi"}, true}, + {"ellipsis: rejects zero (needs one)", []string{"/bin/sh", "-c", ell}, []string{"/bin/sh", "-c"}, false}, + {"ellipsis: rejects two (exactly one)", []string{"/bin/sh", "-c", ell}, []string{"/bin/sh", "-c", "a", "b"}, false}, + {"ellipsis: mid-vector with literal after", []string{"--user", ell, "--port", "8080"}, []string{"--user", "alice", "--port", "8080"}, true}, + {"ellipsis: mid-vector trailing literal mismatch", []string{"--user", ell, "--port", "8080"}, []string{"--user", "alice", "--port", "9090"}, false}, + + // ---- WILDCARD + ELLIPSIS together ---- + {"ellipsis then wildcard: one then zero+", []string{"/bin/foo", ell, wild}, []string{"/bin/foo", "a"}, true}, + {"ellipsis then wildcard: one then many", []string{"/bin/foo", ell, wild}, []string{"/bin/foo", "a", "b", "c"}, true}, + {"ellipsis then wildcard: zero args fails (⋯ needs one)", []string{"/bin/foo", ell, wild}, []string{"/bin/foo"}, false}, + {"wildcard then ellipsis: prefix then one", []string{"/bin/foo", wild, ell}, []string{"/bin/foo", "a", "b", "last"}, true}, + + // ---- ALL combined: literal + ⋯ + literal + * ---- + {"all: literal,⋯,literal,* matches", []string{"/bin/tool", "sub", ell, "--flag", wild}, []string{"/bin/tool", "sub", "X", "--flag", "a", "b"}, true}, + {"all: literal,⋯,literal,* trailing-only ok (* zero)", []string{"/bin/tool", "sub", ell, "--flag", wild}, []string{"/bin/tool", "sub", "X", "--flag"}, true}, + {"all: literal,⋯,literal,* literal-anchor mismatch", []string{"/bin/tool", "sub", ell, "--flag", wild}, []string{"/bin/tool", "sub", "X", "--nope", "a"}, false}, + + // ---- NON-symlinked, versioned binary: postgres (⋯ embedded in argv[0]) ---- + // Profile argv[0] carries a dynamic PATH segment; runtime argv[0] has the + // concrete version. The remaining args are exact flags. + { + "postgres: versioned argv[0] + exact flags", + []string{ + "/usr/lib/postgresql/" + ell + "/bin/postgres", + "--check", "-F", + "-c", "log_checkpoints=false", + "-c", "max_connections=100", + "-c", "shared_buffers=16384", + "-c", "dynamic_shared_memory_type=posix", + }, + []string{ + "/usr/lib/postgresql/16/bin/postgres", + "--check", "-F", + "-c", "log_checkpoints=false", + "-c", "max_connections=100", + "-c", "shared_buffers=16384", + "-c", "dynamic_shared_memory_type=posix", + }, + true, + }, + { + "postgres: versioned argv[0] matches but a flag value differs", + []string{ + "/usr/lib/postgresql/" + ell + "/bin/postgres", + "-c", "max_connections=100", + }, + []string{ + "/usr/lib/postgresql/16/bin/postgres", + "-c", "max_connections=9999", + }, + false, + }, + { + "postgres: versioned argv[0] with trailing * over the -c flags", + []string{ + "/usr/lib/postgresql/" + ell + "/bin/postgres", + "--check", wild, + }, + []string{ + "/usr/lib/postgresql/16/bin/postgres", + "--check", "-c", "log_checkpoints=false", + }, + true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := argvVectorMatches(tc.profile, tc.runtime) + assert.Equalf(t, tc.want, got, "argvVectorMatches(%v, %v) = %v, want %v", + tc.profile, tc.runtime, got, tc.want) + }) + } +} + +// TestArgvVectorMatches_ParityWithStorage pins that the NA reimplementation +// has NOT diverged from storage's CompareExecArgs on the semantics they +// share — the "*" / bare-"⋯" / literal handling. For every NON-empty, +// NON-embedded-⋯ profile vector, the two MUST agree. (The two intentional +// differences — empty profile is strict here, and embedded-⋯ args are +// path-matched here — are covered by the matrix above and excluded from this +// parity set.) +func TestArgvVectorMatches_ParityWithStorage(t *testing.T) { + const wild = dynamicpathdetector.WildcardIdentifier + const ell = dynamicpathdetector.DynamicIdentifier + + profiles := [][]string{ + {"a"}, {"a", "b"}, {"a", wild}, {wild, "b"}, {"a", ell, "c"}, + {ell}, {ell, ell}, {"a", ell, wild}, {wild, ell}, {"a", wild, "c"}, + {"-c", "log=false"}, {"--user", ell, "--port", "8080"}, + {wild}, {"a", "b", wild, "d"}, + } + runtimes := [][]string{ + {}, {"a"}, {"a", "b"}, {"a", "b", "c"}, {"x"}, + {"a", "x", "c"}, {"a", "b", "c", "d"}, {"--user", "alice", "--port", "8080"}, + {"-c", "log=false"}, {"a", "y"}, + } + + for _, p := range profiles { + // Skip vectors with an embedded-⋯ arg (path-aware here, literal in + // storage) — those are an intentional divergence, tested separately. + skip := false + for _, a := range p { + if a != ell && a != wild && containsEllipsis(a) { + skip = true + } + } + if skip { + continue + } + for _, r := range runtimes { + want := dynamicpathdetector.CompareExecArgs(p, r) // storage's matcher + got := argvVectorMatches(p, r) + assert.Equalf(t, want, got, + "parity mismatch: profile=%v runtime=%v — storage=%v na=%v", p, r, want, got) + } + } +} + +func containsEllipsis(s string) bool { + return len(s) != len(dynamicpathdetector.DynamicIdentifier) && + // contains but isn't the bare token + stringsContains(s, dynamicpathdetector.DynamicIdentifier) +} + +func stringsContains(s, sub string) bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} + +// TestWasExecutedWithArgs_PostgresEndToEnd drives the FULL CEL helper +// ap.was_executed_with_args for the non-symlinked, versioned-binary case +// where "⋯" appears in BOTH the path and argv[0]. At runtime the path +// resolves to a concrete version, so: +// +// - the path "/usr/lib/postgresql/⋯/bin/postgres" lands in Execs.Patterns +// and is matched against the runtime "/usr/lib/postgresql/16/bin/postgres" +// via CompareDynamic (the pattern branch of wasExecutedWithArgs), then +// - the argv vector is matched via argvVectorMatches, whose argv[0] also +// carries "⋯" and is path-matched. +// +// This exercises a different code path than the busybox exact-path cases +// (Patterns vs Values) and proves R0040's decision end-to-end: +// +// match => was_executed_with_args true => R0040 silent (allowed). +// no match => false => R0040 fires (unexpected args). +func TestWasExecutedWithArgs_PostgresEndToEnd(t *testing.T) { + const ell = dynamicpathdetector.DynamicIdentifier + pgPath := "/usr/lib/postgresql/" + ell + "/bin/postgres" + pgArgv0 := pgPath + + 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{ + {Path: pgPath, Args: []string{ + pgArgv0, "--check", "-F", + "-c", "log_checkpoints=false", + "-c", "max_connections=100", + "-c", "shared_buffers=16384", + "-c", "dynamic_shared_memory_type=posix", + }}, + }, + }) + 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("env: %v", err) + } + + const runtimePath = "/usr/lib/postgresql/16/bin/postgres" // ⋯ resolved to "16" + cases := []struct { + name string + args []string + want bool // was_executed_with_args; false => R0040 fires + }{ + { + "exact recorded postgres start matches", + []string{runtimePath, "--check", "-F", + "-c", "log_checkpoints=false", + "-c", "max_connections=100", + "-c", "shared_buffers=16384", + "-c", "dynamic_shared_memory_type=posix"}, + true, + }, + { + "tampered flag value mismatches", + []string{runtimePath, "--check", "-F", + "-c", "log_checkpoints=false", + "-c", "max_connections=99999", + "-c", "shared_buffers=16384", + "-c", "dynamic_shared_memory_type=posix"}, + false, + }, + { + "different postgres version still matches the ⋯ path segment", + []string{"/usr/lib/postgresql/15/bin/postgres", "--check", "-F", + "-c", "log_checkpoints=false", + "-c", "max_connections=100", + "-c", "shared_buffers=16384", + "-c", "dynamic_shared_memory_type=posix"}, + true, + }, + } + + for _, tc := range cases { + 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("compile: %v", issues.Err()) + } + prog, err := env.Program(ast) + if err != nil { + t.Fatalf("program: %v", err) + } + res, _, err := prog.Eval(map[string]any{ + "containerID": "test-container-id", + "path": runtimePathOf(tc.args), + "args": tc.args, + }) + if err != nil { + t.Fatalf("eval: %v", err) + } + assert.Equalf(t, tc.want, res.Value().(bool), + "was_executed_with_args(path=%s, args=%v)", runtimePathOf(tc.args), tc.args) + }) + } +} + +// runtimePathOf returns argv[0] (the resolved exec path) for the eval input. +func runtimePathOf(args []string) string { + if len(args) > 0 { + return args[0] + } + return "" +} diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/exec.go b/pkg/rulemanager/cel/libraries/applicationprofile/exec.go index e748ef1f10..5a1332e30f 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/exec.go +++ b/pkg/rulemanager/cel/libraries/applicationprofile/exec.go @@ -1,6 +1,8 @@ package applicationprofile import ( + "strings" + "github.com/google/cel-go/common/types" "github.com/google/cel-go/common/types/ref" @@ -104,13 +106,16 @@ func (l *apLibrary) wasExecutedWithArgs(containerID, path, args ref.Val) ref.Val // composite ExecsByPath surface. // // 3. Path in Values, PRESENT in ExecsByPath: - // Walk each profile argv vector with CompareExecArgs (handles + // Walk each profile argv vector with argvVectorMatches (handles // DynamicIdentifier "⋯" and WildcardIdentifier "*"). Match if - // ANY vector matches. + // ANY vector matches. NOTE: per-vector matching uses + // argvVectorMatches, NOT dynamicpathdetector.CompareExecArgs + // directly — see the helper's doc for why an empty recorded + // vector must not act as a wildcard here. if _, ok := cp.Execs.Values[pathStr]; ok { if vectors, ok := cp.ExecsByPath[pathStr]; ok { for _, profileArgs := range vectors { - if dynamicpathdetector.CompareExecArgs(profileArgs, runtimeArgs) { + if argvVectorMatches(profileArgs, runtimeArgs) { return types.Bool(true) } } @@ -126,7 +131,7 @@ func (l *apLibrary) wasExecutedWithArgs(containerID, path, args ref.Val) ref.Val if dynamicpathdetector.CompareDynamic(execPath, pathStr) { if vectors, ok := cp.ExecsByPath[execPath]; ok { for _, profileArgs := range vectors { - if dynamicpathdetector.CompareExecArgs(profileArgs, runtimeArgs) { + if argvVectorMatches(profileArgs, runtimeArgs) { return types.Bool(true) } } @@ -143,6 +148,97 @@ func (l *apLibrary) wasExecutedWithArgs(containerID, path, args ref.Val) ref.Val return types.Bool(false) } +// argvVectorMatches reports whether ONE profile argv vector matches the +// runtime args. It is a PATH-AWARE superset of +// dynamicpathdetector.CompareExecArgs, composed from the primitives storage +// v0.0.278 already exports (CompareDynamic, WildcardIdentifier, +// DynamicIdentifier). Token semantics, anchored at both ends: +// +// - "*" (WildcardIdentifier) — matches ZERO or more consecutive args. +// - "⋯" (bare DynamicIdentifier) — matches EXACTLY ONE arg, any value. +// - an arg that CONTAINS "⋯" but is not the bare token — a dynamic PATH; +// compared against the runtime arg with dynamicpathdetector.CompareDynamic +// (segment-wise, "⋯" = one path segment). This is the postgres / +// versioned-binary case: profile argv[0] +// "/usr/lib/postgresql/⋯/bin/postgres" must match the runtime +// "/usr/lib/postgresql/16/bin/postgres". CompareExecArgs alone does only +// literal "==" per position, so it never matched such args. +// - anything else — literal string equality. +// +// Empty-vector semantics: an empty profile vector matches ONLY an empty +// runtime argv. A recorder/synthetic "ran with no args" entry must NOT act +// as a wildcard — otherwise it poisons the multi-vector OR in +// wasExecutedWithArgs and R0040 never fires (the #805 production failure). +// This falls out naturally from the anchored base case below, so no special +// guard is needed. +// +// Backtracking over "*" is memoised on (profileIndex, runtimeIndex) to stay +// quadratic, mirroring storage's matchExecArgsStrict. +func argvVectorMatches(profileArgs, runtimeArgs []string) bool { + memo := make(map[[2]int]bool, (len(profileArgs)+1)*(len(runtimeArgs)+1)) + seen := make(map[[2]int]bool, (len(profileArgs)+1)*(len(runtimeArgs)+1)) + + var match func(pi, ri int) bool + match = func(pi, ri int) bool { + key := [2]int{pi, ri} + if seen[key] { + return memo[key] + } + seen[key] = true + + // Profile fully consumed → runtime must also be fully consumed + // (anchored). With pi==0 this is the empty-vector case: empty + // profile matches only empty runtime. + if pi == len(profileArgs) { + memo[key] = ri == len(runtimeArgs) + return memo[key] + } + + head := profileArgs[pi] + + if head == dynamicpathdetector.WildcardIdentifier { + // Absorb 0..remaining runtime args into "*", first split wins. + for k := ri; k <= len(runtimeArgs); k++ { + if match(pi+1, k) { + memo[key] = true + return true + } + } + memo[key] = false + return false + } + + // A non-wildcard head needs a runtime arg to consume. + if ri == len(runtimeArgs) { + memo[key] = false + return false + } + + if argTokenMatches(head, runtimeArgs[ri]) { + memo[key] = match(pi+1, ri+1) + return memo[key] + } + + memo[key] = false + return false + } + + return match(0, 0) +} + +// argTokenMatches compares ONE profile arg token against ONE runtime arg. +// Bare "⋯" matches any single arg; an arg embedding "⋯" is a dynamic path +// matched segment-wise via CompareDynamic; everything else is literal. +func argTokenMatches(profileArg, runtimeArg string) bool { + if profileArg == dynamicpathdetector.DynamicIdentifier { + return true // bare ⋯ — exactly one arg, any value + } + if strings.Contains(profileArg, dynamicpathdetector.DynamicIdentifier) { + return dynamicpathdetector.CompareDynamic(profileArg, runtimeArg) + } + return profileArg == runtimeArg +} + func (l *apLibrary) isExecInPodSpec(containerID, path ref.Val) ref.Val { if l.objectCache == nil { return types.NewErr("objectCache is nil") diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/exec_test.go b/pkg/rulemanager/cel/libraries/applicationprofile/exec_test.go index d89cbb22d8..6a23fd4064 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/exec_test.go +++ b/pkg/rulemanager/cel/libraries/applicationprofile/exec_test.go @@ -455,3 +455,108 @@ func TestExecWithArgsBusyboxMultiVector(t *testing.T) { }) } } + +// TestExecWithArgsEmptyVectorDoesNotPoisonMatch reproduces the production +// failure that survived the first ExecsByPath wiring: R0040 stayed silent on +// every argv mismatch in #805 CT runs through the "side-effects" tip. +// +// In a real merged profile the SAME path can carry both a constrained vector +// (from the user-defined ApplicationProfile, e.g. [echo, hello, *]) AND a +// bare vector with no args (from the recorder, or a synthesised base CP, e.g. +// /bin/busybox observed with empty Args). extractExecsByPath stores the bare +// entry as an empty []string{}. +// +// dynamicpathdetector.CompareExecArgs treats an EMPTY profile vector as "no +// argv constraint" and returns true for ANY runtime args. When +// wasExecutedWithArgs ORs across every vector for the path, that one empty +// vector short-circuits the whole match to true — so !was_executed_with_args +// is always false and R0040 never fires, even for argv vectors that match +// none of the constrained entries. The fix (argvVectorMatches) treats an +// empty recorded vector as "ran with no args" (matches only empty runtime). +func TestExecWithArgsEmptyVectorDoesNotPoisonMatch(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{ + // Bare recorder/synthetic entry: same path, NO args. This is the + // poison vector — present in real merged profiles, absent from + // the clean multi-vector test above. + {Path: "/bin/busybox", Args: nil}, + // User-defined constrained vectors. + {Path: "/bin/busybox", Args: []string{"/bin/echo", "hello", dynamicpathdetector.WildcardIdentifier}}, + {Path: "/bin/busybox", Args: []string{"/bin/sh", "-c", dynamicpathdetector.WildcardIdentifier}}, + }, + }) + 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 + args []string + expectedResult bool + }{ + { + name: "echo goodbye still mismatches despite bare poison vector", + args: []string{"/bin/echo", "goodbye", "world"}, + expectedResult: false, + }, + { + name: "sh -x -c still mismatches despite bare poison vector", + args: []string{"/bin/sh", "-x", "-c", "echo hi"}, + expectedResult: false, + }, + { + name: "echo hello still matches its constrained vector", + args: []string{"/bin/echo", "hello", "world"}, + expectedResult: true, + }, + { + name: "no-args invocation matches the bare vector", + args: []string{}, + expectedResult: true, + }, + } + + 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()) + } + prog, err := env.Program(ast) + if err != nil { + t.Fatalf("failed to create program: %v", err) + } + result, _, err := prog.Eval(map[string]any{ + "containerID": "test-container-id", + "path": "/bin/busybox", + "args": tc.args, + }) + if err != nil { + t.Fatalf("failed to evaluate expression: %v", err) + } + assert.Equal(t, tc.expectedResult, result.Value().(bool), + "ap.was_executed_with_args(/bin/busybox, %v) — empty recorded vector must not poison the multi-vector OR", + tc.args) + }) + } +} diff --git a/tests/component_test.go b/tests/component_test.go index 82eb432fc9..34e595098c 100644 --- a/tests/component_test.go +++ b/tests/component_test.go @@ -1577,24 +1577,27 @@ func Test_24_ProcessTreeDepthTest(t *testing.T) { // // 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) +// +// - 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. +// 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 +// /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 @@ -1843,4 +1846,51 @@ func Test_32_UnexpectedProcessArguments(t *testing.T) { require.Greater(t, countByRule(alerts, "R0040"), 0, "echo goodbye mismatches profile [echo, hello, *] (literal anchor) → R0040 must fire") }) + + // ----------------------------------------------------------------- + // 32e. curl -s — the NON-symlinked binary (curl is a real + // binary in curlimages/curl, not a busybox applet) with an + // ELLIPSIS profile: [curl, -s, ⋯]. ⋯ matches EXACTLY ONE arg, so + // `curl -s ` matches → R0040 silent. + // + // A file:// URL is used so curl reads a local file and exits 0 + // regardless of cluster egress — the test pins argv matching, not + // network reachability. + // ----------------------------------------------------------------- + t.Run("curl_dash_s_one_url_matches_ellipsis", func(t *testing.T) { + wl := setup(t) + stdout, stderr, err := wl.ExecIntoPod([]string{"curl", "-s", "file:///etc/hostname"}, "curl") + t.Logf("curl -s file:///etc/hostname → err=%v stdout=%q stderr=%q", err, stdout, stderr) + require.NoError(t, err) + + alerts := waitAlerts(t, wl.Namespace) + t.Logf("=== %d alerts ===", len(alerts)) + logAlerts(t, alerts) + + assertR0001Silent(t, alerts, "curl") + assert.Equal(t, 0, countByRule(alerts, "R0040"), + "curl -s matches profile [curl, -s, ⋯] — R0040 must stay silent") + }) + + // ----------------------------------------------------------------- + // 32f. curl -s — argv [curl, -s, url1, url2] does NOT match + // profile [curl, -s, ⋯] because ⋯ consumes EXACTLY ONE arg, not + // two. R0040 must fire. Pins the ⋯ (DynamicIdentifier) arity on + // the non-symlinked path. Both file:// URLs are readable so curl + // still exits 0. + // ----------------------------------------------------------------- + t.Run("curl_dash_s_two_urls_mismatches_R0040", func(t *testing.T) { + wl := setup(t) + stdout, stderr, err := wl.ExecIntoPod([]string{"curl", "-s", "file:///etc/hostname", "file:///etc/hosts"}, "curl") + t.Logf("curl -s → err=%v stdout=%q stderr=%q", err, stdout, stderr) + require.NoError(t, err) + + alerts := waitAlerts(t, wl.Namespace) + t.Logf("=== %d alerts ===", len(alerts)) + logAlerts(t, alerts) + + assertR0001Silent(t, alerts, "curl") + require.Greater(t, countByRule(alerts, "R0040"), 0, + "curl -s exceeds the single-arg ⋯ in profile [curl, -s, ⋯] → R0040 must fire") + }) } From cd1e36ff0d2252767b0aa3c72649fd8ad305c7f5 Mon Sep 17 00:00:00 2001 From: entlein Date: Sat, 30 May 2026 21:14:00 +0200 Subject: [PATCH 14/17] everyone get a cookie and grab some popcorn for this worldchanging fix, good thing this ll be squashed Signed-off-by: entlein --- .../cel/libraries/applicationprofile/exec.go | 1 - .../node-agent/default-rule-binding.yaml | 1 + tests/component_test.go | 47 +++++++++++++++++-- .../curl-exec-arg-wildcards-deployment.yaml | 3 +- 4 files changed, 46 insertions(+), 6 deletions(-) diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/exec.go b/pkg/rulemanager/cel/libraries/applicationprofile/exec.go index 5a1332e30f..5c278ec6ff 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/exec.go +++ b/pkg/rulemanager/cel/libraries/applicationprofile/exec.go @@ -4,7 +4,6 @@ import ( "strings" "github.com/google/cel-go/common/types" - "github.com/google/cel-go/common/types/ref" "github.com/kubescape/go-logger" "github.com/kubescape/go-logger/helpers" diff --git a/tests/chart/templates/node-agent/default-rule-binding.yaml b/tests/chart/templates/node-agent/default-rule-binding.yaml index 26367de97f..710deb6e35 100644 --- a/tests/chart/templates/node-agent/default-rule-binding.yaml +++ b/tests/chart/templates/node-agent/default-rule-binding.yaml @@ -15,6 +15,7 @@ spec: - "kubeconfig" rules: - ruleName: "Unexpected process launched" + - ruleName: "Unexpected process arguments" - ruleName: "Files Access Anomalies in container" - ruleName: "Syscalls Anomalies in container" - ruleName: "Linux Capabilities Anomalies in container" diff --git a/tests/component_test.go b/tests/component_test.go index 34e595098c..9d867f7da7 100644 --- a/tests/component_test.go +++ b/tests/component_test.go @@ -1692,18 +1692,57 @@ func Test_32_UnexpectedProcessArguments(t *testing.T) { context.Background(), ap, metav1.CreateOptions{}) require.NoError(t, err, "create AP") + // User-supplied SBOB pattern (mirrors Test_28): the pod carries BOTH + // kubescape.io/user-defined-profile and kubescape.io/user-defined-network. + // Node-agent uses the single overlay name as the lookup key for BOTH + // the user ApplicationProfile and the user NetworkNeighborhood, so the + // NN must exist under the same name and be created before the pod. + // User-authored objects carry managed-by=User + a terminal + // status/completion and the workload-binding labels. + nn := &v1beta1.NetworkNeighborhood{ + ObjectMeta: metav1.ObjectMeta{ + Name: overlayName, + Namespace: ns.Name, + Annotations: map[string]string{ + helpersv1.ManagedByMetadataKey: helpersv1.ManagedByUserValue, + helpersv1.StatusMetadataKey: helpersv1.Completed, + helpersv1.CompletionMetadataKey: helpersv1.Full, + }, + Labels: map[string]string{ + helpersv1.ApiGroupMetadataKey: "apps", + helpersv1.ApiVersionMetadataKey: "v1", + helpersv1.RelatedKindMetadataKey: "Deployment", + helpersv1.RelatedNameMetadataKey: "curl-32", + helpersv1.RelatedNamespaceMetadataKey: ns.Name, + }, + }, + Spec: v1beta1.NetworkNeighborhoodSpec{ + LabelSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "curl-32"}, + }, + Containers: []v1beta1.NetworkNeighborhoodContainer{ + {Name: "curl"}, + }, + }, + } + _, err = storageClient.NetworkNeighborhoods(ns.Name).Create( + context.Background(), nn, metav1.CreateOptions{}) + require.NoError(t, err, "create NN") + require.Eventually(t, func() bool { _, apErr := storageClient.ApplicationProfiles(ns.Name).Get( context.Background(), overlayName, v1.GetOptions{}) - return apErr == nil - }, 30*time.Second, 1*time.Second, "AP must be in storage before pod deploy") + _, nnErr := storageClient.NetworkNeighborhoods(ns.Name).Get( + context.Background(), overlayName, v1.GetOptions{}) + return apErr == nil && nnErr == nil + }, 30*time.Second, 1*time.Second, "AP+NN must be in storage before pod deploy") wl, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/curl-exec-arg-wildcards-deployment.yaml")) require.NoError(t, err) require.NoError(t, wl.WaitForReady(80)) - // let node-agent load the user AP into the CP cache - time.Sleep(15 * time.Second) + // let node-agent load the user AP+NN into the CP cache + time.Sleep(30 * time.Second) return wl } diff --git a/tests/resources/curl-exec-arg-wildcards-deployment.yaml b/tests/resources/curl-exec-arg-wildcards-deployment.yaml index 2f06f8baef..cdb2ddf96b 100644 --- a/tests/resources/curl-exec-arg-wildcards-deployment.yaml +++ b/tests/resources/curl-exec-arg-wildcards-deployment.yaml @@ -4,7 +4,7 @@ ## 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. +## with that name; both the user AP and the user NN use it. apiVersion: apps/v1 kind: Deployment metadata: @@ -21,6 +21,7 @@ spec: labels: app: curl-32 kubescape.io/user-defined-profile: curl-32-overlay + kubescape.io/user-defined-network: curl-32-overlay spec: containers: - name: curl From 9194cc53c2a1d879f022b6b6b66b023a293defe0 Mon Sep 17 00:00:00 2001 From: entlein Date: Sun, 31 May 2026 12:11:34 +0200 Subject: [PATCH 15/17] assuming this ll be green, we def need to port this into storage, cause the cel rule will be slow Signed-off-by: entlein --- .../parse/default_rules_yaml_lint_test.go | 76 ++++++++++++ .../templates/node-agent/default-rules.yaml | 8 +- tests/component_test.go | 111 ++++++++++++------ 3 files changed, 152 insertions(+), 43 deletions(-) diff --git a/pkg/rulemanager/cel/libraries/parse/default_rules_yaml_lint_test.go b/pkg/rulemanager/cel/libraries/parse/default_rules_yaml_lint_test.go index f16ea7a3de..93843e3570 100644 --- a/pkg/rulemanager/cel/libraries/parse/default_rules_yaml_lint_test.go +++ b/pkg/rulemanager/cel/libraries/parse/default_rules_yaml_lint_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "regexp" "runtime" + "strings" "testing" ) @@ -36,6 +37,81 @@ func TestDefaultRulesYAML_NoTwoArgGetExecPath(t *testing.T) { } } +// TestDefaultRulesYAML_NoStringOnArgsList guards against CEL expressions that +// wrap the list-typed event.args field in string(). CEL's string() has no +// list overload, so e.g. string(event.args) compiles-then-fails at rule-eval +// time. When that expression is a rule's message or uniqueId, the rule manager +// drops the whole alert (rule_manager.go: getUniqueIdAndMessage error → the +// event is skipped) and spams error logs on every matching event — which broke +// R0040 delivery and tripped Test_02/Test_32. Render list fields with +// event.args.map(a, string(a)).join(" ") instead (precedent: R0006 uses +// event.flags.join(",")). +func TestDefaultRulesYAML_NoStringOnArgsList(t *testing.T) { + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + repoRoot := filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", "..", "..", "..", "..")) + yamlPath := filepath.Join(repoRoot, "tests", "chart", "templates", "node-agent", "default-rules.yaml") + + data, err := os.ReadFile(yamlPath) + if err != nil { + t.Fatalf("read %s: %v", yamlPath, err) + } + + stringOnArgs := regexp.MustCompile(`string\(\s*event\.args\s*\)`) + if locs := stringOnArgs.FindAllIndex(data, -1); len(locs) > 0 { + lines := lineNumbers(data, locs) + t.Errorf("found %d string(event.args) call(s) at line(s) %v; CEL string() has no list overload — "+ + "render with event.args.map(a, string(a)).join(\" \")", len(locs), lines) + } +} + +// TestDefaultRulesYAML_R1000DetectsDevShmViaArgv guards R1000 ("Process +// executed from malicious source") against silently losing its /dev/shm +// detection. R1000 must inspect argv[0] (event.args[0]) and event.exepath / +// event.cwd directly — NOT route through parse.get_exec_path, because the +// 3-arg resolver prefers the kernel-resolved exepath (e.g. /bin/busybox for a +// busybox-symlinked applet) over the as-invoked argv[0] (/dev/shm/ls). Routing +// /dev/shm detection through get_exec_path therefore resolves the path away +// from /dev/shm and the rule never fires (regressed Test_02). +func TestDefaultRulesYAML_R1000DetectsDevShmViaArgv(t *testing.T) { + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + repoRoot := filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", "..", "..", "..", "..")) + yamlPath := filepath.Join(repoRoot, "tests", "chart", "templates", "node-agent", "default-rules.yaml") + + data, err := os.ReadFile(yamlPath) + if err != nil { + t.Fatalf("read %s: %v", yamlPath, err) + } + + // Isolate the R1000 rule block: from its id line to the next rule's id line. + text := string(data) + start := strings.Index(text, `id: "R1000"`) + if start < 0 { + t.Fatal(`R1000 rule not found in default-rules.yaml`) + } + rest := text[start+len(`id: "R1000"`):] + end := strings.Index(rest, `id: "R`) + if end < 0 { + end = len(rest) + } + block := rest[:end] + + if strings.Contains(block, "get_exec_path") { + t.Errorf("R1000 routes /dev/shm detection through parse.get_exec_path; the resolver " + + "prefers exepath over argv[0], so /dev/shm/ resolves away from /dev/shm and " + + "the rule never fires. Inspect event.args[0] / event.exepath / event.cwd directly.") + } + if !strings.Contains(block, "event.args[0]") { + t.Errorf("R1000 must inspect argv[0] (event.args[0]) so an exec invoked as /dev/shm/ " + + "is detected even when exepath resolves to the symlink target (e.g. /bin/busybox)") + } +} + func lineNumbers(data []byte, locs [][]int) []int { out := make([]int, 0, len(locs)) for _, loc := range locs { diff --git a/tests/chart/templates/node-agent/default-rules.yaml b/tests/chart/templates/node-agent/default-rules.yaml index 530981bbbe..ec0ed45260 100644 --- a/tests/chart/templates/node-agent/default-rules.yaml +++ b/tests/chart/templates/node-agent/default-rules.yaml @@ -332,8 +332,8 @@ spec: - eventType: "exec" expression: > (event.exepath == '/dev/shm' || event.exepath.startsWith('/dev/shm/')) || - (event.cwd == '/dev/shm' || event.cwd.startsWith('/dev/shm/') || - (parse.get_exec_path(event.args, event.comm, event.exepath).startsWith('/dev/shm/'))) + (event.cwd == '/dev/shm' || event.cwd.startsWith('/dev/shm/')) || + (event.args.size() > 0 && (event.args[0] == '/dev/shm' || event.args[0].startsWith('/dev/shm/'))) profileDependency: 2 severity: 8 supportPolicy: false @@ -694,8 +694,8 @@ spec: id: "R0040" description: "Detects an exec event whose path IS in the profile but whose argv vector does not match any recorded argv pattern for that path. Consumes ap.was_executed_with_args, which walks the ExecsByPath projection surface added by node-agent#807 and delegates argv comparison to dynamicpathdetector.CompareExecArgs (storage). Stays silent when the path is unknown (R0001 covers that case) and when the argv vector matches any recorded pattern (including the trailing-wildcard form `[…, *]`)." expressions: - message: "'Unexpected process arguments: ' + event.comm + ' with PID ' + string(event.pid) + ' argv=' + string(event.args)" - uniqueId: "event.comm + '_' + event.exepath + '_' + string(event.args)" + message: "'Unexpected process arguments: ' + event.comm + ' with PID ' + string(event.pid) + ' argv=' + event.args.map(a, string(a)).join(' ')" + uniqueId: "event.comm + '_' + event.exepath + '_' + event.args.map(a, string(a)).join(' ')" ruleExpression: - eventType: "exec" expression: "ap.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm, event.exepath)) && !ap.was_executed_with_args(event.containerId, parse.get_exec_path(event.args, event.comm, event.exepath), event.args)" diff --git a/tests/component_test.go b/tests/component_test.go index 9d867f7da7..02d37644bd 100644 --- a/tests/component_test.go +++ b/tests/component_test.go @@ -1807,17 +1807,20 @@ func Test_32_UnexpectedProcessArguments(t *testing.T) { // ----------------------------------------------------------------- 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) - require.NoError(t, err) - + // Warm the cache: retry the exec until it runs cleanly so the user + // overlay is loaded, then settle and assert R0040 stays silent + // (mirrors Test_28 no-alert idiom). A matching argv must not alert. + require.Eventually(t, func() bool { + _, _, err := wl.ExecIntoPod([]string{"sh", "-c", "echo hi"}, "curl") + return err == nil + }, 60*time.Second, 5*time.Second, "exec must run") + time.Sleep(20 * time.Second) alerts := waitAlerts(t, wl.Namespace) t.Logf("=== %d alerts ===", len(alerts)) logAlerts(t, alerts) - assertR0001Silent(t, alerts, "sh") assert.Equal(t, 0, countByRule(alerts, "R0040"), - "sh -c matches profile [sh, -c, *] — R0040 must stay silent") + "sh -c matches profile [sh, -c, *]: R0040 must stay silent") }) // ----------------------------------------------------------------- @@ -1834,17 +1837,25 @@ func Test_32_UnexpectedProcessArguments(t *testing.T) { // ----------------------------------------------------------------- t.Run("sh_dash_x_mismatches_R0040", func(t *testing.T) { wl := setup(t) - stdout, stderr, err := wl.ExecIntoPod([]string{"sh", "-x", "-c", "echo hi"}, "curl") - t.Logf("sh -x -c 'echo hi' → err=%v stdout=%q stderr=%q", err, stdout, stderr) - require.NoError(t, err) - - alerts := waitAlerts(t, wl.Namespace) + // Retry the trigger until node-agent has loaded the user overlay + // into the ContainerProfileCache and R0040 fires. The overlay loads + // asynchronously, so a single exec can race the load and the + // profile-dependent rule is suppressed (mirrors Test_28). The + // command is idempotent, so re-exec is side-effect-free. + var alerts []testutils.Alert + require.Eventually(t, func() bool { + _, _, err := wl.ExecIntoPod([]string{"sh", "-x", "-c", "echo hi"}, "curl") + if err != nil { + return false + } + alerts = waitAlerts(t, wl.Namespace) + return countByRule(alerts, "R0040") > 0 + }, 120*time.Second, 10*time.Second, "sh -x mismatches profile [sh, -c, *]: R0040 must fire") t.Logf("=== %d alerts ===", len(alerts)) logAlerts(t, alerts) - assertR0001Silent(t, alerts, "sh") require.Greater(t, countByRule(alerts, "R0040"), 0, - "sh -x mismatches profile [sh, -c, *] → R0040 must fire") + "sh -x mismatches profile [sh, -c, *]: R0040 must fire") }) // ----------------------------------------------------------------- @@ -1853,17 +1864,20 @@ func Test_32_UnexpectedProcessArguments(t *testing.T) { // ----------------------------------------------------------------- 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) - require.NoError(t, err) - + // Warm the cache: retry the exec until it runs cleanly so the user + // overlay is loaded, then settle and assert R0040 stays silent + // (mirrors Test_28 no-alert idiom). A matching argv must not alert. + require.Eventually(t, func() bool { + _, _, err := wl.ExecIntoPod([]string{"echo", "hello", "world", "from", "test"}, "curl") + return err == nil + }, 60*time.Second, 5*time.Second, "exec must run") + time.Sleep(20 * time.Second) alerts := waitAlerts(t, wl.Namespace) t.Logf("=== %d alerts ===", len(alerts)) logAlerts(t, alerts) - assertR0001Silent(t, alerts, "echo") assert.Equal(t, 0, countByRule(alerts, "R0040"), - "echo hello matches profile [echo, hello, *] — R0040 must stay silent") + "echo hello matches profile [echo, hello, *]: R0040 must stay silent") }) // ----------------------------------------------------------------- @@ -1873,17 +1887,25 @@ func Test_32_UnexpectedProcessArguments(t *testing.T) { // ----------------------------------------------------------------- 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) - require.NoError(t, err) - - alerts := waitAlerts(t, wl.Namespace) + // Retry the trigger until node-agent has loaded the user overlay + // into the ContainerProfileCache and R0040 fires. The overlay loads + // asynchronously, so a single exec can race the load and the + // profile-dependent rule is suppressed (mirrors Test_28). The + // command is idempotent, so re-exec is side-effect-free. + var alerts []testutils.Alert + require.Eventually(t, func() bool { + _, _, err := wl.ExecIntoPod([]string{"echo", "goodbye", "world"}, "curl") + if err != nil { + return false + } + alerts = waitAlerts(t, wl.Namespace) + return countByRule(alerts, "R0040") > 0 + }, 120*time.Second, 10*time.Second, "echo goodbye mismatches profile [echo, hello, *] (literal anchor): R0040 must fire") t.Logf("=== %d alerts ===", len(alerts)) logAlerts(t, alerts) - assertR0001Silent(t, alerts, "echo") require.Greater(t, countByRule(alerts, "R0040"), 0, - "echo goodbye mismatches profile [echo, hello, *] (literal anchor) → R0040 must fire") + "echo goodbye mismatches profile [echo, hello, *] (literal anchor): R0040 must fire") }) // ----------------------------------------------------------------- @@ -1898,17 +1920,20 @@ func Test_32_UnexpectedProcessArguments(t *testing.T) { // ----------------------------------------------------------------- t.Run("curl_dash_s_one_url_matches_ellipsis", func(t *testing.T) { wl := setup(t) - stdout, stderr, err := wl.ExecIntoPod([]string{"curl", "-s", "file:///etc/hostname"}, "curl") - t.Logf("curl -s file:///etc/hostname → err=%v stdout=%q stderr=%q", err, stdout, stderr) - require.NoError(t, err) - + // Warm the cache: retry the exec until it runs cleanly so the user + // overlay is loaded, then settle and assert R0040 stays silent + // (mirrors Test_28 no-alert idiom). A matching argv must not alert. + require.Eventually(t, func() bool { + _, _, err := wl.ExecIntoPod([]string{"curl", "-s", "file:///etc/hostname"}, "curl") + return err == nil + }, 60*time.Second, 5*time.Second, "exec must run") + time.Sleep(20 * time.Second) alerts := waitAlerts(t, wl.Namespace) t.Logf("=== %d alerts ===", len(alerts)) logAlerts(t, alerts) - assertR0001Silent(t, alerts, "curl") assert.Equal(t, 0, countByRule(alerts, "R0040"), - "curl -s matches profile [curl, -s, ⋯] — R0040 must stay silent") + "curl -s matches profile [curl, -s, dyn]: R0040 must stay silent") }) // ----------------------------------------------------------------- @@ -1920,16 +1945,24 @@ func Test_32_UnexpectedProcessArguments(t *testing.T) { // ----------------------------------------------------------------- t.Run("curl_dash_s_two_urls_mismatches_R0040", func(t *testing.T) { wl := setup(t) - stdout, stderr, err := wl.ExecIntoPod([]string{"curl", "-s", "file:///etc/hostname", "file:///etc/hosts"}, "curl") - t.Logf("curl -s → err=%v stdout=%q stderr=%q", err, stdout, stderr) - require.NoError(t, err) - - alerts := waitAlerts(t, wl.Namespace) + // Retry the trigger until node-agent has loaded the user overlay + // into the ContainerProfileCache and R0040 fires. The overlay loads + // asynchronously, so a single exec can race the load and the + // profile-dependent rule is suppressed (mirrors Test_28). The + // command is idempotent, so re-exec is side-effect-free. + var alerts []testutils.Alert + require.Eventually(t, func() bool { + _, _, err := wl.ExecIntoPod([]string{"curl", "-s", "file:///etc/hostname", "file:///etc/hosts"}, "curl") + if err != nil { + return false + } + alerts = waitAlerts(t, wl.Namespace) + return countByRule(alerts, "R0040") > 0 + }, 120*time.Second, 10*time.Second, "curl -s exceeds the single-arg dyn token in profile [curl, -s, dyn]: R0040 must fire") t.Logf("=== %d alerts ===", len(alerts)) logAlerts(t, alerts) - assertR0001Silent(t, alerts, "curl") require.Greater(t, countByRule(alerts, "R0040"), 0, - "curl -s exceeds the single-arg ⋯ in profile [curl, -s, ⋯] → R0040 must fire") + "curl -s exceeds the single-arg dyn token in profile [curl, -s, dyn]: R0040 must fire") }) } From 33616e034c6b9d53b14bbf625a46e420a9bf29a8 Mon Sep 17 00:00:00 2001 From: entlein Date: Mon, 1 Jun 2026 19:32:53 +0200 Subject: [PATCH 16/17] retro fixing the side-effects of the execArgMatcher that needs to allow various wildcards Signed-off-by: entlein --- go.mod | 6 + go.sum | 2 + .../test32_realpipeline_test.go | 4 +- .../applicationprofile/argvmatch_test.go | 310 ------------------ .../cel/libraries/applicationprofile/exec.go | 112 +------ .../libraries/applicationprofile/exec_test.go | 11 +- 6 files changed, 27 insertions(+), 418 deletions(-) delete mode 100644 pkg/rulemanager/cel/libraries/applicationprofile/argvmatch_test.go diff --git a/go.mod b/go.mod index 32f7128662..1500a3fdcb 100644 --- a/go.mod +++ b/go.mod @@ -483,3 +483,9 @@ 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 + +// TEMP (remove before NA #805 final): pin storage to the fork commit that +// carries the dynamicpathdetector.MatchExecArgs path-aware argv matcher +// (embedded "⋯"/"*" path tokens) moved out of NA into storage. Swap for the +// real released tag once the storage PR merges. +replace github.com/kubescape/storage => github.com/k8sstormcenter/storage v0.0.240-0.20260601171344-787cc4376971 diff --git a/go.sum b/go.sum index 271a7c2cc6..d456521abc 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.20260601171344-787cc4376971 h1:8SKwW9yJQMFSYHCGJ8ybhsgXSV8OaVTHqnoQzpiaEzQ= +github.com/k8sstormcenter/storage v0.0.240-0.20260601171344-787cc4376971/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= diff --git a/pkg/objectcache/containerprofilecache/test32_realpipeline_test.go b/pkg/objectcache/containerprofilecache/test32_realpipeline_test.go index a00fdd3586..e9e53b1a54 100644 --- a/pkg/objectcache/containerprofilecache/test32_realpipeline_test.go +++ b/pkg/objectcache/containerprofilecache/test32_realpipeline_test.go @@ -138,7 +138,7 @@ func keysOf(m map[string]struct{}) []string { // // The test drives the REAL projectUserProfiles → Apply merge, then shows the // raw-CompareExecArgs walk (deployed behaviour) returns the WRONG answer and -// the argvVectorMatches walk (the fix) returns the RIGHT answer. +// the MatchExecArgs (strict) walk (the fix) returns the RIGHT answer. func TestTest32_BaseCPBareVectorPoisonsR0040(t *testing.T) { const wild = dynamicpathdetector.WildcardIdentifier @@ -208,7 +208,7 @@ func TestTest32_BaseCPBareVectorPoisonsR0040(t *testing.T) { } return false } - fixedWalk := func(runtimeArgs []string) bool { // argvVectorMatches behaviour + fixedWalk := func(runtimeArgs []string) bool { // MatchExecArgs(strict) behaviour for _, pv := range vectors { if len(pv) == 0 { if len(runtimeArgs) == 0 { diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/argvmatch_test.go b/pkg/rulemanager/cel/libraries/applicationprofile/argvmatch_test.go deleted file mode 100644 index 3ffa4a2f3f..0000000000 --- a/pkg/rulemanager/cel/libraries/applicationprofile/argvmatch_test.go +++ /dev/null @@ -1,310 +0,0 @@ -package applicationprofile - -import ( - "testing" - - "github.com/google/cel-go/cel" - "github.com/goradd/maps" - "github.com/kubescape/node-agent/pkg/config" - "github.com/kubescape/node-agent/pkg/objectcache" - objectcachev1 "github.com/kubescape/node-agent/pkg/objectcache/v1" - "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" - "github.com/kubescape/storage/pkg/registry/file/dynamicpathdetector" - "github.com/stretchr/testify/assert" -) - -// TestArgvVectorMatches exhaustively pins the per-vector argv matching used by -// ap.was_executed_with_args (and therefore R0040). It exercises argvVectorMatches -// directly — the single-vector comparator — across every token shape the -// profile can carry: -// -// literal — exact string equality -// "⋯" (Dynamic) — exactly ONE arg, any value -// "*" (Wildcard) — ZERO or more args -// embedded "⋯" — a path with a dynamic segment INSIDE one arg -// (the postgres / versioned-binary case) -// -// The "want" column is the contract: true => exec is known-with-these-args -// (R0040 silent); false => argv mismatch (R0040 fires). -func TestArgvVectorMatches(t *testing.T) { - const wild = dynamicpathdetector.WildcardIdentifier // "*" - const ell = dynamicpathdetector.DynamicIdentifier // "⋯" - - cases := []struct { - name string - profile []string - runtime []string - want bool - }{ - // ---- symlinked busybox applet: NO args ---- - {"no-args: empty profile matches empty runtime", []string{}, []string{}, true}, - {"no-args: empty profile rejects non-empty runtime", []string{}, []string{"/bin/echo", "x"}, false}, - - // ---- symlinked busybox applet: literal args only ---- - {"literal: exact match", []string{"/bin/cp", "-r"}, []string{"/bin/cp", "-r"}, true}, - {"literal: value mismatch", []string{"/bin/cp", "-r"}, []string{"/bin/cp", "-f"}, false}, - {"literal: runtime too long (anchored)", []string{"/bin/cp", "-r"}, []string{"/bin/cp", "-r", "x"}, false}, - {"literal: runtime too short (anchored)", []string{"/bin/cp", "-r"}, []string{"/bin/cp"}, false}, - - // ---- with WILDCARD "*" (zero or more) ---- - {"wildcard: trailing * absorbs many", []string{"/bin/echo", "hello", wild}, []string{"/bin/echo", "hello", "a", "b", "c"}, true}, - {"wildcard: trailing * absorbs zero", []string{"/bin/echo", "hello", wild}, []string{"/bin/echo", "hello"}, true}, - {"wildcard: literal anchor before * mismatches", []string{"/bin/echo", "hello", wild}, []string{"/bin/echo", "goodbye", "x"}, false}, - {"wildcard: leading * absorbs prefix", []string{wild, "--end"}, []string{"a", "b", "--end"}, true}, - - // ---- with ELLIPSIS "⋯" (exactly one) ---- - {"ellipsis: matches exactly one arg", []string{"/bin/sh", "-c", ell}, []string{"/bin/sh", "-c", "echo hi"}, true}, - {"ellipsis: rejects zero (needs one)", []string{"/bin/sh", "-c", ell}, []string{"/bin/sh", "-c"}, false}, - {"ellipsis: rejects two (exactly one)", []string{"/bin/sh", "-c", ell}, []string{"/bin/sh", "-c", "a", "b"}, false}, - {"ellipsis: mid-vector with literal after", []string{"--user", ell, "--port", "8080"}, []string{"--user", "alice", "--port", "8080"}, true}, - {"ellipsis: mid-vector trailing literal mismatch", []string{"--user", ell, "--port", "8080"}, []string{"--user", "alice", "--port", "9090"}, false}, - - // ---- WILDCARD + ELLIPSIS together ---- - {"ellipsis then wildcard: one then zero+", []string{"/bin/foo", ell, wild}, []string{"/bin/foo", "a"}, true}, - {"ellipsis then wildcard: one then many", []string{"/bin/foo", ell, wild}, []string{"/bin/foo", "a", "b", "c"}, true}, - {"ellipsis then wildcard: zero args fails (⋯ needs one)", []string{"/bin/foo", ell, wild}, []string{"/bin/foo"}, false}, - {"wildcard then ellipsis: prefix then one", []string{"/bin/foo", wild, ell}, []string{"/bin/foo", "a", "b", "last"}, true}, - - // ---- ALL combined: literal + ⋯ + literal + * ---- - {"all: literal,⋯,literal,* matches", []string{"/bin/tool", "sub", ell, "--flag", wild}, []string{"/bin/tool", "sub", "X", "--flag", "a", "b"}, true}, - {"all: literal,⋯,literal,* trailing-only ok (* zero)", []string{"/bin/tool", "sub", ell, "--flag", wild}, []string{"/bin/tool", "sub", "X", "--flag"}, true}, - {"all: literal,⋯,literal,* literal-anchor mismatch", []string{"/bin/tool", "sub", ell, "--flag", wild}, []string{"/bin/tool", "sub", "X", "--nope", "a"}, false}, - - // ---- NON-symlinked, versioned binary: postgres (⋯ embedded in argv[0]) ---- - // Profile argv[0] carries a dynamic PATH segment; runtime argv[0] has the - // concrete version. The remaining args are exact flags. - { - "postgres: versioned argv[0] + exact flags", - []string{ - "/usr/lib/postgresql/" + ell + "/bin/postgres", - "--check", "-F", - "-c", "log_checkpoints=false", - "-c", "max_connections=100", - "-c", "shared_buffers=16384", - "-c", "dynamic_shared_memory_type=posix", - }, - []string{ - "/usr/lib/postgresql/16/bin/postgres", - "--check", "-F", - "-c", "log_checkpoints=false", - "-c", "max_connections=100", - "-c", "shared_buffers=16384", - "-c", "dynamic_shared_memory_type=posix", - }, - true, - }, - { - "postgres: versioned argv[0] matches but a flag value differs", - []string{ - "/usr/lib/postgresql/" + ell + "/bin/postgres", - "-c", "max_connections=100", - }, - []string{ - "/usr/lib/postgresql/16/bin/postgres", - "-c", "max_connections=9999", - }, - false, - }, - { - "postgres: versioned argv[0] with trailing * over the -c flags", - []string{ - "/usr/lib/postgresql/" + ell + "/bin/postgres", - "--check", wild, - }, - []string{ - "/usr/lib/postgresql/16/bin/postgres", - "--check", "-c", "log_checkpoints=false", - }, - true, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - got := argvVectorMatches(tc.profile, tc.runtime) - assert.Equalf(t, tc.want, got, "argvVectorMatches(%v, %v) = %v, want %v", - tc.profile, tc.runtime, got, tc.want) - }) - } -} - -// TestArgvVectorMatches_ParityWithStorage pins that the NA reimplementation -// has NOT diverged from storage's CompareExecArgs on the semantics they -// share — the "*" / bare-"⋯" / literal handling. For every NON-empty, -// NON-embedded-⋯ profile vector, the two MUST agree. (The two intentional -// differences — empty profile is strict here, and embedded-⋯ args are -// path-matched here — are covered by the matrix above and excluded from this -// parity set.) -func TestArgvVectorMatches_ParityWithStorage(t *testing.T) { - const wild = dynamicpathdetector.WildcardIdentifier - const ell = dynamicpathdetector.DynamicIdentifier - - profiles := [][]string{ - {"a"}, {"a", "b"}, {"a", wild}, {wild, "b"}, {"a", ell, "c"}, - {ell}, {ell, ell}, {"a", ell, wild}, {wild, ell}, {"a", wild, "c"}, - {"-c", "log=false"}, {"--user", ell, "--port", "8080"}, - {wild}, {"a", "b", wild, "d"}, - } - runtimes := [][]string{ - {}, {"a"}, {"a", "b"}, {"a", "b", "c"}, {"x"}, - {"a", "x", "c"}, {"a", "b", "c", "d"}, {"--user", "alice", "--port", "8080"}, - {"-c", "log=false"}, {"a", "y"}, - } - - for _, p := range profiles { - // Skip vectors with an embedded-⋯ arg (path-aware here, literal in - // storage) — those are an intentional divergence, tested separately. - skip := false - for _, a := range p { - if a != ell && a != wild && containsEllipsis(a) { - skip = true - } - } - if skip { - continue - } - for _, r := range runtimes { - want := dynamicpathdetector.CompareExecArgs(p, r) // storage's matcher - got := argvVectorMatches(p, r) - assert.Equalf(t, want, got, - "parity mismatch: profile=%v runtime=%v — storage=%v na=%v", p, r, want, got) - } - } -} - -func containsEllipsis(s string) bool { - return len(s) != len(dynamicpathdetector.DynamicIdentifier) && - // contains but isn't the bare token - stringsContains(s, dynamicpathdetector.DynamicIdentifier) -} - -func stringsContains(s, sub string) bool { - for i := 0; i+len(sub) <= len(s); i++ { - if s[i:i+len(sub)] == sub { - return true - } - } - return false -} - -// TestWasExecutedWithArgs_PostgresEndToEnd drives the FULL CEL helper -// ap.was_executed_with_args for the non-symlinked, versioned-binary case -// where "⋯" appears in BOTH the path and argv[0]. At runtime the path -// resolves to a concrete version, so: -// -// - the path "/usr/lib/postgresql/⋯/bin/postgres" lands in Execs.Patterns -// and is matched against the runtime "/usr/lib/postgresql/16/bin/postgres" -// via CompareDynamic (the pattern branch of wasExecutedWithArgs), then -// - the argv vector is matched via argvVectorMatches, whose argv[0] also -// carries "⋯" and is path-matched. -// -// This exercises a different code path than the busybox exact-path cases -// (Patterns vs Values) and proves R0040's decision end-to-end: -// -// match => was_executed_with_args true => R0040 silent (allowed). -// no match => false => R0040 fires (unexpected args). -func TestWasExecutedWithArgs_PostgresEndToEnd(t *testing.T) { - const ell = dynamicpathdetector.DynamicIdentifier - pgPath := "/usr/lib/postgresql/" + ell + "/bin/postgres" - pgArgv0 := pgPath - - 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{ - {Path: pgPath, Args: []string{ - pgArgv0, "--check", "-F", - "-c", "log_checkpoints=false", - "-c", "max_connections=100", - "-c", "shared_buffers=16384", - "-c", "dynamic_shared_memory_type=posix", - }}, - }, - }) - 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("env: %v", err) - } - - const runtimePath = "/usr/lib/postgresql/16/bin/postgres" // ⋯ resolved to "16" - cases := []struct { - name string - args []string - want bool // was_executed_with_args; false => R0040 fires - }{ - { - "exact recorded postgres start matches", - []string{runtimePath, "--check", "-F", - "-c", "log_checkpoints=false", - "-c", "max_connections=100", - "-c", "shared_buffers=16384", - "-c", "dynamic_shared_memory_type=posix"}, - true, - }, - { - "tampered flag value mismatches", - []string{runtimePath, "--check", "-F", - "-c", "log_checkpoints=false", - "-c", "max_connections=99999", - "-c", "shared_buffers=16384", - "-c", "dynamic_shared_memory_type=posix"}, - false, - }, - { - "different postgres version still matches the ⋯ path segment", - []string{"/usr/lib/postgresql/15/bin/postgres", "--check", "-F", - "-c", "log_checkpoints=false", - "-c", "max_connections=100", - "-c", "shared_buffers=16384", - "-c", "dynamic_shared_memory_type=posix"}, - true, - }, - } - - for _, tc := range cases { - 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("compile: %v", issues.Err()) - } - prog, err := env.Program(ast) - if err != nil { - t.Fatalf("program: %v", err) - } - res, _, err := prog.Eval(map[string]any{ - "containerID": "test-container-id", - "path": runtimePathOf(tc.args), - "args": tc.args, - }) - if err != nil { - t.Fatalf("eval: %v", err) - } - assert.Equalf(t, tc.want, res.Value().(bool), - "was_executed_with_args(path=%s, args=%v)", runtimePathOf(tc.args), tc.args) - }) - } -} - -// runtimePathOf returns argv[0] (the resolved exec path) for the eval input. -func runtimePathOf(args []string) string { - if len(args) > 0 { - return args[0] - } - return "" -} diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/exec.go b/pkg/rulemanager/cel/libraries/applicationprofile/exec.go index 5c278ec6ff..a3836f4d0c 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/exec.go +++ b/pkg/rulemanager/cel/libraries/applicationprofile/exec.go @@ -1,8 +1,6 @@ package applicationprofile import ( - "strings" - "github.com/google/cel-go/common/types" "github.com/google/cel-go/common/types/ref" "github.com/kubescape/go-logger" @@ -105,16 +103,19 @@ func (l *apLibrary) wasExecutedWithArgs(containerID, path, args ref.Val) ref.Val // composite ExecsByPath surface. // // 3. Path in Values, PRESENT in ExecsByPath: - // Walk each profile argv vector with argvVectorMatches (handles - // DynamicIdentifier "⋯" and WildcardIdentifier "*"). Match if - // ANY vector matches. NOTE: per-vector matching uses - // argvVectorMatches, NOT dynamicpathdetector.CompareExecArgs - // directly — see the helper's doc for why an empty recorded - // vector must not act as a wildcard here. + // Match each recorded argv vector with + // dynamicpathdetector.MatchExecArgs(profileArgs, true, runtimeArgs). + // argsRequired=true selects storage's STRICT anchored matcher: + // an empty recorded vector matches ONLY an empty runtime argv, so + // a recorder/synthetic "ran with no args" entry does NOT act as a + // wildcard and poison the multi-vector OR (the #805 production + // failure). The matcher handles WildcardIdentifier "*", bare + // DynamicIdentifier "⋯", and embedded-⋯ path tokens (the postgres + // versioned-binary case) — see storage's compare_exec_args.go. if _, ok := cp.Execs.Values[pathStr]; ok { if vectors, ok := cp.ExecsByPath[pathStr]; ok { for _, profileArgs := range vectors { - if argvVectorMatches(profileArgs, runtimeArgs) { + if dynamicpathdetector.MatchExecArgs(profileArgs, true, runtimeArgs) { return types.Bool(true) } } @@ -130,7 +131,7 @@ func (l *apLibrary) wasExecutedWithArgs(containerID, path, args ref.Val) ref.Val if dynamicpathdetector.CompareDynamic(execPath, pathStr) { if vectors, ok := cp.ExecsByPath[execPath]; ok { for _, profileArgs := range vectors { - if argvVectorMatches(profileArgs, runtimeArgs) { + if dynamicpathdetector.MatchExecArgs(profileArgs, true, runtimeArgs) { return types.Bool(true) } } @@ -147,97 +148,6 @@ func (l *apLibrary) wasExecutedWithArgs(containerID, path, args ref.Val) ref.Val return types.Bool(false) } -// argvVectorMatches reports whether ONE profile argv vector matches the -// runtime args. It is a PATH-AWARE superset of -// dynamicpathdetector.CompareExecArgs, composed from the primitives storage -// v0.0.278 already exports (CompareDynamic, WildcardIdentifier, -// DynamicIdentifier). Token semantics, anchored at both ends: -// -// - "*" (WildcardIdentifier) — matches ZERO or more consecutive args. -// - "⋯" (bare DynamicIdentifier) — matches EXACTLY ONE arg, any value. -// - an arg that CONTAINS "⋯" but is not the bare token — a dynamic PATH; -// compared against the runtime arg with dynamicpathdetector.CompareDynamic -// (segment-wise, "⋯" = one path segment). This is the postgres / -// versioned-binary case: profile argv[0] -// "/usr/lib/postgresql/⋯/bin/postgres" must match the runtime -// "/usr/lib/postgresql/16/bin/postgres". CompareExecArgs alone does only -// literal "==" per position, so it never matched such args. -// - anything else — literal string equality. -// -// Empty-vector semantics: an empty profile vector matches ONLY an empty -// runtime argv. A recorder/synthetic "ran with no args" entry must NOT act -// as a wildcard — otherwise it poisons the multi-vector OR in -// wasExecutedWithArgs and R0040 never fires (the #805 production failure). -// This falls out naturally from the anchored base case below, so no special -// guard is needed. -// -// Backtracking over "*" is memoised on (profileIndex, runtimeIndex) to stay -// quadratic, mirroring storage's matchExecArgsStrict. -func argvVectorMatches(profileArgs, runtimeArgs []string) bool { - memo := make(map[[2]int]bool, (len(profileArgs)+1)*(len(runtimeArgs)+1)) - seen := make(map[[2]int]bool, (len(profileArgs)+1)*(len(runtimeArgs)+1)) - - var match func(pi, ri int) bool - match = func(pi, ri int) bool { - key := [2]int{pi, ri} - if seen[key] { - return memo[key] - } - seen[key] = true - - // Profile fully consumed → runtime must also be fully consumed - // (anchored). With pi==0 this is the empty-vector case: empty - // profile matches only empty runtime. - if pi == len(profileArgs) { - memo[key] = ri == len(runtimeArgs) - return memo[key] - } - - head := profileArgs[pi] - - if head == dynamicpathdetector.WildcardIdentifier { - // Absorb 0..remaining runtime args into "*", first split wins. - for k := ri; k <= len(runtimeArgs); k++ { - if match(pi+1, k) { - memo[key] = true - return true - } - } - memo[key] = false - return false - } - - // A non-wildcard head needs a runtime arg to consume. - if ri == len(runtimeArgs) { - memo[key] = false - return false - } - - if argTokenMatches(head, runtimeArgs[ri]) { - memo[key] = match(pi+1, ri+1) - return memo[key] - } - - memo[key] = false - return false - } - - return match(0, 0) -} - -// argTokenMatches compares ONE profile arg token against ONE runtime arg. -// Bare "⋯" matches any single arg; an arg embedding "⋯" is a dynamic path -// matched segment-wise via CompareDynamic; everything else is literal. -func argTokenMatches(profileArg, runtimeArg string) bool { - if profileArg == dynamicpathdetector.DynamicIdentifier { - return true // bare ⋯ — exactly one arg, any value - } - if strings.Contains(profileArg, dynamicpathdetector.DynamicIdentifier) { - return dynamicpathdetector.CompareDynamic(profileArg, runtimeArg) - } - return profileArg == runtimeArg -} - func (l *apLibrary) isExecInPodSpec(containerID, path ref.Val) ref.Val { if l.objectCache == nil { return types.NewErr("objectCache is nil") diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/exec_test.go b/pkg/rulemanager/cel/libraries/applicationprofile/exec_test.go index 6a23fd4064..5465b7449d 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/exec_test.go +++ b/pkg/rulemanager/cel/libraries/applicationprofile/exec_test.go @@ -354,10 +354,11 @@ func TestExecWithArgsCompilation(t *testing.T) { // "last-write-wins" concern CodeRabbit/matthyx raised on PR #807). // // Test_32 has 4 subtests; this pins the contract for each: -// sh_dash_c_matches_wildcard_trailing — argv matches profile [sh, -c, *]. -// sh_dash_x_mismatches_R0040 — argv mismatches at literal anchor. -// echo_hello_matches_wildcard_trailing — argv matches profile [echo, hello, *]. -// echo_goodbye_mismatches_R0040 — argv mismatches at literal "hello". +// +// sh_dash_c_matches_wildcard_trailing — argv matches profile [sh, -c, *]. +// sh_dash_x_mismatches_R0040 — argv mismatches at literal anchor. +// echo_hello_matches_wildcard_trailing — argv matches profile [echo, hello, *]. +// echo_goodbye_mismatches_R0040 — argv mismatches at literal "hello". func TestExecWithArgsBusyboxMultiVector(t *testing.T) { objCache := objectcachev1.RuleObjectCacheMock{ ContainerIDToSharedData: maps.NewSafeMap[string, *objectcache.WatchedContainerData](), @@ -471,7 +472,7 @@ func TestExecWithArgsBusyboxMultiVector(t *testing.T) { // wasExecutedWithArgs ORs across every vector for the path, that one empty // vector short-circuits the whole match to true — so !was_executed_with_args // is always false and R0040 never fires, even for argv vectors that match -// none of the constrained entries. The fix (argvVectorMatches) treats an +// none of the constrained entries. The fix (dynamicpathdetector.MatchExecArgs, strict) treats an // empty recorded vector as "ran with no args" (matches only empty runtime). func TestExecWithArgsEmptyVectorDoesNotPoisonMatch(t *testing.T) { objCache := objectcachev1.RuleObjectCacheMock{ From 2a4418b028dc42192d56d4f94c5dac4f201a46fc Mon Sep 17 00:00:00 2001 From: Entlein Date: Thu, 4 Jun 2026 17:21:33 +0200 Subject: [PATCH 17/17] R0040: pod-spec exec fallback must match the full argv vector was_executed_with_args fell back to a path-only pod-spec check, so an exec of a pod-spec-declared binary with unexpected arguments was treated as allowed and R0040 stayed silent. Compare the full runtime argv against a declared command vector (Command++Args, or a lifecycle hook Exec.Command) across main / init / ephemeral containers instead. Tests: - unit (exec_podspec_test.go): full-vector match, command+args split, image-entrypoint no-match, PreStop/PostStart hooks, init + ephemeral. - component (Test_32): podspec_declared_command_matches_silent and podspec_command_arg_mismatch_fires_R0040. --- .../cel/libraries/applicationprofile/exec.go | 84 +++++- .../applicationprofile/exec_podspec_test.go | 269 ++++++++++++++++++ tests/component_test.go | 131 +++++++++ .../curl-podspec-command-deployment.yaml | 41 +++ 4 files changed, 524 insertions(+), 1 deletion(-) create mode 100644 pkg/rulemanager/cel/libraries/applicationprofile/exec_podspec_test.go create mode 100644 tests/resources/curl-podspec-command-deployment.yaml diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/exec.go b/pkg/rulemanager/cel/libraries/applicationprofile/exec.go index a3836f4d0c..0cb4156af8 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/exec.go +++ b/pkg/rulemanager/cel/libraries/applicationprofile/exec.go @@ -1,6 +1,8 @@ package applicationprofile import ( + "slices" + "github.com/google/cel-go/common/types" "github.com/google/cel-go/common/types/ref" "github.com/kubescape/go-logger" @@ -9,6 +11,7 @@ import ( "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" + corev1 "k8s.io/api/core/v1" ) func (l *apLibrary) wasExecuted(containerID, path ref.Val) ref.Val { @@ -141,13 +144,92 @@ func (l *apLibrary) wasExecutedWithArgs(containerID, path, args ref.Val) ref.Val } } - if l.isExecInPodSpec(containerID, path).Value().(bool) { + // Pod-spec fallback. Unlike was_executed (which only needs the path to + // appear in the pod spec), the args-aware query must compare the FULL + // runtime argv against a declared command vector — the container's + // startup command (Command ++ Args) or a lifecycle hook's Exec.Command. + // Path-only matching here would let any exec of a declared binary with + // unexpected arguments pass silently and suppress R0040. + if l.isExecWithArgsInPodSpec(containerIDStr, runtimeArgs) { return types.Bool(true) } return types.Bool(false) } +// isExecWithArgsInPodSpec reports whether the full runtime argv vector exactly +// matches a command vector DECLARED in the pod spec for this container: the +// container's startup command (Command ++ Args) or a lifecycle hook's +// Exec.Command. It is the args-aware counterpart of isExecInPodSpec, used by +// wasExecutedWithArgs so that an exec of a declared binary with unexpected +// arguments is not silently treated as allowed. +func (l *apLibrary) isExecWithArgsInPodSpec(containerIDStr string, runtimeArgs []string) bool { + if l.objectCache == nil { + return false + } + + podSpec, err := profilehelper.GetPodSpec(l.objectCache, containerIDStr) + if err != nil { + logger.L().Error("isExecWithArgsInPodSpec - failed to get pod spec", helpers.String("error", err.Error())) + return false + } + containerName := profilehelper.GetContainerName(l.objectCache, containerIDStr) + if containerName == "" { + logger.L().Error("isExecWithArgsInPodSpec - failed to get container name", helpers.String("containerID", containerIDStr)) + return false + } + + // match compares the runtime argv against one declared command vector. + match := func(declared []string) bool { + return len(declared) > 0 && slices.Equal(declared, runtimeArgs) + } + // startupArgv is the argv the kubelet execs for the entrypoint: Command + // followed by Args. Only meaningful when Command is explicitly set; + // otherwise the image ENTRYPOINT is used, which is not visible from the + // pod spec, so we cannot (and must not) claim a match. + startupArgv := func(command, args []string) []string { + if len(command) == 0 { + return nil + } + argv := make([]string, 0, len(command)+len(args)) + argv = append(argv, command...) + argv = append(argv, args...) + return argv + } + // lifecycleMatch checks PreStop/PostStart exec hooks. It only reports a + // match for the return decision; marking the PreStop trigger is handled by + // was_executed (isExecInPodSpec), which the R0040 rule evaluates first. + lifecycleMatch := func(lc *corev1.Lifecycle) bool { + if lc == nil { + return false + } + if lc.PreStop != nil && lc.PreStop.Exec != nil && match(lc.PreStop.Exec.Command) { + return true + } + if lc.PostStart != nil && lc.PostStart.Exec != nil && match(lc.PostStart.Exec.Command) { + return true + } + return false + } + + for _, c := range podSpec.Containers { + if c.Name == containerName { + return match(startupArgv(c.Command, c.Args)) || lifecycleMatch(c.Lifecycle) + } + } + for _, c := range podSpec.InitContainers { + if c.Name == containerName { + return match(startupArgv(c.Command, c.Args)) || lifecycleMatch(c.Lifecycle) + } + } + for _, c := range podSpec.EphemeralContainers { + if c.Name == containerName { + return match(startupArgv(c.Command, c.Args)) || lifecycleMatch(c.Lifecycle) + } + } + return false +} + func (l *apLibrary) isExecInPodSpec(containerID, path ref.Val) ref.Val { if l.objectCache == nil { return types.NewErr("objectCache is nil") diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/exec_podspec_test.go b/pkg/rulemanager/cel/libraries/applicationprofile/exec_podspec_test.go new file mode 100644 index 0000000000..0a8c06f7c0 --- /dev/null +++ b/pkg/rulemanager/cel/libraries/applicationprofile/exec_podspec_test.go @@ -0,0 +1,269 @@ +package applicationprofile + +import ( + "testing" + + "github.com/google/cel-go/cel" + "github.com/goradd/maps" + "github.com/kubescape/node-agent/pkg/config" + "github.com/kubescape/node-agent/pkg/objectcache" + objectcachev1 "github.com/kubescape/node-agent/pkg/objectcache/v1" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" +) + +// newPodSpecExecEnv builds an apLibrary CEL env whose object cache carries an +// EMPTY application profile (so was_executed_with_args is forced down to the +// pod-spec fallback) plus the supplied pod spec, for the main container named +// "test-container". +func newPodSpecExecEnv(t *testing.T, podSpec *corev1.PodSpec) *cel.Env { + t.Helper() + return newPodSpecExecEnvTyped(t, objectcache.Container, "test-container", podSpec) +} + +// newPodSpecExecEnvTyped is newPodSpecExecEnv parameterised by container type +// (Container / InitContainer / EphemeralContainer) and name, so the pod-spec +// fallback can be exercised for init and ephemeral containers too. +func newPodSpecExecEnvTyped(t *testing.T, ctype objectcache.ContainerType, cname string, podSpec *corev1.PodSpec) *cel.Env { + t.Helper() + objCache := objectcachev1.RuleObjectCacheMock{ + ContainerIDToSharedData: maps.NewSafeMap[string, *objectcache.WatchedContainerData](), + } + objCache.SetSharedContainerData("test-container-id", &objectcache.WatchedContainerData{ + ContainerType: ctype, + ContainerInfos: map[objectcache.ContainerType][]objectcache.ContainerInfo{ + ctype: {{Name: cname}}, + }, + }) + // Empty Execs → no Values/Patterns/ExecsByPath entry for the path, so + // wasExecutedWithArgs skips argv matching and reaches the pod-spec fallback. + profile := &v1beta1.ApplicationProfile{} + profile.Spec.Containers = append(profile.Spec.Containers, v1beta1.ApplicationProfileContainer{ + Name: cname, + }) + objCache.SetApplicationProfile(profile) + objCache.SetPodSpec(podSpec) + + 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) + } + return env +} + +func evalWasExecutedWithArgs(t *testing.T, env *cel.Env, path string, args []string) bool { + t.Helper() + ast, issues := env.Compile(`ap.was_executed_with_args(containerID, path, args)`) + if issues != nil && issues.Err() != nil { + t.Fatalf("failed to compile expression: %v", issues.Err()) + } + prog, err := env.Program(ast) + if err != nil { + t.Fatalf("failed to create program: %v", err) + } + out, _, err := prog.Eval(map[string]any{ + "containerID": "test-container-id", + "path": path, + "args": args, + }) + if err != nil { + t.Fatalf("failed to evaluate expression: %v", err) + } + return out.Value().(bool) +} + +// TestExecWithArgs_PodSpecFallbackFullVector pins the contract flagged in the +// node-agent#805 review (2026-06-04): the pod-spec fallback in +// was_executed_with_args must compare the FULL runtime argv vector against the +// declared command vector, not just the executable path. Otherwise an exec of +// a declared binary with UNEXPECTED arguments is silently treated as allowed +// and R0040 ("Unexpected process arguments") never fires. +// +// expectedResult: true = silent (argv matches the declared command — allowed) +// +// false = R0040 must fire (argv differs from anything declared) +func TestExecWithArgs_PodSpecFallbackFullVector(t *testing.T) { + podSpec := &corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "test-container", + Command: []string{"/bin/bash", "-c", "sleep infinity"}, + }, + }, + } + env := newPodSpecExecEnv(t, podSpec) + + testCases := []struct { + name string + path string + args []string + expectedResult bool + }{ + { + // The exact declared startup command is legitimately expected. + name: "exact declared command argv is allowed", + path: "/bin/bash", + args: []string{"/bin/bash", "-c", "sleep infinity"}, + expectedResult: true, + }, + { + // Review repro: extra -x flag. argv != declared command. + name: "extra -x flag must fire R0040", + path: "/bin/bash", + args: []string{"/bin/bash", "-x", "-c", "sleep infinity"}, + expectedResult: false, + }, + { + // Same binary, different trailing argument. + name: "different trailing arg must fire R0040", + path: "/bin/bash", + args: []string{"/bin/bash", "-c", "whoami"}, + expectedResult: false, + }, + { + // Bare binary, no args — declared command has args, so mismatch. + name: "bare binary (declared command has args) must fire R0040", + path: "/bin/bash", + args: []string{"/bin/bash"}, + expectedResult: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got := evalWasExecutedWithArgs(t, env, tc.path, tc.args) + assert.Equal(t, tc.expectedResult, got, + "was_executed_with_args(%s, %v): pod-spec fallback must match the full argv vector", + tc.path, tc.args) + }) + } +} + +// TestExecWithArgs_PodSpecCommandArgsSplit covers the kubelet semantics where a +// container declares Command and Args separately: the executed argv is +// Command ++ Args, and the fallback must match against that concatenation. +func TestExecWithArgs_PodSpecCommandArgsSplit(t *testing.T) { + podSpec := &corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "test-container", + Command: []string{"/bin/bash"}, + Args: []string{"-c", "sleep infinity"}, + }, + }, + } + env := newPodSpecExecEnv(t, podSpec) + + cases := []struct { + name string + args []string + want bool + }{ + {"command++args concatenation is allowed", []string{"/bin/bash", "-c", "sleep infinity"}, true}, + {"extra flag must fire R0040", []string{"/bin/bash", "-x", "-c", "sleep infinity"}, false}, + {"only command part must fire R0040", []string{"/bin/bash"}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, evalWasExecutedWithArgs(t, env, "/bin/bash", tc.args), tc.args) + }) + } +} + +// TestExecWithArgs_PodSpecImageEntrypointNoMatch pins that when a container +// declares NO Command (the image ENTRYPOINT, invisible from the pod spec), the +// fallback must NOT claim a match — otherwise it would silently allow arbitrary +// argv it cannot actually verify. +func TestExecWithArgs_PodSpecImageEntrypointNoMatch(t *testing.T) { + podSpec := &corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "test-container"}, // no Command/Args → image entrypoint + }, + } + env := newPodSpecExecEnv(t, podSpec) + assert.False(t, evalWasExecutedWithArgs(t, env, "/bin/bash", + []string{"/bin/bash", "-c", "sleep infinity"}), + "no declared Command → fallback must not claim a match") +} + +// TestExecWithArgs_PodSpecLifecycleHooks covers PreStop / PostStart exec hooks: +// the declared hook command vector is allowed, anything else fires R0040. +func TestExecWithArgs_PodSpecLifecycleHooks(t *testing.T) { + podSpec := &corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "test-container", + Command: []string{"/bin/bash", "-c", "sleep infinity"}, + Lifecycle: &corev1.Lifecycle{ + PreStop: &corev1.LifecycleHandler{ + Exec: &corev1.ExecAction{Command: []string{"/bin/sh", "-c", "nginx -s quit"}}, + }, + PostStart: &corev1.LifecycleHandler{ + Exec: &corev1.ExecAction{Command: []string{"/bin/sh", "-c", "echo started"}}, + }, + }, + }, + }, + } + env := newPodSpecExecEnv(t, podSpec) + + cases := []struct { + name string + args []string + want bool + }{ + {"prestop exact command allowed", []string{"/bin/sh", "-c", "nginx -s quit"}, true}, + {"poststart exact command allowed", []string{"/bin/sh", "-c", "echo started"}, true}, + {"prestop binary with different args fires R0040", []string{"/bin/sh", "-c", "rm -rf /"}, false}, + {"undeclared command fires R0040", []string{"/bin/sh", "-c", "curl evil"}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, evalWasExecutedWithArgs(t, env, "/bin/sh", tc.args), tc.args) + }) + } +} + +// TestExecWithArgs_PodSpecInitContainer exercises the fallback for an init +// container (separate pod-spec list, resolved via shared-data ContainerType). +func TestExecWithArgs_PodSpecInitContainer(t *testing.T) { + podSpec := &corev1.PodSpec{ + InitContainers: []corev1.Container{ + { + Name: "test-init", + Command: []string{"/bin/sh", "-c", "init-setup"}, + }, + }, + } + env := newPodSpecExecEnvTyped(t, objectcache.InitContainer, "test-init", podSpec) + assert.True(t, evalWasExecutedWithArgs(t, env, "/bin/sh", []string{"/bin/sh", "-c", "init-setup"}), + "init container declared command must be allowed") + assert.False(t, evalWasExecutedWithArgs(t, env, "/bin/sh", []string{"/bin/sh", "-x", "-c", "init-setup"}), + "init container argv mismatch must fire R0040") +} + +// TestExecWithArgs_PodSpecEphemeralContainer exercises the fallback for an +// ephemeral container (debug container injected at runtime). +func TestExecWithArgs_PodSpecEphemeralContainer(t *testing.T) { + podSpec := &corev1.PodSpec{ + EphemeralContainers: []corev1.EphemeralContainer{ + { + EphemeralContainerCommon: corev1.EphemeralContainerCommon{ + Name: "test-ephemeral", + Command: []string{"/bin/sh", "-c", "debug"}, + }, + }, + }, + } + env := newPodSpecExecEnvTyped(t, objectcache.EphemeralContainer, "test-ephemeral", podSpec) + assert.True(t, evalWasExecutedWithArgs(t, env, "/bin/sh", []string{"/bin/sh", "-c", "debug"}), + "ephemeral container declared command must be allowed") + assert.False(t, evalWasExecutedWithArgs(t, env, "/bin/sh", []string{"/bin/sh", "-c", "exfiltrate"}), + "ephemeral container argv mismatch must fire R0040") +} diff --git a/tests/component_test.go b/tests/component_test.go index 02d37644bd..b71859e524 100644 --- a/tests/component_test.go +++ b/tests/component_test.go @@ -1965,4 +1965,135 @@ func Test_32_UnexpectedProcessArguments(t *testing.T) { require.Greater(t, countByRule(alerts, "R0040"), 0, "curl -s exceeds the single-arg dyn token in profile [curl, -s, dyn]: R0040 must fire") }) + + // ----------------------------------------------------------------- + // Pod-spec fallback coverage (node-agent#805 review, 2026-06-04). + // + // was_executed_with_args falls back to the pod spec when the profile + // records no matching argv vector. That fallback must compare the FULL + // runtime argv against a DECLARED command vector (the container's + // Command ++ Args, or a lifecycle hook's Exec.Command) — not just the + // executable path. A path-only fallback lets any exec of a declared + // binary with unexpected arguments pass silently and suppresses R0040. + // + // The pod here declares its command with an explicit /bin/busybox argv0 + // (so exepath == argv0 == command[0] all agree — the symlink forms hide + // the bug) and a postStart hook ["/bin/busybox", "echo", "poststart-hook"]. + // The overlay AP carries NO Execs for the container, so was_executed_with_args + // is forced down to the pod-spec fallback. was_executed still resolves + // (the path appears in the declared command), so R0001 stays silent and + // R0040 gets to evaluate. + // ----------------------------------------------------------------- + const podSpecOverlayName = "curl-32-podspec-overlay" + setupPodSpec := func(t *testing.T) *testutils.TestWorkload { + t.Helper() + ns := testutils.NewRandomNamespace() + k8sClient := k8sinterface.NewKubernetesApi() + storageClient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) + + // Empty Execs → no Values/Patterns/ExecsByPath entry, so + // was_executed_with_args reaches the pod-spec fallback. + ap := &v1beta1.ApplicationProfile{ + ObjectMeta: metav1.ObjectMeta{Name: podSpecOverlayName, Namespace: ns.Name}, + Spec: v1beta1.ApplicationProfileSpec{ + Containers: []v1beta1.ApplicationProfileContainer{ + { + Name: "curl", + Syscalls: []string{"execve", "read", "write", "close", "openat", "fstat", "rt_sigaction", "rt_sigprocmask"}, + }, + }, + }, + } + _, err := storageClient.ApplicationProfiles(ns.Name).Create( + context.Background(), ap, metav1.CreateOptions{}) + require.NoError(t, err, "create AP") + + nn := &v1beta1.NetworkNeighborhood{ + ObjectMeta: metav1.ObjectMeta{ + Name: podSpecOverlayName, + Namespace: ns.Name, + Annotations: map[string]string{ + helpersv1.ManagedByMetadataKey: helpersv1.ManagedByUserValue, + helpersv1.StatusMetadataKey: helpersv1.Completed, + helpersv1.CompletionMetadataKey: helpersv1.Full, + }, + Labels: map[string]string{ + helpersv1.ApiGroupMetadataKey: "apps", + helpersv1.ApiVersionMetadataKey: "v1", + helpersv1.RelatedKindMetadataKey: "Deployment", + helpersv1.RelatedNameMetadataKey: "curl-32-podspec", + helpersv1.RelatedNamespaceMetadataKey: ns.Name, + }, + }, + Spec: v1beta1.NetworkNeighborhoodSpec{ + LabelSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "curl-32-podspec"}, + }, + Containers: []v1beta1.NetworkNeighborhoodContainer{{Name: "curl"}}, + }, + } + _, err = storageClient.NetworkNeighborhoods(ns.Name).Create( + context.Background(), nn, metav1.CreateOptions{}) + require.NoError(t, err, "create NN") + + require.Eventually(t, func() bool { + _, apErr := storageClient.ApplicationProfiles(ns.Name).Get( + context.Background(), podSpecOverlayName, v1.GetOptions{}) + _, nnErr := storageClient.NetworkNeighborhoods(ns.Name).Get( + context.Background(), podSpecOverlayName, v1.GetOptions{}) + return apErr == nil && nnErr == nil + }, 30*time.Second, 1*time.Second, "AP+NN must be in storage before pod deploy") + + wl, err := testutils.NewTestWorkload(ns.Name, + path.Join(utils.CurrentDir(), "resources/curl-podspec-command-deployment.yaml")) + require.NoError(t, err) + require.NoError(t, wl.WaitForReady(80)) + time.Sleep(30 * time.Second) + return wl + } + + // ----------------------------------------------------------------- + // 32g. Exec the EXACT declared postStart hook command + // ["/bin/busybox", "echo", "poststart-hook"]. It matches a command + // vector declared in the pod spec, so the args-aware fallback allows + // it → R0040 must stay silent. + // ----------------------------------------------------------------- + t.Run("podspec_declared_command_matches_silent", func(t *testing.T) { + wl := setupPodSpec(t) + require.Eventually(t, func() bool { + _, _, err := wl.ExecIntoPod([]string{"/bin/busybox", "echo", "poststart-hook"}, "curl") + return err == nil + }, 60*time.Second, 5*time.Second, "exec must run") + time.Sleep(20 * time.Second) + alerts := waitAlerts(t, wl.Namespace) + t.Logf("=== %d alerts ===", len(alerts)) + logAlerts(t, alerts) + assert.Equal(t, 0, countByRule(alerts, "R0040"), + "argv matches the declared postStart command vector: R0040 must stay silent") + }) + + // ----------------------------------------------------------------- + // 32h. Exec the SAME declared binary (/bin/busybox) with an argv vector + // that matches NO declared command — neither the container command + // ["/bin/busybox","sleep","999999"] nor the postStart hook + // ["/bin/busybox","echo","poststart-hook"]. Before the fix the + // path-only pod-spec fallback returned true (R0040 silent — the bug); + // the full-vector fallback now rejects it → R0040 must fire. + // ----------------------------------------------------------------- + t.Run("podspec_command_arg_mismatch_fires_R0040", func(t *testing.T) { + wl := setupPodSpec(t) + var alerts []testutils.Alert + require.Eventually(t, func() bool { + _, _, err := wl.ExecIntoPod([]string{"/bin/busybox", "echo", "not-a-declared-command"}, "curl") + if err != nil { + return false + } + alerts = waitAlerts(t, wl.Namespace) + return countByRule(alerts, "R0040") > 0 + }, 120*time.Second, 10*time.Second, "argv matches no declared pod-spec command vector: R0040 must fire") + t.Logf("=== %d alerts ===", len(alerts)) + logAlerts(t, alerts) + require.Greater(t, countByRule(alerts, "R0040"), 0, + "exec of a declared binary with undeclared args must fire R0040 (pod-spec fallback compares the full argv vector)") + }) } diff --git a/tests/resources/curl-podspec-command-deployment.yaml b/tests/resources/curl-podspec-command-deployment.yaml new file mode 100644 index 0000000000..039b20c713 --- /dev/null +++ b/tests/resources/curl-podspec-command-deployment.yaml @@ -0,0 +1,41 @@ +## Pod for the Test_32 pod-spec-fallback subtests (node-agent#805 review, +## 2026-06-04). The container command is declared with an EXPLICIT +## /bin/busybox argv0 so the kernel-resolved exepath (/bin/busybox), the +## runtime argv[0] (/bin/busybox) and the declared command[0] (/bin/busybox) +## all agree. That alignment is what makes the pod-spec fallback in +## was_executed_with_args observable end-to-end: with the symlink forms +## (/bin/sh -> /bin/busybox) the exepath never appears literally in the +## declared command, so the path-only fallback bug cannot manifest. +## +## A postStart lifecycle hook declares a SHORT command vector so the +## "declared command is allowed" case can be exercised by a quick exec +## (the main command is `sleep 999999`, which would block kubectl exec). +## +## Carries the unified user-defined-profile/network labels (one overlay name +## is the lookup key for BOTH the user AP and the user NN). +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: curl-32-podspec + name: curl-32-podspec +spec: + selector: + matchLabels: + app: curl-32-podspec + replicas: 1 + template: + metadata: + labels: + app: curl-32-podspec + kubescape.io/user-defined-profile: curl-32-podspec-overlay + kubescape.io/user-defined-network: curl-32-podspec-overlay + spec: + containers: + - name: curl + image: docker.io/curlimages/curl@sha256:08e466006f0860e54fc299378de998935333e0e130a15f6f98482e9f8dab3058 + command: ["/bin/busybox", "sleep", "999999"] + lifecycle: + postStart: + exec: + command: ["/bin/busybox", "echo", "poststart-hook"]