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.

Scanner coverage against a geospatial estate The built-in rules cover general cloud hygiene: encryption at rest, public access, logging, versioning and wildcard principals. On a spatial estate they also produce predictable false positives — an intentionally public raster archive, a requester-pays bucket that looks open to a policy-only rule, and a single-zone read replica that is a deliberate capacity choice. And they miss the entire class of spatial defects, because a memory parameter in the wrong unit, a lifecycle rule that would archive live tiles, an unset spatial reference identifier and a tile service auto-publishing every schema are not security findings and no generic rule set will ever report them. Covered well encryption at rest public access blocks logging and versioning wildcard principals Predictable false positives an open archive that should be open requester-pays read as public single-zone tile-query replica suppress at the resource, never globally Never reported memory in the wrong unit lifecycle archiving live tiles SRID left at a default a tile service auto-publishing None of the right-hand column is a security finding — which is exactly why a generic scanner reports the estate as clean.

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

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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"
  }
}
Suppress at the resource, not in the configuration file A suppression written as a comment on the resource names the specific rule and states the justification, so it appears in the pull-request diff where a reviewer sees it, and it can be found later by searching the codebase for skip markers. A rule disabled globally in a configuration file achieves the same silence for that one resource and simultaneously stops the rule protecting every other resource in the estate where it was correct — and it is invisible at the moment someone adds the ninety-first bucket. Skip at the resource names the specific rule states the reason beside the code appears in the diff for review greppable when exceptions are audited Disable the rule globally silences one resource and ninety others it was right about invisible when the next bucket is added nobody remembers why it is off

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.

No findings, or nothing scanned? A scanner that fails to parse a module, or that does not support a syntax it encounters, reports no findings for that module — an output indistinguishable from a genuinely clean result and considerably more dangerous, because it silently removes coverage from exactly the code nobody has looked at. The check is arithmetic: count the resources the scanner reports having evaluated and compare it with the resource count in the plan. A gap is a module that was never examined, and it should be treated as a failure rather than as a quiet success. Resources in the plan what actually exists in the change the number that should be scanned Resources the scanner evaluated a gap means a module was skipped and skipped reads exactly like clean Treat the gap as a failure. A parse error that removes coverage is worse than a finding, because nobody sees it.

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.