Artifact Registry Security: Hardening Harbor, ECR, and GCR Against Supply Chain Attacks

Artifact Registry Security: Hardening Harbor, ECR, and GCR Against Supply Chain Attacks

The container registry sits at the boundary between build systems and runtime environments. Every production deployment pulls from it. Every CI job pushes to it. That position makes it the highest-leverage point in the software supply chain: compromise the registry and you control what runs in production, without touching a single source repository or pipeline configuration.

Most registry deployments start insecure by default. Harbor ships with a hardcoded admin credential. ECR repositories allow any authenticated IAM principal in the account to pull images unless restricted. Artifact Registry grants project-level roles that are broader than most workloads need. Replication credentials accumulate as unrotated secrets. Promotion between environments happens by re-tagging rather than by verified artifact movement.

This article covers hardening each of these registries and building a promotion pipeline that maintains provenance across the dev → staging → production boundary.

The Registry as a Supply Chain Control Point

A registry is not just storage. It is the enforcement boundary for two critical supply chain properties: what artifacts are permitted to run and what those artifacts contain.

Enforcement happens at two points:

Promotion gates prevent unscanned, unsigned, or policy-violating images from advancing to the next environment. An image that passes Trivy in dev is promoted to staging. An image that passes staging validation and carries a valid Cosign signature and SBOM attestation is promoted to production. Each promotion is an explicit trust decision, not an implicit tag copy.

Scan-on-push provides the baseline. Every image pushed to the registry is immediately scanned. CI pipelines block on scan results before they proceed to deploy. Images that accumulate new vulnerabilities after their initial push are flagged by periodic re-scanning.

Without these controls, the registry becomes a pass-through: anything pushed is available to be deployed, and the only barrier between a compromised build and a production cluster is the luck of nobody noticing.

Harbor Hardening

RBAC with Project-Level Isolation

Harbor’s project model is the correct unit of access control. Each application or team owns a project. CI/CD robots get project-scoped credentials, not system-level admin access. Developers get Developer role in their own project, which permits pull and push, but not the ability to delete tags or modify project configuration.

Create project-scoped robot accounts via the Harbor API. Do not use the admin credential in CI/CD pipelines under any circumstances.

curl -X POST \
  -H "Content-Type: application/json" \
  -u admin:"${HARBOR_ADMIN_PASSWORD}" \
  "https://harbor.company.com/api/v2.0/robots" \
  -d '{
    "name": "ci-push-myapp",
    "description": "Scoped push access for myapp CI pipeline",
    "duration": 90,
    "level": "project",
    "permissions": [
      {
        "kind": "project",
        "namespace": "myapp",
        "access": [
          {"resource": "repository", "action": "push"},
          {"resource": "repository", "action": "pull"},
          {"resource": "tag", "action": "create"},
          {"resource": "artifact", "action": "read"}
        ]
      }
    ]
  }'

The duration: 90 field expires the credential in 90 days. Automate rotation before expiry. Store the returned secret in your secrets manager — Vault, AWS Secrets Manager, or Kubernetes external secrets — never in environment variables baked into pipeline configuration.

For a read-only pull credential used by Kubernetes nodes:

curl -X POST \
  -H "Content-Type: application/json" \
  -u admin:"${HARBOR_ADMIN_PASSWORD}" \
  "https://harbor.company.com/api/v2.0/robots" \
  -d '{
    "name": "k8s-pull-myapp",
    "description": "Pull-only for Kubernetes workloads",
    "duration": 365,
    "level": "project",
    "permissions": [
      {
        "kind": "project",
        "namespace": "myapp",
        "access": [
          {"resource": "repository", "action": "pull"},
          {"resource": "artifact", "action": "read"}
        ]
      }
    ]
  }'

System-level robot accounts (scope *) should be reserved for the vulnerability scanner and replication engine only. Any robot account with level: system should be treated as a privileged credential and audited quarterly.

Trivy Adapter for On-Push Scanning

Enable Trivy scanning in Harbor Helm values:

trivy:
  enabled: true
  image:
    tag: "0.52.0"
  resources:
    requests:
      cpu: 500m
      memory: 512Mi
    limits:
      cpu: 2
      memory: 2Gi
  # Update Trivy's vulnerability database on startup and every 24h
  dbUpdateInterval: "24h"

Enable auto-scan at the project level. This triggers a Trivy scan for every image pushed to the project:

curl -X PUT \
  -H "Content-Type: application/json" \
  -u admin:"${HARBOR_ADMIN_PASSWORD}" \
  "https://harbor.company.com/api/v2.0/projects/myapp" \
  -d '{
    "metadata": {
      "auto_scan": "true",
      "severity": "high",
      "prevent_vul": "true"
    }
  }'

The prevent_vul: true and severity: high combination blocks image pulls for any image where the scan result contains a High or Critical vulnerability. This is the enforcement mechanism that turns scanning from informational to blocking.

Configure a webhook to notify your alerting system when a push is blocked:

curl -X POST \
  -H "Content-Type: application/json" \
  -u admin:"${HARBOR_ADMIN_PASSWORD}" \
  "https://harbor.company.com/api/v2.0/projects/myapp/webhook/policies" \
  -d '{
    "name": "security-alerts",
    "event_types": ["SCANNING_FAILED", "SCANNING_COMPLETED"],
    "targets": [
      {
        "type": "http",
        "address": "https://alerts.company.com/harbor-webhook",
        "auth_header": "Bearer ${WEBHOOK_TOKEN}"
      }
    ],
    "enabled": true
  }'

TLS Configuration

Force HTTPS-only access. In Harbor Helm values:

expose:
  type: ingress
  tls:
    enabled: true
    certSource: secret
    secret:
      secretName: harbor-tls
  ingress:
    hosts:
      core: harbor.company.com
    annotations:
      nginx.ingress.kubernetes.io/ssl-redirect: "true"
      nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
      nginx.ingress.kubernetes.io/proxy-body-size: "0"

externalURL: https://harbor.company.com

internalTLS:
  enabled: true
  certSource: secret
  core:
    secretName: harbor-core-internal-tls
  jobservice:
    secretName: harbor-jobservice-internal-tls
  registry:
    secretName: harbor-registry-internal-tls

Internal TLS (internalTLS.enabled: true) encrypts traffic between Harbor components — core, registry, jobservice, and Trivy — not just the ingress. Without this, inter-component traffic on the pod network is unencrypted.

Use cert-manager to issue and renew the TLS certificate automatically:

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: harbor-tls
  namespace: harbor
spec:
  secretName: harbor-tls
  issuerRef:
    name: letsencrypt-prod
    kind: ClusterIssuer
  dnsNames:
    - harbor.company.com

Replication Security

Harbor replication rules move images between registries. Insecure replication exposes credentials to man-in-the-middle attacks and can be abused to exfiltrate images to external registries.

Enforce HTTPS-only replication at the registry endpoint level. When creating a replication endpoint, set insecure: false:

curl -X POST \
  -H "Content-Type: application/json" \
  -u admin:"${HARBOR_ADMIN_PASSWORD}" \
  "https://harbor.company.com/api/v2.0/registries" \
  -d '{
    "name": "ecr-staging",
    "type": "aws-ecr",
    "url": "https://123456789012.dkr.ecr.eu-west-1.amazonaws.com",
    "credential": {
      "type": "secret",
      "access_key": "${AWS_ACCESS_KEY_ID}",
      "access_secret": "${AWS_SECRET_ACCESS_KEY}"
    },
    "insecure": false
  }'

Audit all existing replication policies to identify rules pushing to unauthorized external registries:

curl -s \
  -u admin:"${HARBOR_ADMIN_PASSWORD}" \
  "https://harbor.company.com/api/v2.0/replication/policies" | \
  jq '.[] | {id, name, enabled, dest_registry: .dest_registry.url, filters: .filters}'

Disable any replication rule you cannot attribute to an explicit business requirement. Replication rules that run silently in the background are a reliable mechanism for persistent data exfiltration from a compromised admin account.

Restrict replication rule creation to the harbor-admin group in your LDAP/OIDC configuration. Regular project members and robot accounts should not be able to create system-level replication policies.

AWS ECR Security

Repository Policies for Cross-Account Pull and Public Access Prevention

ECR repository policies control which AWS principals can push and pull. The default is implicit deny for everyone except principals in the owning account who have the relevant IAM permissions. Make the restriction explicit for production repositories.

Block all public access at the ECR registry level (a single setting per region per account):

aws ecr put-registry-policy \
  --policy-text '{
    "Version": "2012-10-17",
    "Statement": [
      {
        "Sid": "DenyPublicAccess",
        "Effect": "Deny",
        "Principal": "*",
        "Action": [
          "ecr:GetDownloadUrlForLayer",
          "ecr:BatchGetImage"
        ],
        "Condition": {
          "StringNotEquals": {
            "aws:PrincipalAccount": [
              "111111111111",
              "222222222222"
            ]
          }
        }
      }
    ]
  }'

For a production repository that should only be pulled by specific EKS node roles:

aws ecr set-repository-policy \
  --repository-name myapp/api \
  --policy-text '{
    "Version": "2012-10-17",
    "Statement": [
      {
        "Sid": "AllowEKSNodePull",
        "Effect": "Allow",
        "Principal": {
          "AWS": [
            "arn:aws:iam::111111111111:role/eks-node-role-prod",
            "arn:aws:iam::111111111111:role/eks-node-role-staging"
          ]
        },
        "Action": [
          "ecr:GetDownloadUrlForLayer",
          "ecr:BatchGetImage",
          "ecr:BatchCheckLayerAvailability"
        ]
      },
      {
        "Sid": "AllowCIPush",
        "Effect": "Allow",
        "Principal": {
          "AWS": "arn:aws:iam::111111111111:role/github-actions-ecr-push"
        },
        "Action": [
          "ecr:GetDownloadUrlForLayer",
          "ecr:BatchGetImage",
          "ecr:BatchCheckLayerAvailability",
          "ecr:InitiateLayerUpload",
          "ecr:UploadLayerPart",
          "ecr:CompleteLayerUpload",
          "ecr:PutImage"
        ]
      },
      {
        "Sid": "DenyEverythingElse",
        "Effect": "Deny",
        "Principal": "*",
        "Action": "ecr:*",
        "Condition": {
          "StringNotEquals": {
            "aws:PrincipalArn": [
              "arn:aws:iam::111111111111:role/eks-node-role-prod",
              "arn:aws:iam::111111111111:role/eks-node-role-staging",
              "arn:aws:iam::111111111111:role/github-actions-ecr-push"
            ]
          }
        }
      }
    ]
  }'

Immutable Tags

Mutable tags are how supply chain attacks are delivered through registries. An attacker who gains push access can overwrite myapp/api:latest or myapp/api:v1.2.3 with a backdoored image, and every deployment that references the tag will pull the malicious version.

Enforce immutable tags on every production repository:

aws ecr put-image-tag-mutability \
  --repository-name myapp/api \
  --image-tag-mutability IMMUTABLE

With IMMUTABLE set, a push attempt that would overwrite an existing tag returns an error:

ImageTagAlreadyExistsException: An image with the tag 'v1.2.3' already exists

CI/CD pipelines must derive tags from content-addressable identifiers — git commit SHA, build ID, or image digest — rather than mutable semantic version tags. If you must use semantic version tags, automate tag creation as a one-time operation in the release pipeline and treat the tag as immutable from that point forward.

Enforce immutability in the repository policy to prevent it from being changed without explicit IAM permission:

# Deny any principal from changing tag mutability settings
aws ecr set-repository-policy \
  --repository-name myapp/api \
  --policy-text '{
    "Version": "2012-10-17",
    "Statement": [
      {
        "Sid": "DenyMutabilityChange",
        "Effect": "Deny",
        "Principal": "*",
        "Action": "ecr:PutImageTagMutability",
        "Condition": {
          "StringNotEquals": {
            "aws:PrincipalArn": "arn:aws:iam::111111111111:role/ecr-admin"
          }
        }
      }
    ]
  }'

ECR Image Scanning with Amazon Inspector

Enable ECR-native scanning at the registry level and configure Amazon Inspector for enhanced scanning:

aws ecr put-registry-scanning-configuration \
  --scan-type ENHANCED \
  --rules '[
    {
      "repositoryFilters": [
        {
          "filter": "*",
          "filterType": "WILDCARD"
        }
      ],
      "scanFrequency": "CONTINUOUS_SCAN"
    }
  ]'

CONTINUOUS_SCAN re-evaluates images against the Inspector vulnerability database without requiring a new push. An image that was clean when pushed and becomes vulnerable when a new CVE is published will be flagged automatically.

Retrieve scan findings for a specific image:

aws ecr describe-image-scan-findings \
  --repository-name myapp/api \
  --image-id imageTag=v1.2.3 \
  --query 'imageScanFindings.findings[?severity==`CRITICAL` || severity==`HIGH`].[name,severity,description]' \
  --output table

Integrate findings into your deployment gate using EventBridge. Inspector publishes findings events that you can route to a Lambda function or SNS topic to block deployments:

aws events put-rule \
  --name "ecr-critical-finding" \
  --event-pattern '{
    "source": ["aws.inspector2"],
    "detail-type": ["Inspector2 Finding"],
    "detail": {
      "severity": ["CRITICAL"],
      "resources": [{"type": ["AWS_ECR_CONTAINER_IMAGE"]}]
    }
  }' \
  --state ENABLED

Lifecycle Rules for Tag Cleanup

Accumulating untagged images expands the attack surface — old images with known vulnerabilities remain pullable by digest. Apply lifecycle rules to expire them:

aws ecr put-lifecycle-policy \
  --repository-name myapp/api \
  --lifecycle-policy-text '{
    "rules": [
      {
        "rulePriority": 1,
        "description": "Expire untagged images older than 7 days",
        "selection": {
          "tagStatus": "untagged",
          "countType": "sinceImagePushed",
          "countUnit": "days",
          "countNumber": 7
        },
        "action": {"type": "expire"}
      },
      {
        "rulePriority": 2,
        "description": "Keep only the 10 most recent tagged images",
        "selection": {
          "tagStatus": "tagged",
          "tagPrefixList": ["v"],
          "countType": "imageCountMoreThan",
          "countNumber": 10
        },
        "action": {"type": "expire"}
      }
    ]
  }'

Adjust countNumber for release cadence. A service that releases daily needs a longer retention window than one that releases monthly.

GCR / Artifact Registry Access Control

Google Container Registry is deprecated in favor of Artifact Registry. If you are still using GCR (gcr.io), migrate. Artifact Registry provides per-repository IAM, CMEK, VPC Service Controls, and a cleaner permission model.

IAM Role Assignments

Artifact Registry uses two primary roles at the repository level:

  • roles/artifactregistry.reader — pull only. Assign to Kubernetes workload identities, CD service accounts, and developer read access.
  • roles/artifactregistry.writer — push and pull. Assign to CI pipelines only.
  • roles/artifactregistry.repoAdmin — create, delete, configure repositories. Assign to platform engineering only, not to CI/CD.

Assign reader access to a GKE workload identity:

gcloud artifacts repositories add-iam-policy-binding myapp \
  --project=my-project \
  --location=europe-west1 \
  --member="serviceAccount:myapp-workload@my-project.iam.gserviceaccount.com" \
  --role="roles/artifactregistry.reader"

Assign writer access to the CI service account:

gcloud artifacts repositories add-iam-policy-binding myapp \
  --project=my-project \
  --location=europe-west1 \
  --member="serviceAccount:github-actions-ci@my-project.iam.gserviceaccount.com" \
  --role="roles/artifactregistry.writer"

Do not assign Artifact Registry roles at the project level. Project-level IAM grants apply to all repositories in the project. Repository-level grants scope access to exactly the repositories that each principal needs.

CMEK

Customer-managed encryption keys give you control over data-at-rest encryption and the ability to revoke access to all registry contents by disabling the key.

# Create a KMS keyring and key for Artifact Registry
gcloud kms keyrings create artifact-registry-keyring \
  --location=europe-west1

gcloud kms keys create artifact-registry-key \
  --keyring=artifact-registry-keyring \
  --location=europe-west1 \
  --purpose=encryption

# Grant Artifact Registry's service account access to the key
gcloud kms keys add-iam-policy-binding artifact-registry-key \
  --keyring=artifact-registry-keyring \
  --location=europe-west1 \
  --member="serviceAccount:service-${PROJECT_NUMBER}@gcp-sa-artifactregistry.iam.gserviceaccount.com" \
  --role="roles/cloudkms.cryptoKeyEncrypterDecrypter"

# Create the repository with CMEK
gcloud artifacts repositories create myapp \
  --repository-format=docker \
  --location=europe-west1 \
  --kms-key="projects/my-project/locations/europe-west1/keyRings/artifact-registry-keyring/cryptoKeys/artifact-registry-key"

VPC Service Controls

VPC Service Controls restrict Artifact Registry access to requests originating from within your VPC perimeter. A CI runner or Kubernetes node outside the perimeter cannot pull images even with valid credentials.

Create a service perimeter that includes artifactregistry.googleapis.com:

gcloud access-context-manager perimeters create artifact-perimeter \
  --title="Artifact Registry Perimeter" \
  --resources="projects/${PROJECT_NUMBER}" \
  --restricted-services="artifactregistry.googleapis.com" \
  --policy="${ACCESS_POLICY_ID}"

Add an ingress rule to allow traffic from your CI runners’ VPC:

gcloud access-context-manager perimeters update artifact-perimeter \
  --add-ingress-policies='[
    {
      "ingressFrom": {
        "sources": [{"resource": "projects/ci-project"}],
        "identities": ["serviceAccount:github-actions-ci@my-project.iam.gserviceaccount.com"]
      },
      "ingressTo": {
        "resources": ["*"],
        "operations": [{"serviceName": "artifactregistry.googleapis.com", "methodSelectors": [{"method": "*"}]}]
      }
    }
  ]' \
  --policy="${ACCESS_POLICY_ID}"

Cross-Registry Promotion Pipeline

The promotion pattern — dev registry → staging (scan pass) → prod (sign + attest) — is how you enforce supply chain controls without duplicating image builds. The same image digest that passed dev scanning is promoted to staging; the same digest that passed staging validation is signed and promoted to production. No rebuilds. No tag copies that break the chain.

The core tool is crane copy, which copies an image by digest between registries without pulling the full layer tarball to the CI runner.

# Step 1: Build and push to dev registry
IMAGE_DIGEST=$(docker build \
  --push \
  --tag harbor.company.com/dev/myapp:${GIT_SHA} \
  --metadata-file metadata.json \
  . && jq -r '."containerimage.digest"' metadata.json)

# Step 2: Scan in dev — block promotion if critical findings exist
CRITICAL=$(trivy image \
  --exit-code 0 \
  --format json \
  --severity CRITICAL \
  harbor.company.com/dev/myapp@${IMAGE_DIGEST} | \
  jq '[.Results[].Vulnerabilities // [] | .[] | select(.Severity=="CRITICAL")] | length')

if [ "${CRITICAL}" -gt 0 ]; then
  echo "Promotion blocked: ${CRITICAL} critical vulnerabilities found"
  exit 1
fi

# Step 3: Promote to staging by digest (not tag)
crane copy \
  harbor.company.com/dev/myapp@${IMAGE_DIGEST} \
  123456789012.dkr.ecr.eu-west-1.amazonaws.com/myapp:staging-${GIT_SHA}

# Step 4: Run integration tests against staging image
# ... test suite ...

# Step 5: Sign the image before production promotion
cosign sign \
  --key awskms:///arn:aws:kms:eu-west-1:111111111111:key/mrk-abcdef123456 \
  123456789012.dkr.ecr.eu-west-1.amazonaws.com/myapp:staging-${GIT_SHA}

# Step 6: Attach SBOM attestation
syft 123456789012.dkr.ecr.eu-west-1.amazonaws.com/myapp:staging-${GIT_SHA} \
  -o spdx-json > sbom.spdx.json

cosign attest \
  --key awskms:///arn:aws:kms:eu-west-1:111111111111:key/mrk-abcdef123456 \
  --predicate sbom.spdx.json \
  --type spdxjson \
  123456789012.dkr.ecr.eu-west-1.amazonaws.com/myapp:staging-${GIT_SHA}

# Step 7: Promote to production registry by digest
crane copy \
  123456789012.dkr.ecr.eu-west-1.amazonaws.com/myapp:staging-${GIT_SHA} \
  123456789012.dkr.ecr.eu-west-1.amazonaws.com/myapp/prod:${GIT_SHA}

The production deployment then verifies the signature before applying:

cosign verify \
  --key awskms:///arn:aws:kms:eu-west-1:111111111111:key/mrk-abcdef123456 \
  123456789012.dkr.ecr.eu-west-1.amazonaws.com/myapp/prod:${GIT_SHA}

This pattern integrates with the Sigstore/Cosign signing workflow and enforces the container image signing policy at deploy time. The SBOM generated and attached here feeds into SBOM consumption workflows.

Registry Credential Security: OIDC and Workload Identity

Long-lived registry credentials — Docker Hub tokens, ECR access keys, service account key files — are the most common vector for registry-level supply chain attacks. A leaked credential gives an attacker push access without compromising any build infrastructure.

Replace long-lived credentials with short-lived tokens issued via OIDC federation.

GitHub Actions to ECR:

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::111111111111:role/github-actions-ecr-push
          aws-region: eu-west-1

      - uses: aws-actions/amazon-ecr-login@v2

      - name: Build and push
        run: |
          docker build -t 111111111111.dkr.ecr.eu-west-1.amazonaws.com/myapp:${GITHUB_SHA} .
          docker push 111111111111.dkr.ecr.eu-west-1.amazonaws.com/myapp:${GITHUB_SHA}

The IAM role trust policy restricts which GitHub Actions workflows can assume it:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::111111111111:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
          "token.actions.githubusercontent.com:sub": "repo:myorg/myapp:ref:refs/heads/main"
        }
      }
    }
  ]
}

The sub condition locks the role to the main branch of the myorg/myapp repository. A forked repository or a feature branch cannot assume this role and cannot push to the production ECR repository.

GKE Workload Identity to Artifact Registry:

# Create a Kubernetes service account bound to a Google service account
gcloud iam service-accounts add-iam-policy-binding \
  myapp-workload@my-project.iam.gserviceaccount.com \
  --role="roles/iam.workloadIdentityUser" \
  --member="serviceAccount:my-project.svc.id.goog[myapp/myapp-sa]"

kubectl annotate serviceaccount myapp-sa \
  --namespace=myapp \
  iam.gke.io/gcp-service-account=myapp-workload@my-project.iam.gserviceaccount.com

Pods using myapp-sa receive a projected token that Artifact Registry accepts directly. No key files. No long-lived credentials mounted into the pod.

For Harbor, use OIDC authentication backed by your IdP (Okta, Azure AD, Keycloak) instead of local accounts. In Harbor Administration > Configuration > Authentication, set Auth Mode to OIDC and configure your IdP’s discovery endpoint. Local accounts can be disabled entirely once OIDC is operational, eliminating the local password management attack surface.

Robot accounts used in CI/CD should use Harbor’s built-in secret mechanism rather than the docker login password flow. Robot account secrets are scoped, audited, and revocable without affecting other accounts. Set duration to 90 days maximum and automate rotation.

Verification

After applying these controls, verify each layer:

# Harbor: confirm auto-scan and vulnerability prevention are enabled
curl -s \
  -u admin:"${HARBOR_ADMIN_PASSWORD}" \
  "https://harbor.company.com/api/v2.0/projects/myapp" | \
  jq '.metadata | {auto_scan, prevent_vul, severity}'

# ECR: confirm tag immutability
aws ecr describe-repositories \
  --repository-names myapp/api \
  --query 'repositories[0].imageTagMutability'

# ECR: confirm enhanced scanning is active
aws ecr describe-registry-scanning-configuration \
  --query 'scanningConfiguration.rules[0].scanFrequency'

# Artifact Registry: verify CMEK is configured
gcloud artifacts repositories describe myapp \
  --location=europe-west1 \
  --format="value(kmsKeyName)"

# Cosign: verify a production image has a valid signature
cosign verify \
  --key awskms:///arn:aws:kms:eu-west-1:111111111111:key/mrk-abcdef123456 \
  123456789012.dkr.ecr.eu-west-1.amazonaws.com/myapp/prod:${GIT_SHA}

Each of these verifications should be run as part of a regular security posture audit, not just at initial configuration. Registry settings drift. Lifecycle policies get disabled to resolve a storage alert and never re-enabled. Robot account expiries get extended indefinitely to avoid a rotation incident. Automated verification catches these regressions before they become incidents.