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()