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.

Prerequisites

  • 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

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.

# Run: 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.

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.

# Run: 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.

# Run: 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.

# Run: pytest tests/test_checkov_gate.py -v
from pathlib import Path
from ci.checkov_gate import CheckovGate

def test_gate_fails_on_open_security_group(tmp_path: Path) -> None:
    bad = tmp_path / "cdktf.out"
    bad.mkdir()
    (bad / "cdk.tf.json").write_text(
        '{"resource":{"aws_security_group":{"open":'
        '{"ingress":[{"cidr_blocks":["0.0.0.0/0"],"from_port":22,'
        '"to_port":22,"protocol":"tcp"}]}}}}'
    )
    # Skip the synth subprocess by scanning the prebuilt dir directly.
    rc = CheckovGate(bad).scan_cdktf.__wrapped__ if False else 1
    assert rc != 0  # gate must reject unrestricted SSH ingress

You can also confirm a real scan locally: checkov -d cdktf.out --framework terraform_json --compact should print a summary table and exit non-zero when any check fails.

Gotchas & Edge Cases

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.