Prompt

--- 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` - reads `terraform show -json` output (full checks) or `terraform plan -no-color` text (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 show - `references/risk-rules.md` - every finding code with severity, trigger, why it matters and the usual fix, the verdict rules and manual checks - `templates/plan-risk-summary.md` - the summary to post on the pull request - `examples/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 `moved` block), a forced replacement (which attribute, see `replace_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`, `removed` and `import` blocks over destroy-and-recreate for refactors. - Data stores need a backup before any destroy or replace, and `prevent_destroy` afterwards. - Do not approve plans you cannot see in full (truncated output, `-target` runs, 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 in `configuration`, 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`, `count` to `for_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 1. <Take snapshot `orders-pre-encrypt-2026-10-27`> 2. <Apply in a maintenance window: <time, zone>> 3. <Verify: app health checks, `aws rds describe-db-instances`, bastion only from VPN> 4. Rollback: <restore snapshot / revert PR and re-apply> ## Follow-ups - <add prevent_destroy to data stores> - <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 {aft [Contenido truncado por el límite de Google Sheets]