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.

Four independent routes to a public bucket The public access block comprises four separate settings and disabling any one re-opens a path, so a plan that sets three and omits the fourth looks careful but is not. A bucket policy granting a wildcard principal read access is the deliberate mechanism for publishing an open archive, which is why a policy must allow it under an exception rather than forbid it. A legacy access control list grant to AllUsers or AuthenticatedUsers is the third route, and AuthenticatedUsers is the more dangerous because it reads as restrictive while meaning any account in the world. Website hosting combined with a broad read grant is the fourth and belongs at a lower severity, because it is the combination rather than either element that creates exposure. 1 · Public access block incomplete four independent settings — any one disabled re-opens a path three of four looks careful and is not 2 · Wildcard principal in the policy the deliberate mechanism for an open archive so allow it by exception, never forbid it outright 3 · Legacy ACL grant AllUsers, or AuthenticatedUsers the second reads as restrictive and means any account anywhere 4 · Website hosting plus broad read the combination is the exposure, not either alone warn rather than deny

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

  1. Export the plan as JSON. terraform show -json tfplan.binary > plan.json. Conftest reads this directly, and the structure worth knowing is resource_changes[], each with a type, a change.after object, and a change.actions list.

  2. Write the deny rules against change.after. The after object is the intended end state, which is what you want to reason about. Check change.actions too, so a resource being destroyed does not trigger a rule about what it would look like if it existed.

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

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

  5. 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
The exception is a tag, so a human sees it in the diff A plan that would make a bucket publicly readable is evaluated by the policy. Without the intent tag the rule denies it and the merge is blocked, with a message naming the bucket and the tag that would authorise it. With the intent tag and an approval reference present, the rule passes, and because the tag is part of the plan diff a human reviewer sees the intent explicitly stated in the change under review. This is what makes the gate durable: a rule that forbade public buckets outright would block legitimate open-data publication and would eventually be disabled, while a rule that requires the intent to be declared is one nobody needs to work around. Plan makes it public by any of the four routes PublicIntent tag present? no Denied — merge blocked the message names the tag that would authorise it yes Allowed — and visible in the diff the reviewer sees the intent stated in the change

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.

Fixture pairs that prove the policy behaves Each of the four routes to a public bucket becomes a fixture plan that must produce a denial with an actionable message. The same fixtures are then repeated with the publication intent tag present. The wildcard-principal case must now pass, because that is the deliberate mechanism the exception exists for. The incomplete public-access-block case must still fail even with the tag, because leaving one of the four settings disabled is never intentional regardless of whether the archive is meant to be published. A policy that passes both halves of that pair is one that has actually been verified. Fixture without the tag with the tag wildcard principal in the bucket policy denied allowed legacy ACL grant to AuthenticatedUsers denied denied one of four access-block settings disabled denied denied never intentional, whatever the publication intent

Preventing recurrence

  • Keep the exception tag in one place and grep for it. A quarterly review listing every bucket carrying PublicIntent is 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.