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 non-system pods using hostPath and identify owners (Deployments, DaemonSets, 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
    | ([ ($m.ownerReferences // [])[] | select(.controller) ] | first) as $own
    | [ (.spec.volumes // [])[] | select(.hostPath != null) ] as $hp
    | select(($hp | length) > 0)
    | "\($m.namespace) \($m.name) \($own.kind)//\($own.name)//\($own.uid)" ][]'
  2. For each affected pod, get the full manifest of its controller (example for a Deployment; run on any machine with kubectl access):

    # Replace NAMESPACE and DEPLOYMENT_NAME from step 1 output
    kubectl -n NAMESPACE get deploy DEPLOYMENT_NAME -o yaml > /tmp/DEPLOYMENT_NAME.yaml

    For other controllers, change deploy to statefulset, daemonset, job, or cronjob as appropriate.

  3. Edit the saved manifest to remove hostPath volumes and references (any machine with kubectl access):

    • Open the file:
      vi /tmp/DEPLOYMENT_NAME.yaml
    • Under spec.template.spec.volumes, delete any entries containing hostPath:, including the path field.
    • Under spec.template.spec.containers[].volumeMounts, delete mounts that reference the removed volume names.
    • Optionally add safer replacements such as emptyDir: {} or PVC-backed volumes according to your application’s storage requirements.
  4. Apply the updated manifest (any machine with kubectl access):

    kubectl apply -f /tmp/DEPLOYMENT_NAME.yaml

    Confirm old pods are being replaced and new pods are running:

    kubectl -n NAMESPACE get pods -l app=APP_LABEL
  5. For any stand-alone Pod objects (no controller ownerReference), edit them in place to remove hostPath (any machine with kubectl access):

    kubectl -n NAMESPACE get pod POD_NAME -o yaml > /tmp/POD_NAME.yaml
    vi /tmp/POD_NAME.yaml
    # Remove hostPath entries and related volumeMounts as in step 3
    kubectl delete pod -n NAMESPACE POD_NAME
    kubectl apply -f /tmp/POD_NAME.yaml
  6. Verify no non-system pods use 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.volumes // [])[] | select(.hostPath != null) ] as $hp
    | select(($hp | length) > 0)
    ] as $rows
    | if ($rows | length) == 0 then "is_compliant=true" else $rows[] end'

    Compliance is achieved when the output is exactly:
    is_compliant=true

Using kubectl

On any machine with kubectl access:

  1. Identify the offending pod and workload
kubectl get pods --all-namespaces \
-o wide

Find the namespace and pod reported in the finding, then see what owns it:

kubectl -n NAMESPACE get pod POD_NAME -o jsonpath='{.metadata.ownerReferences}'
  • If ownerReferences is empty: it’s a naked Pod; you will edit/recreate the Pod.
  • If it shows a controller (Deployment, StatefulSet, DaemonSet, etc.): you will edit that controller, not the pod.
  1. Export the current spec

For a naked Pod (no controller):

kubectl -n NAMESPACE get pod POD_NAME -o yaml > pod-fixed.yaml

For a controller (example: Deployment; adjust kind/name as needed):

kubectl -n NAMESPACE get deployment DEPLOYMENT_NAME -o yaml > deploy-fixed.yaml
  1. Edit the manifest to remove hostPath

Open the saved YAML file and in the pod template:

  • Under spec.volumes, delete any entries that contain hostPath:.
  • Under each container’s volumeMounts, delete mounts that refer to those deleted volumes.

Example of what to remove:

spec:
volumes:
- name: host-logs
hostPath:
path: /var/log
type: Directory

And its mount:

containers:
- name: app
volumeMounts:
- name: host-logs
mountPath: /var/log/host

If storage is still needed, replace with one of:

  • emptyDir:

    volumes:
    - name: app-data
    emptyDir: {}
  • A PersistentVolumeClaim (assuming it already exists):

    volumes:
    - name: app-data
    persistentVolumeClaim:
    claimName: EXISTING_PVC_NAME

Then mount app-data instead of the hostPath volume in volumeMounts.

  1. Apply the updated manifest

For a naked Pod, delete and recreate (Pods cannot be updated in place):

kubectl -n NAMESPACE delete pod POD_NAME
kubectl apply -f pod-fixed.yaml

For a controller (example: Deployment):

kubectl apply -f deploy-fixed.yaml

Kubernetes will roll out new pods without the hostPath volumes.

  1. Verification

On any machine with kubectl access, rerun:

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 for: Pods Should Not Mount hostPath Volumes (CBP C1.7)
# Scope: Any machine with kubectl access to the OKE cluster
#
# Strategy:
# - Find all non-system Pods that use hostPath volumes
# - For Pods that are part of a controller (Deployment/DaemonSet/StatefulSet/Job/CronJob/ReplicaSet/ReplicationController),
# remove hostPath volumes from the controller spec.
# - For naked Pods, warn and optionally delete (cannot safely auto-recreate).
# - Verify that no remaining Pods use hostPath volumes.
#
# Requirements:
# - kubectl configured with cluster-admin (or equivalent) access
# - jq available in PATH
# - bash 4+

set -euo pipefail

# --------- Helpers ---------

need_bin() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "ERROR: Required binary '$1' not found in PATH" >&2
exit 1
fi
}

need_bin kubectl
need_bin jq

KUBECTL_CONTEXT="${KUBECTL_CONTEXT:-}"

kc() {
if [[ -n "${KUBECTL_CONTEXT}" ]]; then
kubectl --context "${KUBECTL_CONTEXT}" "$@"
else
kubectl "$@"
fi
}

echo "Using kubectl context:"
kc config current-context

# Ensure we can talk to the cluster
kc get ns >/dev/null

# Namespaces excluded from the check (per audit command)
EXCLUDED_NS_REGEX='^(kube-system|kube-public|kube-node-lease)$'

timestamp() {
date -u +"%Y%m%dT%H%M%SZ"
}

backup_dir="./hostpath-controller-backups-$(timestamp)"
mkdir -p "${backup_dir}"

# --------- Discovery ---------

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

# Get detailed JSON for pods with hostPath and not in excluded namespaces
PODS_JSON="$(kc get pods --all-namespaces -o json)"

AFFECTED_PODS_JSON="$(
echo "${PODS_JSON}" | jq '
.items[]
| select(.metadata.namespace | test("'"${EXCLUDED_NS_REGEX}"'") | not)
| (.spec.volumes // []) as $vols
| select([ $vols[]? | select(.hostPath != null) ] | length > 0)
'
)"

if [[ -z "${AFFECTED_PODS_JSON}" ]]; then
echo "No pods with hostPath volumes found outside excluded namespaces."
exit 0
fi

# --------- Process each affected Pod ---------

echo "Processing affected pods..."

# We iterate pod by pod
echo "${AFFECTED_PODS_JSON}" | jq -c '.' | while read -r POD; do
NS="$(echo "${POD}" | jq -r '.metadata.namespace')"
NAME="$(echo "${POD}" | jq -r '.metadata.name')"

# Extract controller ownerReference (if any)
OWNER_JSON="$(echo "${POD}" | jq -c '[.metadata.ownerReferences // [] | .[] | select(.controller == true)] | first // empty')"

if [[ -z "${OWNER_JSON}" || "${OWNER_JSON}" == "null" ]]; then
echo "WARN: Pod ${NS}/${NAME} is a naked Pod using hostPath. Automation will NOT modify naked Pods."
echo " Consider: kubectl delete pod -n ${NS} ${NAME} after creating a replacement without hostPath."
continue
fi

OWNER_KIND="$(echo "${OWNER_JSON}" | jq -r '.kind')"
OWNER_NAME="$(echo "${OWNER_JSON}" | jq -r '.name')"
OWNER_UID="$(echo "${OWNER_JSON}" | jq -r '.uid')"

echo "Found controller-managed Pod: ${NS}/${NAME} owned by ${OWNER_KIND}/${OWNER_NAME}"

# Map ReplicaSet/ReplicationController back to top-level controller when possible
TARGET_KIND="${OWNER_KIND}"
TARGET_NAME="${OWNER_NAME}"
TARGET_NS="${NS}"

case "${OWNER_KIND}" in
ReplicaSet)
# Attempt to find owning Deployment
RS_JSON="$(kc -n "${NS}" get replicaset "${OWNER_NAME}" -o json || true)"
if [[ -n "${RS_JSON}" ]]; then
DEPLOY_OWNER="$(echo "${RS_JSON}" | jq -c '[.metadata.ownerReferences // [] | .[] | select(.controller == true and .kind == "Deployment")] | first // empty')"
if [[ -n "${DEPLOY_OWNER}" && "${DEPLOY_OWNER}" != "null" ]]; then
TARGET_KIND="Deployment"
TARGET_NAME="$(echo "${DEPLOY_OWNER}" | jq -r '.name')"
fi
fi
;;
Job)
# Attempt to find owning CronJob
JOB_JSON="$(kc -n "${NS}" get job "${OWNER_NAME}" -o json || true)"
if [[ -n "${JOB_JSON}" ]]; then
CJ_OWNER="$(echo "${JOB_JSON}" | jq -c '[.metadata.ownerReferences // [] | .[] | select(.controller == true and .kind == "CronJob")] | first // empty')"
if [[ -n "${CJ_OWNER}" && "${CJ_OWNER}" != "null" ]]; then
TARGET_KIND="CronJob"
TARGET_NAME="$(echo "${CJ_OWNER}" | jq -r '.name')"
fi
fi
;;
*)
# DaemonSet, StatefulSet, Deployment, CronJob, ReplicationController, etc.
:
;;
esac

echo " Target controller to patch: ${TARGET_KIND}/${TARGET_NS}/${TARGET_NAME}"

# Fetch controller manifest
CONTROLLER_FILE="${backup_dir}/${TARGET_NS}_${TARGET_KIND}_${TARGET_NAME}.yaml"

if [[ ! -f "${CONTROLLER_FILE}" ]]; then
echo " Backing up existing controller manifest to ${CONTROLLER_FILE}"
if ! kc -n "${TARGET_NS}" get "${TARGET_KIND,,}.${TARGET_NS}/${TARGET_NAME}" >/dev/null 2>&1; then
# Fallback to kubectl get KIND NAME form (most common)
kc -n "${TARGET_NS}" get "${TARGET_KIND}" "${TARGET_NAME}" -o yaml > "${CONTROLLER_FILE}"
else
kc get "${TARGET_KIND,,}.${TARGET_NS}/${TARGET_NAME}" -o yaml > "${CONTROLLER_FILE}"
fi
else
echo " Backup already exists: ${CONTROLLER_FILE}"
fi

# Generate patched manifest with hostPath removed from pod template
PATCHED_FILE="${backup_dir}/${TARGET_NS}_${TARGET_KIND}_${TARGET_NAME}_patched.yaml"

echo " Generating patched manifest (removing hostPath volumes from pod template)..."

# Use jq to strip hostPath volumes and volumeMounts referencing them from pod template
# NOTE: This is best-effort; manual review of ${PATCHED_FILE} is strongly recommended.
kc -n "${TARGET_NS}" get "${TARGET_KIND}" "${TARGET_NAME}" -o json | jq '
# work with .spec.template.spec
( .spec.template.spec ) as $spec
| (
$spec.volumes // []
| map(select(.hostPath == null))
) as $cleanVolumes
| $spec.containers as $containers
| $containers as $origContainers
| $origContainers
| map(
. as $c
| .volumeMounts = (
($c.volumeMounts // [])
| map(select(.name as $vn | ($cleanVolumes | map(.name) | index($vn)) != null))
)
) as $cleanContainers
| $spec.initContainers as $initContainers
| $initContainers as $origInitContainers
| $origInitContainers
| map(
. as $c
| .volumeMounts = (
($c.volumeMounts // [])
| map(select(.name as $vn | ($cleanVolumes | map(.name) | index($vn)) != null))
)
) as $cleanInitContainers
| .spec.template.spec.volumes = $cleanVolumes
| if ($cleanContainers | length) > 0 then
.spec.template.spec.containers = $cleanContainers
else
.
end
| if ($cleanInitContainers | length) > 0 then
.spec.template.spec.initContainers = $cleanInitContainers
else
.
end
' > "${PATCHED_FILE}"

echo " Applying patched manifest for ${TARGET_KIND}/${TARGET_NS}/${TARGET_NAME}..."
kc -n "${TARGET_NS}" apply -f "${PATCHED_FILE}"

echo " Controller patched. Pods will be recreated without hostPath volumes (if possible)."

done

# --------- Verification ---------

echo "Waiting 10 seconds for controllers to reconcile..."
sleep 10

echo "Verifying that no remaining pods use hostPath volumes (excluding kube-system, kube-public, kube-node-lease)..."

VERIFY_OUTPUT="$(
kc 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'
)"

echo "${VERIFY_OUTPUT}"

# If any non-compliant line remains, exit non-zero
if echo "${VERIFY_OUTPUT}" | grep -q "is_compliant=false"; then
echo "ERROR: Some pods still use hostPath volumes. Manual review required."
echo " Inspect the above list and adjust controller specs or naked pods accordingly."
exit 2
fi

echo "Remediation succeeded: all checked pods are compliant (no hostPath volumes)."
exit 0