├── .gitignore ├── Dockerfile ├── INSTALL ├── LICENSE.md ├── README.md ├── cli.py ├── data └── sample.zip ├── lib ├── __init__.py ├── aws │ ├── __init__.py │ ├── actions.py │ ├── attacks.py │ ├── ingestor.py │ ├── policy.py │ ├── profile.py │ └── resources.py ├── graph │ ├── base.py │ ├── db.py │ ├── edges.py │ └── nodes.py └── util │ ├── __init__.py │ ├── console.py │ └── keywords.py └── www ├── .browserslistrc ├── .eslintrc.json ├── babel.config.js ├── package.json ├── postcss.config.js ├── public ├── favicon.ico └── index.html └── src ├── App.vue ├── assets ├── fonts │ ├── MaterialIcons-Regular.woff │ └── MaterialIcons-Regular.woff2 └── logo.png ├── codemirror-cypher ├── cypher-codemirror.css └── cypher-codemirror.min.js ├── components ├── Database.vue ├── Graph.vue ├── Menu.vue ├── Properties.vue ├── Search.vue ├── SearchAdvanced.vue ├── SearchAdvancedFilter.vue ├── SearchResultsTable.vue ├── TemplateAutocomplete.vue ├── TemplateSelectItem.vue └── TemplateSelectSearch.vue ├── config.js ├── icons.js ├── main.js ├── neo4j.js └── queries.js /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | data/* 6 | package-lock.json 7 | node_modules 8 | dist 9 | nohup.out 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM neo4j:4.3.2-community 2 | 3 | COPY . /opt/awspx 4 | WORKDIR /opt/awspx 5 | 6 | ENV NEO4J_AUTH=neo4j/password 7 | ENV EXTENSION_SCRIPT=/opt/awspx/INSTALL 8 | 9 | RUN apt -y update && apt install -y \ 10 | awscli \ 11 | nodejs \ 12 | npm \ 13 | python3-pip \ 14 | procps \ 15 | git \ 16 | && rm -rf /var/lib/apt/lists/* \ 17 | && pip3 install --upgrade \ 18 | argparse \ 19 | awscli \ 20 | boto3 \ 21 | configparser \ 22 | git-python \ 23 | neo4j \ 24 | rich \ 25 | && npm install -g npm@latest 26 | 27 | RUN cd /opt/awspx/www && npm install 28 | RUN gosu neo4j wget -q --timeout 300 --tries 30 --output-document=/var/lib/neo4j/plugins/apoc.jar \ 29 | https://github.com/neo4j-contrib/neo4j-apoc-procedures/releases/download/4.3.0.0/apoc-4.3.0.0-all.jar \ 30 | && chmod 644 /var/lib/neo4j/plugins/apoc.jar 31 | 32 | VOLUME /opt/awspx/data 33 | EXPOSE 7373 7474 7687 80 34 | -------------------------------------------------------------------------------- /INSTALL: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ##################################################################################################### 4 | # # 5 | # This script serves a couple of purposes, its behaviour will vary based on how it is named: # 6 | # # 7 | # - INSTALL - will create the awspx container and copy this script to awspx # 8 | # - awspx - will pass arguments to cli.py in the awpx container # 9 | # - docker-entrypoint.sh - will start the webserver and neo4j (sourced by /docker-entrypoint.sh) # 10 | # # 11 | ##################################################################################################### 12 | 13 | _OS_="UNKNOWN" 14 | 15 | function host_checks(){ 16 | 17 | #MacOS 18 | if [ "$(uname)" == "Darwin" ]; then 19 | _OS_="MACOS" 20 | 21 | # Linux 22 | elif [[ "$(uname)" =~ "Linux" ]]; then 23 | _OS_="LINUX" 24 | if [[ "$(whoami)" != "root" ]]; then 25 | echo "[-] awspx must be run with root privileges." 26 | exit 1 27 | fi 28 | # Unsupported 29 | else 30 | echo "[-] Platform: '$(uname)' is not supported" 31 | exit 1 32 | fi 33 | 34 | DOCKER_RUNNING="$(docker info >/dev/null 2>&1)" 35 | 36 | if [ "${?}" -ne 0 ]; then 37 | echo "[-] \"docker\" must first be started." 38 | exit 1 39 | fi 40 | } 41 | 42 | function install(){ 43 | 44 | BIN_PATH="/usr/local/bin" 45 | 46 | [[ $_OS_ == "MACOS" ]] \ 47 | && MOUNT="${HOME}/bin/awspx" \ 48 | || MOUNT="/opt/awspx" 49 | 50 | # Use default path for awspx installation 51 | if [[ ":${PATH}:" != *":${BIN_PATH}:"* ]] || [ ! -w "${BIN_PATH}" ]; then 52 | read -p "[*] '${BIN_PATH}' isn't in your \$PATH. Choose another location to save \"awspx\" [y/n]? " response 53 | if [[ "${response}" == "Y" || "${response}" == "y" ]]; then 54 | select p in $(for p in $(echo "${PATH}" | tr ':' '\n' | sort); do [[ -w $p ]] && echo $p; done); do 55 | case $p in 56 | /*) 57 | BIN_PATH="$p" 58 | break 59 | ;; 60 | *) ;; 61 | esac 62 | done 63 | fi 64 | fi 65 | 66 | cp -f $0 ${BIN_PATH}/awspx 67 | 68 | # Assert awspx exists in $PATH 69 | if (which awspx >/dev/null 2>&1) ; then 70 | echo "awspx successfully written to ${BIN_PATH}/awspx" 71 | else 72 | >&2 echo "Failed to identify a writable \$PATH directory" 73 | exit 2 74 | fi 75 | 76 | # Delete all containers named awspx (prompt for confirmation) 77 | if [ -n "$(docker ps -a -f name=awspx -q)" ]; then 78 | 79 | echo -e "[!] An existing container named \"awspx\" was detected\n" 80 | echo -e " In order to continue, it must be deleted. All data will be lost." 81 | read -p " Continue [y/n]? " response 82 | 83 | [[ "${response}" == "Y" || "${response}" == "y" ]] \ 84 | || exit 85 | 86 | docker stop awspx >/dev/null 2>&1 87 | docker rm awspx >/dev/null 2>&1 88 | 89 | fi 90 | 91 | echo "" 92 | 93 | # Build or pull awspx 94 | case $1 in 95 | build|BUILD) 96 | echo -e "[*] Creating \"awspx\" image...\n" 97 | docker build $(dirname $0) -t beatro0t/awspx:latest 98 | ;; 99 | *) 100 | echo -e "[*] Pulling \"awspx\" image... \n" 101 | docker pull beatro0t/awspx:latest 102 | ;; 103 | esac 104 | 105 | if [ $? -ne 0 ]; then 106 | echo -e "\n[-] Installation failed" 107 | exit 1 108 | fi 109 | 110 | echo "" 111 | 112 | # Create container 113 | echo -en "[*] Creating \"awspx\" container... " 114 | if docker run -itd \ 115 | --name awspx \ 116 | --hostname=awspx \ 117 | --env NEO4J_AUTH=neo4j/password \ 118 | -p 127.0.0.1:80:80 \ 119 | -p 127.0.0.1:7687:7687 \ 120 | -p 127.0.0.1:7373:7373 \ 121 | -p 127.0.0.1:7474:7474 \ 122 | -v ${MOUNT}/data:/opt/awspx/data:z \ 123 | -e NEO4J_dbms_security_procedures_unrestricted=apoc.jar \ 124 | --restart=always beatro0t/awspx:latest >/dev/null; then 125 | 126 | cp $(dirname $0)/data/sample.zip -f ${MOUNT}/data/. >/dev/null 2>&1 127 | 128 | echo -e "and you're all set!\n" 129 | 130 | echo -e " The web interface (http://localhost) will be available shortly..." 131 | echo -e " Run: \`awspx -h\` for a list of options." 132 | fi 133 | 134 | echo "" 135 | } 136 | 137 | function hook(){ 138 | 139 | if [[ "${@}" == "neo4j" ]]; then 140 | 141 | # Start web interface 142 | [[ -z "$(pgrep npm)" ]] \ 143 | && cd /opt/awspx/www \ 144 | && nohup npm run serve>/dev/null 2>&1 & 145 | 146 | # Start neo4j 147 | nohup bash /docker-entrypoint.sh neo4j console 2>&1 & 148 | 149 | # Start bash so /docker-entrypoint.sh doesn't terminate 150 | exec bash 151 | fi 152 | 153 | } 154 | 155 | function awspx(){ 156 | 157 | if [[ -z "$(docker ps -a -f name=^/awspx$ -q)" ]]; then 158 | echo -e "[-] Couldn't find \"awspx\" container, you will need to create it first" 159 | exit 1 160 | fi 161 | 162 | if [[ -z "$(docker ps -a -f name=^/awspx$ -f status=running -q)" ]]; then 163 | docker start awspx > /dev/null 164 | fi 165 | 166 | docker exec -it \ 167 | -e AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID \ 168 | -e AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY \ 169 | -e AWS_SESSION_TOKEN=$AWS_SESSION_TOKEN \ 170 | -e AWS_SECURITY_TOKEN=$AWS_SECURITY_TOKEN \ 171 | awspx /opt/awspx/cli.py $@ 172 | 173 | } 174 | 175 | function main(){ 176 | 177 | case "$(basename $0)" in 178 | INSTALL) 179 | host_checks 180 | install $@ 181 | ;; 182 | docker-entrypoint.sh) 183 | hook $@ 184 | ;; 185 | awspx) 186 | host_checks 187 | awspx $@ 188 | ;; 189 | esac 190 | 191 | } 192 | 193 | main $@ 194 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | > auspex [ˈau̯s.pɛks] noun: An augur of ancient Rome, especially one who interpreted omens derived from the observation of birds. 4 | 5 |  6 |  7 |  8 | 9 | # Overview 10 | 11 | **awspx** is a graph-based tool for visualizing effective access and resource relationships within AWS. It resolves policy information to determine *what* actions affect *which* resources, while taking into account how these actions may be combined to produce attack paths. Unlike tools like [Bloodhound](https://github.com/BloodHoundAD/BloodHound), awspx requires permissions to function — it is not expected to be useful in cases where these privileges have not been granted. 12 | 13 | ### Table of contents 14 | 15 | - [Getting Started](#getting-started) 16 | - [Installation](#installation) 17 | - [Usage](#usage) 18 | - [Contributing](#contributing) 19 | - [License](#license) 20 | 21 | *For more information, checkout the [awspx Wiki](https://github.com/FSecureLABS/awspx/wiki)* 22 | 23 | # Getting Started 24 | 25 | *For detailed installation instructions, usage, and answers to frequently asked questions, see sections: [Setup](https://github.com/FSecureLABS/awspx/wiki/Setup); [Data Collection](https://github.com/FSecureLABS/awspx/wiki/Data-Collection) and [Exploration](https://github.com/FSecureLABS/awspx/wiki/Data-Exploration); and [FAQs](https://github.com/FSecureLABS/awspx/wiki/FAQs), respectively.* 26 | 27 | ## Installation 28 | 29 | **awspx** can be [installed](https://github.com/FSecureLABS/awspx/wiki/Setup) on either Linux or macOS. *In each case [Docker](https://docs.docker.com/get-docker/) is required.* 30 | 31 | 1. Clone this repo 32 | ```bash 33 | git clone https://github.com/FSecureLABS/awspx.git 34 | ``` 35 | 2. Run the `INSTALL` script 36 | ```bash 37 | cd awspx && ./INSTALL 38 | ``` 39 | 40 | ## Usage 41 | 42 | **awspx** consists of two main components: the [**ingestor**](https://github.com/FSecureLABS/awspx/wiki/Data-Collection#ingestion), *which collects AWS account data*; and the [**web interface**](https://github.com/FSecureLABS/awspx/wiki/Data-Exploration#overview), *which allows you to explore it*. 43 | 44 | 1. [Run the **ingestor**](https://github.com/FSecureLABS/awspx/wiki/Data-Collection#ingestion) against an account of your choosing. _You will be prompted for [credentials](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-configure.html#cli-quick-configuration)._ 45 | 46 | ```bash 47 | awspx ingest 48 | ``` 49 | _**OR** optionally forgo this step and [load the sample dataset](https://github.com/FSecureLABS/awspx/wiki/Data-Collection#zip-files) instead._ 50 | 51 | ```bash 52 | awspx db --load-zip sample.zip 53 | awspx attacks 54 | ``` 55 | 56 | 2. Browse to the **web interface** — * by default* — and [explore this environment](https://github.com/FSecureLABS/awspx/wiki/Data-Exploration##usage-examples). 57 | 58 | 59 | 60 | 61 | # Contributing 62 | 63 | This project is in its early days and there's still plenty that can be done. Whether its submitting a fix, identifying bugs, suggesting enhancements, creating or updating documentation, refactoring smell code, or even extending this list — all contributions help and are more than welcome. Please feel free to use your judgement and do whatever you think would benefit the community most. 64 | 65 | *See [Contributing](https://github.com/FSecureLABS/awspx/wiki/Contributing) for more information.* 66 | 67 | # License 68 | 69 | **awspx** is a graph-based tool for visualizing effective access and resource relationships within AWS. (C) 2018-2020 F-SECURE. 70 | 71 | This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. 72 | 73 | This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. 74 | 75 | You should have received a copy of the GNU General Public License along with this program. If not, see . 76 | -------------------------------------------------------------------------------- /cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import argparse 4 | import os 5 | import sys 6 | 7 | import boto3 8 | import git 9 | from botocore.credentials import InstanceMetadataProvider 10 | from botocore.exceptions import ClientError 11 | from botocore.utils import InstanceMetadataFetcher 12 | 13 | from lib.aws.attacks import Attacks 14 | from lib.aws.ingestor import * 15 | from lib.aws.profile import Profile 16 | from lib.aws.resources import RESOURCES 17 | from lib.graph.db import Neo4j 18 | from lib.util.console import console 19 | 20 | SERVICES = list(Ingestor.__subclasses__()) 21 | 22 | 23 | def handle_update(args): 24 | """ 25 | awspx update 26 | """ 27 | 28 | repo = git.Repo("/opt/awspx") 29 | head = repo.head.commit 30 | 31 | repo.remotes.origin.set_url("https://github.com/FSecureLABS/awspx.git") 32 | repo.git.reset('--hard') 33 | repo.remotes.origin.pull() 34 | 35 | if head == repo.head.commit: 36 | console.notice("Already up to date") 37 | return 38 | 39 | console.task(f"Updating to {repo.head.commit}", os.system, args=[ 40 | "cd /opt/awspx/www && npm install >/dev/null 2>&1"], 41 | done=f"Updated to {repo.head.commit}") 42 | 43 | 44 | def handle_profile(args, console=console): 45 | """ 46 | awspx profile 47 | """ 48 | 49 | profile = Profile(console=console) 50 | 51 | if args.create_profile: 52 | profile.create(args.create_profile) 53 | if args.create_profile in Profile().credentials.sections(): 54 | console.notice(f"Saved profile '{args.create_profile}'") 55 | else: 56 | sys.exit() 57 | 58 | elif args.list_profiles: 59 | profile.list() 60 | 61 | elif args.delete_profile: 62 | profile.delete(args.delete_profile) 63 | 64 | 65 | def handle_ingest(args): 66 | """ 67 | awspx ingest 68 | """ 69 | 70 | session = None 71 | 72 | # Get credentials from environment variables 73 | if args.env: 74 | session = boto3.session.Session(region_name=args.region) 75 | 76 | # Use existing profile 77 | elif args.profile in Profile().credentials.sections(): 78 | session = boto3.session.Session(profile_name=args.profile, 79 | region_name=args.region) 80 | # Use instance profile 81 | elif args.profile == "default": 82 | try: 83 | provider = InstanceMetadataProvider( 84 | iam_role_fetcher=InstanceMetadataFetcher()) 85 | creds = provider.load() 86 | 87 | session = boto3.session.Session(region_name=args.region, 88 | aws_access_key_id=creds.access_key, 89 | aws_secret_access_key=creds.secret_key, 90 | aws_session_token=creds.token) 91 | except: 92 | pass 93 | 94 | # Specified profile doesn't exist, offer to create it 95 | if not session: 96 | 97 | profile = console.item("Create profile" 98 | ) if args.pretty else console 99 | 100 | profile.notice(f"The profile '{args.profile}' doesn't exist. " 101 | "Please enter your AWS credentials.\n" 102 | "(this information will be saved automatically)") 103 | 104 | args.create_profile = args.profile 105 | handle_profile(args, console=profile) 106 | 107 | session = boto3.session.Session(profile_name=args.profile, 108 | region_name=args.region) 109 | # Ancillary operations 110 | try: 111 | 112 | if args.mfa_device: 113 | 114 | session_token = session.client('sts').get_session_token( 115 | SerialNumber=args.mfa_device, 116 | TokenCode=args.mfa_token, 117 | DurationSeconds=args.mfa_duration 118 | )["Credentials"] 119 | 120 | session = boto3.session.Session( 121 | aws_access_key_id=session_token["AccessKeyId"], 122 | aws_secret_access_key=session_token["SecretAccessKey"], 123 | aws_session_token=session_token["SessionToken"], 124 | region_name=args.region) 125 | 126 | if args.role_to_assume: 127 | 128 | assume_role_args = {"RoleArn": args.role_to_assume, 129 | "RoleSessionName": "awspx", 130 | "DurationSeconds": args.role_to_assume_duration, 131 | **dict({"ExternalId": args.role_to_assume_external_id} if args.role_to_assume_external_id else {}) 132 | } 133 | 134 | assumed_role = session.client('sts').assume_role( 135 | **assume_role_args)["Credentials"] 136 | 137 | session = boto3.session.Session( 138 | aws_access_key_id=assumed_role["AccessKeyId"], 139 | aws_secret_access_key=assumed_role["SecretAccessKey"], 140 | aws_session_token=assumed_role["SessionToken"], 141 | region_name=args.region) 142 | 143 | except ClientError as e: 144 | console.critical(e) 145 | 146 | ingestor = IngestionManager(session=session, console=console, services=args.services, 147 | db=args.database, quick=args.quick, skip_actions=args.skip_actions_all, 148 | only_types=args.only_types, skip_types=args.skip_types, 149 | only_arns=args.only_arns, skip_arns=args.skip_arns) 150 | 151 | assert ingestor.zip is not None, "Ingestion failed" 152 | 153 | args.load_zips = [ingestor.zip] 154 | handle_db(args, console=console.item("Creating Database")) 155 | 156 | if not (args.skip_attacks_all or args.skip_actions_all): 157 | handle_attacks(args, console=console.item("Updating Attack paths")) 158 | 159 | 160 | def handle_attacks(args, console=console): 161 | """ 162 | awspx attacks 163 | """ 164 | 165 | attacks = Attacks(skip_conditional_actions=args.include_conditional_attacks == False, 166 | skip_attacks=args.skip_attacks, only_attacks=args.only_attacks, 167 | max_search_depth=str(args.max_attack_depth 168 | if args.max_attack_depth is not None 169 | else ""), 170 | console=console) 171 | 172 | attacks.compute(max_iterations=args.max_attack_iterations) 173 | 174 | 175 | def handle_db(args, console=console): 176 | """ 177 | awspx db 178 | """ 179 | 180 | db = Neo4j(console=console) 181 | 182 | if args.load_zips: 183 | 184 | db.load_zips(archives=args.load_zips, 185 | db=args.database if 'database' in args else 'default') 186 | 187 | elif args.list_dbs: 188 | db.list() 189 | 190 | elif args.use_db: 191 | db.use(args.use_db) 192 | 193 | 194 | def main(): 195 | 196 | def profile(p): 197 | if p in list(Profile().credentials.sections()): 198 | raise argparse.ArgumentTypeError(f"profile '{p}' already exists") 199 | return p 200 | 201 | def database(p): 202 | if re.compile("[A-Za-z0-9-_]+").match(p) is None: 203 | suggestion = re.sub(r'[^A-Za-z0-9-_]+', '', p) 204 | raise argparse.ArgumentTypeError( 205 | f"'{p}' is invalid, perhaps you meant '{suggestion}' instead?") 206 | return p 207 | 208 | def service(service): 209 | match = next((s for s in SERVICES 210 | if s.__name__.upper() == service.replace(',', '').upper() 211 | ), None) 212 | if match is None: 213 | raise argparse.ArgumentTypeError( 214 | f"'{service}' is not a supported service " 215 | f"(eg: {', '.join([str(v.__name__) for v in SERVICES])})") 216 | return match 217 | 218 | def resource(resource): 219 | 220 | match = next((r for r in RESOURCES 221 | if r.upper() == resource.upper() 222 | ), None) 223 | if match is None: 224 | raise argparse.ArgumentTypeError( 225 | f"'{resource}' is not a supported resource type " 226 | "(see lib/aws/resources.py for details)") 227 | return match 228 | 229 | def ARN(arn): 230 | 231 | if re.compile( 232 | "arn:aws:([a-zA-Z0-9]+):([a-z0-9-]*):(\d{12}|aws)?:(.*)" 233 | ).match(arn) is None: 234 | raise argparse.ArgumentTypeError( 235 | f"'{arn}' is not a valid ARN") 236 | 237 | return arn 238 | 239 | def attack(name): 240 | match = next((a for a in Attacks.definitions 241 | if a.upper() == name.upper() 242 | ), None) 243 | if match is None: 244 | raise argparse.ArgumentTypeError( 245 | f"'{name}' is not a supported attack " 246 | '(see lib/aws/attacks.py for details)') 247 | return match 248 | 249 | parser = argparse.ArgumentParser( 250 | prog="awspx", 251 | description=("awspx is a graph-based tool for visualizing effective " 252 | "access and resource relationships within AWS. ")) 253 | 254 | subparsers = parser.add_subparsers(title="commands") 255 | 256 | # 257 | # awspx update 258 | # 259 | update_parser = subparsers.add_parser("update", 260 | help="Update awspx to the latest version.") 261 | update_parser.set_defaults(func=handle_update) 262 | 263 | # 264 | # awspx profile 265 | # 266 | profile_parser = subparsers.add_parser("profile", 267 | help="Manage AWS credential profiles used for ingestion.") 268 | profile_parser.set_defaults(func=handle_profile) 269 | 270 | profile_group = profile_parser.add_mutually_exclusive_group(required=True) 271 | 272 | profile_group.add_argument('--create', dest='create_profile', default=None, type=profile, 273 | help="Create a new profile using `aws configure`.") 274 | profile_group.add_argument('--list', dest='list_profiles', action='store_true', 275 | help="List saved profiles.") 276 | profile_group.add_argument('--delete', dest='delete_profile', choices=Profile().credentials.sections(), 277 | help="Delete a saved profile.") 278 | # 279 | # awspx ingest 280 | # 281 | ingest_parser = subparsers.add_parser( 282 | "ingest", help="Ingest data from an AWS account.") 283 | ingest_parser.set_defaults(func=handle_ingest) 284 | 285 | # Profile & region args 286 | pnr = ingest_parser.add_argument_group("Profile and region") 287 | pnr.add_argument('--env', action='store_true', 288 | help="Use AWS credential environment variables.") 289 | pnr.add_argument('--profile', dest='profile', default="default", 290 | help="Profile to use for ingestion (corresponds to a `[section]` in `~/.aws/credentials).") 291 | pnr.add_argument('--mfa-device', dest='mfa_device', 292 | help="ARN of the MFA device to authenticate with.") 293 | pnr.add_argument('--mfa-token', dest='mfa_token', 294 | help="Current MFA token.") 295 | pnr.add_argument('--mfa-duration', dest='mfa_duration', type=int, default=3600, 296 | help="Maximum session duration in seconds (for MFA session).") 297 | pnr.add_argument('--assume-role', dest='role_to_assume', 298 | help="ARN of a role to assume for ingestion (useful for cross-account ingestion).") 299 | pnr.add_argument('--assume-role-duration', dest='role_to_assume_duration', type=int, default=3600, 300 | help="Maximum session duration in seconds (for --assume-role).") 301 | pnr.add_argument('--assume-role-external-id', dest='role_to_assume_external_id', 302 | help="External ID for the role to assume.") 303 | pnr.add_argument('--region', dest='region', default="eu-west-1", choices=Profile.regions, 304 | help="Region to ingest (defaults to profile region, or `eu-west-1` if not set).") 305 | pnr.add_argument('--database', dest='database', default=None, type=database, 306 | help="Database to store results (defaults to ).") 307 | 308 | # Services & resources args 309 | snr = ingest_parser.add_argument_group("Services and resources") 310 | snr.add_argument('--services', dest='services', default=SERVICES, nargs="+", type=service, 311 | help=(f"One or more services to ingest (eg: {' '.join([s.__name__ for s in SERVICES])}).")) 312 | snr.add_argument('--quick', dest='quick', action='store_true', default=False, 313 | help=("Skips supplementary ingestion functions " 314 | "(i.e. speed at the cost of information).")) 315 | 316 | type_args = snr.add_mutually_exclusive_group() 317 | type_args.add_argument('--only-types', dest='only_types', default=[], nargs="+", type=resource, 318 | help="Resource to include by type, all other resource types will be excluded.") 319 | type_args.add_argument('--skip-types', dest='skip_types', nargs="+", default=[], 320 | type=resource, help="Resources to exclude by type.") 321 | 322 | # ARN args 323 | arn_args = snr.add_mutually_exclusive_group() 324 | arn_args.add_argument('--only-arns', dest='only_arns', default=[], nargs="+", type=ARN, 325 | help="Resources to include by ARN, all other resources will be excluded.") 326 | arn_args.add_argument('--skip-arns', dest='skip_arns', default=[], nargs="+", type=ARN, 327 | help="Resources to exclude by ARN.") 328 | 329 | actions = ingest_parser.add_argument_group("Actions") 330 | actions.add_argument('--skip-actions-all', dest='skip_actions_all', action='store_true', default=False, 331 | help="Skip policy resolution (actions will not be processed).") 332 | 333 | # 334 | # awspx attacks 335 | # 336 | attacks_parser = subparsers.add_parser("attacks", 337 | help="Compute attacks using the active database.") 338 | attacks_parser.set_defaults(func=handle_attacks) 339 | 340 | # Add args to ingest, attacks 341 | for p in [ingest_parser, attacks_parser]: 342 | ag = p.add_argument_group("Attack computation") 343 | g = ag.add_mutually_exclusive_group() 344 | 345 | g.add_argument('--skip-attacks', dest='skip_attacks', default=[], nargs="+", type=attack, 346 | help="Attacks to exclude by name.") 347 | g.add_argument('--only-attacks', dest='only_attacks', default=[], nargs="+", type=attack, 348 | help="Attacks to include by name, all other attacks will be excluded.") 349 | ag.add_argument('--max-attack-iterations', dest='max_attack_iterations', default=5, type=int, 350 | help="Maximum number of iterations to run each attack (default: 5).") 351 | ag.add_argument('--max-attack-depth', dest='max_attack_depth', default=None, type=int, 352 | help="Maximum search depth for attacks (default: None).") 353 | ag.add_argument('--include-conditional-attacks', dest='include_conditional_attacks', action='store_true', default=False, 354 | help="Include conditional actions when computing attacks (default: False).") 355 | 356 | if p is ingest_parser: 357 | ag.add_argument('--skip-attacks-all', dest='skip_attacks_all', action='store_true', default=False, 358 | help="Skip attack path computation (it can be run later with `awspx attacks`).") 359 | 360 | # 361 | # awspx db 362 | # 363 | db_parser = subparsers.add_parser( 364 | "db", help="Manage databases used for visualization, ingestion, and attack computation.") 365 | 366 | db_parser.set_defaults(func=handle_db) 367 | 368 | db_group = db_parser.add_mutually_exclusive_group(required=True) 369 | 370 | db_group.add_argument('--use', dest='use_db', choices=Neo4j.databases, 371 | help="Switch to the specified database.") 372 | db_group.add_argument('--list', dest='list_dbs', action='store_true', 373 | help="List available databases.") 374 | db_group.add_argument('--load-zip', dest='load_zips', choices=sorted(Neo4j.zips), action='append', 375 | help="Create/overwrite database using ZIP file content.") 376 | 377 | # Add --pretty to ingest, attacks, db 378 | for p in [ingest_parser, attacks_parser, db_parser]: 379 | p.add_argument('--pretty', dest='pretty', action='store_true', default=False, 380 | help="Enable pretty output (slower).") 381 | 382 | if len(sys.argv) == 1: 383 | parser.print_help(sys.stderr) 384 | sys.exit(1) 385 | 386 | args = parser.parse_args() 387 | 388 | # Unless a database has been defined for ingest, default to 389 | if 'database' in args and args.database is None: 390 | args.database = f"{args.profile}" 391 | 392 | if 'pretty' in args and not args.pretty: 393 | console.verbose() 394 | else: 395 | console.start() 396 | 397 | try: 398 | args.func(args) 399 | 400 | except (KeyboardInterrupt, SystemExit): 401 | console.stop() 402 | os._exit(1) 403 | 404 | except BaseException as e: 405 | console.critical(e) 406 | os._exit(1) 407 | 408 | console.stop() 409 | 410 | 411 | main() 412 | -------------------------------------------------------------------------------- /data/sample.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReversecLabs/awspx/b5eec7448930e042aeb61723d37cd2f38d7483db/data/sample.zip -------------------------------------------------------------------------------- /lib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReversecLabs/awspx/b5eec7448930e042aeb61723d37cd2f38d7483db/lib/__init__.py -------------------------------------------------------------------------------- /lib/aws/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReversecLabs/awspx/b5eec7448930e042aeb61723d37cd2f38d7483db/lib/aws/__init__.py -------------------------------------------------------------------------------- /lib/aws/policy.py: -------------------------------------------------------------------------------- 1 | 2 | import copy 3 | import json 4 | import re 5 | from functools import reduce 6 | 7 | from lib.aws.actions import ACTIONS 8 | from lib.aws.resources import RESOURCES 9 | 10 | from lib.graph.base import Element, Elements 11 | from lib.graph.edges import Action, Trusts 12 | from lib.graph.nodes import Resource, External, Node 13 | 14 | from lib.util.console import console 15 | 16 | 17 | ''' Consists of Principals, Actions, Resources, and Conditions ''' 18 | 19 | 20 | class Statement: 21 | 22 | _principals = None 23 | _actions = None 24 | _resources = None 25 | _conditions = None 26 | 27 | __statement = {} 28 | __resources = Elements() 29 | 30 | def __init__(self, statement, resource, resources): 31 | 32 | self.__statement = copy.deepcopy(statement) 33 | self.__resources = resources 34 | 35 | self.__str__ = lambda: str(statement) 36 | 37 | assert isinstance(self.__statement, dict) 38 | 39 | keys = [k for k in self.__statement.keys()] 40 | 41 | if not ("Effect" in keys 42 | and any([k in keys for k in ["Action", "NotAction"]])): 43 | 44 | console.critical(f"Statement: {self.__statement} " 45 | "is missing required key") 46 | 47 | if "NotPrincipal" in keys: 48 | console.warn("'NotPrincipal' support hasn't been implemented." 49 | f"Statement: {self.__statement} will be ignored.") 50 | 51 | elif "Principal" not in keys: 52 | self.__statement["Principal"] = {"AWS": [str(resource)]} 53 | self._principals = Elements([resource]) 54 | 55 | if (not any([k in keys for k in ["Resource", "NotResource"]]) 56 | and resource is not None): 57 | self.__statement["Resource"] = [str(resource)] 58 | self._resources = Elements([resource]) 59 | 60 | def _get_principals(self): 61 | '''https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_principal.html''' 62 | 63 | principals = Elements() 64 | 65 | key = list(filter(lambda x: "Principal" in x, 66 | self.__statement.keys()))[0] 67 | 68 | statement = self.__statement[key] 69 | 70 | if isinstance(statement, str) and statement == "*": 71 | statement = {"AWS": "*"} 72 | 73 | assert isinstance(statement, dict) 74 | 75 | if "AWS" in statement: 76 | 77 | if not isinstance(statement["AWS"], list): 78 | statement["AWS"] = [statement["AWS"]] 79 | 80 | if '*' in statement["AWS"]: 81 | 82 | external = External( 83 | key="Arn", 84 | labels=["AWS::Account"], 85 | properties={ 86 | "Name": "All AWS Accounts", 87 | "Description": "Pseudo-Account representing anyone who possesses an AWS account", 88 | "Arn": "arn:aws:iam::{Account}:root" 89 | }) 90 | 91 | principals = Elements([*self.__resources.get('AWS::Iam::User').get("Resource"), 92 | *self.__resources.get('AWS::Iam::Role').get("Resource"), 93 | external]) 94 | 95 | for principal in [p for p in statement["AWS"] if '*' not in statement["AWS"]]: 96 | 97 | if '*' in principal: 98 | continue 99 | 100 | node = next((a for a in self.__resources.get("Resource") 101 | if a.id() == principal), 102 | None) 103 | 104 | # We haven't seen this node before. It may belong to another account, 105 | # or it belongs to a service that was not loaded. 106 | if node is None: 107 | 108 | name = principal 109 | labels = ["AWS::Account"] 110 | 111 | if re.compile(f"^{RESOURCES.regex['Account']}$" 112 | ).match(principal) is not None: 113 | 114 | principal = f"arn:aws:iam::{principal}:root" 115 | labels += ["AWS::Account"] 116 | 117 | elif re.compile(f"^arn:aws:iam::{RESOURCES.regex['Account']}:root$" 118 | ).match(principal) is not None: 119 | 120 | name = str(principal.split(":")[4]) 121 | labels += ["AWS::Account"] 122 | 123 | else: 124 | 125 | for k, v in RESOURCES.items(): 126 | 127 | if re.compile(v).match(principal): 128 | name = principal.replace( 129 | '/', ':').split(':')[-1] 130 | labels = [k] 131 | break 132 | 133 | node = External( 134 | key=str("Arn" if principal.startswith("arn") 135 | else "CanonicalUser"), 136 | labels=labels, 137 | properties={ 138 | "Name": str(name), 139 | str("Arn" if principal.startswith("arn") else "CanonicalUser"): principal, 140 | }) 141 | 142 | principals.add(node) 143 | 144 | elif "Service" in statement: 145 | 146 | services = statement["Service"] if isinstance( 147 | statement["Service"], list) else [statement["Service"]] 148 | 149 | for service in services: 150 | 151 | if service.lower().endswith("amazonaws.com"): 152 | labels = ["AWS::Domain"] 153 | else: 154 | labels = ["Internet::Domain"] 155 | 156 | principals.add(Node( 157 | key="Name", 158 | labels=labels, 159 | properties={ 160 | "Name": service 161 | })) 162 | 163 | elif "Federated" in statement: 164 | 165 | node = None 166 | labels = [] 167 | 168 | statements = statement["Federated"] \ 169 | if isinstance(statement["Federated"], list) \ 170 | else [statement["Federated"]] 171 | 172 | for federated in statements: 173 | 174 | if re.compile( 175 | RESOURCES["AWS::Iam::SamlProvider"] 176 | ).match(federated) is not None: 177 | 178 | base = Resource if (next((a for a in self.__resources.get("Resoure") 179 | if a.account() == federated.split(':')[4] 180 | ), False)) else External 181 | node = base( 182 | key="Arn", 183 | labels=["AWS::Iam::SamlProvider"], 184 | properties={ 185 | "Name": federated.split('/')[-1], 186 | "Arn": federated 187 | }) 188 | 189 | elif re.compile( 190 | RESOURCES["AWS::Iam::OidcProvider"] 191 | ).match(federated) is not None: 192 | 193 | base = Resource if (next((a for a in self.__resources.get("Resoure") 194 | if a.account() == federated.split(':')[4] 195 | ), False)) else External 196 | node = base( 197 | key="Arn", 198 | labels=["AWS::Iam::OidcProvider"], 199 | properties={ 200 | "Name": federated.split('/')[-1], 201 | "Arn": federated 202 | }) 203 | 204 | elif re.compile( 205 | "^(?=.{1,253}\.?$)(?:(?!-|[^.]+_)[A-Za-z0-9-_]{1,63}(? 0 285 | ]): 286 | 287 | # Identify variable resource-level permissions 288 | variables = list(re.findall("\$\{[0-9a-zA-Z:]+\}", rlp)) 289 | regex = re.compile(reduce(lambda x, y: x.replace(y, "(.*)"), 290 | variables, rlp)) 291 | 292 | # Match resource-level permissions against resource arns 293 | results = Elements(filter(lambda r: regex.match(r.id()), 294 | all_resources)) 295 | 296 | # Standard case: add results to result set 297 | if len(variables) == 0: 298 | for r in results: 299 | conditions[r.id()] = [{}] 300 | resources.add(r) 301 | 302 | offset = len([x for x in rlp if x == '(']) + 1 303 | 304 | # Handle resource-level permissions 305 | for result in [r for r in results 306 | if r.id() not in conditions 307 | or conditions[r.id()] != [{}]]: 308 | 309 | # TODO: skip resources that incorporate contradictory conditions 310 | condition = { 311 | "StringEquals": { 312 | variables[i]: regex.match( 313 | result.id()).group(offset + i) 314 | for i in range(len(variables))} 315 | } 316 | 317 | if result.id() not in conditions: 318 | conditions[result.id()] = [] 319 | 320 | if condition not in conditions[result.id()]: 321 | conditions[result.id()].append(condition) 322 | 323 | if result not in resources: 324 | resources.add(result) 325 | 326 | if '*' in statement: 327 | 328 | resources = all_resources 329 | 330 | elif key == "NotResource": 331 | resources = [r for r in all_resources 332 | if r not in resources] 333 | 334 | resources = Elements(resources) 335 | conditions = {str(r): conditions[r.id()] if r.id() in conditions else [{}] 336 | for r in resources} 337 | 338 | return (resources, conditions) 339 | 340 | def principals(self): 341 | 342 | if self._principals is None: 343 | self._principals = self._get_principals() 344 | 345 | return self._principals 346 | 347 | def actions(self): 348 | 349 | if self._actions is not None: 350 | return self._actions 351 | 352 | (principals, actions, resources, conditions) = (self.principals(), 353 | Elements(), 354 | self.resources(), 355 | self.conditions()) 356 | 357 | for action in self._get_actions(): 358 | 359 | action_resources = Elements() 360 | 361 | # Actions that do not affect specific resource types. 362 | if ACTIONS[action]["Affects"] == {}: 363 | action_resources.update(Elements( 364 | self.__resources.get("CatchAll"))) 365 | 366 | for affected_type in ACTIONS[action]["Affects"].keys(): 367 | # Ignore mutable actions affecting built in policies 368 | if (affected_type == "AWS::Iam::Policy" and ACTIONS[action]["Access"] in [ 369 | "Permissions Management", 370 | "Write" 371 | ]): 372 | action_resources.update([a for a in resources.get(affected_type) 373 | if str(a).split(':')[4] != "aws"]) 374 | else: 375 | action_resources.update( 376 | resources.get(affected_type) 377 | ) 378 | 379 | for resource in action_resources: 380 | # Action conditions comprise of resource-level conditions and statement conditions 381 | resource_conditions = list(conditions[str(resource)] 382 | if str(resource) in conditions else [{}]) 383 | 384 | statement_conditions = dict(self.__statement["Condition"] 385 | if "Condition" in self.__statement.keys() else {}) 386 | # Add the two together 387 | condition = json.dumps([ 388 | { 389 | **resource_conditions[i], 390 | **statement_conditions 391 | } for i in range(len(resource_conditions)) 392 | ]) if (len(resource_conditions[0]) + len(statement_conditions)) > 0 \ 393 | else "[]" 394 | 395 | # Incorporate all items from ACTIONS.py 396 | supplementary = next((ACTIONS[action]["Affects"][r] 397 | for r in resource.labels() 398 | if r in ACTIONS[action]["Affects"]), 399 | {}) 400 | 401 | for principal in self._principals: 402 | 403 | actions.add(Action( 404 | properties={ 405 | "Name": action, 406 | "Description": ACTIONS[action]["Description"], 407 | "Effect": self.__statement["Effect"], 408 | "Access": ACTIONS[action]["Access"], 409 | "Reference": ACTIONS[action]["Reference"], 410 | "Condition": condition, 411 | **supplementary 412 | }, source=principal, target=resource)) 413 | 414 | # Unset resource level permission conditions 415 | for resource in self._resources: 416 | resource.condition = [] 417 | 418 | self._actions = actions 419 | 420 | return self._actions 421 | 422 | def resources(self): 423 | 424 | if self._resources is None: 425 | (self._resources, self._conditions) = self._get_resources_and_conditions() 426 | 427 | return self._resources 428 | 429 | def conditions(self): 430 | 431 | if self._conditions is None: 432 | (self._resources, self._conditions) = self._get_resources_and_conditions() 433 | 434 | return self._conditions 435 | 436 | 437 | ''' Consists of one or more Statements ''' 438 | 439 | 440 | class Document: 441 | 442 | def __init__(self, document, resource, resources): 443 | 444 | self.statements = [] 445 | 446 | if not (isinstance(document, dict) 447 | and "Version" in document 448 | and document["Version"] == "2012-10-17" 449 | and "Statement" in document): 450 | return 451 | 452 | self.document = json.loads(json.dumps(document)) 453 | 454 | if not isinstance(self.document["Statement"], list): 455 | self.document["Statement"] = [self.document["Statement"]] 456 | 457 | for statement in self.document["Statement"]: 458 | self.statements.append(Statement(statement=statement, 459 | resource=resource, 460 | resources=resources)) 461 | 462 | def __len__(self): 463 | return len(self.statements) 464 | 465 | def principals(self): 466 | 467 | principals = Elements() 468 | 469 | for statement in self.statements: 470 | principals.update(statement.principals()) 471 | 472 | return principals 473 | 474 | def actions(self): 475 | 476 | actions = Elements() 477 | 478 | for statement in self.statements: 479 | actions.update(statement.actions()) 480 | 481 | return actions 482 | 483 | 484 | ''' Consists of one or more Documents ''' 485 | 486 | 487 | class Policy: 488 | 489 | def __init__(self, resource, resources): 490 | 491 | self.__resource = resource 492 | self.documents = {} 493 | 494 | def __len__(self): 495 | return len(self.documents) 496 | 497 | def principals(self): 498 | 499 | principals = Elements() 500 | 501 | for policy in self.documents.values(): 502 | principals.update(policy.principals()) 503 | 504 | return principals 505 | 506 | def actions(self): 507 | 508 | actions = Elements() 509 | 510 | for document in self.documents.values(): 511 | actions.update(document.actions()) 512 | 513 | console.info(f"{self.__class__.__name__} {self.__resource} " 514 | f"resolved to {len(actions)} Action(s)") 515 | 516 | return actions 517 | 518 | 519 | ''' Inline and Managed Policies associated with IAM entities ''' 520 | 521 | 522 | class IdentityBasedPolicy(Policy): 523 | 524 | def __init__(self, resource, resources): 525 | 526 | super().__init__(resource, resources) 527 | 528 | key = list(filter(lambda k: k == "Document" or k == "Documents", 529 | resource.properties().keys())) 530 | if len(key) != 1: 531 | return 532 | 533 | # Set self.documents 534 | for policy in resource.properties()[key[0]]: 535 | for name, document in policy.items(): 536 | self.documents[name] = Document(document, resource, resources) 537 | 538 | 539 | ''' Policies that define actions permitted to be performed on the associated resource. ''' 540 | 541 | 542 | class ResourceBasedPolicy(Policy): 543 | 544 | # https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_aws-services-that-work-with-iam.html 545 | 546 | def __init__(self, resource, resources, keys=[]): 547 | 548 | super().__init__(resource, resources) 549 | 550 | for k, v in resource.properties().items(): 551 | 552 | if not (len(keys) == 0 or k in keys): 553 | continue 554 | 555 | document = Document(v, resource, resources) 556 | 557 | if not len(document) > 0: 558 | continue 559 | 560 | # Set self.documents 561 | self.documents[k] = Document(resource.properties()[k], 562 | resource, resources) 563 | 564 | 565 | ''' Resource based policy variant, specific to S3 Buckets and their Objects ''' 566 | 567 | 568 | class BucketACL(ResourceBasedPolicy): 569 | # https://docs.aws.amazon.com/AmazonS3/latest/dev/acl-overview.html#specifying-grantee 570 | 571 | AccessControlList = { 572 | "READ": [ 573 | "s3:ListBucket", 574 | "s3:ListBucketVersions", 575 | "s3:ListBucketMultipartUploads" 576 | ], 577 | "WRITE": [ 578 | "s3:PutObject", 579 | "s3:DeleteObject" 580 | ], 581 | "READ_ACP": [ 582 | "s3:GetBucketAcl" 583 | ], 584 | "WRITE_ACP": [ 585 | "s3:PutBucketAcl" 586 | ], 587 | "FULL_CONTROL": [ 588 | "s3:DeleteObject", 589 | "s3:GetBucketAcl", 590 | "s3:ListBucket", 591 | "s3:ListBucketMultipartUploads", 592 | "s3:ListBucketVersions", 593 | "s3:PutBucketAcl", 594 | "s3:PutObject" 595 | ], 596 | } 597 | 598 | def __init__(self, resource, resources): 599 | 600 | statements = [] 601 | 602 | for key, acls in resource.properties().items(): 603 | 604 | # Property is not a valid ACL 605 | if not (isinstance(acls, dict) and "Grants" in acls 606 | and all(["Grantee" in x and "Permission" for x in acls["Grants"]])): 607 | continue 608 | 609 | # Construct a policy from ACL 610 | for (grantee, permission) in map(lambda x: (x["Grantee"], x["Permission"]), acls["Grants"]): 611 | 612 | statement = { 613 | "Effect": "Allow" 614 | } 615 | 616 | # Handle Principal 617 | if grantee["Type"] not in ["CanonicalUser", "Group"]: 618 | raise ValueError 619 | 620 | if grantee["Type"] == "CanonicalUser": 621 | 622 | statement["Principal"] = {"CanonicalUser": grantee["ID"]} 623 | 624 | elif grantee["Type"] == "Group": 625 | 626 | group = grantee["URI"].split('/')[-1] 627 | 628 | # Any AWS account can access this resource 629 | if group == "AuthenticatedUsers": 630 | statement["Principal"] = {"AWS": "*"} 631 | 632 | # Anyone (not neccessarily AWS) 633 | elif group == "AllUsers": 634 | statement["Principal"] = {"AWS": "*"} 635 | 636 | # Service 637 | elif group == "LogDelivery": 638 | statement["Principal"] = {"Service": grantee["URI"]} 639 | 640 | # Specific AWS resource 641 | else: 642 | statement["Principal"] = {"AWS": grantee["URI"]} 643 | 644 | # Handle Actions 645 | statement["Action"] = self.AccessControlList[permission] 646 | 647 | # Handle Resources (Bucket and Objects in Bucket) 648 | statement["Resource"] = [resource.id(), resource.id() + "/*"] 649 | 650 | statements.append(statement) 651 | 652 | if len(statements) > 0: 653 | 654 | resource.properties()["_"] = { 655 | "Version": "2012-10-17", 656 | "Statement": statements 657 | } 658 | 659 | super().__init__(resource, resources, keys=["_"]) 660 | 661 | if "_" in resource.properties(): 662 | del resource.properties()["_"] 663 | 664 | 665 | class ObjectACL(BucketACL): 666 | # https://docs.aws.amazon.com/AmazonS3/latest/dev/acl-overview.html#specifying-grantee 667 | 668 | AccessControlList = { 669 | "READ": [ 670 | "s3:GetObject", 671 | "s3:GetObjectVersion", 672 | "s3:GetObjectTorrent" 673 | ], 674 | "WRITE": [ 675 | ], 676 | "READ_ACP": [ 677 | "s3:GetObjectAcl", 678 | "s3:GetObjectVersionAcl", 679 | ], 680 | "WRITE_ACP": [ 681 | "s3:PutObjectAcl", 682 | "s3:PutObjectVersionAcl", 683 | ], 684 | "FULL_CONTROL": [ 685 | "s3:GetObject", 686 | "s3:GetObjectVersion", 687 | "s3:GetObjectTorrent", 688 | "s3:GetObjectAcl", 689 | "s3:GetObjectVersionAcl", 690 | "s3:PutObjectAcl", 691 | "s3:PutObjectVersionAcl", 692 | ], 693 | } 694 | -------------------------------------------------------------------------------- /lib/aws/profile.py: -------------------------------------------------------------------------------- 1 | 2 | import sys 3 | import os 4 | from awscli.clidriver import CLIDriver, load_plugins 5 | from awscli.customizations.configure.configure import ConfigureCommand 6 | from awscli.customizations.configure import mask_value 7 | from botocore.session import Session 8 | from configparser import ConfigParser 9 | 10 | 11 | class InteractivePrompter(object): 12 | 13 | def __init__(self, console): 14 | self.console = console 15 | 16 | def input(self, prompt, value): 17 | return self.console.input(f"{prompt} [{value}]: ") 18 | 19 | # See awscli/customizations/configure/configure.py 20 | 21 | def get_value(self, current_value, config_name, prompt_text=''): 22 | if config_name in ('aws_access_key_id', 'aws_secret_access_key'): 23 | current_value = mask_value(current_value) 24 | response = self.input(prompt_text, current_value) 25 | if not response: 26 | # If the user hits enter, we return a value of None 27 | # instead of an empty string. That way we can determine 28 | # whether or not a value has changed. 29 | response = None 30 | return response 31 | 32 | 33 | class Profile: 34 | 35 | regions = [ 36 | "af-south-1", "ap-east-1", "ap-northeast-1", 37 | "ap-northeast-2", "ap-northeast-3", "ap-south-1", 38 | "ap-southeast-1", "ap-southeast-2", "ca-central-1", 39 | "cn-north-1", "cn-northwest-1", "eu-central-1", 40 | "eu-north-1", "eu-south-1", "eu-west-1", 41 | "eu-west-2", "eu-west-3", "me-south-1", 42 | "sa-east-1", "us-east-1", "us-east-2", 43 | "us-gov-east-1", "us-gov-west-1", "us-west-1", 44 | "us-west-2" 45 | ] 46 | 47 | config_file = os.environ['HOME'] + '/.aws/config' 48 | credentials_file = os.environ['HOME'] + '/.aws/credentials' 49 | 50 | config = ConfigParser() 51 | credentials = ConfigParser() 52 | 53 | def __init__(self, console=None): 54 | 55 | if console is None: 56 | from lib.util.console import console 57 | self.console = console 58 | 59 | self.credentials.read(self.credentials_file) 60 | self.config.read(self.config_file) 61 | 62 | def create(self, profile=None): 63 | self.reconfigure(profile) 64 | 65 | def reconfigure(self, profile=None): 66 | 67 | if profile is None: 68 | return 69 | 70 | # See awscli/clidriver.py 71 | session = Session() 72 | load_plugins(session.full_config.get('plugins', {}), 73 | event_hooks=session.get_component('event_emitter')) 74 | 75 | driver = CLIDriver(session=session) 76 | driver._command_table = driver._build_command_table() 77 | driver._command_table["configure"] = ConfigureCommand( 78 | session, 79 | prompter=InteractivePrompter(self.console) 80 | ) 81 | 82 | driver.main(args=["configure", "--profile", profile]) 83 | 84 | def list(self): 85 | 86 | self.console.list([{"Profile": p} for p in self.credentials.keys() 87 | if p != "DEFAULT"]) 88 | 89 | def delete(self, profile=None): 90 | 91 | if profile is None: 92 | return 93 | 94 | if self.config.has_section(profile): 95 | self.config.remove_section(profile) 96 | 97 | if self.credentials.has_section(profile): 98 | self.credentials.remove_section(profile) 99 | 100 | with open(self.config_file, 'w') as f: 101 | self.config.write(f) 102 | 103 | with open(self.credentials_file, 'w') as f: 104 | self.credentials.write(f) 105 | 106 | self.console.info(f"Profile '{profile}' deleted.") 107 | -------------------------------------------------------------------------------- /lib/aws/resources.py: -------------------------------------------------------------------------------- 1 | 2 | import re 3 | 4 | 5 | class Resources(dict): 6 | 7 | # https://docs.aws.amazon.com/general/latest/gr/aws-arns-and-namespaces.html#genref-aws-service-namespaces 8 | 9 | types = { 10 | "AWS::Account": "arn:aws:iam::{Account}:root", 11 | "AWS::Ec2::CapacityReservation": "arn:aws:ec2:{Region}:{Account}:capacity-reservation/{ReservationId}", 12 | "AWS::Ec2::CarrierGateway": "arn:aws:ec2:{Region}:{Account}:carrier-gateway/{CarrierGatewayId}", 13 | "AWS::Ec2::Certificate": "arn:aws:acm:{Region}:{Account}:certificate/{CertificateId}", 14 | "AWS::Ec2::ClientVpnEndpoint": "arn:aws:ec2:{Region}:{Account}:client-vpn-endpoint/{EndpointId}", 15 | "AWS::Ec2::CustomerGateway": "arn:aws:ec2:{Region}:{Account}:customer-gateway/{CgwId}", 16 | "AWS::Ec2::DedicatedHost": "arn:aws:ec2:{Region}:{Account}:dedicated-host/{DedicatedHostId}", 17 | "AWS::Ec2::DhcpOptions": "arn:aws:ec2:{Region}:{Account}:dhcp-options/{DhcpOptionsId}", 18 | "AWS::Ec2::EgressOnlyInternetGateway": "arn:aws:ec2:{Region}:{Account}:egress-only-internet-gateway/{EgressOnlyInternetGatewayId}", 19 | "AWS::Ec2::ElasticGpu": "arn:aws:ec2:{Region}:{Account}:elastic-gpu/{ElasticGpuId}", 20 | "AWS::Ec2::ElasticIp": "arn:aws:ec2:{Region}:{Account}:elastic-ip/{AllocationId}", 21 | "AWS::Ec2::ExportImageTask": "arn:aws:ec2:{Region}:{Account}:export-image-task/{ExportImageTaskId}", 22 | "AWS::Ec2::ExportInstanceTask": "arn:aws:ec2:{Region}:{Account}:export-instance-task/{ExportTaskId}", 23 | "AWS::Ec2::Fleet": "arn:aws:ec2:{Region}:{Account}:fleet/{FleetId}", 24 | "AWS::Ec2::FpgaImage": "arn:aws:ec2:{Region}::fpga-image/{Name}", 25 | "AWS::Ec2::HostReservation": "arn:aws:ec2:{Region}:{Account}:host-reservation/{HostReservationId}", 26 | "AWS::Ec2::Image": "arn:aws:ec2:{Region}:({Account})?:image/{ImageId}", 27 | "AWS::Ec2::ImportImageTask": "arn:aws:ec2:{Region}:{Account}:import-image-task/{ImportImageTaskId}", 28 | "AWS::Ec2::ImportSnapshotTask": "arn:aws:ec2:{Region}:{Account}:import-snapshot-task/{ImportSnapshotTaskId}", 29 | "AWS::Ec2::Instance": "arn:aws:ec2:{Region}:{Account}:instance/{InstanceId}", 30 | "AWS::Ec2::InternetGateway": "arn:aws:ec2:{Region}:{Account}:internet-gateway/{InternetGatewayId}", 31 | "AWS::Ec2::Ipv4PoolEc2": "arn:aws:ec2:{Region}:{Account}:ipv4pool-ec2/{Ipv4PoolEc2Id}", 32 | "AWS::Ec2::Ipv6PoolEc2": "arn:aws:ec2:{Region}:{Account}:ipv6pool-ec2/{Ipv6PoolEc2Id}", 33 | "AWS::Ec2::KeyPair": "arn:aws:ec2:{Region}:{Account}:key-pair/{KeyName}", 34 | "AWS::Ec2::LaunchTemplate": "arn:aws:ec2:{Region}:{Account}:launch-template/{LaunchTemplateId}", 35 | "AWS::Ec2::LocalGateway": "arn:aws:ec2:{Region}:{Account}:local-gateway/{LocalGatewayId}", 36 | "AWS::Ec2::LocalGatewayRouteTable": "arn:aws:ec2:{Region}:{Account}:local-gateway-route-table/{LocalGatewayRouteTableId}", 37 | "AWS::Ec2::LocalGatewayRouteTableVirtualInterfaceGroupAssociation": "arn:aws:ec2:{Region}:{Account}:local-gateway-route-table-virtual-interface-group-association/{LocalGatewayRouteTableVirtualInterfaceGroupAssociationId}", 38 | "AWS::Ec2::LocalGatewayRouteTableVpcAssociation": "arn:aws:ec2:{Region}:{Account}:local-gateway-route-table-vpc-association/{LocalGatewayRouteTableVpcAssociationId}", 39 | "AWS::Ec2::LocalGatewayVirtualInterface": "arn:aws:ec2:{Region}:{Account}:local-gateway-virtual-interface/{LocalGatewayVirtualInterfaceId}", 40 | "AWS::Ec2::LocalGatewayVirtualInterfaceGroup": "arn:aws:ec2:{Region}:{Account}:local-gateway-virtual-interface-group/{LocalGatewayVirtualInterfaceGroupId}", 41 | "AWS::Ec2::NatGateway": "arn:aws:ec2:{Region}:{Account}:natgateway/{NatGatewayId}", 42 | "AWS::Ec2::NetworkAcl": "arn:aws:ec2:{Region}:{Account}:network-acl/{NetworkAclId}", 43 | "AWS::Ec2::NetworkInsightsAnalysis": "arn:aws:ec2:{Region}:{Account}:network-insights-analysis/{NetworkInsightsAnalysisId}", 44 | "AWS::Ec2::NetworkInsightsPath": "arn:aws:ec2:{Region}:{Account}:network-insights-path/{NetworkInsightsPathId}", 45 | "AWS::Ec2::NetworkInterface": "arn:aws:ec2:{Region}:{Account}:network-interface/{NetworkInterfaceId}", 46 | "AWS::Ec2::PlacementGroup": "arn:aws:ec2:{Region}:{Account}:placement-group/{GroupName}", 47 | "AWS::Ec2::PrefixList": "arn:aws:ec2:{Region}:{Account}:prefix-list/{PrefixListId}", 48 | "AWS::Ec2::ReservedInstances": "arn:aws:ec2:{Region}:{Account}:reserved-instances/{ReservationId}", 49 | "AWS::Ec2::RouteTable": "arn:aws:ec2:{Region}:{Account}:route-table/{RouteTableId}", 50 | "AWS::Ec2::SecurityGroup": "arn:aws:ec2:{Region}:{Account}:security-group/{GroupId}", 51 | "AWS::Ec2::Snapshot": "arn:aws:ec2:{Region}::snapshot/{SnapshotId}", 52 | "AWS::Ec2::SpotFleetRequest": "arn:aws:ec2:{Region}:{Account}:spot-fleet-request/{SpotFleetRequestId}", 53 | "AWS::Ec2::SpotInstanceRequest": "arn:aws:ec2:{Region}::spot-instance-request/{Name}", 54 | "AWS::Ec2::SpotInstancesRequest": "arn:aws:ec2:{Region}:{Account}:spot-instances-request/{SpotInstanceRequestId}", 55 | "AWS::Ec2::Subnet": "arn:aws:ec2:{Region}:{Account}:subnet/{SubnetId}", 56 | "AWS::Ec2::TrafficMirrorFilter": "arn:aws:ec2:{Region}:{Account}:traffic-mirror-filter/{TrafficMirrorFilterId}", 57 | "AWS::Ec2::TrafficMirrorFilterRule": "arn:aws:ec2:{Region}:{Account}:traffic-mirror-filter-rule/{TrafficMirrorFilterRuleId}", 58 | "AWS::Ec2::TrafficMirrorSession": "arn:aws:ec2:{Region}:{Account}:traffic-mirror-session/{TrafficMirrorSessionId}", 59 | "AWS::Ec2::TrafficMirrorTarget": "arn:aws:ec2:{Region}:{Account}:traffic-mirror-target/{TrafficMirrorTargetId}", 60 | "AWS::Ec2::TransitGateway": "arn:aws:ec2:{Region}:{Account}:transit-gateway/{TgwId}", 61 | "AWS::Ec2::TransitGatewayAttachment": "arn:aws:ec2:{Region}:{Account}:transit-gateway-attachment/{TgwattachmentId}", 62 | "AWS::Ec2::TransitGatewayConnectPeer": "arn:aws:ec2:{Region}:{Account}:transit-gateway-connect-peer/{TransitGatewayConnectPeerId}", 63 | "AWS::Ec2::TransitGatewayMulticastDomain": "arn:aws:ec2:{Region}:{Account}:transit-gateway-multicast-domain/{TransitGatewayMulticastDomainId}", 64 | "AWS::Ec2::TransitGatewayRouteTable": "arn:aws:ec2:{Region}:{Account}:transit-gateway-route-table/{TgwroutetableId}", 65 | "AWS::Ec2::Volume": "arn:aws:ec2:{Region}:{Account}:volume/{VolumeId}", 66 | "AWS::Ec2::Vpc": "arn:aws:ec2:{Region}:{Account}:vpc/{VpcId}", 67 | "AWS::Ec2::VpcEndpoint": "arn:aws:ec2:{Region}:{Account}:vpc-endpoint/{VpcEndpointId}", 68 | "AWS::Ec2::VpcEndpointService": "arn:aws:ec2:{Region}:{Account}:vpc-endpoint-service/{VpcEndpointServiceId}", 69 | "AWS::Ec2::VpcFlowLog": "arn:aws:ec2:{Region}:{Account}:vpc-flow-log/{VpcFlowLogId}", 70 | "AWS::Ec2::VpcPeeringConnection": "arn:aws:ec2:{Region}:{Account}:vpc-peering-connection/{VpcPeeringConnectionId}", 71 | "AWS::Ec2::VpnConnection": "arn:aws:ec2:{Region}:{Account}:vpn-connection/{VpnConnectionId}", 72 | "AWS::Ec2::VpnGateway": "arn:aws:ec2:{Region}:{Account}:vpn-gateway/{VpnGatewaygwId}", 73 | "AWS::ElasticInference::Accelerator": "arn:aws:elastic-inference:{Region}:{Account}:elastic-inference-accelerator/{ElasticInferenceAcceleratorId}", 74 | "AWS::Iam::AccessReport": "arn:aws:iam::{Account}:access-report/{EntityPath}", 75 | "AWS::Iam::AssumedRole": "arn:aws:iam::{Account}:assumed-role/{Role}/{RoleSessionName}", 76 | "AWS::Iam::FederatedUser": "arn:aws:iam::{Account}:federated-user/{User}", 77 | "AWS::Iam::Group": "arn:aws:iam::{Account}:group/{Group}", 78 | "AWS::Iam::InstanceProfile": "arn:aws:iam::{Account}:instance-profile/{InstanceProfile}", 79 | "AWS::Iam::MfaDevice": "arn:aws:iam::{Account}:u2f/user/{UserName}/{MfaDevice}", 80 | "AWS::Iam::OidcProvider": "arn:aws:iam::{Account}:oidc-provider/{Provider}", 81 | "AWS::Iam::Policy": "arn:aws:iam::{Account}:policy/{Policy}", 82 | "AWS::Iam::Role": "arn:aws:iam::{Account}:role/{Role}", 83 | "AWS::Iam::SamlProvider": "arn:aws:iam::{Account}:saml-provider/{Provider}", 84 | "AWS::Iam::ServerCertificate": "arn:aws:iam::{Account}:server-certificate/{Certificate}", 85 | "AWS::Iam::SmsMfa": "arn:aws:iam::{Account}:sms-mfa/{MfaTokenIdWithPath}", 86 | "AWS::Iam::U2f": "arn:aws:iam::{Account}:u2f/{U2FTokenId}", 87 | "AWS::Iam::User": "arn:aws:iam::{Account}:user/{UserName}", 88 | "AWS::Iam::VirtualMfaDevice": "arn:aws:iam::{Account}:mfa/{UserName}", 89 | "AWS::Lambda::CodeSigningConfig": "arn:aws:lambda:{Region}:{Account}:codesigningconfig:{CodeSigningConfigId}", 90 | "AWS::Lambda::EventSourceMapping": "arn:aws:lambda:{Region}:{Account}:event-source-mapping:{EventSourceMappingId}", 91 | "AWS::Lambda::Function": "arn:aws:lambda:{Region}:{Account}:function:{Function}(:{Alias})?", 92 | "AWS::Lambda::Layer": "arn:aws:lambda:{Region}:{Account}:layer:{Layer}$", 93 | "AWS::Lambda::LayerVersion": "arn:aws:lambda:{Region}:{Account}:layer:{Layer}:{Version}", 94 | "AWS::S3::AccessPoint": "arn:aws:s3:{Region}:{Account}:accesspoint/{AccessPoint}", 95 | "AWS::S3::Bucket": "arn:aws:s3:::{Name}", 96 | "AWS::S3::Job": "arn:aws:s3:{Region}:{Account}:job/{JobId}", 97 | "AWS::S3::Object": "arn:aws:s3:::{Name}/{Key}", 98 | "AWS::S3::ObjectLambdaAccessPoint": "arn:aws:s3-object-lambda:{Region}:{Account}:accesspoint/{AccessPointName}", 99 | "AWS::S3::StorageLensConfiguration": "arn:aws:s3:{Region}:{Account}:storage-lens/{ConfigId}" 100 | } 101 | regex = { 102 | "Region": r"([a-z0-9-]*)", 103 | "Account": r"(\d{12})?", 104 | "Provider": r"(.*)", 105 | "Key": r"(.*)", 106 | "Default": r"([A-Za-z0-9-_]*)", 107 | } 108 | 109 | def __init__(self): 110 | 111 | format_string = re.compile("{([A-Za-z]+)}") 112 | for k, v in self.types.items(): 113 | self[k] = self.types[k] 114 | for placeholder in set(format_string.findall(v)): 115 | self[k] = self[k].replace(f"{{{placeholder}}}", "(?P<{placeholder}>{regex})".format( 116 | placeholder=placeholder, 117 | regex=str(self.regex[placeholder] if placeholder in self.regex 118 | else self.regex["Default"]))) 119 | self[k] += '$' 120 | 121 | def definition(self, k): 122 | if k not in self.types: 123 | return "" 124 | elif self.types[k][-1] == "$": 125 | return self.types[k][0:-1] 126 | else: 127 | return self.types[k] 128 | 129 | def label(self, arn): 130 | for k, v in self.items(): 131 | if re.match(v, arn): 132 | return k 133 | return None 134 | 135 | 136 | RESOURCES = Resources() 137 | -------------------------------------------------------------------------------- /lib/graph/base.py: -------------------------------------------------------------------------------- 1 | 2 | import json 3 | import os 4 | from datetime import datetime 5 | 6 | from lib.aws.actions import ACTIONS 7 | from lib.aws.resources import RESOURCES 8 | 9 | from lib.graph.db import Neo4j 10 | 11 | 12 | class Element: 13 | 14 | def __init__(self, properties={}, labels=[], key="Name"): 15 | 16 | if not isinstance(properties, dict): 17 | raise ValueError() 18 | 19 | if "Name" not in properties: 20 | raise ValueError("All elements must include a name") 21 | 22 | if key not in properties: 23 | raise ValueError("Missing key: '%s'" % key) 24 | 25 | self._properties = {} 26 | 27 | for k, v in properties.items(): 28 | 29 | if any([isinstance(v, t) for t in [datetime, dict, list, int]]): 30 | self._properties[k] = v 31 | continue 32 | 33 | elif type(v) is None: 34 | self._properties[k] = "" 35 | continue 36 | 37 | try: 38 | self._properties[k] = json.loads(v) 39 | continue 40 | except json.decoder.JSONDecodeError: 41 | pass 42 | 43 | try: 44 | self._properties[k] = datetime.strptime( 45 | v[:-6], '%Y-%m-%d %H:%M:%S') 46 | continue 47 | except ValueError: 48 | pass 49 | 50 | self._properties[k] = str(v) 51 | 52 | self._labels = set(labels) 53 | self._key = key 54 | 55 | def properties(self): 56 | return self._properties 57 | 58 | def label(self): 59 | return [ 60 | *[l for l in self.labels() 61 | if l != self.__class__.__name__ 62 | ], 63 | "" 64 | ][0] 65 | 66 | def labels(self): 67 | return sorted(list(self._labels)) 68 | 69 | def type(self, label): 70 | return label in self._labels 71 | 72 | def id(self): 73 | return self._properties[self._key] 74 | 75 | def get(self, k): 76 | return self._properties[k] 77 | 78 | def set(self, k, v): 79 | self._properties[k] = v 80 | 81 | def __hash__(self): 82 | return hash(self.id()) 83 | 84 | def __eq__(self, other): 85 | if isinstance(other, str): 86 | return other in self.labels() 87 | return self.__hash__() == other.__hash__() 88 | 89 | def __lt__(self, other): 90 | return self.__hash__() < other.__hash__() 91 | 92 | def __gt__(self, other): 93 | return self.__hash__() > other.__hash__() 94 | 95 | def __repr__(self): 96 | return self.id() 97 | 98 | def __str__(self): 99 | return str(self.id()) 100 | 101 | 102 | class Node(Element): 103 | def __init__(self, properties={}, labels=[], key="Name"): 104 | super().__init__(properties, labels, key) 105 | 106 | 107 | class Edge(Element): 108 | 109 | def __init__(self, properties={}, source=None, target=None, label=None): 110 | 111 | if label is None: 112 | label = [str(self.__class__.__name__).upper()] 113 | 114 | super().__init__(properties, label) 115 | 116 | self._source = source 117 | self._target = target 118 | self._set_id() 119 | 120 | def _set_id(self): 121 | 122 | self._id = hash("({source})-[:{label}{{{properties}}}]->({target})".format( 123 | source=self.source(), 124 | label=self.labels()[0], 125 | properties=json.dumps(self.properties(), sort_keys=True), 126 | target=self.target()) 127 | ) 128 | 129 | def source(self): 130 | return self._source 131 | 132 | def target(self): 133 | return self._target 134 | 135 | def id(self): 136 | return self._id 137 | 138 | def modify(self, k, v): 139 | super().set(k, v) 140 | self._set_id() 141 | 142 | def __str__(self): 143 | return str(self.get("Name")) 144 | 145 | 146 | class Elements(set): 147 | 148 | def __init__(self, _=[], load=False, generics=False): 149 | 150 | super().__init__(_) 151 | 152 | def __add__(self, other): 153 | return Elements(self.union(other)) 154 | 155 | def __iadd__(self, other): 156 | self.update(other) 157 | return Elements(self) 158 | 159 | def get(self, label): 160 | return Elements(filter(lambda r: r.type(label), self)) 161 | 162 | def __repr__(self): 163 | return str([str(e) for e in self]) 164 | -------------------------------------------------------------------------------- /lib/graph/db.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import os 3 | import re 4 | import shutil 5 | import subprocess 6 | import sys 7 | import time 8 | import warnings 9 | 10 | from neo4j import ExperimentalWarning, GraphDatabase, exceptions 11 | 12 | warnings.filterwarnings("ignore", category=ExperimentalWarning) 13 | 14 | NEO4J_DB_DIR = "/data/databases" 15 | NEO4J_ZIP_DIR = "/opt/awspx/data" 16 | NEO4J_CONF_DIR = "/var/lib/neo4j/conf" 17 | NEO4J_TRANS_DIR = "/data/transactions" 18 | 19 | 20 | class Neo4j(object): 21 | 22 | driver = None 23 | 24 | zips = [z for z in os.listdir(f"{NEO4J_ZIP_DIR}/") 25 | if z.endswith(".zip")] 26 | 27 | databases = [db for db in os.listdir(f"{NEO4J_DB_DIR}/") 28 | if os.path.isdir(f"{NEO4J_DB_DIR}/{db}")] 29 | 30 | def __init__(self, 31 | host="localhost", 32 | port="7687", 33 | username="neo4j", 34 | password=str(os.environ['NEO4J_AUTH'][6:] 35 | if 'NEO4J_AUTH' in os.environ else "password"), 36 | console=None): 37 | 38 | if console is None: 39 | from lib.util.console import console 40 | self.console = console 41 | 42 | self.uri = f"bolt://{host}:{port}" 43 | self.username = username 44 | self.password = password 45 | 46 | def _start(self): 47 | 48 | retries = 0 49 | max_retries = 60 50 | 51 | while retries < max_retries and not self.available(): 52 | 53 | if retries == 0: 54 | 55 | subprocess.Popen(["nohup", "/docker-entrypoint.sh", 56 | "neo4j", "console", "&"], 57 | stdout=subprocess.PIPE, 58 | stderr=subprocess.STDOUT) 59 | time.sleep(1) 60 | retries += 1 61 | 62 | if not self.available(): 63 | self.console.critical("Neo4j failed to start") 64 | return False 65 | 66 | elif retries == 0: 67 | self.console.info("Neo4j has already been started") 68 | else: 69 | self.console.info("Neo4j has successfully been started") 70 | 71 | return True 72 | 73 | def _stop(self): 74 | 75 | retries = 0 76 | max_retries = 10 77 | 78 | while retries < max_retries and self.running(): 79 | subprocess.Popen(["killall", "java"]) 80 | time.sleep(1) 81 | retries += 1 82 | 83 | if self.running(): 84 | self.console.critical("Neo4j failed to stop") 85 | return False 86 | 87 | subprocess.Popen(["rm", "-f", f"{NEO4J_DB_DIR}/store_lock", 88 | f"{NEO4J_DB_DIR}/system/database_lock"], 89 | stdout=subprocess.PIPE, 90 | stderr=subprocess.STDOUT) 91 | 92 | if retries == 0: 93 | self.console.info("Neo4j has already been stopped") 94 | else: 95 | self.console.info("Neo4j has successfully been stopped") 96 | 97 | return True 98 | 99 | def _delete(self, db): 100 | 101 | subprocess.Popen(["rm", "-rf", f"{NEO4J_DB_DIR}/{db}", 102 | f"{NEO4J_TRANS_DIR}/{db}"]) 103 | 104 | def _run(self, tx, cypher): 105 | results = tx.run(cypher) 106 | return results 107 | 108 | def _switch_database(self, db): 109 | 110 | subprocess.Popen(["sed", "-i", 111 | '/^\(#\)\{0,1\}dbms.default_database=/s/.*/dbms.default_database=%s/' % db, 112 | f"{NEO4J_CONF_DIR}/neo4j.conf" 113 | ], stdout=subprocess.PIPE, stderr=subprocess.STDOUT 114 | ).communicate() 115 | 116 | def _load(self, archives, db): 117 | 118 | ARCHIVES = {} 119 | 120 | for archive in archives: 121 | 122 | ARCHIVES[archive] = { 123 | "DIR": None, 124 | "CSV": None, 125 | } 126 | 127 | ARCHIVES[archive]["DIR"] = archive.split('.')[0] 128 | shutil.unpack_archive(archive, ARCHIVES[archive]["DIR"], "zip") 129 | 130 | ARCHIVES[archive]["CSV"] = [f for f in os.listdir(ARCHIVES[archive]["DIR"]) 131 | if f.endswith(".csv")] 132 | 133 | for c in set([c for a in ARCHIVES 134 | for c in ARCHIVES[a]["CSV"] 135 | if len(ARCHIVES) > 0]): 136 | 137 | keys = set() 138 | 139 | for i in range(2): 140 | 141 | for _, v in ARCHIVES.items(): 142 | 143 | if c not in v["CSV"]: 144 | continue 145 | 146 | else: 147 | 148 | with open(f'{v["DIR"]}/{c}', 'r') as f: 149 | headers = [h.strip() 150 | for h in f.readline().split(',')] 151 | 152 | if i == 0: 153 | keys.update(headers) 154 | continue 155 | 156 | additional = [k for k in keys if k not in headers] 157 | 158 | if not len(additional) > 0: 159 | continue 160 | 161 | self.console.debug(f"Adding columns {additional} " 162 | f'to {v["DIR"]}/{c}') 163 | 164 | with open(f'{v["DIR"]}/{c}', 'r') as f: 165 | rows = f.read().splitlines() 166 | 167 | rows[0] = ','.join(rows[0].split(',') + additional) 168 | 169 | for i in range(1, len(rows)): 170 | rows[i] = ','.join(rows[i].split( 171 | ',') + ['' for _ in additional]) 172 | 173 | with open(f'{v["DIR"]}/{c}', 'w') as f: 174 | f.write('\n'.join(rows)) 175 | 176 | csvs = [f"{a['DIR']}/{csv}" for a in ARCHIVES.values() 177 | for csv in a["CSV"]] 178 | 179 | edges = [e for e in csvs 180 | if re.compile("(.*/)?([A-Z]+)\.csv").match(e)] 181 | 182 | nodes = [n for n in csvs 183 | if n not in edges] 184 | 185 | self._delete(db) 186 | 187 | stdout, _ = subprocess.Popen(["/docker-entrypoint.sh", "neo4j-admin", "import", 188 | "--report-file", "/dev/null", 189 | "--skip-duplicate-nodes", "true", 190 | "--skip-bad-relationships", "true", 191 | "--multiline-fields=true", 192 | f"--database={db}", 193 | *[f"--nodes={n}" for n in nodes], 194 | *[f"--relationships={e}" for e in edges]], 195 | stdout=subprocess.PIPE, 196 | stderr=subprocess.STDOUT).communicate() 197 | 198 | subprocess.Popen(["rm", "-rf", *[a["DIR"] for a in ARCHIVES.values()]]) 199 | 200 | stats = re.compile("([0-9a-zA-Z]+)." 201 | "Imported:([0-9]+)nodes" 202 | "([0-9]+)relationships" 203 | "([0-9]+)properties" 204 | "[A-Za-z ]+:(.*)" 205 | ).match(str(stdout).split("IMPORT DONE in ")[-1] 206 | .replace("\\n", "").replace(" ", "")) 207 | 208 | if stats is None: 209 | self.console.critical(str(stdout).replace( 210 | "\\n", "\n").replace("\\t", "\t")) 211 | 212 | (time, nodes, edges, props, ram) = stats.groups() 213 | 214 | return str(f"Loaded {nodes} nodes, {edges} edges, and {props} properties " 215 | f"into '{db}' from: {', '.join([re.sub(f'^{NEO4J_ZIP_DIR}/', '', a) for a in archives])}") 216 | 217 | def running(self): 218 | 219 | stdout, _ = subprocess.Popen(['pgrep', 'java'], 220 | stdout=subprocess.PIPE, 221 | stderr=subprocess.STDOUT).communicate() 222 | 223 | pids = [int(i) for i in stdout.split()] 224 | 225 | return len(pids) > 0 226 | 227 | def open(self): 228 | 229 | self.driver = GraphDatabase.driver( 230 | self.uri, 231 | auth=(self.username, self.password) 232 | ) 233 | 234 | def close(self): 235 | if self.driver is not None: 236 | self.driver.close() 237 | self.driver = None 238 | 239 | def available(self): 240 | try: 241 | self.open() 242 | self.driver.verify_connectivity() 243 | except Exception: 244 | return False 245 | return True 246 | 247 | def use(self, db): 248 | 249 | self.console.task("Stopping Neo4j", 250 | self._stop, done="Stopped Neo4j") 251 | 252 | self.console.task(f"Switching database to {db}", 253 | self._switch_database, args=[db], 254 | done=f"Switched database to '{db}'") 255 | 256 | self.console.task("Starting Neo4j", 257 | self._start, done="Started Neo4j") 258 | 259 | def load_zips(self, archives=[], db='neo4j'): 260 | 261 | archives = [f"{NEO4J_ZIP_DIR}/{a}" 262 | if not a.startswith(f"{NEO4J_ZIP_DIR}/") else a 263 | for a in archives] 264 | 265 | self.console.task("Stopping Neo4j", 266 | self._stop, done="Stopped Neo4j") 267 | 268 | loaded = self.console.task(f"Creating database '{db}'", 269 | self._load, args=[archives, db], 270 | done=f"Created database '{db}'") 271 | 272 | self.console.task(f"Switching active database to '{db}'", 273 | self._switch_database, args=[db], 274 | done=f"Switched active database to '{db}'") 275 | 276 | self.console.task("Starting Neo4j", 277 | self._start, done="Started Neo4j") 278 | 279 | self.console.notice(loaded) 280 | 281 | def list(self): 282 | 283 | self.console.list([{ 284 | "Name": db, 285 | "Created": datetime.datetime.strptime( 286 | time.ctime(os.path.getctime(f"{NEO4J_DB_DIR}/{db}")), 287 | "%a %b %d %H:%M:%S %Y" 288 | ).strftime('%Y-%m-%d %H:%M:%S') 289 | } for db in self.databases]) 290 | 291 | def run(self, cypher): 292 | 293 | results = [] 294 | 295 | if not self.available(): 296 | self._start() 297 | 298 | try: 299 | with self.driver.session() as session: 300 | results = session.run(cypher).data() 301 | 302 | except exceptions.CypherSyntaxError as e: 303 | self.console.error(str(e)) 304 | 305 | return results 306 | -------------------------------------------------------------------------------- /lib/graph/edges.py: -------------------------------------------------------------------------------- 1 | from lib.graph.base import Edge, json 2 | 3 | 4 | class Associative(Edge): 5 | 6 | def __init__(self, properties={}, source=None, target=None): 7 | super().__init__(properties, source, target) 8 | 9 | 10 | class Transitive(Edge): 11 | 12 | def __init__(self, properties={}, source=None, target=None): 13 | super().__init__(properties, source, target) 14 | 15 | 16 | class Action(Edge): 17 | 18 | def __init__(self, properties={}, source=None, target=None): 19 | 20 | for key in ["Name", "Description", "Effect", "Access", "Reference", "Condition"]: 21 | if key not in properties: 22 | raise ValueError("Edge properties must include '%s'" % key) 23 | 24 | super().__init__(properties, source, target) 25 | 26 | 27 | class Trusts(Action): 28 | 29 | def __init__(self, properties={}, source=None, target=None): 30 | 31 | super().__init__(properties, source, target) 32 | -------------------------------------------------------------------------------- /lib/graph/nodes.py: -------------------------------------------------------------------------------- 1 | from lib.graph.base import Node 2 | 3 | 4 | class Generic(Node): 5 | 6 | def __init__(self, properties={}, labels=[]): 7 | 8 | label = self.__class__.__name__ 9 | 10 | super().__init__(properties, 11 | labels + [label] if label not in labels else labels, 12 | "Arn") 13 | 14 | 15 | class Resource(Node): 16 | 17 | def __init__(self, properties={}, labels=[], key="Arn"): 18 | 19 | label = self.__class__.__name__ 20 | 21 | super().__init__(properties, 22 | labels + [label] if label not in labels else labels, 23 | key) 24 | 25 | def account(self): 26 | if "Arn" not in self.properties() or len(self.properties()["Arn"].split(':')) < 5: 27 | return None 28 | 29 | return str(self.properties()["Arn"].split(':')[4]) 30 | 31 | 32 | class External(Node): 33 | 34 | def __init__(self, properties={}, labels=[], key="Name"): 35 | 36 | label = self.__class__.__name__ 37 | 38 | super().__init__(properties, 39 | labels + [label] if label not in labels else labels, 40 | key) 41 | -------------------------------------------------------------------------------- /lib/util/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReversecLabs/awspx/b5eec7448930e042aeb61723d37cd2f38d7483db/lib/util/__init__.py -------------------------------------------------------------------------------- /lib/util/console.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | import logging 4 | import os 5 | import re 6 | import sys 7 | import termios 8 | import threading 9 | import tty 10 | from datetime import datetime 11 | from logging import Handler 12 | from pathlib import Path 13 | 14 | from rich._log_render import LogRender 15 | from rich.console import Console as RichConsole 16 | from rich.progress import Progress 17 | from rich.progress_bar import ProgressBar 18 | from rich.style import Style 19 | from rich.table import Table, box 20 | from rich.text import Text 21 | 22 | logging.addLevelName(25, "NOTICE") 23 | logger = logging.getLogger(__name__) 24 | logger.setLevel(logging.DEBUG) 25 | logger.addHandler(logging.NullHandler()) 26 | 27 | 28 | class Log(Handler): 29 | 30 | levels = { 31 | "CRITICAL": Style(color="red", bold=True, reverse=True), 32 | "ERROR": Style(color="red", bold=True), 33 | "WARNING": Style(color="dark_red"), 34 | "NOTICE": Style(color="yellow"), 35 | "INFO": Style(dim=True), 36 | "DEBUG": Style(color="green", dim=True), 37 | } 38 | 39 | def __init__( 40 | self, 41 | level=logging.DEBUG, 42 | console=RichConsole(), 43 | ): 44 | 45 | super().__init__(level=level) 46 | 47 | self.console = console 48 | self._render = LogRender(show_level=True) 49 | 50 | def _styllize(self, record): 51 | 52 | from lib.util.keywords import Keywords, Regex 53 | 54 | message = Text(self.format(record)) 55 | 56 | message.highlight_words(sorted([*Keywords.resource, 57 | *Keywords.action, 58 | *Keywords.edge, 59 | *Keywords.node, 60 | *Keywords.attack 61 | ], key=len, reverse=True), 62 | 'i') 63 | 64 | message.highlight_regex(Regex.integer, 'bold i') 65 | message.highlight_regex(Regex.resource, 'i not bold') 66 | message.highlight_regex(Regex.arn, 'dim not bold not i') 67 | 68 | return message 69 | 70 | def emit(self, record): 71 | 72 | level = record.levelname.upper() 73 | style = self.levels[level] if level in self.levels else None 74 | 75 | self.console.print( 76 | self._render( 77 | self.console, 78 | [self._styllize(record)], 79 | log_time=datetime.fromtimestamp(record.created), 80 | time_format="[%d/%m/%y %H:%M:%S]", 81 | level=Text(level, style=style) 82 | ) 83 | ) 84 | 85 | 86 | class Operation(Progress): 87 | 88 | table = None 89 | live = None 90 | 91 | def __init__(self, table): 92 | super().__init__() 93 | self.live.vertical_overflow = "visible" 94 | self.table = table 95 | 96 | def get_renderables(self): 97 | 98 | with self._lock: 99 | yield self.table 100 | 101 | 102 | class Console(Table): 103 | 104 | _verbose = False 105 | console = RichConsole() 106 | logger = logger 107 | level = logging.DEBUG 108 | 109 | def __init__(self, name=None, log=False): 110 | 111 | super().__init__( 112 | box=None if name is None else box.SQUARE, 113 | show_header=name is not None, 114 | expand=True) 115 | 116 | if name is None: 117 | self.add_row(" ") 118 | self.thread = Operation(self) 119 | 120 | def debug(self, message, silent=False): 121 | self.logger.debug(message) 122 | if not silent: 123 | self._annotate(message, style="dim") 124 | 125 | def info(self, message): 126 | self.logger.info(message) 127 | if not self._verbose: 128 | self._annotate(message, "dim") 129 | 130 | def notice(self, message, silent=False): 131 | if self.logger.isEnabledFor(25): 132 | self.logger._log(25, message, args=None) 133 | 134 | if not silent: 135 | self._annotate(message, style="dim") 136 | 137 | def warn(self, message): 138 | self.logger.warn(message) 139 | if not self._verbose: 140 | self._annotate(message, "dark_red") 141 | 142 | def error(self, message): 143 | self.logger.error(message) 144 | if not self._verbose: 145 | self._annotate(message, "bold red") 146 | 147 | def critical(self, message): 148 | 149 | if isinstance(message, str): 150 | self.logger.critical(message) 151 | if not self._verbose: 152 | self._annotate(message, "bold red") 153 | else: 154 | self.stop() 155 | try: 156 | self.console.print_exception() 157 | except ValueError: 158 | self.console.print(message.__repr__()) 159 | 160 | os._exit(1) 161 | 162 | def item(self, message): 163 | 164 | self.notice(message, silent=True) 165 | 166 | if self._verbose: 167 | return self 168 | 169 | service = self.__class__(name=message) 170 | service.add_column(message, justify="center") 171 | service.thread = self.thread.live 172 | 173 | self.add_row(service) 174 | self.spacer() 175 | 176 | return service 177 | 178 | def spacer(self): 179 | self.add_row() 180 | 181 | def task(self, message, function=None, args=[], done=None): 182 | 183 | self.notice(message, silent=True) 184 | 185 | (text, progress, busy) = self._add(message) 186 | 187 | results = function(*args) 188 | 189 | if done.__class__.__name__ == 'function': 190 | done = done(results) 191 | 192 | if done is not None: 193 | text._text = [done] 194 | self.notice(done, silent=True) 195 | 196 | busy._text = [""] 197 | progress.pulse = False 198 | progress.update(completed=progress.total) 199 | 200 | self._annotate() 201 | 202 | return results 203 | 204 | def tasklist(self, message, iterables=[], wait=None, done=None): 205 | 206 | if '__len__' in dir(iterables) and not len(iterables) > 0: 207 | return 208 | 209 | self.notice(message, silent=True) 210 | 211 | (text, progress, busy) = self._add(message, iterables=iterables) 212 | 213 | if wait is not None: 214 | self.debug(wait if wait.__class__.__name__ != 'function' 215 | else wait(0)) 216 | else: 217 | self._annotate() 218 | 219 | for completed, iterable in enumerate(iterables, 1): 220 | 221 | if wait is not None: 222 | self._annotate() 223 | 224 | yield iterable 225 | 226 | if (wait is not None and ('__len__' not in dir(iterables) 227 | or completed != len(iterables))): 228 | self.debug(wait if wait.__class__.__name__ != 'function' 229 | else wait(completed)) 230 | 231 | progress.update(completed=completed) 232 | 233 | if done.__class__.__name__ == 'function': 234 | done = done(iterables) 235 | 236 | if done is not None: 237 | self.notice(done, silent=True) 238 | text._text = [done] 239 | 240 | busy._text = [""] 241 | progress.pulse = False 242 | progress.update(completed=progress.total) 243 | 244 | self._annotate() 245 | 246 | def list(self, dictionaries=[]): 247 | 248 | if not isinstance(dictionaries, list): 249 | dictionaries = [dictionaries] 250 | 251 | if not len(dictionaries) > 0: 252 | return 253 | 254 | t = Table.grid(expand=True) 255 | 256 | for k in dictionaries[0].keys(): 257 | t.add_column(str(k)) 258 | 259 | for d in dictionaries: 260 | t.add_row(*d.values()) 261 | 262 | if self._verbose: 263 | self.console.print(t) 264 | else: 265 | self.add_row(t) 266 | 267 | def input(self, message): 268 | 269 | def readchar(): 270 | 271 | fd = sys.stdin.fileno() 272 | settings = termios.tcgetattr(fd) 273 | 274 | try: 275 | tty.setraw(sys.stdin.fileno()) 276 | char = sys.stdin.read(1) 277 | finally: 278 | termios.tcsetattr(fd, termios.TCSADRAIN, settings) 279 | 280 | if char == '\x03': 281 | raise KeyboardInterrupt 282 | 283 | elif char == '\x04': 284 | raise EOFError 285 | 286 | return char 287 | 288 | def read(main, message, value): 289 | 290 | (text, value, _) = self._add(message, override=value) 291 | text.style = "b" 292 | value.style = "i" 293 | 294 | try: 295 | self.refresh() 296 | char = None 297 | 298 | while True: 299 | 300 | with console.thread.live._lock: 301 | char = readchar() 302 | # Enter 303 | if ord(char) == 13: 304 | break 305 | # Delete 306 | elif ord(char) in [27]: 307 | continue 308 | # Backspace 309 | elif ord(char) == 127: 310 | value._text = [''.join(value._text)[:-1]] 311 | 312 | else: 313 | value._text = [''.join([*value._text, char])] 314 | 315 | self.refresh() 316 | 317 | except (KeyboardInterrupt, EOFError): 318 | value.style = None 319 | self.stop() 320 | os._exit(0) 321 | 322 | if not self._verbose: 323 | value = Text("") 324 | 325 | input_thread = threading.Thread(target=read, 326 | args=(self, message, value)) 327 | input_thread.start() 328 | input_thread.join() 329 | 330 | return ''.join(value._text) 331 | else: 332 | sys.stdout.write(message) 333 | sys.stdout.flush() 334 | return input() 335 | 336 | def verbose(self): 337 | 338 | if self._verbose: 339 | return 340 | 341 | log = Log(console=self.console, level=self.level) 342 | 343 | self.stop() 344 | self.logger.addHandler(log) 345 | self._verbose = True 346 | 347 | def _add(self, message, iterables=[], override=None): 348 | 349 | key = Text(message, overflow='ellipsis', no_wrap=True) 350 | busy = Text() 351 | 352 | if override is None: 353 | 354 | total = 1.0 355 | pulse = True 356 | 357 | try: 358 | total = len(iterables) if iterables != [] else 1 359 | pulse = iterables == [] 360 | 361 | # Not all iterables have a length 362 | except TypeError: 363 | pass 364 | 365 | busy._text = ["→"] 366 | color = Style(color="rgb(161, 209, 255)", dim=True) 367 | value = ProgressBar(total=total, pulse=pulse, 368 | complete_style=color, 369 | finished_style=color, 370 | pulse_style=color) 371 | else: 372 | value = override 373 | 374 | operation = Table(box=None, show_header=False, 375 | show_footer=False, show_edge=True, 376 | padding=(0, 0 if self.show_header else 1)) 377 | 378 | operation.add_column(width=3 if self.show_header else 2, 379 | justify="center") 380 | operation.add_column(width=62 if self.show_header else 60) 381 | operation.add_column() 382 | operation.add_row(busy, key, value) 383 | 384 | self.add_row(operation) 385 | return (key, value, busy) 386 | 387 | def _annotate(self, message="", style=None): 388 | 389 | if self._verbose: 390 | return 391 | 392 | self.caption = message 393 | self.caption_style = style 394 | self.refresh() 395 | 396 | def start(self): 397 | if not console.thread.live._started: 398 | self.thread.start() 399 | 400 | def refresh(self): 401 | if self.thread is not None: 402 | self.thread.refresh() 403 | 404 | def stop(self): 405 | if console.thread.live._started: 406 | self.thread.stop() 407 | self.console.print() 408 | 409 | 410 | console = Console() 411 | -------------------------------------------------------------------------------- /lib/util/keywords.py: -------------------------------------------------------------------------------- 1 | 2 | from lib.aws.attacks import definitions 3 | from lib.aws.ingestor import * 4 | from lib.aws.profile import Profile 5 | from lib.aws.actions import ACTIONS 6 | from lib.aws.resources import Resources 7 | from lib.graph.edges import * 8 | from lib.graph.nodes import * 9 | 10 | 11 | class Keywords: 12 | 13 | service = [_.__name__ for _ in Ingestor.__subclasses__()] 14 | resource = [k.split(':')[-1] for k in Resources.types] 15 | node = [_.__name__ for _ in Node.__subclasses__()] 16 | edge = [_.__name__ for _ in Edge.__subclasses__()] 17 | region = list(Profile.regions) 18 | action = list(ACTIONS.keys()) 19 | attack = list(definitions.keys()) 20 | 21 | 22 | class Regex: 23 | 24 | arn = r'arn:aws:([a-z0-9]+):({Region}|[a-z0-9-]*):({Account}|[0-9]{12}|aws)?:([a-z0-9-]+)([A-Za-z0-9-_\.:/{}]+)?' 25 | resource = r'(AWS(::[A-Za-z0-9-]*){1,2})' 26 | integer = r'[0-9]+' 27 | archive = r'((/opt/awspx/data/)?[0-9]+_[A-Za-z0-9]+.zip)' 28 | database = r'([A-Za-z0-9]+.db)' 29 | -------------------------------------------------------------------------------- /www/.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | -------------------------------------------------------------------------------- /www/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:vue/essential" 9 | ], 10 | "globals": { 11 | "Atomics": "readonly", 12 | "SharedArrayBuffer": "readonly" 13 | }, 14 | "parserOptions": { 15 | "ecmaVersion": 2018, 16 | "sourceType": "module" 17 | }, 18 | "plugins": [ 19 | "vue" 20 | ], 21 | "rules": { 22 | } 23 | } -------------------------------------------------------------------------------- /www/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/app' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /www/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "awspx", 3 | "version": "1.3.0", 4 | "description": "AWS resource, relationship, and attack path visualisation", 5 | "author": "beatro0t", 6 | "private": true, 7 | "scripts": { 8 | "serve": "vue-cli-service serve --port 80", 9 | "build": "vue-cli-service build" 10 | }, 11 | "dependencies": { 12 | "codemirror": "^5.62.2", 13 | "core-js": "^3.16.1", 14 | "cytoscape": "^3.19.0", 15 | "cytoscape-dagre": "^2.3.2", 16 | "neo4j-driver": "^4.3.2", 17 | "vue": "^2.6.14", 18 | "vue-template-compiler": "^2.6.14", 19 | "vuetify": "^2.5.8" 20 | }, 21 | "devDependencies": { 22 | "@mdi/font": "^5.9.55", 23 | "@vue/cli-plugin-babel": "^4.5.13", 24 | "@vue/cli-service": "^4.5.13" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /www/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | autoprefixer: {} 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /www/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReversecLabs/awspx/b5eec7448930e042aeb61723d37cd2f38d7483db/www/public/favicon.ico -------------------------------------------------------------------------------- /www/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | awspx 9 | 10 | 11 | 12 | We're sorry but www doesn't work properly without JavaScript enabled. Please enable it to continue. 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /www/src/App.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 19 | 20 | 30 | -------------------------------------------------------------------------------- /www/src/assets/fonts/MaterialIcons-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReversecLabs/awspx/b5eec7448930e042aeb61723d37cd2f38d7483db/www/src/assets/fonts/MaterialIcons-Regular.woff -------------------------------------------------------------------------------- /www/src/assets/fonts/MaterialIcons-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReversecLabs/awspx/b5eec7448930e042aeb61723d37cd2f38d7483db/www/src/assets/fonts/MaterialIcons-Regular.woff2 -------------------------------------------------------------------------------- /www/src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReversecLabs/awspx/b5eec7448930e042aeb61723d37cd2f38d7483db/www/src/assets/logo.png -------------------------------------------------------------------------------- /www/src/codemirror-cypher/cypher-codemirror.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2002-2017 "Neo Technology," 3 | * Network Engine for Objects in Lund AB [http://neotechnology.com] 4 | * 5 | * This file is part of Neo4j. 6 | * 7 | * Neo4j is free software: you can redistribute it and/or modify 8 | * it under the terms of the GNU General Public License as published by 9 | * the Free Software Foundation, either version 3 of the License, or 10 | * (at your option) any later version. 11 | * 12 | * This program is distributed in the hope that it will be useful, 13 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | * GNU General Public License for more details. 16 | * 17 | * You should have received a copy of the GNU General Public License 18 | * along with this program. If not, see . 19 | */ 20 | 21 | 22 | /* 23 | Credits: http://ethanschoonover.com/solarized 24 | 25 | SOLARIZED HEX 16/8 TERMCOL XTERM/HEX L*A*B RGB HSB 26 | --------- ------- ---- ------- ----------- ---------- ----------- ----------- 27 | base03 #002b36 8/4 brblack 234 #1c1c1c 15 -12 -12 0 43 54 193 100 21 28 | base02 #073642 0/4 black 235 #262626 20 -12 -12 7 54 66 192 90 26 29 | base01 #586e75 10/7 brgreen 240 #585858 45 -07 -07 88 110 117 194 25 46 30 | base00 #657b83 11/7 bryellow 241 #626262 50 -07 -07 101 123 131 195 23 51 31 | base0 #839496 12/6 brblue 244 #808080 60 -06 -03 131 148 150 186 13 59 32 | base1 #93a1a1 14/4 brcyan 245 #8a8a8a 65 -05 -02 147 161 161 180 9 63 33 | base2 #eee8d5 7/7 white 254 #e4e4e4 92 -00 10 238 232 213 44 11 93 34 | base3 #fdf6e3 15/7 brwhite 230 #ffffd7 97 00 10 253 246 227 44 10 99 35 | yellow #b58900 3/3 yellow 136 #af8700 60 10 65 181 137 0 45 100 71 36 | orange #cb4b16 9/3 brred 166 #d75f00 50 50 55 203 75 22 18 89 80 37 | red #dc322f 1/1 red 160 #d70000 50 65 45 220 50 47 1 79 86 38 | magenta #d33682 5/5 magenta 125 #af005f 50 65 -05 211 54 130 331 74 83 39 | violet #6c71c4 13/5 brmagenta 61 #5f5faf 50 15 -45 108 113 196 237 45 77 40 | blue #268bd2 4/4 blue 33 #0087ff 55 -10 -45 38 139 210 205 82 82 41 | cyan #2aa198 6/6 cyan 37 #00afaf 60 -35 -05 42 161 152 175 74 63 42 | green #859900 2/2 green 64 #5f8700 60 -20 65 133 153 0 68 100 60 43 | */ 44 | 45 | 46 | /*********** 47 | * Editor 48 | */ 49 | 50 | .CodeMirror.cm-s-cypher { 51 | background-color: white; 52 | line-height: 1.4375; 53 | color: #657b83; 54 | } 55 | 56 | .CodeMirror.cm-s-cypher.cm-s-cypher-dark { 57 | background-color: #002b36; 58 | color: #839496; 59 | } 60 | 61 | .cm-s-cypher pre { 62 | padding: 0; 63 | } 64 | 65 | .cm-s-cypher .CodeMirror-lines { 66 | padding: 0; 67 | } 68 | 69 | .cm-s-cypher .CodeMirror-cursor { 70 | width: auto; 71 | border: 0; 72 | background: rgba(58, 63, 63, 0.63); 73 | z-index: 1; 74 | } 75 | 76 | .cm-s-cypher.cm-s-cypher-dark .CodeMirror-cursor { 77 | background: rgba(51, 58, 59, 0.63); 78 | } 79 | 80 | 81 | /*********** 82 | * Gutter 83 | */ 84 | 85 | .cm-s-cypher .CodeMirror-gutters { 86 | border-right: 1px solid rgb(25, 118, 210, 0.1); 87 | padding-left: 1px; 88 | padding-right: 3px; 89 | background-color: rgb(25, 118, 210, 0.04); 90 | } 91 | 92 | .cm-s-cypher.cm-s-cypher-dark .CodeMirror-gutters { 93 | background-color: #515151; 94 | border-right: 3px solid #515151; 95 | } 96 | 97 | .cm-s-cypher .CodeMirror-linenumber { 98 | padding-left: 2px; 99 | padding-right: 5px; 100 | color: rgb(25, 118, 210); 101 | } 102 | 103 | .cm-s-cypher.cm-s-cypher-dark .CodeMirror-linenumber { 104 | color: #839496; 105 | } 106 | 107 | 108 | /*********** 109 | * Token 110 | */ 111 | 112 | .cm-s-cypher .cm-comment { 113 | color: #93a1a1; 114 | } 115 | 116 | .cm-s-cypher.cm-s-cypher-dark .cm-comment { 117 | color: #586e75; 118 | } 119 | 120 | .cm-s-cypher .cm-string { 121 | color: #b58900; 122 | } 123 | 124 | .cm-s-cypher .cm-number { 125 | color: #2aa198; 126 | } 127 | 128 | 129 | /* 130 | .cm-s-cypher .cm-operator {} 131 | */ 132 | 133 | .cm-s-cypher .cm-keyword { 134 | color: #859900; 135 | } 136 | 137 | 138 | /*********** 139 | * Parser 140 | */ 141 | 142 | .cm-s-cypher .cm-p-label { 143 | color: #cb4b16; 144 | } 145 | 146 | .cm-s-cypher .cm-p-relationshipType { 147 | color: #cb4b16; 148 | } 149 | 150 | .cm-s-cypher .cm-p-variable { 151 | color: #268bd2; 152 | } 153 | 154 | .cm-s-cypher .cm-p-procedure { 155 | color: #6c71c4; 156 | } 157 | 158 | .cm-s-cypher .cm-p-function { 159 | color: #6c71c4; 160 | } 161 | 162 | .cm-s-cypher .cm-p-parameter { 163 | color: #dc322f; 164 | } 165 | 166 | .cm-s-cypher .cm-p-property { 167 | color: #586e75; 168 | } 169 | 170 | .cm-s-cypher.cm-s-cypher-dark .cm-p-property { 171 | color: #93a1a1; 172 | } 173 | 174 | .cm-s-cypher .cm-p-consoleCommand { 175 | color: #d33682; 176 | } 177 | 178 | .cm-s-cypher .cm-p-procedureOutput { 179 | color: #268bd2; 180 | } 181 | 182 | .CodeMirror-hints { 183 | margin: 0; 184 | padding: 0; 185 | position: absolute; 186 | z-index: 10; 187 | list-style: none; 188 | box-shadow: 2px 3px 5px rgba(0, 0, 0, .2); 189 | border: 1px solid silver; 190 | background: white; 191 | font-size: 90%; 192 | font-family: monospace; 193 | max-height: 30em; 194 | max-width: 600px; 195 | overflow-y: auto; 196 | overflow-x: auto; 197 | } 198 | 199 | .CodeMirror-hint { 200 | margin: 2px 0; 201 | padding: 0 4px; 202 | white-space: pre; 203 | color: #657b83; 204 | cursor: pointer; 205 | font-size: 11pt; 206 | background-position-x: 5px; 207 | } 208 | 209 | .CodeMirror-hint b { 210 | color: #073642; 211 | } 212 | 213 | .CodeMirror-hint-active { 214 | background-color: #EFEFF4; 215 | } 216 | 217 | 218 | /* 219 | .cm-hint-keyword { 220 | } 221 | */ 222 | 223 | .cm-hint-label { 224 | padding-left: 22px !important; 225 | background-size: auto 80% !important; 226 | background-position: 3px center; 227 | background-repeat: no-repeat !important; 228 | background-image: url("data:image/svg+xml;utf8,L"); 229 | } 230 | 231 | .cm-hint-relationshipType { 232 | padding-left: 22px !important; 233 | background-size: auto 80% !important; 234 | background-position: 3px center; 235 | background-repeat: no-repeat !important; 236 | background-image: url("data:image/svg+xml;utf8,R"); 237 | } 238 | 239 | .cm-hint-variable { 240 | padding-left: 22px !important; 241 | background-size: auto 80% !important; 242 | background-position: 3px center; 243 | background-repeat: no-repeat !important; 244 | background-image: url("data:image/svg+xml;utf8,V"); 245 | } 246 | 247 | .cm-hint-procedure { 248 | padding-left: 22px !important; 249 | background-size: auto 80% !important; 250 | background-position: 3px center; 251 | background-repeat: no-repeat !important; 252 | background-image: url("data:image/svg+xml;utf8,λ"); 253 | } 254 | 255 | .cm-hint-function { 256 | padding-left: 22px !important; 257 | background-size: auto 80% !important; 258 | background-position: 3px center; 259 | background-repeat: no-repeat !important; 260 | background-image: url("data:image/svg+xml;utf8,λ"); 261 | } 262 | 263 | .cm-hint-parameter { 264 | padding-left: 22px !important; 265 | background-size: auto 80% !important; 266 | background-position: 3px center; 267 | background-repeat: no-repeat !important; 268 | background-image: url("data:image/svg+xml;utf8,$"); 269 | } 270 | 271 | .cm-hint-propertyKey { 272 | padding-left: 22px !important; 273 | background-size: auto 80% !important; 274 | background-position: 3px center; 275 | background-repeat: no-repeat !important; 276 | background-image: url("data:image/svg+xml;utf8,P"); 277 | } 278 | 279 | .cm-hint-consoleCommand { 280 | padding-left: 22px !important; 281 | background-size: auto 80% !important; 282 | background-position: 3px center; 283 | background-repeat: no-repeat !important; 284 | background-image: url("data:image/svg+xml;utf8,C"); 285 | } 286 | 287 | .cm-hint-consoleCommandSubcommand { 288 | padding-left: 22px !important; 289 | background-size: auto 80% !important; 290 | background-position: 3px center; 291 | background-repeat: no-repeat !important; 292 | background-image: url("data:image/svg+xml;utf8,C"); 293 | } 294 | 295 | .cm-hint-procedureOutput { 296 | padding-left: 22px !important; 297 | background-size: auto 80% !important; 298 | background-position: 3px center; 299 | background-repeat: no-repeat !important; 300 | background-image: url("data:image/svg+xml;utf8,V"); 301 | } -------------------------------------------------------------------------------- /www/src/components/Database.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Database settings 6 | {{ form.values.status }} 7 | 8 | 9 | 10 | 19 | 28 | 40 | 41 | 42 | 43 | 44 | Close 47 | 48 | Test 51 | Connect 58 | 59 | 60 | 61 | 62 | 63 | Hold up, this database appears to be empty... 66 | You'll need to populate it with something before 68 | continuing 70 | 71 | 72 | 73 | 74 | 75 | You can run the 76 | ingestor 80 | (to load an AWS account to explore): 81 | 82 | 83 | awspx ingest 90 | 91 | 92 | or load the sample dataset (if you just want to play around): 93 | 94 | 95 | 101 | awspx db --load-zip sample.zip 102 | awspx attacks 103 | 104 | 105 | 106 | 107 | 108 | 109 | Back 110 | 111 | Check Again 114 | 115 | 116 | 117 | 118 | 119 | 120 | 394 | 395 | 396 | 404 | -------------------------------------------------------------------------------- /www/src/components/Menu.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | mdi-view-dashboard-outline 15 | 16 | 17 | 18 | Configure Layout 19 | 20 | 21 | 22 | 23 | 24 | {{ item }} 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 44 | 45 | mdi-database-edit 46 | 47 | 48 | 49 | Configure database 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 68 | 69 | mdi-monitor-clean 70 | 71 | 72 | 73 | Clear the screen 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 91 | 92 | mdi-camera-plus-outline 93 | 94 | 95 | 96 | Take a screenshot 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 115 | 116 | {{redacted ? 'mdi-eye' : 'mdi-eye-off'}} 117 | 118 | 119 | 120 | {{redacted ? 'Unredact' : "Redact"}} graph 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 139 | 140 | mdi-content-save-edit 141 | 142 | 143 | 144 | Load saved query 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 163 | 164 | mdi-help-circle-outline 165 | 166 | 167 | 168 | View help 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 209 | 210 | 221 | 222 | -------------------------------------------------------------------------------- /www/src/components/Properties.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{ tabs[i].title }} 14 | Notes 20 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 38 | 39 | 40 | 41 | 47 | 48 | {{ item.key }} 54 | {{ item.key }} 55 | 56 | 57 | 58 | 59 | 60 | {{ k }} 61 | 62 | 63 | 67 | Global Conditions 68 | 69 | 70 | 71 | - 72 | 73 | {{ item.value }} 74 | 75 | 76 | 77 | 78 | 85 | 89 | 90 | {{ item.key }} 96 | 97 | 98 | 99 | 100 | 104 | 105 | 106 | {{ item.name }} 107 | 108 | 109 | 110 | mdi-chevron-down 124 | 125 | {{ item.effect }} 126 | 127 | 128 | 129 | {{ item.description }} 134 | 135 | 136 | 137 | 138 | 143 | 144 | 145 | 146 | Step {{ i + 1 }}: 147 | 148 | 152 | {{ item.description }} 153 | 154 | 155 | 156 | 157 | 158 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | {{ item.key }} 171 | {{ 172 | item.value 173 | }} 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 591 | 592 | 649 | -------------------------------------------------------------------------------- /www/src/components/Search.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 14 | 15 | mdi-chevron-up 16 | 17 | 18 | 19 | 20 | 21 | 32 | 33 | 34 | 47 | 48 | 49 | 50 | 51 | 166 | 167 | 178 | -------------------------------------------------------------------------------- /www/src/components/SearchAdvancedFilter.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 38 | 39 | 40 | 49 | 50 | 51 | 61 | 62 | 63 | 64 | 65 | 66 | 346 | 347 | 356 | -------------------------------------------------------------------------------- /www/src/components/SearchResultsTable.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 13 | 14 | Search results 15 | 16 | 17 | 18 | mdi-close 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 116 | 117 | 163 | -------------------------------------------------------------------------------- /www/src/components/TemplateAutocomplete.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | {{ "label" in search ? search.label : "" }} 13 | 14 | 41 | 42 | 47 | 48 | 49 | 50 | s.id !== data.item.id) 56 | " 57 | /> 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | No resources were found 74 | 75 | 76 | 77 | 78 | 79 | 80 | 86 | mdi-close 87 | 88 | 89 | 90 | Clear all 91 | 92 | 93 | 94 | 95 | 96 | 97 | {{ append.icon }} 98 | 99 | 100 | 101 | {{ append.description }} 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 206 | 207 | -------------------------------------------------------------------------------- /www/src/components/TemplateSelectItem.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | {{ data.item.id }} 13 | 14 | 15 | 20 | 21 | mdi-tag 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 69 | 70 | -------------------------------------------------------------------------------- /www/src/components/TemplateSelectSearch.vue: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | 14 | 15 | {{ data.item.name }} 16 | 17 | 18 | 19 | 20 | 45 | 46 | -------------------------------------------------------------------------------- /www/src/config.js: -------------------------------------------------------------------------------- 1 | import icons from './icons.js' 2 | import colors from 'vuetify/lib/util/colors' 3 | 4 | export const access = { 5 | "Allow": colors.green.base, 6 | "Deny": colors.red.base, 7 | "List": colors.yellow.base, 8 | "Permissions Management": colors.deepPurple.darken1, 9 | "Read": colors.pink.darken1, 10 | "Tagging": colors.teal.darken1, 11 | "Write": colors.indigo.darken2, 12 | } 13 | 14 | export const size = { 15 | "ACTION": 1, 16 | "ACTIONS": 1, 17 | "ADMIN": 3, 18 | "ATTACK": 1, 19 | "TRANSITIVE": 2, 20 | "TRUSTS": 1, 21 | 'ASSOCIATIVE': 1, 22 | } 23 | 24 | const cache = {} 25 | const badge = (n, m) => { 26 | 27 | if (!(Object.keys(cache).includes(m))) 28 | cache[m] = {} 29 | 30 | if (!(n.data().type in cache[m])) { 31 | 32 | let svg = decodeURIComponent(n.style("background-image")).split(' parseFloat(i)) 39 | 40 | const width = 2 * vb[2] * 0.0125 41 | const length = 9 * vb[3] * 0.0125 42 | 43 | const radius = 0.5 * length + 1.5 * width 44 | const dx = vb[0] + vb[2] - 2 * radius; 45 | const dy = vb[1] + radius; 46 | 47 | const suffix = `` + 48 | `` + 51 | `` + 52 | ((m === "collapsible") ? 53 | `` : 54 | "" 55 | ) + `` 56 | 57 | cache[m][n.data().type] = 'data:image/svg+xml;utf8,' + encodeURIComponent( 58 | '", suffix + "")); 60 | } 61 | 62 | return cache[m][n.data().type] 63 | } 64 | 65 | export default { 66 | 67 | graph: { 68 | 69 | style: [{ 70 | selector: 'node', 71 | style: { 72 | 'background-color': 'white', 73 | 'border-color': 'black', 74 | 'border-width': 0.2, 75 | 'color': 'white', 76 | 'font-family': 'Roboto Mono, monospace', 77 | 'font-size': '12px', 78 | 'height': 75, 79 | 'label': 'data (name)', 80 | 'text-background-color': 'black', 81 | 'text-background-opacity': '1', 82 | 'text-background-padding': '2px', 83 | 'text-background-shape': 'roundrectangle', 84 | 'text-halign': 'center', 85 | 'text-max-width': '160px', 86 | 'text-valign': 'bottom', 87 | 'text-wrap': 'ellipsis', 88 | 'width': 75 89 | } 90 | }, 91 | { 92 | selector: 'edge', 93 | style: { 94 | 'curve-style': 'bezier', 95 | 'font-family': 'Roboto Mono, monospace', 96 | 'font-size': '10x', 97 | 'font-size': 0, 98 | 'line-color': "#999999", 99 | 'target-arrow-color': "#999999", 100 | 'target-arrow-shape': 'triangle', 101 | 'text-max-width': '50px', 102 | 'text-rotation': 0, 103 | 'text-wrap': 'ellipsis', 104 | 'width': function (e) { 105 | return e.classes().filter(c => 106 | Object.keys(size).includes(c) 107 | ).map(c => size[c]).concat(1)[0] 108 | }, 109 | 'z-index': '3' 110 | } 111 | }, 112 | { 113 | selector: 'node.AWS', 114 | style: { 115 | 'background-image': function (n) { 116 | return n.data().type.split('::').reduce((o, k) => { 117 | return Object.keys(o).includes(k) ? o[k] : icons.AWS.Resource 118 | }, icons) 119 | }, 120 | } 121 | }, 122 | { 123 | selector: 'node.Admin', 124 | style: { 125 | 'background-image': icons.Admin, 126 | } 127 | }, 128 | { 129 | selector: 'node.CatchAll', 130 | style: { 131 | 'background-image': icons.CatchAll, 132 | } 133 | }, 134 | { 135 | selector: 'node.Internet.Domain', 136 | style: { 137 | 'background-image': icons.Internet.Domain, 138 | } 139 | }, 140 | { 141 | selector: function (e) { 142 | return (e.classes().includes("TRANSITIVE") && 143 | Object.keys(e.source().data("properties") 144 | ).includes("PermissionsBoundary")) 145 | }, 146 | style: { 147 | 'line-style': 'dashed' 148 | } 149 | }, 150 | { 151 | selector: 'edge.ASSOCIATIVE', 152 | style: { 153 | 'line-style': 'dotted', 154 | 'target-arrow-shape': 'none' 155 | } 156 | }, 157 | { 158 | selector: 'edge.ATTACK', 159 | style: { 160 | 'line-color': 'maroon', 161 | 'line-style': 'dashed', 162 | } 163 | }, 164 | { 165 | selector: 'edge.TRUSTS', 166 | style: { 167 | 'color': 'black', 168 | 'font-size': '10px', 169 | 'line-color': 'gold', 170 | 'text-background-color': 'white', 171 | 'text-background-opacity': '1', 172 | 'text-max-width': '1000px', 173 | 'text-rotation': 'autorotate' 174 | } 175 | }, 176 | { 177 | selector: 'edge.ACTION', 178 | style: { 179 | "line-fill": "linear-gradient", 180 | 'color': 'black', 181 | 'control-point-step-size': '50', 182 | 'font-size': '10px', 183 | 'label': 'data (name)', 184 | 'line-gradient-stop-colors': function (e) { 185 | return `${access[e.data("properties").Effect]}` 186 | .concat(" ") 187 | .concat(`${access[e.data("properties").Access]}`); 188 | }, 189 | 'target-arrow-color': (e) => `${access[e.data("properties").Access]}`, 190 | 'text-background-color': 'white', 191 | 'text-background-opacity': '1', 192 | 'text-background-padding': '0px', 193 | 'text-max-width': '1000px', 194 | 'text-rotation': 'autorotate', 195 | } 196 | }, 197 | { 198 | selector: 'edge.ACTION.Conditional', 199 | style: { 200 | 'line-style': 'dashed' 201 | } 202 | }, 203 | { 204 | selector: 'edge.ACTIONS', 205 | style: { 206 | "line-fill": "linear-gradient", 207 | 'color': 'black', 208 | 'font-size': '10px', 209 | 'font-weight': 'bold', 210 | 'label': 'data (name)', 211 | 'line-gradient-stop-colors': (e) => e.classes().filter(s => s in access).map(s => access[s]), 212 | 'text-background-color': 'White', 213 | 'text-background-opacity': '1', 214 | 'text-background-padding': '0px', 215 | 'text-max-width': '1000px', 216 | 'text-rotation': 'autorotate' 217 | } 218 | }, 219 | { 220 | selector: 'edge.ADMIN', 221 | style: { 222 | 'opacity': '0.4', 223 | 'overlay-color': 'white', 224 | 'overlay-padding': '1px', 225 | 'overlay-opacity': '1px', 226 | 'target-arrow-shape': 'chevron', 227 | 'target-arrow-fill': 'filled', 228 | 'color': 'black', 229 | } 230 | }, 231 | { 232 | selector: 'node.selected', 233 | style: { 234 | 'border-color': "black", 235 | 'border-width': 1, 236 | 'z-index': 4 237 | } 238 | }, 239 | { 240 | selector: 'edge.selected', 241 | style: { 242 | 'opacity': '1', 243 | 'width': function (e) { 244 | const scale = 1.5; 245 | return e.classes().filter(c => 246 | Object.keys(size).includes(c) 247 | ).map(c => size[c] * scale).concat(scale)[0] 248 | }, 249 | 'z-index': 4, 250 | } 251 | }, 252 | { 253 | selector: '.unselected', 254 | style: { 255 | 'font-size': '0', 256 | 'opacity': 0.1, 257 | 'z-index': 0 258 | } 259 | }, 260 | { 261 | selector: 'edge.hover', 262 | style: { 263 | 'font-size': '10px', 264 | 'font-weight': 'bold', 265 | 'opacity': 1, 266 | 'text-background-color': 'white', 267 | 'text-background-opacity': '1', 268 | 'text-max-width': '1000px', 269 | 'text-rotation': 'autorotate', 270 | 'text-rotation': 0, 271 | 'text-wrap': 'none', 272 | 'width': function (e) { 273 | const scale = 1.75; 274 | return e.classes().filter(c => 275 | Object.keys(size).includes(c) 276 | ).map(c => size[c] * scale).concat(scale)[0] 277 | }, 278 | 'z-index': 10 279 | } 280 | }, 281 | { 282 | selector: 'node.hover', 283 | style: { 284 | 'font-size': '12px', 285 | 'height': 100, 286 | 'opacity': 1, 287 | 'text-wrap': 'none', 288 | 'width': 100, 289 | 'z-index': 10 290 | } 291 | }, 292 | { 293 | selector: 'node.Generic', 294 | style: { 295 | 'border-color': "green", 296 | 'border-style': "dashed", 297 | 'label': '', 298 | 'opacity': 0.7 299 | } 300 | }, 301 | { 302 | selector: 'node.expandible', 303 | style: { 304 | 'background-image': (n) => badge(n, "collapsible") 305 | } 306 | }, 307 | { 308 | selector: 'node.unexpandible', 309 | style: { 310 | 'border-color': "silver", 311 | 'border-width': 2 312 | } 313 | }, 314 | { 315 | selector: 'node.collapsible', 316 | style: { 317 | 'background-image': (n) => badge(n, "expandible") 318 | } 319 | }, 320 | { 321 | selector: 'node.context-menu', 322 | style: { 323 | 'label': "" 324 | } 325 | }], 326 | layout: { 327 | animate: true, 328 | animateFilter: function (node, i) { return true; }, 329 | animationDuration: 250, 330 | animationEasing: undefined, 331 | boundingBox: undefined, 332 | edgeSep: undefined, 333 | edgeWeight: function (edge) { return 1; }, 334 | fit: true, 335 | minLen: function (edge) { return 1; }, 336 | name: 'dagre', 337 | nodeDimensionsIncludeLabels: true, 338 | nodeSep: undefined, 339 | padding: 40, 340 | rankDir: 'BT', 341 | rankSep: undefined, 342 | ranker: 'longest-path', 343 | ready: function () { }, 344 | spacingFactor: 1.5, 345 | stop: function () { }, 346 | transform: function (node, pos) { return pos; } 347 | }, 348 | } 349 | } 350 | -------------------------------------------------------------------------------- /www/src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuetify from 'vuetify' 3 | import App from './App.vue' 4 | import neo4j from './neo4j'; 5 | 6 | import 'vuetify/dist/vuetify.min.css' 7 | import '@mdi/font/css/materialdesignicons.css' 8 | 9 | Vue.use(neo4j); 10 | Vue.use(Vuetify); 11 | const opts = {} 12 | 13 | Vue.config.productionTip = false 14 | 15 | const vuetify = new Vuetify(opts); 16 | 17 | new Vue({ 18 | vuetify, 19 | render: h => h(App) 20 | }).$mount('#app') -------------------------------------------------------------------------------- /www/src/neo4j.js: -------------------------------------------------------------------------------- 1 | import neo4j from 'neo4j-driver' 2 | 3 | 4 | export default { 5 | 6 | methods: { 7 | 8 | /* Generic interface for formatting Neo4j result set. */ 9 | 10 | run(statement, driver, selectors = ["Graph", "Text"]) { 11 | 12 | const Graph = {}; 13 | const Text = []; 14 | 15 | function node(element) { 16 | 17 | const properties = cast(element.properties); 18 | 19 | if (selectors.includes("Graph")) { 20 | 21 | let id = "n" + element.identity.toString(); 22 | if (id in Graph) return properties; 23 | 24 | const node = {}; 25 | const names = Object.keys(element.properties) 26 | .filter(k => (/name/i).test(k)) 27 | .sort((a, b) => a.length - b.length) 28 | 29 | node.group = "nodes"; 30 | node.classes = element.labels.sort(); 31 | node.scratch = {}; 32 | node.data = { 33 | id: id, 34 | properties: properties, 35 | name: (names.length > 0) ? 36 | element.properties[names[0]] : 37 | node.classes[0], 38 | }; 39 | 40 | Graph[id] = node 41 | } 42 | return properties; 43 | } 44 | 45 | function edge(element) { 46 | return cast(element.properties); 47 | } 48 | 49 | function path(element) { 50 | 51 | const properties = cast([ 52 | element.start.properties, 53 | element.relationship.properties, 54 | element.end.properties 55 | ]) 56 | 57 | if (selectors.includes("Graph")) { 58 | 59 | const id = "e" + element.relationship.identity.toString(); 60 | 61 | if (id in Graph) 62 | return properties 63 | 64 | const path = {}; 65 | const source = "n" + element.relationship.start.toString(); 66 | const target = "n" + element.relationship.end.toString(); 67 | const names = Object.keys(properties[1]) 68 | .filter(k => (/name/i).test(k)) 69 | .sort((a, b) => a.length - b.length) 70 | 71 | node(element.start); 72 | node(element.end); 73 | 74 | path.group = "edges"; 75 | path.classes = [element.relationship.type]; 76 | path.scratch = {} 77 | path.data = { 78 | id: id, 79 | source: source, 80 | target: target, 81 | properties: properties[1], 82 | name: (names.length > 0) ? 83 | properties[1][names[0]] : 84 | path.classes[0], 85 | }; 86 | 87 | Graph[id] = path; 88 | } 89 | 90 | return properties 91 | } 92 | 93 | function cast(result) { 94 | 95 | // Node 96 | if (result !== null && 97 | typeof result == "object" && 98 | ["identity", "labels", "properties"].every( 99 | k => Object.keys(result).includes(k)) 100 | ) 101 | return JSON.stringify(node(result), null, 2) 102 | 103 | // Edge 104 | else if ( 105 | result !== null && 106 | typeof result === "object" && 107 | ["identity", "start", "end", "type", "properties"].every( 108 | k => Object.keys(result).includes(k)) 109 | ) 110 | return JSON.stringify(edge(result), null, 2) 111 | // Path 112 | else if ( 113 | result !== null && 114 | typeof result === "object" && 115 | Object.keys(result).includes("segments") && 116 | result.segments.length > 0 117 | ) 118 | return JSON.stringify(result.segments.map(segment => { 119 | return path(segment) 120 | }), null, 2); 121 | 122 | else if (!selectors.includes("Text")) 123 | return null 124 | // Number 125 | else if (result === null || 126 | typeof result === "number") 127 | return result; 128 | // Boolean 129 | else if ( 130 | typeof result === "boolean" || 131 | result.toString().toLowerCase() == "true" || 132 | result.toString().toLowerCase() == "false" 133 | ) 134 | return result.toString().toLowerCase() === "true"; 135 | // Integer 136 | else if ( 137 | typeof result === "object" && 138 | Object.keys(result).includes("high") && 139 | Object.keys(result).includes("low") 140 | ) 141 | return parseInt(result.toString()); 142 | // String 143 | if (typeof result === "string") return result.toString(); 144 | // Array 145 | else if (Array.isArray(result)) return result.map(e => cast(e)); 146 | else if (typeof result === "object") { 147 | Object.keys(result).map(k => (result[k] = cast(result[k]))); 148 | return result; 149 | } 150 | // Unknown 151 | //else console.log(result, "is of an unknown type"); 152 | return null; 153 | } 154 | 155 | if (!Array.isArray(selectors)) 156 | selectors = ["Graph", "Text"] 157 | 158 | const neo4j = driver.session(); 159 | return neo4j 160 | .run(statement) 161 | .then(response => { 162 | response.records.forEach(function (record) { 163 | let result = {}; 164 | for (var i = 0; i < record._fields.length; i++) { 165 | result[record.keys[i]] = cast(record._fields[i]); 166 | } 167 | if (selectors.includes("Text")) 168 | Text.push(result); 169 | }); 170 | 171 | return Promise.resolve({ 172 | Text: Text, 173 | Graph: Object.keys(Graph).length != 0 174 | ? Object.keys(Graph).map(k => Graph[k]) 175 | : [] 176 | }); 177 | }) 178 | }, 179 | 180 | /* Awspx specific stylization (schema abstraction and element styling) */ 181 | 182 | stylize(results) { 183 | 184 | const Graph = [ 185 | ...(Array.isArray(results.Graph)) 186 | ? results.Graph 187 | : [] 188 | ] 189 | 190 | const nodes = [] 191 | const edges = [] 192 | 193 | for (let i = 0; i < Graph.length; i++) { 194 | 195 | let element = Graph[i]; 196 | 197 | element.data.type = element.classes 198 | .filter(c => c.includes("::")) 199 | .concat(element.classes)[0] 200 | 201 | element.classes = element.classes 202 | .map(e => e.split("::")).flat() 203 | 204 | if (element.group === "nodes" 205 | && ["Resource", "Generic", "External", "Admin", "CatchAll"] 206 | .filter(c => element.classes.indexOf(c) != -1).length > 0 207 | ) nodes.push(element) 208 | 209 | else if (element.group === "edges" 210 | && ["TRANSITIVE", "ASSOCIATIVE"] 211 | .filter(c => element.classes.indexOf(c) != -1).length > 0 212 | ) edges.push(element) 213 | 214 | else if (element.group === "edges" 215 | && ["ATTACK", "TRUSTS"] 216 | .filter(c => element.classes.indexOf(c) != -1).length > 0 217 | ) { /* pass */ } 218 | 219 | else if (element.classes.includes("ACTION")) { 220 | element.classes.push(element.data.properties.Access) 221 | element.classes.push(element.data.properties.Effect) 222 | if (Object.keys(element.data.properties).includes("Condition")) { 223 | if (element.data.properties.Condition === "[]") 224 | delete element.data.properties.Condition 225 | else 226 | element.classes.push("Conditional") 227 | } 228 | edges.push(element) 229 | } 230 | 231 | else if (element.classes.includes("Pattern")) { 232 | 233 | const source = Graph.find(e => e.classes.includes("ATTACK") 234 | && e.data.target === element.data.id) 235 | 236 | if (typeof source === 'undefined') 237 | continue 238 | 239 | edges.push.apply(edges, Graph.filter(e => (e.group === "edges" 240 | && e.data.source === element.data.id)).map(e => { 241 | return { 242 | ...e, 243 | data: { 244 | ...e.data, 245 | source: source.data.source 246 | } 247 | } 248 | })); 249 | } 250 | // Default: add the unknown node or edge 251 | else { 252 | if (element.group === "edges") 253 | edges.push(element) 254 | else 255 | nodes.push(element) 256 | } 257 | } 258 | 259 | return Promise.resolve({ 260 | ...results, 261 | Graph: nodes.concat(edges) 262 | }); 263 | 264 | 265 | }, 266 | 267 | /* Collapse action elements into a collection of actions */ 268 | bundle_actions(results, with_size_greater_than = 1) { 269 | 270 | let collections = {} 271 | let Graph = results.Graph.filter(e => { 272 | if (e.classes.includes("ACTION")) { 273 | const id = `a${e.data.source}-${e.data.target}` 274 | const access = e.data.properties.Access 275 | if (!(id in collections)) collections[id] = {} 276 | if (!(access in collections[id])) collections[id][access] = [] 277 | collections[id][access].push(e) 278 | return false 279 | } 280 | return true; 281 | }); 282 | 283 | Object.keys(collections).forEach(k => { 284 | 285 | const actions = Object.keys(collections[k]) 286 | .map(a => collections[k][a]).flat() 287 | 288 | if (actions.length <= with_size_greater_than) 289 | Graph.push.apply(Graph, actions) 290 | 291 | else Graph.push({ 292 | classes: ["ACTIONS"].concat(Array.from(new Set(actions.map(a => a.data.properties.Effect)))), 293 | data: { 294 | id: k, 295 | name: (actions.length > 1) ? `${actions.length} Actions` : `1 Action`, 296 | source: actions[0].data.source, 297 | target: actions[0].data.target, 298 | properties: collections[k] 299 | } 300 | }) 301 | }) 302 | 303 | return Promise.resolve({ 304 | ...results, 305 | Graph: Graph 306 | }); 307 | }, 308 | 309 | // TODO: 310 | collapse_branches(results) { 311 | return Promise.resolve(results) 312 | } 313 | }, 314 | install: function (Vue,) { 315 | 316 | const driver = Vue.observable({ value: {} }); 317 | const error = Vue.observable({ value: {} }); 318 | const state = Vue.observable({ statement: "", active: false }); 319 | 320 | const auth = Vue.observable({ 321 | uri: `bolt://${new URL(location.href).host}:7687`, 322 | username: "neo4j", 323 | password: "password" 324 | }); 325 | 326 | Object.defineProperty(Vue.prototype, 'neo4j', { 327 | value: { 328 | auth: auth, 329 | state: state, 330 | error: error 331 | } 332 | }); 333 | 334 | Object.defineProperty(Vue.prototype.neo4j, 'error', { 335 | get() { return error.value }, 336 | set(value) { error.value = value } 337 | }); 338 | 339 | Vue.prototype.neo4j.setup = ( 340 | uri = auth.uri, 341 | username = auth.username, 342 | password = auth.password 343 | ) => { 344 | 345 | auth.uri = uri; 346 | auth.username = username; 347 | auth.password = password; 348 | 349 | driver.value = neo4j.driver( 350 | uri, neo4j.auth.basic(username, password), { 351 | encrypted: false 352 | }) 353 | } 354 | 355 | Vue.prototype.neo4j.test = ( 356 | uri = auth.uri, 357 | username = auth.username, 358 | password = auth.password 359 | ) => { 360 | try { 361 | const connection = neo4j.driver( 362 | uri, neo4j.auth.basic(username, password), { 363 | encrypted: false 364 | }) 365 | return this.methods.run("RETURN 1", connection).then( 366 | ).catch(e => { 367 | return new Promise(() => { 368 | throw e 369 | }) 370 | }) 371 | } catch (e) { 372 | return new Promise(() => { 373 | throw e 374 | }) 375 | } 376 | } 377 | 378 | Vue.prototype.neo4j.run = ( 379 | statement, 380 | suppress_error = true, 381 | connection = driver.value, 382 | selectors = ["Graph", "Text"] 383 | ) => { 384 | 385 | if (connection.constructor.name !== "Driver") { 386 | return Promise.resolve({ 387 | Text: [], 388 | Graph: [] 389 | }) 390 | } 391 | 392 | const leniency = setTimeout(function () { state.active = true; }, 200); 393 | state.statement = statement; 394 | error.value = {}; 395 | 396 | return this.methods.run(statement, connection, selectors) 397 | // Comment out the following 3 '.then' lines to genericize this component. 398 | .then(r => this.methods.stylize(r)) 399 | .then(r => this.methods.bundle_actions(r)) 400 | .then(r => this.methods.collapse_branches(r)) 401 | .catch(e => { 402 | error.value = e; 403 | if (suppress_error) { 404 | return Promise.resolve({ 405 | Text: [], 406 | Graph: [] 407 | }) 408 | } else throw e 409 | }) 410 | .finally(() => { 411 | clearTimeout(leniency); 412 | state.active = false; 413 | }); 414 | } 415 | }, 416 | } 417 | -------------------------------------------------------------------------------- /www/src/queries.js: -------------------------------------------------------------------------------- 1 | export const queries = [ 2 | { 3 | name: "Inventory", 4 | description: "List all resources in your account", 5 | value: [ 6 | "MATCH (Source:Resource)", 7 | "RETURN Source.Name AS Name,", 8 | "Source.Arn AS ARN" 9 | ] 10 | }, 11 | { 12 | name: "Administrators", 13 | description: "List resources that effectively have full access to your account", 14 | value: [ 15 | "MATCH Path=(Source:Resource)-[:TRANSITIVE|ATTACK*1..]->(Target:Admin)", 16 | "RETURN Source.Name AS Name, Source.Arn AS Arn" 17 | ] 18 | }, 19 | { 20 | name: "Public access", 21 | description: "Shows actions that can be performed by anyone", 22 | value: [ 23 | "MATCH Actions=(Source:`AWS::Account`)-[Action:ACTION]->(Target:Resource)", 24 | "WHERE Source.Name = 'All AWS Accounts'", 25 | "AND Action.Effect = 'Allow'", 26 | "RETURN Actions" 27 | ] 28 | }, 29 | { 30 | name: "Public buckets (read access)", 31 | description: "Shows buckets that can be read anonymously", 32 | value: [ 33 | "MATCH Actions=(Source:`AWS::Account`)-[Action:ACTION]->(Target:`AWS::S3::Bucket`)", 34 | "WHERE Source.Name = 'All AWS Accounts'", 35 | "AND Action.Access = 'Read'", 36 | "AND Action.Effect = 'Allow'", 37 | "RETURN Actions" 38 | ] 39 | }, 40 | { 41 | name: "Public roles (assumable)", 42 | description: "Shows roles that can be assumed anonymously", 43 | value: [ 44 | "MATCH Actions=(Source:`AWS::Account`)-[Action:ACTION]->(Target:`AWS::Iam::Role`)", 45 | "WHERE Source.Name = 'All AWS Accounts'", 46 | "AND Action.Effect = 'Allow'", 47 | "AND Action.Name =~ '.*sts:Assume.*'", 48 | "RETURN Actions" 49 | ] 50 | } 51 | ]; 52 | --------------------------------------------------------------------------------
92 | or load the sample dataset (if you just want to play around): 93 |