From 2ff5f0524483c81dd4b6f4222c8770a5d31e3e7c Mon Sep 17 00:00:00 2001 From: matthyx Date: Thu, 25 Jun 2026 16:00:15 +0200 Subject: [PATCH] feat(cel/parse): add 3-arg get_exec_path(args, comm, exepath) overload Adds an exepath-aware overload of parse.get_exec_path to the CEL parse library. The existing 2-arg form resolves argv[0] -> comm; the new 3-arg form resolves the exec path symmetrically with the recording side's resolveExecPath in pkg/containerprofilemanager/v1/event_reporting.go: 1. exepath (kernel-authoritative, spoof-resistant) 2. argv[0] when non-empty AND exepath empty (fexecve / AT_EMPTY_PATH) 3. comm This lets rules query the same identity the recorder stored, closing the fork-shell mismatch (`sh -c ...` records /bin/sh and the rule side now queries /bin/sh) while preserving argv[0]-spoofing protection. The 2-arg overload is unchanged, so existing rules keep working. The overload is consumed by the OSS R0040 rule in kubescape/rulelibrary (the path-resolution half of the get_exec_path migration). It replaces the engine portion of node-agent#805; the rule YAML and component tests from that PR move to rulelibrary instead. Adds TestGetExecPath_SymmetryWithRecordingSide covering exepath-wins, fexecve fallback, comm fallback, busybox symlink, and absolute-argv[0] spoof cases. Signed-off-by: matthyx Co-Authored-By: Claude Opus 4.8 Docs-exempt: additive CEL helper overload, no node-agent config/API/behavioral change; consumer rule R0040 is documented in kubescape/rulelibrary --- pkg/rulemanager/cel/libraries/parse/parse.go | 41 +++++- .../cel/libraries/parse/parselib.go | 11 ++ .../cel/libraries/parse/parsing_test.go | 119 ++++++++++++++++++ 3 files changed, 170 insertions(+), 1 deletion(-) diff --git a/pkg/rulemanager/cel/libraries/parse/parse.go b/pkg/rulemanager/cel/libraries/parse/parse.go index ba82f982f6..d7735a8d1a 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,39 @@ func (l *parseLibrary) getExecPath(args ref.Val, comm ref.Val) ref.Val { } return types.String(commStr) } + +// getExecPathWithExePath resolves the exec path symmetrically with the +// recording side's resolveExecPath in +// pkg/containerprofilemanager/v1/event_reporting.go. Precedence: +// +// 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. 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. comm as final fallback (also via 2-arg). +// +// 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) + } + + if exepathStr != "" { + return types.String(exepathStr) + } + + // 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/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..c4e10dddfd 100644 --- a/pkg/rulemanager/cel/libraries/parse/parsing_test.go +++ b/pkg/rulemanager/cel/libraries/parse/parsing_test.go @@ -135,3 +135,122 @@ 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 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, 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 +// resolved path "sh" (argv[0]), and R0001 fires spuriously on benign +// shell invocations. +// +// These cases mirror TestResolveExecPath in pkg/containerprofilemanager/v1/ +// event_reporting_test.go. They use the 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`. 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/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: "/bin/busybox", + }, + { + // 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. + 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 { + 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") + }) + } +}