Skip to main content
Cloud Security · lesson 6 of 8
Cloud Security intermediate Lesson 6 of 8

Also part of: AWS

Linting AWS IAM Policies Before Deploy with Parliament

Lint IAM policy JSON locally with parliament: catch s3:* on *, a mistyped action and a resource mismatch, then gate CI on a least-privilege rewrite.

By SkillByExample · Published

cloud-security aws iam least-privilege parliament

An IAM policy is JSON, and AWS accepts a lot of JSON that doesn’t do what its author meant. A mistyped action is saved without complaint, and so is an action paired with a resource it can never match. parliament checks policy files offline, before anything reaches an AWS account, so it can run in a pre-commit hook or a pull-request check.

Tested on 2026-10-15 with parliament 1.6.4 and setuptools 80.10.2 on Python 3.12.15 (python:3.12-slim on Linux). No AWS account or credentials are involved. The first policy is the kind that starts out as “just make it work”:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AppStorage",
      "Effect": "Allow",
      "Action": "s3:*",
      "Resource": "*"
    }
  ]
}

Save it as app-policy.json, then install parliament into a virtual environment:

$ python -m venv .venv && source .venv/bin/activate
$ pip install -q parliament==1.6.4
$ parliament --file app-policy.json 2>&1 | tail -1
ModuleNotFoundError: No module named 'pkg_resources'

The install succeeds, but the first run fails. parliament imports pkg_resources, which is part of setuptools. Virtual environments on Python 3.12 and later no longer include setuptools, and setuptools 82 removed pkg_resources altogether (81 still ships it, with a deprecation warning). So pin a release below 81, as the warning itself advises:

$ pip install -q setuptools==80.10.2
$ parliament --file app-policy.json --minimal
/lab/.venv/lib/python3.12/site-packages/parliament/__init__.py:13: UserWarning: pkg_resources is deprecated as an API. See https://setuptools.pypa.io/en/latest/pkg_resources.html. The pkg_resources package is slated for removal as early as 2025-11-30. Refrain from using this package or pin to Setuptools<81.
  import pkg_resources
RESOURCE_STAR
$ export PYTHONWARNINGS="ignore:pkg_resources is deprecated"

Now it runs, with a deprecation warning on every call. PYTHONWARNINGS silences only the warnings whose message starts with that text, so any other warning still shows. The rest of the lesson runs in this shell.

What parliament says about s3:* on *

--minimal prints only the issue codes, and the exit code tells a script whether anything was found:

$ parliament --file app-policy.json --minimal; echo "exit code: $?"
RESOURCE_STAR
exit code: 1

A policy that grants every S3 action on every bucket in the account produced one finding, and its full output, without --minimal, rates it LOW. parliament’s built-in checks look for policies that are wrong, not for policies that grant too much. The community auditors that ship with it cover the second kind. This helper prints one line per finding from the --json output:

# findings.py: one line per finding from `parliament --json` (JSON Lines on stdin)
import json
import sys

for line in sys.stdin:
    finding = json.loads(line)
    summary = f"{finding['severity']} {finding['issue']}"
    if isinstance(finding["detail"], list):
        summary += f" ({len(finding['detail'])} items)"
    print(summary)
$ parliament --file app-policy.json --include-community-auditors --json | python findings.py
MEDIUM PERMISSIONS_MANAGEMENT_ACTIONS
LOW RESOURCE_STAR (145 items)

The detail of RESOURCE_STAR is the list of actions that s3:* expands to: 145 of them in the IAM data bundled with this release. PERMISSIONS_MANAGEMENT_ACTIONS is the one that matters. Some of those actions are s3:PutBucketPolicy and s3:PutBucketAcl, which let the holder rewrite who can access a bucket, including making it public. Turn the community auditors on in every run.

A severity gate that lets the worst policy through

A CI step usually fails on findings above a threshold. The obvious threshold is the wrong one here:

$ parliament --file app-policy.json --minimum_severity MEDIUM; echo "exit code: $?"
exit code: 0

Without the community auditors, the only finding on s3:* is LOW, so a gate at MEDIUM passes the broadest S3 policy there is. Either keep the default threshold or add --include-community-auditors to the gate. Next, the gate has to run where CI runs it:

$ parliament --file app-policy.json --minimal < /dev/null; echo "exit code: $?"
usage: parliament [-h] [--aws-managed-policies AWS_MANAGED_POLICIES]
                  [--auth-details-file AUTH_DETAILS_FILE] [--string STRING]
                  [--file FILE] [--files FILES] [--directory DIRECTORY]
                  [--include_policy_extension INCLUDE_POLICY_EXTENSION]
                  [--exclude_pattern EXCLUDE_PATTERN] [--minimal] [--json]
                  [--minimum_severity {CRITICAL,HIGH,MEDIUM,LOW,INFO}]
                  [--private_auditors PRIVATE_AUDITORS] [--config CONFIG]
                  [--include-community-auditors] [-v] [--version]
parliament: error: You cannot pass a file with --file and use stdin together
exit code: 2
$ parliament --files app-policy.json --minimal < /dev/null; echo "exit code: $?"
RESOURCE_STAR
exit code: 1

< /dev/null stands in for a CI runner, where stdin is not a terminal. parliament treats any non-terminal stdin as a policy being piped in, so --file errors out. The command worked in your terminal and fails in the pipeline. --files (comma-separated paths) and --directory skip that check, so use them in scripts.

A least-privilege rewrite, one finding at a time

The application only reads and writes objects under one bucket, and lists them. A first rewrite of uploads-policy.json:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadWriteUploads",
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:PutObjects", "s3:ListBucket"],
      "Resource": "arn:aws:s3:::acme-uploads/*"
    }
  ]
}
$ parliament --file uploads-policy.json
LOW - Unknown action -  - Unknown action s3:PutObjects - {'string': 's3:PutObjects', 'line': 7, 'column': 34, 'filepath': 'uploads-policy.json'}

s3:PutObjects isn’t an action. AWS would accept this policy, and the app’s uploads would fail with AccessDenied at runtime. parliament gives the line and column. Fix the typo to s3:PutObject and run it again:

$ parliament --file uploads-policy.json
MEDIUM - No resources match for the given action -  - [{'action': 's3:ListBucket', 'required_format': 'arn:*:s3:::*'}] - {'line': 4, 'column': 5, 'filepath': 'uploads-policy.json'}

A second bug, which the first run didn’t report. s3:ListBucket acts on a bucket (arn:aws:s3:::acme-uploads), but the statement only names objects (acme-uploads/*), so that permission can never take effect. That is the most common S3 policy mistake. Each fix can uncover the next finding, so re-run until the output is empty. The fix splits the statement by resource type:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ListUploads",
      "Effect": "Allow",
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::acme-uploads"
    },
    {
      "Sid": "ReadWriteUploads",
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:PutObject"],
      "Resource": "arn:aws:s3:::acme-uploads/*"
    }
  ]
}
$ parliament --file uploads-policy.json --include-community-auditors; echo "exit code: $?"
exit code: 0

No output and exit code 0, even with the community auditors on. Three actions on one bucket replace 145 actions on every bucket. A clean run proves the policy is well formed. Whether those three actions are the right ones is still for you to decide, by comparing them with what the application actually calls.

Practice

  1. The office-only read policy below has a condition that never matches. Save it as office-read.json and lint it. Which key is wrong, and what should it be?
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadFromOfficeOnly",
      "Effect": "Allow",
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::acme-uploads/*",
      "Condition": {
        "IpAddress": {"aws:SourceIpAddress": "203.0.113.0/24"}
      }
    }
  ]
}
Solution
$ parliament --file office-read.json
MEDIUM - Unknown condition for action - The given condition is not documented to work with the given action - Unknown condition aws:SourceIpAddress for action s3:GetObject - {'line': 10, 'column': 22, 'filepath': 'office-read.json'}

The global key is aws:SourceIp. A condition on a key that is never present doesn’t match, so this Allow never applies and every read is denied. A typo in a Deny statement’s condition is worse: the Deny silently never fires, and nothing breaks to tell you.

  1. A deploy role gets lambda:UpdateFunctionCode and iam:PassRole on *. Save it as deploy-policy.json and run it through the community auditors. What is the highest severity?
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DeployLambda",
      "Effect": "Allow",
      "Action": ["lambda:UpdateFunctionCode", "iam:PassRole"],
      "Resource": "*"
    }
  ]
}
Solution
$ parliament --file deploy-policy.json --include-community-auditors --json | python findings.py
MEDIUM PERMISSIONS_MANAGEMENT_ACTIONS
HIGH PRIVILEGE_ESCALATION
LOW RESOURCE_STAR (2 items)

HIGH. iam:PassRole on * lets the holder hand any role in the account to a service, including an admin role, and then act through that service. Scope Resource to the one execution role’s ARN. Without the community auditors, this policy too is a single LOW finding.

  1. Lint every policy in a policies/ directory in one CI step. Copy uploads-policy.json and deploy-policy.json into it. What does the step report, and does it fail?
Solution
$ parliament --directory policies; echo "exit code: $?"
LOW - Unnecessary use of Resource * -  - ['iam:PassRole', 'lambda:UpdateFunctionCode'] - {'line': 4, 'column': 5, 'filepath': 'policies/deploy-policy.json'}
exit code: 1

Only the deploy policy is reported, with its path, and the exit code fails the step. --directory also avoids the stdin check that breaks --file in CI. Notice that this LOW line is all the step says about the policy rated HIGH in task 2, so the CI step needs --include-community-auditors too.

Frequently Asked Questions

What is parliament for AWS IAM?
Parliament is an open-source linter for AWS IAM policy documents from Duo Labs. It reads policy JSON offline and reports findings such as unknown actions, actions paired with resources they can never match, condition keys that don't apply to an action, and unnecessary use of Resource *. It needs no AWS account or credentials, so it fits in a pre-commit hook or CI job.
Why does parliament fail with No module named pkg_resources?
Parliament 1.6.4 imports pkg_resources, which comes from setuptools. New virtual environments on Python 3.12 and later no longer include setuptools, and setuptools 82 removed pkg_resources. Install a pinned setuptools release below 81, such as setuptools==80.10.2, into the same environment.
Does parliament flag s3:* on Resource * as dangerous?
Only as a LOW finding, RESOURCE_STAR, by default. Its built-in checks are about policies that are wrong, not about how much they grant. Run it with --include-community-auditors to add checks such as PERMISSIONS_MANAGEMENT_ACTIONS and PRIVILEGE_ESCALATION, which rate broad grants like s3:* or iam:PassRole on * as MEDIUM or HIGH.
Why does parliament say I cannot pass a file with --file and use stdin together?
Parliament reads a policy from stdin whenever stdin is not a terminal. CI runners, cron jobs and scripts with redirected input have no terminal, so --file then conflicts with the stdin check and parliament exits with code 2. Use --files with one or more paths, or --directory, which don't trigger the check.
Is a policy that passes parliament least privilege?
No. A clean run means the policy is well formed: every action exists and every action can match one of its resources. Whether the actions are the ones the workload needs is a design decision. Compare the grants against what the application calls, or use IAM Access Analyzer's policy generation from CloudTrail activity in a real account.