1002 lines
30 KiB
Markdown
1002 lines
30 KiB
Markdown
---
|
||
name: container-security-hardening
|
||
description: >
|
||
Harden Docker/container images and runtime deployments with secure base images,
|
||
non-root users, CVE scanning, SBOM/signing, seccomp/AppArmor, and Kubernetes
|
||
pod security controls. Use for Dockerfile security reviews, container CVEs,
|
||
image scanning, distroless images, or production hardening.
|
||
category: security
|
||
risk: safe
|
||
source: community
|
||
date_added: "2026-05-30"
|
||
---
|
||
|
||
# Container Security Hardening Skill
|
||
|
||
A production-focused guide for building, scanning, and running containers securely — from Dockerfile authoring through runtime enforcement and supply chain integrity.
|
||
|
||
---
|
||
|
||
## When to Use This Skill
|
||
|
||
- User mentions Docker security, container hardening, or Dockerfile security review
|
||
- User asks about distroless images, non-root containers, or read-only filesystems
|
||
- User wants to scan images for CVEs with Trivy, Grype, or Snyk
|
||
- User mentions seccomp, AppArmor, Linux capabilities, or runtime security
|
||
- User asks "is my Dockerfile secure?" or "how do I reduce my image attack surface?"
|
||
- User wants to sign/verify images with Cosign or generate SBOMs
|
||
- User asks about Kubernetes pod security, NetworkPolicy, or RBAC hardening
|
||
- User says "fix container CVEs" or "harden my container for production"
|
||
|
||
## When NOT to Use This Skill
|
||
|
||
- The user is primarily asking about GitHub Actions CI/CD → recommend `github-actions-advanced`
|
||
- The user needs general Docker usage help (not security) → recommend `docker-expert`
|
||
- The user is working with Kubernetes orchestration beyond security → recommend `kubernetes-architect`
|
||
- The user needs application-level security (SQL injection, XSS) → recommend `api-security-best-practices`
|
||
|
||
---
|
||
|
||
## Step 1: Understand Context Before Responding
|
||
|
||
When invoked, first detect the current state:
|
||
|
||
```bash
|
||
# Find Dockerfiles in the project
|
||
find . -name "Dockerfile*" -not -path "*/node_modules/*" | head -10
|
||
|
||
# Check for existing security tooling
|
||
ls .trivyignore .hadolint.yaml .snyk docker-compose*.yml 2>/dev/null
|
||
|
||
# Inspect base images currently in use
|
||
grep -r "^FROM" $(find . -name "Dockerfile*") 2>/dev/null
|
||
|
||
# Check if Kubernetes manifests exist
|
||
find . -name "*.yaml" -path "*/k8s/*" -o -name "*.yaml" -path "*/manifests/*" | head -10
|
||
```
|
||
|
||
Then adapt recommendations to:
|
||
- The tech stack (Node, Python, Go, Java — affects base image choice)
|
||
- Whether this is Docker-only or Kubernetes-deployed
|
||
- The CI platform in use (for scanner integration)
|
||
- The existing base images and how far they are from best practice
|
||
|
||
---
|
||
|
||
## The Five Layers of Container Security
|
||
|
||
```
|
||
1. Image Build → Minimal base, no secrets, non-root, read-only FS
|
||
2. Image Scanning → CVE scanning, SBOM, secret detection, Dockerfile lint
|
||
3. Runtime Security → Capabilities, seccomp, AppArmor, resource limits
|
||
4. Supply Chain → Signed images, pinned digests, trusted registries
|
||
5. Kubernetes Layer → Pod Security Admission, NetworkPolicy, RBAC, Kyverno
|
||
```
|
||
|
||
> Work through layers in order — hardening the image first gives the most leverage.
|
||
> See `references/base-image-comparison.md` for a full size/CVE trade-off table.
|
||
|
||
---
|
||
|
||
## Layer 1: Dockerfile Hardening
|
||
|
||
### 1.1 Use a Minimal Base Image
|
||
|
||
```dockerfile
|
||
# ❌ AVOID — massive attack surface (~100–200 CVEs typical)
|
||
FROM ubuntu:latest
|
||
FROM node:20
|
||
|
||
# ✅ BETTER — slim variants (glibc, smaller apt footprint)
|
||
FROM node:20-slim
|
||
FROM python:3.12-slim
|
||
|
||
# ✅ BEST — distroless (no shell, no package manager, built-in nonroot user)
|
||
FROM gcr.io/distroless/nodejs20-debian12
|
||
FROM gcr.io/distroless/python3-debian12
|
||
FROM gcr.io/distroless/static-debian12 # Go/Rust fully-static binaries
|
||
|
||
# ✅ ALSO GREAT — Alpine (musl libc; verify app compatibility first)
|
||
FROM alpine:3.20
|
||
|
||
# ✅ ZERO ATTACK SURFACE — for fully static binaries only
|
||
FROM scratch
|
||
```
|
||
|
||
See `references/base-image-comparison.md` for the full trade-off matrix.
|
||
|
||
### 1.2 Multi-Stage Build — Separate Build from Runtime
|
||
|
||
Never ship build tools, compilers, or dev dependencies in a production image.
|
||
|
||
```dockerfile
|
||
# syntax=docker/dockerfile:1
|
||
|
||
# ── Stage 1: Install & Build ──────────────────────────────
|
||
FROM node:20-slim AS builder
|
||
WORKDIR /build
|
||
COPY package*.json ./
|
||
RUN npm ci # Install all deps (including devDeps)
|
||
COPY . .
|
||
RUN npm run build && npm prune --production
|
||
|
||
# ── Stage 2: Runtime — minimal, no build tools ────────────
|
||
FROM gcr.io/distroless/nodejs20-debian12@sha256:<digest>
|
||
LABEL org.opencontainers.image.source="https://github.com/org/repo"
|
||
LABEL org.opencontainers.image.revision="${BUILD_SHA}"
|
||
LABEL org.opencontainers.image.licenses="MIT"
|
||
WORKDIR /app
|
||
COPY --from=builder --chown=nonroot:nonroot /build/dist ./dist
|
||
COPY --from=builder --chown=nonroot:nonroot /build/node_modules ./node_modules
|
||
USER nonroot:nonroot # UID 65532 — built into distroless
|
||
EXPOSE 3000
|
||
CMD ["dist/server.js"]
|
||
```
|
||
|
||
**Go / Rust static binary pattern:**
|
||
```dockerfile
|
||
FROM golang:1.22-alpine AS builder
|
||
WORKDIR /build
|
||
COPY go.* ./
|
||
RUN go mod download
|
||
COPY . .
|
||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o app .
|
||
|
||
FROM scratch # Zero attack surface
|
||
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||
COPY --from=builder /build/app /app
|
||
USER 65532:65532
|
||
ENTRYPOINT ["/app"]
|
||
```
|
||
|
||
### 1.3 Run as Non-Root User
|
||
|
||
```dockerfile
|
||
# For debian/ubuntu-based images — create dedicated user
|
||
RUN groupadd -r appgroup --gid 10001 && \
|
||
useradd -r -g appgroup --uid 10001 --no-log-init appuser
|
||
|
||
COPY --chown=appuser:appgroup . /app
|
||
|
||
USER appuser # Switch before CMD/ENTRYPOINT — never run as root
|
||
|
||
# ─────────────────────────────────────────────────────────
|
||
# For Alpine-based images
|
||
RUN addgroup -g 10001 -S appgroup && \
|
||
adduser -u 10001 -S appuser -G appgroup
|
||
|
||
# For distroless — nonroot (UID 65532) is already built in
|
||
USER nonroot:nonroot
|
||
```
|
||
|
||
### 1.4 Pin Base Images to Digest
|
||
|
||
```dockerfile
|
||
# ❌ UNSAFE — tags are mutable; image can be silently overwritten (supply chain attack)
|
||
FROM node:20-slim
|
||
|
||
# ✅ SAFE — SHA256 digest is cryptographically immutable
|
||
FROM node:20-slim@sha256:a1b2c3d4e5f6789abcdef0123456789abcdef0123456789abcdef0123456789ab
|
||
```
|
||
|
||
**Get the current digest:**
|
||
```bash
|
||
docker pull node:20-slim
|
||
docker inspect node:20-slim --format='{{index .RepoDigests 0}}'
|
||
```
|
||
|
||
**Automate digest pinning** with Renovate or Dependabot:
|
||
```json
|
||
// .renovaterc.json
|
||
{
|
||
"extends": ["config:base"],
|
||
"dockerfile": { "enabled": true },
|
||
"pinDigests": true
|
||
}
|
||
```
|
||
|
||
### 1.5 Never Bake Secrets into Images
|
||
|
||
```dockerfile
|
||
# ❌ NEVER — secret in ENV or RUN; visible in `docker history` and layer cache
|
||
ENV AWS_SECRET_ACCESS_KEY=supersecret
|
||
RUN curl -H "Authorization: Bearer $TOKEN" https://api.example.com > config.json
|
||
ARG API_KEY # Also unsafe — visible in build args history
|
||
|
||
# ✅ CORRECT — BuildKit secret mount (never persisted in any layer)
|
||
# syntax=docker/dockerfile:1
|
||
RUN --mount=type=secret,id=api_token \
|
||
curl -H "Authorization: Bearer $(cat /run/secrets/api_token)" \
|
||
https://api.example.com/config > config.json
|
||
```
|
||
|
||
Build with: `docker build --secret id=api_token,src=./token.txt .`
|
||
|
||
**Check your image for leaked secrets:**
|
||
```bash
|
||
docker history --no-trunc myapp:latest | grep -iE "secret|key|password|token"
|
||
trivy image --scanners secret myapp:latest
|
||
```
|
||
|
||
### 1.6 Read-Only Filesystem & No New Privileges
|
||
|
||
```dockerfile
|
||
# In the Dockerfile — use exec form (no shell interpretation)
|
||
ENTRYPOINT ["node", "server.js"] # ✅ exec form
|
||
# ENTRYPOINT /bin/sh -c "node..." # ❌ shell form — spawns extra process
|
||
|
||
# Define a HEALTHCHECK
|
||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||
CMD ["node", "-e", "require('http').get('http://localhost:3000/health', r => process.exit(r.statusCode === 200 ? 0 : 1))"]
|
||
```
|
||
|
||
Enforce read-only at runtime (see Layer 3).
|
||
|
||
### 1.7 Minimal .dockerignore
|
||
|
||
```dockerignore
|
||
# Always exclude these from build context
|
||
.git
|
||
.github
|
||
.env
|
||
.env.*
|
||
*.pem
|
||
*.key
|
||
node_modules
|
||
__pycache__
|
||
.pytest_cache
|
||
coverage/
|
||
dist/
|
||
*.log
|
||
.DS_Store
|
||
Dockerfile*
|
||
docker-compose*
|
||
README.md
|
||
docs/
|
||
tests/
|
||
```
|
||
|
||
### 1.8 Full Hardened Dockerfile Example
|
||
|
||
```dockerfile
|
||
# syntax=docker/dockerfile:1
|
||
|
||
# ── Build stage ───────────────────────────────────────────
|
||
FROM node:20-slim AS builder
|
||
WORKDIR /build
|
||
COPY package*.json ./
|
||
RUN --mount=type=cache,target=/root/.npm \
|
||
npm ci
|
||
COPY . .
|
||
RUN npm run build && npm prune --production
|
||
|
||
# ── Runtime stage ─────────────────────────────────────────
|
||
FROM gcr.io/distroless/nodejs20-debian12@sha256:<pin-digest-here>
|
||
|
||
LABEL org.opencontainers.image.source="https://github.com/org/repo"
|
||
LABEL org.opencontainers.image.revision="${BUILD_SHA}"
|
||
LABEL org.opencontainers.image.licenses="MIT"
|
||
|
||
WORKDIR /app
|
||
COPY --from=builder --chown=nonroot:nonroot /build/dist ./dist
|
||
COPY --from=builder --chown=nonroot:nonroot /build/node_modules ./node_modules
|
||
|
||
USER nonroot:nonroot
|
||
EXPOSE 3000
|
||
|
||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||
CMD ["node", "-e", "require('http').get('http://localhost:3000/health', r => process.exit(r.statusCode===200?0:1))"]
|
||
|
||
CMD ["dist/server.js"]
|
||
```
|
||
|
||
---
|
||
|
||
## Layer 2: Image Scanning
|
||
|
||
### 2.1 Trivy (Recommended — Fast, Comprehensive)
|
||
|
||
```bash
|
||
# Install
|
||
brew install trivy # macOS
|
||
apt install trivy # Debian/Ubuntu
|
||
tmpdir="$(mktemp -d)"
|
||
trap 'rm -rf "$tmpdir"' EXIT
|
||
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh \
|
||
-o "$tmpdir/trivy-install.sh"
|
||
sed -n '1,160p' "$tmpdir/trivy-install.sh"
|
||
sh "$tmpdir/trivy-install.sh"
|
||
|
||
# Scan an image for CVEs
|
||
trivy image myapp:latest
|
||
|
||
# Fail CI on HIGH/CRITICAL severity
|
||
trivy image --exit-code 1 --severity HIGH,CRITICAL myapp:latest
|
||
|
||
# Scan Dockerfile for misconfigurations
|
||
trivy config ./Dockerfile
|
||
|
||
# Scan entire repo (vulnerabilities + secrets + misconfigs)
|
||
trivy fs --scanners vuln,secret,misconfig .
|
||
|
||
# Generate SBOM (CycloneDX or SPDX)
|
||
trivy image --format cyclonedx --output sbom.json myapp:latest
|
||
trivy image --format spdx-json --output sbom.spdx.json myapp:latest
|
||
|
||
# Ignore specific CVEs (add justification comments)
|
||
trivy image --ignorefile .trivyignore myapp:latest
|
||
```
|
||
|
||
**.trivyignore example:**
|
||
```
|
||
# CVE-2023-1234 — only exploitable via X feature, not used in this app
|
||
CVE-2023-1234
|
||
|
||
# CVE-2023-5678 — fix not yet available; tracked in issue #42
|
||
CVE-2023-5678
|
||
```
|
||
|
||
### 2.2 Grype (Anchore Alternative)
|
||
|
||
```bash
|
||
# Install
|
||
tmpdir="$(mktemp -d)"
|
||
trap 'rm -rf "$tmpdir"' EXIT
|
||
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh \
|
||
-o "$tmpdir/grype-install.sh"
|
||
sed -n '1,160p' "$tmpdir/grype-install.sh"
|
||
sh "$tmpdir/grype-install.sh"
|
||
|
||
# Scan image
|
||
grype myapp:latest
|
||
|
||
# Fail on critical
|
||
grype myapp:latest --fail-on critical
|
||
|
||
# Output SARIF for GitHub Security tab
|
||
grype myapp:latest -o sarif > results.sarif
|
||
|
||
# Pair with Syft for SBOM generation
|
||
syft myapp:latest -o cyclonedx-json > sbom.json
|
||
grype sbom:sbom.json # Scan the SBOM directly
|
||
```
|
||
|
||
### 2.3 Hadolint — Dockerfile Linting
|
||
|
||
```bash
|
||
# Run directly
|
||
docker run --rm -i hadolint/hadolint < Dockerfile
|
||
|
||
# With config file
|
||
hadolint --config .hadolint.yaml --failure-threshold warning Dockerfile
|
||
```
|
||
|
||
**.hadolint.yaml:**
|
||
```yaml
|
||
failure-threshold: warning
|
||
ignore:
|
||
- DL3008 # Pin versions in apt-get (allow floating for base layer)
|
||
trustedRegistries:
|
||
- gcr.io
|
||
- ghcr.io
|
||
- public.ecr.aws
|
||
```
|
||
|
||
### 2.4 Secret Scanning in Images
|
||
|
||
```bash
|
||
# Trivy covers secrets too
|
||
trivy image --scanners secret myapp:latest
|
||
|
||
# Dedicated: TruffleHog
|
||
trufflehog docker --image myapp:latest
|
||
|
||
# git-secrets to prevent committing secrets
|
||
git secrets --scan
|
||
```
|
||
|
||
### 2.5 CI Integration (GitHub Actions — SHA-Pinned)
|
||
|
||
```yaml
|
||
permissions:
|
||
contents: read
|
||
security-events: write # Required for uploading SARIF
|
||
|
||
jobs:
|
||
security-scan:
|
||
runs-on: ubuntu-24.04
|
||
timeout-minutes: 20
|
||
steps:
|
||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||
|
||
- name: Build image
|
||
run: docker build -t myapp:${{ github.sha }} .
|
||
|
||
- name: Lint Dockerfile
|
||
uses: hadolint/hadolint-action@54c9adbab1582c2ef04b2016b760714a4bfde3cf # v3.1.0
|
||
with:
|
||
dockerfile: Dockerfile
|
||
failure-threshold: warning
|
||
|
||
- name: Scan with Trivy
|
||
uses: aquasecurity/trivy-action@6e7b7d1fd3e4fef0c5fa8cce1229c54b2c9bd0d8 # v0.28.0
|
||
with:
|
||
image-ref: myapp:${{ github.sha }}
|
||
format: sarif
|
||
output: trivy-results.sarif
|
||
severity: HIGH,CRITICAL
|
||
exit-code: '1'
|
||
|
||
- name: Upload results to GitHub Security tab
|
||
uses: github/codeql-action/upload-sarif@4f3212b61783c3c68e8309a0f18a699764811cda # v3.27.1
|
||
if: always() # Upload even if scan found issues
|
||
with:
|
||
sarif_file: trivy-results.sarif
|
||
```
|
||
|
||
---
|
||
|
||
## Layer 3: Runtime Security
|
||
|
||
### 3.1 docker run Hardening Flags
|
||
|
||
```bash
|
||
docker run \
|
||
--read-only \ # Read-only root filesystem
|
||
--tmpfs /tmp:noexec,nosuid,size=100m \ # Writable tmpfs for /tmp only
|
||
--tmpfs /var/run \ # For PID files if needed
|
||
--user 10001:10001 \ # Non-root UID:GID
|
||
--cap-drop ALL \ # Drop ALL Linux capabilities
|
||
--cap-add NET_BIND_SERVICE \ # Re-add only what's truly needed
|
||
--security-opt no-new-privileges:true \ # Prevent privilege escalation via setuid
|
||
--security-opt seccomp=seccomp.json \ # Custom seccomp profile
|
||
--security-opt apparmor=docker-default \ # AppArmor profile
|
||
--pids-limit 100 \ # Prevent fork bombs
|
||
--memory 512m \ # OOM protection
|
||
--memory-swap 512m \ # Disable swap
|
||
--cpus 1.0 \ # CPU limit
|
||
--network none \ # No network (if not needed)
|
||
--health-cmd "curl -f http://localhost:3000/health || exit 1" \
|
||
--health-interval 30s \
|
||
myapp:latest
|
||
```
|
||
|
||
### 3.2 Linux Capabilities — What to Drop and Keep
|
||
|
||
Drop ALL, then explicitly add only what your app requires:
|
||
|
||
| Capability | Purpose | Keep? |
|
||
|---|---|---|
|
||
| `NET_BIND_SERVICE` | Bind ports < 1024 | Only if binding a privileged port |
|
||
| `CHOWN` | Change file ownership | No — set ownership at build time |
|
||
| `SETUID` / `SETGID` | Switch user identity | No — drop always |
|
||
| `SYS_ADMIN` | Broad privileged operations | No — most dangerous capability |
|
||
| `NET_ADMIN` | Configure network interfaces | No (only network tools) |
|
||
| `SYS_PTRACE` | Debug/trace processes | No (only debugger containers) |
|
||
| `DAC_OVERRIDE` | Override file permissions | No — runs as correct user |
|
||
| `NET_RAW` | Raw sockets (ping) | No (blocked by default seccomp anyway) |
|
||
|
||
> **Most web apps need zero capabilities.** `--cap-drop ALL` alone is often sufficient.
|
||
|
||
### 3.3 Docker Compose Hardening
|
||
|
||
```yaml
|
||
services:
|
||
app:
|
||
image: myapp:latest
|
||
read_only: true
|
||
user: "10001:10001"
|
||
tmpfs:
|
||
- /tmp:noexec,nosuid,size=100m
|
||
- /var/run:noexec,nosuid,size=10m
|
||
cap_drop:
|
||
- ALL
|
||
cap_add:
|
||
- NET_BIND_SERVICE # Only if binding port < 1024
|
||
security_opt:
|
||
- no-new-privileges:true
|
||
- seccomp:./references/seccomp-profile-template.json
|
||
pids_limit: 100
|
||
mem_limit: 512m
|
||
memswap_limit: 512m
|
||
cpus: 1.0
|
||
healthcheck:
|
||
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
|
||
interval: 30s
|
||
timeout: 5s
|
||
retries: 3
|
||
start_period: 10s
|
||
networks:
|
||
- backend
|
||
# Only expose externally if truly required
|
||
# ports: ["8080:8080"]
|
||
restart: unless-stopped
|
||
logging:
|
||
driver: json-file
|
||
options:
|
||
max-size: "10m"
|
||
max-file: "3"
|
||
|
||
networks:
|
||
backend:
|
||
driver: bridge
|
||
internal: true # No external connectivity unless needed
|
||
```
|
||
|
||
### 3.4 Seccomp Profiles
|
||
|
||
The Docker default seccomp profile blocks ~44 dangerous syscalls. For stricter control:
|
||
|
||
```bash
|
||
# Step 1: Audit syscalls your app actually makes
|
||
docker run --security-opt seccomp=unconfined \
|
||
--name audit-run myapp:latest &
|
||
|
||
# Capture with strace
|
||
strace -c -p $(docker inspect --format '{{.State.Pid}}' audit-run)
|
||
|
||
# Or with sysdig (more container-friendly)
|
||
sysdig -p "%syscall.type" container.name=audit-run | sort -u
|
||
|
||
# Step 2: Build a custom profile from references/seccomp-profile-template.json
|
||
# Step 3: Apply it
|
||
docker run --security-opt seccomp=references/seccomp-profile-template.json myapp:latest
|
||
```
|
||
|
||
See `references/seccomp-profile-template.json` for a minimal starting allowlist for typical web servers.
|
||
|
||
### 3.5 AppArmor Profile (Linux hosts)
|
||
|
||
```bash
|
||
# Load Docker's default AppArmor profile
|
||
sudo apparmor_parser -r /etc/apparmor.d/docker-default
|
||
|
||
# Apply at runtime
|
||
docker run --security-opt apparmor=docker-default myapp:latest
|
||
|
||
# Generate a custom profile
|
||
aa-genprof myapp # Interactive — run app under aa-complain mode first
|
||
```
|
||
|
||
---
|
||
|
||
## Layer 4: Supply Chain Security
|
||
|
||
### 4.1 Sign Images with Cosign (Sigstore — Keyless)
|
||
|
||
```bash
|
||
# Install cosign
|
||
brew install cosign # macOS
|
||
# or: https://github.com/sigstore/cosign/releases
|
||
|
||
# Sign after push — keyless via OIDC (no long-lived keys)
|
||
cosign sign ghcr.io/org/myapp:latest
|
||
|
||
# Verify before deploy
|
||
cosign verify ghcr.io/org/myapp:latest \
|
||
--certificate-identity-regexp="https://github.com/org/repo" \
|
||
--certificate-oidc-issuer="https://token.actions.githubusercontent.com"
|
||
```
|
||
|
||
**GitHub Actions — Sign & Verify Pipeline:**
|
||
```yaml
|
||
permissions:
|
||
id-token: write # Required for OIDC keyless signing
|
||
packages: write
|
||
|
||
steps:
|
||
- uses: sigstore/cosign-installer@dc72c7d5c4d10cd6bcb8cf6e3fd625a9e5e537da # v3.7.0
|
||
|
||
- name: Sign image (keyless via OIDC)
|
||
run: |
|
||
cosign sign --yes \
|
||
ghcr.io/${{ github.repository }}:${{ github.sha }}
|
||
env:
|
||
COSIGN_EXPERIMENTAL: "true"
|
||
|
||
- name: Attach SBOM attestation
|
||
run: |
|
||
cosign attest --yes \
|
||
--predicate sbom.json \
|
||
--type cyclonedx \
|
||
ghcr.io/${{ github.repository }}:${{ github.sha }}
|
||
```
|
||
|
||
### 4.2 SBOM Generation & Attestation
|
||
|
||
```bash
|
||
# Generate SBOM with Syft
|
||
syft myapp:latest -o cyclonedx-json > sbom.json
|
||
syft myapp:latest -o spdx-json > sbom.spdx.json
|
||
|
||
# Attach to image as attestation
|
||
cosign attest --predicate sbom.json --type cyclonedx ghcr.io/org/myapp:latest
|
||
|
||
# Verify SBOM attestation before deployment
|
||
cosign verify-attestation \
|
||
--type cyclonedx \
|
||
--certificate-identity-regexp="https://github.com/org/repo" \
|
||
--certificate-oidc-issuer="https://token.actions.githubusercontent.com" \
|
||
ghcr.io/org/myapp:latest
|
||
```
|
||
|
||
### 4.3 Use Trusted Registries & Enable Registry Scanning
|
||
|
||
| Registry | Built-in Scanning | Notes |
|
||
|---|---|---|
|
||
| GHCR (GitHub Container Registry) | No (use Trivy in CI) | Best for OSS, OIDC auth |
|
||
| AWS ECR | Yes (enhanced scanning via Inspector) | Enable per-repo |
|
||
| GCP Artifact Registry | Yes (Container Analysis) | Enabled by default |
|
||
| Azure ACR | Yes (Defender for Containers) | Premium tier |
|
||
| Docker Hub | Yes (limited on free tier) | Avoid for private images |
|
||
|
||
```bash
|
||
# Enable ECR enhanced scanning
|
||
aws ecr put-registry-scanning-configuration \
|
||
--scan-type ENHANCED \
|
||
--rules '[{"repositoryFilters":[{"filter":"*","filterType":"WILDCARD"}],"scanFrequency":"CONTINUOUS_SCAN"}]'
|
||
```
|
||
|
||
### 4.4 Admission Control — Block Unsigned/Unscanned Images
|
||
|
||
```yaml
|
||
# Kyverno policy — require signed images before admission
|
||
apiVersion: kyverno.io/v1
|
||
kind: ClusterPolicy
|
||
metadata:
|
||
name: require-signed-images
|
||
spec:
|
||
validationFailureAction: Enforce
|
||
rules:
|
||
- name: verify-image-signature
|
||
match:
|
||
resources:
|
||
kinds: [Pod]
|
||
verifyImages:
|
||
- imageReferences:
|
||
- "ghcr.io/org/*"
|
||
attestors:
|
||
- entries:
|
||
- keyless:
|
||
subject: "https://github.com/org/repo/.github/workflows/*"
|
||
issuer: "https://token.actions.githubusercontent.com"
|
||
```
|
||
|
||
---
|
||
|
||
## Layer 5: Kubernetes Pod Security
|
||
|
||
> Full reference: `references/kubernetes-pod-security.md`
|
||
|
||
### 5.1 Pod Security Context
|
||
|
||
```yaml
|
||
apiVersion: apps/v1
|
||
kind: Deployment
|
||
metadata:
|
||
name: myapp
|
||
namespace: production
|
||
spec:
|
||
replicas: 3
|
||
template:
|
||
spec:
|
||
# ── Pod-level security context ─────────────────────
|
||
securityContext:
|
||
runAsNonRoot: true
|
||
runAsUser: 10001
|
||
runAsGroup: 10001
|
||
fsGroup: 10001
|
||
fsGroupChangePolicy: OnRootMismatch
|
||
seccompProfile:
|
||
type: RuntimeDefault # Use containerd/runc default seccomp
|
||
supplementalGroups: []
|
||
|
||
automountServiceAccountToken: false # Disable unless needed
|
||
|
||
# ── Container-level security context ──────────────
|
||
containers:
|
||
- name: app
|
||
image: ghcr.io/org/myapp@sha256:<digest> # Always use digest
|
||
securityContext:
|
||
allowPrivilegeEscalation: false
|
||
readOnlyRootFilesystem: true
|
||
capabilities:
|
||
drop: ["ALL"]
|
||
add: [] # Add nothing unless absolutely required
|
||
runAsNonRoot: true
|
||
runAsUser: 10001
|
||
seccompProfile:
|
||
type: RuntimeDefault
|
||
|
||
# ── Resource limits (required for restricted PSA) ──
|
||
resources:
|
||
requests:
|
||
memory: "128Mi"
|
||
cpu: "100m"
|
||
limits:
|
||
memory: "512Mi"
|
||
cpu: "500m"
|
||
|
||
# ── Writable tmpfs mounts ──────────────────────
|
||
volumeMounts:
|
||
- name: tmp
|
||
mountPath: /tmp
|
||
- name: varrun
|
||
mountPath: /var/run
|
||
|
||
volumes:
|
||
- name: tmp
|
||
emptyDir:
|
||
medium: Memory
|
||
sizeLimit: 100Mi
|
||
- name: varrun
|
||
emptyDir:
|
||
medium: Memory
|
||
sizeLimit: 10Mi
|
||
```
|
||
|
||
### 5.2 Pod Security Admission (K8s 1.25+)
|
||
|
||
```bash
|
||
# Audit existing workloads before enforcing
|
||
kubectl label namespace production \
|
||
pod-security.kubernetes.io/audit=restricted \
|
||
pod-security.kubernetes.io/audit-version=latest
|
||
|
||
# Warn in staging, enforce in production
|
||
kubectl label namespace staging \
|
||
pod-security.kubernetes.io/warn=restricted
|
||
|
||
kubectl label namespace production \
|
||
pod-security.kubernetes.io/enforce=restricted \
|
||
pod-security.kubernetes.io/enforce-version=latest
|
||
```
|
||
|
||
| PSA Level | What It Blocks |
|
||
|---|---|
|
||
| `privileged` | No restrictions |
|
||
| `baseline` | Blocks hostNetwork, hostPID, privileged containers, hostPath |
|
||
| `restricted` | Also requires non-root, read-only FS, drops capabilities, seccomp |
|
||
|
||
### 5.3 NetworkPolicy — Zero-Trust Networking
|
||
|
||
```yaml
|
||
# Step 1: Deny all ingress and egress by default in the namespace
|
||
apiVersion: networking.k8s.io/v1
|
||
kind: NetworkPolicy
|
||
metadata:
|
||
name: default-deny-all
|
||
namespace: production
|
||
spec:
|
||
podSelector: {}
|
||
policyTypes: [Ingress, Egress]
|
||
|
||
---
|
||
# Step 2: Selectively allow only required traffic
|
||
apiVersion: networking.k8s.io/v1
|
||
kind: NetworkPolicy
|
||
metadata:
|
||
name: allow-app
|
||
namespace: production
|
||
spec:
|
||
podSelector:
|
||
matchLabels:
|
||
app: myapp
|
||
policyTypes: [Ingress, Egress]
|
||
ingress:
|
||
- from:
|
||
- namespaceSelector:
|
||
matchLabels:
|
||
kubernetes.io/metadata.name: ingress-nginx
|
||
podSelector:
|
||
matchLabels:
|
||
app.kubernetes.io/name: ingress-nginx
|
||
ports:
|
||
- port: 3000
|
||
egress:
|
||
- to:
|
||
- podSelector:
|
||
matchLabels:
|
||
app: postgres
|
||
ports:
|
||
- port: 5432
|
||
- to: # Allow only cluster DNS
|
||
- namespaceSelector:
|
||
matchLabels:
|
||
kubernetes.io/metadata.name: kube-system
|
||
podSelector:
|
||
matchLabels:
|
||
k8s-app: kube-dns
|
||
ports:
|
||
- port: 53
|
||
protocol: UDP
|
||
- port: 53
|
||
protocol: TCP
|
||
```
|
||
|
||
### 5.4 RBAC — Least Privilege
|
||
|
||
```yaml
|
||
# Create minimal role — never use wildcards
|
||
apiVersion: rbac.authorization.k8s.io/v1
|
||
kind: Role
|
||
metadata:
|
||
name: app-reader
|
||
namespace: production
|
||
rules:
|
||
- apiGroups: [""]
|
||
resources: ["configmaps", "secrets"]
|
||
resourceNames: ["myapp-config"] # Lock to specific resource names
|
||
verbs: ["get"] # Never ["*"]
|
||
|
||
---
|
||
apiVersion: rbac.authorization.k8s.io/v1
|
||
kind: RoleBinding
|
||
metadata:
|
||
name: app-reader-binding
|
||
namespace: production
|
||
subjects:
|
||
- kind: ServiceAccount
|
||
name: myapp-sa
|
||
namespace: production
|
||
roleRef:
|
||
kind: Role
|
||
name: app-reader
|
||
apiGroup: rbac.authorization.k8s.io
|
||
```
|
||
|
||
```bash
|
||
# Audit what permissions a service account has
|
||
kubectl auth can-i --list --as=system:serviceaccount:production:myapp-sa
|
||
|
||
# Find overly-permissive cluster roles
|
||
kubectl get clusterrolebindings -o json | \
|
||
jq '.items[] | select(.roleRef.name == "cluster-admin") | .subjects'
|
||
```
|
||
|
||
### 5.5 Kyverno Policy Examples
|
||
|
||
```yaml
|
||
# Require non-root containers
|
||
apiVersion: kyverno.io/v1
|
||
kind: ClusterPolicy
|
||
metadata:
|
||
name: require-non-root
|
||
spec:
|
||
validationFailureAction: Enforce
|
||
rules:
|
||
- name: check-run-as-non-root
|
||
match:
|
||
resources:
|
||
kinds: [Pod]
|
||
validate:
|
||
message: "Containers must not run as root (runAsNonRoot: true required)"
|
||
pattern:
|
||
spec:
|
||
containers:
|
||
- securityContext:
|
||
runAsNonRoot: true
|
||
|
||
---
|
||
# Require image digest pinning
|
||
apiVersion: kyverno.io/v1
|
||
kind: ClusterPolicy
|
||
metadata:
|
||
name: require-image-digest
|
||
spec:
|
||
validationFailureAction: Enforce
|
||
rules:
|
||
- name: check-digest
|
||
match:
|
||
resources:
|
||
kinds: [Pod]
|
||
validate:
|
||
message: "Images must be pinned to a SHA256 digest, not just a tag"
|
||
pattern:
|
||
spec:
|
||
containers:
|
||
- image: "*@sha256:*"
|
||
|
||
---
|
||
# Block privileged containers
|
||
apiVersion: kyverno.io/v1
|
||
kind: ClusterPolicy
|
||
metadata:
|
||
name: disallow-privileged
|
||
spec:
|
||
validationFailureAction: Enforce
|
||
rules:
|
||
- name: check-privileged
|
||
match:
|
||
resources:
|
||
kinds: [Pod]
|
||
validate:
|
||
message: "Privileged containers are not allowed"
|
||
pattern:
|
||
spec:
|
||
containers:
|
||
- =(securityContext):
|
||
=(privileged): "false"
|
||
```
|
||
|
||
---
|
||
|
||
## Common Pitfalls & Fixes
|
||
|
||
| Problem | Root Cause | Fix |
|
||
|---|---|---|
|
||
| Image runs as root | No `USER` directive | Add `RUN useradd ...` and `USER appuser` |
|
||
| Secret in `docker history` | `ENV` or `RUN curl -H "Bearer $TOKEN"` | Use `RUN --mount=type=secret` |
|
||
| Large image with many CVEs | Full base image (`node:20`, `ubuntu`) | Switch to `node:20-slim` or `distroless` |
|
||
| App crashes with `--read-only` | Writes to `/tmp` or app directory | Add `--tmpfs /tmp` for writable temp space |
|
||
| Trivy scan blocks CI on unfixable CVEs | No ignore file | Add `.trivyignore` with justified entries |
|
||
| Container needs `SYS_ADMIN` | Missing `--cap-drop` context | Investigate why — almost always avoidable |
|
||
| Tag-based images drift over time | Mutable tags | Pin to `@sha256:` digest; use Renovate to update |
|
||
| K8s pod rejected by PSA | Missing security context fields | Add `runAsNonRoot`, `readOnlyRootFilesystem`, `allowPrivilegeEscalation: false` |
|
||
| App can't write to filesystem | `readOnlyRootFilesystem: true` | Mount `emptyDir` volumes for writable paths |
|
||
|
||
---
|
||
|
||
## Security Checklist
|
||
|
||
### Dockerfile
|
||
- [ ] Minimal base image (distroless, slim, or alpine — not full debian/ubuntu)
|
||
- [ ] Multi-stage build — no build tools, devDependencies, or compilers in runtime image
|
||
- [ ] Non-root `USER` declared before `CMD`/`ENTRYPOINT`
|
||
- [ ] Base image pinned to `@sha256:...` digest (not just tag)
|
||
- [ ] No secrets in `ENV`, `ARG`, or `RUN` commands
|
||
- [ ] `HEALTHCHECK` defined
|
||
- [ ] OCI labels present (`org.opencontainers.image.*`)
|
||
- [ ] `.dockerignore` excludes `.git`, `.env`, secrets, tests
|
||
- [ ] `ENTRYPOINT` uses exec form, not shell form
|
||
|
||
### Image Scanning
|
||
- [ ] Trivy or Grype scan in CI (fails on HIGH/CRITICAL)
|
||
- [ ] Hadolint passes with no warnings
|
||
- [ ] Secret scan run on image (`trivy --scanners secret`)
|
||
- [ ] SBOM generated and stored
|
||
- [ ] `.trivyignore` has justified entries for accepted CVEs
|
||
|
||
### Runtime
|
||
- [ ] `--read-only` filesystem
|
||
- [ ] `--cap-drop ALL` (add back only what's documented as required)
|
||
- [ ] `--security-opt no-new-privileges:true`
|
||
- [ ] `--security-opt seccomp=<profile>` applied
|
||
- [ ] Resource limits set (`--memory`, `--cpus`, `--pids-limit`)
|
||
- [ ] Image signed with Cosign; verified before deploy
|
||
|
||
### Kubernetes
|
||
- [ ] `readOnlyRootFilesystem: true`
|
||
- [ ] `allowPrivilegeEscalation: false`
|
||
- [ ] `runAsNonRoot: true` with explicit UID
|
||
- [ ] `capabilities.drop: ["ALL"]`
|
||
- [ ] Resource `requests` and `limits` defined
|
||
- [ ] `automountServiceAccountToken: false`
|
||
- [ ] Namespace PSA enforced at `restricted` level
|
||
- [ ] `NetworkPolicy` default-deny applied
|
||
- [ ] RBAC uses specific resource names and minimal verbs
|
||
|
||
---
|
||
|
||
## Reference Files
|
||
|
||
- `references/base-image-comparison.md` — Size, CVE count, shell/pkg-manager trade-offs: distroless vs alpine vs slim vs scratch
|
||
- `references/seccomp-profile-template.json` — Minimal syscall allowlist for typical web servers; start here and extend
|
||
- `references/kubernetes-pod-security.md` — NetworkPolicy, RBAC, OPA/Kyverno policies, service account hardening, PSA
|
||
|
||
## Related Skills
|
||
|
||
- `docker-expert` — General Docker usage, Compose orchestration, image optimization
|
||
- `gha-security-review` — Security audit of GitHub Actions workflows
|
||
- `github-actions-advanced` — CI pipeline patterns including scanner integration
|
||
- `kubernetes-architect` — Full Kubernetes architecture, not just security
|
||
- `api-security-best-practices` — Application-level security (injection, auth, OWASP)
|
||
- `k8s-security-policies` — Extended Kubernetes security policies
|
||
|
||
## Limitations
|
||
|
||
- Use this skill only when the task clearly matches the scope described above.
|
||
- Do not treat the output as a substitute for environment-specific penetration testing or a formal security audit.
|
||
- Seccomp profiles and AppArmor are Linux-only; macOS/Windows Docker Desktop uses different mechanisms.
|
||
- Stop and ask for clarification if required inputs, permissions, safety boundaries, or success criteria are missing.
|