Skip to main content

Minimize Cluster Access Read Only For Amazon Ecr

More Info:

Scan images being deployed to Amazon EKS for vulnerabilities.

Risk Level

Medium

Address

Security

Compliance Standards

  • APRA CPS 234 (Australia)
  • BSI C5 (Germany)
  • Brazil LGPD
  • CCPA / CPRA (California)
  • CIS Critical Security Controls v8
  • CIS EKS
  • CMMC 2.0
  • CSA Cloud Controls Matrix v4
  • DPDPA
  • Digital Operational Resilience Act (EU)
  • Essential 8
  • ISO/IEC 27017
  • ISO/IEC 27018
  • ISO/IEC 27701
  • KSA PDPL
  • MAS Technology Risk Management (Singapore)
  • MITRE ATT&CK (Cloud)
  • NIS2 Directive
  • NIST SP 800-171
  • NYDFS 23 NYCRR 500
  • SWIFT Customer Security Controls Framework
  • Sarbanes-Oxley IT General Controls
  • UK NCSC Cyber Assessment Framework

Triage and Remediation

Remediation

Manual Steps
  1. Identify node IAM roles used by the cluster

    • On any machine with AWS CLI access and the right permissions, list the nodegroups:
      aws eks list-nodegroups --cluster-name YOUR_CLUSTER_NAME
    • For each nodegroup, get its IAM role:
      aws eks describe-nodegroup \
      --cluster-name YOUR_CLUSTER_NAME \
      --nodegroup-name YOUR_NODEGROUP_NAME \
      --query 'nodegroup.nodeRole' \
      --output text
  2. Review current ECR-related permissions on each node IAM role

    • For each NODE_ROLE_ARN from step 1, list inline and attached policies:
      ROLE_NAME=$(basename NODE_ROLE_ARN)

      aws iam list-attached-role-policies --role-name "$ROLE_NAME"
      aws iam list-role-policies --role-name "$ROLE_NAME"
    • For each attached or inline policy, view the document:
      aws iam get-role-policy \
      --role-name "$ROLE_NAME" \
      --policy-name INLINE_POLICY_NAME

      aws iam get-policy \
      --policy-arn POLICY_ARN

      aws iam get-policy-version \
      --policy-arn POLICY_ARN \
      --version-id VERSION_ID_FROM_PREVIOUS_COMMAND
  3. Assess whether permissions are restricted to read-only ECR actions

    • In each policy document, locate Action entries containing ecr: and verify they are limited to:
      • ecr:BatchCheckLayerAvailability
      • ecr:BatchGetImage
      • ecr:GetDownloadUrlForLayer
      • ecr:GetAuthorizationToken
    • Record any additional ecr:* actions (e.g., ecr:PutImage, ecr:BatchDeleteImage, ecr:*) that go beyond these read-only requirements.
  4. Decide and plan corrections (principle of least privilege)

    • For each role where extra ECR actions are present, decide whether they are truly needed (for example, by CI/CD or admin tooling) or can be removed.
    • If non-read-only ECR actions are needed, consider moving those permissions to a separate IAM principal (e.g., a CI user/role) rather than the node role.
  5. Correct IAM policies to enforce read-only ECR access

    • To attach a dedicated read-only ECR policy to the node role (if missing), create it once:
      aws iam create-policy \
      --policy-name EKSNodeECRReadOnly \
      --policy-document '{
      "Version": "2012-10-17",
      "Statement": [
      {
      "Effect": "Allow",
      "Action": [
      "ecr:BatchCheckLayerAvailability",
      "ecr:BatchGetImage",
      "ecr:GetDownloadUrlForLayer",
      "ecr:GetAuthorizationToken"
      ],
      "Resource": "*"
      }
      ]
      }'
    • Attach it to each node role:
      aws iam attach-role-policy \
      --role-name "$ROLE_NAME" \
      --policy-arn arn:aws:iam::ACCOUNT_ID:policy/EKSNodeECRReadOnly
    • Edit existing inline or customer-managed policies to remove any ECR actions other than the four listed above. For customer-managed policies, update the policy document and create a new version:
      aws iam create-policy-version \
      --policy-arn POLICY_ARN \
      --policy-document file://updated-policy.json \
      --set-as-default
  6. Verify resulting permissions

    • Re-fetch each node role’s effective ECR permissions and confirm that the only remaining ecr: actions are:
      ecr:BatchCheckLayerAvailability, ecr:BatchGetImage, ecr:GetDownloadUrlForLayer, and ecr:GetAuthorizationToken:
      aws iam list-attached-role-policies --role-name "$ROLE_NAME"
      aws iam list-role-policies --role-name "$ROLE_NAME"

      # For each policy, re-run the get-policy/get-policy-version/get-role-policy
      # commands from step 2 and inspect all "Action" fields containing "ecr:"
Using kubectl

kubectl can’t modify IAM permissions or Amazon ECR access; this configuration is managed in AWS IAM and (optionally) your IaC, not via Kubernetes API objects. To adjust node IAM roles and ECR permissions, follow the guidance in the Manual Steps section using the AWS console/CLI or your infrastructure-as-code tooling.

Automation
#!/usr/bin/env bash
# Report ECR access level for EKS node IAM roles across the cluster
# Requirements: aws CLI configured, kubectl configured, jq

set -euo pipefail

echo "=== Discovering node IAM roles used by this EKS cluster ==="
# Run on any machine with kubectl and aws configured

# Get the AWS account and region from current identity/config
ACCOUNT_ID="$(aws sts get-caller-identity --query 'Account' --output text)"
REGION="$(aws configure get region || echo '')"
if [ -z "$REGION" ]; then
echo "ERROR: AWS region not set in aws CLI config"
exit 1
fi

# 1. List distinct IAM roles from node instance profiles (IRSA roles are separate and not ECR pullers by default)
# We infer roles from EC2 instances that are EKS worker nodes (tagged with kubernetes.io/cluster/<cluster-name>)
# First, get cluster name from kubeconfig/current-context
CLUSTER_NAME="$(kubectl config view --minify -o jsonpath='{.clusters[0].name}' | sed 's/^arn:aws:eks:[^:]*:[0-9]\+:cluster\///')"

if [ -z "$CLUSTER_NAME" ]; then
echo "ERROR: Could not derive EKS cluster name from kubeconfig"
exit 1
fi

echo "Detected cluster name: ${CLUSTER_NAME}"
echo "Using AWS account: ${ACCOUNT_ID}, region: ${REGION}"
echo

# Find EC2 instances that belong to this EKS cluster
INSTANCE_IDS=$(aws ec2 describe-instances \
--region "$REGION" \
--filters "Name=tag:kubernetes.io/cluster/${CLUSTER_NAME},Values=owned,shared" \
--query 'Reservations[].Instances[].InstanceId' \
--output text || true)

if [ -z "$INSTANCE_IDS" ]; then
echo "No EC2 worker instances found for cluster tag kubernetes.io/cluster/${CLUSTER_NAME}"
echo "If using Fargate-only cluster, node IAM role analysis is not applicable."
exit 0
fi

# Map instances -> instance profiles -> IAM roles
ROLE_ARNS=$(aws ec2 describe-instances \
--region "$REGION" \
--instance-ids $INSTANCE_IDS \
--query 'Reservations[].Instances[].IamInstanceProfile.Arn' \
--output text | tr '\t' '\n' | sort -u | while read -r PROFILE_ARN; do
[ -z "$PROFILE_ARN" ] && continue
PROFILE_NAME="$(basename "$PROFILE_ARN")"
aws iam get-instance-profile --instance-profile-name "$PROFILE_NAME" \
--query 'InstanceProfile.Roles[].Arn' --output text
done | tr '\t' '\n' | sort -u)

if [ -z "$ROLE_ARNS" ]; then
echo "No IAM roles associated with worker instances were found."
exit 0
fi

echo "=== Node IAM roles detected ==="
echo "$ROLE_ARNS"
echo

echo "=== Checking ECR permissions for each node IAM role ==="
echo

for ROLE_ARN in $ROLE_ARNS; do
ROLE_NAME="$(basename "$ROLE_ARN")"
echo "----- Role: ${ROLE_NAME} (${ROLE_ARN}) -----"

# Get all inline and attached policies as a single JSON policy set
TMP_POLICIES=$(mktemp)

# Inline policies
aws iam list-role-policies --role-name "$ROLE_NAME" --query 'PolicyNames' --output text | tr '\t' '\n' | while read -r PNAME; do
[ -z "$PNAME" ] && continue
aws iam get-role-policy --role-name "$ROLE_NAME" --policy-name "$PNAME" \
--query 'PolicyDocument' --output json
done | jq -s '.' > "$TMP_POLICIES"

# Attached managed policies
ATTACHED_ARNS=$(aws iam list-attached-role-policies --role-name "$ROLE_NAME" \
--query 'AttachedPolicies[].PolicyArn' --output text || true)
for PARN in $ATTACHED_ARNS; do
VERSION_ID=$(aws iam get-policy --policy-arn "$PARN" \
--query 'Policy.DefaultVersionId' --output text)
aws iam get-policy-version --policy-arn "$PARN" --version-id "$VERSION_ID" \
--query 'PolicyVersion.Document' --output json
done | jq -s '.[. != null][]' >> "$TMP_POLICIES" || true

if ! [ -s "$TMP_POLICIES" ]; then
echo "NO POLICIES FOUND on this role (cannot pull from ECR)."
echo
rm -f "$TMP_POLICIES"
continue
fi

# Normalize to an array of statements
EFFECTIVE=$(jq -s '[.[][] | .Statement] | flatten' "$TMP_POLICIES")

rm -f "$TMP_POLICIES"

echo "ECR-related statements (any non-read or non-ECR access is a potential problem):"
echo "$EFFECTIVE" | jq '
map(select(
(.Action|tostring|test("(^|:)ecr:")) or
((.Action|arrays?//[.]) | any(test("(^|:)ecr:")))
))' || echo " (none)"

echo
echo "Summary for ${ROLE_NAME}:"
# Check for required minimal read-only ECR actions with Resource:* (per benchmark)
for ACTION in ecr:BatchCheckLayerAvailability ecr:BatchGetImage ecr:GetDownloadUrlForLayer ecr:GetAuthorizationToken; do
HAS_ACTION=$(echo "$EFFECTIVE" | jq --arg A "$ACTION" '
any(.[]; .Effect=="Allow" and
((.Action===$A) or
(.Action|type=="array" and any(.==$A)) or
(.Action|type=="string" and .=="ecr:*" or .=="*") or
(.Action|type=="array" and any(.=="ecr:*" or .=="*"))
)
)')
printf " %-30s: %s\n" "$ACTION" "$HAS_ACTION"
done

# Flag overly broad actions
BROAD=$(echo "$EFFECTIVE" | jq '
map(select(.Effect=="Allow" and
(
.Action=="ecr:*" or .Action=="*" or
(.Action|type=="array" and any(.=="ecr:*" or .=="*"))
)
))')
if [ "$(echo "$BROAD" | jq 'length')" -gt 0 ]; then
echo " WARNING: Broad ECR or wildcard permissions detected:"
echo "$BROAD" | jq '.'
else
echo " No wildcard ecr:* or * permissions found."
fi

# Flag non-read ECR permissions (push/delete/admin)
NON_READ=$(echo "$EFFECTIVE" | jq '
map(select(.Effect=="Allow") |
.Action as $a |
if ($a|type)=="string" then [$a] else $a end |
map(select(
test("(^|:)ecr:Put") or
test("(^|:)ecr:Delete") or
test("(^|:)ecr:Upload") or
test("(^|:)ecr:CompleteLayerUpload") or
test("(^|:)ecr:InitiateLayerUpload") or
test("(^|:)ecr:BatchDeleteImage") or
test("(^|:)ecr:SetRepositoryPolicy") or
test("(^|:)ecr:TagResource") or
test("(^|:)ecr:UntagResource") or
test("(^|:)ecr:ReplicateImage") or
test("(^|:)ecr:Create") or
test("(^|:)ecr:Delete") or
test("(^|:)ecr:Update")
)) | select(length>0) | .) | map(select(length>0))')
if [ "$(echo "$NON_READ" | jq 'length')" -gt 0 ]; then
echo " WARNING: Non-read ECR permissions detected (potential write/admin capabilities):"
echo "$NON_READ" | jq '.'
else
echo " No non-read ECR permissions detected."
fi

echo
done

echo "=== How to interpret this report ==="
cat <<'EOF'
Problematic output indicates:

- Missing required read-only ECR actions:
Any "false" next to:
ecr:BatchCheckLayerAvailability
ecr:BatchGetImage
ecr:GetDownloadUrlForLayer
ecr:GetAuthorizationToken
means nodes may not be able to pull from ECR correctly.

- Overly broad or write/admin access:
- "WARNING: Broad ECR or wildcard permissions detected" with statements using "ecr:*" or "*":
This suggests more than read-only access, violating the principle of least privilege.
- "WARNING: Non-read ECR permissions detected":
Any listed actions (Put*, Delete*, Upload*, BatchDeleteImage, SetRepositoryPolicy, Tag/Untag, Create*/Update*) mean
the node role can modify repositories or images, which should generally be restricted.

Use this report to manually adjust IAM policies so node roles have only the four required ECR read-only actions
and avoid wildcard or write/admin ECR permissions.
EOF

Additional Reading: