Skip to main content

Minimize Access To Create Persistent Volumes

More Info:

The ability to create PersistentVolumes can be used to mount hostPath volumes and access the underlying node. Limit who can create them.

Risk Level

Medium

Address

Security

Compliance Standards

  • CIS Kubernetes

Triage and Remediation

Remediation

Manual Steps
  1. Identify all subjects that can create PersistentVolumes

    • Run on: any machine with kubectl access
    kubectl get clusterrole -o json | jq -r '
    .items[]
    | select(.rules[]?
    | any(.apiGroups[]? == "" or .apiGroups[]? == "v1";
    .resources[]? == "persistentvolumes";
    (.verbs[]? == "create" or .verbs[]? == "*")))
    | .metadata.name' | sort -u

    Save this list of ClusterRoles for review.

  2. Review the permissions in each identified ClusterRole

    • Run on: any machine with kubectl access
    for cr in $(kubectl get clusterrole -o name); do
    kubectl get "$cr" -o yaml | \
    yq '. | select(.rules[]?
    | any(.apiGroups[]? == "" or .apiGroups[]? == "v1";
    .resources[]? == "persistentvolumes";
    (.verbs[]? == "create" or .verbs[]? == "*")) )'
    done

    For each displayed ClusterRole, decide if the create permission on persistentvolumes is truly required (e.g., storage admin vs general workloads).

  3. Map ClusterRoles with PV create permission to actual users/groups/service accounts

    • Run on: any machine with kubectl access
    # ClusterRoleBindings
    kubectl get clusterrolebinding -o yaml | \
    yq '.items[]
    | select(.roleRef.kind == "ClusterRole" and
    (.roleRef.name == "cluster-admin" or
    (.roleRef.name as $r |
    env.CR_LIST | split(" ") | any(. == $r))))
    | {name: .metadata.name, role: .roleRef.name, subjects: .subjects}' \
    CR_LIST="$(kubectl get clusterrole -o json | jq -r '
    .items[]
    | select(.rules[]?
    | any(.apiGroups[]? == "" or .apiGroups[]? == "v1";
    .resources[]? == "persistentvolumes";
    (.verbs[]? == "create" or .verbs[]? == "*")))
    | .metadata.name' | tr '\n' ' ')"

    Manually review each binding’s subjects to see which identities gain PV create capability.

  4. Decide and implement least-privilege changes
    For each ClusterRole where create on persistentvolumes is NOT strictly needed:

    • Edit the ClusterRole to remove create (and * if unused) for persistentvolumes:
      • Run on: any machine with kubectl access
      kubectl edit clusterrole <CLUSTERROLE_NAME>
      In the rules section, remove create from verbs for resources: ["persistentvolumes"], or split out a dedicated rule if other verbs are still required.
      If an identity needs only PV consumption, bind it instead to a narrower role that lacks PV create.
  5. If PV creation is needed, confine it to dedicated admin roles

    • Ensure only a small, storage-admin–style ClusterRole retains create on persistentvolumes.
    • Rebind ClusterRoleBindings so that:
      • General developers and application service accounts do NOT reference that ClusterRole.
      • Only designated admin groups/users (e.g., an ops group in your IdP) are subjects of the storage admin ClusterRoleBinding:
        kubectl edit clusterrolebinding <BINDING_NAME>
        Adjust the subjects list accordingly.
  6. Verify effective reduction of PV create access

    • Re-run the permission discovery and confirm only the intended ClusterRoles retain PV create:
      kubectl get clusterrole -o json | jq -r '
      .items[]
      | select(.rules[]?
      | any(.apiGroups[]? == "" or .apiGroups[]? == "v1";
      .resources[]? == "persistentvolumes";
      (.verbs[]? == "create" or .verbs[]? == "*")))
      | .metadata.name' | sort -u
    • Optionally, as a non-privileged user/service account, attempt to create a PersistentVolume and confirm it is rejected with a forbidden error.
Using kubectl

Using kubectl

1. List all ClusterRoles that can create PersistentVolumes

Run on: any machine with kubectl access.

kubectl get clusterroles -o json | jq -r '
.items[]
| select(
(.rules // [])
| map(
(.resources // []) as $r
| (.verbs // []) as $v
| select( ("persistentvolumes" | IN($r[]?)) and ("create" | IN($v[]?)) )
)
| length > 0
)
| .metadata.name
'

If you don’t have jq, use:

kubectl get clusterroles -o yaml | \
awk '
$0 ~ /^kind: ClusterRole/ { inrole=1; name="" }
inrole && $1 == "name:" { name=$2 }
/rules:/ { inrules=1 }
inrules && /resources:/ { inres=1 }
inrules && /verbs:/ { inverbs=1 }
inres && /persistentvolumes/ { gotpv=1 }
inverbs && /create/ { gotcreate=1 }
/^$/ {
if (inrole && gotpv && gotcreate) print name
inrole=inrules=inres=inverbs=gotpv=gotcreate=0
}
'

Output indicating a problem:
Any ClusterRole name returned here grants create on persistentvolumes to whoever is bound to it. These roles need review to see if that permission is justified.


2. Inspect each identified ClusterRole’s rules in detail

For each ClusterRole name from step 1:

kubectl get clusterrole <CLUSTERROLE_NAME> -o yaml

Review the rules: section, focusing on entries where:

apiGroups: [""]
resources: ["persistentvolumes"]
verbs: ["create", ...]

Output indicating a problem:

  • Roles that are broad (e.g., verbs: ["*"] or resources: ["*"]) and include persistentvolumes.
  • Roles meant for general users, CI/CD, or application service accounts that also have create on persistentvolumes without a clear operational need.

3. Discover who actually gets these permissions (ClusterRoleBindings)

For each problematic ClusterRole from step 2:

kubectl get clusterrolebindings -o json | jq -r '
.items[]
| select(.roleRef.kind == "ClusterRole" and .roleRef.name == "<CLUSTERROLE_NAME>")
| .metadata.name
'

Without jq:

kubectl get clusterrolebindings -o yaml | \
awk -v target="<CLUSTERROLE_NAME>" '
$0 ~ /^kind: ClusterRoleBinding/ { inb=1; name="" }
inb && $1 == "name:" { name=$2 }
inb && $1 == "roleRef:" { inrole=1 }
inrole && $1 == "name:" && $2 == target { print name }
/^$/ { inb=inrole=0 }
'

Then inspect each binding:

kubectl get clusterrolebinding <BINDING_NAME> -o yaml

Output indicating a problem:

  • Bindings that grant the ClusterRole to:
    • system:authenticated, system:unauthenticated, or large groups (e.g., developers, ci-users) where most members do not need PV creation.
    • ServiceAccounts in namespaces unrelated to storage/cluster operations.
  • Any binding where you cannot clearly justify why that subject must create PersistentVolumes.

4. Verification after manual changes

After you have manually updated or removed roles/bindings (via manifests or kubectl edit), re-run step 1:

kubectl get clusterroles -o json | jq -r '
.items[]
| select(
(.rules // [])
| map(
(.resources // []) as $r
| (.verbs // []) as $v
| select( ("persistentvolumes" | IN($r[]?)) and ("create" | IN($v[]?)) )
)
| length > 0
)
| .metadata.name
'

Verification result interpretation:

  • If no ClusterRole names are returned, then no ClusterRole currently grants create on persistentvolumes.
  • If only a small, well-justified set of highly privileged operational roles are returned, you must confirm they are appropriate; if not, further manual adjustment is required.
Automation
#!/usr/bin/env bash
# Report who can create PersistentVolumes (CIS Kubernetes 5.1.9)

set -euo pipefail

echo "=== ClusterRoles and ClusterRoleBindings with 'create' on persistentvolumes ==="
echo

# 1) List ClusterRoles that can create persistentvolumes
kubectl get clusterrole -o json \
| jq -r '
.items[]
| select(
.rules // []
| any(.resources[]? == "persistentvolumes"
and (.verbs[]? == "create" or .verbs[]? == "*"))
)
| .metadata.name
' | sort -u | while read -r cr; do
[ -z "$cr" ] && continue
echo "ClusterRole: ${cr}"
kubectl get clusterrole "$cr" -o json \
| jq -r '
.rules[]
| select(any(.resources[]? == "persistentvolumes"))
| " apiGroups: " + ( (.apiGroups // []) | join(",") )
+ "\n resources: " + ( (.resources // []) | join(",") )
+ "\n verbs: " + ( (.verbs // []) | join(",") )
'
echo

# Show who gets this ClusterRole via ClusterRoleBinding
echo " Bound via ClusterRoleBindings:"
kubectl get clusterrolebinding -o json \
| jq -r --arg cr "$cr" '
.items[]
| select(.roleRef.kind == "ClusterRole" and .roleRef.name == $cr)
| " ClusterRoleBinding: " + .metadata.name
+ "\n subjects: "
+ ( if (.subjects // [] | length) == 0
then "NONE"
else
( .subjects[]
| " - kind: " + (.kind // "")
+ ", name: " + (.name // "")
+ (if .namespace then ", namespace: " + .namespace else "" end)
)
| join("\n")
end
)
'
echo
done

echo "=== Roles and RoleBindings with 'create' on persistentvolumes (namespace-scoped RBAC) ==="
echo

# 2) List namespace-scoped Roles that can create persistentvolumes
kubectl get role --all-namespaces -o json \
| jq -r '
.items[]
| select(
.rules // []
| any(.resources[]? == "persistentvolumes"
and (.verbs[]? == "create" or .verbs[]? == "*"))
)
| .metadata.namespace + " " + .metadata.name
' | sort -u | while read -r ns name; do
[ -z "$ns" ] && continue
echo "Role: ${name} (namespace: ${ns})"
kubectl get role "$name" -n "$ns" -o json \
| jq -r '
.rules[]
| select(any(.resources[]? == "persistentvolumes"))
| " apiGroups: " + ( (.apiGroups // []) | join(",") )
+ "\n resources: " + ( (.resources // []) | join(",") )
+ "\n verbs: " + ( (.verbs // []) | join(",") )
'
echo

# Show who gets this Role via RoleBinding
echo " Bound via RoleBindings:"
kubectl get rolebinding -n "$ns" -o json \
| jq -r --arg name "$name" '
.items[]
| select(.roleRef.kind == "Role" and .roleRef.name == $name)
| " RoleBinding: " + .metadata.name
+ "\n subjects: "
+ ( if (.subjects // [] | length) == 0
then "NONE"
else
( .subjects[]
| " - kind: " + (.kind // "")
+ ", name: " + (.name // "")
+ (if .namespace then ", namespace: " + .namespace else "" end)
)
| join("\n")
end
)
'
echo
done

echo "=== Summary: subjects that can create PersistentVolumes (cluster-scoped) ==="
echo

# 3) Compact subject summary for ClusterRoles
kubectl get clusterrole -o json \
| jq -r '
.items[]
| select(
.rules // []
| any(.resources[]? == "persistentvolumes"
and (.verbs[]? == "create" or .verbs[]? == "*"))
)
| .metadata.name
' | sort -u | while read -r cr; do
[ -z "$cr" ] && continue
kubectl get clusterrolebinding -o json \
| jq -r --arg cr "$cr" '
.items[]
| select(.roleRef.kind == "ClusterRole" and .roleRef.name == $cr)
| .subjects[]?
| $cr + " -> " + (.kind // "") + "/" + (.name // "")
+ (if .namespace then " (ns:" + .namespace + ")" else "" end)
'
done | sort -u

How to run (any machine with kubectl access):

  • Save as report-pv-create-access.sh
  • Make executable: chmod +x report-pv-create-access.sh
  • Run: ./report-pv-create-access.sh

What indicates a problem:

  • Any ClusterRole or Role whose rules include:
    • resources: persistentvolumes with verbs containing create or *.
  • Especially concerning:
    • These roles bound to broad subjects, for example:
      • kind: Group, name: system:authenticated
      • kind: Group, name: system:masters (if that group is widely used)
      • ServiceAccounts used by application workloads rather than storage controllers.
  • For the summary section, any line like:
    • cluster-admin -> Group/system:authenticated
    • some-pv-manager -> ServiceAccount/default (ns:default) should be reviewed to confirm that subject truly requires PV creation rights.