Back to Blog & Resources
Container Hardening · Supply Chain · Runtime Defense

Shrink, Scan, Confine: A Container Hardening Playbook from Base Image to Unikernel

Container security is not one control. It starts with how little you ship, depends on reading scan results honestly, and ends with how tightly the running process is confined. This playbook walks the whole path: choosing and shrinking the base image, living with distroless, minifying images you already have, verifying the supply chain, separating real vulnerabilities from scanner noise, locking down the runtime, triaging alerts, and — when a process boundary is not enough — running the workload as a unikernel.

Riad DAHMANIK8SEC Security ResearchReviewed September 26, 2026
Monochrome title card reading Shrink, Scan, Confine: Container Hardening Playbook, above an engraved line drawing of an isometric shipping container
Shrink what you ship, scan it honestly, confine what runs.

01The attack surface lives in the base image

Most of the code in a typical container image was not written by the team that ships it. The base image contributes the C library, TLS stack, shell, coreutils and a package manager — and every one of those packages is a CVE the scanner will report and a tool an attacker can use after a foothold. Shrinking the base image removes both problems at once: fewer packages to patch, and fewer utilities available for post-exploitation.

Diagram comparing a regular container, where the application and its data sit on a base image containing coreutils, system libraries, a package manager, libc and bash, with a distroless container that keeps only the application, its data and the required libraries
Regular containers ship coreutils, a package manager, bash and system libraries under the application. Distroless keeps the application, its data and only the libraries it links against.

Choosing a base image: a comparison

The base image fixes the floor of your attack surface. The main minimalist options differ in size, default identity, debuggability and support model:

ImageTypical sizeDefault userWhat it includesPrimary useDebuggabilitySecurity benefit
scratch0 MBn/aNothingStatically compiled binaries (Go, Rust)Extremely difficultAbsolute minimum surface; zero base-image CVEs
Alpine Linux~5–8 MBrootmusl libc, BusyBox shell, apkGeneral-purpose lightweight containersModerate (shell available)Far smaller surface than a full distribution
Distroless~2 MB (static) to ~200 MB (with a language runtime)root, or 65532 with the :nonroot tagglibc, openssl, CA certs, language runtimeProduction runtime for applicationsDifficult (no shell)Removes the utilities attackers use after exploitation
UBI Minimal~40 MB compressedrootglibc, microdnf, core RHEL utilitiesEnterprise Linux with a small footprintModerate (shell available)Commercially supported, fast patching, stable ABI
Default user matters. Alpine and UBI Minimal start as root, and so do the default distroless tags. Pin the :nonroot distroless variant or set USER explicitly — a minimal image running as UID 0 still hands a foothold root inside the container.
Shrink · Scan · Confine
Shrink the image

scratch / distroless / Alpine / UBI Minimal base → multi-stage build → slim or unbase_oci to prune what is left. Measure with dive and image size.

Verify the supply chain

Private registry of approved bases → pinned versions → Cosign signature → admission only for signed images.

Scan honestly

Distro-aware scanner → discount backports, build-time and kernel findings → record decisions as VEX → patch the node for kernel CVEs.

Confine the runtime

Non-root, drop ALL capabilities, read-only root, RuntimeDefault seccomp, Secrets Store CSI, mesh authorization, and a unikernel runtime where a VM boundary is required.

Detect and triage

Runtime alerts (exec, shell from interpreter, fileless execution, etcd access) → context questions → true-positive signals → response.

02Distroless and the price of no shell

Distroless images are the strongest general-purpose runtime base, but removing the shell and package manager moves work elsewhere. Four problems come up in every adoption; each has a practical answer.

Challenging to build from scratch
Do not assemble a root filesystem by hand. Build in a full SDK image and COPY --from the artifact onto a distroless language base (static, base, cc, java, python3).
Hunting down dependencies by hand
Let tools compute the closure: ldd for a single binary, or slim and unbase_oci (section 03), which resolve dynamic library dependencies automatically.
Debugging without a shell
Attach a temporary debug container instead of baking tools in: kubectl debug adds an ephemeral container that shares the target’s process namespace.
Dev/prod drift
Using a “distrofull” image for testing means testing a different artifact. Promote the same image digest through every environment and debug with ephemeral containers.
Debug a distroless pod without adding a shell to the image
# Ephemeral debug container sharing the app's process namespace
kubectl debug -it pod/web-7d4c9 \
  --image=busybox:1.36 \
  --target=app \
  -- sh

# Inside: inspect the app process without a shell in its own image
ls -l /proc/1/root/app
cat /proc/1/status | grep -E 'Uid|CapEff' 

03Minify what you already ship: slim and unbase_oci

Rebuilding every image on a new base takes time. Two tools shrink existing images without changing the application, using opposite strategies.

slim: dynamic analysis

slimtoolkit/slim runs the container, observes which files and libraries the application actually touches, and builds a new image containing only those. The project reports reductions of up to 30x, more for compiled languages. The trade-off is coverage: code paths not exercised during the probe are removed, so drive it with realistic probes and pin anything loaded lazily.

Minify an image with slim
# Run the image, probe its HTTP endpoints, keep only what was used
slim build \
  --target registry.example/web:1.8 \
  --tag registry.example/web:1.8-slim \
  --http-probe=true \
  --include-path /app/templates   # keep lazily loaded files explicitly

docker image ls registry.example/web

unbase_oci: static analysis against the base

unbase_oci from the Garden Linux project needs no running container. It compares the application image with the base image it was built on, keeps every file the application layers added or modified, resolves their dynamic library dependencies, and prunes the rest of the base. A wrapper script moves images in and out of Podman as OCI archives and runs the analysis in its own container:

./unbase_oci(wrapper script)unbase_ocicontainerpodmanrun and mount OCI archivesreturn output OCI archiveexport OCI archivesimport OCI archive
The wrapper exports the base and application images from Podman, runs the unbase_oci container against the archives, and imports the pruned result back.
unbase_ociunbase_rootfsBase OCIExtract OCIBase rootfsApp OCIExtract OCIApp rootfsDiff filesModified setResolve dependsDepends setKeep setPrune rootfsOutput rootfsBuild OCIOutput OCI
Inside unbase_oci: the diff between base and application root filesystems gives the modified set; dependency resolution adds the libraries those files need; everything outside the keep set is pruned before the output image is built.
Run unbase_oci against a Podman image
# -l auto: detect the base image automatically from the image's layers
./unbase_oci -l auto podman:demo :unbased

podman image ls | grep demo
Terminal output of unbase_oci on a demo image: base rootfs 99 MiB, target rootfs 346 MiB, 2639 modified files, 240 dynamic library dependencies, 86 percent of the base removed, image size reduced from 368 MB to 277 MB
A real run: the base rootfs is 99 MiB and the target 346 MiB; unbase_oci finds 2,639 modified files and 240 required library dependencies, removes 86% of the base (85 MiB), and the image drops from 368 MB to 277 MB.

Measure the result layer by layer with dive, which shows what each layer adds and how much space is wasted:

dive file-tree view of the same image before and after minification: package manager metadata, perl-base and apt directories are gone after pruning while the application libraries remain
dive before and after: package-manager metadata (dpkg, apt lists), perl-base and unused tooling are gone; the application’s shared libraries remain.
Gate image efficiency in CI with dive
# Fail the pipeline if the image wastes space across layers
dive --ci \
  --lowestEfficiency=0.95 \
  --highestUserWastedPercent=0.10 \
  registry.example/web:1.8-slim

04A supply chain you can verify

A hardened image only stays hardened if the pipeline that produces it is controlled. Three practices make the chain verifiable end to end.

Private registry
A single trusted source of approved base images (Amazon ECR, Harbor). Stops developers pulling unvetted public images and gives scanners one place to watch.
Multi-stage builds
The builder stage uses a full SDK to compile; the final stage starts from a minimal base and copies only the artifact. Build tools never ship.
Image signing
Sign every image with Sigstore/Cosign to prove it is authentic and untampered, then configure Kubernetes to admit only signed images.
Secrets stay out
Build credentials go through BuildKit secret mounts, never COPY, ARG or ENV. See the layered-model guide.
Sign and verify with Cosign
# Sign by digest, never by tag
cosign sign --key cosign.key registry.example/web@sha256:3f1c9e0b7a2d4c6e8f00112233445566778899aabbccddeeff00112233445566

# Verify before deploy (also enforced at admission below)
cosign verify --key cosign.pub registry.example/web@sha256:3f1c9e0b7a2d4c6e8f00112233445566778899aabbccddeeff00112233445566
Kyverno: admit only signed images from the private registry
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: verify-signed-images
spec:
  validationFailureAction: Enforce      # fail closed
  webhookTimeoutSeconds: 30
  rules:
    - name: require-cosign-signature
      match:
        any: [{ resources: { kinds: ["Pod"] } }]
      verifyImages:
        - imageReferences: ["registry.example/*"]
          mutateDigest: true             # pin the verified digest into the Pod
          attestors:
            - entries:
                - keys:
                    publicKeys: |-
                      -----BEGIN PUBLIC KEY-----
                      <cosign.pub contents>
                      -----END PUBLIC KEY-----

The secure Dockerfile checklist

Harden the image in the Dockerfile itself. Every line below maps to one rule: run as a non-root user, pin every version, clean caches in the same layer that created them, and prefer COPY over ADD, whose remote-URL download and automatic archive extraction make the build less transparent.

Hardened Dockerfile
# Pin the base: never :latest
FROM python:3.11-slim-bookworm

# Create a system user and group for the app
RUN groupadd --system app && useradd --system --gid app --no-create-home app

# Install OS packages and clean the cache in the SAME layer
RUN apt-get update \
 && apt-get install -y --no-install-recommends libpq5=15.* \
 && apt-get clean \
 && rm -rf /var/lib/apt/lists/*

WORKDIR /app
# Pin application dependencies exactly (fastapi==0.104.1 in requirements.txt)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# COPY, not ADD; hand ownership to the app user
COPY --chown=app:app . .

# Drop root before the process starts
USER app
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]

05Why scanners disagree — and which findings are noise

Run three scanners against the same image and you get three different answers. Chainguard’s analysis of common base images makes the spread concrete:

ImageScannerCriticalHighMediumLowUnknown
node:17Trivy3312218350316
Grype3311216348568
Docker Scan / Snyk1326381380
debian:11.2Trivy645560
Grype622556
Docker Scan / Snyk321370
redhat/ubi8:8.5Trivy0059390
Grype0265404
Docker Scan / Snyk0057460
ubuntu:20.04Trivy0027310
Grype0025310
Docker Scan / Snyk005230
alpine:3.15.0Trivy00000
Grype00000
Docker Scan / Snyk00000

Source: Chainguard analysis of base images. The tags are dated (node:17, debian:11.2); read the table for the spread between scanners, not for current counts.

The same node:17 image reports 122, 112 or 26 high-severity findings depending on the tool. Most of that gap is not missed vulnerabilities — it is disagreement about which findings are real. Four sources of false positives account for most of it:

Backported patches (most common)
A distribution such as Debian fixes a CVE without changing the upstream version number. A naive scanner sees the old version and flags a fixed bug. Use a distro-aware scanner that reads the official security advisories.
Unreachable code
The vulnerable function exists in a library, but the application never calls it. Present, not executable. Record the decision as a VEX not_affected statement rather than silently ignoring it.
Build-time dependencies
A CVE in a tool such as maven that lived only in the builder stage and never reached the final image. Scan the image you ship, not the build context.
Kernel vulnerabilities
A container shares the host kernel; an image has no kernel to patch. Kernel CVEs reported against an image are wrong by construction — they are remediated by patching the worker nodes.

Kernel CVEs belong to the node

Because kernel exposure is a node property, the node operating system deserves the same minimisation as the image. Scanning several node OS images with the same tool shows the scale of the difference:

Operating systemCritical CVEsHigh CVEsTotal not-fixed CVEs
Talos Linux 1.11.00296
Flatcar 4230.2.227752
Ubuntu 22.04.0528019435653
Rocky Linux 10038110808

Results from sudo grype dir:/ on each OS. Talos Linux has no shell, so its CVEs were gathered from its SBOM files as described in the Talos documentation. Source: the Talos Linux project’s published comparison.

Scan what ships, fail only on fixable findings, apply VEX
img="registry.example/web:1.8-slim"

# Distro-aware scan of the final image only; fail on fixable high/critical
grype "$img" --only-fixed --fail-on high

# Apply recorded not_affected decisions (OpenVEX) so reviewed noise stays suppressed
trivy image --vex ./vex/web.openvex.json --severity HIGH,CRITICAL --exit-code 1 "$img"

# Node OS: scan the host filesystem, not the image, for kernel and OS packages
sudo grype dir:/ --only-fixed

06Confine the running container

A secure image is necessary but not sufficient. The runtime decides what a compromised process can do.

Never privileged; drop every capability you do not need

A container with securityContext.privileged: true is effectively root on the host and bypasses kernel permission checks. The flag bundles the container with the host rather than isolating it; it exists for administrative workloads that manipulate the network stack or hardware. Avoid it and grant specific capabilities instead.

Linux splits root’s privileges into distinct capabilities that can be enabled and disabled independently. Contrary to what most teams expect, leaving securityContext.capabilities empty does not mean “no capabilities”: the runtime applies its default set, which is far more generous than most processes need. A simple web application does not need CAP_SYS_ADMIN (which allows mounting filesystems) or CAP_NET_ADMIN (which allows network configuration changes). Drop everything and add back only what the application requires — typically CAP_NET_BIND_SERVICE to bind a port below 1024 — and a compromise yields far less.

Finding the minimal set is an allow-list exercise: start with no capabilities and add them one use case at a time. Running under SELinux in permissive mode and reading the audit log, or tracing capability checks while the test suite runs, shows exactly which capabilities the application exercises.

Pod securityContext: least privilege, read-only root, RuntimeDefault seccomp
apiVersion: v1
kind: Pod
metadata:
  name: web-app
spec:
  securityContext:
    runAsNonRoot: true
    runAsUser: 10001
    seccompProfile:
      type: RuntimeDefault          # filters the most dangerous syscalls
  containers:
    - name: app
      image: registry.example/web:1.8-slim
      securityContext:
        privileged: false
        allowPrivilegeEscalation: false
        readOnlyRootFilesystem: true  # nothing can be written to the image filesystem
        capabilities:
          drop: ["ALL"]               # remove the runtime's default set
          add: ["NET_BIND_SERVICE"]   # add back only what the app needs
      volumeMounts:
        - name: tmp
          mountPath: /tmp             # writable scratch space on a volume, not the rootfs
  volumes:
    - name: tmp
      emptyDir: {}
Read-only root filesystem
Like laminating the container’s filesystem: an attacker with code execution cannot drop malware or modify binaries. The app must log to stdout/stderr and use mounted volumes for temporary files.
RuntimeDefault seccomp
The easy win. seccomp filters the system calls a container may make; the runtime default profile blocks roughly 44 of the most dangerous ones with no application changes in almost all cases.

Secrets from a secrets manager, not from the image

The Container Storage Interface (CSI) is the Kubernetes API for exposing storage systems to containers; it lets third-party providers plug new storage types in as volumes. The Secrets Store CSI Driver, developed and maintained by the Kubernetes community, uses that interface to read secrets from a centralised secrets management system (SMS) — HashiCorp Vault, AWS Secrets Manager, Azure Key Vault, Google Secret Manager — and mount them into the Pod as a regular volume. The secret never exists in the image and need not exist as a Kubernetes Secret object at all.

ClusterKubernetesCSISecrets StoreCSI DriverSMS providerPodvolumeApp containerSecretProviderClassHashiCorpVaultvolume projectionfetch datareferenceskey-value refs
The Pod’s volume references a SecretProviderClass that names the keys in Vault; the provider fetches them and Kubernetes CSI projects the result into the Pod as files. Architecture after Kubernetes Patterns, 2nd ed., Figure 25-4.
SecretProviderClass (Vault) and the Pod volume that uses it
apiVersion: secrets-store.csi.x-k8s.io/v1
kind: SecretProviderClass
metadata:
  name: web-db-creds
spec:
  provider: vault
  parameters:
    vaultAddress: "https://vault.internal:8200"
    roleName: "web"                         # Vault Kubernetes-auth role
    objects: |
      - objectName: "db-password"
        secretPath: "secret/data/web/db"
        secretKey: "password"
---
# In the Pod spec
volumes:
  - name: secrets
    csi:
      driver: secrets-store.csi.k8s.io
      readOnly: true
      volumeAttributes:
        secretProviderClass: web-db-creds
# ...and in the container
volumeMounts:
  - name: secrets
    mountPath: /mnt/secrets                 # the app reads /mnt/secrets/db-password
    readOnly: true

Least privilege on the network, too

Capabilities limit what a process can do on its node; a service mesh limits whom it can talk to. With Istio, an AuthorizationPolicy expresses exactly which callers may reach which endpoints. The classic example lets the monitoring stack scrape metrics and nothing else:

Istio AuthorizationPolicy: Prometheus may only GET /metrics
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
  name: prometheus-scraper
  namespace: istio-system       # root namespace: applies to matching Pods in all namespaces
spec:
  selector:
    matchLabels:
      has-metrics: "true"       # only Pods that expose metrics
  action: ALLOW
  rules:
    - from:
        - source:
            namespaces: ["prometheus"]   # callers from the prometheus namespace...
      to:
        - operation:
            methods: ["GET"]             # ...may only GET
            paths: ["/metrics/*"]        # ...the metrics endpoints

Adapted from Kubernetes Patterns, 2nd ed., Example 24-6. security.istio.io/v1 is the current API; older material uses v1beta1. Remember that an ALLOW policy denies all other traffic to the selected workloads, so add a policy for application traffic before rolling this out.

When a process boundary is not enough: unikernels with urunc

urunc is a specialised OCI runtime, similar to runc, designed to run unikernels instead of standard Linux containers. A unikernel is a single-purpose machine image built by compiling the application together with only the bare minimum of operating-system libraries it needs. The result is tiny, fast to boot, and isolated by a hypervisor rather than by kernel namespaces: urunc starts and manages each unikernel as a lightweight virtual machine, using monitors such as QEMU/KVM, Firecracker or Solo5.

This changes the threat model in section 08: a kernel exploit inside a unikernel attacks the unikernel’s own library OS, not the shared host kernel. The cost is that the workload must be built as a unikernel, which suits focused network services far better than general applications.

Select urunc through a RuntimeClass
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: urunc
handler: urunc              # containerd runtime handler configured on the node
---
apiVersion: v1
kind: Pod
metadata:
  name: edge-proxy
spec:
  runtimeClassName: urunc
  containers:
    - name: proxy
      image: registry.example/edge-proxy-unikernel:2.1

07Triaging runtime alerts

Runtime detection fires constantly; the skill is separating an attack from routine operations. For each alert, ask the context question first, then look for the signals that make it a true positive:

AlertWhat it meansQuestion to askTrue-positive signals
Interactive executionSomeone ran kubectl exec into a container.Was this planned and authorised for debugging or maintenance?The Kubernetes audit log shows an unknown user or an external source IP; no matching support ticket exists.
eBPF program loadedA process loaded an eBPF program into the kernel.Is the process a known monitoring or networking agent (Falco, Cilium)?The loader is your application binary (nginx, gunicorn); the action deviates from the learned baseline.
Code executed on the flyAn interpreter ran a command string (python -c).Is it part of a legitimate init script?The parent is a web server (command injection); the string is obfuscated or opens a network socket.
User or group toolsuseradd or groupadd ran.Why would this container create users at runtime?Almost always a true positive in production: an attempt to create a backdoor account.
etcd connectionA process connected to the etcd database.Does this Pod belong to the control plane?Any connection not from kube-apiserver is critical.
Fileless executableA process wrote code to memory and marked it executable.Is this a JIT compiler (Java, .NET)?The process is not a known JIT; the memory contents match a malware signature (YARA).
Language runtime spawns a shellpython or java executed /bin/sh.Does the app legitimately call external shell commands?Almost always a true positive: a successful command-injection exploit.
Hidden file executedA process ran a file whose name starts with a dot (/.x).Is it a known configuration file, or new?The file was written moments before execution, in a writable path such as /tmp.

Two of these rules are cheap to deploy and rarely noisy in production. The Falco rule catches a language runtime spawning a shell; the audit query lists every exec for comparison against change tickets.

Falco: shell spawned by a language runtime
- rule: Language Runtime Spawned Shell
  desc: A Python, Java, Node or PHP process started a shell inside a container
  condition: >
    spawned_process and container
    and proc.name in (sh, bash, dash, ash)
    and proc.pname in (python, python3, java, node, php, php-fpm)
  output: >
    Shell spawned by language runtime
    (parent=%proc.pname cmd=%proc.cmdline container=%container.name
     image=%container.image.repository)
  priority: CRITICAL
  tags: [container, shell, mitre_execution, T1059]
Every kubectl exec from the API server audit log
# Requires an audit policy that records pods/exec at Metadata level or higher
jq -c 'select(.objectRef.subresource == "exec")
  | {ts: .requestReceivedTimestamp, user: .user.username, ip: .sourceIPs,
     ns: .objectRef.namespace, pod: .objectRef.name}' \
  /var/log/kubernetes/audit/audit.log

08The threats this playbook is built for

Three widely exploited vulnerabilities show why every layer above matters — and, just as important, where each one is fixed. Rebuilding an image fixes none of the last two.

CVEComponent & classHow it is usedWhere it is fixed
CVE-2024-2961glibc iconv heap buffer overflow — supply chainCrafted text reaches an application’s character-set conversion (notably PHP), turning the overflow into remote code execution inside the container.Image: rebuild on a patched glibc. Images without glibc charset modules are not exposed.
CVE-2024-21626runc “Leaky Vessels” file-descriptor leak — container escapeA trojanised image’s entrypoint abuses a leaked handle during startup to read and write the host filesystem.Node runtime: runc 1.1.12 or later on every node.
CVE-2024-1086Linux kernel nf_tables use-after-free — privilege escalationFrom a low-privilege shell in a container, a local exploit corrupts kernel memory and gains root on the node. Listed in CISA KEV.Node kernel: patched kernel. Seccomp profiles that deny user-namespace creation narrow the usual path.
1 · Initial accessCrafted request hits a glibc iconv bug (CVE-2024-2961) in a full base image. Blocked by: patched or glibc-free base.
2 · ExecutionThe interpreter spawns a shell and drops tools. Blocked by: distroless (no shell), read-only root, the Falco rule.
3 · EscalationKernel exploit such as CVE-2024-1086 from inside the container. Blocked by: patched node kernel, RuntimeDefault seccomp, dropped capabilities.
4 · HostRoot on the node and access to every Pod on it. Blocked by: a VM boundary per workload (urunc) where the risk warrants it.
MITRE ATT&CK T1190 — Exploit Public-Facing Application · T1059 — Command and Scripting Interpreter · T1068 — Exploitation for Privilege Escalation · T1611 — Escape to Host

Use the checklist as the release gate for a workload. It scores itself; anything short of full coverage is an open item.

0 controls verified

Sources

Every byte you ship is a byte you defend. The fastest security win in containers is not a new tool; it is deleting what the workload never needed — the shell, the package manager, the capabilities, the network paths. Scanners get quieter and honest at the same time, and the runtime alerts that remain start to mean something.

Then be clear about where each fix lives. The image owns its libraries. The node owns its kernel and runtime. The platform owns admission, secrets and the network. A team that patches the wrong layer is busy and still exposed.

See where images, identities and runtime privileges meet

K8SEC Agent maps workloads, images, service accounts and security contexts read-only, so you can find the privileged, capability-heavy or unsigned workloads first. It does not collect Secret values, exec into pods or mutate resources.

Explore KAOS methodology
On this pageAttack surface & base imagesDistroless trade-offsslim & unbase_ociVerifiable supply chainScanner noiseRuntime confinementAlert triageThreats & checklist