← All projects

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.

  • SC-28
  • AC-3
  • CM-6
  • Open Policy Agent
  • Rego
  • Conftest
  • Terraform
  • Policy as Code

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_bucket with 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.