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
Remediation
Manual Steps
-
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)" ][]' -
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 outputkubectl -n NAMESPACE get deploy DEPLOYMENT_NAME -o yaml > /tmp/DEPLOYMENT_NAME.yamlFor other controllers, change
deploytostatefulset,daemonset,job, orcronjobas appropriate. -
Edit the saved manifest to remove
hostPathvolumes and references (any machine with kubectl access):- Open the file:
vi /tmp/DEPLOYMENT_NAME.yaml
- Under
spec.template.spec.volumes, delete any entries containinghostPath:, including thepathfield. - 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.
- Open the file:
-
Apply the updated manifest (any machine with kubectl access):
kubectl apply -f /tmp/DEPLOYMENT_NAME.yamlConfirm old pods are being replaced and new pods are running:
kubectl -n NAMESPACE get pods -l app=APP_LABEL -
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.yamlvi /tmp/POD_NAME.yaml# Remove hostPath entries and related volumeMounts as in step 3kubectl delete pod -n NAMESPACE POD_NAMEkubectl apply -f /tmp/POD_NAME.yaml -
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:
- 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
ownerReferencesis 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.
- 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
- Edit the manifest to remove
hostPath
Open the saved YAML file and in the pod template:
- Under
spec.volumes, delete any entries that containhostPath:. - 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-dataemptyDir: {} -
A PersistentVolumeClaim (assuming it already exists):
volumes:- name: app-datapersistentVolumeClaim:claimName: EXISTING_PVC_NAME
Then mount app-data instead of the hostPath volume in volumeMounts.
- 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.
- 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