Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion pkg/rulemanager/cel/libraries/parse/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,50 @@ 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])
}
}
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/<pid>/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)
}
11 changes: 11 additions & 0 deletions pkg/rulemanager/cel/libraries/parse/parselib.go
Original file line number Diff line number Diff line change
Expand Up @@ -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])
}),
),
},
}
}
Expand Down
119 changes: 119 additions & 0 deletions pkg/rulemanager/cel/libraries/parse/parsing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
})
}
}
Loading