14 KiB
Kubernetes Pod Security Reference
Full reference for hardening workloads in Kubernetes — NetworkPolicy, RBAC, Pod Security Admission, admission controllers (Kyverno/OPA), and service account hardening.
Table of Contents
- Pod Security Admission (PSA)
- NetworkPolicy — Zero-Trust Networking
- RBAC — Least Privilege
- Admission Controllers (Kyverno / OPA Gatekeeper)
- Service Account Hardening
- Runtime Security — Falco
- Secrets Management in K8s
Pod Security Admission
Built-in K8s 1.25+ policy engine (replaces deprecated PodSecurityPolicy).
Three Built-In Policy Levels
| Level | What It Blocks |
|---|---|
privileged |
No restrictions (cluster default) |
baseline |
Blocks hostNetwork, hostPID, hostIPC, privileged containers, dangerous volume types, hostPath |
restricted |
Everything in baseline + requires non-root, read-only FS, drops capabilities, requires seccomp |
Three Modes Per Level
| Mode | Behavior |
|---|---|
enforce |
Reject pods that violate the policy |
audit |
Allow but log a violation in audit log |
warn |
Allow but return a warning to the user |
Applying PSA Labels
# Audit before enforcing — find what would fail
kubectl label namespace production \
pod-security.kubernetes.io/audit=restricted \
pod-security.kubernetes.io/audit-version=latest
# Gradual rollout: warn in staging, enforce in production
kubectl label namespace staging \
pod-security.kubernetes.io/warn=restricted \
pod-security.kubernetes.io/warn-version=latest
kubectl label namespace production \
pod-security.kubernetes.io/enforce=restricted \
pod-security.kubernetes.io/enforce-version=latest
Check What Would Fail Before Enforcing
# Dry-run check against a namespace
kubectl --dry-run=server apply -f manifests/ --namespace production
# Check a specific pod spec
kubectl run test-pod --image=nginx --dry-run=server -n production
Minimum Pod Spec for restricted Level
spec:
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
seccompProfile:
type: RuntimeDefault # or Localhost with a custom profile
containers:
- name: app
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
# Resource limits are required for restricted PSA
resources:
requests:
memory: "64Mi"
cpu: "50m"
limits:
memory: "256Mi"
cpu: "250m"
NetworkPolicy — Zero-Trust Networking
By default all pods in a cluster can reach all other pods on any port. Lock down with NetworkPolicy.
Prerequisite: Your CNI plugin must support NetworkPolicy (Calico, Cilium, Weave Net — but NOT Flannel by default).
Step 1: Default Deny All
Apply a default-deny to every namespace that holds workloads:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: production
spec:
podSelector: {} # Selects all pods in this namespace
policyTypes:
- Ingress
- Egress
Step 2: Allow Only Required Traffic
# Allow ingress from nginx ingress controller, egress to postgres + DNS
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-myapp
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:
- protocol: TCP
port: 3000
egress:
- to:
- podSelector:
matchLabels:
app: postgres
namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: production
ports:
- protocol: TCP
port: 5432
- to: # Allow DNS resolution to cluster DNS only
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
Allow Access to External Services (e.g., cloud APIs)
egress:
- to:
- ipBlock:
cidr: 0.0.0.0/0 # All external IPs
except:
- 10.0.0.0/8 # But not internal cluster ranges
- 172.16.0.0/12
- 192.168.0.0/16
ports:
- protocol: TCP
port: 443 # HTTPS only
Validate NetworkPolicy with Cilium or Calico CLI
# Cilium — test connectivity between pods
cilium connectivity test
# Calico — list effective policies
kubectl exec -it deploy/myapp -- calicoctl get networkpolicy -n production
RBAC — Least Privilege
Principle: Scope Narrowly, Avoid Wildcards
# ❌ DANGEROUS — grants everything to everything
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: full-admin
subjects:
- kind: ServiceAccount
name: myapp-sa
namespace: production
roleRef:
kind: ClusterRole
name: cluster-admin
apiGroup: rbac.authorization.k8s.io
---
# ✅ CORRECT — minimal namespace-scoped role with specific resource names
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: myapp-role
namespace: production
rules:
- apiGroups: [""]
resources: ["configmaps"]
resourceNames: ["myapp-config"] # Lock to specific named resources
verbs: ["get", "list"] # Never ["*"]
- apiGroups: [""]
resources: ["secrets"]
resourceNames: ["myapp-db-creds"]
verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: myapp-rolebinding
namespace: production
subjects:
- kind: ServiceAccount
name: myapp-sa
namespace: production
roleRef:
kind: Role
name: myapp-role
apiGroup: rbac.authorization.k8s.io
Audit RBAC
# What can a service account do?
kubectl auth can-i --list \
--as=system:serviceaccount:production:myapp-sa \
-n production
# Find all cluster-admin bindings (security anti-pattern)
kubectl get clusterrolebindings -o json | \
jq '.items[] | select(.roleRef.name=="cluster-admin") | {name:.metadata.name, subjects:.subjects}'
# Find overly broad wildcard permissions
kubectl get roles,clusterroles -A -o json | \
jq '.items[] | select(.rules[]?.verbs[]? == "*") | .metadata.name'
# Use rbac-tool for a full audit
kubectl rbac-tool who-can get secrets -n production
Admission Controllers
Kyverno (Policy as Kubernetes Resources)
Kyverno validates, mutates, and generates resources — no Rego knowledge required.
# Install Kyverno
helm repo add kyverno https://kyverno.github.io/kyverno/
helm install kyverno kyverno/kyverno -n kyverno --create-namespace
Essential Policies:
# 1. 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: "runAsNonRoot: true is required"
pattern:
spec:
containers:
- securityContext:
runAsNonRoot: true
---
# 2. 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 use @sha256: digest, not floating tags"
pattern:
spec:
containers:
- image: "*@sha256:*"
---
# 3. Disallow 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"
---
# 4. Require resource limits (prevents resource starvation)
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-resource-limits
spec:
validationFailureAction: Enforce
rules:
- name: check-limits
match:
resources:
kinds: [Pod]
validate:
message: "Resource limits (memory and cpu) must be set"
pattern:
spec:
containers:
- resources:
limits:
memory: "?*"
cpu: "?*"
---
# 5. Auto-mutate: add drop ALL capabilities if not set
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: drop-all-capabilities
spec:
rules:
- name: add-drop-all
match:
resources:
kinds: [Pod]
mutate:
patchStrategicMerge:
spec:
containers:
- (name): "*"
securityContext:
capabilities:
drop: ["ALL"]
OPA Gatekeeper (Policy as Rego)
# Install Gatekeeper
kubectl apply -f https://raw.githubusercontent.com/open-policy-agent/gatekeeper/release-3.17/deploy/gatekeeper.yaml
# ConstraintTemplate — define the Rego policy
apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
name: k8srequiredlabels
spec:
crd:
spec:
names:
kind: K8sRequiredLabels
validation:
openAPIV3Schema:
properties:
labels:
type: array
items:
type: string
targets:
- target: admission.k8s.gatekeeper.sh
rego: |
package k8srequiredlabels
violation[{"msg": msg}] {
provided := {label | input.review.object.metadata.labels[label]}
required := {label | label := input.parameters.labels[_]}
missing := required - provided
count(missing) > 0
msg := sprintf("Missing required labels: %v", [missing])
}
---
# Constraint — apply the policy
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sRequiredLabels
metadata:
name: require-app-label
spec:
enforcementAction: deny
match:
kinds:
- apiGroups: ["apps"]
kinds: ["Deployment"]
parameters:
labels: ["app", "version", "owner"]
Service Account Hardening
# Dedicated service account per workload (never use 'default')
apiVersion: v1
kind: ServiceAccount
metadata:
name: myapp-sa
namespace: production
annotations:
# EKS — IAM Roles for Service Accounts (IRSA)
eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/myapp-role
# GKE — Workload Identity
iam.gke.io/gcp-service-account: myapp@my-project.iam.gserviceaccount.com
automountServiceAccountToken: false # Disable unless app calls K8s API
---
# In the pod spec — also disable token mounting
spec:
serviceAccountName: myapp-sa
automountServiceAccountToken: false
Why use Workload Identity instead of K8s Secrets for cloud credentials?
- Credentials are short-lived (1h) and auto-rotated
- No secret to leak, rotate, or store
- Audit trail tied to workload identity, not a shared key
Runtime Security — Falco
Falco detects anomalous runtime behaviour (unexpected syscalls, network connections, file reads).
# Install via Helm
helm repo add falcosecurity https://falcosecurity.github.io/charts
helm install falco falcosecurity/falco \
--namespace falco --create-namespace \
--set falco.grpc.enabled=true \
--set falco.grpcOutput.enabled=true
Example rules:
# Alert on shell spawned inside a container
- rule: Terminal shell in container
desc: A shell was spawned in a container with an attached terminal
condition: >
spawned_process and container
and shell_procs and proc.tty != 0
and container_entrypoint
output: >
Shell spawned in a container (user=%user.name container=%container.name
shell=%proc.name parent=%proc.pname)
priority: WARNING
# Alert on sensitive file read
- rule: Read sensitive file untrusted
desc: An attempt to read a sensitive file by a non-trusted program
condition: >
open_read and sensitive_files
and not proc.name in (trusted_programs)
output: >
Sensitive file opened for reading (file=%fd.name user=%user.name
container=%container.name)
priority: WARNING
Secrets Management in K8s
Kubernetes Secrets are base64-encoded, not encrypted by default. Use one of these:
| Solution | Mechanism | Best For |
|---|---|---|
| External Secrets Operator | Sync from AWS Secrets Manager / GCP Secret Manager / Vault | Production — secrets never live in etcd |
| Sealed Secrets (Bitnami) | Asymmetric encryption of secrets in Git | GitOps workflows |
| HashiCorp Vault | Dynamic secrets, PKI, lease management | Complex multi-cloud setups |
| SOPS + Age/GPG | Encrypted secret files in Git | Small teams, simple workflows |
# External Secrets Operator — sync from AWS Secrets Manager
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: myapp-db-creds
namespace: production
spec:
refreshInterval: 1h
secretStoreRef:
name: aws-secrets-manager
kind: ClusterSecretStore
target:
name: myapp-db-creds
creationPolicy: Owner
data:
- secretKey: DB_PASSWORD
remoteRef:
key: production/myapp/db
property: password
# Enable etcd encryption at rest (K8s)
# In kube-apiserver: --encryption-provider-config=encryption-config.yaml
# encryption-config.yaml:
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources: [secrets]
providers:
- aescbc:
keys:
- name: key1
secret: <base64-encoded-32-byte-key>
- identity: {}