← All projects

August 2026

GRC Engineering 6 Week Challenge

An in-progress build turning NIST 800-53 controls into running code. Two weeks in: a Terraform baseline that is compliant on creation, and three of its controls rewritten as Open Policy Agent rules that reject a non-compliant plan before anything is deployed.

  • SC-28
  • AC-3
  • CM-6
  • AU-3
  • Terraform
  • AWS
  • Open Policy Agent
  • Rego
  • Conftest
  • Policy as Code
  • NIST 800-53

A six-week build in the GRC Engineering community, one week per deliverable. The through-line is a single idea: a control that lives in a document is a control somebody has to remember, and a control that lives in code is a control that enforces itself.

Status: 2 of 6 weeks complete. Week 1 built the compliant thing. Week 2 made the rules that judge it executable. What follows is what is actually in the repo today, not the plan for what goes in it later.

Week 1 - Your first compliant resource

The premise is that compliance is cheapest at creation. Rather than deploy an S3 bucket and then remediate it, the Terraform describes a bucket that has never been non-compliant, with four controls satisfied in the same apply that creates it.

Two buckets: a primary and a dedicated log bucket.

  • SC-28 (Protection of Information at Rest) - server-side encryption on both buckets, AES256 by default.
  • AC-3 (Access Enforcement) - a public access block on both buckets with all four flags true. Three of four is not a partial pass; it is an open bucket.
  • CM-6 (Configuration Settings) - versioning on the primary, plus a provider default_tags block so every taggable resource inherits Project, Environment, ManagedBy, and ComplianceScope without anyone having to remember to tag it.
  • AU-3 (Content of Audit Records) - ownership controls, then a log-delivery-write ACL on the log bucket, then access logging pointed from the primary at it. The ordering matters: the ACL cannot be applied until object ownership is set, so the dependency is explicit.
# CM-6 - every taggable resource inherits these, no exceptions to police.
provider "aws" {
  region = var.region

  default_tags {
    tags = {
      Project         = "grc-challenge"
      Environment     = "test"
      ManagedBy       = "GRC"
      ComplianceScope = "NIST 800-53"
    }
  }
}

The audit habit that carried over intact was the evidence step. The plan is exported as JSON and kept:

terraform plan -out=tfplan
terraform show -json tfplan > evidence/plan.json

That file is not a build artifact. It is the population - a complete, machine-readable record of exactly what was going to be created, captured before it was created. A verify.sh then re-checks encryption, versioning, and the public access block against the live bucket after apply, which is the difference between design effectiveness and operating effectiveness stated in shell.

Week 2 - Make the rules executable

Week 1 produced a compliant bucket. It did not produce anything that would notice if the next bucket was not.

Week 2 rewrites three of those controls as Open Policy Agent policies that run against the plan JSON. Same controls, opposite direction: instead of describing what compliant looks like, they describe what gets rejected.

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 a denial auditable rather than merely correct. Every failure traces 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 do not exist yet. The bucket name carries a random suffix that is not generated until apply, so matching on values fails.

The answer is to match on references. 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)
}

AC-3 needs both halves of the plan file, and that turned out to be the more interesting one. The relationship - which public access block belongs to which bucket - only exists in configuration, while the four flag values only exist in planned_values. So the rule resolves the block by reference, reconstructs its address, then reads the flags from planned values and requires all four to be true. CM-6 is simpler: read values.tags_all, which is the post-merge tag set including provider defaults, and deny once per missing tag so the message names the exact one.

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. The starter begins at 3 passing, 3 failing, and the three that pass are the happy path ones - they pass against a stub that denies nothing at all. A control test that only proves the compliant case is not evidence the control operates. Proving the negative case is.

Running against the real week 1 plan is then one command per control:

conftest test --policy policies --namespace compliance.sc28_aws plan.json

All three pass against the compliant plan. Delete the encryption block from a copy, regenerate, and SC-28 fails with a message naming the resource and the fix.

Where this is going

Weeks 3 through 6 are still ahead. The obvious next move is wiring conftest into CI so it runs on every pull request - at which point the control stops being a quarterly sample of what already shipped and becomes a gate on what ships at all, with the pipeline log as its evidence. I will write those up as they are actually built.