In a nutshell
Picture the metal detector at an airport gate. Everyone walks through the same arch before they board — not because most travelers are dangerous, but because catching the one blade before it reaches the cabin is a thousand times cheaper than dealing with it at 30,000 feet. IaC scanning is that metal detector, standing at the gate of your pipeline. Every Terraform change walks through it on the way to apply, and it beeps the moment it sees a public bucket, a security group open to the whole internet, an unencrypted disk, or a password pasted into a .tfvars file — while the change is still just text in a pull request, before it becomes a real resource anyone can attack.
The guide below builds that checkpoint out of two scanners — Checkov and Trivy — because one detector with one vendor’s rules has blind spots the way one guard at one door does. Run two overlapping tools and the math changes: when both beep you are confident, and when only one beeps you just caught something the other missed.
You will also meet tfsec, a name you will still see in older pipelines and blog posts. tfsec was the original Terraform-only detector; Aqua Security has since folded its engine and rules into Trivy. So “should I use tfsec or Trivy?” now has a simple answer — use Trivy, because it is tfsec, plus config, secret, and vulnerability scanning in one binary.
The goal is never to run a tool and collect a green checkmark. It is to fail the build on the handful of things that actually become incidents, wave the rest through with a reason on record, and keep the red X trustworthy so nobody learns to ignore it.
Level: Intermediate · Time: ~31 min
A pull request renders both raw HCL and the resolved plan JSON, Checkov and Trivy scan them in parallel (Trivy now carrying tfsec’s engine), their findings normalize to a single SARIF stream that annotates the diff, and one severity gate blocks the merge on HIGH/CRITICAL — or any detected secret — before a line ever reaches the cloud.
A single IaC scanner is a checkbox; a gate is a contract. The difference shows up the first time someone ships a public bucket through a wrapper module your one scanner could not parse, or the tenth time a developer rubber-stamps “12 medium findings” because the gate has cried wolf since onboarding. The job is not to run a tool — it is to catch the misconfigurations that become incidents, suppress noise with an audit trail, and fail the build on the right things so people keep trusting the red X.
This guide assembles that gate from two complementary tools. Checkov (Prisma Cloud / Bridgecrew) is graph-aware and extensible — you write policy in Python or YAML against the parsed resource graph. Trivy (Aqua) adds a second, independently maintained ruleset plus secret detection in the same binary you already use for images. Running both and normalizing their output gives defense in depth without betting your posture on one vendor’s coverage.
1. The misconfiguration threat model and where scanning fits
Static IaC analysis catches one class of defect: a resource declared insecurely by construction. Public storage, unencrypted volumes, security groups open to 0.0.0.0/0, IAM policies with Action: "*", logging disabled. These decisions are baked into the template, visible before provisioning, and cheap to catch.
Be honest about what it does not catch, so you do not oversell it:
| Catches | Misses |
|---|---|
| Insecure resource attributes in the declared config | Runtime drift after a console change |
| Hardcoded secrets and high-entropy strings in source | Logic spanning data sources resolved only at apply |
| Missing encryption, logging, public-access flags | Identity reachability (“can this principal actually reach that bucket?”) |
| Known-bad patterns from a curated policy library | Business intent (“this bucket is meant to be public”) |
Scanning is the cheapest layer of a stack that also includes plan-JSON policy-as-code (OPA/Conftest), admission control, and runtime CSPM. It runs earliest and fails fastest: put it on the pull request, keep it under a minute, reserve slower checks for nightly. But it evaluates the template, not the world — a green scan proves the declared config is clean, not that the deployed resource matches. Necessary, never sufficient.
2. Running Checkov across Terraform, plan JSON, Bicep, and CloudFormation
Checkov auto-detects frameworks under a directory, so the baseline invocation is one command. Pin it in CI — a floating latest breaks builds non-deterministically when it adds a check.
pip install checkov==3.2.450
# Scan everything Checkov can parse under the repo.
checkov --directory . --compact --quiet
# Or scope to one framework / one file to avoid re-parsing the whole tree.
checkov -d ./terraform --framework terraform
checkov -f ./terraform/s3.tf
--quiet drops passed checks; --compact trims the code block from each finding.
Scan the plan, not just the HCL. Raw HCL hides anything resolved at plan time — variable defaults, default_tags, computed names. Render the plan to JSON and feed that so the gate sees what actually deploys:
terraform init -backend=false
terraform plan -out=tfplan.binary
terraform show -json tfplan.binary > tfplan.json
# Checkov understands the plan representation directly.
checkov -f tfplan.json --framework terraform_plan --compact
Scanning both HCL (fast, no credentials) and plan JSON (accurate through modules) is the belt-and-suspenders default for any non-trivial estate.
Checkov also parses Bicep and CloudFormation natively:
# Bicep — Checkov compiles via the bicep CLI, so it must be on PATH.
checkov -d ./bicep --framework bicep --compact
# CloudFormation (JSON or YAML); AWS SAM is recognized via its transform.
checkov -d ./cfn --framework cloudformation --compact
checkov -f ./sam/template.yaml --framework cloudformation
Bicep support depends on the
bicepCLI being onPATH— Checkov compiles to ARM JSON first. If the CI image lacks it, Bicep files are silently skipped, so assert the framework ran (see Verify). Usecheckov --listto see every available check.
3. Authoring custom Checkov policies in Python and YAML
The built-in library is broad but generic. Organizational rules — “every resource carries a cost_center tag,” “no S3 bucket outside an approved region” — you write yourself. Use YAML for attribute and connection-state checks; drop to Python for real logic.
A YAML policy lives in a --external-checks-dir. This one requires S3 buckets to declare a cost_center tag:
# policies/yaml/s3_cost_center_tag.yaml
metadata:
id: "CKV_ORG_S3_1"
name: "S3 buckets must carry a cost_center tag"
category: "CONVENTION"
severity: "MEDIUM"
definition:
cond_type: "attribute"
resource_types:
- "aws_s3_bucket"
attribute: "tags.cost_center"
operator: "exists"
A Python check has the full graph available. This one denies any IAM policy granting Action: "*" on Resource: "*":
# policies/python/IAMNoStarStar.py
from checkov.terraform.checks.resource.base_resource_check import BaseResourceCheck
from checkov.common.models.enums import CheckCategories, CheckResult
class IAMNoStarStar(BaseResourceCheck):
def __init__(self):
super().__init__(
name="IAM policy must not allow Action:* on Resource:*",
id="CKV_ORG_IAM_1",
categories=[CheckCategories.IAM],
supported_resources=["aws_iam_policy"],
)
def scan_resource_conf(self, conf):
policy = conf.get("policy")
if not policy or not isinstance(policy[0], str):
# Computed/HCL-expression policy: cannot evaluate statically.
return CheckResult.UNKNOWN
body = policy[0]
if '"Action": "*"' in body and '"Resource": "*"' in body:
return CheckResult.FAILED
return CheckResult.PASSED
check = IAMNoStarStar()
Run custom policies layered on top of the built-ins with --external-checks-dir; add --check CKV_ORG_IAM_1,... to scope a run to just your org checks:
checkov -d ./terraform --external-checks-dir ./policies/python --compact
Returning
CheckResult.UNKNOWNon computed values separates a useful policy from a flaky one — a check that hard-fails when it cannot read an attribute trains people to suppress it. For security-critical attributes, fail closed on the plan JSON where the value is resolved, not on raw HCL.
Test custom policies before they gate anyone: Checkov ships a pytest harness, so point it at example resources that should pass and fail (pytest policies/python/tests/ -q).
4. Trivy: config scanning, secret detection, and built-in misconfig checks
Trivy is the second opinion. Its config (alias misconfig) subcommand scans Terraform, CloudFormation, Helm, Dockerfiles, and Kubernetes manifests with its own ruleset, detecting secrets in the same pass.
# Misconfiguration scan over a directory tree.
trivy config ./terraform
# Scan a rendered Terraform plan directly.
trivy config --tf-vars prod.tfvars ./terraform
trivy config tfplan.json
Secret scanning is on by default for trivy fs. This is what catches an access key pasted into a .tfvars or a locals.tf:
# Vulnerabilities + secrets + misconfigurations; drop scanners for a faster pre-commit pass.
trivy fs --scanners vuln,secret,misconfig .
Constrain Trivy the same way as Checkov — by severity and exit code (Section 7). Its misconfig rules overlap Checkov’s but are not identical, and that overlap is the point: when both flag the same volume you have high confidence; when only one does, you caught what the other missed. Deduplicate at the reporting layer, not by dropping a tool.
Trivy pulls its policy bundle from a registry on first run and caches it. In an egress-restricted runner, pre-pull the bundle, or the misconfig scan silently runs zero policies and reports a meaningless clean pass.
5. Managing false positives with inline skips and centralized baselines
Every estate generates findings you will not fix today: a deliberately public docs bucket, a third-party module you cannot edit. Suppression must be attributable and reviewable, never a blanket --skip-check buried in a CI script.
Inline, for Checkov, suppress one check on one resource with a reason that lives next to the code it excuses:
resource "aws_s3_bucket" "public_docs" {
bucket = "acme-public-docs"
# checkov:skip=CKV_AWS_20:Intentionally public; serves the docs site. JIRA SEC-1421
}
Inline, for Trivy, use a trailing ignore comment on the offending line or block:
#trivy:ignore:AVD-AWS-0089
resource "aws_s3_bucket" "logs" {
bucket = "acme-access-logs"
}
For suppressions that should not live in application code — third-party modules, time-boxed exceptions — use centralized baselines. Trivy reads a .trivyignore.yaml:
# .trivyignore.yaml
misconfigurations:
- id: AVD-AWS-0086
paths:
- "modules/legacy-vpc/*"
statement: "Vendored module; upstream PR open."
expired_at: 2026-09-30
Checkov reads a .checkov.yaml at the repo root that centralizes skips, frameworks, and excludes (all the CLI flags above, plus a project-wide skip-check list):
# .checkov.yaml
skip-check:
- CKV_AWS_18 # access logging handled centrally by org SCP
framework: [terraform, terraform_plan]
The governing rule: a suppression without a reason and an owner is debt you cannot find later. Inline skips force a :reason; baseline entries get a statement and an expired_at. Review them in the same PR as the code, and audit expirations so they actually expire.
6. Normalizing SARIF and surfacing findings in code review
Both tools emit SARIF, the format GitHub code-scanning ingests — the lingua franca that lets two scanners annotate the same diff without glue. Checkov takes --output sarif --output-file-path checkov.sarif and Trivy takes --format sarif --output trivy.sarif; in a GitHub workflow the job needs permissions: security-events: write, then three steps (after actions/checkout):
- name: Checkov
uses: bridgecrewio/checkov-action@v12
with:
directory: .
output_format: cli,sarif
output_file_path: console,checkov.sarif
soft_fail: true # do not fail here; gate on severity later
quiet: true
- name: Trivy config
uses: aquasecurity/trivy-action@0.28.0
with:
scan-type: config
scan-ref: ./terraform
format: sarif
output: trivy.sarif
exit-code: "0"
# One upload per tool; repeat for trivy.sarif / category: trivy.
- name: Upload Checkov SARIF
if: always() # upload even if a prior step failed
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: checkov.sarif
category: checkov
Two details are load-bearing. The category keys results in GitHub, so distinct categories stop Checkov and Trivy findings overwriting each other. The if: always() runs the upload even when a scan step fails the job — otherwise a red build shows no annotations explaining why. Both scan steps soft_fail/exit-code: 0 so findings always upload; the gate lives in a later step (Section 7).
7. Severity thresholds, exit codes, and failing the build appropriately
A gate that fails on everything gets disabled. Fail on what is exploitable and actionable; report the rest. Both tools express this through severity selection plus exit codes.
For Checkov, --soft-fail always exits 0 (report-only), while --soft-fail-on and --hard-fail-on split by severity:
# Report everything, but only HIGH and CRITICAL fail the build.
checkov -d . --soft-fail-on LOW,MEDIUM --hard-fail-on HIGH,CRITICAL --compact
echo "checkov exit: $?"
Severity gating requires Checkov to know each check’s severity; full metadata ships with the Prisma/Bridgecrew integration, while OSS severities cover the curated set. Verify your critical checks carry the severity you gate on rather than assuming it.
For Trivy, --severity filters what is reported and --exit-code 1 makes any reported finding non-zero. A sane policy:
| Severity | PR behavior | Default-branch / nightly |
|---|---|---|
| CRITICAL | Block | Block |
| HIGH | Block | Block |
| MEDIUM | Report (annotate) | Report + ticket |
| LOW / INFO | Report | Report |
Secrets are the exception: a detected live secret is always blocking, at any severity, on any branch. There is no acceptable medium-severity hardcoded credential.
Wire these two gating commands as their own CI run: step so reporting and gating stay decoupled — the SARIF uploads ran earlier with soft-fail, and this is the only step allowed to fail the job.
8. Tracking posture over time and avoiding alert fatigue
A gate stops new badness; it does not tell you whether posture is improving. For that you need a trend, plus a strategy for the pre-existing backlog so the gate does not block PRs on debt nobody introduced.
Baseline existing findings so only new ones gate. Checkov snapshots current findings; later runs compare against the baseline and fail only on regressions:
checkov -d . --create-baseline && git add .checkov.baseline
# Future runs fail only on findings NOT in the baseline.
checkov -d . --baseline .checkov.baseline --hard-fail-on HIGH,CRITICAL
This makes adoption survivable on a brownfield estate: strict for anything new, lenient for the documented backlog, with the baseline a reviewable artifact you burn down. Regenerate it deliberately — an auto-refreshed baseline silently accepts whatever regressed.
Emit machine-readable output to a dashboard so you watch the trend instead of logs. Both tools support --format json; ship the summary per run:
checkov -d . --output json --output-file-path checkov.json
jq '.summary' checkov.json # -> { "passed": N, "failed": M, "skipped": K, ... }
Push failed, skipped, and severity-bucketed counts to a time-series DB or Grafana panel. The metrics that matter: HIGH/CRITICAL count over time (should trend down), active suppression count (should not silently grow), and mean age of an open finding. The leading indicator of a dying gate is suppressions rising against flat findings — people silencing, not fixing — so alert on that ratio, not raw counts.
Verify
Prove each layer works before you trust it. A clean run is not coverage — assert it.
# 1. Both tools present and pinned.
checkov --version && trivy --version
# 2. Every expected framework actually parsed (a missing line = silent zero coverage).
checkov -d . --compact | grep -iE "terraform|bicep|cloudformation"
# 3. A deny fires: plant a public bucket, confirm a FAILED line and non-zero exit.
checkov -f ./terraform/bad_example.tf --hard-fail-on HIGH,CRITICAL; echo "exit: $?"
# 4. Secret detection fires: drop a fake AWS key in a tracked file, confirm non-zero.
trivy fs --scanners secret --exit-code 1 .; echo "exit: $?"
# 5. A suppression works: add an inline checkov:skip with a reason, confirm it passes.
# 6. SARIF exists and the PR shows annotations from BOTH categories.
test -s checkov.sarif && test -s trivy.sarif && echo "SARIF OK"
# 7. The baseline gates only on NEW findings: re-run with no new bad config, expect exit 0.
checkov -d . --baseline .checkov.baseline; echo "exit: $?"
The decisive test is #4. If a planted credential does not break the build, every other layer is theater — a scanner that misses secrets misses the thing most likely to cause a breach.
Checklist
Pitfalls and next steps
Two failure modes quietly hollow out an IaC gate. The first is silent zero coverage: Checkov skips Bicep without the bicep CLI, Trivy runs zero rules when it cannot pull its bundle, and the build goes green — certifying safety that was never checked. The second is suppression rot: skips without a :reason, owner, and expiry accumulate into a list nobody audits while coverage erodes one exception at a time. Both defenses are in the checklist: assert every framework parsed, and make every suppression attributable and time-boxed.
From here, the high-value extensions are: feeding the same plan JSON to OPA/Conftest for logic awkward as attribute checks (cross-resource invariants, Infracost cost ceilings); packaging custom Checkov policies as a versioned, separately tested artifact rather than a folder people copy; and extending Trivy from IaC into the image and SBOM scanning it already does, so one binary and one SARIF pipeline cover config, dependencies, and secrets end to end.
Going deeper
The eight sections above are the working blueprint. This section is the layer underneath — what each scanner actually is, why plan-JSON scanning sees more than HCL, how the gating and suppression machinery behaves at the edges, and where the gate sits in the wider policy stack. Read it once you have a pipeline running and want to reason about it rather than copy it.
The three scanners, and why one of them is now two
“Checkov or tfsec or Trivy?” is the question every team asks first, and the honest answer today is that there are really two tools with one shared lineage. tfsec is no longer a separate choice you make — it is inside Trivy.
| Tool | Origin | Engine | Scans | Custom policy | Status |
|---|---|---|---|---|---|
| Checkov | Bridgecrew → Prisma Cloud (Palo Alto) | Graph of parsed resources | Terraform, plan JSON, CloudFormation, Bicep, ARM, Kubernetes, Helm, Dockerfile, serverless, GitHub Actions, secrets | Python (BaseResourceCheck) + YAML |
Actively developed |
| tfsec | Aqua Security | Rego over an abstracted resource model | Terraform (HCL) only | Custom Rego / JSON checks | Deprecated — folded into Trivy |
| Trivy | Aqua Security | tfsec’s engine (now trivy-checks, Rego/OPA) + more |
Terraform, plan JSON, CloudFormation, Helm, Kubernetes, Dockerfile — plus vulns, secrets, licenses, SBOM | Rego (trivy-checks) |
Actively developed |
Checkov is graph-aware policy-as-code. It does not just pattern-match a file line by line — it parses your configuration into a graph of resources and their relationships, so a policy can ask connection-state questions: “is this security group attached to an instance that is public?” You get a large built-in library keyed by CKV_* IDs, and you extend it in two registers — declarative YAML for attribute and connection checks, imperative Python when you need real logic (parse an IAM document, walk a list, compare two resources). Gating is severity-driven (--soft-fail-on / --hard-fail-on) and brownfield adoption is survivable because of --baseline. That combination — graph model, dual-language custom policies, severity gating, baselines — is why Checkov tends to be the primary engine in a two-tool gate.
tfsec is history you should recognise, not deploy. tfsec was the beloved, fast, Terraform-only scanner. Aqua Security, which maintained it, announced in 2023 that it would be consolidated into Trivy; its detection engine became the defsec library, later renamed trivy-checks, and its rules now ship inside Trivy. The tfsec CLI still exists in maintenance mode, but no new checks land there — they land in Trivy. The practical fallout you will actually notice: the AVD-AWS-XXXX rule IDs in Trivy’s output (and in the #trivy:ignore:AVD-AWS-0089 suppressions in Section 5) are tfsec’s rules under their canonical “Aqua Vulnerability Database” IDs. If you inherit a repo full of tfsec:ignore:aws-s3-... comments, those old string IDs still resolve, but the migration target is trivy config and #trivy:ignore:AVD-*.
Trivy is three scanners in one binary. trivy config (the misconfiguration scanner; misconfig is its name in --scanners) carries tfsec’s engine. --scanners secret finds high-entropy strings and known credential patterns. --scanners vuln reads lockfiles and SBOMs for known CVEs. Because it is the same binary and same SARIF output your image pipeline already uses, adopting it for IaC is nearly free — and its ruleset is maintained by a different team than Checkov’s, which is exactly why running both is defense in depth rather than redundancy.
Scan the source, then scan the plan
Section 2 said to scan both raw HCL and rendered plan JSON. Here is why the second one exists, because it is the single most common gap in a real gate.
Raw HCL is what you wrote. Plan JSON is what Terraform decided to build after it resolved variables, expanded count/for_each, applied provider default_tags, and inlined every module. A scanner reading raw HCL is reading a template with holes in it; a scanner reading terraform show -json tfplan.binary is reading the filled-in result.
| The scanner sees… | Raw HCL (checkov -d .) |
Plan JSON (terraform show -json) |
|---|---|---|
| Literal attributes | Yes | Yes |
| Variable values / defaults | Only literal defaults; a -var-file override is invisible |
Resolved — the value that actually deploys |
for_each / count expansion |
One un-expanded template block | Every real instance |
| Module internals | Only local modules, or with --download-external-modules |
Resolved and inlined regardless of source |
Provider default_tags |
Invisible | Applied |
| Computed / known-after-apply | Unknown | Still unknown |
Needs terraform init / credentials |
No | Yes (init; a real plan may need provider auth) |
| Speed | Fast, runs anywhere | Slower, needs a working plan |
The classic miss: a bucket ACL is var.acl, whose default is "private", but prod.tfvars overrides it to "public-read". An HCL scan reads the safe default and passes; the plan scan resolves the prod value and fails. Wrapper modules are the other classic — your repo contains a module "bucket" call, and the insecure attribute lives inside the module, invisible to an HCL scan that never descends into a remote module (unless you pass --download-external-modules true). Plan JSON inlines it, so the gate finally sees it.
The tradeoffs are real, which is why you run both rather than only the plan. Plan JSON needs terraform init and often provider credentials, so it is slower and can’t always run on a fork PR. Values that are genuinely known after apply stay unknown even in the plan — this is where Section 3’s CheckResult.UNKNOWN discipline matters. And one sharp edge people miss: plan JSON contains resolved secret values. A db_password sourced from a variable is right there in tfplan.json as plaintext. Treat that file as sensitive — never publish it as a public CI artifact, and delete it after the scan.
One finding format: SARIF and PR annotations
SARIF (Static Analysis Results Interchange Format) is an OASIS-standard JSON schema for static-analysis results. It matters here for one reason: it lets two different scanners write results in one shape that one code-review surface can render. GitHub code scanning ingests it via github/codeql-action/upload-sarif, turns each result into an inline annotation on the PR diff, and tracks it in the Security tab.
Three things in Section 6’s YAML are load-bearing, and each maps to a failure you will hit if you skip it:
categorynamespaces the results. Upload two SARIF files under the same category and the second overwrites the first — you lose one tool’s findings silently. Distinctcategory: checkov/category: trivykeep both visible.if: always()forces the upload step to run even after an earlier step failed the job. Without it, the first red build shows a failure with no annotations explaining why — the worst possible developer experience.permissions: security-events: writeis what lets the workflow write to the code-scanning API at all. Miss it and the upload 403s.
The deeper pattern is decoupling reporting from gating. The scan steps run report-only (soft_fail: true / exit-code: "0") so their SARIF always uploads and annotates; a single later run: step is the only thing allowed to fail the job. This is why a developer always sees what is wrong (annotations) even on a build that is red for a different reason. GitLab and Azure DevOps consume SARIF too (or their own report formats), so the pattern travels. If you already run a keyless plan/apply pipeline, this bolts straight onto it — see the OIDC plan/PR automation lesson for the surrounding workflow.
Gating: exit codes, severity, and suppressions that survive an audit
A gate is ultimately an exit code. Everything else is how you decide which findings produce a non-zero one. The behaviours are worth memorising because “the build passed” and “the build was clean” are not the same sentence.
| Command | Exits 0 when… | Non-zero when… |
|---|---|---|
checkov -d . |
all checks pass or are skipped | any check FAILED |
checkov -d . --soft-fail |
always (report-only) | never |
checkov --soft-fail-on LOW,MEDIUM --hard-fail-on HIGH,CRITICAL |
only ≤ MEDIUM failed | a HIGH/CRITICAL failed |
trivy config . |
always (reports, no gate) | — |
trivy config --exit-code 1 --severity HIGH,CRITICAL . |
no HIGH/CRITICAL found | a HIGH/CRITICAL found |
Suppressions are where gates rot, so the mechanism you choose is a governance decision, not a syntax one. The rule from Section 5 restated: every suppression needs a reason and an owner, and time-boxed ones need an expiry.
| Mechanism | Checkov | Trivy | Lives in | Auditable? |
|---|---|---|---|---|
| Inline skip | #checkov:skip=CKV_AWS_20:reason |
#trivy:ignore:AVD-AWS-0089 |
Next to the resource | Yes — Checkov requires the :reason |
| Central ignore file | .checkov.yaml skip-check: |
.trivyignore / .trivyignore.yaml |
Repo root | Yes, if you add statement + expired_at |
| Baseline | --create-baseline / --baseline |
generated ignore list via --ignorefile |
Committed artifact | Yes — a reviewable diff |
| Blanket CLI flag | --skip-check CKV_... in a script |
--skip-dirs, hidden flags |
CI YAML | No — this is how coverage disappears |
The one to ban is the last row. A --skip-check buried in a pipeline script is invisible to the person reading the Terraform, unattributable in a review, and never expires. Prefer the inline skip precisely because Checkov forces a reason string — the friction is the feature. Baselines are for the debt you are choosing to defer wholesale; inline skips are for the one deliberate exception.
Where the gate lives: pre-commit, CI, and admission-time
Static scanning is one control at one point. The same scanner can run at three very different points, and they are complements, not substitutes.
| Layer | Runs when | Speed | Authority | Bypassable? | Catches |
|---|---|---|---|---|---|
| pre-commit | git commit, on the laptop |
Fastest | None (advisory) | Yes (git commit --no-verify) |
Typos before they leave the machine |
| CI PR gate | On the pull request, server-side | Seconds–minute | Blocks merge | No | Everything, authoritatively |
| Plan / admission-time | Before apply, or at k8s admission |
Slower | Blocks the deploy | No | What bypassed CI; cross-resource logic |
pre-commit is a courtesy, not a control. A pre-commit hook gives a developer sub-second feedback so they fix the public bucket before they even push — but anyone can --no-verify past it, and forks don’t run it. Configure it for speed and kindness, never rely on it for safety:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/bridgecrewio/checkov
rev: 3.2.450
hooks:
- id: checkov
args: [--quiet, --compact, --framework, terraform]
- repo: local
hooks:
- id: trivy-config
name: trivy config
entry: trivy config --exit-code 1 --severity HIGH,CRITICAL .
language: system
pass_filenames: false
files: \.tf$
CI is the real gate. It runs server-side on the PR, it is a required status check that blocks merge, and it produces the SARIF audit trail. This is the layer Sections 6 and 7 build, and it is the one that actually protects the branch.
Admission-time is the last line, and a different kind of check. Attribute scanning answers “is this resource declared safely?” It cannot easily answer “do these three resources together violate an invariant?” or “is this change within the cost ceiling?” That is policy-as-code on the plan JSON — OPA/Conftest on the plan, or Sentinel policy sets in Terraform Cloud — and, for Kubernetes, admission controllers like Gatekeeper or Kyverno that reject a manifest at apply. Scanning and policy-as-code are layers of the same defense: the scanner catches the insecure resource, the policy engine catches the insecure relationship and enforces it at the last possible moment before the world changes.
Supply chain: you can only scan what you can see
The uncomfortable truth of module reuse: a green scan of your repository says nothing about the third-party module it calls. If module "vpc" points at a registry module, an HCL scan sees the module block and its inputs — not the resources the module creates. The insecure default flow log, the over-broad NACL, the public subnet: all invisible unless the scanner descends into the module’s source.
Three moves close the gap:
- Resolve remote modules so the scanner can read them.
checkov --download-external-modules truefetches registry/Git modules and scans inside; Trivy resolves module sources for its config scan as well. Do this in CI even though it is slower — otherwise a wrapper module is a blind spot by construction. - Scan the plan JSON, which inlines every module regardless of source. This is the belt to the download’s suspenders — the resolved plan contains the module’s real resources whether or not the scanner could fetch the source separately.
- Pin and vet what you import. Pin module versions to an exact tag (not a floating branch), review before you bump, and prefer a private registry you control. The same secrets discipline applies to what modules consume — see managed secrets and dynamic credentials for keeping credentials out of the module inputs your scanner would otherwise flag.
The broader trajectory is the one Section 8’s “next steps” points at: package your own custom policies as a versioned, separately tested artifact rather than a folder people copy between repos, and extend the same Trivy binary from IaC into image, dependency, and SBOM scanning so one tool and one SARIF stream cover the whole supply chain.
Practice challenges
Six exercises, escalating from a first scan to a full brownfield gate. These describe representative commands and output shapes — no live cloud or provider credentials are assumed. Try each before opening the solution.
1. Read your first scan (beginner). Run Checkov against a single Terraform file and identify how many checks passed, failed, and were skipped.
<details> <summary>Solution</summary>
checkov -f ./terraform/s3.tf --compact
# Tail of the run prints the summary line, e.g. (representative):
# Passed checks: 14, Failed checks: 3, Skipped checks: 1
Add --quiet to hide the passed checks and show only what failed. Why: the summary line, not the wall of green, is the number you act on — every gate decision is built on Failed and Skipped.
</details>
2. Suppress one finding, with a reason (beginner). You have a genuinely-public docs bucket that trips CKV_AWS_20. Make the scan pass without disabling the check globally.
<details> <summary>Solution</summary>
resource "aws_s3_bucket" "public_docs" {
bucket = "acme-public-docs"
# checkov:skip=CKV_AWS_20:Public docs site by design. Owner: platform. JIRA SEC-1421
}
Re-run and confirm CKV_AWS_20 now reports as skipped, not failed. Why: the inline skip is attributable and lives next to the code it excuses — Checkov requires the :reason, which is exactly the audit trail a blanket --skip-check throws away.
</details>
3. Prove the plan sees what the HCL hides (intermediate). Given a bucket whose ACL is a variable defaulting to private but overridden to public-read in prod.tfvars, show that raw-HCL scanning passes while plan-JSON scanning fails.
<details> <summary>Solution</summary>
# terraform/main.tf
variable "acl" { default = "private" }
resource "aws_s3_bucket" "site" { bucket = "acme-site" }
resource "aws_s3_bucket_acl" "site" {
bucket = aws_s3_bucket.site.id
acl = var.acl
}
# Raw HCL — reads the safe default, reports clean (the miss).
checkov -d terraform --check CKV_AWS_20 --compact
# Resolve prod values into a plan, then scan THAT.
terraform -chdir=terraform init -backend=false
terraform -chdir=terraform plan -var-file=prod.tfvars -out=tfplan.binary # prod.tfvars: acl = "public-read"
terraform -chdir=terraform show -json tfplan.binary > tfplan.json
checkov -f tfplan.json --framework terraform_plan --check CKV_AWS_20 --compact
Why: the public ACL exists only after prod.tfvars is applied — only the plan representation carries the resolved value, so only the plan scan catches it. This is the belt-and-suspenders argument made concrete.
</details>
4. Author a custom org policy (intermediate). Write a Checkov YAML policy that fails any aws_db_instance without storage_encrypted = true, and run it as an external check.
<details> <summary>Solution</summary>
# policies/yaml/rds_encrypted.yaml
metadata:
id: "CKV_ORG_RDS_1"
name: "RDS instances must enable storage encryption"
category: "ENCRYPTION"
severity: "HIGH"
definition:
cond_type: "attribute"
resource_types:
- "aws_db_instance"
attribute: "storage_encrypted"
operator: "equals"
value: true
checkov -d ./terraform --external-checks-dir ./policies/yaml --check CKV_ORG_RDS_1 --compact
Why: the built-in library is generic; encryption-by-default is an organizational rule, and a declarative YAML attribute check is the least-code way to express it and gate on it.
</details>
5. Wire a two-tool CI gate (advanced). Build a GitHub Actions job that uploads both scanners’ SARIF under distinct categories (report-only), then gates in one separate step on HIGH/CRITICAL and on any detected secret.
<details> <summary>Solution</summary>
# .github/workflows/iac-scan.yml
name: iac-scan
on:
pull_request:
paths: ["terraform/**"]
permissions:
contents: read
security-events: write # required to upload SARIF
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# --- Report-only scans: never fail here, always upload ---
- name: Checkov (report)
uses: bridgecrewio/checkov-action@v12
with:
directory: terraform
output_format: cli,sarif
output_file_path: console,checkov.sarif
soft_fail: true
quiet: true
- name: Upload Checkov SARIF
if: always()
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: checkov.sarif
category: checkov
- name: Trivy config (report)
uses: aquasecurity/trivy-action@0.28.0
with:
scan-type: config
scan-ref: terraform
format: sarif
output: trivy.sarif
exit-code: "0"
- name: Upload Trivy SARIF
if: always()
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: trivy.sarif
category: trivy
# --- The ONE gating step: the only step allowed to fail ---
- name: Gate on severity + secrets
run: |
set -euo pipefail
checkov -d terraform --soft-fail-on LOW,MEDIUM --hard-fail-on HIGH,CRITICAL --compact
trivy config --severity HIGH,CRITICAL --exit-code 1 terraform
trivy fs --scanners secret --exit-code 1 terraform # any live secret always blocks
Why: reporting and gating are decoupled — findings always annotate the PR (if: always(), soft_fail), and exactly one step can turn the build red, so a green scan step never hides a finding and a red build always explains itself.
</details>
6. Adopt on a brownfield estate (advanced). A legacy repo has 40 pre-existing HIGH findings nobody will fix this sprint. Make the gate block new HIGH/CRITICAL findings while letting the documented backlog through.
<details> <summary>Solution</summary>
# Snapshot today's findings once; commit the artifact for review.
checkov -d . --create-baseline
git add .checkov.baseline && git commit -m "chore: checkov baseline (40 pre-existing findings)"
# Future PRs fail only on findings NOT in the baseline.
checkov -d . --baseline .checkov.baseline --hard-fail-on HIGH,CRITICAL; echo "exit: $?"
# Prove it: add a NEW public bucket and confirm the build now fails,
# while the 40 backlog findings still pass.
Why: a baseline makes adoption survivable — strict for anything new, lenient for the reviewed backlog you burn down deliberately. Never auto-refresh it, or the next regression is silently accepted into the baseline as “pre-existing.”
</details>
Common beginner mistakes
- “A green scan means the deployment is secure.” It means the declared template is clean. It says nothing about drift after a console edit, whether an identity can actually reach the resource, or whether the public bucket is public on purpose. Static scanning is the earliest and cheapest layer — necessary, never sufficient. Pair it with plan-time policy and runtime CSPM.
- “Scanning the HCL is enough.” Variables,
for_each, providerdefault_tags, and wrapper modules all hide the real config from a raw-HCL scan. The insecure value often exists only after Terraform resolves the plan. Scan raw HCL for fast, credential-free feedback andterraform show -jsonplan output for accuracy through modules. - “Fail the build on every finding.” A gate that flags 12 mediums on every PR gets rubber-stamped, then disabled. Split by severity — HIGH/CRITICAL block, MEDIUM/LOW annotate and ticket — so the red X keeps meaning something. The one exception with no middle ground: a detected secret always blocks.
- “Suppress it with
--skip-checkin the pipeline.” That hides the exception in CI YAML where no reviewer of the Terraform will ever see it, with no owner and no expiry. Use an inline#checkov:skip=CKV_...:reason(Checkov forces the reason) or a baseline entry with astatementandexpired_at. The friction is the point. - “Checkov and Trivy overlap, so I’ll pick one.” The overlap is the value. Two independently-maintained rulesets catch different things; when both flag a resource you are confident, when one does you caught what the other missed. Deduplicate at the reporting layer, not by dropping a tool.
- “I should add tfsec alongside them.” tfsec is deprecated and folded into Trivy — running the tfsec CLI and
trivy configjust runs the same rules twice under two ID schemes. Use Trivy; recognise tfsec only so you can migratetfsec:ignorecomments to#trivy:ignore:AVD-*. - “Pin to
latestso we always get the newest checks.” A floating version means a scanner that adds a check overnight fails an unrelated PR the next morning, non-deterministically. Pin exact versions in CI and upgrade deliberately in their own reviewed PR. - “The build was green, so we’re covered.” The most dangerous outcome is silent zero coverage: Checkov skips Bicep with no
bicepCLI, Trivy runs zero rules when it can’t pull its bundle — and both go green. Assert every expected framework actually parsed and that a planted bad resource does fail.
Glossary
- IaC scanning (static analysis): inspecting infrastructure-as-code before it is applied, to find resources declared insecurely by construction. The metal detector at the pipeline gate.
- Misconfiguration: an insecure-by-declaration setting — public bucket, open security group, disabled encryption or logging — visible in the template without running anything.
- Checkov: graph-aware IaC scanner (Bridgecrew / Prisma Cloud) with a large built-in library, custom policies in Python and YAML, severity gating, and baselines. Rule IDs look like
CKV_AWS_20. - Trivy: Aqua’s all-in-one scanner.
trivy config(misconfiguration) carries tfsec’s engine; it also does secret and vulnerability scanning in the same binary. Rule IDs look likeAVD-AWS-0089. - tfsec: the original Terraform-only static scanner, now deprecated and folded into Trivy; its rules live on as Trivy’s
AVD-*checks. - SARIF (Static Analysis Results Interchange Format): an OASIS-standard JSON schema for analysis results. Both scanners emit it; GitHub code scanning ingests it and renders inline PR annotations.
- Policy-as-code: expressing organizational rules as versioned, testable code (a Checkov Python/YAML check, a Rego policy) rather than a wiki page a human is meant to remember.
- Plan JSON (
terraform show -json): the machine-readable, fully-resolved representation of a Terraform plan — variables filled, modules inlined,default_tagsapplied. Scanning it catches what raw HCL hides. - Graph / connection-state check: a check that reasons about relationships between resources (“is this SG attached to a public instance?”), not just one resource’s attributes. Checkov’s model enables these.
- Custom policy: an organization-specific rule you author — Checkov YAML for attribute/connection checks, Checkov Python for real logic, Rego for Trivy.
- False positive: a finding that is technically correct but intentional or acceptable in context (a deliberately public docs bucket). Managed with attributable suppressions, not by weakening the check.
- Suppression / skip: silencing one finding on one resource. Inline (
#checkov:skip=ID:reason,#trivy:ignore:AVD-*) with a reason, or centralized in.checkov.yaml/.trivyignore.yamlwith an owner and expiry. - Baseline: a committed snapshot of current findings so future runs gate only on new ones — the mechanism that makes adoption on a brownfield estate survivable. Never auto-refresh it.
- Soft-fail / hard-fail: Checkov’s gating split —
--soft-fail/--soft-fail-onreport without failing the build;--hard-fail-onfails it. The knob that separates “block” from “annotate.” - Exit code: the number a scanner returns;
0passes the CI step, non-zero fails it.--exit-code 1(Trivy) and--hard-fail-on(Checkov) are how a finding becomes a red build. - Severity threshold: the line below which findings are reported but not blocked. Typical policy: HIGH/CRITICAL block, MEDIUM/LOW annotate; secrets always block regardless.
- Secret scanning: detecting hardcoded credentials and high-entropy strings in source (Trivy’s
secretscanner). The one finding class with no acceptable severity — always blocking. - CSPM (Cloud Security Posture Management): runtime scanning of deployed cloud resources, catching drift and console changes that IaC scanning — which only sees the template — cannot.
- Admission control: rejecting a change at the last moment before it takes effect — a plan-time policy gate (OPA/Sentinel) or a Kubernetes admission controller (Gatekeeper, Kyverno).
- pre-commit: a local Git hook framework that runs scanners on
git commitfor fast feedback. Advisory only — bypassable with--no-verify, so never a substitute for the CI gate. - Defense in depth: overlapping controls so no single blind spot is fatal — here, two scanners with independent rulesets plus plan-time policy and runtime CSPM.
- Silent zero coverage: a scan that passes because it checked nothing — a missing
bicepCLI, an un-pulled Trivy bundle — certifying safety that was never evaluated. Defeated by asserting frameworks parsed. - Module provenance / supply chain: the trust and visibility you have into third-party modules. A green scan of your repo says nothing about an unresolved remote module — download and scan it, or scan the plan JSON that inlines it.