From 5a29dff160dc151a01eb6dded8a2e7bb34179bbf Mon Sep 17 00:00:00 2001 From: Matthias Bertschy Date: Wed, 7 Oct 2026 17:41:22 +0200 Subject: [PATCH 1/2] fix(cel): tolerate exited processes during environment lookup Signed-off-by: Matthias Bertschy --- .../cel/libraries/process/process.go | 22 +++++- .../process/process_env_exited_test.go | 69 +++++++++++++++++++ .../cel/libraries/process/processlib.go | 2 +- 3 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 pkg/rulemanager/cel/libraries/process/process_env_exited_test.go diff --git a/pkg/rulemanager/cel/libraries/process/process.go b/pkg/rulemanager/cel/libraries/process/process.go index 9028804582..13c7c473e7 100644 --- a/pkg/rulemanager/cel/libraries/process/process.go +++ b/pkg/rulemanager/cel/libraries/process/process.go @@ -1,6 +1,9 @@ package process import ( + "errors" + "fmt" + "os" "strings" "github.com/google/cel-go/common/types" @@ -8,6 +11,8 @@ import ( "github.com/prometheus/procfs" ) +var errProcessExited = errors.New("process exited before environment lookup") + // LD_PRELOAD_ENV_VARS contains the environment variables that can be used for LD_PRELOAD var LD_PRELOAD_ENV_VARS = []string{ "LD_PRELOAD", @@ -48,7 +53,7 @@ func (l *processLibrary) getProcessEnv(pid ref.Val) ref.Val { envMap, err := GetProcessEnv(int(pidInt)) if err != nil { - return types.NewErr("failed to get process environment: %v", err) + return types.WrapErr(fmt.Errorf("failed to get process environment: %w", err)) } // Convert map[string]string to map[string]interface{} for CEL @@ -60,6 +65,15 @@ func (l *processLibrary) getProcessEnv(pid ref.Val) ref.Val { return types.NewDynamicMap(types.DefaultTypeAdapter, result) } +// processEnvOrEmpty converts an exited process to an empty environment after +// caching, so a missing process does not cache an empty map for a reused PID. +func processEnvOrEmpty(result ref.Val) ref.Val { + if err, ok := result.(*types.Err); ok && errors.Is(err, errProcessExited) { + return types.NewStringStringMap(types.DefaultTypeAdapter, map[string]string{}) + } + return result +} + func (l *processLibrary) getLdHookVar(pid ref.Val) ref.Val { pidUint, ok := pid.Value().(uint64) if !ok { @@ -90,11 +104,17 @@ func GetProcessEnv(pid int) (map[string]string, error) { proc, err := fs.Proc(pid) if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("%w: %w", errProcessExited, err) + } return nil, err } env, err := proc.Environ() if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("%w: %w", errProcessExited, err) + } return nil, err } diff --git a/pkg/rulemanager/cel/libraries/process/process_env_exited_test.go b/pkg/rulemanager/cel/libraries/process/process_env_exited_test.go new file mode 100644 index 0000000000..f0d000bcff --- /dev/null +++ b/pkg/rulemanager/cel/libraries/process/process_env_exited_test.go @@ -0,0 +1,69 @@ +package process + +import ( + "fmt" + "os" + "os/exec" + "testing" + + "github.com/google/cel-go/cel" + "github.com/google/cel-go/common/types" + "github.com/google/cel-go/common/types/ref" + "github.com/google/cel-go/common/types/traits" + "github.com/kubescape/node-agent/pkg/config" + "github.com/kubescape/node-agent/pkg/rulemanager/cel/libraries/cache" + "github.com/stretchr/testify/require" +) + +func TestProcessEnvExitedProcess(t *testing.T) { + cmd := exec.Command("true") + require.NoError(t, cmd.Run()) + pid := cmd.Process.Pid + _, err := os.Stat(fmt.Sprintf("/proc/%d", pid)) + require.ErrorIs(t, err, os.ErrNotExist) + + env, err := cel.NewEnv(Process(config.Config{})) + require.NoError(t, err) + for index, expression := range []string{ + fmt.Sprintf("size(process.get_process_env(%d)) == 0", pid), + fmt.Sprintf("'GLIBC_TUNABLES' in process.get_process_env(%d) && process.get_process_env(%d)['GLIBC_TUNABLES'].matches('glibc')", pid, pid), + } { + ast, issues := env.Compile(expression) + require.NoError(t, issues.Err()) + program, err := env.Program(ast) + require.NoError(t, err) + result, _, err := program.Eval(map[string]any{}) + require.NoError(t, err) + if index == 0 { + require.Equal(t, types.True, result) + } else { + require.Equal(t, types.False, result) + } + } +} + +func TestProcessEnvPreservesOtherErrors(t *testing.T) { + for _, err := range []error{os.ErrPermission, os.ErrNotExist, fmt.Errorf("unexpected read failure")} { + result := types.WrapErr(fmt.Errorf("failed to get process environment: %w", err)) + require.Same(t, result, processEnvOrEmpty(result)) + } +} + +func TestProcessEnvExitedResultIsNotCached(t *testing.T) { + functionCache := cache.NewFunctionCache(cache.FunctionCacheConfig{}) + calls := 0 + cached := functionCache.WithCache(func(...ref.Val) ref.Val { + calls++ + if calls == 1 { + return types.WrapErr(fmt.Errorf("%w: %w", errProcessExited, os.ErrNotExist)) + } + return types.NewStringStringMap(types.DefaultTypeAdapter, map[string]string{"GLIBC_TUNABLES": "glibc.malloc.check=1"}) + }, "process.get_process_env") + + pid := types.Int(123) + require.Equal(t, types.IntZero, processEnvOrEmpty(cached(pid)).(traits.Sizer).Size()) + result := processEnvOrEmpty(cached(pid)) + require.Equal(t, types.String("glibc.malloc.check=1"), result.(traits.Indexer).Get(types.String("GLIBC_TUNABLES"))) + require.Equal(t, result, processEnvOrEmpty(cached(pid))) + require.Equal(t, 2, calls, "missing PIDs must be retried; successful environments must still be cached") +} diff --git a/pkg/rulemanager/cel/libraries/process/processlib.go b/pkg/rulemanager/cel/libraries/process/processlib.go index 3672003d36..b87c8b702f 100644 --- a/pkg/rulemanager/cel/libraries/process/processlib.go +++ b/pkg/rulemanager/cel/libraries/process/processlib.go @@ -48,7 +48,7 @@ func (l *processLibrary) Declarations() map[string][]cel.FunctionOpt { return l.getProcessEnv(args[0]) } cachedFunc := l.functionCache.WithCache(wrapperFunc, "process.get_process_env") - return cachedFunc(values[0]) + return processEnvOrEmpty(cachedFunc(values[0])) }), ), }, From d06bbd5e227dd212e071f1ab3950b1705f5714c2 Mon Sep 17 00:00:00 2001 From: Matthias Bertschy Date: Wed, 7 Oct 2026 17:49:16 +0200 Subject: [PATCH 2/2] docs(cel): clarify short-lived process environment coverage Signed-off-by: Matthias Bertschy --- pkg/rulemanager/cel/libraries/process/process.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pkg/rulemanager/cel/libraries/process/process.go b/pkg/rulemanager/cel/libraries/process/process.go index 13c7c473e7..dc683dde9d 100644 --- a/pkg/rulemanager/cel/libraries/process/process.go +++ b/pkg/rulemanager/cel/libraries/process/process.go @@ -67,6 +67,8 @@ func (l *processLibrary) getProcessEnv(pid ref.Val) ref.Val { // processEnvOrEmpty converts an exited process to an empty environment after // caching, so a missing process does not cache an empty map for a reused PID. +// This avoids expected evaluation errors; it cannot recover the exited process's +// environment, so environment-based rules may miss short-lived commands. func processEnvOrEmpty(result ref.Val) ref.Val { if err, ok := result.(*types.Err); ok && errors.Is(err, errProcessExited) { return types.NewStringStringMap(types.DefaultTypeAdapter, map[string]string{}) @@ -95,7 +97,9 @@ func (l *processLibrary) getLdHookVar(pid ref.Val) ref.Val { return types.String(envVar) } -// GetProcessEnv retrieves the environment variables for a given process ID +// GetProcessEnv reads a process's live /proc//environ at call time, not at +// exec-event capture time. Short-lived processes can exit before the read; +// their environment cannot be recovered from the exec event by this helper. func GetProcessEnv(pid int) (map[string]string, error) { fs, err := procfs.NewFS("/proc") if err != nil {