Skip to main content

pods/exec Should Not Be Granted To Broad Subjects

More Info:​

Advisory: review Roles/ClusterRoles that grant create on pods/exec. Exec into a running pod bypasses image immutability and admission controls.

Risk Level​

High

Address​

Security

Compliance Standards​

  • Cloudanix Best Practice

Triage and Remediation​

Remediation​

Manual Steps
  1. List ClusterRoleBindings that grant pods/exec to broad subjects

    • Run on: any machine with kubectl access
    # Identify bindings flagged by the audit (copy relevant lines or rerun the jq if needed)
    kubectl get clusterrolebindings -o yaml | grep -n "system:authenticated\|system:unauthenticated\|system:anonymous\|system:serviceaccounts" -n
  2. Inspect each implicated ClusterRole and its bindings

    • Run on: any machine with kubectl access
      Replace <clusterrole-name> with the name from the audit output.
    # See which permissions include pods/exec
    kubectl get clusterrole <clusterrole-name> -o yaml

    # See which ClusterRoleBindings use this ClusterRole
    kubectl get clusterrolebindings \
    -o jsonpath='{range .items[?(@.roleRef.kind=="ClusterRole" && @.roleRef.name=="<clusterrole-name>")]}{@.metadata.name}{"\n"}{end}'
  3. Remove broad subjects from each non-essential binding and replace with named users/groups

    • Run on: any machine with kubectl access
      For each implicated ClusterRoleBinding <binding-name>:
    # Edit the binding to remove broad subjects and add specific subjects
    kubectl edit clusterrolebinding <binding-name>

    In the editor:

    • Under subjects:, remove any entries with:
      • name: system:authenticated
      • name: system:unauthenticated
      • name: system:anonymous
      • kind: Group and name: system:serviceaccounts (or similar broad SA groups)
    • Optionally add explicit human operators or specific groups, for example:
      subjects:
      - kind: User
      name: alice@example.com
      apiGroup: rbac.authorization.k8s.io
      - kind: Group
      name: ops-team
      apiGroup: rbac.authorization.k8s.io
  4. If needed, split shared ClusterRoles to avoid giving pods/exec broadly

    • Run on: any machine with kubectl access
      When a ClusterRole is used by both broad and restricted bindings and only some need pods/exec:
    # Export the existing ClusterRole
    kubectl get clusterrole <clusterrole-name> -o yaml > /tmp/<clusterrole-name>.yaml

    # Create a copy for operators-only that still has pods/exec
    cp /tmp/<clusterrole-name>.yaml /tmp/<clusterrole-name>-operators.yaml
    sed -i 's/name: <clusterrole-name>/name: <clusterrole-name>-operators/' /tmp/<clusterrole-name>-operators.yaml

    # In the ORIGINAL file, remove pods/exec (and any '*' that implies it) from rules:
    # - Edit /tmp/<clusterrole-name>.yaml manually to delete "pods/exec" from resources
    # and avoid "*" covering it.
    # Apply both roles:
    kubectl apply -f /tmp/<clusterrole-name>.yaml
    kubectl apply -f /tmp/<clusterrole-name>-operators.yaml
  5. Point restricted operators at the new operators-only ClusterRole

    • Run on: any machine with kubectl access
      For bindings that should retain pods/exec, update roleRef.name:
    kubectl edit clusterrolebinding <binding-name>

    In the editor, change:

    roleRef:
    kind: ClusterRole
    name: <clusterrole-name>

    to:

    roleRef:
    kind: ClusterRole
    name: <clusterrole-name>-operators
  6. Verification: confirm no broad subjects have create on pods/exec

    • Run on: any machine with kubectl access
    { kubectl get roles,clusterroles --all-namespaces -o json
    kubectl get rolebindings,clusterrolebindings --all-namespaces -o json
    } | jq -rs '
    .[0] as $roles | .[1] |
    def broad: ["system:authenticated","system:unauthenticated","system:anonymous","system:serviceaccounts"];
    [ $roles.items[]
    | select(any(.rules[]?;
    (any(.resources[]?; . == "pods/exec" or . == "*"))
    and (any(.verbs[]?; . == "create" or . == "*"))))
    | { kind: .kind, ns: (.metadata.namespace // ""), name: .metadata.name } ] as $execRoles
    | [ .items[]
    | .kind as $kind | .metadata as $m | .roleRef as $ref
    | select(any($execRoles[];
    .name == $ref.name and .kind == $ref.kind
    and (.ns == "" or .ns == ($m.namespace // ""))))
    | ((.subjects // [])[] | select(.name as $n | broad | index($n)))
    ] as $rows
    | if ($rows | length) == 0 then "is_compliant=true" else $rows[] end'

    Ensure the output is is_compliant=true.

Using kubectl

On any machine with kubectl access:

  1. Identify the offending bindings and their ClusterRoles (from the audit output)

Use the audit output to note:

  • The binding kind/name/namespace
  • The roleRef (ClusterRole name)
  • The broad subject (e.g. system:authenticated, system:serviceaccounts)
  1. Inspect the ClusterRole rules granting pods/exec
kubectl get clusterrole <CLUSTERROLE_NAME> -o yaml

Look for rules like:

rules:
- apiGroups: [""]
resources: ["pods/exec"] # or ["*"]
verbs: ["create"] # or ["*"]
  1. Restrict pods/exec to a small set of named human operators

a) Create a dedicated ClusterRole that only grants pods/exec:

cat << 'EOF' > exec-into-pods-clusterrole.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: exec-into-pods
rules:
- apiGroups: [""]
resources: ["pods/exec"]
verbs: ["create"]
EOF

kubectl apply -f exec-into-pods-clusterrole.yaml

b) Bind that role only to specific named users or a single dedicated SA (example with two Azure AD users):

cat << 'EOF' > exec-into-pods-clusterrolebinding.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: exec-into-pods-operators
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: exec-into-pods
subjects:
- kind: User
name: "alice@contoso.com"
- kind: User
name: "bob@contoso.com"
EOF

kubectl apply -f exec-into-pods-clusterrolebinding.yaml

Adjust subjects to your actual Azure AD user principals or a single service account as appropriate.

  1. Remove broad subjects from existing offending bindings

For each binding reported by the audit as non‑compliant, edit it to remove broad subjects such as system:authenticated, system:unauthenticated, system:anonymous, system:serviceaccounts, and (if present) any Group that represents all users.

ClusterRoleBinding (cluster‑scoped):

kubectl edit clusterrolebinding <BINDING_NAME>

RoleBinding (namespaced):

kubectl edit rolebinding <BINDING_NAME> -n <NAMESPACE>

In the opened YAML, under subjects:, delete entries like:

- kind: Group
name: system:authenticated
apiGroup: rbac.authorization.k8s.io
- kind: Group
name: system:serviceaccounts
apiGroup: rbac.authorization.k8s.io
- kind: User
name: system:anonymous

If the binding is only used to grant pods/exec broadly and you have replaced it with the new, restricted binding, you can optionally delete the old binding entirely:

kubectl delete clusterrolebinding <BINDING_NAME>
# or
kubectl delete rolebinding <BINDING_NAME> -n <NAMESPACE>
  1. (Optional) Narrow the original ClusterRole if it is too broad

If the original ClusterRole grants pods/exec alongside other permissions and is bound to many subjects, you can remove pods/exec from it and rely on the new exec-into-pods ClusterRole for exec access:

kubectl edit clusterrole <CLUSTERROLE_NAME>

Under rules:, either:

  • Remove "pods/exec" from resources, or
  • Split rules so that non‑exec permissions stay in this ClusterRole, and exec is granted only by the new dedicated ClusterRole.
  1. Verification

Run the same logic used by the audit to confirm there are no bindings granting create on pods/exec to broad subjects:

{ kubectl get roles,clusterroles --all-namespaces -o json
kubectl get rolebindings,clusterrolebindings --all-namespaces -o json
} | jq -rs '
.[0] as $roles | .[1] |
def broad: ["system:authenticated","system:unauthenticated","system:anonymous","system:serviceaccounts"];
[ $roles.items[]
| select(any(.rules[]?;
(any(.resources[]?; . == "pods/exec" or . == "*"))
and (any(.verbs[]?; . == "create" or . == "*"))))
| { kind: .kind, ns: (.metadata.namespace // ""), name: .metadata.name } ] as $execRoles
| [ .items[]
| .kind as $kind | .metadata as $m | .roleRef as $ref
| select(any($execRoles[];
.name == $ref.name and .kind == $ref.kind
and (.ns == "" or .ns == ($m.namespace // ""))))
| ((.subjects // [])[] | select(.name as $n | broad | index($n)))
| "kind=\($kind)"
+ (if ($m.namespace // "") == "" then "" else " ns=\($m.namespace)" end)
+ " name=\($m.name) uid=\($m.uid) apiVersion=rbac.authorization.k8s.io/v1"
+ (if ($m.creationTimestamp // "") == "" then "" else " created=\($m.creationTimestamp)" end)
+ " subject=\(.name) roleRef=\($ref.kind)/\($ref.name) grants=pods/exec is_compliant=false"
] as $rows
| if ($rows | length) == 0 then "is_compliant=true" else $rows[] end'
Automation
#!/usr/bin/env bash
#
# Restrict broad subjects from ClusterRoles that grant create on pods/exec
# Platform: Azure AKS
# Runs on: any machine with kubectl access and jq installed
#
# Behavior:
# - Detects ClusterRoleBindings that:
# * bind a ClusterRole/Role that allows create (or *) on pods/exec (or *)
# * and have at least one subject in:
# system:authenticated, system:unauthenticated,
# system:anonymous, system:serviceaccounts
# - For each such binding:
# * removes the broad subjects from the binding
# * leaves any named users, groups, or individual service accounts intact
# - Safe to re-run: when no broad subjects remain, no changes are made
# - Verification: re-runs the benchmark jq audit at the end

set -euo pipefail

# -------- Preconditions --------
if ! command -v kubectl >/dev/null 2>&1; then
echo "kubectl not found in PATH. Install kubectl and ensure KUBECONFIG is set." >&2
exit 1
fi

if ! command -v jq >/dev/null 2>&1; then
echo "jq not found in PATH. Install jq." >&2
exit 1
fi

echo "Fetching current Roles/ClusterRoles and Bindings..."
TMPDIR="$(mktemp -d)"
trap 'rm -rf "'"$TMPDIR"'"' EXIT

ROLES_JSON="${TMPDIR}/roles.json"
BINDINGS_JSON="${TMPDIR}/bindings.json"

kubectl get roles,clusterroles --all-namespaces -o json > "${ROLES_JSON}"
kubectl get rolebindings,clusterrolebindings --all-namespaces -o json > "${BINDINGS_JSON}"

# -------- Identify exec-capable roles (pods/exec create or *) --------
echo "Identifying Roles/ClusterRoles that can create pods/exec..."
EXEC_ROLES_JSON="${TMPDIR}/exec_roles.json"

jq '
[ .items[]
| select(any(.rules[]?;
(any(.resources[]?; . == "pods/exec" or . == "*"))
and (any(.verbs[]?; . == "create" or . == "*"))))
| { kind: .kind, ns: (.metadata.namespace // ""), name: .metadata.name }
]
' "${ROLES_JSON}" > "${EXEC_ROLES_JSON}"

if [[ "$(jq 'length' "${EXEC_ROLES_JSON}")" -eq 0 ]]; then
echo "No Roles or ClusterRoles grant create on pods/exec. Nothing to do."
exit 0
fi

# -------- Process RoleBindings and ClusterRoleBindings --------
echo "Scanning RoleBindings and ClusterRoleBindings for broad subjects..."

BROAD_SUBJECTS='["system:authenticated","system:unauthenticated","system:anonymous","system:serviceaccounts"]'

# Function to patch a single binding (RoleBinding or ClusterRoleBinding)
patch_binding() {
local ns kind name
ns="$1" # can be empty for ClusterRoleBinding
kind="$2" # RoleBinding or ClusterRoleBinding
name="$3"

echo "Processing ${kind}/${name}${ns:+ in namespace ${ns}} ..."

# Get current YAML
if [[ "${kind}" == "ClusterRoleBinding" ]]; then
kubectl get clusterrolebinding "${name}" -o yaml > "${TMPDIR}/binding.yaml"
else
kubectl -n "${ns}" get rolebinding "${name}" -o yaml > "${TMPDIR}/binding.yaml"
fi

# Use yq if available; otherwise fall back to kubectl+jsonpatch
if command -v yq >/dev/null 2>&1; then
# Remove broad subjects from subjects list, if any
yq eval '
.subjects |= (
. // [] |
map(select(
(.kind == "Group" and .name == "system:authenticated" ) | not and
(.kind == "Group" and .name == "system:unauthenticated" ) | not and
(.kind == "User" and .name == "system:anonymous" ) | not and
(.kind == "Group" and .name == "system:serviceaccounts" ) | not
))
)
' "${TMPDIR}/binding.yaml" > "${TMPDIR}/binding_patched.yaml"

# Apply only if changed
if ! diff -q "${TMPDIR}/binding.yaml" "${TMPDIR}/binding_patched.yaml" >/dev/null 2>&1; then
echo " Removing broad subjects from ${kind}/${name}..."
kubectl apply -f "${TMPDIR}/binding_patched.yaml"
else
echo " No broad subjects present; no change."
fi
else
# yq not available: do JSON patch via kubectl
echo " yq not found; using jsonpatch via kubectl."

# Build JSON representation of subjects without broad subjects
local subj_json filtered_json
if [[ "${kind}" == "ClusterRoleBinding" ]]; then
subj_json="$(kubectl get clusterrolebinding "${name}" -o json | jq '.subjects // []')"
else
subj_json="$(kubectl -n "${ns}" get rolebinding "${name}" -o json | jq '.subjects // []')"
fi

filtered_json="$(jq --argjson broad "${BROAD_SUBJECTS}" '
[ .[]
| select(
(.kind == "Group" and (.name | IN($broad[]))) | not and
(.kind == "User" and (.name == "system:anonymous")) | not
)
]
' <<< "${subj_json}")"

if [[ "${subj_json}" == "${filtered_json}" ]]; then
echo " No broad subjects present; no change."
return
fi

# Construct and apply JSON patch
local patch
patch="$(jq -n --argjson subs "${filtered_json}" '[{"op":"replace","path":"/subjects","value":$subs}]')"

echo " Removing broad subjects from ${kind}/${name} via jsonpatch..."
if [[ "${kind}" == "ClusterRoleBinding" ]]; then
kubectl patch clusterrolebinding "${name}" --type=json -p "${patch}"
else
kubectl -n "${ns}" patch rolebinding "${name}" --type=json -p "${patch}"
fi
fi
}

export TMPDIR BROAD_SUBJECTS
export -f patch_binding

# Build list of offending bindings and invoke patch_binding for each
jq -rs --argjson broad "${BROAD_SUBJECTS}" '
.[0] as $roles | .[1] |
[ $roles.items[]
| select(any(.rules[]?;
(any(.resources[]?; . == "pods/exec" or . == "*"))
and (any(.verbs[]?; . == "create" or . == "*"))))
| { kind: .kind, ns: (.metadata.namespace // ""), name: .metadata.name } ] as $execRoles
| [ .items[]
| .kind as $kind | .metadata as $m | .roleRef as $ref
| select(any($execRoles[];
.name == $ref.name and .kind == $ref.kind
and (.ns == "" or .ns == ($m.namespace // ""))))
| { kind: $kind, ns: ($m.namespace // ""), name: .metadata.name,
subjects: (.subjects // []) }
| select(any(.subjects[];
(.kind == "Group" and .name as $n | $broad | index($n))
or (.kind == "User" and .name == "system:anonymous")))
]
' "${ROLES_JSON}" "${BINDINGS_JSON}" > "${TMPDIR}/offending_bindings.json"

COUNT="$(jq 'length' "${TMPDIR}/offending_bindings.json")"
if [[ "${COUNT}" -eq 0 ]]; then
echo "No RoleBindings or ClusterRoleBindings with broad subjects and pods/exec create found. Nothing to change."
else
echo "Found ${COUNT} offending bindings. Updating..."
# Iterate in bash to keep compatibility
for i in $(seq 0 $((COUNT - 1))); do
ns="$(jq -r ".[$i].ns" "${TMPDIR}/offending_bindings.json")"
kind="$(jq -r ".[$i].kind" "${TMPDIR}/offending_bindings.json")"
name="$(jq -r ".[$i].name" "${TMPDIR}/offending_bindings.json")"
patch_binding "${ns}" "${kind}" "${name}"
done
fi

# -------- Verification (re-run audit) --------
echo
echo "Re-running audit to verify that no broad subjects can exec pods..."
{
kubectl get roles,clusterroles --all-namespaces -o json
kubectl get rolebindings,clusterrolebindings --all-namespaces -o json
} | jq -rs '
.[0] as $roles | .[1] |
def broad: ["system:authenticated","system:unauthenticated","system:anonymous","system:serviceaccounts"];
[ $roles.items[]
| select(any(.rules[]?;
(any(.resources[]?; . == "pods/exec" or . == "*"))
and (any(.verbs[]?; . == "create" or . == "*"))))
| { kind: .kind, ns: (.metadata.namespace // ""), name: .metadata.name } ] as $execRoles
| [ .items[]
| .kind as $kind | .metadata as $m | .roleRef as $ref
| select(any($execRoles[];
.name == $ref.name and .kind == $ref.kind
and (.ns == "" or .ns == ($m.namespace // ""))))
| ((.subjects // [])[] | select(.name as $n | broad | index($n)))
| "kind=\($kind)"
+ (if ($m.namespace // "") == "" then "" else " ns=\($m.namespace)" end)
+ " name=\($m.name) uid=\($m.uid) apiVersion=rbac.authorization.k8s.io/v1"
+ (if ($m.creationTimestamp // "") == "" then "" else " created=\($m.creationTimestamp)" end)
+ " subject=\(.name) roleRef=\($ref.kind)/\($ref.name) grants=pods/exec is_compliant=false"
] as $rows
| if ($rows | length) == 0 then "is_compliant=true" else $rows[] end
'