Policy-as-code & scanning
tfsec, Checkov, OPA/Sentinel gates.
A building inspector turns up before anyone pours concrete and reads the drawings. A drawing can be flawless as a drawing. Every line meets, every dimension adds up, and the boiler room still has no fire door. terraform validate is the drafter's check: does this hold together on its own terms, are the arguments real, do the references point at something that exists. It runs after terraform init, talks to no cloud account, and has no opinion at all about safety. Policy-as-code is the inspector: is the thing you are about to build allowed. It means your organisation's rules written down as code a machine can check, not as a wiki page people skim once. Terraform will cheerfully build you a public S3 bucket (Simple Storage Service, Amazon's object storage), a firewall rule open to the whole internet, or a database with no encryption. All three are valid HCL (HashiCorp Configuration Language, the language Terraform files are written in), and validate waves every one of them through.
That gap matters because nobody has to break into a public bucket. Bucket names live in one worldwide namespace, so guessing them is a hobby with tooling behind it, and scanning services re-walk the entire public internet address space in hours rather than weeks. A bucket that goes public at 14:02 is in somebody's index before you finish lunch. Faster incident response does not close that window. Making the misconfiguration impossible to merge closes it. Two families of tool do that work, and they overlap: static scanners, which arrive with a large library of known-bad patterns already written, and policy engines, which run the rules you wrote yourself. Most teams end up running both, for different reasons.
Static Scanners: The Code Book Already Written
A static scanner is the national building code, printed, bound, and applied to your drawing for you. You write no rules to start with. Checkov, Trivy (which absorbed tfsec, the old Terraform-specific scanner) and KICS each ship with hundreds of checks covering the same tired failure modes: public exposure, missing encryption at rest, over-broad IAM (Identity and Access Management, the permission system that decides who can do what in AWS) grants, no access logging, default credentials left exactly where the vendor put them. They read your .tf files, match patterns, and print each finding against an identifier you can look up. They finish in seconds and need no cloud credentials, which is precisely what you want running on a pull request from a stranger.
$ checkov -d . --compact --quiet
By Prisma Cloud | version: 3.2.334terraform scan results:Passed checks: 47, Failed checks: 3, Skipped checks: 0Check: CKV_AWS_18: "Ensure the S3 bucket has access logging enabled"FAILED for resource: aws_s3_bucket.dataFile: /main.tf:12-18Check: CKV_AWS_145: "Ensure that S3 buckets are encrypted with KMS by default"FAILED for resource: aws_s3_bucket.dataFile: /main.tf:12-18Check: CKV_AWS_24: "Ensure no security groups allow ingress from 0.0.0.0:0 to port 22"FAILED for resource: aws_security_group.webFile: /network.tf:22-34
Two things in that output earn their keep. The check ID (CKV_AWS_18 here, AVD-AWS-0107 in Trivy's numbering) is the stable handle. It is what goes into a suppression, a baseline file or a ticket, and it survives tool upgrades far better than the wording does. The second is the exit code: 1 when anything failed, 0 when nothing did. That single integer is the entire mechanism by which a scanner becomes a gate. A scanner that prints findings and exits 0 is a newsletter. It also pays to know what you are actually enforcing. Every new S3 object has been encrypted at rest by default since January 2023, so CKV_AWS_145 is not shouting about plaintext data on disk. It is asking you to use KMS (Key Management Service, where you hold the key and control who may use it) instead of the free built-in one. That is a preference, and a defensible one, but treat it differently from a bucket the whole internet can read.
$ trivy config --severity HIGH,CRITICAL --exit-code 1 .
2026-07-21T09:41:12+01:00 INFO [misconfig] Misconfiguration scanning is enabled2026-07-21T09:41:13+01:00 INFO Detected config files num=2network.tf (terraform)Tests: 58 (SUCCESSES: 56, FAILURES: 2)Failures: 2 (UNKNOWN: 0, LOW: 0, MEDIUM: 0, HIGH: 1, CRITICAL: 1)AVD-AWS-0107 (CRITICAL): Security group rule allows ingress from public internet.════════════════════════════════════════════════════════════════Opening up ports to the public internet is generally to be avoided. You shouldrestrict access to IP addresses or ranges that explicitly require it.See https://avd.aquasec.com/misconfig/avd-aws-0107────────────────────────────────────────────────────────────────network.tf:22-34────────────────────────────────────────────────────────────────22 ┌ resource "aws_security_group" "web" {23 │ ingress {24 │ from_port = 2225 │ to_port = 2226 │ protocol = "tcp"27 │ cidr_blocks = ["0.0.0.0/0"]28 │ }..────────────────────────────────────────────────────────────────
tfsec is the name a lot of people still search for. Aqua Security folded its check library into Trivy and archived the standalone tool, so trivy config runs those same rules today. The identifier printed above is the AVD ID (Aqua Vulnerability Database, the public catalogue behind that avd.aquasec.com link), which tfsec already printed alongside its own naming: the old aws-ec2-no-public-ingress-sgr is AVD-AWS-0107. Existing tfsec:ignore comments are still honoured, so a repository full of them keeps behaving while you switch over. If a tutorial tells you to install tfsec, install Trivy. And look hard at that --exit-code 1 flag, because Trivy exits 0 by default even when it finds things. Leave it off and you have a green pipeline that enforces nothing at all.
Scan the Plan, Not Only the Files
Reading the .tf files is reading the recipe. Reading the plan is reading the shopping list the chef wrote after checking what was already in the pantry. A file pass sees literal values and little else. It does not know what an environment-specific variable file sets, what a module worked out, what a data source returned, or which resources this particular change actually touches. So a bucket whose ACL (access control list, the older per-bucket and per-object permission model in S3) comes from var.bucket_acl looks clean on the page and ships public. terraform show -json turns a saved plan into a machine-readable document listing every planned change, and Checkov, Conftest and Trivy can all read that document. Fix the three findings above, rerun until the file pass is quiet, then run the plan pass on the same branch.
$ terraform plan -var-file=prod.tfvars -out=tfplan$ terraform show -json tfplan | jq . > tfplan.json$ checkov -f tfplan.json --repo-root-for-plan-enrichment . --compact --quiet
By Prisma Cloud | version: 3.2.334terraform_plan scan results:Passed checks: 51, Failed checks: 2, Skipped checks: 0Check: CKV_AWS_20: "Ensure the S3 bucket does not allow READ permissions to everyone"FAILED for resource: aws_s3_bucket_acl.dataFile: /main.tf:21-24Check: CKV_AWS_260: "Ensure no security groups allow ingress from 0.0.0.0:0 to port 80"FAILED for resource: aws_security_group_rule.web_httpFile: /network.tf:41-48
Neither finding appeared in the file pass, and the reason is the same for both. On the page one reads acl = var.bucket_acl (since version 4 of the AWS provider the ACL lives in its own aws_s3_bucket_acl resource rather than on the bucket) and the other reads cidr_blocks = var.ingress_cidrs, whose default in variables.tf is a private range. Both real values come from prod.tfvars, and Checkov only auto-loads terraform.tfvars and .auto.tfvars files, exactly like Terraform does. Point it at the others with --var-file, or accept that the plan is the only place the truth shows up. The jq in the middle of that pipeline is doing real work too: jq is a command-line tool for reshaping JSON (JavaScript Object Notation, a plain-text data format), and terraform show -json emits the whole plan as one enormous single line. Pretty-printing gives the scanner line numbers to report against. The repo-root flag tells Checkov where the source lives, so a finding points back at main.tf line 21 instead of a character offset inside a blob. Now look at the shape of what the policy tools actually see.
$ jq '.resource_changes[]| select(.type == "aws_security_group_rule")| {address,actions: .change.actions,cidr: .change.after.cidr_blocks,unknown: .change.after_unknown.cidr_blocks}' tfplan.json
{"address": "aws_security_group_rule.web_http","actions": ["create"],"cidr": ["0.0.0.0/0"],"unknown": null}{"address": "aws_security_group_rule.admin_ssh","actions": ["create"],"cidr": null,"unknown": true}
Two fields in that document pay for themselves. The actions list says what is happening to this resource: create, update, delete, or a delete followed by a create when Terraform has to replace something. Filter on it, or your rule will fire on a resource you are in the middle of deleting. The other is after_unknown, which marks every attribute whose value Terraform cannot know until apply time. Here, admin_ssh takes its range from the CIDR block (Classless Inter-Domain Routing, the 10.0.0.0/16 style notation for a range of IP addresses) of a VPC (virtual private cloud, your own walled-off network inside AWS) that this same plan is creating. The network does not exist yet, so the value is a blank space on the form: after_unknown says true and the matching field in after is null. Here is the sharp edge. A rule that asks whether cidr_blocks contains 0.0.0.0/0 does not fail on a blank. It goes undefined, undefined means the rule stays quiet, and quiet in pipeline terms means pass.
Policy Engines: Your Own House Rules
The built-in library knows the building code. It does not know that your company wants a cost-center tag on everything, allows three instance families and no others, and lets nobody outside the network team create a peering connection. Those are house rules, the way a lease adds conditions the law never mentioned, and you write them yourself. OPA (Open Policy Agent, a general-purpose engine that answers yes-or-no questions about JSON documents) is the usual pick. Its rule language is called Rego. A small companion tool called Conftest feeds documents to it and reports results in the shape a pipeline understands: messages on the way out, and a non-zero exit code when something was denied.
package mainimport rego.v1# 1. The obvious one: an ingress rule open to the whole internet.deny contains msg if {some rc in input.resource_changesrc.type == "aws_security_group_rule""create" in rc.change.actions # ignore rules being deletedrc.change.after.type == "ingress"some cidr in rc.change.after.cidr_blockscidr == "0.0.0.0/0"msg := sprintf("%s opens port %v to the whole internet",[rc.address, rc.change.after.from_port])}# 2. Fail closed: if the range is not known yet, we cannot prove it is safe.deny contains msg if {some rc in input.resource_changesrc.type == "aws_security_group_rule""create" in rc.change.actionsrc.change.after_unknown.cidr_blocksmsg := sprintf("%s: cidr_blocks unknown at plan time, cannot prove it is not 0.0.0.0/0",[rc.address])}# 3. A warning, not a block. Passes unless you run with --fail-on-warn.warn contains msg if {some rc in input.resource_changesrc.mode == "managed""create" in rc.change.actionsnot rc.change.after.tags["cost-center"]msg := sprintf("%s has no cost-center tag", [rc.address])}
$ conftest test --policy policy/ tfplan.json
FAIL - tfplan.json - main - aws_security_group_rule.web_http opens port 80 to the whole internetFAIL - tfplan.json - main - aws_security_group_rule.admin_ssh: cidr_blocks unknown at plan time, cannot prove it is not 0.0.0.0/0WARN - tfplan.json - main - aws_s3_bucket_acl.data has no cost-center tagWARN - tfplan.json - main - aws_security_group.web has no cost-center tagWARN - tfplan.json - main - aws_security_group_rule.admin_ssh has no cost-center tagWARN - tfplan.json - main - aws_security_group_rule.web_http has no cost-center tag6 tests, 0 passed, 4 warnings, 2 failures, 0 exceptions
deny fails the run. warn prints and passes unless you add --fail-on-warn, which is how you land a new rule without breaking everybody on a Tuesday: ship it as warn, watch the pull requests for two weeks, promote it to deny. Look at what those two weeks would have told you here. Four warnings, and only one is real. aws_security_group.web genuinely accepts tags and genuinely has none. The other three resource types have no tags argument at all, so the rule is scolding them for something they cannot do. Finding that out from a log line beats finding it out from a blocked release. Two operational details while you are in here. Conftest reads the package named main by default, so if you organise policies into packages such as terraform.network you have to pass the matching --namespace or use --all-namespaces. And the temporary file on disk is optional: terraform show -json tfplan piped into conftest test --parser json - does the same job, with the parser named explicitly because there is no filename left to guess from.
Policies are code, and a policy with a typo in the resource type passes everything, forever, in total silence. There is a live example of this waiting for you: the AWS provider now steers people toward aws_vpc_security_group_ingress_rule instead of aws_security_group_rule, and the new resource stores its range in cidr_ipv4 as a plain string rather than cidr_blocks as a list. A rule that only names the old type keeps returning a clean bill of health for every wide-open rule written the new way. So test the policy the way you test anything else, with a small fixture standing in for the plan. This is the step almost everyone skips, and it is the difference between a gate and a gate-shaped object.
package mainimport rego.v1open_ssh := {"resource_changes": [{"address": "aws_security_group_rule.admin_ssh","type": "aws_security_group_rule","mode": "managed","change": {"actions": ["create"],"after": {"type": "ingress","from_port": 22,"cidr_blocks": ["0.0.0.0/0"],},},}]}test_open_ssh_is_denied if {count(deny) == 1 with input as open_ssh}
$ conftest verify --policy policy/
PASS - data.main.test_open_ssh_is_denied1 test, 1 passed, 0 warnings, 0 failures, 0 exceptions
Sentinel and the Three Enforcement Levels
If you run HCP Terraform (HashiCorp Cloud Platform Terraform, the hosted service previously called Terraform Cloud) or Terraform Enterprise, there is a second policy language built into the platform: Sentinel. Same idea, different plumbing. The policy runs on HashiCorp's side, between plan and apply, with access to the plan data plus the configuration and the run's own metadata such as the cost estimate. What Sentinel adds is a formal enforcement level per policy, which answers a question every team hits at the worst possible moment: who is allowed to override this, and does the override leave a record.
policy "require-cost-center" {source = "./require-cost-center.sentinel"enforcement_level = "soft-mandatory" # advisory | soft-mandatory | hard-mandatory}
import "tfplan/v2" as tfplanrequired = ["cost-center", "owner"]untagged = filter tfplan.resource_changes as _, rc {rc.mode is "managed" andrc.change.actions contains "create" andany required as tag {tag not in keys(rc.change.after.tags else {})}}main = rule { length(untagged) is 0 }
advisory is a sign on the door. It records the violation and the run carries on. soft-mandatory is a locked door where the duty manager holds a key and every use of that key goes in a book: the apply is blocked, but a user with the override permission can push it through, and the override is recorded against their name. hard-mandatory is a wall. Nobody overrides it, and the only ways past are changing the code or changing the policy itself. Choose deliberately. A rule written as hard-mandatory on day one has a habit of becoming the reason a 3 a.m. incident fix cannot ship. HCP Terraform also runs OPA policy sets natively, with advisory and mandatory levels, so the Rego you already wrote travels with you rather than being rewritten in a second language.
Landing This in a Repo That Is Already Failing
Switch a scanner on in an established repository and you get a wall of red, and that wall is exactly why the check gets commented out in week two. The way through is a baseline, the same move as photographing every dent on a hire car before you drive off. Record today's findings as accepted, then fail only on findings that are new. Checkov writes the file for you, and you commit it like any other file so the accepted list stays reviewable and shows up in diffs when it changes.
$ checkov -d . --create-baseline$ git add .checkov.baseline$ git commit -m "policy: baseline existing Terraform findings"$ checkov -d . --baseline .checkov.baseline --compact --quiet
By Prisma Cloud | version: 3.2.334terraform scan results:Passed checks: 47, Failed checks: 0, Skipped checks: 0
directory:- .framework:- terraformquiet: truecompact: truebaseline: .checkov.baselinesoft-fail: true # the long tail does not break the build ...hard-fail-on: # ... but these three never merge, baseline or not- CKV_AWS_20 # S3 bucket readable by everyone- CKV_AWS_24 # security group ingress from 0.0.0.0/0 to port 22- CKV_AWS_16 # RDS storage not encrypted at rest
Two traps live in that config file. Open-source Checkov carries no severity metadata for its checks, so a threshold like hard-fail-on HIGH quietly matches nothing unless you have connected the tool to the paid Prisma Cloud platform with a key. List check IDs instead and you keep control locally. Trivy does ship severities in the free version, which is why the severity threshold in the earlier Trivy command works standalone. The soft-fail and hard-fail-on pairing is the combination worth copying: the build stays green for the long tail of legacy findings, while a short hand-picked list of things you would never ship stops the merge outright, baseline or no baseline. Both tools also emit SARIF (Static Analysis Results Interchange Format, the standard JSON shape that code-hosting platforms read), so one output-format flag puts findings inline on the diff instead of burying them in a build log nobody opens. Trivy will read the plan JSON as well, with trivy config tfplan.json, if you would rather run one tool at both stages.
Some findings really are exceptions. A public bucket that genuinely is a static marketing site, say. Record that next to the code, with a reason and a ticket number attached, rather than as a global mute in a config file nobody will ever open again.
resource "aws_s3_bucket" "marketing_site" {#checkov:skip=CKV_AWS_20:Static marketing site, public by design. Approved in SEC-4471.bucket = "acme-marketing-site"}
misconfigurations:- id: AVD-AWS-0107paths:- "network.tf"statement: "Public load balancer must accept internet traffic. Reviewed in SEC-4471."expired-at: 2026-09-30
Expiry is the part people leave out, which is why Trivy's ignore file has a field for it. A suppression with no end date is a hole nobody ever revisits, like a temporary parking permit printed without a date. When 30 September comes round the finding returns and a human has to look at it again. Skips also pile up faster than anyone believes, so audit them on a schedule: grep -rn 'checkov:skip' . takes two seconds and tells you how much of your policy is currently switched off. Watch the other direction as well. A pull request that adds a suppression is a change to your security posture and deserves the same reading as a firewall change, because deleting a check is how somebody with commit access makes room for a back door.
soft-mandatory and one set to hard-mandatory?checkov -d .) reports a clean S3 bucket, but the plan scan on the same branch flags aws_s3_bucket_acl.data as world-readable. The ACL (access control list) is set from var.bucket_acl, whose default in variables.tf is private, while prod.tfvars sets it to public-read. Why the discrepancy?Prove the Gate Actually Fires
A gate you have never watched fail is decoration. Test it the way you test a smoke alarm, by holding something under it: branch, add a resource that must be caught, run the check, confirm the pipeline goes red. Give the canary its own hard-coded security group ID so it does not tangle with the rules in your real configuration, and note that it never gets applied. It exists purely to be scanned.
$ git switch -c test/policy-gate$ cat >> canary.tf <<'EOF'resource "aws_security_group_rule" "gate_canary" {description = "Deliberately wide open. If this merges, the gate is broken."type = "ingress"from_port = 22to_port = 22protocol = "tcp"cidr_blocks = ["0.0.0.0/0"]security_group_id = "sg-0123456789abcdef0"}EOF$ checkov -f canary.tf --compact --quiet ; echo "exit=$?"
By Prisma Cloud | version: 3.2.334terraform scan results:Passed checks: 3, Failed checks: 1, Skipped checks: 0Check: CKV_AWS_24: "Ensure no security groups allow ingress from 0.0.0.0:0 to port 22"FAILED for resource: aws_security_group_rule.gate_canaryFile: /canary.tf:1-9exit=1
Then delete the branch, but keep that canary somewhere as a fixture, sitting next to your Rego tests. Eight months from now somebody upgrades Checkov, swaps the CI (continuous integration, the service that runs your checks automatically on every push) runner image, or tidies up the workflow file, and re-running the canary tells you in thirty seconds whether the gate still closes. In a build log, a scanner that found nothing and a scanner that never ran look exactly the same.
Try this
Run checkov -d . --compact --quiet on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.
Takeaway
The trap worth remembering here: the plan file holds your secrets in plaintext. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.