Modernizing Security: AWS Series - Analyzing IAM Policies at Scale with Parliament
For even the most seasoned AWS engineer, configuring Identity and Access Management (IAM) with least privilege can become an endeavor in complex environments. During a breach, an overly permissive policy can result in not only the application’s data being leaked, but it could also allow an attacker horizontal movement into another application, or even an entirely different environment.
IAM policies and roles are a foundational part of every AWS cloud deployment. Eliminating misconfigurations in the environment will help reduce blast radius and decrease attacker effectiveness in the event of a breach. The most efficient and repeatable method for finding these misconfigurations is to automate the detection process using existing libraries. This blog will explore how the Parliament library can be run ad-hoc on a single policy, upon an entire role with multiple attached policies, and leverage a custom detector.
Parliament is a tool written in python that lints IAM policies and returns detailed findings based upon misconfigurations in the policy content. It will detect if a policy is malformed, identify unknown permissions that do not exist within the platform, and callout resource mismatches when resources and permissions do not apply to each other.
If you have company-specific security controls and requirements for IAM policies, custom detectors (Private Auditors) can be created to extend Parliament.
{ |
"Statement": [ |
{ |
"Sid": "ElasticComputeCloudFull", |
"Action": [ |
"ec2:*" |
], |
"Effect": "Allow", |
"Resource": [ |
"*" |
] |
}, |
{ |
"Sid": "RDSFull", |
"Action": [ |
"rds:ModifyDBInstance" |
], |
"Effect": "Allow", |
"Resource": [ |
"*" |
] |
}, |
{ |
"Sid": "S3Full", |
"Action": [ |
"s3:GetObject", |
"s3:ListBucket" |
], |
"Effect": "Allow", |
"Resource": [ |
"arn:aws:s3:::myprivatebucket" |
] |
} |
], |
"Version": "2012-10-17" |
} |
Let’s explore an example policy: this policy grants the full EC2 service and a single RDS action, ModifyDBInstance
on any resource. It’s also scoped down S3 calls to a specific bucket.
This policy has a few problems, one of which is easy to spot. ec2:*
grants 399 different permissions. This includes access to DeleteVPC
and DeleteNetworkAclEntry
.
Sometimes the problems aren’t this obvious, but coming to the table informed about overly-permissive policies and misconfigurations will help implement more secure policies.
This is where Parliament can help.
First, install Parliament with pip:
pip install parliament
Save the overly permissive policy to a file called policy.json and run it through Parliament with the --file
argument:
parliament --file policy.json
The results should look like this:
Parliament globs wildcard permissions, such as ec2:*
into every possible permission included in the wildcard. Each permission is included in the Actions array of the Resource Star finding.
{ |
"issue": "RESOURCE_STAR", |
"title": "Unnecessary use of Resource *", |
"severity": "LOW", |
"description": "", |
"detail": null, |
"location": { |
"actions": [ |
"ec2:AcceptTransitGatewayPeeringAttachment", |
"ec2:AcceptTransitGatewayVpcAttachment", |
"ec2:AcceptVpcEndpointConnections", |
"ec2:AcceptVpcPeeringConnection", |
"....", |
"ec2:TerminateInstances", |
"ec2:UpdateSecurityGroupRuleDescriptionsEgress", |
"ec2:UpdateSecurityGroupRuleDescriptionsIngress" |
], |
"sid": "ElasticComputeCloudFull", |
"filepath": "policy.json" |
} |
} |
{ |
"issue": "RESOURCE_STAR", |
"title": "Unnecessary use of Resource *", |
"severity": "LOW", |
"description": "", |
"detail": null, |
"location": { |
"actions": [ |
"rds:ModifyDBInstance" |
], |
"sid": "RDSFull", |
"filepath": "policy.json" |
} |
} |
{ |
"issue": "RESOURCE_MISMATCH", |
"title": "No resources match for the given action", |
"severity": "MEDIUM", |
"description": "", |
"detail": [ |
{ |
"action": "s3:GetObject", |
"required_format": "arn:*:s3:::*/*" |
} |
], |
"location": { |
"actions": [ |
"s3:GetObject", |
"s3:ListBucket" |
], |
"sid": "S3Full", |
"filepath": "policy.json" |
} |
} |
Using a slightly more condensed version of the output, there are two instances of unnecessary usage of Resource: “*”.
This finding is created when an action is not restricted to any resource, but could be restricted according to the IAM documentation. Parliament retrieves this information from an internal IAM actions json file that is scraped from the various AWS Service’s IAM documentation pages.
Resource mismatch is created when the permission cannot take action on a resource restriction. In this example, s3:GetObject
and s3:ListBucket
were restricted to a bucket, but the s3:GetObject
permission can only act upon object resource types.
The full list of finding types Parliament can generate out of the box is located in the config file.
Running Parliament in the command line interface (CLI) gives users the ability to identify issues with IAM policies before creation, and you can scan all policies in an account with the --auth-details-file
flag, but what if you wanted to look at specific roles?
Parliament is packaged as a CLI tool, but can also be leveraged as a Python library.
First, create a boto3 connection to get all the policies for a role. The profile and role used will be specific to your environment, but for the sake of example, here it is called ‘dev’ and the role being investigated is ‘test-role’.
Instantiate an empty variable named all_policies
to store the policy data. All policy names and content will be stored in this dictionary for analysis.
import boto3 |
profile_name = 'dev' |
role_name = 'test-role' |
session = boto3.Session(profile_name=profile_name) |
iam = session.client('iam') |
def get_policies_for_role(rolename): |
all_policies = {} |
# two possible types of policies - inline and managed |
inline_policies = iam.list_role_policies(RoleName=rolename)['PolicyNames'] |
attached_policies = iam.list_attached_role_policies(RoleName=rolename)['AttachedPolicies'] |
There are two different types of IAM policies that could be attached to the role: inline and managed. Each type has a unique boto3 call to list and retrieve the policy name (inline) and ARN (managed). Inline policies use list_role_policies. Managed policies use list_attached_role_policies. The policy identifier is stored in the key PolicyNames
for inline policies, and AttachedPolicies
for managed policies.
Stored within the inline_policies variable is the list of inline policy names attached to the role, e.g. ['inline-policy', 'instance-policy']
Within the attached_policies variable is the list of PolicyNames and PolicyArns for each managed policy, e.g.[{'PolicyName': 'SecurityAudit', 'PolicyArn': 'arn:aws:iam::aws:policy/SecurityAudit'}]
Now that the policies attached to the role have been identified, the next step is to iterate through the lists and pull the policy documents.
Inline policies are retrieved with get_role_policy. The policy content is stored within the PolicyDocument
key.
Managed policies can have different versions. The default version of the policy document is retrieved with get_policy. After retrieving the policy version number, it will be specified in the get_policy_version call.
import boto3 |
profile_name = 'dev' |
role_name = 'test-role' |
session = boto3.Session(profile_name=profile_name) |
iam = session.client('iam') |
def get_policies_for_role(rolename): |
all_policies = {} |
# two possible types of policies - inline and managed |
inline_policies = iam.list_role_policies(RoleName=rolename)['PolicyNames'] |
attached_policies = iam.list_attached_role_policies(RoleName=rolename)['AttachedPolicies'] |
# Get each inline policy |
for policy in inline_policies: |
all_policies[policy] = iam.get_role_policy( |
RoleName=rolename, |
PolicyName=policy)['PolicyDocument'] |
# Get the default VersionId, then use that to retrieve that version of the policy. |
for policy in attached_policies: |
policy_version = iam.get_policy( |
PolicyArn=policy['PolicyArn'] |
)['Policy']['DefaultVersionId'] |
all_policies[policy['PolicyArn']] = iam.get_policy_version( |
PolicyArn=policy['PolicyArn'], |
VersionId=policy_version |
)['PolicyVersion']['Document'] |
return all_policies |
Finally, each policy document is added to the all_policies
dictionary and the function returns that content.
{ |
InlinePolicyName1: { |
PolicyDocument |
}, |
InlinePolicyName2: { |
PolicyDocument |
}, |
ManagedPolicyARN1: { |
PolicyDocument |
} |
} |
The example above shows the structure of the all_policies
dictionary. Policy content is stored under each policy’s name. This will make it easier to report which policy has each finding.
Now that policy documents can be retrieved, let’s create a function to run them through Parliament. At its core, Parliament has a Policy Python class. From this class, policies can be generated and findings identified. Import this class into the script, convert each policy document to the Policy class, and then invoke the analyze()
function.
from parliament import Policy |
def run_parliament(policy_json, policy_name): |
policy = Policy(policy_json) |
policy.analyze() |
When running this function, you will notice that there is no output from analyze()
. The analyze()
function stores findings in the Policy class. Formatting will be added based off of the built in Parliament function print_finding. When running this function across many policies at once, adding a field in the finding dictionary called policy_name
will identify the location of the finding.
from parliament import Policy |
def run_parliament(policy_json, policy_name): |
policy = Policy(policy_json) |
policy.analyze() |
# Assign finding details |
findings = [ |
{ |
"issue": finding.issue, |
"title": finding.title, |
"severity": finding.severity, |
"description": finding.description, |
"detail": finding.detail, |
"location": finding.location, |
"policy_name": policy_name |
} |
for finding in policy.findings |
] |
# Deduplicate the findings for easy output |
deduped_findings = [] |
for f in findings: |
if f not in deduped_findings: |
deduped_findings.append(f) |
return deduped_findings |
After creating the findings, a loop deduplicates the findings and only adds it to the new array if the finding does not already exist. Finally, the deduplicated findings are returned to be printed or parsed.
Tie this all together with a check_role
function that invokes get_policies_for_role
, runs each policy through the run_parliament function
, and prints the findings.
import json |
def check_role(rolename): |
policies = get_policies_for_role(rolename) |
# Run each finding through parliament and store the findings in a list |
all_findings = [ run_parliament(policy_json, policy_name) for policy_name,policy_json in policies.items() ] |
print(json.dumps(all_findings, indent=2)) |
Run this with check_role(rolename)
and it can now parse multiple policies on a single role, and have similar output as the CLI.
With the basic framework created for retrieving and analyzing all policy data for a role, it can be extended to detect misconfigurations that are unique to the environment and company standards. Perhaps there is a control that states IAM policies should not have wildcards in the action. Requiring policies specifically enumerate permissions in IAM policies can prevent unexpected permissions being granted to the application.
Reflecting upon ec2:*
with its 399 permissions, when AWS introduces a new EC2 permission, it would automatically be granted to the role. The role likely only needs to use a few APIs, but using a wildcard has the potential to allow the role’s permissions to grow exponentially over time.
These wildcards can be detected with a Private Auditor, a separate python file that is integrated into the existing policy analysis. The Private Auditor file can also be used in the CLI version of Parliament.
Create a file called “action_wildcard.py” and then define a function called check_for_wildcard
. Raw policy content is retrieved in json format from the Policy class with policy_json.
def check_for_wildcard(policy): |
# Make sure Statement is a list |
if type(policy.policy_json['Statement']) is dict: |
policy.policy_json['Statement'] = [ policy.policy_json['Statement'] ] |
for sid in policy.policy_json['Statement']: |
if 'Action' in sid: |
# Action should be a list for easy iteration |
if type(sid['Action']) is str: |
sid['Action'] = [ sid['Action'] ] |
# Check each action in the list if it has a wildcard, add finding if so. |
for action in sid['Action']: |
if '*' in action: |
policy.add_finding('Action_Wildcard', location={"action": action}) |
Ensure the Statement is a list of SID(s), and that the Action contained within is also a list. Iterate through the actions and detect if *
is in the action and if so, add a finding called Action_Wildcard, and denote the offending action.
Import the private auditor file and invoke check_wildcard(policy)
after analyze()
. ec2:*
has a wildcard in the action; the private auditor found it, and created a finding.
{ |
"issue": "Action_Wildcard", |
"title": "", |
"severity": "", |
"description": "", |
"detail": "", |
"location": { |
"action": "ec2:*", |
"filepath": null |
}, |
"policy_name": "arn:aws:iam::123456789012:policy/test-policy" |
} |
Wildcards on action permissions is but one example; private auditors can be extended to find other patterns that may exist in IAM policies. Detection using Parliament private auditors can fit a variety of IAM policy control requirements — explicit resource restriction, ABAC, and even which conditionals exist in a policy. If placed in an IAM deploy pipeline, Parliament could be an effective preventative control, ensuring policies with undesirable content are not created, and providing feedback to developers on how they can be fixed.
A more operationally ready version of the script, which parameterizes role name and profile, can be found here.
ScaleSec is an APN Consulting Partner guiding AWS customers through security and compliance challenges. ScaleSec’s compliance advisory and implementation services help teams leverage cloud-native services on AWS. We are hiring!
Connect with ScaleSec for AWS business.