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. On any machine with kubectl access, list all pods using hostPath volumes (excluding system namespaces) and note which are controlled by higher-level resources (Deployment/DaemonSet/StatefulSet/Job/etc.):

    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
    | [ ($m.ownerReferences // [])[] | select(.controller) ] | first as $own
    | select(($hp | length) > 0)
    | "\($m.namespace) \($m.name) \($own.kind // "Pod") \($own.name // $m.name)"
    ][]'
  2. For each offending pod that is controlled by a workload resource (e.g., Deployment), edit the controller manifest and remove/replace the hostPath volumes. Example for a Deployment:

    kubectl -n NAMESPACE edit deployment CONTROLLER_NAME

    In the editor:

    • Under spec.template.spec.volumes, delete any entries containing hostPath: or replace them with emptyDir: {} or a PersistentVolume/PVC reference as appropriate.
    • Under spec.template.spec.containers[].volumeMounts, remove the corresponding name: entries or adjust them to point to the new volume type. Save and exit; Kubernetes will roll out new pods without hostPath.
  3. For pods created directly (no controller) using hostPath, export, modify, and reapply their manifests:

    kubectl -n NAMESPACE get pod POD_NAME -o yaml > /tmp/pod-with-hostpath.yaml

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

    • Remove the entire metadata: block fields resourceVersion, uid, selfLink, creationTimestamp, status, and any ownerReferences.
    • Under spec.volumes, remove or replace any entries containing hostPath: with emptyDir: {} or a PV/PVC.
    • Update spec.containers[].volumeMounts to remove or point away from those volume names.

    Then delete and recreate the pod:

    kubectl -n NAMESPACE delete pod POD_NAME
    kubectl -n NAMESPACE apply -f /tmp/pod-with-hostpath.yaml
  4. For workloads that legitimately need node storage, create and use PersistentVolumes and PersistentVolumeClaims instead of hostPath (on any machine with kubectl access). Example (adapt for your case) to create a PVC and reference it from the workload:

    cat <<'EOF' > /tmp/pvc.yaml
    apiVersion: v1
    kind: PersistentVolumeClaim
    metadata:
    name: app-pvc
    namespace: NAMESPACE
    spec:
    accessModes:
    - ReadWriteOnce
    resources:
    requests:
    storage: 10Gi
    storageClassName: standard
    EOF

    kubectl apply -f /tmp/pvc.yaml

    Then in the controller manifest (kubectl -n NAMESPACE edit deployment CONTROLLER_NAME), use:

    spec:
    template:
    spec:
    volumes:
    - name: app-storage
    persistentVolumeClaim:
    claimName: app-pvc
    containers:
    - name: APP_CONTAINER
    volumeMounts:
    - name: app-storage
    mountPath: /path/in/container
  5. For any third-party or GKE-managed add-ons that appear to use hostPath, review their documentation and only modify them if they are not required or if the vendor provides a hostPath-free configuration. If you must keep a hostPath-based add-on, document the risk and namespace, labels, and pod names for risk acceptance:

    kubectl -n NAMESPACE get pod POD_NAME -o yaml > /tmp/justify-hostpath-POD_NAME.yaml
  6. Verification (on any machine with kubectl access): rerun the audit command and confirm there are no lines with is_compliant=false and no hostPaths= fields:

    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'

    The cluster is compliant when the output is either is_compliant=true only, or all listed pods show is_compliant=true and no hostPaths=.

Using kubectl

On any machine with kubectl access:

  1. Identify offending pods and their owners
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'

For each non‑compliant line, note:

  • ns=<namespace>
  • owner=<Kind>/<namespace>/<name>/... if present; otherwise treat it as a standalone Pod.
  1. Edit the controller manifest to remove hostPath

For a Deployment (example):

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, delete any entries with hostPath:.
  • In spec.template.spec.containers[].volumeMounts, remove mounts that referenced those hostPath volumes.
  • If needed, add a safer volume type, for example:
spec:
template:
spec:
volumes:
- name: app-data
emptyDir: {}
containers:
- name: app
volumeMounts:
- name: app-data
mountPath: /app/data

Apply the updated manifest:

kubectl apply -f /tmp/deploy-no-hostpath.yaml

Repeat the same pattern for other controller types as applicable:

  • ReplicaSet: kubectl -n NAMESPACE get rs NAME -o yaml > /tmp/rs-no-hostpath.yaml
  • StatefulSet: kubectl -n NAMESPACE get sts NAME -o yaml > /tmp/sts-no-hostpath.yaml
  • DaemonSet: kubectl -n NAMESPACE get ds NAME -o yaml > /tmp/ds-no-hostpath.yaml
  • Job/CronJob: kubectl -n NAMESPACE get job|cronjob NAME -o yaml > /tmp/job-no-hostpath.yaml

Then edit to remove hostPath volumes and corresponding mounts, replace with emptyDir, PersistentVolumeClaims, or projected volumes, and:

kubectl apply -f /tmp/<edited-file>.yaml
  1. For standalone Pods (no controller)

Export, edit, and recreate without hostPath:

kubectl -n NAMESPACE get pod POD_NAME -o yaml \
| sed '/^[ ]*resourceVersion:/d;/^[ ]*uid:/d;/^[ ]*creationTimestamp:/d;/^[ ]*selfLink:/d;/^[ ]*managedFields:/d;/^[ ]*status:/,$d' \
> /tmp/pod-no-hostpath.yaml

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

  • Remove hostPath entries under spec.volumes.
  • Remove corresponding volumeMounts.
  • Optionally add safer volume types as above.

Delete and recreate:

kubectl -n NAMESPACE delete pod POD_NAME
kubectl apply -f /tmp/pod-no-hostpath.yaml
  1. Verification

Run the original audit command and confirm either is_compliant=true only, or that no lines show 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'
Automation
#!/usr/bin/env bash
#
# Remediation script for: Pods Should Not Mount HostPath Volumes (CBP C1.7)
# Platform: GKE (managed)
# Surface: Kubernetes API objects via kubectl/manifests
#
# Runs on: any machine with kubectl access to the cluster/context.
#
# Behavior:
# - Scans all namespaces (except kube-system, kube-public, kube-node-lease)
# for pods using hostPath volumes.
# - For each owning controller (Deployment/DaemonSet/StatefulSet/Job/CronJob/ReplicaSet/ReplicationController)
# it writes a patched manifest with hostPath volumes removed into ./backup-hostpath-fix
# but DOES NOT APPLY ANY CHANGES AUTOMATICALLY.
# - Prints next-step instructions.
# - Re-runs the audit command to verify once you have applied the changes.
#
# This is idempotent: re-running overwrites the backup/patch files for the
# same controllers and re-runs the audit.
#
# IMPORTANT:
# - Removal of hostPath volumes is an application-level change and must be
# evaluated by the owning team. This script only prepares manifests to edit.
# - You must manually replace hostPath with a safer volume type
# (PersistentVolume, emptyDir, projected volumes, etc.) before applying.

set -euo pipefail

BACKUP_DIR="./backup-hostpath-fix"
mkdir -p "${BACKUP_DIR}"

echo "=== Step 1: Discover pods using hostPath volumes (excluding system namespaces) ==="

# Store audit output for later comparison
AUDIT_BEFORE="${BACKUP_DIR}/audit-before.txt"

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' | tee "${AUDIT_BEFORE}"

# Extract controllers that own non-compliant pods
echo
echo "=== Step 2: Identify owning controllers for non-compliant pods ==="

# jq filter to output: namespace kind name for each unique controller
CONTROLLERS_FILE="${BACKUP_DIR}/controllers.txt"

grep "is_compliant=false" "${AUDIT_BEFORE}" || true

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)
| [ (.spec.volumes // [])[] | select(.hostPath != null) ] as $hp
| select(($hp | length) > 0)
| [ (.metadata.ownerReferences // [])[] | select(.controller) ] | first as $own
| .metadata.namespace as $ns
| if $own == null then
# Standalone pod
"\($ns) Pod \(.metadata.name)"
else
"\($ns) \($own.kind) \($own.name)"
end
' | sort -u > "${CONTROLLERS_FILE}"

if [[ ! -s "${CONTROLLERS_FILE}" ]]; then
echo "No non-compliant pods discovered (or only in system namespaces)."
echo "Cluster appears compliant for this control."
exit 0
fi

echo "Controllers (or standalone Pods) owning pods with hostPath volumes:"
cat "${CONTROLLERS_FILE}"

echo
echo "=== Step 3: Export current manifests for review and patch preparation ==="
echo "Manifests will be written under: ${BACKUP_DIR}"
echo

while read -r NS KIND NAME; do
SAFE_KIND="${KIND}"
# Normalize common aliases to full kinds (kubectl handles short names but keep it explicit in filenames)
case "${KIND}" in
ds|daemonset|Daemonset) SAFE_KIND="DaemonSet" ;;
deploy|deployment|Deployment) SAFE_KIND="Deployment" ;;
sts|statefulset|Statefulset) SAFE_KIND="StatefulSet" ;;
rs|replicaset|ReplicaSet) SAFE_KIND="ReplicaSet" ;;
rc|replicationcontroller|Replicationcontroller) SAFE_KIND="ReplicationController" ;;
job|Job) SAFE_KIND="Job" ;;
cj|cronjob|Cronjob) SAFE_KIND="CronJob" ;;
pod|Pod) SAFE_KIND="Pod" ;;
esac

OUT_BASE="${BACKUP_DIR}/${NS}__${SAFE_KIND}__${NAME}"
MANIFEST_ORIG="${OUT_BASE}-original.yaml"
MANIFEST_PATCH="${OUT_BASE}-hostpath-removed.yaml"

echo "Exporting ${SAFE_KIND} ${NS}/${NAME} to ${MANIFEST_ORIG}"

# Export current manifest
if ! kubectl get "${SAFE_KIND}" "${NAME}" -n "${NS}" -o yaml > "${MANIFEST_ORIG}" 2>/dev/null; then
echo "WARNING: Failed to get ${SAFE_KIND} ${NS}/${NAME}. It may have been deleted or the kind is unsupported. Skipping."
continue
fi

# Prepare a version with hostPath volumes removed, for manual review/edit.
# This removes any volumes with hostPath and their corresponding volumeMounts in containers.
echo "Preparing tentative hostPath-removed manifest at ${MANIFEST_PATCH}"

# Use yq if available, otherwise just copy and let user edit manually.
if command -v yq >/dev/null 2>&1; then
yq '
# Remove hostPath volumes
(.. | select(has("volumes"))).volumes |=
( . // [] | map(select(.hostPath == null)) ) |
# For each container, remove volumeMounts that refer to deleted volumes
. as $root |
def strip_mounts(path):
(.spec.template.spec.containers // []) as $c |
(.spec.template.spec.volumes // []) as $v |
.spec.template.spec.containers |=
( $c
| map(
.volumeMounts |=
( . // [] |
map(select(.name as $n | ($v | map(.name) | index($n)) != null))
)
)
) ;
# For standalone Pod
if .kind == "Pod" then
(.spec.containers // []) as $c |
(.spec.volumes // []) as $v |
.spec.containers |=
( $c
| map(
.volumeMounts |=
( . // [] |
map(select(.name as $n | ($v | map(.name) | index($n)) != null))
)
)
)
else
strip_mounts(.)
end
' "${MANIFEST_ORIG}" > "${MANIFEST_PATCH}"
else
cp "${MANIFEST_ORIG}" "${MANIFEST_PATCH}"
echo "NOTE: yq not found; ${MANIFEST_PATCH} is a copy of the original."
echo " Manually edit it to remove hostPath volumes and corresponding mounts."
fi

done < "${CONTROLLERS_FILE}"

cat <<'EOF'

=== Step 4: Manual review & application required ===

For each generated *-hostpath-removed.yaml file in ./backup-hostpath-fix:

1. Open the file and ensure all `hostPath` volumes have been removed.
2. Replace them with safer volume types where needed:
- PersistentVolumeClaim (PVC) backed storage
- `emptyDir`
- projected/configMap/secret volumes
3. Confirm the resulting spec is acceptable for the workload owner.
4. Apply the manifest to the cluster, for example:
kubectl apply -f ./backup-hostpath-fix/<namespace>__<Kind>__<name>-hostpath-removed.yaml

This will update the controller and cause pods to be recreated without hostPath volumes.

EOF

echo "=== Step 5: Verification (re-run audit) ==="
echo "After you have applied the updated manifests, run the following command to verify:"
echo
cat <<'EOF'
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'
EOF

echo
echo "Compliance target: no line with 'is_compliant=false' outside system namespaces."