Skip to main content

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

Manual Steps
  1. 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'
  2. 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 NAMESPACE and SA_NAME from 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)"
    '
  3. 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.yaml
    apiVersion: rbac.authorization.k8s.io/v1
    kind: Role
    metadata:
    name: sa-least-priv-role
    namespace: NAMESPACE
    rules:
    # TODO: replace with only required resources/verbs
    - apiGroups: [""]
    resources: ["pods"]
    verbs: ["get", "list"]
    ---
    apiVersion: rbac.authorization.k8s.io/v1
    kind: RoleBinding
    metadata:
    name: sa-least-priv-binding
    namespace: NAMESPACE
    subjects:
    - kind: ServiceAccount
    name: SA_NAME
    namespace: NAMESPACE
    roleRef:
    apiGroup: rbac.authorization.k8s.io
    kind: Role
    name: sa-least-priv-role
    EOF

    kubectl apply -f sa-least-priv-role.yaml
  4. 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.yaml
    apiVersion: rbac.authorization.k8s.io/v1
    kind: ClusterRole
    metadata:
    name: sa-least-priv-clusterrole
    rules:
    # TODO: replace with only required resources/verbs at cluster scope
    - apiGroups: [""]
    resources: ["nodes"]
    verbs: ["get", "list"]
    ---
    apiVersion: rbac.authorization.k8s.io/v1
    kind: ClusterRoleBinding
    metadata:
    name: sa-least-priv-clusterrolebinding
    subjects:
    - kind: ServiceAccount
    name: SA_NAME
    namespace: NAMESPACE
    roleRef:
    apiGroup: rbac.authorization.k8s.io
    kind: ClusterRole
    name: sa-least-priv-clusterrole
    EOF

    kubectl apply -f sa-least-priv-clusterrole.yaml
  5. After confirming workloads function with the new least‑privilege bindings, delete each offending cluster-admin ClusterRoleBinding (run on any machine with kubectl access, substitute the name from step 1 output):

    kubectl delete clusterrolebinding BINDING_NAME
  6. 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:

  1. 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'
  1. For each violating ClusterRoleBinding, delete it (replace BINDING_NAME with 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
  1. 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'