Blocking Public Raster Buckets with Conftest in CI
A publicly readable raster bucket is the most common serious misconfiguration in a geospatial estate, and it is common for an understandable reason: making imagery public is often the goal. A basemap should be public. A published open-data archive should be public. So the rule cannot be “no public buckets” — it has to be “no bucket becomes public except deliberately, through a named exception, reviewed by a human”. Conftest gives you exactly that shape: a Rego policy evaluated against the Terraform plan before merge, where the exception is a tag the plan must carry and the reviewer can see. This guide extends Policy as Code for Spatial Resources within CI/CD Automation and Governance.
The four ways a bucket becomes public
A rule that checks one of them catches a quarter of the problem. All four appear in the plan, and all four must be covered or the gate provides false comfort.
The public access block is absent or incomplete. Four independent settings, and disabling any one of them re-opens a path. A plan that sets three and omits the fourth looks careful and is not.
A bucket policy grants a wildcard principal. "Principal": "*" with s3:GetObject and no condition. This is the deliberate mechanism for a public archive and is exactly why the rule must permit it under an exception rather than forbid it outright.
A grant to AllUsers or AuthenticatedUsers. The legacy access-control-list path. AuthenticatedUsers is the more dangerous of the two because it reads as restrictive and means “any account in the world”.
Website hosting or a permissive CORS policy combined with a broad read. Less absolute but still an exposure route, and worth flagging at a lower severity because the combination is what matters rather than either alone.
Prerequisites and environment assumptions
Terraform 1.6 or later. Conftest available in the pipeline. A plan exported as JSON — Conftest evaluates structured data, and terraform show -json is the input. A convention for the exception: a tag such as PublicIntent = "open-data-archive" plus a second tag naming the approval reference, so the exception is self-documenting in the plan and in the account afterwards.
Decide where the rule runs before writing it. On the plan, before merge, is the right answer: a scanner that finds a public bucket an hour after apply has found a real incident, while a policy that fails the plan has prevented one. The layering rationale is in Testing and Validation for Spatial IaC.
Step-by-step implementation
-
Export the plan as JSON.
terraform show -json tfplan.binary > plan.json. Conftest reads this directly, and the structure worth knowing isresource_changes[], each with atype, achange.afterobject, and achange.actionslist. -
Write the deny rules against
change.after. Theafterobject is the intended end state, which is what you want to reason about. Checkchange.actionstoo, so a resource being destroyed does not trigger a rule about what it would look like if it existed. -
Express the exception as a tag on the bucket, checked in the rule. The rule permits a wildcard-principal policy only when the corresponding bucket carries the intent tag. This keeps the exception visible in the diff, which is the entire mechanism by which a human reviews it.
-
Separate deny from warn. Deny blocks the merge; warn annotates it. The unambiguous routes to public belong in deny; the combination-based one belongs in warn so it is visible without blocking a legitimate change.
-
Test the policy itself. Conftest supports unit tests over fixture plans. A policy that has never been shown to fail a genuinely bad plan is a policy nobody has verified, and the fixture set is what stops a refactor silently disabling a rule.
package terraform.spatial.public_buckets
import future.keywords.contains
import future.keywords.if
import future.keywords.in
# The intent tag is the exception mechanism: it appears in the diff, so a human
# reviews it. Without this the rule would forbid publishing an open archive,
# and a rule that blocks legitimate work gets disabled.
intent_tag := "PublicIntent"
buckets_with_intent contains name if {
some rc in input.resource_changes
rc.type == "aws_s3_bucket"
rc.change.after.tags[intent_tag]
name := rc.change.after.bucket
}
# 1 — all four public access block settings must be true.
deny contains msg if {
some rc in input.resource_changes
rc.type == "aws_s3_bucket_public_access_block"
"delete" not in rc.change.actions
some setting in [
"block_public_acls",
"block_public_policy",
"ignore_public_acls",
"restrict_public_buckets",
]
rc.change.after[setting] != true
msg := sprintf(
"public access block on %q leaves %s disabled — all four must be true",
[rc.change.after.bucket, setting],
)
}
# 2 — a wildcard principal is allowed ONLY on a bucket carrying the intent tag.
deny contains msg if {
some rc in input.resource_changes
rc.type == "aws_s3_bucket_policy"
"delete" not in rc.change.actions
policy := json.unmarshal(rc.change.after.policy)
some stmt in policy.Statement
stmt.Effect == "Allow"
wildcard_principal(stmt)
not rc.change.after.bucket in buckets_with_intent
msg := sprintf(
"bucket policy on %q grants a wildcard principal without a %s tag — tag the bucket if this archive is meant to be open",
[rc.change.after.bucket, intent_tag],
)
}
wildcard_principal(stmt) if stmt.Principal == "*"
wildcard_principal(stmt) if stmt.Principal.AWS == "*"
wildcard_principal(stmt) if {
some p in stmt.Principal.AWS
p == "*"
}
# 3 — legacy ACL grants. AuthenticatedUsers is the dangerous one: it reads as
# restrictive and means every account in the world.
deny contains msg if {
some rc in input.resource_changes
rc.type in {"aws_s3_bucket_acl", "aws_s3_bucket"}
acl := rc.change.after.acl
acl in {"public-read", "public-read-write", "authenticated-read"}
msg := sprintf("bucket %q sets a public ACL (%s)", [rc.change.after.bucket, acl])
}
# 4 — website hosting plus a broad read is an exposure route, but the
# combination is what matters, so warn rather than block.
warn contains msg if {
some rc in input.resource_changes
rc.type == "aws_s3_bucket_website_configuration"
rc.change.after.bucket in buckets_with_intent
msg := sprintf(
"bucket %q hosts a website and is tagged public — confirm only intended prefixes are served",
[rc.change.after.bucket],
)
}
# .github/workflows/spatial-policy.yml
name: Spatial policy gate
on: [pull_request]
jobs:
policy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- name: Plan and export
run: |
terraform init -input=false
terraform plan -out=tfplan.binary -input=false
terraform show -json tfplan.binary > plan.json
- name: Policy unit tests
# A policy that has never been shown to fail a bad fixture is a policy
# nobody has verified. This runs before the real plan is judged.
run: conftest verify --policy policy/
- name: Evaluate the plan
run: conftest test plan.json --policy policy/ --all-namespaces
Verification
Verify the policy by feeding it plans that should fail. Build fixtures for each of the four routes and confirm each produces a denial with a message a reader can act on; then build the same fixtures with the intent tag and confirm the wildcard-principal case now passes while the incomplete-access-block case still fails, because that one is never intentional regardless of publication intent.
Then verify against the real estate. Run the policy over a plan for the existing infrastructure — a no-op plan will not exercise it, so run it against a plan that re-creates the resources, or use the account scanner as a one-time cross-check. A gate that has only ever seen synthetic input has not been shown to understand your actual Terraform.
Preventing recurrence
- Keep the exception tag in one place and grep for it. A quarterly review listing every bucket carrying
PublicIntentis a short, high-value audit, and one that is only possible because the exception is a tag rather than a code comment. - Run the same policy on a schedule against deployed state. The plan gate catches proposals; a scheduled evaluation catches a bucket made public in the console, which the plan gate never sees. The pairing with Drift Detection and Remediation is what makes coverage complete.
- Version the policy alongside the modules. A module change that adds a new bucket resource type needs a policy update, and coupling their versions makes the omission visible.
- Write the denial messages for the person who will read them. A message naming the bucket, the route and the remedy resolves itself; a message saying “policy violation” generates a conversation.
Frequently Asked Questions
Why allow public buckets at all?
Because publishing open geospatial data is a legitimate and often central purpose of the platform. A rule that forbids it outright blocks real work, and a rule that blocks real work is disabled within a quarter — at which point nothing is checked. Requiring the intent to be declared keeps the rule alive.
Conftest, OPA, or Sentinel?
Conftest is the lightest path for evaluating plan JSON in a pipeline and needs no server. OPA proper is the same language with more deployment options. Sentinel is tied to a specific platform. For a policy that runs in a pull-request pipeline, Conftest is usually the shortest route to a working gate.
Should the policy run before or after the cost estimate?
Either, but both before the integration tests. Policy and cost are fast evaluations over the same plan artifact and can run in parallel; the expensive layer is what should wait for them.
What about buckets created outside Terraform?
The plan gate cannot see them, by definition. That is what the scheduled evaluation against deployed state is for, and it is also an argument for closing console write access to bucket configuration in the first place.
Related
- Policy as Code for Spatial Resources — the parent topic on governing spatial infrastructure by rule
- Enforcing Tagging and Sizing Policies with OPA for PostGIS — the sibling policy covering the database tier
- Static Analysis with Checkov and tfsec for Geospatial Stacks — the faster, less specific layer that runs before this one
- Requester Pays Buckets for Public Raster Archives — the intentional-publication case this policy is designed to permit