Scanning Python IaC with Checkov
Checkov reads Terraform and CloudFormation, not Python — so the trick to scanning Pulumi and CDKTF is to feed it the synthesized output and gate the pipeline on the results before any cloud API call runs. This task sits inside the Security & Compliance Basics workflow of Python IaC Fundamentals & Strategy, and getting it right turns a misconfiguration into a failed CI job instead of a production incident.
CDKTF makes this natural: cdktf synth emits Terraform JSON that Checkov scans directly. Pulumi does not produce HCL, so you scan its preview plan in JSON form. Either way the goal is the same — a deterministic, machine-readable artifact that Checkov can evaluate, with non-zero exit on critical findings wired into the gate.
Context
Static analysis of infrastructure has exactly one useful position in the lifecycle: after the program has been reduced to concrete resource definitions, and before anything is applied. Earlier than that and there is nothing to analyse — a Python file that computes a bucket name at runtime tells a linter nothing about whether the bucket ends up encrypted. Later than that and the misconfiguration already exists in the account, and you are doing detection rather than prevention.
That window is narrow, and it is why the synthesis step matters more than the scanner. CDKTF hands it to you: cdktf synth writes cdk.tf.json, a complete Terraform configuration with every attribute resolved except values Terraform itself computes at apply time. Pulumi has no equivalent intermediate file, so the closest artefact is the preview plan. Both are snapshots of intent, and both are cheap to produce in CI.
It is worth being precise about what this catches and what it does not. Checkov evaluates declared attributes: is server_side_encryption_configuration present, is publicly_accessible false, does this security group allow 0.0.0.0/0 on port 22. It has no view of runtime behaviour, of what an IAM policy actually permits once resolved against a real principal, or of resources created outside the stack. A scan that passes means "nothing in this configuration matches a known-bad pattern" — a genuinely useful statement, and a much weaker one than "this infrastructure is secure".
What Checkov actually evaluates
Checkov is a collection of individual checks, each with a stable identifier, run over a parsed representation of a file. Two details determine whether your scan finds anything at all.
The framework decides the parser. Passing --framework terraform at a directory of cdk.tf.json files finds nothing, because the HCL parser does not recognise them. CDKTF output needs terraform_json; a Terraform plan rendered with terraform show -json needs terraform_plan. Getting this wrong is silent — Checkov reports zero passed and zero failed checks, which looks like success in a compact summary.
| Artefact | Produced by | Framework flag |
|---|---|---|
cdk.tf.json |
cdktf synth |
terraform_json |
plan.json |
terraform show -json plan.bin |
terraform_plan |
.tf files |
hand-written or generated HCL | terraform |
plan.json |
pulumi preview --json |
json (limited coverage) |
Two kinds of check exist, and only one of them reads your whole configuration. A CKV_-prefixed check inspects a single resource block in isolation — CKV_AWS_24 fails a security group with 0.0.0.0/0 on port 22 without looking at anything else. A CKV2_-prefixed check is a graph check: it walks the relationships between resources, so CKV2_AWS_6 (S3 bucket has a public access block) can only pass if it finds the separate aws_s3_bucket_public_access_block resource pointing at that bucket.
Graph checks are where synthesized output shines and where hand-written fixtures mislead you. CDKTF splits a modern S3 configuration across aws_s3_bucket, aws_s3_bucket_versioning, aws_s3_bucket_server_side_encryption_configuration and aws_s3_bucket_public_access_block, all wired by reference. Scan them together and the graph checks resolve; scan one file in isolation and they report failures that do not exist.
Prerequisites
- Python 3.9+ with
checkov >= 3.2installed (pip install checkov). - For CDKTF:
cdktf >= 0.20and a stack that synthesizes tocdktf.out/. - For Pulumi:
pulumi >= 3.0and a stack you can runpulumi preview --jsonagainst. - IAM permissions sufficient for
pulumi preview(read-only describe access) when scanning Pulumi plans. - A CI runner that can fail the job on a non-zero exit code.
Implementation
1. Scan synthesized CDKTF JSON
Synthesize first, then point Checkov at the output directory with the terraform_json framework. Wrap the invocation in a typed helper so CI and local runs behave identically.
# CLI: python -m ci.checkov_gate cdktf
from __future__ import annotations
import subprocess
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class CheckovGate:
target_dir: Path
soft_fail: bool = False # State implication: Checkov only READS files; never mutates state.
def scan_cdktf(self) -> int:
# cdktf synth writes Terraform JSON into cdktf.out/ — scan that, not the .py source.
subprocess.run(["cdktf", "synth"], check=True)
cmd = [
"checkov",
"-d", str(self.target_dir),
"--framework", "terraform_json",
"--compact",
]
if self.soft_fail:
cmd.append("--soft-fail")
# Non-zero exit on any failed check unless soft_fail is set — this is the gate.
return subprocess.run(cmd).returncode
if __name__ == "__main__":
raise SystemExit(CheckovGate(Path("cdktf.out")).scan_cdktf())
Provider note: Always run
cdktf synthimmediately before scanning. Scanning a stalecdktf.out/checks yesterday's infrastructure and passes a gate it should fail.
The --compact flag suppresses the source-code excerpt under each finding, which keeps CI logs readable. For anything beyond a human reading the log you want a machine-readable format as well, and Checkov will emit both in one run:
# CLI: bash scripts/scan.sh
cdktf synth
checkov -d cdktf.out \
--framework terraform_json \
--compact \
--output cli \
--output sarif --output-file-path console,checkov.sarif
The --output-file-path argument maps positionally onto the --output flags: the first goes to console, the second to checkov.sarif. SARIF is the format code-scanning dashboards ingest, so this one command both fails the job and files the findings. A JSON output is the right choice instead if you want to post-process the results yourself — every finding carries check_id, resource, file_path and guideline, which is enough to route by severity or to diff against the previous run.
One argument is easy to miss and materially changes the result: --download-external-modules. If the CDKTF stack adopts a Terraform module, the synthesized JSON contains a module block with a source address, not the resources inside it. Without that flag Checkov scans the call and not the contents, so a module that creates an unencrypted bucket passes cleanly.
2. Scan a Pulumi preview plan
Pulumi has no HCL, so export the plan as JSON and scan that file. Checkov's coverage of raw Pulumi plan JSON is narrower than for Terraform, so pair it with the in-program typed assertions described in the parent overview.
# CLI: python -m ci.checkov_gate pulumi
from __future__ import annotations
import subprocess
from pathlib import Path
def scan_pulumi_plan(plan_path: Path = Path("plan.json")) -> int:
# Provider note: --json triggers a read-only preview; no resources are changed.
with plan_path.open("w") as fh:
subprocess.run(["pulumi", "preview", "--json"], check=True, stdout=fh)
# State implication: the plan reflects desired state, scanned before any apply.
return subprocess.run(
["checkov", "-f", str(plan_path), "--compact"]
).returncode
3. Handle suppressions deliberately
Suppress a check only with a justification, and prefer inline skip comments in the source so the reason travels with the code. For CDKTF, attach the skip as a resource override; for plain Terraform JSON you can also pass --skip-check at the gate, but inline is auditable.
# CLI: cdktf synth && checkov -d cdktf.out --framework terraform_json
from cdktf import TerraformResourceLifecycle # noqa: F401
from cdktf_cdktf_provider_aws.s3_bucket import S3Bucket
bucket = S3Bucket(self, "logs", bucket="app-access-logs")
# State implication: this is metadata only — it changes Checkov behavior, not the resource.
bucket.add_override(
"//",
{"checkov": {"skip": [
{"id": "CKV_AWS_18", "comment": "Access logging on the log bucket itself is circular"}
]}},
)
Provider note: A blanket
--skip-check CKV_AWS_*defeats the gate. Skip individual check IDs with a written reason, and review skips in code review like any other change.
Verification
Prove the gate actually blocks a bad config by asserting Checkov returns a non-zero exit on a known-bad fixture and zero on a clean one.
# CLI: pytest tests/test_checkov_gate.py -v
from __future__ import annotations
import json
import subprocess
from pathlib import Path
BAD_SG = {
"resource": {
"aws_security_group": {
"open": {
"name": "open-ssh",
"ingress": [
{
"from_port": 22,
"to_port": 22,
"protocol": "tcp",
"cidr_blocks": ["0.0.0.0/0"],
}
],
}
}
}
}
def _scan(directory: Path) -> subprocess.CompletedProcess[str]:
# Provider note: Checkov performs no cloud calls, so this test needs no credentials.
return subprocess.run(
["checkov", "-d", str(directory), "--framework", "terraform_json",
"--compact", "--quiet", "--output", "json"],
capture_output=True, text=True,
)
def test_gate_fails_on_open_ssh_ingress(tmp_path: Path) -> None:
(tmp_path / "cdk.tf.json").write_text(json.dumps(BAD_SG))
result = _scan(tmp_path)
assert result.returncode != 0, "unrestricted SSH ingress must fail the gate"
failed = json.loads(result.stdout)["results"]["failed_checks"]
assert any(c["check_id"] == "CKV_AWS_24" for c in failed)
def test_gate_passes_on_restricted_ingress(tmp_path: Path) -> None:
good = json.loads(json.dumps(BAD_SG))
good["resource"]["aws_security_group"]["open"]["ingress"][0]["cidr_blocks"] = ["10.0.0.0/16"]
(tmp_path / "cdk.tf.json").write_text(json.dumps(good))
failed = json.loads(_scan(tmp_path).stdout)["results"]["failed_checks"]
assert not any(c["check_id"] == "CKV_AWS_24" for c in failed)
Two properties are being asserted here, and both matter. The first test proves the gate is wired up — that a known-bad configuration produces a non-zero exit rather than a warning in a log nobody reads. The second proves it is not simply failing everything, which is the state a gate degenerates into after someone adds a check that fires on every resource. Assert on the specific check_id rather than on the exit code alone; a test that passes because a different check failed is worse than no test.
Confirm a real scan locally as well. checkov -d cdktf.out --framework terraform_json --compact prints a per-resource summary and a trailing count:
# CLI: checkov -d cdktf.out --framework terraform_json --compact
Passed checks: 41, Failed checks: 3, Skipped checks: 1
Check: CKV_AWS_18: "Ensure the S3 bucket has access logging enabled"
FAILED for resource: aws_s3_bucket.app_artifacts
File: /cdk.tf.json:1-1
The File: /cdk.tf.json:1-1 line is not a bug — synthesized JSON is emitted on a single line, so every finding points at line 1. If you want findings that map to something a reviewer can navigate, pretty-print the synthesized file before scanning: python -m json.tool cdktf.out/stacks/prod/cdk.tf.json > scan/cdk.tf.json. Checkov parses either form, and the pretty-printed one gives real line numbers in the SARIF output.
Gotchas & Edge Cases
Scanning the Python source finds nothing. Checkov does not parse Pulumi or CDKTF Python. Pointing it at your
.pyfiles yields zero findings and a false sense of safety — always scan synthesized JSON or the preview plan.
Stale
cdktf.out/passes silently. Ifcdktf synthis skipped in CI, Checkov scans the last committed output. Run synth as a non-cached step immediately before the scan, every time.
Soft-fail hides regressions.
--soft-failmakes Checkov exit 0 even on failures, which is useful while baselining but must be removed before the gate is meaningful. Track which checks are deferred and re-enable hard failure once fixed.
The wrong
--frameworkreports success. Scanningcdk.tf.jsonwith the default framework set producesPassed checks: 0, Failed checks: 0and exit code 0. Nothing in that output says "I could not parse these files". Assert in CI that the scan found a non-zero number of passed checks, not merely that it exited zero — a scan that evaluates nothing is the most dangerous outcome available.
Module contents are invisible without
--download-external-modules. A CDKTF stack that adopts a Terraform module synthesizes to amoduleblock with asourceaddress. Checkov scans what it can see, and by default that is the call site. The flag makes it fetch and traverse the module, at the cost of network access in CI and a noticeably slower scan.
Multi-stack CDKTF apps produce one directory per stack.
cdktf.out/stacks/dev/cdk.tf.jsonandcdktf.out/stacks/prod/cdk.tf.jsonare separate configurations. Pointing Checkov atcdktf.outscans both, which is usually what you want — but the findings are then reported against near-identical file paths, and a failure in dev blocks a prod deploy. Scan per stack when the pipeline deploys per stack.
Operational Notes
Introducing a gate to a repository that already has infrastructure is a different problem from keeping one green. On day one an existing stack of any size will produce dozens of findings, most of them real and none of them urgent, and a gate that fails every build gets disabled within a week.
The mechanism for this is a baseline. Checkov records the current set of findings to a file, and subsequent runs only fail on findings that are not in it:
# CLI: checkov -d cdktf.out --framework terraform_json --create-baseline
cdktf synth
checkov -d cdktf.out --framework terraform_json --create-baseline
git add .checkov.baseline
# Later runs: only NEW findings fail the build.
checkov -d cdktf.out --framework terraform_json --baseline .checkov.baseline
Commit the baseline and treat shrinking it as ordinary backlog work. The important property is that it is a file in version control: adding a finding to it is a diff a reviewer can see and question, which is exactly what --soft-fail is not.
Between "everything fails the build" and "nothing does" there are two useful middle settings. --hard-fail-on fails only on the listed check IDs or severities, which is the right shape for the first month — pick the handful that genuinely map to incidents, such as CKV_AWS_24 for open SSH and CKV_AWS_20 for public S3 read access, and let the rest report. --soft-fail-on is its inverse, demoting specific checks to warnings while everything else stays hard. Configure whichever you choose in a committed .checkov.yaml rather than in the CI command line, so a local run and a pipeline run behave identically:
# .checkov.yaml — read automatically from the working directory
framework:
- terraform_json
directory:
- cdktf.out
compact: true
download-external-modules: true
hard-fail-on:
- CKV_AWS_24
- CKV_AWS_20
- CKV2_AWS_6
skip-check:
- CKV_AWS_18 # access logging on the log bucket itself is circular
Scan cost is the other thing that decides whether the gate survives. Checkov's startup dominates for small stacks and the graph build dominates for large ones; a synthesized configuration with a few thousand resources takes minutes rather than seconds, largely in the CKV2_ graph checks. If that becomes the slowest step in the pipeline, run the full scan on the merge queue and a --hard-fail-on subset on every push, rather than reaching for --skip-check to make it finish.
Finally, be honest in the pipeline about the boundary of the tool. Checkov will not tell you that an IAM policy grants more than it should once wildcards resolve, that a security group is unused, or that a bucket contains data it should not. Those need least-privilege construction at build time and runtime controls afterwards. A green Checkov run is a floor, not a certificate.
FAQ
Does Checkov scan Pulumi programs directly?
Checkov scans static files — Terraform, CloudFormation, Kubernetes manifests, and CDKTF-synthesized cdk.tf.json. For Pulumi programs, scan the synthesized plan or pair it with Pulumi CrossGuard, which evaluates the planned graph at preview.
How do I stop one check without disabling the scan?
Add an inline checkov:skip=CKV_ID:reason comment or a config-file suppression, and record the justification so the exception stays auditable.
Can I add my own rules?
Yes — write them as Python classes and load them with --external-checks-dir, as covered in writing custom Checkov policies. Custom checks get their own ID prefix, so they are as suppressible and as baselinable as the built-in ones.
Why does my scan report zero passed and zero failed checks?
Almost always because the framework does not match the artefact. cdk.tf.json needs --framework terraform_json; the HCL parser skips it silently and Checkov exits 0. Add an assertion in CI that the passed-check count is greater than zero so this failure mode cannot look like success.
Should I scan before or after terraform plan?
Scan the synthesized configuration for the fast feedback loop, and optionally scan the plan as well for the resources whose final values only appear there. The configuration scan is deterministic and needs no credentials; the plan scan needs a working backend and a refresh, so it belongs later in the pipeline.
How do I introduce this to a repository that already fails a hundred checks?
Generate a baseline with --create-baseline, commit it, and gate on new findings only. Then pick a small set of check IDs that map to real incident classes and make those hard failures immediately. Fixing the baseline is backlog work; stopping it from growing is the gate's actual job on day one.
Does the scan need cloud credentials?
No. Checkov reads files and never calls a cloud API, which is what makes it safe to run on a pull request from a fork. The steps around it may need credentials — pulumi preview --json performs read-only describes, and terraform plan needs backend access — but the scan itself does not.
Related
- Security & Compliance Basics — the parent overview of policy-as-code layers, secret handling, and drift detection.
- Enforcing IAM least privilege in Python IaC — pair Checkov gates with typed policy builders that reject wildcard permissions at construction time.
- Python IaC Fundamentals & Strategy — the grandparent overview connecting compliance scanning to design principles and tooling choice.