Skip to main content

Kubernetes Minimize Access To Secrets

More Info:

Broad get, list and watch access to Secret objects lets subjects read sensitive credentials. Restrict this access to the minimum required.

Risk Level

High

Address

Security

Compliance Standards

  • CIS Kubernetes

Triage and Remediation

Remediation

Manual Steps
  1. Identify which Roles/ClusterRoles grant broad Secret access (run on any machine with kubectl access)

    kubectl get clusterroles -o yaml | grep -nA5 "kind: ClusterRole" | grep -nB5 "secrets"
    kubectl get roles --all-namespaces -o yaml | grep -nA5 "kind: Role" | grep -nB5 "secrets"

    Or more explicitly:

    kubectl get clusterroles -o yaml | awk '/kind: ClusterRole/{cr=$0} /resources:/{r=$0} /verbs:/{v=$0} /secrets/{print cr ORS r ORS v ORS "----"}'
    kubectl get roles --all-namespaces -o yaml | awk '/kind: Role/{ro=$0} /resources:/{r=$0} /verbs:/{v=$0} /secrets/{print ro ORS r ORS v ORS "----"}'
  2. Review which subjects actually use those roles (run on any machine with kubectl access)
    For each Role/ClusterRole identified, list RoleBindings/ClusterRoleBindings:

    kubectl get rolebindings --all-namespaces -o wide | grep "<ROLE_NAME>"
    kubectl get clusterrolebindings -o wide | grep "<CLUSTERROLE_NAME>"

    Replace <ROLE_NAME> / <CLUSTERROLE_NAME> with each name found in step 1 and decide, based on application requirements, which subjects truly need get/list/watch on secrets.

  3. Edit ClusterRoles to remove unnecessary get/list/watch on secrets (run on any machine with kubectl access)
    For each ClusterRole that is too broad:

    kubectl edit clusterrole <CLUSTERROLE_NAME>

    In the editor, within rules that include resources: ["secrets"], remove get, list, and/or watch from verbs where they are not strictly required. If no verb on secrets is needed, remove the entire rule item that references secrets.

  4. Edit namespace-scoped Roles to remove unnecessary get/list/watch on secrets (run on any machine with kubectl access)
    For each Role that is too broad:

    kubectl edit role <ROLE_NAME> -n <NAMESPACE>

    As in step 3, adjust or remove the verbs on resources: ["secrets"] so that only subjects that truly require secret access retain it.

  5. If needed, create least-privilege roles and re-bind subjects (run on any machine with kubectl access)
    Where applications still require limited Secret access, create narrowly scoped Roles/ClusterRoles (for example, restricted to specific namespaces or resources) and bind only the necessary service accounts/users:

    cat <<'EOF' | kubectl apply -f -
    apiVersion: rbac.authorization.k8s.io/v1
    kind: Role
    metadata:
    name: app-needs-secret-read
    namespace: default
    rules:
    - apiGroups: [""]
    resources: ["secrets"]
    resourceNames: ["app-config-secret"]
    verbs: ["get"]
    ---
    apiVersion: rbac.authorization.k8s.io/v1
    kind: RoleBinding
    metadata:
    name: app-needs-secret-read-binding
    namespace: default
    subjects:
    - kind: ServiceAccount
    name: app-sa
    namespace: default
    roleRef:
    kind: Role
    name: app-needs-secret-read
    apiGroup: rbac.authorization.k8s.io
    EOF
  6. Verify that broad secret access is minimized (run on any machine with kubectl access)

    echo "canGetListWatchSecretsAsSystemAuthenticated: $(kubectl auth can-i get,list,watch secrets --all-namespaces --as=system:authenticated)"

    Confirm this returns no or otherwise reflects only the explicitly required, minimized access you decided on in steps 2–5.

Using kubectl

On any machine with kubectl access:

  1. Identify Roles and ClusterRoles that grant secret read access

    kubectl get clusterroles -o yaml | grep -nE 'kind: ClusterRole|^- apiGroups:|^- secrets$|^- get$|^- list$|^- watch$'
    kubectl get roles --all-namespaces -o yaml | grep -nE 'kind: Role|^- apiGroups:|^- secrets$|^- get$|^- list$|^- watch$'
  2. For each Role/ClusterRole, review usage (which subjects are bound)

    # Cluster-wide bindings
    kubectl get clusterrolebindings -o yaml | grep -nE 'kind: ClusterRoleBinding|roleRef:|^ name:|kind: (User|Group|ServiceAccount)'

    # Namespaced bindings
    kubectl get rolebindings --all-namespaces -o yaml | grep -nE 'kind: RoleBinding|roleRef:|^ name:|kind: (User|Group|ServiceAccount)'
  3. For roles that should NOT have broad secrets read access, edit them to remove get, list, watch on secrets. Example commands (repeat per offending role):

    • ClusterRole:

      kubectl edit clusterrole <clusterrole-name>

      In the editor, locate any rule like:

      - apiGroups: [""]
      resources:
      - secrets
      verbs:
      - get
      - list
      - watch
      # possibly others

      and either:

      • remove secrets from resources, or
      • remove get, list, watch from verbs, keeping only what is required.
    • Namespaced Role:

      kubectl edit role <role-name> -n <namespace>

      Apply the same edit pattern as above.

    If you manage RBAC declaratively, instead edit the corresponding YAML in version control. Example patch of a ClusterRole manifest before applying:

    before:

    kind: ClusterRole
    apiVersion: rbac.authorization.k8s.io/v1
    metadata:
    name: example-broad-secret-access
    rules:
    - apiGroups: [""]
    resources: ["secrets"]
    verbs: ["get", "list", "watch"]

    after (no secret read access):

    kind: ClusterRole
    apiVersion: rbac.authorization.k8s.io/v1
    metadata:
    name: example-broad-secret-access
    rules:
    - apiGroups: [""]
    resources: [] # or remove this rule entirely if now empty
    verbs: []

    Apply the manifest:

    kubectl apply -f <path-to-updated-clusterrole>.yaml
  4. For roles that must read specific secrets, scope access narrowly instead of removing it entirely, for example using resourceNames:

    kind: Role
    apiVersion: rbac.authorization.k8s.io/v1
    metadata:
    name: limited-secret-reader
    namespace: app-namespace
    rules:
    - apiGroups: [""]
    resources: ["secrets"]
    resourceNames:
    - db-credentials
    verbs: ["get"]

    Apply:

    kubectl apply -f limited-secret-reader.yaml
  5. Re-check which roles still allow all authenticated users to read secrets

    echo "canGetListWatchSecretsAsSystemAuthenticated: $(kubectl auth can-i get,list,watch secrets --all-namespaces --as=system:authenticated)"

    The goal is that this returns canGetListWatchSecretsAsSystemAuthenticated: no unless you have a conscious, documented exception.

Automation
#!/usr/bin/env bash
#
# Restrict broad get/list/watch access to Secret objects.
# Scope: Kubernetes API objects, runs from any machine with kubectl access.
#
# Strategy:
# - Enumerate Roles and ClusterRoles that grant get/list/watch on secrets.
# - For each, remove those verbs from the "secrets" resource only.
# - Skip system and well-known controller roles by default (can be overridden).
# - Re-run safe: uses server-side apply with filtered rules.
#
# REQUIREMENTS:
# - kubectl configured with cluster-admin privileges.
# - jq installed.

set -euo pipefail

# ------------- CONFIGURABLE FILTERS ----------------

# Namespaces to skip (space-separated). System namespaces typically need broad access.
SKIP_NAMESPACES=("kube-system" "kube-public" "kube-node-lease")

# ClusterRoles to skip (space-separated) even if they grant access.
SKIP_CLUSTERROLES=(
"cluster-admin"
"system:controller:attachdetach-controller"
"system:controller:cronjob-controller"
"system:controller:deployment-controller"
"system:controller:endpoint-controller"
"system:controller:generic-garbage-collector"
"system:controller:replicaset-controller"
"system:controller:statefulset-controller"
"system:controller:namespace-controller"
"system:controller:job-controller"
"system:controller:persistent-volume-binder"
"system:controller:service-account-controller"
"system:controller:ttl-after-finished-controller"
)

# Roles to skip per-namespace, format: "namespace/name"
SKIP_ROLES=()

# ------------- HELPER FUNCTIONS ----------------

contains() {
local needle="$1"; shift
for x in "$@"; do
[[ "$x" == "$needle" ]] && return 0
done
return 1
}

skip_namespace() {
local ns="$1"
contains "$ns" "${SKIP_NAMESPACES[@]}"
}

skip_clusterrole() {
local name="$1"
contains "$name" "${SKIP_CLUSTERROLES[@]}"
}

skip_role() {
local ns="$1" name="$2"
local fq="${ns}/${name}"
contains "$fq" "${SKIP_ROLES[@]}"
}

patch_role_like() {
# $1: kind (Role|ClusterRole)
# $2: namespace (empty for ClusterRole)
# $3: name
local kind="$1" ns="$2" name="$3"

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

local get_cmd=(kubectl get "$kind" "$name" -o json)
if [[ "$kind" == "Role" ]]; then
get_cmd+=( -n "$ns" )
fi

local json
if ! json="$("${get_cmd[@]}")"; then
echo " WARN: Failed to fetch ${kind} ${ns:+$ns/}$name, skipping." >&2
return
fi

# Build a new rules array with 'secrets' rules stripped of get/list/watch, but keep other resources/verbs.
local patched
patched="$(jq '
.rules |= map(
if (.resources // [] | index("secrets") | not) then
.
else
# Split rules which mention secrets into:
# - secrets-only: verbs without get/list/watch (if any remain)
# - non-secrets: same verbs for remaining resources
(
(.resources // []) as $res
| (.verbs // []) as $verbs
| ($res - ["secrets"]) as $otherRes
| ($verbs - ["get","list","watch"]) as $safeVerbs
| if ($otherRes | length) == 0 and ($safeVerbs | length) == 0 then
# Rule becomes empty / useless: drop by emitting null (filtered later)
null
elif ($otherRes | length) == 0 then
# Only secrets in resources, but with restricted verbs
.resources = ["secrets"] | .verbs = $safeVerbs
elif ($safeVerbs | length) == 0 then
# Only non-secret resources remain, original verbs
.resources = $otherRes
else
# Need two rules: one for secrets with safe verbs, one for others unchanged verbs
[
(. | .resources = ["secrets"] | .verbs = $safeVerbs),
(. | .resources = $otherRes)
]
end
)
end
)
# Flatten arrays and remove nulls
| .rules = (.rules | map(if type=="array" then .[] else . end) | map(select(. != null)))
' <<< "$json")"

# If no change, skip apply.
if diff -q <(echo "$json" | jq '.rules') <(echo "$patched" | jq '.rules') >/dev/null 2>&1; then
echo " No change needed."
return
fi

echo " Updating ${kind} ${ns:+$ns/}$name"
if [[ "$kind" == "Role" ]]; then
echo "$patched" | kubectl apply -f -
else
echo "$patched" | kubectl apply -f -
fi
}

# ------------- MAIN LOGIC ----------------

echo "=== Restricting broad access to Secret objects ==="

# 1) Process Roles
echo "Scanning Roles for get/list/watch on secrets..."
mapfile -t roles < <(
kubectl get roles --all-namespaces -o json \
| jq -r '
.items[]
| select(
(.rules // [])
| map(
(.resources // []) | index("secrets")
and
(.verbs // []) | (index("get") or index("list") or index("watch"))
)
| any
)
| .metadata.namespace + " " + .metadata.name
'
)

for line in "${roles[@]}"; do
ns="${line%% *}"
name="${line#* }"

if skip_namespace "$ns"; then
echo "Skipping Role $ns/$name (namespace filtered)"
continue
fi
if skip_role "$ns" "$name"; then
echo "Skipping Role $ns/$name (role filtered)"
continue
fi

patch_role_like "Role" "$ns" "$name"
done

# 2) Process ClusterRoles
echo "Scanning ClusterRoles for get/list/watch on secrets..."
mapfile -t croles < <(
kubectl get clusterroles -o json \
| jq -r '
.items[]
| select(
(.rules // [])
| map(
(.resources // []) | index("secrets")
and
(.verbs // []) | (index("get") or index("list") or index("watch"))
)
| any
)
| .metadata.name
'
)

for name in "${croles[@]}"; do
if skip_clusterrole "$name"; then
echo "Skipping ClusterRole $name (clusterrole filtered)"
continue
fi

patch_role_like "ClusterRole" "" "$name"
done

# 3) VERIFICATION
echo "=== Verification ==="
echo "canGetListWatchSecretsAsSystemAuthenticated: $(kubectl auth can-i get,list,watch secrets --all-namespaces --as=system:authenticated)"

echo "Script complete."