July 2026
Executable NIST 800-53 Controls
Three NIST 800-53 controls rewritten as Open Policy Agent rules with a full unit-test suite, so a non-compliant Terraform plan is rejected automatically instead of being caught in next quarter's sample.
A control written in English is ambiguous by nature. “Information at rest is protected” is a sentence two competent people can reasonably disagree about, and that ambiguity is where audit findings are born. A control written in Rego is not ambiguous. It either denies the plan or it does not.
This project takes three controls and makes them executable against Terraform plan output.
The policies
- SC-28 (Encryption at Rest) - denies any
aws_s3_bucketwith no server-side encryption configuration pointing at it. - AC-3 (Access Enforcement) - denies any bucket whose public access block is
missing or has any of the four flags set to
false. - CM-6 (Configuration Settings) - denies any resource missing a required tag, and names the specific tag in the failure message.
Each policy carries a metadata header binding it back to its source control:
# METADATA
# title: SC-28 - Encryption at Rest (AWS S3)
# custom:
# control_id: SC-28
# framework: nist-800-53
# severity: high
# remediation: Add aws_s3_bucket_server_side_encryption_configuration referencing the bucket.
package compliance.sc28_aws
That header is what makes the policy auditable rather than just functional. Any denial traces directly back to a control ID, a framework, a severity, and the specific remediation - which is most of a finding write-up, generated automatically.
The technique that makes it work
At plan time, resource names are not yet known. The bucket name includes a random suffix that will not exist until apply, so matching on values fails.
The answer is to match on references instead. Terraform’s plan JSON records
the dependency graph in configuration.root_module.resources[], where an
encryption resource points at its bucket via
expressions.bucket.references - strings like "aws_s3_bucket.primary.id".
deny contains msg if {
some bucket in input.configuration.root_module.resources
bucket.type == "aws_s3_bucket"
addr := sprintf("aws_s3_bucket.%s", [bucket.name])
not bucket_has_encryption(addr)
msg := sprintf("SC-28: %s has no server-side encryption configuration", [addr])
}
bucket_has_encryption(addr) if {
some enc in input.configuration.root_module.resources
enc.type == "aws_s3_bucket_server_side_encryption_configuration"
some ref in enc.expressions.bucket.references
startswith(ref, addr)
}
Checking the relationship rather than the value means the control is enforced before anything is deployed - the point at which fixing it is cheapest.
Tests are the specification
Every policy ships with unit tests covering both directions: a compliant plan must produce no denial, and a non-compliant plan must be denied.
opa test policies/ -v # 6/6 passing
This is the part that translates most directly from audit work. A control test that only confirms the happy path is not a test - it will pass just as happily against a policy that denies nothing at all. Proving the negative case is what demonstrates the control actually operates.
Running against a real plan is then a single command per control:
conftest test --policy policies --namespace compliance.sc28_aws plan.json
Wired into CI, this runs on every pull request. The control moves from point-in-time sampling to continuous enforcement, and the evidence is the pipeline log.