CoursesInfrastructure as Code & automationScanning: Checkov, tfsec/Trivy, Terrascan, KICS

Scanning: Checkov, tfsec/Trivy, Terrascan, KICS

Catch misconfigurations before apply.

Advanced14 min · lesson 18 of 23

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

/main.tf
resource "aws_s3_bucket" "exports" {
bucket = "acme-analytics-exports"
}
resource "aws_s3_bucket_acl" "exports" {
bucket = aws_s3_bucket.exports.id
acl = "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_id
ingress {
from_port = 22
to_port = 22
protocol = "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.

terminal
# --quiet: show failed checks only. --compact: do not print the code blocks
checkov -d . --compact --quiet
output
terraform scan results:
Passed checks: 11, Failed checks: 16, Skipped checks: 0
Check: CKV_AWS_79: "Ensure Instance Metadata Service Version 1 is not enabled"
FAILED for resource: aws_instance.runner
File: /main.tf:10-14
Guide: https://docs.prismacloud.io/en/enterprise-edition/policy-reference/aws-policies/aws-general-policies/bc-aws-general-31
Check: CKV_AWS_24: "Ensure no security groups allow ingress from 0.0.0.0:0 to port 22"
FAILED for resource: aws_security_group.runner
File: /main.tf:16-26
Guide: https://docs.prismacloud.io/en/enterprise-edition/policy-reference/aws-policies/aws-networking-policies/networking-1-port-security
Check: CKV_AWS_20: "S3 Bucket has an ACL defined which allows public READ access."
FAILED for resource: aws_s3_bucket.exports
File: /main.tf:1-3
Guide: https://docs.prismacloud.io/en/enterprise-edition/policy-reference/aws-policies/s3-policies/s3-1-acl-read-permissions-everyone
Check: CKV_AWS_18: "Ensure the S3 bucket has access logging enabled"
FAILED for resource: aws_s3_bucket.exports
File: /main.tf:1-3
Guide: https://docs.prismacloud.io/en/enterprise-edition/policy-reference/aws-policies/s3-policies/s3-13-enable-logging
Check: CKV2_AWS_6: "Ensure that S3 bucket has a Public Access block"
FAILED for resource: aws_s3_bucket.exports
File: /main.tf:1-3
Guide: 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.

terminal
journalctl -u ssh --since "40 min ago" | grep -c "Invalid user"
journalctl -u ssh -n 2 --no-pager
output
1843
Jul 21 09:58:41 ci-runner-01 sshd[2219]: Invalid user admin from 45.148.10.87 port 51234
Jul 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.

terminal
aws s3 ls s3://acme-analytics-exports --no-sign-request
output
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

Same job, different catalogues
Checkov
CKV_ and CKV2_ ids
CKV2_ are graph checks across resources
custom checks in Python or YAML
terraform, CFN, k8s, helm, Dockerfile
--baseline for legacy repos
exits 1 on any failure
Trivy (absorbed tfsec)
AVD- ids, Rego checks
one binary: config, images, secrets
--severity HIGH,CRITICAL
severity ships in the open rules
.trivyignore.yaml
ignores can carry an expiry date
Terrascan
Rego policies (Open Policy Agent)
terraform, k8s, helm, ARM, CFT
exit code 3 on violations
not 1; CI scripts get this wrong
#ts:skip= inline
reason text required
KICS
Rego queries over a JSON model
very broad platform list
--fail-on high,critical
exit encodes severity (60 CRITICAL, 50 HIGH)
# kics-scan ignore-block
scoped suppression comments
Pick one primary scanner and one second opinion. Four gates on one repo means four ignore files, four ID schemes and four upgrade schedules nobody maintains.

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.

terminal
trivy config . --severity HIGH,CRITICAL --exit-code 1
echo "exit=$?"
output
2026-07-21T09:12:44+05:30 INFO [misconfig] Misconfiguration scanning is enabled
2026-07-21T09:12:47+05:30 INFO [misconfig] Need to update the checks bundle
2026-07-21T09:12:49+05:30 INFO [misconfig] Downloading the checks bundle...
2026-07-21T09:12:53+05:30 INFO Detected config files num=1
main.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 AWS
resources. It is recommended that no security group allows unrestricted ingress access to
remote 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 = 22
22 │ to_port = 22
23 │ 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.

terminal
terrascan scan -i terraform -t aws -d .
echo "exit=$?"
output
Violation Details -
Description : Ensure that your AWS S3 bucket is not publicly accessible
File : main.tf
Module Name : root
Plan Root : ./
Line : 5
Severity : HIGH
-----------------------------------------------------------------------
Description : Ensure S3 bucket has server side encryption enabled
File : main.tf
Module Name : root
Plan Root : ./
Line : 1
Severity : HIGH
-----------------------------------------------------------------------
Scan Summary -
File/Folder : /home/dev/infra
IaC Type : terraform
Scanned At : 2026-07-21 04:41:12.343
Policies Validated : 821
Violated Policies : 6
Low : 1
Medium : 2
High : 3
exit=3
terminal
kics scan -p . --report-formats json -o ./kics-out
echo "exit=$?"
output
Scanning with Keeping Infrastructure as Code Secure v2.1.5
Preparing Scan Assets: Done
Executing queries: [-----------------------------------------] 100.00%
S3 Bucket Without Server-side-encryption, Severity: HIGH, Results: 1
Description: S3 Bucket resource should have server-side encryption enabled
Platform: Terraform
CWE: 311
[1]: main.tf:1
001: resource "aws_s3_bucket" "exports" {
002: bucket = "acme-analytics-exports"
003: }
Results Summary:
CRITICAL: 1
HIGH: 4
MEDIUM: 3
LOW: 2
INFO: 0
TOTAL: 10
Results saved to ./kics-out
exit=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.

/infra/prod/main.tf
module "exports" {
source = "terraform-aws-modules/s3-bucket/aws"
version = "4.1.2"
bucket = "acme-analytics-exports"
acl = "public-read"
}
terminal
checkov -d . --framework terraform -o json | jq '.summary'
checkov -d . --framework terraform --download-external-modules true -o json | jq '.summary'
output
{
"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.

terminal
terraform plan -out=tf.plan > /dev/null
terraform show -json tf.plan > plan.json
checkov -f plan.json --repo-root-for-plan-enrichment . --deep-analysis -o json \
| jq -r '.results.failed_checks[] | "\(.check_id) \(.resource)"'
output
CKV_AWS_20 module.exports.aws_s3_bucket.this
CKV_AWS_18 module.exports.aws_s3_bucket.this
CKV_AWS_21 module.exports.aws_s3_bucket.this
CKV_AWS_145 module.exports.aws_s3_bucket.this
CKV2_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.

/infra/site/main.tf
resource "aws_s3_bucket" "marketing_site" {
# checkov:skip=CKV_AWS_20: public marketing site, no private objects, approved in INFRA-4412
bucket = "acme-marketing-site"
}
resource "aws_s3_bucket_acl" "marketing_site" {
bucket = aws_s3_bucket.marketing_site.id
acl = "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.

.trivyignore.yaml
misconfigurations:
- id: AVD-AWS-0132
paths:
- "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.

terminal
checkov -d . --framework terraform --create-baseline --soft-fail
jq '.failed_checks[0]' .checkov.baseline
output
{
"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

.gitlab-ci.yml
stages: [test]
# fast, every commit: report everything, fail on anything new
iac-scan-checkov:
stage: test
image:
name: bridgecrew/checkov:3.2.524 # pinned; :latest moves the gate under you
entrypoint: [""]
script:
- checkov -d . --framework terraform --download-external-modules true --baseline .checkov.baseline --compact -o cli -o sarif --output-file-path console,.
artifacts:
when: always
expire_in: 30 days
paths:
- results.sarif
# the merge blocker: high signal only
iac-scan-trivy:
stage: test
image:
name: aquasec/trivy:0.65.0
entrypoint: [""]
variables:
TRIVY_CACHE_DIR: .trivycache # keep the checks bundle between runs
cache:
paths:
- .trivycache
script:
- 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.

Gates that quietly stop gating
Open-source Checkov ships no severity metadata; severity comes from the Prisma Cloud platform. So the popular pattern --soft-fail --hard-fail-on HIGH,CRITICAL matches nothing without --bc-api-key, and the job exits 0 on every run while looking like a gate. Swap the severity for a check ID and it works: --soft-fail --hard-fail-on CKV_AWS_24 exits 1 on the same repo. Gate on check IDs, on a baseline, or on the raw exit code, and leave severity gating to Trivy or KICS, which carry severity in their open rules. The other silent killer is a pipe: checkov -d . | tee scan.log returns tee's exit status, not Checkov's, so the failure vanishes. Add set -o pipefail, or write the report with --output-file-path instead of piping.

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.

terminal
git switch -c test/scanner-gate
cat >> main.tf <<'EOF'
resource "aws_s3_bucket_public_access_block" "exports" {
bucket = aws_s3_bucket.exports.id
block_public_acls = false
block_public_policy = false
}
EOF
docker run --rm -v "$PWD:/tf" -w /tf bridgecrew/checkov:3.2.524 \
-d /tf --compact --quiet --baseline .checkov.baseline
echo "exit=$?"
output
terraform scan results:
Passed checks: 0, Failed checks: 4, Skipped checks: 0
Check: CKV_AWS_53: "Ensure S3 bucket has block public ACLS enabled"
FAILED for resource: aws_s3_bucket_public_access_block.exports
File: /main.tf:28-32
Check: CKV_AWS_54: "Ensure S3 bucket has block public policy enabled"
FAILED for resource: aws_s3_bucket_public_access_block.exports
File: /main.tf:28-32
Check: CKV_AWS_55: "Ensure S3 bucket has ignore public ACLs enabled"
FAILED for resource: aws_s3_bucket_public_access_block.exports
File: /main.tf:28-32
Check: CKV_AWS_56: "Ensure S3 bucket has 'restrict_public_buckets' enabled"
FAILED for resource: aws_s3_bucket_public_access_block.exports
File: /main.tf:28-32
exit=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.

Quick check
01Your Terraform root only calls modules from the public registry. checkov -d . reports 'Passed checks: 1, Failed checks: 1' and the JSON summary shows "resource_count": 0. The pipeline goes green. What is most likely true?
Incorrect — Those two are CKV_TF_1 and CKV_TF_2, which judge the module source line itself (commit hash, version tag). They say nothing about what the module builds.
Incorrect — Terraform is scanned by default; that flag narrows an already-broad scan, it does not enable it.
Correct — resource_count: 0 is the proof. Add --download-external-modules true, or scan the plan JSON where modules are already expanded.
Incorrect — It is static analysis over files on disk and never calls the cloud API.
02You want to suppress CKV_AWS_20 (public-read ACL, access control list) on a bucket that is public on purpose. The 'acl = "public-read"' line lives in the aws_s3_bucket_acl block, but the scan reports the finding against aws_s3_bucket. Where does the '# checkov:skip=CKV_AWS_20: ...' comment go, and how do you confirm it took?
Incorrect — Checkov reports the check against the bucket, so a skip in the ACL block is pure decoration and changes nothing.
Incorrect — the lesson's method is an in-code comment inside the flagged resource, confirmed by the Skipped count, not a file-path list.
Incorrect — the comment must sit inside the specific resource named in the FAILED line, not just somewhere in the file.
Correct — the skip belongs in the resource the finding is reported against, and a Skipped count of 1 proves it landed.
03A team swaps Checkov for Terrascan but keeps their gate script, which fails the job only when the scanner's exit code equals 1. Terrascan prints several HIGH violations, yet the pipeline still goes green. Why?
Incorrect — Terrascan does exit non-zero on violations; it just uses 3, not 0.
Correct — different scanners use different exit codes, so checking for code 1 specifically silently passes a tool that signals failure with 3.
Incorrect — nothing indicates a suppression; the cause is the exit-code mismatch, not a skip.
Incorrect — Terrascan sets exit 3 on its own; the bug is the script assuming the failing code is 1.

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.

Related