Pod Security Standards: Validating Kubernetes PSS with Policy Tests
Pod Security Standards (PSS) replaced PodSecurityPolicy (PSP) in Kubernetes 1.25. They define three security levels for pods — Privileged, Baseline, and Restricted — and the Pod Security Admission (PSA) controller enforces them at the namespace level. But "enforces" only works if you actually test that your manifests comply.
This guide covers how to validate PSS compliance, test PSA behavior, and enforce standards in CI/CD pipelines.
Pod Security Standards Overview
Kubernetes defines three PSS levels:
Privileged
No restrictions. Allows everything including host namespace access, privilege escalation, and arbitrary capabilities. For system workloads like monitoring agents and CNI plugins that legitimately need low-level access.
Baseline
Prevents known privilege escalations while allowing common container configurations. Key restrictions:
- No
hostPID,hostIPC,hostNetwork - No privileged containers
- No
allowPrivilegeEscalation: true - No dangerous capabilities (
NET_ADMIN,SYS_ADMIN,SYS_PTRACE, etc.) - No host path mounts to sensitive directories
- No
hostPortbindings
Restricted
Follows hardening best practices. All Baseline restrictions plus:
- Must explicitly drop
ALLcapabilities - Must set
runAsNonRoot: true - Must set
seccompProfile(RuntimeDefault or Localhost) - Cannot add any capabilities (even normally allowed ones)
- Volume types limited to specific allowed types
Enabling Pod Security Admission
PSA is enabled by labeling namespaces:
# Enforce Restricted level — pods violating it are rejected
kubectl label namespace my-app \
pod-security.kubernetes.io/enforce=restricted \
pod-security.kubernetes.io/enforce-version=latest
<span class="hljs-comment"># Warn on Baseline violations (warn but don't reject)
kubectl label namespace my-app \
pod-security.kubernetes.io/warn=baseline
<span class="hljs-comment"># Audit-only (log but don't reject or warn)
kubectl label namespace my-app \
pod-security.kubernetes.io/audit=restrictedThree modes for each level:
- enforce: Reject non-compliant pods
- warn: Allow but show warnings
- audit: Allow and log to audit log
You can combine all three:
kubectl label namespace production \
pod-security.kubernetes.io/enforce=restricted \
pod-security.kubernetes.io/warn=restricted \
pod-security.kubernetes.io/audit=restricted \
pod-security.kubernetes.io/enforce-version=v1.29 \
pod-security.kubernetes.io/warn-version=v1.29 \
pod-security.kubernetes.io/audit-version=v1.29Writing Compliant Manifests
Restricted-Level Compliant Pod
apiVersion: v1
kind: Pod
metadata:
name: my-app
namespace: production
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 3000
fsGroup: 2000
seccompProfile:
type: RuntimeDefault
containers:
- name: app
image: my-app:latest
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
volumeMounts:
- name: tmp
mountPath: /tmp
- name: cache
mountPath: /app/cache
volumes:
- name: tmp
emptyDir: {}
- name: cache
emptyDir: {}Common Violations and Fixes
| Violation | Fix |
|---|---|
runAsRoot: true or no runAsNonRoot |
Add runAsNonRoot: true, set runAsUser to non-zero |
No seccompProfile |
Add seccompProfile: {type: RuntimeDefault} |
capabilities.drop missing ALL |
Add capabilities: {drop: [ALL]} |
allowPrivilegeEscalation not set |
Add allowPrivilegeEscalation: false |
hostPID: true |
Remove or set hostPID: false |
privileged: true |
Remove or set privileged: false |
| Dangerous capability added | Remove capabilities.add or use only explicitly allowed caps |
Static Analysis Tools
kube-score
kube-score analyzes manifests and grades them against Kubernetes best practices including PSS:
# Install
brew install kube-score
<span class="hljs-comment"># or
go install github.com/zegl/kube-score/cmd/kube-score@latest
<span class="hljs-comment"># Score a single file
kube-score score deploy.yaml
<span class="hljs-comment"># Score with output format
kube-score score deploy.yaml --output-format ci
<span class="hljs-comment"># Score everything in a directory
kube-score score k8s/*.yaml
<span class="hljs-comment"># Exit code 1 if any critical checks fail
kube-score score deploy.yaml <span class="hljs-pipe">|| <span class="hljs-built_in">exit 1Sample output:
v1/Pod my-app in default 💥 CRITICAL
[CRITICAL] Container Security Context User Group ID
· my-app -> The pod has a container running as root, or the user and
group IDs are not set
[CRITICAL] Container Security Context ReadOnlyRootFilesystem
· my-app -> The pod has a container without
readOnlyRootFilesystem set to true
[WARNING] Container Resources
· my-app -> No resource limits set for cpukubesec
kubesec provides a security risk score for Kubernetes resources:
# Scan a manifest
kubesec scan deploy.yaml
<span class="hljs-comment"># Or use the API
curl -sSX POST \
--data-binary @deploy.yaml \
https://v2.kubesec.io/scan <span class="hljs-pipe">| jq <span class="hljs-string">'.[0].score'Sample output:
[
{
"object": "Pod/my-app.default",
"valid": true,
"fileName": "deploy.yaml",
"message": "Passed with a score of 7 points",
"score": 7,
"scoring": {
"passed": [
{
"id": "ReadOnlyRootFilesystem",
"selector": "containers[] .securityContext .readOnlyRootFilesystem == true",
"reason": "An immutable root filesystem can prevent malicious binaries being added to PATH and increase attack cost",
"points": 1
}
],
"critical": [
{
"id": "CapSysAdmin",
"selector": "containers[] .securityContext .capabilities .add == SYS_ADMIN",
"reason": "CAP_SYS_ADMIN is the most privileged capability and should always be avoided",
"points": -30
}
]
}
}
]kubectl with Dry-Run
Test whether Kubernetes itself would accept your manifest:
# Test against an already-labeled namespace
kubectl apply --dry-run=server -f deploy.yaml -n production
<span class="hljs-comment"># The server validates against PSA labels on 'production' namespace
<span class="hljs-comment"># If the namespace enforces restricted, non-compliant pods are rejected hereWriting PSS Tests
Test Suite: Policy Level Compliance
import subprocess
import json
import pytest
from pathlib import Path
def kube_score(manifest_path):
"""Run kube-score and return parsed results."""
result = subprocess.run(
["kube-score", "score", "--output-format", "json", str(manifest_path)],
capture_output=True, text=True
)
if result.returncode not in (0, 1): # 1 = warnings
raise RuntimeError(f"kube-score failed: {result.stderr}")
return json.loads(result.stdout)
def get_critical_checks(score_results):
"""Extract all CRITICAL failures from kube-score output."""
critical = []
for resource in score_results:
for check in resource.get("checks", []):
if check.get("grade") == 1: # 1 = CRITICAL
critical.append({
"resource": resource.get("object_name"),
"check": check.get("check", {}).get("name"),
"comment": [c.get("summary") for c in check.get("comments", [])]
})
return critical
class TestProductionManifests:
"""All production manifests must pass kube-score CRITICAL checks."""
@pytest.fixture(params=list(Path("k8s/production").glob("*.yaml")))
def manifest(self, request):
return request.param
def test_no_critical_security_issues(self, manifest):
results = kube_score(manifest)
critical = get_critical_checks(results)
assert len(critical) == 0, (
f"Critical security issues in {manifest}:\n" +
"\n".join(
f" [{c['resource']}] {c['check']}: {c['comment']}"
for c in critical
)
)
def test_runs_as_non_root(self, manifest):
"""Verify manifest explicitly sets runAsNonRoot."""
import yaml
with open(manifest) as f:
docs = list(yaml.safe_load_all(f))
for doc in docs:
if not doc or doc.get("kind") not in ("Pod", "Deployment", "StatefulSet"):
continue
spec = doc.get("spec", {})
if doc["kind"] != "Pod":
spec = spec.get("template", {}).get("spec", {})
pod_sc = spec.get("securityContext", {})
containers = spec.get("containers", [])
# Either pod-level or container-level must set runAsNonRoot
for container in containers:
c_sc = container.get("securityContext", {})
run_as_non_root = (
pod_sc.get("runAsNonRoot") or
c_sc.get("runAsNonRoot")
)
assert run_as_non_root, (
f"{manifest}: container '{container['name']}' in "
f"{doc['kind']}/{doc['metadata']['name']} "
f"does not set runAsNonRoot: true"
)
def test_has_seccomp_profile(self, manifest):
"""Verify manifest sets a seccomp profile (Restricted requirement)."""
import yaml
with open(manifest) as f:
docs = list(yaml.safe_load_all(f))
for doc in docs:
if not doc or doc.get("kind") not in ("Pod", "Deployment", "StatefulSet"):
continue
spec = doc.get("spec", {})
if doc["kind"] != "Pod":
spec = spec.get("template", {}).get("spec", {})
pod_sc = spec.get("securityContext", {})
has_seccomp = "seccompProfile" in pod_sc
assert has_seccomp, (
f"{manifest}: {doc['kind']}/{doc['metadata']['name']} "
f"missing seccompProfile in pod securityContext"
)
def test_drops_all_capabilities(self, manifest):
"""All containers must drop ALL capabilities."""
import yaml
with open(manifest) as f:
docs = list(yaml.safe_load_all(f))
for doc in docs:
if not doc or doc.get("kind") not in ("Pod", "Deployment", "StatefulSet"):
continue
spec = doc.get("spec", {})
if doc["kind"] != "Pod":
spec = spec.get("template", {}).get("spec", {})
for container in spec.get("containers", []):
c_sc = container.get("securityContext", {})
capabilities = c_sc.get("capabilities", {})
drop = capabilities.get("drop", [])
assert "ALL" in drop, (
f"{manifest}: container '{container['name']}' in "
f"{doc['kind']}/{doc['metadata']['name']} "
f"does not drop ALL capabilities. Current drop: {drop}"
)Test Suite: Live PSA Enforcement
Test that your cluster actually enforces PSS on labeled namespaces:
import subprocess
import pytest
import yaml
from kubernetes import client, config
config.load_kube_config()
v1 = client.CoreV1Api()
RESTRICTED_NAMESPACE = "pss-test-restricted"
BASELINE_NAMESPACE = "pss-test-baseline"
@pytest.fixture(scope="session", autouse=True)
def setup_test_namespaces():
"""Create namespaces with PSS labels."""
for ns_name, level in [
(RESTRICTED_NAMESPACE, "restricted"),
(BASELINE_NAMESPACE, "baseline")
]:
try:
v1.create_namespace(client.V1Namespace(
metadata=client.V1ObjectMeta(
name=ns_name,
labels={
f"pod-security.kubernetes.io/enforce": level,
f"pod-security.kubernetes.io/enforce-version": "latest"
}
)
))
except Exception:
pass # Already exists
yield
for ns_name in [RESTRICTED_NAMESPACE, BASELINE_NAMESPACE]:
try:
v1.delete_namespace(ns_name)
except Exception:
pass
def try_apply_manifest(manifest_dict, namespace):
"""Attempt to create a pod, return (success, error_message)."""
manifest_yaml = yaml.dump(manifest_dict)
result = subprocess.run(
["kubectl", "apply", "--dry-run=server", "-f", "-", "-n", namespace],
input=manifest_yaml, capture_output=True, text=True
)
return result.returncode == 0, result.stderr
PRIVILEGED_POD = {
"apiVersion": "v1",
"kind": "Pod",
"metadata": {"name": "test-privileged"},
"spec": {
"containers": [{
"name": "app",
"image": "alpine:latest",
"securityContext": {"privileged": True}
}]
}
}
COMPLIANT_POD = {
"apiVersion": "v1",
"kind": "Pod",
"metadata": {"name": "test-compliant"},
"spec": {
"securityContext": {
"runAsNonRoot": True,
"runAsUser": 1000,
"seccompProfile": {"type": "RuntimeDefault"}
},
"containers": [{
"name": "app",
"image": "alpine:latest",
"securityContext": {
"allowPrivilegeEscalation": False,
"readOnlyRootFilesystem": True,
"capabilities": {"drop": ["ALL"]}
}
}]
}
}
def test_privileged_pod_rejected_in_restricted_namespace():
success, error = try_apply_manifest(PRIVILEGED_POD, RESTRICTED_NAMESPACE)
assert not success, "Privileged pod should be rejected in restricted namespace"
assert "violates PodSecurity" in error or "forbidden" in error.lower()
def test_privileged_pod_rejected_in_baseline_namespace():
success, error = try_apply_manifest(PRIVILEGED_POD, BASELINE_NAMESPACE)
assert not success, "Privileged pod should be rejected in baseline namespace"
def test_compliant_pod_accepted_in_restricted_namespace():
success, error = try_apply_manifest(COMPLIANT_POD, RESTRICTED_NAMESPACE)
assert success, (
f"Compliant pod should be accepted in restricted namespace. "
f"Error: {error}"
)
def test_compliant_pod_accepted_in_baseline_namespace():
success, error = try_apply_manifest(COMPLIANT_POD, BASELINE_NAMESPACE)
assert success, (
f"Compliant pod should be accepted in baseline namespace. "
f"Error: {error}"
)CI/CD Integration
GitHub Actions Pipeline
name: Pod Security Standards Validation
on:
pull_request:
paths:
- 'k8s/**'
- 'helm/**'
jobs:
pss-validation:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install kube-score
run: |
curl -fsSL https://github.com/zegl/kube-score/releases/latest/download/kube-score_linux_amd64.tar.gz \
| tar xz
sudo mv kube-score /usr/local/bin/
- name: Install Python deps
run: pip install pyyaml pytest kubernetes
- name: Render Helm charts
run: |
helm template my-app helm/my-app/ \
--values helm/my-app/values.production.yaml \
> /tmp/rendered-production.yaml
- name: Run kube-score
run: |
kube-score score /tmp/rendered-production.yaml \
--output-format ci \
--exit-one-on-warning
echo "kube-score: OK"
- name: Run PSS compliance tests
run: |
pytest tests/pss/ -v \
--manifests-dir=k8s/production \
--junit-xml=test-results.xml
- name: Publish test results
uses: actions/upload-artifact@v4
if: always()
with:
name: pss-test-results
path: test-results.xmlPre-Commit Hook
#!/bin/bash
<span class="hljs-comment"># .git/hooks/pre-commit
CHANGED_MANIFESTS=$(git diff --cached --name-only <span class="hljs-pipe">| grep -E <span class="hljs-string">'\.(yaml|yml)$' <span class="hljs-pipe">| grep -E <span class="hljs-string">'^k8s/')
<span class="hljs-keyword">if [ -z <span class="hljs-string">"$CHANGED_MANIFESTS" ]; <span class="hljs-keyword">then
<span class="hljs-built_in">exit 0
<span class="hljs-keyword">fi
<span class="hljs-built_in">echo <span class="hljs-string">"Validating Kubernetes manifests..."
FAIL=0
<span class="hljs-keyword">for manifest <span class="hljs-keyword">in <span class="hljs-variable">$CHANGED_MANIFESTS; <span class="hljs-keyword">do
<span class="hljs-keyword">if kubectl apply --dry-run=client -f <span class="hljs-string">"$manifest" 2>/dev/null; <span class="hljs-keyword">then
<span class="hljs-keyword">if ! kube-score score <span class="hljs-string">"$manifest" --output-format ci 2>/dev/null; <span class="hljs-keyword">then
<span class="hljs-built_in">echo <span class="hljs-string">"FAIL: $manifest has kube-score violations"
FAIL=1
<span class="hljs-keyword">fi
<span class="hljs-keyword">fi
<span class="hljs-keyword">done
<span class="hljs-keyword">if [ <span class="hljs-variable">$FAIL -ne 0 ]; <span class="hljs-keyword">then
<span class="hljs-built_in">echo <span class="hljs-string">"Pre-commit check failed. Fix security issues before committing."
<span class="hljs-built_in">exit 1
<span class="hljs-keyword">fi
<span class="hljs-built_in">echo <span class="hljs-string">"All manifests passed PSS checks"Auditing Existing Cluster Compliance
Find Non-Compliant Pods in Running Cluster
#!/bin/bash
<span class="hljs-comment"># audit-pss-compliance.sh
<span class="hljs-built_in">echo <span class="hljs-string">"=== Pods running as root ==="
kubectl get pods -A -o json <span class="hljs-pipe">| jq -r <span class="hljs-string">'
.items[] |
select(
(.spec.securityContext.runAsUser == 0 or .spec.securityContext.runAsUser == null) and
(.spec.containers[].securityContext.runAsNonRoot != true)
) <span class="hljs-pipe">|
"\(.metadata.namespace)/\(.metadata.name)"
'
<span class="hljs-built_in">echo <span class="hljs-string">""
<span class="hljs-built_in">echo <span class="hljs-string">"=== Pods without seccompProfile ==="
kubectl get pods -A -o json <span class="hljs-pipe">| jq -r <span class="hljs-string">'
.items[] |
select(.spec.securityContext.seccompProfile == null) <span class="hljs-pipe">|
"\(.metadata.namespace)/\(.metadata.name)"
'
<span class="hljs-built_in">echo <span class="hljs-string">""
<span class="hljs-built_in">echo <span class="hljs-string">"=== Pods with privileged containers ==="
kubectl get pods -A -o json <span class="hljs-pipe">| jq -r <span class="hljs-string">'
.items[] |
select(.spec.containers[].securityContext.privileged == true) <span class="hljs-pipe">|
"\(.metadata.namespace)/\(.metadata.name)"
'
<span class="hljs-built_in">echo <span class="hljs-string">""
<span class="hljs-built_in">echo <span class="hljs-string">"=== Namespaces without PSS labels ==="
kubectl get namespaces -o json <span class="hljs-pipe">| jq -r <span class="hljs-string">'
.items[] |
select(
.metadata.labels["pod-security.kubernetes.io/enforce"] == null
) <span class="hljs-pipe">|
.metadata.name
'Simulate PSA Enforcement Without Enforcing
Use warn mode to see what would be rejected without blocking existing workloads:
# Label namespace with warn mode only (safe for production)
kubectl label namespace production \
pod-security.kubernetes.io/warn=restricted \
pod-security.kubernetes.io/warn-version=latest
<span class="hljs-comment"># Then watch for warnings in your deployment pipeline
kubectl apply -f k8s/production/ 2>&1 <span class="hljs-pipe">| grep -i <span class="hljs-string">"warning.*PodSecurity"Namespace PSS Configuration Strategy
Different namespaces need different levels:
# System namespaces — often need privileged
kubectl label namespace kube-system \
pod-security.kubernetes.io/enforce=privileged
<span class="hljs-comment"># Monitoring (Prometheus, Grafana) — often needs baseline
kubectl label namespace monitoring \
pod-security.kubernetes.io/enforce=baseline
<span class="hljs-comment"># Application workloads — enforce restricted
kubectl label namespace production \
pod-security.kubernetes.io/enforce=restricted
kubectl label namespace staging \
pod-security.kubernetes.io/enforce=restricted \
pod-security.kubernetes.io/warn=restrictedDocument your namespace security levels in a central configuration:
# namespace-security-config.yaml
namespaces:
kube-system:
level: privileged
reason: "System components require privileged access"
monitoring:
level: baseline
reason: "Prometheus node-exporter needs host network"
production:
level: restricted
reason: "Application workloads should meet full hardening standards"
staging:
level: restricted
reason: "Mirror production security posture"Use this to programmatically apply and test labels:
import yaml
from kubernetes import client, config
config.load_kube_config()
v1 = client.CoreV1Api()
with open("namespace-security-config.yaml") as f:
config_doc = yaml.safe_load(f)
for ns_name, settings in config_doc["namespaces"].items():
level = settings["level"]
ns = v1.read_namespace(ns_name)
labels = ns.metadata.labels or {}
assert labels.get("pod-security.kubernetes.io/enforce") == level, (
f"Namespace '{ns_name}' has enforce={labels.get('pod-security.kubernetes.io/enforce')} "
f"but should be {level}"
)
print(f"OK: {ns_name} enforce={level}")Summary
Validating Pod Security Standards requires three layers:
- Static analysis: Run
kube-scoreandkubesecagainst manifests in CI — catch violations before they reach the cluster - Live admission testing: Use
kubectl apply --dry-run=serveragainst PSA-labeled namespaces — verify the cluster would reject non-compliant pods - Cluster audit: Periodically scan running pods for drift — workloads running before PSA was applied won't be retroactively rejected
The goal is that no non-compliant pod reaches production without an explicit, documented exception. Static analysis + server dry-run in CI covers new deployments. Periodic auditing catches drift.