Skip to main content

Pods Should Not Mount HostPath Volumes

More Info:​

Verifies no pod mounts a hostPath volume. hostPath exposes the node filesystem to the pod and can be used to escape to the host.

Risk Level​

High

Address​

Security

Compliance Standards​

  • Cloudanix Best Practice

Triage and Remediation​

Remediation​

Manual Steps
  1. Identify all non-system pods using hostPath (any machine with kubectl access):

    kubectl get pods --all-namespaces -o json | jq -r '
    [ .items[]
    | select(.metadata.namespace as $n | ["kube-system","kube-public","kube-node-lease"] | index($n) | not)
    | .metadata as $m
    | (.spec.nodeName // "") as $node
    | (($m.labels // {}) | to_entries | map("\(.key):\(.value)") | join(",")) as $labels
    | ([ ($m.ownerReferences // [])[] | select(.controller) ] | first) as $own
    | [ (.spec.volumes // [])[] | select(.hostPath != null) ] as $hp
    | "kind=Pod ns=\($m.namespace) name=\($m.name) uid=\($m.uid) apiVersion=v1"
    + (if ($m.creationTimestamp // "") == "" then "" else " created=\($m.creationTimestamp)" end)
    + (if $node == "" then "" else " node=\($node)" end)
    + (if $labels == "" then "" else " labels=\($labels)" end)
    + (if $own == null then "" else " owner=\($own.kind)/\($m.namespace)/\($own.name)/\($own.uid)" end)
    + (if ($hp | length) == 0 then "" else " hostPaths=\([ $hp[] | .hostPath.path ] | join("+"))" end)
    + " is_compliant=\(if ($hp | length) > 0 then "false" else "true" end)"
    ] as $rows
    | if ($rows | length) == 0 then "is_compliant=true" else $rows[] end'
  2. For each non-compliant pod, identify its managing controller (any machine with kubectl access). Replace NAMESPACE and OWNER_NAME as needed:

    kubectl -n NAMESPACE get pod -o json POD_NAME | jq '.metadata.ownerReferences'

    If .ownerReferences is empty, you must edit the Pod manifest source in your own deployment process; do not edit live pods directly as they are not persistent.

  3. Export the controller manifest that creates the offending pods (any machine with kubectl access). Use the kind/name from ownerReferences, for example:

    • Deployment:
      kubectl -n NAMESPACE get deployment OWNER_NAME -o yaml > deployment-OWNER_NAME.yaml
    • StatefulSet:
      kubectl -n NAMESPACE get statefulset OWNER_NAME -o yaml > statefulset-OWNER_NAME.yaml
    • DaemonSet:
      kubectl -n NAMESPACE get daemonset OWNER_NAME -o yaml > daemonset-OWNER_NAME.yaml
  4. Edit the exported manifest locally to remove hostPath volumes and use safer alternatives (local file edit on your workstation):

    • In spec.template.spec.volumes[], delete any entries that contain hostPath:.
    • Update corresponding spec.template.spec.containers[].volumeMounts[] to either:
      • Remove the mount completely, or
      • Point to a replacement volume, such as:
        volumes:
        - name: data
        emptyDir: {}
        # or a PersistentVolumeClaim:
        # - name: data
        # persistentVolumeClaim:
        # claimName: pvc-name

    Save the file.

  5. Apply the updated controller manifest so new pods are created without hostPath (any machine with kubectl access):

    kubectl apply -f deployment-OWNER_NAME.yaml
    # or statefulset-OWNER_NAME.yaml / daemonset-OWNER_NAME.yaml as appropriate

    Then, force recreation of existing pods that were using hostPath:

    kubectl -n NAMESPACE rollout restart deployment/OWNER_NAME
    # or:
    # kubectl -n NAMESPACE rollout restart statefulset/OWNER_NAME
    # kubectl -n NAMESPACE rollout restart daemonset/OWNER_NAME
  6. Verification (any machine with kubectl access): rerun the audit and confirm all remaining rows show is_compliant=true and none have hostPaths=:

    kubectl get pods --all-namespaces -o json | jq -r '
    [ .items[]
    | select(.metadata.namespace as $n | ["kube-system","kube-public","kube-node-lease"] | index($n) | not)
    | .metadata as $m
    | (.spec.nodeName // "") as $node
    | (($m.labels // {}) | to_entries | map("\(.key):\(.value)") | join(",")) as $labels
    | ([ ($m.ownerReferences // [])[] | select(.controller) ] | first) as $own
    | [ (.spec.volumes // [])[] | select(.hostPath != null) ] as $hp
    | "kind=Pod ns=\($m.namespace) name=\($m.name) uid=\($m.uid) apiVersion=v1"
    + (if ($m.creationTimestamp // "") == "" then "" else " created=\($m.creationTimestamp)" end)
    + (if $node == "" then "" else " node=\($node)" end)
    + (if $labels == "" then "" else " labels=\($labels)" end)
    + (if $own == null then "" else " owner=\($own.kind)/\($m.namespace)/\($own.name)/\($own.uid)" end)
    + (if ($hp | length) == 0 then "" else " hostPaths=\([ $hp[] | .hostPath.path ] | join("+"))" end)
    + " is_compliant=\(if ($hp | length) > 0 then "false" else "true" end)"
    ] as $rows
    | if ($rows | length) == 0 then "is_compliant=true" else $rows[] end'
Using kubectl

On any machine with kubectl access:

  1. Identify Pods using hostPath (and their controllers)
kubectl get pods --all-namespaces -o json | jq -r '
[ .items[]
| select(.metadata.namespace as $n | ["kube-system","kube-public","kube-node-lease"] | index($n) | not)
| .metadata as $m
| [ (.spec.volumes // [])[] | select(.hostPath != null) ] as $hp
| select(($hp | length) > 0)
| [ ($m.ownerReferences // [])[] | select(.controller) ] | first
]'
  1. For each affected workload, edit the owning object (Deployment, StatefulSet, DaemonSet, etc.) to remove hostPath and use a safer volume type such as emptyDir or a PersistentVolumeClaim.

Example: replace a hostPath volume with emptyDir in a Deployment.

kubectl -n NAMESPACE get deploy DEPLOYMENT_NAME -o yaml > /tmp/deploy-no-hostpath.yaml

Edit /tmp/deploy-no-hostpath.yaml:

  • In .spec.template.spec.volumes[], remove entries like:
- name: data
hostPath:
path: /var/data
type: Directory
  • Replace with:
- name: data
emptyDir: {}
  • Ensure containers’ volumeMounts still reference name: data only (no change usually needed).

Apply the updated manifest:

kubectl apply -f /tmp/deploy-no-hostpath.yaml
  1. For standalone Pods not managed by a controller, recreate them from a manifest without hostPath:
kubectl -n NAMESPACE get pod POD_NAME -o yaml > /tmp/pod-no-hostpath.yaml

Edit /tmp/pod-no-hostpath.yaml:

  • Remove metadata.uid, metadata.resourceVersion, metadata.creationTimestamp, metadata.ownerReferences, status fields.
  • In .spec.volumes[], delete hostPath volumes and replace with emptyDir or a PVC-backed volume, for example:
volumes:
- name: data
emptyDir: {}

Delete and recreate the Pod:

kubectl -n NAMESPACE delete pod POD_NAME
kubectl apply -f /tmp/pod-no-hostpath.yaml
  1. Verification (same machine with kubectl access)
kubectl get pods --all-namespaces -o json | jq -r '
[ .items[]
| select(.metadata.namespace as $n | ["kube-system","kube-public","kube-node-lease"] | index($n) | not)
| .metadata as $m
| [ (.spec.volumes // [])[] | select(.hostPath != null) ] as $hp
| select(($hp | length) > 0)
] | if (length) == 0 then "is_compliant=true" else . end'
Automation
#!/usr/bin/env bash
#
# remediate_hostpath_pods.sh
#
# Purpose:
# For each non-system Pod that mounts a hostPath volume, patch the Pod spec
# to remove all hostPath volumes and any volumeMounts that reference them.
#
# Scope:
# Run on any machine with:
# - kubectl installed
# - Access/permissions to modify workloads in the target namespaces
#
# Notes / Limitations:
# - This script ONLY edits live Pod objects.
# - It does NOT modify controllers (Deployments, DaemonSets, etc.).
# Controllers will usually recreate Pods with the original spec (and
# hostPath volumes). To make changes persistent, you must also update the
# controllers’ manifests/templates manually or via your IaC.
# - Removing hostPath volumes may break workloads that depend on them.
# Review impact and ensure valid alternatives (e.g. PersistentVolumes,
# emptyDir, projected volumes) are configured in the owning controller.
#
# Idempotency:
# - Re-running the script will skip Pods with no hostPath volumes.
# - Re-running after a previous successful run will typically find no
# remaining hostPath volumes unless controllers recreated noncompliant Pods.

set -euo pipefail

# Ensure jq is available
if ! command -v jq >/dev/null 2>&1; then
echo "ERROR: jq is required but not installed. Install jq and re-run." >&2
exit 1
fi

# Ensure kubectl is available
if ! command -v kubectl >/dev/null 2>&1; then
echo "ERROR: kubectl is required but not installed. Install kubectl and re-run." >&2
exit 1
fi

echo "Discovering Pods that mount hostPath volumes (excluding kube-system, kube-public, kube-node-lease)..."

# Get the list of Pods with hostPath volumes (excluding system namespaces)
mapfile -t HOSTPATH_PODS < <(
kubectl get pods --all-namespaces -o json | \
jq -r '
.items[]
| select(.metadata.namespace as $n
| ["kube-system","kube-public","kube-node-lease"] | index($n) | not)
| select((.spec.volumes // []) | map(select(.hostPath != null)) | length > 0)
| "\(.metadata.namespace) \(.metadata.name)"
'
)

if [ "${#HOSTPATH_PODS[@]}" -eq 0 ]; then
echo "No Pods with hostPath volumes found outside system namespaces. Nothing to do."
else
echo "Found ${#HOSTPATH_PODS[@]} Pod(s) with hostPath volumes:"
printf ' - %s\n' "${HOSTPATH_PODS[@]}"
fi

for entry in "${HOSTPATH_PODS[@]}"; do
ns=$(awk '{print $1}' <<< "${entry}")
pod=$(awk '{print $2}' <<< "${entry}")

echo
echo "Processing Pod ${pod} in namespace ${ns}..."

# Check if Pod still exists (it may have been deleted/recreated since discovery)
if ! kubectl get pod "${pod}" -n "${ns}" >/dev/null 2>&1; then
echo " Pod no longer exists; skipping."
continue
fi

# Extract full Pod spec as JSON
pod_json=$(kubectl get pod "${pod}" -n "${ns}" -o json)

# Determine hostPath volumes names and paths
hp_info=$(jq -r '
(.spec.volumes // [])
| map(select(.hostPath != null) | {name: .name, path: .hostPath.path})
' <<< "${pod_json}")

hp_count=$(jq 'length' <<< "${hp_info}")
if [ "${hp_count}" -eq 0 ]; then
echo " Pod no longer has hostPath volumes; skipping."
continue
fi

echo " Found ${hp_count} hostPath volume(s):"
jq -r '.[] | " - name=\(.name) path=\(.path)"' <<< "${hp_info}"

# Compute list of hostPath volume names
hp_names=$(jq -r '.[].name' <<< "${hp_info}" | tr '\n' ' ')
read -r -a hp_names_array <<< "${hp_names}"

# Build JSONPatch operations:
# - Remove each hostPath volume in spec.volumes
# - Remove volumeMounts in each container/ephemeralContainer/ initContainer
# that reference these hostPath volumes
patches='[]'

# Remove hostPath volumes from spec.volumes
for vol_name in "${hp_names_array[@]}"; do
patches=$(jq --arg vname "${vol_name}" '
. + [{
"op": "remove",
"path": "/spec/volumes"
}]
' <<< "${patches}" 2>/dev/null || echo "${patches}")
done

# For each container type, remove volumeMounts that reference hostPath volumes
for ctype in containers initContainers ephemeralContainers; do
# Determine number of containers of this type
c_count=$(jq ".spec.${ctype} // [] | length" <<< "${pod_json}")
if [ "${c_count}" -eq 0 ]; then
continue
fi

for ((i=0; i< c_count; i++)); do
# For each container, identify indices of volumeMounts to remove
vm_indices=$(jq --argjson vnames "$(printf '%s\n' "${hp_names_array[@]}" | jq -R . | jq -s .)" \
".spec.${ctype}[${i}].volumeMounts // []
| to_entries
| map(select(.value.name as \$n | \$vnames | index(\$n)))
| map(.key)
| reverse" <<< "${pod_json}")

# For each index, append a remove operation
idx_count=$(jq 'length' <<< "${vm_indices}")
if [ "${idx_count}" -eq 0 ]; then
continue
fi

for ((k=0; k< idx_count; k++)); do
idx=$(jq ".[$k]" <<< "${vm_indices}")
patches=$(jq --arg ctype "${ctype}" --argjson ci "${i}" --argjson vi "${idx}" '
. + [{
"op": "remove",
"path": ("/spec/" + $ctype + "/" + ($ci|tostring) + "/volumeMounts/" + ($vi|tostring))
}]
' <<< "${patches}")
done
done
done

# Deduplicate patch operations and make them valid:
# - Removing /spec/volumes entirely is destructive; instead filter to remove
# only entries with hostPath. We'll compute a full replacement of spec.volumes.
#
# Rebuild spec.volumes without hostPath entries and use a single "replace" op.
new_volumes=$(jq '
(.spec.volumes // [])
| map(select(.hostPath == null))
' <<< "${pod_json}")

patches=$(jq --argjson vols "${new_volumes}" '
# filter out any prior /spec/volumes remove ops
map(select(.path != "/spec/volumes"))
+ [{
"op": "replace",
"path": "/spec/volumes",
"value": $vols
}]
' <<< "${patches}")

# If no volumeMount-related patches and volumes are already filtered, this may be a no-op
if [ "$(jq 'length' <<< "${patches}")" -eq 0 ]; then
echo " No applicable patches to apply; skipping."
continue
fi

echo " Applying JSONPatch to Pod..."
echo "${patches}" | kubectl patch pod "${pod}" -n "${ns}" \
--type=json -p "$(cat)" >/dev/null

echo " Patch applied."
done

echo
echo "Verification: re-running audit to ensure no remaining Pods with hostPath volumes..."

kubectl get pods --all-namespaces -o json | jq -r '
[ .items[]
| select(.metadata.namespace as $n | ["kube-system","kube-public","kube-node-lease"] | index($n) | not)
| .metadata as $m
| [ (.spec.volumes // [])[] | select(.hostPath != null) ] as $hp
| select(($hp | length) > 0)
| "kind=Pod ns=\($m.namespace) name=\($m.name) hostPaths=\([ $hp[] | .hostPath.path ] | join("+")) is_compliant=false"
] as $rows
| if ($rows | length) == 0 then "is_compliant=true" else $rows[] end
'