From 67b164ebf755f69803661c2c022f75e2914a412c Mon Sep 17 00:00:00 2001
From: "Brian A. Teller" <5016178+bateller@users.noreply.github.com>
Date: Mon, 28 Sep 2026 18:05:38 -0400
Subject: [PATCH] plan-skeptic v0.1.0
Reads Terraform/OpenTofu `show -json` output and reports what a change
introduces that a reviewer must not skim: ten rules (PS001-PS010), text/JSON/
SARIF 2.1.0 output, --fail-on thresholds, a composite GitHub Action, and
eight fixtures (one captured from real tofu 1.12.5 output).
---
.github/workflows/ci.yml | 44 ++
.gitignore | 6 +
LICENSE | 21 +
README.md | 113 +++-
action.yml | 40 ++
fixtures/README.md | 26 +
fixtures/clean/01-tags-only.json | 61 +++
fixtures/flawed/01-quickstart-postgres.json | 500 ++++++++++++++++++
fixtures/flawed/01-quickstart-postgres.tf | 33 ++
.../flawed/02-rename-replaces-database.json | 43 ++
fixtures/flawed/03-iam-wildcard-creep.json | 46 ++
.../flawed/04-open-ssh-for-debugging.json | 52 ++
.../05-public-bucket-for-static-site.json | 48 ++
fixtures/flawed/06-admin-to-unblock-ci.json | 38 ++
.../07-refactor-without-moved-block.json | 62 +++
pyproject.toml | 28 +
src/plan_skeptic/__init__.py | 3 +
src/plan_skeptic/__main__.py | 5 +
src/plan_skeptic/cli.py | 89 ++++
src/plan_skeptic/plan.py | 90 ++++
src/plan_skeptic/report.py | 101 ++++
src/plan_skeptic/rules.py | 418 +++++++++++++++
tests/test_plan_skeptic.py | 264 +++++++++
23 files changed, 2130 insertions(+), 1 deletion(-)
create mode 100644 .github/workflows/ci.yml
create mode 100644 .gitignore
create mode 100644 LICENSE
create mode 100644 action.yml
create mode 100644 fixtures/README.md
create mode 100644 fixtures/clean/01-tags-only.json
create mode 100644 fixtures/flawed/01-quickstart-postgres.json
create mode 100644 fixtures/flawed/01-quickstart-postgres.tf
create mode 100644 fixtures/flawed/02-rename-replaces-database.json
create mode 100644 fixtures/flawed/03-iam-wildcard-creep.json
create mode 100644 fixtures/flawed/04-open-ssh-for-debugging.json
create mode 100644 fixtures/flawed/05-public-bucket-for-static-site.json
create mode 100644 fixtures/flawed/06-admin-to-unblock-ci.json
create mode 100644 fixtures/flawed/07-refactor-without-moved-block.json
create mode 100644 pyproject.toml
create mode 100644 src/plan_skeptic/__init__.py
create mode 100644 src/plan_skeptic/__main__.py
create mode 100644 src/plan_skeptic/cli.py
create mode 100644 src/plan_skeptic/plan.py
create mode 100644 src/plan_skeptic/report.py
create mode 100644 src/plan_skeptic/rules.py
create mode 100644 tests/test_plan_skeptic.py
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..ec1988d
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,44 @@
+name: ci
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ python: ["3.9", "3.13"]
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python }}
+ - run: python -m unittest discover -s tests -v
+
+ action:
+ # Runs the composite action against the fixtures: a clean plan must pass
+ # and a flawed one must fail, or the action is not gating anything.
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Clean plan passes
+ uses: ./
+ with:
+ plan-json: fixtures/clean/01-tags-only.json
+ - name: Flawed plan fails
+ id: flawed
+ continue-on-error: true
+ uses: ./
+ with:
+ plan-json: fixtures/flawed/02-rename-replaces-database.json
+ sarif-file: flawed.sarif
+ - name: Assert the flawed plan failed and wrote SARIF
+ run: |
+ test "${{ steps.flawed.outcome }}" = failure
+ python3 -c "import json; r=json.load(open('flawed.sarif'))['runs'][0]['results']; assert r, 'no results'"
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3a36083
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+__pycache__/
+*.egg-info/
+build/
+dist/
+.venv/
+*.sarif
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..450f7ef
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Brian Teller
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index e642d5d..b1ca7e9 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,113 @@
# plan-skeptic
-Flag the parts of a Terraform/OpenTofu plan that need a human. Ten checks, SARIF output, GitHub Action.
+
+Flag the parts of a Terraform/OpenTofu plan that need a human.
+
+AI assistants write infrastructure changes that are fluent, plausible and
+occasionally destructive: a rename that replaces a database, a policy widened
+to `s3:*` to make an error go away, SSH opened "temporarily". The diff reads
+fine. The plan says what will actually happen. `plan-skeptic` reads the plan
+and points at the lines a reviewer must not skim.
+
+It is not a policy engine and does not try to replace one. It is ten
+opinionated checks about **what a change introduces**, tuned so that the output
+is short enough to be read on every pull request.
+
+```console
+$ terraform plan -out=plan.out && terraform show -json plan.out > plan.json
+$ plan-skeptic plan.json
+plan-skeptic: 2 finding(s) across 1 resource change(s)
+
+[HIGH ] PS001 stateful-resource-replaced
+ aws_db_instance.orders: aws_db_instance will be replaced because identifier changed: destroyed, then recreated empty.
+ why a human should look: Replacement is destroy-then-create unless create_before_destroy is set. ...
+
+[MEDIUM] PS010 recovery-guard-removed
+ aws_db_instance.orders: deletion_protection changes true -> false.
+```
+
+Works the same with `tofu show -json`. No dependencies; Python 3.9+.
+
+## Install
+
+```sh
+pipx install git+https://github.com/TellersTechOrg/plan-skeptic
+# or run from a checkout
+PYTHONPATH=src python3 -m plan_skeptic plan.json
+```
+
+## GitHub Action
+
+```yaml
+- run: |
+ terraform plan -out=plan.out
+ terraform show -json plan.out > plan.json
+- uses: TellersTechOrg/plan-skeptic@v0.1.0
+ with:
+ plan-json: plan.json
+ fail-on: high # high | medium | low | never
+- uses: github/codeql-action/upload-sarif@v3
+ if: always()
+ with:
+ sarif_file: plan-skeptic.sarif
+```
+
+Findings appear in the Security tab and inline on the pull request. Uploading
+SARIF needs `security-events: write`.
+
+## Rules
+
+| Id | Severity | Flags |
+|---|---|---|
+| PS001 | high | A data-holding resource (database, bucket, table, volume, key, PVC) is destroyed or replaced, and why it is being replaced |
+| PS002 | medium | Any other resource is deleted |
+| PS003 | high | An IAM policy newly grants `*` / `service:*`, or uses `NotAction` in an Allow |
+| PS004 | high | AdministratorAccess, PowerUserAccess or IAMFullAccess is attached |
+| PS005 | high | A trust policy lets any principal assume the role without a Condition |
+| PS006 | high | Ingress opened to 0.0.0.0/0 or ::/0 (medium for ports 80 and 443) |
+| PS007 | high | An S3 bucket made public by ACL, bucket policy or public access block |
+| PS008 | high | A database given `publicly_accessible = true` |
+| PS009 | medium | Encryption at rest set to false |
+| PS010 | medium | `deletion_protection` switched off, or `skip_final_snapshot` / `force_destroy` switched on |
+
+A delete paired with a create of the same type and name gets a hint to use a
+`moved` block, since that is what an unfinished refactor looks like.
+
+### What it deliberately does not do
+
+- **It reports changes, not state.** An update that leaves an existing
+ 0.0.0.0/0 rule alone is not flagged. Scanning everything on every plan is
+ how a tool's output stops being read.
+- **Unknown-after-apply values never trigger a rule.** Guessing at a value the
+ plan itself cannot see would invent findings.
+- **AWS first.** Replacement detection covers AWS, GCP, Azure and Kubernetes
+ storage; the exposure and IAM rules are AWS-only in v0.1.
+
+## Options
+
+```
+plan-skeptic PLAN [--format text|json|sarif] [--output FILE]
+ [--fail-on high|medium|low|never] [--disable RULE ...]
+ [--list-rules]
+```
+
+Exit codes: `0` nothing at or above `--fail-on`, `1` findings at or above it,
+`2` the input could not be reviewed. A binary plan file or a state file is
+refused with exit 2 rather than reported clean.
+
+## Fixtures
+
+[`fixtures/`](fixtures/) holds seven flawed plans, each paired with the request
+that produced it, plus a clean control. They are the exercises for the
+[Confidently Wrong workshop](https://www.tellerstech.com/workshops/ai-era-infrastructure-risk-workshop/)
+and come from the same material as the book
+[*Confidently Wrong*](https://www.tellerstech.com/book/).
+
+## Development
+
+```sh
+python3 -m unittest discover -s tests
+```
+
+## License
+
+MIT
diff --git a/action.yml b/action.yml
new file mode 100644
index 0000000..62cb22f
--- /dev/null
+++ b/action.yml
@@ -0,0 +1,40 @@
+name: plan-skeptic
+description: Flag the parts of a Terraform/OpenTofu plan that need a human, as SARIF for code scanning.
+branding:
+ icon: alert-triangle
+ color: orange
+
+inputs:
+ plan-json:
+ description: Path to `terraform show -json` / `tofu show -json` output of a saved plan.
+ required: true
+ fail-on:
+ description: Lowest severity that fails the step (high, medium, low, never).
+ default: high
+ sarif-file:
+ description: Where to write the SARIF report. Upload it with github/codeql-action/upload-sarif.
+ default: plan-skeptic.sarif
+ disable:
+ description: Space-separated rule ids to skip, e.g. "PS002 PS010".
+ default: ""
+
+outputs:
+ sarif-file:
+ description: Path of the SARIF report.
+ value: ${{ inputs.sarif-file }}
+
+runs:
+ using: composite
+ steps:
+ - name: Run plan-skeptic
+ shell: bash
+ env:
+ PS_PLAN: ${{ inputs.plan-json }}
+ PS_FAIL_ON: ${{ inputs.fail-on }}
+ PS_SARIF: ${{ inputs.sarif-file }}
+ PS_DISABLE: ${{ inputs.disable }}
+ PYTHONPATH: ${{ github.action_path }}/src
+ run: |
+ args=(--format sarif --output "$PS_SARIF" --fail-on "$PS_FAIL_ON")
+ for rule in $PS_DISABLE; do args+=(--disable "$rule"); done
+ python3 -m plan_skeptic "${args[@]}" "$PS_PLAN"
diff --git a/fixtures/README.md b/fixtures/README.md
new file mode 100644
index 0000000..7e580e3
--- /dev/null
+++ b/fixtures/README.md
@@ -0,0 +1,26 @@
+# Fixtures: plans an assistant would happily hand you
+
+Each file under `flawed/` is `show -json` output of a plan that answers the
+question it was asked and does something else as well. They double as the
+exercises for the *Confidently Wrong* workshop: show the prompt and the diff,
+ask the room what they would approve, then run `plan-skeptic` on the file.
+
+`01` is real OpenTofu 1.12 output, generated from `01-quickstart-postgres.tf`
+with `tofu plan -refresh=false -out=plan.out && tofu show -json plan.out`
+(dummy credentials; a create-only plan makes no AWS calls). The others need
+prior state to produce an update or replacement, so they are written by hand in
+the same shape, trimmed to the attributes the rules read.
+
+| File | What was asked | What the plan also does | Rules |
+|---|---|---|---|
+| `01-quickstart-postgres` | "Give me a Postgres database the app can reach" | Public endpoint, unencrypted, no final snapshot, port 5432 open to the internet, `s3:*` on the side | PS003 PS006 PS008 PS009 PS010 |
+| `02-rename-replaces-database` | "Rename the database to match our naming convention" | `identifier` forces replacement, so the database is destroyed and recreated empty; deletion protection switched off so the apply succeeds | PS001 PS010 |
+| `03-iam-wildcard-creep` | "The exporter gets AccessDenied on KMS, fix it" | `s3:*` and `kms:*` on `*`; a second policy gains `NotAction: iam:*`, which allows everything except IAM | PS003 |
+| `04-open-ssh-for-debugging` | "I can't SSH in to debug, and the app can't reach Redis" | Port 22 and every port open to 0.0.0.0/0 (the pre-existing 443 rule is correctly not reported) | PS006 |
+| `05-public-bucket-for-static-site` | "Serve these assets as a static site" | Public ACL, public bucket policy, and the public access block relaxed, where CloudFront with OAC needed none of it | PS007 |
+| `06-admin-to-unblock-ci` | "CI fails with AccessDenied on deploy" | AdministratorAccess on the CI role, and a trust statement that lets any AWS account assume it | PS004 PS005 |
+| `07-refactor-without-moved-block` | "Move the orders resources into a module" | Destroys the table and log group and creates new ones at the module address, because nobody wrote a `moved` block | PS001 PS002 |
+
+`clean/01-tags-only` is the control: a tags-only change on resources that
+already carry a wildcard policy and a public 443 rule. It must produce nothing,
+because the tool reports what a plan *introduces*.
diff --git a/fixtures/clean/01-tags-only.json b/fixtures/clean/01-tags-only.json
new file mode 100644
index 0000000..5c4d6ba
--- /dev/null
+++ b/fixtures/clean/01-tags-only.json
@@ -0,0 +1,61 @@
+{
+ "format_version": "1.2",
+ "terraform_version": "1.12.5",
+ "resource_changes": [
+ {
+ "address": "aws_db_instance.orders",
+ "mode": "managed",
+ "type": "aws_db_instance",
+ "name": "orders",
+ "provider_name": "registry.opentofu.org/hashicorp/aws",
+ "change": {
+ "actions": ["update"],
+ "before": {"identifier": "orders-db", "publicly_accessible": false, "storage_encrypted": true, "deletion_protection": true, "skip_final_snapshot": false, "tags": {"team": "payments"}},
+ "after": {"identifier": "orders-db", "publicly_accessible": false, "storage_encrypted": true, "deletion_protection": true, "skip_final_snapshot": false, "tags": {"team": "payments", "cost-center": "cc-114"}},
+ "after_unknown": {}
+ }
+ },
+ {
+ "address": "aws_security_group.web",
+ "mode": "managed",
+ "type": "aws_security_group",
+ "name": "web",
+ "provider_name": "registry.opentofu.org/hashicorp/aws",
+ "change": {
+ "actions": ["update"],
+ "before": {
+ "name": "web",
+ "ingress": [{"cidr_blocks": ["0.0.0.0/0"], "from_port": 443, "ipv6_cidr_blocks": [], "protocol": "tcp", "to_port": 443}],
+ "tags": {}
+ },
+ "after": {
+ "name": "web",
+ "ingress": [{"cidr_blocks": ["0.0.0.0/0"], "from_port": 443, "ipv6_cidr_blocks": [], "protocol": "tcp", "to_port": 443}],
+ "tags": {"cost-center": "cc-114"}
+ },
+ "after_unknown": {}
+ }
+ },
+ {
+ "address": "aws_iam_policy.exporter",
+ "mode": "managed",
+ "type": "aws_iam_policy",
+ "name": "exporter",
+ "provider_name": "registry.opentofu.org/hashicorp/aws",
+ "change": {
+ "actions": ["update"],
+ "before": {"name": "report-exporter", "policy": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":\"ecr:*\",\"Resource\":\"*\"}]}", "tags": {}},
+ "after": {"name": "report-exporter", "policy": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":\"ecr:*\",\"Resource\":\"*\"}]}", "tags": {"cost-center": "cc-114"}},
+ "after_unknown": {}
+ }
+ },
+ {
+ "address": "data.aws_caller_identity.current",
+ "mode": "data",
+ "type": "aws_caller_identity",
+ "name": "current",
+ "provider_name": "registry.opentofu.org/hashicorp/aws",
+ "change": {"actions": ["read"], "before": null, "after": {}, "after_unknown": {"id": true}}
+ }
+ ]
+}
diff --git a/fixtures/flawed/01-quickstart-postgres.json b/fixtures/flawed/01-quickstart-postgres.json
new file mode 100644
index 0000000..d06998b
--- /dev/null
+++ b/fixtures/flawed/01-quickstart-postgres.json
@@ -0,0 +1,500 @@
+{
+ "configuration": {
+ "provider_config": {
+ "aws": {
+ "expressions": {
+ "access_key": {
+ "constant_value": "x"
+ },
+ "region": {
+ "constant_value": "us-east-1"
+ },
+ "secret_key": {
+ "constant_value": "x"
+ },
+ "skip_credentials_validation": {
+ "constant_value": true
+ },
+ "skip_metadata_api_check": {
+ "constant_value": true
+ },
+ "skip_requesting_account_id": {
+ "constant_value": true
+ }
+ },
+ "full_name": "registry.opentofu.org/hashicorp/aws",
+ "name": "aws"
+ }
+ },
+ "root_module": {
+ "resources": [
+ {
+ "address": "aws_db_instance.main",
+ "expressions": {
+ "allocated_storage": {
+ "constant_value": 20
+ },
+ "engine": {
+ "constant_value": "postgres"
+ },
+ "identifier": {
+ "constant_value": "main"
+ },
+ "instance_class": {
+ "constant_value": "db.t3.micro"
+ },
+ "password": {
+ "constant_value": "notreal123"
+ },
+ "publicly_accessible": {
+ "constant_value": true
+ },
+ "skip_final_snapshot": {
+ "constant_value": true
+ },
+ "storage_encrypted": {
+ "constant_value": false
+ },
+ "username": {
+ "constant_value": "app"
+ }
+ },
+ "mode": "managed",
+ "name": "main",
+ "provider_config_key": "aws",
+ "schema_version": 2,
+ "type": "aws_db_instance"
+ },
+ {
+ "address": "aws_iam_role_policy.p",
+ "expressions": {
+ "name": {
+ "constant_value": "p"
+ },
+ "policy": {},
+ "role": {
+ "constant_value": "r"
+ }
+ },
+ "mode": "managed",
+ "name": "p",
+ "provider_config_key": "aws",
+ "schema_version": 0,
+ "type": "aws_iam_role_policy"
+ },
+ {
+ "address": "aws_security_group.db",
+ "expressions": {
+ "ingress": {
+ "constant_value": [
+ {
+ "cidr_blocks": [
+ "0.0.0.0/0"
+ ],
+ "description": null,
+ "from_port": 5432,
+ "ipv6_cidr_blocks": null,
+ "prefix_list_ids": null,
+ "protocol": "tcp",
+ "security_groups": null,
+ "self": null,
+ "to_port": 5432
+ }
+ ]
+ },
+ "name": {
+ "constant_value": "db"
+ }
+ },
+ "mode": "managed",
+ "name": "db",
+ "provider_config_key": "aws",
+ "schema_version": 1,
+ "type": "aws_security_group"
+ }
+ ]
+ }
+ },
+ "errored": false,
+ "format_version": "1.2",
+ "planned_values": {
+ "root_module": {
+ "resources": [
+ {
+ "address": "aws_db_instance.main",
+ "identity": {
+ "account_id": null,
+ "identifier": null,
+ "region": null
+ },
+ "identity_schema_version": 0,
+ "mode": "managed",
+ "name": "main",
+ "provider_name": "registry.opentofu.org/hashicorp/aws",
+ "schema_version": 2,
+ "sensitive_values": {
+ "blue_green_update": [],
+ "listener_endpoint": [],
+ "master_user_secret": [],
+ "password": true,
+ "password_wo": true,
+ "replicas": [],
+ "restore_to_point_in_time": [],
+ "s3_import": [],
+ "tags_all": {},
+ "vpc_security_group_ids": []
+ },
+ "type": "aws_db_instance",
+ "values": {
+ "allocated_storage": 20,
+ "allow_major_version_upgrade": null,
+ "apply_immediately": false,
+ "auto_minor_version_upgrade": true,
+ "blue_green_update": [],
+ "copy_tags_to_snapshot": false,
+ "custom_iam_instance_profile": null,
+ "customer_owned_ip_enabled": null,
+ "dedicated_log_volume": false,
+ "delete_automated_backups": true,
+ "deletion_protection": null,
+ "domain": null,
+ "domain_auth_secret_arn": null,
+ "domain_dns_ips": null,
+ "domain_iam_role_name": null,
+ "domain_ou": null,
+ "enabled_cloudwatch_logs_exports": null,
+ "engine": "postgres",
+ "final_snapshot_identifier": null,
+ "iam_database_authentication_enabled": null,
+ "identifier": "main",
+ "instance_class": "db.t3.micro",
+ "manage_master_user_password": null,
+ "max_allocated_storage": null,
+ "monitoring_interval": 0,
+ "password": "REDACTED",
+ "password_wo": null,
+ "password_wo_version": null,
+ "performance_insights_enabled": false,
+ "publicly_accessible": true,
+ "region": "us-east-1",
+ "replicate_source_db": null,
+ "restore_to_point_in_time": [],
+ "s3_import": [],
+ "skip_final_snapshot": true,
+ "storage_encrypted": false,
+ "tags": null,
+ "timeouts": null,
+ "upgrade_storage_config": null,
+ "username": "app",
+ "warning_event_categories": null
+ }
+ },
+ {
+ "address": "aws_iam_role_policy.p",
+ "identity": {
+ "account_id": null,
+ "name": null,
+ "role": null
+ },
+ "identity_schema_version": 0,
+ "mode": "managed",
+ "name": "p",
+ "provider_name": "registry.opentofu.org/hashicorp/aws",
+ "schema_version": 0,
+ "sensitive_values": {},
+ "type": "aws_iam_role_policy",
+ "values": {
+ "name": "p",
+ "policy": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Action\":\"s3:*\",\"Effect\":\"Allow\",\"Resource\":\"*\"}]}",
+ "role": "r"
+ }
+ },
+ {
+ "address": "aws_security_group.db",
+ "identity": {
+ "account_id": null,
+ "id": null,
+ "region": null
+ },
+ "identity_schema_version": 0,
+ "mode": "managed",
+ "name": "db",
+ "provider_name": "registry.opentofu.org/hashicorp/aws",
+ "schema_version": 1,
+ "sensitive_values": {
+ "egress": [],
+ "ingress": [
+ {
+ "cidr_blocks": [
+ false
+ ],
+ "ipv6_cidr_blocks": [],
+ "prefix_list_ids": [],
+ "security_groups": []
+ }
+ ],
+ "tags_all": {}
+ },
+ "type": "aws_security_group",
+ "values": {
+ "description": "Managed by Terraform",
+ "ingress": [
+ {
+ "cidr_blocks": [
+ "0.0.0.0/0"
+ ],
+ "description": "",
+ "from_port": 5432,
+ "ipv6_cidr_blocks": [],
+ "prefix_list_ids": [],
+ "protocol": "tcp",
+ "security_groups": [],
+ "self": false,
+ "to_port": 5432
+ }
+ ],
+ "name": "db",
+ "region": "us-east-1",
+ "revoke_rules_on_delete": false,
+ "tags": null,
+ "timeouts": null
+ }
+ }
+ ]
+ }
+ },
+ "resource_changes": [
+ {
+ "address": "aws_db_instance.main",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "after": {
+ "allocated_storage": 20,
+ "allow_major_version_upgrade": null,
+ "apply_immediately": false,
+ "auto_minor_version_upgrade": true,
+ "blue_green_update": [],
+ "copy_tags_to_snapshot": false,
+ "custom_iam_instance_profile": null,
+ "customer_owned_ip_enabled": null,
+ "dedicated_log_volume": false,
+ "delete_automated_backups": true,
+ "deletion_protection": null,
+ "domain": null,
+ "domain_auth_secret_arn": null,
+ "domain_dns_ips": null,
+ "domain_iam_role_name": null,
+ "domain_ou": null,
+ "enabled_cloudwatch_logs_exports": null,
+ "engine": "postgres",
+ "final_snapshot_identifier": null,
+ "iam_database_authentication_enabled": null,
+ "identifier": "main",
+ "instance_class": "db.t3.micro",
+ "manage_master_user_password": null,
+ "max_allocated_storage": null,
+ "monitoring_interval": 0,
+ "password": "REDACTED",
+ "password_wo": null,
+ "password_wo_version": null,
+ "performance_insights_enabled": false,
+ "publicly_accessible": true,
+ "region": "us-east-1",
+ "replicate_source_db": null,
+ "restore_to_point_in_time": [],
+ "s3_import": [],
+ "skip_final_snapshot": true,
+ "storage_encrypted": false,
+ "tags": null,
+ "timeouts": null,
+ "upgrade_storage_config": null,
+ "username": "app",
+ "warning_event_categories": null
+ },
+ "after_identity": {
+ "account_id": null,
+ "identifier": null,
+ "region": null
+ },
+ "after_sensitive": {
+ "blue_green_update": [],
+ "listener_endpoint": [],
+ "master_user_secret": [],
+ "password": true,
+ "password_wo": true,
+ "replicas": [],
+ "restore_to_point_in_time": [],
+ "s3_import": [],
+ "tags_all": {},
+ "vpc_security_group_ids": []
+ },
+ "after_unknown": {
+ "address": true,
+ "arn": true,
+ "availability_zone": true,
+ "backup_retention_period": true,
+ "backup_target": true,
+ "backup_window": true,
+ "blue_green_update": [],
+ "ca_cert_identifier": true,
+ "character_set_name": true,
+ "database_insights_mode": true,
+ "db_name": true,
+ "db_subnet_group_name": true,
+ "domain_fqdn": true,
+ "endpoint": true,
+ "engine_lifecycle_support": true,
+ "engine_version": true,
+ "engine_version_actual": true,
+ "hosted_zone_id": true,
+ "id": true,
+ "identifier_prefix": true,
+ "iops": true,
+ "kms_key_id": true,
+ "latest_restorable_time": true,
+ "license_model": true,
+ "listener_endpoint": true,
+ "maintenance_window": true,
+ "master_user_secret": true,
+ "master_user_secret_kms_key_id": true,
+ "monitoring_role_arn": true,
+ "multi_az": true,
+ "nchar_character_set_name": true,
+ "network_type": true,
+ "option_group_name": true,
+ "parameter_group_name": true,
+ "performance_insights_kms_key_id": true,
+ "performance_insights_retention_period": true,
+ "port": true,
+ "replica_mode": true,
+ "replicas": true,
+ "resource_id": true,
+ "restore_to_point_in_time": [],
+ "s3_import": [],
+ "snapshot_identifier": true,
+ "status": true,
+ "storage_throughput": true,
+ "storage_type": true,
+ "tags_all": true,
+ "timezone": true,
+ "upgrade_rollout_order": true,
+ "vpc_security_group_ids": true
+ },
+ "before": null,
+ "before_sensitive": false
+ },
+ "mode": "managed",
+ "name": "main",
+ "provider_name": "registry.opentofu.org/hashicorp/aws",
+ "type": "aws_db_instance"
+ },
+ {
+ "address": "aws_iam_role_policy.p",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "after": {
+ "name": "p",
+ "policy": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Action\":\"s3:*\",\"Effect\":\"Allow\",\"Resource\":\"*\"}]}",
+ "role": "r"
+ },
+ "after_identity": {
+ "account_id": null,
+ "name": null,
+ "role": null
+ },
+ "after_sensitive": {},
+ "after_unknown": {
+ "id": true,
+ "name_prefix": true
+ },
+ "before": null,
+ "before_sensitive": false
+ },
+ "mode": "managed",
+ "name": "p",
+ "provider_name": "registry.opentofu.org/hashicorp/aws",
+ "type": "aws_iam_role_policy"
+ },
+ {
+ "address": "aws_security_group.db",
+ "change": {
+ "actions": [
+ "create"
+ ],
+ "after": {
+ "description": "Managed by Terraform",
+ "ingress": [
+ {
+ "cidr_blocks": [
+ "0.0.0.0/0"
+ ],
+ "description": "",
+ "from_port": 5432,
+ "ipv6_cidr_blocks": [],
+ "prefix_list_ids": [],
+ "protocol": "tcp",
+ "security_groups": [],
+ "self": false,
+ "to_port": 5432
+ }
+ ],
+ "name": "db",
+ "region": "us-east-1",
+ "revoke_rules_on_delete": false,
+ "tags": null,
+ "timeouts": null
+ },
+ "after_identity": {
+ "account_id": null,
+ "id": null,
+ "region": null
+ },
+ "after_sensitive": {
+ "egress": [],
+ "ingress": [
+ {
+ "cidr_blocks": [
+ false
+ ],
+ "ipv6_cidr_blocks": [],
+ "prefix_list_ids": [],
+ "security_groups": []
+ }
+ ],
+ "tags_all": {}
+ },
+ "after_unknown": {
+ "arn": true,
+ "egress": true,
+ "id": true,
+ "ingress": [
+ {
+ "cidr_blocks": [
+ false
+ ],
+ "ipv6_cidr_blocks": [],
+ "prefix_list_ids": [],
+ "security_groups": []
+ }
+ ],
+ "name_prefix": true,
+ "owner_id": true,
+ "tags_all": true,
+ "vpc_id": true
+ },
+ "before": null,
+ "before_sensitive": false
+ },
+ "mode": "managed",
+ "name": "db",
+ "provider_name": "registry.opentofu.org/hashicorp/aws",
+ "type": "aws_security_group"
+ }
+ ],
+ "terraform_version": "1.12.5",
+ "timestamp": "2026-09-28T16:54:19Z"
+}
\ No newline at end of file
diff --git a/fixtures/flawed/01-quickstart-postgres.tf b/fixtures/flawed/01-quickstart-postgres.tf
new file mode 100644
index 0000000..ecef9a4
--- /dev/null
+++ b/fixtures/flawed/01-quickstart-postgres.tf
@@ -0,0 +1,33 @@
+provider "aws" {
+ region = "us-east-1"
+ access_key = "x"
+ secret_key = "x"
+ skip_credentials_validation = true
+ skip_requesting_account_id = true
+ skip_metadata_api_check = true
+}
+resource "aws_security_group" "db" {
+ name = "db"
+ ingress {
+ from_port = 5432
+ to_port = 5432
+ protocol = "tcp"
+ cidr_blocks = ["0.0.0.0/0"]
+ }
+}
+resource "aws_iam_role_policy" "p" {
+ name = "p"
+ role = "r"
+ policy = jsonencode({ Version = "2012-10-17", Statement = [{ Effect = "Allow", Action = "s3:*", Resource = "*" }] })
+}
+resource "aws_db_instance" "main" {
+ identifier = "main"
+ engine = "postgres"
+ instance_class = "db.t3.micro"
+ allocated_storage = 20
+ username = "app"
+ password = "notreal123"
+ publicly_accessible = true
+ storage_encrypted = false
+ skip_final_snapshot = true
+}
diff --git a/fixtures/flawed/02-rename-replaces-database.json b/fixtures/flawed/02-rename-replaces-database.json
new file mode 100644
index 0000000..aff7754
--- /dev/null
+++ b/fixtures/flawed/02-rename-replaces-database.json
@@ -0,0 +1,43 @@
+{
+ "format_version": "1.2",
+ "terraform_version": "1.12.5",
+ "resource_changes": [
+ {
+ "address": "aws_db_instance.orders",
+ "mode": "managed",
+ "type": "aws_db_instance",
+ "name": "orders",
+ "provider_name": "registry.opentofu.org/hashicorp/aws",
+ "change": {
+ "actions": ["delete", "create"],
+ "before": {
+ "identifier": "orders-db",
+ "engine": "postgres",
+ "engine_version": "16.4",
+ "instance_class": "db.r6g.large",
+ "allocated_storage": 200,
+ "publicly_accessible": false,
+ "storage_encrypted": true,
+ "deletion_protection": true,
+ "skip_final_snapshot": false,
+ "tags": {"team": "payments"}
+ },
+ "after": {
+ "identifier": "payments-orders-db",
+ "engine": "postgres",
+ "engine_version": "16.4",
+ "instance_class": "db.r6g.large",
+ "allocated_storage": 200,
+ "publicly_accessible": false,
+ "storage_encrypted": true,
+ "deletion_protection": false,
+ "skip_final_snapshot": false,
+ "tags": {"team": "payments"}
+ },
+ "after_unknown": {"arn": true, "endpoint": true, "id": true},
+ "replace_paths": [["identifier"]]
+ },
+ "action_reason": "replace_because_cannot_update"
+ }
+ ]
+}
diff --git a/fixtures/flawed/03-iam-wildcard-creep.json b/fixtures/flawed/03-iam-wildcard-creep.json
new file mode 100644
index 0000000..c7c615c
--- /dev/null
+++ b/fixtures/flawed/03-iam-wildcard-creep.json
@@ -0,0 +1,46 @@
+{
+ "format_version": "1.2",
+ "terraform_version": "1.12.5",
+ "resource_changes": [
+ {
+ "address": "aws_iam_policy.exporter",
+ "mode": "managed",
+ "type": "aws_iam_policy",
+ "name": "exporter",
+ "provider_name": "registry.opentofu.org/hashicorp/aws",
+ "change": {
+ "actions": ["update"],
+ "before": {
+ "name": "report-exporter",
+ "policy": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"s3:GetObject\",\"s3:PutObject\"],\"Resource\":\"arn:aws:s3:::acme-reports/*\"}]}"
+ },
+ "after": {
+ "name": "report-exporter",
+ "policy": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"s3:*\",\"kms:*\"],\"Resource\":\"*\"}]}"
+ },
+ "after_unknown": {}
+ }
+ },
+ {
+ "address": "aws_iam_role_policy.ci_deploy",
+ "mode": "managed",
+ "type": "aws_iam_role_policy",
+ "name": "ci_deploy",
+ "provider_name": "registry.opentofu.org/hashicorp/aws",
+ "change": {
+ "actions": ["update"],
+ "before": {
+ "name": "ci-deploy",
+ "role": "ci",
+ "policy": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":\"ecr:*\",\"Resource\":\"arn:aws:ecr:us-east-1:111122223333:repository/app\"}]}"
+ },
+ "after": {
+ "name": "ci-deploy",
+ "role": "ci",
+ "policy": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":\"ecr:*\",\"Resource\":\"arn:aws:ecr:us-east-1:111122223333:repository/app\"},{\"Effect\":\"Allow\",\"NotAction\":\"iam:*\",\"Resource\":\"*\"}]}"
+ },
+ "after_unknown": {}
+ }
+ }
+ ]
+}
diff --git a/fixtures/flawed/04-open-ssh-for-debugging.json b/fixtures/flawed/04-open-ssh-for-debugging.json
new file mode 100644
index 0000000..8118082
--- /dev/null
+++ b/fixtures/flawed/04-open-ssh-for-debugging.json
@@ -0,0 +1,52 @@
+{
+ "format_version": "1.2",
+ "terraform_version": "1.12.5",
+ "resource_changes": [
+ {
+ "address": "aws_security_group.web",
+ "mode": "managed",
+ "type": "aws_security_group",
+ "name": "web",
+ "provider_name": "registry.opentofu.org/hashicorp/aws",
+ "change": {
+ "actions": ["update"],
+ "before": {
+ "name": "web",
+ "ingress": [
+ {"cidr_blocks": ["0.0.0.0/0"], "description": "https", "from_port": 443, "ipv6_cidr_blocks": ["::/0"], "prefix_list_ids": [], "protocol": "tcp", "security_groups": [], "self": false, "to_port": 443}
+ ]
+ },
+ "after": {
+ "name": "web",
+ "ingress": [
+ {"cidr_blocks": ["0.0.0.0/0"], "description": "https", "from_port": 443, "ipv6_cidr_blocks": ["::/0"], "prefix_list_ids": [], "protocol": "tcp", "security_groups": [], "self": false, "to_port": 443},
+ {"cidr_blocks": ["0.0.0.0/0"], "description": "http redirect", "from_port": 80, "ipv6_cidr_blocks": [], "prefix_list_ids": [], "protocol": "tcp", "security_groups": [], "self": false, "to_port": 80},
+ {"cidr_blocks": ["0.0.0.0/0"], "description": "temp debug", "from_port": 22, "ipv6_cidr_blocks": [], "prefix_list_ids": [], "protocol": "tcp", "security_groups": [], "self": false, "to_port": 22}
+ ]
+ },
+ "after_unknown": {}
+ }
+ },
+ {
+ "address": "aws_vpc_security_group_ingress_rule.cache_all",
+ "mode": "managed",
+ "type": "aws_vpc_security_group_ingress_rule",
+ "name": "cache_all",
+ "provider_name": "registry.opentofu.org/hashicorp/aws",
+ "change": {
+ "actions": ["create"],
+ "before": null,
+ "after": {
+ "cidr_ipv4": "0.0.0.0/0",
+ "cidr_ipv6": null,
+ "description": "allow app to reach redis",
+ "from_port": null,
+ "ip_protocol": "-1",
+ "security_group_id": "sg-0abc1234",
+ "to_port": null
+ },
+ "after_unknown": {"arn": true, "id": true}
+ }
+ }
+ ]
+}
diff --git a/fixtures/flawed/05-public-bucket-for-static-site.json b/fixtures/flawed/05-public-bucket-for-static-site.json
new file mode 100644
index 0000000..de2ba4f
--- /dev/null
+++ b/fixtures/flawed/05-public-bucket-for-static-site.json
@@ -0,0 +1,48 @@
+{
+ "format_version": "1.2",
+ "terraform_version": "1.12.5",
+ "resource_changes": [
+ {
+ "address": "aws_s3_bucket_public_access_block.assets",
+ "mode": "managed",
+ "type": "aws_s3_bucket_public_access_block",
+ "name": "assets",
+ "provider_name": "registry.opentofu.org/hashicorp/aws",
+ "change": {
+ "actions": ["update"],
+ "before": {"bucket": "acme-assets", "block_public_acls": true, "block_public_policy": true, "ignore_public_acls": true, "restrict_public_buckets": true},
+ "after": {"bucket": "acme-assets", "block_public_acls": false, "block_public_policy": false, "ignore_public_acls": true, "restrict_public_buckets": false},
+ "after_unknown": {}
+ }
+ },
+ {
+ "address": "aws_s3_bucket_acl.assets",
+ "mode": "managed",
+ "type": "aws_s3_bucket_acl",
+ "name": "assets",
+ "provider_name": "registry.opentofu.org/hashicorp/aws",
+ "change": {
+ "actions": ["create"],
+ "before": null,
+ "after": {"bucket": "acme-assets", "acl": "public-read"},
+ "after_unknown": {"id": true}
+ }
+ },
+ {
+ "address": "aws_s3_bucket_policy.assets",
+ "mode": "managed",
+ "type": "aws_s3_bucket_policy",
+ "name": "assets",
+ "provider_name": "registry.opentofu.org/hashicorp/aws",
+ "change": {
+ "actions": ["create"],
+ "before": null,
+ "after": {
+ "bucket": "acme-assets",
+ "policy": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Sid\":\"PublicRead\",\"Effect\":\"Allow\",\"Principal\":\"*\",\"Action\":\"s3:GetObject\",\"Resource\":\"arn:aws:s3:::acme-assets/*\"}]}"
+ },
+ "after_unknown": {"id": true}
+ }
+ }
+ ]
+}
diff --git a/fixtures/flawed/06-admin-to-unblock-ci.json b/fixtures/flawed/06-admin-to-unblock-ci.json
new file mode 100644
index 0000000..59234f1
--- /dev/null
+++ b/fixtures/flawed/06-admin-to-unblock-ci.json
@@ -0,0 +1,38 @@
+{
+ "format_version": "1.2",
+ "terraform_version": "1.12.5",
+ "resource_changes": [
+ {
+ "address": "aws_iam_role_policy_attachment.ci_admin",
+ "mode": "managed",
+ "type": "aws_iam_role_policy_attachment",
+ "name": "ci_admin",
+ "provider_name": "registry.opentofu.org/hashicorp/aws",
+ "change": {
+ "actions": ["create"],
+ "before": null,
+ "after": {"role": "ci", "policy_arn": "arn:aws:iam::aws:policy/AdministratorAccess"},
+ "after_unknown": {"id": true}
+ }
+ },
+ {
+ "address": "aws_iam_role.ci",
+ "mode": "managed",
+ "type": "aws_iam_role",
+ "name": "ci",
+ "provider_name": "registry.opentofu.org/hashicorp/aws",
+ "change": {
+ "actions": ["update"],
+ "before": {
+ "name": "ci",
+ "assume_role_policy": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Federated\":\"arn:aws:iam::111122223333:oidc-provider/token.actions.githubusercontent.com\"},\"Action\":\"sts:AssumeRoleWithWebIdentity\",\"Condition\":{\"StringLike\":{\"token.actions.githubusercontent.com:sub\":\"repo:acme/app:*\"}}}]}"
+ },
+ "after": {
+ "name": "ci",
+ "assume_role_policy": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Federated\":\"arn:aws:iam::111122223333:oidc-provider/token.actions.githubusercontent.com\"},\"Action\":\"sts:AssumeRoleWithWebIdentity\",\"Condition\":{\"StringLike\":{\"token.actions.githubusercontent.com:sub\":\"repo:acme/app:*\"}}},{\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"*\"},\"Action\":\"sts:AssumeRole\"}]}"
+ },
+ "after_unknown": {}
+ }
+ }
+ ]
+}
diff --git a/fixtures/flawed/07-refactor-without-moved-block.json b/fixtures/flawed/07-refactor-without-moved-block.json
new file mode 100644
index 0000000..73ef4fa
--- /dev/null
+++ b/fixtures/flawed/07-refactor-without-moved-block.json
@@ -0,0 +1,62 @@
+{
+ "format_version": "1.2",
+ "terraform_version": "1.12.5",
+ "resource_changes": [
+ {
+ "address": "aws_dynamodb_table.orders",
+ "mode": "managed",
+ "type": "aws_dynamodb_table",
+ "name": "orders",
+ "provider_name": "registry.opentofu.org/hashicorp/aws",
+ "change": {
+ "actions": ["delete"],
+ "before": {"name": "orders", "billing_mode": "PAY_PER_REQUEST", "hash_key": "order_id", "deletion_protection_enabled": false},
+ "after": null,
+ "after_unknown": {}
+ },
+ "action_reason": "delete_because_no_resource_config"
+ },
+ {
+ "address": "module.orders.aws_dynamodb_table.this",
+ "mode": "managed",
+ "type": "aws_dynamodb_table",
+ "module_address": "module.orders",
+ "name": "this",
+ "provider_name": "registry.opentofu.org/hashicorp/aws",
+ "change": {
+ "actions": ["create"],
+ "before": null,
+ "after": {"name": "orders", "billing_mode": "PAY_PER_REQUEST", "hash_key": "order_id", "deletion_protection_enabled": false},
+ "after_unknown": {"arn": true, "id": true}
+ }
+ },
+ {
+ "address": "aws_cloudwatch_log_group.orders",
+ "mode": "managed",
+ "type": "aws_cloudwatch_log_group",
+ "name": "orders",
+ "provider_name": "registry.opentofu.org/hashicorp/aws",
+ "change": {
+ "actions": ["delete"],
+ "before": {"name": "/app/orders", "retention_in_days": 365},
+ "after": null,
+ "after_unknown": {}
+ },
+ "action_reason": "delete_because_no_resource_config"
+ },
+ {
+ "address": "module.orders.aws_cloudwatch_log_group.this",
+ "mode": "managed",
+ "type": "aws_cloudwatch_log_group",
+ "module_address": "module.orders",
+ "name": "this",
+ "provider_name": "registry.opentofu.org/hashicorp/aws",
+ "change": {
+ "actions": ["create"],
+ "before": null,
+ "after": {"name": "/app/orders", "retention_in_days": 365},
+ "after_unknown": {"arn": true, "id": true}
+ }
+ }
+ ]
+}
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..90dcc36
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,28 @@
+[build-system]
+requires = ["setuptools>=61"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "plan-skeptic"
+version = "0.1.0"
+description = "Flag the parts of a Terraform/OpenTofu plan that need a human."
+readme = "README.md"
+requires-python = ">=3.9"
+license = {text = "MIT"}
+authors = [{name = "Brian Teller"}]
+keywords = ["terraform", "opentofu", "plan", "security", "sarif", "ai"]
+classifiers = [
+ "Programming Language :: Python :: 3",
+ "Topic :: System :: Systems Administration",
+ "Topic :: Security",
+]
+dependencies = []
+
+[project.urls]
+Homepage = "https://github.com/TellersTechOrg/plan-skeptic"
+
+[project.scripts]
+plan-skeptic = "plan_skeptic.cli:main"
+
+[tool.setuptools.packages.find]
+where = ["src"]
diff --git a/src/plan_skeptic/__init__.py b/src/plan_skeptic/__init__.py
new file mode 100644
index 0000000..c49e831
--- /dev/null
+++ b/src/plan_skeptic/__init__.py
@@ -0,0 +1,3 @@
+"""plan-skeptic: flag the parts of a Terraform/OpenTofu plan that need a human."""
+
+__version__ = "0.1.0"
diff --git a/src/plan_skeptic/__main__.py b/src/plan_skeptic/__main__.py
new file mode 100644
index 0000000..dd8a8c9
--- /dev/null
+++ b/src/plan_skeptic/__main__.py
@@ -0,0 +1,5 @@
+import sys
+
+from .cli import main
+
+sys.exit(main())
diff --git a/src/plan_skeptic/cli.py b/src/plan_skeptic/cli.py
new file mode 100644
index 0000000..5d80c5d
--- /dev/null
+++ b/src/plan_skeptic/cli.py
@@ -0,0 +1,89 @@
+"""Command line entry point.
+
+Exit codes: 0 nothing at or above --fail-on, 1 findings at or above it,
+2 the input could not be reviewed. A failed read must never exit 0, because
+"no findings" and "never looked" would then be the same signal.
+"""
+
+from __future__ import annotations
+
+import argparse
+import sys
+
+from . import __version__
+from .plan import PlanError, load_plan
+from .report import render_json, render_sarif, render_text
+from .rules import RULES, SEVERITY_ORDER, review
+
+
+def build_parser() -> argparse.ArgumentParser:
+ p = argparse.ArgumentParser(
+ prog="plan-skeptic",
+ description="Flag the parts of a Terraform/OpenTofu plan that need a human.",
+ epilog="Produce input with: terraform show -json plan.out > plan.json (or tofu show -json).",
+ )
+ p.add_argument("plan", help="plan JSON file, or - for stdin")
+ p.add_argument("--format", choices=("text", "json", "sarif"), default="text")
+ p.add_argument("--output", "-o", help="write the report here instead of stdout")
+ p.add_argument(
+ "--fail-on", choices=("high", "medium", "low", "never"), default="high",
+ help="lowest severity that makes the exit code 1 (default: high)",
+ )
+ p.add_argument(
+ "--disable", action="append", default=[], metavar="RULE",
+ help="skip a rule by id (repeatable), e.g. --disable PS002",
+ )
+ p.add_argument("--list-rules", action="store_true", help="print the rules and exit")
+ p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
+ return p
+
+
+def main(argv=None) -> int:
+ if argv is None:
+ argv = sys.argv[1:]
+ if "--list-rules" in argv:
+ for r in RULES.values():
+ print(f"{r.id} {r.severity:6} {r.name}: {r.summary}")
+ return 0
+ args = build_parser().parse_args(argv)
+
+ unknown = [d for d in args.disable if d.upper() not in RULES]
+ if unknown:
+ print(f"plan-skeptic: unknown rule id(s): {', '.join(unknown)}", file=sys.stderr)
+ return 2
+
+ try:
+ if args.plan == "-":
+ text = sys.stdin.read()
+ else:
+ with open(args.plan, encoding="utf-8") as fh:
+ text = fh.read()
+ changes = load_plan(text)
+ except (OSError, UnicodeDecodeError, PlanError) as exc:
+ print(f"plan-skeptic: {exc}", file=sys.stderr)
+ return 2
+
+ findings = review(changes, args.disable)
+ if args.format == "sarif":
+ out = render_sarif(findings, "plan.json" if args.plan == "-" else args.plan)
+ elif args.format == "json":
+ out = render_json(findings, len(changes))
+ else:
+ out = render_text(findings, len(changes))
+
+ if args.output:
+ with open(args.output, "w", encoding="utf-8") as fh:
+ fh.write(out)
+ if args.format != "text":
+ sys.stdout.write(render_text(findings, len(changes)))
+ else:
+ sys.stdout.write(out)
+
+ if args.fail_on == "never":
+ return 0
+ floor = SEVERITY_ORDER[args.fail_on]
+ return 1 if any(SEVERITY_ORDER[f.severity] >= floor for f in findings) else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/src/plan_skeptic/plan.py b/src/plan_skeptic/plan.py
new file mode 100644
index 0000000..50268bb
--- /dev/null
+++ b/src/plan_skeptic/plan.py
@@ -0,0 +1,90 @@
+"""Load `terraform show -json` / `tofu show -json` output into resource changes."""
+
+from __future__ import annotations
+
+import json
+from dataclasses import dataclass, field
+from typing import Any
+
+
+class PlanError(ValueError):
+ """The input is not a machine-readable plan we can review."""
+
+
+@dataclass(frozen=True)
+class ResourceChange:
+ address: str
+ type: str
+ actions: tuple
+ before: dict = field(default_factory=dict)
+ after: dict = field(default_factory=dict)
+ replace_paths: tuple = ()
+
+ @property
+ def is_create(self) -> bool:
+ return self.actions == ("create",)
+
+ @property
+ def is_update(self) -> bool:
+ return self.actions == ("update",)
+
+ @property
+ def is_delete(self) -> bool:
+ return self.actions == ("delete",)
+
+ @property
+ def is_replace(self) -> bool:
+ return set(self.actions) == {"create", "delete"}
+
+ @property
+ def writes(self) -> bool:
+ """True when the resource will exist afterwards with `after` values."""
+ return self.is_create or self.is_update or self.is_replace
+
+
+def load_plan(text: str) -> list:
+ """Parse plan JSON text into managed-resource changes.
+
+ Refuses a binary plan file or a state file rather than reporting "no
+ findings", because an empty review of the wrong input reads as a clean plan.
+ """
+ try:
+ doc = json.loads(text)
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
+ raise PlanError(
+ "input is not JSON; pass the output of `terraform show -json plan.out`, "
+ "not the binary plan file"
+ ) from exc
+ if not isinstance(doc, dict):
+ raise PlanError("plan JSON must be an object")
+ if "resource_changes" not in doc:
+ if "values" in doc and "planned_values" not in doc:
+ raise PlanError("this looks like state (`show -json` without a plan file), not a plan")
+ raise PlanError("no `resource_changes` key; is this `show -json` output of a saved plan?")
+
+ changes = []
+ for rc in doc.get("resource_changes") or []:
+ if not isinstance(rc, dict) or rc.get("mode", "managed") != "managed":
+ continue
+ change = rc.get("change") or {}
+ actions = tuple(change.get("actions") or ())
+ if actions in ((), ("no-op",), ("read",)):
+ continue
+ changes.append(
+ ResourceChange(
+ address=str(rc.get("address", "")),
+ type=str(rc.get("type", "")),
+ actions=actions,
+ before=_as_dict(change.get("before")),
+ after=_as_dict(change.get("after")),
+ replace_paths=tuple(
+ tuple(p) if isinstance(p, list) else (p,)
+ for p in (change.get("replace_paths") or [])
+ ),
+ )
+ )
+ return changes
+
+
+def _as_dict(value: Any) -> dict:
+ return value if isinstance(value, dict) else {}
diff --git a/src/plan_skeptic/report.py b/src/plan_skeptic/report.py
new file mode 100644
index 0000000..0765e06
--- /dev/null
+++ b/src/plan_skeptic/report.py
@@ -0,0 +1,101 @@
+"""Render findings as text, JSON or SARIF 2.1.0."""
+
+from __future__ import annotations
+
+import json
+
+from . import __version__
+from .rules import HIGH, LOW, MEDIUM, RULES
+
+SARIF_LEVEL = {HIGH: "error", MEDIUM: "warning", LOW: "note"}
+REPO_URL = "https://github.com/TellersTechOrg/plan-skeptic"
+
+
+def render_text(findings: list, change_count: int) -> str:
+ if not findings:
+ return f"plan-skeptic: {change_count} resource change(s) reviewed, nothing flagged.\n"
+ lines = [f"plan-skeptic: {len(findings)} finding(s) across {change_count} resource change(s)", ""]
+ for f in findings:
+ rule = RULES[f.rule_id]
+ lines.append(f"[{f.severity.upper():6}] {f.rule_id} {rule.name}")
+ lines.append(f" {f.address}: {f.message}")
+ lines.append(f" why a human should look: {rule.why}")
+ lines.append("")
+ return "\n".join(lines)
+
+
+def render_json(findings: list, change_count: int) -> str:
+ return json.dumps(
+ {
+ "version": __version__,
+ "changes_reviewed": change_count,
+ "findings": [
+ {
+ "rule_id": f.rule_id,
+ "rule": RULES[f.rule_id].name,
+ "severity": f.severity,
+ "address": f.address,
+ "message": f.message,
+ }
+ for f in findings
+ ],
+ },
+ indent=2,
+ ) + "\n"
+
+
+def render_sarif(findings: list, plan_uri: str) -> str:
+ """SARIF for GitHub code scanning.
+
+ Code scanning drops a result that has no physical location, and a plan has
+ no source line for a resource, so every result points at the plan file and
+ carries the resource address as a logical location.
+ """
+ rules = [
+ {
+ "id": r.id,
+ "name": r.name,
+ "shortDescription": {"text": r.summary},
+ "fullDescription": {"text": r.why},
+ "helpUri": f"{REPO_URL}#{r.id.lower()}",
+ "defaultConfiguration": {"level": SARIF_LEVEL[r.severity]},
+ "properties": {"security-severity": {HIGH: "8.0", MEDIUM: "5.0", LOW: "2.0"}[r.severity]},
+ }
+ for r in RULES.values()
+ ]
+ results = [
+ {
+ "ruleId": f.rule_id,
+ "level": SARIF_LEVEL[f.severity],
+ "message": {"text": f"{f.address}: {f.message}"},
+ "locations": [
+ {
+ "physicalLocation": {
+ "artifactLocation": {"uri": plan_uri},
+ "region": {"startLine": 1},
+ },
+ "logicalLocations": [{"fullyQualifiedName": f.address, "kind": "resource"}],
+ }
+ ],
+ "partialFingerprints": {"resourceRule": f"{f.rule_id}:{f.address}:{f.message}"},
+ }
+ for f in findings
+ ]
+ doc = {
+ "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
+ "version": "2.1.0",
+ "runs": [
+ {
+ "tool": {
+ "driver": {
+ "name": "plan-skeptic",
+ "version": __version__,
+ "informationUri": REPO_URL,
+ "rules": rules,
+ }
+ },
+ "results": results,
+ }
+ ],
+ }
+ return json.dumps(doc, indent=2) + "\n"
diff --git a/src/plan_skeptic/rules.py b/src/plan_skeptic/rules.py
new file mode 100644
index 0000000..cbde31f
--- /dev/null
+++ b/src/plan_skeptic/rules.py
@@ -0,0 +1,418 @@
+"""Review rules.
+
+Every rule reports what a change *introduces*, not what the resource already
+was: an update that leaves an existing 0.0.0.0/0 rule alone is not this plan's
+decision, and a reviewer who is shown every pre-existing problem on every plan
+stops reading the output. Creates are judged on `after` alone; updates and
+replacements are judged on whether `before` already had the property.
+
+Unknown-after-apply values arrive as `null` in `after` and never trigger a rule.
+That is a deliberate false negative: guessing at a value Terraform itself cannot
+see yet would put invented findings in front of the reviewer.
+"""
+
+from __future__ import annotations
+
+import json
+from dataclasses import dataclass
+from typing import Callable, Iterable
+
+from .plan import ResourceChange
+
+HIGH = "high"
+MEDIUM = "medium"
+LOW = "low"
+SEVERITY_ORDER = {LOW: 1, MEDIUM: 2, HIGH: 3}
+
+
+@dataclass(frozen=True)
+class Rule:
+ id: str
+ name: str
+ severity: str
+ summary: str
+ why: str
+
+
+@dataclass(frozen=True)
+class Finding:
+ rule_id: str
+ severity: str
+ address: str
+ message: str
+
+
+RULES = {
+ r.id: r
+ for r in (
+ Rule(
+ "PS001", "stateful-resource-replaced", HIGH,
+ "A resource that holds data is being destroyed or replaced.",
+ "Replacement is destroy-then-create unless create_before_destroy is set. "
+ "An AI-suggested attribute tweak (engine version, subnet group, name) that "
+ "forces replacement reads like an edit in the diff and deletes the data.",
+ ),
+ Rule(
+ "PS002", "resource-deleted", MEDIUM,
+ "A resource is being deleted.",
+ "Deletions are the change a reviewer most often skims past. Confirm the "
+ "resource is really unused and not merely moved to another address "
+ "(use a `moved` block instead).",
+ ),
+ Rule(
+ "PS003", "iam-policy-wildcard", HIGH,
+ "An IAM policy grants a wildcard action or uses NotAction in an Allow.",
+ "`service:*` and `*` grant every current and future action. Generated "
+ "policies widen to wildcards to make an error go away; the error was "
+ "the least-privilege boundary working.",
+ ),
+ Rule(
+ "PS004", "broad-managed-policy-attached", HIGH,
+ "An administrator-level AWS managed policy is being attached.",
+ "AdministratorAccess, PowerUserAccess and IAMFullAccess each amount to "
+ "full account control. Attaching one is an access decision, not an "
+ "infrastructure change.",
+ ),
+ Rule(
+ "PS005", "trust-policy-any-principal", HIGH,
+ "A role's trust policy allows any principal to assume it.",
+ "A Principal of `*` with no Condition lets any AWS account assume the role.",
+ ),
+ Rule(
+ "PS006", "ingress-open-to-world", HIGH,
+ "A security group rule opens a port to 0.0.0.0/0 or ::/0.",
+ "Opening SSH, RDP or a database port to the internet is the classic "
+ "'make the connection work' fix. Ports 80 and 443 are reported at medium, "
+ "since public web ingress is often intended.",
+ ),
+ Rule(
+ "PS007", "s3-public-access", HIGH,
+ "An S3 bucket is being made publicly readable or writable.",
+ "Public ACLs, a relaxed public access block, or a bucket policy granting "
+ "`*` without a Condition expose every object in the bucket.",
+ ),
+ Rule(
+ "PS008", "database-publicly-accessible", HIGH,
+ "A database is being given a public endpoint.",
+ "publicly_accessible = true places the database on the internet, "
+ "protected only by its security groups and password.",
+ ),
+ Rule(
+ "PS009", "encryption-disabled", MEDIUM,
+ "Encryption at rest is set to false.",
+ "Turning encryption on later usually forces replacement, so a resource "
+ "created unencrypted tends to stay that way.",
+ ),
+ Rule(
+ "PS010", "recovery-guard-removed", MEDIUM,
+ "A guard against accidental data loss is being switched off.",
+ "deletion_protection, skip_final_snapshot and force_destroy exist to make "
+ "the next mistake recoverable. Turning one off is often the first step "
+ "of a plan that deletes something in a later apply.",
+ ),
+ )
+}
+
+STATEFUL_TYPES = frozenset({
+ "aws_db_instance", "aws_rds_cluster", "aws_rds_cluster_instance",
+ "aws_dynamodb_table", "aws_s3_bucket", "aws_efs_file_system", "aws_ebs_volume",
+ "aws_elasticache_cluster", "aws_elasticache_replication_group",
+ "aws_redshift_cluster", "aws_docdb_cluster", "aws_neptune_cluster",
+ "aws_opensearch_domain", "aws_elasticsearch_domain", "aws_msk_cluster",
+ "aws_kms_key", "aws_secretsmanager_secret",
+ "google_sql_database_instance", "google_storage_bucket", "google_compute_disk",
+ "google_bigquery_dataset", "google_bigquery_table", "google_spanner_instance",
+ "azurerm_storage_account", "azurerm_mssql_database", "azurerm_mssql_server",
+ "azurerm_postgresql_flexible_server", "azurerm_mysql_flexible_server",
+ "azurerm_cosmosdb_account", "azurerm_managed_disk", "azurerm_key_vault",
+ "kubernetes_persistent_volume_claim", "kubernetes_persistent_volume_claim_v1",
+ "kubernetes_persistent_volume", "kubernetes_persistent_volume_v1",
+})
+
+IAM_POLICY_TYPES = frozenset({
+ "aws_iam_policy", "aws_iam_role_policy", "aws_iam_user_policy", "aws_iam_group_policy",
+})
+POLICY_ATTACHMENT_TYPES = frozenset({
+ "aws_iam_role_policy_attachment", "aws_iam_user_policy_attachment",
+ "aws_iam_group_policy_attachment", "aws_iam_policy_attachment",
+})
+BROAD_MANAGED_POLICIES = ("AdministratorAccess", "PowerUserAccess", "IAMFullAccess")
+PUBLIC_DB_TYPES = frozenset({"aws_db_instance", "aws_rds_cluster_instance", "aws_redshift_cluster"})
+WORLD_CIDRS = frozenset({"0.0.0.0/0", "::/0"})
+PUBLIC_ACLS = frozenset({"public-read", "public-read-write", "authenticated-read"})
+WEB_PORTS = frozenset({80, 443})
+
+ENCRYPTION_FLAGS = {
+ "aws_db_instance": "storage_encrypted",
+ "aws_rds_cluster": "storage_encrypted",
+ "aws_docdb_cluster": "storage_encrypted",
+ "aws_neptune_cluster": "storage_encrypted",
+ "aws_ebs_volume": "encrypted",
+ "aws_efs_file_system": "encrypted",
+ "aws_redshift_cluster": "encrypted",
+ "aws_elasticache_replication_group": "at_rest_encryption_enabled",
+}
+# attribute -> the value that removes the guard
+RECOVERY_GUARDS = {
+ "deletion_protection": False,
+ "deletion_protection_enabled": False,
+ "skip_final_snapshot": True,
+ "force_destroy": True,
+}
+
+
+def _introduced(rc: ResourceChange, test: Callable[[dict], bool]) -> bool:
+ if not rc.writes or not test(rc.after):
+ return False
+ return rc.is_create or not test(rc.before)
+
+
+def _new_items(rc: ResourceChange, extract: Callable[[dict], set]) -> list:
+ if not rc.writes:
+ return []
+ after = extract(rc.after)
+ before = set() if rc.is_create else extract(rc.before)
+ return sorted(after - before, key=str)
+
+
+def _finding(rule_id: str, rc: ResourceChange, message: str, severity: str = "") -> Finding:
+ return Finding(rule_id, severity or RULES[rule_id].severity, rc.address, message)
+
+
+def _as_list(value) -> list:
+ if value is None:
+ return []
+ return value if isinstance(value, list) else [value]
+
+
+def _policy_statements(doc) -> list:
+ if isinstance(doc, str):
+ try:
+ doc = json.loads(doc)
+ except (json.JSONDecodeError, TypeError):
+ return []
+ if not isinstance(doc, dict):
+ return []
+ return [s for s in _as_list(doc.get("Statement")) if isinstance(s, dict)]
+
+
+def _allows(stmt: dict) -> bool:
+ return stmt.get("Effect", "Allow") == "Allow"
+
+
+def _principal_is_anyone(principal) -> bool:
+ if principal == "*":
+ return True
+ if isinstance(principal, dict):
+ return any("*" in _as_list(v) for v in principal.values())
+ return False
+
+
+def _wildcard_grants(values: dict) -> set:
+ grants = set()
+ for stmt in _policy_statements(values.get("policy")):
+ if not _allows(stmt):
+ continue
+ for action in _as_list(stmt.get("Action")):
+ if isinstance(action, str) and (action == "*" or action.endswith(":*")):
+ grants.add(f"Action {action}")
+ if stmt.get("NotAction") is not None:
+ grants.add("NotAction in an Allow statement")
+ return grants
+
+
+def _anyone_can_assume(values: dict) -> bool:
+ return any(
+ _allows(s) and _principal_is_anyone(s.get("Principal")) and not s.get("Condition")
+ for s in _policy_statements(values.get("assume_role_policy"))
+ )
+
+
+def _bucket_policy_public(values: dict) -> bool:
+ return any(
+ _allows(s) and _principal_is_anyone(s.get("Principal")) and not s.get("Condition")
+ for s in _policy_statements(values.get("policy"))
+ )
+
+
+def _port_label(from_port, to_port) -> str:
+ if from_port in (None, -1, 0) and to_port in (None, -1, 0, 65535):
+ return "all ports"
+ if from_port == to_port:
+ return f"port {from_port}"
+ return f"ports {from_port}-{to_port}"
+
+
+def _world_ingress(rc_type: str, values: dict) -> set:
+ """(from_port, to_port, cidr) tuples reachable from the whole internet."""
+ rules = []
+ if rc_type == "aws_security_group":
+ rules = [r for r in _as_list(values.get("ingress")) if isinstance(r, dict)]
+ elif rc_type == "aws_security_group_rule":
+ if values.get("type") == "ingress":
+ rules = [values]
+ elif rc_type == "aws_vpc_security_group_ingress_rule":
+ rules = [{
+ "from_port": values.get("from_port"),
+ "to_port": values.get("to_port"),
+ "cidr_blocks": [values.get("cidr_ipv4")],
+ "ipv6_cidr_blocks": [values.get("cidr_ipv6")],
+ }]
+ found = set()
+ for r in rules:
+ cidrs = _as_list(r.get("cidr_blocks")) + _as_list(r.get("ipv6_cidr_blocks"))
+ for cidr in cidrs:
+ if cidr in WORLD_CIDRS:
+ found.add((r.get("from_port"), r.get("to_port"), cidr))
+ return found
+
+
+def _is_web_only(from_port, to_port) -> bool:
+ return from_port == to_port and from_port in WEB_PORTS
+
+
+def check_stateful_replaced(rc: ResourceChange) -> Iterable[Finding]:
+ if rc.type not in STATEFUL_TYPES or not (rc.is_replace or rc.is_delete):
+ return
+ if rc.is_delete:
+ yield _finding("PS001", rc, f"{rc.type} will be destroyed.")
+ return
+ cause = ""
+ if rc.replace_paths:
+ cause = " because " + ", ".join(".".join(str(p) for p in path) for path in rc.replace_paths) + " changed"
+ order = "created before the old one is destroyed" if rc.actions[0] == "create" else "destroyed, then recreated empty"
+ yield _finding("PS001", rc, f"{rc.type} will be replaced{cause}: {order}.")
+
+
+def check_deleted(rc: ResourceChange) -> Iterable[Finding]:
+ if rc.is_delete and rc.type not in STATEFUL_TYPES:
+ yield _finding("PS002", rc, f"{rc.type} will be deleted.")
+
+
+def check_iam_wildcard(rc: ResourceChange) -> Iterable[Finding]:
+ if rc.type not in IAM_POLICY_TYPES:
+ return
+ for grant in _new_items(rc, _wildcard_grants):
+ yield _finding("PS003", rc, f"Policy now grants {grant}.")
+
+
+def check_broad_attachment(rc: ResourceChange) -> Iterable[Finding]:
+ def broad(values: dict) -> set:
+ arns = []
+ if rc.type in POLICY_ATTACHMENT_TYPES:
+ arns = [values.get("policy_arn")]
+ elif rc.type == "aws_iam_role":
+ arns = _as_list(values.get("managed_policy_arns"))
+ return {
+ name for arn in arns if isinstance(arn, str)
+ for name in BROAD_MANAGED_POLICIES if arn.endswith(":policy/" + name)
+ }
+
+ for name in _new_items(rc, broad):
+ yield _finding("PS004", rc, f"Attaches the AWS managed policy {name}.")
+
+
+def check_trust_anyone(rc: ResourceChange) -> Iterable[Finding]:
+ if rc.type == "aws_iam_role" and _introduced(rc, _anyone_can_assume):
+ yield _finding("PS005", rc, "Trust policy allows Principal * with no Condition.")
+
+
+def check_open_ingress(rc: ResourceChange) -> Iterable[Finding]:
+ for from_port, to_port, cidr in _new_items(rc, lambda v: _world_ingress(rc.type, v)):
+ severity = MEDIUM if _is_web_only(from_port, to_port) else HIGH
+ yield _finding("PS006", rc, f"Opens {_port_label(from_port, to_port)} to {cidr}.", severity)
+
+
+def check_s3_public(rc: ResourceChange) -> Iterable[Finding]:
+ if rc.type == "aws_s3_bucket_public_access_block":
+ flags = ("block_public_acls", "block_public_policy", "ignore_public_acls", "restrict_public_buckets")
+ off = _new_items(rc, lambda v: {f for f in flags if v.get(f) is False})
+ if off:
+ yield _finding("PS007", rc, "Public access block disables " + ", ".join(off) + ".")
+ elif rc.type in ("aws_s3_bucket_acl", "aws_s3_bucket"):
+ if _introduced(rc, lambda v: v.get("acl") in PUBLIC_ACLS):
+ yield _finding("PS007", rc, f"Bucket ACL set to {rc.after.get('acl')}.")
+ elif rc.type == "aws_s3_bucket_policy":
+ if _introduced(rc, _bucket_policy_public):
+ yield _finding("PS007", rc, "Bucket policy allows Principal * with no Condition.")
+
+
+def check_public_database(rc: ResourceChange) -> Iterable[Finding]:
+ if rc.type in PUBLIC_DB_TYPES and _introduced(rc, lambda v: v.get("publicly_accessible") is True):
+ yield _finding("PS008", rc, "publicly_accessible = true.")
+
+
+def check_encryption_disabled(rc: ResourceChange) -> Iterable[Finding]:
+ attr = ENCRYPTION_FLAGS.get(rc.type)
+ if attr and _introduced(rc, lambda v: v.get(attr) is False):
+ yield _finding("PS009", rc, f"{attr} = false.")
+
+
+def check_recovery_guards(rc: ResourceChange) -> Iterable[Finding]:
+ if not (rc.is_update or rc.is_replace or (rc.is_create and rc.type in STATEFUL_TYPES)):
+ return
+ for attr, unsafe in RECOVERY_GUARDS.items():
+ if attr not in rc.after:
+ continue
+ if rc.is_create:
+ # Defaults on a new resource are not a change a reviewer can weigh,
+ # except skip_final_snapshot/force_destroy, which are opt-in.
+ if attr in ("skip_final_snapshot", "force_destroy") and rc.after.get(attr) is unsafe:
+ yield _finding("PS010", rc, f"{attr} = {str(unsafe).lower()} on a new data store.")
+ elif rc.after.get(attr) is unsafe and rc.before.get(attr) is (not unsafe):
+ yield _finding("PS010", rc, f"{attr} changes {str(not unsafe).lower()} -> {str(unsafe).lower()}.")
+
+
+CHECKS = (
+ check_stateful_replaced,
+ check_deleted,
+ check_iam_wildcard,
+ check_broad_attachment,
+ check_trust_anyone,
+ check_open_ingress,
+ check_s3_public,
+ check_public_database,
+ check_encryption_disabled,
+ check_recovery_guards,
+)
+
+
+def _moved_candidates(changes: list) -> dict:
+ """Map a deleted address to the create that looks like the same object.
+
+ A delete plus a create of the same type carrying the same `name` is what a
+ refactor into a module looks like when nobody wrote a `moved` block, and
+ that plan destroys the object rather than re-addressing it.
+ """
+ creates = {}
+ for rc in changes:
+ name = rc.after.get("name") or rc.after.get("identifier") or rc.after.get("bucket")
+ if rc.is_create and isinstance(name, str):
+ creates.setdefault((rc.type, name), rc.address)
+ found = {}
+ for rc in changes:
+ name = rc.before.get("name") or rc.before.get("identifier") or rc.before.get("bucket")
+ if rc.is_delete and isinstance(name, str) and (rc.type, name) in creates:
+ found[rc.address] = creates[(rc.type, name)]
+ return found
+
+
+def review(changes: Iterable[ResourceChange], disabled: Iterable[str] = ()) -> list:
+ changes = list(changes)
+ skip = {d.upper() for d in disabled}
+ moved = _moved_candidates(changes)
+ findings = []
+ for rc in changes:
+ for check in CHECKS:
+ for f in check(rc):
+ if f.rule_id in skip:
+ continue
+ if f.rule_id in ("PS001", "PS002") and rc.address in moved:
+ f = Finding(
+ f.rule_id, f.severity, f.address,
+ f"{f.message} The same object is created at {moved[rc.address]}; "
+ "if this is a refactor, add a `moved` block instead.",
+ )
+ findings.append(f)
+ findings.sort(key=lambda f: (-SEVERITY_ORDER[f.severity], f.rule_id, f.address))
+ return findings
diff --git a/tests/test_plan_skeptic.py b/tests/test_plan_skeptic.py
new file mode 100644
index 0000000..4c8278a
--- /dev/null
+++ b/tests/test_plan_skeptic.py
@@ -0,0 +1,264 @@
+"""Tests use the stdlib runner so the repo has no dependencies at all:
+
+ python -m unittest discover -s tests
+"""
+
+from __future__ import annotations
+
+import contextlib
+import io
+import json
+import os
+import sys
+import tempfile
+import unittest
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+sys.path.insert(0, os.path.join(ROOT, "src"))
+
+from plan_skeptic.cli import main # noqa: E402
+from plan_skeptic.plan import PlanError, load_plan # noqa: E402
+from plan_skeptic.rules import RULES, review # noqa: E402
+
+FIXTURES = os.path.join(ROOT, "fixtures")
+
+
+def fixture(rel: str) -> str:
+ with open(os.path.join(FIXTURES, rel), encoding="utf-8") as fh:
+ return fh.read()
+
+
+def findings_for(rel: str) -> set:
+ return {(f.rule_id, f.severity, f.address) for f in review(load_plan(fixture(rel)))}
+
+
+def run_cli(*argv) -> tuple:
+ out, err = io.StringIO(), io.StringIO()
+ with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
+ code = main(list(argv))
+ return code, out.getvalue(), err.getvalue()
+
+
+def plan_with(*changes) -> str:
+ return json.dumps({"format_version": "1.2", "resource_changes": list(changes)})
+
+
+def change(address, rtype, actions, before=None, after=None, **extra) -> dict:
+ return {
+ "address": address, "mode": "managed", "type": rtype,
+ "change": dict({"actions": actions, "before": before, "after": after}, **extra),
+ }
+
+
+class FixtureExpectations(unittest.TestCase):
+ """Each flawed fixture is a workshop exercise; its expected findings are the answer key.
+
+ Asserting the exact set (not "at least") catches a rule that starts firing
+ on the wrong resource as well as one that stops firing.
+ """
+
+ def test_quickstart_postgres_real_opentofu_output(self):
+ self.assertEqual(findings_for("flawed/01-quickstart-postgres.json"), {
+ ("PS003", "high", "aws_iam_role_policy.p"),
+ ("PS006", "high", "aws_security_group.db"),
+ ("PS008", "high", "aws_db_instance.main"),
+ ("PS009", "medium", "aws_db_instance.main"),
+ ("PS010", "medium", "aws_db_instance.main"),
+ })
+
+ def test_rename_replaces_database(self):
+ self.assertEqual(findings_for("flawed/02-rename-replaces-database.json"), {
+ ("PS001", "high", "aws_db_instance.orders"),
+ ("PS010", "medium", "aws_db_instance.orders"),
+ })
+
+ def test_replace_message_names_the_cause(self):
+ f = [f for f in review(load_plan(fixture("flawed/02-rename-replaces-database.json"))) if f.rule_id == "PS001"][0]
+ self.assertIn("because identifier changed", f.message)
+ self.assertIn("destroyed, then recreated", f.message)
+
+ def test_iam_wildcard_creep(self):
+ found = review(load_plan(fixture("flawed/03-iam-wildcard-creep.json")))
+ self.assertEqual(
+ sorted((f.address, f.message) for f in found),
+ [
+ ("aws_iam_policy.exporter", "Policy now grants Action kms:*."),
+ ("aws_iam_policy.exporter", "Policy now grants Action s3:*."),
+ ("aws_iam_role_policy.ci_deploy", "Policy now grants NotAction in an Allow statement."),
+ ],
+ )
+
+ def test_existing_wildcard_is_not_reported_again(self):
+ # ci_deploy already had ecr:* before; only the NotAction statement is new.
+ found = review(load_plan(fixture("flawed/03-iam-wildcard-creep.json")))
+ self.assertFalse(any("ecr:*" in f.message for f in found))
+
+ def test_open_ssh_for_debugging(self):
+ self.assertEqual(findings_for("flawed/04-open-ssh-for-debugging.json"), {
+ ("PS006", "high", "aws_security_group.web"),
+ ("PS006", "medium", "aws_security_group.web"),
+ ("PS006", "high", "aws_vpc_security_group_ingress_rule.cache_all"),
+ })
+
+ def test_pre_existing_https_ingress_is_not_reported(self):
+ found = review(load_plan(fixture("flawed/04-open-ssh-for-debugging.json")))
+ self.assertFalse(any("443" in f.message for f in found))
+
+ def test_public_bucket(self):
+ self.assertEqual(findings_for("flawed/05-public-bucket-for-static-site.json"), {
+ ("PS007", "high", "aws_s3_bucket_acl.assets"),
+ ("PS007", "high", "aws_s3_bucket_policy.assets"),
+ ("PS007", "high", "aws_s3_bucket_public_access_block.assets"),
+ })
+
+ def test_admin_to_unblock_ci(self):
+ self.assertEqual(findings_for("flawed/06-admin-to-unblock-ci.json"), {
+ ("PS004", "high", "aws_iam_role_policy_attachment.ci_admin"),
+ ("PS005", "high", "aws_iam_role.ci"),
+ })
+
+ def test_refactor_without_moved_block(self):
+ found = review(load_plan(fixture("flawed/07-refactor-without-moved-block.json")))
+ self.assertEqual(
+ {(f.rule_id, f.address) for f in found},
+ {("PS001", "aws_dynamodb_table.orders"), ("PS002", "aws_cloudwatch_log_group.orders")},
+ )
+ for f in found:
+ self.assertIn("moved", f.message)
+
+ def test_clean_plan_has_no_findings(self):
+ self.assertEqual(findings_for("clean/01-tags-only.json"), set())
+
+ def test_every_rule_is_exercised_by_a_fixture(self):
+ fired = set()
+ for name in sorted(os.listdir(os.path.join(FIXTURES, "flawed"))):
+ if name.endswith(".json"):
+ fired |= {r for r, _, _ in findings_for("flawed/" + name)}
+ self.assertEqual(fired, set(RULES))
+
+
+class RuleEdges(unittest.TestCase):
+ def test_unknown_after_apply_never_triggers(self):
+ plan = plan_with(change("aws_db_instance.x", "aws_db_instance", ["create"],
+ after={"publicly_accessible": None, "storage_encrypted": None},
+ after_unknown={"publicly_accessible": True}))
+ self.assertEqual(review(load_plan(plan)), [])
+
+ def test_policy_that_is_not_json_is_ignored_not_crashed(self):
+ plan = plan_with(change("aws_iam_policy.x", "aws_iam_policy", ["create"], after={"policy": "not json"}))
+ self.assertEqual(review(load_plan(plan)), [])
+
+ def test_deny_wildcard_is_not_a_grant(self):
+ policy = json.dumps({"Statement": [{"Effect": "Deny", "Action": "*", "Resource": "*"}]})
+ plan = plan_with(change("aws_iam_policy.x", "aws_iam_policy", ["create"], after={"policy": policy}))
+ self.assertEqual(review(load_plan(plan)), [])
+
+ def test_trust_policy_with_condition_is_not_anyone(self):
+ policy = json.dumps({"Statement": [{"Effect": "Allow", "Principal": {"AWS": "*"}, "Action": "sts:AssumeRole",
+ "Condition": {"StringEquals": {"aws:PrincipalOrgID": "o-123"}}}]})
+ plan = plan_with(change("aws_iam_role.x", "aws_iam_role", ["create"], after={"assume_role_policy": policy}))
+ self.assertEqual(review(load_plan(plan)), [])
+
+ def test_deletion_protection_default_on_create_is_not_reported(self):
+ plan = plan_with(change("aws_db_instance.x", "aws_db_instance", ["create"],
+ after={"deletion_protection": False, "skip_final_snapshot": False}))
+ self.assertEqual(review(load_plan(plan)), [])
+
+ def test_replace_with_create_before_destroy_says_so(self):
+ plan = plan_with(change("aws_s3_bucket.x", "aws_s3_bucket", ["create", "delete"], before={}, after={}))
+ (f,) = review(load_plan(plan))
+ self.assertIn("created before the old one is destroyed", f.message)
+
+ def test_disable_skips_a_rule(self):
+ plan = fixture("flawed/07-refactor-without-moved-block.json")
+ self.assertEqual({f.rule_id for f in review(load_plan(plan), ["ps002"])}, {"PS001"})
+
+ def test_findings_sort_high_first(self):
+ found = review(load_plan(fixture("flawed/01-quickstart-postgres.json")))
+ self.assertEqual([f.severity for f in found][:3], ["high"] * 3)
+ self.assertEqual(found[-1].severity, "medium")
+
+
+class PlanLoading(unittest.TestCase):
+ def test_binary_plan_is_refused(self):
+ with self.assertRaises(PlanError) as ctx:
+ load_plan("PK\x03\x04 not json")
+ self.assertIn("show -json", str(ctx.exception))
+
+ def test_state_is_refused_rather_than_reported_clean(self):
+ with self.assertRaises(PlanError) as ctx:
+ load_plan(json.dumps({"format_version": "1.0", "values": {"root_module": {}}}))
+ self.assertIn("state", str(ctx.exception))
+
+ def test_no_op_read_and_data_sources_are_skipped(self):
+ plan = plan_with(
+ change("aws_s3_bucket.a", "aws_s3_bucket", ["no-op"], before={}, after={}),
+ dict(change("data.x.y", "x", ["read"]), mode="data"),
+ )
+ self.assertEqual(load_plan(plan), [])
+
+ def test_empty_plan_is_valid(self):
+ self.assertEqual(load_plan(json.dumps({"resource_changes": []})), [])
+
+
+class Cli(unittest.TestCase):
+ def path(self, rel: str) -> str:
+ return os.path.join(FIXTURES, rel)
+
+ def test_exit_1_on_high_by_default(self):
+ code, out, _ = run_cli(self.path("flawed/02-rename-replaces-database.json"))
+ self.assertEqual(code, 1)
+ self.assertIn("PS001", out)
+
+ def test_exit_0_on_clean(self):
+ code, out, _ = run_cli(self.path("clean/01-tags-only.json"))
+ self.assertEqual(code, 0)
+ self.assertIn("nothing flagged", out)
+
+ def test_fail_on_threshold(self):
+ # 07 has one high and one medium; disabling the high leaves a medium.
+ p = self.path("flawed/07-refactor-without-moved-block.json")
+ self.assertEqual(run_cli(p, "--disable", "PS001")[0], 0)
+ self.assertEqual(run_cli(p, "--disable", "PS001", "--fail-on", "medium")[0], 1)
+ self.assertEqual(run_cli(p, "--fail-on", "never")[0], 0)
+
+ def test_unreadable_input_exits_2_not_0(self):
+ code, _, err = run_cli(self.path("does-not-exist.json"))
+ self.assertEqual(code, 2)
+ self.assertIn("plan-skeptic:", err)
+
+ def test_unknown_rule_id_exits_2(self):
+ self.assertEqual(run_cli(self.path("clean/01-tags-only.json"), "--disable", "PS999")[0], 2)
+
+ def test_sarif_is_valid_shape(self):
+ code, out, _ = run_cli(self.path("flawed/05-public-bucket-for-static-site.json"), "--format", "sarif")
+ self.assertEqual(code, 1)
+ doc = json.loads(out)
+ self.assertEqual(doc["version"], "2.1.0")
+ run = doc["runs"][0]
+ self.assertEqual({r["id"] for r in run["tool"]["driver"]["rules"]}, set(RULES))
+ self.assertEqual(len(run["results"]), 3)
+ for result in run["results"]:
+ self.assertEqual(result["level"], "error")
+ loc = result["locations"][0]
+ self.assertIn("physicalLocation", loc, "code scanning drops results without one")
+ self.assertTrue(loc["logicalLocations"][0]["fullyQualifiedName"].startswith("aws_s3_"))
+
+ def test_output_file_plus_text_summary(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ target = os.path.join(tmp, "out.sarif")
+ code, out, _ = run_cli(self.path("flawed/06-admin-to-unblock-ci.json"),
+ "--format", "sarif", "--output", target)
+ self.assertEqual(code, 1)
+ with open(target, encoding="utf-8") as fh:
+ self.assertEqual(len(json.load(fh)["runs"][0]["results"]), 2)
+ self.assertIn("PS004", out)
+
+ def test_list_rules(self):
+ code, out, _ = run_cli("--list-rules")
+ self.assertEqual(code, 0)
+ self.assertEqual(len(out.strip().splitlines()), len(RULES))
+
+
+if __name__ == "__main__":
+ unittest.main()