Why does a custom Checkov check comparing conf["ami"] to a string fail every instance?
answer
- you are not handed the HCL you wrote
- the parser's shape, not the source's
- everything arrives wrapped
- one index away from correct
- conf.get with a default, then [0]
basics
~20 sCheckov hands scan_resource_conf its own parsed representation of the block, where every attribute value is wrapped in a list. conf["ami"] is ["ami-0aaa1111"], never the bare string, so the comparison is always false and the check fails everything.
solid answer
~50 sThe check is comparing the wrong shape. `scan_resource_conf` does not receive the HCL you wrote; it receives the scanner's parsed dictionary, and the HCL parser wraps each attribute value in a list — `ami = "ami-0aaa1111"` arrives as `conf["ami"] == ["ami-0aaa1111"]`, and nested blocks arrive as lists of dicts. Comparing that list against a list of approved strings is never true, so the check returns FAILED for every instance, including the compliant ones. The fix is to unwrap: `conf.get("ami", [None])[0]`. Using `.get` with a default matters as much as the index — a resource that sets no `ami` at all (it launches from a launch template, say) would raise a KeyError with `conf["ami"]`, and a check that explodes is worse than one that is wrong. It is also worth remembering that the value may be an unresolved reference rather than a literal when it comes from a variable the scanner could not resolve, so the allowlist comparison should not assume a literal id.
code
python · 18 linesfrom checkov.common.models.enums import CheckCategories, CheckResult
from checkov.terraform.checks.resource.base_resource_check import BaseResourceCheck
APPROVED = ["ami-0aaa1111", "ami-0bbb2222"]
class ApprovedAmi(BaseResourceCheck):
def __init__(self):
super().__init__(name="EC2 instance uses an approved AMI",
id="CKV_ACME_1",
categories=[CheckCategories.GENERAL_SECURITY],
supported_resources=["aws_instance"])
def scan_resource_conf(self, conf):
if conf["ami"] in APPROVED: # conf["ami"] is ["ami-0aaa1111"]
return CheckResult.PASSED
return CheckResult.FAILED
check = ApprovedAmi()go deeper
Recall that a custom check inspects the scanner's parsed dictionary rather than the file you wrote, and that attribute values arrive wrapped in a list — so you index before you compare.
Explain the parsed shape precisely: attributes as single-element lists, nested blocks as lists of dicts, and why a missing key raises rather than returning a verdict. Show the corrected read with a default value.
Demonstrate that you think about the three input shapes a rule meets in production — literal, absent, and unresolved reference — and that you choose deliberately what each one means rather than letting the code decide by accident.
Frame it as a reliability question for the gate itself: a custom rule that fails compliant resources burns the credibility of every rule you ship next, so testing custom checks and defining the unresolved-value policy are org-level requirements, not personal habits.
## The symptom You wrote a custom check to enforce an approved-AMI allowlist. Every instance in the repository fails it, including the three that use AMIs literally present in your list. Nothing in the rule's logic looks wrong. ## What the check is actually handed The important thing about extending a scanner rather than writing a rule against raw plan JSON is that you are working inside **the scanner's own parsed model of the resource block**, and that model is not the text you wrote. Checkov parses HCL with a parser that represents every attribute as a list of values. So for: ```hcl resource "aws_instance" "web" { ami = "ami-0aaa1111" instance_type = "m6i.large" } ``` the dictionary passed to `scan_resource_conf` looks roughly like `{"ami": ["ami-0aaa1111"], "instance_type": ["m6i.large"]}`. A comparison of `conf["ami"]` against a list of approved id strings compares a list to strings, is never satisfied, and the check falls through to `FAILED` for every resource. The rule is not "broken" in any way the scanner can detect — it runs, it returns a result, and the result is wrong on every input. Nested blocks are wrapped too: a block like `root_block_device { encrypted = true }` arrives as a list containing a dictionary whose own values are lists. Any check that walks into a nested block has to unwrap at each level. ## The fix, and the second bug hiding behind it ```python ami = conf.get("ami", [None])[0] if ami in APPROVED: return CheckResult.PASSED return CheckResult.FAILED ``` Two things changed. The `[0]` unwraps the parser's list. The `.get` with a default handles the resource that has no `ami` key at all — an instance created from a launch template, a resource type variant, a module that sets it elsewhere. `conf["ami"]` on such a resource raises a `KeyError`, and an exception inside a custom check is a worse failure than a wrong verdict: depending on how the run is configured you either lose that resource's result or lose the run. Ask yourself explicitly what the *absent* attribute should mean. For an allowlist, "no AMI specified" is usually not a violation of the AMI rule — the instance is getting its image from somewhere your check cannot see, and failing it produces a finding nobody can action. Deciding the absent case deliberately is the difference between a rule that gets adopted and a rule a team demands be turned off. ## The third shape: values that are not literals A custom check is reading configuration, not a realised resource. `ami = var.approved_ami` or `ami = data.aws_ami.base.id` cannot be compared to a literal allowlist. Checkov resolves what it can — variables with defaults, values passed into modules it parsed — but plenty of values remain references, and your check will see a string that is not an AMI id. A strict allowlist comparison then fails a resource that is, in fact, compliant at apply time. The honest options are to fail closed (treat unresolvable as a violation, and accept that developers will need an exception path), to pass and rely on a second control that sees resolved values, or — the usual middle ground — to fail only on a literal that is not in the list, and let anything unresolved through with a rule you know has a hole. What you must not do is write the strict comparison and then be surprised when a team tells you the gate is wrong. ## Why this is the canonical first bug Everything about the parsed model — list wrapping, nested blocks as lists of dicts, unresolved references — is invisible from the HCL you are looking at while writing the rule. That is why the practical advice for writing any custom check is: print the `conf` dictionary for a real resource once before writing any logic, and write the check against what you actually see. It is also why writing a unit test for a custom check pays for itself immediately: a fixture with one compliant and one violating resource catches the list-wrapping bug in the first run, and it catches it again the day someone refactors the rule. ## The generalisable lesson Extending a scanner means inheriting its data model. Whether the engine hands you a Python dictionary or a document to query in Rego, the shape it hands you is the shape you must code against — not the shape of the file on disk. Every scanner-extension bug in this category is the same bug: the author wrote the rule against the source text they had in their head instead of the parsed structure the engine actually passes in.
- What happens when the resource has no `ami` attribute at all?`conf["ami"]` raises a KeyError inside your check. Use `conf.get("ami", [None])[0]` and then decide deliberately what absence means. For an allowlist, an instance with no literal AMI is usually getting its image from somewhere the check cannot see, so failing it produces a finding nobody can act on.
- The AMI comes from `var.base_ami`. What does your check see, and what should it do?If the scanner cannot resolve the variable, the check sees a reference string rather than an AMI id, so a strict allowlist comparison fails a compliant resource. Decide the policy explicitly: fail closed with an exception path, or only fail literals that are not in the list and accept that the rule has a known hole for unresolved values.
- How would you keep this class of bug from reaching the pipeline?Unit-test the check like any other code: fixtures with one compliant resource, one violating resource, and one with the attribute missing, asserting the returned result for each. Print the parsed conf for a real resource once before writing logic — the list wrapping is invisible from the HCL you are reading.
saying these in an interview costs you the question
- Assumes the check receives the raw HCL text
- Indexes conf["ami"][0] without handling a missing key
- Treats an unresolved variable reference as a literal id
- Never tests a custom check before shipping it
- Lets an exception in a check stand in for a verdict