Sachin Chaurasiya

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.

Author
Sachin Chaurasiya
Sachin Chaurasiya
Published
Reading time
12 min read
Difficulty
intermediate

Reviewed Tested with Checkov 3.3.17 (bridgecrew/checkov image), Terraform 1.14.9, Docker 29.1

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

SymptomCauseFix
Hundreds of findings on a large repositoryEvery resource evaluated against every checkSort by exposure first; fix in groups; skip with reasons; consider --check for a curated set
A finding disappears without a fixA skip was added, or the check was renamed in a newer CheckovSearch for checkov:skip and skip-check; diff the pinned image version
Skip comment has no effectPlaced outside the resource block, or wrong check IDPut it inside the block; copy the ID from the finding
.checkov.yaml ignoredRun from a different directoryRun with --directory set to the directory that holds the file, or pass --config-file
Secret in HCL not detectedNot a known token format or high-entropy stringDo 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.yaml committed 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