Bảo mật Container & KubernetesContainer & Kubernetes Security
Thuộc bộ kiến thức DevSecOps Roadmap.
Tổng quan
Container và Kubernetes thay đổi cách phần mềm được triển khai, nhưng đồng thời cũng thay đổi bề mặt tấn công (attack surface). Một container không phải là một VM thu nhỏ — nó là một tập hợp các process bình thường trên host, được cô lập bởi các cơ chế của kernel chứ không phải bằng ảo hóa phần cứng. Chính điều này chi phối hầu hết các control bảo mật container: vì các container dùng chung kernel của host, một lỗ hổng container escape hay một exploit kernel có thể ảnh hưởng tới cả host lẫn mọi container khác đang chạy trên đó. Kubernetes sau đó bổ sung thêm một lớp hoàn toàn mới: một API cấp cluster có thể tạo, sửa, và xóa workload, với mô hình authentication, authorization, và network riêng — và API này “mở theo mặc định” (open by default) trừ khi bạn chủ động khóa lại.
Note này coi bảo mật container và Kubernetes là một bài toán liên tục, đi theo vòng đời workload từ lúc build đến lúc chạy:
- Build time — image chứa gì (base image, package, layer) và có chứa CVE đã biết hay không.
- Ship time — image đi từ registry đến cluster như thế nào: có được ký (sign) không, có được pull từ nguồn tin cậy không, có được scan trước khi cho phép chạy không.
- Run time — container đang chạy và cluster xung quanh nó cho phép nó làm gì: quyền user, quyền truy cập filesystem, syscall của kernel, khả năng reach mạng, và quyền API.
Đây là một trường hợp cụ thể của các chủ đề shift-left và supply chain security rộng hơn đã được đề cập ở các note khác trong roadmap DevSecOps này, áp dụng vào stack container/Kubernetes. Note này bổ sung cho ../kubernetes/en/08-security.md — note đó đi sâu hơn về bảo mật cluster Kubernetes (mô hình 4Cs, authentication, RBAC, admission control) từ góc nhìn platform engineering. Ở đây trọng tâm là góc nhìn của người làm DevSecOps: cần scan gì, cần enforce gì, và cần kiểm tra gì trong pipeline hoặc audit.
Kiến thức nền tảng
Image layers và mô hình shared kernel
Một container image là một chồng các layer chỉ đọc (read-only), mỗi layer là một diff filesystem sinh ra từ một instruction trong Dockerfile (FROM, RUN, COPY, …). Khi chạy, container engine thêm một layer ghi (writable) mỏng lên trên (thông qua union filesystem như overlayfs) và khởi động một hoặc nhiều process bên trong một tập các cơ chế cô lập của kernel Linux:
| Cơ chế kernel | Cô lập cái gì |
|---|---|
Namespaces (pid, net, mnt, uts, ipc, user) | Những gì process nhìn thấy — cây process riêng, network stack riêng, mount table riêng, hostname riêng |
| cgroups | Những gì process được tiêu thụ — giới hạn CPU, memory, I/O |
| Capabilities | Những thao tác kernel vốn chỉ dành cho root mà process được phép thực hiện, mà không cần full root |
| seccomp | Những syscall mà process được phép gọi |
| LSM (AppArmor / SELinux) | Mandatory access control chi tiết trên file, network, capabilities |
Với kernel, mọi process trong container vẫn chỉ là một process Linux bình thường — nó chỉ được bọc trong các namespace kể trên. Đó chính là khác biệt then chốt so với virtual machine (VM):
| Virtual Machine | Container | |
|---|---|---|
| Ranh giới cô lập | Ảo hóa phần cứng; mỗi VM chạy kernel riêng | Namespaces/cgroups trên một kernel host dùng chung |
| Bề mặt tấn công nếu bị compromise | Cần hypervisor escape mới chạm được host hoặc VM khác (hiếm, được kiểm soát chặt) | Một kernel exploit hoặc misconfiguration (ví dụ chạy root với capability thừa) có thể chạm host trực tiếp |
| Thời gian khởi động / mật độ | Vài giây, nặng hơn (mỗi VM một OS đầy đủ) | Vài mili giây, nhẹ (dùng chung kernel, userspace riêng) |
| Hệ quả thực tế | Cô lập mặc định mạnh | Mức độ cô lập chỉ mạnh bằng cấu hình bạn thiết lập — non-root, drop capability, seccomp, read-only root FS không phải là tùy chọn thêm, mà chính là thứ khiến cô lập container có ý nghĩa |
Đây là lý do vì sao “container mặc định không phải là một ranh giới bảo mật” là một cảnh báo phổ biến: hai container trên cùng một node giống hai process trên cùng một máy Linux hơn là hai máy riêng biệt. Hãy coi node là ranh giới tin cậy thật sự, và coi mỗi control bảo mật container bên dưới là một cách thu hẹp những gì một process bị compromise trong kernel dùng chung đó có thể làm.
Chạy non-root và dùng base image tối giản
Hai bước hardening có hiệu quả cao nhất mà chi phí thấp nhất:
- Chạy bằng user non-root. Nếu một process bị compromise (ví dụ qua một RCE trong dependency), chạy bằng root trong container cho attacker quyền tương đương root trong namespace của container đó — bao gồm, nếu capabilities hoặc mount cũng bị cấu hình sai, một con đường dễ dàng hơn nhiều để chạm tới host. Chạy bằng một UID không có quyền, được đặt tên rõ ràng, giới hạn phạm vi thiệt hại ngay cả trước khi áp dụng bất kỳ control nào khác.
- Dùng base image tối giản. Mỗi package trong image là bề mặt tấn công: càng nhiều binary thì càng nhiều khả năng có CVE, càng nhiều shell và package manager mà attacker có thể dùng để leo thang hoặc pivot sau khi đã có chỗ đứng ban đầu. Ưu tiên biến thể
-slim, imagedistroless(không shell, không package manager, chỉ có runtime ngôn ngữ và app của bạn), hoặcscratchcho các binary được link tĩnh (ví dụ Go).
Một Dockerfile multi-stage đã hardening cho service Go, kết hợp cả hai:
# --- build stage: có đầy đủ toolchain, không bao giờ được ship ---
FROM golang:1.22 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/app ./cmd/app
# --- final stage: distroless, không shell, không package manager ---
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/app /app
USER nonroot:nonroot # UID 65532, đã là non-root sẵn trong base image này
ENTRYPOINT ["/app"]
Và cách hardening tương đương trên image nền Debian/Alpine khi không thể dùng distroless hoàn toàn (ví dụ cần shell hoặc dynamic library):
FROM node:20-alpine
RUN addgroup -S app && adduser -S app -G app \
&& apk add --no-cache dumb-init \
&& rm -rf /var/cache/apk/*
WORKDIR /app
COPY --chown=app:app package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --chown=app:app . .
USER app # không bao giờ chạy bằng root
EXPOSE 3000
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "server.js"]
Một số thói quen build-time khác nên duy trì:
- Pin phiên bản base image và digest (
FROM node:20-alpine@sha256:...), không chỉ dùng tag nổi (floating tag) — một tag có thể bị trỏ lại sang nội dung khác; digest thì không. - Dùng
.dockerignoređể giữ.git,.env, và secret local ra khỏi build context (nếu không chúng sẽ nằm lại trong lịch sử layer). - Không bao giờ
COPYhayARGmột secret vào image — giá trịARGtại build-time và các layer trung gian vẫn tồn tại trong lịch sử image ngay cả khi một layer sau đó xóa file. Dùng BuildKit secret mount (RUN --mount=type=secret) thay thế. - Multi-stage build nghĩa là chỉ stage cuối cùng được ship — compiler, source code, và các dependency chỉ dùng để build không bao giờ tới production.
Image scanning
Image scanning kiểm tra các layer của image so với các database lỗ hổng đã biết (CVE) trong package hệ điều hành và dependency ngôn ngữ, cộng thêm misconfiguration (thực hành Dockerfile sai, secret hardcode, key bị lộ).
Những gì một scanner thực sự kiểm tra:
- Lỗ hổng package OS — danh mục package manager của base image (apt/apk/rpm) được đối chiếu với các nguồn CVE (NVD, security advisory của distro).
- Lỗ hổng dependency ngôn ngữ — lockfile và package đã cài (
package-lock.json,go.sum,requirements.txt, JAR) được đối chiếu với database advisory (phần này chồng lấn với SCA/dependency scanning đã đề cập ở note khác trong roadmap, nhưng chạy bên trong image đã build, bắt được những gì thực sự được ship chứ không chỉ những gì khai báo trong manifest). - Secret vô tình bị đóng gói vào layer — API key, private key, credential còn sót lại ở bất kỳ layer nào, kể cả khi đã bị xóa ở layer sau đó.
- Misconfiguration — chạy bằng root, expose port nhạy cảm, thiếu
HEALTHCHECK, dùngADDtừ URL từ xa, v.v. (một số scanner; số khác giao việc này cho linter như Hadolint). - Tuân thủ license — cảnh báo các license OSS không được phép trong dependency (một số công cụ).
Các công cụ phổ biến:
| Công cụ | Loại | Ghi chú |
|---|---|---|
| Trivy (Aqua Security) | OSS, tất cả trong một | Scan image, filesystem, git repo, IaC, và manifest Kubernetes; một binary static duy nhất; rất nhanh; lựa chọn mặc định phổ biến nhất trong CI hiện nay |
| Grype (Anchore) | OSS, scan image/SBOM | Đi kèm Syft (sinh SBOM); phù hợp khi scan lại một SBOM đã sinh sẵn thay vì scan lại toàn bộ image |
| Clair | OSS, dạng server/API | Chạy như một service mà registry (Harbor, Quay) gọi để scan image vừa push; phù hợp với scan tích hợp registry hơn là dùng CLI đơn lẻ |
| Docker Scout | Thương mại (có bản miễn phí) | Tích hợp sẵn trong docker scan / Docker Desktop; đánh giá policy dựa trên metadata Docker Hub |
| Snyk Container | Thương mại | Hướng dẫn khắc phục (dependency-to-fix) mạnh, tích hợp với kết quả SCA từ cùng nhà cung cấp |
Pattern scan-in-CI — fail build khi có phát hiện High/Critical, nhưng không chặn với các phát hiện chỉ mang tính thông tin:
# .github/workflows/image-scan.yml
name: Build and scan image
on: [push, pull_request]
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t myapp:${{ github.sha }} .
- name: Scan with Trivy
uses: aquasecurity/trivy-action@0.24.0
with:
image-ref: myapp:${{ github.sha }}
format: sarif
output: trivy-results.sarif
severity: HIGH,CRITICAL
exit-code: '1' # fail pipeline nếu có CVE high/critical
ignore-unfixed: true # không fail với CVE chưa có bản vá
- name: Upload results to code scanning
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: trivy-results.sarif
Hai lưu ý thực tế: (1) scan sớm (trên mọi PR, đối với image đã build, không chỉ lúc deploy) để bắt được base image có lỗ hổng trước khi merge, và (2) cũng nên scan định kỳ ngay trong registry — một image build sạch hôm nay có thể trở thành có lỗ hổng vào ngày mai khi một CVE mới được công bố cho một package đã nằm sẵn trong đó, mà không có commit mới nào để kích hoạt scan trên PR.
Bảo mật container runtime
Scanning bao phủ những gì nằm trong image; các control runtime giới hạn những gì một container đang chạy được phép làm, điều này quan trọng ngay cả với một image “sạch” (zero-day vẫn tồn tại) và là defense-in-depth thiết yếu phòng khi scanner bỏ sót.
- Read-only root filesystem. Nếu app không cần ghi vào filesystem của chính nó, mount nó ở chế độ read-only và chỉ cấp một
tmpfs/emptyDirghi được nhỏ ở nơi thực sự cần. Điều này ngăn attacker thả payload giai đoạn hai hoặc sửa binary khi đang chạy. - Drop Linux capabilities. Container mặc định có một tập capability đã giảm nhưng vẫn còn khá rộng (ví dụ
CAP_NET_RAW,CAP_CHOWN). Drop toàn bộ, rồi chỉ add lại những gì đã được chứng minh là cần thiết — hầu hết web app không cần capability nào cả. - seccomp profile. Giới hạn các syscall mà process được phép gọi (chặn
ptrace,mount, load kernel module, v.v.). Container runtime đi kèm sẵn profileRuntimeDefaultchặn khoảng 44 syscall nguy hiểm trong tổng số hơn 300+; dùng nó gần như luôn an toàn và miễn phí. - AppArmor/SELinux profile. Mandatory access control nằm trên các quyền DAC — giới hạn một process ngay cả khi nó chạy bằng root, hạn chế file, thao tác mạng, và capability nào nó được dùng, dựa trên một profile được đặt tên thay vì quyền Unix tùy ý (discretionary).
- Resource limits (cgroups).
requests/limitscho CPU/memory ngăn một container bị compromise hoặc lỗi làm cạn kiệt tài nguyên node (một vector denial-of-service, dù cố ý hay không) và cho phép eviction/OOM-kill thay vì làm cạn kiệt toàn bộ node.
Góc nhìn tương đương với Docker Compose / docker run:
docker run \
--read-only \
--tmpfs /tmp \
--cap-drop=ALL \
--cap-add=NET_BIND_SERVICE \
--security-opt no-new-privileges:true \
--security-opt seccomp=default.json \
--memory=256m --cpus=0.5 \
--user 1000:1000 \
myapp:latest
Tương đương và phổ biến hơn trong production, securityContext của Kubernetes:
apiVersion: v1
kind: Pod
metadata:
name: hardened-app
spec:
securityContext: # cấp pod: áp dụng cho tất cả container
runAsNonRoot: true
runAsUser: 10001
fsGroup: 10001
seccompProfile:
type: RuntimeDefault
containers:
- name: app
image: myregistry.example.com/myapp@sha256:abcd1234...
securityContext: # cấp container: override/bổ sung cho cấp pod
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
add: ["NET_BIND_SERVICE"] # chỉ khi cần bind port <1024, nếu không thì drop hết
resources:
requests: { cpu: "100m", memory: "128Mi" }
limits: { cpu: "500m", memory: "256Mi" }
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}
Khái niệm chính
Bề mặt tấn công của Kubernetes
Kubernetes là một hệ thống phân tán với nhiều thành phần mà attacker có thể nhắm tới, mỗi thành phần có phạm vi ảnh hưởng khác nhau nếu bị compromise:
| Thành phần | Vai trò | Vì sao là mục tiêu |
|---|---|---|
| API server | Cổng vào của toàn bộ cluster; mọi lệnh kubectl và hành động của controller đều đi qua đây | Mục tiêu giá trị nhất — bất kỳ ai có thể giao tiếp với nó với quyền RBAC đủ lớn đều có thể tạo, đọc, hoặc xóa gần như mọi thứ trong cluster |
| kubelet | Agent trên node chạy Pod, expose một API trên mỗi node (dùng cho log, exec, port-forward) | Một kubelet API bị lộ hoặc thiếu xác thực cho phép attacker chạy lệnh tùy ý trực tiếp trên node đó, bỏ qua hoàn toàn API server |
| etcd | Lưu toàn bộ state của cluster, bao gồm cả Secrets (chỉ base64-encode, không mã hóa, trừ khi cấu hình encryption-at-rest) | Truy cập trực tiếp vào etcd tương đương với compromise toàn bộ cluster — ai có quyền đọc etcd đều đọc được mọi Secret trong cluster dưới dạng plaintext |
| Container runtime / CRI socket | Thực thi container trên node | Truy cập vào runtime socket (ví dụ /var/run/docker.sock được mount vào một Pod) là một con đường container escape/leo thang quyền đã được biết rõ |
| Admission webhook | Chặn và mutate/validate các request API | Một webhook cấu hình sai hoặc không sẵn sàng có thể fail open (bỏ qua policy đáng ra phải enforce) hoặc fail closed (denial of service cho toàn cluster) |
Khóa chặt các thành phần này chính là nội dung của ../kubernetes/en/08-security.md (authentication, RBAC, admission control chi tiết); note này tóm tắt hai control mà một kỹ sư DevSecOps sẽ cấu hình và audit thường xuyên nhất — RBAC và NetworkPolicy — cộng thêm các control hướng supply chain.
RBAC cho quyền truy cập cluster
RBAC trả lời câu hỏi “identity này được phép làm gì.” Nguyên tắc cốt lõi: least privilege, mặc định theo namespace, không dùng wildcard. Một ví dụ tối giản, dễ audit — một CI/CD deployer bị giới hạn trong một namespace, một tập verb, một tập resource:
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
namespace: staging
name: ci-deployer
rules:
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "patch", "update"] # không "delete", không "create" kind mới
- apiGroups: [""]
resources: ["pods", "pods/log"]
verbs: ["get", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
namespace: staging
name: ci-deployer-binding
subjects:
- kind: ServiceAccount
name: ci-bot
namespace: staging
roleRef:
kind: Role
name: ci-deployer
apiGroup: rbac.authorization.k8s.io
Checklist audit riêng cho RBAC: không có ClusterRoleBinding tới cluster-admin cho bất kỳ ai ngoài một nhóm admin break-glass nhỏ; không có binding tới ServiceAccount default; không có wildcard ("*") trong resources hay verbs ngoài các role platform-admin thực sự; automountServiceAccountToken: false cho các Pod không bao giờ gọi API. Các công cụ như kubectl-who-can và rbac-lookup giúp tìm binding quá rộng ở quy mô lớn.
Network policy để phân đoạn pod-to-pod
Mặc định, networking của Kubernetes là phẳng và default-allow: bất kỳ Pod nào cũng có thể reach bất kỳ Pod nào khác ở bất kỳ namespace nào trên bất kỳ port nào, miễn là CNI plugin không enforce khác đi. NetworkPolicy là cách bạn biến điều này thành default-deny và chỉ cho phép rõ ràng đúng lưu lượng mà một service thực sự cần — tương đương RBAC nhưng cho traffic east-west.
# 1. Default-deny toàn bộ ingress và egress trong namespace
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: prod
spec:
podSelector: {} # áp dụng cho mọi Pod trong namespace
policyTypes: ["Ingress", "Egress"]
---
# 2. Chỉ cho phép Pod API nhận traffic từ frontend,
# và chỉ reach database trên đúng port + DNS
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-frontend-to-api
namespace: prod
spec:
podSelector:
matchLabels: { app: api }
policyTypes: ["Ingress", "Egress"]
ingress:
- from:
- podSelector: { matchLabels: { app: frontend } }
ports:
- protocol: TCP
port: 8080
egress:
- to:
- podSelector: { matchLabels: { app: database } }
ports:
- protocol: TCP
port: 5432
- to: # cho phép phân giải DNS
- namespaceSelector: {}
ports:
- protocol: UDP
port: 53
Lưu ý quan trọng: NetworkPolicy cần một CNI plugin hỗ trợ nó (Calico, Cilium, Weave; không phải kiểu networking bridge/kubenet cơ bản mà một số managed cluster dùng mặc định) — cần áp dụng một plugin phù hợp và xác minh việc enforce thực sự hoạt động, đừng chỉ giả định.
Pod Security Standards và admission control
Kubernetes thay thế PodSecurityPolicy đã deprecated bằng Pod Security Standards (PSS), được enforce thông qua controller Pod Security Admission (PSA) built-in (hoặc, cho policy phong phú hơn, dùng OPA/Gatekeeper/Kyverno). PSS định nghĩa ba mức:
| Mức | Hành vi |
|---|---|
| Privileged | Không giới hạn — gần như không có policy; chỉ dành cho workload tin cậy, thiết yếu của cluster |
| Baseline | Chặn các leo thang quyền đã biết: không container privileged, không host namespace (hostNetwork, hostPID, hostIPC), không mount host path nhạy cảm, capability bị hạn chế |
| Restricted | Hardening nặng: yêu cầu thêm runAsNonRoot, chặn leo thang quyền, yêu cầu seccomp RuntimeDefault trở lên, drop toàn bộ (ALL) capability theo mặc định |
Được enforce theo từng namespace thông qua label — không cần CRD, không cần webhook cho việc enforce baseline built-in:
apiVersion: v1
kind: Namespace
metadata:
name: prod
labels:
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/enforce-version: latest
pod-security.kubernetes.io/audit: restricted # ghi log vi phạm mà không chặn, hữu ích khi đang migrate
pod-security.kubernetes.io/warn: restricted # cảnh báo ở phía client (kubectl) mà không chặn
Với những gì vượt ngoài phạm vi PSS (ví dụ “image phải đến từ myregistry.example.com”, “không dùng tag :latest”, “mọi Deployment phải có resource limit”), dùng một policy engine dưới dạng validating admission webhook — OPA/Gatekeeper (constraint/constraint template dựa trên Rego) hoặc Kyverno (policy viết bằng YAML Kubernetes thuần, nhìn chung được coi là dễ viết hơn). Các engine này nằm trong chuỗi admission sau khi RBAC authorization đã pass và trước khi object được ghi vào etcd, nên chúng có thể từ chối thẳng các object không tuân thủ.
Quản lý secrets trong Kubernetes
Một object Secret gốc chỉ base64-encode, không mã hóa, theo mặc định — bất kỳ ai đọc được object Secret qua API (hoặc đọc trực tiếp etcd) đều có thể decode nó dễ dàng. Hệ quả và cách khắc phục thực tế:
- Base64 là encoding, không phải encryption.
echo <value> | base64 -dkhôi phục plaintext ngay lập tức. Đừng bao giờ coi một Secret object là “an toàn khi ở trạng thái nghỉ” (safe at rest) nếu không có cấu hình bổ sung. - Bật encryption at rest cho etcd (một
EncryptionConfigurationvới KMS provider, lý tưởng nhất là dùng cloud KMS thay vì key tĩnh local) để chính lớp lưu trữ mã hóa dữ liệu Secret, chứ không chỉ mã hóa transport của API. - RBAC vẫn kiểm soát quyền đọc Secrets bất kể etcd có mã hóa hay không — ai có
get/listtrênsecretstrong một namespace đều đọc được chúng qua API; cần scope chặt chẽ, tránh cấplist/watchtrên secrets cho các role rộng (listcho phép liệt kê tất cả cùng lúc, khác vớigetvốn cần biết tên trước). - Ưu tiên external secret store cho bất kỳ giá trị nhạy cảm nào trong production: HashiCorp Vault, AWS Secrets Manager, GCP Secret Manager, Azure Key Vault, đồng bộ vào cluster qua External Secrets Operator hoặc CSI Secrets Store driver, để Kubernetes không bao giờ giữ nguồn dữ liệu gốc dài hạn và việc rotate diễn ra ngoài cluster.
- Không bao giờ commit manifest Secret đã render lên git. Với GitOps, dùng Sealed Secrets (Bitnami) hoặc giá trị mã hóa bằng SOPS để ciphertext an toàn khi lưu trong repo và chỉ được giải mã trong cluster.
- Tránh dùng biến môi trường (env var) cho các giá trị cực kỳ nhạy cảm khi có thể — env var có thể thấy được qua
/proc/<pid>/environ,container inspect, và thường xuất hiện trong crash dump/log; mount dưới dạng file (vẫn đọc được bởi process, nhưng khó bị rò rỉ hơn qua tooling) tốt hơn một chút, dù cách khắc phục thực sự là hạn chế ai/cái gì được exec vào hoặc inspect Pod đó.
Image pull policy và trusted registry
- Chỉ pull từ một tập nhỏ registry được allow-list (registry riêng của bạn, hoặc một mirror đã được kiểm duyệt), không dùng registry công khai tùy ý — enforce bằng admission policy (rule Gatekeeper/Kyverno từ chối image ngoài allow-list), chứ không chỉ dựa vào quy ước.
- Pin image theo digest (
image: myregistry.example.com/app@sha256:...) trong manifest production thay vì dùng tag có thể thay đổi — một tag có thể bị trỏ lại sau khi đã pass review/scan. imagePullPolicy: Alwayscho các tag có thể thay đổi ở môi trường non-prod (để thực sự lấy được target scan mới nhất); với image pin theo digest thì pull policy gần như không còn ý nghĩa vì digest vốn đã bất biến —IfNotPresentlà đủ và nhanh hơn.- Dùng
imagePullSecretsscope theo từng namespace/ServiceAccount thay vì credential dùng chung toàn cluster, để một namespace bị compromise không thể pull từ các registry mà nó không có lý do chính đáng để truy cập.
Supply chain: ký image và enforce policy
-
Ký image (Sigstore/cosign).
cosignký digest của image (không phải tag có thể thay đổi) bằng keypair lưu sẵn hoặc, phổ biến hơn hiện nay, keyless signing — một key tạm thời gắn với một OIDC identity (ví dụ identity của CI provider), với chữ ký và một entry trong transparency log (Rekor) được ghi công khai để chữ ký có thể được audit mà không cần tin tưởng vào bất kỳ một bên duy nhất nào.# ký trong CI, sau khi build+scan pass cosign sign --yes myregistry.example.com/app@sha256:abcd1234... # verify trước khi deploy (hoặc như một admission check) cosign verify \ --certificate-identity="https://github.com/org/repo/.github/workflows/build.yml@refs/heads/main" \ --certificate-oidc-issuer="https://token.actions.githubusercontent.com" \ myregistry.example.com/app@sha256:abcd1234... -
Enforce ở thời điểm admission. Việc ký không có ý nghĩa gì nếu không có gì kiểm tra chữ ký trước khi cho phép workload chạy. Gatekeeper, Kyverno, và
policy-controllercủa chính Sigstore đều có thể đóng vai trò validating admission webhook từ chối các image chưa ký hoặc thiếu attestation hợp lệ tại thời điểm deploy — khép kín vòng lặp từ “chúng tôi ký trong CI” tới “cluster từ chối bất kỳ thứ gì chưa ký.” -
SBOM (Software Bill of Materials). Được sinh tại build time (Syft,
docker sbom,trivy sbom) và thường được đính kèm image dưới dạng attestation đã ký, SBOM cho bạn một bản kiểm kê để đối chiếu với các CVE mới công bố mà không cần pull hay scan lại toàn bộ image — bạn đã biết chính xác bên trong có gì.
Đây là một lát cắt hẹp hơn, tập trung vào container, của các thực hành CI/CD và supply chain security rộng hơn được đề cập ở ./12-cicd-and-supply-chain-security.md.
CIS Benchmarks cho Docker và Kubernetes
CIS (Center for Internet Security) Benchmarks là các hướng dẫn hardening cấu hình được đồng thuận rộng rãi, trung lập về nhà cung cấp. Có một CIS Docker Benchmark và một CIS Kubernetes Benchmark (cộng thêm các biến thể riêng cho cloud như EKS, GKE, AKS, vì control plane managed khác với control plane tự quản lý). Mỗi benchmark là một checklist được đánh số các khuyến nghị (“1.1.1 Ensure the container host has been hardened,” “5.2.5 Minimize the admission of containers with allowPrivilegeEscalation”), mỗi khuyến nghị có trạng thái chấm điểm (Scored/Not Scored) và bước khắc phục.
Trong thực tế, bạn không đọc PDF rồi tick tay từng mục — bạn chạy một công cụ benchmark tự động nhắm vào cluster hoặc host đang chạy:
kube-bench(Aqua Security) — chạy các kiểm tra của CIS Kubernetes Benchmark nhắm vào file cấu hình, flag, và manifest thực tế của một cluster, báo cáo PASS/FAIL/WARN cho từng mục.docker-bench-security— tương đương cho Docker, chạy nhắm vào một Docker host.
# chạy kube-bench như một Job nhắm vào một node, dùng chính config của node đó
kubectl apply -f https://raw.githubusercontent.com/aquasecurity/kube-bench/main/job.yaml
kubectl logs job/kube-bench
CIS Benchmarks cũng là nền tảng cho nhiều bộ mapping tuân thủ (SOC 2, PCI-DSS, các control FedRAMP thường trích dẫn CIS làm baseline control kỹ thuật), nên việc pass các benchmark này thường vừa là một cải thiện bảo mật vừa là một yêu cầu audit.
Best Practices
Checklist hardening — danh sách audit thực tế trải dài từ build, ship, đến run:
| Khu vực | Thực hành | Vì sao |
|---|---|---|
| Image build | Dùng base image tối giản/distroless, pin theo digest | Giảm bề mặt tấn công và loại bỏ rủi ro tag bị thay đổi |
| Image build | Multi-stage build; không bao giờ ship build tool/source | Compiler và shell là bề mặt tấn công không cần thiết trong prod |
| Image build | Không secret trong ARG/COPY; dùng BuildKit secret mount | Secret bị đóng gói vẫn tồn tại trong lịch sử layer kể cả sau khi xóa |
| Image build | Chạy non-root (USER), tránh binary sudo/setuid | Giới hạn phạm vi ảnh hưởng của một process bị compromise |
| Scanning | Scan mọi image trong CI; fail khi có High/Critical | Bắt được CVE đã biết trước khi merge/deploy |
| Scanning | Scan lại định kỳ các image đã có sẵn trong registry | CVE mới được công bố sau khi image đã build và pass scan sạch |
| Runtime | readOnlyRootFilesystem: true trừ khi thực sự cần ghi | Chặn việc sửa đổi lúc runtime / thả payload |
| Runtime | capabilities: drop: [ALL], chỉ add lại những gì đã chứng minh cần thiết | Giảm thiểu các thao tác kernel tương đương root có sẵn |
| Runtime | seccompProfile: RuntimeDefault (hoặc chặt hơn) cho mọi workload | Chặn syscall nguy hiểm với chi phí gần như bằng không |
| Runtime | Đặt requests và limits CPU/memory cho mọi container | Ngăn một workload gây DoS do cạn kiệt tài nguyên |
| Cluster | Enforce Pod Security Standards (restricted cho namespace prod) | Đưa các control runtime ở trên vào toàn cluster, không phải từng manifest riêng lẻ |
| Cluster | NetworkPolicy default-deny theo namespace, cho phép rõ ràng | Ngăn di chuyển ngang (lateral movement) giữa workload bị compromise và workload lân cận |
| Cluster | RBAC least-privilege; không wildcard; audit binding thường xuyên | Giới hạn những gì một identity/ServiceAccount bị compromise có thể làm |
| Cluster | automountServiceAccountToken: false nơi không gọi API | Loại bỏ một credential mà attacker không cần phải đánh cắp |
| Secrets | Mã hóa etcd at rest; ưu tiên external secret store + sync operator | Chỉ base64 Secret thôi không đảm bảo confidentiality |
| Supply chain | Ký image (cosign); enforce verify chữ ký ở admission | Đảm bảo chỉ artifact do bạn build mới thực sự chạy được |
| Supply chain | Allow-list registry; pin image production theo digest | Ngăn pull từ nguồn không tin cậy hoặc tag bị trỏ lại |
| Compliance | Chạy kube-bench / docker-bench-security thường xuyên, không chỉ một lần | Cấu hình có thể trôi dạt (drift) theo thời gian; benchmark bắt được các regression |
Note này cố ý chồng lấn, thay vì lặp lại, với ba trang liên quan: đọc ../kubernetes/en/08-security.md để hiểu đầy đủ mô hình authentication/authorization/admission của Kubernetes, ./09-security-testing-tools.md để biết image/IaC scanning nằm ở đâu trong toolchain SAST/DAST/SCA rộng hơn, ./11-cloud-security.md để biết managed Kubernetes (EKS/GKE/AKS) chuyển một phần trách nhiệm này sang cloud provider như thế nào, và ./12-cicd-and-supply-chain-security.md để biết về signing, provenance, và hardening pipeline ngoài phạm vi image.
Tài liệu tham khảo
Part of the DevSecOps Roadmap knowledge base.
Overview
Containers and Kubernetes changed how software ships, but they also changed the attack surface. A container is not a lightweight VM — it is a set of regular processes on the host, isolated by kernel features rather than by hardware virtualization. That single fact drives almost every container security control: because containers share the host kernel, a container escape or kernel exploit can compromise the host and every other container on it. Kubernetes then adds a second layer entirely: a cluster-wide API that can create, mutate, and destroy workloads, with its own authentication, authorization, and network model — and that API is “open by default” unless you deliberately lock it down.
This note treats container and Kubernetes security as one continuous problem, following the workload from build to runtime:
- Build time — what goes into the image (base image, packages, layers) and whether it contains known vulnerabilities.
- Ship time — how the image gets from a registry to a cluster: is it signed, is it pulled from a trusted source, is it scanned before it is allowed to run.
- Run time — what the running container and the surrounding cluster allow it to do: user privileges, filesystem access, kernel syscalls, network reachability, and API permissions.
This is a specific case of the broader shift-left and supply chain security themes covered elsewhere in this DevSecOps roadmap, applied to the container/Kubernetes stack. It complements ../kubernetes/en/08-security.md, which covers Kubernetes cluster security (the 4Cs model, authentication, RBAC, admission control) in more depth from the platform-engineering angle. Here the focus is the DevSecOps practitioner’s view: what to scan, what to enforce, and what to check in a pipeline or audit.
Fundamentals
Image layers and the shared-kernel model
A container image is a stack of read-only layers, each one a filesystem diff produced by a Dockerfile instruction (FROM, RUN, COPY, …). At runtime, the container engine adds a thin writable layer on top (via a union filesystem such as overlayfs) and starts one or more processes inside a set of Linux kernel isolation primitives:
| Kernel feature | What it isolates |
|---|---|
Namespaces (pid, net, mnt, uts, ipc, user) | What the process can see — its own process tree, network stack, mount table, hostname |
| cgroups | What the process can consume — CPU, memory, I/O limits |
| Capabilities | Which root-only kernel operations the process may perform, without needing full root |
| seccomp | Which syscalls the process may invoke at all |
| LSM (AppArmor / SELinux) | Fine-grained mandatory access control on files, network, capabilities |
Every containerized process on a host is still, from the kernel’s point of view, a regular Linux process — it is just wrapped in these namespaces. That is the crucial difference from a virtual machine:
| Virtual Machine | Container | |
|---|---|---|
| Isolation boundary | Hardware-virtualized; each VM runs its own kernel | Namespaces/cgroups on one shared host kernel |
| Attack surface if compromised | Hypervisor escape needed to reach host or other VMs (rare, heavily scrutinized) | A kernel exploit or misconfiguration (e.g., running as root with extra capabilities) can reach the host directly |
| Startup / density | Seconds, heavier (full OS per VM) | Milliseconds, lightweight (shared kernel, own userspace) |
| Practical implication | Strong default isolation | Isolation is only as strong as your configuration — non-root, dropped capabilities, seccomp, read-only root FS are not optional extras, they are what makes container isolation meaningful |
This is why “containers are not a security boundary by default” is a common warning: two containers on the same node are closer to two processes on the same Linux box than to two separate machines. Treat the node as the real trust boundary, and treat every container security control below as narrowing what a compromised process inside that shared kernel can do.
Running as non-root and minimal base images
Two of the highest-leverage, lowest-cost hardening steps:
- Run as a non-root user. If a process is compromised (e.g., via a dependency RCE), running as root inside the container gives the attacker root-equivalent reach within that container’s namespace — including, if capabilities or mounts are also misconfigured, a much easier path to the host. Running as a named, unprivileged UID limits blast radius even before any other control is applied.
- Use minimal base images. Every package in an image is attack surface: more binaries to have CVEs, more shells and package managers an attacker can use to escalate or pivot after an initial foothold. Prefer
-slimvariants,distrolessimages (no shell, no package manager, only the language runtime and your app), orscratchfor statically linked binaries (e.g., Go).
A hardened multi-stage Dockerfile for a Go service, combining both:
# --- build stage: has the full toolchain, never shipped ---
FROM golang:1.22 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/app ./cmd/app
# --- final stage: distroless, no shell, no package manager ---
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/app /app
USER nonroot:nonroot # UID 65532, already non-root in this base image
ENTRYPOINT ["/app"]
And the equivalent hardening on a Debian/Alpine-based image where you cannot go fully distroless (e.g., you need a shell or dynamic libraries):
FROM node:20-alpine
RUN addgroup -S app && adduser -S app -G app \
&& apk add --no-cache dumb-init \
&& rm -rf /var/cache/apk/*
WORKDIR /app
COPY --chown=app:app package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --chown=app:app . .
USER app # never run as root
EXPOSE 3000
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "server.js"]
Other build-time habits worth keeping:
- Pin base image versions and digests (
FROM node:20-alpine@sha256:...), not just floating tags — a tag can be repointed to different bytes; a digest cannot. - Use
.dockerignoreto keep.git,.env, and local secrets out of the build context (they end up in layer history otherwise). - Never
COPYorARGa secret into an image — build-timeARGvalues and intermediate layers persist in image history even if a later layer deletes the file. Use BuildKit secret mounts (RUN --mount=type=secret) instead. - Multi-stage builds mean only the final stage ships — compilers, source, and build-only dependencies never reach production.
Image scanning
Image scanning inspects an image’s layers against databases of known vulnerabilities (CVEs) in OS packages and language dependencies, plus misconfigurations (bad Dockerfile practices, hardcoded secrets, exposed keys).
What a scanner actually checks:
- OS package vulnerabilities — the base image’s package manager inventory (apt/apk/rpm) matched against CVE feeds (NVD, distro security advisories).
- Language dependency vulnerabilities — lockfiles and installed packages (
package-lock.json,go.sum,requirements.txt, JARs) matched against advisory databases (this overlaps with SCA/dependency scanning covered elsewhere in this roadmap, but run inside the built image, catching what actually ships rather than what’s declared in a manifest). - Secrets accidentally baked into layers — API keys, private keys, credentials left in any layer, even if deleted in a later one.
- Misconfigurations — running as root, exposed sensitive ports, missing
HEALTHCHECK, use ofADDfrom remote URLs, etc. (some scanners; others delegate this to a linter like Hadolint). - License compliance — flagging disallowed OSS licenses in dependencies (some tools).
Tools at a glance:
| Tool | Type | Notes |
|---|---|---|
| Trivy (Aqua Security) | OSS, all-in-one | Scans images, filesystems, git repos, IaC, and Kubernetes manifests; single static binary; very fast; the most common default in CI today |
| Grype (Anchore) | OSS, image/SBOM scanner | Pairs with Syft (SBOM generation); good for scanning an SBOM you already generated rather than re-scanning the image |
| Clair | OSS, server/API-based | Runs as a service that registries (Harbor, Quay) query to scan pushed images; better fit for registry-integrated scanning than ad hoc CLI use |
| Docker Scout | Commercial (free tier) | Built into docker scan / Docker Desktop; policy evaluation against Docker Hub metadata |
| Snyk Container | Commercial | Strong dependency-to-fix guidance, integrates with SCA findings from the same vendor |
Scan-in-CI pattern — fail the build on high/critical findings, but do not block on informational ones:
# .github/workflows/image-scan.yml
name: Build and scan image
on: [push, pull_request]
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t myapp:${{ github.sha }} .
- name: Scan with Trivy
uses: aquasecurity/trivy-action@0.24.0
with:
image-ref: myapp:${{ github.sha }}
format: sarif
output: trivy-results.sarif
severity: HIGH,CRITICAL
exit-code: '1' # fail the pipeline on high/critical CVEs
ignore-unfixed: true # don't fail on CVEs with no available fix yet
- name: Upload results to code scanning
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: trivy-results.sarif
Two practical notes: (1) scan early (on every PR, against the built image, not just at deploy time) so a vulnerable base image is caught before merge, and (2) also scan periodically in the registry — an image built clean today can become vulnerable tomorrow when a new CVE is disclosed against a package already baked into it, with no new commit to trigger a PR scan.
Container runtime security
Scanning covers what’s in the image; runtime controls limit what a running container can do, which matters even for a clean image (zero-days exist) and is essential defense-in-depth against a scanner miss.
- Read-only root filesystem. If the app doesn’t need to write to its own filesystem, mount it read-only and give it a small writable
tmpfs/emptyDironly where needed. This stops an attacker from dropping a second-stage payload or tampering with binaries at runtime. - Drop Linux capabilities. Containers get a reduced-but-still-broad default capability set (e.g.,
CAP_NET_RAW,CAP_CHOWN). Drop all, then add back only what’s proven necessary — most web apps need none. - seccomp profiles. Restrict the syscalls a process may invoke (block
ptrace,mount, kernel module loading, etc.). Container runtimes ship aRuntimeDefaultprofile that blocks ~44 dangerous syscalls out of ~300+; using it is nearly always safe and free. - AppArmor/SELinux profiles. Mandatory access control on top of DAC permissions — confines a process even if it’s running as root, restricting which files, network operations, and capabilities it may use, based on a named profile rather than discretionary Unix permissions.
- Resource limits (cgroups). CPU/memory
requests/limitsprevent one compromised or buggy container from starving the node (a denial-of-service vector, intentional or not) and enable eviction/OOM-kill instead of node-wide exhaustion.
Docker Compose / docker run equivalent view:
docker run \
--read-only \
--tmpfs /tmp \
--cap-drop=ALL \
--cap-add=NET_BIND_SERVICE \
--security-opt no-new-privileges:true \
--security-opt seccomp=default.json \
--memory=256m --cpus=0.5 \
--user 1000:1000 \
myapp:latest
The equivalent, and more commonly used in production, Kubernetes securityContext:
apiVersion: v1
kind: Pod
metadata:
name: hardened-app
spec:
securityContext: # pod-level: applies to all containers
runAsNonRoot: true
runAsUser: 10001
fsGroup: 10001
seccompProfile:
type: RuntimeDefault
containers:
- name: app
image: myregistry.example.com/myapp@sha256:abcd1234...
securityContext: # container-level: overrides/adds to pod-level
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
add: ["NET_BIND_SERVICE"] # only if binding <1024, otherwise drop entirely
resources:
requests: { cpu: "100m", memory: "128Mi" }
limits: { cpu: "500m", memory: "256Mi" }
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}
Key Concepts
The Kubernetes attack surface
Kubernetes is a distributed system with several components an attacker can target, each with a different blast radius if compromised:
| Component | Role | Why it’s a target |
|---|---|---|
| API server | Front door to the whole cluster; every kubectl command and controller action goes through it | The single most valuable target — anyone who can talk to it with sufficient RBAC permissions can create, read, or destroy almost anything in the cluster |
| kubelet | Node agent that runs Pods, exposes an API on each node (used for logs, exec, port-forward) | An exposed or under-authenticated kubelet API lets an attacker run arbitrary commands on that node directly, bypassing the API server entirely |
| etcd | Stores all cluster state, including Secrets (base64-encoded, not encrypted, unless encryption-at-rest is configured) | Direct etcd access is equivalent to full cluster compromise — anyone with etcd read access can read every Secret in the cluster in plaintext |
| Container runtime / CRI socket | Executes containers on the node | Access to the runtime socket (e.g., /var/run/docker.sock mounted into a Pod) is a well-known container escape/privilege escalation path |
| Admission webhooks | Intercept and mutate/validate API requests | A misconfigured or unavailable webhook can either fail open (bypassing the policy it should enforce) or fail closed (denial of service for the whole cluster) |
Locking these down is the substance of ../kubernetes/en/08-security.md (authentication, RBAC, admission control in depth); this note summarizes the two controls a DevSecOps engineer will configure and audit most often — RBAC and NetworkPolicy — plus the supply-chain-facing ones.
RBAC for cluster access
RBAC answers “what may this identity do.” The core rule of thumb: least privilege, namespaced by default, no wildcards. A minimal, auditable example — a CI/CD deployer restricted to one namespace, one verb set, one set of resources:
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
namespace: staging
name: ci-deployer
rules:
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "patch", "update"] # no "delete", no "create" of new kinds
- apiGroups: [""]
resources: ["pods", "pods/log"]
verbs: ["get", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
namespace: staging
name: ci-deployer-binding
subjects:
- kind: ServiceAccount
name: ci-bot
namespace: staging
roleRef:
kind: Role
name: ci-deployer
apiGroup: rbac.authorization.k8s.io
Audit checklist for RBAC specifically: no ClusterRoleBinding to cluster-admin for anything other than a small break-glass admin group; no bindings to the default ServiceAccount; no wildcard ("*") in resources or verbs outside genuine platform-admin roles; automountServiceAccountToken: false on Pods that never call the API. Tools like kubectl-who-can and rbac-lookup help find over-broad bindings at scale.
Network policies for pod-to-pod segmentation
By default, Kubernetes networking is flat and default-allow: any Pod can reach any other Pod in any namespace on any port, provided the CNI plugin doesn’t enforce otherwise. A NetworkPolicy is how you make this default-deny and explicitly allow only the traffic a service actually needs — the network equivalent of RBAC for east-west traffic.
# 1. Default-deny all ingress and egress in the namespace
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: prod
spec:
podSelector: {} # applies to every Pod in the namespace
policyTypes: ["Ingress", "Egress"]
---
# 2. Explicitly allow the API Pods to receive traffic only from the frontend,
# and to reach only the database on its port + DNS
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-frontend-to-api
namespace: prod
spec:
podSelector:
matchLabels: { app: api }
policyTypes: ["Ingress", "Egress"]
ingress:
- from:
- podSelector: { matchLabels: { app: frontend } }
ports:
- protocol: TCP
port: 8080
egress:
- to:
- podSelector: { matchLabels: { app: database } }
ports:
- protocol: TCP
port: 5432
- to: # allow DNS resolution
- namespaceSelector: {}
ports:
- protocol: UDP
port: 53
Important caveat: NetworkPolicy requires a CNI plugin that implements it (Calico, Cilium, Weave; not the basic kubenet/bridge networking some managed clusters default to) — apply one and verify enforcement, don’t assume it works.
Pod Security Standards and admission control
Kubernetes replaced the deprecated PodSecurityPolicy with Pod Security Standards (PSS), enforced via the built-in Pod Security Admission (PSA) controller (or, for richer policy, OPA/Gatekeeper/Kyverno). PSS defines three levels:
| Level | Behavior |
|---|---|
| Privileged | Unrestricted — effectively no policy; only for trusted, cluster-critical workloads |
| Baseline | Blocks known privilege escalations: no privileged containers, no host namespaces (hostNetwork, hostPID, hostIPC), no host path mounts of sensitive paths, restricted capabilities |
| Restricted | Heavily hardened: additionally requires runAsNonRoot, blocks privilege escalation, requires seccomp RuntimeDefault or stricter, drops ALL capabilities by default |
Enforced per namespace via labels — no CRDs, no webhooks required for the baseline built-in enforcement:
apiVersion: v1
kind: Namespace
metadata:
name: prod
labels:
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/enforce-version: latest
pod-security.kubernetes.io/audit: restricted # log violations without blocking, useful during migration
pod-security.kubernetes.io/warn: restricted # warn client-side (kubectl) without blocking
For anything beyond what PSS covers (e.g., “images must come from myregistry.example.com”, “no :latest tags”, “every Deployment must have resource limits”), use a policy engine as a validating admission webhook — OPA/Gatekeeper (Rego-based constraints/constraint templates) or Kyverno (native Kubernetes YAML policies, generally considered easier to author). These sit in the admission chain after RBAC authorization and before the object is persisted to etcd, so they can reject non-compliant objects outright.
Secrets management in Kubernetes
A native Secret object is base64-encoded, not encrypted, by default — anyone who can read the Secret object via the API (or read etcd directly) can trivially decode it. Practical implications and mitigations:
- Base64 is encoding, not encryption.
echo <value> | base64 -drecovers the plaintext instantly. Never treat a Secret object as “safe at rest” without additional configuration. - Enable encryption at rest for etcd (an
EncryptionConfigurationwith a KMS provider, ideally backed by a cloud KMS rather than a static local key) so the storage layer actually encrypts Secret data, not just the API’s transport. - RBAC still governs read access to Secrets regardless of etcd encryption — anyone with
get/listonsecretsin a namespace can read them via the API; scope this tightly, and avoidlist/watchon secrets for broad roles (listlets you enumerate everything at once, unlikegetwhich requires knowing the name). - Prefer external secret stores for anything sensitive in production: HashiCorp Vault, AWS Secrets Manager, GCP Secret Manager, Azure Key Vault, synced into the cluster via the External Secrets Operator or CSI Secrets Store driver, so Kubernetes never holds the long-lived source of truth and rotation happens outside the cluster.
- Never commit rendered Secret manifests to git. For GitOps, use Sealed Secrets (Bitnami) or SOPS-encrypted values so the ciphertext is safe to store in a repo and only decrypted in-cluster.
- Avoid env vars for highly sensitive values where possible — env vars are visible via
/proc/<pid>/environ, container inspect, and often end up in crash dumps/logs; mounting as a file (still readable by the process, but not as easily leaked by tooling) is marginally better, though the real fix is restricting who/what can exec into or inspect the Pod at all.
Image pull policies and trusted registries
- Pull from a small, allow-listed set of registries (your own private registry, or a vetted mirror), not arbitrary public registries — enforced via an admission policy (Gatekeeper/Kyverno rule rejecting images outside the allow-list), not just convention.
- Pin images by digest (
image: myregistry.example.com/app@sha256:...) in production manifests rather than a mutable tag — a tag can be repointed after review/scanning has already passed. imagePullPolicy: Alwaysfor mutable tags in non-prod (so you actually get the latest scan target); for digest-pinned images the pull policy is largely moot since the digest is immutable by definition —IfNotPresentis fine and faster.- Use
imagePullSecretsscoped per namespace/ServiceAccount rather than cluster-wide credentials, so a compromised namespace can’t pull from registries it has no legitimate reason to access.
Supply chain: signed images and policy enforcement
-
Image signing (Sigstore/cosign).
cosignsigns an image digest (not a mutable tag) using either a stored keypair or, more commonly now, keyless signing — an ephemeral key tied to an OIDC identity (e.g., your CI provider’s identity), with the signature and a transparency-log entry (Rekor) recorded publicly so signatures can be audited without trusting any single party.# sign in CI, after build+scan pass cosign sign --yes myregistry.example.com/app@sha256:abcd1234... # verify before deploy (or as an admission check) cosign verify \ --certificate-identity="https://github.com/org/repo/.github/workflows/build.yml@refs/heads/main" \ --certificate-oidc-issuer="https://token.actions.githubusercontent.com" \ myregistry.example.com/app@sha256:abcd1234... -
Admission-time enforcement. Signing alone does nothing unless something checks the signature before allowing a workload to run. Gatekeeper, Kyverno, and Sigstore’s own
policy-controllercan all act as a validating admission webhook that rejects unsigned or improperly-attested images at deploy time — closing the loop from “we sign in CI” to “the cluster refuses anything unsigned.” -
SBOMs (Software Bill of Materials). Generated at build time (Syft,
docker sbom,trivy sbom) and often attached to the image as a signed attestation, an SBOM gives you an inventory to check against newly disclosed CVEs without re-pulling or re-scanning every image — you already know exactly what’s inside.
This is a narrower, container-focused slice of the broader CI/CD and supply chain security practices covered in ./12-cicd-and-supply-chain-security.md.
CIS Benchmarks for Docker and Kubernetes
The CIS (Center for Internet Security) Benchmarks are consensus-based, vendor-neutral configuration hardening guides. There is a CIS Docker Benchmark and a CIS Kubernetes Benchmark (plus cloud-specific variants for EKS, GKE, AKS, since managed control planes differ from self-managed ones). Each benchmark is a numbered checklist of recommendations (“1.1.1 Ensure the container host has been hardened,” “5.2.5 Minimize the admission of containers with allowPrivilegeEscalation”), each tagged with a scoring status (Scored/Not Scored) and remediation steps.
In practice, you don’t read the PDF and check boxes by hand — you run an automated benchmark tool against a live cluster or host:
kube-bench(Aqua Security) — runs the CIS Kubernetes Benchmark checks against an actual cluster’s config files, flags, and manifests, and reports PASS/FAIL/WARN per check.docker-bench-security— the Docker equivalent, run against a Docker host.
# run kube-bench as a Job against a node, using the node's config
kubectl apply -f https://raw.githubusercontent.com/aquasecurity/kube-bench/main/job.yaml
kubectl logs job/kube-bench
CIS Benchmarks are also the basis for many compliance mappings (SOC 2, PCI-DSS, FedRAMP controls often cite CIS as the technical control baseline), so passing them is frequently both a security improvement and an audit requirement.
Best Practices
Hardening checklist — a practical audit list spanning build, ship, and run:
| Area | Practice | Why |
|---|---|---|
| Image build | Use minimal/distroless base images, pin by digest | Reduces attack surface and eliminates tag-mutation risk |
| Image build | Multi-stage builds; never ship build tools/source | Compilers and shells are unnecessary attack surface in prod |
| Image build | No secrets in ARG/COPY; use BuildKit secret mounts | Baked-in secrets persist in layer history even after deletion |
| Image build | Run as non-root (USER), avoid sudo/setuid binaries | Limits blast radius of a compromised process |
| Scanning | Scan every image in CI; fail on High/Critical | Catches known CVEs before merge/deploy |
| Scanning | Re-scan images already in the registry on a schedule | New CVEs are disclosed after an image is built and passes clean |
| Runtime | readOnlyRootFilesystem: true unless writes are required | Blocks runtime tampering / payload drops |
| Runtime | capabilities: drop: [ALL], add back only what’s proven needed | Minimizes root-equivalent kernel operations available |
| Runtime | seccompProfile: RuntimeDefault (or stricter) on every workload | Blocks dangerous syscalls at near-zero cost |
| Runtime | Set CPU/memory requests and limits on every container | Prevents resource-exhaustion DoS from one workload |
| Cluster | Enforce Pod Security Standards (restricted in prod namespaces) | Bakes in the above runtime controls cluster-wide, not per-manifest |
| Cluster | Default-deny NetworkPolicy per namespace, allow explicitly | Stops lateral movement between compromised and adjacent workloads |
| Cluster | Least-privilege RBAC; no wildcards; audit bindings regularly | Limits what a compromised identity/ServiceAccount can do |
| Cluster | automountServiceAccountToken: false where the API isn’t called | Removes a credential an attacker doesn’t need to steal |
| Secrets | Encrypt etcd at rest; prefer external secret stores + sync operator | Base64 Secrets alone are not confidentiality |
| Supply chain | Sign images (cosign); enforce signature verification at admission | Ensures only artifacts you built actually run |
| Supply chain | Allow-list registries; pin production images by digest | Prevents pulling from untrusted sources or a repointed tag |
| Compliance | Run kube-bench / docker-bench-security regularly, not once | Configuration drifts; benchmarks catch regressions |
This note deliberately overlaps with, rather than duplicates, three related pages: read ../kubernetes/en/08-security.md for the full Kubernetes authentication/authorization/admission model, ./09-security-testing-tools.md for how image/IaC scanning fits into the broader SAST/DAST/SCA toolchain, ./11-cloud-security.md for how managed Kubernetes (EKS/GKE/AKS) shifts some of this responsibility to the cloud provider, and ./12-cicd-and-supply-chain-security.md for signing, provenance, and pipeline hardening beyond the image itself.