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
Remediation
Manual Steps
-
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 "----"}' -
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. -
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
rulesthat includeresources: ["secrets"], removeget,list, and/orwatchfromverbswhere they are not strictly required. If no verb onsecretsis needed, remove the entire rule item that referencessecrets. -
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
verbsonresources: ["secrets"]so that only subjects that truly require secret access retain it. -
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/v1kind: Rolemetadata:name: app-needs-secret-readnamespace: defaultrules:- apiGroups: [""]resources: ["secrets"]resourceNames: ["app-config-secret"]verbs: ["get"]---apiVersion: rbac.authorization.k8s.io/v1kind: RoleBindingmetadata:name: app-needs-secret-read-bindingnamespace: defaultsubjects:- kind: ServiceAccountname: app-sanamespace: defaultroleRef:kind: Rolename: app-needs-secret-readapiGroup: rbac.authorization.k8s.ioEOF -
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
noor otherwise reflects only the explicitly required, minimized access you decided on in steps 2–5.
Using kubectl
On any machine with kubectl access:
-
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$' -
For each Role/ClusterRole, review usage (which subjects are bound)
# Cluster-wide bindingskubectl get clusterrolebindings -o yaml | grep -nE 'kind: ClusterRoleBinding|roleRef:|^ name:|kind: (User|Group|ServiceAccount)'# Namespaced bindingskubectl get rolebindings --all-namespaces -o yaml | grep -nE 'kind: RoleBinding|roleRef:|^ name:|kind: (User|Group|ServiceAccount)' -
For roles that should NOT have broad secrets read access, edit them to remove
get,list,watchonsecrets. Example commands (repeat per offending role):-
ClusterRole:
kubectl edit clusterrole <clusterrole-name>In the editor, locate any rule like:
- apiGroups: [""]resources:- secretsverbs:- get- list- watch# possibly othersand either:
- remove
secretsfromresources, or - remove
get,list,watchfromverbs, keeping only what is required.
- remove
-
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: ClusterRoleapiVersion: rbac.authorization.k8s.io/v1metadata:name: example-broad-secret-accessrules:- apiGroups: [""]resources: ["secrets"]verbs: ["get", "list", "watch"]after (no secret read access):
kind: ClusterRoleapiVersion: rbac.authorization.k8s.io/v1metadata:name: example-broad-secret-accessrules:- apiGroups: [""]resources: [] # or remove this rule entirely if now emptyverbs: []Apply the manifest:
kubectl apply -f <path-to-updated-clusterrole>.yaml -
-
For roles that must read specific secrets, scope access narrowly instead of removing it entirely, for example using
resourceNames:kind: RoleapiVersion: rbac.authorization.k8s.io/v1metadata:name: limited-secret-readernamespace: app-namespacerules:- apiGroups: [""]resources: ["secrets"]resourceNames:- db-credentialsverbs: ["get"]Apply:
kubectl apply -f limited-secret-reader.yaml -
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: nounless 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."