Container Security in the Cloud: From Image to Runtime
Container security is not a single control but a series of layers that address different phases of the container lifecycle: what goes into an image, whether that image is built from verified sources, how Kubernetes constrains what containers can do at runtime, what network traffic is permitted between pods, where secrets live and how they reach workloads, and what actually happens when a container behaves unexpectedly in production. Each layer independently catches a class of attacks; together they define a posture that is difficult to compromise end-to-end.
This post walks through that full stack, from image provenance to runtime threat detection. The focus is on AWS and Kubernetes, but most of the patterns apply to any cloud.
Supply Chain Security: The Build and Push Phase
A container vulnerability that never gets deployed cannot hurt you. The supply chain controls—image signing, SBOM generation, and vulnerability scanning—apply before any container runs in production.
Image Signing with Cosign and Sigstore
Cosign is the signing tool in the Sigstore ecosystem. The key insight of Sigstore’s keyless signing model is that you do not need to manage a long-lived private signing key. Instead, the CI pipeline proves its identity through an OIDC token (from GitHub Actions, GitLab, Google, or any OIDC provider), Fulcio issues a short-lived certificate tied to that identity, and the signature and certificate are recorded in Rekor—an immutable, append-only transparency log. The private key is ephemeral; the proof of signing is permanent.
|
|
The --certificate-identity pins verification to a specific workflow path. An attacker who compromises a different workflow cannot produce valid signatures for the main build path—the certificate transparency log would contain the wrong identity.
For private registries or environments where keyless signing is not suitable, Cosign also supports KMS-backed keys (AWS KMS, HashiCorp Vault, GCP KMS) and traditional key pairs. The verification tooling is identical regardless of signing method.
Cosign’s attach attestation command goes beyond just signing—it attaches SBOM attestations, test result attestations, or any JSON document to the image in the registry:
|
|
Admission controllers can then verify that a deployed image has a valid SBOM attestation, blocking any image that was not produced by your build pipeline.
Generating SBOMs with Syft
Syft generates Software Bills of Materials—structured inventories of every package in a container image. An SBOM is valuable for vulnerability tracking, license compliance, and incident response (“which running containers contain libssl 3.1.4?”).
|
|
Syft runs entirely offline, supports 50+ package ecosystems (APK, DEB, RPM, npm, pip, Maven, Go modules, Cargo, NuGet, RubyGems), and completes in seconds even for large images. The output in CycloneDX or SPDX formats integrates with any vulnerability scanner or security platform.
Vulnerability Scanning with Grype
Grype scans images or SBOMs for known CVEs, using NVD, GitHub Security Advisories, and distribution-specific feeds updated daily. It pairs naturally with Syft—Grype can accept a Syft SBOM as input rather than re-scanning the image layers:
|
|
Grype uses EPSS (Exploit Prediction Scoring System) and KEV (Known Exploited Vulnerabilities) scores to prioritize findings. A CVE with a high EPSS score or KEV listing warrants immediate attention; a theoretical vulnerability with no public exploit and a low EPSS score can wait for the next patch cycle.
In CI, the pipeline looks like:
|
|
This pipeline blocks promotion of images with high/critical CVEs and produces a signed attestation that later stages can verify.
ECR Image Scanning with Amazon Inspector
ECR’s enhanced scanning mode integrates Amazon Inspector to provide continuous vulnerability scanning—not just at push time, but whenever new CVE data is published. An image that was clean yesterday may have findings today after a new vulnerability disclosure.
Enable enhanced scanning with scan-on-push for a repository:
|
|
Enhanced scanning covers OS packages (Alpine, Debian, Ubuntu, RHEL, Amazon Linux) and language packages (Go, Python, Java, JavaScript, .NET, Ruby, Rust, PHP). As of 2025–2026, it also handles distroless and Chainguard images that lack a traditional OS package manager.
Findings now include lastInUseAt (when the image was last running in ECS or EKS) and InUseCount (how many running tasks or pods use the image). This lets you prioritize remediating vulnerabilities in actively running workloads over images sitting in the registry unused.
To fail a deployment pipeline on high or critical findings:
|
|
Hardening Images Before They Ship
Vulnerability scanning catches known CVEs. Image hardening reduces the attack surface against future unknown vulnerabilities.
Dockerfile Best Practices
|
|
The critical elements:
-
Multi-stage build: The builder stage contains a Go compiler, Alpine packages, and build artifacts. None of that reaches the production image. If an attacker compromises the running container, they cannot access build tools.
-
Distroless base:
gcr.io/distroless/static-debian12:nonrootcontains only the C library (for statically-linked binaries, not even that) and the nonroot user. No shell, noapt, nocurl. An attacker who achieves RCE cannot easily pivot—standard post-exploitation tools don’t exist in the image. -
:nonroottag: Runs as UID 65532 by default. Combine withrunAsNonRoot: truein the pod spec to enforce this.
For applications that require more packages (interpreters, native libraries), Chainguard images provide hardened, regularly rebuilt versions of common base images with minimal CVE exposure:
|
|
Debugging Distroless Containers
The main operational friction with distroless images is that there is no shell to exec into when debugging. Use ephemeral debug containers:
|
|
The distroless :debug tag includes BusyBox for exactly this purpose and can be used in production for break-glass scenarios.
Pod Security Standards
Pod Security Standards (PSA) replaced PodSecurityPolicy in Kubernetes 1.25 and represent the current mechanism for constraining what pods can do at the platform level. PSA is enforced by the Pod Security admission controller, which watches namespace labels to determine the enforcement profile.
The Three Profiles
Privileged: No restrictions. Appropriate only for system-level workloads like node agents, storage drivers, and CNI plugins that legitimately need elevated access.
Baseline: Blocks the most egregious privilege escalation paths—host network access, privileged containers, HostPath volumes, host process namespace sharing. Most application workloads can run under Baseline without changes.
Restricted: Enforces current hardening best practices. Requires runAsNonRoot, drops all Linux capabilities, requires a seccomp profile, and disallows privilege escalation. Some legitimate workloads (those that need specific capabilities or run as root for good reason) cannot satisfy Restricted without application changes.
To enforce Restricted on a namespace:
|
|
The audit and warn labels log or warn on violations without blocking—useful for assessing impact before switching to enforce. The version pin prevents profile behavior from changing when you upgrade Kubernetes.
A pod spec compliant with Restricted:
|
|
The readOnlyRootFilesystem: true setting is frequently omitted because it breaks applications that write to their own filesystem. The correct fix is to mount emptyDir volumes at the paths where the application writes temporary or cache data—not to leave the filesystem writable.
Migration from PodSecurityPolicy
PodSecurityPolicy was removed in Kubernetes 1.25. If you are still running pre-1.25 clusters or have configuration in Helm charts that references PSP, the migration path is:
- Enable PSA in
warnmode at the namespace level while PSP is still active - Collect violations for one sprint—these are pods that will need changes
- Fix the pods (update security contexts, add capability drops)
- Switch to
enforcemode - Remove PSP RBAC and PSP objects
Kyverno’s mutate rules can smooth this transition by automatically patching non-compliant pods rather than blocking them, giving application teams time to update their manifests.
Admission Controllers: Kyverno vs OPA/Gatekeeper
PSA enforces platform-wide security baselines. Admission controllers enforce organization-specific policies: “all pods must have resource limits,” “images must use digests not tags,” “every deployment must have a team label.”
Kyverno
Kyverno uses Kubernetes YAML—no Rego, no custom language. Policies are ClusterPolicy or Policy objects.
Block :latest image tags:
|
|
Require CPU and memory limits:
|
|
Verify image signatures at deploy time:
|
|
This last policy blocks deployment of any image from ghcr.io/myorg/ that was not signed by your main branch GitHub Actions workflow. An attacker who builds and pushes an image manually cannot deploy it to production—the signature verification fails.
OPA/Gatekeeper
Gatekeeper uses Rego policies and is appropriate when you need a unified policy language across Kubernetes and non-Kubernetes infrastructure, or when your team already has Rego expertise. For Kubernetes-only teams, Kyverno is significantly easier.
|
|
Network Policies for Pod Isolation
By default, Kubernetes networking is fully permissive: any pod in any namespace can reach any other pod. A compromised pod can probe the entire cluster. Network policies change this.
The fundamental property: once any NetworkPolicy selects a pod via its podSelector, that pod is isolated—all traffic not explicitly permitted by a policy is dropped.
Default Deny
Apply this to every namespace that doesn’t have a specific reason for open traffic:
|
|
After applying default-deny, explicitly permit what is needed:
|
|
DNS egress (port 53) must be explicitly permitted in the egress policy, or DNS resolution breaks.
Cilium for L7 Policies
Standard NetworkPolicy operates at L3/L4—IP addresses and ports. Cilium extends this to L7, allowing policy decisions based on HTTP methods, paths, or gRPC service names:
|
|
This permits the frontend to make GET requests to /api/v1/users and POST requests to /api/v1/orders, but nothing else—no administrative endpoints, no other methods. L7 policy enforcement happens in the eBPF datapath; it does not add a proxy hop and therefore has minimal latency impact.
External Secrets Operator
Secrets in Kubernetes manifests are base64-encoded but not encrypted. Committing a manifest with a Secret to git exposes the plaintext value to anyone with git access. External Secrets Operator (ESO) eliminates this by never putting secret values in Kubernetes manifests at all—they live in AWS Secrets Manager, SSM Parameter Store, or another external vault, and ESO syncs them into Kubernetes Secrets on a schedule.
Setup with AWS Secrets Manager
First, give the ESO pod permission to read secrets. Use IRSA so ESO has fine-grained access to only the secrets it needs:
|
|
Create a ClusterSecretStore that references this provider:
|
|
Create an ExternalSecret to sync a specific secret:
|
|
The resulting Kubernetes Secret is owned by the ExternalSecret object—when the ExternalSecret is deleted, the Secret is deleted. The Secret’s value is automatically updated when Secrets Manager rotates the credential (within the refreshInterval window). The git repository contains only the ExternalSecret manifest, which has no secret values.
For bulk sync from Parameter Store by path:
|
|
This pulls all SSM parameters under /production/payments-service/ and creates a Kubernetes Secret with one key per parameter.
Kubernetes RBAC for Workloads
Every pod has a service account. By default, Kubernetes mounts a service account token into every pod, and that token can make requests to the Kubernetes API server. Most application pods don’t need Kubernetes API access—mounting a token gives them unnecessary capability.
Disable Automatic Token Mounting
For application workloads that don’t need cluster API access:
|
|
For pods that do need Kubernetes API access, use projected service account tokens with bounded lifetimes rather than the static tokens mounted by default in older Kubernetes versions:
|
|
Projected tokens are automatically rotated before expiry. If the pod is deleted, the token is immediately invalidated.
For IRSA (AWS), the EKS Pod Identity mutating webhook automatically injects the projected token volume—you do not need to configure it manually. The service account annotation points to the IAM role:
|
|
Falco Runtime Threat Detection
All the controls above are preventive. Falco is detective—it watches what containers actually do and alerts when behavior deviates from expectations. It hooks into the kernel via eBPF (or a kernel module on older kernels) and evaluates every syscall against a rule set.
The default Falco rule set catches:
- Shell executed inside a container (
proc.name in (sh, bash, zsh, dash) and container.id != host) - Write to sensitive files (
fd.name in (/etc/passwd, /etc/shadow, /etc/sudoers)) - Network connection outbound from unexpected containers
- Process privilege escalation (
proc.vpid != proc.pid) - Reading SSH keys or cloud credentials
Install Falco via Helm on Kubernetes:
|
|
Writing Custom Rules
Falco rules use a domain-specific condition language over the event fields. A rule to detect unexpected outbound connections from a specific service:
|
|
A rule to detect when a container writes to an unexpected path:
|
|
Falco Sidekick Routing
Falco outputs events to stdout by default. Falcosidekick is a companion service that reads Falco’s HTTP output and routes events to 80+ destinations:
|
|
Route low-severity events to Elasticsearch for retention and search; medium-severity events to Slack for team awareness; critical events to PagerDuty for immediate response.
Kubernetes Audit Log Integration
Falco’s k8saudit plugin processes Kubernetes API server audit logs—capturing who called what API, with what parameters, and with what result. This adds visibility into cluster management activity that syscall-based rules cannot see.
|
|
With the k8saudit plugin, Falco can detect:
kubectl execinto production pods- Secret reads from the API server
- RBAC binding changes (new admin role grants)
- Namespace label modifications (bypassing PSA)
- Image pull from untrusted registries
Putting It Together: A Deployment Pipeline
The full security chain for a containerized workload:
Development
│
▼ git push
CI Pipeline
├── Build image (multi-stage, distroless base)
├── Generate SBOM (syft)
├── Scan vulnerabilities (grype --fail-on high)
├── Push to ECR
├── Sign image (cosign --keyless)
└── Attach SBOM attestation (cosign attest)
│
▼ (all gates pass)
ECR
├── Enhanced scanning enabled (scan-on-push + continuous)
└── Inspector finds new CVEs → alerts team, blocks further deploys
│
▼ kubectl apply
Kubernetes Admission
├── Kyverno: verify Cosign signature
├── Kyverno: block :latest tags
├── Kyverno: require resource limits
└── PSA: enforce Restricted profile
│
▼ (pod scheduled)
Runtime
├── readOnlyRootFilesystem, runAsNonRoot enforced by pod spec
├── capabilities: drop: [ALL] enforced by pod spec
├── NetworkPolicy: default-deny, explicit ingress/egress
├── ESO: secrets from Secrets Manager, never in manifests
└── Falco: runtime anomaly detection → alert on deviations
No single control is sufficient. Vulnerability scanning misses unknown CVEs; runtime detection catches behaviors that scanners cannot. Image signing prevents pull of unsigned images but not exploitation of a signed image’s own vulnerabilities. The value is in the combination: an attacker must bypass every layer simultaneously.
Comments