From e38bf4f86ce51375f512347c369499fd98d23906 Mon Sep 17 00:00:00 2001 From: Matthias Bertschy Date: Mon, 2 Nov 2026 13:34:56 +0100 Subject: [PATCH] feat(cel): expose the executed file as event.file trace_exec v0.57 emits file, d_path(bprm->file): the file the kernel executed. For a shebang script it is the script, while exepath is the interpreter and argv[0] is the interpreter as written in the shebang, so no existing CEL field gives rules the script path. Register it as event.file with presence tracking, so has(event.file) reports whether the gadget emits it. Rules use it with an exepath fallback and an agent version gate. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Matthias Bertschy --- docs/features/exec-file-cel-field.md | 44 ++++++++++++++++ pkg/rulemanager/cel/cel_file_test.go | 76 ++++++++++++++++++++++++++++ pkg/utils/cel.go | 14 ++++- pkg/utils/struct_event.go | 2 + 4 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 docs/features/exec-file-cel-field.md create mode 100644 pkg/rulemanager/cel/cel_file_test.go diff --git a/docs/features/exec-file-cel-field.md b/docs/features/exec-file-cel-field.md new file mode 100644 index 0000000000..b321da19cc --- /dev/null +++ b/docs/features/exec-file-cel-field.md @@ -0,0 +1,44 @@ +# Exec `file` CEL field + +`event.file` is the file the kernel executed: `d_path(bprm->file)`, emitted by +`trace_exec` since Inspektor Gadget v0.57.0. It is symlink-resolved, like +`event.exepath`, but the two differ when the executed file is not the binary +that ends up running: + +| Exec | `event.file` | `event.exepath` | `event.args[0]` | +|---|---|---|---| +| ELF binary `/bin/ls` | `/usr/bin/ls` | `/usr/bin/ls` | `ls` | +| Shebang script `/run/x.pid` (`#!/bin/sh`) | `/run/x.pid` | `/usr/bin/dash` | `/bin/sh` | +| binfmt_misc (e.g. a `.jar` handler) | the registered file | the interpreter | as passed | + +Use `event.file` when a rule must match the executed file itself, for example a +script under a runtime-state directory. Keep `event.exepath` for profile +lookups: profiles store `exepath`, and `GetExecPathFromEvent` resolves the +alert path as file, then exepath, then comm. + +## Presence and fallback + +`has(event.file)` is true when the running gadget emits the field. It can still +be empty when the kernel path could not be resolved, so rules use a fallback: + +```cel +(event.file != "" ? event.file : event.exepath) +``` + +`StructEvent` (ptrace and tests) reports `file` present when it is non-empty. + +## Version gate + +An agent built before this field existed does not register `event.file`, so a +rule that references it fails to compile there and is skipped. Gate such rules +with `agentVersionRequirement` set to the first release that carries this +change (kubescape/node-agent, and for private agents the first +private-node-agent release that pins it). The rules watcher skips a rule whose +requirement the agent does not meet before compiling it. + +## Code + +- `pkg/utils/cel.go`: the `file` entry in `CelFields`, `IsSet: presenceOf("file")`. +- `pkg/utils/struct_event.go`: `FieldPresent("file")`. +- `pkg/rulemanager/cel/cel_file_test.go`: shebang, missing-field and fallback cases + against both `StructEvent` and a synthetic exec datasource. diff --git a/pkg/rulemanager/cel/cel_file_test.go b/pkg/rulemanager/cel/cel_file_test.go new file mode 100644 index 0000000000..5e23e8a5de --- /dev/null +++ b/pkg/rulemanager/cel/cel_file_test.go @@ -0,0 +1,76 @@ +package cel + +import ( + "testing" + + "github.com/inspektor-gadget/inspektor-gadget/pkg/datasource" + "github.com/inspektor-gadget/inspektor-gadget/pkg/gadget-service/api" + "github.com/kubescape/node-agent/pkg/utils" + "github.com/stretchr/testify/require" +) + +// newDatasourceFileExecEvent builds a synthetic "exec" datasource with comm and +// exepath and, when withFile is set, the "file" field trace_exec v0.57 emits. +func newDatasourceFileExecEvent(t *testing.T, exepath, file string, withFile bool) *utils.DatasourceEvent { + t.Helper() + + ds, err := datasource.New(datasource.TypeSingle, "exec") + require.NoError(t, err) + + commAcc, err := ds.AddField("comm", api.Kind_String) + require.NoError(t, err) + exeAcc, err := ds.AddField("exepath", api.Kind_String) + require.NoError(t, err) + var fileAcc datasource.FieldAccessor + if withFile { + fileAcc, err = ds.AddField("file", api.Kind_String) + require.NoError(t, err) + } + + data, err := ds.NewPacketSingle() + require.NoError(t, err) + t.Cleanup(func() { ds.Release(data) }) + + require.NoError(t, commAcc.PutString(data, "sh")) + require.NoError(t, exeAcc.PutString(data, exepath)) + if withFile { + require.NoError(t, fileAcc.PutString(data, file)) + } + + return &utils.DatasourceEvent{ + Data: data, + Datasource: ds, + EventType: utils.ExecveEventType, + } +} + +// TestCELExecFile covers event.file: for a shebang exec it is the script while +// event.exepath is the interpreter, so rules can match the executed file. +func TestCELExecFile(t *testing.T) { + shebang := &utils.StructEvent{ + EventType: utils.ExecveEventType, + Comm: "x.pid", + Args: []string{"/bin/sh", "/run/x.pid"}, + ExePath: "/usr/bin/dash", + File: "/run/x.pid", + } + require.True(t, evalTTY(t, shebang, `has(event.file) && event.file == "/run/x.pid"`)) + require.True(t, evalTTY(t, shebang, `event.exepath == "/usr/bin/dash"`)) + + noFile := &utils.StructEvent{EventType: utils.ExecveEventType, Comm: "ls", ExePath: "/usr/bin/ls"} + require.False(t, evalTTY(t, noFile, `has(event.file)`)) + require.True(t, evalTTY(t, noFile, + `(event.file != "" ? event.file : event.exepath) == "/usr/bin/ls"`), + "the documented fallback picks exepath when file is empty") + + ds := newDatasourceFileExecEvent(t, "/usr/bin/dash", "/run/x.pid", true) + require.True(t, evalTTYEvent(t, ds, utils.ExecveEventType, + `has(event.file) && event.file == "/run/x.pid" && event.exepath == "/usr/bin/dash"`)) + + // A gadget that does not emit file (pre-v0.57): has() is false and the + // expression still compiles, so a guarded rule is not silently disabled. + old := newDatasourceFileExecEvent(t, "/usr/bin/dash", "", false) + require.False(t, evalTTYEvent(t, old, utils.ExecveEventType, `has(event.file)`)) + require.True(t, evalTTYEvent(t, old, utils.ExecveEventType, + `(has(event.file) && event.file != "" ? event.file : event.exepath) == "/usr/bin/dash"`)) +} diff --git a/pkg/utils/cel.go b/pkg/utils/cel.go index 056208554e..d8c42f7d22 100644 --- a/pkg/utils/cel.go +++ b/pkg/utils/cel.go @@ -93,7 +93,8 @@ func presenceOf(dsField string) ref.FieldTester { // // A field whose IsSet tester is presenceOf(...) can be absent at runtime when // the running gadget does not emit it; rules should guard those with has(). -// See docs/features/exec-tty-field.md for the tty fields specifically. +// See docs/features/exec-tty-field.md for the tty fields and +// docs/features/exec-file-cel-field.md for file. var CelFields = map[string]*celtypes.FieldType{ "args": { Type: celtypes.ListType, @@ -309,6 +310,17 @@ var CelFields = map[string]*celtypes.FieldType{ return celtypes.String(x.Raw.GetExePath()), nil }), }, + "file": { + Type: celtypes.StringType, + IsSet: presenceOf("file"), + GetFrom: ref.FieldGetter(func(target any) (any, error) { + x := target.(*xcel.Object[CelEvent]) + if x.Raw == nil { + return nil, errCelObjectNil + } + return celtypes.String(x.Raw.GetFile()), nil + }), + }, "flags": { Type: celtypes.ListType, IsSet: isSet, diff --git a/pkg/utils/struct_event.go b/pkg/utils/struct_event.go index 6a8aeb0fec..5676f9a4ae 100644 --- a/pkg/utils/struct_event.go +++ b/pkg/utils/struct_event.go @@ -125,6 +125,8 @@ func (e *StructEvent) FieldPresent(name string) bool { return e.TTYMajor != nil case "tty_minor": return e.TTYMinor != nil + case "file": + return e.File != "" default: return false }