Open Policy Agent (OPA) Security Testing: Rego Policy Bypass, Kubernetes Admission Webhook, and Bundle Tampering

Open Policy Agent has become the standard policy engine for Kubernetes admission control, API authorization, and infrastructure guardrails. OPA's Rego language is powerful but expressive enough to write policies with logic gaps. Admission webhook misconfiguration (failOpen, namespace exemptions), Rego undefined-is-false semantics exploitable through missing fields, bundle signing bypass, and Gatekeeper dryRun constraints that never block — these make OPA deployments a critical security testing target. This guide covers systematic OPA policy bypass testing.

Table of Contents

  1. Rego Policy Logic Bypass
  2. Kubernetes Admission Webhook Misconfiguration
  3. Namespace and Label Exemption Bypass
  4. Gatekeeper Constraint Template Testing
  5. OPA Bundle Security and Signing
  6. OPA API Authorization Bypass
  7. OPA Data API Exposure
  8. OPA Hardening

Rego Policy Logic Bypass

Rego has specific semantics around undefined values: a rule body that references a missing field evaluates to undefined, which is treated as false. Policies that don't account for absent fields can be bypassed by omitting required fields from the input.

# Example: policy that intends to block privileged containers
# VULNERABLE Rego:
package kubernetes.admission
deny[msg] {
    input.request.object.spec.containers[_].securityContext.privileged == true
    msg := "Privileged containers are not allowed"
}

# Bypass: submit a pod without securityContext field at all
# When securityContext is absent, the field access returns undefined
# undefined == true is undefined (not true), so the deny rule never fires

# Test the bypass by creating a pod with no securityContext:
kubectl apply -f - < import data.kubernetes.admission
# > input := {"request": {"object": {"spec": {"containers": [{"name": "test"}]}}}}
# > data.kubernetes.admission.deny
# Should return deny message for missing privileged field

Integer and String Type Confusion

# Rego is type-aware: "80" != 80
# Policies checking port numbers as integers fail if API sends strings

# Vulnerable policy:
deny[msg] {
    port := input.request.object.spec.containers[_].ports[_].containerPort
    port == 80
    msg := "Port 80 not allowed"
}
# Bypass: submit containerPort as string "80" — the == comparison returns undefined

# Test:
echo '{"request":{"object":{"spec":{"containers":[{"ports":[{"containerPort":"80"}]}]}}}}' | \
  opa eval -I -d policy.rego 'data.kubernetes.admission.deny'
# If output is [] — bypass works

# Secure: use to_number() or check both types
deny[msg] {
    port := input.request.object.spec.containers[_].ports[_].containerPort
    to_number(port) == 80
    msg := "Port 80 not allowed"
}

Kubernetes Admission Webhook Misconfiguration

# Check ValidatingWebhookConfiguration and MutatingWebhookConfiguration
kubectl get validatingwebhookconfiguration -o json | \
  python3 -c "
import json,sys
webhooks = json.load(sys.stdin)['items']
for wh in webhooks:
    meta = wh['metadata']
    for hook in wh.get('webhooks', []):
        failure_policy = hook.get('failurePolicy', 'Fail')
        namespace_selector = hook.get('namespaceSelector', {})
        print(f\"Webhook: {meta['name']}/{hook['name']}\")
        print(f\"  failurePolicy: {failure_policy}\")
        if failure_policy == 'Ignore':
            print('  [!] failurePolicy=Ignore: OPA unavailability bypasses all checks')
        if namespace_selector:
            print(f\"  namespaceSelector: {namespace_selector}\")
            print('  [?] Check for namespace exemptions that could be exploited')
        rules = hook.get('rules', [])
        for rule in rules:
            ops = rule.get('operations', [])
            resources = rule.get('resources', [])
            print(f\"  Rules: operations={ops} resources={resources}\")
"

# CRITICAL: failurePolicy: Ignore means OPA downtime = all admission checks bypassed
# Test by killing OPA pods:
# kubectl scale deployment opa -n opa-system --replicas=0
# Then: kubectl apply -f privileged-pod.yaml — should be BLOCKED but isn't

# Check if webhook applies to kube-system (often exempted but should be audited)
kubectl get validatingwebhookconfiguration opa-validating-webhook -o json | \
  python3 -c "
import json,sys
wh = json.load(sys.stdin)
for hook in wh.get('webhooks',[]):
    ns_sel = hook.get('namespaceSelector',{})
    me = ns_sel.get('matchExpressions',[])
    for expr in me:
        if expr.get('key') == 'kubernetes.io/metadata.name' and expr.get('operator') == 'NotIn':
            print(f\"Exempt namespaces: {expr.get('values', [])}\")
"

Namespace and Label Exemption Bypass

# OPA admission webhooks often exempt certain namespaces or pod labels
# An attacker who can create namespaces or set labels can bypass policies

# Find OPA exempt namespaces
kubectl get validatingwebhookconfiguration -o json | \
  python3 -c "
import json,sys
data = json.load(sys.stdin)
for wh in data['items']:
    for hook in wh.get('webhooks', []):
        ns_sel = hook.get('namespaceSelector', {})
        obj_sel = hook.get('objectSelector', {})
        print(f\"Webhook: {wh['metadata']['name']}\")
        print(f\"  namespaceSelector: {json.dumps(ns_sel)}\")
        print(f\"  objectSelector: {json.dumps(obj_sel)}\")
"

# Test: create a namespace that matches exemption criteria
# If webhook exempts namespace label 'admission.gatekeeper.sh/ignore: yes'
kubectl create namespace bypass-ns
kubectl label namespace bypass-ns admission.gatekeeper.sh/ignore=yes
kubectl apply -n bypass-ns -f - <

Gatekeeper Constraint Template Testing

# Gatekeeper uses ConstraintTemplates + Constraints to define policies
# Common issues: dryrun enforcement mode, missing parameters, incomplete coverage

# Check enforcement actions (audit vs dryrun vs deny)
kubectl get constraints --all-namespaces -o json | \
  python3 -c "
import json,sys
cs = json.load(sys.stdin)['items']
for c in cs:
    meta = c['metadata']
    spec = c.get('spec', {})
    enforcement = spec.get('enforcementAction', 'deny')
    kinds = spec.get('match', {}).get('kinds', [])
    print(f\"{meta['name']}: enforcementAction={enforcement}\")
    if enforcement in ['audit', 'dryrun']:
        print(f\"  [!] {enforcement}: violations are LOGGED but not BLOCKED\")
    print(f\"  Applies to: {kinds}\")
"

# Check Gatekeeper audit results — violations in audit mode mean real policy breaches
kubectl get constraint -o json | \
  python3 -c "
import json,sys
cs = json.load(sys.stdin)['items']
for c in cs:
    violations = c.get('status', {}).get('violations', [])
    if violations:
        print(f\"{c['metadata']['name']}: {len(violations)} violations\")
        for v in violations[:3]:
            print(f\"  Namespace: {v.get('namespace','?')} Name: {v.get('name','?')}\")
            print(f\"  Message: {v.get('message','?')}\")
"

# Test for missing constraint coverage
# Example: if there's a template for container privileges but no matching constraint,
# the template is never enforced

# Get all ConstraintTemplates
templates=$(kubectl get constrainttemplates -o jsonpath='{.items[*].metadata.name}')

# For each template, check if a Constraint exists
for tmpl in $templates; do
    kind=$(kubectl get constrainttemplate "$tmpl" -o jsonpath='{.spec.crd.spec.names.kind}')
    count=$(kubectl get "$kind" 2>/dev/null | wc -l)
    [ "$count" -le 1 ] && echo "Template $tmpl has no deployed constraint → not enforced"
done

OPA Bundle Security and Signing

# OPA bundles carry policy and data — tampering with bundle source = policy bypass
# Bundles should be signed; signature verification must be enforced

# Check if OPA is configured to verify bundle signatures
kubectl get configmap opa-config -n opa-system -o yaml | grep -A 10 "bundle\|signing"
# Look for: keys, scope, signing configuration

# If bundle signing is NOT enabled, test by serving a modified bundle from a rogue server
# This requires MITM or DNS poisoning to bundle_url, but tests the signing requirement

# Check OPA startup flags for signature verification
kubectl get deploy opa -n opa-system -o json | \
  python3 -c "
import json,sys
deploy = json.load(sys.stdin)
for c in deploy['spec']['template']['spec']['containers']:
    if 'opa' in c['name']:
        args = c.get('args', [])
        print('OPA args:', args)
        if any('bundle-signing-key' in str(a) for a in args):
            print('  [OK] Bundle signing key configured')
        else:
            print('  [!] No bundle signing key — bundles not verified')
"

# Check OPA bundle configuration
# Secure bundle config with signature:
# services:
#   bundle-service:
#     url: https://bundle.example.com
# bundles:
#   main:
#     resource: /bundles/policy.tar.gz
#     signing:
#       keyid: my-signing-key
#       scope: write
# keys:
#   my-signing-key:
#     algorithm: RS256
#     key: |
#       -----BEGIN PUBLIC KEY-----
#       ...

# Test OPA management API exposure
kubectl port-forward svc/opa -n opa-system 8181:8181 &
# Check if management API is accessible without authentication
curl -s http://localhost:8181/v1/policies | head -20
# Returns policies → OPA management API is unauthenticated

OPA API Authorization Bypass

# OPA is widely used for API authorization — policy gaps allow access to protected endpoints

# Test OPA policy with unexpected input shapes
# If policy expects input.user.roles but JWT claim is input.user.role (singular):
curl -s -X POST http://opa-endpoint:8181/v1/data/authz/allow \
  -H "Content-Type: application/json" \
  -d '{
    "input": {
      "user": {
        "role": "admin"    # singular, policy checks .roles (plural)
      },
      "path": "/api/admin",
      "method": "GET"
    }
  }' | python3 -c "import json,sys; print(json.load(sys.stdin))"
# If result: {"result": false} — correct denial
# If result: {} or undefined — policy has field mismatch that evaluates to undefined = allow

# Test for wildcard/glob matching bypass in path policies
# Policy using glob.match("/api/admin*", [], input.path)
# may match /api/adminextra or /api/admin%2fetc

# Test method normalization
curl -s -X POST http://opa-endpoint:8181/v1/data/authz/allow \
  -d '{"input": {"method": "get", "path": "/api/admin"}}' | \
  python3 -c "import json,sys; d=json.load(sys.stdin); print('Allow:', d.get('result'))"
# If policy only checks upper(input.method) but receives lowercase → bypass

# Enumerate OPA policy endpoints
curl -s http://opa-endpoint:8181/v1/policies | \
  python3 -c "import json,sys; [print(p['id']) for p in json.load(sys.stdin).get('result',[])]"

OPA Data API Exposure

# OPA's data API stores context used by policies — users, groups, allowed paths
# An exposed data API lets attackers read authorization state

# Check if OPA data API is externally accessible
kubectl get svc opa -n opa-system -o json | \
  python3 -c "
import json,sys
svc = json.load(sys.stdin)
print('Type:', svc['spec']['type'])
ports = svc['spec']['ports']
for p in ports:
    print(f\"Port: {p.get('port')} → {p.get('targetPort')} ({p.get('protocol','TCP')})\")
# ClusterIP = only internal (OK)
# LoadBalancer or NodePort = exposed externally
"

# Test data API enumeration
curl -s http://opa-endpoint:8181/v1/data | \
  python3 -c "
import json,sys
d = json.load(sys.stdin).get('result', {})
def print_keys(obj, indent=0):
    if isinstance(obj, dict):
        for k in obj:
            print(' '*indent + str(k))
            print_keys(obj[k], indent+2)
print_keys(d)
"
# If policy data includes user → role mappings, allowed endpoints, etc.
# this is sensitive information an attacker can use to understand the policy

# OPA decision logging — check if logs go somewhere secure
kubectl get deploy opa -n opa-system -o json | \
  python3 -c "
import json,sys
d = json.load(sys.stdin)
for c in d['spec']['template']['spec']['containers']:
    if 'opa' in c['name']:
        print('Args:', c.get('args', []))
        # Look for: --set=decision_logs.console=true (logs to stdout only)
        # vs --set=decision_logs.service=... (ships logs to storage)
"

OPA Hardening

OPA Security Hardening Checklist:
  • Set webhook failurePolicy: Fail (not Ignore) — OPA downtime should block admissions
  • Review all Rego policies for undefined-field bypass (missing securityContext, absent labels)
  • Run Gatekeeper constraints in deny enforcement mode, not dryrun
  • Verify every ConstraintTemplate has a deployed Constraint targeting relevant resources
  • Enable OPA bundle signing — unsigned bundles allow policy tampering
  • Restrict OPA management API (port 8181) to cluster-internal only
  • Restrict who can create namespaces or apply OPA-exemption labels
  • Test policies with OPA's built-in test framework (opa test) including negative cases
  • Enable decision logging to an immutable store for audit trail
  • Regularly run kubectl get constraint -o json to check audit violation counts
Security TestMethodRisk
failurePolicy: Ignore on webhookkubectl get validatingwebhookconfigurationCritical
Rego undefined-field bypassSubmit input without required fields, test allowCritical
Namespace exemption exploitableLabel test namespace, deploy privileged podHigh
Gatekeeper dryrun constraintskubectl get constraints — check enforcementActionHigh
ConstraintTemplate without ConstraintCross-reference templates vs deployed kindsHigh
OPA bundle signing disabledCheck deployment args for signing configHigh
Data API externally accessibleCheck service type, test port 8181Medium

Automate OPA Policy Security Testing

Ironimo tests your OPA/Gatekeeper deployment for Rego logic bypass paths, admission webhook misconfiguration, constraint coverage gaps, and bundle integrity — ensuring your policy-as-code actually enforces what you think it does.

Start free scan