Dev.to Security 🔐 Cybersecurity 👁 0 📖 4 min read

How to Block Over-Privileged IAM Roles in AWS Using Policy-as-Code

Chances are, your AWS account has an over-privileged IAM role right now, and you may not know which one. When you build infrastructure, you write Terraform that declares your resources and assigns IAM roles to them. IAM

How to Block Over-Privileged IAM Roles in AWS Using Policy-as-Code

Chances are, your AWS account has an over-privileged IAM role right now, and you may not know which one.

When you build infrastructure, you write Terraform that declares your resources and assigns IAM roles to them. IAM controls who can do what in your account, so the principle of least privilege has to apply: an entity should get only the permissions it needs to do its job.

That rule gets broken constantly, and the usual shortcut is a wildcard:

{
  "Effect": "Allow",
  "Action": "*",
  "Resource": "*"
}

"Action": "*" grants a function every permission it needs, plus every permission it doesn't. If that function is ever compromised, the attacker inherits everything that role can reach. One line in a policy file becomes your blast radius.

Where the check goes

You can run terraform apply from your laptop, but most teams run it through a CI/CD pipeline. A pipeline is a list of commands, and each one finishes with an exit code. Exit code 0 means the step passed and the pipeline continues. Any other exit code means the step failed and the pipeline stops.

If you can make a security check fail with a non-zero exit code, you can halt the deployment. That's the whole idea.

The policy

To block over-privileged roles, you write a policy in Rego. OPA, the policy engine, evaluates your Rego against an input document, which here is your Terraform plan converted to JSON. If your infrastructure code contains a wildcard where it shouldn't, OPA flags it.

Here's the rule that does the work:

package main

iam_policy_resources := {"aws_iam_role_policy", "aws_iam_policy"}

# Does this field contain a wildcard? Handles a single string or a list.
has_wildcard(field) {
    is_string(field)
    field == "*"
}

has_wildcard(field) {
    is_array(field)
    field[_] == "*"
}

deny[msg] {
    resource := input.resource_changes[_]
    iam_policy_resources[resource.type]

    policy := json.unmarshal(resource.change.after.policy)
    statement := policy.Statement[_]
    has_wildcard(statement.Action)

    msg := sprintf("IAM policy '%s' contains wildcard (*) action: violates least privilege", [resource.name])
}

A second rule does the same check for Resource.

The part that bit me

My first version of has_wildcard only had the string check:

# my first version: only catches Action = "*"
has_wildcard(field) {
    field == "*"
}

It caught "Action": "*". It missed "Action": ["s3:GetObject", "*"], which grants the same thing written differently. My policy let it through.

The fix is the second has_wildcard definition above. That field[_] walks every element in the array instead of treating the array as a single value, so a wildcard sitting anywhere inside the list gets caught.

That second definition is the difference between a policy that works and a policy that looks like it works.

Turning a decision into a failed pipeline

OPA only produces a decision. It doesn't decide whether your pipeline lives or dies.

That's where Conftest comes in. Conftest runs on top of OPA and prints pass or fail the way a normal software test does.

When Conftest finds a deny violation, it exits with code 1, and the GitHub runner stops. Here are the steps that matter in my workflow:

- name: Terraform Plan
  working-directory: ./terraform
  run: terraform plan -out=tfplan

- name: Convert Plan to JSON
  working-directory: ./terraform
  run: terraform show -json tfplan > tfplan.json

- name: OPA Policy Validation (Conftest)
  working-directory: ./terraform
  run: conftest test tfplan.json --policy ./policies/ --all-namespaces

The deploy job is a separate job that waits for this one:

terraform-apply:
  needs: terraform-plan-and-validate

If the validation job fails, the apply job never runs. Terraform never talks to AWS.

GitHub Actions run showing the OPA Validation job failing. Conftest reports that IAM policy 'lambda_dynamodb' contains a wildcard action, violating least privilege. The step exits with code 1, and the Terraform Apply job is greyed out because it never ran.

Conftest failed the build. Exit code 1. Terraform never talks to AWS.

GitHub Actions emails you when the job fails by default, and you can wire it to Slack with a webhook if that's where your team lives.

Results

I tested the policy against a deliberately over-privileged IAM role, one with "Action": "*" in its policy document. The build failed, and the deployment never ran.

The validation step added about 7.3 seconds to the pipeline. That's the cost of catching a wildcard before it reaches your account, and against a pipeline that already ran for over a minute, it was noise.

What this doesn't catch

The rule matches a bare "*" only. A service-level wildcard like "s3:*" still gets through, and catching it would need a pattern match rather than an exact string check.

It also only guards what goes through the pipeline. A change made directly in the console bypasses it, which is why my project also uses AWS Config to catch configuration drift after deployment.

My repo also excludes two platform policies, because AWS requires a wildcard resource for them. Real pipelines usually need a small exception list like that.

There's a bigger failure mode I found later, one where the check passes when it shouldn't because it can't read the policy at all. I've written about it separately, because it deserves its own piece.

I'd rather tell you where the gaps are than pretend the policy is complete. A rule you trust too much is worse than no rule at all.

The point

A bare wildcard in a policy that Terraform can fully resolve at plan time never reaches AWS through this pipeline. The pipeline decides, and the pipeline says no.

That's the common case. It isn't every case, and I've written about the exception separately.

The full implementation, with the Terraform, the Rego policies, and the GitHub Actions workflow, is here:

github.com/vicGrey/aws-serverless-zero-trust-governance

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.