2a388863创建于 2021年8月24日历史提交

import copy
import json
import re
import time

from lib.graph.db import Neo4j

definitions = {

    "CreatePolicyVersion": {

        "Description": "Overwrite the default version of the target managed policy:",

        "Commands": [
            "aws create-policy-version"
            "   --policy-arn ${AWS::Iam::Policy.Arn}"
            "   --set-as-default"
            "   --policy-document file://<(cat <<EOF\n"
            "{\n"
            "    \"Version\": \"2012-10-17\",\n"
            "    \"Statement\": [\n"
            "        {\n"
            "            \"Sid\": \"Admin\",\n"
            "            \"Effect\": \"Allow\",\n"
            "            \"Action\": \"*\",\n"
            "            \"Resource\": \"*\"\n"
            "        }]\n"
            "}\n"
            "EOF\n"
            ")"
        ],

        "Attack": {

            "Depends": "AWS::Iam::Policy",

            "Requires": [
                "iam:CreatePolicyVersion"
            ],

            "Affects": "AWS::Iam::Policy",

            "Grants": "Admin"

        }
    },

    "AssociateInstanceProfile": {

        "Description": "Associate the specified EC2 instance with the target instance profile: ",

        "Commands": [
            "aws ec2 associate-iam-instance-profile"
            "   --instance-id ${AWS::Ec2::Instance}"
            "   --iam-instance-profile Name=${AWS::Iam::InstanceProfile}"
        ],

        "Attack": {

            "Depends": "AWS::Ec2::Instance",

            "Requires": [
                "ec2:AssociateIamInstanceProfile"
            ],

            "Affects": "AWS::Ec2::Instance",

            "Grants": "AWS::Iam::InstanceProfile",

            "Cypher": [
                # The instance profile doesnt exist or it can be deleted indirectly
                "(NOT EXISTS((${AWS::Ec2::Instance})-[:TRANSITIVE]->(:`AWS::Iam::InstanceProfile`))",
                "   OR EXISTS((${})-[:I|ACTION{Name:'ec2:DisassociateIamInstanceProfile'}]->(${AWS::Ec2::Instance}))",
                # The instance profile has no role or iam:Pass role can be performed
                ") AND (NOT EXISTS((${AWS::Iam::InstanceProfile})-[:TRANSITIVE]->(:`AWS::Iam::Role`))",
                "OR EXISTS((${})-[:D|ACTION{Name:'iam:PassRole'}]->(:`AWS::Iam::Role`)<-[:TRANSITIVE]-(${AWS::Iam::InstanceProfile})))"
            ]

        }
    },

    "AssumeRole": {

        "Description": "Retrieve a set of temporary security credentials from assuming the target role:",

        "Commands": [
            "aws sts assume-role"
            "   --role-arn ${AWS::Iam::Role.Arn}"
            "   --role-session-name AssumeRole"
        ],

        "Attack": {

            "Requires": [
                "sts:AssumeRole"
            ],

            "Affects": "AWS::Iam::Role",

            "Cypher": [
                "(${})<-[:TRUSTS{Name:'sts:AssumeRole'}]-(${AWS::Iam::Role})"
            ]

        }
    },

    "AddRoleToInstanceProfile": {

        "Description": "Add the target role to the specified instance profile:",

        "Commands": [
            "aws iam add-role-to-instance-profile"
            "   --instance-profile-name ${AWS::Iam::InstanceProfile}"
            "   --role-name ${AWS::Iam::Role}"
        ],

        "Attack": {

            "Depends": "AWS::Iam::InstanceProfile",

            "Requires": [
                "iam:AddRoleToInstanceProfile"
            ],

            "Affects": "AWS::Iam::InstanceProfile",

            "Grants": "AWS::Iam::Role",

            "Cypher": [
                # EC2 is trusted by the role
                "(EXISTS((${AWS::Iam::Role})-[:TRUSTS]->({Name:'ec2.amazonaws.com'}))",
                # The instance profile has no role or it can be detached
                "AND EXISTS((${})-[:I|ACTION{Name:'iam:RemoveRoleFromInstanceProfile'}]->(${AWS::Iam::InstanceProfile}))",
                "   OR NOT EXISTS((${AWS::Iam::InstanceProfile})-[:TRANSITIVE]->(${AWS::Iam::Role})))"
            ]
        }
    },

    "AddUserToGroup": {

        "Description": "Add the specified user to the target group:",

        "Commands": [
            "aws iam add-user-to-group"
            "   --group-name ${AWS::Iam::Group}"
            "   --user-name ${AWS::Iam::User}"
        ],

        "Attack": {

            "Depends": "AWS::Iam::User",

            "Requires": [
                "iam:AddUserToGroup"
            ],

            "Affects": "AWS::Iam::Group"

        }
    },

    "AttachGroupPolicy": {

        "Description": "Attach the target managed policy to the specified group:",

        "Commands": [
            "aws iam attach-group-policy"
            "   --group-name ${AWS::Iam::Group}"
            "   --policy-arn ${AWS::Iam::Policy.Arn}"
        ],

        "Attack": {

            "Depends": "AWS::Iam::Group",

            "Requires": [
                "iam:AttachGroupPolicy",
            ],

            "Affects": "AWS::Iam::Group",

            "Grants": "AWS::Iam::Policy"

        }
    },

    "AttachRolePolicy": {

        "Description": "Attach the target managed policy to the specified role:",

        "Commands": [
            "aws iam attach-role-policy"
            "   --role-name ${AWS::Iam::Role}"
            "   --policy-arn ${AWS::Iam::Policy.Arn}"
        ],

        "Attack": {

            "Depends": "AWS::Iam::Role",

            "Requires": [
                "iam:AttachRolePolicy"
            ],

            "Affects": "AWS::Iam::Role",

            "Grants": "AWS::Iam::Policy"
        }

    },

    "AttachUserPolicy": {

        "Description": "Attach the target managed policy to the specified user:",

        "Commands": [
            "aws iam attach-user-policy"
            "   --user-name ${AWS::Iam::User}"
            "   --policy-arn ${AWS::Iam::Policy.Arn}"
        ],

        "Attack": {

            "Depends": "AWS::Iam::User",

            "Requires": [
                "iam:AttachUserPolicy",
            ],

            "Affects": "AWS::Iam::User",

            "Grants": "AWS::Iam::Policy"

        }
    },

    "CreateGroup": {

        "Description": "Create a new group and add the specified user to it:",

        "Options": {
            "CreateAction": True,
            "Transitive": False
        },

        "Commands": [
            "aws iam create-group --group-name ${AWS::Iam::Group}"
        ],

        "Attack": {

            "Requires": [
                "iam:CreateGroup"
            ],

            "Affects": "AWS::Iam::Group"

        },
    },

    "CreateInstance": {

        "Description": "Launch a new EC2 instance:",

        "Options": {
            "CreateAction": True,
            "Transitive": True
        },

        "Commands": [
            "aws ec2 run-instances"
            "   --count 1"
            "   --instance-type t2.micro"
            "   --image-id $AmiId",
        ],

        "Attack": {

            "Requires": [
                "ec2:RunInstances"
            ],

            "Affects": "AWS::Ec2::Instance",

        }
    },

    "CreateInstanceProfile": {

        "Description": "Create a new instance profile:",

        "Options": {
            "CreateAction": True,
            "Transitive": False
        },

        "Commands": [
            "aws iam create-instance-profile"
            "   --instance-profile-name ${AWS::Iam::InstanceProfile}"
        ],


        "Attack": {

            "Requires": [
                "iam:CreateInstanceProfile"
            ],

            "Affects": "AWS::Iam::InstanceProfile",

        }
    },

    "CreatePolicy": {

        "Description": "Create a new managed policy:",

        "Options": {
            "CreateAction": True,
            "Transitive": False
        },

        "Commands": [
            "aws iam create-policy"
            "   --policy-name ${AWS::Iam::Policy}"
            "   --policy-document file://<(cat <<EOF\n"
            "{\n"
            "    \"Version\": \"2012-10-17\",\n"
            "    \"Statement\": [\n"
            "        {\n"
            "            \"Sid\": \"Admin\",\n"
            "            \"Effect\": \"Allow\",\n"
            "            \"Action\": \"*\",\n"
            "            \"Resource\": \"*\"\n"
            "        }]\n"
            "}\n"
            "EOF\n"
            ")"
        ],

        "Attack": {

            "Requires": [
                "iam:CreatePolicy",
            ],

            "Affects": "AWS::Iam::Policy",

            "Grants": "Admin"
        },
    },

    "CreateRole": {

        "Description": [
            "Create a new role to assume:",
            "Retrieve a set of temporary security credentials from assuming the target role:"
        ],

        "Options": {
            "CreateAction": True,
            "Transitive": True
        },

        "Commands": [
            "aws iam create-role"
            "   --role-name ${AWS::Iam::Role} "
            "   --assume-role-policy-document file://<(cat <<EOF\n"
            "{\n"
            "  \"Version\": \"2012-10-17\",\n"
            "  \"Statement\": [\n"
            "    {\n"
            "      \"Effect\": \"Allow\",\n"
            "      \"Action\": \"sts:AssumeRole\",\n"
            "      \"Principal\": {\n"
            "        \"AWS\": \"*\"\n"
            "      }\n"
            "    }\n"
            "  ]\n"
            "}\n"
            "EOF\n"
            ")",
            "aws sts assume-role"
            "   --role-arn ${AWS::Iam::Role.Arn}"
            "   --role-session-name AssumeRole"
        ],

        "Attack": {

            "Requires": [
                "iam:CreateRole",
            ],

            "Affects": "AWS::Iam::Role",

        }

    },

    "CreateUser": {

        "Description": "Create a new user:",

        "Options": {
            "CreateAction": True,
            "Transitive": False
        },

        "Commands": [
            "aws iam create-user --user-name ${AWS::Iam::User}",
        ],

        "Attack": {

            "Requires": [
                "iam:CreateUser"
            ],

            "Affects": "AWS::Iam::User"

        }
    },

    "PutGroupPolicy": {

        "Description": "Add a new administrative inline policy document to the target group:",

        "Commands": [
            "aws iam put-group-policy"
            "   --group-name ${AWS::Iam::Group}"
            "   --policy-name Admin"
            "   --policy-document file://<(cat <<EOF\n"
            "{\n"
            "    \"Version\": \"2012-10-17\",\n"
            "    \"Statement\": [\n"
            "        {\n"
            "            \"Sid\": \"Admin\",\n"
            "            \"Effect\": \"Allow\",\n"
            "            \"Action\": \"*\",\n"
            "            \"Resource\": \"*\"\n"
            "        }]\n"
            "}\n"
            "EOF\n"
            ")"
        ],

        "Attack": {

            "Depends": "AWS::Iam::Group",

            "Requires": [
                "iam:PutGroupPolicy"
            ],

            "Affects": "AWS::Iam::Group",

            "Grants": "Admin"

        },
    },

    "PutRolePolicy": {

        "Description": "Add a new administrative inline policy document to the target role:",

        "Commands": [
            "aws iam put-role-policy"
            "   --role-name ${AWS::Iam::Role}"
            "   --policy-name Admin"
            "   --policy-document file://<(cat <<EOF\n"
            "{\n"
            "    \"Version\": \"2012-10-17\",\n"
            "    \"Statement\": [\n"
            "        {\n"
            "            \"Sid\": \"Admin\",\n"
            "            \"Effect\": \"Allow\",\n"
            "            \"Action\": \"*\",\n"
            "            \"Resource\": \"*\"\n"
            "        }]\n"
            "}\n"
            "EOF\n"
            ")"
        ],

        "Attack": {

            "Depends": "AWS::Iam::Role",

            "Requires": [
                "iam:PutRolePolicy"
            ],

            "Affects": "AWS::Iam::Role",

            "Grants": "Admin"

        },
    },

    "PutUserPolicy": {

        "Description": "Add a new administrative inline policy document to the target user:",

        "Commands": [
            "aws iam put-user-policy"
            "   --user-name ${AWS::Iam::User}"
            "   --policy-name Admin "
            "   --policy-document file://<(cat <<EOF\n"
            "{\n"
            "    \"Version\": \"2012-10-17\",\n"
            "    \"Statement\": [\n"
            "        {\n"
            "            \"Sid\": \"Admin\",\n"
            "            \"Effect\": \"Allow\",\n"
            "            \"Action\": \"*\",\n"
            "            \"Resource\": \"*\"\n"
            "        }]\n"
            "}\n"
            "EOF\n"
            ")"
        ],

        "Attack": {

            "Depends": "AWS::Iam::User",

            "Requires": [
                "iam:PutUserPolicy"
            ],

            "Affects": "AWS::Iam::User",

            "Grants": "Admin"

        },
    },

    "UpdateRole": {

        "Description": [
            "Update the assume-role policy document of the target role and assume it thereafter:",
            "Retrieve a set of temporary security credentials from assuming the target role:"
        ],

        "Commands": [
            "aws iam update-assume-role-policy"
            "   --role-name ${AWS::Iam::Role}"
            "   --policy-document file://<(cat <<EOF\n"
            "{\n"
            "  \"Version\": \"2012-10-17\",\n"
            "  \"Statement\": [\n"
            "    {\n"
            "      \"Effect\": \"Allow\",\n"
            "      \"Action\": \"sts:AssumeRole\",\n"
            "      \"Principal\": {\n"
            "        \"AWS\": \"*\"\n"
            "      }\n"
            "    }\n"
            "  ]\n"
            "}\n"
            "EOF\n"
            ")",

            "aws sts assume-role"
            "   --role-arn ${AWS::Iam::Role.Arn}"
            "   --role-session-name AssumeRole"
        ],

        "Attack": {

            "Requires": [
                "iam:UpdateAssumeRolePolicy"
            ],

            "Affects": "AWS::Iam::Role"

        }
    },

    "UpdateLoginProfile": {

        "Description": "Reset the target user's console password and login as them:",

        "Commands": [
            "aws iam update-login-profile"
            "   --user-name ${AWS::Iam::User}"
            "   --password $Password"
        ],

        "Attack": {

            "Requires": [
                "iam:UpdateLoginProfile"
            ],

            "Affects": "AWS::Iam::User"

        }
    },

    "CreateLoginProfile": {

        "Description": "Set a console password for the target user and login as them, nothing has been set before:",

        "Commands": [
            "aws iam create-login-profile"
            "   --user-name ${AWS::Iam::User}"
            "   --password $Password"
        ],

        "Attack": {

            "Requires": [
                "iam:CreateLoginProfile"
            ],

            "Affects": "AWS::Iam::User",

            "Cypher": [
                "(${AWS::Iam::User.LoginProfile} IS NULL",
                "OR EXISTS((${})-[:I|ACTION{Name:'iam:DeleteLoginProfile'}]->(${AWS::Iam::User})))"
            ]
        }
    },

    "CreateAccessKey": {

        "Description": "Create an access key for the target user and authenticate as them using the API:",

        "Commands": [
            "aws iam create-access-key --user-name ${AWS::Iam::User}"
        ],

        "Attack": {

            "Requires": [
                "iam:CreateAccessKey"
            ],

            "Affects": "AWS::Iam::User",

            "Cypher": [
                "((COALESCE(SIZE(SPLIT(${AWS::Iam::User.AccessKeys},'Status')), 1) - 1) < 2",
                "OR EXISTS((${})-[:I|ACTION{Name:'iam:DeleteAccessKey'}]->(${AWS::Iam::User})))"
            ]

        }
    }

}


class Attacks:

    definitions = definitions
    stats = []

    def __init__(self, skip_attacks=[], only_attacks=[],
                 skip_conditional_actions=True, max_search_depth="",
                 console=None
                 ):

        if console is None:
            from lib.util.console import console

        self.console = console
        self.conditional = skip_conditional_actions

        self.definitions = {k: self.definitions[k]
                            for k in list(self.definitions.keys()
                                          if only_attacks == []
                                          else only_attacks)
                            if k not in skip_attacks
                            }
        self.queries = {k: self.translate(k, max_search_depth)
                        for k, v in self.definitions.items()
                        }

    def translate(self, name, max_search_depth=""):

        definition = self.definitions[name]
        attack = definition["Attack"]
        cypher = str()

        strings = {
            "name": name,
            "description": list([definition["Description"]]
                                if isinstance(definition["Description"], str)
                                else definition["Description"]),

            "commands": definition["Commands"],
            "requires": attack["Requires"],
            "affects": attack["Affects"],

            "depends": str(attack["Depends"]
                           if "Depends" in attack
                           else ""),
            "grants": str(attack["Grants"]
                          if "Grants" in attack
                          else ""),

            "depth": max_search_depth,
            "size": len(attack["Requires"]),
        }

        options = {
            "AffectsGeneric": False,
            "CreateAction": False,
            "Transitive": True,
            "Admin": bool(True
                          if "Grants" in attack
                          and attack["Grants"] == "Admin"
                          else False),
            ** dict(definition["Options"]
                    if "Options" in definition
                    else {}),
        }

        def cypher_resolve_commands(history=False):

            # A nested REPLACE operation must occur for every placeholder in the format string.
            # Resolution occurs by performing a type comparison against fields in the pattern's
            # definition.

            resolved = "_"

            for (placeholder, attr) in sorted(
                    re.findall(r"\$\{(AWS\:\:[A-Za-z0-9]+\:\:[A-Za-z0-9]+)?(\.[A-Za-z]+)?\}",
                               ';'.join(strings["commands"])
                               ),
                    key=lambda x: len(x[0]+x[1]),
                    reverse=True):

                substitute = None

                if placeholder == attack["Affects"]:
                    substitute = "grant" if "Grants" not in attack else "option"

                elif ("Depends" in attack
                      and placeholder == attack["Depends"]):
                    substitute = "option"

                elif ("Grants" in attack
                      and placeholder == attack["Grants"]):
                    substitute = "grant"

                elif placeholder == '':
                    substitute = "source"

                else:
                    self.console.error(f"Unknown placeholder: '{placeholder}'")
                    continue

                if len(attr) == 0:
                    substitute += ".Name"

                else:
                    substitute += attr
                    placeholder += attr

                resolved = f"REPLACE({resolved}, \"${{{placeholder}}}\", {substitute})"

            resolved = ("[_ IN %s|%s]" % (strings["commands"], resolved)
                        ).replace('{', '{{').replace('}', '}}')

            if history:

                resolved = (
                    "REDUCE(commands=[], _ IN history + %s|"
                    "CASE WHEN _ IN commands THEN commands "
                    "ELSE commands + _ END) "
                    "AS commands") % (resolved)

            else:
                resolved += " AS commands"

            return resolved

        def cypher_resolve_placeholder(placeholder):

            if placeholder == "":
                return "source"

            elif placeholder == attack["Affects"]:
                return "target"

            elif ("Depends" in attack
                  and placeholder == attack["Depends"]):
                return "option"

            elif ("Grants" in attack
                  and placeholder == attack["Grants"]):
                return "grant"

            else:
                r = re.compile(r"(AWS::[A-Za-z0-9:]+)"
                               ).match(placeholder)

                if r.group(1) is not None:
                    return str(f"{r.group(1).replace(':', '').lower()}"
                               f":`{r.group(1)}`")

        def cypher_inject():

            inject = ' '.join(attack["Cypher"])
            retain = ["source", "edge", "options", "target",
                      "path", "grants", "admin"
                      ]

            unwound = []

            for k, v in {
                    k: cypher_resolve_placeholder(k) for k in set([
                        r for (r, _) in re.findall(
                            r"\$\{(AWS\:\:[A-Za-z0-9]+\:\:[A-Za-z0-9]+)?(\.[A-Za-z]+)?\}",
                            inject
                        )
                    ])}.items():

                if (v not in unwound and v in ["option", "grant"]):
                    unwound.append(v)

                inject = re.sub(
                    rf"\${{{k}(?P<property>\.[a-zA-Z]+)?}}",
                    lambda x: (f"{v}{x.groupdict()['property']}"
                               if x.groupdict()['property'] is not None
                               else v),
                    inject)

            # Expand ACTION shorthand
            inject = re.sub(
                r"\[:((?P<directive>I|D)?\|)?(?P<type>ACTION|TRUSTS)(\{(?P<properties>[^}.]*)\})?\]",
                lambda x: ''.join([
                    str(  # D (Direct)
                        f"[:TRANSITIVE|ATTACK*0..{strings['depth']}]->()-" if x.groupdict()['directive'] == "I"
                        # I (Indirect)
                        else f"[:TRANSITIVE*0..{strings['depth']}]->()-" if x.groupdict()['directive'] == "D"
                        # None
                        else ""
                    ),
                    f"[:{x.groupdict()['type']}{{",
                    ', '.join([
                        f"{k}: '{v}'" for k, v in {"Effect": 'Allow',  # Default to allow
                                                   **dict({"Condition": []} if self.conditional else {}),
                                                   **{k: v for (k, v) in [
                                                       re.sub(r"[^\S]*(?P<k>[^:.]*):'(?P<v>[^'.]*)'",
                                                              lambda x: f"{x.groupdict()['k']},{x.groupdict()['v']}",
                                                              p).split(',')
                                                       for p in str(x.groupdict()["properties"]).split(",") if p != "None"
                                                   ]}
                                                   }.items()]),
                    "}]"
                ]),
                inject)

            if len(unwound) > 0:

                inject = " ".join((
                    "WITH " + ", ".join(retain),
                    " ".join(["UNWIND %s AS %s" % (i, i[:-1])
                              for i in retain
                              if i[:-1] in unwound
                              ]),
                    "WITH " + ", ".join([i
                                         if i[:-1] not in unwound
                                         else "{i}[0] AS {i}, {i}[1] AS _{i}".format(i=i[:-1])
                                         for i in retain
                                         ]),
                    "WHERE " + inject,  # Cypher injection point
                    "WITH " + ", ".join([i if i[:-1] not in unwound
                                         else "COLLECT([{i},_{i}]) AS {j}".format(i=i[:-1], j=i)
                                         for i in retain
                                         ])
                ))

            else:
                inject = "AND " + inject

            return re.sub("([{}]+)", lambda x: x.groups()[0] * 2, inject)

        # If a node, or edge, is identified to grant Admin, it is excluded from search.
        # This is because all patterns incorporating Admin are implied - searching further
        # would be redundant.

        cypher += (
            "OPTIONAL MATCH (admin)-[r:ATTACK|TRANSITIVE*0..]->(:Admin), "
            "   (default:Admin{{Arn:'arn:aws:iam::{{Account}}:policy/Admin'}}) "
            "   WHERE NOT (admin:Pattern OR admin:Admin) "
            "WITH COLLECT(DISTINCT COALESCE(admin, default)) AS admin, "
            "   [[NULL, []]] AS options, [NULL, []] AS grants "
        )

        # Patterns including a "Depends" value require an additional argument, which must
        # be reachable. This amounts to determining whether any nodes of that type can
        # be reached (transitively, or through performing one or more attacks).
        # Consequently, it must incorporate a weight that is computed once all required
        # commands have been consilidated. Dependencies need to be determined first to
        # avoid erroneous exclusion when deduplication is performed.

        if "Depends" in attack:

            strings["option_type"] = attack["Depends"]

            cypher += (
                "MATCH path=(source)-[:TRANSITIVE|ATTACK|CREATE*0..{depth}]->(option:`{option_type}`) "
                "   WHERE ALL(_ IN RELATIONSHIPS(path) WHERE TYPE(_) <> 'CREATE' OR _.Transitive) "
                "   AND NOT (source IN admin OR option IN NODES(path)[1..-1]) "
                "   AND (source:Resource OR source:External) AND (option:Resource OR option:Generic) "

                "WITH DISTINCT source, option, admin, "
                "   [_ IN RELATIONSHIPS(path) WHERE STARTNODE(_):Pattern] AS dependencies "

                "WITH admin, COLLECT([source, option, REDUCE("
                "   commands=[], _ IN dependencies|"
                "       CASE WHEN _ IN commands THEN commands "
                "       ELSE commands + _.Commands END)]"
                "   ) AS results "

                "UNWIND results AS result "

                "WITH admin, results, result[0] AS source, result[1] AS option, "
                "   MIN(SIZE(result[2])) AS weight "

                "UNWIND results AS result "
                "WITH admin, result, source, option, weight, result[2] AS commands "
                "   WHERE source = result[0] AND option = result[1] "
                "   AND weight = SIZE(result[2]) "

                "WITH admin, source, option, commands, weight ORDER BY weight "
                "WITH admin, source, COLLECT([option, commands]) AS options, [NULL, []] AS grants "
            )

        # No dependencies: source may be any Resource/External (excluding those known to grant Admin).

        else:

            cypher += (
                "MATCH (source) "
                "WHERE NOT source IN admin AND (source:Resource OR source:External) "

                "WITH admin, source, options, grants "
            )

        # Patterns including a "Grants" value grant transitivity to a resource type that may not the
        # affected type (e.g. iam:AttachGroupPolicy affects a group but grants transitivity to a policy).
        # The grants type can either be a resource or a creatable generic, reachable directly or indirectly.

        if "Grants" in attack:

            cypher += ''.join((

                "OPTIONAL MATCH (grant:`{grants}`) ",
                "   WHERE NOT grant:Generic ",
                "   AND grant:Resource " if attack["Grants"] != "Admin" else "",

                "OPTIONAL MATCH creation=shortestPath((source)-[:TRANSITIVE|ATTACK|CREATE*..{depth}]->(pattern:Pattern)), "
                "   (pattern)-[create:ATTACK]->(generic) "
                "   WHERE source <> pattern AND TYPE(REVERSE(RELATIONSHIPS(creation))[0]) = 'CREATE' "
                "       AND EXISTS((pattern)-[:OPTION|ATTACK]->(:`{grants}`)) ",

                "WITH DISTINCT source, options, admin, ",
                "grant, generic, REDUCE(commands=[], _ IN [",
                "   _ IN [_ IN RELATIONSHIPS(creation) ",
                "       WHERE STARTNODE(_):Pattern]|_.Commands]|",
                "   CASE WHEN _ IN commands THEN commands ",
                "   ELSE commands + _ END",
                "   ) + create.Commands AS commands ",

                "WITH source, options, admin, ",
                "COLLECT([grant, []]) + COLLECT([generic, commands]) AS grants ",
                "UNWIND grants AS grant ",

                "WITH DISTINCT source, options, admin, ",
                "grant[0] AS grant, grant[1] AS commands ",
                "WHERE grant[0] IS NOT NULL ",

                "WITH source, options, admin, ",
                "COLLECT([grant, commands]) AS grants ",
            ))

        # Assert: WITH options, grants, admin

        if (strings["size"] == 1 and "Depends" not in attack and "Cypher" not in attack):

            # If only one relationship is required, and there are no dependencies,
            # only direct relationships need to be identified, weight computation
            # and pruning requirements can be safely ommitted.

            cypher += ' '.join((
                "MATCH path=(source)-[edge:ACTION{{Name:'{requires[0]}', Effect: 'Allow'}}]"
                "->(target:`{affects}`) ",

                "WHERE NOT source:Pattern ",
                "   AND ALL(_ IN REVERSE(TAIL(REVERSE(NODES(path)))) WHERE NOT _ IN admin) ",
                "   AND edge.Condition = '[]' " if self.conditional else "",

                # target types that are dependant on being reachable transitively.
                '   AND target IN [_ IN options|_[0]] ' if ("Depends" in attack
                                                            and attack["Depends"] == attack["Affects"]) else "",

                cypher_inject() if "Cypher" in attack else "",

                "WITH  source, target, [] AS commands, options, grants, admin ",
            ))
        else:

            # Otherwise, the attack may incorporate indirect relationships (a dependency or
            # or a combination of one or more relationships.

            cypher += ' '.join((

                "MATCH path=(source)-[:TRANSITIVE|ATTACK*0..{depth}]->()-[edge:ACTION]->(target:`{affects}`)",
                "   WHERE NOT source:Pattern",
                "   AND ALL(_ IN REVERSE(TAIL(REVERSE(NODES(path)))) WHERE NOT _ IN admin)",
                "   AND edge.Name IN {requires} AND edge.Effect = 'Allow' ",
                "   AND edge.Condition = '[]' " if self.conditional else "",
                '   AND target IN [_ IN options|_[0]] ' if ("Depends" in attack
                                                            and attack["Depends"] == attack["Affects"]) else "",

                cypher_inject() if "Cypher" in attack else "",

                "WITH COLLECT([source, edge.Name, target, path, options, grants]) AS results, admin",
                "UNWIND results AS result",
                "WITH results, result[0] AS source,",
                "   result[1] AS edge, result[2] AS target, admin",

                # Eliminate all source nodes that incorporate other source nodes
                # producing the same edge to the same target. Nodes are validated
                # by counting the number of distinct edges produced.

                "WITH results, source,",
                "   SIZE(COLLECT(DISTINCT edge)) AS size, target, admin",
                "   WHERE size = {size}",

                "WITH results, target, ",
                "   COLLECT(DISTINCT source) AS sources, admin ",
                "WITH results, ",
                "   COLLECT(DISTINCT [target, sources]) AS pairs, admin ",

                "UNWIND pairs AS pair ",
                "UNWIND results AS result ",

                "WITH results, ",
                "result[0] AS source, result[2] AS target, ",
                "pair[0] AS t, pair[1] AS s, ",
                "TAIL(REVERSE(TAIL(NODES(result[3])))) AS intermediaries, admin ",
                "   WHERE ALL(_ IN intermediaries WHERE NOT _ IN s) ",
                "   AND target = t ",

                "WITH source, target, results, admin ",
                "UNWIND results AS result "
                "WITH source, target, result, admin "
                "   WHERE result[0] = source "
                "   AND result[2] = target "

                "WITH result[0] AS source, result[2] AS target, ",
                "   result[3] AS path, result[4] AS options, ",
                "   result[5] AS grants, admin ",

                # Attack path weight: Each outcome is representative of a distinct
                # requirement that must be satisfied for the associated path to be traversed.
                # Each path may be contingent on zero or more dependencies, represented by
                # patterns that must first be executed. This set may be empty, in which case
                # the associated weight - or the number of steps required - will be zero.

                "WITH source, target, options, grants,",
                "   [_ IN RELATIONSHIPS(path) WHERE STARTNODE(_):Pattern] AS dependencies,",
                "   LAST([_ IN RELATIONSHIPS(path)|_.Name]) AS outcome, admin",

                # [source, target, options, outcome, commands, grants, admin]

                "WITH COLLECT([source, target, options, outcome,",
                "   REDUCE(commands=[], _ IN dependencies|",
                "       CASE WHEN _ IN commands THEN commands ",
                "       ELSE commands + _.Commands END), grants]",
                "   ) AS results, admin",

                # Attack the combined minimum weight associated with each distinct source,
                # target node pair - all other results are discarded.
                # Note: this method may double count commands.

                "UNWIND results AS result",
                "WITH results, result[0] AS source, result[1] AS target,",
                "   result[3] AS outcome, MIN(SIZE(result[4])) AS weight, admin",

                "UNWIND results AS result",
                "WITH result, source, target, outcome, weight, admin",
                "   WHERE source = result[0] AND target = result[1]",
                "   AND outcome = result[3] AND weight = SIZE(result[4])",

                "WITH source, target,",
                "   REDUCE(commands=[], _ IN COLLECT(result[4])|",
                "       CASE WHEN _ IN commands THEN commands",
                "       ELSE _ + commands END) AS commands,",
                "result[2] AS options, result[5] AS grants, admin ",
            ))

        # Assert: cypher includes source, targets, options, grants, admin; where options, targets
        #         and grants comprise of (destination, commands) tuples.

        if options["AffectsGeneric"] or options["CreateAction"]:

            # Reduce result set to Generics only when a CreateAction has been specified.

            strings["affects"] += "`:`Generic"

        else:

            # A target must be either Generic or a Resource. If the target is Generic,
            # the source must be able to create it. This additional set of actions must
            # be reflected so that it can be incorporated into computed weights

            cypher += ' '.join((

                "OPTIONAL MATCH (source)-[:TRANSITIVE|ATTACK*0..{depth}]->"
                "   ()-[edge:CREATE]->(:Pattern)-->(target:Generic)",

                # Previous patterns may have already satisfied Dependency requirements. In order
                # to avoid duplicate steps, weights must be recomputed.

                "WITH source, target, options, grants,",
                "   REDUCE(commands=[], _ IN commands + COALESCE(edge.Commands,[])|",
                "       CASE WHEN _ IN commands THEN commands ",
                "       ELSE commands + _ END",
                ") AS commands, admin",

                "WHERE (NOT edge IS NULL AND target:Generic) OR target:Resource "

            ))

        # Create (source)-[:ATTACK]->(pattern:Pattern)

        cypher += ' '.join((

            "WITH DISTINCT source, target, options, grants,",
            "COALESCE(commands, []) AS commands, admin",

            # Prune targets where a transitive relationship does not exist
            # when a dependency that applies to the affected node type has
            # has been specified.

            "UNWIND options AS option "
            "WITH source, target, grants, option[0] AS option,"
            "   REDUCE(commands=[], _ IN commands + option[1]|"
            "       CASE WHEN _ IN commands THEN commands "
            "       ELSE commands + _ END"
            "   ) AS commands, admin "

            "WITH source, target, commands, grants, [[NULL, []]] AS options, admin "
            "WHERE target = option "

            if ("Depends" in attack and attack["Depends"] == attack["Affects"])
            else "ORDER BY SIZE(commands)",

            "WITH source, target, commands, options, grants, admin",
            "ORDER BY SIZE(commands)",

            "WITH DISTINCT source, options, COLLECT([target, commands]) AS grants, admin " if ("Grants" not in attack) else
            "WITH DISTINCT source, COLLECT([target, commands]) AS options, grants, admin ",

            # if any grant in admin: grants = grants ∩ admin

            "UNWIND grants AS grant ",
            "WITH source, options, grant[0] AS grant, grant[1] AS commands, ",
            "   ANY(_ IN grants WHERE _[0] IN admin) AS isadmin ",
            "WHERE NOT isadmin OR grant IN admin ",

            "WITH DISTINCT source, options, COLLECT([grant, commands]) AS grants ",

            "MERGE (source)-[edge:%s]->(pattern:Pattern:{name})" % str("CREATE" if options["CreateAction"]
                                                                       else "ATTACK"),
            "ON CREATE SET "
            "   edge.Name = \"{name}\", ",
            f"  edge.Transitive = {options['Transitive']}, " if options["CreateAction"] else "",
            "   pattern.Name = \"{name}\","
            "   pattern.Depends = \"{depends}\", ",
            "   pattern.Requires = {requires}",

            "WITH DISTINCT source, pattern, options, grants",
            "UNWIND grants AS grant",

            "WITH source, pattern, options, grant[0] AS grant, options[0][0] AS option,",
            "   REDUCE(commands=[], _ IN options[0][1] + grant[1]|",
            "       CASE WHEN _ IN commands THEN commands",
            "       ELSE commands + _ END",
            "   ) AS history",

            "WITH source, pattern, options, grant, option, ",

            cypher_resolve_commands(history=True),

            "WITH DISTINCT pattern, options, grant, option, commands ",
            "MATCH (grant) "
            "MERGE (pattern)-[edge:ATTACK{{Name:'{name}'}}]->(grant)",
            "ON CREATE SET edge.Description = {description},",
            "   edge.Created = True,",
            "   edge.Commands = commands,",
            "   edge.Weight = SIZE(commands),",
            "   edge.Option = ID(option)",

            # Create pattern options
            "WITH pattern, options "
            "UNWIND options AS option "
            "WITH pattern, option[0] AS option, option[1] AS commands "
            "MERGE (pattern)-[edge:OPTION{{Name:'Option'}}]->(option) "
            "ON CREATE SET edge.Weight = SIZE(commands), "
            "   edge.Description = {description}, "
            "   edge.Commands = commands " if (("Depends" in attack and attack["Depends"] != attack["Affects"])
                                               or "Grants" in attack) else "",

            # Used for stats
            "WITH pattern",
            "MATCH (source)-->(pattern)-[edge:ATTACK]->(grant) WHERE edge.Created",
            "OPTIONAL MATCH (pattern)-[:OPTION]->(option)",
            "REMOVE edge.Created",
            "RETURN source, edge, grant, COLLECT(DISTINCT option) AS options "
        ))

        cypher = cypher.format(**strings)

        return cypher

    def compute(self, max_iterations=5):

        converged = 0
        pruned = 0

        db = Neo4j(console=self.console)

        self.console.task("Removing all existing attacks",
                          db.run, args=["MATCH (p) WHERE p:Pattern "
                                        "   OR p.Arn = 'arn:aws:iam::{Account}:policy/Admin' "
                                        "OPTIONAL MATCH (p)-[a:ATTACK|ADMIN]->() "
                                        "DETACH DELETE p "
                                        "RETURN COUNT(a) AS deleted"
                                        ],
                          done="Removed all existing attacks"
                          )

        self.console.task("Creating pseudo Admin",
                          db.run,  args=[
                              "MERGE (admin:Admin:`AWS::Iam::Policy`{"
                              "Name: 'Effective Admin', "
                              "Description: 'Pseudo-Policy representing full and unfettered access.', "
                              "Arn: 'arn:aws:iam::{Account}:policy/Admin', "
                              'Document: \'[{"DefaultVersion": {"Version": "2012-10-17", '
                              '"Statement": [{"Effect": "Allow", "Action": "*", "Resource": "*"'
                              '}]}}]\''
                              '}) '
                              "WITH admin MATCH (r:Resource) "
                              " MERGE (admin)-[access:ADMIN]->(r) "
                              " ON CREATE SET "
                              "     access.Name = 'Admin Access', "
                              "     access.Description = 'Implies all related actions and attacks' "
                          ],
                          done="Created pseudo Admin")

        attacks = [
            (list(self.definitions.keys())[i % len(self.definitions)],  # pattern name
             int(i / len(self.definitions)) + 1,  # iteration
             i % len(self.definitions) + 1  # pattern index
             ) for i in range(max_iterations * len(self.definitions))
        ]

        for (pattern, iteration, i) in self.console.tasklist(
            "Computing attack paths (this search can take a while)",
            attacks,
            done=lambda results: str("Added {count} potential attack paths"
                                     ).format(count=sum([len(s["results"]) if "results" in s else 0
                                                         for s in self.stats]) - pruned),
        ):

            if converged != 0:
                continue

            timestamp = time.time()

            self.console.info(f"Searching for attack ({i:02}/{len(self.definitions):02}): "
                              f"{pattern} (iteration: {iteration} of max: {max_iterations})")

            results = db.run(self.queries[pattern])

            for r in results:
                self.console.debug(f"Added: ({r['source']['Arn']})-->"
                                   f"({r['grant']['Arn']})")

            self.stats.append({
                "pattern": pattern,
                "iteration": iteration,
                "time_elapsed": time.time() - timestamp,
                "results": results
            })

            if (i == len(self.definitions)):  # End of iteration i

                # Check for convergence
                if (sum([len(s["results"]) for s in self.stats[-(len(self.definitions)):]]) == 0):
                    converged = iteration

                # Only retain 'cheapest' paths to Admin, using weight comprised of command length

                redundant = db.run("MATCH shortestPath((admin)-[r:ATTACK|TRANSITIVE*1..]->(:Admin)) "
                                   "    WHERE NOT (admin:Pattern OR admin:Admin) "
                                   "WITH admin MATCH path=(admin)-[r:ATTACK|TRANSITIVE*..]->(:Admin) "
                                   "WITH DISTINCT admin, path, "
                                   "    REDUCE(sum=0, _ IN [_ IN RELATIONSHIPS(path)|"
                                   "        COALESCE(_.Weight, 0)]|sum + _"
                                   "    ) AS weight "
                                   "ORDER BY admin, weight "
                                   "WITH admin, COLLECT([weight, path]) AS paths "
                                   "WITH admin, [attack IN NODES(paths[0][1]) WHERE attack:Pattern] AS cheapest "
                                   "MATCH path=(admin)-[:ATTACK]->(pattern:Pattern) "
                                   "    WHERE NOT pattern IN cheapest "
                                   "WITH pattern MATCH (source)-[attack:ATTACK]->(pattern) "
                                   "MERGE (source)-[redundant:REDUNDANT]->(pattern) "
                                   "    ON CREATE SET redundant = attack "
                                   "DELETE attack "
                                   "WITH pattern MATCH pruned=()-[:REDUNDANT]->(pattern)-[:ATTACK]->() "
                                   "RETURN COUNT(pruned) AS pruned"
                                   )[0]["pruned"]

                if redundant > 0:
                    self.console.debug(f"{redundant} redundant admin paths "
                                       "have been marked for deletion")
                pruned += redundant

                # Achieved convergence: no new attacks were discovered during the iteration
                if converged != 0 or iteration == max_iterations:

                    if converged == 0:

                        self.console.debug("Reached maximum number of iterations "
                                           f"({max_iterations}) - Tidying up")
                    else:
                        self.console.debug("Search converged on iteration: "
                                           f"{iteration} of max: {max_iterations} - Tidying up")

                    # Update attack descriptions
                    db.run("MATCH (:Pattern)-[attack:ATTACK|OPTION|CREATE]->() "
                           "WHERE SIZE(attack.Commands) > 0 "
                           "WITH COLLECT(DISTINCT attack) AS attacks "
                           "UNWIND attacks AS attack "

                           # Construct a lookup table comprising of command, description pairs.
                           "WITH attacks, attack, "
                           "   SIZE(attack.Commands) - SIZE(attack.Description) AS offset "
                           "WITH attacks, offset, attack, "
                           "   [i IN RANGE(0, SIZE(attack.Commands) -1)|[attack.Commands[i], "
                           "       CASE WHEN i - offset < 0 THEN NULL "
                           "       ELSE attack.Description[i - offset] END]] "
                           "   AS lookups "

                           # Remove NULL value command, description pairs
                           "WITH attacks, lookups "
                           "UNWIND lookups AS lookup "
                           "WITH DISTINCT attacks, lookup "
                           "   WHERE lookup[1] IS NOT NULL "
                           "WITH COLLECT(lookup) AS lookups, attacks "
                           "UNWIND attacks AS attack "

                           # Map attack commands to descriptions
                           "WITH attack, lookups, ["
                           "   description IN [command IN attack.Commands|"
                           "       [lookup in lookups WHERE lookup[0] = command][0][1]] "
                           "   WHERE description IS NOT NULL"
                           "] AS descriptions "

                           "SET attack.Descriptions = descriptions "
                           "REMOVE attack.Description"
                           )

                    # Remove redundant attack paths
                    db.run("MATCH ()-[:REDUNDANT]->(pattern:Pattern)-[redundant:ATTACK]->() "
                           "DETACH DELETE pattern "
                           "RETURN COUNT(DISTINCT redundant) AS pruned"
                           )

                    # Remove attacks affecting generic resources
                    pruned += db.run(
                        "MATCH (:Pattern)-[attack:ATTACK]->(:Generic) "
                        "DELETE attack "
                        "WITH COUNT(attack) AS pruned "
                        "OPTIONAL MATCH (p:Pattern) WHERE NOT EXISTS((p)-[:ATTACK|CREATE]->()) "
                        "DETACH DELETE p "
                        "RETURN pruned"
                    )[0]["pruned"]

        db.close()