CKS — Image Allowlisting

9 minute read

Image Allowlisting

CKS Exam Domain: Supply Chain Security (20%)

Without image restrictions, any user with Pod creation privileges can deploy any image from any registry — including Docker Hub, personal registries, or malicious sources. Image allowlisting ensures that only images from trusted, approved registries are permitted to run in the cluster, forming a critical supply chain security gate.


1. The Threat: Unrestricted Image Sources

Without allowlisting:

  Developer A → kubectl run test --image=nginx                     ✅ Docker Hub (public)
  Developer B → kubectl run app --image=registry.evil.com/backdoor ✅ Malicious registry!
  Developer C → kubectl run miner --image=cryptominer:latest       ✅ Crypto mining image!
  Attacker    → kubectl run shell --image=attacker/reverse-shell   ✅ Reverse shell!

With allowlisting:

  Developer A → kubectl run test --image=registry.company.com/nginx:1.27  ✅ Trusted registry
  Developer B → kubectl run app --image=registry.evil.com/backdoor        ❌ DENIED
  Developer C → kubectl run miner --image=cryptominer:latest              ❌ DENIED
  Attacker    → kubectl run shell --image=attacker/reverse-shell          ❌ DENIED

Supply chain attack vectors that allowlisting blocks:

  • Typosquatting — ngixn instead of nginx on Docker Hub
  • tag hijacking — latest tag replaced with malicious image
  • Registry compromise — Public registry serves tampered images
  • Shadow IT — Developers pulling unvetted images

2. OPA Gatekeeper: Allowed Registries

OPA Gatekeeper is a Kubernetes-native admission controller that enforces policies using Rego. This is a CKS-exam-relevant approach.

Step 1: ConstraintTemplate

The ConstraintTemplate defines the policy logic — what registries are allowed.

apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
  name: k8sallowedrepos
spec:
  crd:
    spec:
      names:
        kind: K8sAllowedRepos
      validation:
        openAPIV3Schema:
          type: object
          properties:
            repos:
              type: array
              items:
                type: string
              description: "List of allowed container image registries"
  targets:
  - target: admission.k8s.gatekeeper.sh
    rego: |
      package k8sallowedrepos

      violation[{"msg": msg}] {
        container := input.review.object.spec.containers[_]
        not startswith(container.image, input.parameters.repos[_])
        msg := sprintf("Container '%v' uses image '%v' which is not from an allowed registry. Allowed registries: %v",
          [container.name, container.image, input.parameters.repos])
      }

      # Also check init containers
      violation[{"msg": msg}] {
        container := input.review.object.spec.initContainers[_]
        not startswith(container.image, input.parameters.repos[_])
        msg := sprintf("Init container '%v' uses image '%v' which is not from an allowed registry. Allowed registries: %v",
          [container.name, container.image, input.parameters.repos])
      }

      # Also check ephemeral containers
      violation[{"msg": msg}] {
        container := input.review.object.spec.ephemeralContainers[_]
        not startswith(container.image, input.parameters.repos[_])
        msg := sprintf("Ephemeral container '%v' uses image '%v' which is not from an allowed registry. Allowed registries: %v",
          [container.name, container.image, input.parameters.repos])
      }

Step 2: Constraint

The Constraint applies the template to specific resources with specific parameters.

apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sAllowedRepos
metadata:
  name: allowed-repos
spec:
  enforcementAction: deny          # deny | warn | dryrun
  match:
    kinds:
    - apiGroups: [""]
      kinds: ["Pod"]
    - apiGroups: ["apps"]
      kinds: ["Deployment", "StatefulSet", "DaemonSet"]
    namespaces:
    - production
    - staging
    excludedNamespaces:
    - kube-system                   # Don't block system images
  parameters:
    repos:
    - "registry.company.com/"
    - "gcr.io/company-project/"
    - "012345678901.dkr.ecr.us-east-1.amazonaws.com/"

Test: Verify the Policy

# Apply the policy
kubectl apply -f constrainttemplate.yaml
kubectl apply -f constraint.yaml

# This should SUCCEED
kubectl run trusted-app \
  --image=registry.company.com/nginx:1.27 \
  -n production

# This should be DENIED
kubectl run untrusted-app \
  --image=nginx:1.27 \
  -n production

Expected error:

Error from server (Forbidden): admission webhook "validation.gatekeeper.sh" denied the request:
[allowed-repos] Container 'untrusted-app' uses image 'nginx:1.27' which is not from an allowed registry.
Allowed registries: ["registry.company.com/", "gcr.io/company-project/"]

3. Kyverno: Image Validation Policy

Kyverno is a Kubernetes-native policy engine that uses YAML (no Rego needed). It’s simpler to write and read.

ClusterPolicy: Restrict Image Registries

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: restrict-image-registries
  annotations:
    policies.kyverno.io/title: Restrict Image Registries
    policies.kyverno.io/category: Supply Chain Security
    policies.kyverno.io/severity: high
    policies.kyverno.io/description: >-
      Only allow images from trusted registries to run in the cluster.
      Blocks Docker Hub, quay.io, and other public registries.
spec:
  validationFailureAction: Enforce   # Enforce | Audit
  background: true
  rules:
  - name: validate-registries
    match:
      any:
      - resources:
          kinds:
          - Pod
    exclude:
      any:
      - resources:
          namespaces:
          - kube-system
          - kyverno
    validate:
      message: >-
        Image '{{request.object.spec.containers[].image}}' is not from an allowed registry.
        Only images from registry.company.com and gcr.io/company-project are permitted.
      pattern:
        spec:
          containers:
          - image: "registry.company.com/* | gcr.io/company-project/*"
          =(initContainers):
          - image: "registry.company.com/* | gcr.io/company-project/*"

Test the Kyverno Policy

# Apply the policy
kubectl apply -f restrict-image-registries.yaml

# Allowed — matches registry pattern
kubectl run test --image=registry.company.com/app:v1.0 -n default

# Denied — Docker Hub image
kubectl run test --image=nginx:1.27 -n default
# Error from server: resource Pod/default/test was blocked due to the following policies:
# restrict-image-registries:
#   validate-registries: Image 'nginx:1.27' is not from an allowed registry.

OPA Gatekeeper vs Kyverno

Feature OPA Gatekeeper Kyverno
Policy language Rego YAML
Learning curve Steep Easy
CKS exam relevance High (exam may provide Rego) High (simpler to write under exam pressure)
Mutation support Yes (via assign) Yes (native)
Generation support No Yes (generate resources)
Audit mode Yes (dryrun) Yes (Audit)

4. Private Registry Pull Secrets

To pull images from a private registry, Kubernetes needs authentication credentials stored as a Secret.

Create a Pull Secret

kubectl create secret docker-registry reg-cred \
  --docker-server=registry.company.com \
  --docker-username=k8s-puller \
  --docker-password='P@ss1234' \
  --docker-email=k8s@company.com \
  -n production

Use in a Pod

apiVersion: v1
kind: Pod
metadata:
  name: private-app
  namespace: production
spec:
  containers:
  - name: app
    image: registry.company.com/app:v2.1
  imagePullSecrets:
  - name: reg-cred

Attach to a ServiceAccount (Automatic for All Pods)

# Patch the default ServiceAccount to always use this pull secret
kubectl patch serviceaccount default \
  -n production \
  -p '{"imagePullSecrets": [{"name": "reg-cred"}]}'

Now every Pod in the production namespace using the default ServiceAccount will automatically use reg-cred for image pulls.

Security Note: Pull secrets are stored base64-encoded in etcd. Combine with encryption at rest (EncryptionConfiguration) to protect registry credentials.


5. imagePullPolicy Security Implications

The imagePullPolicy field controls when the kubelet pulls the container image.

Policy Behavior Security Implication
Always Pull every time a Pod starts Detects tag overwrites — if someone pushes a new image to the same tag, you get the new one
IfNotPresent Pull only if image not cached locally Uses cached image — won’t detect tag overwrite (stale but predictable)
Never Never pull, image must exist locally Only works with pre-loaded images (air-gapped environments)

The Problem with Mutable Tags

# ❌ DANGEROUS: "latest" or "v1" tags can be overwritten
image: nginx:latest
imagePullPolicy: IfNotPresent
# Node A caches nginx:latest from Monday
# Attacker pushes malicious image as nginx:latest on Tuesday
# Node A still runs Monday's image (cached)
# Node B pulls Tuesday's malicious image (not cached)
# → Different nodes run different versions!
# ⚠️ BETTER but still not fully secure
image: nginx:latest
imagePullPolicy: Always
# Always pulls, so all nodes get the same version
# BUT if the tag was overwritten, all nodes get the malicious version!
# ✅ SECURE: Digest pinning — immutable reference
image: nginx@sha256:6db391d1c0cfb30588ba0bf72ea999404f2764facf3b22...
# Cannot be overwritten
# Unique to this exact image content
# Same image on every node, always

6. Image Digest Pinning

A digest is a SHA-256 hash of the image manifest. It uniquely and immutably identifies an image. Tags are mutable pointers; digests are not.

Get an Image Digest

# From a pulled image
docker pull nginx:1.27
docker inspect --format='{{index .RepoDigests 0}}' nginx:1.27
# nginx@sha256:6db391d1c0cfb30588ba0bf72ea999404f2764facf3b22...

# From a remote registry (without pulling)
crane digest nginx:1.27
# sha256:6db391d1c0cfb30588ba0bf72ea999404f2764facf3b22...

# Using skopeo
skopeo inspect docker://nginx:1.27 | jq -r '.Digest'

Use in Kubernetes

apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
  namespace: production
spec:
  replicas: 3
  selector:
    matchLabels:
      app: web
  template:
    metadata:
      labels:
        app: web
    spec:
      containers:
      - name: nginx
        image: registry.company.com/nginx@sha256:6db391d1c0cfb30588ba0bf72ea999404f2764facf3b22abc123def456
        ports:
        - containerPort: 80

Enforce Digest Pinning with Kyverno

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-image-digest
spec:
  validationFailureAction: Enforce
  rules:
  - name: check-digest
    match:
      any:
      - resources:
          kinds:
          - Pod
    validate:
      message: "Images must use digest (@sha256:...) instead of tags."
      pattern:
        spec:
          containers:
          - image: "*@sha256:*"

7. Combining Allowlisting Controls

A defense-in-depth approach combines multiple layers:

┌──────────────────────────────────────────────────────┐
│                  Image Pull Request                   │
│                                                       │
│  1. Registry Allowlist (Gatekeeper/Kyverno)           │
│     └── Is the image from a trusted registry? ───→ ❌ │
│                                                       │
│  2. Digest Requirement (Kyverno)                      │
│     └── Does the image use @sha256:...? ──────→ ❌    │
│                                                       │
│  3. ImagePolicyWebhook                                │
│     └── Does the backend approve? ────────────→ ❌    │
│                                                       │
│  4. Pull Secret Authentication                        │
│     └── Can we authenticate to the registry? ─→ ❌    │
│                                                       │
│  5. Image Pull                                        │
│     └── ✅ Image runs in the cluster                  │
└──────────────────────────────────────────────────────┘

Summary

Control Tool What It Blocks
Registry allowlist OPA Gatekeeper, Kyverno Images from untrusted registries
Digest pinning Kyverno policy Mutable tag references
Private registry + pull secrets Kubernetes Secrets Unauthenticated pulls
imagePullPolicy: Always Pod spec Stale cached images (but doesn’t prevent tag overwrite)
ImagePolicyWebhook API server admission Any image not approved by external backend

Practice Questions

Question 1

Write an OPA Gatekeeper Constraint that allows images only from gcr.io/myproject/ and docker.io/library/ in the production namespace.

Show Answer

First, ensure the K8sAllowedRepos ConstraintTemplate is applied (see section 2 above), then apply the Constraint:

apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sAllowedRepos
metadata:
  name: production-repos
spec:
  enforcementAction: deny
  match:
    kinds:
    - apiGroups: [""]
      kinds: ["Pod"]
    namespaces:
    - production
  parameters:
    repos:
    - "gcr.io/myproject/"
    - "docker.io/library/"

Question 2

A developer reports that kubectl run test --image=nginx:1.27 -n staging is being rejected. The error message says the image is not from an allowed registry. However, they need to use this image temporarily for debugging. What are your options?

Show Answer

Options (from most to least secure):

  1. Mirror the image to the trusted registry:
    docker pull nginx:1.27
    docker tag nginx:1.27 registry.company.com/nginx:1.27
    docker push registry.company.com/nginx:1.27
    kubectl run test --image=registry.company.com/nginx:1.27 -n staging
    
  2. Temporarily switch the Gatekeeper constraint to warn mode:
    spec:
      enforcementAction: warn  # Changed from deny
    

    This logs a warning but allows the image. Reset to deny after debugging.

  3. Exclude the staging namespace from the constraint (if acceptable by policy).

  4. Use kubectl debug with an ephemeral container instead of running a new Pod.

Option 1 is the correct approach — always mirror to your trusted registry.

Question 3

What is the security risk of using imagePullPolicy: IfNotPresent with mutable tags like latest?

Show Answer

With IfNotPresent, the kubelet uses the cached image if it exists on the node. Risks:

  1. Inconsistency across nodes — Node A has the cached (old) image, Node B pulls the new (possibly malicious) image. Different Pods in the same Deployment run different code.

  2. Tag overwrite not detected — If an attacker pushes a new image to the same tag, nodes with the cached version are safe but new nodes get the attacker’s image.

  3. No rollback visibility — kubectl describe pod shows nginx:latest on both nodes, hiding the fact that they’re running different content.

Mitigation: Use image digests (@sha256:...) instead of mutable tags. Digests are immutable — the same digest always references the same image content.

Question 4

How do you configure a ServiceAccount so that all Pods using it automatically pull from a private registry?

Show Answer
  1. Create the pull secret:

    kubectl create secret docker-registry reg-cred \
      --docker-server=registry.company.com \
      --docker-username=deployer \
      --docker-password='s3cur3' \
      -n production
    
  2. Patch the ServiceAccount:

    kubectl patch serviceaccount default \
      -n production \
      -p '{"imagePullSecrets": [{"name": "reg-cred"}]}'
    
  3. Verify:

    kubectl get serviceaccount default -n production -o yaml
    # Should show:
    # imagePullSecrets:
    # - name: reg-cred
    

Now every Pod in production that uses the default ServiceAccount will automatically have reg-cred as its image pull secret without specifying it in the Pod spec.

Question 5

Write a Kyverno ClusterPolicy that requires all images in the secure namespace to use SHA256 digests instead of tags.

Show Answer
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-digest
spec:
  validationFailureAction: Enforce
  background: true
  rules:
  - name: require-image-digest
    match:
      any:
      - resources:
          kinds:
          - Pod
          namespaces:
          - secure
    validate:
      message: >-
        All images in the 'secure' namespace must reference a digest
        (@sha256:...) rather than a tag. Image '{{element.image}}'
        does not use a digest.
      foreach:
      - list: "request.object.spec.containers"
        deny:
          conditions:
            any:
            - key: "{{element.image}}"
              operator: NotEquals
              value: "*@sha256:*"

Alternatively, using the simpler pattern syntax:

    validate:
      message: "Images must use digest references (@sha256:...)."
      pattern:
        spec:
          containers:
          - image: "*@sha256:*"

Test:

# Denied
kubectl run test --image=nginx:1.27 -n secure

# Allowed
kubectl run test --image=nginx@sha256:6db391d1c0cfb30588ba... -n secure

Updated: