AWS Builder Center
Testing Terraform for AWS With No AWS Account: 164 Assertions, 115 Planted Violations

Testing Terraform for AWS With No AWS Account: 164 Assertions, 115 Planted Violations

The console gives an ALB listener post-quantum TLS; Terraform gives it a 2016 policy. Testing Terraform for AWS with no account, and proving the tests can fail.

Docker Captain · Field CTO · Technical Editor of "Docker & Kubernetes Security"
Create an HTTPS listener on an Application Load Balancer in the AWS console and it gets a hybrid post-quantum TLS policy by default, ELBSecurityPolicy-TLS13-1-2-Res-PQ-2025-09. Create the same listener through the API, the CLI, CloudFormation or the CDK and the default, by AWS's own documentation , is ELBSecurityPolicy-2016-08. Terraform calls the API, and the AWS provider documents the same 2016 policy as its own default  for aws_lb_listener, in every example on that page. Copy the example and you have shipped a TLS posture from 2016, with nothing in the pipeline that would notice.
I maintain eight public Terraform pipelines for AWS, and I got that line wrong in a release this September. What follows is how those pipelines are tested now: with no AWS account, no credentials and nothing created, and with every test shown the violation it exists to catch before anyone trusts it.
How the listener is createdDefault TLS policyPost-quantum key exchange
AWS consoleELBSecurityPolicy-TLS13-1-2-Res-PQ-2025-09Yes
API, CLI, CloudFormation, CDKELBSecurityPolicy-2016-08No
Terraform, ssl_policy left outELBSecurityPolicy-2016-08, the provider's documented defaultNo
Terraform, an example from the provider docs pasted inELBSecurityPolicy-2016-08, written explicitlyNo

A plan is enough to test a posture

Since Terraform 1.7 , terraform test can replace a provider with a mock. A test file plans the configuration with its default variables against the mocked provider, then asserts what would be built. No AWS account is involved at any point. The CI job that runs it holds no credentials at all, which is the cheapest way to guarantee a test job can never touch production.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# tests/posture.tftest.hcl in the RDS pipeline, run by terraform test, shortened: mocked provider, no AWS account
mock_provider "aws" {}

run "the_defaults_are_the_secure_ones" {
command = plan

assert {
condition = aws_db_instance.db_instance_1.storage_encrypted == true
error_message = "db_instance_1 stores data unencrypted"
}

assert {
condition = aws_db_instance.db_instance_1.publicly_accessible == false
error_message = "db_instance_1 is reachable from the internet"
}
}
Across the eight pipelines, covering RDS, EKS, EC2, Lightsail and Route 53, the test files make 164 assertions. They cover KMS key rotation and the public access blocks, encryption and versioning of S3. They check the state lock table's encryption and point-in-time recovery, IMDSv2 on every instance, invalid-header dropping on the load balancers and the TLS policy on every HTTPS listener, down to VPC flow logs and EKS secrets encryption.
The assertions were written from each configuration's own resources, which produced the more useful result. A test written that way can only assert what the defaults deliver, so every place the defaults fall short had to become a sentence in the README instead of a silent gap. SSH is open to the whole internet in four of the EC2 pipelines. Redis is not encrypted at rest or in transit in two. The EKS API endpoint is public. A reader now sees those before copying anything.
The README now says what the tests cannot.

A test nobody has seen fail is a rumour

Every one of those assertions passed on the first run, which proves nothing on its own. A test that passes because it checks the wrong attribute looks exactly like a test that passes because the configuration is right. In every regulated environment I have worked in, a control was trusted because it was installed, not because anyone had ever watched it fire.
So each pipeline keeps a list of planted violations beside its tests: the smallest edit that breaks one promise, and a sentence saying what the tests must notice. A runner of about eighty lines of Python applies each plant to a fresh copy of the configuration, runs terraform test in a pinned container, and counts what stayed green. On the first run, 109 plants across the eight pipelines broke the promises behind them, and every one was caught.
That number is the evidence. The 164 assertions were only the claim.

The TLS policy I got wrong

Three of these pipelines put GitLab, Keycloak and Nextcloud behind an Application Load Balancer. Their version 1.2.0 moved the HTTPS listeners to ELBSecurityPolicy-TLS13-1-2-2021-06 and called it the policy AWS recommends. It was not. AWS now recommends its hybrid post-quantum policies, ELBSecurityPolicy-TLS13-1-2-Res-PQ-2025-09 or the FIPS variant, and that is what the console already gives you.
Version 1.3.0 moved all three the same day, and the changelog says plainly that 1.2.0 was wrong. It also says what the move costs: the post-quantum policy allows TLS 1.2 only with ECDHE and AES-GCM, so a client that can offer nothing but CBC or non-ECDHE ciphers can no longer connect. Current browsers and git clients can. An old appliance or a pinned SDK may not, and that is the conversation to have before the change, not after it.
The more durable fix is the assertion, which stopped naming a policy and started naming the two properties that matter:
1
2
3
4
5
# tests/posture.tftest.hcl in the GitLab pipeline, run by terraform test: TLS 1.3 and post-quantum key exchange, by name
assert {
condition = strcontains(aws_lb_listener.alb_1_https_listener_1.ssl_policy, "TLS13") && strcontains(aws_lb_listener.alb_1_https_listener_1.ssl_policy, "-PQ-")
error_message = "alb_1_https_listener_1 accepts a policy without TLS 1.3 or without post-quantum key exchange"
}
AWS names its policies by what they offer, and the assertion reads those names. When AWS publishes the next post-quantum policy, the test accepts it without an edit. Two plants in each of the three pipelines try the 2021 policy and a TLS 1.2 one, and both have to turn the test red. Those six took the Terraform total to 115.
If your listeners are created by anything other than the console, read what ssl_policy says. If it says nothing, it says 2016.

Framework: promise, plant, a Terraform plan that has to go red

Layer 1: write the promises and their plants (week 1)

One sentence per promise, in the words a reviewer would use: "the database is not reachable from the internet", not the attribute that makes it so. Start with the promises whose failure would be an audit finding rather than a bug, which on AWS means public access, encryption at rest, IMDSv2, the TLS policy on every listener, and who can assume which role. For each one, write the smallest edit that breaks it. The plant runner reads them from a tab-separated file in which each field is a JSON string, so a plant can span lines and carry quotes:
1
2
3
# tests/plants.tsv in the GitLab pipeline, read by tests/plant_violations.py: file, old text, new text, what the test must notice
"17-alb.tf" "ssl_policy = \"ELBSecurityPolicy-TLS13-1-2-Res-PQ-2025-09\"" "ssl_policy = \"ELBSecurityPolicy-TLS13-1-2-2021-06\"" "the HTTPS listener falls back to a policy without post-quantum key exchange"
"17-alb.tf" "ssl_policy = \"ELBSecurityPolicy-TLS13-1-2-Res-PQ-2025-09\"" "ssl_policy = \"ELBSecurityPolicy-TLS-1-2-2017-01\"" "the HTTPS listener falls back to a policy without TLS 1.3"
Owner: the module's maintainer, with security owning the plants that guard access.

Layer 2: test the plan against a mocked provider, in a pinned image (week 2)

mock_provider "aws" {} and command = plan cover everything a default variable decides, with no credentials anywhere in CI. Run Terraform from an image pinned by digest, so a new Terraform release changes nothing until a person moves the pin:
1
2
3
4
# ci-images.pins and the test step: terraform test, from a digest-pinned image, with no AWS credentials
TERRAFORM_IMAGE=hashicorp/terraform:1.16@sha256:c7926feace05d0f7e73542842bf3945924e955a1f782cf000ccbb8d18fa42d77
docker run --rm -v "$PWD:/mnt" -w /mnt "$TERRAFORM_IMAGE" init -backend=false -input=false -lockfile=readonly
docker run --rm -v "$PWD:/mnt" -w /mnt "$TERRAFORM_IMAGE" test
What a plan cannot see, such as a value only known after apply, belongs in a separate, slower suite against a sandbox account. Keep the two apart, so the fast one never needs a credential.
Owner: platform engineer.

Layer 3: run the plants on every push, and fail on a stale one (week 3 onward)

The plant runner tests the untouched copy first, because a broken suite would make every plant look caught. It gives each plant a fresh copy, so two violations can never mask each other. A plant whose text no longer exists in its file fails the run as stale, because a plant that silently stops applying is a test that silently stops being tested. In CI it sits directly after the tests:
1
2
3
4
5
6
7
8
9
# .github/workflows/terraform-verification.yml in the RDS pipeline, the test job, shortened
- name: terraform test
run: |
docker run --rm -v "$PWD:/mnt" -w /mnt "$TERRAFORM_IMAGE" test

# A test that cannot fail is not a test: each promise is broken on a
# copy of the configuration, and the run fails if the test stays green.
- name: The test, shown each violation it exists for
run: python3 tests/plant_violations.py
When a plant survives, fix the test before anything else. A surviving plant is the only proof you will ever get that a test was decoration.
Owner: platform engineer, with security reviewing any plant that survives.

Tradeoffs

Each plant replans one copy, so a pipeline with eighteen plants runs its test nineteen times: a few minutes on a hosted runner, and not a cent in AWS, because nothing is created. The post-quantum move has a cost of its own, the clients that cannot negotiate it, and that cost belongs in a change record before the listener moves.
Skipping all of it costs less today and more on the day a listener created by a script turns out to have been speaking 2016 for two years. Nobody finds that in a plan review, because nobody reads a default.
A few minutes per push, against a TLS posture nobody chose. Take the minutes.

The closing argument

A default is a decision somebody else made for a different caller. The console decided post-quantum. The API kept 2016, and Terraform inherits whichever one nobody wrote down. Test the property, not the policy name, and make every test prove it can fail before anyone trusts it. A green plan that has never gone red is a guess with a checkmark.

Sources

Discussion

If you test Terraform for AWS another way, or you have found a listener that had been speaking 2016 without anyone choosing it, drop a comment below. Counterarguments are welcome, and the comments are where I answer first.
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article