Registry security
Push control, immutable tags, trust the digest.
It is 02:00. A continuous integration (CI) job prints a registry credential into its own log, and someone who should not be reading that log copies it. They never touch your build. They never touch your signing key. They never go near your admission controller. They run one command, a docker push to registry/payments:2.8.0, the exact tag that passed scanning, got signed, and shipped yesterday. The registry accepts it. That tag now resolves to a different pile of bytes, and every node that pulls payments:2.8.0 runs their code without raising a single alarm. Nothing failed. A tag is a movable pointer, and they moved it. That is the registry threat. The registry sits between build and deploy holding every artifact your cluster will ever trust, so it needs hardening as a boundary in its own right: control who can push, freeze what a tag means, scan what lands, and make the digest the unit of trust instead of the friendly name.
The tag is a sticky note, the digest is a fingerprint
A container registry is a warehouse for OCI artifacts (Open Container Initiative, the standard shape a container image is stored in). Inside it, a repository such as registry/api holds many versions of the same service. Each version is described by a manifest, a small JSON document listing the image config and every layer blob by its own hash. The digest is a SHA-256 fingerprint (Secure Hash Algorithm, 256 bits) taken over those exact manifest bytes, written sha256:9f2c... and so on. Because the fingerprint is computed from the content, it cannot drift away from it. Change one byte anywhere in the image and you get a different digest. People call this content addressing, and it means the name is the content. A tag such as :v2.8.0 or :latest works nothing like that. It is a sticky note stuck on one digest, and sticky notes peel off. Re-push or retag and the label ends up on different content while the spelling on the label stays identical. Multi-architecture images add one hop (the tag points at an image index whose own digest covers the per-architecture manifests), and the same fingerprinting holds all the way down. A stable name over shifting content is the exact gap the attacker at 02:00 walked through. It is also why a signature or a scan attached to a tag tells you nothing about what that tag will point at tomorrow.
# A tag is a mutable pointer; the digest IS the content. crane resolves the tag:$ crane digest 123456789012.dkr.ecr.us-east-1.amazonaws.com/api:v2.8.0sha256:9f2c3a8b4d6e1f70c2a5b9d8e3f4a1c6b7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2# Prove it is content-addressed: the digest equals the SHA-256 of the raw# manifest bytes the registry serves for that tag.$ crane manifest 123456789012.dkr.ecr.us-east-1.amazonaws.com/api:v2.8.0 | sha256sum9f2c3a8b4d6e1f70c2a5b9d8e3f4a1c6b7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2 -# Identical. Change one byte of image content and this value changes:# the digest cannot point at anything other than the bytes that produced it.
Immutable tags: glue the sticky note down
Tag immutability tells the registry to refuse any push that would move an already-published label. On Amazon ECR (Elastic Container Registry) you set imageTagMutability to IMMUTABLE. Do that when you create the repository, alongside scan-on-push, so no repo is ever born mutable. You can also flip it on an existing repo, then read the setting back to confirm it actually took. From then on, overwriting a published tag is rejected and the command exits non-zero, so a pipeline that tries it fails out loud rather than shipping in silence. Pair that with a tight grip on who may push at all. Only your CI system's OIDC identity (OpenID Connect, the short-lived token a build receives from its own provider instead of a stored password) should hold ecr:PutImage on production repositories. A human with console access, or a long-lived key that leaked into a log, then has no route to upload anything under a trusted name. Add one more layer above that: pin the guarantee across the whole organization with a Service Control Policy (SCP) or a registry policy that denies changing imageTagMutability, so a single misconfigured repository cannot quietly reopen the hole. Identity-pinned push plus frozen tags means a published name maps to one set of bytes and keeps meaning that.
# Freeze tags on an existing repo (or pass both flags at create-repository time):$ aws ecr put-image-tag-mutability \--repository-name api \--image-tag-mutability IMMUTABLE{"registryId": "123456789012","repositoryName": "api","imageTagMutability": "IMMUTABLE"}# Verify the setting actually took on the repo (never trust, confirm):$ aws ecr describe-repositories --repository-names api \--query 'repositories[0].{tags:imageTagMutability,scan:imageScanningConfiguration}'{"tags": "IMMUTABLE","scan": { "scanOnPush": true }}
# An attacker (or a fat-fingered engineer) tries to overwrite the published tag:$ docker push 123456789012.dkr.ecr.us-east-1.amazonaws.com/api:v2.8.0The push refers to repository [123456789012.dkr.ecr.us-east-1.amazonaws.com/api]9c1b6dd6c1e6: Layer already exists5f70bf18a086: Layer already existstag invalid: The image tag 'v2.8.0' already exists in the 'api' repositoryand cannot be overwritten because the repository is immutable.$ echo $?1# The registry refused to move the pointer, and the push exited non-zero:# a mutating deploy fails the pipeline instead of quietly repointing the tag.
Registries you run yourself do the same job with different plumbing. Harbor, the open-source registry many teams host in their own network, enforces immutability per project through pattern rules, matchers that mark any tag they match as read-only. The rule below freezes every tag matching release-** across every repository in the prod project. Once it is in place, a re-push of release-1.4 is refused by the registry itself, whoever is holding the credential.
# Harbor enforces immutability per project via pattern rules (v2 API):$ curl -sS -u "$HARBOR_USER:$HARBOR_PASS" -i \-X POST https://harbor.acme.internal/api/v2.0/projects/prod/immutabletagrules \-H 'Content-Type: application/json' \-d '{"disabled": false,"action": "immutable","template": "immutable_template","tag_selectors": [{"kind":"doublestar","decoration":"matches","pattern":"release-**"}],"scope_selectors": {"repository": [{"kind":"doublestar","decoration":"repoMatches","pattern":"**"}]}}'HTTP/1.1 201 CreatedLocation: /api/v2.0/projects/prod/immutabletagrules/7# Every repo in project "prod" with a tag matching release-** can now never be overwritten.
Scan on push: stop the known-bad package at the door
Owning the label says nothing about what is inside the box, so the second control looks at the contents. Scan-on-push runs a vulnerability scan the moment a new digest lands, before anything is allowed to pull it, and your pipeline reads the findings and gates on the severity counts. Reach for ECR enhanced scanning, which is backed by Amazon Inspector, rather than basic scanning. Basic scanning reads the operating-system package database and stops there. Enhanced scanning also resolves language-level dependencies, the Go, npm (Node Package Manager) and Python packages where most application CVEs (Common Vulnerabilities and Exposures, the public catalogue of known security bugs) actually live. The golang.org/x/crypto finding below is exactly that kind, and an OS-only scan never sees it. This control is separate from scanning at admission time. The registry scan is the earliest signal you can get, and it leaves a durable, queryable record of what a stored artifact contains. It is also a photograph, not a live feed. An image that came back clean on Tuesday is not clean on Friday when a fresh CVE is published against a library that has been baked into it since March. That is why enhanced scanning and Harbor both re-scan stored images continuously, re-checking them against updated vulnerability data with no re-push involved. Treat the registry as an inventory you keep auditing, not a turnstile you pass through once.
# Enhanced scanning (Amazon Inspector) is set at the REGISTRY level and covers# BOTH OS and language packages; basic repo-level scanning sees only OS packages.# Scan every repository the moment a new digest is pushed:$ aws ecr put-registry-scanning-configuration \--scan-type ENHANCED \--rules '[{"scanFrequency":"SCAN_ON_PUSH","repositoryFilters":[{"filter":"*","filterType":"WILDCARD"}]}]'{"registryScanningConfiguration": {"scanType": "ENHANCED","rules": [{"scanFrequency": "SCAN_ON_PUSH","repositoryFilters": [ { "filter": "*", "filterType": "WILDCARD" } ]}]}}# Swap scanFrequency to CONTINUOUS_SCAN to re-evaluate stored images against new# CVE data without a re-push -- point-in-time scanning is not enough on its own.# Read the findings for the pushed digest and gate the pipeline on the counts:$ aws ecr describe-image-scan-findings \--repository-name api \--image-id imageDigest=sha256:9f2c3a8b4d6e...{"imageScanStatus": { "status": "ACTIVE" },"imageScanFindings": {"findingSeverityCounts": { "HIGH": 1, "MEDIUM": 4 },"enhancedFindings": [{"title": "CVE-2025-22869 - golang.org/x/crypto","severity": "HIGH","status": "ACTIVE","type": "PACKAGE_VULNERABILITY","score": 7.5,"fixAvailable": "YES","packageVulnerabilityDetails": {"vulnerabilityId": "CVE-2025-22869","source": "NVD","sourceUrl": "https://nvd.nist.gov/vuln/detail/CVE-2025-22869","vendorSeverity": "HIGH","cvss": [{"baseScore": 7.5,"scoringVector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H","source": "NVD","version": "3.1"}],"vulnerablePackages": [{"name": "golang.org/x/crypto","version": "0.32.0","fixedInVersion": "0.35.0","packageManager": "GOBINARY"}]}}]}}# HIGH >= 1 -> the pipeline stops before this digest is ever promoted to prod.
Deploy the digest, verify the digest
The last control closes the loop: stop deploying by tag at all. In CI, resolve the tag to a digest exactly once, then use only that digest everywhere downstream. Sign it, attach your attestations to it, write it into the Deployment manifest, and have admission (the check Kubernetes runs before it will start a workload) re-verify that same digest at the door. cosign verify, the Sigstore command-line tool, checks two things at once: that the signature is bound to that digest, and that the certificate used for signing carries the identity of the build workflow you expect. Trust is anchored to a specific pile of bytes made by a specific producer, not to a name that anyone with push rights could move. The check is cheap. It is a hash comparison plus a certificate check plus a look at the bundled Rekor entry (Rekor is Sigstore's public append-only transparency log), done offline in well under a second, so it costs nothing you would notice on a pull or an admission decision. Pinning the digest also protects the wire. When a kubelet (the agent running on each Kubernetes node) or a container runtime fetches image@sha256:..., it recomputes the SHA-256 of the manifest it received and refuses the image if that hash is not the one it asked for, so a compromised mirror or a man-in-the-middle cannot swap in different content. A digest-pinned manifest documents itself, too. Anyone can re-resolve and re-verify it eight months later and get the identical artifact back.
# 1) CI resolves the tag to a digest ONCE, then never mentions the tag again:$ DIGEST=$(crane digest 123456789012.dkr.ecr.us-east-1.amazonaws.com/api:v2.8.0)$ REF="123456789012.dkr.ecr.us-east-1.amazonaws.com/api@${DIGEST}"# 2) Verify the signature is bound to THAT digest and the expected build identity:$ cosign verify \--certificate-identity-regexp '^https://github.com/acme/api/\.github/workflows/release\.yml@refs/tags/v.*$' \--certificate-oidc-issuer https://token.actions.githubusercontent.com \"$REF"Verification for ...api@sha256:9f2c3a8b4d6e... --The following checks were performed on each of these signatures:- The cosign claims were validated- Existence of the claims in the transparency log was verified offline- The code-signing certificate was verified using trusted certificate authority certificates
apiVersion: apps/v1kind: Deploymentmetadata:name: apispec:template:spec:containers:- name: api# Pinned to the verified digest, NOT a tag. The kubelet content-verifies# this exact digest on pull, and admission re-verifies its signature.image: 123456789012.dkr.ecr.us-east-1.amazonaws.com/api@sha256:9f2c3a8b4d6e1f70c2a5b9d8e3f4a1c6b7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2
Every control on this page depends on something the registry does not own. It can only freeze the tag your pipeline published, only record the digest your pipeline pushed, only store the signature your pipeline produced. Whoever holds that CI identity decides what the boundary you built here will treat as trustworthy. The next lesson goes after that producer and hardens the pipeline itself.
Try this
Work through “Deploy the digest, verify the digest” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.
Takeaway
The trap worth remembering here: immutability stops an overwrite, not a delete-then-repush. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.