From 31ebc0309afa9954d9458f8bb5bc64e4a1f63718 Mon Sep 17 00:00:00 2001 From: Brian Teller Date: Tue, 29 Sep 2026 22:27:58 -0400 Subject: [PATCH] plan-skeptic v0.1.1: close two silent misses, improve Action UX Catch IAM wildcards on role inline_policy and open KMS key policies (PS011). Stabilize SARIF fingerprints so message edits do not reopen alerts, and tee findings into the GitHub job summary. --- README.md | 13 ++- action.yml | 9 +- fixtures/README.md | 1 + .../08-kms-open-to-unblock-encrypt.json | 25 +++++ pyproject.toml | 2 +- src/plan_skeptic/__init__.py | 2 +- src/plan_skeptic/report.py | 4 +- src/plan_skeptic/rules.py | 105 +++++++++++++----- tests/test_plan_skeptic.py | 70 ++++++++++++ 9 files changed, 193 insertions(+), 38 deletions(-) create mode 100644 fixtures/flawed/08-kms-open-to-unblock-encrypt.json diff --git a/README.md b/README.md index b1ca7e9..7e709b6 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ 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 +It is not a policy engine and does not try to replace one. It is eleven opinionated checks about **what a change introduces**, tuned so that the output is short enough to be read on every pull request. @@ -41,7 +41,7 @@ PYTHONPATH=src python3 -m plan_skeptic plan.json - run: | terraform plan -out=plan.out terraform show -json plan.out > plan.json -- uses: TellersTechOrg/plan-skeptic@v0.1.0 +- uses: TellersTechOrg/plan-skeptic@v0.1.1 with: plan-json: plan.json fail-on: high # high | medium | low | never @@ -51,8 +51,8 @@ PYTHONPATH=src python3 -m plan_skeptic plan.json sarif_file: plan-skeptic.sarif ``` -Findings appear in the Security tab and inline on the pull request. Uploading -SARIF needs `security-events: write`. +Findings appear in the job summary, the Security tab (when SARIF is uploaded), +and inline on the pull request. Uploading SARIF needs `security-events: write`. ## Rules @@ -60,7 +60,7 @@ SARIF needs `security-events: write`. |---|---|---| | 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 | +| PS003 | high | An IAM policy (including role `inline_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) | @@ -68,6 +68,7 @@ SARIF needs `security-events: write`. | 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 | +| PS011 | high | A KMS key policy lets any principal use the key without a Condition | 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. @@ -96,7 +97,7 @@ refused with exit 2 rather than reported clean. ## Fixtures -[`fixtures/`](fixtures/) holds seven flawed plans, each paired with the request +[`fixtures/`](fixtures/) holds eight 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 diff --git a/action.yml b/action.yml index 62cb22f..8b4b0fd 100644 --- a/action.yml +++ b/action.yml @@ -35,6 +35,13 @@ runs: PS_DISABLE: ${{ inputs.disable }} PYTHONPATH: ${{ github.action_path }}/src run: | + set -euo pipefail 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" + # Text summary always goes to stdout when SARIF is written to a file; + # tee it into the job summary so findings are visible without code scanning. + if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + python3 -m plan_skeptic "${args[@]}" "$PS_PLAN" | tee -a "$GITHUB_STEP_SUMMARY" + else + python3 -m plan_skeptic "${args[@]}" "$PS_PLAN" + fi diff --git a/fixtures/README.md b/fixtures/README.md index 7e580e3..26b0067 100644 --- a/fixtures/README.md +++ b/fixtures/README.md @@ -20,6 +20,7 @@ the same shape, trimmed to the attributes the rules read. | `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 | +| `08-kms-open-to-unblock-encrypt` | "The app gets AccessDeniedException on Decrypt, fix the key policy" | Adds a statement granting `kms:*` to Principal `*` with no Condition | PS011 | `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, diff --git a/fixtures/flawed/08-kms-open-to-unblock-encrypt.json b/fixtures/flawed/08-kms-open-to-unblock-encrypt.json new file mode 100644 index 0000000..21b7100 --- /dev/null +++ b/fixtures/flawed/08-kms-open-to-unblock-encrypt.json @@ -0,0 +1,25 @@ +{ + "format_version": "1.2", + "terraform_version": "1.12.5", + "resource_changes": [ + { + "address": "aws_kms_key.app", + "mode": "managed", + "type": "aws_kms_key", + "name": "app", + "provider_name": "registry.opentofu.org/hashicorp/aws", + "change": { + "actions": ["update"], + "before": { + "description": "app data key", + "policy": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Sid\":\"Root\",\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::111122223333:root\"},\"Action\":\"kms:*\",\"Resource\":\"*\"},{\"Sid\":\"App\",\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::111122223333:role/app\"},\"Action\":[\"kms:Decrypt\",\"kms:GenerateDataKey\"],\"Resource\":\"*\"}]}" + }, + "after": { + "description": "app data key", + "policy": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Sid\":\"Root\",\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::111122223333:root\"},\"Action\":\"kms:*\",\"Resource\":\"*\"},{\"Sid\":\"App\",\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::111122223333:role/app\"},\"Action\":[\"kms:Decrypt\",\"kms:GenerateDataKey\"],\"Resource\":\"*\"},{\"Sid\":\"Anyone\",\"Effect\":\"Allow\",\"Principal\":\"*\",\"Action\":\"kms:*\",\"Resource\":\"*\"}]}" + }, + "after_unknown": {} + } + } + ] +} diff --git a/pyproject.toml b/pyproject.toml index 90dcc36..89aa15d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plan-skeptic" -version = "0.1.0" +version = "0.1.1" description = "Flag the parts of a Terraform/OpenTofu plan that need a human." readme = "README.md" requires-python = ">=3.9" diff --git a/src/plan_skeptic/__init__.py b/src/plan_skeptic/__init__.py index c49e831..69c388e 100644 --- a/src/plan_skeptic/__init__.py +++ b/src/plan_skeptic/__init__.py @@ -1,3 +1,3 @@ """plan-skeptic: flag the parts of a Terraform/OpenTofu plan that need a human.""" -__version__ = "0.1.0" +__version__ = "0.1.1" diff --git a/src/plan_skeptic/report.py b/src/plan_skeptic/report.py index 0765e06..fcdd776 100644 --- a/src/plan_skeptic/report.py +++ b/src/plan_skeptic/report.py @@ -77,7 +77,9 @@ def render_sarif(findings: list, plan_uri: str) -> str: "logicalLocations": [{"fullyQualifiedName": f.address, "kind": "resource"}], } ], - "partialFingerprints": {"resourceRule": f"{f.rule_id}:{f.address}:{f.message}"}, + "partialFingerprints": { + "resourceRule": f"{f.rule_id}:{f.address}" + (f":{f.key}" if f.key else ""), + }, } for f in findings ] diff --git a/src/plan_skeptic/rules.py b/src/plan_skeptic/rules.py index cbde31f..9983c86 100644 --- a/src/plan_skeptic/rules.py +++ b/src/plan_skeptic/rules.py @@ -40,6 +40,10 @@ class Finding: severity: str address: str message: str + # Distinguishes multiple findings of the same rule on one address in SARIF + # fingerprints. Must not be the free-text message: wording changes would + # reopen alerts as new. + key: str = "" RULES = { @@ -110,6 +114,12 @@ class Finding: "the next mistake recoverable. Turning one off is often the first step " "of a plan that deletes something in a later apply.", ), + Rule( + "PS011", "kms-key-policy-any-principal", HIGH, + "A KMS key policy allows any principal without a Condition.", + "A Principal of `*` with no Condition lets any AWS account use the key. " + "Generated policies open this to make encrypt/decrypt errors go away.", + ), ) } @@ -132,6 +142,8 @@ class Finding: IAM_POLICY_TYPES = frozenset({ "aws_iam_policy", "aws_iam_role_policy", "aws_iam_user_policy", "aws_iam_group_policy", }) +# aws_iam_role carries nested inline_policy blocks; same wildcard rules apply. +IAM_WILDCARD_TYPES = IAM_POLICY_TYPES | {"aws_iam_role"} POLICY_ATTACHMENT_TYPES = frozenset({ "aws_iam_role_policy_attachment", "aws_iam_user_policy_attachment", "aws_iam_group_policy_attachment", "aws_iam_policy_attachment", @@ -175,8 +187,10 @@ def _new_items(rc: ResourceChange, extract: Callable[[dict], set]) -> list: 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 _finding( + rule_id: str, rc: ResourceChange, message: str, severity: str = "", key: str = "", +) -> Finding: + return Finding(rule_id, severity or RULES[rule_id].severity, rc.address, message, key) def _as_list(value) -> list: @@ -208,31 +222,46 @@ def _principal_is_anyone(principal) -> bool: return False +def _policy_allows_anyone(doc) -> bool: + return any( + _allows(s) and _principal_is_anyone(s.get("Principal")) and not s.get("Condition") + for s in _policy_statements(doc) + ) + + +def _policy_docs(values: dict) -> list: + """Policy documents on a resource: top-level `policy` plus role inline_policy blocks.""" + docs = [values.get("policy")] + for block in _as_list(values.get("inline_policy")): + if isinstance(block, dict): + docs.append(block.get("policy")) + return docs + + 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") + for doc in _policy_docs(values): + for stmt in _policy_statements(doc): + 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")) - ) + return _policy_allows_anyone(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")) - ) + return _policy_allows_anyone(values.get("policy")) + + +def _kms_policy_anyone(values: dict) -> bool: + return _policy_allows_anyone(values.get("policy")) def _port_label(from_port, to_port) -> str: @@ -290,10 +319,10 @@ def check_deleted(rc: ResourceChange) -> Iterable[Finding]: def check_iam_wildcard(rc: ResourceChange) -> Iterable[Finding]: - if rc.type not in IAM_POLICY_TYPES: + if rc.type not in IAM_WILDCARD_TYPES: return for grant in _new_items(rc, _wildcard_grants): - yield _finding("PS003", rc, f"Policy now grants {grant}.") + yield _finding("PS003", rc, f"Policy now grants {grant}.", key=grant) def check_broad_attachment(rc: ResourceChange) -> Iterable[Finding]: @@ -309,7 +338,7 @@ def broad(values: dict) -> set: } for name in _new_items(rc, broad): - yield _finding("PS004", rc, f"Attaches the AWS managed policy {name}.") + yield _finding("PS004", rc, f"Attaches the AWS managed policy {name}.", key=name) def check_trust_anyone(rc: ResourceChange) -> Iterable[Finding]: @@ -320,7 +349,11 @@ def check_trust_anyone(rc: ResourceChange) -> Iterable[Finding]: 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) + label = _port_label(from_port, to_port) + yield _finding( + "PS006", rc, f"Opens {label} to {cidr}.", severity, + key=f"{from_port}-{to_port}-{cidr}", + ) def check_s3_public(rc: ResourceChange) -> Iterable[Finding]: @@ -328,13 +361,16 @@ def check_s3_public(rc: ResourceChange) -> Iterable[Finding]: 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) + ".") + yield _finding( + "PS007", rc, "Public access block disables " + ", ".join(off) + ".", + key=",".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')}.") + yield _finding("PS007", rc, f"Bucket ACL set to {rc.after.get('acl')}.", key="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.") + yield _finding("PS007", rc, "Bucket policy allows Principal * with no Condition.", key="policy") def check_public_database(rc: ResourceChange) -> Iterable[Finding]: @@ -345,7 +381,7 @@ def check_public_database(rc: ResourceChange) -> Iterable[Finding]: 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.") + yield _finding("PS009", rc, f"{attr} = false.", key=attr) def check_recovery_guards(rc: ResourceChange) -> Iterable[Finding]: @@ -358,9 +394,20 @@ def check_recovery_guards(rc: ResourceChange) -> Iterable[Finding]: # 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.") + yield _finding( + "PS010", rc, f"{attr} = {str(unsafe).lower()} on a new data store.", key=attr, + ) 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()}.") + yield _finding( + "PS010", rc, + f"{attr} changes {str(not unsafe).lower()} -> {str(unsafe).lower()}.", + key=attr, + ) + + +def check_kms_anyone(rc: ResourceChange) -> Iterable[Finding]: + if rc.type == "aws_kms_key" and _introduced(rc, _kms_policy_anyone): + yield _finding("PS011", rc, "Key policy allows Principal * with no Condition.") CHECKS = ( @@ -374,6 +421,7 @@ def check_recovery_guards(rc: ResourceChange) -> Iterable[Finding]: check_public_database, check_encryption_disabled, check_recovery_guards, + check_kms_anyone, ) @@ -412,6 +460,7 @@ def review(changes: Iterable[ResourceChange], disabled: Iterable[str] = ()) -> l 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.", + f.key, ) findings.append(f) findings.sort(key=lambda f: (-SEVERITY_ORDER[f.severity], f.rule_id, f.address)) diff --git a/tests/test_plan_skeptic.py b/tests/test_plan_skeptic.py index 4c8278a..a7e65f9 100644 --- a/tests/test_plan_skeptic.py +++ b/tests/test_plan_skeptic.py @@ -126,6 +126,11 @@ def test_refactor_without_moved_block(self): for f in found: self.assertIn("moved", f.message) + def test_kms_open_to_unblock_encrypt(self): + self.assertEqual(findings_for("flawed/08-kms-open-to-unblock-encrypt.json"), { + ("PS011", "high", "aws_kms_key.app"), + }) + def test_clean_plan_has_no_findings(self): self.assertEqual(findings_for("clean/01-tags-only.json"), set()) @@ -153,6 +158,58 @@ def test_deny_wildcard_is_not_a_grant(self): plan = plan_with(change("aws_iam_policy.x", "aws_iam_policy", ["create"], after={"policy": policy})) self.assertEqual(review(load_plan(plan)), []) + def test_role_inline_policy_wildcard(self): + # Nested inline_policy is what generated Terraform often emits instead of + # a separate aws_iam_role_policy; missing it was a silent false negative. + role = { + "name": "app", + "assume_role_policy": json.dumps({ + "Statement": [{"Effect": "Allow", "Principal": {"Service": "ec2.amazonaws.com"}, + "Action": "sts:AssumeRole"}], + }), + "inline_policy": [{ + "name": "wide", + "policy": json.dumps({ + "Statement": [{"Effect": "Allow", "Action": "*", "Resource": "*"}], + }), + }], + } + plan = plan_with(change("aws_iam_role.x", "aws_iam_role", ["create"], after=role)) + found = review(load_plan(plan)) + self.assertEqual([(f.rule_id, f.message, f.key) for f in found], [ + ("PS003", "Policy now grants Action *.", "Action *"), + ]) + + def test_role_inline_policy_existing_wildcard_not_reported_again(self): + before = { + "inline_policy": [{ + "name": "wide", + "policy": json.dumps({"Statement": [{"Effect": "Allow", "Action": "s3:*", "Resource": "*"}]}), + }], + } + after = { + "inline_policy": [{ + "name": "wide", + "policy": json.dumps({"Statement": [ + {"Effect": "Allow", "Action": "s3:*", "Resource": "*"}, + {"Effect": "Allow", "Action": "kms:*", "Resource": "*"}, + ]}), + }], + } + plan = plan_with(change("aws_iam_role.x", "aws_iam_role", ["update"], before=before, after=after)) + found = review(load_plan(plan)) + self.assertEqual([f.message for f in found], ["Policy now grants Action kms:*."]) + + def test_kms_policy_with_condition_is_not_anyone(self): + policy = json.dumps({ + "Statement": [{ + "Effect": "Allow", "Principal": "*", "Action": "kms:Decrypt", "Resource": "*", + "Condition": {"StringEquals": {"kms:CallerAccount": "111122223333"}}, + }], + }) + plan = plan_with(change("aws_kms_key.x", "aws_kms_key", ["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"}}}]}) @@ -178,6 +235,16 @@ def test_findings_sort_high_first(self): self.assertEqual([f.severity for f in found][:3], ["high"] * 3) self.assertEqual(found[-1].severity, "medium") + def test_fingerprint_ignores_message_wording(self): + found = review(load_plan(fixture("flawed/03-iam-wildcard-creep.json"))) + keys = {(f.rule_id, f.address, f.key) for f in found} + self.assertEqual(keys, { + ("PS003", "aws_iam_policy.exporter", "Action kms:*"), + ("PS003", "aws_iam_policy.exporter", "Action s3:*"), + ("PS003", "aws_iam_role_policy.ci_deploy", "NotAction in an Allow statement"), + }) + for f in found: + self.assertNotEqual(f.key, f.message) class PlanLoading(unittest.TestCase): def test_binary_plan_is_refused(self): @@ -243,6 +310,9 @@ def test_sarif_is_valid_shape(self): loc = result["locations"][0] self.assertIn("physicalLocation", loc, "code scanning drops results without one") self.assertTrue(loc["logicalLocations"][0]["fullyQualifiedName"].startswith("aws_s3_")) + fp = result["partialFingerprints"]["resourceRule"] + self.assertTrue(fp.startswith("PS007:"), fp) + self.assertNotIn("Bucket", fp, "message wording must not enter the fingerprint") def test_output_file_plus_text_summary(self): with tempfile.TemporaryDirectory() as tmp: