Infrastructure as Code Part 2 of 3 · Infrastructure as Code Security
Scan Infrastructure as Code with Checkov
Run Checkov against Terraform with a public database, an open security group and an unprotected bucket; read the 22 findings and why they matter; fix them; then record the rest as documented skips.
On this page
Overview
Checkov reads infrastructure-as-code (Terraform, CloudFormation, Kubernetes manifests,
Dockerfiles and more) and evaluates it against a library of policies, each with an ID such as CKV_AWS_24 and a
sentence saying what it wants. It runs on the source, before plan, so it needs no credentials and takes
seconds. That makes it the first gate in an IaC pipeline; this part is about what it finds and what to do with it.
The workflow below was run end to end with Checkov 3.3.17 from the bridgecrew/checkov image: scan an insecure
configuration, fix it, scan again, then deal with what remains. Every count and check ID is from those runs.
Prerequisites
- Docker (Checkov and Terraform are run from their images; nothing is installed)
- The foundations from part 1: pinned versions and a repository to work in
- No AWS account is needed; nothing is planned or applied
The insecure configuration
Three resources, each written the way it often appears in a first draft: a bucket for build artifacts, a security group for a bastion, a small Postgres instance.
resource "aws_s3_bucket" "artifacts" {
bucket = "example-build-artifacts"
}
resource "aws_security_group" "ssh" {
name = "ssh-from-anywhere"
description = "SSH access for the bastion"
ingress {
description = "ssh"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_db_instance" "app" {
identifier = "app-db"
engine = "postgres"
engine_version = "16"
instance_class = "db.t4g.micro"
allocated_storage = 20
username = "app"
password = "SuperSecret123!"
publicly_accessible = true
skip_final_snapshot = true
}
terraform fmt -check and terraform validate both pass on this. It is syntactically perfect and would apply
without complaint, which is the point: the tooling that ships with Terraform checks shape, not safety.
Run Checkov
docker run --rm -v "$PWD:/tf" -w /tf bridgecrew/checkov:3.3.17 \
--directory /tf --framework terraform --compact --quiet
--compact drops the code snippets, --quiet shows only failures. The summary line and the first findings:
terraform scan results:
Passed checks: 13, Failed checks: 22, Skipped checks: 0
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.ssh
File: /main.tf:5-23
Check: CKV_AWS_382: "Ensure no security groups allow egress from 0.0.0.0:0 to port -1"
FAILED for resource: aws_security_group.ssh
Check: CKV_AWS_17: "Ensure all data stored in RDS is not publicly accessible"
FAILED for resource: aws_db_instance.app
Check: CKV_AWS_16: "Ensure all data stored in the RDS is securely encrypted at rest"
FAILED for resource: aws_db_instance.app
Check: CKV2_AWS_6: "Ensure that S3 bucket has a Public Access block"
FAILED for resource: aws_s3_bucket.artifacts
Check: CKV_AWS_21: "Ensure all data stored in the S3 bucket have versioning enabled"
FAILED for resource: aws_s3_bucket.artifacts
Check: CKV_AWS_145: "Ensure that S3 buckets are encrypted with KMS by default"
FAILED for resource: aws_s3_bucket.artifacts
Twenty-two failures for thirty lines of HCL is normal. Each finding names the check, the resource and the file lines, and the full output links a guide page per check. The exit code was 1, which is what a pipeline will key on.
Reading the findings
Not all twenty-two are equal, and the first job is to sort them. Grouped by what an attacker would do with them:
Exposure. CKV_AWS_24 (SSH from 0.0.0.0/0) and CKV_AWS_17 (publicly_accessible = true on the
database) put a login prompt and a database port on the internet. CKV2_AWS_6 (no public access block on the
bucket) means one wrong ACL or bucket policy later, the artifacts are public. These are the findings to fix
first, always.
Data protection. CKV_AWS_16 (RDS not encrypted at rest), CKV_AWS_145 (bucket not encrypted with KMS),
CKV_AWS_21 (no versioning: a deleted or overwritten artifact is gone). Encryption at rest is not the control that
stops the exposure above, but it is the one auditors ask for and the one that limits what a snapshot or disk leak
reveals.
Operational hardening. Logging (CKV_AWS_18, CKV_AWS_129), deletion protection (CKV_AWS_293), minor
version upgrades (CKV_AWS_226), IAM authentication (CKV_AWS_161), a description on every rule
(CKV_AWS_23). Cheap to add, and each one removes a category of incident.
Opinions. CKV_AWS_157 (Multi-AZ), CKV_AWS_144 (cross-region replication), CKV2_AWS_62 (S3 event
notifications), CKV2_AWS_5 (security group attached to something), CKV_AWS_353 (Performance Insights). These
are reasonable defaults for some workloads and wrong or irrelevant for others. They are not security findings in
the sense the first group is, and a team that treats them the same way will end up ignoring the whole report.
What Checkov did not find
The hardcoded password = "SuperSecret123!" produced no finding, in either the Terraform framework or with
--framework secrets, which was also run. Checkov’s secret detection matches known token formats and high-entropy
strings; a human-shaped password is neither. Two lessons: a clean secrets scan is not proof there is no secret in
the file, and the fix for this line is structural, not a scanner. Part 1 covered it: let the provider manage the
credential.
Fix the configuration
The fixed version replaces the password with manage_master_user_password, closes the security group to a
variable-supplied office range and a single egress rule, and gives each bucket a public-access block, versioning,
KMS encryption, access logging and a lifecycle rule. The database gets encryption, deletion protection, automatic
minor upgrades, IAM authentication, enhanced monitoring and log exports. The resources that carry the decisions:
resource "aws_s3_bucket_public_access_block" "artifacts" {
bucket = aws_s3_bucket.artifacts.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_server_side_encryption_configuration" "artifacts" {
bucket = aws_s3_bucket.artifacts.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
kms_master_key_id = aws_kms_key.storage.arn
}
}
}
resource "aws_security_group" "ssh" {
name = "ssh-from-office"
description = "SSH access to the bastion from the office range only"
vpc_id = var.vpc_id
ingress {
description = "ssh from office"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = [var.office_cidr]
}
egress {
description = "https to package mirrors and the AWS API"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_db_instance" "app" {
identifier = "app-db"
engine = "postgres"
engine_version = "16"
instance_class = "db.t4g.micro"
allocated_storage = 20
username = "app"
manage_master_user_password = true
storage_encrypted = true
kms_key_id = aws_kms_key.storage.arn
publicly_accessible = false
deletion_protection = true
auto_minor_version_upgrade = true
iam_database_authentication_enabled = true
monitoring_interval = 60
monitoring_role_arn = aws_iam_role.rds_monitoring.arn
enabled_cloudwatch_logs_exports = ["postgresql", "upgrade"]
copy_tags_to_snapshot = true
skip_final_snapshot = false
final_snapshot_identifier = "app-db-final"
}
The access-log bucket gets the same block, versioning, encryption and lifecycle resources as the artifacts bucket; the monitoring role and its assume-role policy are the usual three resources. Scan again:
Passed checks: 68, Failed checks: 10, Skipped checks: 0
Check: CKV_AWS_300: "Ensure S3 lifecycle configuration sets period for aborting failed uploads"
FAILED for resource: aws_s3_bucket_lifecycle_configuration.access_logs
Check: CKV_AWS_353: "Ensure that RDS instances have performance insights enabled"
FAILED for resource: aws_db_instance.app
Check: CKV_AWS_157: "Ensure that RDS instances have Multi-AZ enabled"
FAILED for resource: aws_db_instance.app
Check: CKV2_AWS_30: "Ensure Postgres RDS as aws_db_instance has Query Logging enabled"
FAILED for resource: aws_db_instance.app
Check: CKV2_AWS_62: "Ensure S3 buckets should have event notifications enabled"
FAILED for resource: aws_s3_bucket.artifacts
Check: CKV2_AWS_64: "Ensure KMS key Policy is defined"
FAILED for resource: aws_kms_key.storage
Check: CKV_AWS_144: "Ensure that S3 bucket has cross-region replication enabled"
FAILED for resource: aws_s3_bucket.artifacts
Check: CKV2_AWS_5: "Ensure that Security Groups are attached to another resource"
FAILED for resource: aws_security_group.ssh
From 22 to 10, and 68 checks now pass because the new resources are each evaluated too. Every exposure and
data-protection finding is gone. Note CKV_AWS_300: it did not exist in the first scan because the lifecycle
configuration did not exist. Fixing one finding can create a resource that has findings of its own, which is why
the loop is scan, fix, scan, not scan, fix.
CKV_AWS_300 is a real one-line fix (an abort_incomplete_multipart_upload block, which the artifacts bucket
already had). The other nine are the opinion group from earlier.
Handle what remains, with reasons
A finding you decide not to fix needs a decision recorded where the next person will see it. Checkov gives two places, and the choice between them is the choice between “this resource is a justified exception” and “this check does not apply to us”.
Per resource, in the code. A comment inside the resource block, with the check ID and a reason. The reason is mandatory syntax, which is the best feature of the whole tool:
resource "aws_db_instance" "app" {
#checkov:skip=CKV_AWS_157:Single-AZ is accepted for this non-critical app database; RPO is covered by automated snapshots
#checkov:skip=CKV_AWS_353:Performance Insights is not enabled on db.t4g.micro in this account to keep cost down
identifier = "app-db"
# ...
}
resource "aws_kms_key" "storage" {
#checkov:skip=CKV2_AWS_64:The default key policy (account root) is intentional here; a scoped policy is added when the key is shared across accounts
description = "Encrypts build artifacts and the app database"
enable_key_rotation = true
}
The skip travels with the resource in review, in blame and in the diff that removes it.
Per repository, in .checkov.yaml. For checks that are a policy decision for the whole codebase:
# Organisation policy for Checkov. Every skip carries a reason; review it with the code.
framework:
- terraform
- secrets
skip-check:
- CKV_AWS_144 # cross-region replication is a DR decision made per bucket, not a default
- CKV2_AWS_62 # S3 event notifications are a feature, not a security control
- CKV2_AWS_5 # security groups are attached by the compute module in another repository
- CKV2_AWS_30 # Postgres query logging writes statements (and their parameters) to CloudWatch; decided per database
compact: true
quiet: true
Checkov reads .checkov.yaml from the scanned directory automatically, so the CI command and the local command
stay identical. With the lifecycle fix and both kinds of skip in place:
docker run --rm -v "$PWD:/tf" -w /tf bridgecrew/checkov:3.3.17 --directory /tf
echo "exit=$?"
terraform scan results:
Passed checks: 69, Failed checks: 0, Skipped checks: 3
exit=0
Three skipped checks are the three inline skips, each visible in the full (non---quiet) output with its reason.
The four repository-level skips do not count as skipped; those checks are simply not run. Zero failures, an exit
code of 0, and a reason on record for every check that was not satisfied. That is the state a merge request should
be in before anyone reads the plan.
Output formats for the pipeline
The same run can write machine-readable reports next to the console output:
docker run --rm -v "$PWD:/tf" -w /tf bridgecrew/checkov:3.3.17 --directory /tf \
--output cli --output junitxml --output sarif --output-file-path /tf/reports
reports/results_cli.txt
reports/results_junitxml.xml # tests="35" failures="22" on the insecure configuration
reports/results_sarif.sarif # Checkov 3.3.17, 22 results
JUnit renders as a test report in GitLab and Jenkins; SARIF is what code-scanning integrations and IDEs read. Both
list findings by check ID and resource, so the console report can be --quiet and the details still land
somewhere. Part 3 wires these into a merge-request job and adds the mechanism for
“fail only on findings that are new”.
Security Considerations
- Checkov evaluates the configuration, not the account. A resource that is already public in AWS and is not in
Terraform is invisible to it; a Terraform default that is insecure only after
apply(because the provider fills it in) may be too. Pair it with a scan of the deployed account. - Findings are only as good as the policy library’s version. Pin the image tag (
bridgecrew/checkov:3.3.17, as above) so a policy update is a reviewed bump rather than a surprise red pipeline, and bump it regularly. - The report names resources and files. It is not a secret, but it is a map of your infrastructure; keep it project-private.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Hundreds of findings on a large repository | Every resource evaluated against every check | Sort by exposure first; fix in groups; skip with reasons; consider --check for a curated set |
| A finding disappears without a fix | A skip was added, or the check was renamed in a newer Checkov | Search for checkov:skip and skip-check; diff the pinned image version |
| Skip comment has no effect | Placed outside the resource block, or wrong check ID | Put it inside the block; copy the ID from the finding |
.checkov.yaml ignored | Run from a different directory | Run with --directory set to the directory that holds the file, or pass --config-file |
| Secret in HCL not detected | Not a known token format or high-entropy string | Do not rely on detection; move the value to a managed secret (part 1) |
Production Recommendations
- Run Checkov on every merge request, pinned to an image tag, with
.checkov.yamlcommitted next to the code. - Fix exposure and data-protection findings; decide opinion findings per resource or per repository, with a reason.
- Review skips like security changes. Reject skips without reasons.
- Write JUnit and SARIF reports so findings are visible in the platform, not only in a job log.
Conclusion
Twenty-two findings became zero without a single silent suppression: the dangerous ones were fixed, the operational ones were added, and the opinions were declined in writing. That is the whole discipline of IaC scanning. Part 3 makes it automatic and, more importantly, makes it something a team cannot quietly route around.
References
Keep reading