Registry security

Push control, immutable tags, trust the digest.

Advanced25 min · lesson 14 of 15

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.

resolve-and-verify-the-digest.sh
# 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.0
sha256: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 | sha256sum
9f2c3a8b4d6e1f70c2a5b9d8e3f4a1c6b7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2 -
# 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.

ecr-immutable-tags.sh
# 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 }
}
mutating-push-rejected.sh
# 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.0
The push refers to repository [123456789012.dkr.ecr.us-east-1.amazonaws.com/api]
9c1b6dd6c1e6: Layer already exists
5f70bf18a086: Layer already exists
tag invalid: The image tag 'v2.8.0' already exists in the 'api' repository
and 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-immutability-rule.sh
# 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 Created
Location: /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.

ecr-scan-on-push.sh
# 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.

deploy-and-verify-by-digest.sh
# 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
deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
spec:
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
Immutability stops an overwrite, not a delete-then-repush
ECR refuses to move a published tag, but it will happily let someone delete the image and then push fresh content under the freed-up name. An identity holding ecr:BatchDeleteImage walks straight around immutability: delete v2.8.0, then push malware as v2.8.0. Your tag freeze is only as strong as your delete permissions, so keep ecr:BatchDeleteImage on a break-glass role that nobody uses day to day. The real backstop is still digest pinning plus signature verification, because a tag that was deleted and recreated resolves to a different digest, and that digest fails verification against the signature you hold for the original.
Deploying registry/api:v2.8.0: what actually protects you?
A workload references registry/api:v2.8.0
a stable name over shifting content, so how far can you trust it?
Mutable tag
The digest you verified is not the digest that runs, and nothing tells you
the tag can be repointed after the scan and the signature
Immutable tag, pull by tag
Locks the pointer, but only if ecr:BatchDeleteImage is locked down too
overwrite blocked, delete-then-repush is not
Pull by digest + verify identity
Runs exactly the bytes that were scanned and signed
content-addressed, signature bound to the digest
The digest is the only reference that content-verifies on pull and can be signed and re-verified for its whole life.

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.

Quick check
01You turned on ECR tag immutability for the prod api repository, but a leaked CI token still carries ecr:BatchDeleteImage. What can its holder do to the published tag v2.8.0?
Incorrect — Immutability blocks overwriting an existing tag and says nothing about deletion, so on its own it is not the whole guarantee.
Correct — Delete-then-repush walks around immutability completely, which is why you also lock down ecr:BatchDeleteImage and pin plus verify by digest.
Incorrect — Immutability covers the mapping from tag to digest, so a direct overwrite of v2.8.0 is refused. That route is closed.
Incorrect — Repointing a published tag is precisely what immutability rejects. The only way through is delete then push, and that produces a new digest.
02Amazon ECR (Elastic Container Registry) offers basic scanning and enhanced scanning, the latter powered by Amazon Inspector. What does enhanced scanning see that basic scanning misses?
Incorrect — No. When a scan runs, on push or continuously, is a separate setting and is not what separates basic from enhanced.
Incorrect — No. Scanning reports vulnerabilities and never produces a signature. Signatures come from cosign in a separate step.
Correct — Most application CVEs sit in language dependencies, which an OS-only scan never looks at.
Incorrect — No. Enhanced scanning reports across severities with richer detail. It widens coverage rather than filtering results down.
03Your Deployment pins the image by digest (image@sha256:...). A compromised registry mirror hands the kubelet different bytes for that digest. What happens?
Incorrect — No. A digest is verified rather than trusted blindly. The runtime hashes whatever bytes arrive.
Correct — Pulling by digest is content-addressed, so substituted bytes hash differently and the pull fails at the transport layer.
Incorrect — No. The mismatch is caught at pull time by content verification, so those bytes never start running.
Incorrect — No. A digest can only refer to the bytes that hash to it, so there is nothing to quietly update.

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.

Related