Scanning: Checkov, tfsec/Trivy, Terrascan, KICS
Catch misconfigurations before apply.
A building inspector walks the blueprints before the concrete truck arrives. Wrong stair rise, no second fire exit, a socket over the bathtub: cheap on paper, brutal once the floor is poured. Infrastructure-as-code scanners do that inspection for your Terraform (the tool that turns text files describing buckets and servers into real cloud resources). They read your files as plain text, compare every resource against a catalogue of known-bad settings written by other people, and tell you which lines would open a hole the moment you run terraform apply. Checkov ships around a thousand of those checks. Trivy, Terrascan and KICS ship their own. You write none of the rules, and you get them for the cost of one command.
Every finding is a small prediction about how you get attacked. 'S3 bucket allows READ to everyone' means a stranger lists your files with no credentials at all. 'Instance Metadata Service Version 1 enabled' means one server-side request forgery bug (an application tricked into fetching a URL of the attacker's choosing) hands over working cloud keys. 'No access logging' means that when the first two happen, you cannot say what was taken or by whom. The scanner is mechanical and a little dumb, which is the point. It checks the same nine hundred things at six on a Friday, on the change everyone was sure was harmless.
One Small File, Four Real Holes
resource "aws_s3_bucket" "exports" {bucket = "acme-analytics-exports"}resource "aws_s3_bucket_acl" "exports" {bucket = aws_s3_bucket.exports.idacl = "public-read"}resource "aws_instance" "runner" {ami = "ami-0c1ac8a41498c1a9c"instance_type = "t3.small"subnet_id = var.subnet_id}resource "aws_security_group" "runner" {name = "ci-runner"vpc_id = var.vpc_idingress {from_port = 22to_port = 22protocol = "tcp"cidr_blocks = ["0.0.0.0/0"]}}
Four resources, four problems, none of them exotic. The bucket in S3 (Amazon's Simple Storage Service, its object store) has no encryption, no versioning and no access logging. Its ACL (access control list, the older per-object permission model) is set to public-read, so anyone on the internet can list and download. The EC2 instance (Elastic Compute Cloud, a virtual machine) has no metadata_options block, which leaves the old token-free metadata protocol switched on. The security group opens port 22, the SSH (secure shell) port you administer Linux boxes through, to 0.0.0.0/0, meaning every address on earth. A reviewer skims this in ten seconds and approves it, because it looks like ordinary Terraform.
# --quiet: show failed checks only. --compact: do not print the code blockscheckov -d . --compact --quiet
terraform scan results:Passed checks: 11, Failed checks: 16, Skipped checks: 0Check: CKV_AWS_79: "Ensure Instance Metadata Service Version 1 is not enabled"FAILED for resource: aws_instance.runnerFile: /main.tf:10-14Guide: https://docs.prismacloud.io/en/enterprise-edition/policy-reference/aws-policies/aws-general-policies/bc-aws-general-31Check: CKV_AWS_24: "Ensure no security groups allow ingress from 0.0.0.0:0 to port 22"FAILED for resource: aws_security_group.runnerFile: /main.tf:16-26Guide: https://docs.prismacloud.io/en/enterprise-edition/policy-reference/aws-policies/aws-networking-policies/networking-1-port-securityCheck: CKV_AWS_20: "S3 Bucket has an ACL defined which allows public READ access."FAILED for resource: aws_s3_bucket.exportsFile: /main.tf:1-3Guide: https://docs.prismacloud.io/en/enterprise-edition/policy-reference/aws-policies/s3-policies/s3-1-acl-read-permissions-everyoneCheck: CKV_AWS_18: "Ensure the S3 bucket has access logging enabled"FAILED for resource: aws_s3_bucket.exportsFile: /main.tf:1-3Guide: https://docs.prismacloud.io/en/enterprise-edition/policy-reference/aws-policies/s3-policies/s3-13-enable-loggingCheck: CKV2_AWS_6: "Ensure that S3 bucket has a Public Access block"FAILED for resource: aws_s3_bucket.exportsFile: /main.tf:1-3Guide: https://docs.prismacloud.io/en/enterprise-edition/policy-reference/aws-policies/aws-networking-policies/s3-bucket-should-have-public-access-blocks-defaults-to-false-if-the-public-access-block-is-not-attached... 11 more failed checks: CKV_AWS_8, CKV_AWS_21, CKV_AWS_23, CKV_AWS_126,CKV_AWS_135, CKV_AWS_144, CKV_AWS_145, CKV2_AWS_5, CKV2_AWS_41,CKV2_AWS_61, CKV2_AWS_62
Read the shape before you read the content. Every check has a stable ID, and that ID is the contract for everything that follows: you suppress by ID, you gate by ID, you search your history by ID. Checks with a CKV_AWS_ prefix judge one resource on its own. The CKV2_AWS_ ones are graph checks, where Checkov builds a map of how resources reference each other and then asks questions that span several of them, such as whether any public access block is attached to this bucket.
Now look closely at CKV_AWS_20, because this detail bites people later. The public-read setting is written in the aws_s3_bucket_acl block on lines 5 to 8, but Checkov reports the finding against aws_s3_bucket.exports on lines 1 to 3. The graph resolved the ACL back to the bucket it belongs to. The line the scanner blames and the line you edit are not always the same line. Remember that. The counts matter too: eleven passed, sixteen failed, and the process exits 1, which is the part that turns a pipeline job red.
The Finding, Forty Minutes Later
Take the metadata one, because it is the finding people wave away. Think of a concierge desk inside the building that reads out the guest's keycard details to whoever walks up and asks. Every EC2 instance can reach a link-local address, 169.254.169.254, that hands out its own configuration, including temporary keys for the IAM (identity and access management) role attached to the machine. Version 1 of that service answers a plain GET request, so anything that can make your app fetch a URL can fetch those keys. Version 2 makes the caller send a PUT first to obtain a short-lived token, then include that token as a header, which a typical server-side request forgery cannot do. Version 2 also defaults to a hop limit of 1, so a container on a bridge network cannot reach the desk at all. CKV_AWS_79 reads, in plain words: any bug in your app that fetches an attacker-chosen URL can read your cloud credentials. When those keys are later used from an address that is not your instance, GuardDuty calls it UnauthorizedAccess:IAMUser/InstanceCredentialExfiltration.OutsideAWS.
The security group finding is less subtle. Internet-wide scanners find an open port 22 in minutes, not days. This runner came up at 09:20. Note what you are counting below: a default Ubuntu cloud image ships with password login switched off, so you will not see 'Failed password' lines. You see bots being turned away at the door, which is the honest signal. The exposure is real even though none of these got in.
journalctl -u ssh --since "40 min ago" | grep -c "Invalid user"journalctl -u ssh -n 2 --no-pager
1843Jul 21 09:58:41 ci-runner-01 sshd[2219]: Invalid user admin from 45.148.10.87 port 51234Jul 21 09:58:41 ci-runner-01 sshd[2219]: Connection closed by invalid user admin 45.148.10.87 port 51234 [preauth]
The bucket needs no tooling at all to confirm, which is the whole problem. Anyone, from anywhere, can ask, because --no-sign-request tells the AWS command line tool to send no credentials.
aws s3 ls s3://acme-analytics-exports --no-sign-request
2026-07-21 09:44:12 88213 2026-07-20-customer-exports.csv
The scanner line about main.tf:1-3 and the CSV a stranger can download are the same fact, separated by one terraform apply and a few hours.
Four Scanners, One Job
Checkov, from Prisma Cloud, is the broadest and the easiest to extend: custom checks in Python or YAML, an in-memory resource graph, and support for Terraform, Terraform plan files, CloudFormation, Kubernetes, Helm, Kustomize, Dockerfiles and GitHub Actions workflows. tfsec was the Terraform-native favourite for years, until Aqua folded its engine into Trivy and put tfsec into maintenance. That merge is why Trivy findings carry AVD identifiers (from the Aqua Vulnerability Database), and why one binary now scans container images, filesystems, running clusters and your Terraform with the same flags.
trivy config . --severity HIGH,CRITICAL --exit-code 1echo "exit=$?"
2026-07-21T09:12:44+05:30 INFO [misconfig] Misconfiguration scanning is enabled2026-07-21T09:12:47+05:30 INFO [misconfig] Need to update the checks bundle2026-07-21T09:12:49+05:30 INFO [misconfig] Downloading the checks bundle...2026-07-21T09:12:53+05:30 INFO Detected config files num=1main.tf (terraform)Tests: 34 (SUCCESSES: 26, FAILURES: 8)Failures: 8 (UNKNOWN: 0, LOW: 0, MEDIUM: 0, HIGH: 8, CRITICAL: 0)AVD-AWS-0107 (HIGH): Security group rule allows unrestricted ingress from any IP address.════════════════════════════════════════Security groups provide stateful filtering of ingress and egress network traffic to AWSresources. It is recommended that no security group allows unrestricted ingress access toremote server administration ports, such as SSH to port 22 and RDP to port 3389.See https://avd.aquasec.com/misconfig/avd-aws-0107────────────────────────────────────────main.tf:20-25────────────────────────────────────────20 ┌ ingress {21 │ from_port = 2222 │ to_port = 2223 │ protocol = "tcp"24 │ cidr_blocks = ["0.0.0.0/0"]25 └ }────────────────────────────────────────exit=1
Three details there earn their keep. Trivy reports how many checks ran, not only how many failed, so the Tests: 34 line is your evidence that the file was parsed rather than quietly skipped. The severity is HIGH, not CRITICAL, which is worth checking before you write a gate: had you filtered on --severity CRITICAL alone, this wide-open SSH port would have produced no findings and a clean exit 0. And --exit-code is opt-in. Without it, Trivy prints every finding and still exits 0, so a job that looks like a gate waves everything through, forever.
Terrascan (now under Tenable) and KICS (Keeping Infrastructure as Code Secure, from Checkmarx) cover similar ground with different engines. Both write their rules in Rego, the policy language from Open Policy Agent, so custom scanner rules end up looking like the admission policies you may already run in Kubernetes. Terrascan has gone quiet lately, and its old documentation site now redirects to a Tenable product page, so treat it as a second opinion rather than the gate you bet the release on. Both surprise people with their exit codes.
terrascan scan -i terraform -t aws -d .echo "exit=$?"
Violation Details -Description : Ensure that your AWS S3 bucket is not publicly accessibleFile : main.tfModule Name : rootPlan Root : ./Line : 5Severity : HIGH-----------------------------------------------------------------------Description : Ensure S3 bucket has server side encryption enabledFile : main.tfModule Name : rootPlan Root : ./Line : 1Severity : HIGH-----------------------------------------------------------------------Scan Summary -File/Folder : /home/dev/infraIaC Type : terraformScanned At : 2026-07-21 04:41:12.343Policies Validated : 821Violated Policies : 6Low : 1Medium : 2High : 3exit=3
kics scan -p . --report-formats json -o ./kics-outecho "exit=$?"
Scanning with Keeping Infrastructure as Code Secure v2.1.5Preparing Scan Assets: DoneExecuting queries: [-----------------------------------------] 100.00%S3 Bucket Without Server-side-encryption, Severity: HIGH, Results: 1Description: S3 Bucket resource should have server-side encryption enabledPlatform: TerraformCWE: 311[1]: main.tf:1001: resource "aws_s3_bucket" "exports" {002: bucket = "acme-analytics-exports"003: }Results Summary:CRITICAL: 1HIGH: 4MEDIUM: 3LOW: 2INFO: 0TOTAL: 10Results saved to ./kics-outexit=60
Terrascan exits 3 when it finds violations, 4 on scan errors and 5 for both. KICS encodes the highest severity it found in the exit code: 60 for CRITICAL, 50 for HIGH, 40 for MEDIUM, 30 for LOW, 20 for INFO, and 126 when the engine itself breaks. That is why the KICS run above ends in 60 rather than 50, since one CRITICAL outranks the four HIGHs. A script that asks whether the exit code was zero handles every one of these correctly. A script that tests for exit code 1 specifically, which people write all the time, reads every violation as success. Look up the exit-code table for the tool you pick, then test it against a file you know is bad.
The Code Your Scanner Never Reads
Here is the failure that leaves a scanning programme looking healthy while it inspects nothing. Most production Terraform is thin: the root calls other people's modules, and the real resources live inside them. Checkov does not fetch module source by default. It parses your root, finds a module block and no resources, and reports something that looks like a clean bill of health.
module "exports" {source = "terraform-aws-modules/s3-bucket/aws"version = "4.1.2"bucket = "acme-analytics-exports"acl = "public-read"}
checkov -d . --framework terraform -o json | jq '.summary'checkov -d . --framework terraform --download-external-modules true -o json | jq '.summary'
{"passed": 1,"failed": 1,"skipped": 0,"parsing_errors": 0,"resource_count": 0,"checkov_version": "3.2.524"}{"passed": 31,"failed": 12,"skipped": 0,"parsing_errors": 0,"resource_count": 19,"checkov_version": "3.2.524"}
Look at that first summary again, because it is a trap dressed as a result. One passed, one failed. It reads like a scan happened. It did not. Those two checks are CKV_TF_1 and CKV_TF_2, which judge the module source line itself (is it pinned to a commit hash, is it pinned to a version tag) and know nothing about what the module builds. The honest field is resource_count, and it says 0. Zero resources were parsed, so a bucket wide open to the internet raised nothing. One flag fixes it: --download-external-modules true pulls the module sources into .external_modules/ and scans them as well. Trivy resolves remote modules by default and gives you --tf-exclude-downloaded-modules for when you want the opposite. Terrascan can reuse whatever terraform init already downloaded with --use-terraform-cache.
Scanning the plan closes a second gap. Your code says acl = var.bucket_acl, and the scanner shrugs at a variable it cannot resolve. The plan knows the value. Render it to JSON and scan that, and every variable, count, for_each and module has already been expanded into concrete resources.
terraform plan -out=tf.plan > /dev/nullterraform show -json tf.plan > plan.jsoncheckov -f plan.json --repo-root-for-plan-enrichment . --deep-analysis -o json \| jq -r '.results.failed_checks[] | "\(.check_id) \(.resource)"'
CKV_AWS_20 module.exports.aws_s3_bucket.thisCKV_AWS_18 module.exports.aws_s3_bucket.thisCKV_AWS_21 module.exports.aws_s3_bucket.thisCKV_AWS_145 module.exports.aws_s3_bucket.thisCKV2_AWS_6 module.exports.aws_s3_bucket.this
The price is access. A plan needs credentials and a reachable provider, so it runs later in the pipeline, and on a pull request from a fork it means handing CI (continuous integration, the system that builds and tests every change) cloud access you may not want to hand out. Keep both scans. The code scan is instant and runs on every commit; the plan scan is the authoritative one before apply.
Suppressions That Expire
Some findings are wrong for you. The marketing site bucket really is public on purpose. Suppress narrowly, in the code, next to the thing being excused, with a reason a stranger can audit.
resource "aws_s3_bucket" "marketing_site" {# checkov:skip=CKV_AWS_20: public marketing site, no private objects, approved in INFRA-4412bucket = "acme-marketing-site"}resource "aws_s3_bucket_acl" "marketing_site" {bucket = aws_s3_bucket.marketing_site.idacl = "public-read"}
That placement is deliberate, and it is the part that costs people an afternoon. CKV_AWS_20 is about a public read ACL, so the obvious home for the comment is the aws_s3_bucket_acl block where the word public-read actually appears. Put it there and nothing happens. Checkov reports that check against the bucket, as you saw in the very first scan, so a comment in the ACL block is decoration. The rule is: the skip comment goes inside the resource named in the FAILED line, not the resource that looks guilty. There is an easy way to confirm you got it right. Rerun the scan and watch the Skipped count. If it went from 0 to 1, the comment took. If it still says 0, your suppression is doing nothing and the finding is still red.
misconfigurations:- id: AVD-AWS-0132paths:- "modules/legacy-reporting/main.tf"statement: "customer-managed KMS key migration scheduled, INFRA-4412"expired_at: 2026-10-01
Two properties make a suppression safe: it names one check on one path, so it cannot hide the next mistake, and it carries a reason with a ticket number. Trivy adds a third, expired_at, written as yyyy-mm-dd, after which the ignore stops applying and the finding comes back on its own. That is the only reliable way to stop a temporary exception living for four years. Checkov has no expiry, so the review is yours to do. Run grep -rn 'checkov:skip' . during code review and read every one, because a skip added at 5pm to make a build pass looks exactly like a skip added after careful thought.
A repo with four hundred existing findings cannot be suppressed one at a time. Take a baseline instead. A baseline is a photograph of the mess you already have, so tomorrow's mistakes stand out against it.
checkov -d . --framework terraform --create-baseline --soft-failjq '.failed_checks[0]' .checkov.baseline
{"file": "/main.tf","findings": [{"resource": "aws_instance.runner","check_ids": ["CKV2_AWS_41","CKV_AWS_126","CKV_AWS_135","CKV_AWS_79","CKV_AWS_8"]},{"resource": "aws_s3_bucket.exports","check_ids": ["CKV2_AWS_6","CKV2_AWS_61","CKV2_AWS_62","CKV_AWS_144","CKV_AWS_145","CKV_AWS_18","CKV_AWS_20","CKV_AWS_21"]}]}
Commit that file. From then on, --baseline .checkov.baseline still reports the known findings but fails the build only on findings that are not in it, so yesterday's mess stops blocking today's work while new mistakes still get caught. The risk is plain. A baseline is a debt file. Give it an owner and a shrinking target, or it becomes a permanent list of things everyone agreed to stop looking at.
Wire It Up, Then Prove It Blocks
stages: [test]# fast, every commit: report everything, fail on anything newiac-scan-checkov:stage: testimage:name: bridgecrew/checkov:3.2.524 # pinned; :latest moves the gate under youentrypoint: [""]script:- checkov -d . --framework terraform --download-external-modules true --baseline .checkov.baseline --compact -o cli -o sarif --output-file-path console,.artifacts:when: alwaysexpire_in: 30 dayspaths:- results.sarif# the merge blocker: high signal onlyiac-scan-trivy:stage: testimage:name: aquasec/trivy:0.65.0entrypoint: [""]variables:TRIVY_CACHE_DIR: .trivycache # keep the checks bundle between runscache:paths:- .trivycachescript:- trivy config . --severity HIGH,CRITICAL --exit-code 1
Two jobs with two different intents. The Checkov job reports everything and fails only on what is new since the baseline, and it publishes SARIF (Static Analysis Results Interchange Format, the JSON that security dashboards and GitHub code scanning read) so findings land somewhere other than a job log nobody opens. The Trivy job fails on HIGH and CRITICAL only, because that one blocks merges, and a blocking gate needs a high signal rate to survive contact with a deadline. Both images are pinned to an exact version.
Test the alarm the way you would test a smoke detector, by holding something smoky under it rather than trusting the little green light. On a scratch branch, add something obviously wrong, run the same pinned container the pipeline runs, and look at the exit code.
git switch -c test/scanner-gatecat >> main.tf <<'EOF'resource "aws_s3_bucket_public_access_block" "exports" {bucket = aws_s3_bucket.exports.idblock_public_acls = falseblock_public_policy = false}EOFdocker run --rm -v "$PWD:/tf" -w /tf bridgecrew/checkov:3.2.524 \-d /tf --compact --quiet --baseline .checkov.baselineecho "exit=$?"
terraform scan results:Passed checks: 0, Failed checks: 4, Skipped checks: 0Check: CKV_AWS_53: "Ensure S3 bucket has block public ACLS enabled"FAILED for resource: aws_s3_bucket_public_access_block.exportsFile: /main.tf:28-32Check: CKV_AWS_54: "Ensure S3 bucket has block public policy enabled"FAILED for resource: aws_s3_bucket_public_access_block.exportsFile: /main.tf:28-32Check: CKV_AWS_55: "Ensure S3 bucket has ignore public ACLs enabled"FAILED for resource: aws_s3_bucket_public_access_block.exportsFile: /main.tf:28-32Check: CKV_AWS_56: "Ensure S3 bucket has 'restrict_public_buckets' enabled"FAILED for resource: aws_s3_bucket_public_access_block.exportsFile: /main.tf:28-32exit=1
The baseline swallowed the sixteen findings you already knew about, which is why the passed count reads 0 rather than eleven. The four new ones came through, and the exit code is 1. Delete the branch. You now know the gate closes, which is more than most teams can say about theirs.
Keep the size of the claim in mind. These tools match your code against a catalogue of known patterns. They do not know that this bucket holds payroll data, that this role is assumed by a service reachable from the internet, or that a module grants iam:PassRole to a wildcard for a reason nobody remembers. The catalogue also moves. Upgrading the ruleset can turn a green main branch red with forty new findings on code nobody touched. Pin the scanner version, upgrade on a schedule you chose, and read the release notes for new checks before the upgrade lands in everyone's pipeline.
One habit worth adding: schedule a weekly scan of main with the newest ruleset, and send the difference against last week to a ticket queue rather than to the build gate. New checks then arrive as work you planned, instead of as a red pipeline on Monday morning for code that nobody has touched since Friday.
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: gates that quietly stop gating. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.