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 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)
    ][]'
  2. 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_NAME

    In each affected container (and initContainer if present) under spec.template.spec.containers[] (or initContainers[]), ensure:

    securityContext:
    readOnlyRootFilesystem: true

    If securityContext already exists, add only the readOnlyRootFilesystem: true line.

  3. If a container needs a writable path, add an emptyDir and mount it (same kubectl edit session as step 2):

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

    Adjust name and mountPath as needed for each workload.

  4. 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.yaml

    Edit /tmp/pod-POD_NAME.yaml:

    • Remove metadata.uid, metadata.resourceVersion, metadata.creationTimestamp, metadata.ownerReferences, status and other server-populated fields.
    • Under spec.containers[] and spec.initContainers[] set:
      securityContext:
      readOnlyRootFilesystem: true
      and, if needed, add emptyDir + volumeMounts as in step 3. Apply:
    kubectl -n NAMESPACE delete pod POD_NAME
    kubectl -n NAMESPACE apply -f /tmp/pod-POD_NAME.yaml
  5. 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
  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.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=true or that all listed containers show is_compliant=true.

Using kubectl

On any machine with kubectl access:

  1. 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.

  1. 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.

  1. 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
  1. 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
'