├── 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("