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
cluster-adminto any ServiceAccount (run on any machine withkubectlaccess):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 identified in step 1, review what permissions it actually needs by inspecting the workloads that use it (run on any machine with
kubectlaccess). ReplaceVIOLATING_NAMESPACEandVIOLATING_SAwith actual names:kubectl get pods -A -o json | jq -r '.items[]| select(.spec.serviceAccountName == "VIOLATING_SA" and .metadata.namespace == "VIOLATING_NAMESPACE")| "namespace=\(.metadata.namespace) pod=\(.metadata.name)"' -
Define a narrowly-scoped Role or ClusterRole that grants only the minimal verbs/resources required by those workloads (run on any machine with
kubectlaccess). Example template to save asminimal-role.yamland edit for correctnamespace,apiGroups,resources, andverbs:apiVersion: rbac.authorization.k8s.io/v1kind: Rolemetadata:name: minimal-violation-rolenamespace: VIOLATING_NAMESPACErules:- apiGroups: [""]resources: ["pods"]verbs: ["get", "list"]Apply it:
kubectl apply -f minimal-role.yaml -
Bind the violating ServiceAccount to the new narrowly-scoped Role or ClusterRole (run on any machine with
kubectlaccess). Example for a namespaced Role:cat << 'EOF' > minimal-rolebinding.yamlapiVersion: rbac.authorization.k8s.io/v1kind: RoleBindingmetadata:name: minimal-violation-rolebindingnamespace: VIOLATING_NAMESPACEsubjects:- kind: ServiceAccountname: VIOLATING_SAnamespace: VIOLATING_NAMESPACEroleRef:apiGroup: rbac.authorization.k8s.iokind: Rolename: minimal-violation-roleEOFkubectl apply -f minimal-rolebinding.yaml -
After confirming that workloads still function with the new, reduced privileges, remove the ClusterRoleBinding that granted
cluster-adminto that ServiceAccount (run on any machine withkubectlaccess). ReplaceCLUSTERROLEBINDING_NAMEwith the name from step 1:kubectl delete clusterrolebinding CLUSTERROLEBINDING_NAME -
Verification (run on any machine with
kubectlaccess): confirm that no ServiceAccount is bound tocluster-adminand that the check now reports compliance: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'
Using kubectl
On any machine with kubectl access:
- Identify violating ClusterRoleBindings
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'
Note the name= of each violating ClusterRoleBinding.
- (Optional but recommended) Create a narrowly-scoped Role/ClusterRole and binding
Adapt the following to the actual verbs and resources the workload requires.
Example namespaced Role and RoleBinding for a ServiceAccount my-sa in namespace my-namespace:
# save as rbac-my-sa.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: my-sa-role
namespace: my-namespace
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: my-sa-rolebinding
namespace: my-namespace
subjects:
- kind: ServiceAccount
name: my-sa
namespace: my-namespace
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: my-sa-role
Apply it:
kubectl apply -f rbac-my-sa.yaml
For cluster-scoped access, use ClusterRole and ClusterRoleBinding instead of Role/RoleBinding.
- Delete ClusterRoleBindings that bind ServiceAccounts to
cluster-admin
For each violating binding name (replace BINDING_NAME with the actual name):
kubectl delete clusterrolebinding BINDING_NAME
You can delete multiple at once:
kubectl delete clusterrolebinding BINDING_NAME_1 BINDING_NAME_2
- Verification
Run the audit command again and confirm it returns only is_compliant=true:
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
# Purpose: Remove any ServiceAccount bindings to the cluster-admin ClusterRole
# Platform: AKS (or any Kubernetes cluster reachable by kubectl)
# Requirements: kubectl, jq; run on any machine with kubectl access
set -euo pipefail
echo "[INFO] Verifying kubectl access..."
kubectl auth can-i list clusterrolebindings >/dev/null 2>&1 || {
echo "[ERROR] Current identity cannot list ClusterRoleBindings."
exit 1
}
echo "[INFO] Ensuring jq is available..."
if ! command -v jq >/dev/null 2>&1; then
echo "[ERROR] jq is required but not installed."
exit 1
fi
echo "[INFO] Discovering ClusterRoleBindings that bind ServiceAccounts to cluster-admin..."
# Find all ClusterRoleBindings that:
# - reference roleRef.name == "cluster-admin"
# - have at least one subject of kind ServiceAccount
violating_crbs=$(
kubectl get clusterrolebindings -o json |
jq -r '
.items[]
| select(.roleRef.name == "cluster-admin")
| select((.subjects // [])[]? | .kind == "ServiceAccount")
| .metadata.name
' | sort -u
)
if [[ -z "${violating_crbs}" ]]; then
echo "[INFO] No ClusterRoleBindings found that bind ServiceAccounts to cluster-admin."
else
echo "[INFO] The following ClusterRoleBindings bind ServiceAccounts to cluster-admin and will be deleted:"
echo "${violating_crbs}" | sed 's/^/ - /'
# Delete each violating ClusterRoleBinding. This is idempotent: deletions are skipped if already gone.
while IFS= read -r crb_name; do
[[ -z "${crb_name}" ]] && continue
echo "[INFO] Deleting ClusterRoleBinding: ${crb_name}"
# Use --ignore-not-found to keep script idempotent
kubectl delete clusterrolebinding "${crb_name}" --ignore-not-found
done <<< "${violating_crbs}"
fi
echo "[INFO] Verification: re-running compliance check..."
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
'