Overview
Misconfigured Kubernetes manifests are one of the top causes of production security incidents. A container running as root, an unpinned image tag, a missing resource limit, or an exposed host network can each open the door to privilege escalation, resource exhaustion, or lateral movement inside your cluster.
The problem is that these misconfigurations are invisible until deployment time — or worse, until an attacker exploits them. The solution is to shift security left and catch policy violations before manifests ever reach the cluster.
In this hands-on lab, you will use Conftest — a testing framework built on the Open Policy Agent (OPA) engine — to write Rego policies that validate Kubernetes manifests. You will then integrate those checks into GitHub Actions and GitLab CI so that every pull request is automatically scanned for violations.
By the end of this lab you will have:
- A library of reusable Rego policies covering image tags, security contexts, resource limits, and host-level access.
- Unit tests for those policies using
opa test. - Working CI/CD pipelines that block insecure manifests and provide clear, actionable violation messages.
Prerequisites
Before you begin, make sure you have the following tools and knowledge in place:
- conftest CLI installed — install with Homebrew:
brew install conftestAlternatively, download the binary from the Conftest releases page.
- kubectl and a test cluster (optional) — if you want to verify that your fixed manifests actually deploy, spin up a local cluster with
minikube startorkind create cluster. - A test repository — create a fresh Git repository or use an existing one. We will build all files from scratch.
- Basic YAML and Kubernetes knowledge — you should be comfortable reading Deployment, Service, and Pod manifests.
Environment Setup
Start by creating the project structure and a set of intentionally insecure Kubernetes manifests. These will serve as our test fixtures throughout every exercise.
Project Structure
conftest-k8s-lab/
├── k8s/
│ ├── deployment-latest-tag.yaml
│ ├── deployment-run-as-root.yaml
│ ├── deployment-no-limits.yaml
│ ├── service-loadbalancer.yaml
│ └── pod-host-network.yaml
└── policy/
Create the directories:
mkdir -p conftest-k8s-lab/k8s conftest-k8s-lab/policy
cd conftest-k8s-lab
Manifest 1 — Deployment with Unpinned Image Tag
Create k8s/deployment-latest-tag.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: web-latest
spec:
replicas: 1
selector:
matchLabels:
app: web-latest
template:
metadata:
labels:
app: web-latest
spec:
containers:
- name: nginx
image: nginx:latest
ports:
- containerPort: 80
This manifest uses nginx:latest, which means every pull could silently introduce a different binary into your cluster.
Manifest 2 — Deployment Running as Root
Create k8s/deployment-run-as-root.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: web-root
spec:
replicas: 1
selector:
matchLabels:
app: web-root
template:
metadata:
labels:
app: web-root
spec:
containers:
- name: nginx
image: nginx:1.25.4
ports:
- containerPort: 80
No securityContext is set, so the container defaults to running as root — a well-known privilege escalation vector.
Manifest 3 — Deployment with No Resource Limits
Create k8s/deployment-no-limits.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: web-no-limits
spec:
replicas: 1
selector:
matchLabels:
app: web-no-limits
template:
metadata:
labels:
app: web-no-limits
spec:
containers:
- name: nginx
image: nginx:1.25.4
ports:
- containerPort: 80
Without CPU and memory limits a single misbehaving pod can starve the entire node.
Manifest 4 — Service of Type LoadBalancer
Create k8s/service-loadbalancer.yaml:
apiVersion: v1
kind: Service
metadata:
name: web-lb
spec:
type: LoadBalancer
selector:
app: web
ports:
- port: 80
targetPort: 80
A bare LoadBalancer service with no annotations can expose workloads to the public internet in cloud environments.
Manifest 5 — Pod with Host Network Access
Create k8s/pod-host-network.yaml:
apiVersion: v1
kind: Pod
metadata:
name: debug-pod
spec:
hostNetwork: true
containers:
- name: debug
image: busybox:1.36
command: ["sleep", "3600"]
hostNetwork: true gives the pod full access to the node’s network stack, bypassing network policies entirely.
Exercise 1: Write Your First Rego Policy — No Latest Tags
Your first policy will deny any container image that uses the :latest tag or omits a tag entirely (which also resolves to latest).
Step 1 — Create the Policy
Create policy/tags.rego:
package main
import future.keywords.in
deny[msg] {
input.kind == "Deployment"
container := input.spec.template.spec.containers[_]
image := container.image
not contains(image, ":")
msg := sprintf("Container '%s' uses image '%s' without a tag. Pin to a specific version.", [container.name, image])
}
deny[msg] {
input.kind == "Deployment"
container := input.spec.template.spec.containers[_]
image := container.image
endswith(image, ":latest")
msg := sprintf("Container '%s' uses the ':latest' tag in image '%s'. Pin to a specific version.", [container.name, image])
}
Step 2 — Run Conftest Against the Insecure Manifest
conftest test k8s/deployment-latest-tag.yaml
Expected output:
FAIL - k8s/deployment-latest-tag.yaml - main - Container 'nginx' uses the ':latest' tag in image 'nginx:latest'. Pin to a specific version.
1 test, 0 passed, 0 warnings, 1 failure
Step 3 — Fix the Manifest
Edit k8s/deployment-latest-tag.yaml and change the image line:
image: nginx:1.25.4
Run Conftest again:
conftest test k8s/deployment-latest-tag.yaml
Expected output:
1 test, 1 passed, 0 warnings, 0 failures
Understanding the Rego Structure
Every Rego policy file used by Conftest follows a simple pattern:
package main— Conftest looks for themainpackage by default. You can override this with--namespace.deny[msg]— a rule set. If all conditions inside the rule body evaluate to true, the rule fires and addsmsgto the set of violations.input— represents the YAML document being tested. Conftest parses it into a JSON object automatically.sprintf— formats a human-readable error message that appears in CI logs.
Exercise 2: No Containers Running as Root
Containers that run as root can modify the filesystem, install packages, and — if combined with a kernel exploit — escape to the host. This policy enforces two controls: runAsNonRoot: true and allowPrivilegeEscalation: false.
Step 1 — Create the Policy
Create policy/security_context.rego:
package main
deny[msg] {
input.kind == "Deployment"
container := input.spec.template.spec.containers[_]
not container.securityContext.runAsNonRoot == true
msg := sprintf("Container '%s' must set securityContext.runAsNonRoot to true.", [container.name])
}
deny[msg] {
input.kind == "Deployment"
container := input.spec.template.spec.containers[_]
not container.securityContext.allowPrivilegeEscalation == false
msg := sprintf("Container '%s' must set securityContext.allowPrivilegeEscalation to false.", [container.name])
}
Step 2 — Test Against the Insecure Manifest
conftest test k8s/deployment-run-as-root.yaml
Expected output:
FAIL - k8s/deployment-run-as-root.yaml - main - Container 'nginx' must set securityContext.runAsNonRoot to true.
FAIL - k8s/deployment-run-as-root.yaml - main - Container 'nginx' must set securityContext.allowPrivilegeEscalation to false.
1 test, 0 passed, 0 warnings, 2 failures
Step 3 — Fix the Manifest
Update k8s/deployment-run-as-root.yaml to include a security context on each container:
apiVersion: apps/v1
kind: Deployment
metadata:
name: web-root
spec:
replicas: 1
selector:
matchLabels:
app: web-root
template:
metadata:
labels:
app: web-root
spec:
containers:
- name: nginx
image: nginx:1.25.4
ports:
- containerPort: 80
securityContext:
runAsNonRoot: true
allowPrivilegeEscalation: false
Run Conftest again — both rules now pass.
Exercise 3: Require Resource Limits
Without resource limits, a single container can consume all CPU and memory on the node, causing cascading failures across unrelated workloads. Many compliance frameworks (SOC 2, CIS Benchmarks) require explicit limits.
Step 1 — Create the Policy
Create policy/resources.rego:
package main
deny[msg] {
input.kind == "Deployment"
container := input.spec.template.spec.containers[_]
not container.resources.limits.cpu
msg := sprintf("Container '%s' must define resources.limits.cpu.", [container.name])
}
deny[msg] {
input.kind == "Deployment"
container := input.spec.template.spec.containers[_]
not container.resources.limits.memory
msg := sprintf("Container '%s' must define resources.limits.memory.", [container.name])
}
Step 2 — Test Against the Insecure Manifest
conftest test k8s/deployment-no-limits.yaml
Expected output:
FAIL - k8s/deployment-no-limits.yaml - main - Container 'nginx' must define resources.limits.cpu.
FAIL - k8s/deployment-no-limits.yaml - main - Container 'nginx' must define resources.limits.memory.
1 test, 0 passed, 0 warnings, 2 failures
Step 3 — Fix the Manifest
Add resource limits to k8s/deployment-no-limits.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: web-no-limits
spec:
replicas: 1
selector:
matchLabels:
app: web-no-limits
template:
metadata:
labels:
app: web-no-limits
spec:
containers:
- name: nginx
image: nginx:1.25.4
ports:
- containerPort: 80
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "250m"
memory: "256Mi"
Run Conftest again — both CPU and memory checks pass.
Exercise 4: Deny Privileged Host Access
Pods that request host-level access — hostNetwork, hostPID, hostIPC, or a privileged security context — effectively run outside the container sandbox. A compromised pod with any of these flags can see all traffic on the node, attach to other processes, or escape to the host entirely.
Step 1 — Create the Policy
Create policy/host_access.rego:
package main
deny[msg] {
input.kind == "Pod"
input.spec.hostNetwork == true
msg := sprintf("Pod '%s' must not use hostNetwork: true.", [input.metadata.name])
}
deny[msg] {
input.kind == "Pod"
input.spec.hostPID == true
msg := sprintf("Pod '%s' must not use hostPID: true.", [input.metadata.name])
}
deny[msg] {
input.kind == "Pod"
input.spec.hostIPC == true
msg := sprintf("Pod '%s' must not use hostIPC: true.", [input.metadata.name])
}
deny[msg] {
input.kind == "Pod"
container := input.spec.containers[_]
container.securityContext.privileged == true
msg := sprintf("Container '%s' in Pod '%s' must not run in privileged mode.", [container.name, input.metadata.name])
}
deny[msg] {
input.kind == "Deployment"
input.spec.template.spec.hostNetwork == true
msg := sprintf("Deployment '%s' must not use hostNetwork: true.", [input.metadata.name])
}
deny[msg] {
input.kind == "Deployment"
container := input.spec.template.spec.containers[_]
container.securityContext.privileged == true
msg := sprintf("Container '%s' in Deployment '%s' must not run in privileged mode.", [container.name, input.metadata.name])
}
Step 2 — Test Against the Insecure Pod
conftest test k8s/pod-host-network.yaml
Expected output:
FAIL - k8s/pod-host-network.yaml - main - Pod 'debug-pod' must not use hostNetwork: true.
1 test, 0 passed, 0 warnings, 1 failure
Step 3 — Fix the Manifest
Remove the hostNetwork: true line from k8s/pod-host-network.yaml:
apiVersion: v1
kind: Pod
metadata:
name: debug-pod
spec:
containers:
- name: debug
image: busybox:1.36
command: ["sleep", "3600"]
Run Conftest — the pod now passes all host-access checks.
Exercise 5: Testing Policies with opa test
Policies are code, and code needs tests. Without tests you cannot be sure that a policy catches what it should or that a future refactor does not introduce a false positive that blocks legitimate deployments.
Step 1 — Create Test Cases
Create policy/tags_test.rego:
package main
test_latest_denied {
input := {
"kind": "Deployment",
"spec": {
"template": {
"spec": {
"containers": [
{
"name": "app",
"image": "nginx:latest"
}
]
}
}
}
}
count(deny) > 0
}
test_no_tag_denied {
input := {
"kind": "Deployment",
"spec": {
"template": {
"spec": {
"containers": [
{
"name": "app",
"image": "nginx"
}
]
}
}
}
}
count(deny) > 0
}
test_pinned_allowed {
input := {
"kind": "Deployment",
"spec": {
"template": {
"spec": {
"containers": [
{
"name": "app",
"image": "nginx:1.25.4"
}
]
}
}
}
}
count(deny) == 0
}
Step 2 — Run the Tests
opa test policy/ -v
Expected output:
policy/tags_test.rego:
data.main.test_latest_denied: PASS (1.234ms)
data.main.test_no_tag_denied: PASS (0.567ms)
data.main.test_pinned_allowed: PASS (0.432ms)
--------------------------------------------------------------------------------
PASS: 3/3
Why Policy Testing Matters
In a CI/CD context, a false negative means an insecure manifest slips through, while a false positive blocks a legitimate deployment and erodes developer trust in the pipeline. By writing explicit test cases for both allowed and denied inputs, you get a regression suite that runs in milliseconds and guarantees your policies behave correctly as the rule library grows.
Make it a habit to add a *_test.rego file for every new policy file. Run opa test policy/ -v as part of your CI pipeline alongside conftest test.
Exercise 6: Integrate Conftest into GitHub Actions
With policies written and tested, the next step is to wire them into your CI pipeline so that every pull request is automatically validated.
Step 1 — Create the Workflow
Create .github/workflows/policy-check.yml:
name: Kubernetes Policy Check
on:
pull_request:
paths:
- "k8s/**"
- "policy/**"
push:
branches: [main]
paths:
- "k8s/**"
- "policy/**"
jobs:
conftest:
name: Validate K8s Manifests
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Conftest
run: |
CONFTEST_VERSION="0.56.0"
wget -q "https://github.com/open-policy-agent/conftest/releases/download/v${CONFTEST_VERSION}/conftest_${CONFTEST_VERSION}_Linux_x86_64.tar.gz"
tar xzf "conftest_${CONFTEST_VERSION}_Linux_x86_64.tar.gz"
sudo mv conftest /usr/local/bin/
conftest --version
- name: Install OPA
run: |
OPA_VERSION="v0.68.0"
curl -L -o opa "https://openpolicyagent.org/downloads/${OPA_VERSION}/opa_linux_amd64_static"
chmod +x opa
sudo mv opa /usr/local/bin/
opa version
- name: Run policy unit tests
run: opa test policy/ -v
- name: Run Conftest against all manifests
run: |
echo "Scanning all Kubernetes manifests in k8s/ ..."
FAILED=0
for file in k8s/*.yaml; do
echo ""
echo "--- Testing: $file ---"
if ! conftest test "$file" --policy policy/; then
FAILED=1
fi
done
if [ "$FAILED" -eq 1 ]; then
echo ""
echo "❌ One or more manifests violated policy. Fix the issues above."
exit 1
fi
echo ""
echo "✅ All manifests passed policy checks."
Step 2 — Observe a Failing PR
Push a branch that contains the original insecure manifests. The pipeline output will look like this:
--- Testing: k8s/deployment-latest-tag.yaml ---
FAIL - k8s/deployment-latest-tag.yaml - main - Container 'nginx' uses the ':latest' tag in image 'nginx:latest'. Pin to a specific version.
--- Testing: k8s/deployment-run-as-root.yaml ---
FAIL - k8s/deployment-run-as-root.yaml - main - Container 'nginx' must set securityContext.runAsNonRoot to true.
FAIL - k8s/deployment-run-as-root.yaml - main - Container 'nginx' must set securityContext.allowPrivilegeEscalation to false.
--- Testing: k8s/pod-host-network.yaml ---
FAIL - k8s/pod-host-network.yaml - main - Pod 'debug-pod' must not use hostNetwork: true.
❌ One or more manifests violated policy. Fix the issues above.
Error: Process completed with exit code 1.
The PR status check turns red with clear violation messages that tell the developer exactly what to fix and where.
Step 3 — Observe a Passing PR
Fix all manifests as shown in the previous exercises, push again, and the pipeline passes:
--- Testing: k8s/deployment-latest-tag.yaml ---
1 test, 1 passed, 0 warnings, 0 failures
--- Testing: k8s/deployment-run-as-root.yaml ---
1 test, 1 passed, 0 warnings, 0 failures
--- Testing: k8s/deployment-no-limits.yaml ---
1 test, 1 passed, 0 warnings, 0 failures
✅ All manifests passed policy checks.
Exercise 7: Integrate Conftest into GitLab CI
If your team uses GitLab, the integration is just as straightforward. Add the following job to your .gitlab-ci.yml:
Full Working Configuration
stages:
- validate
conftest-policy-check:
stage: validate
image: alpine:3.19
variables:
CONFTEST_VERSION: "0.56.0"
OPA_VERSION: "v0.68.0"
before_script:
- apk add --no-cache curl wget tar
- wget -q "https://github.com/open-policy-agent/conftest/releases/download/v${CONFTEST_VERSION}/conftest_${CONFTEST_VERSION}_Linux_x86_64.tar.gz"
- tar xzf "conftest_${CONFTEST_VERSION}_Linux_x86_64.tar.gz"
- mv conftest /usr/local/bin/
- curl -L -o /usr/local/bin/opa "https://openpolicyagent.org/downloads/${OPA_VERSION}/opa_linux_amd64_static"
- chmod +x /usr/local/bin/opa
script:
- echo "Running policy unit tests..."
- opa test policy/ -v
- echo "Running Conftest against all manifests..."
- |
FAILED=0
for file in k8s/*.yaml; do
echo ""
echo "--- Testing: $file ---"
if ! conftest test "$file" --policy policy/; then
FAILED=1
fi
done
if [ "$FAILED" -eq 1 ]; then
echo ""
echo "One or more manifests violated policy."
exit 1
fi
echo ""
echo "All manifests passed policy checks."
rules:
- changes:
- k8s/**/*
- policy/**/*
when: always
Pass/Fail Behavior
The behavior mirrors the GitHub Actions workflow exactly. When insecure manifests are present, the job fails with violation messages. When all manifests are compliant, the job passes with a clean summary. The rules block ensures the job only runs when Kubernetes manifests or policy files change, keeping pipeline runtime minimal.
Advanced: Warnings vs. Denials
Not every policy violation should block a deployment. Some are recommendations — best practices that you want to surface without breaking the pipeline. Conftest supports this distinction through warn rules.
How It Works
deny[msg]— a hard gate. If any deny rule fires,conftest testexits with a non-zero code and the pipeline fails.warn[msg]— an advisory. The message is printed but the exit code remains zero, so the pipeline passes.conftest test --fail-on-warn— optionally promotes all warnings to failures. Useful when you want to gradually tighten policy: start withwarn, and once teams have fixed existing violations, switch todenyor enable--fail-on-warn.
Create an Advisory Policy
Create policy/recommendations.rego:
package main
warn[msg] {
input.kind == "Service"
input.spec.type == "LoadBalancer"
not input.metadata.annotations
msg := sprintf("Service '%s' is of type LoadBalancer with no annotations. Consider adding cloud-provider-specific annotations for internal load balancers.", [input.metadata.name])
}
warn[msg] {
input.kind == "Deployment"
container := input.spec.template.spec.containers[_]
not container.readinessProbe
msg := sprintf("Container '%s' has no readinessProbe. Add one so Kubernetes can route traffic only to healthy pods.", [container.name])
}
warn[msg] {
input.kind == "Deployment"
container := input.spec.template.spec.containers[_]
not container.livenessProbe
msg := sprintf("Container '%s' has no livenessProbe. Add one so Kubernetes can restart unhealthy pods.", [container.name])
}
Test the Advisory Policy
conftest test k8s/service-loadbalancer.yaml
Expected output:
WARN - k8s/service-loadbalancer.yaml - main - Service 'web-lb' is of type LoadBalancer with no annotations. Consider adding cloud-provider-specific annotations for internal load balancers.
1 test, 1 passed, 1 warning, 0 failures
Note: the exit code is 0 — the pipeline still passes. If you want to enforce warnings:
conftest test k8s/service-loadbalancer.yaml --fail-on-warn
Now the exit code is 1 and the pipeline would fail.
This pattern lets you roll out new policies gradually: introduce them as warnings, give teams time to remediate, then promote to denials.
Cleanup
When you are finished with the lab, remove the test resources:
# Remove the lab directory
rm -rf conftest-k8s-lab
# If you deployed any fixed manifests to a test cluster
kubectl delete -f k8s/ --ignore-not-found
# If you created a kind cluster for this lab
kind delete cluster --name conftest-lab
Key Takeaways
- Shift left aggressively. Catching a misconfigured manifest in a pull request is orders of magnitude cheaper than discovering it after a breach.
- Conftest + Rego is a lightweight entry point to policy-as-code. You do not need a full OPA server or Gatekeeper installation to start enforcing policies — a single CLI binary and a few Rego files are enough.
- Test your policies like application code. Use
opa testwith explicit positive and negative test cases to prevent regressions in your rule library. - Use warnings for gradual rollout. Start new policies as
warnrules, socialize them with the team, and promote todenyonce existing violations are resolved. - Actionable error messages are critical. Use
sprintfin every rule to tell the developer which container, which field, and what to do about it. Generic “policy violated” messages erode trust in CI gates. - Keep policies in the same repository as the manifests. Co-locating
policy/withk8s/means policy changes go through the same review process as infrastructure changes.
Next Steps
Now that you have a working Conftest pipeline, continue building your policy-as-code practice:
- Policy as Code for CI/CD: OPA and Rego — go deeper into the Rego language, learn about data imports, bundle management, and decision logging for audit trails.
- Defensive Patterns and Mitigations — explore the broader landscape of CI/CD pipeline hardening, from secret management to artifact signing and runtime enforcement.