├── tests ├── __init__.py ├── unit │ ├── __init__.py │ ├── test_resources.py │ ├── test_privilege_data.py │ ├── test_get_resources_for_privilege.py │ ├── test_community_auditors.py │ ├── test_action_expansion.py │ ├── test_principals.py │ ├── test_resource_formatting.py │ ├── test_authorization_file.py │ ├── test_patterns.py │ └── test_formatting.py └── scripts │ └── unit_tests.sh ├── parliament ├── community_auditors │ ├── __init__.py │ ├── tests │ │ ├── test_privilege_escalation.py │ │ ├── test_single_value_condition_too_permissive.py │ │ ├── test_credentials_exposure.py │ │ ├── test_permissions_management.py │ │ ├── test_sensitive_access.py │ │ └── test_advanced_policy_elements.py │ ├── sensitive_access.py │ ├── credentials_exposure.py │ ├── config_override.yaml │ ├── advanced_policy_elements.py │ ├── single_value_condition_too_permissive.py │ ├── privilege_escalation.py │ └── permissions_management.py ├── finding.py ├── misc.py ├── config.yaml ├── __init__.py ├── policy.py └── cli.py ├── Makefile ├── .coveragerc ├── .travis.yml ├── setup.cfg ├── bin └── parliament ├── .gitignore ├── requirements.txt ├── .github └── workflows │ └── publish.yml ├── LICENSE ├── setup.py ├── README.md └── utils └── update_iam_data.py /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /parliament/community_auditors/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | setup: 2 | pip install -r requirements.txt 3 | test: 4 | bash tests/scripts/unit_tests.sh -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = parliament 3 | omit = parliament/cli.py 4 | 5 | [report] 6 | fail_under = 75 -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "3.7" 4 | - "3.8" 5 | install: make setup 6 | script: make test -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [nosetests] 2 | with-coverage=1 3 | cover-erase=1 4 | cover-package=parliament 5 | cover-html=1 6 | cover-html-dir=htmlcov 7 | 8 | [aliases] 9 | test=nosetests -------------------------------------------------------------------------------- /bin/parliament: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | import os 5 | from pathlib import Path 6 | path = Path(os.path.abspath(__file__)) 7 | sys.path.append(str(path.parent.parent)) 8 | 9 | from parliament.cli import main 10 | main() -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .coverage 3 | utils/docs/ 4 | *.pyc 5 | *.egg-info 6 | .eggs 7 | venv/ 8 | .coverage 9 | htmlcov/ 10 | dist/ 11 | .env/ 12 | parliament/private_auditors 13 | tmp/* 14 | .idea/* 15 | .vscode 16 | 17 | # generated documentation folder 18 | docs/ 19 | -------------------------------------------------------------------------------- /tests/scripts/unit_tests.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | if [ -f .coverage ]; then 3 | rm .coverage 4 | fi 5 | 6 | export PRIVATE_TESTS="" 7 | if [ -d parliament/private_auditors/tests/ ]; then 8 | export PRIVATE_TESTS="parliament/private_auditors/tests/" 9 | fi 10 | 11 | export COMMUNITY_TESTS 12 | if [ -d parliament/community_auditors/tests/ ]; then 13 | export COMMUNITY_TESTS="parliament/community_auditors/tests/" 14 | fi 15 | 16 | pytest tests/unit --cov-report html --cov --cov-config=.coveragerc 17 | -------------------------------------------------------------------------------- /parliament/finding.py: -------------------------------------------------------------------------------- 1 | class Finding: 2 | """Class for storing findings""" 3 | 4 | issue = "" 5 | detail = "" 6 | location = {} 7 | severity = "" 8 | title = "" 9 | description = "" 10 | ignore_locations = {} 11 | 12 | def __init__(self, issue, detail, location): 13 | self.issue = issue 14 | self.detail = detail 15 | self.location = location 16 | 17 | def __repr__(self): 18 | """Return a string for printing""" 19 | return "{} - {} - {}".format(self.issue, self.detail, self.location) 20 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | attrs==22.1.0 2 | beautifulsoup4==4.11.1 3 | boto3==1.24.66 4 | botocore==1.27.66 5 | certifi==2024.7.4 6 | chardet==5.0.0 7 | charset-normalizer==2.1.1 8 | coverage==6.4.4 9 | docutils==0.19 10 | idna==3.7 11 | iniconfig==1.1.1 12 | jmespath==1.0.1 13 | json-cfg==0.4.2 14 | kwonly-args==1.0.10 15 | packaging==21.3 16 | pluggy==1.0.0 17 | py==1.11.0 18 | pyparsing==3.0.9 19 | pytest==7.1.3 20 | pytest-cov==3.0.0 21 | python-dateutil==2.8.2 22 | PyYAML==6.0 23 | requests==2.32.2 24 | s3transfer==0.6.0 25 | six==1.16.0 26 | soupsieve==2.3.2.post1 27 | tomli==2.0.1 28 | urllib3==1.26.19 29 | -------------------------------------------------------------------------------- /tests/unit/test_resources.py: -------------------------------------------------------------------------------- 1 | from parliament import analyze_policy_string 2 | 3 | 4 | class TestResources: 5 | """Test class for principals""" 6 | 7 | def test_resource_with_sub(self): 8 | policy = analyze_policy_string( 9 | """{ 10 | "Version":"2012-10-17", 11 | "Statement":[ 12 | { 13 | "Sid":"AddPerm", 14 | "Effect":"Allow", 15 | "Principal": "*", 16 | "Action":["ssm:PutParameter"], 17 | "Resource":[{"Fn::Sub": "arn:aws:ssm:*:${AWS::AccountId}:*"}] 18 | } 19 | ] 20 | }""" 21 | ) 22 | assert policy.finding_ids == {"INVALID_ARN"} 23 | -------------------------------------------------------------------------------- /parliament/misc.py: -------------------------------------------------------------------------------- 1 | import jsoncfg 2 | 3 | 4 | def make_list(v): 5 | """ 6 | If the object is not a list already, it converts it to one 7 | Examples: 8 | [1, 2, 3] -> [1, 2, 3] 9 | [1] -> [1] 10 | 1 -> [1] 11 | """ 12 | if not jsoncfg.node_is_array(v): 13 | if jsoncfg.node_is_scalar(v): 14 | location = jsoncfg.node_location(v) 15 | line = location.line 16 | column = location.column 17 | elif jsoncfg.node_exists(v): 18 | line = v.line 19 | column = v.column 20 | else: 21 | return [] 22 | 23 | a = jsoncfg.config_classes.ConfigJSONArray(line, column) 24 | a._append(v) 25 | return a 26 | return v 27 | 28 | 29 | class ACCESS_DECISION: 30 | IMPLICIT_DENY = 0 31 | EXPLICIT_DENY = 1 32 | EXPLICIT_ALLOW = 2 33 | -------------------------------------------------------------------------------- /parliament/community_auditors/tests/test_privilege_escalation.py: -------------------------------------------------------------------------------- 1 | from parliament import analyze_policy_string 2 | 3 | 4 | class TestPrivilegeEscalation: 5 | """Test class for Privilege Escalation auditor""" 6 | 7 | def test_privilege_escalation(self): 8 | example_policy_string = """ 9 | { 10 | "Version": "2012-10-17", 11 | "Statement": [ 12 | { 13 | "Effect": "Allow", 14 | "Action": [ 15 | "glue:updatedevendpoint", 16 | "lambda:updatefunctioncode" 17 | ], 18 | "Resource": "*" 19 | } 20 | ] 21 | } 22 | """ 23 | policy = analyze_policy_string( 24 | example_policy_string, include_community_auditors=True 25 | ) 26 | assert_equal(policy.finding_ids, set(["PRIVILEGE_ESCALATION", "RESOURCE_STAR"])) 27 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Parliament to PyPI 5 | 6 | on: 7 | release: 8 | types: [created] 9 | workflow_dispatch: 10 | 11 | jobs: 12 | deploy: 13 | 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | - name: Set up Python 19 | uses: actions/setup-python@v1 20 | with: 21 | python-version: '3.x' 22 | - name: Install dependencies 23 | run: | 24 | python -m pip install --upgrade pip 25 | pip install setuptools wheel twine 26 | - name: Build and publish 27 | env: 28 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 29 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 30 | run: | 31 | python setup.py sdist bdist_wheel 32 | twine upload dist/* 33 | -------------------------------------------------------------------------------- /parliament/community_auditors/tests/test_single_value_condition_too_permissive.py: -------------------------------------------------------------------------------- 1 | from parliament import analyze_policy_string 2 | 3 | 4 | class TestSensitiveAccess: 5 | """Test class for single value condition too permissive auditor""" 6 | 7 | example_policy_string = """ 8 | { 9 | "Version": "2012-10-17", 10 | "Statement": [ 11 | { 12 | "Effect": "Allow", 13 | "Action": [ 14 | "s3:GetObject" 15 | ], 16 | "Resource": "arn:aws:s3:::secretbucket/*", 17 | "Condition": { 18 | "ForAllValues:StringEquals": { 19 | "aws:ResourceTag/Tag": [ 20 | "Value" 21 | ] 22 | 23 | } 24 | } 25 | } 26 | ] 27 | } 28 | """ 29 | policy = analyze_policy_string( 30 | example_policy_string, include_community_auditors=True 31 | ) 32 | assert_equal(policy.finding_ids, set(["SINGLE_VALUE_CONDITION_TOO_PERMISSIVE"])) 33 | -------------------------------------------------------------------------------- /parliament/community_auditors/tests/test_credentials_exposure.py: -------------------------------------------------------------------------------- 1 | from parliament import analyze_policy_string 2 | 3 | 4 | class TestCredentialsManagement: 5 | """Test class for Credentials Management auditor""" 6 | 7 | def test_credentials_management(self): 8 | example_policy_string = """ 9 | { 10 | "Version": "2012-10-17", 11 | "Statement": [ 12 | { 13 | "Effect": "Allow", 14 | "Action": [ 15 | "redshift:getclustercredentials", 16 | "ecr:getauthorizationtoken" 17 | ], 18 | "Resource": "*" 19 | } 20 | ] 21 | } 22 | """ 23 | policy = analyze_policy_string( 24 | example_policy_string, include_community_auditors=True 25 | ) 26 | 27 | assert_equal( 28 | policy.finding_ids, 29 | set( 30 | [ 31 | "CREDENTIALS_EXPOSURE", 32 | "PERMISSIONS_MANAGEMENT_ACTIONS", 33 | "RESOURCE_STAR", 34 | ] 35 | ), 36 | ) 37 | -------------------------------------------------------------------------------- /parliament/community_auditors/tests/test_permissions_management.py: -------------------------------------------------------------------------------- 1 | from parliament import analyze_policy_string 2 | 3 | 4 | class TestPermissionsManagement: 5 | """Test class for Permissions Management auditor""" 6 | 7 | def test_permissions_management(self): 8 | example_policy_string = """ 9 | { 10 | "Version": "2012-10-17", 11 | "Statement": [ 12 | { 13 | "Effect": "Allow", 14 | "Action": [ 15 | "lambda:addpermission", 16 | "s3:putbucketacl", 17 | "ram:CreateResourceShare" 18 | ], 19 | "Resource": "*" 20 | } 21 | ] 22 | } 23 | """ 24 | policy = analyze_policy_string( 25 | example_policy_string, include_community_auditors=True 26 | ) 27 | 28 | assert_equal( 29 | policy.finding_ids, 30 | set( 31 | [ 32 | "PERMISSIONS_MANAGEMENT_ACTIONS", 33 | "RESOURCE_POLICY_PRIVILEGE_ESCALATION", 34 | "RESOURCE_STAR", 35 | ] 36 | ), 37 | ) 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019 Duo Security 2 | 3 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 4 | 5 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 6 | 7 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 8 | 9 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 10 | 11 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /parliament/community_auditors/sensitive_access.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | 3 | from parliament import is_arn_match, expand_action 4 | 5 | 6 | def _expand_action(operation): 7 | data = expand_action(operation)[0] 8 | 9 | return "{}:{}".format(data["service"], data["action"]) 10 | 11 | 12 | def audit(policy): 13 | allowed_actions = policy.get_allowed_actions() 14 | 15 | try: 16 | config_resources = policy.config["SENSITIVE_ACCESS"]["resources"] 17 | except KeyError: 18 | config_resources = {} 19 | 20 | sensitive_resources = defaultdict(list) 21 | for item in config_resources: 22 | action = list(item.keys())[0] 23 | expanded_action = _expand_action(action) 24 | resources = list(item.values())[0] 25 | 26 | sensitive_resources[expanded_action].extend(resources) 27 | 28 | action_resources = {} 29 | for action in allowed_actions: 30 | expanded_action = _expand_action(action) 31 | service, operation = expanded_action.split(":") 32 | action_resources[expanded_action] = policy.get_allowed_resources( 33 | service, operation 34 | ) 35 | 36 | for action in action_resources: 37 | for action_resource in action_resources[action]: 38 | for sensitive_resource in sensitive_resources[action]: 39 | if is_arn_match("object", action_resource, sensitive_resource): 40 | policy.add_finding( 41 | "SENSITIVE_ACCESS", 42 | location={"resource": action_resource, "actions": action}, 43 | ) 44 | -------------------------------------------------------------------------------- /parliament/community_auditors/credentials_exposure.py: -------------------------------------------------------------------------------- 1 | # https://gist.github.com/kmcquade/33860a617e651104d243c324ddf7992a 2 | CREDENTIALS_EXPOSURE_ACTIONS = [ 3 | "codepipeline:pollforjobs", 4 | "cognito-idp:associatesoftwaretoken", 5 | "cognito-identity:getopenidtoken", 6 | "cognito-identity:getopenidtokenfordeveloperidentity", 7 | "cognito-identity:getcredentialsforidentity", 8 | "connect:getfederationtoken", 9 | "connect:getfederationtokens", 10 | "ecr:getauthorizationtoken", 11 | "gamelift:requestuploadcredentials", 12 | "iam:createaccesskey", 13 | "iam:createloginprofile", 14 | "iam:createservicespecificcredential", 15 | "iam:resetservicespecificcredential", 16 | "iam:updateaccesskey", 17 | "iot:assumerolewithcertificate", 18 | "lightsail:getinstanceaccessdetails", 19 | "lightsail:getrelationaldatabasemasteruserpassword", 20 | "rds-db:connect", 21 | "redshift:getclustercredentials", 22 | "sso:getrolecredentials", 23 | "mediapackage:rotateingestendpointcredentials", 24 | "sts:assumerole", 25 | "sts:assumerolewithsaml", 26 | "sts:assumerolewithwebidentity", 27 | "sts:getfederationtoken", 28 | "sts:getsessiontoken", 29 | "cognito-idp:describeuserpoolclient", 30 | ] 31 | 32 | 33 | def audit(policy): 34 | actions = policy.get_allowed_actions() 35 | 36 | credentials_exposure_actions_in_policy = [] 37 | for action in actions: 38 | if action in CREDENTIALS_EXPOSURE_ACTIONS: 39 | credentials_exposure_actions_in_policy.append(action) 40 | if len(credentials_exposure_actions_in_policy) > 0: 41 | policy.add_finding( 42 | "CREDENTIALS_EXPOSURE", 43 | location={"actions": credentials_exposure_actions_in_policy}, 44 | ) 45 | -------------------------------------------------------------------------------- /parliament/community_auditors/config_override.yaml: -------------------------------------------------------------------------------- 1 | PERMISSIONS_MANAGEMENT_ACTIONS: 2 | title: Permissions management actions 3 | description: Allows the principal to modify IAM, RAM, identity-based policies, or resource based policies. 4 | severity: MEDIUM 5 | group: CUSTOM 6 | 7 | PRIVILEGE_ESCALATION: 8 | title: Privilege escalation 9 | description: Actions contain a combination of Privilege Escalation actions established by Rhino Security Labs 10 | severity: HIGH 11 | group: CUSTOM 12 | 13 | CREDENTIALS_EXPOSURE: 14 | title: Credentials exposure 15 | description: Policy grants access to API calls that can return credentials to the user 16 | severity: MEDIUM 17 | group: CUSTOM 18 | 19 | SENSITIVE_ACCESS: 20 | title: Sensitive resource access check 21 | description: Policy contains sensitive operation and resource access 22 | severity: HIGH 23 | group: CUSTOM 24 | # Resource definitions have to be in the format: 25 | # - 'service:action': [afn1, arn2] 26 | resources: [] 27 | 28 | NOTPRINCIPAL_WITH_ALLOW: 29 | title: NotPrincipal used with Allow effect 30 | description: NotPrincipal with Allow automatically grants the permission to all principals, except the ones specified. 31 | severity: MEDIUM 32 | group: CUSTOM 33 | 34 | NOTRESOURCE_WITH_ALLOW: 35 | title: NotResource used with Allow effect 36 | description: NotResource with Allow automatically grants the Principal all services and resources that are not explicitly listed 37 | severity: MEDIUM 38 | group: CUSTOM 39 | 40 | SINGLE_VALUE_CONDITION_TOO_PERMISSIVE: 41 | title: A single value conditional is checked against a set of values 42 | description: Checking a single value conditional key against a set of values results in overly permissive policies. 43 | severity: MEDIUM 44 | group: CUSTOM 45 | -------------------------------------------------------------------------------- /tests/unit/test_privilege_data.py: -------------------------------------------------------------------------------- 1 | import parliament 2 | 3 | 4 | class TestPrivilegData: 5 | """Test class for the parliament/iam_definition.json file""" 6 | 7 | def test_minimum_number_of_services(self): 8 | assert ( 9 | len(parliament.iam_definition) > 220 10 | ), "There should be over 220 services in the definition file" 11 | 12 | def test_contains_all_elements(self): 13 | # Find the ec2 service 14 | ec2_service = None 15 | for service in parliament.iam_definition: 16 | if service["prefix"] == "ec2": 17 | ec2_service = service 18 | break 19 | assert ec2_service is not None 20 | 21 | assert ec2_service["service_name"] == "Amazon EC2" 22 | assert ( 23 | len(ec2_service["resources"]) > 30 24 | ), "There should be over 30 resources in the EC2 service" 25 | 26 | vpc_resource = None 27 | for resource in ec2_service["resources"]: 28 | if resource["resource"] == "vpc": 29 | vpc_resource = resource 30 | break 31 | assert vpc_resource is not None 32 | 33 | assert ( 34 | "vpc" in vpc_resource["arn"] 35 | ), "The arn for the vpc resource should contain the string 'vpc'" 36 | assert ( 37 | len(vpc_resource["condition_keys"]) >= 5 38 | ), "There should be at least 5 condition_keys in the vpc resource" 39 | assert len(ec2_service["resources"]) >= 32 40 | 41 | vpc_condition = None 42 | for condition in ec2_service["conditions"]: 43 | if condition["condition"] == "ec2:Vpc": 44 | vpc_condition = condition 45 | break 46 | assert vpc_condition is not None 47 | 48 | assert vpc_condition["type"] == "ARN" 49 | assert len(ec2_service["conditions"]) >= 59 50 | assert len(ec2_service["privileges"]) >= 363 51 | -------------------------------------------------------------------------------- /parliament/community_auditors/advanced_policy_elements.py: -------------------------------------------------------------------------------- 1 | """ 2 | For AWS resource policies, check whether they use discouraged constructions. 3 | See the [AWS Policy Troubleshooting Guide](https://docs.aws.amazon.com/IAM/latest/UserGuide/troubleshoot_policies.html). 4 | 5 | AWS documentation discourages the use of NotPrincipal, NotAction and 6 | NotResource, particularly with Allow. These constructs, by default, grant 7 | permissions, then Deny the ones explicitly listed. Instead, use an explicit 8 | Resource, Action or Principal in your Allow list. 9 | """ 10 | 11 | from typing import Iterable 12 | 13 | import jsoncfg 14 | 15 | from parliament import Policy 16 | 17 | 18 | def get_stmts(policy: Policy) -> Iterable: 19 | if "jsoncfg.config_classes.ConfigJSONObject" in str( 20 | type(policy.policy_json.Statement) 21 | ): 22 | return [policy.policy_json.Statement] 23 | elif "jsoncfg.config_classes.ConfigJSONArray" in str( 24 | type(policy.policy_json.Statement) 25 | ): 26 | return policy.policy_json.Statement 27 | 28 | 29 | def audit(policy: Policy) -> None: 30 | for stmt in get_stmts(policy): 31 | if stmt.Effect.value == "Allow" and jsoncfg.node_exists(stmt["NotPrincipal"]): 32 | # See https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_notprincipal.html#specifying-notprincipal-allow 33 | policy.add_finding( 34 | "NOTPRINCIPAL_WITH_ALLOW", 35 | location=("NotPrincipal", stmt["NotPrincipal"]), 36 | ) 37 | elif stmt.Effect.value == "Allow" and jsoncfg.node_exists(stmt["NotResource"]): 38 | # See https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_notresource.html#notresource-element-combinations 39 | policy.add_finding( 40 | "NOTRESOURCE_WITH_ALLOW", 41 | location=("NotResource", stmt["NotResource"]), 42 | ) 43 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """Setup script for parliament""" 2 | import os 3 | import re 4 | 5 | from setuptools import find_packages, setup 6 | 7 | 8 | HERE = os.path.dirname(__file__) 9 | VERSION_RE = re.compile(r"""__version__ = ['"]([0-9.]+)['"]""") 10 | TESTS_REQUIRE = ["coverage", "nose"] 11 | 12 | 13 | def get_version(): 14 | init = open(os.path.join(HERE, "parliament", "__init__.py")).read() 15 | return VERSION_RE.search(init).group(1) 16 | 17 | 18 | def get_description(): 19 | return open( 20 | os.path.join(os.path.abspath(HERE), "README.md"), encoding="utf-8" 21 | ).read() 22 | 23 | 24 | setup( 25 | name="parliament", 26 | version=get_version(), 27 | author="Duo Security", 28 | author_email="scott@summitroute.com", 29 | description=("parliament audits your AWS IAM policies"), 30 | long_description=get_description(), 31 | long_description_content_type="text/markdown", 32 | url="https://github.com/duo-labs/parliament", 33 | entry_points={"console_scripts": "parliament=parliament.cli:cli"}, 34 | test_suite="tests/unit", 35 | tests_require=TESTS_REQUIRE, 36 | extras_require={"dev": TESTS_REQUIRE + ["autoflake", "autopep8", "pylint"]}, 37 | install_requires=["boto3", "jmespath", "pyyaml", "json-cfg"], 38 | setup_requires=["nose"], 39 | packages=find_packages(exclude=["tests*"]), 40 | package_data={ 41 | "parliament": ["iam_definition.json", "config.yaml"], 42 | "parliament.community_auditors": ["config_override.yaml"], 43 | }, 44 | zip_safe=True, 45 | license="BSD 3", 46 | keywords="aws parliament iam lint audit", 47 | python_requires=">=3.6", 48 | classifiers=[ 49 | "License :: OSI Approved :: BSD License", 50 | "Programming Language :: Python :: 3", 51 | "Programming Language :: Python :: 3.6", 52 | "Programming Language :: Python :: 3.7", 53 | "Programming Language :: Python :: 3.8", 54 | "Programming Language :: Python :: 3 :: Only", 55 | "Development Status :: 5 - Production/Stable", 56 | ], 57 | ) 58 | -------------------------------------------------------------------------------- /tests/unit/test_get_resources_for_privilege.py: -------------------------------------------------------------------------------- 1 | from parliament import analyze_policy_string 2 | 3 | 4 | class TestGetResourcesForPrivilege: 5 | """Test class for get_resources_for_privilege""" 6 | 7 | def test_policy_simple(self): 8 | policy = analyze_policy_string( 9 | """{ 10 | "Version":"2012-10-17", 11 | "Statement":[ 12 | { 13 | "Effect":"Allow", 14 | "Action":["s3:GetObject"], 15 | "Resource":["arn:aws:s3:::examplebucket/*"] 16 | } 17 | ] 18 | }""" 19 | ) 20 | 21 | assert ( 22 | set(policy.statements[0].get_resources_for_privilege("s3", "GetObject")) 23 | == set(["arn:aws:s3:::examplebucket/*"]), 24 | "s3:GetObject matches the object resource", 25 | ) 26 | 27 | assert ( 28 | set(policy.statements[0].get_resources_for_privilege("s3", "PutObject")) 29 | == set([]), 30 | "s3:PutObject not in policy", 31 | ) 32 | 33 | def test_policy_multiple_resources(self): 34 | policy = analyze_policy_string( 35 | """{ 36 | "Version":"2012-10-17", 37 | "Statement":[ 38 | { 39 | "Effect":"Allow", 40 | "Action": "s3:*", 41 | "Resource":["arn:aws:s3:::examplebucket", "arn:aws:s3:::examplebucket/*"] 42 | } 43 | ] 44 | }""" 45 | ) 46 | 47 | assert ( 48 | set(policy.statements[0].get_resources_for_privilege("s3", "GetObject")) 49 | == set(["arn:aws:s3:::examplebucket/*"]), 50 | "s3:GetObject matches the object resource", 51 | ) 52 | 53 | # s3:PutBucketPolicy will match on both because a bucket resource type is defined as: 54 | # "arn:*:s3:::*" so it doesn't care whether or not there is a slash 55 | # assert_equal(set(policy.statements[0].get_resources_for_privilege("s3", "PutBucketPolicy")), set(["arn:aws:s3:::examplebucket"]), "s3:PutBucketPolicy matches the bucket resource") 56 | 57 | assert ( 58 | set( 59 | policy.statements[0].get_resources_for_privilege( 60 | "s3", "ListAllMyBuckets" 61 | ) 62 | ) 63 | == set([]), 64 | "s3:ListAllMyBuckets matches none of the resources", 65 | ) 66 | -------------------------------------------------------------------------------- /parliament/community_auditors/single_value_condition_too_permissive.py: -------------------------------------------------------------------------------- 1 | """ 2 | For AWS policies using conditionals, checking a single valued condition key with a check 3 | designed for multi-value condition keys results in "overly permissive policies" 4 | https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_single-vs-multi-valued-condition-keys.html 5 | """ 6 | import re 7 | from parliament import Policy 8 | from parliament.misc import make_list 9 | 10 | 11 | def audit(policy: Policy) -> None: 12 | global_single_valued_condition_keys = [ 13 | "aws:CalledViaFirst", 14 | "aws:CalledViaLast", 15 | "aws:CurrentTime", 16 | "aws:EpochTime", 17 | "aws:FederatedProvider", 18 | "aws:MultiFactorAuthAge", 19 | "aws:MultiFactorAuthPresent", 20 | "aws:PrincipalAccount", 21 | "aws:PrincipalArn", 22 | "aws:PrincipalIsAWSService", 23 | "aws:PrincipalOrgID", 24 | "aws:PrincipalServiceName", 25 | "aws:PrincipalTag", 26 | "aws:PrincipalType", 27 | "aws:referer", 28 | "aws:RequestedRegion", 29 | "aws:RequestTag/*", 30 | "aws:ResourceTag/*", 31 | "aws:SecureTransport", 32 | "aws:SourceAccount", 33 | "aws:SourceArn", 34 | "aws:SourceIdentity", 35 | "aws:SourceIp", 36 | "aws:SourceVpc", 37 | "aws:SourceVpce", 38 | "aws:TokenIssueTime", 39 | "aws:UserAgent", 40 | "aws:userid", 41 | "aws:username", 42 | "aws:ViaAWSService", 43 | "aws:VpcSourceIp", 44 | ] 45 | 46 | for stmt in policy.statements: 47 | if "Condition" not in stmt.stmt: 48 | return 49 | 50 | conditions = stmt.stmt["Condition"] 51 | for condition in conditions: 52 | # The operator is the first element (ex. `StringLike`) and the condition_block follows it 53 | operator = condition[0] 54 | condition_block = condition[1] 55 | if re.match(r"^For(All|Any)Values:", operator): 56 | keys = list(k for k, _v in condition_block) 57 | if any( 58 | any(re.match(k, key) for k in global_single_valued_condition_keys) 59 | for key in keys 60 | ): 61 | policy.add_finding( 62 | "SINGLE_VALUE_CONDITION_TOO_PERMISSIVE", 63 | detail="Checking a single value conditional key against a set of values results in overly permissive policies.", 64 | ) 65 | -------------------------------------------------------------------------------- /tests/unit/test_community_auditors.py: -------------------------------------------------------------------------------- 1 | from parliament import analyze_policy_string 2 | 3 | 4 | class TestCommunityAuditors: 5 | """Test class for importing/enabling/disabling community auditors properly""" 6 | 7 | def test_analyze_policy_string_enable_community(self): 8 | """Enable community auditors with the policy string.""" 9 | example_policy_with_wildcards = """{ 10 | "Version": "2012-10-17", 11 | "Statement": [ 12 | { 13 | "Effect": "Allow", 14 | "Action": [ 15 | "ecr:*", 16 | "s3:*" 17 | ], 18 | "Resource": "*" 19 | } 20 | ] 21 | } 22 | """ 23 | policy = analyze_policy_string( 24 | example_policy_with_wildcards, include_community_auditors=True 25 | ) 26 | """ 27 | The resulting findings will look like this: 28 | MEDIUM - Credentials exposure - Policy grants access to API calls that can return credentials to the user - - {'actions': ['ecr:getauthorizationtoken'], 'filepath': 'wildcards.json'} 29 | MEDIUM - Permissions management actions - Allows the principal to modify IAM, RAM, identity-based policies, or resource based policies. - - {'actions': ['ecr:setrepositorypolicy', 's3:bypassgovernanceretention', 's3:deleteaccesspointpolicy', 's3:deletebucketpolicy', 's3:objectowneroverridetobucketowner', 's3:putaccesspointpolicy', 's3:putaccountpublicaccessblock', 's3:putbucketacl', 's3:putbucketpolicy', 's3:putbucketpublicaccessblock', 's3:putobjectacl', 's3:putobjectversionacl'], 'filepath': 'wildcards.json'} 30 | 31 | We are just not including the full results here because the Permissions management actions might expand as AWS expands their API. We don't want to have to update the unit tests every time that happens. 32 | """ 33 | assert ( 34 | policy.finding_ids 35 | == set( 36 | [ 37 | "RESOURCE_STAR", 38 | "CREDENTIALS_EXPOSURE", 39 | "PERMISSIONS_MANAGEMENT_ACTIONS", 40 | ] 41 | ), 42 | ) 43 | 44 | def test_analyze_policy_string_disable_community(self): 45 | """Disable community auditors with the policy string.""" 46 | example_policy_with_wildcards = """{ 47 | "Version": "2012-10-17", 48 | "Statement": [ 49 | { 50 | "Effect": "Allow", 51 | "Action": [ 52 | "ecr:*", 53 | "s3:*" 54 | ], 55 | "Resource": "*" 56 | } 57 | ] 58 | } 59 | """ 60 | policy = analyze_policy_string( 61 | example_policy_with_wildcards, include_community_auditors=False 62 | ) 63 | 64 | assert policy.finding_ids == set(["RESOURCE_STAR"]) 65 | -------------------------------------------------------------------------------- /parliament/community_auditors/tests/test_sensitive_access.py: -------------------------------------------------------------------------------- 1 | from parliament import analyze_policy_string 2 | 3 | 4 | class TestSensitiveAccess: 5 | """Test class for Sensitive access auditor""" 6 | 7 | def test_sensitive_access(self): 8 | example_policy_string = """ 9 | { 10 | "Version": "2012-10-17", 11 | "Statement": [ 12 | { 13 | "Effect": "Allow", 14 | "Action": [ 15 | "s3:GetObject" 16 | ], 17 | "Resource": "arn:aws:s3:::secretbucket/*" 18 | }, 19 | { 20 | "Effect": "Allow", 21 | "Action": [ 22 | "s3:PutObject" 23 | ], 24 | "Resource": "arn:aws:s3:::otherbucket/*" 25 | } 26 | ] 27 | } 28 | """ 29 | config = { 30 | "SENSITIVE_ACCESS": { 31 | "resources": [{"s3:GetObject": ["arn:aws:s3:::secret*"]}] 32 | } 33 | } 34 | policy = analyze_policy_string( 35 | example_policy_string, include_community_auditors=True, config=config 36 | ) 37 | assert_equal(policy.finding_ids, set(["SENSITIVE_ACCESS"])) 38 | 39 | # Ensure nothing triggers when we change the bucket location 40 | config = { 41 | "SENSITIVE_ACCESS": { 42 | "resources": [{"s3:GetObject": ["arn:aws:s3:::otherbucket*"]}] 43 | } 44 | } 45 | policy = analyze_policy_string( 46 | example_policy_string, include_community_auditors=True, config=config 47 | ) 48 | assert_equal(policy.finding_ids, set([])) 49 | 50 | # Ensure we can test multiple actions 51 | config = { 52 | "SENSITIVE_ACCESS": { 53 | "resources": [ 54 | {"iam:CreateUser": ["*"]}, 55 | { 56 | "s3:GetObject": [ 57 | "arn:aws:s3:::otherbucket*", 58 | "arn:aws:s3:::secret*", 59 | ] 60 | }, 61 | {"s3:PutObject": ["arn:aws:s3:::secret*"]}, 62 | ] 63 | } 64 | } 65 | policy = analyze_policy_string( 66 | example_policy_string, include_community_auditors=True, config=config 67 | ) 68 | assert_equal(policy.finding_ids, set(["SENSITIVE_ACCESS"])) 69 | 70 | # Ensure multiple actions with none matching works 71 | config = { 72 | "SENSITIVE_ACCESS": { 73 | "resources": [ 74 | {"iam:CreateUser": ["*"]}, 75 | {"s3:GetObject": ["arn:aws:s3:::otherbucket*"]}, 76 | ] 77 | } 78 | } 79 | policy = analyze_policy_string( 80 | example_policy_string, include_community_auditors=True, config=config 81 | ) 82 | assert_equal(policy.finding_ids, set([])) 83 | -------------------------------------------------------------------------------- /parliament/community_auditors/privilege_escalation.py: -------------------------------------------------------------------------------- 1 | def audit(policy): 2 | actions = policy.get_allowed_actions() 3 | permissions_on_other_users(policy, actions) 4 | 5 | 6 | # Categories based on https://know.bishopfox.com/blog/5-privesc-attack-vectors-in-aws 7 | 8 | 9 | def permissions_on_other_users(policy, expanded_actions): 10 | # Turn into lowercase 11 | expanded_actions_normalized = [x.lower() for x in expanded_actions] 12 | expanded_actions = set(expanded_actions_normalized) 13 | 14 | escalation_methods = { 15 | # 1. IAM Permissions on Other Users 16 | "CreateAccessKey": ["iam:createaccesskey"], 17 | "CreateLoginProfile": ["iam:createloginprofile"], 18 | "UpdateLoginProfile": ["iam:updateloginprofile"], 19 | # 2. Permissions on Policies 20 | "CreateNewPolicyVersion": ["iam:createpolicyversion"], 21 | "SetExistingDefaultPolicyVersion": ["iam:setdefaultpolicyversion"], 22 | "AttachUserPolicy": ["iam:attachuserpolicy"], 23 | "AttachGroupPolicy": ["iam:attachgrouppolicy"], 24 | "AttachRolePolicy": ["iam:attachrolepolicy", "sts:assumerole"], 25 | "PutUserPolicy": ["iam:putuserpolicy"], 26 | "PutGroupPolicy": ["iam:putgrouppolicy"], 27 | "PutRolePolicy": ["iam:putrolepolicy", "sts:assumerole"], 28 | "AddUserToGroup": ["iam:addusertogroup"], 29 | # 3. Updating an AssumeRolePolicy 30 | "UpdateRolePolicyToAssumeIt": ["iam:updateassumerolepolicy", "sts:assumerole"], 31 | # 4. iam:PassRole:* 32 | "CreateEC2WithExistingIP": ["iam:passrole", "ec2:runinstances"], 33 | "PassExistingRoleToNewLambdaThenInvoke": [ 34 | "iam:passrole", 35 | "lambda:createfunction", 36 | "lambda:invokefunction", 37 | ], 38 | "PassExistingRoleToNewLambdaThenTriggerWithNewDynamo": [ 39 | "iam:passrole", 40 | "lambda:createfunction", 41 | "lambda:createeventsourcemapping", 42 | "dynamodb:createtable", 43 | "dynamodb:putitem", 44 | ], 45 | "PassExistingRoleToNewLambdaThenTriggerWithExistingDynamo": [ 46 | "iam:passrole", 47 | "lambda:createfunction", 48 | "lambda:createeventsourcemapping", 49 | ], 50 | "PassExistingRoleToNewGlueDevEndpoint": [ 51 | "iam:passrole", 52 | "glue:createdevendpoint", 53 | ], 54 | "PassExistingRoleToCloudFormation": [ 55 | "iam:passrole", 56 | "cloudformation:createstack", 57 | ], 58 | "PassExistingRoleToNewDataPipeline": [ 59 | "iam:passrole", 60 | "datapipeline:createpipeline", 61 | ], 62 | # 5. Privilege Escalation Using AWS Services 63 | "UpdateExistingGlueDevEndpoint": ["glue:updatedevendpoint"], 64 | "EditExistingLambdaFunctionWithRole": ["lambda:updatefunctioncode"], 65 | } 66 | 67 | for key in escalation_methods: 68 | if set(escalation_methods[key]).issubset(expanded_actions): 69 | policy.add_finding( 70 | "PRIVILEGE_ESCALATION", 71 | location={"type": key, "actions": escalation_methods[key]}, 72 | ) 73 | -------------------------------------------------------------------------------- /tests/unit/test_action_expansion.py: -------------------------------------------------------------------------------- 1 | import parliament 2 | 3 | from parliament import UnknownPrefixException, UnknownActionException 4 | from parliament.statement import expand_action 5 | 6 | 7 | class TestActionExpansion: 8 | """Test class for expand_action function""" 9 | 10 | def test_expand_action_no_expansion(self): 11 | expanded_actions = expand_action("s3:listallmybuckets") 12 | assert len(expanded_actions) == len( 13 | [{"service": "s3", "action": "ListAllMyBuckets"}] 14 | ) 15 | 16 | def test_expand_action_with_expansion(self): 17 | expanded_actions = expand_action("s3:listallmybucke*") 18 | assert len(expanded_actions) == len( 19 | [{"service": "s3", "action": "ListAllMyBuckets"}] 20 | ) 21 | 22 | def test_expand_action_with_casing(self): 23 | expanded_actions = expand_action("iAm:li*sTuS*rs") 24 | assert len(expanded_actions) == len([{"service": "iam", "action": "ListUsers"}]) 25 | 26 | def test_expand_action_with_expansion_for_prefix_used_multiple_times(self): 27 | expanded_actions = expand_action("ses:Describe*") 28 | assert len(expanded_actions) == len( 29 | [ 30 | {"service": "ses", "action": "DescribeActiveReceiptRuleSet"}, 31 | {"service": "ses", "action": "DescribeConfigurationSet"}, 32 | {"service": "ses", "action": "DescribeReceiptRule"}, 33 | {"service": "ses", "action": "DescribeReceiptRuleSet"}, 34 | ] 35 | ) 36 | 37 | def test_expand_action_with_permission_only_action(self): 38 | # There are 17 privileges list as "logs.CreateLogDelivery [permission only]" 39 | expanded_actions = expand_action("logs:GetLogDelivery") 40 | assert len(expanded_actions) == len( 41 | [{"service": "logs", "action": "GetLogDelivery"}] 42 | ) 43 | 44 | def test_exception_malformed(self): 45 | try: 46 | expand_action("malformed") 47 | assert False 48 | except ValueError as e: 49 | assert True 50 | 51 | def test_exception_bad_service(self): 52 | try: 53 | expand_action("333:listallmybuckets") 54 | assert False, "333 is not a valid prefix" 55 | except UnknownPrefixException as e: 56 | assert True 57 | 58 | def test_exception_bad_action(self): 59 | try: 60 | expand_action("s3:zzz") 61 | assert False, "s3:zzz is not a valid action" 62 | except UnknownActionException as e: 63 | assert True 64 | 65 | def test_exception_bad_expansion(self): 66 | try: 67 | expand_action("s3:zzz*") 68 | assert False, "No expansion is possible from s3:zzz*" 69 | except UnknownActionException as e: 70 | assert True 71 | 72 | def test_expand_all(self): 73 | assert len(expand_action("*")) > 5000 74 | assert len(expand_action("*:*")) > 5000 75 | 76 | def test_expand_iq(self): 77 | expand_action("iq:*") 78 | assert True 79 | 80 | try: 81 | expand_action("iq:dostuff") 82 | assert False, "iq:dostuff is invalid" 83 | except UnknownActionException as e: 84 | assert True 85 | -------------------------------------------------------------------------------- /parliament/community_auditors/tests/test_advanced_policy_elements.py: -------------------------------------------------------------------------------- 1 | from parliament import analyze_policy_string 2 | 3 | S3_STAR_FINDINGS = {"PERMISSIONS_MANAGEMENT_ACTIONS", "RESOURCE_MISMATCH"} 4 | 5 | 6 | class TestAdvancedPolicyElements: 7 | def test_notresource_allow(self): 8 | # NotResource is OK with Effect: Deny. This denies access to 9 | # all S3 buckets except Payroll buckets. This example is taken from 10 | # https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_notresource.html 11 | policystr = """{ 12 | "Version": "2012-10-17", 13 | "Statement": { 14 | "Effect": "Deny", 15 | "Action": "s3:*", 16 | "NotResource": [ 17 | "arn:aws:s3:::HRBucket/Payroll", 18 | "arn:aws:s3:::HRBucket/Payroll/*" 19 | ] 20 | } 21 | }""" 22 | 23 | policy = analyze_policy_string(policystr, include_community_auditors=True) 24 | assert_equal(policy.finding_ids, set()) 25 | 26 | # According to AWS documentation, "This statement is very dangerous, 27 | # because it allows all actions in AWS on all resources except the 28 | # HRBucket S3 bucket." See: 29 | # https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_notresource.html#notresource-element-combinations 30 | policystr = """{ 31 | "Version": "2012-10-17", 32 | "Statement": { 33 | "Effect": "Allow", 34 | "Action": "s3:*", 35 | "NotResource": [ 36 | "arn:aws:s3:::HRBucket/Payroll", 37 | "arn:aws:s3:::HRBucket/Payroll/*" 38 | ] 39 | } 40 | }""" 41 | 42 | policy = analyze_policy_string(policystr, include_community_auditors=True) 43 | 44 | assert_equal(policy.finding_ids, S3_STAR_FINDINGS | {"NOTRESOURCE_WITH_ALLOW"}) 45 | 46 | def test_notprincipal_allow(self): 47 | # NotPrincipal is OK with Effect: Deny. This explcitly omits these 48 | # users from the list of Principals denied access to this resource 49 | # This example is taken from https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_notprincipal.html#specifying-notprincipal 50 | policystr = """{ 51 | "Version": "2012-10-17", 52 | "Statement": [{ 53 | "Effect": "Deny", 54 | "NotPrincipal": {"AWS": [ 55 | "arn:aws:iam::444455556666:user/Bob", 56 | "arn:aws:iam::444455556666:root" 57 | ]}, 58 | "Action": "s3:*", 59 | "Resource": [ 60 | "arn:aws:s3:::BUCKETNAME", 61 | "arn:aws:s3:::BUCKETNAME/*" 62 | ] 63 | }] 64 | }""" 65 | 66 | policy = analyze_policy_string(policystr, include_community_auditors=True) 67 | 68 | assert_equal(policy.finding_ids, set()) 69 | 70 | # This implicitly allows everyone _except_ Bob to access BUCKETNAME! 71 | policystr = """{ 72 | "Version": "2012-10-17", 73 | "Statement": [{ 74 | "Effect": "Allow", 75 | "NotPrincipal": {"AWS": [ 76 | "arn:aws:iam::444455556666:user/Bob", 77 | ]}, 78 | "Action": "s3:*", 79 | "Resource": [ 80 | "arn:aws:s3:::BUCKETNAME", 81 | "arn:aws:s3:::BUCKETNAME/*" 82 | ] 83 | }] 84 | }""" 85 | 86 | policy = analyze_policy_string(policystr, include_community_auditors=True) 87 | 88 | assert_equal(policy.finding_ids, S3_STAR_FINDINGS | {"NOTPRINCIPAL_WITH_ALLOW"}) 89 | -------------------------------------------------------------------------------- /parliament/config.yaml: -------------------------------------------------------------------------------- 1 | EXCEPTION: 2 | title: An exception occurred during the audit. 3 | description: Other issues cannot be checked for until this is fixed. 4 | severity: CRITICAL 5 | group: ERROR 6 | 7 | MALFORMED_JSON: 8 | title: JSON is malformed 9 | description: Other issues cannot be checked for until this is fixed. 10 | severity: CRITICAL 11 | group: ERROR 12 | 13 | MALFORMED: 14 | title: Malformed 15 | description: Policy does not contain a required element 16 | severity: HIGH 17 | group: MALFORMED 18 | 19 | DUPLICATE_SID: 20 | title: Duplicate Statement Ids 21 | description: The Statement Ids in the policy are not unique 22 | severity: HIGH 23 | group: MALFORMED 24 | 25 | RESOURCE_POLICY_PRIVILEGE_ESCALATION: 26 | title: Possible resource policy privilege escalation 27 | description: One example of this is an S3 bucket where s3:Delete is not allowed, but s3:PutBucketPolicy is, which could be abused to grant anonymous object deletion. 28 | severity: MEDIUM 29 | group: LOGICAL_INCONSISTANCY 30 | 31 | UNKNOWN_PRINCIPAL: 32 | title: Unknown AWS principal 33 | severity: MEDIUM 34 | group: INVALID 35 | 36 | UNKNOWN_FEDERATION_SOURCE: 37 | title: Unknown federation source 38 | description: The federation element does not match a known pattern 39 | severity: MEDIUM 40 | group: INVALID 41 | 42 | MISMATCHED_TYPE_OPERATION_TO_NULL: 43 | title: Mismatched type of operator to null 44 | description: Null operation requires being matched against true or false but given something else 45 | severity: MEDIUM 46 | group: INVALID 47 | 48 | MISMATCHED_TYPE_BUT_USABLE: 49 | title: Mismatched type, but usable. For example, you may be using a StringEquals to match against an ARN, when an ArnEquals would be more correct. 50 | severity: LOW 51 | group: INVALID 52 | 53 | MISMATCHED_TYPE: 54 | title: Mismatched type 55 | severity: MEDIUM 56 | group: INVALID 57 | 58 | UNKNOWN_ACTION: 59 | title: Unknown action 60 | severity: LOW 61 | group: INVALID 62 | 63 | UNKNOWN_PREFIX: 64 | title: Unknown prefix 65 | severity: LOW 66 | group: INVALID 67 | 68 | UNKNOWN_CONDITION_FOR_ACTION: 69 | title: Unknown condition for action 70 | description: The given condition is not documented to work with the given action 71 | severity: MEDIUM 72 | group: INVALID 73 | 74 | BAD_PATTERN_FOR_MFA: 75 | title: "Bad pattern used for MFA check" 76 | severity: MEDIUM 77 | group: BAD_PATTERN 78 | 79 | INVALID_SID: 80 | title: Invalid SID 81 | description: Statement Sid does match regex [0-9A-Za-z]* 82 | severity: LOW 83 | group: INVALID 84 | 85 | INVALID_ARN: 86 | title: Invalid ARN 87 | description: Malformed resource, should have the form arn:partition:service:region:account:id 88 | severity: MEDIUM 89 | group: INVALID 90 | 91 | NO_VERSION: 92 | title: Policy does not contain a Version element 93 | severity: LOW 94 | group: NOT_IDEAL 95 | 96 | INVALID_VERSION: 97 | title: Invalid Version 98 | description: "Unknown Version used. Version must be either 2012-10-17 or 2008-10-17" 99 | severity: MEDIUM 100 | group: INVALID 101 | 102 | OLD_VERSION: 103 | title: Old version 104 | description: "Older version used. Variables will not be allowed." 105 | severity: LOW 106 | group: OLD 107 | 108 | RESOURCE_MISMATCH: 109 | title: No resources match for the given action 110 | severity: MEDIUM 111 | group: MALFORMED 112 | 113 | RESOURCE_STAR: 114 | title: Unnecessary use of Resource * 115 | severity: LOW 116 | group: NOT_IDEAL 117 | 118 | RESOURCE_EFFECTIVELY_STAR: 119 | title: Unnecessary use of Resource * based on wildcards 120 | description: "Resource block is functionally equivalent to Resource *" 121 | severity: LOW 122 | group: MALFORMED 123 | 124 | UNKNOWN_OPERATOR: 125 | title: The condition operator is unknown. 126 | severity: MEDIUM 127 | group: INVALID 128 | -------------------------------------------------------------------------------- /tests/unit/test_principals.py: -------------------------------------------------------------------------------- 1 | from parliament import analyze_policy_string 2 | 3 | 4 | class TestPrincipals: 5 | """Test class for principals""" 6 | 7 | def test_policy_with_principal(self): 8 | # S3 bucket policy 9 | policy = analyze_policy_string( 10 | """{ 11 | "Version":"2012-10-17", 12 | "Statement":[ 13 | { 14 | "Sid":"AddPerm", 15 | "Effect":"Allow", 16 | "Principal": "*", 17 | "Action":["s3:GetObject"], 18 | "Resource":["arn:aws:s3:::examplebucket/*"] 19 | } 20 | ] 21 | }""", 22 | ignore_private_auditors=True, 23 | ) 24 | assert policy.finding_ids == set(), "Basic S3 bucket policy" 25 | 26 | policy = analyze_policy_string( 27 | """{ 28 | "Version":"2012-10-17", 29 | "Statement":[ 30 | { 31 | "Effect":"Allow", 32 | "Principal": {"AWS": ["arn:aws:iam::000000000000:root","arn:aws:iam::111111111111:root"]}, 33 | "Action":["s3:GetObject"], 34 | "Resource":["arn:aws:s3:::examplebucket/*"] 35 | } 36 | ] 37 | }""", 38 | ignore_private_auditors=True, 39 | ) 40 | assert ( 41 | policy.finding_ids == set() 42 | ), "S3 bucket policy with two accounts granted access via account ARN" 43 | 44 | policy = analyze_policy_string( 45 | """{ 46 | "Version":"2012-10-17", 47 | "Statement":[ 48 | { 49 | "Effect":"Allow", 50 | "Principal":{"AWS":"000000000000"}, 51 | "Action":["s3:GetObject"], 52 | "Resource":["arn:aws:s3:::examplebucket/*"] 53 | } 54 | ] 55 | }""", 56 | ignore_private_auditors=True, 57 | ) 58 | assert ( 59 | policy.finding_ids == set() 60 | ), "S3 bucket policy with one account granted access via ID" 61 | 62 | policy = analyze_policy_string( 63 | """{ 64 | "Version":"2012-10-17", 65 | "Statement":[ 66 | { 67 | "Effect":"Allow", 68 | "Principal": { "AWS": "arn:aws:iam::000000000000:user/alice" }, 69 | "Action":["s3:GetObject"], 70 | "Resource":["arn:aws:s3:::examplebucket/*"] 71 | } 72 | ] 73 | }""", 74 | ignore_private_auditors=True, 75 | ) 76 | assert policy.finding_ids == set(), "S3 bucket policy with ARN of user" 77 | 78 | policy = analyze_policy_string( 79 | """{ 80 | "Version":"2012-10-17", 81 | "Statement":[ 82 | { 83 | "Effect":"Allow", 84 | "Principal": { "Federated": "cognito-identity.amazonaws.com" }, 85 | "Action":["s3:GetObject"], 86 | "Resource":["arn:aws:s3:::examplebucket/*"] 87 | } 88 | ] 89 | }""", 90 | ignore_private_auditors=True, 91 | ) 92 | assert policy.finding_ids == set(), "Federated access" 93 | 94 | policy = analyze_policy_string( 95 | """{ 96 | "Version":"2012-10-17", 97 | "Statement":[ 98 | { 99 | "Effect":"Allow", 100 | "Principal": { "AWS": "arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity 00000000000000" }, 101 | "Action":["s3:GetObject"], 102 | "Resource":["arn:aws:s3:::examplebucket/*"] 103 | } 104 | ] 105 | }""", 106 | ignore_private_auditors=True, 107 | ) 108 | assert ( 109 | policy.finding_ids == set() 110 | ), "S3 bucket policy with CloudFront OAI access" 111 | 112 | def test_bad_principals(self): 113 | # Good principal 114 | policy = analyze_policy_string( 115 | """{ 116 | "Version":"2012-10-17", 117 | "Statement":[ 118 | { 119 | "Sid":"AddPerm", 120 | "Effect":"Allow", 121 | "Principal": "*", 122 | "Action":["s3:GetObject"], 123 | "Resource":["arn:aws:s3:::examplebucket/*"] 124 | } 125 | ] 126 | }""", 127 | ignore_private_auditors=True, 128 | ) 129 | assert policy.finding_ids == set(), "Basic S3 bucket policy" 130 | -------------------------------------------------------------------------------- /tests/unit/test_resource_formatting.py: -------------------------------------------------------------------------------- 1 | from parliament import ( 2 | analyze_policy_string, 3 | is_arn_match, 4 | is_arn_strictly_valid, 5 | is_glob_match, 6 | ) 7 | from parliament.statement import is_valid_region, is_valid_account_id 8 | 9 | 10 | class TestResourceFormatting: 11 | """Test class for resource formatting""" 12 | 13 | def test_resource_bad(self): 14 | policy = analyze_policy_string( 15 | """{ 16 | "Version": "2012-10-17", 17 | "Statement": { 18 | "Effect": "Allow", 19 | "Action": "s3:listallmybuckets", 20 | "Resource": "s3"}}""", 21 | ignore_private_auditors=True, 22 | ) 23 | assert len(policy.findings) == 1 24 | 25 | def test_resource_good(self): 26 | policy = analyze_policy_string( 27 | """{ 28 | "Version": "2012-10-17", 29 | "Statement": { 30 | "Effect": "Allow", 31 | "Action": "s3:getobject", 32 | "Resource": "arn:aws:s3:::my_corporate_bucket/*"}}""", 33 | ignore_private_auditors=True, 34 | ) 35 | print(policy.findings) 36 | assert len(policy.findings) == 0 37 | 38 | def test_is_valid_region(self): 39 | assert is_valid_region(""), "Empty regions are allowed" 40 | assert is_valid_region("us-east-1"), "This region is allowed" 41 | assert not is_valid_region("us-east-1f"), "This is an AZ, not a region" 42 | assert not is_valid_region("us-east-*"), "No regexes in regions" 43 | assert not is_valid_region("us"), "Not a valid region" 44 | assert not is_valid_region("us-east-1-f"), "Not a valid region" 45 | assert is_valid_region("us-gov-east-1"), "This is a valid govcloud region" 46 | 47 | def test_is_valid_account_id(self): 48 | assert is_valid_account_id(""), "Empty account id is allowed" 49 | assert is_valid_account_id("000000001234"), "This account id is allowed" 50 | assert not is_valid_account_id("abc"), "Account id must have 12 digits" 51 | assert not is_valid_account_id( 52 | "00000000123?" 53 | ), "Regex not allowed in account id" 54 | 55 | def test_arn_match(self): 56 | assert is_arn_match("object", "arn:*:s3:::*/*", "arn:*:s3:::*/*") 57 | assert is_arn_match("object", "*", "arn:*:s3:::*/*") 58 | assert is_arn_match("object", "arn:*:s3:::*/*", "*") 59 | assert is_arn_match("object", "arn:*:s3:::*/*", "arn:aws:s3:::*personalize*") 60 | assert is_arn_match("bucket", "arn:*:s3:::mybucket", "arn:*:s3:::mybucket") 61 | assert not is_arn_match( 62 | "bucket", "arn:*:s3:::mybucket", "arn:*:s3:::mybucket/*" 63 | ), "Bucket and object types should not match" 64 | assert not is_arn_match( 65 | "object", "arn:*:s3:::*/*", "arn:aws:s3:::examplebucket" 66 | ), "Object and bucket types should not match" 67 | assert is_arn_match("bucket", "arn:*:s3:::mybucket*", "arn:*:s3:::mybucket2") 68 | assert is_arn_match("bucket", "arn:*:s3:::*", "arn:*:s3:::mybucket2") 69 | assert not is_arn_match( 70 | "object", "arn:*:s3:::*/*", "arn:aws:logs:*:*:/aws/cloudfront/*" 71 | ) 72 | assert not is_arn_match( 73 | "object", "arn:aws:s3:::*/*", "arn:aws:logs:*:*:/aws/cloudfront/*" 74 | ) 75 | assert is_arn_match( 76 | "cloudfront", 77 | "arn:aws:logs:*:*:/aws/cloudfront/*", 78 | "arn:aws:logs:us-east-1:000000000000:/aws/cloudfront/test", 79 | ) 80 | assert is_arn_match( 81 | "bucket", 82 | "arn:*:s3:::*", 83 | "arn:aws:s3:::bucket-for-client-${aws:PrincipalTag/Namespace}-*", 84 | ) 85 | 86 | def test_is_arn_strictly_valid(self): 87 | assert is_arn_strictly_valid( 88 | "user", 89 | "arn:*:iam::*:user/*", 90 | "arn:aws:iam::123456789012:user/Development/product_1234/*", 91 | ) 92 | 93 | assert is_arn_strictly_valid( 94 | "user", "arn:*:iam::*:user/*", "arn:aws:iam::123456789012:*" 95 | ) 96 | 97 | assert is_arn_strictly_valid( 98 | "ssm", 99 | "arn:*:ssm::*:resource-data-sync/*", 100 | "arn:aws:ssm::123456789012:resource-data-sync/*", 101 | ) 102 | 103 | assert not is_arn_strictly_valid( 104 | "ssm", 105 | "arn:*:ssm::*:resource-data-sync/*", 106 | "arn:aws:ssm::123456789012:resource-data-*/*", 107 | ) 108 | 109 | assert not is_arn_strictly_valid( 110 | "user", "arn:*:iam::*:user/*", "arn:aws:iam::123456789012:*/*" 111 | ) 112 | 113 | assert not is_arn_strictly_valid( 114 | "user", "arn:*:iam::*:user/*", "arn:aws:iam::123456789012:u*" 115 | ) 116 | 117 | assert not is_arn_strictly_valid( 118 | "dbuser", 119 | "arn:*:redshift:*:*:dbuser:*/*", 120 | "arn:aws:redshift:us-west-2:123456789012:db*:the_cluster/the_user", 121 | ) 122 | 123 | def test_arn_match_cloudtrail_emptysegments(self): 124 | assert not is_arn_match( 125 | "cloudtrail", "arn:*:cloudtrail:*:*:trail/*", "arn:::::trail/my-trail" 126 | ) 127 | 128 | def test_arn_match_s3_withregion(self): 129 | assert not is_arn_match( 130 | "object", "arn:*:s3:::*/*", "arn:aws:s3:us-east-1::bucket1/*" 131 | ) 132 | 133 | def test_arn_match_s3_withaccount(self): 134 | assert not is_arn_match( 135 | "object", "arn:*:s3:::*/*", "arn:aws:s3::123412341234:bucket1/*" 136 | ) 137 | 138 | def test_arn_match_s3_withregion_account(self): 139 | assert not is_arn_match( 140 | "object", "arn:*:s3:::*/*", "arn:aws:s3:us-east-1:123412341234:bucket1/*" 141 | ) 142 | 143 | def test_arn_match_iam_emptysegments(self): 144 | assert not is_arn_match( 145 | "role", "arn:*:iam::*:role/*", "arn:aws:iam:::role/my-role" 146 | ) 147 | 148 | def test_arn_match_iam_withregion(self): 149 | assert not is_arn_match( 150 | "role", "arn:*:iam::*:role/*", "arn:aws:iam:us-east-1::role/my-role" 151 | ) 152 | 153 | def test_arn_match_apigw_emptysegments(self): 154 | assert not is_arn_match( 155 | "apigateway", 156 | "arn:*:apigateway:*::*", 157 | "arn:aws:apigateway:::/restapis/a123456789/*", 158 | ) 159 | 160 | def test_arn_match_apigw_withaccount(self): 161 | assert not is_arn_match( 162 | "apigateway", 163 | "arn:*:apigateway:*::*", 164 | "arn:aws:apigateway:us-east-1:123412341234:/restapis/a123456789/*", 165 | ) 166 | 167 | def test_is_glob_match(self): 168 | tests = [ 169 | # string1, string2, whether they match 170 | ("a", "b", False), 171 | ("a", "a", True), 172 | ("a", "*", True), 173 | ("*", "a", True), 174 | ("a*a", "*", True), 175 | ("a*a", "a*b", False), 176 | ("a*a", "aa", True), 177 | ("a*a", "aba", True), 178 | ("*a*", "*b*", True), # Example "ab" 179 | ("a*a*", "a*b*", True), # Example "aba" 180 | ("aaaaaa:/b", "aa*a:/b", True), 181 | ("*/*", "*personalize*", True), # Example "personalize/" 182 | ("", "*", True), 183 | ("", "**", True), 184 | ("", "a", False), 185 | ("**", "a", True), 186 | ] 187 | 188 | for s1, s2, expected in tests: 189 | assert ( 190 | is_glob_match(s1, s2) == expected 191 | ), "Matching {} with {} should return {}".format(s1, s2, expected) 192 | -------------------------------------------------------------------------------- /tests/unit/test_authorization_file.py: -------------------------------------------------------------------------------- 1 | import jsoncfg 2 | import json 3 | from parliament import analyze_policy_string 4 | 5 | 6 | class TestAuthDetailsFile: 7 | def test_auth_details_example(self): 8 | auth_details_json = { 9 | "UserDetailList": [ 10 | { 11 | "Path": "/", 12 | "UserName": "obama", 13 | "UserId": "YAAAAASSQUEEEN", 14 | "Arn": "arn:aws:iam::012345678901:user/obama", 15 | "CreateDate": "2019-12-18 19:10:08+00:00", 16 | "GroupList": ["admin"], 17 | "AttachedManagedPolicies": [], 18 | "Tags": [], 19 | } 20 | ], 21 | "GroupDetailList": [ 22 | { 23 | "Path": "/", 24 | "GroupName": "admin", 25 | "GroupId": "YAAAAASSQUEEEN", 26 | "Arn": "arn:aws:iam::012345678901:group/admin", 27 | "CreateDate": "2017-05-15 17:33:36+00:00", 28 | "GroupPolicyList": [], 29 | "AttachedManagedPolicies": [ 30 | { 31 | "PolicyName": "AdministratorAccess", 32 | "PolicyArn": "arn:aws:iam::aws:policy/AdministratorAccess", 33 | } 34 | ], 35 | } 36 | ], 37 | "RoleDetailList": [ 38 | { 39 | "Path": "/", 40 | "RoleName": "MyRole", 41 | "RoleId": "YAAAAASSQUEEEN", 42 | "Arn": "arn:aws:iam::012345678901:role/MyRole", 43 | "CreateDate": "2019-08-16 17:27:59+00:00", 44 | "AssumeRolePolicyDocument": { 45 | "Version": "2012-10-17", 46 | "Statement": [ 47 | { 48 | "Effect": "Allow", 49 | "Principal": {"Service": "ssm.amazonaws.com"}, 50 | "Action": "sts:AssumeRole", 51 | } 52 | ], 53 | }, 54 | "InstanceProfileList": [], 55 | "RolePolicyList": [ 56 | { 57 | "PolicyName": "Stuff", 58 | "PolicyDocument": { 59 | "Version": "2012-10-17", 60 | "Statement": [ 61 | { 62 | "Action": [ 63 | "s3:ListBucket", 64 | "s3:Put*", 65 | "s3:Get*", 66 | "s3:*MultipartUpload*", 67 | ], 68 | "Resource": ["*"], 69 | "Effect": "Allow", 70 | } 71 | ], 72 | }, 73 | } 74 | ], 75 | "AttachedManagedPolicies": [], 76 | "Tags": [], 77 | "RoleLastUsed": {}, 78 | }, 79 | { 80 | "Path": "/", 81 | "RoleName": "MyOtherRole", 82 | "RoleId": "YAAAAASSQUEEEN", 83 | "Arn": "arn:aws:iam::012345678901:role/MyOtherRole", 84 | "CreateDate": "2019-08-16 17:27:59+00:00", 85 | "AssumeRolePolicyDocument": { 86 | "Version": "2012-10-17", 87 | "Statement": [ 88 | { 89 | "Effect": "Allow", 90 | "Principal": {"Service": "ssm.amazonaws.com"}, 91 | "Action": "sts:AssumeRole", 92 | } 93 | ], 94 | }, 95 | "InstanceProfileList": [], 96 | "RolePolicyList": [ 97 | { 98 | "PolicyName": "SupYo", 99 | "PolicyDocument": { 100 | "Version": "2012-10-17", 101 | "Statement": [ 102 | { 103 | "Sid": "VisualEditor0", 104 | "Effect": "Allow", 105 | "Action": [ 106 | "s3:PutBucketPolicy", 107 | "s3:PutBucketAcl", 108 | "s3:PutLifecycleConfiguration", 109 | "s3:PutObject", 110 | "s3:GetObject", 111 | "s3:DeleteObject", 112 | ], 113 | "Resource": "*", 114 | } 115 | ], 116 | }, 117 | } 118 | ], 119 | "AttachedManagedPolicies": [], 120 | "Tags": [], 121 | "RoleLastUsed": {}, 122 | }, 123 | ], 124 | "Policies": [ 125 | { 126 | "PolicyName": "NotYourPolicy", 127 | "PolicyId": "YAAAAASSQUEEEN", 128 | "Arn": "arn:aws:iam::012345678901:policy/NotYourPolicy", 129 | "Path": "/", 130 | "DefaultVersionId": "v9", 131 | "AttachmentCount": 1, 132 | "PermissionsBoundaryUsageCount": 0, 133 | "IsAttachable": True, 134 | "CreateDate": "2020-01-29 21:24:20+00:00", 135 | "UpdateDate": "2020-01-29 23:23:12+00:00", 136 | "PolicyVersionList": [ 137 | { 138 | "Document": { 139 | "Version": "2012-10-17", 140 | "Statement": [ 141 | { 142 | "Sid": "VisualEditor0", 143 | "Effect": "Allow", 144 | "Action": [ 145 | "s3:PutBucketPolicy", 146 | "s3:PutBucketAcl", 147 | "s3:PutLifecycleConfiguration", 148 | "s3:PutObject", 149 | "s3:GetObject", 150 | "s3:DeleteObject", 151 | ], 152 | "Resource": [ 153 | "arn:aws:s3:::mybucket/*", 154 | "arn:aws:s3:::mybucket", 155 | ], 156 | } 157 | ], 158 | }, 159 | "VersionId": "v9", 160 | "IsDefaultVersion": True, 161 | "CreateDate": "2020-01-29 23:23:12+00:00", 162 | } 163 | ], 164 | } 165 | ], 166 | } 167 | findings = [] 168 | for policy in auth_details_json["Policies"]: 169 | # Ignore AWS defined policies 170 | if "arn:aws:iam::aws:" not in policy["Arn"]: 171 | continue 172 | if ( 173 | policy["Path"] == "/service-role/" 174 | or policy["Path"] == "/aws-service-role/" 175 | or policy["PolicyName"].startswith("AWSServiceRoleFor") 176 | or policy["PolicyName"].endswith("ServiceRolePolicy") 177 | or policy["PolicyName"].endswith("ServiceLinkedRolePolicy") 178 | ): 179 | continue 180 | 181 | for version in policy["PolicyVersionList"]: 182 | if not version["IsDefaultVersion"]: 183 | continue 184 | print(version["Document"]) 185 | policy = analyze_policy_string( 186 | json.dumps(version["Document"]), 187 | policy["Arn"], 188 | ) 189 | findings.extend(policy.findings) 190 | 191 | # Review the inline policies on Users, Roles, and Groups 192 | for user in auth_details_json["UserDetailList"]: 193 | for policy in user.get("UserPolicyList", []): 194 | policy = analyze_policy_string( 195 | json.dumps(policy["PolicyDocument"]), 196 | user["Arn"], 197 | private_auditors_custom_path=None, 198 | ) 199 | findings.extend(policy.findings) 200 | for role in auth_details_json["RoleDetailList"]: 201 | for policy in role.get("RolePolicyList", []): 202 | policy = analyze_policy_string( 203 | json.dumps(policy["PolicyDocument"]), 204 | role["Arn"], 205 | private_auditors_custom_path=None, 206 | ) 207 | findings.extend(policy.findings) 208 | for group in auth_details_json["GroupDetailList"]: 209 | for policy in group.get("GroupPolicyList", []): 210 | policy = analyze_policy_string( 211 | json.dumps(policy["PolicyDocument"]), 212 | group["Arn"], 213 | private_auditors_custom_path=None, 214 | ) 215 | findings.extend(policy.findings) 216 | 217 | self.maxDiff = None 218 | assert "RESOURCE_POLICY_PRIVILEGE_ESCALATION" in str(findings) 219 | -------------------------------------------------------------------------------- /tests/unit/test_patterns.py: -------------------------------------------------------------------------------- 1 | from parliament import analyze_policy_string 2 | 3 | 4 | class TestPatterns: 5 | """Test class for bad patterns""" 6 | 7 | def test_bad_mfa_condition(self): 8 | policy = analyze_policy_string( 9 | """{ 10 | "Version": "2012-10-17", 11 | "Statement": { 12 | "Effect": "Allow", 13 | "Action": "*", 14 | "Resource": "*", 15 | "Condition": {"Bool": {"aws:MultiFactorAuthPresent":"false"}} 16 | }}""", 17 | ignore_private_auditors=True, 18 | ) 19 | assert policy.finding_ids == set( 20 | ["BAD_PATTERN_FOR_MFA"] 21 | ), "Policy contains bad MFA check" 22 | 23 | def test_resource_policy_privilege_escalation(self): 24 | # This policy is actually granting essentially s3:* due to the ability to put a policy on a bucket 25 | policy = analyze_policy_string( 26 | """{ 27 | "Version": "2012-10-17", 28 | "Statement": { 29 | "Effect": "Allow", 30 | "Action": ["s3:GetObject", "s3:PutBucketPolicy"], 31 | "Resource": "*" 32 | }}""", 33 | ignore_private_auditors=True, 34 | ) 35 | assert policy.finding_ids == set( 36 | ["RESOURCE_POLICY_PRIVILEGE_ESCALATION", "RESOURCE_STAR"] 37 | ), "Resource policy privilege escalation" 38 | 39 | policy = analyze_policy_string( 40 | """{ 41 | "Version": "2012-10-17", 42 | "Statement": [ 43 | { 44 | "Action": [ 45 | "s3:ListBucket", 46 | "s3:Put*", 47 | "s3:Get*", 48 | "s3:*MultipartUpload*" 49 | ], 50 | "Resource": [ 51 | "*" 52 | ], 53 | "Effect": "Allow" 54 | }, 55 | { 56 | "Action": [ 57 | "s3:*Policy*", 58 | "sns:*Permission*", 59 | "sns:*Delete*", 60 | "s3:*Delete*", 61 | "sns:*Remove*" 62 | ], 63 | "Resource": [ 64 | "*" 65 | ], 66 | "Effect": "Deny" 67 | } 68 | ]}""", 69 | ignore_private_auditors=True, 70 | ) 71 | 72 | assert policy.finding_ids == set( 73 | ["RESOURCE_POLICY_PRIVILEGE_ESCALATION", "RESOURCE_STAR"] 74 | ), "Resource policy privilege escalation across two statement" 75 | 76 | def test_resource_effectively_star(self): 77 | policy = analyze_policy_string( 78 | """{ 79 | "Version": "2012-10-17", 80 | "Statement": [ 81 | { 82 | "Sid": "CloudtrailReadTrail", 83 | "Effect": "Allow", 84 | "Action": [ 85 | "cloudtrail:GetEventSelectors", 86 | "cloudtrail:PutEventSelectors" 87 | ], 88 | "Resource": [ 89 | "arn:*:cloudtrail:*:*:trail/*" 90 | ] 91 | } 92 | ] 93 | }""" 94 | ) 95 | 96 | assert policy.finding_ids == set( 97 | ["RESOURCE_EFFECTIVELY_STAR"] 98 | ), "Resource policy spans all Cloudtrails even without an asterisk." 99 | 100 | policy = analyze_policy_string( 101 | """{ 102 | "Version": "2012-10-17", 103 | "Statement": [ 104 | { 105 | "Sid": "CloudtrailReadTrail", 106 | "Effect": "Allow", 107 | "Action": [ 108 | "cloudtrail:GetEventSelectors", 109 | "cloudtrail:PutEventSelectors" 110 | ], 111 | "Resource": [ 112 | "arn:aws:cloudtrail:us-east-1:000000000000:trail/*" 113 | ] 114 | } 115 | ] 116 | }""" 117 | ) 118 | 119 | assert ( 120 | policy.finding_ids == set() 121 | ), "Resource policy is scoped by AWS partition, account and region and is therefore not 'STAR'." 122 | 123 | def test_resource_policy_privilege_escalation_with_deny(self): 124 | # This test ensures if we have an allow on a specific resource, but a Deny on *, 125 | # that it is denied. 126 | policy = analyze_policy_string( 127 | """{ 128 | "Version": "2012-10-17", 129 | "Statement": [ 130 | { 131 | "Effect": "Allow", 132 | "Action": "s3:PutBucketPolicy", 133 | "Resource": "arn:aws:s3:::examplebucket" 134 | }, 135 | { 136 | "Effect": "Deny", 137 | "Action": "*", 138 | "Resource": "*" 139 | } 140 | ]}""", 141 | ignore_private_auditors=True, 142 | ) 143 | assert ( 144 | policy.finding_ids == set() 145 | ), "Resource policy privilege escalation does not exist because all our denied" 146 | 147 | def test_resource_policy_privilege_escalation_at_bucket_level(self): 148 | policy = analyze_policy_string( 149 | """{ 150 | "Version": "2012-10-17", 151 | "Statement": { 152 | "Effect": "Allow", 153 | "Action": ["s3:GetObject", "s3:PutBucketPolicy"], 154 | "Resource": ["arn:aws:s3:::bucket", "arn:aws:s3:::bucket/*"] 155 | }}""", 156 | ignore_private_auditors=True, 157 | ) 158 | assert policy.finding_ids == set( 159 | ["RESOURCE_POLICY_PRIVILEGE_ESCALATION"] 160 | ), "Resource policy privilege escalation" 161 | 162 | policy = analyze_policy_string( 163 | """{ 164 | "Version": "2012-10-17", 165 | "Statement": [{ 166 | "Effect": "Allow", 167 | "Action": ["s3:*Bucket*", "s3:*Object*"], 168 | "Resource": ["arn:aws:s3:::bucket1", "arn:aws:s3:::bucket1/*"] 169 | }, 170 | { 171 | "Effect": "Allow", 172 | "Action": ["s3:*Object"], 173 | "Resource": ["arn:aws:s3:::bucket2/*"] 174 | }]}""", 175 | ignore_private_auditors=True, 176 | ) 177 | # There is one finding for "No resources match for s3:ListAllMyBuckets which requires a resource format of *" 178 | assert policy.finding_ids == set( 179 | ["RESOURCE_MISMATCH"] 180 | ), "Buckets do not match so no escalation possible" 181 | 182 | 183 | # # # The following test for detections of various bad patterns, but unfortunately 184 | # # # these detections were never implemented. 185 | 186 | # # def test_bad_tagging(self): 187 | # # # This was the original policy used by AmazonSageMakerFullAccess 188 | # # policy = analyze_policy_string( 189 | # # """{ 190 | # # "Version": "2012-10-17", 191 | # # "Statement": [ 192 | # # { 193 | # # "Action": [ 194 | # # "secretsmanager:CreateSecret", 195 | # # "secretsmanager:DescribeSecret", 196 | # # "secretsmanager:ListSecrets", 197 | # # "secretsmanager:TagResource" 198 | # # ], 199 | # # "Resource": "*", 200 | # # "Effect": "Allow" 201 | # # }, 202 | # # { 203 | # # "Action": [ 204 | # # "secretsmanager:GetSecretValue" 205 | # # ], 206 | # # "Resource": "*", 207 | # # "Effect": "Allow", 208 | # # "Condition": { 209 | # # "StringEquals": { 210 | # # "secretsmanager:ResourceTag/SageMaker": "true" 211 | # # } 212 | # # } 213 | # # } 214 | # # ]}""" 215 | # # ) 216 | # # assert_false( 217 | # # len(policy.findings) == 0, 218 | # # "Policy attempts to restrict by tags, but allows any tag to be added", 219 | # # ) 220 | 221 | # # policy = analyze_policy_string( 222 | # # """{ 223 | # # "Version": "2012-10-17", 224 | # # "Statement": [ 225 | # # { 226 | # # "Action": [ 227 | # # "secretsmanager:ListSecrets" 228 | # # ], 229 | # # "Resource": "*", 230 | # # "Effect": "Allow" 231 | # # }, 232 | # # { 233 | # # "Action": [ 234 | # # "secretsmanager:DescribeSecret", 235 | # # "secretsmanager:GetSecretValue", 236 | # # "secretsmanager:CreateSecret" 237 | # # ], 238 | # # "Resource": [ 239 | # # "arn:aws:secretsmanager:*:*:secret:AmazonSageMaker-*" 240 | # # ], 241 | # # "Effect": "Allow" 242 | # # }, 243 | # # { 244 | # # "Action": [ 245 | # # "secretsmanager:DescribeSecret", 246 | # # "secretsmanager:GetSecretValue" 247 | # # ], 248 | # # "Resource": "*", 249 | # # "Effect": "Allow", 250 | # # "Condition": { 251 | # # "StringEquals": { 252 | # # "secretsmanager:ResourceTag/SageMaker": "true" 253 | # # } 254 | # # } 255 | # # } 256 | # # ]}""" 257 | # # ) 258 | # # assert_true(len(policy.findings) == 0, "Correct policy") 259 | 260 | # # def test_bad_mfa_policy(self): 261 | # # # Good policy 262 | # # policy = analyze_policy_string( 263 | # # """{ 264 | # # "Version": "2012-10-17", 265 | # # "Statement": [ 266 | # # { 267 | # # "Sid": "AllowViewAccountInfo", 268 | # # "Effect": "Allow", 269 | # # "Action": "iam:ListVirtualMFADevices", 270 | # # "Resource": "*" 271 | # # }, 272 | # # { 273 | # # "Sid": "AllowManageOwnVirtualMFADevice", 274 | # # "Effect": "Allow", 275 | # # "Action": [ 276 | # # "iam:CreateVirtualMFADevice", 277 | # # "iam:DeleteVirtualMFADevice" 278 | # # ], 279 | # # "Resource": "arn:aws:iam::*:mfa/${aws:username}" 280 | # # }, 281 | # # { 282 | # # "Sid": "AllowManageOwnUserMFA", 283 | # # "Effect": "Allow", 284 | # # "Action": [ 285 | # # "iam:DeactivateMFADevice", 286 | # # "iam:EnableMFADevice", 287 | # # "iam:GetUser", 288 | # # "iam:ListMFADevices", 289 | # # "iam:ResyncMFADevice" 290 | # # ], 291 | # # "Resource": "arn:aws:iam::*:user/${aws:username}" 292 | # # }, 293 | # # { 294 | # # "Sid": "DenyAllExceptListedIfNoMFA", 295 | # # "Effect": "Deny", 296 | # # "NotAction": [ 297 | # # "iam:CreateVirtualMFADevice", 298 | # # "iam:EnableMFADevice", 299 | # # "iam:GetUser", 300 | # # "iam:ListMFADevices", 301 | # # "iam:ListVirtualMFADevices", 302 | # # "iam:ResyncMFADevice" 303 | # # ], 304 | # # "Resource": "*", 305 | # # "Condition": { 306 | # # "BoolIfExists": {"aws:MultiFactorAuthPresent": "false"} 307 | # # } 308 | # # } 309 | # # ] 310 | # # }""" 311 | # # ) 312 | 313 | # # assert_true(len(policy.findings) == 0, "Good MFA policy") 314 | 315 | # # policy = analyze_policy_string( 316 | # # """ 317 | # # { 318 | # # "Version": "2012-10-17", 319 | # # "Statement": [ 320 | # # { 321 | # # "Sid": "AllowIndividualUserToManageThierMFA", 322 | # # "Effect": "Allow", 323 | # # "Action": [ 324 | # # "iam:CreateVirtualMFADevice", 325 | # # "iam:DeactivateMFADevice", 326 | # # "iam:DeleteVirtualMFADevice", 327 | # # "iam:EnableMFADevice", 328 | # # "iam:ResyncMFADevice" 329 | # # ], 330 | # # "Resource": [ 331 | # # "arn:aws:iam::000000000000:mfa/${aws:username}", 332 | # # "arn:aws:iam::000000000000:user/${aws:username}" 333 | # # ] 334 | # # }, 335 | # # { 336 | # # "Sid": "DenyIamAccessToOtherAccountsUnlessMFAd", 337 | # # "Effect": "Deny", 338 | # # "Action": [ 339 | # # "iam:CreateVirtualMFADevice", 340 | # # "iam:DeactivateMFADevice", 341 | # # "iam:DeleteVirtualMFADevice", 342 | # # "iam:EnableMFADevice", 343 | # # "iam:ResyncMFADevice", 344 | # # "iam:ChangePassword", 345 | # # "iam:CreateLoginProfile", 346 | # # "iam:DeleteLoginProfile", 347 | # # "iam:GetAccountSummary", 348 | # # "iam:GetLoginProfile", 349 | # # "iam:UpdateLoginProfile" 350 | # # ], 351 | # # "NotResource": [ 352 | # # "arn:aws:iam::000000000000:mfa/${aws:username}", 353 | # # "arn:aws:iam::000000000000:user/${aws:username}" 354 | # # ], 355 | # # "Condition": { 356 | # # "Bool": { 357 | # # "aws:MultiFactorAuthPresent": "false" 358 | # # } 359 | # # } 360 | # # } 361 | # # ]}""" 362 | # # ) 363 | # # assert_false(len(policy.findings) == 0, "Bad MFA policy") 364 | -------------------------------------------------------------------------------- /parliament/community_auditors/permissions_management.py: -------------------------------------------------------------------------------- 1 | def audit(policy): 2 | # The following list is obtained from Policy Sentry via the following code. 3 | # This is done to avoid pulling in Policy Sentry and its requirements which adds ~50MB to this library. 4 | """ 5 | from policy_sentry.shared.database import connect_db 6 | from policy_sentry.querying.actions import get_actions_with_access_level 7 | 8 | db_session = connect_db("bundled") 9 | permissions_management_actions = get_actions_with_access_level( 10 | db_session, "all", "Permissions management" 11 | ) 12 | permissions_management_actions_normalized = [ 13 | x.lower() for x in permissions_management_actions 14 | ] 15 | permissions_management_actions = permissions_management_actions_normalized 16 | """ 17 | 18 | permissions_management_actions = [ 19 | "acm-pca:createpermission", 20 | "acm-pca:deletepermission", 21 | "backup:deletebackupvaultaccesspolicy", 22 | "backup:putbackupvaultaccesspolicy", 23 | "chime:deletevoiceconnectorterminationcredentials", 24 | "chime:putvoiceconnectorterminationcredentials", 25 | "cloudformation:setstackpolicy", 26 | "cloudsearch:updateserviceaccesspolicies", 27 | "codebuild:deleteresourcepolicy", 28 | "codebuild:deletesourcecredentials", 29 | "codebuild:importsourcecredentials", 30 | "codebuild:putresourcepolicy", 31 | "codestar:associateteammember", 32 | "codestar:createproject", 33 | "codestar:deleteproject", 34 | "codestar:disassociateteammember", 35 | "codestar:updateteammember", 36 | "cognito-identity:createidentitypool", 37 | "cognito-identity:deleteidentities", 38 | "cognito-identity:deleteidentitypool", 39 | "cognito-identity:getid", 40 | "cognito-identity:mergedeveloperidentities", 41 | "cognito-identity:setidentitypoolroles", 42 | "cognito-identity:unlinkdeveloperidentity", 43 | "cognito-identity:unlinkidentity", 44 | "cognito-identity:updateidentitypool", 45 | "connect:getfederationtoken", 46 | "connect:getfederationtokens", 47 | "deeplens:associateserviceroletoaccount", 48 | "ds:createconditionalforwarder", 49 | "ds:createdirectory", 50 | "ds:createidentitypooldirectory", 51 | "ds:createmicrosoftad", 52 | "ds:createtrust", 53 | "ds:sharedirectory", 54 | "ec2:createnetworkinterfacepermission", 55 | "ec2:deletenetworkinterfacepermission", 56 | "ec2:modifysnapshotattribute", 57 | "ec2:modifyvpcendpointservicepermissions", 58 | "ec2:resetsnapshotattribute", 59 | "ecr:setrepositorypolicy", 60 | "elasticmapreduce:putblockpublicaccessconfiguration", 61 | "es:createelasticsearchdomain", 62 | "es:updateelasticsearchdomainconfig", 63 | "gamelift:requestuploadcredentials", 64 | "glacier:abortvaultlock", 65 | "glacier:completevaultlock", 66 | "glacier:deletevaultaccesspolicy", 67 | "glacier:initiatevaultlock", 68 | "glacier:setdataretrievalpolicy", 69 | "glacier:setvaultaccesspolicy", 70 | "glue:deleteresourcepolicy", 71 | "glue:putresourcepolicy", 72 | "greengrass:associateserviceroletoaccount", 73 | "health:describehealthservicestatusfororganization", 74 | "health:disablehealthserviceaccessfororganization", 75 | "health:enablehealthserviceaccessfororganization", 76 | "iam:addclientidtoopenidconnectprovider", 77 | "iam:addroletoinstanceprofile", 78 | "iam:addusertogroup", 79 | "iam:attachgrouppolicy", 80 | "iam:attachrolepolicy", 81 | "iam:attachuserpolicy", 82 | "iam:changepassword", 83 | "iam:createaccesskey", 84 | "iam:createaccountalias", 85 | "iam:creategroup", 86 | "iam:createinstanceprofile", 87 | "iam:createloginprofile", 88 | "iam:createopenidconnectprovider", 89 | "iam:createpolicy", 90 | "iam:createpolicyversion", 91 | "iam:createrole", 92 | "iam:createsamlprovider", 93 | "iam:createservicelinkedrole", 94 | "iam:createservicespecificcredential", 95 | "iam:createuser", 96 | "iam:createvirtualmfadevice", 97 | "iam:deactivatemfadevice", 98 | "iam:deleteaccesskey", 99 | "iam:deleteaccountalias", 100 | "iam:deleteaccountpasswordpolicy", 101 | "iam:deletegroup", 102 | "iam:deletegrouppolicy", 103 | "iam:deleteinstanceprofile", 104 | "iam:deleteloginprofile", 105 | "iam:deleteopenidconnectprovider", 106 | "iam:deletepolicy", 107 | "iam:deletepolicyversion", 108 | "iam:deleterole", 109 | "iam:deleterolepermissionsboundary", 110 | "iam:deleterolepolicy", 111 | "iam:deletesamlprovider", 112 | "iam:deletesshpublickey", 113 | "iam:deleteservercertificate", 114 | "iam:deleteservicelinkedrole", 115 | "iam:deleteservicespecificcredential", 116 | "iam:deletesigningcertificate", 117 | "iam:deleteuser", 118 | "iam:deleteuserpermissionsboundary", 119 | "iam:deleteuserpolicy", 120 | "iam:deletevirtualmfadevice", 121 | "iam:detachgrouppolicy", 122 | "iam:detachrolepolicy", 123 | "iam:detachuserpolicy", 124 | "iam:enablemfadevice", 125 | "iam:passrole", 126 | "iam:putgrouppolicy", 127 | "iam:putrolepermissionsboundary", 128 | "iam:putrolepolicy", 129 | "iam:putuserpermissionsboundary", 130 | "iam:putuserpolicy", 131 | "iam:removeclientidfromopenidconnectprovider", 132 | "iam:removerolefrominstanceprofile", 133 | "iam:removeuserfromgroup", 134 | "iam:resetservicespecificcredential", 135 | "iam:resyncmfadevice", 136 | "iam:setdefaultpolicyversion", 137 | "iam:setsecuritytokenservicepreferences", 138 | "iam:updateaccesskey", 139 | "iam:updateaccountpasswordpolicy", 140 | "iam:updateassumerolepolicy", 141 | "iam:updategroup", 142 | "iam:updateloginprofile", 143 | "iam:updateopenidconnectproviderthumbprint", 144 | "iam:updaterole", 145 | "iam:updateroledescription", 146 | "iam:updatesamlprovider", 147 | "iam:updatesshpublickey", 148 | "iam:updateservercertificate", 149 | "iam:updateservicespecificcredential", 150 | "iam:updatesigningcertificate", 151 | "iam:updateuser", 152 | "iam:uploadsshpublickey", 153 | "iam:uploadservercertificate", 154 | "iam:uploadsigningcertificate", 155 | "imagebuilder:getcomponentpolicy", 156 | "imagebuilder:putcomponentpolicy", 157 | "imagebuilder:putimagepolicy", 158 | "imagebuilder:putimagerecipepolicy", 159 | "iot:attachpolicy", 160 | "iot:attachprincipalpolicy", 161 | "iot:detachpolicy", 162 | "iot:detachprincipalpolicy", 163 | "iot:setdefaultauthorizer", 164 | "iot:setdefaultpolicyversion", 165 | "iotsitewise:createaccesspolicy", 166 | "iotsitewise:deleteaccesspolicy", 167 | "iotsitewise:listaccesspolicies", 168 | "iotsitewise:updateaccesspolicy", 169 | "kms:creategrant", 170 | "kms:createkey", 171 | "kms:putkeypolicy", 172 | "kms:retiregrant", 173 | "kms:revokegrant", 174 | "lakeformation:batchgrantpermissions", 175 | "lakeformation:batchrevokepermissions", 176 | "lakeformation:grantpermissions", 177 | "lakeformation:putdatalakesettings", 178 | "lakeformation:revokepermissions", 179 | "lambda:addlayerversionpermission", 180 | "lambda:addpermission", 181 | "lambda:disablereplication", 182 | "lambda:enablereplication", 183 | "lambda:removelayerversionpermission", 184 | "lambda:removepermission", 185 | "license-manager:updateservicesettings", 186 | "lightsail:getinstanceaccessdetails", 187 | "lightsail:getrelationaldatabasemasteruserpassword", 188 | "logs:deleteresourcepolicy", 189 | "logs:putresourcepolicy", 190 | "mediapackage:rotateingestendpointcredentials", 191 | "mediastore:deletecontainerpolicy", 192 | "mediastore:putcontainerpolicy", 193 | "opsworks:setpermission", 194 | "opsworks:updateuserprofile", 195 | "ram:acceptresourceshareinvitation", 196 | "ram:associateresourceshare", 197 | "ram:createresourceshare", 198 | "ram:deleteresourceshare", 199 | "ram:disassociateresourceshare", 200 | "ram:enablesharingwithawsorganization", 201 | "ram:rejectresourceshareinvitation", 202 | "ram:updateresourceshare", 203 | "rds:authorizedbsecuritygroupingress", 204 | "rds-db:connect", 205 | "redshift:authorizesnapshotaccess", 206 | "redshift:createclusteruser", 207 | "redshift:createsnapshotcopygrant", 208 | "redshift:getclustercredentials", 209 | "redshift:joingroup", 210 | "redshift:modifyclusteriamroles", 211 | "redshift:revokesnapshotaccess", 212 | "s3:bypassgovernanceretention", 213 | "s3:deleteaccesspointpolicy", 214 | "s3:deletebucketpolicy", 215 | "s3:objectowneroverridetobucketowner", 216 | "s3:putaccesspointpolicy", 217 | "s3:putaccountpublicaccessblock", 218 | "s3:putbucketacl", 219 | "s3:putbucketpolicy", 220 | "s3:putbucketpublicaccessblock", 221 | "s3:putobjectacl", 222 | "s3:putobjectversionacl", 223 | "secretsmanager:deleteresourcepolicy", 224 | "secretsmanager:putresourcepolicy", 225 | "sns:addpermission", 226 | "sns:createtopic", 227 | "sns:removepermission", 228 | "sns:settopicattributes", 229 | "sqs:addpermission", 230 | "sqs:createqueue", 231 | "sqs:removepermission", 232 | "sqs:setqueueattributes", 233 | "ssm:modifydocumentpermission", 234 | "sso:associatedirectory", 235 | "sso:associateprofile", 236 | "sso:createapplicationinstance", 237 | "sso:createapplicationinstancecertificate", 238 | "sso:createpermissionset", 239 | "sso:createprofile", 240 | "sso:createtrust", 241 | "sso:deleteapplicationinstance", 242 | "sso:deleteapplicationinstancecertificate", 243 | "sso:deletepermissionset", 244 | "sso:deletepermissionspolicy", 245 | "sso:deleteprofile", 246 | "sso:disassociatedirectory", 247 | "sso:disassociateprofile", 248 | "sso:importapplicationinstanceserviceprovidermetadata", 249 | "sso:putpermissionspolicy", 250 | "sso:startsso", 251 | "sso:updateapplicationinstanceactivecertificate", 252 | "sso:updateapplicationinstancedisplaydata", 253 | "sso:updateapplicationinstanceresponseconfiguration", 254 | "sso:updateapplicationinstanceresponseschemaconfiguration", 255 | "sso:updateapplicationinstancesecurityconfiguration", 256 | "sso:updateapplicationinstanceserviceproviderconfiguration", 257 | "sso:updateapplicationinstancestatus", 258 | "sso:updatedirectoryassociation", 259 | "sso:updatepermissionset", 260 | "sso:updateprofile", 261 | "sso:updatessoconfiguration", 262 | "sso:updatetrust", 263 | "sso-directory:addmembertogroup", 264 | "sso-directory:createalias", 265 | "sso-directory:creategroup", 266 | "sso-directory:createuser", 267 | "sso-directory:deletegroup", 268 | "sso-directory:deleteuser", 269 | "sso-directory:disableuser", 270 | "sso-directory:enableuser", 271 | "sso-directory:removememberfromgroup", 272 | "sso-directory:updategroup", 273 | "sso-directory:updatepassword", 274 | "sso-directory:updateuser", 275 | "sso-directory:verifyemail", 276 | "storagegateway:deletechapcredentials", 277 | "storagegateway:setlocalconsolepassword", 278 | "storagegateway:setsmbguestpassword", 279 | "storagegateway:updatechapcredentials", 280 | "waf:deletepermissionpolicy", 281 | "waf:getchangetoken", 282 | "waf:putpermissionpolicy", 283 | "waf-regional:deletepermissionpolicy", 284 | "waf-regional:getchangetoken", 285 | "waf-regional:putpermissionpolicy", 286 | "wafv2:createwebacl", 287 | "wafv2:deletewebacl", 288 | "wafv2:updatewebacl", 289 | "worklink:updatedevicepolicyconfiguration", 290 | "workmail:resetpassword", 291 | "workmail:resetuserpassword", 292 | "xray:putencryptionconfig", 293 | ] 294 | 295 | actions = policy.get_allowed_actions() 296 | 297 | permissions_management_actions_in_policy = [] 298 | for action in actions: 299 | if action in permissions_management_actions: 300 | permissions_management_actions_in_policy.append(action) 301 | if len(permissions_management_actions_in_policy) > 0: 302 | policy.add_finding( 303 | "PERMISSIONS_MANAGEMENT_ACTIONS", 304 | location={"actions": permissions_management_actions_in_policy}, 305 | ) 306 | -------------------------------------------------------------------------------- /parliament/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This library is a linter for AWS IAM policies. 3 | """ 4 | 5 | __version__ = "1.6.4" 6 | 7 | import fnmatch 8 | import functools 9 | import json 10 | import jsoncfg 11 | import re 12 | 13 | import pkg_resources 14 | import yaml 15 | 16 | # On initialization, load the IAM data 17 | iam_definition_path = pkg_resources.resource_filename(__name__, "iam_definition.json") 18 | iam_definition = json.load(open(iam_definition_path, "r")) 19 | 20 | # And the config data 21 | config_path = pkg_resources.resource_filename(__name__, "config.yaml") 22 | config = yaml.safe_load(open(config_path, "r")) 23 | 24 | 25 | def override_config(override_config_path): 26 | if override_config_path is None: 27 | return 28 | 29 | # Load the override file 30 | override_config = yaml.safe_load(open(override_config_path, "r")) 31 | 32 | # Over-write the settings 33 | for finding_type, settings in override_config.items(): 34 | if finding_type not in config: 35 | config[finding_type] = {} 36 | for setting, setting_value in settings.items(): 37 | config[finding_type][setting] = setting_value 38 | 39 | 40 | def enhance_finding(finding): 41 | if finding.issue not in config: 42 | raise Exception("Uknown finding issue: {}".format(finding.issue)) 43 | config_settings = config[finding.issue] 44 | finding.severity = config_settings["severity"] 45 | finding.title = config_settings["title"] 46 | finding.description = config_settings.get("description", "") 47 | finding.ignore_locations = config_settings.get("ignore_locations", None) 48 | return finding 49 | 50 | 51 | def analyze_policy_string( 52 | policy_str, 53 | filepath=None, 54 | ignore_private_auditors=False, 55 | private_auditors_custom_path=None, 56 | include_community_auditors=False, 57 | config=None, 58 | ): 59 | """Given a string reperesenting a policy, convert it to a Policy object with findings""" 60 | 61 | try: 62 | # TODO Need to write my own json parser so I can track line numbers. See https://stackoverflow.com/questions/7225056/python-json-decoding-library-which-can-associate-decoded-items-with-original-li 63 | policy_json = jsoncfg.loads_config(policy_str) 64 | except jsoncfg.parser.JSONConfigParserException as e: 65 | policy = Policy(None) 66 | policy.add_finding( 67 | "MALFORMED_JSON", 68 | detail="json parsing error: {}".format(e), 69 | location={"line": e.line, "column": e.column}, 70 | ) 71 | return policy 72 | 73 | policy = Policy(policy_json, filepath, config) 74 | policy.analyze( 75 | ignore_private_auditors, 76 | private_auditors_custom_path, 77 | include_community_auditors, 78 | ) 79 | return policy 80 | 81 | 82 | class UnknownPrefixException(Exception): 83 | pass 84 | 85 | 86 | class UnknownActionException(Exception): 87 | pass 88 | 89 | 90 | def is_arn_match(resource_type, arn_format, resource): 91 | """ 92 | Match the arn_format specified in the docs, with the resource 93 | given in the IAM policy. These can each be strings with globbing. For example, we 94 | want to match the following two strings: 95 | - arn:*:s3:::*/* 96 | - arn:aws:s3:::*personalize* 97 | 98 | That should return true because you could have "arn:aws:s3:::personalize/" which matches both. 99 | 100 | Input: 101 | - resource_type: Example "bucket", this is only used to identify special cases. 102 | - arn_format: ARN regex from the docs 103 | - resource: ARN regex from IAM policy 104 | 105 | 106 | We can cheat some because after the first sections of the arn match, meaning until the 5th colon (with some 107 | rules there to allow empty or asterisk sections), we only need to match the ID part. 108 | So the above is simplified to "*/*" and "*personalize*". 109 | 110 | Let's look at some examples and if these should be marked as a match: 111 | "*/*" and "*personalize*" -> True 112 | "*", "mybucket" -> True 113 | "mybucket", "*" -> True 114 | "*/*", "mybucket" -> False 115 | "*/*", "mybucket*" -> True 116 | "*mybucket", "*myotherthing" -> False 117 | """ 118 | if arn_format == "*" or resource == "*": 119 | return True 120 | 121 | if "bucket" in resource_type: 122 | # We have to do a special case here for S3 buckets 123 | # and since resources can use variables which contain / need to replace them 124 | if "/" in strip_var_from_arn(resource, "theVar"): 125 | return False 126 | 127 | # The ARN has at least 6 parts, separated by a colon. Ensure these exist. 128 | arn_parts = arn_format.split(":") 129 | if len(arn_parts) < 6: 130 | raise Exception("Unexpected format for ARN: {}".format(arn_format)) 131 | resource_parts = resource.split(":") 132 | if len(resource_parts) < 6: 133 | raise Exception("Unexpected format for resource: {}".format(resource)) 134 | 135 | # For the first 5 parts (ex. arn:aws:SERVICE:REGION:ACCOUNT:), ensure these match appropriately 136 | # We do this because we don't want "arn:*:s3:::*/*" and "arn:aws:logs:*:*:/aws/cloudfront/*" to return True 137 | for position in range(0, 5): 138 | if arn_parts[position] == "*" and resource_parts[position] != "": 139 | continue 140 | elif resource_parts[position] == "*": 141 | continue 142 | elif arn_parts[position] == resource_parts[position]: 143 | continue 144 | else: 145 | return False 146 | 147 | # Everything up to and including the account ID section matches, so now try to match the remainder 148 | arn_id = ":".join(arn_parts[5:]) 149 | resource_id = ":".join(resource_parts[5:]) 150 | 151 | # Some of the arn_id's contain regexes of the form "[key]" so replace those with "*" 152 | resource_id = re.sub(r"\[.+?\]", "*", resource_id) 153 | return is_glob_match(arn_id, resource_id) 154 | 155 | 156 | def is_arn_strictly_valid(resource_type, arn_format, resource): 157 | """ 158 | Strictly validate the arn_format specified in the docs, with the resource 159 | given in the IAM policy. These can each be strings with globbing. For example, we 160 | want to match the following two strings: 161 | - arn:*:s3:::*/* 162 | - arn:aws:s3:::*personalize* 163 | 164 | That should return true because you could have "arn:aws:s3:::personalize/" which matches both. 165 | 166 | However when not using *, must include the resource type in the resource arn and wildcards 167 | are not valid for the resource type portion (https://docs.aws.amazon.com/general/latest/gr/aws-arns-and-namespaces.html#genref-aws-service-namesspaces) 168 | 169 | Input: 170 | - resource_type: Example "bucket", this is only used to identify special cases. 171 | - arn_format: ARN regex from the docs 172 | - resource: ARN regex from IAM policy 173 | 174 | """ 175 | if is_arn_match(resource_type, arn_format, resource): 176 | # this would have already raised exception 177 | arn_parts = arn_format.split(":") 178 | resource_parts = resource.split(":") 179 | arn_id = ":".join(arn_parts[5:]) 180 | resource_id = ":".join(resource_parts[5:]) 181 | 182 | # Does the resource contain a resource type component 183 | # regex looks for a resource type word like "user" or "cluster-endpoint" followed by a 184 | # : or / and then anything else excluding the resource type string starting with a * 185 | arn_id_resource_type = re.match(r"(^[^\*][\w-]+)[\/\:].+", arn_id) 186 | 187 | if arn_id_resource_type != None and resource_id != "*": 188 | 189 | # https://docs.aws.amazon.com/general/latest/gr/aws-arns-and-namespaces.html#genref-aws-service-namesspaces 190 | # The following is not allowed: arn:aws:iam::123456789012:u* 191 | if not (resource_id.startswith(arn_id_resource_type[1])): 192 | return False 193 | 194 | # replace aws variable and check for other colons 195 | resource_id_no_vars = strip_var_from_arn(resource_id) 196 | if ":" in resource_id_no_vars and not ":" in arn_id: 197 | return False 198 | 199 | return True 200 | return False 201 | 202 | 203 | def strip_var_from_arn(arn, replace_with=""): 204 | return re.sub(r"\$\{aws.[\w\/]+\}", replace_with, arn) 205 | 206 | 207 | def is_glob_match(s1, s2): 208 | # This comes from https://github.com/duo-labs/parliament/issues/36#issuecomment-574001764 209 | 210 | # If strings are equal, TRUE 211 | if s1 == s2: 212 | return True 213 | # If either string is all '*'s, TRUE 214 | if s1 and all(c == "*" for c in s1) or s2 and all(c == "*" for c in s2): 215 | return True 216 | # If either string is '', FALSE (already handled case if both are '' in A) 217 | if not s1 or not s2: 218 | return False 219 | # At this point, we know that both s1 and s2 are non-empty, so safe to access [0]'th element 220 | # If both strings start with '*', TRUE if match first with remainder of second or second with remainder of first 221 | if s1[0] == s2[0] == "*": 222 | return is_glob_match(s1[1:], s2) or is_glob_match(s1, s2[1:]) 223 | # If s1 starts with '*', TRUE if remainder of s1 matches any length remainder of s2 224 | if s1[0] == "*": 225 | return any(is_glob_match(s1[1:], s2[i:]) for i in range(len(s2))) 226 | # If s2 starts with '*', TRUE if remainder of s2 matches any length remainder of s1 227 | if s2[0] == "*": 228 | return any(is_glob_match(s1[i:], s2[1:]) for i in range(len(s1))) 229 | # TRUE if s1 and s2 both have same first element and remainder of s1 matches remainder of s2 230 | return s1[0] == s2[0] and is_glob_match(s1[1:], s2[1:]) 231 | 232 | 233 | @functools.lru_cache(maxsize=10240) 234 | def expand_action(action, raise_exceptions=True): 235 | """ 236 | Converts "iam:*List*" to 237 | [ 238 | {'service':'iam', 'action': 'ListAccessKeys'}, 239 | {'service':'iam', 'action': 'ListUsers'}, ... 240 | ] 241 | """ 242 | if action == "*": 243 | action = "*:*" 244 | 245 | parts = action.split(":") 246 | if len(parts) != 2: 247 | raise ValueError("Action should be in form service:action") 248 | prefix = parts[0] 249 | unexpanded_action = parts[1] 250 | 251 | actions = [] 252 | service_match = None 253 | for service in iam_definition: 254 | if service["prefix"] == prefix.lower() or prefix == "*": 255 | service_match = service 256 | 257 | if len(service["privileges"]) == 0 and prefix != "*": 258 | # Service has no privileges, so the action must be * 259 | # For example iq:* 260 | if unexpanded_action.lower() == "*": 261 | return [] 262 | 263 | for privilege in service["privileges"]: 264 | if fnmatch.fnmatchcase( 265 | privilege["privilege"].lower(), unexpanded_action.lower() 266 | ): 267 | actions.append( 268 | { 269 | "service": service_match["prefix"], 270 | "action": privilege["privilege"], 271 | } 272 | ) 273 | 274 | if not service_match and raise_exceptions: 275 | raise UnknownPrefixException("Unknown prefix {}".format(prefix)) 276 | 277 | if len(actions) == 0 and raise_exceptions: 278 | raise UnknownActionException( 279 | "Unknown action {}:{}".format(prefix, unexpanded_action) 280 | ) 281 | 282 | return actions 283 | 284 | 285 | def get_resource_type_matches_from_arn(arn): 286 | """Given an ARN such as "arn:aws:s3:::mybucket", find resource types that match it. 287 | This would return: 288 | [ 289 | "resource": { 290 | "arn": "arn:${Partition}:s3:::${BucketName}", 291 | "condition_keys": [], 292 | "resource": "bucket" 293 | }, 294 | "service": { 295 | "service_name": "Amazon S3", 296 | "privileges": [...] 297 | ... 298 | } 299 | ] 300 | """ 301 | matches = [] 302 | for service in iam_definition: 303 | for resource in service["resources"]: 304 | arn_format = re.sub(r"\$\{.*?\}", "*", resource["arn"]) 305 | if is_arn_match(resource["resource"], arn, arn_format): 306 | matches.append({"resource": resource, "service": service}) 307 | return matches 308 | 309 | 310 | def get_privilege_matches_for_resource_type(resource_type_matches): 311 | """Given the response from get_resource_type_matches_from_arn(...), this will identify the relevant privileges.""" 312 | privilege_matches = [] 313 | for match in resource_type_matches: 314 | for privilege in match["service"]["privileges"]: 315 | for resource_type_dict in privilege["resource_types"]: 316 | resource_type = resource_type_dict["resource_type"].replace("*", "") 317 | if resource_type == match["resource"]["resource"]: 318 | privilege_matches.append( 319 | { 320 | "privilege_prefix": match["service"]["prefix"], 321 | "privilege_name": privilege["privilege"], 322 | "resource_type": resource_type, 323 | } 324 | ) 325 | 326 | return privilege_matches 327 | 328 | 329 | # Import moved here to deal with cyclic dependency 330 | from .policy import Policy 331 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | parliament is an AWS IAM linting library. It reviews policies looking for problems such as: 2 | - malformed json 3 | - missing required elements 4 | - incorrect prefix and action names 5 | - incorrect resources or conditions for the actions provided 6 | - type mismatches 7 | - bad policy patterns 8 | 9 | This library duplicates (and adds to!) much of the functionality in the web console page when reviewing IAM policies in the browser. We wanted that functionality as a library. 10 | 11 | [demo](https://parliament.summitroute.com/) 12 | 13 | # Installation 14 | ``` 15 | pip install parliament 16 | ``` 17 | 18 | # Usage 19 | ``` 20 | cat > test.json << EOF 21 | { 22 | "Version": "2012-10-17", 23 | "Statement": { 24 | "Effect": "Allow", 25 | "Action":["s3:GetObject"], 26 | "Resource": ["arn:aws:s3:::bucket1"] 27 | } 28 | } 29 | EOF 30 | 31 | parliament --file test.json 32 | ``` 33 | 34 | This will output: 35 | ``` 36 | MEDIUM - No resources match for the given action - - [{'action': 's3:GetObject', 'required_format': 'arn:*:s3:::*/*'}] - {'actions': ['s3:GetObject'], 'filepath': 'test.json'} 37 | ``` 38 | 39 | This example is showing that the action s3:GetObject requires a resource matching an object path (ie. it must have a "/" in it). 40 | 41 | The different input types allowed include: 42 | - --file: Filename 43 | - --directory: A directory path, for exmaple: `--directory . --include_policy_extension json --exclude_pattern ".*venv.*"` 44 | - --aws-managed-policies: For use specifically with the repo https://github.com/z0ph/aws_managed_policies 45 | - --auth-details-file: For use with the file returned by "aws iam get-account-authorization-details" 46 | - --string: Provide a string such as '{"Version": "2012-10-17","Statement": {"Effect": "Allow","Action": ["s3:GetObject", "s3:PutBucketPolicy"],"Resource": ["arn:aws:s3:::bucket1", "arn:aws:s3:::bucket2/*"]}}' 47 | 48 | ## Using parliament as a library 49 | Parliament was meant to be used a library in other projects. A basic example follows. 50 | 51 | ``` 52 | from parliament import analyze_policy_string 53 | 54 | analyzed_policy = analyze_policy_string(policy_doc) 55 | for f in analyzed_policy.findings: 56 | print(f) 57 | ``` 58 | 59 | ## Custom config file 60 | You may decide you want to change the severity of a finding, the text associated with it, or that you want to ignore certain types of findings. To support this, you can provide an override config file. First, create a test.json file: 61 | 62 | ``` 63 | { 64 | "Version": "2012-10-17", 65 | "Id": "123", 66 | "Statement": [ 67 | { 68 | "Effect": "Allow", 69 | "Action": "s3:abc", 70 | "Resource": "*" 71 | }, 72 | { 73 | "Effect": "Allow", 74 | "Action": ["s3:*", "ec2:*"], 75 | "Resource": "arn:aws:s3:::test/*" 76 | } 77 | ] 78 | } 79 | ``` 80 | 81 | This will have two findings: 82 | - LOW - Unknown action - - Unknown action s3:abc 83 | - MEDIUM - No resources match for the given action 84 | 85 | The second finding will be very long, because every s3 and ec2 action are expanded and most are incorrect for the S3 object path resource that is provided. 86 | 87 | Now create a file `config_override.yaml` with the following contents: 88 | 89 | ``` 90 | UNKNOWN_ACTION: 91 | severity: MEDIUM 92 | ignore_locations: 93 | - filepath: 94 | - testa.json 95 | - .*.py 96 | 97 | RESOURCE_MISMATCH: 98 | ignore_locations: 99 | - actions: ".*s3.*" 100 | ``` 101 | 102 | Now run: `parliament --file test.json --config config_override.yaml` 103 | You will have only one output: `MEDIUM - Unknown action - - Unknown action s3:abc` 104 | 105 | Notice that the severity of that finding has been changed from a `LOW` to a `MEDIUM`. Also, note that the other finding is gone, because the previous `RESOURCE_MISMATCH` finding contained an `actions` element of `["s3:*", "ec2:*"]`. The ignore logic converts the value you provide, and the finding value to lowercase, 106 | and then uses your string as a regex. This means that we are checking if `s3` is in `str(["s3:*", "ec2:*"])` 107 | 108 | Now rename `test.json` to `testa.json` and rerun the command. You will no longer have any output, because we are filtering based on the filepath for `UNKNOWN_ACTION` and filtering for any filepaths that contain `testa.json` or `.py`. 109 | 110 | You can also check the exit status with `echo $?` and see the exit status is 0 when there are no findings. The exit status will be non-zero when there are findings. 111 | 112 | You can have multiple elements in `ignore_locations`. For example, 113 | ``` 114 | - filepath: "test.json" 115 | action: "s3:GetObject" 116 | resource: 117 | - "a" 118 | - "b" 119 | - resource: "c.*" 120 | ``` 121 | 122 | Assuming the finding has these types of values in the `location` element, this will ignore any finding that matches the filepath to "test.json" AND action to "s3:GetObject" AND the resource to "a" OR "b". It will also ignore a resource that matches "c.*". 123 | 124 | # Custom auditors 125 | 126 | ## Private Auditors 127 | This section will show how to create your own private auditor to look for any policies that grant access to either of the sensitive buckets `secretbucket` and `othersecretbucket`. 128 | 129 | Create a file `test.json` with contents: 130 | ``` 131 | { 132 | "Version": "2012-10-17", 133 | "Statement": { 134 | "Effect": "Allow", 135 | "Action": "s3:GetObject", 136 | "Resource": "arn:aws:s3:::secretbucket/*" 137 | } 138 | } 139 | ``` 140 | This is an example of the policy we want to alert on. That policy will normally not generate any findings. 141 | 142 | Create the file `config_override.yaml` with contents: 143 | 144 | ``` 145 | SENSITIVE_BUCKET_ACCESS: 146 | title: Sensitive bucket access 147 | description: Allows read access to an important S3 bucket 148 | severity: MEDIUM 149 | group: CUSTOM 150 | ``` 151 | 152 | In the `parliament` directory (that contains iam_definition.json), create the directory `private_auditors` and the file `parliament/private_auditors/sensitive_bucket_access.py` 153 | 154 | 155 | ``` 156 | from parliament import is_arn_match, expand_action 157 | 158 | def audit(policy): 159 | action_resources = {} 160 | for action in expand_action("s3:*"): 161 | # Iterates through a list of containing elements such as 162 | # {'service': 's3', 'action': 'GetObject'} 163 | action_name = "{}:{}".format(action["service"], action["action"]) 164 | action_resources[action_name] = policy.get_allowed_resources(action["service"], action["action"]) 165 | 166 | for action_name in action_resources: 167 | resources = action_resources[action_name] 168 | for r in resources: 169 | if is_arn_match("object", "arn:aws:s3:::secretbucket*", r) or is_arn_match("object", "arn:aws:s3:::othersecretbucket*", r): 170 | policy.add_finding("SENSITIVE_BUCKET_ACCESS", location={"action": action_name, "resource": r}) 171 | ``` 172 | 173 | This will look for any s3 access to the buckets of interest, including not only object access such as `s3:GetObject` access, but also things like `s3:PutBucketAcl`. 174 | 175 | Running against our test file, we'll get the following output: 176 | ``` 177 | ./bin/parliament --file test.json --config config_override.yaml --json 178 | 179 | {"issue": "SENSITIVE_BUCKET_ACCESS", "title": "Sensitive bucket access", "severity": "MEDIUM", "description": "Allows read access to an important S3 bucket", "detail": "", "location": {"action": "s3:GetObject", "resource": "arn:aws:s3:::secretbucket/*", "filepath": "test.json"}} 180 | ``` 181 | 182 | You can now decide if this specific situation is ok for you, and choose to ignore it by modifying the 183 | `config_override.yaml` to include: 184 | 185 | ``` 186 | ignore_locations: 187 | - filepath: "test.json" 188 | action: "s3:GetObject" 189 | resource: "arn:aws:s3:::secretbucket/\\*" 190 | ``` 191 | 192 | Notice that I had to double-escape the escape asterisk. If another policy is created, say in test2.json that you'd like to ignore, you can just append those values to the list: 193 | 194 | ``` 195 | ignore_locations: 196 | - filepath: "test.json" 197 | action: "s3:GetObject" 198 | resource: "arn:aws:s3:::secretbucket/\\*" 199 | - filepath: "test2.json" 200 | action: "s3:GetObject" 201 | resource: "arn:aws:s3:::secretbucket/\\*" 202 | ``` 203 | 204 | Or you could do: 205 | 206 | ``` 207 | ignore_locations: 208 | - filepath: 209 | - "test.json" 210 | - "test2.json" 211 | action: "s3:GetObject" 212 | resource: "arn:aws:s3:::secretbucket/\\*" 213 | ``` 214 | 215 | ## Unit tests for private auditors 216 | 217 | To create unit tests for our new private auditor, create the directory `./parliament/private_auditors/tests/` and create the file `test_sensitive_bucket_access.py` there with the contents: 218 | 219 | ``` 220 | from parliament import analyze_policy_string 221 | 222 | class TestCustom(): 223 | """Test class for custom auditor""" 224 | 225 | def test_my_auditor(self): 226 | policy = analyze_policy_string( 227 | """{ 228 | "Version": "2012-10-17", 229 | "Statement": { 230 | "Effect": "Allow", 231 | "Action": "s3:GetObject", 232 | "Resource": "arn:aws:s3:::secretbucket/*"}}""", 233 | ) 234 | assert_equal(len(policy.findings), 1) 235 | ``` 236 | 237 | That test ensures that for the given policy (which is granting read access to our secret bucket) one finding will be created. 238 | 239 | Now when you run `./tests/scripts/unit_tests.sh` there should be one additional test run. 240 | 241 | ## Using Private auditors in a custom folder 242 | You can store your private auditors in a folder and use them from there instead of the parliament directory (that contains iam_definition.json). 243 | 1. From the CLI 244 | 245 | If you're running the command line you can store your private auditors in another folder and define the parameter --private_auditors. Example: 246 | ```bash 247 | parliament --file test.json --config config_override.yaml --private_auditors {my_custom_folder} --json 248 | ``` 249 | 2. As a library 250 | 251 | Consider the following project structure: 252 | ```bash 253 | test_project 254 | ├── policy_validator.py 255 | ├── private_auditors_folder 256 | │   ├── config_override.yaml 257 | │   └── sensitive_bucket_access.py 258 | └── test.json 259 | ``` 260 | 261 | In addition to what you did to run parliament as a library, you'll need to pass to analyze_policy_string method: 262 | - the config override yaml file path 263 | - private_auditors_custom_path path 264 | 265 | Inside policy_validator, I have to read the policy test file as string: 266 | ```python 267 | def read_file(): 268 | with open("test.json", "r", encoding="utf-8") as json_test_file: 269 | data = json.load(json_test_file) 270 | return json.dumps(data) 271 | 272 | my_test_file = read_file() 273 | ``` 274 | Define the location of private auditors path: 275 | ```python 276 | private_auditors_path = ( 277 | Path(os.path.abspath(__file__)).parent / "private_auditors_folder" 278 | ) 279 | ``` 280 | Define the location of config override path: 281 | ```python 282 | config_override_path = ( 283 | Path(os.path.abspath(__file__)).parent 284 | / "private_auditors_folder" 285 | / "config_override.yaml" 286 | ) 287 | ``` 288 | Call analyze_policy_string with the specified config override and private auditors custom folder: 289 | ```python 290 | parliament.analyze_policy_string( 291 | my_test_file, 292 | config=config_override_path, private_auditors_custom_path=private_auditors_path, 293 | ) 294 | ``` 295 | You should be able to read the results using: 296 | ```python 297 | for f in analyzed_policy.findings: 298 | print(f) 299 | ``` 300 | 301 | ## Community auditors 302 | 303 | * The process for community auditors is the same as private auditors, except that: 304 | - The community auditors are located in the `parliament/community_auditors` folder instead of the `parliament/private_auditors` 305 | - The community auditors are bundled with the package and users can include them in their testing by specifying `--include-community-auditors` flag. 306 | 307 | # Development 308 | Setup a testing environment 309 | ``` 310 | python3 -m venv ./venv && source venv/bin/activate 311 | pip3 install -r requirements.txt 312 | ``` 313 | 314 | Run unit tests with: 315 | ``` 316 | make test 317 | ``` 318 | 319 | Run locally as: 320 | ``` 321 | bin/parliament 322 | ``` 323 | 324 | ## Updating the privilege info 325 | The IAM data is obtained from scraping the docs [here](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_actions-resources-contextkeys.html) and parsing this information with beautifulsoup using `./utils/update_iam_data.py`. 326 | 327 | Use a script like this to generate a new `iam_definition.json`. 328 | 329 | ```bash 330 | python3 -m venv ./venv 331 | source ./venv/bin/activate 332 | pip install requests beautifulsoup4 333 | wget "https://raw.githubusercontent.com/duo-labs/parliament/main/utils/update_iam_data.py" 334 | python ./update_iam_data.py > iam_definition.json 335 | ``` 336 | 337 | Find the Python environment in which you installed Parliament and overwrite the old `iam_definition.json`. 338 | 339 | # Projects that use Parliament 340 | - [CloudMapper](https://github.com/duo-labs/cloudmapper): Has functionality to audit AWS environments and will audit the IAM policies as part of that. 341 | - [tf-parliament](https://github.com/rdkls/tf-parliament): Runs Parliament against terraform files 342 | - [iam-lint](https://github.com/xen0l/iam-lint): Github action for linting AWS IAM policy documents 343 | - [Paco](https://paco-cloud.io): Cloud orchestration tool that integrates Parliament as a library to verify a project's IAM Policies and warns about findings. 344 | - [ConsoleMe](https://github.com/Netflix/consoleme): Web service that makes administering and using multiple AWS accounts easier, that uses Parliament for linting IAM Policies 345 | - [iamlive](https://github.com/iann0036/iamlive): Generates IAM Policies from observing AWS calls through client-side monitoring 346 | -------------------------------------------------------------------------------- /utils/update_iam_data.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from os import listdir 4 | from os.path import isfile, join 5 | import re 6 | import json 7 | import requests 8 | from pathlib import Path 9 | 10 | from bs4 import BeautifulSoup 11 | 12 | # Code for get_links_from_base_actions_resources_conditions_page and update_html_docs_directory borrowed from https://github.com/salesforce/policy_sentry/blob/1126f174f49050b95bddf7549aedaf11fa51a50b/policy_sentry/scraping/awsdocs.py#L31 13 | BASE_DOCUMENTATION_URL = "https://docs.aws.amazon.com/service-authorization/latest/reference/reference_policies_actions-resources-contextkeys.html" 14 | 15 | 16 | def get_links_from_base_actions_resources_conditions_page(): 17 | """Gets the links from the actions, resources, and conditions keys page, and returns their filenames.""" 18 | 19 | headers = { 20 | 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36' 21 | } 22 | html = requests.get(BASE_DOCUMENTATION_URL, headers=headers) 23 | soup = BeautifulSoup(html.content, "html.parser") 24 | html_filenames = [] 25 | for i in soup.find("div", {"class": "highlights"}).findAll("a"): 26 | html_filenames.append(i["href"]) 27 | return html_filenames 28 | 29 | 30 | def update_html_docs_directory(html_docs_destination): 31 | """ 32 | Updates the HTML docs from remote location to either (1) local directory 33 | (i.e., this repository, or (2) the config directory 34 | :return: 35 | """ 36 | link_url_prefix = ( 37 | "https://docs.aws.amazon.com/service-authorization/latest/reference/" 38 | ) 39 | initial_html_filenames_list = ( 40 | get_links_from_base_actions_resources_conditions_page() 41 | ) 42 | # Remove the relative path so we can download it 43 | html_filenames = [sub.replace("./", "") for sub in initial_html_filenames_list] 44 | pos = 0 45 | 46 | for page in html_filenames: 47 | pos += 1 48 | print(f"Downloading {pos} of {len(html_filenames)} - {page}") 49 | 50 | response = requests.get(link_url_prefix + page, allow_redirects=False) 51 | # Replace the CSS stuff. Basically this: 52 | """ 53 | 54 | 55 | 56 | 57 | 58 | 59 | list_amazonkendra.html downloaded 60 | """ 61 | soup = BeautifulSoup(response.content, "html.parser") 62 | for link in soup.find_all("link"): 63 | if link.get("href").startswith("/"): 64 | temp = link.attrs["href"] 65 | link.attrs["href"] = link.attrs["href"].replace( 66 | temp, f"https://docs.aws.amazon.com{temp}" 67 | ) 68 | 69 | for script in soup.find_all("script"): 70 | try: 71 | if "src" in script.attrs: 72 | if script.get("src").startswith("/"): 73 | temp = script.attrs["src"] 74 | script.attrs["src"] = script.attrs["src"].replace( 75 | temp, f"https://docs.aws.amazon.com{temp}" 76 | ) 77 | except TypeError as t_e: 78 | print(t_e) 79 | print(script) 80 | except AttributeError as a_e: 81 | print(a_e) 82 | print(script) 83 | 84 | with open(html_docs_destination + page, "w") as file: 85 | # file.write(str(soup.html)) 86 | file.write(str(soup.prettify())) 87 | file.close() 88 | # print(f"{page} downloaded") 89 | 90 | 91 | def chomp(string): 92 | """This chomp cleans up all white-space, not just at the ends""" 93 | string = str(string) 94 | response = string.replace("\n", " ") # Convert line ends to spaces 95 | response = re.sub( 96 | " [ ]*", " ", response 97 | ) # Truncate multiple spaces to single space 98 | response = re.sub("^[ ]*", "", response) # Clean start 99 | return re.sub("[ ]*$", "", response) # Clean end 100 | 101 | 102 | def no_white_space(string): 103 | string = str(string) 104 | response = string.replace("\n", "") # Convert line ends to spaces 105 | response = re.sub("[ ]*", "", response) 106 | return response 107 | 108 | 109 | def header_matches(string, table): 110 | headers = [chomp(str(x)).lower() for x in table.find_all("th")] 111 | match_found = False 112 | for header in headers: 113 | if string in header: 114 | match_found = True 115 | if not match_found: 116 | return False 117 | return True 118 | 119 | 120 | # Create the docs directory 121 | DOCS_DIR = "docs" 122 | print("Downloading html pages to:", DOCS_DIR) 123 | Path(DOCS_DIR).mkdir(parents=True, exist_ok=True) 124 | update_html_docs_directory(f"{DOCS_DIR}/") 125 | 126 | mypath = f"./{DOCS_DIR}/" 127 | schema = [] 128 | 129 | # for filename in ['list_amazons3.html']: 130 | for filename in [f for f in listdir(mypath) if isfile(join(mypath, f))]: 131 | if not filename.startswith("list_"): 132 | continue 133 | 134 | with open(mypath + filename, "r") as f: 135 | soup = BeautifulSoup(f.read(), "html.parser") 136 | main_content = soup.find(id="main-content") 137 | if main_content is None: 138 | continue 139 | 140 | # Get service name 141 | title = main_content.find("h1", class_="topictitle").text 142 | title = re.sub(".*Actions, resources, and condition keys for *", "", str(title)) 143 | title = title.replace("", "") 144 | service_name = chomp(title) 145 | 146 | prefix = "" 147 | for c in main_content.find("h1", class_="topictitle").parent.children: 148 | if "prefix" in str(c): 149 | prefix = str(c) 150 | prefix = prefix.split('')[1] 151 | prefix = chomp(prefix.split("")[0]) 152 | break 153 | 154 | service_schema = { 155 | "service_name": service_name, 156 | "prefix": prefix, 157 | "privileges": [], 158 | "resources": [], 159 | "conditions": [], 160 | } 161 | 162 | tables = main_content.find_all("div", class_="table-contents") 163 | 164 | for table in tables: 165 | # There can be 3 tables, the actions table, an ARN table, and a condition key table 166 | # Example: https://docs.aws.amazon.com/IAM/latest/UserGuide/list_awssecuritytokenservice.html 167 | if not header_matches("actions", table) or not header_matches( 168 | "description", table 169 | ): 170 | continue 171 | 172 | rows = table.find_all("tr") 173 | row_number = 0 174 | while row_number < len(rows): 175 | row = rows[row_number] 176 | 177 | cells = row.find_all("td") 178 | if len(cells) == 0: 179 | # Skip the header row, which has th, not td cells 180 | row_number += 1 181 | continue 182 | 183 | if len(cells) != 6: 184 | # Sometimes the privilege contains Scenarios, and I don't know how to handle this 185 | break 186 | # raise Exception("Unexpected format in {}: {}".format(prefix, row)) 187 | 188 | # See if this cell spans multiple rows 189 | rowspan = 1 190 | if "rowspan" in cells[0].attrs: 191 | rowspan = int(cells[0].attrs["rowspan"]) 192 | 193 | priv = "" 194 | # Get the privilege 195 | for link in cells[0].find_all("a"): 196 | if "href" not in link.attrs: 197 | # Skip the tags 198 | continue 199 | priv = chomp(link.text) 200 | if priv == "": 201 | priv = chomp(cells[0].text) 202 | 203 | description = chomp(cells[1].text) 204 | access_level = chomp(cells[2].text) 205 | 206 | resource_types = [] 207 | resource_cell = 3 208 | 209 | while rowspan > 0: 210 | if len(cells) == 3 or len(cells) == 6: 211 | # ec2:RunInstances contains a few "scenarios" which start in the 212 | # description field, len(cells) is 5. 213 | # I'm ignoring these as I don't know how to handle them. 214 | # These include things like "EC2-Classic-InstanceStore" and 215 | # "EC2-VPC-InstanceStore-Subnet" 216 | 217 | resource_type = chomp(cells[resource_cell].text) 218 | condition_keys_element = cells[resource_cell + 1] 219 | condition_keys = [] 220 | if condition_keys_element.text != "": 221 | for key_element in condition_keys_element.find_all("p"): 222 | condition_keys.append(chomp(key_element.text)) 223 | 224 | dependent_actions_element = cells[resource_cell + 2] 225 | dependent_actions = [] 226 | if dependent_actions_element.text != "": 227 | for action_element in dependent_actions_element.find_all( 228 | "p" 229 | ): 230 | dependent_actions.append(chomp(action_element.text)) 231 | resource_types.append( 232 | { 233 | "resource_type": resource_type, 234 | "condition_keys": condition_keys, 235 | "dependent_actions": dependent_actions, 236 | } 237 | ) 238 | rowspan -= 1 239 | if rowspan > 0: 240 | row_number += 1 241 | resource_cell = 0 242 | row = rows[row_number] 243 | cells = row.find_all("td") 244 | 245 | if "[permission only]" in priv: 246 | priv = priv.split(" ")[0] 247 | 248 | privilege_schema = { 249 | "privilege": priv, 250 | "description": description, 251 | "access_level": access_level, 252 | "resource_types": resource_types, 253 | } 254 | 255 | service_schema["privileges"].append(privilege_schema) 256 | row_number += 1 257 | 258 | # Get resource table 259 | for table in tables: 260 | if not header_matches("resource types", table) or not header_matches( 261 | "arn", table 262 | ): 263 | continue 264 | 265 | rows = table.find_all("tr") 266 | for row in rows: 267 | cells = row.find_all("td") 268 | if len(cells) == 0: 269 | # Skip the header row, which has th, not td cells 270 | continue 271 | 272 | if len(cells) != 3: 273 | raise Exception( 274 | "Unexpected number of resource cells {} in {}".format( 275 | len(cells), filename 276 | ) 277 | ) 278 | 279 | resource = chomp(cells[0].text) 280 | 281 | arn = no_white_space(cells[1].text) 282 | conditions = [] 283 | for condition in cells[2].find_all("p"): 284 | conditions.append(chomp(condition.text)) 285 | 286 | service_schema["resources"].append( 287 | {"resource": resource, "arn": arn, "condition_keys": conditions} 288 | ) 289 | 290 | # Get condition keys table 291 | for table in tables: 292 | if not ( 293 | header_matches(" condition keys ", table) 294 | and header_matches(" type ", table) 295 | ): 296 | continue 297 | 298 | rows = table.find_all("tr") 299 | for row in rows: 300 | cells = row.find_all("td") 301 | 302 | if len(cells) == 0: 303 | # Skip the header row, which has th, not td cells 304 | continue 305 | 306 | if len(cells) != 3: 307 | raise Exception( 308 | "Unexpected number of condition cells {} in {}".format( 309 | len(cells), filename 310 | ) 311 | ) 312 | 313 | condition = no_white_space(cells[0].text) 314 | description = chomp(cells[1].text) 315 | value_type = chomp(cells[2].text) 316 | 317 | service_schema["conditions"].append( 318 | { 319 | "condition": condition, 320 | "description": description, 321 | "type": value_type, 322 | } 323 | ) 324 | schema.append(service_schema) 325 | 326 | 327 | schema.sort(key=lambda x: x["prefix"]) 328 | 329 | print(f"--------------------") 330 | # write json to file 331 | iam_definition_file_path = "parliament/iam_definition.json" 332 | with open( 333 | "parliament/iam_definition.json", "w", encoding="utf-8" 334 | ) as iam_definition_file: 335 | iam_definition_file.write(json.dumps(schema, indent=2, sort_keys=True)) 336 | iam_definition_file.close() 337 | 338 | print(f"Updated {iam_definition_file_path}") 339 | -------------------------------------------------------------------------------- /parliament/policy.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import logging 3 | import os 4 | import pkgutil 5 | import sys 6 | import jsoncfg 7 | from glob import glob 8 | from pathlib import Path 9 | 10 | from . import expand_action 11 | from .finding import Finding 12 | from .misc import make_list 13 | from .statement import Statement 14 | 15 | 16 | class Policy: 17 | _findings = [] 18 | policy_json = None 19 | version = None 20 | statements = [] 21 | policy = None 22 | 23 | def __init__(self, policy_json, filepath=None, config=None): 24 | self._findings = [] 25 | self.statements = [] 26 | self.policy_json = policy_json 27 | self.filepath = filepath 28 | self.config = config if config else {} 29 | 30 | def add_finding(self, finding, detail="", location={}): 31 | if type(location) == tuple and "jsoncfg.config_classes" in str( 32 | type(location[1]) 33 | ): 34 | location_data = {} 35 | location_data["string"] = location[0] 36 | location_data["lineno"] = jsoncfg.node_location(location[1])[0] 37 | location_data["column"] = jsoncfg.node_location(location[1])[1] 38 | location = location_data 39 | elif "ConfigJSONScalar" in str(type(location)): 40 | location_data = {} 41 | location_data["string"] = location.value 42 | location_data["lineno"] = jsoncfg.node_location(location).line 43 | location_data["column"] = jsoncfg.node_location(location).column 44 | location = location_data 45 | if "filepath" not in location: 46 | location["filepath"] = self.filepath 47 | self._findings.append(Finding(finding, detail, location)) 48 | 49 | @property 50 | def findings(self): 51 | all_findings = [] 52 | all_findings.extend(self._findings) 53 | 54 | for stmt in self.statements: 55 | for finding in stmt.findings: 56 | if "filepath" not in finding.location: 57 | finding.location["filepath"] = self.filepath 58 | 59 | all_findings.append(finding) 60 | 61 | return all_findings 62 | 63 | @property 64 | def finding_ids(self): 65 | finding_ids = set() 66 | for finding in self.findings: 67 | finding_ids.add(finding.issue) 68 | return finding_ids 69 | 70 | @property 71 | def is_valid(self): 72 | for stmt in self.statements: 73 | if not stmt.is_valid: 74 | return False 75 | return True 76 | 77 | def get_references(self, privilege_prefix, privilege_name): 78 | """ 79 | Identify all statements that reference this privilege, 80 | then return a dictionary where the keys are the resources referenced by the statements, 81 | and the values are a list of the statements 82 | """ 83 | references = {} 84 | for stmt in self.statements: 85 | stmt_relevant_resources = stmt.get_resources_for_privilege( 86 | privilege_prefix, privilege_name 87 | ) 88 | for resource in stmt_relevant_resources: 89 | references[resource] = references.get(resource, []) 90 | references[resource].append(stmt) 91 | return references 92 | 93 | def get_allowed_actions(self, raise_exceptions=True): 94 | actions_referenced = set() 95 | for stmt in self.statements: 96 | actions = make_list(stmt.stmt["Action"]) 97 | for action in actions: 98 | expanded_actions = expand_action(action.value, raise_exceptions) 99 | for expanded_action in expanded_actions: 100 | actions_referenced.add( 101 | "{}:{}".format( 102 | expanded_action["service"], expanded_action["action"] 103 | ) 104 | ) 105 | 106 | # actions_referenced is now a set like: {'lambda:UpdateFunctionCode', 'glue:UpdateDevEndpoint'} 107 | # We need to identify which of these are actually allowed though, as some of those could just be a deny 108 | # Worst case scenario though, we have a list of every action if someone included Action '*' 109 | 110 | allowed_actions = [] 111 | for action in actions_referenced: 112 | parts = action.split(":") 113 | allowed_resources = self.get_allowed_resources(parts[0], parts[1]) 114 | if len(allowed_resources) > 0: 115 | action = action.lower() 116 | allowed_actions.append(action) 117 | return allowed_actions 118 | 119 | def get_allowed_resources(self, privilege_prefix, privilege_name): 120 | """ 121 | Given a privilege like s3:GetObject, identify all the resources (if any), 122 | this is allowed to be used with. 123 | 124 | Examples, assuming given "s3" "GetObject": 125 | - With a policy with s3:* on "*", this would return "*" 126 | - With a policy with s3:* on ["arn:aws:s3:::examplebucket", "arn:aws:s3:::examplebucket/*"], 127 | this would only return "arn:aws:s3:::examplebucket/*" because that is the only object resource. 128 | """ 129 | 130 | def __is_allowed(stmts): 131 | """ 132 | Given statements that are all relevant to the same resource and privilege, 133 | (meaning each statement must have an explicit allow or deny on the privilege) 134 | determine if it is allowed, which means no Deny effects. 135 | """ 136 | has_allow = False 137 | for stmt in stmts: 138 | if stmt.effect_allow: 139 | has_allow = True 140 | else: 141 | # If there is a Condition in the Deny, we don't count this as Deny'ing the action 142 | # entirely so skip it 143 | if "Condition" in stmt.stmt: 144 | continue 145 | return False 146 | return has_allow 147 | 148 | allowed_resources = [] 149 | all_references = self.get_references(privilege_prefix, privilege_name) 150 | for resource in all_references: 151 | resource_is_allowed = __is_allowed(all_references[resource]) 152 | 153 | # To avoid situations where we have an allow on a specific resource, but a deny 154 | # on *, I'm making a special case here 155 | # I should do regex intersections across each resource, but this will avoid 156 | # common situations for now 157 | if resource == "*" and not resource_is_allowed: 158 | # Only apply this case when the deny statement has no condition 159 | for stmt in all_references[resource]: 160 | if not stmt.effect_allow and "Condition" not in stmt.stmt: 161 | return [] 162 | 163 | if resource_is_allowed: 164 | allowed_resources.append(resource) 165 | 166 | return allowed_resources 167 | 168 | def check_for_bad_patterns(self): 169 | """ 170 | Look for privileges across multiple statements that result in problems such as privilege escalation. 171 | """ 172 | 173 | def check_bucket_privesc(refs, bucket_privilege, object_privilege): 174 | # If the bucket privilege exists for a bucket, but not the object privilege for objects 175 | # in that bucket then the bucket privilege can be abused to get that object privilege 176 | for resource in refs[bucket_privilege]: 177 | if not ( 178 | resource in refs[object_privilege] 179 | or resource + "/*" in refs[object_privilege] 180 | ): 181 | self.add_finding( 182 | "RESOURCE_POLICY_PRIVILEGE_ESCALATION", 183 | detail="Possible resource policy privilege escalation on {} due to s3:{} not being allowed, but does allow s3:{}".format( 184 | resource, object_privilege, bucket_privilege 185 | ), 186 | ) 187 | 188 | # Get the resource references we'll be using 189 | refs = {} 190 | for priv in [ 191 | "PutBucketPolicy", 192 | "PutBucketAcl", 193 | "PutLifecycleConfiguration", 194 | "PutObject", 195 | "GetObject", 196 | "DeleteObject", 197 | ]: 198 | refs[priv] = self.get_allowed_resources("s3", priv) 199 | 200 | # Check each bad combination. If the bucket level privilege is allowed, 201 | # but not the object level privilege, then we likely have a privilege escalation issue. 202 | check_bucket_privesc(refs, "PutBucketPolicy", "PutObject") 203 | check_bucket_privesc(refs, "PutBucketPolicy", "GetObject") 204 | check_bucket_privesc(refs, "PutBucketPolicy", "DeleteObject") 205 | 206 | check_bucket_privesc(refs, "PutBucketAcl", "PutObject") 207 | check_bucket_privesc(refs, "PutBucketAcl", "GetObject") 208 | check_bucket_privesc(refs, "PutBucketAcl", "DeleteObject") 209 | 210 | check_bucket_privesc(refs, "PutLifecycleConfiguration", "DeleteObject") 211 | 212 | def analyze( 213 | self, 214 | ignore_private_auditors=False, 215 | private_auditors_custom_path=None, 216 | include_community_auditors=False, 217 | ): 218 | """ 219 | Returns False if this policy is so broken that it couldn't be analyzed further. 220 | On True, it may still have findings. 221 | 222 | In either case, it will create Findings if there are any. 223 | """ 224 | 225 | # Check no unknown elements exist 226 | element_strings = [] 227 | for element in self.policy_json: 228 | element_strings.append(element[0]) 229 | if element[0] not in ["Version", "Statement", "Id"]: 230 | self.add_finding( 231 | "MALFORMED", 232 | detail="Policy contains an unknown element", 233 | location=element, 234 | ) 235 | return False 236 | 237 | if "Statement" not in element_strings: 238 | self.add_finding( 239 | "MALFORMED", 240 | detail="Policy does not contain a required element Statement", 241 | ) 242 | return False 243 | 244 | # Check Version 245 | if not jsoncfg.node_exists(self.policy_json["Version"]): 246 | self.add_finding("NO_VERSION") 247 | else: 248 | self.version = self.policy_json["Version"].value 249 | 250 | if self.version not in ["2012-10-17", "2008-10-17"]: 251 | self.add_finding( 252 | "INVALID_VERSION", location=self.policy_json["Version"] 253 | ) 254 | elif self.version != "2012-10-17": 255 | # TODO I should have a check so that if an older version is being used, 256 | # and a variable is detected, it should be marked as higher severity. 257 | self.add_finding("OLD_VERSION", location=self.policy_json["Version"]) 258 | 259 | # Check Statements 260 | if not jsoncfg.node_exists(self.policy_json["Statement"]): 261 | self.add_finding( 262 | "MALFORMED", detail="Policy does not contain a Statement element" 263 | ) 264 | return False 265 | 266 | sids = {} 267 | stmts_json = make_list(self.policy_json["Statement"]) 268 | for stmt_json in stmts_json: 269 | stmt = Statement(stmt_json) 270 | self.statements.append(stmt) 271 | 272 | # Report duplicate Statement Ids 273 | if stmt.sid is not None: 274 | sid = stmt.sid 275 | sids.setdefault(sid, 0) 276 | sids[sid] += 1 277 | 278 | # Only report the finding once, when encountering the first duplicate 279 | if sids[sid] == 2: 280 | self.add_finding( 281 | "DUPLICATE_SID", 282 | detail="Duplicate Statement Id '{}' in policy".format(sid), 283 | ) 284 | 285 | if not self.is_valid: 286 | # Do not continue. Further checks will not work with invalid statements. 287 | return False 288 | 289 | # Look for bad patterns 290 | self.check_for_bad_patterns() 291 | 292 | if not ignore_private_auditors: 293 | # Import any private auditing modules 294 | private_auditors_directory = "private_auditors" 295 | private_auditors_directory_path = ( 296 | Path(os.path.abspath(__file__)).parent / private_auditors_directory 297 | ) 298 | 299 | if private_auditors_custom_path is not None: 300 | private_auditors_directory_path = private_auditors_custom_path 301 | # Ensure we can import from this directory 302 | sys.path.append(".") 303 | 304 | private_auditors = {} 305 | 306 | for path in glob(f"{private_auditors_directory_path}/*.py"): 307 | if path.endswith("__init__.py"): 308 | continue 309 | 310 | package_name = "private_auditors" 311 | module_name = Path(path).stem 312 | 313 | module_spec = importlib.util.spec_from_file_location( 314 | f"{package_name}.{module_name}", path 315 | ) 316 | module = importlib.util.module_from_spec(module_spec) 317 | module_spec.loader.exec_module(module) 318 | 319 | private_auditors[module_name] = module 320 | 321 | if len(private_auditors) == 0 and private_auditors_custom_path is not None: 322 | raise Exception( 323 | "No private auditors found at {}".format( 324 | private_auditors_custom_path 325 | ) 326 | ) 327 | 328 | # Run them 329 | for m in private_auditors: 330 | logging.info(f"*** Checking with private auditor: {m}") 331 | private_auditors[m].audit(self) 332 | 333 | if include_community_auditors is True: 334 | # Import any private auditing modules 335 | community_auditors_directory = "community_auditors" 336 | community_auditors_directory_path = str( 337 | Path(os.path.abspath(__file__)).parent / community_auditors_directory 338 | ) 339 | 340 | community_auditors = {} 341 | for importer, name, _ in pkgutil.iter_modules( 342 | [community_auditors_directory_path] 343 | ): 344 | full_package_name = "parliament.%s.%s" % ( 345 | community_auditors_directory, 346 | name, 347 | ) 348 | 349 | path_with_dots = full_package_name.replace("/", ".") 350 | full_package_name = path_with_dots 351 | 352 | module = importlib.import_module(full_package_name) 353 | community_auditors[name] = module 354 | 355 | # Run them 356 | for m in community_auditors: 357 | logging.info(f"*** Checking with community auditor: {m}") 358 | try: 359 | community_auditors[m].audit(self) 360 | except Exception as e: 361 | self.add_finding( 362 | "EXCEPTION", detail=str(e), location={"community_auditor": m} 363 | ) 364 | 365 | return True 366 | -------------------------------------------------------------------------------- /parliament/cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import argparse 3 | import json 4 | import jsoncfg 5 | import logging 6 | import re 7 | import sys 8 | from os import walk 9 | from os.path import abspath 10 | from os.path import join 11 | from pathlib import Path 12 | 13 | from parliament import ( 14 | analyze_policy_string, 15 | enhance_finding, 16 | override_config, 17 | config, 18 | __version__, 19 | ) 20 | from parliament.misc import make_list 21 | 22 | logger = logging.getLogger(__name__) 23 | 24 | 25 | def is_finding_filtered(finding, minimum_severity="LOW"): 26 | # Return True if the finding should be filtered (ie. return False if it should be displayed) 27 | minimum_severity = minimum_severity.upper() 28 | severity_choices = ["MUTE", "INFO", "LOW", "MEDIUM", "HIGH", "CRITICAL"] 29 | if severity_choices.index(finding.severity) < severity_choices.index( 30 | minimum_severity 31 | ): 32 | return True 33 | 34 | if finding.ignore_locations: 35 | # The ignore_locations element looks like this: 36 | # 37 | # ignore_locations: 38 | # - filepath: "test.json" 39 | # action: "s3:GetObject" 40 | # resource: 41 | # - "a" 42 | # - "b" 43 | # - action: "s3:GetObject" 44 | # resource: 45 | # - "c.*" 46 | # 47 | # Assuming the finding has these types of values in the `location` element, 48 | # this will ignore any finding that matches the filepath to "test.json" 49 | # AND action to "s3:GetObject" 50 | # AND the resource to "a" OR "b" 51 | # It will also ignore a resource that matches "c.*". 52 | 53 | for ignore_location in finding.ignore_locations: 54 | all_match = True 55 | for location_type, locations_to_ignore in ignore_location.items(): 56 | has_array_match = False 57 | for location_to_ignore in make_list(locations_to_ignore): 58 | if re.fullmatch( 59 | location_to_ignore.lower(), 60 | str(finding.location.get(location_type, "")).lower(), 61 | ): 62 | has_array_match = True 63 | if not has_array_match: 64 | all_match = False 65 | if all_match: 66 | return True 67 | return False 68 | 69 | 70 | def print_finding(finding, minimal_output=False, json_output=False): 71 | if minimal_output: 72 | print("{}".format(finding.issue)) 73 | elif json_output: 74 | print( 75 | json.dumps( 76 | { 77 | "issue": finding.issue, 78 | "title": finding.title, 79 | "severity": finding.severity, 80 | "description": finding.description, 81 | "detail": finding.detail, 82 | "location": finding.location, 83 | } 84 | ) 85 | ) 86 | else: 87 | print( 88 | "{} - {} - {} - {} - {}".format( 89 | finding.severity, 90 | finding.title, 91 | finding.description, 92 | finding.detail, 93 | finding.location, 94 | ) 95 | ) 96 | 97 | 98 | def find_files(directory, exclude_pattern=None, policy_extension=""): 99 | exclude = None 100 | if exclude_pattern: 101 | exclude = re.compile(exclude_pattern) 102 | 103 | discovered_files = [] 104 | for root, _, files in walk(directory): 105 | for name in files: 106 | if name.endswith(policy_extension): 107 | file = join(root, name) 108 | if exclude: 109 | result = exclude.match(file) 110 | if result: 111 | logger.info( 112 | 'Found file %s matches exclude pattern "%s"', 113 | file, 114 | exclude_pattern, 115 | ) 116 | continue 117 | logger.info("Found file %s", file) 118 | discovered_files.append(file) 119 | 120 | return discovered_files 121 | 122 | 123 | def main(argv): 124 | with open("/tmp/parliament.log", "w") as fout: 125 | fout.write(f"Argv: {argv}\n") 126 | parser = argparse.ArgumentParser() 127 | parser.add_argument( 128 | "--aws-managed-policies", 129 | help="This is used with the policies directory of https://github.com/SummitRoute/aws_managed_policies", 130 | type=str, 131 | ) 132 | parser.add_argument( 133 | "--auth-details-file", 134 | help='Provide the path to a file returned by "aws iam get-account-authorization-details"', 135 | type=str, 136 | ) 137 | parser.add_argument( 138 | "--string", 139 | help='Provide a string such as \'{"Version": "2012-10-17","Statement": {"Effect": "Allow","Action": ["s3:GetObject", "s3:PutBucketPolicy"],"Resource": ["arn:aws:s3:::bucket1", "arn:aws:s3:::bucket2/*"]}}\'', 140 | type=str, 141 | ) 142 | parser.add_argument( 143 | "--file", 144 | help="Provide a policy via stdin (e.g. through piping) or --file", 145 | type=argparse.FileType("r"), 146 | default=sys.stdin, 147 | ) 148 | parser.add_argument( 149 | "--files", 150 | help="Provide a comma-separated list of policies", 151 | type=str, 152 | ) 153 | parser.add_argument( 154 | "--directory", help="Provide a path to directory with policy files", type=str 155 | ) 156 | parser.add_argument( 157 | "--include_policy_extension", 158 | help='Policy file extension to scan for in directory mode (ex. ".json")', 159 | default="json", 160 | type=str, 161 | ) 162 | parser.add_argument( 163 | "--exclude_pattern", 164 | help='File name regex pattern to exclude (ex. ".*venv.*")', 165 | type=str, 166 | ) 167 | parser.add_argument( 168 | "--minimal", help="Minimal output", default=False, action="store_true" 169 | ) 170 | parser.add_argument( 171 | "--json", help="json output", default=False, action="store_true" 172 | ) 173 | parser.add_argument( 174 | "--minimum_severity", 175 | help="Minimum severity to display. Options: CRITICAL, HIGH, MEDIUM, LOW, INFO", 176 | default="LOW", 177 | choices=["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"], 178 | ) 179 | parser.add_argument( 180 | "--private_auditors", 181 | help="Directory of the private auditors. Defaults to looking in private_auditors in the same directory as the iam_definition.json file.", 182 | default=None, 183 | ) 184 | parser.add_argument( 185 | "--config", help="Custom config file for over-riding values", type=str 186 | ) 187 | parser.add_argument( 188 | "--include-community-auditors", 189 | help="Use this flag to enable community-provided auditors", 190 | default=None, 191 | action="store_true", 192 | ) 193 | parser.add_argument( 194 | "-v", 195 | "--verbose", 196 | help="Increase output verbosity", 197 | action="store_true", 198 | default=False, 199 | ) 200 | parser.add_argument( 201 | "--version", 202 | action="version", 203 | version="%(prog)s {version}".format(version=__version__), 204 | ) 205 | args = parser.parse_args(args=argv[1:]) 206 | 207 | with open("/tmp/parliament.log", "a") as fout: 208 | fout.write(f"Files: {args.files}\n") 209 | 210 | log_level = logging.ERROR 211 | log_format = "%(message)s" 212 | 213 | # We want to silence dependencies 214 | logging.getLogger("botocore").setLevel(logging.CRITICAL) 215 | logging.getLogger("boto3").setLevel(logging.CRITICAL) 216 | logging.getLogger("urllib3").setLevel(logging.CRITICAL) 217 | 218 | if args.verbose: 219 | log_level = logging.INFO 220 | log_format = "%(message)s" 221 | 222 | logging.basicConfig(level=log_level, stream=sys.stderr, format=log_format) 223 | 224 | if args.minimal and args.json: 225 | raise Exception("You cannot choose both minimal and json output") 226 | 227 | # If I have some stdin to read it should be my policy, file input should indicate stdin 228 | if not sys.stdin.isatty() and args.file.name != "": 229 | parser.error("You cannot pass a file with --file and use stdin together") 230 | elif args.file.name != "" and args.files: 231 | parser.error("You cannot pass files with both --file and --files together") 232 | 233 | # Change the exit status if there are errors 234 | findings = [] 235 | 236 | if args.include_community_auditors: 237 | community_auditors_directory = "community_auditors" 238 | community_auditors_override_file = ( 239 | Path(abspath(__file__)).parent 240 | / community_auditors_directory 241 | / "config_override.yaml" 242 | ) 243 | override_config(community_auditors_override_file) 244 | override_config(args.config) 245 | 246 | if args.aws_managed_policies: 247 | file_paths = find_files(directory=args.aws_managed_policies) 248 | for file_path in file_paths: 249 | with open(file_path) as f: 250 | contents = f.read() 251 | policy_file_json = jsoncfg.loads_config(contents) 252 | policy_string = json.dumps(policy_file_json.PolicyVersion.Document()) 253 | policy = analyze_policy_string( 254 | policy_string, 255 | file_path, 256 | private_auditors_custom_path=args.private_auditors, 257 | include_community_auditors=args.include_community_auditors, 258 | config=config, 259 | ) 260 | findings.extend(policy.findings) 261 | 262 | elif args.auth_details_file: 263 | with open(args.auth_details_file) as f: 264 | contents = f.read() 265 | auth_details_json = jsoncfg.loads_config(contents) 266 | for policy in auth_details_json.Policies: 267 | # Ignore AWS defined policies 268 | if "arn:aws:iam::aws:" in policy.Arn(): 269 | continue 270 | 271 | # Ignore AWS Service-linked roles 272 | if ( 273 | policy.Path() == "/service-role/" 274 | or policy.Path() == "/aws-service-role/" 275 | or policy.PolicyName().startswith("AWSServiceRoleFor") 276 | or policy.PolicyName().endswith("ServiceRolePolicy") 277 | or policy.PolicyName().endswith("ServiceLinkedRolePolicy") 278 | ): 279 | continue 280 | 281 | for version in policy.PolicyVersionList: 282 | if not version.IsDefaultVersion(): 283 | continue 284 | policy = analyze_policy_string( 285 | json.dumps(version.Document()), 286 | policy.Arn(), 287 | ) 288 | findings.extend(policy.findings) 289 | 290 | # Review the inline policies on Users, Roles, and Groups 291 | for user in auth_details_json.UserDetailList: 292 | for policy in user.UserPolicyList([]): 293 | policy = analyze_policy_string( 294 | json.dumps(policy["PolicyDocument"]), 295 | user.Arn(), 296 | private_auditors_custom_path=args.private_auditors, 297 | include_community_auditors=args.include_community_auditors, 298 | config=config, 299 | ) 300 | findings.extend(policy.findings) 301 | for role in auth_details_json.RoleDetailList: 302 | for policy in role.RolePolicyList([]): 303 | policy = analyze_policy_string( 304 | json.dumps(policy["PolicyDocument"]), 305 | role.Arn(), 306 | private_auditors_custom_path=args.private_auditors, 307 | include_community_auditors=args.include_community_auditors, 308 | config=config, 309 | ) 310 | findings.extend(policy.findings) 311 | for group in auth_details_json.GroupDetailList: 312 | for policy in group.GroupPolicyList([]): 313 | policy = analyze_policy_string( 314 | json.dumps(policy["PolicyDocument"]), 315 | group.Arn(), 316 | private_auditors_custom_path=args.private_auditors, 317 | include_community_auditors=args.include_community_auditors, 318 | config=config, 319 | ) 320 | findings.extend(policy.findings) 321 | elif args.string: 322 | policy = analyze_policy_string( 323 | args.string, 324 | private_auditors_custom_path=args.private_auditors, 325 | include_community_auditors=args.include_community_auditors, 326 | config=config, 327 | ) 328 | findings.extend(policy.findings) 329 | elif args.files and not args.directory: 330 | for file_path in (stripped_path for path in args.files.split(",") if (stripped_path := path.strip())): 331 | path = Path(file_path) 332 | contents = path.read_text() 333 | with open("/tmp/parliament.log", "a") as fout: 334 | fout.write(f"Path: {path}\nContents: {contents}\n") 335 | policy = analyze_policy_string( 336 | contents, 337 | file_path, 338 | private_auditors_custom_path=args.private_auditors, 339 | include_community_auditors=args.include_community_auditors, 340 | config=config, 341 | ) 342 | findings.extend(policy.findings) 343 | elif args.file and not args.directory: 344 | contents = args.file.read() 345 | args.file.close() 346 | policy = analyze_policy_string( 347 | contents, 348 | args.file.name, 349 | private_auditors_custom_path=args.private_auditors, 350 | include_community_auditors=args.include_community_auditors, 351 | config=config, 352 | ) 353 | findings.extend(policy.findings) 354 | elif args.directory: 355 | file_paths = find_files( 356 | directory=args.directory, 357 | exclude_pattern=args.exclude_pattern, 358 | policy_extension=args.include_policy_extension, 359 | ) 360 | for file_path in file_paths: 361 | with open(file_path) as f: 362 | contents = f.read() 363 | policy = analyze_policy_string( 364 | contents, 365 | file_path, 366 | private_auditors_custom_path=args.private_auditors, 367 | include_community_auditors=args.include_community_auditors, 368 | config=config, 369 | ) 370 | findings.extend(policy.findings) 371 | else: 372 | parser.print_help() 373 | return -1 374 | 375 | filtered_findings = [] 376 | for finding in findings: 377 | finding = enhance_finding(finding) 378 | if not is_finding_filtered(finding, args.minimum_severity): 379 | filtered_findings.append(finding) 380 | 381 | if len(filtered_findings) == 0: 382 | # Return with exit code 0 if no findings 383 | return 0 384 | 385 | for finding in filtered_findings: 386 | print_finding(finding, args.minimal, args.json) 387 | 388 | # There were findings, so return with a non-zero exit code 389 | return 1 390 | 391 | 392 | def cli() -> int: 393 | sys.exit(main(sys.argv)) 394 | 395 | 396 | if __name__ == "__main__": 397 | cli() 398 | -------------------------------------------------------------------------------- /tests/unit/test_formatting.py: -------------------------------------------------------------------------------- 1 | from parliament import analyze_policy_string 2 | 3 | 4 | class TestFormatting: 5 | """Test class for formatting""" 6 | 7 | def test_analyze_policy_string_not_json(self): 8 | policy = analyze_policy_string("not json") 9 | assert policy.finding_ids == set(["MALFORMED_JSON"]), "Policy is not valid json" 10 | 11 | def test_analyze_policy_string_opposites(self): 12 | # Policy contains Action and NotAction 13 | policy = analyze_policy_string( 14 | """{ 15 | "Version": "2012-10-17", 16 | "Statement": { 17 | "Effect": "Allow", 18 | "Action": "s3:listallmybuckets", 19 | "NotAction": "s3:listallmybuckets", 20 | "Resource": "*"}}""", 21 | ignore_private_auditors=True, 22 | ) 23 | assert policy.finding_ids == set( 24 | ["MALFORMED"] 25 | ), "Policy contains Action and NotAction" 26 | 27 | def test_analyze_policy_string_no_action(self): 28 | policy = analyze_policy_string( 29 | """{ 30 | "Version": "2012-10-17", 31 | "Statement": { 32 | "Effect": "Allow", 33 | "Resource": "*"}}""", 34 | ignore_private_auditors=True, 35 | ) 36 | assert policy.finding_ids == set(["MALFORMED"]) 37 | 38 | def test_analyze_policy_string_no_statement(self): 39 | policy = analyze_policy_string( 40 | """{ 41 | "Version": "2012-10-17" }""" 42 | ) 43 | assert policy.finding_ids == set(["MALFORMED"]), "Policy has no Statement" 44 | 45 | def test_analyze_policy_string_invalid_sid(self): 46 | policy = analyze_policy_string( 47 | """{ 48 | "Version": "2012-10-17", 49 | "Statement": { 50 | "Sid": "Statement With Spaces And Special Chars!?", 51 | "Effect": "Allow", 52 | "Action": "s3:listallmybuckets", 53 | "Resource": "*"}}""", 54 | ignore_private_auditors=True, 55 | ) 56 | assert policy.finding_ids == set( 57 | ["INVALID_SID"] 58 | ), "Policy statement has invalid Sid" 59 | 60 | def test_analyze_policy_string_correct_simple(self): 61 | policy = analyze_policy_string( 62 | """{ 63 | "Version": "2012-10-17", 64 | "Statement": { 65 | "Effect": "Allow", 66 | "Action": "s3:listallmybuckets", 67 | "Resource": "*"}}""", 68 | ignore_private_auditors=True, 69 | ) 70 | assert policy.finding_ids == set() 71 | 72 | def test_analyze_policy_string_correct_multiple_statements_and_actions(self): 73 | policy = analyze_policy_string( 74 | """{ 75 | "Version": "2012-10-17", 76 | "Statement": [{ 77 | "Effect": "Allow", 78 | "Action": "s3:listallmybuckets", 79 | "Resource": "*"}, 80 | { 81 | "Effect": "Allow", 82 | "Action": "iam:listusers", 83 | "Resource": "*"}]}""", 84 | ignore_private_auditors=True, 85 | ) 86 | assert policy.finding_ids == set() 87 | 88 | def test_analyze_policy_string_multiple_statements_one_bad(self): 89 | policy = analyze_policy_string( 90 | """{ 91 | "Version": "2012-10-17", 92 | "Statement": [{ 93 | "Effect": "Allow", 94 | "Action": "s3:listallmybuckets", 95 | "Resource": "*"}, 96 | { 97 | "Effect": "Allow", 98 | "Action": ["iam:listusers", "iam:list"], 99 | "Resource": "*"}]}""", 100 | ignore_private_auditors=True, 101 | ) 102 | assert policy.finding_ids == set( 103 | ["UNKNOWN_ACTION"] 104 | ), "Policy with multiple statements has one bad" 105 | 106 | def test_condition(self): 107 | policy = analyze_policy_string( 108 | """{ 109 | "Version": "2012-10-17", 110 | "Statement": { 111 | "Effect": "Allow", 112 | "Action": "s3:listbucket", 113 | "Resource": "arn:aws:s3:::bucket-name", 114 | "Condition": {"DateGreaterThan" :{"aws:CurrentTime" : "2019-07-16T12:00:00Z"}} }}""", 115 | ignore_private_auditors=True, 116 | ) 117 | assert policy.finding_ids == set() 118 | 119 | def test_condition_bad_key(self): 120 | policy = analyze_policy_string( 121 | """{ 122 | "Version": "2012-10-17", 123 | "Statement": { 124 | "Effect": "Allow", 125 | "Action": "s3:listbucket", 126 | "Resource": "arn:aws:s3:::bucket-name", 127 | "Condition": {"DateGreaterThan" :{"bad" : "2019-07-16T12:00:00Z"}} }}""", 128 | ignore_private_auditors=True, 129 | ) 130 | assert policy.finding_ids == set( 131 | ["UNKNOWN_CONDITION_FOR_ACTION"] 132 | ), "Policy has bad key in Condition" 133 | 134 | def test_condition_action_specific(self): 135 | policy = analyze_policy_string( 136 | """{ 137 | "Version": "2012-10-17", 138 | "Statement": { 139 | "Effect": "Allow", 140 | "Action": "s3:listbucket", 141 | "Resource": "arn:aws:s3:::bucket-name", 142 | "Condition": {"StringEquals": {"s3:prefix":["home/${aws:username}/*"]}} }}""", 143 | ignore_private_auditors=True, 144 | ) 145 | assert policy.finding_ids == set() 146 | 147 | # The key s3:x-amz-storage-class is not allowed for ListBucket, 148 | # but is for other S3 actions 149 | policy = analyze_policy_string( 150 | """{ 151 | "Version": "2012-10-17", 152 | "Statement": { 153 | "Effect": "Allow", 154 | "Action": "s3:listbucket", 155 | "Resource": "arn:aws:s3:::bucket-name", 156 | "Condition": {"StringEquals": {"s3:x-amz-storage-class":"bad"}} }}""", 157 | ignore_private_auditors=True, 158 | ) 159 | assert policy.finding_ids == set( 160 | ["UNKNOWN_CONDITION_FOR_ACTION"] 161 | ), "Policy uses key that cannot be used for the action" 162 | 163 | def test_condition_action_specific_bad_type(self): 164 | # s3:signatureage requires a number 165 | policy = analyze_policy_string( 166 | """{ 167 | "Version": "2012-10-17", 168 | "Statement": { 169 | "Effect": "Allow", 170 | "Action": "s3:listbucket", 171 | "Resource": "arn:aws:s3:::bucket-name", 172 | "Condition": {"StringEquals": {"s3:signatureage":"bad"}} }}""", 173 | ignore_private_auditors=True, 174 | ) 175 | assert policy.finding_ids == set( 176 | ["MISMATCHED_TYPE"] 177 | ), 'Wrong type, "bad" should be a number' 178 | 179 | def test_condition_multiple(self): 180 | # Both good 181 | policy = analyze_policy_string( 182 | """{ 183 | "Version": "2012-10-17", 184 | "Statement": { 185 | "Effect": "Allow", 186 | "Action": "s3:listbucket", 187 | "Resource": "arn:aws:s3:::bucket-name", 188 | "Condition": { 189 | "DateGreaterThan" :{"aws:CurrentTime" : "2019-07-16T12:00:00Z"}, 190 | "StringEquals": {"s3:prefix":["home/${aws:username}/*"]} 191 | } }}""", 192 | ignore_private_auditors=True, 193 | ) 194 | assert policy.finding_ids == set() 195 | 196 | # First bad 197 | policy = analyze_policy_string( 198 | """{ 199 | "Version": "2012-10-17", 200 | "Statement": { 201 | "Effect": "Allow", 202 | "Action": "s3:listbucket", 203 | "Resource": "arn:aws:s3:::bucket-name", 204 | "Condition": { 205 | "DateGreaterThan" :{"aws:CurrentTime" : "bad"}, 206 | "StringEquals": {"s3:prefix":["home/${aws:username}/*"]} 207 | } }}""", 208 | ignore_private_auditors=True, 209 | ) 210 | assert policy.finding_ids == set(["MISMATCHED_TYPE"]), "First condition is bad" 211 | 212 | # Second bad 213 | policy = analyze_policy_string( 214 | """{ 215 | "Version": "2012-10-17", 216 | "Statement": { 217 | "Effect": "Allow", 218 | "Action": "s3:listbucket", 219 | "Resource": "arn:aws:s3:::bucket-name", 220 | "Condition": { 221 | "DateGreaterThan" :{"aws:CurrentTime" : "2019-07-16T12:00:00Z"}, 222 | "StringEquals": {"s3:x":["home/${aws:username}/*"]} 223 | } }}""", 224 | ignore_private_auditors=True, 225 | ) 226 | assert policy.finding_ids == set( 227 | ["UNKNOWN_CONDITION_FOR_ACTION"] 228 | ), "Second condition is bad" 229 | 230 | def test_condition_mismatch(self): 231 | policy = analyze_policy_string( 232 | """{ 233 | "Version": "2012-10-17", 234 | "Statement": { 235 | "Effect": "Allow", 236 | "Action": ["ec2:*", "s3:*"], 237 | "Resource": "*", 238 | "Condition": {"StringNotEquals": {"iam:ResourceTag/status":"prod"}} }}""", 239 | ignore_private_auditors=True, 240 | ) 241 | assert policy.finding_ids == set( 242 | ["UNKNOWN_CONDITION_FOR_ACTION", "RESOURCE_STAR"] 243 | ), "Condition mismatch" 244 | 245 | def test_condition_operator(self): 246 | policy = analyze_policy_string( 247 | """{ 248 | "Version": "2012-10-17", 249 | "Statement": { 250 | "Effect": "Allow", 251 | "Action": "s3:listbucket", 252 | "Resource": "arn:aws:s3:::bucket-name", 253 | "Condition": {"StringEqualsIfExists": {"s3:prefix":["home/${aws:username}/*"]}} }}""", 254 | ignore_private_auditors=True, 255 | ) 256 | assert policy.finding_ids == set() 257 | 258 | policy = analyze_policy_string( 259 | """{ 260 | "Version": "2012-10-17", 261 | "Statement": { 262 | "Effect": "Allow", 263 | "Action": "s3:listbucket", 264 | "Resource": "arn:aws:s3:::bucket-name", 265 | "Condition": {"bad": {"s3:prefix":["home/${aws:username}/*"]}} }}""", 266 | ignore_private_auditors=True, 267 | ) 268 | assert policy.finding_ids == set( 269 | ["UNKNOWN_OPERATOR", "MISMATCHED_TYPE"] 270 | ), "Unknown operator" 271 | 272 | policy = analyze_policy_string( 273 | """{ 274 | "Version": "2012-10-17", 275 | "Statement": { 276 | "Effect": "Allow", 277 | "Action": "s3:listbucket", 278 | "Resource": "arn:aws:s3:::bucket-name", 279 | "Condition": {"NumericEquals": {"s3:prefix":["home/${aws:username}/*"]}} }}""", 280 | ignore_private_auditors=True, 281 | ) 282 | assert policy.finding_ids == set(["MISMATCHED_TYPE"]), "Operator type mismatch" 283 | 284 | def test_condition_type_unqoted_bool(self): 285 | policy = analyze_policy_string( 286 | """{ 287 | "Version": "2012-10-17", 288 | "Statement": { 289 | "Effect": "Allow", 290 | "Action": "kms:CreateGrant", 291 | "Resource": "*", 292 | "Condition": {"Bool": {"kms:GrantIsForAWSResource": true}} }}""", 293 | ignore_private_auditors=True, 294 | ) 295 | assert policy.finding_ids == set(["RESOURCE_STAR"]) 296 | 297 | def test_condition_with_null(self): 298 | policy = analyze_policy_string( 299 | """{ 300 | "Version": "2012-10-17", 301 | "Id": "123", 302 | "Statement": [ 303 | { 304 | "Sid": "", 305 | "Effect": "Deny", 306 | "Principal": "*", 307 | "Action": "s3:*", 308 | "Resource": "arn:aws:s3:::examplebucket/taxdocuments/*", 309 | "Condition": { "Null": { "aws:MultiFactorAuthAge": true }} 310 | } 311 | ] 312 | }""", 313 | ignore_private_auditors=True, 314 | ) 315 | assert policy.finding_ids == set() 316 | 317 | def test_condition_with_MultiFactorAuthAge(self): 318 | policy = analyze_policy_string( 319 | """{ 320 | "Version": "2012-10-17", 321 | "Id": "123", 322 | "Statement": [ 323 | { 324 | "Sid": "", 325 | "Effect": "Deny", 326 | "Action": "*", 327 | "Resource": "*", 328 | "Condition": { "NumericGreaterThan": { "aws:MultiFactorAuthAge": "28800" }} 329 | } 330 | ] 331 | }""", 332 | ignore_private_auditors=True, 333 | ) 334 | assert policy.finding_ids == set() 335 | 336 | def test_redshift_GetClusterCredentials(self): 337 | policy = analyze_policy_string( 338 | """{ 339 | "Version": "2012-10-17", 340 | "Id": "123", 341 | "Statement": [ 342 | { 343 | "Action": "redshift:GetClusterCredentials", 344 | "Effect": "Allow", 345 | "Resource": "arn:aws:redshift:us-west-2:123456789012:dbuser:the_cluster/the_user" 346 | } 347 | ] 348 | }""", 349 | ignore_private_auditors=True, 350 | ) 351 | 352 | # This privilege has a required format of arn:*:redshift:*:*:dbuser:*/* 353 | assert policy.finding_ids == set() 354 | 355 | def test_lambda_AddLayerVersionPermission(self): 356 | policy = analyze_policy_string( 357 | """{ 358 | "Version": "2012-10-17", 359 | "Id": "123", 360 | "Statement": [ 361 | { 362 | "Sid": "TestPol", 363 | "Effect": "Allow", 364 | "Action": "lambda:AddLayerVersionPermission", 365 | "Resource": "arn:aws:lambda:*:123456789012:layer:sol-*:*" 366 | } 367 | ] 368 | }""", 369 | ignore_private_auditors=True, 370 | ) 371 | 372 | # This privilege has a required format of arn:*:redshift:*:*:dbuser:*/* 373 | assert policy.finding_ids == set() 374 | 375 | def test_lambda_TerminateInstances(self): 376 | policy = analyze_policy_string( 377 | """{ 378 | "Version": "2012-10-17", 379 | "Id": "123", 380 | "Statement": [ 381 | { 382 | "Action": [ 383 | "ec2:TerminateInstances" 384 | ], 385 | "Effect": "Allow", 386 | "Resource": "*", 387 | "Condition": { 388 | "ArnEquals": { 389 | "ec2:InstanceProfile": "arn:aws:iam::123456789012:instance-profile/my_role" 390 | } 391 | } 392 | } 393 | ] 394 | }""", 395 | ignore_private_auditors=True, 396 | ) 397 | 398 | assert policy.finding_ids == set(["RESOURCE_STAR"]) 399 | 400 | def test_priv_that_requires_star_resource(self): 401 | policy = analyze_policy_string( 402 | """{ 403 | "Version": "2012-10-17", 404 | "Id": "123", 405 | "Statement": [ 406 | { 407 | "Action": [ 408 | "guardduty:ListDetectors" 409 | ], 410 | "Effect": "Allow", 411 | "Resource": "*" 412 | } 413 | ] 414 | }""", 415 | ignore_private_auditors=True, 416 | ) 417 | 418 | # guardduty:ListDetectors has no required resources, so it can have "*". 419 | # This should not create a RESOURCE_STAR finding 420 | 421 | assert policy.finding_ids == set() 422 | 423 | def test_condition_operator_values(self): 424 | policy = analyze_policy_string( 425 | """{ 426 | "Version": "2012-10-17", 427 | "Statement": [ 428 | { 429 | "Action": [ 430 | "ec2:TerminateInstances" 431 | ], 432 | "Effect": "Allow", 433 | "Resource": "*", 434 | "Condition": { 435 | "StringEquals": { 436 | "ec2:InstanceProfile": "arn:aws:iam::123456789012:instance-profile/my_role" 437 | } 438 | } 439 | } 440 | ] 441 | }""", 442 | ignore_private_auditors=True, 443 | ) 444 | 445 | assert policy.finding_ids == set( 446 | ["RESOURCE_STAR", "MISMATCHED_TYPE_BUT_USABLE"] 447 | ) 448 | 449 | def test_duplicate_sids(self): 450 | policy = analyze_policy_string( 451 | """{ 452 | "Version": "2012-10-17", 453 | "Statement": [ 454 | { 455 | "Sid": "stmt", 456 | "Action": [ 457 | "s3:ListAllMyBuckets" 458 | ], 459 | "Effect": "Allow", 460 | "Resource": "*" 461 | }, 462 | { 463 | "Sid": "stmt", 464 | "Action": [ 465 | "s3:ListAllMyBuckets" 466 | ], 467 | "Effect": "Allow", 468 | "Resource": "*" 469 | } 470 | 471 | ] 472 | }""", 473 | ignore_private_auditors=True, 474 | ) 475 | 476 | assert policy.finding_ids == set(["DUPLICATE_SID"]) 477 | 478 | def test_analyze_policy_string_MFA_formatting(self): 479 | policy = analyze_policy_string( 480 | """{ 481 | "Version": "2012-10-17", 482 | "Statement": { 483 | "Sid": "AllowManageOwnVirtualMFADevice", 484 | "Effect": "Allow", 485 | "Action": [ 486 | "iam:CreateVirtualMFADevice", 487 | "iam:DeleteVirtualMFADevice" 488 | ], 489 | "Resource": "arn:aws:iam::*:mfa/${aws:username}" 490 | } 491 | }""" 492 | ) 493 | assert policy.finding_ids == set([]), "Policy is valid" 494 | --------------------------------------------------------------------------------