Catching over-permissive IAM in the pull request, not in production
Why I built Cloud Policy Guard: a small Python CLI that reads a Terraform plan, scores IAM risk, and fails the pipeline before a wildcard policy ever reaches AWS.

Over-permissive IAM is one of the most common AWS misconfigurations: "Action": "*", iam:PassRole granted on every resource, a trust policy with "Principal": "*". The problem is usually found during an audit, after the policy has already been live for weeks.
I wanted that check to happen where the change is made: in the pull request. So I built Cloud Policy Guard, a Policy-as-Code security gate for Terraform.
Scan the plan, not the account
The tool reads the JSON form of a Terraform plan, which describes exactly what Terraform is about to create:
terraform plan -out=tfplan
terraform show -json tfplan > plan.json
cloud-policy-guard scan --terraform-plan plan.json
Because it works on the plan file, it runs fully offline and needs no AWS credentials. Nothing has to be deployed, and no secrets have to be handed to the CI job.
The rules
| ID | Detects | Severity |
|---|---|---|
| LP001 | Wildcard action, Action: "*" |
Critical |
| LP002 | Wildcard resource, Resource: "*" (context-aware) |
High / Medium |
| LP003 | Administrative permissions (* on *, or admin managed policies) |
Critical |
| LP004 | Sensitive IAM actions such as iam:PassRole, iam:PutRolePolicy |
High |
| LP005 | Service-level wildcards such as s3:*, iam:*, secretsmanager:* |
High / Medium |
| LP006 | Broad trust policies, Principal: "*" or insecure cross-account trust |
Critical |
The rules are context-aware: they evaluate the combination of actions and resources to set the severity, instead of flagging every wildcard the same way.
Turning findings into a pass/fail decision
Each finding adds to a deterministic risk score: Critical = 10, High = 7, Medium = 4, Low = 1. A pipeline fails if any finding reaches the fail_on severity, or if the total score passes max_risk_score:
# .cpg.yaml
fail_on: critical # fail if any finding is critical or worse
max_risk_score: 50 # fail if the total risk score exceeds 50
exclude_rules:
- LP005 # rules can be disabled per repo
format: text # text for humans, json for pipelines
The exit code does the rest: 0 for PASS, 1 for FAIL. Any CI system (GitHub Actions, GitLab CI, Jenkins) blocks the merge without extra integration work.
In a GitHub Actions workflow
- name: Terraform Plan
run: |
terraform init
terraform plan -out=tfplan
terraform show -json tfplan > plan.json
- name: Scan IAM least privilege
# exit code 1 fails the job and blocks the PR
run: cloud-policy-guard scan --terraform-plan plan.json
Testing it honestly
The repo ships with a deliberately vulnerable Terraform example and a secure one. The vulnerable plan must FAIL and the secure plan must PASS, and a pytest suite keeps the rules from regressing. The tool is also containerised with Docker, so it behaves the same on my laptop and in CI.
What I took away
Shifting left only works if the check is fast, offline and gives a clear yes/no.
A severity model plus a score threshold lets teams adopt a gate gradually instead of all at once.
Two outputs matter: coloured text that developers will actually read, and JSON that pipelines can parse.
Comments
Join the discussionNo comments yet. Be the first to comment.
