Using infrastructure policies for more secure infrastructure
Recently, I had a problem where I needed to enforce certain security rules on an infrastructure repository.
As we were using Kubernetes and kustomize I knew we would be able to inspect the infrastructure pieces as data. We also used ingress-nginx as our default reverse proxy implementation, and had a very strong convention around setting the global headers for the applications at the ingress level.
Knowing this, I set out to add an infrastructure policy to our codebase.
For doing this, we used conftest
which uses a Prolog-inspired language from the Open Policy Agent project called Rego. It's used and supported by the Kubernetes ecosystem, but it can also be used as a stand-alone command-line binary to parse and check for various standard serialization formats such as JSON, YAML, edn, and Terraform code!
conftest
works by adding deny-based rules[0] where the body of a "policy" are pieced together by an implicit AND. To be able to express a logical OR, you need to define multiple rules with the same name. I find this still to be a little confusing, and indeed there are many other potential beartraps such as the fact that not
doesn't necessarily invert the result if it is undefined.
In the end, I ended up writing something like this for my CORS check.
main.rego
:
package main
import data.util as util
name = input.metadata.name
deny[msg] {
allowlist := [
"cdn",
]
input.kind == "Ingress"
input.metadata.annotations["nginx.ingress.kubernetes.io/cors-allow-origin"] == "*"
not util.exists_in_list(name, allowlist)
msg := sprintf("CORS is too permissive (nginx.ingress.kubernetes.io/cors-allow-origin)', name: %s, actual: '%s'", [name, input.metadata.annotations["nginx.ingress.kubernetes.io/cors-allow-origin"]])
}
util.rego
:
package util
exists_in_list(element, list) {
startswith(element, list[_])
endswith(element, list[_])
}
Assuming we have a my-ingress.yaml
:
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
annotations:
nginx.ingress.kubernetes.io/cors-allow-origin: '*'
name: service-a
namespace: foobar
We'll be able to verify that there is a problem with it:
% cat my-ingress.yaml | conftest test --policy . -
FAIL - - main - CORS is too permissive (nginx.ingress.kubernetes.io/cors-allow-origin)', name: service-a, actual: '*'
1 test, 0 passed, 0 warnings, 1 failure, 0 exceptions
However, if our name is slightly different, tuned to the name of cdn
, we will be able to bypass the check using our own allowlisting mechanism:
% conftest test --policy . - <<EOF
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
annotations:
nginx.ingress.kubernetes.io/cors-allow-origin: '*'
name: cdn
namespace: foobar
EOF
1 test, 1 passed, 0 warnings, 0 failures, 0 exceptions
So there we have it! Bearing in mind this is not foolproof. One can always enable loose CORS on another level (for example from the application side), or there could be another header that allows for loose CORS practises to keep on going.
So you probably should not limit yourself to just this and call it job done, but also consider talking about the new enforced conventions within the engineering team or maybe even actively scanning your systems against CORS vulnerabilities.
Note that you may still have to support people when they are creating new ingresses and running into problems while creating a new ingress and having their policy checks failing on them during the pre-commit phase or on the CI (which is where we placed this check).
False positives (a passing test that should fail) may also be a common occurrence, especially with nested data or optional fields. Unit tests are a good way to mitigate this.
Despite its quirky behavior, it's still a battle-tested tool and served my purposes well. I recommend you try it out and see for yourself :-)
[0] Oddly enough, OPA itself does allow for allow-based rules whereas conftest's documentation doesn't really make a mention of it.