All articles

// Knowledge.log — 技術記事

Offline IAM Policy Linting with Parliament: Catching Privilege Escalation Before Deploy

Parliament 1.6.4 catches iam:PassRole + lambda:CreateFunction/InvokeFunction offline and fails CI before deploy, no AWS account needed.

Three statements, each with Effect Allow, a specific Resource, and no Action: "*" anywhere. Reviewed one at a time, each looks reasonable: pass a role to a Lambda function, create the function, invoke the function. The problem is that these three permissions together form a known privilege escalation path — the principal creates a new Lambda with the passed role and invokes it, inheriting whatever that role can do. None of the three statements is suspicious on its own. The combination is.

A human reviewer reading the JSON top to bottom is unlikely to piece that equation together in their head, especially when the three statements aren't even adjacent. This is exactly the kind of finding worth automating before deploy, without needing an AWS account, credentials, or Access Analyzer running against a real environment. That's what Parliament does.

The policy and the finding

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "PassExistingRole",
      "Effect": "Allow",
      "Action": "iam:PassRole",
      "Resource": "arn:aws:iam::123456789012:role/worker"
    },
    {
      "Sid": "CreateWorkerFunction",
      "Effect": "Allow",
      "Action": "lambda:CreateFunction",
      "Resource": "arn:aws:lambda:us-east-1:123456789012:function:worker"
    },
    {
      "Sid": "InvokeWorkerFunction",
      "Effect": "Allow",
      "Action": "lambda:InvokeFunction",
      "Resource": "arn:aws:lambda:us-east-1:123456789012:function:worker"
    }
  ]
}

Parliament is an IAM policy linter that runs entirely locally — no API call, no STS, nothing. Tested here on version 1.6.4, installed from PyPI. The installed binary is called parliament, not parliament-cli — easy to get wrong from memory if you've used a similarly-named tool before.

Version, and the flag that ships off

$ parliament --version
parliament 1.6.4

The part that catches people off guard: community auditors — including the privilege-escalation one, which is what matters here — ship disabled by default. Without --include-community-auditors, running Parliament against the policy above produces no output and exits 0:

$ parliament --files extracted-bad-policy.json --json
$ echo $?
0

Dead silence. A CI gate that forgets this flag is only validating JSON syntax, not privilege escalation. With the flag on, the same file produces two findings:

$ parliament --files extracted-bad-policy.json --include-community-auditors --json
{"issue": "PERMISSIONS_MANAGEMENT_ACTIONS", "title": "Permissions management actions", "severity": "MEDIUM", "description": "Allows the principal to modify IAM, RAM, identity-based policies, or resource based policies.", "detail": "", "location": {"actions": ["iam:passrole"], "filepath": "extracted-bad-policy.json"}}
{"issue": "PRIVILEGE_ESCALATION", "title": "Privilege escalation", "severity": "HIGH", "description": "Actions contain a combination of Privilege Escalation actions established by Rhino Security Labs", "detail": "", "location": {"type": "PassExistingRoleToNewLambdaThenInvoke", "actions": ["iam:passrole", "lambda:createfunction", "lambda:invokefunction"], "filepath": "extracted-bad-policy.json"}}

The second finding is the one that matters: PRIVILEGE_ESCALATION, severity HIGH, location.type equal to PassExistingRoleToNewLambdaThenInvoke, with all three actions listed. detail comes back empty — the real explanation lives in location.type, which is the name of the escalation method. Notice also that the issue id (PRIVILEGE_ESCALATION) only shows up in --json output; in the default text mode you get title, severity, and description, but not that identifier — which matters if you want to filter findings by type in a script.

The first finding, PERMISSIONS_MANAGEMENT_ACTIONS, is something else: the same flag turns on a separate auditor that flags any standalone iam:PassRole as a permissions-management action, severity MEDIUM. Don't conflate the two — it's PRIVILEGE_ESCALATION that proves the dangerous combination; the MEDIUM is correlated noise, not the finding itself.

The privilege-escalation auditor installed in this version has 22 registered methods in escalation_methods (PassExistingRoleToNewLambdaThenInvoke is one of them). The finding's description credits the list to Rhino Security Labs, and the Rhino post is the reference this text names — not a Parliament API, just the source the tool cites.

Extract before you lint

Parliament reads IAM policies, not CloudFormation templates. Pointing it straight at template.json fails, and it fails in a way that isn't very helpful for debugging:

$ parliament --files template.json --include-community-auditors --json
{"issue": "MALFORMED", "title": "Malformed", "severity": "HIGH", "description": "Policy does not contain a required element", "detail": "Policy contains an unknown element", "location": {"string": "AWSTemplateFormatVersion", "lineno": 2, "column": 31, "filepath": "template.json"}}

It's trying to interpret AWSTemplateFormatVersion as if it were a field of an IAM policy. Makes sense — Parliament knows nothing about the structure of a CloudFormation template. The missing step is extracting the PolicyDocument out of the template before linting. Here that was done with a local script that walks Resources, pulls Properties.Policies[].PolicyDocument from every AWS::IAM::Role, and the PolicyDocument from AWS::IAM::Policy, AWS::IAM::ManagedPolicy, and AWS::IAM::RolePolicy, writing each one out as a standalone policy JSON. (It does not extract AssumeRolePolicyDocument — that's the trust policy, a different topic.) The result matched the fixture's reference policies byte for byte — the extractor didn't invent or drop anything along the way.

What Guard still catches, and what this local rule doesn't

The earlier post on CloudFormation Guard showed an org-wide rule running against the whole template, no AWS account required. Worth contrasting the two, since they solve different problems. Running a wildcard-only local rule here (not the full org.guard from that article) against three variations of the same template:

FixtureParliament (extracted policy)Guard (wildcard-only rule)
Named actions, no *PRIVILEGE_ESCALATION HIGH — exit 1PASS — exit 0
iam:Pass* + lambda:* (wildcard split across two statements)PRIVILEGE_ESCALATION HIGH — exit 1PASS — exit 0
One statement with Action: "*"(not tested in this fixture)FAIL — exit 19

That doesn't mean Guard is blind to wildcards — it caught Action: "*" directly, exiting with 19. What it doesn't catch is the combination of named actions, nor the wildcard sliced across two different statements (iam:Pass* in one, lambda:* in another): neither one on its own matches a rule checking "Action is exactly * or s3:*", so Guard passes both. Parliament, which looks at the set of allowed actions after expanding wildcards, caught both cases. These tools audit different things: Guard validates template resource properties against a rule; Parliament understands IAM semantics and knows that passing a role to a new Lambda plus invoking it is a cataloged escalation method, regardless of how the actions are spread across statements.

The gate: exit code, HIGH vs. CRITICAL, and the missing-InvokeFunction trap

A simple CI gate just needs to propagate Parliament's exit code:

#!/bin/sh
set -eu
ROOT=$(CDPATH= cd -- "$(dirname "$0")" && pwd)
POLICY=${1:?policy json path}
shift
exec "$ROOT/venv/bin/parliament" \
  --files "$POLICY" \
  --include-community-auditors \
  --json \
  "$@"

Note --files, plural, not --file. In CI, without a TTY, --file blows up:

$ parliament --file bad-policy.json --include-community-auditors --json
parliament: error: You cannot pass a file with --file and use stdin together
$ echo $?
2

--files doesn't have that problem — it's what the gate above used the whole time.

cli.py returns 1 if any finding survives the severity filter, and 0 if none does. An argparse argument error — wrong flag, value outside the allowed choices — exits 2, a different error category from "the policy has a problem." The real severity flag is --minimum_severity, with choices CRITICAL, HIGH, MEDIUM, LOW, INFO. There is no --min-severity; passing that gives unrecognized arguments and exits 2, filtering nothing:

$ parliament --files extracted-bad-policy.json --min-severity HIGH
parliament: error: unrecognized arguments: --min-severity HIGH

Running the gate with no filter, both findings show up and the exit is 1 — the PERMISSIONS_MANAGEMENT_ACTIONS MEDIUM alone is already enough to fail the build. With --minimum_severity HIGH, only PRIVILEGE_ESCALATION survives and the exit stays 1 — which is the behavior you want from a gate: isolate the serious finding from lower-severity noise. With --minimum_severity CRITICAL, that HIGH disappears from the report and the gate exits 0. CRITICAL is too high a bar for this specific finding — Parliament classifies privilege escalation via this chain as HIGH, not CRITICAL, and a gate configured for CRITICAL lets through exactly the case you meant to catch.

The most common practical trap is the policy variant that passes the role and creates the function but forgets lambda:InvokeFunction. Without the third permission, the subset the privesc auditor looks for doesn't close, and PRIVILEGE_ESCALATION doesn't fire. The default gate (no severity filter) still fails, but because of the PERMISSIONS_MANAGEMENT_ACTIONS MEDIUM from the standalone iam:PassRole — not for the reason you'd assume. At --minimum_severity HIGH, that same fixture exits 0: no HIGH finding survives, because the combination that triggers HIGH never formed. And there's a trap in the opposite direction worth remembering: putting all three actions in a single statement whose Resource is only the role's ARN doesn't actually make lambda:CreateFunction and lambda:InvokeFunction allowed — those two require a Lambda function ARN. In that case Parliament doesn't find PRIVILEGE_ESCALATION; it finds RESOURCE_MISMATCH.

Recommendation

DevDojo would put this check in CI against the policy JSON — standalone or extracted from a template — with --include-community-auditors and --minimum_severity HIGH. It doesn't need an AWS account and catches a class of error that statement-by-statement manual review tends to miss. A gate configured only for CRITICAL, or a standalone "no wildcard" Guard rule, is not the same control — neither one catches this specific combination of named actions.

That said, Parliament doesn't replace a check against the real account. It doesn't run IAM Access Analyzer, doesn't know what other policies or SCPs in the environment allow, and the list of 22 escalation methods is finite — a combination of actions outside it won't trigger anything. Treat it as a gate before deploy, not as the final word on what the role can actually do in production.

Next step

Run the bad fixture and the clean fixture side by side and check the exit codes:

$ parliament --files extracted-bad-policy.json --include-community-auditors --json --minimum_severity HIGH; echo "exit=$?"
$ parliament --files extracted-clean-policy.json --include-community-auditors --json --minimum_severity HIGH; echo "exit=$?"

The first should exit 1 with PRIVILEGE_ESCALATION in the JSON. The second, which only has an s3:GetObject scoped to a prefix, should exit 0. If both match, the gate is reading the right finding — not just reacting to whatever shows up in the output.

awsiam

// Continue.training — 次のステップ

Knowledge only counts when it becomes practice.

Go back to the article, run the examples, and share what you learned.

Explore more articles