Responding to an Exposed PostGIS Port Incident

You have just discovered that a security group left PostGIS 5432 open to 0.0.0.0/0, and the clock is now running on a spatial database that holds cadastral records, tenant boundaries, or survey geometry reachable from the open internet. This runbook walks the full incident lifecycle — detection, containment through code rather than the console, credential rotation, access auditing, and permanent closure — as the emergency companion to Security Group Hardening within the broader Network Security & Access Control framework. The stakes are specific to geospatial estates: an exposed PostGIS instance is not just a data-loss risk but a route to poisoning the authoritative geometry that every downstream tile renderer and OGC endpoint trusts.

Symptom Identification and Triage

An exposed-port incident surfaces from one of three sources, and each carries a different confidence level about whether the port was actually reached, not just reachable:

  • AWS Config rule flag: the managed rule restricted-common-ports or vpc-sg-open-only-to-authorized-ports reports a NON_COMPLIANT security group with an ingress rule pairing 5432 with 0.0.0.0/0 or ::/0. This proves reachability from the config plane but says nothing about traffic.
  • GuardDuty finding: a Recon:EC2/PortProbeUnprotectedPort or UnauthorizedAccess:EC2/RDPBruteForce-style finding names the database ENI, confirming that external hosts are actively probing the open port. Treat any GuardDuty finding on 5432 as a live-traffic incident, not a theoretical one.
  • External scan confirmation: an nmap -Pn -p 5432 <public-ip> from outside the VPC returns open and a PostgreSQL banner. This is the definitive proof the port answers to the internet.

Triage classifies the exposure by whether the data plane was touched. Cross-reference the security group’s AuthorizeSecurityGroupIngress event in CloudTrail (who added the rule, when, from which principal) against RDS connection metrics and VPC flow logs for the database ENI. Three outcomes follow, and the branch you land on sets the containment urgency.

Triage decision tree for an exposed PostGIS 5432 port An open-port alert arrives from AWS Config, GuardDuty, or an external nmap scan. Read the VPC flow logs and RDS connection metrics for the database ENI and cross-reference the CloudTrail AuthorizeSecurityGroupIngress event. Three classifications follow. If there is no external ACCEPT in flow logs the exposure is latent — close the rule and back-date the window. If there are external ACCEPT entries but no successful authentication it is probed — close the rule and review credentials as a precaution. If there are successful external connections it is a confirmed breach — isolate immediately, rotate all credentials, and run a full access audit. Open 5432 alert Config · GuardDuty · nmap Read flow logs + RDS metrics for the database ENI Latent no external ACCEPT Probed ACCEPT, no auth success Confirmed breach successful connection Close rule via IaC, back-date exposure window Close rule, then rotate credentials Isolate, rotate all, full access audit Confirm whether the port was reached, not merely reachable, before choosing the containment path.

Prerequisites and Environment Assumptions

Before you touch anything, confirm the operating context this runbook assumes:

  • Tooling. Terraform >= 1.6 with the hashicorp/aws provider pinned at ~> 5.60, the same pinning used across Security Group Hardening. The AWS CLI v2 for CloudTrail and flow-log queries. Contain through the pipeline, not the console — a console hotfix desynchronizes state and is exactly the class of change that opened the port in the first place.
  • State backend. A locked remote backend so the emergency apply cannot race a scheduled pipeline run. Concurrent applies during an incident are how a half-authorized ruleset lingers.
  • IAM. A human-gated break-glass role holding ec2:RevokeSecurityGroupIngress, ec2:AuthorizeSecurityGroupIngress, secretsmanager:UpdateSecret/PutSecretValue, rds:ModifyDBInstance, and read access to CloudTrail and flow logs. The default CI/CD runner identity must never carry revoke rights — that boundary is defined in IAM Role Mapping for GIS.
  • Log coverage. VPC flow logs enabled on the database subnet and CloudTrail management events retained — without them you cannot distinguish “latent” from “confirmed breach,” and you must then treat the incident as a breach by default.

Step-by-Step Remediation

Contain first, investigate second, harden last. The order matters: leaving the port open while you assemble a perfect audit trail extends the exposure window with no benefit.

  1. Contain through code, not the console. The instinct to click “Edit inbound rules” and delete the offending rule is wrong — it fixes the account while leaving the codebase describing an open port, so the next apply re-opens it. Instead, correct the rule in Terraform and apply immediately with a targeted plan. If the pipeline is too slow for the blast radius, use the break-glass role to revoke via CLI and then land the same change in code in the same hour, treating the CLI action as a stopgap that state must catch up to.

    # Stopgap only if the pipeline cannot land in time — mirror into IaC immediately after.
    aws ec2 revoke-security-group-ingress \
      --group-id sg-0abc123postgis \
      --protocol tcp --port 5432 --cidr 0.0.0.0/0
  2. Rotate every credential the exposed database could authenticate. Whether the classification is “probed” or “confirmed,” rotate the PostGIS master password and any application role passwords, because a probe you cannot fully rule out is a probe you must assume succeeded. Rotate in the secrets store so the new value propagates to consumers rather than editing the database by hand.

  3. Audit who connected. Reconstruct the access history from pg_stat_activity (for live sessions), the PostgreSQL log exports in CloudWatch (for authentication attempts and source IPs), and VPC flow logs (for the packet-level ACCEPT records). Any source IP outside your VPC CIDR or egress NAT range on 5432 is an external connection that must be accounted for.

  4. Close the rule permanently and restore tiered trust. Replace the open rule with a security-group-ID reference from the rendering tier plus a scoped analytics CIDR, exactly as the tiered model in Security Group Hardening prescribes. The database should accept 5432 only from workload identities, never from a raw internet range.

  5. Rotate credentials without downtime for consumers. Coordinate the password change with connected tile renderers so the rotation does not itself cause an outage — the dual-secret pattern in Rotating PostGIS Credentials in Terraform Without Downtime lets old and new credentials overlap during the cutover.

The corrected, containment-grade ingress rule in Terraform — an explicit revoke of the open range expressed as its absence, and a scoped replacement:

terraform {
  required_version = ">= 1.6.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.60"
    }
  }
}

variable "rendering_sg_id" { type = string }
variable "analytics_subnet_cidr" { type = string }

# The open 0.0.0.0/0 rule is DELETED by removing it from code — do not keep it.
# PostGIS 5432 is now reachable only from the rendering tier's workload identity.
resource "aws_vpc_security_group_ingress_rule" "postgis_from_rendering" {
  security_group_id            = aws_security_group.data_plane.id
  description                  = "PostGIS 5432 from rendering tier only (post-incident)"
  ip_protocol                  = "tcp"
  from_port                    = 5432
  to_port                      = 5432
  referenced_security_group_id = var.rendering_sg_id
}

resource "aws_vpc_security_group_ingress_rule" "postgis_from_analytics" {
  security_group_id = aws_security_group.data_plane.id
  description       = "PostGIS 5432 from scoped analytics subnet only"
  ip_protocol       = "tcp"
  from_port         = 5432
  to_port           = 5432
  cidr_ipv4         = var.analytics_subnet_cidr
}

resource "aws_security_group" "data_plane" {
  name        = "gis-postgis-prod"
  description = "PostGIS - rendering + analytics only, never public"
  vpc_id      = var.vpc_id
  lifecycle { prevent_destroy = true }
}

variable "vpc_id" { type = string }

Verification

Do not close the incident on the assumption that the apply worked. Prove each layer is contained:

  • Port closed from outside. Re-run nmap -Pn -p 5432 <public-ip> from a host outside the VPC and confirm filtered or closed, not open. This is the single most important check — it proves the internet can no longer reach the port.
  • No open rule remains. aws ec2 describe-security-groups --group-ids sg-0abc123postgis should show no ingress rule with 0.0.0.0/0 or ::/0 on 5432. A terraform plan must report No changes, confirming code and account agree.
  • Config compliant. The restricted-common-ports rule should re-evaluate to COMPLIANT for the group; force an evaluation rather than waiting for the next scheduled sweep.
  • Credentials rotated and live. Confirm the new secret version is AWSCURRENT and that a tile renderer authenticates with it — a SELECT postgis_full_version(); through the rendering path proves the PostGIS Cluster Provisioning baseline still answers on the new credential.
  • No lingering external sessions. SELECT client_addr, usename, state FROM pg_stat_activity; returns only internal addresses.

Preventing Recurrence

Encode the fix so a 0.0.0.0/0 rule on 5432 cannot merge again:

  • Policy-as-code gate. Add an OPA/Conftest or checkov rule to the pull-request pipeline that fails any plan pairing a data-plane port (5432, 6379) with 0.0.0.0/0 or ::/0. This blocks the highest-frequency regression before it reaches the account, the same gating philosophy detailed in Hardening Security Groups for PostGIS Ports.
  • Auto-remediating Config rule. Wire the restricted-common-ports rule to an SSM automation document that revokes the offending rule on detection, so a console edit is closed in minutes even before a human responds.
  • Scheduled drift detection. A nightly terraform plan -detailed-exitcode surfaces any out-of-band rule as a non-zero exit and pages the owning team, rather than waiting for the next deploy.
  • Least-privilege revoke rights. Keep AuthorizeSecurityGroupIngress/RevokeSecurityGroupIngress off the CI/CD runner and behind the human-gated break-glass role, and route every security-group mutation to a central audit log so the next incident has a clean trail from the first minute.

Frequently Asked Questions

Should I fix the open port in the console first to stop the bleeding?

Only as a break-glass stopgap, and only if the pipeline genuinely cannot land the change in time. A console revoke leaves the codebase still describing an open port, so the next apply re-opens it. If you must use the CLI to contain immediately, land the identical change in Terraform within the same hour so state catches up and the rule cannot reappear.

Do I have to rotate credentials if flow logs show no successful connection?

If you have complete flow-log and PostgreSQL authentication coverage and both show no external success, rotation is precautionary rather than mandatory. If log coverage has any gap, treat the exposure as a breach and rotate — you cannot prove a negative without the logs.

How do I rotate the PostGIS password without dropping live tile renderers?

Use a dual-secret overlap: publish the new credential alongside the old, let consumers pick it up, then retire the old value. The full pattern is in Rotating PostGIS Credentials in Terraform Without Downtime, and it is what keeps the rotation itself from becoming a second outage.

Why reference the rendering security group by ID instead of a CIDR?

A security-group-ID reference anchors trust to the workload, so re-IPing or scaling the rendering tier never accidentally opens the database to a stale range. CIDR is reserved for the one tightly scoped analytics subnet that has no security group of its own.