├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── aws_keys_sectool ├── __init__.py ├── __main__.py ├── common.py ├── list_keys.py └── protect_keys.py ├── requirements.txt ├── scripts └── test.sh └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | .vscode 132 | aws_keys_report.json -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Nikola Tosic 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | clean: 2 | rm -rf build dist 3 | package: 4 | python3 setup.py bdist_wheel 5 | 6 | dist: package 7 | twine upload dist/* 8 | 9 | test: 10 | bash -c scripts/test.sh -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # aws-keys-sectool 2 | 3 | 4 | [See it in action on asciinema](https://asciinema.org/a/481461) 5 | 6 | Command line to list and protect working AWS credentials 7 | on workstations and servers (use IAM roles for any compute, though!) 8 | 9 | 10 | ❌ **IF YOUR IP IS NOT STATIC AND YOU DON'T USE -b OPTION YOU CAN EASILY LOCK 11 | YOURSELF OUT** 12 | 13 | ## Why? 14 | 15 | AWS long lived static credentials were still number 1 initial access vector 16 | in 2021 security breaches, according to many security researches. 17 | See [here](https://blog.christophetd.fr/cloud-security-breaches-and-vulnerabilities-2021-in-review/#Static_Credentials_Remain_the_Major_Initial_Access_Vector) 18 | 19 | ## What 20 | 21 | `aws-keys-sectool` allows you to 22 | 23 | - list all of the working profiles from `~/.aws/credentials`, including those based on session tokens 24 | 25 | - optionally protect yourself from AWS keys usage by simply whitelistening only current IP address for 26 | API calls. This, however, does come with a few caveats: 27 | 28 | - This works only if credentials allow `iam:PutUserPolicy` on the user credentials itself. 29 | 30 | - in order not to lock yourself out when changing IPs, [iam:PutUserPolicy] is left out of the full protection when using `-b` option, however 31 | it is conditioned using `aws:UserAgent` condition and expecting hash of the 32 | user's arn for it's value. So, in credential leak scenario user is still protected if malicous actor is not aware the keys are protected using this utility 33 | 34 | - everytime client IP address is changed, script needs to be executed again to align the policy with the new IP address. 35 | 36 | 37 | ## Requirements 38 | 39 | - `python3` 40 | - `boto3` 41 | 42 | If you are using AWS CLI, chances are good that these are already present on the system. 43 | 44 | ## How 45 | 46 | Simply, clone the repo and run the scripts 47 | 48 | 1 - build and install package 49 | 50 | ``` 51 | ### install from PyPi 52 | pip3 install aws-keys-sectool 53 | 54 | ## or clone and build 55 | git clone https://github.com:toshke/aws-keys-sectool.git && \ 56 | cd aws-keys-sectool && \ 57 | python3 setup.py install 58 | 59 | ## see usage 60 | aws-keys-sectool -h 61 | ``` 62 | 63 | ### Key listing 64 | 65 | ``` 66 | ### default behaviour prints results in human readable format to stdout 67 | aws-keys-sectool list-all-keys 68 | 69 | ### optionally to write output to json file use (aws_keys_report.json) 70 | aws-keys-sectool list-all-keys -j 71 | ``` 72 | 73 | ### Key protection 74 | 75 | ```shell 76 | ### 77 | ### Options explained 78 | ### -b Add backdoor access. User will only be able to perform 79 | ### iam:PutUserPolicy action from different IP address, and 80 | ### with UA string set to hash of user ARN. Not added 81 | ### by default, assuming that user is on a static IP 82 | ### and there is admin account that can restore user's access in 83 | ### case of different IP 84 | ### 85 | ### --profile PROFILE : Target specific AWS profile. All profiles 86 | ### are protected by default with a user prompt 87 | ### 88 | ### --ip IP_ADDRESS: If you're whitelisting IP address (or range using CIDR format) 89 | ### other than your current public IP, use this option. 90 | ### Default value is your current IP address obtained via ipinfo.io 91 | ### 92 | aws-keys-sectool protect-keys [-b] [-p PROFILE] [-i] ip_address_or_cidr 93 | ``` 94 | 95 | ## FAQ 96 | 97 | *Q*: Can I whitelist IPs/CIDRs manually, outside of the tool? 98 | 99 | *A*: Yes, see policy below for policy without backdoor access 100 | ``` 101 | { 102 | "Sid": "DenyIpBased", 103 | "Effect": "Deny", 104 | "NotAction": "iam:PutUserPolicy", 105 | "Resource": "*", 106 | "Condition": { 107 | "NotIpAddress": { 108 | "aws:SourceIp": 109 | } 110 | } 111 | } 112 | ``` 113 | 114 | 115 | 116 | *Q*: What if I use backdoor option and my creds are leaked 117 | 118 | *A*: Obviously backdoor implies there is vulnerability by design. 119 | Ideal scenario is avoid using backdoor option, and an admin profile 120 | to update ip when changed. 121 | -------------------------------------------------------------------------------- /aws_keys_sectool/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/toshke/aws-keys-sectool/668b85ccefd862131638a37373eb402bb401fa12/aws_keys_sectool/__init__.py -------------------------------------------------------------------------------- /aws_keys_sectool/__main__.py: -------------------------------------------------------------------------------- 1 | 2 | import sys 3 | from optparse import OptionParser 4 | 5 | from .list_keys import list_keys 6 | from .protect_keys import protect_keys 7 | 8 | 9 | parser = OptionParser('aws-key-sectool (list-keys|protect-keys) ') 10 | parser.add_option("-p", "--profile", dest="target_profile", default="", 11 | help='''Select profile to apply IP protection to. 12 | If not specified, all accessible profiles are protected with prompt. 13 | Applicable only to protect-keys action''', metavar="AWS_PROFILE") 14 | 15 | 16 | parser.add_option("-b","--back-door", action='store_true', 17 | dest="enable_backdoor", default=False, 18 | help='''Creates backdoor access for iam:PutUserPolicy 19 | using aws:UserAgent condition and identity arn. 20 | Applicable only to protect-keys action''') 21 | 22 | parser.add_option("-j","--json", action='store_true', 23 | dest="dump_json", default=False, 24 | help='''Creates aws_keys_report.json file as an output. 25 | Applies only to list-keys action''') 26 | 27 | 28 | parser.add_option("-i","--ip", dest="target_ip", default="", 29 | help='''Specify whitelist block address in CIDR or IP format. e.g. 127.0.0.1[/32]''') 30 | 31 | def main(): 32 | """Main entrypoint""" 33 | 34 | (options, args) = parser.parse_args() 35 | if len(args) < 1: 36 | parser.print_usage() 37 | sys.exit(1) 38 | if args[0] == "list-keys": 39 | list_keys(options) 40 | elif args[0] == "protect-keys": 41 | protect_keys(options) 42 | -------------------------------------------------------------------------------- /aws_keys_sectool/common.py: -------------------------------------------------------------------------------- 1 | import os 2 | import boto3 3 | import sys 4 | 5 | from botocore.exceptions import ClientError 6 | 7 | KEY_ID = 'aws_access_key_id' 8 | SECRET_KEY = 'aws_secret_access_key' 9 | SESSION_TOKEN = 'aws_session_token' 10 | 11 | def get_accessibility_data(print_output): 12 | """ 13 | Return dict with all local aws profiles present and their identity/accessibility information 14 | """ 15 | fname = os.environ['HOME'] + '/.aws/credentials' 16 | if not os.path.isfile(fname): 17 | print(f'{fname} does not exist, can\'t proceed') 18 | sys.exit(1) 19 | 20 | with open(fname, 'r') as f: 21 | content = f.read() 22 | 23 | profile = None 24 | data = {} 25 | for line in content.split('\n'): 26 | if line.strip().startswith('['): 27 | profile = line.strip().replace('[','').replace(']','') 28 | if print_output: 29 | print(f'Reading profile: {profile}') 30 | data[profile] = {} 31 | elif '=' in line: 32 | parts = line.split('=') 33 | key = parts[0].strip() 34 | value = parts[1].strip() 35 | data[profile][key] = value 36 | 37 | for profile in data: 38 | if print_output: 39 | print(f'Checking profile {profile}.... ') 40 | if KEY_ID in data[profile] and SECRET_KEY in data[profile]: 41 | kwargs = { KEY_ID: data[profile][KEY_ID], SECRET_KEY: data[profile][SECRET_KEY] } 42 | if SESSION_TOKEN in data[profile]: 43 | kwargs[SESSION_TOKEN] = data[profile][SESSION_TOKEN] 44 | 45 | client = boto3.client('sts', **kwargs) 46 | try: 47 | arn = client.get_caller_identity()['Arn'] 48 | if print_output: 49 | print(f'✅ identity: {arn}\n') 50 | data[profile]['accessible'] = True 51 | data[profile]['identity'] = arn 52 | except ClientError as e: 53 | data[profile]['accessible'] = False 54 | if print_output: 55 | print('❌ failed\n') 56 | return data -------------------------------------------------------------------------------- /aws_keys_sectool/list_keys.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import json 3 | 4 | from .common import get_accessibility_data 5 | 6 | 7 | def list_keys(options): 8 | dump_json = options.dump_json 9 | data = get_accessibility_data(not dump_json) 10 | accessible_profiles = [profile for profile in data if data[profile].get('accessible',False)] 11 | 12 | if dump_json: 13 | with open('aws_keys_report.json','w') as f: 14 | f.write(json.dumps( 15 | { 16 | 'accessible_profiles': accessible_profiles, 17 | 'profile_data': 18 | dict(map(lambda x:[x, {'accessible':data[x]['accessible'],'identity':data[x].get('identity','')}], data)) 19 | }, indent=2)) 20 | print(f'Machine readable info written to aws_keys_report.json') 21 | else: 22 | print(f'\nAccessible profiles are:\n') 23 | print('✅ ' + '\n✅ '.join(accessible_profiles)) 24 | -------------------------------------------------------------------------------- /aws_keys_sectool/protect_keys.py: -------------------------------------------------------------------------------- 1 | """ 2 | AWS keys protection module 3 | """ 4 | 5 | import sys 6 | import json 7 | import hashlib 8 | import copy 9 | import ipaddress 10 | 11 | import urllib.request 12 | 13 | import boto3 14 | import botocore.config 15 | 16 | from .common import get_accessibility_data 17 | 18 | DENY_NOT_IP_POLICY = { 19 | "Sid": "DenyIpBased", 20 | "Effect": "Deny", 21 | "NotAction": "iam:PutUserPolicy", 22 | "Resource": "*", 23 | "Condition": { 24 | "NotIpAddress": { 25 | "aws:SourceIp": "" 26 | } 27 | } 28 | } 29 | 30 | DENY_NOT_UA_POLICY = { 31 | "Sid": "DenyUABased", 32 | "Effect": "Deny", 33 | "Action": "iam:PutUserPolicy", 34 | "Resource": "*", 35 | "Condition": { 36 | "StringNotEquals": { 37 | "aws:UserAgent": "" 38 | } 39 | } 40 | } 41 | 42 | 43 | def protect_keys(options): 44 | """ 45 | Protect AWS access keys 46 | by applying IAM policy with aws:SourceIp 47 | condition 48 | """ 49 | 50 | data = get_accessibility_data(False) 51 | accessible_profiles = [ 52 | profile for profile in data if data[profile].get('accessible', False)] 53 | 54 | if options.target_ip == "": 55 | response = urllib.request.urlopen("http://ipinfo.io").read() 56 | ip_cidr = json.loads(response)['ip'] + "/32" 57 | else: 58 | try: 59 | ip_cidr = str(ipaddress.ip_network(options.target_ip)) 60 | except ValueError as err: 61 | sys.stderr.write( 62 | f'Error parsing IP address {options.target_ip}:{str(err)}') 63 | sys.exit(-1) 64 | 65 | if options.target_profile != "" and options.target_profile not in accessible_profiles: 66 | print(f'Profile {options.target_profile} not available or accessible') 67 | return 68 | 69 | if options.target_profile == "": 70 | print(f'IP based protection ({ip_cidr}) will be applied to all \ 71 | of the following active profiles:\n') 72 | print('\n'.join(accessible_profiles)) 73 | answer = input('\nProceed? (y/n)') 74 | if not answer.lower().strip() == 'y': 75 | print('Aborting...') 76 | sys.exit(0) 77 | 78 | for profile in accessible_profiles: 79 | # if single profile targeted 80 | if options.target_profile != "" and options.target_profile != profile: 81 | continue 82 | 83 | arn = data[profile]['identity'] 84 | if ':user' not in arn: 85 | print(f'Not applying protection for non-user identity {arn}') 86 | continue 87 | arn_digest = hashlib.sha256(arn.encode('utf-8')).hexdigest() 88 | policy = { 89 | "Version": "2012-10-17", 90 | "Statement": [copy.copy(DENY_NOT_IP_POLICY), copy.copy(DENY_NOT_UA_POLICY)] 91 | } 92 | policy['Statement'][0]['Condition']['NotIpAddress']['aws:SourceIp'] = ip_cidr 93 | if not options.enable_backdoor: 94 | del policy['Statement'][1] 95 | del policy['Statement'][0]['NotAction'] 96 | policy['Statement'][0]['Action'] = '*' 97 | else: 98 | policy['Statement'][1]['Condition']['StringNotEquals']['aws:UserAgent'] = arn_digest 99 | 100 | iam = boto3.Session(profile_name=profile).client('iam', config=botocore.config.Config( 101 | user_agent=arn_digest 102 | )) 103 | print(f'Processing profile {profile}: {arn}') 104 | user = arn.split('/')[1] 105 | print(f'🔒 Set IP based protection ({ip_cidr}) on user {user}') 106 | if options.enable_backdoor: 107 | print( 108 | 'Backdoor 🚪 access enabled, you can use this utility from differenty IP to protect again\n') 109 | else: 110 | print( 111 | f'No backdoor 🚪 access. User policy will only accept API calls from {ip_cidr}\n') 112 | iam.put_user_policy( 113 | UserName=user, 114 | PolicyName='IpBasedProtection', 115 | PolicyDocument=json.dumps(policy) 116 | ) 117 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | boto3 -------------------------------------------------------------------------------- /scripts/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cmd="python3 setup.py install && aws-keys-sectool list-keys" 4 | 5 | versions="3.6 3.7 3.8 3.9 3.10" 6 | set -x 7 | for v in $versions; do 8 | echo "Testing install and list with version $x" 9 | docker run --rm -it \ 10 | -v $PWD:/src \ 11 | -w /src \ 12 | -v $HOME/.aws:/root/.aws "python:${v}-alpine" /bin/sh -c "$cmd" 13 | done -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | from pathlib import Path 4 | this_directory = Path(__file__).parent 5 | long_description = (this_directory / "README.md").read_text() 6 | 7 | setup(name='aws-keys-sectool', version='0.3.0', author='Nikola Tosic', 8 | author_email='nikola@toshke.me', 9 | url='https://github.com/toshke/aws-keys-security', 10 | classifiers=[ 11 | 'Development Status :: 3 - Alpha', 12 | 'Programming Language :: Python :: 3.6', 13 | 'Programming Language :: Python :: 3.8', 14 | 'Programming Language :: Python :: 3.9', 15 | 'Programming Language :: Python :: 3.10', 16 | 'Intended Audience :: System Administrators', 17 | 'Intended Audience :: Information Technology', 18 | 'License :: OSI Approved :: MIT License', 19 | ], 20 | keywords='aws security credentials', 21 | packages=['aws_keys_sectool'], 22 | install_requires=['boto3'], 23 | python_requires='>=3.6', 24 | description='List active aws profiles and protect their conusmption via IP condition IAM policies', 25 | entry_points={ 26 | 'console_scripts': ['aws-keys-sectool = aws_keys_sectool.__main__:main'], 27 | }) --------------------------------------------------------------------------------