LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Container Security in the Cloud: From Image to Runtime

securitykubernetescontainersawsdevopscloudsupply-chainfalco

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# In GitHub Actions, this is all you need
# OIDC identity is automatic; no keys to manage
cosign sign \
  --yes \
  ghcr.io/myorg/myapp:sha-abc1234

# Verify an image before deployment
cosign verify \
  --certificate-identity "https://github.com/myorg/myapp/.github/workflows/build.yml@refs/heads/main" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  ghcr.io/myorg/myapp:sha-abc1234

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:

1
2
3
4
5
6
# Attach an SBOM as an attestation (more powerful than signing alone)
cosign attest \
  --yes \
  --predicate sbom.spdx.json \
  --type spdxjson \
  ghcr.io/myorg/myapp:sha-abc1234

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?”).

1
2
3
4
5
6
7
8
# Generate SBOM from image
syft ghcr.io/myorg/myapp:sha-abc1234 -o spdx-json=sbom.spdx.json

# Or from a local directory during build
syft dir:. -o cyclonedx-json=sbom.cyclonedx.json

# Generate from a running container's filesystem
syft packages container:myapp

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# Scan directly
grype ghcr.io/myorg/myapp:sha-abc1234

# Scan from SBOM (faster; no image pull)
grype sbom:sbom.spdx.json

# Fail CI on high or critical findings
grype ghcr.io/myorg/myapp:sha-abc1234 --fail-on high

# Output JSON for processing
grype ghcr.io/myorg/myapp:sha-abc1234 -o json | \
  jq '.matches[] | select(.vulnerability.severity == "Critical") | .vulnerability.id'

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# GitHub Actions example
- name: Build image
  run: docker build -t myapp:${{ github.sha }} .

- name: Generate SBOM
  run: syft myapp:${{ github.sha }} -o spdx-json=sbom.json

- name: Scan for vulnerabilities
  run: grype sbom:sbom.json --fail-on high

- name: Sign image
  run: |
    cosign sign --yes \
      $ECR_REGISTRY/myapp:${{ github.sha }}

- name: Attach SBOM attestation
  run: |
    cosign attest --yes \
      --predicate sbom.json \
      --type spdxjson \
      $ECR_REGISTRY/myapp:${{ github.sha }}

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:

1
2
3
4
5
6
7
8
aws ecr put-registry-scanning-configuration \
  --scan-type ENHANCED \
  --rules '[
    {
      "repositoryFilters": [{"filter": "*", "filterType": "WILDCARD"}],
      "scanFrequency": "CONTINUOUS_SCAN"
    }
  ]'

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# Query findings for a specific image
aws ecr describe-image-scan-findings \
  --repository-name myapp \
  --image-id imageDigest=sha256:abc123 \
  --query 'imageScanFindings.findingSeverityCounts' \
  --output json

# Fail if HIGH or CRITICAL findings exist
CRITICAL=$(aws ecr describe-image-scan-findings \
  --repository-name myapp \
  --image-id imageDigest=sha256:abc123 \
  --query 'imageScanFindings.findingSeverityCounts.CRITICAL || `0`' \
  --output text)

if [ "$CRITICAL" -gt 0 ]; then
  echo "Blocking deployment: $CRITICAL critical vulnerabilities found"
  exit 1
fi

Hardening Images Before They Ship

Vulnerability scanning catches known CVEs. Image hardening reduces the attack surface against future unknown vulnerabilities.

Dockerfile Best Practices

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Multi-stage build: build tools never reach production image
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o server ./cmd/server

# Minimal final stage — no shell, no package manager
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]

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:nonroot contains only the C library (for statically-linked binaries, not even that) and the nonroot user. No shell, no apt, no curl. An attacker who achieves RCE cannot easily pivot—standard post-exploitation tools don’t exist in the image.

  • :nonroot tag: Runs as UID 65532 by default. Combine with runAsNonRoot: true in 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:

1
2
3
4
5
6
7
FROM cgr.dev/chainguard/python:latest-dev AS builder
# ... install dependencies in builder stage

FROM cgr.dev/chainguard/python:latest
COPY --from=builder /install /usr/local
COPY app/ /app/
ENTRYPOINT ["python", "/app/main.py"]

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Add a debug container to a running pod without restarting it
kubectl debug -it \
  --image=busybox \
  --target=myapp \
  pod/myapp-abc123 \
  -- sh

# Or use a debug image that shares the process namespace
kubectl debug -it \
  --image=gcr.io/distroless/base-debian12:debug \
  pod/myapp-abc123

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:

1
2
3
4
5
6
7
8
9
apiVersion: v1
kind: Namespace
metadata:
  name: production
  labels:
    pod-security.kubernetes.io/enforce: restricted
    pod-security.kubernetes.io/enforce-version: v1.30
    pod-security.kubernetes.io/audit: restricted
    pod-security.kubernetes.io/warn: restricted

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
securityContext:
  runAsNonRoot: true
  runAsUser: 1001
  runAsGroup: 1001
  fsGroup: 1001
  seccompProfile:
    type: RuntimeDefault
containers:
  - name: app
    image: myapp:sha-abc1234
    securityContext:
      allowPrivilegeEscalation: false
      readOnlyRootFilesystem: true
      capabilities:
        drop:
          - ALL
    volumeMounts:
      - name: tmp
        mountPath: /tmp
      - name: cache
        mountPath: /app/cache
volumes:
  - name: tmp
    emptyDir: {}
  - name: cache
    emptyDir: {}

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:

  1. Enable PSA in warn mode at the namespace level while PSP is still active
  2. Collect violations for one sprint—these are pods that will need changes
  3. Fix the pods (update security contexts, add capability drops)
  4. Switch to enforce mode
  5. 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: disallow-latest-tag
spec:
  validationFailureAction: Enforce
  rules:
    - name: require-image-tag
      match:
        any:
          - resources:
              kinds: [Pod]
      validate:
        message: "Image tag ':latest' is not allowed. Use a specific tag or digest."
        pattern:
          spec:
            containers:
              - image: "!*:latest"
            initContainers:
              - image: "!*:latest"

Require CPU and memory limits:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-resource-limits
spec:
  validationFailureAction: Enforce
  rules:
    - name: check-container-resources
      match:
        any:
          - resources:
              kinds: [Pod]
              namespaces: [production, staging]
      validate:
        message: "CPU and memory limits are required for all containers."
        pattern:
          spec:
            containers:
              - resources:
                  limits:
                    memory: "?*"
                    cpu: "?*"

Verify image signatures at deploy time:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: verify-image-signature
spec:
  validationFailureAction: Enforce
  rules:
    - name: check-signature
      match:
        any:
          - resources:
              kinds: [Pod]
              namespaces: [production]
      verifyImages:
        - imageReferences:
            - "ghcr.io/myorg/*"
          attestors:
            - entries:
                - keyless:
                    subject: "https://github.com/myorg/*/.github/workflows/*@refs/heads/main"
                    issuer: "https://token.actions.githubusercontent.com"

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Gatekeeper: Deny latest tag
package k8sdisallowlatesttag

violation[{"msg": msg}] {
  container := input.review.object.spec.containers[_]
  endswith(container.image, ":latest")
  msg := sprintf("Container '%v' uses ':latest' tag, which is not allowed", [container.name])
}

violation[{"msg": msg}] {
  container := input.review.object.spec.containers[_]
  not contains(container.image, ":")
  msg := sprintf("Container '%v' has no tag, which is not allowed", [container.name])
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Deny all ingress and egress for pods in this namespace
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-all
  namespace: production
spec:
  podSelector: {}  # selects all pods
  policyTypes:
    - Ingress
    - Egress

After applying default-deny, explicitly permit what is needed:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
# Allow the payments service to receive traffic from the API gateway
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-ingress-from-api-gateway
  namespace: production
spec:
  podSelector:
    matchLabels:
      app: payments-service
  policyTypes:
    - Ingress
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: api-gateway
          podSelector:
            matchLabels:
              app: api-gateway
      ports:
        - protocol: TCP
          port: 8080

---
# Allow payments service to reach its database
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-egress-to-database
  namespace: production
spec:
  podSelector:
    matchLabels:
      app: payments-service
  policyTypes:
    - Egress
  egress:
    - to:
        - podSelector:
            matchLabels:
              app: postgres
      ports:
        - protocol: TCP
          port: 5432
    - to:
        - namespaceSelector: {}  # Allow DNS resolution
      ports:
        - protocol: UDP
          port: 53

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
  name: allow-specific-http-paths
  namespace: production
spec:
  endpointSelector:
    matchLabels:
      app: internal-api
  ingress:
    - fromEndpoints:
        - matchLabels:
            app: frontend
      toPorts:
        - ports:
            - port: "8080"
              protocol: TCP
          rules:
            http:
              - method: GET
                path: "/api/v1/users"
              - method: POST
                path: "/api/v1/orders"

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": [
      "secretsmanager:GetSecretValue",
      "secretsmanager:DescribeSecret"
    ],
    "Resource": "arn:aws:secretsmanager:us-east-1:123456789:secret:production/*"
  }]
}

Create a ClusterSecretStore that references this provider:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
  name: aws-secretsmanager
spec:
  provider:
    aws:
      service: SecretsManager
      region: us-east-1
      auth:
        jwt:
          serviceAccountRef:
            name: external-secrets-sa
            namespace: external-secrets

Create an ExternalSecret to sync a specific secret:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: database-credentials
  namespace: production
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: aws-secretsmanager
    kind: ClusterSecretStore
  target:
    name: database-credentials  # name of the Kubernetes Secret to create/update
    creationPolicy: Owner
  data:
    - secretKey: DB_PASSWORD        # key in the Kubernetes Secret
      remoteRef:
        key: production/database    # secret name in Secrets Manager
        property: password          # JSON field within the secret
    - secretKey: DB_USERNAME
      remoteRef:
        key: production/database
        property: username

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:

1
2
3
dataFrom:
  - extract:
      key: /production/payments-service

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
apiVersion: v1
kind: ServiceAccount
metadata:
  name: payments-service
  namespace: production
automountServiceAccountToken: false

---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: payments-service
spec:
  template:
    spec:
      serviceAccountName: payments-service
      automountServiceAccountToken: false  # belt and suspenders
      containers:
        - name: payments
          image: myapp:sha-abc1234

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:

1
2
3
4
5
6
7
8
volumes:
  - name: kube-api-token
    projected:
      sources:
        - serviceAccountToken:
            path: token
            expirationSeconds: 3600
            audience: https://kubernetes.default.svc

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:

1
2
3
4
5
6
7
8
apiVersion: v1
kind: ServiceAccount
metadata:
  name: payments-service
  namespace: production
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::123456789:role/PaymentsServiceRole
    eks.amazonaws.com/token-expiration: "3600"  # 1 hour

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
helm repo add falcosecurity https://falcosecurity.github.io/charts
helm repo update

helm install falco falcosecurity/falco \
  --namespace falco \
  --create-namespace \
  --set driver.kind=ebpf \
  --set falcosidekick.enabled=true \
  --set falcosidekick.config.slack.webhookurl=https://hooks.slack.com/services/... \
  --set falcosidekick.config.slack.minimumpriority=warning

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
- rule: Unexpected outbound connection from payments-service
  desc: payments-service should only connect to the database and internal APIs
  condition: >
    outbound
    and container.name = "payments"
    and not (
      fd.rip in (10.0.1.50, 10.0.1.51)  # database IPs
      or fd.rip_name endswith ".internal.example.com"
    )
  output: >
    Unexpected outbound connection from payments service
    (container=%container.name pid=%proc.pid connection=%fd.name
     user=%user.name image=%container.image.repository)
  priority: WARNING
  tags: [network, payments, custom]

A rule to detect when a container writes to an unexpected path:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
- rule: Write outside allowed paths
  desc: containers should only write to /tmp and /app/cache
  condition: >
    open_write
    and container
    and not (fd.name startswith /tmp or fd.name startswith /app/cache)
    and not proc.name = "syslog"
  output: >
    Unexpected write in container
    (user=%user.name path=%fd.name container=%container.name image=%container.image.repository)
  priority: WARNING

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# falcosidekick values.yaml
config:
  slack:
    webhookurl: "https://hooks.slack.com/services/..."
    minimumpriority: "warning"
    messageformat: "Falco Alert: *{{.Rule}}* | Priority: {{.Priority}} | Container: {{.OutputFields.container_name}}"

  elasticsearch:
    hostport: "https://elasticsearch.internal:9200"
    index: "falco-events"
    minimumpriority: "notice"

  pagerduty:
    routingkey: "your-pagerduty-routing-key"
    minimumpriority: "critical"

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# Enable Kubernetes audit logging (kube-apiserver flags)
--audit-log-path=/var/log/kubernetes/audit.log
--audit-policy-file=/etc/kubernetes/audit-policy.yaml
--audit-webhook-config-file=/etc/kubernetes/falco-webhook.yaml

# falco-webhook.yaml
apiVersion: v1
kind: Config
clusters:
  - name: falco
    cluster:
      server: http://falco.falco.svc.cluster.local:9765/k8s-audit

With the k8saudit plugin, Falco can detect:

  • kubectl exec into 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