terraform fixes
From GAP to OK: the Terraform behind each fix.
Each fix comes from a Terraform change we applied and re‑scanned in our own AWS lab: the GAP posture, the fixed posture, and the tests the re‑scan saw change. Where an excerpt differs from what the lab applied, the fix says so.
- Lab-proven in a Vishcore-owned sandbox, not a client environment.
- Minimal change observed by the scanner, not a complete or production-ready configuration.
- Not an audit opinion. An OK row means the scanner observed the fixed posture at scan time.
how we proved them
One lab, two modes, and a re‑scan.
Our lab runs one Terraform configuration in two modes, gap and fixed. On 2026-10-23, Vishcore scanned it in gap mode, applied fixed mode, scanned it again, and compared the two scans test by test. Both scans evaluated the same 193 tests in us-east-1.
Each excerpt shows only the attributes that change between the two modes, under a minimal resource header. A # ... line marks what is left out, and names use the placeholder example. The excerpts are partial, so they do not run on their own.
fix by fix
Each fix: the re‑scan, then the Terraform.
Every fix lists the tests whose result changed in the re‑scan, then the gap and fixed Terraform. Pick one to jump to it.
Fix 1 of 11
EC2 exposure: world-open rules, public IP, instance profile
The fixed posture removes the world-open tcp/22 and tcp/8080 rules, drops the instance's public IP, and attaches an instance profile with the AWS managed SSM policy, so administration runs through Session Manager instead of inbound ports. The lab attaches the managed policy by its literal policy ARN; this excerpt resolves it by name to keep ARNs off this page. Toggling the public IP replaces the instance.
Lab-only fact: do not copy
No internet path. The lab VPC has no internet gateway and no NAT. The GAP instance's public IP and every world-open rule were unreachable by design. A real environment with an internet gateway turns the same GAP posture into live exposure.
Re‑scan
- Security groups do not expose SSH to the internet Before: GAP After: OK
- No EC2 instance is attached to a security group that opens SSH to the internet Before: GAP After: OK
- Internet-reachable EC2 instances expose only approved ports Before: GAP After: OK
- EC2 instances do not have public IP addresses Before: GAP After: OK
- EC2 instances run with an IAM instance profile Before: GAP After: OK
resource "aws_vpc_security_group_ingress_rule" "exposure_world_open" {
for_each = { ssh = 22, app = 8080 }
security_group_id = aws_security_group.exposure.id
ip_protocol = "tcp"
from_port = each.value
to_port = each.value
cidr_ipv4 = "0.0.0.0/0"
}
resource "aws_instance" "app" {
# ...
associate_public_ip_address = true
iam_instance_profile = null
} # No world-open ingress rules exist (for_each = {}).
data "aws_iam_policy_document" "app_assume_role" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["ec2.amazonaws.com"]
}
}
}
data "aws_iam_policy" "ssm_core" {
name = "AmazonSSMManagedInstanceCore"
}
resource "aws_iam_role" "app_ssm" {
name = "example-ssm"
assume_role_policy = data.aws_iam_policy_document.app_assume_role.json
}
resource "aws_iam_role_policy_attachment" "app_ssm" {
role = aws_iam_role.app_ssm.name
policy_arn = data.aws_iam_policy.ssm_core.arn
}
resource "aws_iam_instance_profile" "app" {
name = "example-ssm"
role = aws_iam_role.app_ssm.name
}
resource "aws_instance" "app" {
# ...
associate_public_ip_address = false
iam_instance_profile = aws_iam_instance_profile.app.name
} Fix 2 of 11
RDS security group: world-open database port
The fixed posture deletes the tcp/5432 rule from 0.0.0.0/0 on the security group shared by the database and the instance. PostgreSQL stays reachable only from inside the VPC through the internal group, which exists unchanged in both modes.
Lab-only fact: do not copy
No internet path. The lab VPC has no internet gateway and no NAT. The GAP instance's public IP and every world-open rule were unreachable by design. A real environment with an internet gateway turns the same GAP posture into live exposure.
Re‑scan
- Security groups keep database, cache, and search ports off the internet Before: GAP After: OK
- RDS databases are not attached to security groups open to the internet Before: GAP After: OK
resource "aws_vpc_security_group_ingress_rule" "exposure_world_open_db" {
security_group_id = aws_security_group.exposure.id
ip_protocol = "tcp"
from_port = 5432
to_port = 5432
cidr_ipv4 = "0.0.0.0/0"
} # The world-open tcp/5432 rule does not exist (count = 0).
# Database access stays on the internal group, scoped to the VPC CIDR:
resource "aws_security_group" "internal" {
# ...
ingress {
from_port = 5432
to_port = 5432
protocol = "tcp"
cidr_blocks = [aws_vpc.example.cidr_block]
}
} Fix 3 of 11
EBS volume and snapshot encryption
A snapshot inherits encryption from its source volume, so encrypting the volume produces an encrypted snapshot. Toggling encryption replaces both the volume and the snapshot. Turning on EBS encryption by default for the Region covers new volumes without per-resource changes; the lab keeps it off only so the unencrypted GAP volume can exist.
Re‑scan
- EBS snapshots owned by the account are encrypted Before: GAP After: OK
resource "aws_ebs_volume" "posture" {
# ...
encrypted = false
}
resource "aws_ebs_snapshot" "posture" {
volume_id = aws_ebs_volume.posture.id
} resource "aws_ebs_volume" "posture" {
# ...
encrypted = true
}
resource "aws_ebs_snapshot" "posture" {
volume_id = aws_ebs_volume.posture.id
} Fix 4 of 11
RDS backups and retention
Note: the lab applied backup_retention_period = 1. That value turned automated backups on, which flipped the first two rows to OK, and it moved “RDS backup retention is at least 7 days” from OK to GAP because one day is below the 7-day minimum. The lab's 1-day value is toggle proof only, never the recommended value. A value of 7 or higher satisfies all three tests; the lab did not apply 7, so the third row is not lab-proven at OK.
Re‑scan
- RDS databases have automated backups or retained snapshots Before: GAP After: OK
- RDS instances and clusters take automated backups Before: GAP After: OK
- RDS backup retention is at least 7 days Before: OK After: GAP (lab 1-day value; see note)
The retention trap
The retention example shows 7 days; the lab applied 1 day as toggle proof, and the 7-day row is not lab-proven at OK.
In GAP mode retention was 0, which the minimum test does not count as a short retention, so it read OK; one day is a real retention below the 7-day minimum, so it reads GAP.
resource "aws_db_instance" "example" {
# ...
backup_retention_period = 0
} resource "aws_db_instance" "example" {
# ...
backup_retention_period = 7 # scanner minimum is 7 days; the lab used 1 as toggle proof only
} Fix 5 of 11
RDS TLS, pgaudit, and automatic minor version upgrades
rds.force_ssl = 1 makes PostgreSQL reject unencrypted client connections, and loading pgaudit through shared_preload_libraries makes audit logging available. shared_preload_libraries is a static parameter: it takes effect only after the database reboots. Automatic minor version upgrades are an instance attribute, not a parameter-group setting.
Re‑scan
- RDS databases reject unencrypted client connections Before: GAP After: OK
- PostgreSQL databases load the pgaudit extension Before: GAP After: OK
- RDS instances receive automatic minor version upgrades Before: GAP After: OK
resource "aws_db_parameter_group" "example" {
family = "postgres18"
parameter {
name = "rds.force_ssl"
value = "0"
apply_method = "immediate"
}
}
resource "aws_db_instance" "example" {
# ...
parameter_group_name = aws_db_parameter_group.example.name
auto_minor_version_upgrade = false
} resource "aws_db_parameter_group" "example" {
family = "postgres18"
parameter {
name = "rds.force_ssl"
value = "1"
apply_method = "immediate"
}
parameter {
name = "shared_preload_libraries"
value = "pgaudit"
apply_method = "pending-reboot"
}
}
resource "aws_db_instance" "example" {
# ...
parameter_group_name = aws_db_parameter_group.example.name
auto_minor_version_upgrade = true
} Fix 6 of 11
RDS snapshot encryption
In GAP mode no RDS snapshot existed, so both tests were not applicable. Fixed mode created an automated snapshot through backup retention and one manual snapshot. Both inherit encryption from the encrypted database, and neither is shared publicly. The lab never creates an unencrypted database, so the GAP path for these tests is proven against the scanner's test inputs, not in the lab.
See the retention trap in fix 4Re‑scan
- RDS snapshots are encrypted at rest Before: Not applicable After: OK
- Manual RDS DB snapshots are not shared publicly Before: Not applicable After: OK
resource "aws_db_instance" "example" {
# ...
storage_encrypted = true # both modes
backup_retention_period = 0 # no automated snapshot
}
# No manual snapshot exists. resource "aws_db_instance" "example" {
# ...
storage_encrypted = true # both modes
backup_retention_period = 7 # lab used 1; see section 4
}
resource "aws_db_snapshot" "example" {
db_instance_identifier = aws_db_instance.example.identifier
db_snapshot_identifier = "example-pg-manual"
} Fix 7 of 11
ALB TLS policy
The legacy 2016 policy allows TLS 1.0 and is not FIPS. The TLS 1.3 FIPS policy sets a TLS 1.2 floor and uses FIPS-validated ciphers, which satisfies both tests with one attribute change. The lab listener used a self-signed certificate; production listeners use an ACM-issued certificate.
Lab-only fact: do not copy
Short-lived self-signed certificates. The GAP-only IAM server certificate expired one hour after apply, and the ALB HTTPS listener used a self-signed IAM server certificate because the lab guardrails exclude ACM. Production listeners use an ACM-issued certificate.
Re‑scan
- ALB HTTPS listeners negotiate TLS 1.2 or newer only Before: GAP After: OK
- ALB HTTPS listeners use a FIPS security policy Before: GAP After: OK
resource "aws_lb_listener" "https" {
# ...
port = 443
protocol = "HTTPS"
ssl_policy = "ELBSecurityPolicy-2016-08"
} resource "aws_lb_listener" "https" {
# ...
port = 443
protocol = "HTTPS"
ssl_policy = "ELBSecurityPolicy-TLS13-1-2-FIPS-2023-04"
} Fix 8 of 11
CloudWatch alarms: RDS, ALB, EC2
Fixed mode adds one alarm per monitored metric. The scanner checks that each alarm exists on the right metric and resource; the lab thresholds are placeholders, so a real environment sets thresholds from its own baseline and adds alarm actions that notify an owner. The unhealthy-host alarm needs both the load balancer and the target group dimensions.
Re‑scan
- RDS databases have a CPU utilization alarm Before: GAP After: OK
- RDS databases have a free storage alarm Before: GAP After: OK
- RDS databases have a freeable memory alarm Before: GAP After: OK
- RDS databases have a read or write I/O alarm Before: GAP After: OK
- Load balancers have a target response time alarm Before: GAP After: OK
- Load balancers have a 5xx error alarm Before: GAP After: OK
- Load balancers have an unhealthy host alarm Before: GAP After: OK
- Load balancers have any CloudWatch alarm (superseded by the three metric tests) Before: GAP After: OK
- EC2 instances have a CPU utilization alarm Before: GAP After: OK
- The account has at least one CloudWatch alarm Before: GAP After: OK
# No CloudWatch metric alarms exist (rds_alarms = {}, alb_alarms = {}, ec2_cpu count = 0). locals {
rds_alarms = {
cpu = { metric = "CPUUtilization", stat = "Average", op = "GreaterThanThreshold", threshold = 80 }
storage = { metric = "FreeStorageSpace", stat = "Average", op = "LessThanThreshold", threshold = 2147483648 }
memory = { metric = "FreeableMemory", stat = "Average", op = "LessThanThreshold", threshold = 134217728 }
io = { metric = "ReadIOPS", stat = "Average", op = "GreaterThanThreshold", threshold = 1000 }
}
alb_alarms = {
latency = { metric = "TargetResponseTime", stat = "Average", op = "GreaterThanThreshold", threshold = 2 }
server_errors = { metric = "HTTPCode_ELB_5XX_Count", stat = "Sum", op = "GreaterThanThreshold", threshold = 10 }
unhealthy_host = { metric = "UnHealthyHostCount", stat = "Maximum", op = "GreaterThanThreshold", threshold = 0 }
}
}
resource "aws_cloudwatch_metric_alarm" "rds" {
for_each = local.rds_alarms
alarm_name = "example-rds-${each.key}"
namespace = "AWS/RDS"
metric_name = each.value.metric
statistic = each.value.stat
comparison_operator = each.value.op
threshold = each.value.threshold
period = 300
evaluation_periods = 1
dimensions = { DBInstanceIdentifier = aws_db_instance.example.identifier }
}
resource "aws_cloudwatch_metric_alarm" "alb" {
for_each = local.alb_alarms
alarm_name = "example-alb-${each.key}"
namespace = "AWS/ApplicationELB"
metric_name = each.value.metric
statistic = each.value.stat
comparison_operator = each.value.op
threshold = each.value.threshold
period = 300
evaluation_periods = 1
dimensions = each.key == "unhealthy_host" ? {
LoadBalancer = aws_lb.example.arn_suffix
TargetGroup = aws_lb_target_group.example.arn_suffix
} : {
LoadBalancer = aws_lb.example.arn_suffix
}
}
resource "aws_cloudwatch_metric_alarm" "ec2_cpu" {
alarm_name = "example-ec2-cpu"
namespace = "AWS/EC2"
metric_name = "CPUUtilization"
statistic = "Average"
comparison_operator = "GreaterThanThreshold"
threshold = 80
period = 300
evaluation_periods = 1
dimensions = { InstanceId = aws_instance.app.id }
} Fix 9 of 11
CloudTrail: CloudWatch Logs delivery, log bucket access logging, S3 data events, logging active
Fixed mode turns the trail on, delivers its events to a CloudWatch Logs group through a CloudTrail-assumed role, adds an advanced selector for S3 object-level data events, and records server access logs for the trail log bucket in a second private bucket. The lab trail is single-Region. The lab's fixed mode also adds cross-Region replication of the trail bucket; that change produced no status transition in the re‑scan and is omitted here.
Re‑scan
- CloudTrail trails are actively logging Before: GAP After: OK
- CloudTrail trails deliver to CloudWatch Logs Before: GAP After: OK
- A CloudTrail trail records S3 object-level events Before: GAP After: OK
- CloudTrail log buckets record server access logs Before: GAP After: OK
resource "aws_cloudtrail" "example" {
# ...
enable_logging = false
cloud_watch_logs_group_arn = null
cloud_watch_logs_role_arn = null
advanced_event_selector {
name = "management-events"
field_selector {
field = "eventCategory"
equals = ["Management"]
}
}
}
# No server access logging on the trail log bucket. resource "aws_cloudwatch_log_group" "trail" {
name = "/aws/cloudtrail/example"
retention_in_days = 365
}
resource "aws_iam_role" "trail_cwl" {
name = "example-trail-cwl"
assume_role_policy = data.aws_iam_policy_document.trail_cwl_assume.json # cloudtrail.amazonaws.com
}
resource "aws_iam_role_policy" "trail_cwl" {
role = aws_iam_role.trail_cwl.id
policy = data.aws_iam_policy_document.trail_cwl.json # logs:CreateLogStream, logs:PutLogEvents on the group
}
resource "aws_cloudtrail" "example" {
# ...
enable_logging = true
cloud_watch_logs_group_arn = "${aws_cloudwatch_log_group.trail.arn}:*"
cloud_watch_logs_role_arn = aws_iam_role.trail_cwl.arn
advanced_event_selector {
name = "management-events"
field_selector {
field = "eventCategory"
equals = ["Management"]
}
}
advanced_event_selector {
name = "all-s3-object-events"
field_selector {
field = "eventCategory"
equals = ["Data"]
}
field_selector {
field = "resources.type"
equals = ["AWS::S3::Object"]
}
}
}
resource "aws_s3_bucket_logging" "trail_logs" {
bucket = aws_s3_bucket.trail_logs.id
target_bucket = aws_s3_bucket.trail_access_logs.id # private bucket that allows logging.s3.amazonaws.com to write
target_prefix = "trail-bucket/"
} Fix 10 of 11
CloudWatch log group retention
The CloudTrail delivery group from fix 9 is the only log group in the lab, so both tests were not applicable in GAP mode. Fixed mode creates it with an explicit 365-day retention. Without retention_in_days, a log group keeps events indefinitely and this test reports a gap.
Re‑scan
- CloudWatch log group inventory was observed Before: Not applicable After: OK
- CloudWatch log groups have a retention period Before: Not applicable After: OK
# No CloudWatch Logs log group exists in scope. resource "aws_cloudwatch_log_group" "trail" {
name = "/aws/cloudtrail/example"
retention_in_days = 365
} Fix 11 of 11
IAM user permissions via groups
Fixed mode removes the direct user attachment and grants the same policy through a group membership. The lab policy denies everything and the lab user has no console login or access key, so the change moves only where the permission is attached.
Re‑scan
- IAM users receive permissions only through groups Before: GAP After: OK
resource "aws_iam_user_policy_attachment" "hygiene_direct" {
user = aws_iam_user.hygiene.name
policy_arn = aws_iam_policy.hygiene_deny_all.arn
} resource "aws_iam_group" "hygiene" {
name = "example-hygiene"
}
resource "aws_iam_group_policy_attachment" "hygiene" {
group = aws_iam_group.hygiene.name
policy_arn = aws_iam_policy.hygiene_deny_all.arn
}
resource "aws_iam_user_group_membership" "hygiene" {
user = aws_iam_user.hygiene.name
groups = [aws_iam_group.hygiene.name]
} Want your own gaps fixed in Terraform?
Start with a short note. A read‑only Cloud Control Scan finds the gaps first; Audit Blocker Remediation then scopes Terraform-backed fixes for the blockers.
Browse the AWS test library