Threat-modeling a cluster
Trace pivots, control each hop, rank by blast radius.
Most cluster security reviews end in a flat list. Forty findings, every one tagged 'medium', and no signal about which one actually ends your quarter. Threat modeling replaces that list with the question an attacker would ask: if I land right here, what can I reach, and how far does it spread? The attack-surface lesson enumerated the doors into a cluster. This one ranks them, so you fix the single break-in that leads to everything before you touch the ten that lead nowhere.
A home security survey is the right mental picture. A good surveyor won't just hand you a list of every weak point (the shed latch, the side gate, the garage keypad). They tell you which broken lock matters. A flimsy latch on an empty shed is a shrug. That same latch on the door between the garage and the house, where the car keys and the alarm panel sit, is the first thing to fix. That difference is blast radius: not how easy the lock is to pick, but how much sits behind it. In a cluster the rooms are your assets (Secrets, workloads, the control plane, the nodes, the cloud account) and the doors are your entry points (an internet-facing pod, a CI (Continuous Integration) pipeline that deploys, a developer running kubectl). You build a grid of assets against entry points, then score every cell by what it reaches.
Build the grid from what's actually running
Don't fill the grid from imagination. Fill it from the API. The API server is the cluster's source of truth; it already knows every service, pod, and permission, so ask it instead of guessing. Start with the entry points, because those are your columns. An entry point is anything an attacker can plausibly land on, and the first ones to map are the workloads exposed to the internet and the identity each of them carries. That identity is the Service Account (SA), the account a pod uses to authenticate to the API server. Once you know what's exposed and which SA it runs as, you know where a foothold starts and whose permissions it inherits.
kubectl get svc -A -o json | jq -r '.items[]| select(.spec.type=="LoadBalancer")| "\(.metadata.namespace)/\(.metadata.name)\t\(.status.loadBalancer.ingress[0].ip)"'
prod/frontend 34.120.55.10prod/api-gateway 34.120.55.11
Two public services, both in the prod namespace. Now map every pod in that namespace to the SA it runs as and the node it sits on. Hold onto the node column. It changes the ranking later.
kubectl get pods -n prod -o custom-columns=POD:.metadata.name,SA:.spec.serviceAccountName,NODE:.spec.nodeName
POD SA NODEfrontend-7c9f8b6d4-2xk9l frontend gke-prod-a1api-gateway-5b7d9c8f-q4m2z api-gw gke-prod-a1worker-0 ci-runner gke-prod-a1
Three pods, one node, two of them facing the internet. Each carries a different SA, and those SAs are the real subject of the ranking, because a foothold is only as dangerous as the token it can steal. So score them. The command kubectl auth can-i --list asks the API server, as any identity you name, exactly what that identity is allowed to do.
for sa in frontend api-gw ci-runner; doecho "== $sa =="kubectl auth can-i --list -n prod --as=system:serviceaccount:prod:$sa \| grep -Ei 'secrets|pods|\*\.\*'done
== frontend ==== api-gw ==secrets [] [] [get list]pods [] [] [create]== ci-runner ==*.* [] [] [*]
Now the cells write themselves. frontend can't reach Secrets, pods, or anything cluster-wide, so its whole column is low blast radius even though it faces the internet. api-gw can read every Secret in its namespace (get and list hand you those credentials, and a Kubernetes Secret is only base64-encoded, which is not encryption, so it's readable in seconds) and it can create pods, a well-known first step toward owning the node it runs on. That's a high cell. And ci-runner returns *.* with verb [*], which is cluster-admin: every resource, every verb. One line, and the whole ranking tips over.
Rank the cells: find the fire
The top of every blast-radius ranking is the same rung: any entry point whose identity reaches cluster-admin. From there an attacker owns Secrets, workloads, nodes, and usually the cloud account behind them. So hunt for it directly. This query lists every Service Account bound to cluster-admin, and it doubles as your detection. Run it on a schedule and diff the output, and a freshly planted admin binding surfaces the moment it's created.
kubectl get clusterrolebindings -o json | jq -r '.items[]| select(.roleRef.name=="cluster-admin")| .subjects[]? | select(.kind=="ServiceAccount")| "\(.namespace)/\(.name)"'
kube-system/kubernetes-dashboardprod/ci-runner
There it is. ci-runner is bound to cluster-admin, and ci-runner is the SA on worker-0, a pod sitting on the same node as your two internet-facing services. The CI pipeline is itself an entry point (it deploys, so a poisoned build runs as ci-runner), and that entry point owns the cluster. Walk the path an attacker walks: land on a public pod, break out to the node, lift ci-runner's token, and you're cluster-admin without ever tripping a firewall rule. Everything else on the report can wait. You close it by granting CI only what it actually does, then deleting the admin grant.
apiVersion: rbac.authorization.k8s.io/v1kind: Rolemetadata:namespace: prodname: deployerrules:- apiGroups: ["apps"]resources: ["deployments"]verbs: ["get", "list", "patch"]---apiVersion: rbac.authorization.k8s.io/v1kind: RoleBindingmetadata:namespace: prodname: ci-runner-deployersubjects:- kind: ServiceAccountname: ci-runnernamespace: prodroleRef:kind: Rolename: deployerapiGroup: rbac.authorization.k8s.io
$ kubectl apply -f ci-runner-rbac.yamlrole.rbac.authorization.k8s.io/deployer createdrolebinding.rbac.authorization.k8s.io/ci-runner-deployer created$ kubectl delete clusterrolebinding ci-runner-adminclusterrolebinding.rbac.authorization.k8s.io "ci-runner-admin" deleted
Notice how many of the high cells traced back to one thing: what a Service Account is allowed to do. The *.* grant, the secrets get and list, the create on pods. Ranking told you these are the fires. The next lesson, on RBAC (Role-Based Access Control) and the powerful grants, is where you learn to read those permissions precisely and catch the ones that look narrow in a binding but quietly hand over the whole cluster.
Try this
Work through “Rank the cells: find the fire” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.
Takeaway
The trap worth remembering here: ranking a pod by its own token undercounts it. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.