Skip to main content

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โ€‹

Manual Steps
  1. Identify which registries and service accounts your GKE nodes use

    • On any machine with gcloud access, list your node service accounts and note which ones pull images (often the default compute SA):
      gcloud container clusters describe <cluster-name> \
      --region <region> \
      --format="value(nodeConfig.serviceAccount)"
      If you use Workload Identity or custom node pools, also list any additional service accounts used to pull images.
  2. Review Artifact Registry (AR) IAM for node / puller service accounts

    • On any machine with gcloud access, 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 with roles/artifactregistry.reader before removal to avoid breaking image pulls.
  3. Review GCR (GCS bucket) IAM for explicitly granted accounts

    • On any machine with gsutil access, for each GCR bucket (commonly gs://artifacts.<project_id>.appspot.com or 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.admin
      • roles/storage.objectAdmin
      • roles/storage.objectCreator
      • roles/storage.legacyBucketOwner
      • roles/storage.legacyBucketWriter
      • roles/storage.legacyObjectOwner
    • Ensure those accounts instead have only roles/storage.objectViewer (or are covered by a narrower custom read-only role, if used).
  4. Review project-level IAM that may grant indirect write/admin access to registries

    • On any machine with gcloud access, 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.
  5. Decide and apply least-privilege changes in console/CLI/IaC

    • For AR: for each affected service account, add roles/artifactregistry.reader at the repository (or minimally scoping level) and then remove any broader AR roles.
    • For GCR buckets: for each affected service account, add objectViewer using gsutil iam ch, then delete the excessive role entries (storage.admin, storage.objectAdmin, etc.) using gsutil 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.
  6. 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.
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