Skip to content
Infrastructure as Code iac security 4 min read

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 .
ToolScopeCustom policiesNotes
CheckovHCL + plan + Helm + K8sPython or YAMLLargest rule set; graph-based checks
tfsec / TrivyHCL + planRego (Trivy)Fast, good default AWS/Azure/GCP rules
TerrascanHCL + planRegoOPA-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.json catches 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-fail or 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_tags and 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.
Last updated June 14, 2026
Was this helpful?