No ServiceAccount Should Be Bound To cluster-admin
More Info:
Verifies no ServiceAccount is bound to the cluster-admin ClusterRole. Such a binding hands full cluster control to any workload using that account.
Risk Level
Critical
Address
Security
Compliance Standards
- Cloudanix Best Practice
Triage and Remediation
- Remediation
Remediation
Manual Steps
-
List all ClusterRoleBindings that bind ServiceAccounts to
cluster-admin(run on any machine with kubectl access):kubectl get clusterrolebindings -o json | jq -r '[ .items[]| select(.roleRef.name == "cluster-admin")| .metadata as $m| ((.subjects // [])[] | select(.kind == "ServiceAccount"))| "kind=ClusterRoleBinding name=\($m.name) uid=\($m.uid) apiVersion=rbac.authorization.k8s.io/v1"+ (if ($m.creationTimestamp // "") == "" then "" else " created=\($m.creationTimestamp)" end)+ " sa=\(.namespace)/\(.name) roleRef=cluster-admin is_compliant=false"] as $rows| if ($rows | length) == 0 then "is_compliant=true" else $rows[] end' -
For each violating ServiceAccount, inspect what it is used by so you can design a narrower Role/ClusterRole (run on any machine with kubectl access, replace
NAMESPACEandSA_NAMEfrom step 1 output):kubectl get pods -A -o json | jq -r '.items[]| select(.spec.serviceAccountName == "SA_NAME" and .metadata.namespace == "NAMESPACE")| "ns=\(.metadata.namespace) pod=\(.metadata.name)"' -
Create a least-privilege Role or ClusterRole with exactly the permissions the workload needs (example skeleton; edit rules before applying, run on any machine with kubectl access):
cat << 'EOF' > sa-least-priv-role.yamlapiVersion: rbac.authorization.k8s.io/v1kind: Rolemetadata:name: sa-least-priv-rolenamespace: NAMESPACErules:# TODO: replace with only required resources/verbs- apiGroups: [""]resources: ["pods"]verbs: ["get", "list"]---apiVersion: rbac.authorization.k8s.io/v1kind: RoleBindingmetadata:name: sa-least-priv-bindingnamespace: NAMESPACEsubjects:- kind: ServiceAccountname: SA_NAMEnamespace: NAMESPACEroleRef:apiGroup: rbac.authorization.k8s.iokind: Rolename: sa-least-priv-roleEOFkubectl apply -f sa-least-priv-role.yaml -
If the ServiceAccount truly requires cluster‑scope privileges (rare), create a narrowly scoped ClusterRole instead (edit rules before applying, run on any machine with kubectl access):
cat << 'EOF' > sa-least-priv-clusterrole.yamlapiVersion: rbac.authorization.k8s.io/v1kind: ClusterRolemetadata:name: sa-least-priv-clusterrolerules:# TODO: replace with only required resources/verbs at cluster scope- apiGroups: [""]resources: ["nodes"]verbs: ["get", "list"]---apiVersion: rbac.authorization.k8s.io/v1kind: ClusterRoleBindingmetadata:name: sa-least-priv-clusterrolebindingsubjects:- kind: ServiceAccountname: SA_NAMEnamespace: NAMESPACEroleRef:apiGroup: rbac.authorization.k8s.iokind: ClusterRolename: sa-least-priv-clusterroleEOFkubectl apply -f sa-least-priv-clusterrole.yaml -
After confirming workloads function with the new least‑privilege bindings, delete each offending
cluster-adminClusterRoleBinding (run on any machine with kubectl access, substitute thenamefrom step 1 output):kubectl delete clusterrolebinding BINDING_NAME -
Verify no ServiceAccount remains bound to
cluster-admin(run on any machine with kubectl access):kubectl get clusterrolebindings -o json | jq -r '[ .items[]| select(.roleRef.name == "cluster-admin")| .metadata as $m| ((.subjects // [])[] | select(.kind == "ServiceAccount"))| "kind=ClusterRoleBinding name=\($m.name) uid=\($m.uid) apiVersion=rbac.authorization.k8s.io/v1"+ (if ($m.creationTimestamp // "") == "" then "" else " created=\($m.creationTimestamp)" end)+ " sa=\(.namespace)/\(.name) roleRef=cluster-admin is_compliant=false"] as $rows| if ($rows | length) == 0 then "is_compliant=true" else $rows[] end'A compliant cluster prints:
is_compliant=true
Using kubectl
On any machine with kubectl access:
- Identify ServiceAccounts bound to
cluster-admin:
kubectl get clusterrolebindings -o json | jq -r '
[ .items[]
| select(.roleRef.name == "cluster-admin")
| .metadata as $m
| ((.subjects // [])[] | select(.kind == "ServiceAccount"))
| "kind=ClusterRoleBinding name=\($m.name) uid=\($m.uid) apiVersion=rbac.authorization.k8s.io/v1"
+ (if ($m.creationTimestamp // "") == "" then "" else " created=\($m.creationTimestamp)" end)
+ " sa=\(.namespace)/\(.name) roleRef=cluster-admin is_compliant=false"
] as $rows
| if ($rows | length) == 0 then "is_compliant=true" else $rows[] end'
- For each violating
ClusterRoleBinding, delete it (replaceBINDING_NAMEwith the name from step 1):
kubectl delete clusterrolebinding BINDING_NAME
If you need to recreate a narrower-scope binding for the same ServiceAccount, define a dedicated ClusterRole or Role and binding, for example:
# save as rbac-limited.yaml and adjust rules/subjects as needed
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: my-workload-limited
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: my-workload-limited-binding
namespace: my-namespace
subjects:
- kind: ServiceAccount
name: my-serviceaccount
namespace: my-namespace
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: my-workload-limited
Apply the narrowed permissions:
kubectl apply -f rbac-limited.yaml
- Verification (on any machine with kubectl access):
kubectl get clusterrolebindings -o json | jq -r '
[ .items[]
| select(.roleRef.name == "cluster-admin")
| .metadata as $m
| ((.subjects // [])[] | select(.kind == "ServiceAccount"))
| "kind=ClusterRoleBinding name=\($m.name) uid=\($m.uid) apiVersion=rbac.authorization.k8s.io/v1"
+ (if ($m.creationTimestamp // "") == "" then "" else " created=\($m.creationTimestamp)" end)
+ " sa=\(.namespace)/\(.name) roleRef=cluster-admin is_compliant=false"
] as $rows
| if ($rows | length) == 0 then "is_compliant=true" else $rows[] end'
Automation
#!/usr/bin/env bash
#
# Remediation for: "No ServiceAccount Should Be Bound To cluster-admin"
# Scope: any machine with kubectl access to the EKS cluster
#
# Requirements:
# - kubectl installed and configured (KUBECONFIG or in-cluster)
# - jq installed
#
# Behavior:
# - Identifies all ClusterRoleBindings that:
# * reference roleRef.name == "cluster-admin"
# * have at least one subject of kind == "ServiceAccount"
# - Deletes those ClusterRoleBindings
# - Safe to re-run: no-op if there are no such bindings
# - Prints a post-remediation verification using the benchmark’s audit query
set -euo pipefail
echo "=== Detecting ClusterRoleBindings that bind ServiceAccounts to cluster-admin ==="
# Get names of offending ClusterRoleBindings
mapfile -t CRBS_TO_DELETE < <(
kubectl get clusterrolebindings -o json | jq -r '
.items[]
| select(.roleRef.name == "cluster-admin")
| select(((.subjects // [])[] | select(.kind == "ServiceAccount")) | length) as $len
| .metadata.name
' 2>/dev/null | sort -u
)
if [ "${#CRBS_TO_DELETE[@]}" -eq 0 ]; then
echo "No ClusterRoleBindings found that bind ServiceAccounts to cluster-admin."
else
echo "The following ClusterRoleBindings bind ServiceAccounts to cluster-admin and will be deleted:"
for crb in "${CRBS_TO_DELETE[@]}"; do
echo " - ${crb}"
done
echo
echo "=== Deleting offending ClusterRoleBindings ==="
for crb in "${CRBS_TO_DELETE[@]}"; do
if kubectl get clusterrolebinding "${crb}" >/dev/null 2>&1; then
echo "Deleting clusterrolebinding/${crb} ..."
kubectl delete clusterrolebinding "${crb}"
else
echo "clusterrolebinding/${crb} already absent; skipping."
fi
done
fi
echo
echo "=== Verification (benchmark audit command) ==="
kubectl get clusterrolebindings -o json | jq -r '
[ .items[]
| select(.roleRef.name == "cluster-admin")
| .metadata as $m
| ((.subjects // [])[] | select(.kind == "ServiceAccount"))
| "kind=ClusterRoleBinding name=\($m.name) uid=\($m.uid) apiVersion=rbac.authorization.k8s.io/v1"
+ (if ($m.creationTimestamp // "") == "" then "" else " created=\($m.creationTimestamp)" end)
+ " sa=\(.namespace)/\(.name) roleRef=cluster-admin is_compliant=false"
] as $rows
| if ($rows | length) == 0 then "is_compliant=true" else $rows[] end'