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 and their owning 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
    | "ns=\($m.namespace) pod=\($m.name) container=\(.name) image=\(.image)"
    + (if $own == null then "" else " ownerKind=\($own.kind) ownerName=\($own.name)" end)
    + " readOnlyRootFilesystem=\(if .securityContext.readOnlyRootFilesystem == null then "unset" else .securityContext.readOnlyRootFilesystem end)"
    + " is_compliant=\(if $ok then "true" else "false" end)"
    ][]' | grep 'is_compliant=false'
  2. For each affected controller (Deployment/DaemonSet/StatefulSet/Job/CronJob), export its manifest (run on any machine with kubectl access; adjust kind/name/namespace):

    kubectl -n <namespace> get <kind> <name> -o yaml > /tmp/<namespace>-<kind>-<name>.yaml
  3. Edit the manifest to set readOnlyRootFilesystem: true for each affected container, and, if writes are needed, define an emptyDir volume and mount it at the writable path (run on any machine with kubectl access):

    vi /tmp/<namespace>-<kind>-<name>.yaml

    Example changes inside each affected container spec:

    spec:
    containers:
    - name: <container-name>
    securityContext:
    readOnlyRootFilesystem: true
    volumeMounts:
    - name: writable-tmp
    mountPath: /tmp
    volumes:
    - name: writable-tmp
    emptyDir: {}

    Apply the same pattern to initContainers if present.

  4. Apply the updated controller manifest so new pods use a read-only root filesystem (run on any machine with kubectl access):

    kubectl apply -f /tmp/<namespace>-<kind>-<name>.yaml
  5. If necessary, recreate existing pods so they pick up the new security context (run on any machine with kubectl access):

    kubectl -n <namespace> delete pod -l <label-selector-of-controller>

    The controller will create new pods with readOnlyRootFilesystem: true.

  6. Verify all non-system pods now have readOnlyRootFilesystem set to true (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
    | "ns=\($m.namespace) pod=\($m.name) container=\(.name)"
    + " 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'
Using kubectl

On any machine with kubectl access:

  1. Identify a noncompliant Pod and its owning workload (Deployment/DaemonSet/Job, etc.):
kubectl get pods --all-namespaces -o wide
kubectl get pod -n <NAMESPACE> <POD_NAME> -o jsonpath='{.metadata.ownerReferences[0].kind}{"\n"}{.metadata.ownerReferences[0].name}{"\n"}'
  1. Export the owning workload manifest:
# Example for a Deployment
kubectl get deployment -n <NAMESPACE> <DEPLOYMENT_NAME> -o yaml > /tmp/deployment-readonly-fix.yaml
  1. Edit containers to use a read-only root filesystem and add a writable emptyDir for any paths that must be writable.

Example patch in /tmp/deployment-readonly-fix.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
name: my-app
namespace: my-namespace
spec:
template:
spec:
volumes:
- name: tmp-dir
emptyDir: {}
containers:
- name: app
image: my-registry/my-image:tag
securityContext:
readOnlyRootFilesystem: true
volumeMounts:
- name: tmp-dir
mountPath: /tmp
initContainers:
- name: init-app
image: my-registry/my-init-image:tag
securityContext:
readOnlyRootFilesystem: true
volumeMounts:
- name: tmp-dir
mountPath: /tmp

Apply the same securityContext.readOnlyRootFilesystem: true and emptyDir pattern to every container and initContainer that currently needs a writable path.

  1. Apply the updated manifest:
kubectl apply -f /tmp/deployment-readonly-fix.yaml
  1. (If the Pod is not controlled by a higher-level object, edit it directly; note this is not persistent across restarts):
kubectl edit pod -n <NAMESPACE> <POD_NAME>

Add under each container / initContainer:

securityContext:
readOnlyRootFilesystem: true

And define any emptyDir volumes and volumeMounts needed for writable paths.

  1. Verification (same scope: any machine with kubectl):

Run the audit and confirm readOnlyRootFilesystem=true and is_compliant=true for all containers:

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
# Remediate: Ensure containers use a read-only root filesystem
# Scope: Any machine with kubectl access to the OKE cluster
# Requirements: kubectl, jq, yq (https://mikefarah.gitbook.io/yq/) available in PATH
# Idempotent: can be re-run; only touches non-compliant workloads

set -euo pipefail

# Namespace filter: exclude Kubernetes control namespaces per check definition
EXCLUDED_NAMESPACES_REGEX='^(kube-system|kube-public|kube-node-lease)$'

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

log() {
echo "[$(timestamp)] $*"
}

need_cmd() {
command -v "$1" >/dev/null 2>&1 || {
echo "ERROR: Required command '$1' not found in PATH" >&2
exit 1
}
}

need_cmd kubectl
need_cmd jq
need_cmd yq

# 1) Discover non-compliant Pods (for reporting only; fix is applied on controllers)
log "Discovering non-compliant Pods (for visibility)..."
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 // []) | map(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) readOnlyRootFilesystem=\(if .securityContext.readOnlyRootFilesystem == null then "unset" else .securityContext.readOnlyRootFilesystem end)"
][]' || true

# 2) Target higher-level controllers to make fix durable
# We patch: Deployments, StatefulSets, DaemonSets, ReplicaSets, Jobs, CronJobs
KINDS=("Deployment" "StatefulSet" "DaemonSet" "ReplicaSet" "Job" "CronJob")

log "Identifying non-compliant controllers and patching with readOnlyRootFilesystem: true ..."

for kind in "${KINDS[@]}"; do
# Get all objects of this kind (may not exist in all clusters)
if ! kubectl get "$kind" --all-namespaces >/dev/null 2>&1; then
continue
fi

# JSONPath to list namespace/name pairs
mapfile -t resources < <(kubectl get "$kind" --all-namespaces -o jsonpath='{range .items[*]}{.metadata.namespace}{" "}{.metadata.name}{"\n"}{end}' 2>/dev/null || true)
for entry in "${resources[@]}"; do
ns=$(awk '{print $1}' <<<"$entry")
name=$(awk '{print $2}' <<<"$entry")
[[ -z "$ns" || -z "$name" ]] && continue
[[ "$ns" =~ $EXCLUDED_NAMESPACES_REGEX ]] && continue

# Export the resource as YAML
tmpfile="$(mktemp)"
kubectl get "$kind" "$name" -n "$ns" -o yaml > "$tmpfile"

# Build a patched YAML in-memory using yq:
# - For each normal container and init container:
# * ensure securityContext exists
# * set readOnlyRootFilesystem: true
patched="$(yq eval '
# Helper: set readOnlyRootFilesystem on a container entry
def set_rofs:
. as $root |
if $root == null then . else
.securityContext = ($root.securityContext // {}) |
.securityContext.readOnlyRootFilesystem = true
end;
( .spec.template.spec.containers |= (map( set_rofs )) ) |
( .spec.template.spec.initContainers |= (map( set_rofs )) )' "$tmpfile")"

# Check if anything changed (idempotency)
if diff -q <(yq eval '.' "$tmpfile") <(echo "$patched") >/dev/null 2>&1; then
rm -f "$tmpfile"
continue
fi

log "Patching $kind $ns/$name to set readOnlyRootFilesystem: true on all containers..."
# Apply the patched manifest
# --server-side helps with managed fields; can be changed to client-side apply if desired
printf '%s\n' "$patched" | kubectl apply -f -

rm -f "$tmpfile"
done
done

log "Waiting for updated workloads to roll out..."
# This is a best-effort wait; non-fatal if some remain progressing
for kind in Deployment StatefulSet DaemonSet; do
if ! kubectl get "$kind" --all-namespaces >/dev/null 2>&1; then
continue
fi
mapfile -t resources < <(kubectl get "$kind" --all-namespaces -o jsonpath='{range .items[*]}{.metadata.namespace}{" "}{.metadata.name}{"\n"}{end}' 2>/dev/null || true)
for entry in "${resources[@]}"; do
ns=$(awk '{print $1}' <<<"$entry")
name=$(awk '{print $2}' <<<"$entry")
[[ -z "$ns" || -z "$name" ]] && continue
[[ "$ns" =~ $EXCLUDED_NAMESPACES_REGEX ]] && continue
log "Waiting for $kind $ns/$name to be ready (timeout 300s)..."
kubectl rollout status "$kind/$name" -n "$ns" --timeout=300s || true
done
done

# 3) Verification: rerun the benchmark-style audit and show only remaining failures
log "Verification: listing any remaining containers without readOnlyRootFilesystem=true..."

out="$(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)
| "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=false"
] as $rows
| if ($rows | length) == 0 then "is_compliant=true" else $rows[] end')"

echo "$out"

if [[ "$out" == "is_compliant=true" ]]; then
log "All evaluated Pods are now compliant (readOnlyRootFilesystem=true)."
else
log "Some Pods remain non-compliant. These may be:
- Managed by controllers not covered by this script
- Standalone Pods created directly
- System/third-party components that require manual review.

Review each listed Pod and its owning resource; if the container truly requires writes,
mount an emptyDir to the specific paths needing write access instead of keeping the root
filesystem writable."
fi