Minimize Cluster Access To Read-Only For Container Image
More Info:โ
Grant the clusters service account only read access to container image repositories, not write or admin. Read-only access limits the blast radius if node credentials are compromised.
Risk Levelโ
Medium
Addressโ
Security
Compliance Standardsโ
- CIS GKE
Triage and Remediationโ
- Remediation
Remediationโ
Manual Steps
-
Identify which registries and service accounts your GKE nodes use
- On any machine with
gcloudaccess, list your node service accounts and note which ones pull images (often the default compute SA):If you use Workload Identity or custom node pools, also list any additional service accounts used to pull images.gcloud container clusters describe <cluster-name> \--region <region> \--format="value(nodeConfig.serviceAccount)"
- On any machine with
-
Review Artifact Registry (AR) IAM for node / puller service accounts
- On any machine with
gcloudaccess, for each Artifact Registry repository that stores images used by the cluster:gcloud artifacts repositories get-iam-policy <repository> \--location=<repository-location> \--format="table(bindings.role, bindings.members)" - Confirm that any member matching your node or image-puller service accounts has at most
roles/artifactregistry.reader. - If you find broader roles (for example,
roles/artifactregistry.writer,roles/artifactregistry.admin,roles/owner,roles/editor), plan to replace them withroles/artifactregistry.readerbefore removal to avoid breaking image pulls.
- On any machine with
-
Review GCR (GCS bucket) IAM for explicitly granted accounts
- On any machine with
gsutilaccess, for each GCR bucket (commonlygs://artifacts.<project_id>.appspot.comor regional equivalents):gsutil iam get gs://artifacts.<project_id>.appspot.com - Inspect the output for entries granting your node or image-puller service accounts any of:
roles/storage.adminroles/storage.objectAdminroles/storage.objectCreatorroles/storage.legacyBucketOwnerroles/storage.legacyBucketWriterroles/storage.legacyObjectOwner
- Ensure those accounts instead have only
roles/storage.objectViewer(or are covered by a narrower custom read-only role, if used).
- On any machine with
-
Review project-level IAM that may grant indirect write/admin access to registries
- On any machine with
gcloudaccess, run:gcloud projects get-iam-policy <project_id> \--flatten="bindings[].members" \--format='table(bindings.members,bindings.role)' \--filter="bindings.role:roles/storage.admin OR bindings.role:roles/storage.objectAdmin OR bindings.role:roles/storage.objectCreator OR bindings.role:roles/storage.legacyBucketOwner OR bindings.role:roles/storage.legacyBucketWriter OR bindings.role:roles/storage.legacyObjectOwner" - Check whether any node or image-puller service accounts (or groups containing them) appear in the output. If so, they inherit write/admin rights to GCR buckets and must be removed or replaced with read-only roles at project level.
- On any machine with
-
Decide and apply least-privilege changes in console/CLI/IaC
- For AR: for each affected service account, add
roles/artifactregistry.readerat the repository (or minimally scoping level) and then remove any broader AR roles. - For GCR buckets: for each affected service account, add
objectViewerusinggsutil iam ch, then delete the excessive role entries (storage.admin,storage.objectAdmin, etc.) usinggsutil iam ch -d. - For project-level IAM: update IAM bindings (via console or by editing your IAM policy / Terraform / other IaC) so that node/image-puller service accounts no longer have storage admin/write roles, while ensuring other workloads that legitimately push images retain required permissions.
- For AR: for each affected service account, add
-
Verify the remediation and cluster functionality
- Re-run the project-level audit to confirm no node or image-puller service accounts hold the listed storage write/admin roles:
gcloud projects get-iam-policy <project_id> \--flatten="bindings[].members" \--format='table(bindings.members,bindings.role)' \--filter="bindings.role:roles/storage.admin OR bindings.role:roles/storage.objectAdmin OR bindings.role:roles/storage.objectCreator OR bindings.role:roles/storage.legacyBucketOwner OR bindings.role:roles/storage.legacyBucketWriter OR bindings.role:roles/storage.legacyObjectOwner"
- Optionally, re-check AR and GCR bucket IAM as in steps 2โ3, then deploy a test pod in the cluster that pulls an image from each registry to ensure image pulls still succeed with read-only access.
- Re-run the project-level audit to confirm no node or image-puller service accounts hold the listed storage write/admin roles:
Using kubectl
kubectl cannot be used to change container image repository IAM or project-level IAM; this finding is remediated in the Google Cloud console / gcloud CLI / IaC that manages Artifact Registry, GCR, and project IAM. Refer to the Manual Steps section for the exact gcloud and gsutil commands to adjust roles to read-only access.
Automation
#!/usr/bin/env bash
#
# Purpose:
# Help review CIS GKE 5.1.3 at scale by:
# 1. Listing all GKE node service accounts actually used by the cluster.
# 2. Showing their risky Storage and Artifact Registryโrelated IAM roles
# at the project and (optionally) AR repository level.
#
# Usage (run on any machine with gcloud and kubectl configured):
# export PROJECT_ID="my-gcp-project"
# ./audit-gcr-ar-readonly.sh
#
# Optional:
# export AR_LOCATIONS="us-central1,us,europe-west1"
#
# NOTE:
# This only reports; it does NOT make changes. Use it to identify where
# project or repository IAM may give more than read-only access to
# container image repositories.
set -euo pipefail
PROJECT_ID="${PROJECT_ID:-}"
if [[ -z "${PROJECT_ID}" ]]; then
echo "ERROR: PROJECT_ID environment variable must be set (gcloud project ID)." >&2
exit 1
fi
AR_LOCATIONS="${AR_LOCATIONS:-}" # e.g. "us-central1,us,europe-west1"
echo "=== Step 1: Discover node service accounts actually in use (via kubectl) ==="
# We collect node and default service accounts referenced in node pools
# and workloads. This runs only against the Kubernetes API.
echo
echo "# Kubernetes node (GCE/GKE) underlying service accounts (from node annotations)"
kubectl get nodes -o jsonpath='{range .items[*]}{.metadata.name}{" "}{.metadata.annotations.container\.googleapis\.com/instance\_service\_account}{"\n"}{end}' \
| sort -u
echo
echo "# Kubernetes workload serviceAccounts referenced in Pods (namespace/name -> GSA annotation if present)"
kubectl get pods --all-namespaces -o json \
| jq -r '
.items[]
| .metadata.namespace as $ns
| .spec.serviceAccountName as $ksa
| .metadata.annotations["iam.gke.io/gcp-service-account"]? as $gsa
| [$ns, .metadata.name, ($ksa // "<default>"), ($gsa // "<none>")]
| @tsv
' \
| sort -u \
| awk 'BEGIN{OFS="\t"; print "NAMESPACE","POD","KSA","GSA_ANNOTATION"}1'
echo
echo "# Distinct GCP service accounts referenced by GKE (from node + pod annotations)"
GSA_LIST=$(
{
kubectl get nodes -o jsonpath='{range .items[*]}{.metadata.annotations.container\.googleapis\.com/instance\_service\_account}{"\n"}{end}' 2>/dev/null || true
kubectl get pods --all-namespaces -o json 2>/dev/null \
| jq -r '.items[].metadata.annotations["iam.gke.io/gcp-service-account"] // empty'
} \
| sed '/^$/d' \
| sort -u
)
if [[ -z "${GSA_LIST}" ]]; then
echo "No GCP service accounts discovered from nodes or pods (via annotations)."
else
echo "${GSA_LIST}" | nl -ba
fi
echo
echo "=== Step 2: Project-level IAM โ risky Storage and AR-related roles ==="
echo "# These roles indicate potential write/admin access to GCR buckets or storage:"
echo "# roles/storage.admin"
echo "# roles/storage.objectAdmin"
echo "# roles/storage.objectCreator"
echo "# roles/storage.legacyBucketOwner"
echo "# roles/storage.legacyBucketWriter"
echo "# roles/storage.legacyObjectOwner"
echo "# and broad Artifact Registry roles (admin/writer, etc.)."
echo
echo "## Full project IAM bindings for risky Storage roles (cluster-wide):"
gcloud projects get-iam-policy "${PROJECT_ID}" \
--flatten="bindings[].members" \
--format='table(bindings.members,bindings.role)' \
--filter="bindings.role:roles/storage.admin OR \
bindings.role:roles/storage.objectAdmin OR \
bindings.role:roles/storage.objectCreator OR \
bindings.role:roles/storage.legacyBucketOwner OR \
bindings.role:roles/storage.legacyBucketWriter OR \
bindings.role:roles/storage.legacyObjectOwner"
echo
echo "## Project IAM bindings filtered to discovered GCP service accounts (Storage + Artifact Registry):"
if [[ -z "${GSA_LIST}" ]]; then
echo "Skipping GSA-focused filter: no GCP service accounts discovered in Step 1."
else
while read -r GSA; do
MEMBER="serviceAccount:${GSA}"
echo
echo "### Service account: ${MEMBER}"
gcloud projects get-iam-policy "${PROJECT_ID}" \
--flatten="bindings[].members" \
--format='table(bindings.members,bindings.role)' \
--filter="bindings.members:${MEMBER} AND \
(bindings.role:roles/storage. OR bindings.role:roles/artifactregistry.)"
done <<< "${GSA_LIST}"
fi
echo
echo "=== Step 3 (optional): Artifact Registry repository-level IAM for discovered GSAs ==="
if [[ -z "${AR_LOCATIONS}" ]]; then
echo "AR_LOCATIONS not set; skipping per-repository IAM review."
echo "Set AR_LOCATIONS (e.g. 'us-central1,us,europe-west1') to enable."
else
IFS=',' read -r -a LOCS <<< "${AR_LOCATIONS}"
for LOC in "${LOCS[@]}"; do
echo
echo "## Listing Artifact Registry repositories in location: ${LOC}"
gcloud artifacts repositories list \
--project="${PROJECT_ID}" \
--location="${LOC}" \
--format='table(name,format)'
done
if [[ -n "${GSA_LIST}" ]]; then
for LOC in "${LOCS[@]}"; do
echo
echo "## Per-repository IAM in ${LOC} for discovered GSAs"
REPOS=$(gcloud artifacts repositories list \
--project="${PROJECT_ID}" \
--location="${LOC}" \
--format='value(name)' || true)
for REPO in ${REPOS}; do
echo
echo "### Repository: ${REPO}"
for GSA in ${GSA_LIST}; do
MEMBER="serviceAccount:${GSA}"
gcloud artifacts repositories get-iam-policy "${REPO}" \
--location="${LOC}" \
--project="${PROJECT_ID}" \
--flatten="bindings[].members" \
--format='table(bindings.members,bindings.role)' \
--filter="bindings.members:${MEMBER}"
done
done
done
fi
fi
cat <<'EOF'
INTERPRETING OUTPUT (what indicates a problem):
1) For GCR (image storage in GCS):
Any service account used by GKE nodes or workloads that has one of these
Storage roles โ either directly on the GCR bucket or inherited at the
PROJECT level โ is overly privileged for pull-only scenarios:
- roles/storage.admin
- roles/storage.objectAdmin
- roles/storage.objectCreator
- roles/storage.legacyBucketOwner
- roles/storage.legacyBucketWriter
- roles/storage.legacyObjectOwner
Expected for pull-only:
- roles/storage.objectViewer on the specific GCR bucket
(or equivalent read-only permission via IAM).
2) For Artifact Registry:
For GKE-related service accounts, expected is:
- roles/artifactregistry.reader (or custom read-only role) on the AR repo.
Potential problems:
- roles/artifactregistry.admin
- roles/artifactregistry.writer
- roles/artifactregistry.repoAdmin
- Any other non-read-only Artifact Registry role.
This script only reports current IAM state. Use the provider console, gcloud,
or IaC to adjust roles so that cluster-related service accounts have read-only
access to image repositories wherever possible.
EOF