Kubernetes incident response
Cut identity, preserve, hunt persistence, trust.
A Falco alert lands at 2 a.m. Falco is the tool that watches for weird behavior inside running containers, and right now it's telling you a shell just spawned inside a pod named payments-api-7c9. A shell means someone, or something, has a live command prompt running inside your production workload. Seconds later that same pod started looking up a web address your DNS logs (the record of every domain your systems try to reach) have never seen before. That's a live compromise. What you do in the next ten minutes decides whether you learn how they got in, or whether you delete the only copy of the evidence by accident.
Incident response on a cluster pulls you two ways at once. You want to stop the attacker this second, and you also want to understand what they did, and those two urges fight each other. Kill the pod and the network traffic stops, sure, but so does the process memory, the open connections, and whatever the attacker staged in /tmp. All gone. So work like a careful investigator, not a bouncer. Seal the room so nobody gets in or out. Photograph the scene before you touch a thing. Then change the locks. In Kubernetes that maps to four moves, and the order matters: isolate the pod, freeze the node, snapshot for forensics, and revoke the identity.
Seal the room without killing the witness
A NetworkPolicy is a firewall you write as a Kubernetes object. It picks pods by their labels and spells out what traffic is allowed in and what's allowed out. Most clusters ship wide open, allow-all by default, so a policy that selects your bad pod and permits nothing at all turns into a hard quarantine. No more talking to the database. No more beaconing home, which is the malware term for quietly calling back to the attacker's server. No reaching the pod running right next to it. And the pod keeps running, which is the whole point. It's boxed in but still alive, so its memory and its file system sit there waiting for you to inspect them. Leave out the ingress and egress rules entirely and you've said deny everything.
apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata:name: quarantine-payments-7c9namespace: prodspec:podSelector:matchLabels:quarantine: "isolate"policyTypes:- Ingress- Egress
kubectl label pod payments-api-7c9 quarantine=isolate -n prod --overwritekubectl apply -f quarantine.yaml
pod/payments-api-7c9 labelednetworkpolicy.networking.k8s.io/quarantine-payments-7c9 created
Now freeze the node. kubectl cordon marks a node unschedulable, so the scheduler quits placing new pods on it while you work. Resist the urge to reach for kubectl drain. Drain evicts every pod on the node, the compromised one included, and eviction deletes it. That's bulldozing the crime scene with the evidence still inside. Cordon holds the node still and touches nothing. Drain comes later, after you've captured everything worth keeping.
kubectl cordon node-17kubectl get node node-17
node/node-17 cordonedNAME STATUS ROLES AGE VERSIONnode-17 Ready,SchedulingDisabled <none> 88d v1.31.4
Photograph the scene
With the pod boxed in, grab its state before any of it changes. Three things matter here. First, the node's disk, so you hang on to the container file system and anything the attacker wrote outside it. That snapshot is a cloud operation on the underlying volume, an Elastic Block Store (EBS) snapshot on AWS or a Persistent Disk snapshot on Google Cloud, and you take it from the provider, not from kubectl. Second, the pod's logs, and pull the crashed prior instance too with --previous. Third, the audit log, which is how you piece together what the stolen identity actually did.
kubectl logs payments-api-7c9 -n prod --previous > payments-7c9.logaws ec2 create-snapshot --volume-id vol-0ab1234cd5678ef90 --description "IR payments-7c9 node-17"
{"SnapshotId": "snap-0f3e9c7b1d2a4e5f6","State": "pending","VolumeId": "vol-0ab1234cd5678ef90","StartTime": "2026-07-16T02:14:07+00:00"}
The API audit log is the running record of every request the API server handled, who made it and what they asked for. If you don't have audit logging switched on, that's the finding to fix the moment this incident is over, because without it you're just guessing. Pull every call the compromised service account made and read it back as a timeline. jq lets you filter that stream of JSON down to one identity and just the fields you care about.
jq -c 'select(.user.username=="system:serviceaccount:prod:payments") | {t:.requestReceivedTimestamp, verb, res:.objectRef.resource, name:.objectRef.name}' /var/log/kubernetes/audit.log
{"t":"2026-07-16T01:52:03Z","verb":"list","res":"secrets","name":null}{"t":"2026-07-16T01:52:44Z","verb":"create","res":"pods","name":"cryptominer-x"}{"t":"2026-07-16T01:53:10Z","verb":"create","res":"clusterrolebindings","name":"cluster-admin-backdoor"}
There's the entire story in three lines. The token listed every Secret in the namespace, launched a miner, then wrote itself a ClusterRoleBinding for persistence so it holds cluster-admin even after you kill the pod. A ClusterRoleBinding is the object that grants a role to an identity across the whole cluster, and cluster-admin is the top one, full control over everything. That binding just went on your eradication list.
Change the locks
Now you revoke the identity, and this is the part people get wrong. A service account (SA) is the identity a pod uses to talk to the Kubernetes API. Modern clusters hand each pod a bound token: a signed credential, like a temporary keycard, that expires on a timer and is stapled to that one specific pod and SA. The API server honors it only while the pod still exists and the SA's internal ID (its UID, a unique fingerprint) still matches. Older clusters also carried long-lived Secret tokens that never expire at all. The two kinds need different handling.
Deleting the pod invalidates a bound token, because the token is stapled to that pod. Deleting the service account hits harder: it kills every token that SA ever handed out, because the API server checks that the SA still exists with a matching UID, and a deleted or recreated SA no longer matches. For a legacy Secret token, you delete the Secret it lives in. And that cluster-admin-backdoor binding you spotted in the audit log gets deleted now, before the attacker uses it again.
kubectl delete clusterrolebinding cluster-admin-backdoorkubectl delete serviceaccount payments -n prodkubectl delete secret payments-legacy-token -n prod
clusterrolebinding.rbac.authorization.k8s.io "cluster-admin-backdoor" deletedserviceaccount "payments" deletedsecret "payments-legacy-token" deleted
Only now, with the evidence captured and the identity dead, do you drain the node, delete the pod, and rebuild the workload from an image you trust. Then you ask the hardest question last: how deep did they actually get? If the attacker only ever held this one pod's token, you're mopping up a contained mess and you can breathe. If they reached the control plane, or etcd (the cluster's key-value database, where every object and every Secret is stored), or the cluster's certificate authority (CA, the root of trust that signs every identity), then they can forge brand-new identities that survive everything you just did. Deleting bindings won't stop someone who can sign with your CA. That case means rotating the CA, rotating every credential, and rebuilding the cluster from the ground up. Painful, and sometimes it's the only honest call. So rehearse this whole sequence as a drill on a calm day, and the real 2 a.m. version becomes a runbook you follow instead of something you invent while your heart pounds.
Try this
Run kubectl label pod payments-api-7c9 quarantine=isolate -n prod --overwrite on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.
Takeaway
The trap worth remembering here: a NetworkPolicy is only as real as your CNI. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.