Static Analysis with Checkov and tfsec for Geospatial Stacks
Static analysis is the cheapest layer in a validation pipeline and the one most likely to be adopted badly. Enabled with defaults on a geospatial stack, a scanner produces a few hundred findings — most of them true, few of them urgent, and enough of them irrelevant that the team stops reading the output within a fortnight. A scanner nobody reads is worse than no scanner, because it creates the impression of coverage. Making it useful means suppressing deliberately, adding the rules the tool cannot know about, and separating what blocks a merge from what merely annotates it. This guide extends Testing and Validation for Spatial IaC within CI/CD Automation and Governance.
What the scanners do and do not know
The built-in rules encode general cloud hygiene: encryption at rest, no public access, logging enabled, versioning on, no wildcard principals. All of that is worth having and all of it is generic — the tools know nothing about geospatial workloads, so several rules will be wrong for your estate and several important checks will be missing.
The recurring false positives on a spatial stack are predictable. A public raster archive is supposed to be public, and the scanner flags it every run. A requester-pays bucket looks like an open bucket to a rule that only checks the policy. A read replica in a single availability zone is a legitimate choice for a tile-query replica whose loss is a capacity event rather than an outage. Each of these needs a documented suppression at the resource, not a rule disabled globally — a globally disabled rule stops protecting the ninety resources where it was right.
The missing checks are the spatial ones, and they are the reason to write custom rules rather than only tuning. A memory parameter in the wrong unit, a lifecycle rule that would archive live tiles, an SRID left at a default, a tile service configured to auto-publish every schema: none of these are security findings, so no generic scanner will ever report them, and every one of them causes a real production failure.
Prerequisites and environment assumptions
Checkov and tfsec available in the pipeline. Terraform 1.6 or later. A decision about which tool is authoritative, because running both with overlapping rule sets produces duplicate findings that halve the attention each receives — the workable arrangement is Checkov for the broad rule set and custom policies, tfsec for its faster feedback on the source before a plan exists, with any overlapping rule enabled in one of them only.
Decide the failure policy before the first run. Failing the build on every finding at adoption is how a scanner gets disabled in week two. Start by failing on high severity only, fix the backlog, then tighten. Recording the intended end state — everything above a chosen severity blocks — keeps the ratchet moving rather than leaving the initial concession permanent.
Step-by-step implementation
-
Run tfsec on source, early and fast. No plan required, so it gives feedback in seconds on the file the author just edited. Keep its rule set narrow and its output actionable.
-
Run Checkov on the plan, not only on source. A plan resolves variables and module inputs, so a scan of the plan catches misconfigurations that a source scan cannot see — a module invoked with a bad parameter looks fine in the module’s own source.
-
Suppress at the resource with a reason. An inline suppression comment naming the rule and the justification survives review, appears in the diff, and is greppable. A rule disabled in a configuration file is invisible at the point where someone would question it.
-
Write custom policies for the spatial checks. Checkov supports custom Python and YAML policies; the four in the diagram above are a good starting set, and each corresponds to a real production failure rather than to a compliance framework.
-
Split blocking from advisory. High severity blocks the merge, medium and low annotate it. Publish both in the pull request so the annotation is read, and review the advisory set periodically rather than pretending it does not exist.
# .checkov.yaml — configuration lives in the repository so it is reviewable.
framework:
- terraform
- terraform_plan
# Load the spatial rules the generic set cannot know about.
external-checks-dir:
- policy/checkov
# Skip only what is genuinely wrong for this estate, and say why here as well
# as at the resource. A global skip stops protecting every other resource the
# rule was right about, so keep this list short and justified.
skip-check:
# Public access on the open-data archive is the product, not a defect —
# deliberate publication is gated by the Conftest intent-tag rule instead.
- CKV_AWS_20 # S3 bucket should not be public-read
- CKV_AWS_57 # S3 bucket should not be public-write (see note below)
# Everything at high severity blocks the merge; the rest annotates it.
hard-fail-on:
- HIGH
soft-fail-on:
- MEDIUM
- LOW
# policy/checkov/parameter_group_memory_units.py
#
# The defect this catches has never appeared in any generic rule set, because
# it is not a security finding: memory parameters on managed PostgreSQL are
# expressed in 8 kB BLOCKS, and a value written as a byte count or a percentage
# string applies cleanly and means something entirely different. On a spatial
# workload that is a real performance regression rather than a warning.
from checkov.common.models.enums import CheckCategories, CheckResult
from checkov.terraform.checks.resource.base_resource_check import BaseResourceCheck
MEMORY_PARAMS = {"shared_buffers", "work_mem", "maintenance_work_mem", "effective_cache_size"}
# 8 kB blocks: 1 MB is 128 blocks, so anything below this is almost certainly
# a value someone believed was bytes.
MIN_PLAUSIBLE_BLOCKS = 128
class ParameterGroupMemoryUnits(BaseResourceCheck):
def __init__(self):
super().__init__(
name="Postgres memory parameters must be integers in 8 kB blocks",
id="CKV_SPATIAL_1",
categories=[CheckCategories.GENERAL_SECURITY],
supported_resources=["aws_db_parameter_group"],
)
def scan_resource_conf(self, conf):
for parameter in conf.get("parameter", []):
entries = parameter if isinstance(parameter, list) else [parameter]
for entry in entries:
name = _first(entry.get("name"))
value = _first(entry.get("value"))
if name not in MEMORY_PARAMS or value is None:
continue
text = str(value)
if text.endswith("%") or not text.lstrip("-").isdigit():
# A percentage string or an expression: not blocks.
return CheckResult.FAILED
if int(text) < MIN_PLAUSIBLE_BLOCKS:
# Below 1 MB — almost certainly a byte count.
return CheckResult.FAILED
return CheckResult.PASSED
def _first(value):
if isinstance(value, list) and value:
return value[0]
return value
check = ParameterGroupMemoryUnits()
# Suppression at the resource: named rule, stated reason, visible in the diff,
# and greppable when someone audits the exceptions later.
resource "aws_s3_bucket" "open_archive" {
bucket = "open-raster-archive"
# checkov:skip=CKV_AWS_20: Deliberate open-data publication. Authorised by
# the PublicIntent tag and gated by the Conftest intent rule; see the
# open-data publication decision record.
# tfsec:ignore:aws-s3-block-public-acls
tags = {
PublicIntent = "open-data-archive"
Approval = "ADR-2026-014"
}
}
Verification
Verify the custom rules by feeding them the defects they exist to catch. A parameter group with shared_buffers = "25%", one with a byte count, one with a correct block value — the first two must fail and the third must pass. Fixture-driven testing of policies is the same discipline as fixture-driven testing of infrastructure, and a custom rule that has never been shown to fail is a rule nobody has verified.
Verify the suppressions still apply. A quarterly grep for checkov:skip and tfsec:ignore across the repository, checked against whether each reason still holds, is a short exercise that catches the suppression added during an incident two years ago for a resource that has since changed purpose entirely.
Verify the scanner sees what you think it sees. Run it against a plan for the real estate rather than only against source, and compare the resource count in its output against the resource count in the plan. A scanner silently skipping a module — because of a parse failure or an unsupported syntax — reports no findings for it, which is indistinguishable from a clean result.
Preventing recurrence
- Ratchet the severity threshold on a schedule. The initial concession to adoption should have an end date, or it becomes the permanent standard.
- Add a custom rule whenever a spatial defect reaches production. Each incident that a generic scanner could never have caught is a candidate rule, and the rule is usually twenty lines.
- Keep the configuration in the repository. Scanner settings applied in a pipeline user interface are unreviewable and drift silently.
- Report both blocking and advisory findings in the pull request. An advisory finding filed somewhere else is a finding nobody reads, and the point of the advisory tier is that it is seen without blocking.
Frequently Asked Questions
Checkov or tfsec — do I need both?
Not strictly. Running both is defensible if each has a clear job — tfsec for fast source feedback, Checkov for plan scanning and custom policies — and each overlapping rule is enabled in only one. Running both with default rule sets produces duplicate findings and halves the attention each receives.
How do I stop hundreds of findings on first adoption?
Fail on high severity only, capture the medium and low findings as a backlog with an owner, and tighten the threshold as the backlog clears. Failing on everything immediately is the reliable way to have the scanner disabled within a fortnight.
Should scanning replace the Conftest policies?
No, they answer different questions. A scanner encodes general hygiene from a rule set someone else maintains; Conftest encodes decisions specific to your estate, such as which buckets may be public and under what declared intent, as described in Blocking Public Raster Buckets with Conftest in CI.
Why scan the plan when the source is already scanned?
Because a plan resolves variables and module inputs. A module whose source is impeccable can be invoked with a parameter that produces a bad resource, and only the plan shows the resulting configuration.
Related
- Testing and Validation for Spatial IaC — the parent topic defining where this layer sits
- Terratest Integration Tests for PostGIS Modules — the slow layer that runs long after this one
- Blocking Public Raster Buckets with Conftest in CI — estate-specific rules alongside generic ones
- Tuning RDS Parameter Groups for PostGIS Workloads — the unit arithmetic the custom rule protects