Custom policies

YAML and Python checks.

Intermediate14 min · lesson 6 of 12

Checkov ships with hundreds of built-in checks, and they work like the standard clauses in a rental agreement: the rules everybody already agrees on. Your organization has house rules the landlord never wrote down. Every S3 bucket (Amazon's object storage) must carry an Owner tag. No security group may open the database port to 0.0.0.0/0. Staging buckets must use your own KMS key (Key Management Service, the AWS service that holds encryption keys). Custom policies turn those house rules into checks that run beside the built-ins: YAML for a plain assertion, Python when the rule needs real logic.

Here is how the gap shows up in practice. An auditor asks why production buckets are allowed to skip your mandatory encryption tag, and the only honest answer is that stock Checkov never knew the rule existed. A custom check moves that house rule into the Terraform gate that runs before merge, so the problem surfaces there instead of in an audit finding months later.

You load custom checks with --external-checks-dir, which points at a folder of YAML or Python rule files. YAML covers plain attribute checks. Python handles branching and cross-referencing between resources. Treat both as security-critical code and test them like it.

YAML attribute checks

Most policies boil down to one sentence: this attribute must equal that value. A YAML check says exactly that and nothing more. The metadata block gives it a unique id, a name, a category and a severity. The definition block gives it cond_type attribute, the resource_types it applies to, the attribute path, an operator, and the value you expect. Nest several of those under and or or when one sentence is not enough. Anyone on the team can read the result, including people who never write Python.

terminal
$ checkov -d tests/fixtures --external-checks-dir policies/custom -c CKV2_CUSTOM_S3_1 --compact
output
Check: CKV2_CUSTOM_S3_1: "S3 public-access block must deny public ACLs"
FAILED for resource: aws_s3_bucket_public_access_block.bad
File: /fixtures/s3.tf:8-12

Python when the rule needs logic

YAML runs out of road the moment you need an if, a loop, or a look at some other resource. A Python check subclasses BaseResourceCheck, declares supported_resources, and implements scan_resource_conf, which returns CheckResult.PASSED or CheckResult.FAILED. The last line of the file matters as much as the logic: you have to create an instance with check = MyCheck(). Leave it out and Checkov loads your file and runs nothing at all. One more surprise waits inside scan_resource_conf, and the warning below spells it out.

terminal
$ checkov -d tests/fixtures --external-checks-dir policies/custom -c CKV_CUSTOM_TAG_1 --compact
output
Check: CKV_CUSTOM_TAG_1: "EC2 instances must carry an Owner tag"
FAILED for resource: aws_instance.web
File: /fixtures/ec2.tf:1-6
YAML vs Python
1Write rule
a house rule Checkov does not ship
2Simple?
attribute = value → YAML
3Needs logic?
branching → Python
4--external-checks-dir
runs beside built-ins
YAML for flat assertions; Python when a rule needs code.

Load, distribute, and test

Point --external-checks-dir at a folder on your own disk, or point --external-checks-git at a shared repo so every pipeline pulls the same pack. While you are still writing a rule, -c narrows the run to that one check against your fixtures, which keeps the feedback loop down to a second or two. For Python checks, Checkov's pytest harness (pytest is the standard Python test runner) counts passes and failures for you. A guardrail deserves the same regression tests as the code it guards.

terminal
$ checkov -d . --external-checks-dir policies/custom --external-checks-git github.com/acme/checkov-policies
output
terraform scan results:
Passed checks: 815, Failed checks: 40, Skipped checks: 9
Check: CKV_CUSTOM_TAG_1: "EC2 instances must carry an Owner tag"
FAILED for resource: aws_instance.app
Every attribute arrives wrapped in a list
Inside a Python check, conf.get('enabled') hands you [True], not True. Nested blocks come back as lists too. Print conf while you develop and read what is actually there, because the structure does not mirror your HCL (HashiCorp Configuration Language, what your .tf files are written in) the way you expect.

YAML policy anatomy

A YAML policy has two halves. metadata carries the id, and that id has to be globally unique, so put an org prefix on it like CKV_ACME_1. definition carries either cond_type attribute for a plain value check or cond_type connection for a relationship check. The operators available to you are equals, exists, not_equals, regex_match, within and contains. A connection check (cond_type: connection) states a graph rule in YAML with no Python involved, for example a security group attached to a public instance.

terminal
# policies/custom/s3_public_acls.yaml excerpt
# metadata.id: CKV2_CUSTOM_S3_1
# definition.and: block_public_acls equals true
output
Check: CKV2_CUSTOM_S3_1: "S3 public-access block must deny public ACLs"
FAILED for resource: aws_s3_bucket_public_access_block.bad

Test custom checks with pytest

Keep Python checks in version control with fixtures beside them: one resource you know is bad, one you know is good. Checkov's test utilities let you assert how many checks passed and how many failed. A check with a logic bug that quietly passes everything is worse than having no check, because the team now trusts a guardrail that is not there. Run pytest in CI (continuous integration, the automated checks that run on every change) next to the policy pack so a regression blocks the merge.

terminal
$ pytest tests/policies/test_require_owner_tag.py -v
output
test_require_owner_tag.py::test_fails_without_owner_tag PASSED
test_require_owner_tag.py::test_passes_with_owner_tag PASSED
2 passed

Distribute org packs

Point every repo at the same pack with --external-checks-git github.com/acme/checkov-policies?ref=v2026.07.1, or vendor it as a submodule at org-policies/. Tag your releases. A new org rule then reaches every pipeline on its next run, instead of whenever somebody remembers to copy a YAML file across.

This is the point where Checkov starts to overlap with OPA (Open Policy Agent, a general-purpose policy engine) and its Conftest wrapper. Pick Checkov YAML or Python if your teams already run Checkov in CI and already lean on baselines. Pick Rego, the language OPA speaks, if admission control and Terrascan (another infrastructure-as-code scanner that reads Rego) already share an OPA library. Running both with nobody owning either gives you two gates that can contradict each other on the same resource.

terminal
# policies/custom/require_owner_tag.py — conf shape reminder
# tags = conf.get("tags") -> [{"Owner": ["team-a"]}] wrapped lists
# if tags and "Owner" in tags[0]: PASSED
output
# During development, log conf once — never assume conf mirrors HCL literally

Prefix your ids (CKV_ACME_, CKV2_CONTOSO_) so they can never collide with a future built-in or with another team's pack. Fill in category and severity in the YAML metadata even though today's open-source command line ignores severity. If you ever connect Prisma Cloud, the paid platform built around Checkov, that metadata is what feeds the reporting, and writing it now costs you nothing.

cond_type: connection is how a YAML policy talks about relationships instead of single values. A security group attached to a public ENI (elastic network interface, the virtual network card bolted onto an instance). An unencrypted volume attached to a production instance. Stay in YAML while the relationship still reads clearly out loud. Once the connection block turns into something nobody can follow, move to a Python BaseResourceCheck where you can loop, branch and unit-test the edge cases.

Version a policy pack the way you version application code. Tag v2026.07.1. Write a changelog line when CKV_ACME_4 starts demanding a new tag. Run checkov against the pack's own fixtures in the policy repo's CI before any consumer pins the new tag. A custom check that silently passes non-compliant resources is a control failure nobody notices, which is why fixtures are not optional polish.

When the Rego team asks why you are writing Python checks instead of Rego, the honest answers are developer ergonomics and the size of the built-in catalog you get for free. If OPA already owns admission control in your clusters, point Terrascan at that same Rego library and keep Checkov for the plan scanning and baseline features OPA does not ship out of the box. Splitting the work that way beats maintaining the same rule twice in two languages.

A worked YAML example

The S3 public access block resource has two flags worth pinning down: block_public_acls and ignore_public_acls (an ACL is an access control list, the older per-object permission model on S3). Neither flag alone closes the hole, so the policy nests both assertions under definition.and and requires each of them to be true. Run it against your fixtures folder with -c and the scanner reports that check and nothing else, which is what you want while you are still tuning the rule.

terminal
# policies/custom/s3_public_acls.yaml
# CKV2_CUSTOM_S3_1 — block_public_acls and ignore_public_acls must be true
$ checkov -d tests/fixtures --external-checks-dir policies/custom -c CKV2_CUSTOM_S3_1
output
Check: CKV2_CUSTOM_S3_1: "S3 public-access block must deny public ACLs"
FAILED for resource: aws_s3_bucket_public_access_block.bad

A worked Python example

Take the Owner tag rule. supported_resources lists aws_instance. scan_resource_conf pulls the tags out of conf, and this is where the list wrapping bites you: conf.get("tags") returns [{"Owner": ["team-a"]}], so the value you care about sits at tags[0]["Owner"]. Return PASSED when the key is there and FAILED when it is missing, then instantiate the class at the bottom of the file so Checkov picks it up.

One run can pull from both places at once. checkov -d . --external-checks-dir policies/custom --external-checks-git github.com/acme/checkov-policies?ref=v2026.07.1 loads the rule you are drafting locally alongside the pinned org pack. That pairing is useful when you want to be sure your new rule does not contradict something the org pack already enforces.

A quick way to decide the format: if you can state the rule in one sentence with the word must and a single attribute, write YAML. If the sentence needs the word unless, or it has to look at a second resource, write Python. Guessing wrong in one direction is cheap, because a YAML file is easy to rewrite as a Python check later. Guessing wrong in the other direction leaves you with Python nobody on the team can review.

Review a policy change the way you would review a firewall rule change. The question in the pull request is which existing resources start failing the moment this lands. Run the pack against its fixtures and against one real repo before you tag the release. The first person to meet a newly required tag should not be a stranger whose pipeline breaks at 4pm on a Friday.

One habit saves you an hour a week: never debug a custom check against a whole repo. Run it with -c CKV_CUSTOM_TAG_1 against a fixtures directory holding two resources, one that should pass and one that should fail. A full scan prints hundreds of results from the built-in catalog and buries the single line you are trying to read.

Try this

Do this: build the smallest custom check that can possibly work and watch it fire. Make a folder, drop in a YAML rule saying every aws_s3_bucket must carry an Owner tag, point --external-checks-dir at the folder, and use -c so the output shows your rule alone.

terminal
$ mkdir -p policies/custom
$ cat > policies/custom/CKV_ACME_TAG_1.yaml <<'EOF'
metadata:
id: "CKV_ACME_TAG_1"
name: "Ensure resources have Owner tag"
category: "GENERAL_SECURITY"
definition:
cond_type: "attribute"
resource_types:
- "aws_s3_bucket"
attribute: "tags.Owner"
operator: "exists"
EOF
$ checkov -d . --external-checks-dir policies/custom -c CKV_ACME_TAG_1 --compact
output
Check: CKV_ACME_TAG_1: "Ensure resources have Owner tag"
FAILED for resource: aws_s3_bucket.logs
File: /main.tf:12-18
Passed checks: 0, Failed checks: 1, Skipped checks: 0

Takeaway

Remember: a custom rule buys you org-specific policy and charges you ownership in return. You version it, you test it, you distribute it, exactly as you would a library your applications import. YAML carries a flat assertion. Python BaseResourceCheck carries anything that needs branching. And do not stand up a second policy language in Rego next to Checkov unless your admission controllers already force that stack on you.

Ship every pack with fixtures and a pinned git tag, then consume it through --external-checks-git in each repo's CI. Next hard skill is plan scanning (cv-plan). Your Owner tag rule reading source alone still cannot see a tag that a module or a tfvars file only fills in once terraform plan resolves it.

Quick check
01Why must a Python custom check end with check = MyCheck()?
Incorrect — No. Severity comes from the check's metadata, not from creating an instance.
Correct — Yes. Checkov collects checks as it imports the module, and only an instance gets registered.
Incorrect — No. Loading a pack from git is a command-line flag and has nothing to do with the class.
Incorrect — No. SARIF is a report format; without the instance the check never runs at all, whatever the output looks like.
02When is YAML the right choice over a Python check?
Incorrect — No. Python is fully supported and it is where you go the moment a rule needs logic.
Correct — Yes. A single must-equal rule stays readable in YAML for platform teams and app teams alike.
Incorrect — No. Custom checks run against source or plan either way; the format you pick is about rule complexity.
Incorrect — No. Graph rules can be written as YAML connection blocks or as Python; needing a graph does not decide the format.
03Your Python check compares conf.get('enabled') to True, and every resource fails, including the ones where you set enabled = true. What do you do next?
Incorrect — No. That leaves the guardrail switched off and the bug still in the pack every other repo pulls.
Incorrect — No. Python reads booleans fine; the problem is the shape the value arrives in, not the type.
Correct — Yes. Attributes come back wrapped in single-element lists, so [True] never equals True.
Incorrect — No. Where the pack is loaded from has no bearing on how conf values are structured.

Related