---
title: "Terraform Fix Examples | Vishcore Inc."
description: "Terraform fixes proven in a Vishcore-owned AWS lab: for each one, the gap and fixed HCL beside the re-scan rows that changed. Not a client environment."
canonical: https://vishcore.com/resources/terraform-fixes
---

1. [Resources](https://vishcore.com/resources)
2. Terraform Fix Examples

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.

[Start discovery](https://vishcore.com/start-discovery) Jump to the fixes

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

The lab run

**Fixes**

11 families of Terraform changes

**Lab run**

2026-10-23, us-east-1, 193 tests in each scan

**GAP to OK**

30 tests

**Not applicable to OK**

4 tests

**OK to GAP**

1 test: the retention trap in fix 4

**Markdown**

[/resources/terraform-fixes.md](https://vishcore.com/resources/terraform-fixes.md)

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.

1. 01 EC2 exposure 5 tests
2. 02 RDS security group 2 tests
3. 03 EBS volume and snapshot encryption 1 test
4. 04 RDS backups and retention 3 tests
5. 05 RDS TLS, pgaudit, and automatic minor version upgrades 3 tests
6. 06 RDS snapshot encryption 2 tests
7. 07 ALB TLS policy 2 tests
8. 08 CloudWatch alarms 10 tests
9. 09 CloudTrail 4 tests
10. 10 CloudWatch log group retention 2 tests
11. 11 IAM user permissions via groups 1 test

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

Gap posture

```
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
}
```

Fixed posture

```
# 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
}
```

Back to the fix index

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

Gap posture

```
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"
}
```

Fixed posture

```
# 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]
  }
}
```

Back to the fix index

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

Gap posture

```
resource "aws_ebs_volume" "posture" {
  # ...
  encrypted = false
}

resource "aws_ebs_snapshot" "posture" {
  volume_id = aws_ebs_volume.posture.id
}
```

Fixed posture

```
resource "aws_ebs_volume" "posture" {
  # ...
  encrypted = true
}

resource "aws_ebs_snapshot" "posture" {
  volume_id = aws_ebs_volume.posture.id
}
```

Back to the fix index

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.

Gap posture

```
resource "aws_db_instance" "example" {
  # ...
  backup_retention_period = 0
}
```

Fixed posture (recommended example)

```
resource "aws_db_instance" "example" {
  # ...
  backup_retention_period = 7 # scanner minimum is 7 days; the lab used 1 as toggle proof only
}
```

Back to the fix index

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

Gap posture

```
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
}
```

Fixed posture

```
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
}
```

Back to the fix index

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 4

Re-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

Gap posture

```
resource "aws_db_instance" "example" {
  # ...
  storage_encrypted       = true # both modes
  backup_retention_period = 0    # no automated snapshot
}

# No manual snapshot exists.
```

Fixed posture

```
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"
}
```

Back to the fix index

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

Gap posture

```
resource "aws_lb_listener" "https" {
  # ...
  port       = 443
  protocol   = "HTTPS"
  ssl_policy = "ELBSecurityPolicy-2016-08"
}
```

Fixed posture

```
resource "aws_lb_listener" "https" {
  # ...
  port       = 443
  protocol   = "HTTPS"
  ssl_policy = "ELBSecurityPolicy-TLS13-1-2-FIPS-2023-04"
}
```

Back to the fix index

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

Gap posture

```
# No CloudWatch metric alarms exist (rds_alarms = {}, alb_alarms = {}, ec2_cpu count = 0).
```

Fixed posture

```
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 }
}
```

Back to the fix index

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

Gap posture

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

Fixed posture

```
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/"
}
```

Back to the fix index

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

Gap posture

```
# No CloudWatch Logs log group exists in scope.
```

Fixed posture

```
resource "aws_cloudwatch_log_group" "trail" {
  name              = "/aws/cloudtrail/example"
  retention_in_days = 365
}
```

Back to the fix index

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

Gap posture

```
resource "aws_iam_user_policy_attachment" "hygiene_direct" {
  user       = aws_iam_user.hygiene.name
  policy_arn = aws_iam_policy.hygiene_deny_all.arn
}
```

Fixed posture

```
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]
}
```

Back to the fix index

## 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](https://vishcore.com/resources/test-library)

[Start discovery](https://vishcore.com/start-discovery)
