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.

Where the scan fits in the pipeline Where the scan fits in the pipeline: Python program then Synthesis then Checkov scan then Apply then Runtime audit Python program no attributes yet Synthesis cdk.tf.json written Checkov scan the only gate point Apply resources exist Runtime audit detection, not prevention
Static analysis is only possible between synthesis and apply; before it there is nothing to read, after it the misconfiguration is live.

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.

Resource checks versus graph checks Resource checks versus graph checks: comparison across Reads, Example. Prefix Reads Example CKV_ one resource block CKV_AWS_24 open SSH ingress CKV2_ resource relationships CKV2_AWS_6 bucket has PAB Custom whatever you traverse loaded per external dir
Graph checks only resolve when the related resources are scanned together, which is why you point Checkov at the directory rather than one file.

Prerequisites

Prerequisites Prerequisites: layered from Python down to Pulumi. Python IAM Pulumi
Prerequisites: the building blocks this section assembles.
  • Python 3.9+ with checkov >= 3.2 installed (pip install checkov).
  • For CDKTF: cdktf >= 0.20 and a stack that synthesizes to cdktf.out/.
  • For Pulumi: pulumi >= 3.0 and a stack you can run pulumi preview --json against.
  • 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

Implementation Implementation: 1. Scan synthesized then 2. Scan a Pulumi then 3. Handle 1. Scansynthesized 2. Scan a Pulumi 3. Handle
Implementation: the stages run left to right — 1. Scan synthesized, 2. Scan a Pulumi, 3. Handle.

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 synth immediately before scanning. Scanning a stale cdktf.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.

Verification Verification: Test → Program → Mock/Cloud. Test Program Mock/Cloud invoke declare resolve assert
Verification: the test drives the program and asserts on resolved values.
# 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

Gotchas & Edge Cases Gotchas & Edge Cases: Where it breaks with 4 facets. Where it breaks Edge Cases watch this boundary Python watch this boundary Checkov watch this boundary Pulumi watch this boundary
Gotchas & Edge Cases: the boundaries where things break and what to check.

Scanning the Python source finds nothing. Checkov does not parse Pulumi or CDKTF Python. Pointing it at your .py files yields zero findings and a false sense of safety — always scan synthesized JSON or the preview plan.

Stale cdktf.out/ passes silently. If cdktf synth is 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-fail makes 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 --framework reports success. Scanning cdk.tf.json with the default framework set produces Passed checks: 0, Failed checks: 0 and 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 a module block with a source address. 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.json and cdktf.out/stacks/prod/cdk.tf.json are separate configurations. Pointing Checkov at cdktf.out scans 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.

Living with the gate Living with the gate: Baseline today → Hard-fail a subset → Fix the backlog → Shrink baseline → repeat. Baseline today Hard-fail asubset Fix the backlog Shrink baseline
A gate survives by starting narrow and tightening, not by failing every build on day one.

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.