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
Remediation​
Manual Steps
-
Identify non-compliant Pods and their controllers (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| ([ ($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) image=\(.image)"+ (if $own == null then "" else " ownerKind=\($own.kind) ownerName=\($own.name)" end)][]' -
For each affected controller (Deployment, StatefulSet, DaemonSet, Job, CronJob, etc.), edit the manifest to set
readOnlyRootFilesystem: true(run on any machine with kubectl access). Example for a Deployment:kubectl -n NAMESPACE edit deployment DEPLOYMENT_NAMEIn each affected container (and initContainer if present) under
spec.template.spec.containers[](orinitContainers[]), ensure:securityContext:readOnlyRootFilesystem: trueIf
securityContextalready exists, add only thereadOnlyRootFilesystem: trueline. -
If a container needs a writable path, add an
emptyDirand mount it (samekubectl editsession as step 2):- Under
spec.template.spec.volumes:- name: writable-tmpemptyDir: {} - Under the appropriate container:
volumeMounts:- name: writable-tmpmountPath: /path/that/must/be/writable
Adjust
nameandmountPathas needed for each workload. - Under
-
For standalone Pods that are not managed by a higher-level controller, export, edit, and re-apply (run on any machine with kubectl access):
kubectl -n NAMESPACE get pod POD_NAME -o yaml > /tmp/pod-POD_NAME.yamlEdit
/tmp/pod-POD_NAME.yaml:- Remove
metadata.uid,metadata.resourceVersion,metadata.creationTimestamp,metadata.ownerReferences,statusand other server-populated fields. - Under
spec.containers[]andspec.initContainers[]set:and, if needed, addsecurityContext:readOnlyRootFilesystem: trueemptyDir+volumeMountsas in step 3. Apply:
kubectl -n NAMESPACE delete pod POD_NAMEkubectl -n NAMESPACE apply -f /tmp/pod-POD_NAME.yaml - Remove
-
Wait for updated workloads to roll out and confirm Pods are running (run on any machine with kubectl access):
kubectl -n NAMESPACE get pods -w -
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.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'Confirm the output is
is_compliant=trueor that all listed containers showis_compliant=true.
Using kubectl
On any machine with kubectl access:
- Identify non‑compliant Pods (and their controllers)
kubectl get pods --all-namespaces -o wide
Use the audit output you already have to see the owner= field (e.g. Deployment/StatefulSet/Job). You must edit the owning controller, not the live Pod.
- Patch a controller to set
readOnlyRootFilesystem: true
Example: patch a Deployment in namespace my-app named web so all containers use a read‑only root filesystem:
kubectl -n my-app patch deployment web \
--type='json' \
-p='[
{
"op": "add",
"path": "/spec/template/spec/containers/0/securityContext",
"value": {
"readOnlyRootFilesystem": true
}
}
]'
If securityContext already exists, use:
kubectl -n my-app patch deployment web \
--type='json' \
-p='[
{
"op": "add",
"path": "/spec/template/spec/containers/0/securityContext/readOnlyRootFilesystem",
"value": true
}
]'
Repeat for each non‑compliant controller (Deployment, StatefulSet, DaemonSet, Job, CronJob), adjusting the resource type, name, namespace, and container index as needed.
- Provide writable paths via
emptyDir(when needed)
If an application needs to write to specific paths, mount emptyDir volumes there instead of leaving the root filesystem writable. Example manifest snippet:
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
namespace: my-app
spec:
replicas: 1
selector:
matchLabels:
app: web
template:
metadata:
labels:
app: web
spec:
volumes:
- name: tmp
emptyDir: {}
containers:
- name: web
image: gcr.io/my-project/web:1.0.0
securityContext:
readOnlyRootFilesystem: true
volumeMounts:
- name: tmp
mountPath: /tmp
Apply the manifest:
kubectl apply -f /absolute/path/to/web-deployment.yaml
- Verification
After controllers have rolled out new Pods, re‑run the audit command from any machine with kubectl:
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
#
# Enforce readOnlyRootFilesystem=true on Pods' owning controllers in a GKE cluster.
# Applies to all namespaces except kube-system, kube-public, kube-node-lease.
#
# REQUIREMENTS (run on any machine with kubectl access):
# - kubectl
# - jq
#
# LIMITATIONS:
# - Only edits controllers (Deployment, StatefulSet, DaemonSet, ReplicaSet, Job, CronJob).
# - Skips naked Pods and controllers you lack RBAC to patch.
# - For containers that must write, you must manually add an emptyDir and mount it.
set -euo pipefail
echo "[INFO] Discovering non-compliant containers..."
NON_COMPLIANT_JSON="$(kubectl get pods --all-namespaces -o json | jq -c '
.items[]
| select(.metadata.namespace as $n | ["kube-system","kube-public","kube-node-lease"] | index($n) | not)
| (.metadata.ownerReferences // []) as $owns
| [ $owns[] | select(.controller == true) ] as $ctrls
| if ($ctrls | length) == 0 then empty else
. as $pod
| $ctrls[0] as $own
| ((.spec.containers // []) + (.spec.initContainers // []))[]
| select(.securityContext.readOnlyRootFilesystem != true)
| {
podNamespace: $pod.metadata.namespace,
podName: $pod.metadata.name,
ownerKind: $own.kind,
ownerName: $own.name,
ownerUid: $own.uid,
containerName: .name
}
end
')"
if [[ -z "${NON_COMPLIANT_JSON}" ]]; then
echo "[INFO] All checked containers already have readOnlyRootFilesystem=true (excluding system namespaces)."
exit 0
fi
# Build a unique list of controllers to patch
echo "[INFO] Building list of owning controllers to patch..."
CONTROLLERS_JSON="$(printf '%s\n' "${NON_COMPLIANT_JSON}" | jq -cs '
. as $items
| reduce $items[] as $i ({}; .[
($i.podNamespace + "|" + $i.ownerKind + "|" + $i.ownerName)
] = 1)
| keys[]
| split("|")
| { namespace: .[0], kind: .[1], name: .[2] }
')"
if [[ -z "${CONTROLLERS_JSON}" ]]; then
echo "[INFO] No controllers found to patch (only naked Pods are affected)."
echo "[INFO] Review and recreate affected Pods with readOnlyRootFilesystem=true in their Pod specs."
exit 0
fi
echo "[INFO] Controllers to patch:"
printf '%s\n' "${CONTROLLERS_JSON}" | jq -r '.namespace + " " + .kind + " " + .name'
patch_controller() {
local ns="$1"
local kind="$2"
local name="$3"
echo "[INFO] Patching ${kind}/${ns}/${name} to set readOnlyRootFilesystem=true..."
# Determine the path to pod template containers depending on kind/apiVersion
# Strategy: fetch as JSON, transform with jq, then apply via kubectl apply -f -
local tmpfile
tmpfile="$(mktemp)"
if ! kubectl -n "${ns}" get "${kind}" "${name}" -o json > "${tmpfile}"; then
echo "[WARN] Failed to fetch ${kind}/${ns}/${name}; skipping."
rm -f "${tmpfile}"
return
fi
# Detect spec path
local jq_filter
case "${kind}" in
Deployment|ReplicaSet|StatefulSet|DaemonSet)
jq_filter='
.spec.template.spec as $spec
| .spec.template.spec.containers |=
(map(.securityContext.readOnlyRootFilesystem = true))
| if ($spec.initContainers // null) != null then
.spec.template.spec.initContainers |=
(map(.securityContext.readOnlyRootFilesystem = true))
else .
end
'
;;
Job)
jq_filter='
.spec.template.spec as $spec
| .spec.template.spec.containers |=
(map(.securityContext.readOnlyRootFilesystem = true))
| if ($spec.initContainers // null) != null then
.spec.template.spec.initContainers |=
(map(.securityContext.readOnlyRootFilesystem = true))
else .
end
'
;;
CronJob)
jq_filter='
.spec.jobTemplate.spec.template.spec as $spec
| .spec.jobTemplate.spec.template.spec.containers |=
(map(.securityContext.readOnlyRootFilesystem = true))
| if ($spec.initContainers // null) != null then
.spec.jobTemplate.spec.template.spec.initContainers |=
(map(.securityContext.readOnlyRootFilesystem = true))
else .
end
'
;;
*)
echo "[WARN] Kind ${kind} not handled by this script; skipping ${kind}/${ns}/${name}."
rm -f "${tmpfile}"
return
;;
esac
local patched
if ! patched="$(jq "${jq_filter}" "${tmpfile}")"; then
echo "[WARN] Failed to transform ${kind}/${ns}/${name}; skipping."
rm -f "${tmpfile}"
return
fi
# Idempotent: applying the same spec again is safe
if ! printf '%s\n' "${patched}" | kubectl apply -f - >/dev/null; then
echo "[WARN] Failed to apply patch for ${kind}/${ns}/${name}; skipping."
rm -f "${tmpfile}"
return
fi
rm -f "${tmpfile}"
echo "[INFO] Patched ${kind}/${ns}/${name}."
}
# Patch each controller
printf '%s\n' "${CONTROLLERS_JSON}" | jq -c '.' | while read -r ctrl; do
ns="$(printf '%s\n' "${ctrl}" | jq -r '.namespace')"
kind="$(printf '%s\n' "${ctrl}" | jq -r '.kind')"
name="$(printf '%s\n' "${ctrl}" | jq -r '.name')"
patch_controller "${ns}" "${kind}" "${name}"
done
echo "[INFO] Waiting for pods to be recreated with updated specs..."
sleep 10
echo "[INFO] Re-running compliance check..."
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 | map(select(. | test("is_compliant=false$"))) | length) == 0
then "is_compliant=true"
else $rows[]
end
'