Compliance & Scanning
Infrastructure as Code makes misconfigurations reproducible: a single unencrypted bucket or wide-open security group, once committed, gets deployed everywhere the module is used. Compliance scanning catches those mistakes before they reach a cloud account by analysing your HCL — and the plan it produces — against a library of security rules and your own organisational policies. This page covers static scanning with Checkov and tfsec, policy-as-code guardrails, tagging and encryption standards, and how to wire continuous checks into CI so non-compliant code simply cannot merge.
Static scanning with Checkov and tfsec
Static scanners parse your Terraform without running it and flag insecure patterns: public S3 buckets, unencrypted volumes, security groups open to 0.0.0.0/0, missing logging, and hundreds more. They run in seconds, need no cloud credentials, and are the cheapest place to catch problems.
Take this deliberately insecure resource:
resource "aws_s3_bucket" "data" {
bucket = "devcraftly-app-data"
}
resource "aws_s3_bucket_public_access_block" "data" {
bucket = aws_s3_bucket.data.id
block_public_acls = false
block_public_policy = false
ignore_public_acls = false
restrict_public_buckets = false
}
Run Checkov over the directory:
checkov --directory . --framework terraform --quiet --compact
Output:
Check: CKV_AWS_53: "Ensure S3 bucket has block public ACLS enabled"
FAILED for resource: aws_s3_bucket_public_access_block.data
File: /main.tf:7-13
Check: CKV2_AWS_6: "Ensure that S3 bucket has a Public Access block"
FAILED for resource: aws_s3_bucket.data
Passed checks: 0, Failed checks: 2, Skipped checks: 0
tfsec (now merged into Trivy as trivy config) covers similar ground with a slightly different rule set. Running both gives broader coverage:
trivy config --severity HIGH,CRITICAL .
| Tool | Scope | Custom policies | Notes |
|---|---|---|---|
| Checkov | HCL + plan + Helm + K8s | Python or YAML | Largest rule set; graph-based checks |
| tfsec / Trivy | HCL + plan | Rego (Trivy) | Fast, good default AWS/Azure/GCP rules |
| Terrascan | HCL + plan | Rego | OPA-based, CIS benchmark packs |
Tip: Scan the plan as well as the source.
terraform plan -out tfplan && terraform show -json tfplan > plan.json && checkov -f plan.jsoncatches misconfigurations introduced by variable values and module composition that source-only scans miss.
Policy as code for organisational guardrails
Generic scanners enforce industry baselines; policy as code enforces your rules — “every resource must carry a cost-center tag”, “no instance type larger than m5.2xlarge outside prod”, “RDS must be Multi-AZ”. Open Policy Agent (OPA) with conftest, or HashiCorp Sentinel, evaluate these against the JSON plan.
A Rego policy rejecting untagged resources:
package main
required_tags := {"cost-center", "owner", "environment"}
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_instance"
provided := {k | resource.change.after.tags[k]}
missing := required_tags - provided
count(missing) > 0
msg := sprintf("%s is missing required tags: %v", [resource.address, missing])
}
terraform show -json tfplan > plan.json
conftest test plan.json --policy ./policies
Output:
FAIL - plan.json - main - aws_instance.app is missing required tags: {"owner"}
1 test, 0 passed, 0 warnings, 1 failure
The non-zero exit code fails the pipeline, so the change cannot proceed until the tag is added.
Tagging and encryption standards
Rather than relying only on after-the-fact scanning, encode standards directly so they are correct by construction. default_tags on the provider stamps every taggable resource automatically:
provider "aws" {
region = "eu-west-1"
default_tags {
tags = {
environment = var.environment
owner = "platform-team"
managed_by = "terraform"
}
}
}
For encryption, set it explicitly on each resource and let scanners verify it stays set:
resource "aws_s3_bucket_server_side_encryption_configuration" "data" {
bucket = aws_s3_bucket.data.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
kms_master_key_id = aws_kms_key.data.arn
}
bucket_key_enabled = true
}
}
resource "aws_kms_key" "data" {
description = "KMS key for app data bucket"
enable_key_rotation = true
deletion_window_in_days = 30
}
Combining defaults (so the right thing happens automatically) with scanning (so drift from the standard is caught) is far more robust than either alone.
Continuous checks in CI
Compliance only works if it runs on every change. Add scanning as a required status check so a failing scan blocks the merge:
# .github/workflows/compliance.yml (excerpt)
- run: terraform init -backend=false
- run: terraform validate
- run: checkov --directory . --framework terraform --soft-fail-on LOW
- run: |
terraform plan -out tfplan
terraform show -json tfplan > plan.json
conftest test plan.json --policy ./policies
Suppress individual findings inline only with a justification, so exceptions are reviewable in the diff:
resource "aws_security_group_rule" "office_ssh" {
# checkov:skip=CKV_AWS_24: SSH restricted to office CIDR, reviewed 2026-06-14
type = "ingress"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["203.0.113.0/24"]
security_group_id = aws_security_group.bastion.id
}
Warning: A blanket
--soft-failor a global skip file silently turns compliance into theatre. Fail hard on HIGH and CRITICAL, and require per-resource skips with a written reason for everything else.
All of this is identical under OpenTofu — Checkov, Trivy, and conftest read the same HCL and JSON plan format, so swap terraform for tofu and the pipeline is unchanged.
Best Practices
- Run at least two scanners (Checkov plus Trivy/tfsec) — their rule sets overlap but neither is complete.
- Scan the JSON plan, not just source, to catch misconfigurations that depend on variables and module inputs.
- Encode standards as
default_tagsand explicit encryption blocks so the secure path is the default path. - Add policy-as-code (OPA/conftest or Sentinel) for organisation-specific rules generic scanners cannot know.
- Make scans a required, hard-failing CI status check on HIGH/CRITICAL so non-compliant code cannot merge.
- Require inline, justified suppressions for any exception instead of global skip lists, so they show up in code review.
- Track the same checks against deployed infrastructure (Checkov can scan running state) to catch out-of-band drift.