Repository: kubernetes-sigs/security-profiles-operator

Issue:
Add end user information to In-Pod Activity logs
<!-- Please only use this template for submitting feature requests -->

#### What would you like to be added:
Now that we've added the [In-Pod Activity log recorder](https://github.com/kubernetes-sigs/security-profiles-operator/issues/2817), we need to figure out which end user is performing actions in the audit logs.

Right now, the JSON Enricher (which is part of this new recorder) only captures the system user id, not the actual person. For example:
```
{
  "auditID": "d15faf67-1980-44ad-ac1a-b98fbed8bdcf",
  "cmdLine": "touch /tmp/createdbydemoroot ",
  "executable": "/usr/bin/bash",
  "gid": 0,
  "node": {
    "name": "ip-10-0-79-24.ec2.internal"
  },
  "pid": 57281,
  "resource": {
    "container": "container-00",
    "namespace": "default",
    "pod": "ip-10-0-79-24ec2internal-debug-xks4f"
  },
  "syscalls": [
    "execve",
    "getpid"
  ],
  "timestamp": "2025-05-21T04:34:10.000Z",
  "uid": 0,
  "version": "spo/v1_alpha"
}

```
**Here's the problem:** When users exec into a pod or container, they often all share the same system user ID (UID) and group ID (GID)—most commonly, they all appear as the root user. This makes it impossible to tell which specific end user performed actions in the audit logs.

We want to add a way to identify the actual end-user in each audit log entry. However, the Kubernetes API server, which handles the authentication for these exec sessions, doesn't pass the end user's identity to the container itself. It only records that an exec request was made in its own audit logs along with the identity information.

We can enhance the Security Profiles Operator (SPO) to pass end user information into exec sessions. Here's a simplified plan:

1. **Configure SPO:** When the operator is set up, it will install or update a mutating webhook
2. **Inject User Info:** This mutating webhook will be specifically configured for “pods/exec” requests. It will insert two new environment variables into the exec session:
    - REQUEST_UID (containing the user's ID)
    - REQUEST_USERNAME (containing the user's username) 
The original exec command will remain unchanged.
3. **Retrieve User Info**: Linux automatically saves environment variables in a special file (/proc/$$/environ) for each running process. Crucially, this file doesn't change even if the user later modifies their environment variables.
4. **Update Logs**: JSON Enricher will then read this /proc/$$/environ file to extract the REQUEST_UID and REQUEST_USERNAME, adding this vital end user identity to the audit logs.

Another approach: Sending user information directly from the Kubernetes API server to Kubelet. However, we felt that passing user details throughout core Kubernetes components might not be the best design.

Instead, the mutating webhook approach described above is better because it gives the cluster administrator the choice to enable sharing this user information.

#### Why is this needed:
Without this feature it's hard to tie the API server side audit log, container audit log (from JSON enricher) and the end user.

#### User story covered

<!-- Please add a reference to the user story that this feature would cover

The existing user stories exist in docs/user-stores.md

If the user story doesn't exist yet, please add it to the main document
to help people understand what the feature is trying to solve and how it's
meant to help the target personas.

If this would help a target persona that's not currently listed, please add it
to the document in docs/personas.md
 -->


Hints from issue comments:
@haircommander: We just need to make sure that the env variables aren't able to be overridden by the pod spec. we've already checked `/proc/$$/environ` doesn't update if a process updates environ variables, so it can't happen at runtime, but we can't use anything a user could edit without admin access@ngopalak-redhat: @haircommander In the webhook, we have visibility into the environment variables set by the user also. So the webhook should overwrite them if the user attempts to set the same environment variables. We'll have to document the behavior.@haircommander: AFAIU the webhook wouldn't have any power to overwrite them as it's job is done when the pod is running @haircommander: since this feature is used for security auditing the container itself must not be allowed to interfere with the process if a cluster admin enables the feature. documentation cannot be enough it must be impossible to interfere with@ngopalak-redhat: @haircommander I have created a PR for this https://github.com/kubernetes-sigs/security-profiles-operator/pull/2878 .
I tested on openshift with the env override worked fine. Hope it addresses the queries you had above.

Solution patch:
diff --git a/cmd/security-profiles-operator/main.go b/cmd/security-profiles-operator/main.go
index 161d2dab6..7e4f60079 100644
--- a/cmd/security-profiles-operator/main.go
+++ b/cmd/security-profiles-operator/main.go
@@ -75,6 +75,7 @@ import (
 	"sigs.k8s.io/security-profiles-operator/internal/pkg/util"
 	"sigs.k8s.io/security-profiles-operator/internal/pkg/version"
 	"sigs.k8s.io/security-profiles-operator/internal/pkg/webhooks/binding"
+	"sigs.k8s.io/security-profiles-operator/internal/pkg/webhooks/execmetadata"
 	"sigs.k8s.io/security-profiles-operator/internal/pkg/webhooks/recording"
 )
 
@@ -811,6 +812,7 @@ func runWebhook(ctx *cli.Context, info *version.Info) error {
 	hookserver := mgr.GetWebhookServer()
 	binding.RegisterWebhook(hookserver, mgr.GetScheme(), mgr.GetClient())
 	recording.RegisterWebhook(hookserver, mgr.GetScheme(), mgr.GetEventRecorderFor("recording-webhook"), mgr.GetClient())
+	execmetadata.RegisterWebhook(hookserver)
 
 	sigHandler := ctrl.SetupSignalHandler()
 
diff --git a/go.mod b/go.mod
index f169f657d..d391e5a4f 100644
--- a/go.mod
+++ b/go.mod
@@ -33,6 +33,7 @@ require (
 	golang.org/x/mod v0.25.0
 	golang.org/x/net v0.41.0
 	golang.org/x/sync v0.15.0
+	gomodules.xyz/jsonpatch/v2 v2.4.0
 	google.golang.org/grpc v1.73.0
 	google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1
 	google.golang.org/protobuf v1.36.6
@@ -268,7 +269,6 @@ require (
 	golang.org/x/text v0.26.0 // indirect
 	golang.org/x/time v0.12.0 // indirect
 	golang.org/x/tools v0.34.0 // indirect
-	gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
 	google.golang.org/api v0.237.0 // indirect
 	google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 // indirect
 	google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
diff --git a/installation-usage.md b/installation-usage.md
index 7347c7f5f..36e8858b7 100644
--- a/installation-usage.md
+++ b/installation-usage.md
@@ -39,6 +39,7 @@
         - [Audit Log File Fine-Tuning (Rotation)](#audit-log-file-fine-tuning-rotation)
         - [Verbosity (Debugging Logs)](#verbosity-debugging-logs)
     - [How to Monitor Audit Logs for a Specific Pod](#how-to-monitor-audit-logs-for-a-specific-pod)
+    - [Correlating with API Server Audit Log](#correlating-with-api-server-audit-log)
   - [AppArmor Profile](#apparmor-profile)
     - [Record AppArmor profile](#record-apparmor-profile)
     - [Use AppArmor profile](#use-apparmor-profile)
@@ -491,7 +492,7 @@ spec:
 And patch the `spod/spod` instance:
 
 ```shell
-$ kubectl -nsecurity-profiles-operator patch spod spod -p $(cat /tmp/spod-wh.patch) --type=merge
+$ kubectl -n security-profiles-operator patch spod spod -p $(cat /tmp/spod-wh.patch) --type=merge
 ```
 
 To view the resulting `MutatingWebhookConfiguration`, call:
@@ -500,6 +501,9 @@ To view the resulting `MutatingWebhookConfiguration`, call:
 $ kubectl get MutatingWebhookConfiguration spo-mutating-webhook-configuration -oyaml
 ```
 
+The Exec Metadata Webhook works in conjunction with the JSON Log Enricher. It's enabled only when JSON Log Enricher is 
+enabled. For details on its configuration, please refer to the [JSON Log Enricher](#audit-json-log-enricher) section
+
 ## Create and Install Security Profiles
 
 The next sections will describe how to record and install security profiles for a container. The namespace
@@ -1004,6 +1008,48 @@ To enable a single pod log the activity following these steps:
 By following above steps, you can enable and monitor audit logs in JSON lines format for your Kubernetes pods, 
 giving you better visibility into their activities.
 
+#### Correlating with API Server Audit Log
+By default, when you use `kubectl exec` to access a pod or container, Kubernetes doesn't pass the user's authentication
+details into that session's environment. This means JSON Log Enricher can't include "who did what" information 
+for exec commands. The `uid`, `gid` recorded will map to the system user which in most cases would be the root user.
+
+To address this, the JSON Log Enricher relies on a mutating webhook (`execmetadata.spo.io`). This webhook injects 
+the exec request UID as an environment variable into the exec session. Now, when the administrator enables audit 
+logging on the API server, the webhook will add an audit annotation, `SPO_EXEC_REQUEST_UID`. The API server audit log 
+will contain this information. This request ID will also be available in the JSON lines produced by the 
+JSON Log Enricher, specifically within the `requestUID` field.
+
+By default, this webhook is enabled for all the namespaces with JSON Log Enricher is enabled. 
+To reduce the scope of this webhook you can disable it for certain namespaces.
+
+Edit the spod configuration:
+
+```shell
+kubectl edit spod spod -n security-profiles-operator
+```
+
+Add `webhookOptions` to the spec:
+
+Locate the `spec:` section and add the following webhookOptions block. This will tell the webhook to apply to a 
+specific namespaces
+
+```yaml
+# ... (rest of your spod configuration)
+spec:
+webhookOptions:
+- name: execmetadata.spo.io
+  namespaceSelector:
+  #...add rules 
+# ...
+```
+
+After saving your changes, the operator will reconfigure the mutating webhook, allowing request 
+details to be passed into `kubectl exec` sessions cluster-wide.
+
+NOTE: This webhook injects the environment variable `SPO_EXEC_REQUEST_UID` into your exec request. If a container in your Pod 
+already defines an environment variable with this exact name, the webhook's injected value will override it for this
+exec session.
+
 ### AppArmor Profile
 
 Ensure that the spod daemon has AppArmor enabled:
diff --git a/internal/pkg/daemon/enricher/enricher.go b/internal/pkg/daemon/enricher/enricher.go
index d7bf9ca26..fd936fa67 100644
--- a/internal/pkg/daemon/enricher/enricher.go
+++ b/internal/pkg/daemon/enricher/enricher.go
@@ -70,7 +70,7 @@ type Enricher struct {
 // New returns a new Enricher instance.
 func New(logger logr.Logger) *Enricher {
 	return &Enricher{
-		impl:   &defaultImpl{},
+		impl:   newDefaultImpl(),
 		source: auditsource.NewAuditdSource(logger),
 		logger: logger,
 		containerIDCache: ttlcache.New(
diff --git a/internal/pkg/daemon/enricher/enricherfakes/fake_impl.go b/internal/pkg/daemon/enricher/enricherfakes/fake_impl.go
index dab58e225..ba2dcb0a6 100644
--- a/internal/pkg/daemon/enricher/enricherfakes/fake_impl.go
+++ b/internal/pkg/daemon/enricher/enricherfakes/fake_impl.go
@@ -121,6 +121,19 @@ type FakeImpl struct {
 		result2 context.CancelFunc
 		result3 error
 	}
+	EnvForPidStub        func(int) (map[string]string, error)
+	envForPidMutex       sync.RWMutex
+	envForPidArgsForCall []struct {
+		arg1 int
+	}
+	envForPidReturns struct {
+		result1 map[string]string
+		result2 error
+	}
+	envForPidReturnsOnCall map[int]struct {
+		result1 map[string]string
+		result2 error
+	}
 	FlushBacklogStub        func(*ttlcache.Cache[string, []*types.AuditLine], string)
 	flushBacklogMutex       sync.RWMutex
 	flushBacklogArgsForCall []struct {
@@ -737,6 +750,70 @@ func (fake *FakeImpl) DialReturnsOnCall(i int, result1 *grpc.ClientConn, result2
 	}{result1, result2, result3}
 }
 
+func (fake *FakeImpl) EnvForPid(arg1 int) (map[string]string, error) {
+	fake.envForPidMutex.Lock()
+	ret, specificReturn := fake.envForPidReturnsOnCall[len(fake.envForPidArgsForCall)]
+	fake.envForPidArgsForCall = append(fake.envForPidArgsForCall, struct {
+		arg1 int
+	}{arg1})
+	stub := fake.EnvForPidStub
+	fakeReturns := fake.envForPidReturns
+	fake.recordInvocation("EnvForPid", []interface{}{arg1})
+	fake.envForPidMutex.Unlock()
+	if stub != nil {
+		return stub(arg1)
+	}
+	if specificReturn {
+		return ret.result1, ret.result2
+	}
+	return fakeReturns.result1, fakeReturns.result2
+}
+
+func (fake *FakeImpl) EnvForPidCallCount() int {
+	fake.envForPidMutex.RLock()
+	defer fake.envForPidMutex.RUnlock()
+	return len(fake.envForPidArgsForCall)
+}
+
+func (fake *FakeImpl) EnvForPidCalls(stub func(int) (map[string]string, error)) {
+	fake.envForPidMutex.Lock()
+	defer fake.envForPidMutex.Unlock()
+	fake.EnvForPidStub = stub
+}
+
+func (fake *FakeImpl) EnvForPidArgsForCall(i int) int {
+	fake.envForPidMutex.RLock()
+	defer fake.envForPidMutex.RUnlock()
+	argsForCall := fake.envForPidArgsForCall[i]
+	return argsForCall.arg1
+}
+
+func (fake *FakeImpl) EnvForPidReturns(result1 map[string]string, result2 error) {
+	fake.envForPidMutex.Lock()
+	defer fake.envForPidMutex.Unlock()
+	fake.EnvForPidStub = nil
+	fake.envForPidReturns = struct {
+		result1 map[string]string
+		result2 error
+	}{result1, result2}
+}
+
+func (fake *FakeImpl) EnvForPidReturnsOnCall(i int, result1 map[string]string, result2 error) {
+	fake.envForPidMutex.Lock()
+	defer fake.envForPidMutex.Unlock()
+	fake.EnvForPidStub = nil
+	if fake.envForPidReturnsOnCall == nil {
+		fake.envForPidReturnsOnCall = make(map[int]struct {
+			result1 map[string]string
+			result2 error
+		})
+	}
+	fake.envForPidReturnsOnCall[i] = struct {
+		result1 map[string]string
+		result2 error
+	}{result1, result2}
+}
+
 func (fake *FakeImpl) FlushBacklog(arg1 *ttlcache.Cache[string, []*types.AuditLine], arg2 string) {
 	fake.flushBacklogMutex.Lock()
 	fake.flushBacklogArgsForCall = append(fake.flushBacklogArgsForCall, struct {
@@ -1755,6 +1832,8 @@ func (fake *FakeImpl) Invocations() map[string][][]interface{} {
 	defer fake.containerIDForPIDMutex.RUnlock()
 	fake.dialMutex.RLock()
 	defer fake.dialMutex.RUnlock()
+	fake.envForPidMutex.RLock()
+	defer fake.envForPidMutex.RUnlock()
 	fake.flushBacklogMutex.RLock()
 	defer fake.flushBacklogMutex.RUnlock()
 	fake.getFromBacklogMutex.RLock()
diff --git a/internal/pkg/daemon/enricher/impl.go b/internal/pkg/daemon/enricher/impl.go
index 7be5c3b66..fd5e62751 100644
--- a/internal/pkg/daemon/enricher/impl.go
+++ b/internal/pkg/daemon/enricher/impl.go
@@ -18,9 +18,11 @@ package enricher
 
 import (
 	"bufio"
+	"bytes"
 	"context"
 	"fmt"
 	"io"
+	"io/fs"
 	"net"
 	"os"
 	"path/filepath"
@@ -41,7 +43,15 @@ import (
 	"sigs.k8s.io/security-profiles-operator/internal/pkg/util"
 )
 
-type defaultImpl struct{}
+type defaultImpl struct {
+	fsys fs.FS // Must be initialized by newDefaultImpl
+}
+
+func newDefaultImpl() *defaultImpl {
+	return &defaultImpl{
+		fsys: os.DirFS("/"),
+	}
+}
 
 //go:generate go run github.com/maxbrunsfeld/counterfeiter/v6 -generate -header ../../../../hack/boilerplate/boilerplate.generatego.txt
 //counterfeiter:generate . impl
@@ -70,6 +80,7 @@ type impl interface {
 	RemoveAll(string) error
 	CmdlineForPID(pid int) (string, error)
 	PrintJsonOutput(w io.Writer, output string)
+	EnvForPid(pid int) (map[string]string, error)
 }
 
 func (d *defaultImpl) Getenv(key string) string {
@@ -189,9 +200,9 @@ func (d *defaultImpl) CmdlineForPID(
 ) (string, error) {
 	var retErr error
 
-	cmdline := fmt.Sprintf("/proc/%d/cmdline", pid)
+	cmdline := fmt.Sprintf("proc/%d/cmdline", pid)
 
-	file, err := os.Open(filepath.Clean(cmdline))
+	file, err := d.fsys.Open(filepath.Clean(cmdline))
 	if err != nil {
 		retErr = fmt.Errorf("%w: %w", ErrProcessNotFound, err)
 
@@ -221,6 +232,39 @@ func (d *defaultImpl) CmdlineForPID(
 	return sb.String(), retErr
 }
 
+func (d *defaultImpl) EnvForPid(pid int) (map[string]string, error) {
+	var retErr error
+
+	envFile := fmt.Sprintf("proc/%d/environ", pid)
+	envMap := make(map[string]string)
+
+	content, err := fs.ReadFile(d.fsys, filepath.Clean(envFile))
+	if err != nil {
+		retErr = fmt.Errorf("%w: %w", ErrProcessNotFound, err)
+
+		return envMap, retErr
+	}
+
+	envVars := bytes.Split(content, []byte{0})
+
+	for _, envVarBytes := range envVars {
+		envVar := string(envVarBytes)
+		if envVar == "" {
+			continue
+		}
+
+		// Ignore keys with no values
+		parts := strings.SplitN(envVar, "=", 2)
+		if len(parts) == 2 {
+			key := parts[0]
+			value := parts[1]
+			envMap[key] = value
+		}
+	}
+
+	return envMap, nil
+}
+
 func (d *defaultImpl) PrintJsonOutput(w io.Writer, output string) {
 	_, err := fmt.Fprintln(w, output)
 	if err != nil {
diff --git a/internal/pkg/daemon/enricher/jsonenricher.go b/internal/pkg/daemon/enricher/jsonenricher.go
index 3093246fb..19f650669 100644
--- a/internal/pkg/daemon/enricher/jsonenricher.go
+++ b/internal/pkg/daemon/enricher/jsonenricher.go
@@ -118,7 +118,7 @@ func NewJsonEnricherArgs(logger logr.Logger, opts *JsonEnricherOptions) (*JsonEn
 	}
 
 	jsonEnricher := &JsonEnricher{
-		impl:   &defaultImpl{},
+		impl:   newDefaultImpl(),
 		logger: logger,
 		containerIDCache: ttlcache.New(
 			ttlcache.WithTTL[string, string](defaultCacheTimeout),
@@ -402,6 +402,8 @@ func (e *JsonEnricher) dispatchSeccompLine(
 		return
 	}
 
+	// As close as possible to k8s server side audit json
+	// In future map this to a object and produce JSON using marshal/unmarshal functions
 	auditMap := map[string]interface{}{
 		"version":    "spo/v1_alpha",
 		"auditID":    uuid.New().String(),
@@ -416,6 +418,10 @@ func (e *JsonEnricher) dispatchSeccompLine(
 		"timestamp":  isoTimestamp,
 	}
 
+	if logBucket.ProcessInfo.ExecRequestId != nil {
+		auditMap["requestUID"] = *logBucket.ProcessInfo.ExecRequestId
+	}
+
 	auditJson, err := json.Marshal(auditMap)
 	if err != nil {
 		e.logger.Error(err, "unable to output audit line")
diff --git a/internal/pkg/daemon/enricher/process.go b/internal/pkg/daemon/enricher/process.go
index 6a9e85ed5..faae51772 100644
--- a/internal/pkg/daemon/enricher/process.go
+++ b/internal/pkg/daemon/enricher/process.go
@@ -36,6 +36,10 @@ var (
 	ErrCmdlineNotFound = errors.New("cmdline empty or not found for the process")
 )
 
+const (
+	requestIdEnv = "SPO_EXEC_REQUEST_UID"
+)
+
 func GetProcessInfo(
 	pid int, executable string, uid, gid uint32,
 	processCache *ttlcache.Cache[int, *types.ProcessInfo],
@@ -76,7 +80,17 @@ func populateProcessCache(
 
 	cmdLine, err := impl.CmdlineForPID(pid)
 	if err != nil {
-		return err
+		return fmt.Errorf("failed to get cmdline for pid %d: %w", pid, err)
+	}
+
+	env, err := impl.EnvForPid(pid)
+	if err != nil {
+		return fmt.Errorf("failed to get env for pid %d: %w", pid, err)
+	}
+
+	reqId, ok := env[requestIdEnv]
+	if ok {
+		procInfo.ExecRequestId = &reqId
 	}
 
 	procInfo.CmdLine = cmdLine
diff --git a/internal/pkg/daemon/enricher/types/types.go b/internal/pkg/daemon/enricher/types/types.go
index 47851bfd0..4666ebf4d 100644
--- a/internal/pkg/daemon/enricher/types/types.go
+++ b/internal/pkg/daemon/enricher/types/types.go
@@ -16,7 +16,9 @@ limitations under the License.
 
 package types
 
-import "sync"
+import (
+	"sync"
+)
 
 const (
 	AuditTypeSeccomp  = "seccomp"
@@ -62,13 +64,13 @@ type ContainerInfo struct {
 }
 
 type ProcessInfo struct {
-	Pid        int
-	CmdLine    string
-	Uid        uint32
-	Gid        uint32
-	Executable string
+	Pid           int
+	CmdLine       string
+	Uid           uint32
+	Gid           uint32
+	Executable    string
+	ExecRequestId *string
 }
-
 type LogBucket struct {
 	SyscallIds    sync.Map
 	ContainerInfo *ContainerInfo
diff --git a/internal/pkg/manager/spod/bindata/webhook.go b/internal/pkg/manager/spod/bindata/webhook.go
index 38344d970..3478d48b6 100644
--- a/internal/pkg/manager/spod/bindata/webhook.go
+++ b/internal/pkg/manager/spod/bindata/webhook.go
@@ -39,10 +39,9 @@ import (
 var (
 	replicas                int32 = 3
 	defaultMode             int32 = 420
-	failurePolicy                 = admissionregv1.Fail
+	failurePolicyFail             = admissionregv1.Fail
+	failurePolicyIgnore           = admissionregv1.Ignore
 	caBundle                      = []byte("Cg==")
-	bindingPath                   = "/mutate-v1-pod-binding"
-	recordingPath                 = "/mutate-v1-pod-recording"
 	sideEffects                   = admissionregv1.SideEffectClassNone
 	admissionReviewVersions       = []string{"v1beta1"}
 	rules                         = []admissionregv1.RuleWithOperations{
@@ -57,6 +56,18 @@ var (
 			},
 		},
 	}
+	rulesExec = []admissionregv1.RuleWithOperations{
+		{
+			Operations: []admissionregv1.OperationType{
+				"*",
+			},
+			Rule: admissionregv1.Rule{
+				APIGroups:   []string{""},
+				APIVersions: []string{"v1"},
+				Resources:   []string{"pods/exec"},
+			},
+		},
+	}
 	objectSelector = metav1.LabelSelector{
 		MatchExpressions: []metav1.LabelSelectorRequirement{
 			{
@@ -86,6 +97,18 @@ const (
 	webhookServerCert  = "webhook-server-cert"
 )
 
+type webhook struct {
+	index int
+	name  string
+	path  string
+}
+
+var (
+	binding      = webhook{0, "binding.spo.io", "/mutate-v1-pod-binding"}
+	recording    = webhook{1, "recording.spo.io", "/mutate-v1-pod-recording"}
+	execMetadata = webhook{2, "execmetadata.spo.io", "/mutate-v1-exec-metadata"}
+)
+
 type Webhook struct {
 	log        logr.Logger
 	deployment *appsv1.Deployment
@@ -102,6 +125,7 @@ func GetWebhook(
 	caInjectType CAInjectType,
 	tolerations []corev1.Toleration,
 	imagePullSecrets []corev1.LocalObjectReference,
+	execMetadataWebhookEnabled bool,
 ) *Webhook {
 	deployment := webhookDeployment.DeepCopy()
 	deployment.Namespace = namespace
@@ -118,10 +142,14 @@ func GetWebhook(
 	ctr.Image = image
 	ctr.ImagePullPolicy = pullPolicy
 
-	cfg := webhookConfig.DeepCopy()
+	cfg := getWebhookConfig(execMetadataWebhookEnabled).DeepCopy()
 	cfg.Namespace = namespace
-	cfg.Webhooks[0].ClientConfig.Service.Namespace = namespace
-	cfg.Webhooks[1].ClientConfig.Service.Namespace = namespace
+	cfg.Webhooks[binding.index].ClientConfig.Service.Namespace = namespace
+	cfg.Webhooks[recording.index].ClientConfig.Service.Namespace = namespace
+
+	if execMetadataWebhookEnabled {
+		cfg.Webhooks[execMetadata.index].ClientConfig.Service.Namespace = namespace
+	}
 
 	service := webhookService.DeepCopy()
 	service.Namespace = namespace
@@ -211,6 +239,10 @@ func (w *Webhook) NeedsUpdate(ctx context.Context, c client.Client) (bool, error
 	}
 
 	if len(existingWebHook.Webhooks) != len(w.config.Webhooks) {
+		w.log.V(1).Info("updating webhook configuration",
+			"len(existingWebHook)", len(existingWebHook.Webhooks),
+			"len(config)", len(w.config.Webhooks))
+
 		return true, nil
 	}
 
@@ -224,7 +256,11 @@ func (w *Webhook) NeedsUpdate(ctx context.Context, c client.Client) (bool, error
 				continue
 			}
 
-			if webhookNeedsUpdate(&ew, &cw) {
+			if w.webhookNeedsUpdate(&ew, j) {
+				w.log.V(1).Info("updating webhook configuration",
+					"configName", cw.Name,
+					"existingName", ew.Name)
+
 				return true, nil
 			}
 		}
@@ -234,47 +270,96 @@ func (w *Webhook) NeedsUpdate(ctx context.Context, c client.Client) (bool, error
 }
 
 // only compare the settings that are tunable in spod now.
-func webhookNeedsUpdate(existing, configured *admissionregv1.MutatingWebhook) bool {
+func (w *Webhook) webhookNeedsUpdate(existing *admissionregv1.MutatingWebhook, index int) bool {
+	configured := w.config.Webhooks[index]
+
+	// comparing pointers, not values
 	if existing.FailurePolicy == nil && configured.FailurePolicy != nil ||
 		existing.FailurePolicy != nil && configured.FailurePolicy == nil {
-		// comparing pointers, not values
+		w.log.V(1).Info("updating webhook configuration",
+			"existing FailurePolicy", existing.FailurePolicy,
+			"configured FailurePolicy", configured.FailurePolicy)
+
 		return true
 	}
 
+	// comparing values this time
 	if existing.FailurePolicy != nil &&
 		configured.FailurePolicy != nil &&
 		*existing.FailurePolicy != *configured.FailurePolicy {
-		// comparing values this time
+		w.log.V(1).Info("updating webhook configuration",
+			"existing FailurePolicy", existing.FailurePolicy,
+			"configured FailurePolicy", configured.FailurePolicy)
+
 		return true
 	}
 
-	if existing.NamespaceSelector == nil && configured.NamespaceSelector != nil ||
-		existing.NamespaceSelector != nil && configured.NamespaceSelector == nil {
-		// comparing pointers, not values
+	// Both nil and empty object selector are equal
+	// Some platform set the Namespace selector as empty when nil is configured
+	if existing.NamespaceSelector != nil && configured.NamespaceSelector == nil {
+		if existing.NamespaceSelector.Size() > 0 {
+			w.log.V(1).Info("updating webhook configuration",
+				"existing ObjectSelector", existing.ObjectSelector,
+				"configured ObjectSelector", configured.ObjectSelector)
+
+			return true
+		}
+	}
+
+	// comparing pointers, not values
+	if existing.NamespaceSelector == nil && configured.NamespaceSelector != nil {
+		w.log.V(1).Info("updating webhook configuration",
+			"existing NamespaceSelector", existing.NamespaceSelector,
+			"configured NamespaceSelector", configured.NamespaceSelector)
+
 		return true
 	}
 
+	// Only compare managed labels, all others are out of scope
 	if existing.NamespaceSelector != nil && configured.NamespaceSelector != nil {
-		// Only compare managed labels, all others are out of scope
 		for _, label := range []string{EnableBindingLabel, EnableRecordingLabel} {
 			if namespaceSelectorUnequalForLabel(label, existing.NamespaceSelector, configured.NamespaceSelector) {
+				w.log.V(1).Info("updating webhook configuration",
+					"existing NamespaceSelector", existing.NamespaceSelector,
+					"configured NamespaceSelector", configured.NamespaceSelector)
+
 				return true
 			}
 		}
 	}
 
-	if existing.ObjectSelector == nil && configured.ObjectSelector != nil ||
-		existing.ObjectSelector != nil && configured.ObjectSelector == nil {
-		// comparing pointers, not values
+	// Both nil and empty object selector are equal
+	if existing.ObjectSelector != nil && configured.ObjectSelector == nil {
+		if existing.ObjectSelector.Size() > 0 {
+			w.log.V(1).Info("updating webhook configuration",
+				"existing ObjectSelector", existing.ObjectSelector,
+				"configured ObjectSelector", configured.ObjectSelector)
+
+			return true
+		}
+	}
+
+	// comparing pointers, not values
+	if existing.ObjectSelector == nil && configured.ObjectSelector != nil {
+		w.log.V(1).Info("updating webhook configuration",
+			"existing ObjectSelector", existing.ObjectSelector,
+			"configured ObjectSelector", configured.ObjectSelector)
+
 		return true
 	}
 
 	if existing.ObjectSelector != nil &&
 		configured.ObjectSelector != nil &&
 		!reflect.DeepEqual(*existing.ObjectSelector, *configured.ObjectSelector) {
+		w.log.V(1).Info("updating webhook configuration",
+			"existing ObjectSelector", existing.ObjectSelector,
+			"configured ObjectSelector", configured.ObjectSelector)
+
 		return true
 	}
 
+	w.log.V(1).Info("webbhook does not need update")
+
 	return false
 }
 
@@ -333,6 +418,82 @@ func (w *Webhook) objectMap() map[string]client.Object {
 	}
 }
 
+// getWebhookConfig returns the webhooks in the order binding, recording and execMetadata.
+func getWebhookConfig(execMetadataWebhookEnabled bool) *admissionregv1.MutatingWebhookConfiguration {
+	webhooks := []admissionregv1.MutatingWebhook{
+		{
+			Name:           binding.name,
+			FailurePolicy:  &failurePolicyFail,
+			SideEffects:    &sideEffects,
+			Rules:          rules,
+			ObjectSelector: &objectSelector,
+			NamespaceSelector: &metav1.LabelSelector{
+				MatchExpressions: []metav1.LabelSelectorRequirement{
+					{
+						Key:      EnableBindingLabel,
+						Operator: metav1.LabelSelectorOpExists,
+					},
+				},
+			},
+			ClientConfig: admissionregv1.WebhookClientConfig{
+				CABundle: caBundle,
+				Service: &admissionregv1.ServiceReference{
+					Name: serviceName,
+					Path: &binding.path,
+				},
+			},
+			AdmissionReviewVersions: admissionReviewVersions,
+		},
+		{
+			Name:           recording.name,
+			FailurePolicy:  &failurePolicyFail,
+			SideEffects:    &sideEffects,
+			Rules:          rules,
+			ObjectSelector: &objectSelector,
+			NamespaceSelector: &metav1.LabelSelector{
+				MatchExpressions: []metav1.LabelSelectorRequirement{
+					{
+						Key:      EnableRecordingLabel,
+						Operator: metav1.LabelSelectorOpExists,
+					},
+				},
+			},
+			ClientConfig: admissionregv1.WebhookClientConfig{
+				CABundle: caBundle,
+				Service: &admissionregv1.ServiceReference{
+					Name: serviceName,
+					Path: &recording.path,
+				},
+			},
+			AdmissionReviewVersions: admissionReviewVersions,
+		},
+	}
+
+	if execMetadataWebhookEnabled {
+		webhooks = append(webhooks, admissionregv1.MutatingWebhook{
+			Name:          execMetadata.name,
+			FailurePolicy: &failurePolicyIgnore,
+			SideEffects:   &sideEffects,
+			Rules:         rulesExec,
+			ClientConfig: admissionregv1.WebhookClientConfig{
+				CABundle: caBundle,
+				Service: &admissionregv1.ServiceReference{
+					Name: serviceName,
+					Path: &execMetadata.path,
+				},
+			},
+			AdmissionReviewVersions: admissionReviewVersions,
+		})
+	}
+
+	return &admissionregv1.MutatingWebhookConfiguration{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: webhookConfigName,
+		},
+		Webhooks: webhooks,
+	}
+}
+
 var webhookDeployment = &appsv1.Deployment{
 	ObjectMeta: metav1.ObjectMeta{
 		Name: webhookName,
@@ -433,60 +594,6 @@ var webhookDeployment = &appsv1.Deployment{
 	},
 }
 
-var webhookConfig = &admissionregv1.MutatingWebhookConfiguration{
-	ObjectMeta: metav1.ObjectMeta{
-		Name: webhookConfigName,
-	},
-	Webhooks: []admissionregv1.MutatingWebhook{
-		{
-			Name:           "binding.spo.io",
-			FailurePolicy:  &failurePolicy,
-			SideEffects:    &sideEffects,
-			Rules:          rules,
-			ObjectSelector: &objectSelector,
-			NamespaceSelector: &metav1.LabelSelector{
-				MatchExpressions: []metav1.LabelSelectorRequirement{
-					{
-						Key:      EnableBindingLabel,
-						Operator: metav1.LabelSelectorOpExists,
-					},
-				},
-			},
-			ClientConfig: admissionregv1.WebhookClientConfig{
-				CABundle: caBundle,
-				Service: &admissionregv1.ServiceReference{
-					Name: serviceName,
-					Path: &bindingPath,
-				},
-			},
-			AdmissionReviewVersions: admissionReviewVersions,
-		},
-		{
-			Name:           "recording.spo.io",
-			FailurePolicy:  &failurePolicy,
-			SideEffects:    &sideEffects,
-			Rules:          rules,
-			ObjectSelector: &objectSelector,
-			NamespaceSelector: &metav1.LabelSelector{
-				MatchExpressions: []metav1.LabelSelectorRequirement{
-					{
-						Key:      EnableRecordingLabel,
-						Operator: metav1.LabelSelectorOpExists,
-					},
-				},
-			},
-			ClientConfig: admissionregv1.WebhookClientConfig{
-				CABundle: caBundle,
-				Service: &admissionregv1.ServiceReference{
-					Name: serviceName,
-					Path: &recordingPath,
-				},
-			},
-			AdmissionReviewVersions: admissionReviewVersions,
-		},
-	},
-}
-
 var webhookService = &corev1.Service{
 	ObjectMeta: metav1.ObjectMeta{
 		Name:   serviceName,
diff --git a/internal/pkg/manager/spod/spod_controller.go b/internal/pkg/manager/spod/spod_controller.go
index 90e7c6106..e3270e2f8 100644
--- a/internal/pkg/manager/spod/spod_controller.go
+++ b/internal/pkg/manager/spod/spod_controller.go
@@ -181,9 +181,7 @@ func (r *ReconcileSPOd) Reconcile(ctx context.Context, req reconcile.Request) (r
 	}
 
 	configuredSPOd := r.getConfiguredSPOd(spod, image, pullPolicy, caInjectType)
-
-	webhook := bindata.GetWebhook(r.log, r.namespace, spod.Spec.WebhookOpts, image,
-		pullPolicy, caInjectType, spod.Spec.Tolerations, spod.Spec.ImagePullSecrets)
+	webhook := r.getConfiguredWebook(spod, image, pullPolicy, caInjectType)
 	metricsService := bindata.GetMetricsService(r.namespace, caInjectType)
 	serviceMonitor := bindata.ServiceMonitor(caInjectType)
 
@@ -797,6 +795,17 @@ func (r *ReconcileSPOd) getConfiguredJsonEnricher(cfg *spodv1alpha1.SecurityProf
 	}
 }
 
+// getConfiguredWebook gets a fully configured webhook instance from a desired
+// configuration and the reference base SPOd.
+func (r *ReconcileSPOd) getConfiguredWebook(cfg *spodv1alpha1.SecurityProfilesOperatorDaemon,
+	image string, pullPolicy corev1.PullPolicy, caInjectType bindata.CAInjectType,
+) *bindata.Webhook {
+	webhook := bindata.GetWebhook(r.log, r.namespace, cfg.Spec.WebhookOpts, image,
+		pullPolicy, caInjectType, cfg.Spec.Tolerations, cfg.Spec.ImagePullSecrets, isJsonEnricherEnabled(cfg))
+
+	return webhook
+}
+
 func isLogEnricherEnabled(cfg *spodv1alpha1.SecurityProfilesOperatorDaemon) bool {
 	enableLogEnricherEnv, err := strconv.ParseBool(os.Getenv(config.EnableLogEnricherEnvKey))
 	if err != nil {
diff --git a/internal/pkg/webhooks/execmetadata/execmetadata.go b/internal/pkg/webhooks/execmetadata/execmetadata.go
new file mode 100644
index 000000000..8ce32e629
--- /dev/null
+++ b/internal/pkg/webhooks/execmetadata/execmetadata.go
@@ -0,0 +1,110 @@
+/*
+Copyright 2025 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package execmetadata
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"regexp"
+	"slices"
+
+	"github.com/go-logr/logr"
+	"gomodules.xyz/jsonpatch/v2"
+	corev1 "k8s.io/api/core/v1"
+	logf "sigs.k8s.io/controller-runtime/pkg/log"
+	"sigs.k8s.io/controller-runtime/pkg/webhook"
+	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
+)
+
+const (
+	ExecRequestUid = "SPO_EXEC_REQUEST_UID"
+)
+
+type Handler struct {
+	log logr.Logger
+}
+
+// Ensure ExecMetadataHandler implements admission.Handler at compile time.
+var _ admission.Handler = (*Handler)(nil)
+
+//nolint:gocritic
+func (p Handler) Handle(_ context.Context, req admission.Request) admission.Response {
+	p.log.V(1).Info("Executing execmetadata webhook")
+
+	execObject := corev1.PodExecOptions{}
+
+	if err := json.Unmarshal(req.Object.Raw, &execObject); err != nil {
+		p.log.Error(err, "unmarshal pod exec request")
+
+		return admission.Allowed("pod exec request unmodified")
+	}
+
+	p.log.V(1).Info("execObject before mutate", "execObject", execObject)
+
+	execObject.Command = removeRegexMatches(execObject.Command,
+		ExecRequestUid+"=.*")
+
+	execObject.Command = slices.Insert(execObject.Command, 0, "env",
+		fmt.Sprintf("%s=%s", ExecRequestUid, req.UID))
+
+	p.log.V(1).Info("execObject after mutate", "execObject", execObject)
+
+	jsonPathOps := []jsonpatch.JsonPatchOperation{
+		{
+			Operation: "add",
+			Path:      "/command",
+			Value:     execObject.Command,
+		},
+	}
+
+	resp := admission.Patched("UID added to execmetadata", jsonPathOps...)
+
+	// The RequestUid will be used to correlate the log from container and API server
+	resp.AuditAnnotations = map[string]string{
+		ExecRequestUid: string(req.UID),
+	}
+
+	p.log.V(1).Info("response sent", "resp", resp)
+
+	return resp
+}
+
+func removeRegexMatches(slice []string, pattern string) []string {
+	re := regexp.MustCompile(pattern)
+
+	var result []string
+
+	for _, s := range slice {
+		if !re.MatchString(s) {
+			result = append(result, s)
+		}
+	}
+
+	return result
+}
+
+func RegisterWebhook(server webhook.Server) {
+	server.Register(
+		"/mutate-v1-exec-metadata",
+		&webhook.Admission{
+			Handler: &Handler{
+				log: logf.Log.WithName("execmetadata"),
+			},
+		},
+	)
+}



Test patch:
diff --git a/internal/pkg/daemon/enricher/impl_test.go b/internal/pkg/daemon/enricher/impl_test.go
new file mode 100644
index 000000000..8f8b74c1c
--- /dev/null
+++ b/internal/pkg/daemon/enricher/impl_test.go
@@ -0,0 +1,140 @@
+/*
+Copyright 2025 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package enricher
+
+import (
+	"testing"
+	"testing/fstest"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestDefaultImpl_EnvForPid(t *testing.T) {
+	t.Parallel()
+
+	for _, tc := range []struct {
+		prepare func() *fstest.MapFS
+		assert  func(env map[string]string, retErr error)
+	}{
+		{
+			// Test the best case
+			prepare: func() *fstest.MapFS {
+				return &fstest.MapFS{
+					"proc/1234/environ": {Data: []byte("hello=world")},
+				}
+			},
+			assert: func(env map[string]string, retErr error) {
+				require.NoError(t, retErr)
+				require.Equal(t, "world", env["hello"])
+			},
+		},
+		{
+			// Test key with empty value
+			prepare: func() *fstest.MapFS {
+				return &fstest.MapFS{
+					"proc/1234/environ": {Data: []byte("hello=")},
+				}
+			},
+			assert: func(env map[string]string, retErr error) {
+				require.NoError(t, retErr)
+				require.Len(t, env, 1)
+			},
+		},
+		{
+			// Test keys with no equals sign
+			prepare: func() *fstest.MapFS {
+				return &fstest.MapFS{
+					"proc/1234/environ": {Data: []byte("hello")},
+				}
+			},
+			assert: func(env map[string]string, retErr error) {
+				require.NoError(t, retErr)
+				require.Empty(t, env)
+			},
+		},
+		{
+			// Test empty lines
+			prepare: func() *fstest.MapFS {
+				return &fstest.MapFS{
+					"proc/1234/environ": {Data: []byte("hello=world\x00\x00\x00test1=test2")},
+				}
+			},
+			assert: func(env map[string]string, retErr error) {
+				require.NoError(t, retErr)
+				require.Equal(t, "world", env["hello"])
+				require.Equal(t, "test2", env["test1"])
+				require.Len(t, env, 2)
+			},
+		},
+		{
+			// Test incorrect file
+			prepare: func() *fstest.MapFS {
+				return &fstest.MapFS{
+					"proc/unknown/environ": {Data: []byte("hello=world")},
+				}
+			},
+			assert: func(env map[string]string, retErr error) {
+				require.Error(t, retErr)
+			},
+		},
+	} {
+		testImpl := defaultImpl{
+			fsys: tc.prepare(),
+		}
+		env, err := testImpl.EnvForPid(1234)
+		tc.assert(env, err)
+	}
+}
+
+func TestDefaultImpl_CmdlineForPID(t *testing.T) {
+	t.Parallel()
+
+	for _, tc := range []struct {
+		prepare func() *fstest.MapFS
+		assert  func(cmd string, retErr error)
+	}{
+		{
+			// Test the best case
+			prepare: func() *fstest.MapFS {
+				return &fstest.MapFS{
+					"proc/1234/cmdline": {Data: []byte("/sh")},
+				}
+			},
+			assert: func(cmd string, retErr error) {
+				require.NoError(t, retErr)
+				require.Equal(t, "/sh", cmd)
+			},
+		},
+		{
+			// Test incorrect file
+			prepare: func() *fstest.MapFS {
+				return &fstest.MapFS{
+					"proc/unknown/environ": {Data: []byte("/sh")},
+				}
+			},
+			assert: func(cmd string, retErr error) {
+				require.Error(t, retErr)
+			},
+		},
+	} {
+		testImpl := defaultImpl{
+			fsys: tc.prepare(),
+		}
+		cmd, err := testImpl.CmdlineForPID(1234)
+		tc.assert(cmd, err)
+	}
+}
diff --git a/internal/pkg/manager/spod/bindata/webhook_test.go b/internal/pkg/manager/spod/bindata/webhook_test.go
index f5c05e175..8478935ca 100644
--- a/internal/pkg/manager/spod/bindata/webhook_test.go
+++ b/internal/pkg/manager/spod/bindata/webhook_test.go
@@ -19,7 +19,10 @@ package bindata
 import (
 	"testing"
 
+	"github.com/go-logr/logr"
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	admissionregv1 "k8s.io/api/admissionregistration/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 )
 
@@ -112,3 +115,116 @@ func TestNamespaceSelectorUnequalForLabel(t *testing.T) {
 		})
 	}
 }
+
+func TestWebhook_NeedsUpdate(t *testing.T) {
+	t.Parallel()
+
+	for _, tc := range []struct {
+		name                 string
+		existing, configured *admissionregv1.MutatingWebhook
+		expected             bool
+	}{
+		{
+			name:       "label not available in both selectors",
+			existing:   &admissionregv1.MutatingWebhook{},
+			configured: &admissionregv1.MutatingWebhook{},
+			expected:   false,
+		},
+		{
+			name: "Some content in object select and empty",
+			existing: &admissionregv1.MutatingWebhook{
+				Name: "foo",
+				ObjectSelector: &metav1.LabelSelector{
+					MatchExpressions: []metav1.LabelSelectorRequirement{
+						{
+							Key:      testLabel,
+							Operator: metav1.LabelSelectorOpExists,
+							Values:   []string{"val"},
+						},
+					},
+				},
+			},
+			configured: &admissionregv1.MutatingWebhook{
+				Name:           "foo",
+				ObjectSelector: &metav1.LabelSelector{},
+			},
+			expected: true,
+		},
+		{
+			name: "Empty existing and nil. Required to handle defaults",
+			existing: &admissionregv1.MutatingWebhook{
+				Name:           "foo",
+				ObjectSelector: &metav1.LabelSelector{},
+			},
+			configured: &admissionregv1.MutatingWebhook{
+				Name:           "foo",
+				ObjectSelector: nil,
+			},
+			expected: false,
+		},
+		{
+			name: "Nil existing and empty",
+			existing: &admissionregv1.MutatingWebhook{
+				Name:           "foo",
+				ObjectSelector: nil,
+			},
+			configured: &admissionregv1.MutatingWebhook{
+				Name:           "foo",
+				ObjectSelector: &metav1.LabelSelector{},
+			},
+			expected: true,
+		},
+		{
+			name: "Nil existing and empty",
+			existing: &admissionregv1.MutatingWebhook{
+				Name:              "foo",
+				NamespaceSelector: nil,
+			},
+			configured: &admissionregv1.MutatingWebhook{
+				Name:              "foo",
+				NamespaceSelector: &metav1.LabelSelector{},
+			},
+			expected: true,
+		},
+		{
+			name: "existing empty and nil",
+			existing: &admissionregv1.MutatingWebhook{
+				Name:              "foo",
+				NamespaceSelector: &metav1.LabelSelector{},
+			},
+			configured: &admissionregv1.MutatingWebhook{
+				Name:              "foo",
+				NamespaceSelector: nil,
+			},
+			expected: false,
+		},
+	} {
+		existing := tc.existing
+		configured := tc.configured
+		expected := tc.expected
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
+			w := Webhook{
+				log: logr.Discard(),
+				config: &admissionregv1.MutatingWebhookConfiguration{
+					Webhooks: []admissionregv1.MutatingWebhook{
+						*configured,
+					},
+				},
+			}
+			requireUpdate := w.webhookNeedsUpdate(existing, 0)
+			assert.Equal(t, expected, requireUpdate)
+		})
+	}
+}
+
+func TestWebhook_getWebhookConfig(t *testing.T) {
+	t.Parallel()
+
+	webhookConfig := getWebhookConfig(false)
+	require.Len(t, webhookConfig.Webhooks, 2)
+
+	webhookConfig = getWebhookConfig(true)
+	require.Len(t, webhookConfig.Webhooks, 3)
+}
diff --git a/internal/pkg/webhooks/execmetadata/execmetadata_test.go b/internal/pkg/webhooks/execmetadata/execmetadata_test.go
new file mode 100644
index 000000000..d5779182c
--- /dev/null
+++ b/internal/pkg/webhooks/execmetadata/execmetadata_test.go
@@ -0,0 +1,121 @@
+/*
+Copyright 2025 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package execmetadata
+
+import (
+	"encoding/json"
+	"strings"
+	"testing"
+
+	"github.com/go-logr/logr"
+	"github.com/stretchr/testify/require"
+	admissionv1 "k8s.io/api/admission/v1"
+	corev1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
+)
+
+func getAdmRequest(t *testing.T, execOpts *corev1.PodExecOptions) admissionv1.AdmissionRequest {
+	t.Helper()
+
+	return admissionv1.AdmissionRequest{
+		Object: runtime.RawExtension{
+			Raw: func() []byte {
+				b, err := json.Marshal(execOpts.DeepCopy())
+				require.NoError(t, err)
+
+				return b
+			}(),
+		},
+	}
+}
+
+func TestHandlePodExecWithContainer(t *testing.T) {
+	t.Parallel()
+
+	testExecCall := &corev1.PodExecOptions{
+		Container: "test",
+		Command:   []string{"echo", "hello"},
+		Stdin:     true,
+		Stdout:    true,
+		Stderr:    true,
+		TTY:       true,
+	}
+
+	handler := Handler{logr.Discard()}
+	resp := handler.Handle(t.Context(), admission.Request{
+		AdmissionRequest: getAdmRequest(t, testExecCall),
+	})
+
+	if !strings.Contains(resp.Patches[0].Json(), ExecRequestUid) {
+		t.Errorf("Expected the response to contain " + ExecRequestUid)
+	}
+}
+
+func TestHandlePodExecWithoutContainer(t *testing.T) {
+	t.Parallel()
+
+	testExecCall := &corev1.PodExecOptions{
+		Command: []string{"echo", "hello"},
+		Stdin:   true,
+		Stdout:  true,
+		Stderr:  true,
+		TTY:     true,
+	}
+
+	handler := Handler{logr.Discard()}
+	resp := handler.Handle(t.Context(), admission.Request{
+		AdmissionRequest: getAdmRequest(t, testExecCall),
+	})
+
+	if !strings.Contains(resp.Patches[0].Json(), ExecRequestUid) {
+		t.Errorf("Expected the response to contain " + ExecRequestUid)
+	}
+}
+
+func TestHandlePodExecMulUid(t *testing.T) {
+	t.Parallel()
+
+	testExecCall := &corev1.PodExecOptions{
+		Command: []string{
+			ExecRequestUid + "=overwriteid",
+		},
+		Stdin:  true,
+		Stdout: true,
+		Stderr: true,
+		TTY:    true,
+	}
+
+	handler := Handler{logr.Discard()}
+	resp := handler.Handle(t.Context(), admission.Request{
+		AdmissionRequest: getAdmRequest(t, testExecCall),
+	})
+
+	if !strings.Contains(resp.Patches[0].Json(), ExecRequestUid) {
+		t.Errorf("Expected the response to contain " + ExecRequestUid)
+	}
+
+	if strings.Count(resp.Patches[0].Json(), ExecRequestUid) > 1 {
+		t.Errorf("Expected only one environment variable" + ExecRequestUid)
+	}
+
+	if strings.Count(resp.Patches[0].Json(), "overwriteid") > 1 {
+		t.Errorf("Expected overwriteid to be revomed")
+	}
+
+	require.Contains(t, resp.AuditAnnotations, ExecRequestUid)
+}
diff --git a/test/suite_test.go b/test/suite_test.go
index a2f118c30..a01c975bc 100644
--- a/test/suite_test.go
+++ b/test/suite_test.go
@@ -793,6 +793,10 @@ func (e *e2e) enableJsonEnricherInSpod() {
 	time.Sleep(defaultWaitTime)
 	e.waitInOperatorNSFor("condition=ready", "spod", "spod")
 
+	e.kubectlOperatorNS("get", "pods")
+
+	e.logf("Done waiting for the rollout restart")
+
 	e.kubectlOperatorNS("rollout", "status", "ds", "spod", "--timeout", defaultLogEnricherOpTimeout)
 }
 
diff --git a/test/tc_json_enricher_test.go b/test/tc_json_enricher_test.go
index 8a9856b19..331155e4d 100644
--- a/test/tc_json_enricher_test.go
+++ b/test/tc_json_enricher_test.go
@@ -126,10 +126,10 @@ spec:
   syscalls:
   - action: SCMP_ACT_LOG
     names:
-    - listen
     - execve
     - clone
-    - getpid
+    - fork
+    - execveat
 `, profileName)
 
 	profileCleanup := e.writeAndCreate(profile, "test-profile-*.yaml")
@@ -181,13 +181,19 @@ spec:
 		time.Sleep(5 * time.Second)
 	}
 
-	e.kubectl("exec", podName, "--", "ls", "/")
+	e.kubectl("exec", "-it", podName, "--", "ls")
+	e.kubectl("exec", "-it", podName, "--", "date")
+	envOutput := e.kubectl("exec", "-it", podName, "--", "env")
+	e.Contains(envOutput, "SPO_EXEC_REQUEST_UID")
 
 	// wait for at least one component of the expected logs to appear
-	e.waitForJsonEnricherLogs(since, regexp.MustCompile(`(?m)"syscallName"="listen|execve|clone|getpid"`))
+	e.waitForJsonEnricherLogs(since, regexp.MustCompile(`(?m)"syscalls":"execve|clone"`))
 
-	e.logf("Wait for the audit lines to come within 60 seconds")
-	time.Sleep(60 * time.Second)
+	e.logf("Wait for the audit lines to come within 30 seconds")
+	time.Sleep(30 * time.Second)
+	e.kubectlOperatorNS("logs", "-l", "name=spod", "-c", "json-enricher")
+	e.logf("Another wait for the audit lines to come within 30 seconds")
+	time.Sleep(30 * time.Second)
 	e.logf("Checking JSON enricher output")
 	output := e.kubectlOperatorNS("logs", "-l", "name=spod", "-c", "json-enricher")
 
diff --git a/test/tc_log_enricher_test.go b/test/tc_log_enricher_test.go
index 479131cff..faa48b5b7 100644
--- a/test/tc_log_enricher_test.go
+++ b/test/tc_log_enricher_test.go
@@ -96,6 +96,10 @@ spec:
 		time.Sleep(5 * time.Second)
 	}
 
+	// Make sure that Exec webhook is not enabled unless JSON Log Enricher is used
+	envOutput := e.kubectl("exec", "-it", podName, "--", "env")
+	e.NotContains(envOutput, "SPO_EXEC_REQUEST_UID")
+
 	// wait for at least one component of the expected logs to appear
 	e.waitForEnricherLogs(since, regexp.MustCompile(`(?m)"syscallName"="listen"`))