Tf Best Practices
AWSOfficialPublished by AWS
- Skill
- Tool integration
- Cloud and DevOps
- Apache-2.0
- Low risk
- Has scripts
AutoGineer summary
Written by AutoGineer from the skill’s own files, not by the author
tf-best-practices provides a structured approach to authoring and validating AWS Terraform code. It functions as a two-part system: an authoring guide that defines security posture rules before code generation, and a read-only policy gate that evaluates generated Terraform against those rules after it is written. The skill is designed to be integrated into migration workflows, providing deterministic verdicts and actionable fix hints without modifying the underlying files.
What it does
- Provides security posture rules for authoring AWS Terraform.
- Executes a read-only policy check on generated Terraform directories.
- Generates machine-readable JSON reports detailing policy violations.
- Offers specific fix hints for identified infrastructure issues.
- Validates migration artifacts including database and deployment configurations.
- Supports a multi-stage validation protocol including fmt, init, and validate.
When to use it
- Before writing Terraform code to ensure adherence to security posture rules.
- After generating Terraform to verify compliance with defined policy gates.
- When integrating automated validation into a migration workflow.
- To identify and remediate infrastructure configuration issues during development.
Advantages
- Enforces consistent security standards across generated infrastructure.
- Provides actionable feedback with file and line-specific remediation hints.
- Operates as a read-only tool that does not modify user files.
- Functions without requiring provider downloads or active registry connections.
- Integrates into existing migration pipelines as a deterministic gate.
Usage examples
- “Check the generated terraform/ directory for policy violations.”
- The skill runs the policy checker against the specified directory and returns a JSON report containing any identified violations and corresponding fix hints.
- “What are the security requirements for an internet-facing ALB?”
- The skill provides the posture rules requiring an HTTPS listener on port 443 and an HTTP redirect to HTTPS on port 80.
Install
Download the skill as a ZIP:
https://mp-staging.autogineer.com/agent-skills/aws/tf-best-practices/downloadUnzip it into the cross-client skills folder of your project:
mkdir -p .agents/skills && unzip tf-best-practices.zip -d .agents/skills
Documentation by AWS
tf-best-practices — Generated-IaC posture rules + read-only policy gate
A shared authoring guide and verdict producer, not a workflow. It answers two questions for a phase that generates AWS Terraform:
Before writing — "what security posture must the generated
terraform/follow?" (the posture rules + thebaseline.tfaccount-hardening spec)After writing — "does the generated
terraform/pass policy?" (a deterministic, read-only verdict + a machine-readable report)
Routing — load the part that matches your context
This skill is entered at two touchpoints in the caller's Generate flow, with the caller's own terraform-authoring work in between. The caller states which touchpoint it is at when it loads this skill, and reads the corresponding part:
| Caller context | Load | Why |
|---|---|---|
About to author terraform/ (before writing) | Part 1 → references/security-posture-rules.md | The "what to emit" AWS authoring rules (gate-enforced + authoring-only + compliance-conditional). |
terraform/ written, ready to validate (after writing) | Part 2 → references/terraform-validation.md + run the gate script | The fmt → init → validate → policy protocol and the read-only verdict. |
Everything this skill states is source-cloud-agnostic (pure AWS Terraform). Any GCP/Heroku
detection or artifact reading is the caller's job; where a rule needs a caller-known fact (e.g.
declared compliance frameworks), the caller passes it as a caller-context signal — see
references/security-posture-rules.md § Caller-context signals.
Boundary (read this first)
This unit is a verdict producer, never a mutator. Its entire write surface is the JSON verdict it is asked to emit. Specifically it MUST NOT:
edit, format, or rewrite any
.tffile (the caller owns remediation),read or write
.phase-status.jsonor any run-state file (interpreter-owned),decide whether a phase may complete, or prompt the user (caller policy).
The caller (a migration skill's Generate phase) owns: the fix-and-retry loop that edits
the .tf it generated, terraform fmt auto-apply, the retry/skip/abort prompt, the
Phase Completion gate, and every .phase-status.json write. See the consuming skill's
generate phase for how the verdict feeds those decisions.
Consumers:
gcp-to-aws(prose Generate) andheroku-to-aws(DSL Generate). The contract is source-agnostic; each caller wires the two touchpoints in its own Generate idiom — gcp-to-aws as prose steps, heroku-to-aws as a fragment step plus a fail-closed_postconditionsassert enforced by the interpreter.
Part 1 — Authoring posture (load before writing terraform/)
Emit generated Terraform that satisfies the posture in
references/security-posture-rules.md.
These are the "what good AWS Terraform looks like" rules. Following them makes the Part 2 gate pass by construction. This unit does not read the caller's artifacts — it consumes only caller-context signals the caller passes in.
Scope.
security-posture-rules.mdcovers, in three tiers:
Gate-enforced (Part 2 verifies statically): ALB TLS, no-public-database, RDS + ElastiCache encryption-at-rest, no-public-DB-port ingress, no-public admin/datastore-port ingress, no-wildcard-IAM.
Authoring-only (not gate-checkable, still required):
deletion_protection, master-password-via-Secrets-Manager, S3 hardening, Fargate/EKS/ECR settings, private-subnet placement, backups, baseline monitoring.Compliance-conditional (emitted when the caller declares
soc2/pci/hipaa/fedramp): VPC flow logs, S3 access logging, secret rotation, customer-managed KMS.Still the caller's own generation concern (candidates to migrate here later): the account-hardening
baseline.tflayer (CloudTrail, GuardDuty, Config, Security Hub).
Part 2 — Policy gate (run after writing terraform/)
Run the read-only checker against the generated directory. Resolve the script path relative to
the plugin root ($PLUGIN_ROOT/skills/tf-best-practices/scripts/...), the same convention the
plugin uses for its other scripts:
python3 "$PLUGIN_ROOT/skills/tf-best-practices/scripts/validate-terraform-policy.py" "$TERRAFORM_DIR" --json "$VERDICT_PATH"$TERRAFORM_DIR— required, caller-supplied: the generatedterraform/directory (e.g.$MIGRATION_DIR/terraform). This skill never defaults or discovers it — the caller always passes the path it wrote Terraform to.--json $VERDICT_PATH— optional; writes a machine-readable verdict the caller can merge into its ownvalidation-report.json.
The policy check is one stage of a larger validation flow (fmt → init → validate → policy).
The full protocol — including offline-fallback behavior and how the policy verdict maps into a
validation-report.json — is documented in
references/terraform-validation.md. That protocol is
descriptive: the caller owns the fmt/init/validate execution, the fix-and-retry loop, and
the report write; this unit contributes only the read-only policy stage + verdict shape.
Before infrastructure completion, the caller must run
scripts/validate-terraform-report.py on the saved validation-report.json and
require exit 0. The checker enforces the shared v2 completion contract. Its
RUNTIME_REPORT_OK result does not establish that Terraform ran or passed.
Exit codes → caller action
| Exit | stdout | Meaning | Caller does |
|---|---|---|---|
0 | POLICY_OK | posture satisfied | proceed |
1 | POLICY_FAIL | violations present | read violations[], edit the named .tf sites, re-run (caller's retry budget) |
2 | (usage error) | bad path / IO | surface to user; do not treat as pass |
Verdict shape (--json)
{
"check": "policy",
"policy_status": "POLICY_OK | POLICY_FAIL",
"violations": [
{
"check": "policy",
"rule": "alb_https_listener | alb_http_redirect | no_tf_files",
"file": "compute.tf",
"line": 7,
"severity": "error",
"summary": "human-readable violation",
"fix_hint": "concrete remediation the caller can apply"
}
]
}Each violations[] entry is actionable evidence — file + line + fix_hint tell the
caller exactly what to edit. The caller applies the edit; this unit only reports.
Policy rules enforced today
Companion checks for migration artifacts
Migration callers can also run the shared read-only
scripts/validate-migration-artifacts.py against their complete migration
directory:
python3 "$PLUGIN_ROOT/skills/tf-best-practices/scripts/validate-migration-artifacts.py" \
"$MIGRATION_DIR" --json "$MIGRATION_DIR/artifact-validation-status.json"This produces ARTIFACTS_OK, ARTIFACTS_FAIL, or ARTIFACTS_ERROR, separately
from the Terraform policy verdict. The caller must inspect the real exit code
and report; it must not synthesize success. Supported checks cover PostgreSQL
verification control flow, EB/database security-group wiring, GitHub Actions
step references and deployment operations, and literal RDS time windows.
Unsupported structures require review. This is not a general Bash safety proof,
provider validation, or proof that a deployment succeeds.
Heroku callers add --require-postgres-script when PostgreSQL is in the design,
and --require-eb-workflow when GitHub Actions is the selected EB deployment
method. Other callers must not impose those Heroku-specific requirements.
The checker never executes a generated migration script or changes input files
or phase state; only the explicit JSON report path may be written.
For RDS/Aurora generation, scripts/rds_windows.py resolves independent daily
backup and weekly maintenance windows from caller preferences. It rejects
invalid or explicitly conflicting choices rather than silently changing them.
Callers use its actual JSON values when authoring Terraform.
Terraform policy rules
Every rule is fail-open on ambiguity — it fires only on unambiguous, in-block literal
evidence, so a valid stack is never falsely blocked (a POLICY_FAIL is a hard completion gate
for the caller, so a false positive would block a real migration).
Internet-facing ALB TLS posture (an ALB is internet-facing when internal is absent,
false, or variable-driven — fail-safe):
alb_https_listener— must have an HTTPS listener on443withcertificate_arnand aforwardaction.alb_http_redirect— an HTTP:80listener mustredirectto HTTPS, neverforwardto targets. Internal ALBs (internal = true) are exempt.
Elastic Beanstalk ALBs are invisible to these rules. The ALB rules inspect standalone
aws_lb_listenerblocks. An EB LoadBalanced environment provisions its ALB fromaws_elastic_beanstalk_environmentsettingblocks, which the static checker does not read — so a pure-EB design passes the ALB rules vacuously (no listener to inspect). EB listener/TLS posture is therefore authoring-only, not gate-enforced. (Fixturesgood-heroku-eb-onlyandgood-heroku-eb-singleinstancedocument this;good-heroku-eb-loadbalancedcarries a standalone ALB so the listener rules are exercised on real blocks.)
Managed database exposure & encryption (aws_db_instance, aws_rds_cluster):
rds_not_public— must not setpublicly_accessible = true(absent/variable → fail-open).rds_encryption_at_rest— must setstorage_encrypted = true; missing or literalfalsefires (RDS defaults to unencrypted), variable-driven fails open. S3 is not checked (default SSE-S3 since Jan 2023).
ElastiCache encryption (aws_elasticache_replication_group, Redis aws_elasticache_cluster):
elasticache_encryption_at_rest— a replication group must setat_rest_encryption_enabled = true; missing or literalfalsefires, variable-driven fails open.elasticache_cluster_encryption— a Redis-engineaws_elasticache_cluster(single-node:engine = "redis", noreplication_group_id) must set BOTHat_rest_encryption_enabled = trueandtransit_encryption_enabled = true; missing or literalfalseon either fires, variable-driven fails open.engine = "memcached"clusters (and variable-driven/absent engine) are exempt — Memcached does not support these attributes.
Security group ingress:
db_sg_no_public_ingress— an inlineaws_security_groupingress covering5432/3306must not allow0.0.0.0/0or::/0.sg_no_public_admin_ingress— an inline ingress must not open a curated never-public admin/datastore port (22,3389,6379,11211,27017,9200/9300,5601) to0.0.0.0/0or::/0. Web (80/443) and app/game ports are not flagged; DB ports are handled by the rule above. Both checkcidr_blocksandipv6_cidr_blocksindependently, so a benign IPv4 list does not mask an open IPv6 one. Both: separateaws_security_group_rule/aws_vpc_security_group_ingress_ruleresources fail open (not correlated).
IAM least-privilege (aws_iam_policy, aws_iam_role_policy, aws_iam_group_policy,
aws_iam_user_policy):
no_wildcard_iam— anAllowstatement must not useAction/Resource"*". The one narrow exception is an isolatedelasticbeanstalk:CreateStorageLocationstatement withResource = "*"because AWS does not support resource-level permissions for that action.aws_iam_policy_documentdata sources and assume-role trust policies fail open.
The checker is a zero-dependency static HCL reader (no
terraform init, no provider download) — it runs even when the registry is unreachable. It uses brace-depth matching for nested blocks, so a valid HTTPS listener written with a nestedforward { ... }block is not a false failure.
Fixtures (also the checker's regression suite)
fixtures/terraform-policy/ holds intentionally-shaped Terraform used by
scripts/test_validate_terraform_policy.py:
bad-http-forward/— internet-facing ALB that forwards plaintext HTTP → MUSTPOLICY_FAIL.internal-alb-only/— internal ALB on HTTP → MUSTPOLICY_OK(HTTP allowed internally).good-https-redirect/— the correct pattern →POLICY_OK.
These are deliberately non-compliant test data (never deployed). They are excluded from the
repo-wide checkov scan via .checkov.yaml skip-path; do not "harden" them — doing so
breaks the tests that assert the failure paths.
Verification
# from skills/tf-best-practices/
uv run --python 3.12 --with pytest python -m pytest scripts/test_validate_terraform_policy.py -qFiles
- .gitignore51 B
- ATTRIBUTION.mdAdded by AutoGineer915 B
- LICENSELicence file added by AutoGineer9.9 KiB
- NOTICELicence file added by AutoGineer67 B
- SKILL.md14.1 KiB
- fixtures/terraform-policy/bad-db-sg-public-quoted-port/vpc.tf514 B
- fixtures/terraform-policy/bad-db-sg-public/vpc.tf356 B
- fixtures/terraform-policy/bad-elasticache-cluster-redis-unencrypted/cache.tf389 B
- fixtures/terraform-policy/bad-elasticache-unencrypted/cache.tf422 B
- fixtures/terraform-policy/bad-heroku-eb-elasticache/cache.tf834 B
- fixtures/terraform-policy/bad-http-forward/compute.tf556 B
- fixtures/terraform-policy/bad-rds-public-brace-in-comment/main.tf690 B
- fixtures/terraform-policy/bad-rds-public-closing-brace/main.tf692 B
- fixtures/terraform-policy/bad-rds-public-interpolated-brace/rds.tf903 B
- fixtures/terraform-policy/bad-rds-public-unencrypted/database.tf372 B
- fixtures/terraform-policy/bad-sg-public-ipv6/vpc.tf1.1 KiB
- fixtures/terraform-policy/bad-sg-public-ssh/vpc.tf537 B
- fixtures/terraform-policy/bad-wildcard-iam-listform/security.tf470 B
- fixtures/terraform-policy/bad-wildcard-iam/security.tf381 B
- fixtures/terraform-policy/good-db-sg-scoped/vpc.tf986 B
- fixtures/terraform-policy/good-elasticache-cluster-memcached/cache.tf372 B
- fixtures/terraform-policy/good-elasticache-cluster-redis-encrypted/cache.tf601 B
- fixtures/terraform-policy/good-elasticache-encrypted/cache.tf749 B
- fixtures/terraform-policy/good-heroku-eb-codepipeline/pipeline.tf295 B
- fixtures/terraform-policy/good-heroku-eb-loadbalanced/compute.tf2.6 KiB
- fixtures/terraform-policy/good-heroku-eb-loadbalanced/database.tf839 B
- fixtures/terraform-policy/good-heroku-eb-loadbalanced/security.tf899 B
- fixtures/terraform-policy/good-heroku-eb-only/compute.tf1.5 KiB
- fixtures/terraform-policy/good-heroku-eb-singleinstance/compute.tf1.8 KiB
- fixtures/terraform-policy/good-https-redirect/compute.tf1008 B
- fixtures/terraform-policy/good-iam-scoped-list/security.tf573 B
- fixtures/terraform-policy/good-internet-nlb/compute.tf621 B
- fixtures/terraform-policy/good-quoted-port-https/compute.tf1.3 KiB
- fixtures/terraform-policy/good-rds-private-encrypted/database.tf607 B
- fixtures/terraform-policy/good-scoped-iam/security.tf744 B
- fixtures/terraform-policy/good-sg-ipv6-scoped/vpc.tf1.0 KiB
- fixtures/terraform-policy/good-sg-public-webapp/vpc.tf989 B
- fixtures/terraform-policy/internal-alb-only/compute.tf555 B
- references/security-posture-rules.md17.3 KiB
- references/terraform-validation.md9.8 KiB
- scripts/rds_windows.pyScript10.1 KiB
- scripts/test_validate_terraform_policy.pyScript33.6 KiB
- scripts/validate-migration-artifacts.pyScript89.3 KiB
- scripts/validate-terraform-policy.pyScript43.6 KiB
- scripts/validate-terraform-report.pyScript4.1 KiB
scripts/validate-terraform-policy.py
#!/usr/bin/env python3
"""Static policy checks on generated Terraform (no provider init required).
Read-only VERDICT PRODUCER. This script never edits .tf files and never touches
run state (.phase-status.json). It parses HCL, evaluates policy, and emits a
structured verdict (stdout summary + optional --json report). Remediation and any
phase/state decisions belong to the CALLER (see tf-best-practices SKILL.md).
Enforces (each rule is fail-open on ambiguity — fires only on unambiguous,
in-block literal evidence, so a valid stack is never falsely blocked):
- Internet-facing ALB TLS posture:
* HTTPS listener on port 443 with certificate_arn and a forward action
* HTTP listener on port 80 must redirect to HTTPS (never forward to targets)
- rds_not_public: aws_db_instance / aws_rds_cluster must not set
publicly_accessible = true (fail-open when variable-driven or absent).
- db_sg_no_public_ingress: an inline aws_security_group ingress covering a
database port (5432 / 3306) must not allow 0.0.0.0/0 (IPv4) or ::/0 (IPv6)
(fail-open on separate aws_security_group_rule /
aws_vpc_security_group_ingress_rule resources, which this static reader
cannot correlate).
- sg_no_public_admin_ingress: an inline ingress must not open a well-known
admin/datastore port (SSH, RDP, Redis, Memcached, Mongo, Elasticsearch,
Kibana) to 0.0.0.0/0 or ::/0. Scoped to a fixed never-public port list — web
ports and app/game ports are not flagged. Same inline-only fail-open scope.
Both SG rules read cidr_blocks and ipv6_cidr_blocks independently: an
IPv6-only or dual-stack VPC is exposed by ::/0 exactly as IPv4 is by
0.0.0.0/0.
- no_wildcard_iam: a literal IAM policy document with Effect "Allow" must not
use Action "*" or Resource "*", except an isolated
elasticbeanstalk:CreateStorageLocation statement because AWS provides no
resource-level permission for it (fail-open on aws_iam_policy_document data
sources, whose statements are not visible as literal JSON here).
- rds_encryption_at_rest: aws_db_instance / aws_rds_cluster must set
storage_encrypted = true (RDS defaults to UNENCRYPTED). Fail-open when
variable-driven. S3 is intentionally NOT checked: buckets have default
SSE-S3 since Jan 2023, so a missing SSE block is not an unencrypted bucket.
- elasticache_encryption_at_rest: aws_elasticache_replication_group must set
at_rest_encryption_enabled = true; a standalone Redis aws_elasticache_cluster
must set transit_encryption_enabled = true (at_rest_encryption_enabled is not
a valid argument on that resource — at-rest requires a replication group).
Fail-open when variable-driven.
Usage:
python3 validate-terraform-policy.py /path/to/terraform [--json report.json]
Exit 0 on POLICY_OK, 1 on POLICY_FAIL, 2 on usage/IO error.
"""
from __future__ import annotations
import argparse
import ipaddress
import json
import re
import sys
from dataclasses import asdict, dataclass
from pathlib import Path
RESOURCE_OPEN = re.compile(
r'resource\s+"(?P<type>[a-zA-Z0-9_]+)"\s+"(?P<name>[^"]+)"\s*\{',
re.MULTILINE,
)
@dataclass(frozen=True)
class Violation:
check: str # "policy"
rule: str # "alb_https_listener" | "alb_http_redirect" | "no_tf_files"
# | "rds_not_public" | "db_sg_no_public_ingress"
# | "no_wildcard_iam" | "rds_encryption_at_rest"
file: str
line: int # 1-based; 0 if unknown
severity: str # "error" | "warning"
summary: str
fix_hint: str
@dataclass(frozen=True)
class ListenerSpec:
file: str
name: str
line: int
port: int | None
protocol: str | None
action_type: str | None
has_certificate_arn: bool
def _read_tf_files(terraform_dir: Path) -> list[tuple[str, str]]:
files: list[tuple[str, str]] = []
for path in sorted(terraform_dir.rglob("*.tf")):
files.append((str(path.relative_to(terraform_dir)), path.read_text(encoding="utf-8")))
return files
_HEREDOC_OPEN = re.compile(r"<<(-?)\s*([A-Za-z_][A-Za-z0-9_]*)")
def _lex_hcl(block: str) -> tuple[list[int], list[bool], list[bool]]:
"""Scan HCL once, returning per-index (brace depth, is-real-code, is-comment)
arrays.
Only braces that are actual block delimiters count toward depth. HCL permits
`{` and `}` inside `#` / `//` line comments, `/* */` block comments, quoted
strings, and heredoc bodies, and those are NOT delimiters — counting them
would let unrelated text shift the apparent nesting of a real attribute.
`is_code` additionally lets callers ignore an attribute that only appears
commented out. `is_comment` marks exactly the comment spans (delimiters
included, terminating newline excluded), distinguishing them from the other
non-code states — a reader that must ignore comments but still read string
contents (list entries are quoted strings) needs that distinction.
Interpolations are treated as opaque string content: `{` and `}` inside a
string are both ignored, so the running depth is unaffected either way. This
requires tracking interpolation nesting, because `${...}` may contain its own
quoted strings (`"${lookup(var.m, "a{b")}"`). Without that, the inner string's
closing quote would end the OUTER string and everything after it would be
scanned as code — putting textual braces back into the depth count, which is
the failure mode this lexer exists to prevent.
"""
n = len(block)
depths = [0] * n
codes = [False] * n
comments = [False] * n
depth = 0
state = "code"
tag = ""
tag_indent_ok = False
interp = 0 # open `${` levels inside the current string
inner_str = False # inside a quoted string nested in an interpolation
i = 0
while i < n:
ch = block[i]
nxt = block[i + 1] if i + 1 < n else ""
if state == "code":
if ch == "#" or (ch == "/" and nxt == "/"):
depths[i], codes[i], comments[i] = depth, False, True
state = "line_comment"
i += 1
continue
if ch == "/" and nxt == "*":
depths[i], depths[i + 1] = depth, depth
comments[i], comments[i + 1] = True, True
state = "block_comment"
i += 2
continue
if ch == '"':
depths[i], codes[i] = depth, False
state = "string"
interp = 0
inner_str = False
i += 1
continue
if ch == "<" and nxt == "<":
heredoc = _HEREDOC_OPEN.match(block, i)
if heredoc:
for j in range(i, heredoc.end()):
depths[j] = depth
tag = heredoc.group(2)
# Only `<<-TAG` permits an indented terminator; plain `<<TAG`
# requires it at column 0, so an indented tag-looking line is
# ordinary body text.
tag_indent_ok = heredoc.group(1) == "-"
state = "heredoc"
i = heredoc.end()
continue
depths[i], codes[i] = depth, True
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
i += 1
continue
depths[i] = depth
if state == "line_comment":
if ch == "\n":
state = "code"
else:
comments[i] = True
i += 1
elif state == "block_comment":
comments[i] = True
if ch == "*" and nxt == "/":
depths[i + 1] = depth
comments[i + 1] = True
state = "code"
i += 2
else:
i += 1
elif state == "string":
if ch == "\\" and i + 1 < n:
depths[i + 1] = depth
i += 2
elif ch == "$" and nxt == "{" and not inner_str:
depths[i + 1] = depth
interp += 1
i += 2
elif interp > 0 and ch == '"':
# A quote inside `${...}` opens/closes a NESTED string; it must not
# be mistaken for the end of the interpolated string itself.
inner_str = not inner_str
i += 1
elif interp > 0 and ch == "}" and not inner_str:
interp -= 1
i += 1
else:
if ch == '"' and interp == 0:
state = "code"
i += 1
else: # heredoc — body is literal until a line holding only the tag
end = block.find("\n", i)
line_end = n if end == -1 else end + 1
for j in range(i, line_end):
depths[j] = depth
line = block[i:line_end]
terminator = line.strip() if tag_indent_ok else line.rstrip()
if terminator == tag:
state = "code"
i = line_end
return depths, codes, comments
def _extract_braced_block(
content: str,
open_brace: int,
lexed: tuple[list[int], list[bool], list[bool]] | None = None,
) -> tuple[str, int]:
"""Return (block_text_including_braces, index_after_close).
Uses _lex_hcl, not raw brace counting: a `}` inside a comment, string, or
heredoc body is not a delimiter, and treating it as one truncates the block
early. That silently drops every attribute after the stray brace — a resource
body cut before `publicly_accessible = true` reports no violation at all. Pass
`lexed` to reuse a scan already done for this exact `content`.
"""
depths, codes, _ = lexed if lexed is not None else _lex_hcl(content)
if open_brace >= len(codes) or not codes[open_brace]:
return content[open_brace:], len(content)
target = depths[open_brace] + 1
for idx in range(open_brace + 1, len(content)):
if codes[idx] and content[idx] == "}" and depths[idx] == target:
return content[open_brace : idx + 1], idx + 1
return content[open_brace:], len(content)
def _extract_blocks(content: str, resource_type: str) -> list[tuple[str, str, int]]:
"""Return (name, body, 1-based line) for each resource of resource_type.
The file is lexed once and the scan is shared with every extraction, so
extraction, attribute presence, and attribute values all agree on what counts
as code. A resource declaration that is itself commented out is skipped.
"""
lexed = _lex_hcl(content)
_, codes, _ = lexed
blocks: list[tuple[str, str, int]] = []
for match in RESOURCE_OPEN.finditer(content):
if match.group("type") != resource_type:
continue
if not codes[match.start()]:
continue
name = match.group("name")
brace_start = match.end() - 1
body, _ = _extract_braced_block(content, brace_start, lexed)
line = content.count("\n", 0, match.start()) + 1
blocks.append((name, body, line))
return blocks
def _own_blocks(body: str, block_type: str) -> list[str]:
"""Bodies of `block_type { ... }` blocks declared directly in `body`.
Shares the lexer so a commented-out `ingress {` is not treated as a real rule.
"""
lexed = _lex_hcl(body)
_, codes, _ = lexed
found: list[str] = []
for match in re.finditer(rf"{re.escape(block_type)}\s*\{{", body):
if not codes[match.start()]:
continue
inner, _ = _extract_braced_block(body, match.end() - 1, lexed)
found.append(inner)
return found
def _has_own_attr(block: str, attr: str) -> bool:
"""True if `attr` is assigned among the block's OWN attributes.
Presence must use the same lexical rules as value reading. A probe that counts
a commented-out assignment while the reader correctly ignores it makes the two
disagree, and the rules that branch on "attribute present but unreadable →
variable-driven → fail open" then skip a resource whose attribute is genuinely
absent.
"""
return (
_first_own_attr(block, rf"^\s*{re.escape(attr)}\s*=", re.MULTILINE) is not None
)
def _first_own_attr(block: str, pattern: str, flags: int = 0) -> re.Match[str] | None:
"""First match of `pattern` that sits among the block's OWN attributes.
`_extract_blocks` / `_extract_braced_block` return a resource body INCLUDING
its nested blocks, and a plain `re.search` takes the first match anywhere —
so an attribute of a nested block can be read as if it belonged to the
resource. `aws_lb_listener` is the live example: `default_action { redirect {
port = "443", protocol = "HTTPS" } }` carries both a `port` and a `protocol`,
and HCL does not require the listener's own `port` to precede its
`default_action` (`terraform fmt` will not reorder attributes relative to
blocks). Read whole-body, such a listener reports port 443 / protocol HTTPS
instead of 80 / HTTP.
Candidates are therefore filtered by brace depth — only those at the body's
own depth are eligible (1 for a braced body as returned by
_extract_braced_block, 0 for a bare attribute fragment) — and a candidate that
is merely commented out is skipped.
Depth comes from _lex_hcl, NOT from raw `{`/`}` counting. Raw counting is
unsafe here: a brace in an unrelated comment or string literal would shift the
depth of a real top-level attribute and hide it, and for these rules a hidden
attribute means a MISSED violation — `publicly_accessible = true` reported as
compliant. That is the failure class this reader exists to prevent, so
lexically-irrelevant braces must not participate.
"""
base = 1 if block.lstrip().startswith("{") else 0
depths, codes, _ = _lex_hcl(block)
for match in re.finditer(pattern, block, flags):
start = match.start()
if start < len(codes) and codes[start] and depths[start] == base:
return match
return None
def _attr_string(block: str, attr: str) -> str | None:
match = _first_own_attr(
block, rf'^\s*{re.escape(attr)}\s*=\s*"([^"]*)"', re.MULTILINE
)
if match:
return match.group(1)
bool_match = _first_own_attr(
block,
rf"^\s*{re.escape(attr)}\s*=\s*(true|false)\b",
re.MULTILINE | re.IGNORECASE,
)
return bool_match.group(1).lower() if bool_match else None
def _attr_int(block: str, attr: str) -> int | None:
"""Read an integer attribute, accepting BARE (443) and QUOTED ("443") forms.
Terraform accepts a quoted integer for number-typed arguments (`port = "443"`
is valid, idiomatic, `terraform fmt`-clean HCL and converts to 443), so a
bare-only reader silently degrades every port-based rule: a correct HTTPS
listener written as port = "443" was reported as a missing HTTPS listener
(false positive), and — worse — an ingress with from_port = "5432" /
cidr_blocks = ["0.0.0.0/0"] produced no covered ports, so the public-ingress
rules never fired on a genuinely world-open database (fail-CLOSED miss).
The quoted arm requires the value to be ENTIRELY digits (the closing quote is
anchored against the digits), so "443x", "443-444" and "${var.port}" do NOT
read as 443 — they return None and keep the existing fail-open behaviour for
non-literal / non-integer values.
Accepting the quoted form also makes a nested block's `port` a candidate where
a bare-only pattern could never match one, so the search is scoped to the
block's own attributes via _first_own_attr — see its docstring.
"""
match = _first_own_attr(
block,
rf'^\s*{re.escape(attr)}\s*=\s*(?:(\d+)|"(\d+)")',
re.MULTILINE,
)
if not match:
return None
bare, quoted = match.group(1), match.group(2)
return int(bare if bare is not None else quoted)
def _default_action_type(block: str) -> str | None:
"""Extract default_action { ... type = "X" ... } via BRACE-DEPTH matching.
NOTE: the naive r'default_action\\s*\\{[^}]*?type' approach breaks when the
default_action contains a nested block (redirect {} / forward {}) placed
BEFORE the type attribute — it stops at the first '}'. We isolate the full
default_action body by brace matching, then read `type` from it.
"""
m = re.search(r"default_action\s*\{", block)
if not m:
return None
body, _ = _extract_braced_block(block, m.end() - 1)
tmatch = re.search(r'^\s*type\s*=\s*"([^"]+)"', body, re.MULTILINE)
return tmatch.group(1) if tmatch else None
def _has_internet_facing_alb(tf_files: list[tuple[str, str]]) -> bool:
"""True if any aws_lb is an internet-facing APPLICATION load balancer.
The HTTPS-listener posture applies only to Application Load Balancers (L7).
Network (L4 TCP/UDP) and Gateway (L3) load balancers legitimately have no
HTTPS:443 listener, so an internet-facing NLB/GWLB must NOT be flagged.
- load_balancer_type == "network" | "gateway" (literal) => skip (not an ALB).
- load_balancer_type absent (Terraform default is "application"),
"application", or variable-driven => treat as an ALB (fail-safe).
- internal absent, "false", or variable-driven => internet-facing (fail-safe:
demand HTTPS unless explicitly internal=true).
"""
for _, content in tf_files:
for _, body, _line in _extract_blocks(content, "aws_lb"):
lb_type = _attr_string(body, "load_balancer_type")
if lb_type in ("network", "gateway"):
continue # L4/L3 balancer — HTTPS-listener rule does not apply
internal = _attr_string(body, "internal")
if internal is None or internal == "false":
return True
return False
def _parse_listeners(tf_files: list[tuple[str, str]]) -> list[ListenerSpec]:
listeners: list[ListenerSpec] = []
for rel_path, content in tf_files:
for name, body, line in _extract_blocks(content, "aws_lb_listener"):
listeners.append(
ListenerSpec(
file=rel_path,
name=name,
line=line,
port=_attr_int(body, "port"),
protocol=_attr_string(body, "protocol"),
action_type=_default_action_type(body),
has_certificate_arn="certificate_arn" in body,
)
)
return listeners
def check_alb_https_policy(terraform_dir: Path) -> list[Violation]:
tf_files = _read_tf_files(terraform_dir)
if not tf_files:
return [
Violation(
check="policy",
rule="no_tf_files",
file=".",
line=0,
severity="error",
summary="No .tf files found in terraform directory",
fix_hint="Ensure the generate step wrote terraform/ before validation",
)
]
if not _has_internet_facing_alb(tf_files):
return []
listeners = _parse_listeners(tf_files)
violations: list[Violation] = []
https_ok = [
l
for l in listeners
if l.port == 443
and (l.protocol or "").upper() == "HTTPS"
and l.has_certificate_arn
and l.action_type == "forward"
]
if not https_ok:
# Point at an aws_lb file when we can, else the first tf file.
lb_file = next(
(rel for rel, c in tf_files if _extract_blocks(c, "aws_lb")),
tf_files[0][0],
)
violations.append(
Violation(
check="policy",
rule="alb_https_listener",
file=lb_file,
line=0,
severity="error",
summary=(
"Internet-facing ALB requires an HTTPS listener on port 443 "
"with certificate_arn and a forward action"
),
fix_hint=(
'Add an aws_lb_listener on port 443, protocol "HTTPS", with '
"ssl_policy, certificate_arn, and a forward default_action"
),
)
)
for l in listeners:
if l.port != 80 or (l.protocol or "").upper() != "HTTP":
continue
if l.action_type == "forward":
violations.append(
Violation(
check="policy",
rule="alb_http_redirect",
file=l.file,
line=l.line,
severity="error",
summary=(
f"ALB HTTP listener '{l.name}' on port 80 forwards to targets; "
"it must redirect to HTTPS"
),
fix_hint=(
"Replace the forward default_action with a redirect block: "
'type = "redirect", redirect { port = "443", protocol = "HTTPS", '
'status_code = "HTTP_301" }'
),
)
)
return violations
_DB_PORTS = (5432, 3306)
# Well-known admin / datastore ports that should never be open to the whole
# internet in either address family (0.0.0.0/0 or ::/0).
# Deliberately EXCLUDES 5432/3306 (covered by db_sg_no_public_ingress, so no
# double-reporting) and web ports 80/443 (legitimately public). Kept tight to
# unambiguous "never public" ports so valid designs (e.g. game servers on high
# ports) are not falsely flagged.
_SENSITIVE_NONDB_PORTS = (
22, # SSH
3389, # RDP
6379, # Redis
11211, # Memcached
27017, # MongoDB
9200, # Elasticsearch HTTP
9300, # Elasticsearch transport
5601, # Kibana
)
def check_rds_not_public(tf_files: list[tuple[str, str]]) -> list[Violation]:
"""Flag RDS resources that explicitly set publicly_accessible = true.
Fail-open: absent (RDS default is false) or variable-driven values do not
fire — only a literal `true` is a violation.
"""
violations: list[Violation] = []
for rel_path, content in tf_files:
for res_type in ("aws_db_instance", "aws_rds_cluster"):
for name, body, line in _extract_blocks(content, res_type):
if _attr_string(body, "publicly_accessible") == "true":
violations.append(
Violation(
check="policy",
rule="rds_not_public",
file=rel_path,
line=line,
severity="error",
summary=(
f"{res_type} '{name}' sets publicly_accessible = true — "
"the database is reachable from the internet"
),
fix_hint=(
"Set publicly_accessible = false and place the database in "
"private subnets; reach it from application security groups only"
),
)
)
return violations
def _ingress_covered_ports(ingress_body: str, ports: tuple[int, ...]) -> list[int]:
"""Return the subset of `ports` whose value falls in this ingress rule's
[from_port, to_port] range. Empty when the range is missing/non-literal."""
from_p = _attr_int(ingress_body, "from_port")
to_p = _attr_int(ingress_body, "to_port")
if from_p is None or to_p is None:
return []
return [port for port in ports if from_p <= port <= to_p]
def _ingress_covers_db_port(ingress_body: str) -> bool:
return bool(_ingress_covered_ports(ingress_body, _DB_PORTS))
def _strip_hcl_comments(text: str) -> str:
"""Blank out comment spans, using the lexer's classification of them.
This must share _lex_hcl's single definition of "what counts as code" rather
than keep a parallel comment regex: a regex cannot know that a `/*` inside a
quoted string does not open a comment, and stripping from such a false opener
swallows every real attribute up to the next `*/` — including a live
`cidr_blocks = ["0.0.0.0/0"]`, which then reports POLICY_OK. Comment
characters are replaced with spaces rather than deleted, so surviving tokens
never fuse and line anchors keep working.
"""
_, _, comments = _lex_hcl(text)
return "".join(" " if comments[i] else ch for i, ch in enumerate(text))
def _attr_list_inner(block: str, attr: str) -> str | None:
"""Return the raw text inside a literal `attr = [ ... ]`, else None.
The name must start a line OR directly follow a `{`. The line-start arm keeps
`cidr_blocks` from matching the tail of `ipv6_cidr_blocks` (reading one list
while believing it is the other silently inverts the verdict); the brace arm
keeps an attribute that shares its block's opening line visible, e.g.
`ingress { cidr_blocks = [...]`, which is valid HCL that a line-anchored
pattern alone would miss.
"""
m = re.search(
rf"(?:^|\{{)\s*{re.escape(attr)}\s*=\s*\[(.*?)\]",
block,
re.DOTALL | re.MULTILINE,
)
return m.group(1) if m else None
def _is_entire_internet(cidr: str) -> bool:
"""True if `cidr` is a literal range covering the whole address space.
Canonicalises through `ipaddress` instead of comparing text, because IPv6 has
many legal spellings of the zero address (`::/0`, `::0/0`, `0:0:0:0:0:0:0:0/0`)
and nothing in Terraform normalises them — `terraform fmt` treats a CIDR as an
opaque string. Anything that is not a parseable literal (`var.x`, `${...}`,
malformed text) returns False, preserving the module's fail-open posture.
"""
try:
return ipaddress.ip_network(cidr, strict=False).prefixlen == 0
except ValueError:
return False
def _ingress_public_cidrs(ingress_body: str) -> list[str]:
"""Return the "entire internet" CIDRs this ingress rule allows, both families.
IPv4 `0.0.0.0/0` and IPv6 `::/0` are equally public: on a dual-stack or
IPv6-only VPC, `::/0` on an admin port is a live exposure. Each family is read
under its own attribute name so the two are never confused, comments are
stripped first (by the shared lexer, so a comment marker inside a quoted
string is not a comment), and only quoted string literals count as list
entries — so a range mentioned in a comment inside the list is not an allowed
range. Values are returned as spelled in the file, so the violation names
what is written. Empty list => no unambiguous public exposure.
"""
body = _strip_hcl_comments(ingress_body)
found: list[str] = []
for attr in ("cidr_blocks", "ipv6_cidr_blocks"):
inner = _attr_list_inner(body, attr)
if inner is None:
continue
for cidr in re.findall(r'"([^"]*)"', inner):
if _is_entire_internet(cidr) and cidr not in found:
found.append(cidr)
return found
def check_db_sg_no_public_ingress(tf_files: list[tuple[str, str]]) -> list[Violation]:
"""Flag inline security-group ingress that opens a DB port to 0.0.0.0/0 or ::/0.
Fail-open: only INLINE `ingress { ... }` blocks inside aws_security_group are
inspected. Separate aws_security_group_rule / aws_vpc_security_group_ingress_rule
resources are not correlated here (this static reader cannot resolve the
referenced security_group_id), so they never trigger a false positive.
"""
violations: list[Violation] = []
for rel_path, content in tf_files:
for name, body, line in _extract_blocks(content, "aws_security_group"):
# Walk each inline ingress block via brace matching.
for ingress_body in _own_blocks(body, "ingress"):
public = _ingress_public_cidrs(ingress_body)
if _ingress_covers_db_port(ingress_body) and public:
violations.append(
Violation(
check="policy",
rule="db_sg_no_public_ingress",
file=rel_path,
line=line,
severity="error",
summary=(
f"aws_security_group '{name}' has an ingress rule that opens a "
f"database port (5432/3306) to {' and '.join(public)}"
),
fix_hint=(
"Restrict the ingress to the application security group "
"(security_groups = [aws_security_group.app.id]) or a private "
"CIDR — never 0.0.0.0/0 or ::/0 for a database port"
),
)
)
return violations
def check_sg_no_public_admin_ingress(tf_files: list[tuple[str, str]]) -> list[Violation]:
"""Flag inline security-group ingress that opens a well-known admin/datastore
port (SSH, RDP, Redis, Memcached, Mongo, Elasticsearch, Kibana) to 0.0.0.0/0
or ::/0.
Deliberately scoped to a fixed list of ports that are ~never legitimately
public — NOT "any public ingress" — so valid public workloads (web on
80/443, game servers on high ports, etc.) are not falsely flagged. DB ports
(5432/3306) are handled by db_sg_no_public_ingress and excluded here to avoid
double-reporting.
Fail-open (same scope as db_sg_no_public_ingress): only INLINE ingress blocks
inside aws_security_group are inspected; separate rule resources are not
correlated.
"""
port_names = {
22: "SSH", 3389: "RDP", 6379: "Redis", 11211: "Memcached",
27017: "MongoDB", 9200: "Elasticsearch", 9300: "Elasticsearch",
5601: "Kibana",
}
violations: list[Violation] = []
for rel_path, content in tf_files:
for name, body, line in _extract_blocks(content, "aws_security_group"):
for ingress_body in _own_blocks(body, "ingress"):
public = _ingress_public_cidrs(ingress_body)
if not public:
continue
hit = _ingress_covered_ports(ingress_body, _SENSITIVE_NONDB_PORTS)
if not hit:
continue
labels = ", ".join(f"{p} ({port_names[p]})" for p in sorted(set(hit)))
violations.append(
Violation(
check="policy",
rule="sg_no_public_admin_ingress",
file=rel_path,
line=line,
severity="error",
summary=(
f"aws_security_group '{name}' opens sensitive port(s) {labels} "
f"to {' and '.join(public)}"
),
fix_hint=(
"Restrict this ingress to a bastion/app security group or a private "
"CIDR; never expose admin or datastore ports to the internet"
),
)
)
return violations
def _iam_key_is_wildcard(body: str, key: str) -> bool:
"""True if an IAM policy `key` (Action/Resource) is a sole "*" — string OR
list form. Matches:
"Action" : "*" (heredoc JSON, string)
Action = "*" (jsonencode HCL, string)
"Resource" : ["*"] (heredoc JSON, single-element list)
Resource = ["*"] (jsonencode HCL, single-element list)
A list is only treated as wildcard when "*" is its ONLY element — a list that
also contains scoped ARNs/actions is not a blanket wildcard.
"""
# String form: key = "*"
if re.search(rf'"?{key}"?\s*[:=]\s*"\*"', body):
return True
# List form: key = [ "*" ] with nothing else inside the brackets.
m = re.search(rf'"?{key}"?\s*[:=]\s*\[(.*?)\]', body, re.DOTALL)
if m:
inner = m.group(1).strip()
if inner == '"*"':
return True
return False
def _remove_allowed_resource_wildcard_statements(body: str) -> str:
"""Remove narrowly-approved IAM statements whose actions require Resource "*".
Keep this allowlist statement-shaped rather than action-token-shaped so an
approved action cannot hide a second, over-broad action in the same statement.
"""
return re.sub(
r"""
\{\s*
"?Effect"?\s*[:=]\s*"Allow"\s*,?\s*
"?Action"?\s*[:=]\s*"elasticbeanstalk:CreateStorageLocation"\s*,?\s*
"?Resource"?\s*[:=]\s*"\*"\s*,?\s*
\}
""",
"",
body,
flags=re.DOTALL | re.VERBOSE,
)
def check_no_wildcard_iam(tf_files: list[tuple[str, str]]) -> list[Violation]:
"""Flag literal IAM policy JSON with an unsafe Allow Action/Resource "*".
Fail-open: only literal `policy = jsonencode({...})` / heredoc JSON inside
aws_iam_policy, aws_iam_role_policy, or *_inline_policy blocks is scanned.
aws_iam_policy_document DATA sources are not inspected (their statements are
HCL blocks, not literal JSON here) — avoids false positives on the common,
reviewable data-source pattern. Assume-role trust policies are excluded
(a Service/AWS principal trust with Action sts:AssumeRole is not a wildcard).
"""
violations: list[Violation] = []
policy_res_types = ("aws_iam_policy", "aws_iam_role_policy", "aws_iam_group_policy",
"aws_iam_user_policy")
for rel_path, content in tf_files:
for res_type in policy_res_types:
for name, body, line in _extract_blocks(content, res_type):
# Accept both heredoc JSON ("Effect": "Allow") and HCL jsonencode
# ({...}) (Effect = "Allow") forms — the separator is : or =.
if not re.search(r'"?Effect"?\s*[:=]\s*"Allow"', body):
continue
body_without_allowed = _remove_allowed_resource_wildcard_statements(body)
if _iam_key_is_wildcard(body, "Action") or _iam_key_is_wildcard(
body_without_allowed, "Resource"
):
violations.append(
Violation(
check="policy",
rule="no_wildcard_iam",
file=rel_path,
line=line,
severity="error",
summary=(
f"{res_type} '{name}' grants Action \"*\" or Resource \"*\" "
"in an Allow statement — over-broad permissions"
),
fix_hint=(
"Scope the policy to specific actions and resource ARNs; "
"isolate any documented action that requires Resource \"*\""
),
)
)
return violations
def check_rds_encryption_at_rest(tf_files: list[tuple[str, str]]) -> list[Violation]:
"""Flag RDS resources that do not enable storage_encrypted (RDS defaults to
UNENCRYPTED).
Fail-open: fires only when storage_encrypted is literally `false` OR the
attribute is absent. A variable-driven value (storage_encrypted = var.x) is
NOT flagged. S3 is intentionally excluded — buckets have default SSE-S3 since
Jan 2023, so a missing SSE block is not an unencrypted bucket.
"""
violations: list[Violation] = []
for rel_path, content in tf_files:
for res_type in ("aws_db_instance", "aws_rds_cluster"):
for name, body, line in _extract_blocks(content, res_type):
val = _attr_string(body, "storage_encrypted")
# Variable-driven / non-literal → attribute present but not "true"/"false".
has_attr = _has_own_attr(body, "storage_encrypted")
if has_attr and val is None:
continue # variable-driven — fail open
if val == "true":
continue
violations.append(
Violation(
check="policy",
rule="rds_encryption_at_rest",
file=rel_path,
line=line,
severity="error",
summary=(
f"{res_type} '{name}' does not set storage_encrypted = true — "
"RDS storage defaults to unencrypted"
),
fix_hint="Set storage_encrypted = true (optionally with a kms_key_id)",
)
)
return violations
def check_elasticache_encryption_at_rest(tf_files: list[tuple[str, str]]) -> list[Violation]:
"""Flag ElastiCache replication groups without at-rest encryption enabled.
Applies to aws_elasticache_replication_group (the resource that supports
at_rest_encryption_enabled). Fires when the attribute is literally `false` or
absent; a variable-driven value fails open. aws_elasticache_cluster is NOT
checked — standalone Memcached clusters don't support this attribute and
Redis-in-cluster is configured via the replication group.
"""
violations: list[Violation] = []
for rel_path, content in tf_files:
for name, body, line in _extract_blocks(content, "aws_elasticache_replication_group"):
val = _attr_string(body, "at_rest_encryption_enabled")
has_attr = _has_own_attr(body, "at_rest_encryption_enabled")
if has_attr and val is None:
continue # variable-driven — fail open
if val == "true":
continue
violations.append(
Violation(
check="policy",
rule="elasticache_encryption_at_rest",
file=rel_path,
line=line,
severity="error",
summary=(
f"aws_elasticache_replication_group '{name}' does not set "
"at_rest_encryption_enabled = true"
),
fix_hint=(
"Set at_rest_encryption_enabled = true (and consider "
"transit_encryption_enabled = true for in-transit protection)"
),
)
)
return violations
def check_elasticache_cluster_encryption(tf_files: list[tuple[str, str]]) -> list[Violation]:
"""Flag single-node Redis aws_elasticache_cluster resources without encryption.
aws_elasticache_cluster is the standalone (non-replication-group) cache form.
Per the AWS provider schema, this resource exposes ONLY
transit_encryption_enabled — at_rest_encryption_enabled is NOT a valid
argument on aws_elasticache_cluster (it lives on
aws_elasticache_replication_group). So for a Redis-engine cluster
(engine = "redis") that is NOT a member of a replication group (no
replication_group_id) we require transit_encryption_enabled = true, and the
fix for at-rest is to move the cache into an encrypted
aws_elasticache_replication_group — never to add a nonexistent cluster
argument (which would produce Terraform that fails to apply).
Exempt (fail open), matching the replication-group check's style:
- engine == "memcached" (does not support these attributes),
- a variable-driven engine or an absent engine (cannot evaluate literally),
- a cluster that carries replication_group_id (encryption is enforced on the
owning aws_elasticache_replication_group).
A variable-driven encryption attribute also fails open.
"""
violations: list[Violation] = []
for rel_path, content in tf_files:
for name, body, line in _extract_blocks(content, "aws_elasticache_cluster"):
engine = _attr_string(body, "engine")
if engine != "redis":
continue # memcached / variable-driven / absent engine → exempt
if _has_own_attr(body, "replication_group_id"):
continue # node of a replication group — encryption enforced there
# Only transit_encryption_enabled is a valid argument on this resource.
val = _attr_string(body, "transit_encryption_enabled")
has_attr = _has_own_attr(body, "transit_encryption_enabled")
if has_attr and val is None:
continue # variable-driven — fail open
if val == "true":
continue
violations.append(
Violation(
check="policy",
rule="elasticache_encryption_at_rest",
file=rel_path,
line=line,
severity="error",
summary=(
f"aws_elasticache_cluster '{name}' (engine = redis) does "
"not set transit_encryption_enabled = true"
),
fix_hint=(
"Set transit_encryption_enabled = true on the Redis "
"aws_elasticache_cluster. For at-rest encryption, move the "
"cache into an aws_elasticache_replication_group with "
"at_rest_encryption_enabled = true — at_rest_encryption_enabled "
"is not a valid argument on aws_elasticache_cluster."
),
)
)
return violations
def validate(terraform_dir: Path) -> tuple[bool, list[Violation]]:
tf_files = _read_tf_files(terraform_dir)
if not tf_files:
# Preserve the existing no_tf_files verdict path.
return False, check_alb_https_policy(terraform_dir)
violations: list[Violation] = []
violations.extend(check_alb_https_policy(terraform_dir))
violations.extend(check_rds_not_public(tf_files))
violations.extend(check_db_sg_no_public_ingress(tf_files))
violations.extend(check_sg_no_public_admin_ingress(tf_files))
violations.extend(check_no_wildcard_iam(tf_files))
violations.extend(check_rds_encryption_at_rest(tf_files))
violations.extend(check_elasticache_encryption_at_rest(tf_files))
violations.extend(check_elasticache_cluster_encryption(tf_files))
return len(violations) == 0, violations
def main() -> int:
parser = argparse.ArgumentParser(description="Validate generated Terraform policy rules")
parser.add_argument("terraform_dir", type=Path, help="Path to terraform/ directory")
parser.add_argument(
"--json",
type=Path,
default=None,
help="Optional path to write a machine-readable JSON verdict",
)
args = parser.parse_args()
terraform_dir = args.terraform_dir.resolve()
if not terraform_dir.is_dir():
print(f"POLICY_FAIL | path={terraform_dir} | reason=not_a_directory", file=sys.stderr)
return 2
ok, violations = validate(terraform_dir)
if args.json is not None:
report = {
"check": "policy",
"policy_status": "POLICY_OK" if ok else "POLICY_FAIL",
"violations": [asdict(v) for v in violations],
}
try:
args.json.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
except OSError as exc:
print(f"POLICY_FAIL | reason=json_write_failed | detail={exc}", file=sys.stderr)
return 2
checks = (
"alb_https,rds_not_public,db_sg_no_public_ingress,sg_no_public_admin_ingress,"
"no_wildcard_iam,rds_encryption,elasticache_encryption,"
"elasticache_cluster_encryption"
)
if ok:
print(f"POLICY_OK | checks={checks}")
return 0
print(f"POLICY_FAIL | checks={checks}", file=sys.stderr)
for v in violations:
print(
f"POLICY_FAIL | file={v.file} | line={v.line} | rule={v.rule} | reason={v.summary}",
file=sys.stderr,
)
return 1
if __name__ == "__main__":
sys.exit(main())
Requirements and compatibility
Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta
“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.
Needs: Needs Python, Needs Node.js
Security
What our scanner noticed· Low risk
- Scripts that your agent may run
This skill includes scripts that your agent may run. Read them before you use it.
A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.
Attribution and licence
- Published by
- AWS
- Publisher
- AWS (Official)
- Source
- aws/agent-toolkit-for-aws · plugins/aws-startup-advisor/skills/tf-best-practices · commit 0a218f8
- Licence
- Apache License 2.0 (redistribution allowed)
- LICENSE (added by AutoGineer from LICENSE)
- NOTICE (added by AutoGineer from NOTICE)
- ATTRIBUTION.md (written by AutoGineer)
- Synced
AutoGineer is not affiliated with or endorsed by AWS.
Download ZIP
The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download tf-best-practices.zip