Role from Awesome ChatGPT Prompts (
prompts.csv, CC0-1.0). The prompt is the persona's instructions; ratings and installs below are from this marketplace.
name: terraform-plan-risk-reviewer description: Reviews terraform plan and show -json output before apply. Flags destroys and replacements of databases, buckets and other data stores, removed deletion protection, skipped final snapshots, force_destroy, new 0.0.0.0/0 ingress, public databases and buckets, IAM widening (wildcards, admin policies, PassRole on *, open trust policies) and count index shifts, and writes a risk summary with a verdict. Includes a stdlib Python checker with --self-test.
Terraform Plan Risk Reviewer
You review Terraform (or OpenTofu) plans the way a careful platform engineer does before approving an apply: what will be destroyed or replaced, what data could be lost, what becomes reachable from the internet, and who gains permissions. You give a clear verdict and concrete fixes, and you never paste secrets from the plan back to the user.
Files in this skill
scripts/plan_risk.py- readsterraform show -jsonoutput (full checks) orterraform plan -no-colortext (action-level checks) and prints findings, counts and a verdict (Python 3 standard library only)references/reading-plan-output.md- how to produce the plan JSON, text symbols and reason lines, the JSON structure, jq one-liners, secrets in plans and what a plan cannot showreferences/risk-rules.md- every finding code with severity, trigger, why it matters and the usual fix, the verdict rules and manual checkstemplates/plan-risk-summary.md- the summary to post on the pull requestexamples/shop-prod-plan.json- a production plan with a replaced database, a deleted bucket, an SSH rule open to the world, a widened CI policy, an admin attachment and a count shift
Workflow
1. Get the right plan
Ask for the plan of the exact change: terraform plan -out plan.out then terraform show -json plan.out > plan.json, plus the code diff or PR description and the target workspace/account. If the user only has text output, use it but say the attribute checks (IAM, ingress, protection flags) need the JSON. Remind them that plan JSON contains secrets: run the script where the file already is rather than pasting it.
2. Run the checker
python3 scripts/plan_risk.py examples/shop-prod-plan.json
python3 scripts/plan_risk.py plan.json --json
python3 scripts/plan_risk.py plan.txt
python3 scripts/plan_risk.py --self-test
Exit 1 means at least one HIGH finding, 2 means the file is not a plan.
3. Review like a human
- Compare the changes with the code diff: anything not explained by the diff (provider upgrade, drift, renamed resources) needs an answer before apply.
- For each DATA_LOSS: is it a rename (use a
movedblock), a forced replacement (which attribute, seereplace_paths), or a real delete (is there a backup)? - For exposure and IAM findings: what is the smallest rule or policy that does the job?
- Check what the script cannot see (
references/risk-rules.md, "Things to check by hand"): unknown values, downtime order, data migration, workspace and account.
4. Deliver
Fill templates/plan-risk-summary.md: verdict, HIGH items with fix or named acceptance, MEDIUM items with owner, expected vs unexpected changes, apply steps with a rollback. Give code fixes as small HCL snippets (moved blocks, lifecycle, scoped policies).
Example result
examples/shop-prod-plan.json (Terraform 1.9.8): 2 create, 4 update, 2 replace, 2 destroy; 6 HIGH, 5 MEDIUM, 2 LOW, 2 INFO, verdict DO NOT APPLY. HIGH: the orders RDS instance is replaced (destroy first) because storage_encrypted changed, with deletion protection off and no final snapshot; the legacy logs bucket is deleted with force_destroy; SSH opened to 0.0.0.0/0 on the bastion; the S3 public access block is turned off for the assets bucket; the CI role gets iam:PassRole on * (and s3:*); AdministratorAccess is attached to the ops role. MEDIUM also includes aws_instance.web[1] destroyed by a count index shift. Fix: snapshot and migrate the database, moved or a removed block for the bucket, SSM instead of public SSH, scoped policies, for_each for web instances.
Rules
- Never print or quote passwords, keys or tokens from a plan, even if they are visible in the JSON.
- A HIGH finding blocks the apply until it is fixed or a named owner accepts it in writing.
- Prefer
moved,removedandimportblocks over destroy-and-recreate for refactors. - Data stores need a backup before any destroy or replace, and
prevent_destroyafterwards. - Do not approve plans you cannot see in full (truncated output,
-targetruns, errored plans); ask for the complete plan. - Policy-as-code (OPA/Conftest, Sentinel, Checkov, tfsec) is a good follow-up, not a replacement for reading the plan. FILE:references/reading-plan-output.md
Reading Terraform plan output
Get a machine-readable plan
terraform plan -out plan.out # add -var-file / -target exactly as the real apply will
terraform show -json plan.out > plan.json
terraform show -no-color plan.out > plan.txt # human-readable copy of the same saved plan
Review the SAME saved plan you will apply (terraform apply plan.out), not a fresh plan: state or remote resources can change in between. OpenTofu uses the same commands (tofu show -json) and format.
Text symbols
| Symbol | Header line | Meaning |
|---|---|---|
+ |
will be created |
new resource |
~ |
will be updated in-place |
attributes change, same resource ID |
- |
will be destroyed |
resource deleted |
-/+ |
must be replaced |
destroy first, then create (default) - downtime gap |
+/- |
must be replaced |
create first, then destroy (create_before_destroy) |
<= |
will be read during apply |
data source read; check why it is unknown at plan time |
# forces replacement |
after an attribute | this attribute change causes the replacement |
(known after apply) |
value | computed; you cannot review it now |
(sensitive value) |
value | hidden in text output, but present in plan JSON |
Reason lines under a header: # (because aws_x.y is not in configuration) (removed from code or moved without a moved block), # (because index [1] is out of range for count) (count shrank or shifted), # (because key ["a"] is not in for_each map), # (resource is tainted, so must be replaced), # (replace requested) (-replace= flag).
Summary line: Plan: 3 to add, 1 to change, 3 to destroy. A replacement counts once in "add" and once in "destroy", so compare destroy count with the number of - and -/+ headers.
JSON structure (format_version 1.x)
{
"format_version": "1.2", "terraform_version": "1.9.8",
"resource_changes": [ {
"address": "module.db.aws_db_instance.main", "module_address": "module.db",
"mode": "managed" | "data", "type": "aws_db_instance", "name": "main", "index": 0 | "key",
"change": {
"actions": ["create"] | ["update"] | ["delete"] | ["delete","create"] | ["create","delete"] | ["no-op"] | ["read"] | ["forget"],
"before": {...} | null, "after": {...} | null,
"after_unknown": {"id": true}, # values known only after apply
"before_sensitive": {...}, "after_sensitive": {"password": true},
"replace_paths": [["storage_encrypted"]], # attributes that force replacement
"importing": {"id": "..."} # import blocks (1.5+)
},
"action_reason": "replace_because_cannot_update" | "replace_because_tainted" | "replace_by_request" |
"delete_because_no_resource_config" | "delete_because_count_index" | "delete_because_each_key" | ...,
"previous_address": "aws_db_instance.old" # moved blocks
} ],
"resource_drift": [ ... changes made outside Terraform ... ],
"output_changes": {...}, "prior_state": {...}, "configuration": {...},
"applyable": true, "complete": true, "errored": false
}
Useful one-liners:
jq -r '.resource_changes[] | select(.change.actions != ["no-op"]) | "\(.change.actions | join(",")) \(.address)"' plan.json
jq -r '.resource_changes[] | select(.change.actions | index("delete")) | .address' plan.json
jq -r '.resource_changes[] | select(.change.replace_paths) | "\(.address): \(.change.replace_paths)"' plan.json
Secrets in plans
before/after contain real values, including passwords and private keys, even when *_sensitive marks them. Treat plan.out and plan.json like secrets: do not attach them to tickets, chats or public CI logs; keep them as short-lived, access-controlled CI artifacts. Share the risk summary instead.
What a plan cannot tell you
- Values computed at apply time (
known after apply), including many IAM policy documents built from other resources. - Side effects outside Terraform: DNS caches, clients pinned to an IP, data inside a database being replaced.
- Whether
lifecycle { prevent_destroy = true }is set (it is inconfiguration, and a plan that would violate it fails instead). - Provider-specific behaviour such as RDS applying some changes only in the next maintenance window (
apply_immediately). FILE:references/risk-rules.md
Risk rules used by plan_risk.py
| Code | Severity | Trigger | Why it matters | Typical fix |
|---|---|---|---|---|
| DATA_LOSS | HIGH | destroy or replace of a data-holding type: databases (RDS, Cloud SQL, Azure SQL/PostgreSQL/MySQL, Cosmos DB, DynamoDB), buckets, disks/volumes, EFS/FSx, caches, queues/streams, KMS keys, secrets, DNS zones, log groups, ECR, Kubernetes PVCs/namespaces | a new resource starts empty; KMS key deletion makes encrypted data unreadable | snapshot/backup first, moved block for refactors, prevent_destroy on data stores, migrate data before replacing |
| PROTECTION_OFF | HIGH (update) / MEDIUM (new or replaced) | deletion_protection, deletion_protection_enabled, enable_deletion_protection, termination_protection, disable_api_termination false |
the next mistake can delete production | keep true; disable only in a separate change right before a planned delete |
| PUBLIC_INGRESS | HIGH | new 0.0.0.0/0 or ::/0 ingress on all ports or admin/data ports (22, 3389, 3306, 5432, 1433, 6379, 27017, 9200, 6443 ...) | direct attack surface, brute force, data exposure | known CIDRs, SSM Session Manager, VPN, bastion, private endpoints |
| PUBLIC_INGRESS | MEDIUM / INFO | other ports / 80 and 443 | expose only load balancers | |
| PUBLIC_DATABASE | HIGH | publicly_accessible = true |
database endpoint reachable from the internet | private subnets, proxy, VPN |
| S3_PUBLIC_ACCESS | HIGH | any of the four public access block settings set to false | bucket policies or ACLs can make data public | keep all four true; CloudFront + Origin Access Control |
| PUBLIC_ACL | HIGH | public-read, public-read-write, authenticated-read |
everyone can list or read (or write) | BucketOwnerEnforced, private |
| PUBLIC_POLICY | HIGH | resource policy Allow with Principal * and no Condition |
anyone on the internet (or any AWS account) gets access | named principals; aws:SourceArn, aws:PrincipalOrgID, aws:SourceVpce |
| PUBLIC_IAM | HIGH | GCP binding to allUsers / allAuthenticatedUsers |
public access | specific members |
| TRUST_ANYONE | HIGH | role trust policy with Principal * and no Condition |
any AWS account can assume the role | exact ARNs + sts:ExternalId |
| IAM_ADMIN | HIGH | Action * / Resource *, AdministratorAccess, PowerUserAccess, IAMFullAccess, GCP owner/editor, Azure Owner/User Access Administrator (Contributor = MEDIUM) |
full control of the account or project | least privilege; break-glass admin with MFA |
| IAM_ESCALATION | HIGH | iam:PassRole, iam:Attach*/Put*Policy, iam:CreatePolicyVersion, iam:CreateAccessKey, sts:AssumeRole, kms:* on *; iam:*, sts:* anywhere |
the holder can grant itself more rights | scope to specific ARNs; iam:PassedToService condition |
| IAM_WILDCARD | HIGH on * / MEDIUM |
service:* actions, *FullAccess managed policies |
far more than needed, includes delete | list actions actually used (Access Analyzer, CloudTrail) |
| IAM_NOT_ACTION | MEDIUM | Allow with NotAction | grants everything else, including future actions | explicit Action list |
| IAM_WIDENED | LOW | update adds actions or resources to a policy | permission creep | confirm with the owner |
| TRUST_WIDENED | MEDIUM | new principals in a trust policy | new cross-account access | confirm each principal |
| NO_FINAL_SNAPSHOT | MEDIUM | skip_final_snapshot = true on RDS/Aurora/DocumentDB/Neptune/Redshift |
deleting leaves nothing to restore | false + final_snapshot_identifier |
| FORCE_DESTROY | MEDIUM | force_destroy = true newly set |
delete wipes all objects or records | keep false on real data |
| ENCRYPTION_OFF | MEDIUM | storage_encrypted/encrypted false |
compliance; enabling later usually forces replacement | enable now |
| BACKUPS_OFF | MEDIUM | backup_retention_period = 0 |
no point-in-time recovery | 7 to 35 days |
| SECRET_REGENERATED | MEDIUM | replace of random_password, random_string, tls_private_key, aws_iam_access_key |
consumers break | plan the rotation |
| COUNT_INDEX_SHIFT | MEDIUM | delete_because_count_index |
removing a middle item destroys and recreates the following ones | for_each with stable keys, moved blocks |
| DESTROY | MEDIUM (compute, LB, IP, cluster, count/each removal) / LOW | non-stateful destroy | downtime, lost IPs | confirm intent; moved for refactors |
| REPLACE | MEDIUM (destroy-first compute/LB) / LOW | non-stateful replace | new IDs, IPs, DNS names; downtime gap | create_before_destroy, check dependents |
| PUBLIC_FUNCTION_URL | MEDIUM | Lambda URL with auth NONE | public endpoint | AWS_IAM auth |
| PLAN_ERRORED / PLAN_INCOMPLETE | HIGH / MEDIUM | errored / complete: false |
partial picture | re-plan |
| DRIFT, REMOVED_FROM_CONFIG, MOVED, IMPORT, FORGET, POLICY_UNKNOWN, TEXT_MODE | INFO | context |
Verdict
- Any HIGH: do not apply until each HIGH is fixed or explicitly accepted by the owner (name and reason in the review).
- Only MEDIUM: apply after the owner of each affected resource agrees.
- LOW/INFO only: normal review.
Things to check by hand
- Blast radius: how many resources change, and are they all expected from the code diff? A one-line change that replaces 40 resources is a red flag (provider upgrade, changed
name,counttofor_each). - Order and downtime: destroy-first replacements of anything serving traffic; DNS TTLs; target group swaps.
- Data migration: a replaced database needs a restore from snapshot or a data migration plan; Terraform will not copy data.
- Unknown values: policies or security group rules that are
known after apply- review the module code instead. - Provider and module upgrades: read the changelog for forced replacements and default changes.
- Environment: confirm the workspace/backend (
terraform workspace show) and account (aws sts get-caller-identity) before applying. - Policy as code: OPA/Conftest, Sentinel, Checkov or tfsec rules can enforce the HIGH checks in CI. FILE:templates/plan-risk-summary.md
Terraform plan risk summary: <stack / workspace>
Change: <PR #123 "Encrypt orders DB, open bastion for vendor"> Workspace/account: <prod / 123456789012 eu-west-1> Plan: <terraform 1.9.8, plan.out from CI run 4567> Counts: <2 create, 4 update, 2 replace, 2 destroy> Verdict: <DO NOT APPLY as is / Apply after review / Low risk>
Must fix or explicitly accept (HIGH)
| # | Resource | Risk | Why | Fix or acceptance (who) |
|---|---|---|---|---|
| 1 | <aws_db_instance.orders> |
<DATA_LOSS: replaced because storage_encrypted changed> | <new empty DB; no final snapshot> | <snapshot, restore into encrypted copy, switch with a moved/import plan> |
Needs owner review (MEDIUM)
| # | Resource | Risk | Owner OK? |
|---|---|---|---|
| 1 | <aws_instance.web[1]> |
<COUNT_INDEX_SHIFT> | <yes/no> |
Low / info
- <IAM_WIDENED on ci_deploy: s3:* - replaced by explicit actions in the fix>
- <Drift on aws_lb.public tags: set in console by ...>
Expected vs unexpected changes
- Expected from the code diff: <...>
- Not explained by the code diff: <... investigate before apply>
Apply plan
- <Take snapshot
orders-pre-encrypt-2026-10-27> - <Apply in a maintenance window: <time, zone>>
- <Verify: app health checks,
aws rds describe-db-instances, bastion only from VPN> - Rollback: <restore snapshot / revert PR and re-apply>
Follow-ups
- <policy-as-code rule for 0.0.0.0/0 on port 22> FILE:examples/shop-prod-plan.json { "format_version": "1.2", "terraform_version": "1.9.8", "planned_values": {"root_module": {}}, "resource_drift": [ { "address": "aws_lb.public", "mode": "managed", "type": "aws_lb", "name": "public", "provider_name": "registry.terraform.io/hashicorp/aws", "change": { "actions": ["update"], "before": {"name": "shop-public", "tags": {"env": "prod"}}, "after": {"name": "shop-public", "tags": {"env": "prod", "owner": "set-in-console"}}, "after_unknown": {}, "before_sensitive": {}, "after_sensitive": {} } } ], "resource_changes": [ { "address": "aws_cloudwatch_log_group.app", "mode": "managed", "type": "aws_cloudwatch_log_group", "name": "app", "provider_name": "registry.terraform.io/hashicorp/aws", "change": { "actions": ["create"], "before": null, "after": {"name": "/shop/app", "retention_in_days": 30, "tags": {"env": "prod"}}, "after_unknown": {"arn": true, "id": true}, "before_sensitive": false, "after_sensitive": {} } }, { "address": "aws_db_instance.orders", "mode": "managed", "type": "aws_db_instance", "name": "orders", "provider_name": "registry.terraform.io/hashicorp/aws", "change": { "actions": ["delete", "create"], "before": {"identifier": "shop-orders", "engine": "postgres", "engine_version": "16.4", "instance_class": "db.t4g.medium", "allocated_storage": 100, "storage_encrypted": false, "deletion_protection": false, "skip_final_snapshot": true, "backup_retention_period": 7, "publicly_accessible": false, "username": "orders_admin", "password": null, "tags": {"env": "prod"}}, "after": {"identifier": "shop-orders", "engine": "postgres", "engine_version": "16.4", "instance_class": "db.t4g.medium", "allocated_storage": 100, "storage_encrypted": true, "deletion_protection": false, "skip_final_snapshot": true, "backup_retention_period": 7, "publicly_accessible": false, "username": "orders_admin", "password": null, "tags": {"env": "prod"}, "kms_key_id": null}, "after_unknown": {"id": true, "arn": true, "endpoint": true, "address": true}, "before_sensitive": {"password": true}, "after_sensitive": {"password": true}, "replace_paths": [["storage_encrypted"]] }, "action_reason": "replace_because_cannot_update" }, { "address": "aws_iam_role_policy.ci_deploy", "mode": "managed", "type": "aws_iam_role_policy", "name": "ci_deploy", "provider_name": "registry.terraform.io/hashicorp/aws", "change": { "actions": ["update"], "before": {"id": "shop-ci:deploy", "name": "deploy", "role": "shop-ci", "policy": "{"Version": "2012-10-17", "Statement": [{"Sid": "Deploy", "Effect": "Allow", "Action": ["s3:PutObject", "s3:GetObject"], "Resource": "arn:aws:s3:::shop-assets-prod/"}]}"}, "after": {"id": "shop-ci:deploy", "name": "deploy", "role": "shop-ci", "policy": "{"Version": "2012-10-17", "Statement": [{"Sid": "Deploy", "Effect": "Allow", "Action": "s3:", "Resource": ["arn:aws:s3:::shop-assets-prod", "arn:aws:s3:::shop-assets-prod/"]}, {"Sid": "PassAnyRole", "Effect": "Allow", "Action": "iam:PassRole", "Resource": ""}]}"}, "after_unknown": {}, "before_sensitive": {}, "after_sensitive": {} } }, { "address": "aws_iam_role_policy_attachment.ops_admin", "mode": "managed", "type": "aws_iam_role_policy_attachment", "name": "ops_admin", "provider_name": "registry.terraform.io/hashicorp/aws", "change": { "actions": ["create"], "before": null, "after": {"role": "shop-ops", "policy_arn": "arn:aws:iam::aws:policy/AdministratorAccess"}, "after_unknown": {"id": true}, "before_sensitive": false, "after_sensitive": {} } }, { "address": "aws_instance.web[1]", "mode": "managed", "type": "aws_instance", "name": "web", "index": 1, "provider_name": "registry.terraform.io/hashicorp/aws", "change": { "actions": ["delete"], "before": {"id": "i-0b1c2d3e4f5a6b7c8", "ami": "ami-0abc1234def567890", "instance_type": "t3.medium", "associate_public_ip_address": false, "tags": {"Name": "shop-web-1"}}, "after": null, "after_unknown": {}, "before_sensitive": {}, "after_sensitive": false }, "action_reason": "delete_because_count_index" }, { "address": "aws_lb.public", "mode": "managed", "type": "aws_lb", "name": "public", "provider_name": "registry.terraform.io/hashicorp/aws", "change": { "actions": ["update"], "before": {"name": "shop-public", "internal": false, "enable_deletion_protection": true, "tags": {"env": "prod", "owner": "set-in-console"}}, "after": {"name": "shop-public", "internal": false, "enable_deletion_protection": true, "tags": {"env": "prod", "team": "shop"}}, "after_unknown": {}, "before_sensitive": {}, "after_sensitive": {} } }, { "address": "aws_lb_target_group.web", "mode": "managed", "type": "aws_lb_target_group", "name": "web", "provider_name": "registry.terraform.io/hashicorp/aws", "change": { "actions": ["create", "delete"], "before": {"name_prefix": "web-", "port": 8080, "protocol": "HTTP", "vpc_id": "vpc-0a1b2c3d"}, "after": {"name_prefix": "web-", "port": 8081, "protocol": "HTTP", "vpc_id": "vpc-0a1b2c3d"}, "after_unknown": {"id": true, "arn": true, "name": true}, "before_sensitive": {}, "after_sensitive": {}, "replace_paths": [["port"]] }, "action_reason": "replace_because_cannot_update" }, { "address": "aws_s3_bucket.legacy_logs", "mode": "managed", "type": "aws_s3_bucket", "name": "legacy_logs", "provider_name": "registry.terraform.io/hashicorp/aws", "change": { "actions": ["delete"], "before": {"bucket": "shop-legacy-logs-prod", "force_destroy": true, "tags": {"env": "prod"}}, "after": null, "after_unknown": {}, "before_sensitive": {}, "after_sensitive": false }, "action_reason": "delete_because_no_resource_config" }, { "address": "aws_s3_bucket_public_access_block.assets", "mode": "managed", "type": "aws_s3_bucket_public_access_block", "name": "assets", "provider_name": "registry.terraform.io/hashicorp/aws", "change": { "actions": ["update"], "before": {"bucket": "shop-assets-prod", "block_public_acls": true, "block_public_policy": true, "ignore_public_acls": true, "restrict_public_buckets": true}, "after": {"bucket": "shop-assets-prod", "block_public_acls": true, "block_public_policy": false, "ignore_public_acls": true, "restrict_public_buckets": false}, "after_unknown": {}, "before_sensitive": {}, "after_sensitive": {} } }, { "address": "aws_security_group.bastion", "mode": "managed", "type": "aws_security_group", "name": "bastion", "provider_name": "registry.terraform.io/hashicorp/aws", "change": { "actions": ["update"], "before": {"name": "shop-bastion", "ingress": [{"cidr_blocks": ["10.20.0.0/16"], "description": "SSH from VPN", "from_port": 22, "to_port": 22, "protocol": "tcp", "ipv6_cidr_blocks": [], "prefix_list_ids": [], "security_groups": [], "self": false}], "egress": []}, "after": {"name": "shop-bastion", "ingress": [{"cidr_blocks": ["10.20.0.0/16"], "description": "SSH from VPN", "from_port": 22, "to_port": 22, "protocol": "tcp", "ipv6_cidr_blocks": [], "prefix_list_ids": [], "security_groups": [], "self": false}, {"cidr_blocks": ["0.0.0.0/0"], "description": "temp: SSH for vendor", "from_port": 22, "to_port": 22, "protocol": "tcp", "ipv6_cidr_blocks": [], "prefix_list_ids": [], "security_groups": [], "self": false}], "egress": []}, "after_unknown": {}, "before_sensitive": {}, "after_sensitive": {} } }, { "address": "module.network.aws_vpc.main", "module_address": "module.network", "mode": "managed", "type": "aws_vpc", "name": "main", "provider_name": "registry.terraform.io/hashicorp/aws", "change": { "actions": ["no-op"], "before": {"cidr_block": "10.20.0.0/16"}, "after": {"cidr_block": "10.20.0.0/16"}, "after_unknown": {}, "before_sensitive": {}, "after_sensitive": {} } } ], "prior_state": {"format_version": "1.0", "terraform_version": "1.9.8"}, "configuration": {"provider_config": {"aws": {"name": "aws", "full_name": "registry.terraform.io/hashicorp/aws"}}}, "timestamp": "2026-10-27T07:10:00Z", "applyable": true, "complete": true, "errored": false } FILE:scripts/plan_risk.py #!/usr/bin/env python3 """Terraform plan risk reviewer (Python 3 standard library only).
Reads terraform show -json plan.out output (or, with less detail, the text of
terraform plan -no-color) and flags risky changes: destroys and replacements of
stateful resources (data loss), removed deletion protection, skipped final
snapshots, force_destroy, disabled encryption, new public network exposure
(0.0.0.0/0 or ::/0 ingress, public databases, S3 public access blocks, public ACLs
and policies, allUsers bindings), IAM widening (wildcard actions, admin policies,
iam:PassRole on *, trust policies open to anyone) and count index shifts. Prints a
risk summary. Never prints attribute values that Terraform marks as sensitive.
Usage: terraform plan -out plan.out && terraform show -json plan.out > plan.json python3 plan_risk.py plan.json [--json] terraform plan -no-color > plan.txt && python3 plan_risk.py plan.txt (text mode) python3 plan_risk.py --self-test Exit codes: 0 = no HIGH findings, 1 = HIGH findings, 2 = unreadable input. """ import argparse, json, re, sys
SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2, "INFO": 3} STATEFUL = re.compile(r"^(aws_(db_instance|rds_cluster|dynamodb_table|s3_bucket|ebs_volume|efs_file_system|" r"elasticache_(cluster|replication_group)|redshift_cluster|docdb_cluster|neptune_cluster|" r"opensearch_domain|elasticsearch_domain|kinesis_stream|sqs_queue|kms_key|secretsmanager_secret|" r"backup_vault|ecr_repository|cloudwatch_log_group|msk_cluster|fsx_\w+|route53_zone|" r"cognito_user_pool|timestream_table)|" r"google_(sql_database_instance|sql_database|storage_bucket|bigquery_(dataset|table)|compute_disk|" r"spanner_(instance|database)|kms_crypto_key|redis_instance|filestore_instance|firestore_database|" r"dns_managed_zone)|" r"azurerm_(storage_account|storage_container|mssql_(database|server)|postgresql_flexible_server|" r"mysql_flexible_server|cosmosdb_account|key_vault|managed_disk|redis_cache|dns_zone)|" r"kubernetes_(persistent_volume_claim|persistent_volume|namespace)(v1)?)$") SECRET_GEN = re.compile(r"^(random_password|random_string|tls_private_key|aws_iam_access_key)$") DOWNTIME = re.compile(r"^(aws(instance|lb|alb|eip|nat_gateway|cloudfront_distribution|api_gateway_rest_api|" r"eks_cluster|ecs_service|acm_certificate)|google_(compute_instance|container_cluster)|" r"azurerm_(linux|windows)_virtual_machine|azurerm_kubernetes_cluster)$") SENSITIVE_PORTS = {22: "SSH", 23: "Telnet", 3389: "RDP", 3306: "MySQL", 5432: "PostgreSQL", 1433: "SQL Server", 1521: "Oracle", 6379: "Redis", 27017: "MongoDB", 9200: "Elasticsearch", 5601: "Kibana", 2375: "Docker API", 2379: "etcd", 11211: "Memcached", 5900: "VNC", 9092: "Kafka", 6443: "Kubernetes API", 10250: "Kubelet"} WEB_PORTS = {80, 443} PUBLIC_CIDRS = {"0.0.0.0/0", "::/0", "", "internet", "any"} ADMIN_POLICIES = re.compile(r"(AdministratorAccess|PowerUserAccess|IAMFullAccess)$") ESCALATION_ACTIONS = {"iam:", "iam:passrole", "iam:createpolicyversion", "iam:attachrolepolicy", "iam:putrolepolicy", "iam:createaccesskey", "iam:updateassumerolepolicy", "sts:assumerole", "sts:", "kms:", "*"} POLICY_ATTRS = ("policy", "assume_role_policy", "inline_policy") PROTECTION_ATTRS = ("deletion_protection", "deletion_protection_enabled", "enable_deletion_protection", "termination_protection", "disable_api_termination")
def finding(sev, code, addr, problem, fix): return {"severity": sev, "code": code, "resource": addr, "problem": problem, "fix": fix}
def kind_of(actions): a = list(actions or []) if a == ["delete", "create"]: return "replace (destroy first)" if a == ["create", "delete"]: return "replace (create first)" return {"create": "create", "update": "update", "delete": "destroy", "no-op": "no-op", "read": "read", "forget": "forget"}.get(a[0] if len(a) == 1 else ",".join(a), ",".join(a))
def sensitive(mask, key): return mask is True or (isinstance(mask, dict) and mask.get(key) not in (None, False))
def get(d, key, default=None): return d.get(key, default) if isinstance(d, dict) else default
def ports_of(lo, hi, proto): proto = str(proto).lower() if proto in ("-1", "all", "*"): return 0, 65535, "all" try: lo, hi = int(lo if lo not in (None, "") else 0), int(hi if hi not in (None, "") else 65535) except (TypeError, ValueError): return 0, 65535, proto if proto in ("icmp", "icmpv6", "1", "58"): return None if lo in (-1,) or (lo == 0 and hi in (0, 65535)): return 0, 65535, proto return lo, hi, proto
def open_rules(rtype, values): """Yield (cidr, lo, hi, proto) for internet-open ingress rules in a resource's values.""" v = values or {} out = set() def add(cidrs, lo, hi, proto): p = ports_of(lo, hi, proto) if p is None: return for c in cidrs or []: if str(c).lower() in PUBLIC_CIDRS: out.add((str(c), p[0], p[1], p[2])) if rtype == "aws_security_group": for r in v.get("ingress") or []: add((r.get("cidr_blocks") or []) + (r.get("ipv6_cidr_blocks") or []), r.get("from_port"), r.get("to_port"), r.get("protocol")) elif rtype == "aws_security_group_rule" and v.get("type") == "ingress": add((v.get("cidr_blocks") or []) + (v.get("ipv6_cidr_blocks") or []), v.get("from_port"), v.get("to_port"), v.get("protocol")) elif rtype == "aws_vpc_security_group_ingress_rule": add([x for x in (v.get("cidr_ipv4"), v.get("cidr_ipv6")) if x], v.get("from_port"), v.get("to_port"), v.get("ip_protocol")) elif rtype == "google_compute_firewall" and str(v.get("direction", "INGRESS")).upper() == "INGRESS": for al in v.get("allow") or []: ports = al.get("ports") or ["0-65535"] for pr in ports: lo, _, hi = str(pr).partition("-") add(v.get("source_ranges"), lo, hi or lo, al.get("protocol", "all")) elif rtype == "azurerm_network_security_rule" and str(v.get("direction")).lower() == "inbound" and str(v.get("access")).lower() == "allow": src = [v.get("source_address_prefix")] + (v.get("source_address_prefixes") or []) rng = v.get("destination_port_range") or "" for pr in [rng] + (v.get("destination_port_ranges") or []): if pr == "": add(src, 0, 65535, v.get("protocol", "")) else: lo, _, hi = str(pr).partition("-") add(src, lo, hi or lo, v.get("protocol", "")) return out
def port_label(lo, hi, proto): if proto == "all" or (lo == 0 and hi == 65535): return "all ports" names = [f"{p} ({n})" for p, n in SENSITIVE_PORTS.items() if lo <= p <= hi] rng = str(lo) if lo == hi else f"{lo}-{hi}" return f"port {rng}" + (f" incl. {', '.join(names[:3])}" if names and lo != hi else (f" ({SENSITIVE_PORTS[lo]})" if lo == hi and lo in SENSITIVE_PORTS else ""))
def parse_policy(doc): if isinstance(doc, str): try: doc = json.loads(doc) except ValueError: return None if not isinstance(doc, dict): return None st = doc.get("Statement") or [] return [st] if isinstance(st, dict) else st
def as_list(x): return x if isinstance(x, list) else ([] if x is None else [x])
def allow_actions(stmts): acts = set() for s in stmts or []: if s.get("Effect", "Allow") != "Allow": continue for a in as_list(s.get("Action")): for r in as_list(s.get("Resource")) or ["(none)"]: acts.add((str(a), str(r))) return acts
def check_policy(addr, attr, before, after, unknown): out = [] if unknown: return [finding("INFO", "POLICY_UNKNOWN", addr, f"{attr} is only known after apply", "Review the rendered policy after apply or render it with a data source in a separate plan.")] b, a = parse_policy(before), parse_policy(after) if a is None: return out if attr == "assume_role_policy": for s in a: if s.get("Effect", "Allow") != "Allow": continue pr = s.get("Principal") vals = [pr] if isinstance(pr, str) else [v for x in (pr or {}).values() for v in as_list(x)] if "" in vals and not s.get("Condition"): out.append(finding("HIGH", "TRUST_ANYONE", addr, "trust policy lets any AWS principal assume this role (Principal * without Condition)", "Name the exact account or role ARNs, and add sts:ExternalId or aws:PrincipalOrgID conditions.")) bp = {v for s in (b or []) for x in (get(s, "Principal") or {}).values() for v in as_list(x)} if b and all(isinstance(get(s, "Principal"), dict) for s in b) else set() ap = {v for s in a if isinstance(s.get("Principal"), dict) for x in s["Principal"].values() for v in as_list(x)} new = sorted(ap - bp - {""}) if b is not None and new: out.append(finding("MEDIUM", "TRUST_WIDENED", addr, "new principals can assume this role: " + ", ".join(new), "Confirm each new principal is expected (cross-account access?).")) return out for s in a: if s.get("Effect", "Allow") != "Allow": continue orig = [str(x) for x in as_list(s.get("Action"))] acts = [x.lower() for x in orig] res = [str(x) for x in as_list(s.get("Resource"))] pr = s.get("Principal") if pr is not None: vals = [pr] if isinstance(pr, str) else [v for x in pr.values() for v in as_list(x)] if "" in vals and not s.get("Condition"): out.append(finding("HIGH", "PUBLIC_POLICY", addr, f"resource policy allows Principal * ({', '.join(acts)[:80]}) without a Condition", "Restrict Principal to known ARNs or add aws:SourceArn / aws:PrincipalOrgID / aws:SourceVpce conditions; use CloudFront OAC for public sites.")) if s.get("NotAction"): out.append(finding("MEDIUM", "IAM_NOT_ACTION", addr, "Allow with NotAction grants everything except the listed actions", "List the allowed actions explicitly.")) if "" in acts and "" in res: out.append(finding("HIGH", "IAM_ADMIN", addr, "Allow Action * on Resource * (full admin)", "Grant only the actions and resources this principal needs.")) continue esc = sorted({o for o, x in zip(orig, acts) if x in ESCALATION_ACTIONS and x != "" and ("" in res or x in ("iam:", "sts:"))}) if esc: out.append(finding("HIGH", "IAM_ESCALATION", addr, f"{', '.join(esc)} on {', '.join(res) or ''} lets the holder gain more privileges", "Scope iam:PassRole and sts:AssumeRole to specific role ARNs (and iam:PassedToService); avoid iam:* and kms:* wildcards.")) wild = sorted({o for o, x in zip(orig, acts) if x.endswith(":") and x not in ESCALATION_ACTIONS}) if wild: sev = "HIGH" if "" in res else "MEDIUM" out.append(finding(sev, "IAM_WILDCARD", addr, f"service wildcard {', '.join(wild)} on {', '.join(res)}", "Replace with the specific actions used (check IAM Access Analyzer or CloudTrail for what is actually called).")) if b is not None: added = sorted({x[0] + (" on " + x[1] if x[1] not in ("*", "(none)") else " on *") for x in allow_actions(a) - allow_actions(b)}) if added: out.append(finding("LOW", "IAM_WIDENED", addr, "policy grants new actions/resources: " + ", ".join(added[:8]) + (" ..." if len(added) > 8 else ""), "Confirm the new permissions are needed; least privilege.")) return out
def review_change(rc):
out = []
addr, rtype = rc.get("address", "?"), rc.get("type", "")
ch = rc.get("change") or {}
actions = ch.get("actions") or []
kind = kind_of(actions)
before, after = ch.get("before") or {}, ch.get("after") or {}
unknown = ch.get("after_unknown") or {}
reason = rc.get("action_reason", "")
if rc.get("mode") == "data" or kind in ("no-op", "read"):
return kind, out
why = ""
if ch.get("replace_paths"):
why = " (forced by " + ", ".join(".".join(map(str, p)) for p in ch["replace_paths"]) + ")"
elif reason:
why = f" ({reason.replace('', ' ')})"
stateful = bool(STATEFUL.match(rtype))
if kind == "forget":
out.append(finding("INFO", "FORGET", addr, "removed from state but not destroyed (removed block)", "Make sure another configuration or team takes ownership of it."))
if kind == "destroy" or kind.startswith("replace"):
if stateful:
extra = "; force_destroy = true, so everything in it is deleted too" if get(before, "force_destroy") is True else ""
out.append(finding("HIGH", "DATA_LOSS", addr, f"{rtype} will be {'destroyed' if kind == 'destroy' else 'replaced, ' + kind[9:-1]}{why}; its data does not carry over{extra}",
"Back up or snapshot first; if this is a rename/refactor add a moved block or terraform state mv; add lifecycle prevent_destroy for data stores."))
elif SECRET_GEN.match(rtype) and kind.startswith("replace"):
out.append(finding("MEDIUM", "SECRET_REGENERATED", addr, f"{rtype} will be regenerated{why}; everything using the old value breaks",
"Check what consumes it (database password, keys) and plan the rotation."))
elif kind == "destroy":
sev = "MEDIUM" if DOWNTIME.match(rtype) or reason in ("delete_because_count_index", "delete_because_each_key") else "LOW"
out.append(finding(sev, "DESTROY", addr, f"will be destroyed{why}",
"Confirm it is intended; for a rename/refactor use a moved block instead of destroy + create."))
else:
sev = "MEDIUM" if kind == "replace (destroy first)" and DOWNTIME.match(rtype) else "LOW"
out.append(finding(sev, "REPLACE", addr, f"{kind}{why}",
"Expect a new ID/IP/DNS name; use create_before_destroy where possible and check dependents."))
if reason in ("delete_because_count_index",):
out.append(finding("MEDIUM", "COUNT_INDEX_SHIFT", addr, "destroyed because a count index moved (an item was removed from the middle of a list)",
"Switch to for_each with stable keys, or add moved blocks for the shifted indexes."))
if reason == "delete_because_no_resource_config":
out.append(finding("INFO", "REMOVED_FROM_CONFIG", addr, "resource no longer in configuration", "If it moved to another module or address, add a moved block; to keep it but stop managing it, use a removed block with destroy = false."))
if kind in ("create", "update") or kind.startswith("replace"):
bm, am = ch.get("before_sensitive") or {}, ch.get("after_sensitive") or {}
for attr in PROTECTION_ATTRS:
if get(after, attr) is False and (kind != "update" or get(before, attr) is True):
out.append(finding("HIGH" if kind == "update" else "MEDIUM", "PROTECTION_OFF", addr, f"{attr} {'turned off' if kind == 'update' else 'is false'}",
f"Keep {attr} = true for production resources; turn it off only in a separate, reviewed change right before a planned delete."))
if get(after, "skip_final_snapshot") is True and rtype in ("aws_db_instance", "aws_rds_cluster", "aws_docdb_cluster", "aws_neptune_cluster", "aws_redshift_cluster"):
if kind != "update" or get(before, "skip_final_snapshot") is not True:
out.append(finding("MEDIUM", "NO_FINAL_SNAPSHOT", addr, "skip_final_snapshot = true: a delete leaves no snapshot",
"Set skip_final_snapshot = false with a final_snapshot_identifier."))
if get(after, "force_destroy") is True and (kind != "update" or get(before, "force_destroy") is not True):
out.append(finding("MEDIUM", "FORCE_DESTROY", addr, "force_destroy = true lets Terraform delete the bucket/zone with everything in it",
"Keep force_destroy = false on buckets and zones that hold real data."))
for attr in ("storage_encrypted", "encrypted", "server_side_encryption"):
if get(after, attr) is False and (kind != "update" or get(before, attr) is True):
out.append(finding("MEDIUM", "ENCRYPTION_OFF", addr, f"{attr} = false", "Enable encryption at rest (it usually forces replacement later, so do it now)."))
if get(after, "backup_retention_period") == 0 and get(before, "backup_retention_period", 1) != 0:
out.append(finding("MEDIUM", "BACKUPS_OFF", addr, "backup_retention_period = 0 disables automated backups", "Keep at least 7 days for production databases."))
if get(after, "publicly_accessible") is True and get(before, "publicly_accessible") is not True:
out.append(finding("HIGH", "PUBLIC_DATABASE", addr, "publicly_accessible = true gives the database a public endpoint",
"Keep databases private; connect through a bastion, VPN, SSM or RDS Proxy."))
if rtype == "aws_s3_bucket_public_access_block":
off = [k for k in ("block_public_acls", "block_public_policy", "ignore_public_acls", "restrict_public_buckets")
if get(after, k) is False and (kind != "update" or get(before, k) is not False)]
if off:
out.append(finding("HIGH", "S3_PUBLIC_ACCESS", addr, "S3 public access block turned off: " + ", ".join(off),
"Keep all four settings true; serve public content through CloudFront with Origin Access Control."))
if rtype in ("aws_s3_bucket_acl", "aws_s3_object") and get(after, "acl") in ("public-read", "public-read-write", "authenticated-read"):
out.append(finding("HIGH", "PUBLIC_ACL", addr, f"acl = {after['acl']}", "Use private ACLs (BucketOwnerEnforced) and CloudFront for public files."))
if rtype.startswith("google") and "iam" in rtype:
members = as_list(get(after, "member")) + as_list(get(after, "members"))
if any(m in ("allUsers", "allAuthenticatedUsers") for m in members):
out.append(finding("HIGH", "PUBLIC_IAM", addr, "grants a role to allUsers/allAuthenticatedUsers", "Grant to specific users, groups or service accounts."))
if get(after, "role") in ("roles/owner", "roles/editor", "roles/iam.securityAdmin"):
out.append(finding("HIGH", "IAM_ADMIN", addr, f"grants {after['role']}", "Use a narrower predefined or custom role."))
if rtype == "azurerm_role_assignment" and get(after, "role_definition_name") in ("Owner", "User Access Administrator", "Contributor"):
sev = "MEDIUM" if after["role_definition_name"] == "Contributor" else "HIGH"
out.append(finding(sev, "IAM_ADMIN", addr, f"assigns {after['role_definition_name']}", "Use the narrowest built-in role at the smallest scope."))
if rtype.startswith("aws_iam_") and rtype.endswith("policy_attachment"):
arn = get(after, "policy_arn") or ""
if ADMIN_POLICIES.search(arn) and get(before, "policy_arn") != arn:
out.append(finding("HIGH", "IAM_ADMIN", addr, f"attaches {arn.split('/')[-1]}", "Attach a least-privilege policy instead; keep admin roles for break-glass use with MFA."))
elif arn.endswith("FullAccess") and get(before, "policy_arn") != arn:
out.append(finding("MEDIUM", "IAM_WILDCARD", addr, f"attaches {arn.split('/')[-1]}", "Prefer a scoped customer-managed policy."))
if rtype == "aws_lambda_function_url" and get(after, "authorization_type") == "NONE":
out.append(finding("MEDIUM", "PUBLIC_FUNCTION_URL", addr, "Lambda function URL with authorization_type NONE is public", "Use AWS_IAM auth or validate requests in the function."))
new_open = open_rules(rtype, after) - (open_rules(rtype, before) if kind == "update" else set())
for cidr, lo, hi, proto in sorted(new_open, key=lambda x: (x[1], x[2])):
label = port_label(lo, hi, proto)
if proto == "all" or (lo == 0 and hi == 65535) or any(lo <= p <= hi for p in SENSITIVE_PORTS):
sev = "HIGH"
elif lo in WEB_PORTS and hi == lo:
sev = "INFO"
else:
sev = "MEDIUM"
out.append(finding(sev, "PUBLIC_INGRESS", addr, f"new ingress from {cidr} on {label}",
"Restrict the source to known CIDRs or a load balancer security group; reach admin ports through SSM, VPN or a bastion." if sev != "INFO" else "Expected for a public web endpoint; make sure only the load balancer is exposed."))
for attr in POLICY_ATTRS:
if attr in after or get(unknown, attr):
if sensitive(am, attr):
continue
out += check_policy(addr, attr, get(before, attr), get(after, attr), get(unknown, attr) is True)
return kind, out
def review_json(plan): if not isinstance(plan, dict) or "format_version" not in plan: raise ValueError("not a terraform show -json document (no format_version)") changes, findings = [], [] for rc in plan.get("resource_changes") or []: kind, f = review_change(rc) if kind not in ("no-op", "read") and rc.get("mode") != "data": changes.append((kind, rc.get("address", "?"))) findings += f if (rc.get("change") or {}).get("importing"): findings.append(finding("INFO", "IMPORT", rc.get("address", "?"), "will be imported", "Check the plan shows no further changes after import.")) if rc.get("previous_address"): findings.append(finding("INFO", "MOVED", rc.get("address", "?"), f"moved from {rc['previous_address']}", "No action; moved blocks avoid destroy + create.")) drift = [d.get("address", "?") for d in plan.get("resource_drift") or []] if drift: findings.append(finding("INFO", "DRIFT", ", ".join(drift[:5]) + (" ..." if len(drift) > 5 else ""), f"{len(drift)} resource(s) changed outside Terraform", "Find out who changed them; the plan may revert manual fixes.")) if plan.get("errored"): findings.append(finding("HIGH", "PLAN_ERRORED", "-", "the plan errored", "Fix the errors and re-plan before applying anything.")) elif plan.get("complete") is False: findings.append(finding("MEDIUM", "PLAN_INCOMPLETE", "-", "plan is not complete (deferred or -target changes)", "Run a full plan after this apply.")) return changes, findings, plan.get("terraform_version", "?")
TEXT_RE = re.compile(r"^\s*# (\S+) (will be created|will be destroyed|will be updated in-place|must be replaced|will be read during apply|will no longer be managed by Terraform)")
def review_text(text):
text = re.sub(r"\x1b[[0-9;]m", "", text)
lines = text.splitlines()
changes, findings = [], []
i = 0
while i < len(lines):
m = TEXT_RE.match(lines[i])
if not m:
i += 1; continue
addr, verb = m.group(1), m.group(2)
rtype = re.sub(r"^(module.[^.]+.)", "", addr).split(".")[0]
if addr.startswith("data.") or ".data." in addr or "read during apply" in verb:
i += 1; continue
block, j = [], i + 1
while j < len(lines) and not TEXT_RE.match(lines[j]) and not lines[j].startswith("Plan:"):
block.append(lines[j]); j += 1
first = next((l.strip() for l in block if l.strip() and not l.strip().startswith("#")), "")
reason = next((l.strip()[3:-1] for l in block[:3] if l.strip().startswith("# (")), "")
kind = {"will be created": "create", "will be destroyed": "destroy", "will be updated in-place": "update",
"will no longer be managed by Terraform": "forget"}.get(verb)
if verb == "must be replaced":
kind = "replace (create first)" if first.startswith("+/-") else "replace (destroy first)"
forced = [re.sub(r"^[~+-/ ]+", "", l.strip()).split(" ")[0] for l in block if "# forces replacement" in l]
rc = {"address": addr, "type": rtype, "change": {"actions": {"create": ["create"], "update": ["update"], "destroy": ["delete"], "forget": ["forget"],
"replace (create first)": ["create", "delete"], "replace (destroy first)": ["delete", "create"]}[kind],
"replace_paths": [[f] for f in forced]}}
if "out of range for count" in reason or "count index" in reason:
rc["action_reason"] = "delete_because_count_index"
elif "not in for_each map" in reason:
rc["action_reason"] = "delete_because_each_key"
elif "not in configuration" in reason:
rc["action_reason"] = "delete_because_no_resource_config"
k, f = review_change(rc)
changes.append((k, addr))
findings += f
i = j
if not changes and "No changes." not in text:
raise ValueError("no resource changes found; is this terraform plan output?")
findings.append(finding("INFO", "TEXT_MODE", "-", "text plan: only actions were checked, not attribute values (IAM, ingress, protection flags)",
"Run terraform show -json plan.out > plan.json and review the JSON for full checks."))
return changes, findings, "version unknown (text plan)"
def summarize(changes, findings): counts = {} for k, _ in changes: key = "replace" if k.startswith("replace") else k counts[key] = counts.get(key, 0) + 1 findings.sort(key=lambda f: (SEV_ORDER[f["severity"]], f["resource"])) sev = {s: sum(1 for f in findings if f["severity"] == s) for s in SEV_ORDER} if sev["HIGH"]: verdict = "DO NOT APPLY as is: resolve or explicitly accept every HIGH finding" elif sev["MEDIUM"]: verdict = "Apply after review: MEDIUM findings need an owner's OK" else: verdict = "Low risk" return counts, sev, verdict
def render(path, version, changes, findings):
counts, sev, verdict = summarize(changes, findings)
out = [f"# Terraform plan risk review: {path}", "",
f"Terraform {version}. Changes: " + ", ".join(f"{counts.get(k, 0)} {k}" for k in ("create", "update", "replace", "destroy")) +
(f", {counts['forget']} forget" if counts.get("forget") else ""),
f"Findings: {sev['HIGH']} HIGH, {sev['MEDIUM']} MEDIUM, {sev['LOW']} LOW, {sev['INFO']} INFO",
f"Verdict: {verdict}", "", "## Findings", ""]
if findings:
out += ["| Severity | Code | Resource | Problem | What to do |", "|---|---|---|---|---|"]
for f in findings:
out.append(f"| {f['severity']} | {f['code']} | {f['resource']} | {f['problem']} | {f['fix']} |")
else:
out.append("No findings.")
out += ["", "## All changes", "", "| Action | Resource |", "|---|---|"]
order = {"destroy": 0, "replace (destroy first)": 1, "replace (create first)": 2, "update": 3, "create": 4, "forget": 5}
for k, a in sorted(changes, key=lambda x: (order.get(x[0], 9), x[1])):
out.append(f"| {k} | {a} |")
out += ["", "Plan JSON can contain secrets in plain text: do not paste it into tickets or chats; share this summary instead."]
return "\n".join(out)
def self_test(): cases = [] def t(name, cond): cases.append((name, bool(cond))) def codes(rc): return {f["code"]: f["severity"] for f in review_change(rc)[1]} def rc(rtype, actions, before=None, after=None, **kw): d = {"address": f"{rtype}.x", "mode": "managed", "type": rtype, "change": {"actions": actions, "before": before, "after": after, kw.pop("change", {})}} d.update(kw) return d t("kind names", kind_of(["delete", "create"]) == "replace (destroy first)" and kind_of(["create", "delete"]) == "replace (create first)" and kind_of(["delete"]) == "destroy") t("db destroy = HIGH DATA_LOSS", codes(rc("aws_db_instance", ["delete"], {"id": "a"}, None)).get("DATA_LOSS") == "HIGH") t("bucket replace = HIGH DATA_LOSS", codes(rc("aws_s3_bucket", ["create", "delete"], {}, {})).get("DATA_LOSS") == "HIGH") t("stateless destroy LOW", codes(rc("aws_iam_role", ["delete"], {}, None)).get("DESTROY") == "LOW") t("instance destroy-first replace MEDIUM", codes(rc("aws_instance", ["delete", "create"], {}, {})).get("REPLACE") == "MEDIUM") t("create-first replace LOW", codes(rc("aws_lb_target_group", ["create", "delete"], {}, {})).get("REPLACE") == "LOW") t("replace_paths shown", "forced by engine" in review_change(rc("aws_instance", ["delete", "create"], {}, {}, change={"replace_paths": [["engine"]]}))[1][0]["problem"]) t("random_password regenerated", codes(rc("random_password", ["delete", "create"], {}, {})).get("SECRET_REGENERATED") == "MEDIUM") t("count index shift", "COUNT_INDEX_SHIFT" in codes(rc("aws_instance", ["delete"], {}, None, action_reason="delete_because_count_index"))) t("no-op ignored", review_change(rc("aws_vpc", ["no-op"], {}, {}))[1] == []) t("deletion_protection turned off HIGH", codes(rc("aws_db_instance", ["update"], {"deletion_protection": True}, {"deletion_protection": False})).get("PROTECTION_OFF") == "HIGH") t("skip_final_snapshot MEDIUM", "NO_FINAL_SNAPSHOT" in codes(rc("aws_db_instance", ["create"], None, {"skip_final_snapshot": True}))) t("force_destroy MEDIUM", "FORCE_DESTROY" in codes(rc("aws_s3_bucket", ["update"], {"force_destroy": False}, {"force_destroy": True}))) t("unchanged force_destroy not re-flagged", "FORCE_DESTROY" not in codes(rc("aws_s3_bucket", ["update"], {"force_destroy": True}, {"force_destroy": True}))) t("encryption off", "ENCRYPTION_OFF" in codes(rc("aws_ebs_volume", ["create"], None, {"encrypted": False}))) t("publicly_accessible HIGH", codes(rc("aws_db_instance", ["update"], {"publicly_accessible": False}, {"publicly_accessible": True})).get("PUBLIC_DATABASE") == "HIGH") sg_b = {"ingress": [{"cidr_blocks": ["10.0.0.0/8"], "from_port": 22, "to_port": 22, "protocol": "tcp"}]} sg_a = {"ingress": sg_b["ingress"] + [{"cidr_blocks": ["0.0.0.0/0"], "from_port": 22, "to_port": 22, "protocol": "tcp"}]} t("new SSH from 0.0.0.0/0 HIGH", codes(rc("aws_security_group", ["update"], sg_b, sg_a)).get("PUBLIC_INGRESS") == "HIGH") t("existing open rule not re-flagged", "PUBLIC_INGRESS" not in codes(rc("aws_security_group", ["update"], sg_a, sg_a))) t("443 from internet INFO", codes(rc("aws_vpc_security_group_ingress_rule", ["create"], None, {"cidr_ipv4": "0.0.0.0/0", "from_port": 443, "to_port": 443, "ip_protocol": "tcp"})).get("PUBLIC_INGRESS") == "INFO") t("all protocols HIGH", codes(rc("aws_security_group_rule", ["create"], None, {"type": "ingress", "cidr_blocks": ["0.0.0.0/0"], "from_port": 0, "to_port": 0, "protocol": "-1"})).get("PUBLIC_INGRESS") == "HIGH") t("ipv6 ::/0 8080 MEDIUM", codes(rc("aws_security_group_rule", ["create"], None, {"type": "ingress", "ipv6_cidr_blocks": ["::/0"], "from_port": 8080, "to_port": 8080, "protocol": "tcp"})).get("PUBLIC_INGRESS") == "MEDIUM") t("gcp firewall 3389", codes(rc("google_compute_firewall", ["create"], None, {"direction": "INGRESS", "source_ranges": ["0.0.0.0/0"], "allow": [{"protocol": "tcp", "ports": ["3389"]}]})).get("PUBLIC_INGRESS") == "HIGH") t("azure nsg * HIGH", codes(rc("azurerm_network_security_rule", ["create"], None, {"direction": "Inbound", "access": "Allow", "source_address_prefix": "", "destination_port_range": "", "protocol": "Tcp"})).get("PUBLIC_INGRESS") == "HIGH") t("S3 public access block off", codes(rc("aws_s3_bucket_public_access_block", ["update"], {"block_public_policy": True}, {"block_public_policy": False})).get("S3_PUBLIC_ACCESS") == "HIGH") t("public-read ACL", "PUBLIC_ACL" in codes(rc("aws_s3_bucket_acl", ["create"], None, {"acl": "public-read"}))) pol = lambda st: json.dumps({"Version": "2012-10-17", "Statement": list(st)}) t("admin policy HIGH", codes(rc("aws_iam_policy", ["create"], None, {"policy": pol({"Effect": "Allow", "Action": "", "Resource": ""})})).get("IAM_ADMIN") == "HIGH") t("PassRole on * HIGH", codes(rc("aws_iam_role_policy", ["create"], None, {"policy": pol({"Effect": "Allow", "Action": ["iam:PassRole"], "Resource": ""})})).get("IAM_ESCALATION") == "HIGH") t("s3: on bucket MEDIUM", codes(rc("aws_iam_role_policy", ["create"], None, {"policy": pol({"Effect": "Allow", "Action": "s3:", "Resource": "arn:aws:s3:::b/"})})).get("IAM_WILDCARD") == "MEDIUM") t("IAM_WIDENED lists new actions", "s3:DeleteObject on arn:aws:s3:::b/" in next(f["problem"] for f in review_change(rc("aws_iam_role_policy", ["update"], {"policy": pol({"Effect": "Allow", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::b/"})}, {"policy": pol({"Effect": "Allow", "Action": ["s3:GetObject", "s3:DeleteObject"], "Resource": "arn:aws:s3:::b/"})}))[1] if f["code"] == "IAM_WIDENED")) t("Deny statements ignored", "IAM_ADMIN" not in codes(rc("aws_iam_policy", ["create"], None, {"policy": pol({"Effect": "Deny", "Action": "", "Resource": ""})}))) t("trust anyone HIGH", codes(rc("aws_iam_role", ["create"], None, {"assume_role_policy": pol({"Effect"
Truncated - read the full file at https://github.com/f/awesome-chatgpt-prompts/blob/main/prompts.csv.
