OPA Rego policy bypasses

OPA Rego policy bypasses

TL;DR: OPA (Open Policy Agent) policies in Rego decide allow/deny for K8s admission, API authorization, terraform plans, more. Bypass patterns: (1) input-field-confusion (policy checks input.containers but K8s also has initContainers / ephemeralContainers), (2) array iteration that misses indices, (3) type confusion (string vs number), (4) policy-bundle staleness, (5) decision-log leak, (6) Rego logic that defaults to allow on undefined values. Companion to k8s-admission-webhook-abuse and authorization-patterns-rebac-abac.

Quick Rego refresher

1
2
3
4
5
6
7
8
9
10
11
12
13
package kubernetes.admission

default allow = false                # "default deny"

allow {
    input.kind.kind == "Pod"
    not has_privileged_container
}

has_privileged_container {
    container := input.request.object.spec.containers[_]
    container.securityContext.privileged == true
}

The _ is an iterator over the array. Anything not explicitly defined is undefined (a value distinct from false).

Bypass 1 — input-field gaps

K8s Pod spec has three container arrays:

  • spec.containers
  • spec.initContainers
  • spec.ephemeralContainers

A policy that only iterates spec.containers misses bypasses via initContainers.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# BAD
has_privileged_container {
    c := input.request.object.spec.containers[_]
    c.securityContext.privileged == true
}

# GOOD
all_containers[c] {
    c := input.request.object.spec.containers[_]
} {
    c := input.request.object.spec.initContainers[_]
} {
    c := input.request.object.spec.ephemeralContainers[_]
}

has_privileged_container {
    c := all_containers[_]
    c.securityContext.privileged == true
}

Audit: grep policies for spec.containers without initContainers.

Bypass 2 — default-deny missing

1
2
3
4
# BAD — no default
allow {
    input.user == "admin"
}

If input.user != "admin", allow is undefined. OPA treats undefined ≠ false in some integrations. Always:

1
default allow = false

Bypass 3 — type confusion

Rego compares values strictly typed. If a policy expects a number but the request has a string:

1
2
3
4
deny[msg] {
    input.replicas > 10                # both must be numbers
    msg := "too many replicas"
}

Attacker submits "replicas": "100" — string comparison fails the rule; replicas pass. Policies that use comparison operators must check types first.

Bypass 4 — undefined == false flaw

1
2
3
4
deny[msg] {
    input.tls.minVersion != "1.2"      # if tls is undefined, evaluation halts; no deny
    msg := "must require TLS 1.2"
}

If input.tls is absent, the comparison can’t evaluate; the rule body fails entirely; deny produces no message. The request passes.

Fix: explicitly check existence:

1
2
3
4
5
6
7
8
deny[msg] {
    not input.tls
    msg := "tls block required"
}
deny[msg] {
    input.tls.minVersion != "1.2"
    msg := "must require TLS 1.2"
}

Bypass 5 — set-comprehension scoping

1
2
3
4
5
6
deny[msg] {
    some i
    bad := input.containers[i].securityContext.privileged
    bad
    msg := sprintf("container %d privileged", [i])
}

If input.containers[i].securityContext is undefined for some i, the body fails for that i. Other i’s may pass — the rule fires correctly only for the i’s where the field exists. An attacker could craft a manifest where only specific indices have the offending field but the rule is designed for the wrong shape.

Bypass 6 — policy bundle staleness

OPA fetches policy bundles from a remote endpoint periodically. Bugs:

  • Polling interval too long; attacker exploits the window between policy update and OPA fetch.
  • Bundle signature not verified — attacker controls bundle URL → injects policies.
  • “Status API” returns 200 even when bundle hasn’t refreshed; admin doesn’t notice.

Audit: bundle signing enabled? Polling interval tight? Status API monitored?

Bypass 7 — decision log leaks

OPA’s decision log includes the input — full request body. If the log goes to an unsecured destination (S3 bucket, syslog), sensitive K8s manifests leak.

Audit:

  • Decision logs masked for sensitive fields.
  • Sink permissions reviewed.

Bypass 8 — rego function bugs

Custom http.send builtins fetch external data. Misuse:

  • Network policy doesn’t restrict where OPA can reach.
  • Cached responses with stale data.
  • Failure modes — http.send failure returns null; policy treats null as “no problem”.

Bypass 9 — package conflicts

A cluster with multiple OPA policies in different packages can have overlapping decisions. Last loaded wins in some Gatekeeper configurations; attackers add a permissive policy that overrides the strict one.

Bypass 10 — Gatekeeper constraint vs constraint template

ConstraintTemplate defines the Rego; Constraint instantiates it. A Constraint that scopes only to certain namespaces, kinds, or matches:

1
2
3
4
5
6
spec:
  match:
    kinds:
      - apiGroups: [""]
        kinds: ["Pod"]
    namespaces: ["prod"]

Attacker creates resources outside the matched namespaces. Constraint missing → bypass.

Source audit Rego policies

1
2
3
4
5
6
find . -name '*.rego'
# patterns to flag
grep -rn 'default allow = true' rego/        # default permit
grep -rn 'spec.containers' rego/ | grep -v initContainers   # incomplete iteration
grep -rn 'http\.send' rego/                  # external dependencies
grep -rn '!= ' rego/                         # comparison-only check

Tooling

  • OPA test framework — write unit tests per policy.
  • conftest — test policies against config files.
  • Regal — Rego linter that catches many of the above patterns.
  • Open Policy Agent Playground — try inputs against policies.

Reporting

For each finding:

  • Specific rule and line.
  • Input that bypasses.
  • Recommended fix (added check, missed iteration, type assertion).

Defence

  • Default deny.
  • Iterate over all container arrays.
  • Type-check before compare.
  • Test policies with negative cases.
  • Sign and verify policy bundles.
  • Mask sensitive fields in decision logs.

References