Pod Security Standards: Validating Kubernetes PSS with Policy Tests

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 hostPort bindings

Restricted

Follows hardening best practices. All Baseline restrictions plus:

  • Must explicitly drop ALL capabilities
  • 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=restricted

Three 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.29

Writing 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 1

Sample 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 cpu

kubesec

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 here

Writing 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.xml

Pre-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=restricted

Document 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:

  1. Static analysis: Run kube-score and kubesec against manifests in CI — catch violations before they reach the cluster
  2. Live admission testing: Use kubectl apply --dry-run=server against PSA-labeled namespaces — verify the cluster would reject non-compliant pods
  3. 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.

Read more