Skip to main content

Containers Should Use A Read-Only Root Filesystem

More Info:​

Verifies readOnlyRootFilesystem is true. A writable root filesystem lets an attacker persist tools or modify binaries inside a running container.

Risk Level​

Medium

Address​

Security

Compliance Standards​

  • Cloudanix Best Practice

Triage and Remediation​

Remediation​

Manual Steps
  1. Identify noncompliant Pods (run on 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.containers // []) + (.spec.initContainers // []))[]
    | (.securityContext.readOnlyRootFilesystem == true) as $ok
    | select($ok | not)
    | "ns=\($m.namespace) pod=\($m.name) ownerKind=\($own.kind // "Pod") ownerName=\($own.name // $m.name)"
    ] | unique[]'
  2. For each listed Pod, edit the owning workload manifest (e.g., Deployment) to set a read-only root filesystem (run on any machine with kubectl access; example for a Deployment):

    kubectl -n NAMESPACE edit deployment DEPLOYMENT_NAME

    In the opened spec, under each .spec.template.spec.containers[] (and .initContainers[] if present) add or modify:

    securityContext:
    readOnlyRootFilesystem: true

    Save and exit to apply the change.

  3. If the container needs a writable path, add an emptyDir volume and mount it there (run on any machine with kubectl access; continue in the same edit):

    • Under spec.template.spec.volumes:
      - name: writable-tmp
      emptyDir: {}
    • Under the container that needs write access:
      volumeMounts:
      - name: writable-tmp
      mountPath: /path/that/must/be/writable

    Adjust name and mountPath to match the application’s needs. Do not remove readOnlyRootFilesystem: true.

  4. For Pods created directly (no controller), update or recreate their manifests (run on any machine with kubectl access):

    • Export the Pod spec:
      kubectl -n NAMESPACE get pod POD_NAME -o yaml > /tmp/pod-NAMESPACE-POD_NAME.yaml
    • Edit the file, adding:
      securityContext:
      readOnlyRootFilesystem: true
      under each container and initContainer, and optionally add emptyDir + volumeMounts as in step 3.
    • Delete and recreate:
      kubectl -n NAMESPACE delete pod POD_NAME
      kubectl apply -f /tmp/pod-NAMESPACE-POD_NAME.yaml
  5. Allow the workloads to roll out the updated Pods and confirm all Pods are running (run on any machine with kubectl access):

    kubectl get pods --all-namespaces
  6. Verification (run on 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.containers // []) + (.spec.initContainers // []))[]
    | (.securityContext.readOnlyRootFilesystem == true) as $ok
    | select($ok | not)
    ] as $rows
    | if ($rows | length) == 0 then "is_compliant=true" else $rows[] end'

    The output should be is_compliant=true when all applicable containers use a read-only root filesystem.

Using kubectl

On any machine with kubectl access:

  1. Edit the Pod’s controller manifest (Deployment example)
kubectl -n YOUR_NAMESPACE get deploy YOUR_DEPLOYMENT -o yaml > /tmp/deploy.yaml

In /tmp/deploy.yaml, under each container (and initContainer) that should be read‑only, add or update:

spec:
template:
spec:
containers:
- name: your-container
image: your-image
securityContext:
readOnlyRootFilesystem: true
# If the app needs write access:
volumeMounts:
- name: writable-tmp
mountPath: /path/that/must/be/writable
volumes:
- name: writable-tmp
emptyDir: {}

Apply the updated manifest:

kubectl apply -f /tmp/deploy.yaml

Repeat the same pattern for other controllers (StatefulSet, DaemonSet, CronJob, Job) by replacing deploy and the kind in the kubectl get call.

  1. Directly patch a running Pod (only if it is not controlled by a Deployment/StatefulSet/etc.)
kubectl -n YOUR_NAMESPACE patch pod YOUR_POD \
--type='json' \
-p='[
{"op":"add","path":"/spec/containers/0/securityContext","value":{"readOnlyRootFilesystem":true}}
]'

Adjust the container index /0/ or patch additional containers as needed.

If a Pod-level initContainer also needs the setting:

kubectl -n YOUR_NAMESPACE patch pod YOUR_POD \
--type='json' \
-p='[
{"op":"add","path":"/spec/initContainers/0/securityContext","value":{"readOnlyRootFilesystem":true}}
]'
  1. Verification

Run the benchmark audit command again from 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.containers // []) + (.spec.initContainers // []))[]
| (.securityContext.readOnlyRootFilesystem == true) as $ok
| "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)
+ " container=\(.name) image=\(.image)"
+ " readOnlyRootFilesystem=\(if .securityContext.readOnlyRootFilesystem == null then "unset" else .securityContext.readOnlyRootFilesystem end)"
+ " is_compliant=\(if $ok then "true" else "false" end)"
] as $rows
| if ($rows | length) == 0 then "is_compliant=true" else $rows[] end'
Automation
#!/usr/bin/env bash
# Purpose: Ensure all non-system Pods use readOnlyRootFilesystem=true on every container
# Scope: Any machine with kubectl access to the AKS cluster
# Impact: Pods will be deleted and recreated as updated Deployments/Workloads roll out

set -euo pipefail

# REQUIREMENTS:
# - kubectl configured to point to the AKS cluster
# - jq installed

timestamp="$(date +%Y%m%d-%H%M%S)"
backup_dir="./readonly-rootfs-backups-${timestamp}"
mkdir -p "${backup_dir}"

echo "==> Discovering non-compliant Pods and their owning workloads..."

# Fetch pods JSON once
pods_json="$(kubectl get pods --all-namespaces -o json)"

# Function: list unique owning controllers (kind, namespace, name, apiVersion)
# for pods with any container that has readOnlyRootFilesystem unset/false
echo "${pods_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
| select($own != null)
| (.spec.containers // [] + .spec.initContainers // []) as $cs
| select(any($cs[];
(.securityContext.readOnlyRootFilesystem // "unset") != true))
| "\($own.apiVersion),\($own.kind),\($m.namespace),\($own.name)"
' \
| sort -u > "${backup_dir}/non_compliant_owners.list"

if [[ ! -s "${backup_dir}/non_compliant_owners.list" ]]; then
echo "No non-compliant workloads found. Cluster is already compliant."
exit 0
fi

echo "Found non-compliant owning workloads:"
cat "${backup_dir}/non_compliant_owners.list"
echo

patch_count=0

while IFS=',' read -r apiVersion kind namespace name; do
# Skip types we cannot confidently patch generically
case "${kind}" in
Deployment|StatefulSet|DaemonSet|ReplicaSet|Job|CronJob)
;;
*)
echo "Skipping ${kind}/${namespace}/${name} (not a supported controller type). Review manually."
continue
;;
esac

res="${kind,,}" # lowercase resource type for kubectl
# Special pluralization
case "${res}" in
deployment) res="deployments" ;;
statefulset) res="statefulsets" ;;
daemonset) res="daemonsets" ;;
replicaset) res="replicasets" ;;
job) res="jobs" ;;
cronjob) res="cronjobs" ;;
esac

echo "==> Processing ${kind}/${namespace}/${name}"

# Backup full manifest
backup_file="${backup_dir}/${namespace}-${kind}-${name}.yaml"
kubectl -n "${namespace}" get "${res}" "${name}" -o yaml > "${backup_file}"
echo " Backup saved to ${backup_file}"

# Build a strategic merge patch that enforces readOnlyRootFilesystem: true
# for every container and initContainer in the pod spec.
# We fetch the current pod template, then construct a patch dynamically.
tmpl_json="$(kubectl -n "${namespace}" get "${res}" "${name}" -o json)"

containers_patch="$(echo "${tmpl_json}" | jq '
.spec.template.spec.containers
| map({
name: .name,
securityContext: (
.securityContext // {} | .readOnlyRootFilesystem = true
)
})
')"

init_containers_patch="$(echo "${tmpl_json}" | jq '
(.spec.template.spec.initContainers // [])
| map({
name: .name,
securityContext: (
.securityContext // {} | .readOnlyRootFilesystem = true
)
})
')"

patch_json="$(jq -n --argjson c "${containers_patch}" --argjson ic "${init_containers_patch}" '
{
"spec": {
"template": {
"spec": {
"containers": $c
}
}
}
+ if ($ic | length) > 0 then
{ "spec": { "template": { "spec": { "initContainers": $ic } } } }
else {} end
}
')"

echo " Applying patch to set readOnlyRootFilesystem=true..."
echo "${patch_json}" | kubectl -n "${namespace}" patch "${res}" "${name}" \
--type merge -p "$(cat)"

patch_count=$((patch_count + 1))
done < "${backup_dir}/non_compliant_owners.list"

if [[ "${patch_count}" -eq 0 ]]; then
echo "No supported controller types were patched. Remaining resources must be reviewed and fixed manually."
else
echo "Patched ${patch_count} workload(s). Waiting for Pods to be updated..."
# Give controllers time to roll out new Pods
sleep 10
fi

echo
echo "==> Verifying compliance (excluding kube-system, kube-public, kube-node-lease)..."

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.containers // []) + (.spec.initContainers // []))[]
| (.securityContext.readOnlyRootFilesystem == true) as $ok
| select($ok | not)
| "ns=\($m.namespace) pod=\($m.name) container=\(.name) readOnlyRootFilesystem=\(if .securityContext.readOnlyRootFilesystem == null then "unset" else .securityContext.readOnlyRootFilesystem end)"
] as $rows
| if ($rows | length) == 0 then
"All evaluated containers have readOnlyRootFilesystem=true (is_compliant=true)"
else
"Non-compliant containers remain:\n" + ($rows | join("\n"))
end
'

echo
echo "Script completed. Review backups in ${backup_dir} if rollback is needed."