[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"skill:01M71GQMEWPDSPDN52KVMN8ZG3":3,"skill-same:01M71GQMEWPDSPDN52KVMN8ZG3":88},{"skillNo":4,"name":5,"nameZh":6,"description":7,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":16,"categories":17,"keywords":26,"publishedAt":47,"linkOnly":48,"markdownContent":49,"markdownBody":50,"frontMatter":51,"refName":65,"commitSha":66,"skillDirectory":67,"forksCount":68,"repositoryHtmlUrl":69,"repositoryHomepageUrl":6,"ownerHtmlUrl":70,"ownerBlogUrl":71,"ownerDescription":72,"updatedAt":73,"install":74,"resources":87},"01M71GQMEWPDSPDN52KVMN8ZG3","iac-review",null,"Read-only review of a Terraform plan or cdk diff: destroys, replacements, state safety, pins, secrets, EKS.","timurgaleev\u002Fvibestack","timurgaleev","https:\u002F\u002Favatars.githubusercontent.com\u002Fu\u002F32696429?v=4","01M71GMS53Z9B4JQTCQEFXAE9E","Timur Galeev","01M71GMS5S1Q7BEYB3Z7GY3E85",8,"MIT","https:\u002F\u002Fgithub.com\u002Ftimurgaleev\u002Fvibestack\u002Fblob\u002Fmain\u002Fskills\u002Fiac-review\u002FSKILL.md",[18,23],{"categoryNo":19,"categoryName":20,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000010","后端与接口","SCENARIO","使用场景",{"categoryNo":24,"categoryName":25,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000011","运维与部署",[27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46],"review","iac","iac review","cdk","backend","state","check","apply","read-only","diff","cluster","Safety","safe","secrets","read","eks","plan","Bash","only","terraform state backend","2026-11-10 10:12:30",false,"---\nname: iac-review\ndescription: |\n  Read-only review of a Terraform plan or cdk diff: destroys, replacements, state safety, pins, secrets, EKS.\ntriggers:\n  - review the terraform plan\n  - iac review\n  - review the cdk diff\n  - is this plan safe to apply\n  - eks review\n  - review the eks cluster\n  - check the terraform state backend\nallowed-tools:\n  - Bash\n  - Read\n  - Grep\n  - Glob\n  - AskUserQuestion\n---\n\nIf this session was compacted, the copy of this skill in context may be cut off.\nRead `${CLAUDE_SKILL_DIR}\u002FSKILL.md` from disk and resume at the step you are on.\n\n## When to invoke\n\nUse when: \"review this terraform plan\", \"is this plan safe to apply\", \"what will this cdk diff destroy\", \"review our Terraform\", \"review the EKS cluster config\", \"check the state backend\". Read-only: it never applies, deploys, imports, moves state or edits a file.\n\n## Preamble\n\n```bash\neval \"$(\"${VIBESTACK_HOME:-$HOME\u002F.vibestack}\u002Fbin\u002Fvibe-slug\" 2>\u002Fdev\u002Fnull)\" 2>\u002Fdev\u002Fnull || SLUG=\"unknown\"\n_LEARN_FILE=\"${VIBESTACK_HOME:-$HOME\u002F.vibestack}\u002Fprojects\u002F${SLUG:-unknown}\u002Flearnings.jsonl\"\nif [ -f \"$_LEARN_FILE\" ]; then\n  _LEARN_COUNT=$(wc -l \u003C \"$_LEARN_FILE\" 2>\u002Fdev\u002Fnull | tr -d ' ')\n  echo \"LEARNINGS: $_LEARN_COUNT entries loaded\"\n  if [ \"$_LEARN_COUNT\" -gt 5 ] 2>\u002Fdev\u002Fnull; then\n    \"${VIBESTACK_HOME:-$HOME\u002F.vibestack}\u002Fbin\u002Fvibe-learnings-search\" --limit 5 2>\u002Fdev\u002Fnull || true\n  fi\nelse\n  echo \"LEARNINGS: none yet\"\nfi\n```\n\n{{include lib\u002Fsnippets\u002Fsession-host.md}}\n\n{{include lib\u002Fsnippets\u002Fdecision-brief.md}}\n\n{{include lib\u002Fsnippets\u002Fworking-protocols.md}}\n\n## User-invocable\nWhen the user types `\u002Fiac-review`, run this skill. `\u002Fiac-review \u003Cplan.json | planfile | dir>` reviews that input.\n\n**N-A is never a pass.** Every check below ends as FINDING, PASS or `N-A (\u003Creason>)`. A check that could not run — no plan, a failed call, a scanner not installed, a partial backend config — is N-A with the reason, and it keeps the verdict at INCOMPLETE. An empty result counts as PASS only when the call that produced it succeeded.\n\n---\n\n## Step 0: Find the inputs\n\n```bash\nfind . \\( -name .terraform -o -name node_modules -o -name .git -o -name cdk.out \\) -prune -o \\\n  \\( -name '*.tf' -o -name cdk.json -o -name Chart.yaml -o -name '*.tfplan' -o -name 'plan*.json' \\) -print 2>\u002Fdev\u002Fnull | head -n 60\ngrep -rlE '^kind:[[:space:]]*(Deployment|StatefulSet|DaemonSet|Namespace|NetworkPolicy|PodDisruptionBudget)' \\\n  --include='*.yaml' --include='*.yml' . 2>\u002Fdev\u002Fnull | grep -vE 'node_modules|\u002F\\.terraform\u002F' | head -n 40\ngrep -rlE 'aws_eks_|AWS::EKS::|eks\\.Cluster|terraform-aws-modules\u002Feks' --include='*.tf' --include='*.ts' \\\n  --include='*.py' --include='*.json' . 2>\u002Fdev\u002Fnull | grep -vE 'node_modules|\u002F\\.terraform\u002F|cdk\\.out' | head -n 20\n```\n\nA Terraform root module is a directory whose `.tf` files hold a `backend` or `cloud` block, or the provider configuration. When there are several roots (`envs\u002Fprod`, `envs\u002Fstaging`), ask which one (AskUserQuestion) unless the request named it. Write the scope in one line: root module or CDK app, environment, and whether EKS resources, manifests or Helm values are present — that decides whether the `## EKS` section runs.\n\n{{include lib\u002Fsnippets\u002Faws-context.md}}\n\nThe identity matters here because a plan and a `cdk diff` read live state with it: a plan run against the wrong account reports a diff for the wrong environment.\n\n---\n\n## Step 1: Get the plan\n\nPrefer a plan the user already has. A `terraform show -json` file is used as-is; a binary plan file is rendered with `terraform show -json \u003Cplanfile>`; a `cdk diff` printout is read as text. Only when there is none, generate one.\n\nGenerating a plan runs the providers with the user's credentials and executes any `external` data source in the configuration; `cdk diff` synthesizes the app, which runs its code. Ask once (AskUserQuestion) before the first of these, naming the root module, the profile and region from the context block, and that `terraform init` downloads the providers and modules the configuration names when `.terraform\u002F` is missing. On no, every plan check is `N-A (no plan: declined)` and Step 3 still runs.\n\n```bash\nTF_DIR='\u003CTF_DIR>'\n# A run that stopped before Step 6 left its plan (secrets in clear text) behind.\nfind \"${TMPDIR:-\u002Ftmp}\" -maxdepth 1 -type d -name 'iac-review.??????' -mmin +60 -exec rm -rf {} + 2>\u002Fdev\u002Fnull\nPLAN_DIR=\"$(mktemp -d \"${TMPDIR:-\u002Ftmp}\u002Fiac-review.XXXXXX\")\" || { echo \"ERROR: mktemp failed\" >&2; exit 1; }\ntrap 'rm -rf \"$PLAN_DIR\"; exit 130' INT TERM HUP\necho \"PLAN_DIR: $PLAN_DIR\"\nINIT_OK=1; NEW_LOCK=0\nif [ ! -d \"$TF_DIR\u002F.terraform\" ]; then\n  if [ -f \"$TF_DIR\u002F.terraform.lock.hcl\" ]; then\n    terraform -chdir=\"$TF_DIR\" init -input=false -lockfile=readonly >\"$PLAN_DIR\u002Fplan.log\" 2>&1 || INIT_OK=0\n  else\n    NEW_LOCK=1\n    terraform -chdir=\"$TF_DIR\" init -input=false >\"$PLAN_DIR\u002Fplan.log\" 2>&1 || INIT_OK=0\n  fi\nfi\nif [ \"$INIT_OK\" = 0 ]; then\n  echo \"PLAN_JSON: N-A (terraform init failed)\"; tail -n 20 \"$PLAN_DIR\u002Fplan.log\"\nelif terraform -chdir=\"$TF_DIR\" plan -lock=false -input=false -out=\"$PLAN_DIR\u002Fplan.bin\" >>\"$PLAN_DIR\u002Fplan.log\" 2>&1 \\\n  && terraform -chdir=\"$TF_DIR\" show -json \"$PLAN_DIR\u002Fplan.bin\" >\"$PLAN_DIR\u002Fplan.json\" 2>>\"$PLAN_DIR\u002Fplan.log\"; then\n  rm -f \"$PLAN_DIR\u002Fplan.bin\"; echo \"PLAN_JSON: $PLAN_DIR\u002Fplan.json\"\nelse\n  echo \"PLAN_JSON: N-A (terraform plan failed)\"; tail -n 20 \"$PLAN_DIR\u002Fplan.log\"\nfi\nif [ \"$NEW_LOCK\" = 1 ]; then rm -f \"$TF_DIR\u002F.terraform.lock.hcl\"; fi\n[ -s \"$PLAN_DIR\u002Fplan.json\" ] || { rm -rf \"$PLAN_DIR\"; echo \"PLAN_DIR: removed\"; }\n```\n\n`-lock=false` keeps a read-only review from holding the state lock someone else's apply needs. `-lockfile=readonly` stops init from rewriting an existing `.terraform.lock.hcl`; a root with none gets a plain init, and the lock file that init writes is removed afterwards so Step 3 still sees it missing. The plan JSON carries sensitive values in clear text: never print it, never paste from it, and delete it in Step 6 — or as soon as the review stops early, by running the Step 6 cleanup block. A failed plan removes its directory here, and a directory an aborted run left behind is swept on the next run after an hour.\n\nFor a CDK app, use the project's own CDK (no download); `--no-change-set` diffs against the deployed template without creating a CloudFormation change set:\n\n```bash\nCDK_DIR='\u003CCDK_DIR>'\n(cd \"$CDK_DIR\" && npx --no-install cdk diff --no-change-set 2>&1) | tail -n 400\n```\n\n---\n\n## Step 2: Rank what the plan changes\n\n```bash\nPLAN_JSON='\u003CPLAN_JSON>'\npython3 -I - \"$PLAN_JSON\" \u003C\u003C'PY'\nimport json, re, sys\n\npath = sys.argv[1]\ntry:\n    with open(path, encoding=\"utf-8\") as fh:\n        plan = json.load(fh)\nexcept (OSError, ValueError) as exc:\n    print(f\"PLAN: N-A (cannot read the plan JSON: {type(exc).__name__})\")\n    sys.exit(0)\nif not isinstance(plan, dict) or \"format_version\" not in plan or \"planned_values\" not in plan:\n    print(\"PLAN: N-A (not terraform show -json output for a plan; a state file or other JSON)\")\n    sys.exit(0)\nif plan.get(\"errored\"):\n    print(\"PLAN: N-A (the plan errored, so its change list is incomplete)\")\n    sys.exit(0)\n\nSTATEFUL = {\n    \"aws_db_instance\", \"aws_rds_cluster\", \"aws_rds_cluster_instance\", \"aws_docdb_cluster\", \"aws_neptune_cluster\",\n    \"aws_redshift_cluster\", \"aws_dynamodb_table\", \"aws_s3_bucket\", \"aws_efs_file_system\", \"aws_ebs_volume\",\n    \"aws_fsx_lustre_file_system\", \"aws_fsx_windows_file_system\", \"aws_fsx_ontap_file_system\",\n    \"aws_elasticache_cluster\", \"aws_elasticache_replication_group\", \"aws_elasticache_serverless_cache\",\n    \"aws_memorydb_cluster\", \"aws_opensearch_domain\", \"aws_elasticsearch_domain\", \"aws_msk_cluster\",\n    \"aws_kinesis_stream\", \"aws_sqs_queue\", \"aws_kms_key\", \"aws_secretsmanager_secret\", \"aws_cognito_user_pool\",\n    \"aws_route53_zone\", \"aws_eks_cluster\", \"aws_backup_vault\", \"aws_ecr_repository\",\n    \"google_sql_database_instance\", \"google_storage_bucket\", \"google_bigquery_dataset\", \"google_container_cluster\",\n    \"azurerm_storage_account\", \"azurerm_mssql_database\", \"azurerm_postgresql_flexible_server\",\n    \"azurerm_kubernetes_cluster\", \"azurerm_key_vault\",\n}\nSECRET_NAME = re.compile(r\"password|passwd|secret|token|private_?key|api_?key|credential\", re.I)\nRANK = {\"CRITICAL\": 0, \"HIGH\": 1, \"MEDIUM\": 2}\nfound = []\n\ndef add(sev, label, subject, detail=\"\"):\n    found.append((RANK[sev], sev, label, subject, detail))\n\ndef sensitive_paths(node, prefix=\"\"):\n    if node is True:\n        yield prefix or \"whole value\"\n    elif isinstance(node, dict):\n        for key, val in node.items():\n            yield from sensitive_paths(val, f\"{prefix}.{key}\" if prefix else str(key))\n    elif isinstance(node, list):\n        for i, val in enumerate(node):\n            yield from sensitive_paths(val, f\"{prefix}[{i}]\")\n\nchanged, groups = 0, {}\nfor rc in plan.get(\"resource_changes\") or []:\n    change = rc.get(\"change\") or {}\n    acts = change.get(\"actions\") or []\n    if rc.get(\"mode\") != \"managed\" or not acts or set(acts) \u003C= {\"no-op\", \"read\"}:\n        continue\n    changed += 1\n    addr, rtype = rc.get(\"address\", \"?\"), rc.get(\"type\", \"?\")\n    replace = \"delete\" in acts and \"create\" in acts\n    delete = acts == [\"delete\"]\n    forced = \", \".join(\".\".join(map(str, p)) for p in change.get(\"replace_paths\") or [])\n    detail = \"(\" + \"+\".join(acts) + (f\"; forced by {forced}\" if forced else \"\") + \")\"\n    if rtype in STATEFUL and (replace or delete):\n        add(\"CRITICAL\", \"stateful-replace\" if replace else \"stateful-destroy\", addr, detail)\n    elif delete:\n        add(\"HIGH\", \"destroy\", addr, detail)\n    elif replace:\n        add(\"HIGH\", \"replace\", addr, detail)\n    indexed = re.fullmatch(r\"(.+)\\[(\\d+)\\]\", addr)\n    if indexed and not rc.get(\"previous_address\"):\n        groups.setdefault(indexed.group(1), []).append((int(indexed.group(2)), replace or delete))\n    if not delete:\n        paths = list(sensitive_paths(change.get(\"after_sensitive\")))\n        if paths:\n            add(\"MEDIUM\", \"sensitive-in-state\", addr, \"(\" + \", \".join(paths[:3]) + \")\")\n\nfor base, members in sorted(groups.items()):\n    if len(members) >= 2 and any(destructive for _, destructive in members):\n        idx = \",\".join(str(i) for i, _ in sorted(members))\n        add(\"HIGH\", \"count-churn\", base, f\"(indexes {idx} change together: an element moved in a count list)\")\n\nfor name, oc in sorted((plan.get(\"output_changes\") or {}).items()):\n    if (oc.get(\"actions\") or []) != [\"delete\"] and SECRET_NAME.search(name) and oc.get(\"after_sensitive\") is not True:\n        add(\"HIGH\", \"output-unmarked\", name, \"(secret-named output without sensitive = true)\")\n\ncfg = plan.get(\"configuration\") or {}\npinned = {}\nfor key, pc in (cfg.get(\"provider_config\") or {}).items():\n    name = pc.get(\"name\", key)\n    pinned[name] = pinned.get(name, False) or bool(pc.get(\"version_constraint\"))\nfor name in sorted(n for n, ok in pinned.items() if not ok and n != \"terraform\"):\n    add(\"MEDIUM\", \"unpinned-provider\", name, \"(no version constraint in required_providers)\")\n\ndef walk(calls, prefix):\n    for name, mc in sorted((calls or {}).items()):\n        full, src = prefix + name, mc.get(\"source\", \"\")\n        if src.startswith((\".\u002F\", \"..\u002F\")):\n            pass\n        elif \"::\" in src or \":\u002F\u002F\" in src or src.startswith((\"github.com\u002F\", \"bitbucket.org\u002F\", \"git@\")):\n            if not re.search(r\"[?&]ref=\", src):\n                add(\"MEDIUM\", \"unpinned-module\", full, f\"{src} (no ?ref=)\")\n        elif not mc.get(\"version_constraint\"):\n            add(\"MEDIUM\", \"unpinned-module\", full, f\"{src} (no version)\")\n        walk((mc.get(\"module\") or {}).get(\"module_calls\"), full + \".\")\n\nwalk((cfg.get(\"root_module\") or {}).get(\"module_calls\"), \"\")\n\nprint(f\"PLAN: ok {changed} resource changes\")\nfor _, sev, label, subject, detail in sorted(found, key=lambda f: f[0]):\n    print(\" \".join(part for part in (sev, label, subject, detail) if part))\nprint(f\"RANKED: {len(found)}\")\nPY\n```\n\nThe block prints addresses and attribute paths, never values. What each label means and what to recommend:\n\n- **stateful-replace \u002F stateful-destroy** (CRITICAL) — a database, bucket, volume, queue, key or cluster is deleted or recreated, and its data with it. Name the attribute that forces it (`forced by`). Fix: revert that attribute, or plan the migration (snapshot, `create_before_destroy`, a new resource plus cut-over); add `lifecycle { prevent_destroy = true }` and the service's deletion protection.\n- **destroy \u002F replace** (HIGH) — read each one: a replaced security group, IAM role or launch template is often harmless, a replaced load balancer or NAT gateway changes an endpoint or IP. Downgrade to MEDIUM with the reason when the address is clearly disposable; never drop it.\n- **count-churn** (HIGH) — several indexes of one `count` resource change together: an element was inserted or removed mid-list and every later index shifts. Fix: `for_each` keyed by a stable value, with `moved` blocks so the existing objects keep their addresses.\n- **output-unmarked** (HIGH) — a secret-named output is printed in plain text by every `plan` and `apply`, and in CI logs. Fix: `sensitive = true`, or drop the output.\n- **sensitive-in-state** (MEDIUM) — the value is redacted in the CLI but stored in clear text in state. This is normal for Terraform; the finding is what it makes the backend checks worth. Raise it to HIGH when Step 3 finds the backend unencrypted or local.\n- **unpinned-provider \u002F unpinned-module** (MEDIUM) — the next `init -upgrade` or a fresh checkout pulls a different version. A git source pinned to a branch (`?ref=main`) is not a pin either; read the `ref`.\n\n**CDK diff.** Read the printed diff the same way: a `[-]` resource is a destroy; `requires replacement` or `may be replaced` is a replace; a stateful CloudFormation type (`AWS::RDS::*`, `AWS::DynamoDB::Table`, `AWS::S3::Bucket`, `AWS::EFS::FileSystem`, `AWS::ElastiCache::*`, `AWS::OpenSearchService::Domain`, `AWS::KMS::Key`, `AWS::SQS::Queue`, `AWS::EKS::Cluster`) destroyed or replaced is CRITICAL unless its `DeletionPolicy` is `Retain` or `Snapshot`. A `[-]` and a `[+]` of the same type with different logical ids is the CDK form of count churn — a construct was renamed or moved; fix with the original id (`overrideLogicalId`) or a planned migration.\n\n---\n\n## Step 3: State backend, lock file and pins\n\n```bash\nTF_DIR='\u003CTF_DIR>'\npython3 -I - \"$TF_DIR\" \u003C\u003C'PY'\nimport os, re, sys\n\nroot = sys.argv[1]\nif not os.path.isdir(root):\n    print(\"TF_FILES: N-A (not a directory)\")\n    sys.exit(0)\nskip = {\".terraform\", \".git\", \"node_modules\", \"cdk.out\", \".terragrunt-cache\"}\ntf_files = []\nfor here, dirs, files in os.walk(root):\n    dirs[:] = [d for d in dirs if d not in skip]\n    tf_files += [os.path.join(here, f) for f in files if f.endswith(\".tf\")]\nprint(f\"TF_FILES: {len(tf_files)}\")\nif not tf_files:\n    sys.exit(0)\n\ndef block_body(text, brace):\n    depth = 0\n    for i in range(brace, len(text)):\n        if text[i] == \"{\":\n            depth += 1\n        elif text[i] == \"}\":\n            depth -= 1\n            if depth == 0:\n                return text[brace + 1:i]\n    return text[brace + 1:]\n\nbackends, findings = [], []\nfor path in sorted(tf_files):\n    try:\n        with open(path, encoding=\"utf-8\", errors=\"replace\") as fh:\n            text = re.sub(r\"(?m)^\\s*(#|\u002F\u002F).*$\", \"\", fh.read())\n    except OSError:\n        continue\n    rel = os.path.relpath(path, root)\n    for m in re.finditer(r'\\bbackend\\s+\"([A-Za-z0-9_]+)\"\\s*\\{', text):\n        backends.append((m.group(1), rel, block_body(text, m.end() - 1)))\n    if re.search(r\"(?m)^\\s*cloud\\s*\\{\", text):\n        backends.append((\"cloud\", rel, \"\"))\n\nif not backends:\n    findings.append((\"MEDIUM\", \"backend-local\", \"(no backend block: state stays on one disk, unlocked and unshared)\"))\nfor kind, rel, body in backends:\n    print(f\"BACKEND: {kind} {rel}\")\n    if kind == \"s3\":\n        if not re.search(r\"(?m)^\\s*bucket\\s*=\", body):\n            print(f\"N-A backend-s3 {rel} (partial config: values come from -backend-config at init; read that file)\")\n            continue\n        if not re.search(r\"(?m)^\\s*encrypt\\s*=\\s*true\\b\", body):\n            findings.append((\"MEDIUM\", \"backend-unencrypted\", f\"{rel} (no encrypt = true; check the bucket's default encryption)\"))\n        if not re.search(r\"(?m)^\\s*(use_lockfile\\s*=\\s*true\\b|dynamodb_table\\s*=)\", body):\n            findings.append((\"HIGH\", \"backend-no-lock\", f\"{rel} (no use_lockfile or dynamodb_table: two applies can corrupt state)\"))\n    elif kind == \"local\":\n        findings.append((\"MEDIUM\", \"backend-local\", f\"{rel} (local backend)\"))\n    elif kind not in (\"gcs\", \"azurerm\", \"remote\", \"cloud\"):\n        print(f\"N-A backend-{kind} {rel} (encryption and locking not assessed for this backend)\")\n\nif not os.path.exists(os.path.join(root, \".terraform.lock.hcl\")):\n    findings.append((\"MEDIUM\", \"no-lock-file\", \"(no .terraform.lock.hcl in the root module: provider builds are not pinned)\"))\norder = {\"HIGH\": 0, \"MEDIUM\": 1}\nfor sev, label, detail in sorted(findings, key=lambda f: order[f[0]]):\n    print(sev, label, detail)\nprint(f\"REPO_FINDINGS: {len(findings)}\")\nPY\n```\n\n`gcs`, `azurerm` and HCP (`remote`, `cloud`) encrypt and lock natively. For an `N-A backend-s3` partial config, read the `-backend-config` file the repo uses (`*.tfbackend`, `backend.hcl`, a CI step) and judge it by the same two rules. With the context block's identity confirmed, the state bucket can be checked read-only: `aws s3api get-bucket-encryption`, `get-bucket-versioning` and `get-public-access-block` on the bucket the backend names — a failed call is N-A. Without a plan, read `required_providers` and each `module` block's `source`\u002F`version` by hand for the pin checks.\n\n---\n\n## Step 4: Static scanners, only when installed\n\n```bash\nfor _t in checkov tfsec trivy tflint kube-linter kubeconform; do\n  if command -v \"$_t\" >\u002Fdev\u002Fnull 2>&1; then echo \"SCANNER: $_t present\"; else echo \"SCANNER: $_t absent\"; fi\ndone\n```\n\nRun each present one read-only against the root module or the manifest directory: `checkov -d \u003Cdir> --compact --quiet`, `tfsec \u003Cdir>`, `trivy config --skip-check-update \u003Cdir>`, `tflint --chdir=\u003Cdir>`, `kube-linter lint \u003Cdir>`, `kubeconform -summary \u003Cdir>`. Never install one, and never run `tflint --init` or anything else that downloads. Name every absent scanner in the report; the review does not depend on them. A scanner hit is a candidate: confirm it in the source before it becomes a finding, and report a confirmed one under its own check.\n\n---\n\n## EKS\n\nRun this section when the plan or the repo holds `aws_eks_*` resources, the `terraform-aws-modules\u002Feks` module, an `AWS::EKS::Cluster`, Kubernetes manifests or Helm values; otherwise every row is `N-A (no EKS in scope)`. Static first: read the Terraform, the manifests, and Helm values (`helm template \u003Clocal chart> -f \u003Cvalues>` when `helm` is installed — never `install`, `upgrade` or `dependency update`).\n\n| Check | Where to read it | Finding |\n|-------|------------------|---------|\n| API endpoint exposure | `vpc_config.endpoint_public_access` and `public_access_cidrs` on `aws_eks_cluster` (module: `cluster_endpoint_public_access[_cidrs]` or `endpoint_public_access[_cidrs]`). Both default to public and `0.0.0.0\u002F0`, so an absent setting is public to the internet. | Public with `0.0.0.0\u002F0` → HIGH. Public with narrow CIDRs → LOW. Private only → PASS. |\n| Workload identity vs node-role sprawl | IRSA (an OIDC provider plus `eks.amazonaws.com\u002Frole-arn` on service accounts) or Pod Identity (`aws_eks_pod_identity_association`, the `eks-pod-identity-agent` add-on). Then the node group role's policies: beyond `AmazonEKSWorkerNodePolicy`, the ECR read\u002Fpull policy, `AmazonEKS_CNI_Policy` and `AmazonSSMManagedInstanceCore`, every pod on the node inherits them. Launch template `http_put_response_hop_limit` above 1 lets pods reach the node's credentials. | Admin, `*` or a service-wide FullAccess policy on the node role → HIGH. Extra app policies on the node role, or hop limit > 1 → MEDIUM. |\n| Pod Security admission | Namespace label `pod-security.kubernetes.io\u002Fenforce` on every application namespace (`kube-system` and add-on namespaces excepted). No label means `privileged`. | Missing or `privileged` on an app namespace → MEDIUM. `baseline` or `restricted` → PASS. |\n| Network policies | A `NetworkPolicy` (default-deny plus allows) in every namespace with workloads, and something enforcing it: the VPC CNI with `enableNetworkPolicy` set in the `vpc-cni` add-on's configuration values, Calico or Cilium. | Policies with no enforcing engine → HIGH (they read as protection and do nothing). A workload namespace without any policy → MEDIUM. |\n| Requests and limits | `resources.requests` (cpu and memory) on every container; Helm `resources: {}` counts as missing. | Missing requests → MEDIUM. Missing memory limit → LOW. |\n| Disruption budgets | A `PodDisruptionBudget` matching every Deployment or StatefulSet with 2+ replicas. | None for a multi-replica workload → LOW (MEDIUM for a StatefulSet or the ingress controller). `maxUnavailable: 0` or `minAvailable` equal to replicas → MEDIUM: it blocks node drains and upgrades. |\n| Version and add-on lag | Cluster `version`; each `aws_eks_addon` `addon_version` against `aws eks describe-addon-versions` for that Kubernetes version. | Cluster past standard support → HIGH (extended support also bills more: `\u002Faws-cost`). Add-on incompatible with the cluster version → HIGH. Behind the default version → LOW. No live data → N-A. |\n\n**Live, read-only, optional.** Only when the static pass leaves rows N-A and the tools are present. Print what a call would touch first — this reads local config only:\n\n```bash\ncommand -v kubectl >\u002Fdev\u002Fnull 2>&1 && kubectl config current-context 2>\u002Fdev\u002Fnull || echo \"KUBECTL: absent or no current context\"\n```\n\nThen ask once (AskUserQuestion), naming the kube context printed above, the cluster, and the AWS profile and region from the context block. On yes, only these calls; each failed call makes its rows `N-A (\u003Ccommand> failed)`:\n\n```bash\nCLUSTER='\u003CCLUSTER>'\nREGION='\u003CREGION>'\naws eks describe-cluster --name \"$CLUSTER\" --region \"$REGION\" --output json \\\n  --query 'cluster.{version:version,public:resourcesVpcConfig.endpointPublicAccess,cidrs:resourcesVpcConfig.publicAccessCidrs}' \\\n  || echo \"EKS_CLUSTER: N-A (describe-cluster failed)\"\naws eks list-addons --cluster-name \"$CLUSTER\" --region \"$REGION\" --output text || echo \"EKS_ADDONS: N-A (list-addons failed)\"\naws eks list-pod-identity-associations --cluster-name \"$CLUSTER\" --region \"$REGION\" --output text \\\n  || echo \"EKS_POD_IDENTITY: N-A (list-pod-identity-associations failed)\"\nkubectl get namespaces -o jsonpath='{range .items[*]}{.metadata.name}{\"\\t\"}{.metadata.labels.pod-security\\.kubernetes\\.io\u002Fenforce}{\"\\n\"}{end}' \\\n  || echo \"PSA: N-A (kubectl get namespaces failed)\"\nkubectl get networkpolicies -A --no-headers || echo \"NETPOL: N-A (kubectl get networkpolicies failed)\"\nkubectl get pdb -A --no-headers || echo \"PDB: N-A (kubectl get pdb failed)\"\n```\n\nFor an add-on's lag: `aws eks describe-addon --cluster-name \"$CLUSTER\" --addon-name \u003Cname> --region \"$REGION\"` for the installed version, and `aws eks describe-addon-versions --kubernetes-version \u003Ccluster version> --addon-name \u003Cname> --region \"$REGION\"` for the default one. Never `kubectl apply`, `edit`, `delete`, `exec` or `port-forward`, and no `aws eks update-*`, `create-*` or `delete-*`.\n\n---\n\n## Step 5: Severity and verdict\n\n- **CRITICAL** — data loss on apply: a stateful resource destroyed or replaced.\n- **HIGH** — an outage, an exposure or a corrupted state is likely: destroys and replacements, count churn, unlocked state, secrets printed in plain text, a world-open cluster endpoint, an over-privileged node role, unenforced network policies.\n- **MEDIUM** — a weakness that needs a second mistake to hurt: unpinned versions, unencrypted or local state, sensitive values in state, missing Pod Security, policies or requests.\n- **LOW** — hygiene.\n\nVerdict: **BLOCK** if any CRITICAL; **FIX FIRST** if any HIGH; **INCOMPLETE** if neither but any check is N-A — including no plan at all; **OK** only when every check ran and none is above LOW.\n\n---\n\n## Step 6: Report and clean up\n\n```\nIAC REVIEW\n==========\nScope:     \u003Croot module \u002F CDK app \u002F manifests>   Environment: \u003Cname>\nPlan:      \u003CPLAN: line, or N-A + reason>  (source: provided file | generated | none)\nAWS:       PROFILE \u002F REGION \u002F ACCOUNT \u002F PRINCIPAL from the context block, or N-A\nScanners:  \u003Cpresent: what ran and its hit count>  \u003Cabsent: names>\nVerdict:   BLOCK | FIX FIRST | INCOMPLETE | OK\n\nFindings (most severe first)\n| # | Severity | Check | Address \u002F file:line | Evidence | Fix |\n\nChecks\n| Check | Result |   one row per check in Steps 2-4 and the EKS table: FINDING (#n) \u002F PASS \u002F N-A (\u003Creason>)\n\nNext\n- Cost of this change: \u002Faws-cost --estimate \u003Cplan JSON>   (before Step 6 deletes it)\n- Secrets, CI\u002FCD and supply chain beyond this plan: \u002Fcso\n```\n\nOffer `\u002Faws-cost --estimate` while the plan JSON still exists. Then delete the generated plan — it holds secrets in clear text:\n\n```bash\nPLAN_DIR='\u003CPLAN_DIR>'\ncase \"$PLAN_DIR\" in *\u002Fiac-review.??????) rm -rf \"$PLAN_DIR\" && echo \"PLAN_DIR: removed\" ;; *) echo \"ERROR: not a plan dir: $PLAN_DIR\" >&2 ;; esac\n```\n\nSkip that block when the user supplied the plan; it is theirs. Finish with the completion status from the working protocols.\n","If this session was compacted, the copy of this skill in context may be cut off.\nRead `${CLAUDE_SKILL_DIR}\u002FSKILL.md` from disk and resume at the step you are on.\n\n## When to invoke\n\nUse when: \"review this terraform plan\", \"is this plan safe to apply\", \"what will this cdk diff destroy\", \"review our Terraform\", \"review the EKS cluster config\", \"check the state backend\". Read-only: it never applies, deploys, imports, moves state or edits a file.\n\n## Preamble\n\n```bash\neval \"$(\"${VIBESTACK_HOME:-$HOME\u002F.vibestack}\u002Fbin\u002Fvibe-slug\" 2>\u002Fdev\u002Fnull)\" 2>\u002Fdev\u002Fnull || SLUG=\"unknown\"\n_LEARN_FILE=\"${VIBESTACK_HOME:-$HOME\u002F.vibestack}\u002Fprojects\u002F${SLUG:-unknown}\u002Flearnings.jsonl\"\nif [ -f \"$_LEARN_FILE\" ]; then\n  _LEARN_COUNT=$(wc -l \u003C \"$_LEARN_FILE\" 2>\u002Fdev\u002Fnull | tr -d ' ')\n  echo \"LEARNINGS: $_LEARN_COUNT entries loaded\"\n  if [ \"$_LEARN_COUNT\" -gt 5 ] 2>\u002Fdev\u002Fnull; then\n    \"${VIBESTACK_HOME:-$HOME\u002F.vibestack}\u002Fbin\u002Fvibe-learnings-search\" --limit 5 2>\u002Fdev\u002Fnull || true\n  fi\nelse\n  echo \"LEARNINGS: none yet\"\nfi\n```\n\n{{include lib\u002Fsnippets\u002Fsession-host.md}}\n\n{{include lib\u002Fsnippets\u002Fdecision-brief.md}}\n\n{{include lib\u002Fsnippets\u002Fworking-protocols.md}}\n\n## User-invocable\nWhen the user types `\u002Fiac-review`, run this skill. `\u002Fiac-review \u003Cplan.json | planfile | dir>` reviews that input.\n\n**N-A is never a pass.** Every check below ends as FINDING, PASS or `N-A (\u003Creason>)`. A check that could not run — no plan, a failed call, a scanner not installed, a partial backend config — is N-A with the reason, and it keeps the verdict at INCOMPLETE. An empty result counts as PASS only when the call that produced it succeeded.\n\n---\n\n## Step 0: Find the inputs\n\n```bash\nfind . \\( -name .terraform -o -name node_modules -o -name .git -o -name cdk.out \\) -prune -o \\\n  \\( -name '*.tf' -o -name cdk.json -o -name Chart.yaml -o -name '*.tfplan' -o -name 'plan*.json' \\) -print 2>\u002Fdev\u002Fnull | head -n 60\ngrep -rlE '^kind:[[:space:]]*(Deployment|StatefulSet|DaemonSet|Namespace|NetworkPolicy|PodDisruptionBudget)' \\\n  --include='*.yaml' --include='*.yml' . 2>\u002Fdev\u002Fnull | grep -vE 'node_modules|\u002F\\.terraform\u002F' | head -n 40\ngrep -rlE 'aws_eks_|AWS::EKS::|eks\\.Cluster|terraform-aws-modules\u002Feks' --include='*.tf' --include='*.ts' \\\n  --include='*.py' --include='*.json' . 2>\u002Fdev\u002Fnull | grep -vE 'node_modules|\u002F\\.terraform\u002F|cdk\\.out' | head -n 20\n```\n\nA Terraform root module is a directory whose `.tf` files hold a `backend` or `cloud` block, or the provider configuration. When there are several roots (`envs\u002Fprod`, `envs\u002Fstaging`), ask which one (AskUserQuestion) unless the request named it. Write the scope in one line: root module or CDK app, environment, and whether EKS resources, manifests or Helm values are present — that decides whether the `## EKS` section runs.\n\n{{include lib\u002Fsnippets\u002Faws-context.md}}\n\nThe identity matters here because a plan and a `cdk diff` read live state with it: a plan run against the wrong account reports a diff for the wrong environment.\n\n---\n\n## Step 1: Get the plan\n\nPrefer a plan the user already has. A `terraform show -json` file is used as-is; a binary plan file is rendered with `terraform show -json \u003Cplanfile>`; a `cdk diff` printout is read as text. Only when there is none, generate one.\n\nGenerating a plan runs the providers with the user's credentials and executes any `external` data source in the configuration; `cdk diff` synthesizes the app, which runs its code. Ask once (AskUserQuestion) before the first of these, naming the root module, the profile and region from the context block, and that `terraform init` downloads the providers and modules the configuration names when `.terraform\u002F` is missing. On no, every plan check is `N-A (no plan: declined)` and Step 3 still runs.\n\n```bash\nTF_DIR='\u003CTF_DIR>'\n# A run that stopped before Step 6 left its plan (secrets in clear text) behind.\nfind \"${TMPDIR:-\u002Ftmp}\" -maxdepth 1 -type d -name 'iac-review.??????' -mmin +60 -exec rm -rf {} + 2>\u002Fdev\u002Fnull\nPLAN_DIR=\"$(mktemp -d \"${TMPDIR:-\u002Ftmp}\u002Fiac-review.XXXXXX\")\" || { echo \"ERROR: mktemp failed\" >&2; exit 1; }\ntrap 'rm -rf \"$PLAN_DIR\"; exit 130' INT TERM HUP\necho \"PLAN_DIR: $PLAN_DIR\"\nINIT_OK=1; NEW_LOCK=0\nif [ ! -d \"$TF_DIR\u002F.terraform\" ]; then\n  if [ -f \"$TF_DIR\u002F.terraform.lock.hcl\" ]; then\n    terraform -chdir=\"$TF_DIR\" init -input=false -lockfile=readonly >\"$PLAN_DIR\u002Fplan.log\" 2>&1 || INIT_OK=0\n  else\n    NEW_LOCK=1\n    terraform -chdir=\"$TF_DIR\" init -input=false >\"$PLAN_DIR\u002Fplan.log\" 2>&1 || INIT_OK=0\n  fi\nfi\nif [ \"$INIT_OK\" = 0 ]; then\n  echo \"PLAN_JSON: N-A (terraform init failed)\"; tail -n 20 \"$PLAN_DIR\u002Fplan.log\"\nelif terraform -chdir=\"$TF_DIR\" plan -lock=false -input=false -out=\"$PLAN_DIR\u002Fplan.bin\" >>\"$PLAN_DIR\u002Fplan.log\" 2>&1 \\\n  && terraform -chdir=\"$TF_DIR\" show -json \"$PLAN_DIR\u002Fplan.bin\" >\"$PLAN_DIR\u002Fplan.json\" 2>>\"$PLAN_DIR\u002Fplan.log\"; then\n  rm -f \"$PLAN_DIR\u002Fplan.bin\"; echo \"PLAN_JSON: $PLAN_DIR\u002Fplan.json\"\nelse\n  echo \"PLAN_JSON: N-A (terraform plan failed)\"; tail -n 20 \"$PLAN_DIR\u002Fplan.log\"\nfi\nif [ \"$NEW_LOCK\" = 1 ]; then rm -f \"$TF_DIR\u002F.terraform.lock.hcl\"; fi\n[ -s \"$PLAN_DIR\u002Fplan.json\" ] || { rm -rf \"$PLAN_DIR\"; echo \"PLAN_DIR: removed\"; }\n```\n\n`-lock=false` keeps a read-only review from holding the state lock someone else's apply needs. `-lockfile=readonly` stops init from rewriting an existing `.terraform.lock.hcl`; a root with none gets a plain init, and the lock file that init writes is removed afterwards so Step 3 still sees it missing. The plan JSON carries sensitive values in clear text: never print it, never paste from it, and delete it in Step 6 — or as soon as the review stops early, by running the Step 6 cleanup block. A failed plan removes its directory here, and a directory an aborted run left behind is swept on the next run after an hour.\n\nFor a CDK app, use the project's own CDK (no download); `--no-change-set` diffs against the deployed template without creating a CloudFormation change set:\n\n```bash\nCDK_DIR='\u003CCDK_DIR>'\n(cd \"$CDK_DIR\" && npx --no-install cdk diff --no-change-set 2>&1) | tail -n 400\n```\n\n---\n\n## Step 2: Rank what the plan changes\n\n```bash\nPLAN_JSON='\u003CPLAN_JSON>'\npython3 -I - \"$PLAN_JSON\" \u003C\u003C'PY'\nimport json, re, sys\n\npath = sys.argv[1]\ntry:\n    with open(path, encoding=\"utf-8\") as fh:\n        plan = json.load(fh)\nexcept (OSError, ValueError) as exc:\n    print(f\"PLAN: N-A (cannot read the plan JSON: {type(exc).__name__})\")\n    sys.exit(0)\nif not isinstance(plan, dict) or \"format_version\" not in plan or \"planned_values\" not in plan:\n    print(\"PLAN: N-A (not terraform show -json output for a plan; a state file or other JSON)\")\n    sys.exit(0)\nif plan.get(\"errored\"):\n    print(\"PLAN: N-A (the plan errored, so its change list is incomplete)\")\n    sys.exit(0)\n\nSTATEFUL = {\n    \"aws_db_instance\", \"aws_rds_cluster\", \"aws_rds_cluster_instance\", \"aws_docdb_cluster\", \"aws_neptune_cluster\",\n    \"aws_redshift_cluster\", \"aws_dynamodb_table\", \"aws_s3_bucket\", \"aws_efs_file_system\", \"aws_ebs_volume\",\n    \"aws_fsx_lustre_file_system\", \"aws_fsx_windows_file_system\", \"aws_fsx_ontap_file_system\",\n    \"aws_elasticache_cluster\", \"aws_elasticache_replication_group\", \"aws_elasticache_serverless_cache\",\n    \"aws_memorydb_cluster\", \"aws_opensearch_domain\", \"aws_elasticsearch_domain\", \"aws_msk_cluster\",\n    \"aws_kinesis_stream\", \"aws_sqs_queue\", \"aws_kms_key\", \"aws_secretsmanager_secret\", \"aws_cognito_user_pool\",\n    \"aws_route53_zone\", \"aws_eks_cluster\", \"aws_backup_vault\", \"aws_ecr_repository\",\n    \"google_sql_database_instance\", \"google_storage_bucket\", \"google_bigquery_dataset\", \"google_container_cluster\",\n    \"azurerm_storage_account\", \"azurerm_mssql_database\", \"azurerm_postgresql_flexible_server\",\n    \"azurerm_kubernetes_cluster\", \"azurerm_key_vault\",\n}\nSECRET_NAME = re.compile(r\"password|passwd|secret|token|private_?key|api_?key|credential\", re.I)\nRANK = {\"CRITICAL\": 0, \"HIGH\": 1, \"MEDIUM\": 2}\nfound = []\n\ndef add(sev, label, subject, detail=\"\"):\n    found.append((RANK[sev], sev, label, subject, detail))\n\ndef sensitive_paths(node, prefix=\"\"):\n    if node is True:\n        yield prefix or \"whole value\"\n    elif isinstance(node, dict):\n        for key, val in node.items():\n            yield from sensitive_paths(val, f\"{prefix}.{key}\" if prefix else str(key))\n    elif isinstance(node, list):\n        for i, val in enumerate(node):\n            yield from sensitive_paths(val, f\"{prefix}[{i}]\")\n\nchanged, groups = 0, {}\nfor rc in plan.get(\"resource_changes\") or []:\n    change = rc.get(\"change\") or {}\n    acts = change.get(\"actions\") or []\n    if rc.get(\"mode\") != \"managed\" or not acts or set(acts) \u003C= {\"no-op\", \"read\"}:\n        continue\n    changed += 1\n    addr, rtype = rc.get(\"address\", \"?\"), rc.get(\"type\", \"?\")\n    replace = \"delete\" in acts and \"create\" in acts\n    delete = acts == [\"delete\"]\n    forced = \", \".join(\".\".join(map(str, p)) for p in change.get(\"replace_paths\") or [])\n    detail = \"(\" + \"+\".join(acts) + (f\"; forced by {forced}\" if forced else \"\") + \")\"\n    if rtype in STATEFUL and (replace or delete):\n        add(\"CRITICAL\", \"stateful-replace\" if replace else \"stateful-destroy\", addr, detail)\n    elif delete:\n        add(\"HIGH\", \"destroy\", addr, detail)\n    elif replace:\n        add(\"HIGH\", \"replace\", addr, detail)\n    indexed = re.fullmatch(r\"(.+)\\[(\\d+)\\]\", addr)\n    if indexed and not rc.get(\"previous_address\"):\n        groups.setdefault(indexed.group(1), []).append((int(indexed.group(2)), replace or delete))\n    if not delete:\n        paths = list(sensitive_paths(change.get(\"after_sensitive\")))\n        if paths:\n            add(\"MEDIUM\", \"sensitive-in-state\", addr, \"(\" + \", \".join(paths[:3]) + \")\")\n\nfor base, members in sorted(groups.items()):\n    if len(members) >= 2 and any(destructive for _, destructive in members):\n        idx = \",\".join(str(i) for i, _ in sorted(members))\n        add(\"HIGH\", \"count-churn\", base, f\"(indexes {idx} change together: an element moved in a count list)\")\n\nfor name, oc in sorted((plan.get(\"output_changes\") or {}).items()):\n    if (oc.get(\"actions\") or []) != [\"delete\"] and SECRET_NAME.search(name) and oc.get(\"after_sensitive\") is not True:\n        add(\"HIGH\", \"output-unmarked\", name, \"(secret-named output without sensitive = true)\")\n\ncfg = plan.get(\"configuration\") or {}\npinned = {}\nfor key, pc in (cfg.get(\"provider_config\") or {}).items():\n    name = pc.get(\"name\", key)\n    pinned[name] = pinned.get(name, False) or bool(pc.get(\"version_constraint\"))\nfor name in sorted(n for n, ok in pinned.items() if not ok and n != \"terraform\"):\n    add(\"MEDIUM\", \"unpinned-provider\", name, \"(no version constraint in required_providers)\")\n\ndef walk(calls, prefix):\n    for name, mc in sorted((calls or {}).items()):\n        full, src = prefix + name, mc.get(\"source\", \"\")\n        if src.startswith((\".\u002F\", \"..\u002F\")):\n            pass\n        elif \"::\" in src or \":\u002F\u002F\" in src or src.startswith((\"github.com\u002F\", \"bitbucket.org\u002F\", \"git@\")):\n            if not re.search(r\"[?&]ref=\", src):\n                add(\"MEDIUM\", \"unpinned-module\", full, f\"{src} (no ?ref=)\")\n        elif not mc.get(\"version_constraint\"):\n            add(\"MEDIUM\", \"unpinned-module\", full, f\"{src} (no version)\")\n        walk((mc.get(\"module\") or {}).get(\"module_calls\"), full + \".\")\n\nwalk((cfg.get(\"root_module\") or {}).get(\"module_calls\"), \"\")\n\nprint(f\"PLAN: ok {changed} resource changes\")\nfor _, sev, label, subject, detail in sorted(found, key=lambda f: f[0]):\n    print(\" \".join(part for part in (sev, label, subject, detail) if part))\nprint(f\"RANKED: {len(found)}\")\nPY\n```\n\nThe block prints addresses and attribute paths, never values. What each label means and what to recommend:\n\n- **stateful-replace \u002F stateful-destroy** (CRITICAL) — a database, bucket, volume, queue, key or cluster is deleted or recreated, and its data with it. Name the attribute that forces it (`forced by`). Fix: revert that attribute, or plan the migration (snapshot, `create_before_destroy`, a new resource plus cut-over); add `lifecycle { prevent_destroy = true }` and the service's deletion protection.\n- **destroy \u002F replace** (HIGH) — read each one: a replaced security group, IAM role or launch template is often harmless, a replaced load balancer or NAT gateway changes an endpoint or IP. Downgrade to MEDIUM with the reason when the address is clearly disposable; never drop it.\n- **count-churn** (HIGH) — several indexes of one `count` resource change together: an element was inserted or removed mid-list and every later index shifts. Fix: `for_each` keyed by a stable value, with `moved` blocks so the existing objects keep their addresses.\n- **output-unmarked** (HIGH) — a secret-named output is printed in plain text by every `plan` and `apply`, and in CI logs. Fix: `sensitive = true`, or drop the output.\n- **sensitive-in-state** (MEDIUM) — the value is redacted in the CLI but stored in clear text in state. This is normal for Terraform; the finding is what it makes the backend checks worth. Raise it to HIGH when Step 3 finds the backend unencrypted or local.\n- **unpinned-provider \u002F unpinned-module** (MEDIUM) — the next `init -upgrade` or a fresh checkout pulls a different version. A git source pinned to a branch (`?ref=main`) is not a pin either; read the `ref`.\n\n**CDK diff.** Read the printed diff the same way: a `[-]` resource is a destroy; `requires replacement` or `may be replaced` is a replace; a stateful CloudFormation type (`AWS::RDS::*`, `AWS::DynamoDB::Table`, `AWS::S3::Bucket`, `AWS::EFS::FileSystem`, `AWS::ElastiCache::*`, `AWS::OpenSearchService::Domain`, `AWS::KMS::Key`, `AWS::SQS::Queue`, `AWS::EKS::Cluster`) destroyed or replaced is CRITICAL unless its `DeletionPolicy` is `Retain` or `Snapshot`. A `[-]` and a `[+]` of the same type with different logical ids is the CDK form of count churn — a construct was renamed or moved; fix with the original id (`overrideLogicalId`) or a planned migration.\n\n---\n\n## Step 3: State backend, lock file and pins\n\n```bash\nTF_DIR='\u003CTF_DIR>'\npython3 -I - \"$TF_DIR\" \u003C\u003C'PY'\nimport os, re, sys\n\nroot = sys.argv[1]\nif not os.path.isdir(root):\n    print(\"TF_FILES: N-A (not a directory)\")\n    sys.exit(0)\nskip = {\".terraform\", \".git\", \"node_modules\", \"cdk.out\", \".terragrunt-cache\"}\ntf_files = []\nfor here, dirs, files in os.walk(root):\n    dirs[:] = [d for d in dirs if d not in skip]\n    tf_files += [os.path.join(here, f) for f in files if f.endswith(\".tf\")]\nprint(f\"TF_FILES: {len(tf_files)}\")\nif not tf_files:\n    sys.exit(0)\n\ndef block_body(text, brace):\n    depth = 0\n    for i in range(brace, len(text)):\n        if text[i] == \"{\":\n            depth += 1\n        elif text[i] == \"}\":\n            depth -= 1\n            if depth == 0:\n                return text[brace + 1:i]\n    return text[brace + 1:]\n\nbackends, findings = [], []\nfor path in sorted(tf_files):\n    try:\n        with open(path, encoding=\"utf-8\", errors=\"replace\") as fh:\n            text = re.sub(r\"(?m)^\\s*(#|\u002F\u002F).*$\", \"\", fh.read())\n    except OSError:\n        continue\n    rel = os.path.relpath(path, root)\n    for m in re.finditer(r'\\bbackend\\s+\"([A-Za-z0-9_]+)\"\\s*\\{', text):\n        backends.append((m.group(1), rel, block_body(text, m.end() - 1)))\n    if re.search(r\"(?m)^\\s*cloud\\s*\\{\", text):\n        backends.append((\"cloud\", rel, \"\"))\n\nif not backends:\n    findings.append((\"MEDIUM\", \"backend-local\", \"(no backend block: state stays on one disk, unlocked and unshared)\"))\nfor kind, rel, body in backends:\n    print(f\"BACKEND: {kind} {rel}\")\n    if kind == \"s3\":\n        if not re.search(r\"(?m)^\\s*bucket\\s*=\", body):\n            print(f\"N-A backend-s3 {rel} (partial config: values come from -backend-config at init; read that file)\")\n            continue\n        if not re.search(r\"(?m)^\\s*encrypt\\s*=\\s*true\\b\", body):\n            findings.append((\"MEDIUM\", \"backend-unencrypted\", f\"{rel} (no encrypt = true; check the bucket's default encryption)\"))\n        if not re.search(r\"(?m)^\\s*(use_lockfile\\s*=\\s*true\\b|dynamodb_table\\s*=)\", body):\n            findings.append((\"HIGH\", \"backend-no-lock\", f\"{rel} (no use_lockfile or dynamodb_table: two applies can corrupt state)\"))\n    elif kind == \"local\":\n        findings.append((\"MEDIUM\", \"backend-local\", f\"{rel} (local backend)\"))\n    elif kind not in (\"gcs\", \"azurerm\", \"remote\", \"cloud\"):\n        print(f\"N-A backend-{kind} {rel} (encryption and locking not assessed for this backend)\")\n\nif not os.path.exists(os.path.join(root, \".terraform.lock.hcl\")):\n    findings.append((\"MEDIUM\", \"no-lock-file\", \"(no .terraform.lock.hcl in the root module: provider builds are not pinned)\"))\norder = {\"HIGH\": 0, \"MEDIUM\": 1}\nfor sev, label, detail in sorted(findings, key=lambda f: order[f[0]]):\n    print(sev, label, detail)\nprint(f\"REPO_FINDINGS: {len(findings)}\")\nPY\n```\n\n`gcs`, `azurerm` and HCP (`remote`, `cloud`) encrypt and lock natively. For an `N-A backend-s3` partial config, read the `-backend-config` file the repo uses (`*.tfbackend`, `backend.hcl`, a CI step) and judge it by the same two rules. With the context block's identity confirmed, the state bucket can be checked read-only: `aws s3api get-bucket-encryption`, `get-bucket-versioning` and `get-public-access-block` on the bucket the backend names — a failed call is N-A. Without a plan, read `required_providers` and each `module` block's `source`\u002F`version` by hand for the pin checks.\n\n---\n\n## Step 4: Static scanners, only when installed\n\n```bash\nfor _t in checkov tfsec trivy tflint kube-linter kubeconform; do\n  if command -v \"$_t\" >\u002Fdev\u002Fnull 2>&1; then echo \"SCANNER: $_t present\"; else echo \"SCANNER: $_t absent\"; fi\ndone\n```\n\nRun each present one read-only against the root module or the manifest directory: `checkov -d \u003Cdir> --compact --quiet`, `tfsec \u003Cdir>`, `trivy config --skip-check-update \u003Cdir>`, `tflint --chdir=\u003Cdir>`, `kube-linter lint \u003Cdir>`, `kubeconform -summary \u003Cdir>`. Never install one, and never run `tflint --init` or anything else that downloads. Name every absent scanner in the report; the review does not depend on them. A scanner hit is a candidate: confirm it in the source before it becomes a finding, and report a confirmed one under its own check.\n\n---\n\n## EKS\n\nRun this section when the plan or the repo holds `aws_eks_*` resources, the `terraform-aws-modules\u002Feks` module, an `AWS::EKS::Cluster`, Kubernetes manifests or Helm values; otherwise every row is `N-A (no EKS in scope)`. Static first: read the Terraform, the manifests, and Helm values (`helm template \u003Clocal chart> -f \u003Cvalues>` when `helm` is installed — never `install`, `upgrade` or `dependency update`).\n\n| Check | Where to read it | Finding |\n|-------|------------------|---------|\n| API endpoint exposure | `vpc_config.endpoint_public_access` and `public_access_cidrs` on `aws_eks_cluster` (module: `cluster_endpoint_public_access[_cidrs]` or `endpoint_public_access[_cidrs]`). Both default to public and `0.0.0.0\u002F0`, so an absent setting is public to the internet. | Public with `0.0.0.0\u002F0` → HIGH. Public with narrow CIDRs → LOW. Private only → PASS. |\n| Workload identity vs node-role sprawl | IRSA (an OIDC provider plus `eks.amazonaws.com\u002Frole-arn` on service accounts) or Pod Identity (`aws_eks_pod_identity_association`, the `eks-pod-identity-agent` add-on). Then the node group role's policies: beyond `AmazonEKSWorkerNodePolicy`, the ECR read\u002Fpull policy, `AmazonEKS_CNI_Policy` and `AmazonSSMManagedInstanceCore`, every pod on the node inherits them. Launch template `http_put_response_hop_limit` above 1 lets pods reach the node's credentials. | Admin, `*` or a service-wide FullAccess policy on the node role → HIGH. Extra app policies on the node role, or hop limit > 1 → MEDIUM. |\n| Pod Security admission | Namespace label `pod-security.kubernetes.io\u002Fenforce` on every application namespace (`kube-system` and add-on namespaces excepted). No label means `privileged`. | Missing or `privileged` on an app namespace → MEDIUM. `baseline` or `restricted` → PASS. |\n| Network policies | A `NetworkPolicy` (default-deny plus allows) in every namespace with workloads, and something enforcing it: the VPC CNI with `enableNetworkPolicy` set in the `vpc-cni` add-on's configuration values, Calico or Cilium. | Policies with no enforcing engine → HIGH (they read as protection and do nothing). A workload namespace without any policy → MEDIUM. |\n| Requests and limits | `resources.requests` (cpu and memory) on every container; Helm `resources: {}` counts as missing. | Missing requests → MEDIUM. Missing memory limit → LOW. |\n| Disruption budgets | A `PodDisruptionBudget` matching every Deployment or StatefulSet with 2+ replicas. | None for a multi-replica workload → LOW (MEDIUM for a StatefulSet or the ingress controller). `maxUnavailable: 0` or `minAvailable` equal to replicas → MEDIUM: it blocks node drains and upgrades. |\n| Version and add-on lag | Cluster `version`; each `aws_eks_addon` `addon_version` against `aws eks describe-addon-versions` for that Kubernetes version. | Cluster past standard support → HIGH (extended support also bills more: `\u002Faws-cost`). Add-on incompatible with the cluster version → HIGH. Behind the default version → LOW. No live data → N-A. |\n\n**Live, read-only, optional.** Only when the static pass leaves rows N-A and the tools are present. Print what a call would touch first — this reads local config only:\n\n```bash\ncommand -v kubectl >\u002Fdev\u002Fnull 2>&1 && kubectl config current-context 2>\u002Fdev\u002Fnull || echo \"KUBECTL: absent or no current context\"\n```\n\nThen ask once (AskUserQuestion), naming the kube context printed above, the cluster, and the AWS profile and region from the context block. On yes, only these calls; each failed call makes its rows `N-A (\u003Ccommand> failed)`:\n\n```bash\nCLUSTER='\u003CCLUSTER>'\nREGION='\u003CREGION>'\naws eks describe-cluster --name \"$CLUSTER\" --region \"$REGION\" --output json \\\n  --query 'cluster.{version:version,public:resourcesVpcConfig.endpointPublicAccess,cidrs:resourcesVpcConfig.publicAccessCidrs}' \\\n  || echo \"EKS_CLUSTER: N-A (describe-cluster failed)\"\naws eks list-addons --cluster-name \"$CLUSTER\" --region \"$REGION\" --output text || echo \"EKS_ADDONS: N-A (list-addons failed)\"\naws eks list-pod-identity-associations --cluster-name \"$CLUSTER\" --region \"$REGION\" --output text \\\n  || echo \"EKS_POD_IDENTITY: N-A (list-pod-identity-associations failed)\"\nkubectl get namespaces -o jsonpath='{range .items[*]}{.metadata.name}{\"\\t\"}{.metadata.labels.pod-security\\.kubernetes\\.io\u002Fenforce}{\"\\n\"}{end}' \\\n  || echo \"PSA: N-A (kubectl get namespaces failed)\"\nkubectl get networkpolicies -A --no-headers || echo \"NETPOL: N-A (kubectl get networkpolicies failed)\"\nkubectl get pdb -A --no-headers || echo \"PDB: N-A (kubectl get pdb failed)\"\n```\n\nFor an add-on's lag: `aws eks describe-addon --cluster-name \"$CLUSTER\" --addon-name \u003Cname> --region \"$REGION\"` for the installed version, and `aws eks describe-addon-versions --kubernetes-version \u003Ccluster version> --addon-name \u003Cname> --region \"$REGION\"` for the default one. Never `kubectl apply`, `edit`, `delete`, `exec` or `port-forward`, and no `aws eks update-*`, `create-*` or `delete-*`.\n\n---\n\n## Step 5: Severity and verdict\n\n- **CRITICAL** — data loss on apply: a stateful resource destroyed or replaced.\n- **HIGH** — an outage, an exposure or a corrupted state is likely: destroys and replacements, count churn, unlocked state, secrets printed in plain text, a world-open cluster endpoint, an over-privileged node role, unenforced network policies.\n- **MEDIUM** — a weakness that needs a second mistake to hurt: unpinned versions, unencrypted or local state, sensitive values in state, missing Pod Security, policies or requests.\n- **LOW** — hygiene.\n\nVerdict: **BLOCK** if any CRITICAL; **FIX FIRST** if any HIGH; **INCOMPLETE** if neither but any check is N-A — including no plan at all; **OK** only when every check ran and none is above LOW.\n\n---\n\n## Step 6: Report and clean up\n\n```\nIAC REVIEW\n==========\nScope:     \u003Croot module \u002F CDK app \u002F manifests>   Environment: \u003Cname>\nPlan:      \u003CPLAN: line, or N-A + reason>  (source: provided file | generated | none)\nAWS:       PROFILE \u002F REGION \u002F ACCOUNT \u002F PRINCIPAL from the context block, or N-A\nScanners:  \u003Cpresent: what ran and its hit count>  \u003Cabsent: names>\nVerdict:   BLOCK | FIX FIRST | INCOMPLETE | OK\n\nFindings (most severe first)\n| # | Severity | Check | Address \u002F file:line | Evidence | Fix |\n\nChecks\n| Check | Result |   one row per check in Steps 2-4 and the EKS table: FINDING (#n) \u002F PASS \u002F N-A (\u003Creason>)\n\nNext\n- Cost of this change: \u002Faws-cost --estimate \u003Cplan JSON>   (before Step 6 deletes it)\n- Secrets, CI\u002FCD and supply chain beyond this plan: \u002Fcso\n```\n\nOffer `\u002Faws-cost --estimate` while the plan JSON still exists. Then delete the generated plan — it holds secrets in clear text:\n\n```bash\nPLAN_DIR='\u003CPLAN_DIR>'\ncase \"$PLAN_DIR\" in *\u002Fiac-review.??????) rm -rf \"$PLAN_DIR\" && echo \"PLAN_DIR: removed\" ;; *) echo \"ERROR: not a plan dir: $PLAN_DIR\" >&2 ;; esac\n```\n\nSkip that block when the user supplied the plan; it is theirs. Finish with the completion status from the working protocols.\n",{"name":5,"triggers":52,"description":59,"allowed-tools":60},[53,29,54,55,56,57,58],"review the terraform plan","review the cdk diff","is this plan safe to apply","eks review","review the eks cluster","check the terraform state backend","Read-only review of a Terraform plan or cdk diff: destroys, replacements, state safety, pins, secrets, EKS.\n",[44,61,62,63,64],"Read","Grep","Glob","AskUserQuestion","main","777ec643a83c17a0955856b2eb7f596601709fda","skills\u002Fiac-review",0,"https:\u002F\u002Fgithub.com\u002Ftimurgaleev\u002Fvibestack","https:\u002F\u002Fgithub.com\u002Ftimurgaleev","https:\u002F\u002Ftgaleev.com\u002F","Forward Deployed Engineer \u002F\r\nAWS Community Builder \u002F Cloud Architect ","2026-11-11 15:42:25",{"source":8,"skillName":5,"commands":75,"prompt":85,"zipAvailable":86},[76,79,82],{"tool":77,"command":78},"npx","npx skills add timurgaleev\u002Fvibestack --skill iac-review",{"tool":80,"command":81},"bunx","bunx skills add timurgaleev\u002Fvibestack --skill iac-review",{"tool":83,"command":84},"pnpm","pnpm dlx skills add timurgaleev\u002Fvibestack --skill iac-review","安装技能，技能地址：https:\u002F\u002Fgithub.com\u002Ftimurgaleev\u002Fvibestack\u002Fblob\u002Fmain\u002Fskills\u002Fiac-review\u002FSKILL.md",true,[],[89,95,101,107,113,119],{"skillNo":90,"name":91,"nameZh":6,"description":6,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":92,"categories":93,"keywords":94,"publishedAt":47},"01M71GVHCCSQCCPVM8XPSPTA73","docs","https:\u002F\u002Fgithub.com\u002Ftimurgaleev\u002Fvibestack\u002Fblob\u002Fmain\u002Ftest\u002Ffixtures\u002Frender\u002F15-backtick-info-string\u002Fskills\u002Fdocs\u002FSKILL.md",[],[91],{"skillNo":96,"name":97,"nameZh":6,"description":6,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":98,"categories":99,"keywords":100,"publishedAt":47},"01M71GVFT1S5MRNM0AYRT2E6VQ","fences","https:\u002F\u002Fgithub.com\u002Ftimurgaleev\u002Fvibestack\u002Fblob\u002Fmain\u002Ftest\u002Ffixtures\u002Frender\u002F14-indented-tilde-fence\u002Fskills\u002Ffences\u002FSKILL.md",[],[97],{"skillNo":102,"name":103,"nameZh":6,"description":6,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":104,"categories":105,"keywords":106,"publishedAt":47},"01M71GVEYB54K1S83FGRNKN8CF","dirty","https:\u002F\u002Fgithub.com\u002Ftimurgaleev\u002Fvibestack\u002Fblob\u002Fmain\u002Ftest\u002Ffixtures\u002Frender\u002F10-check-with-drift\u002Fskills\u002Fdirty\u002FSKILL.md",[],[103],{"skillNo":108,"name":109,"nameZh":6,"description":6,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":110,"categories":111,"keywords":112,"publishedAt":47},"01M71GVDX9PMND1337C4388DK4","clean","https:\u002F\u002Fgithub.com\u002Ftimurgaleev\u002Fvibestack\u002Fblob\u002Fmain\u002Ftest\u002Ffixtures\u002Frender\u002F09-check-no-drift\u002Fskills\u002Fclean\u002FSKILL.md",[],[109],{"skillNo":114,"name":115,"nameZh":6,"description":6,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":116,"categories":117,"keywords":118,"publishedAt":47},"01M71GVC3VM66F1ZQT2G8JXDR0","bare","https:\u002F\u002Fgithub.com\u002Ftimurgaleev\u002Fvibestack\u002Fblob\u002Fmain\u002Ftest\u002Ffixtures\u002Frender\u002F08-no-frontmatter\u002Fskills\u002Fbare\u002FSKILL.md",[],[115],{"skillNo":120,"name":121,"nameZh":6,"description":6,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":122,"categories":123,"keywords":124,"publishedAt":47},"01M71GV9T8DAYZT0PX52QX0TE1","idem","https:\u002F\u002Fgithub.com\u002Ftimurgaleev\u002Fvibestack\u002Fblob\u002Fmain\u002Ftest\u002Ffixtures\u002Frender\u002F07-idempotent-rerender\u002Fskills\u002Fidem\u002FSKILL.md",[],[121]]