From 7323bf9db28bad4ea96bc75416b72275bf51d363 Mon Sep 17 00:00:00 2001 From: Anuj Bhandar <14915809+anujbhan@users.noreply.github.com> Date: Tue, 13 Jun 2023 14:06:41 -0700 Subject: [PATCH] initial commit (#1) * initial commit * updates to the README --------- Co-authored-by: Anuj Bhandar Co-authored-by: diophant0x <84159007+diophant0x@users.noreply.github.com> --- .gitignore | 26 ++ .vscode/extensions.json | 3 + .vscode/settings.json | 21 ++ CODE_OF_CONDUCT.md | 32 +++ CONTRIBUTING.md | 83 ++++++ MANIFEST.in | 14 + README.md | 149 ++++++++++ aws/ec2_setup.yaml | 144 ++++++++++ aws/vpc.yaml | 92 +++++++ docs/deployment_patterns.md | 21 ++ docs/images/aws_setup.png | Bin 0 -> 44494 bytes docs/images/deploy_pattern_1.png | Bin 0 -> 18589 bytes docs/images/deploy_pattern_2.png | Bin 0 -> 21398 bytes docs/images/deploy_pattern_3.png | Bin 0 -> 10897 bytes init.sh | 11 + mastko/__init__.py | 6 + mastko/cli/__init__.py | 0 mastko/cli/commands/__init__.py | 0 mastko/cli/commands/bruteforce.py | 64 +++++ mastko/cli/commands/validate_targets.py | 52 ++++ mastko/cli/executer.py | 20 ++ mastko/cli/parser.py | 29 ++ mastko/config/__init__.py | 0 mastko/config/configs.py | 13 + mastko/data/__init__.py | 4 + mastko/data/ec2.py | 8 + mastko/data/eip.py | 7 + mastko/data/host.py | 35 +++ mastko/data/target.py | 154 +++++++++++ mastko/lib/__init__.py | 0 mastko/lib/aws_cidr.py | 89 ++++++ mastko/lib/bruteforcer.py | 114 ++++++++ mastko/lib/ec2_client.py | 124 +++++++++ mastko/lib/exceptions.py | 38 +++ mastko/lib/logger.py | 42 +++ mastko/lib/target_generator.py | 255 ++++++++++++++++++ mastko/mastko.py | 18 ++ mastko/version.py | 1 + requirements.dev.txt | 5 + requirements.txt | 3 + scripts/git-hooks/hooks-wrapper.sh | 11 + scripts/git-hooks/install-hooks.sh | 12 + scripts/git-hooks/pre-push | 7 + setup.py | 18 ++ tests/__init__.py | 0 tests/conftest.py | 24 ++ tests/mastko/cli/commands/test_bruteforce.py | 48 ++++ .../cli/commands/test_validate_targets.py | 42 +++ tests/mastko/cli/test_executer.py | 38 +++ tests/mastko/cli/test_parser.py | 33 +++ tests/mastko/data/__init__.py | 0 tests/mastko/data/test_host.py | 32 +++ tests/mastko/data/test_target.py | 70 +++++ tests/mastko/lib/__init__.py | 0 tests/mastko/lib/test_aws_cidr.py | 72 +++++ tests/mastko/lib/test_bruteforcer.py | 136 ++++++++++ tests/mastko/lib/test_ec2_client.py | 186 +++++++++++++ tests/mastko/lib/test_logger.py | 11 + tests/mastko/test_mastko.py | 23 ++ tests/mastko/test_version.py | 5 + tox.ini | 54 ++++ 61 files changed, 2499 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/extensions.json create mode 100644 .vscode/settings.json create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 MANIFEST.in create mode 100644 README.md create mode 100644 aws/ec2_setup.yaml create mode 100644 aws/vpc.yaml create mode 100644 docs/deployment_patterns.md create mode 100644 docs/images/aws_setup.png create mode 100644 docs/images/deploy_pattern_1.png create mode 100644 docs/images/deploy_pattern_2.png create mode 100644 docs/images/deploy_pattern_3.png create mode 100644 init.sh create mode 100644 mastko/__init__.py create mode 100644 mastko/cli/__init__.py create mode 100644 mastko/cli/commands/__init__.py create mode 100644 mastko/cli/commands/bruteforce.py create mode 100644 mastko/cli/commands/validate_targets.py create mode 100644 mastko/cli/executer.py create mode 100644 mastko/cli/parser.py create mode 100644 mastko/config/__init__.py create mode 100644 mastko/config/configs.py create mode 100644 mastko/data/__init__.py create mode 100644 mastko/data/ec2.py create mode 100644 mastko/data/eip.py create mode 100644 mastko/data/host.py create mode 100644 mastko/data/target.py create mode 100644 mastko/lib/__init__.py create mode 100644 mastko/lib/aws_cidr.py create mode 100644 mastko/lib/bruteforcer.py create mode 100644 mastko/lib/ec2_client.py create mode 100644 mastko/lib/exceptions.py create mode 100644 mastko/lib/logger.py create mode 100644 mastko/lib/target_generator.py create mode 100644 mastko/mastko.py create mode 100644 mastko/version.py create mode 100644 requirements.dev.txt create mode 100644 requirements.txt create mode 100755 scripts/git-hooks/hooks-wrapper.sh create mode 100755 scripts/git-hooks/install-hooks.sh create mode 100755 scripts/git-hooks/pre-push create mode 100644 setup.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/mastko/cli/commands/test_bruteforce.py create mode 100644 tests/mastko/cli/commands/test_validate_targets.py create mode 100644 tests/mastko/cli/test_executer.py create mode 100644 tests/mastko/cli/test_parser.py create mode 100644 tests/mastko/data/__init__.py create mode 100644 tests/mastko/data/test_host.py create mode 100644 tests/mastko/data/test_target.py create mode 100644 tests/mastko/lib/__init__.py create mode 100644 tests/mastko/lib/test_aws_cidr.py create mode 100644 tests/mastko/lib/test_bruteforcer.py create mode 100644 tests/mastko/lib/test_ec2_client.py create mode 100644 tests/mastko/lib/test_logger.py create mode 100644 tests/mastko/test_mastko.py create mode 100644 tests/mastko/test_version.py create mode 100644 tox.ini diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bee97a3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# system files +.DS_Store + +# virtual env dirs +.venv + +# build/setuputils data files +*.zip +*/build/ +build/ +/*.egg-info +__pycache__/ +*/.python-version +temp/ +dist/ +maskto.egg-info/ +*.db +*.csv + +# cache dirs +.coverage* +.pytest_cache +.mypy_cache +.pytest_cache +*.pyc +.tox \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..26f95e8 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["ms-python.black-formatter",] + } \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..8586e83 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,21 @@ +{ + "python.formatting.provider": "none", + "python.linting.enabled": true, + "python.linting.flake8Enabled": true, + "python.linting.flake8Args": [ + "--max-line-length=110" + ], + "python.linting.mypyEnabled": true, + "python.linting.pylintEnabled": false, + "python.testing.pytestEnabled": true, + "python.testing.unittestEnabled": false, + "[python]": { + "editor.defaultFormatter": "ms-python.black-formatter", + "editor.formatOnSave": true, + }, + "black-formatter.args": [ + "--line-length=110", + "--experimental-string-processing" + ], + "black-formatter.importStrategy": "fromEnvironment", +} \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..4eb9364 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,32 @@ +Open source projects are “living.” Contributions in the form of issues and pull requests are welcomed and encouraged. When you contribute, you explicitly say you are part of the community and abide by its Code of Conduct. + +# The Code + +At Intuit, we foster a kind, respectful, harassment-free cooperative community. Our open source community works to: + +- Be kind and respectful; +- Act as a global community; +- Conduct ourselves professionally. + +As members of this community, we will not tolerate behaviors including, but not limited to: + +- Violent threats or language; +- Discriminatory or derogatory jokes or language; +- Public or private harassment of any kind; +- Other conduct considered inappropriate in a professional setting. + +## Reporting Concerns + +If you see someone violating the Code of Conduct please email TechOpenSource@intuit.com + +## Scope + +This code of conduct applies to: + +All repos and communities for Intuit-managed projects, whether or not the text is included in an Intuit-managed project's repository; + +Individuals or teams representing projects in official capacity, such as via official social media channels or at in-person meetups. + +## Attribution + +This Code of Conduct is partly inspired by and based on those of Amazon, CocoaPods, GitHub, Microsoft, thoughtbot, and on the Contributor Covenant version 1.4.1. \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..de0adb3 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,83 @@ +# Contributing + +## Reporting Issues + +Please use GitHub issues to report any conerns about the code. + +## Contributions + +All code contributions should be submitted through GitHub pull requests. + +## Developing Locally + +```shell +$ git clone path_to_git +$ cd mastko +$ sh init.sh +``` + +This will create a python virtual environment using `venv`, install dependencies and runs `tox`. + +## Code Quality + +`tox` is currently configured to run `black`, `flake8`, `isort`, `mypy`, `pytest-cov`, and `pytest` in addition to the unit tests by default. You can run all these tools by running the tox command: + +``` +tox +``` + +Run only `mypy` command as specified in tox.ini file: + +``` +tox run -e mypy +``` + +Run only `flake8` command as specified in tox.ini file: + +``` +tox run -e flake8 +``` + +## Code Formatting + +Use the `black` and `isort` auto-formatter with line-length set to 160 to ensure consistent code formatting. This will run only `black` and `isort`. + +``` +tox run -e autoformat +``` + +Run only `isort` command as specified in tox.ini file: + +``` +tox run -e isort +``` + +Run only `black` command as specified in tox.ini file: + +``` +tox run -e black +``` + +## Code Coverage and Unit Tests + +We are using the `pytest` and `pytest-cov` libraries for unit tests and code coverage analysis. Please add +sufficient code coverage for all new/modified functionality and verify that all tests are passing via `tox`. + +To run all the unit tests and code coverage tool: + +``` +tox run -e py39 +``` + +To run individual test files: + +``` +tox run -e py39 -- tests/stand/cli/test_get_args.py +``` + +To run individual test functions: + +``` +tox run -e py39 -- tests/stand/cli/test_get_args.py::test_getArgValue_withWrappedValue_shouldReturnValue +``` + diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..dd4460e --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,14 @@ +include README.md +include LICENSE.md +include MANIFEST.in + +include requirements.txt +include requirement.dev.txt +include tox.ini + +graft aws +graft docs +graft mastko + +global-exclude __pycache__ +global-exclude *.py[co] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..f64cc68 --- /dev/null +++ b/README.md @@ -0,0 +1,149 @@ +``` +█▀▄▀█ ▄▀█ █▀ ▀█▀ █▄▀ █▀█ +█░▀░█ █▀█ ▄█ ░█░ █░█ █▄█ +``` +--- +MasTKO is a security tool which detects DNS entries associated with AWS’s EC2 servers susceptible to takeover attack and attempts a takeover. This tool aims to help development teams detect DNS hygiene issues and proactively fix them. + +## The Vulnerability + +Any AWS customer may request an EC2 instance with a public IP sourced from a pool of AWS EC2 IPs that is shared within the various availability zones in a AWS region. If the EC2 IP changes but the DNS mapping to the old IP remains, then the old IP goes back into the shared AWS IP pool and becomes available for anyone else to pick up. Domains mapped to such IPs can then be a target for MasTKO to attempt take over. + +## How Does it Work + +MasTKO is a CLI with two subcommands, `validate_targets` and `bruteforce`. + +``` +$ mastko --help +usage: mastko [-h] [--version] {bruteforce,validate_targets} ... + +optional arguments: + -h, --help show this help message and exit + --version print out cli version + +subcommands: + Please select a subcommand. For more information run `mastko {sub-command} --help`. + + {bruteforce,validate_targets} + sub-commands: + bruteforce runs subdomain takeover bruteforce service + validate_targets validates and loads targets to DB +``` + +### validate_targets + +This subcommand takes an input DNS hosts as a newline separate file and performs the initial recon to collect contextual details for the provided host and checks if the domains are vulnerable to EC2 subdomain takeover. The output is given two ways, as CSV file to users and also loaded to internal DB to facilitate Bruteforcing. + +``` +$ mastko validate_targets --help +usage: mastko validate_targets [-h] --hosts-file hosts_file [--region region] + +options: + -h, --help show this help message and exit + --hosts-file hosts_file + A newline delimited file with all interested domains. + --region region Filter target for a given AWS Region. +``` + +### bruteforce + +#### prequisites: + +* In order to run this command, a dedicated AWS Elastic IP and AWS EC2 instance is required. Refer to [AWS Setup](#aws-setup) section for more details. +* Requires AWS CLI credentials, please refer to: [Configuration and credential file settings](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-files.html) for more details. If you are running MasTKO inside the EC2 Instance created by our cloudformation ([AWS Setup](#aws-setup)), the AWS boto3 SDK will automatically fetch credentials from the attached instance role, no need to set user credentials. +* This subcommand should be run after running `validate_targets` as it is a pre-requisite. + +#### bruteforcing + +``` +$ mastko bruteforce --help +usage: mastko bruteforce [-h] --iterations iterations --elastic-ip eip_ip --ec2-instance-id instance_id --region region + +optional arguments: + -h, --help show this help message and exit + --iterations iterations + Specify the number of bruteforce iterations + --elastic-ip eip_ip Specify the Elastic IP to use for bruteforcing + --ec2-instance-id instance_id + Specify the EC2 instance-id to use for bruteforcing + --region region Specify the AWS region to use for bruteforcing +``` + +The bruteforce service performs the following tasks: + +1. Associating and immediately disassociating an Elastic IP to EC2 instance changes the public IP assigned to this instance. This rotation is done for specified number of `iterations` +2. Checking if a vulnerable IP is acquired + +As a result, if a vulnerable IP has been acquired through brute forcing, we end up with complete control over the identified DNS record. + +## Deployment + +MasTKO is very versatile when it comes to deployment, at the bare minimum, this tool will require a Elastic IP and EC2 instance to get it up and running. Refer [AWS Setup](#aws-setup) section for more details. + +There are a variety of deployment patterns for MasTKO. [Common Deployment Patterns](/docs/deployment_patterns.md) document covers a few patterns. + +### AWS Setup + +![AWS Setup](docs/images/aws_setup.png) + +1. MasTKO requires EC2 deployed in public subnet of an AWS VPC. Use the AWS Cloudformation [vpc.yaml](aws/vpc.yaml) to deploy a bare minimum AWS VPC to run MasTKO. This cloudformation outputs the VPC ID and Subnet ID after creation, which will be required for next step. This step is optional if a VPC is already available. +2. For the bruteforcing function to work, masTKO requires a AWS EC2 host and a Elastic IP. Use the AWS Cloudformation [ec2_setup.yaml](aws/ec2_setup.yaml). This cloudformation template will output the EC2 Instance Id and the Elastic IP, these values are required for running the `mastko bruteforce` command. The AWS EC2 Instance deployed using the provided Cloudformation template has IAM permissions attached to run the bruteforce function and also AWS SSM permissions to connect to that instance using Session Manager. + +#### AWS References: +1. To deploy a stack using AWS Cloudformation, follow step 3 in [Getting Started with AWS Cloudformation](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/GettingStarted.Walkthrough.html) +2. [AWS VPC Documentation](https://docs.aws.amazon.com/vpc/latest/userguide/what-is-amazon-vpc.html) +3. [Connect to EC2 using Session Manager](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/session-manager.html) + +## AWS Policy on Penetration Testing + +AWS provides a guideline on the kind of security testing that is allowed on its platform. Although not explicitly stated, running tools like MasTKO should be done with utmost care. Please refer to [AWS Penetration Testing Policies](https://aws.amazon.com/security/penetration-testing/) for more details. + +## Installation + +MasTKO requires python >= v3.9 and it has been tested to work on v3.9.10 + +### Options 1: + +``` +python3 -m pip install --upgrade "mastko[all] @ git+ssh://git@github.com/intuit/mastko.git" +``` +or + +``` +python3 -m pip install --upgrade "mastko[all] @ git+https://github.com/intuit/mastko.git" +``` +then + +``` +mastko --help +``` + +### Option 2: + +``` +git clone git@github.com:intuit/mastko.git +cd mastko +python3 -m pip install . +mastko --help +``` + +## Contributing + +Follow the contribution guidelines documented in [CONTRIBUTING.md](/CONTRIBUTING.md) + +## LICENSE + +This project is licensed under the terms of the [Apache 2.0 license](/LICENSE.md). + +## External Dependencies + +MasTKO requires that the following open source software to be installed to function: + +1. [masscan](https://github.com/robertdavidgraham/masscan) licensed under [GNU Affero General Public License version 3](https://github.com/robertdavidgraham/masscan/blob/master/LICENSE) +2. [nmap](https://nmap.org/) licensed under [Nmap Public Source License (based on GNU GPLv2)](https://nmap.org/npsl/) + +## Contributors + +- [@anujbhan](https://github.com/anujbhan) +- [@diophant0x](https://github.com/diophant0x) +- [@Steeeeeeven](https://github.com/Steeeeeeven) diff --git a/aws/ec2_setup.yaml b/aws/ec2_setup.yaml new file mode 100644 index 0000000..5f3181a --- /dev/null +++ b/aws/ec2_setup.yaml @@ -0,0 +1,144 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: Deploy's EC2 instance and Elastic IP to use with masTKO +Parameters: + InstanceName: + Type: String + Description: A unique name given to the instance + InstanceType: + Type: String + Description: EC2 Instance type to use. The default EC2 AMI requires a ARM64 based processor, please choose a compatible EC2 Instance Type. Refer https://aws.amazon.com/ec2/instance-types/ for details. + VpcId: + Type: AWS::EC2::VPC::Id + Description: AWS VPC id to use for deployment. + SubnetId: + Type: AWS::EC2::Subnet::Id + Description: AWS VPC Public Subnet ID to place the instance. + Ec2ImageId: + Type: AWS::SSM::Parameter::Value + Description: defaults to latest Amazon Linux 2, change only if necessary + Default: /aws/service/ami-amazon-linux-latest/amzn2-ami-hvm-arm64-gp2 + SshPublicKey: + Type: String + Description: public key to setup SSH access to Ec2 Instance. The access will be through AWS Systems Manager (Session Manger) plugin. Refer https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/session-manager.html +Resources: + Ec2InstanceRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Effect: Allow + Principal: + Service: + - ec2.amazonaws.com + Action: + - 'sts:AssumeRole' + Path: / + Policies: + - PolicyName: !Sub 'mastko-ssm-login-permissions-for-${InstanceName}' + PolicyDocument: + Statement: + - Action: + - "ssm:UpdateInstanceInformation" + - "ssmmessages:CreateControlChannel" + - "ssmmessages:CreateDataChannel" + - "ssmmessages:OpenControlChannel" + - "ssmmessages:OpenDataChannel" + Effect: "Allow" + Resource: "*" + - Action: + - "s3:GetEncryptionConfiguration" + Effect: "Allow" + Resource: "*" + - Action: + - "kms:Decrypt" + Effect: "Allow" + Resource: "*" + - PolicyName: !Sub 'mastko-bruteforce-permissions-for-${InstanceName}' + PolicyDocument: + Statement: + - Action: + - "ec2:DisassociateAddress" + - "ec2:DescribeAddresses" + - "ec2:DescribeInstances" + - "ec2:CreateTags" + - "ec2:AssociateAddress" + Effect: "Allow" + Resource: "*" + + Ec2InstanceProfile: + Type: AWS::IAM::InstanceProfile + Properties: + Roles: + - !Ref Ec2InstanceRole + + Ec2InstanceSecurityGroup: + Type: AWS::EC2::SecurityGroup + Properties: + GroupDescription: !Sub "In/out traffic for mastko ${InstanceName}" + GroupName: !Sub "mastko-${InstanceName}-sg" + VpcId: !Ref VpcId + SecurityGroupEgress: + - IpProtocol: tcp + FromPort: 0 + ToPort: 65535 + CidrIp: 0.0.0.0/0 + + Ec2Instance: + Type: AWS::EC2::Instance + Properties: + ImageId: !Ref Ec2ImageId + InstanceType: !Ref InstanceType + IamInstanceProfile: !Ref Ec2InstanceProfile + Tags: + - Key: Name + Value: !Ref InstanceName + NetworkInterfaces: + - DeviceIndex: "0" + AssociatePublicIpAddress: "true" + SubnetId: !Ref SubnetId + GroupSet: + - !GetAtt Ec2InstanceSecurityGroup.GroupId + UserData: + Fn::Base64: !Sub | + #!/bin/bash + + echo ${SshPublicKey} >> /home/ec2-user/.ssh/authorized_keys + + # set up python environment + yum -y groupinstall "Development Tools" + yum -y install openssl-devel bzip2-devel libffi-devel sqlite-devel libpcap-devel + yum -y install wget + cd /opt + wget https://www.python.org/ftp/python/3.9.10/Python-3.9.10.tgz + tar xvf Python-3.9.10.tgz + cd Python-3.9.10 + ./configure --enable-optimizations + make altinstall + + yum install -y nmap git + + export WORKDIR=/opt + + # Install masscan + cd $WORKDIR + git clone https://github.com/robertdavidgraham/masscan.git + cd masscan + make + make install + + # Install MasTKO + cd $WORKDIR + git clone https://github.com/intuit/mastko.git + cd mastko + python3 -m pip install . + EIP: + Type: AWS::EC2::EIP + Properties: + Domain: vpc + + +Outputs: + InstanceId: + Value: !Ref Ec2Instance + EIP: + Value: !Ref EIP diff --git a/aws/vpc.yaml b/aws/vpc.yaml new file mode 100644 index 0000000..e66a90f --- /dev/null +++ b/aws/vpc.yaml @@ -0,0 +1,92 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: Create a simple vpc to be used with masTKO +Parameters: + vpcName: + Type: String + Default: mastko-vpc + Description: Unique name given to vpc components + IPV4CIDR: + Type: String + Default: "10.0.0.0/22" + Description: IPV4 CIDR block to be used for VPC. +Resources: + VPC: + Type: AWS::EC2::VPC + Properties: + CidrBlock: !Ref IPV4CIDR + InstanceTenancy: "default" + EnableDnsHostnames: "true" + EnableDnsSupport: "true" + Tags: + - Key: Name + Value: !Ref vpcName + IGW: + Type: AWS::EC2::InternetGateway + Properties: + Tags: + - Key: Name + Value: !Sub "${vpcName}-igw" + VpcIgwAttachment: + Type: AWS::EC2::VPCGatewayAttachment + Properties: + VpcId: !Ref VPC + InternetGatewayId: !Ref IGW + PublicSubnet: + Type: AWS::EC2::Subnet + Properties: + VpcId: !Ref VPC + CidrBlock: !Ref IPV4CIDR + Tags: + - Key: Name + Value: !Sub ${vpcName}-public-subnet + PublicRouteTable: + DependsOn: + - IGW + - VpcIgwAttachment + Type: AWS::EC2::RouteTable + Properties: + VpcId: !Ref VPC + Tags: + - Key: Name + Value: !Sub ${vpcName}-rtb + PublicRoute: + Type: AWS::EC2::Route + Properties: + DestinationCidrBlock: "0.0.0.0/0" + GatewayId: !Ref IGW + RouteTableId: !Ref PublicRouteTable + SubnetRouteTableAssociation: + Type: AWS::EC2::SubnetRouteTableAssociation + Properties: + RouteTableId: !Ref PublicRouteTable + SubnetId: !Ref PublicSubnet + # required to setup the Ec2 access through systems manager + SsmVpcEndpoint: + Type: AWS::EC2::VPCEndpoint + Properties: + ServiceName: !Sub "com.amazonaws.${AWS::Region}.ssm" + VpcId: !Ref VPC + VpcEndpointType: Interface + SubnetIds: + - !Ref PublicSubnet + SsmMessagesVpcEndpoint: + Type: AWS::EC2::VPCEndpoint + Properties: + ServiceName: !Sub "com.amazonaws.${AWS::Region}.ssmmessages" + VpcId: !Ref VPC + VpcEndpointType: Interface + SubnetIds: + - !Ref PublicSubnet + Ec2MessagesEndpoint: + Type: AWS::EC2::VPCEndpoint + Properties: + ServiceName: !Sub "com.amazonaws.${AWS::Region}.ec2messages" + VpcId: !Ref VPC + VpcEndpointType: Interface + SubnetIds: + - !Ref PublicSubnet +Outputs: + VpcId: + Value: !Ref VPC + SubnetId: + Value: !Ref PublicSubnet diff --git a/docs/deployment_patterns.md b/docs/deployment_patterns.md new file mode 100644 index 0000000..6e07324 --- /dev/null +++ b/docs/deployment_patterns.md @@ -0,0 +1,21 @@ +# Deployment Patterns for MasTKO + +Although there are numerous ways in which MasTKO can be deployed, we have only presented a few below. All the patterns mentioned below require the AWS EC2 instances used by the brute force function to be deployed in a public subnet of AWS VPC with auto-assigned public IPs. Refer to [AWS Setup](/README.md#aws-setup) for more details. + +## Pattern 1 (Recommended): + +![deploy_pattern_1](images/deploy_pattern_1.png) + +Run 1..n instances of MasTKO inside a AWS EC2 instance, each instance of MasTKO will require a unique AWS EC2 Instance Id and a unique AWS Elastic IP. This pattern is beneficial to run parallel instances of MasTKO to achieve greater probability of takeovers but will also increase cost. An additional benefit, versus [pattern 2](#pattern-2), is it removes dependency on a local machine, and theoretically will be faster because of reduced network path. + +## Pattern 2: + +![deploy_pattern_2](images/deploy_pattern_2.png) + +Run 1..n instances of MasTKO inside your local machine, each instance of MasTKO will require a unique AWS EC2 Instance Id and a unique AWS Elastic IP. This pattern is beneficial to run parallel instances of MasTKO to achieve greater probability of takeovers but will also increase cost. + +## Pattern 3: + +![deploy_pattern_3](images/deploy_pattern_3.png) + +Run MasTKO inside a AWS EC2 instance and let it bruteforce against itself. The tool will stop once it encounters a targeted (DNS, IP) pair or it reaches the max number of iterations. Disclaimer, during our testing, this deployment pattern has proven to the slowest, due to intermittent errors from AWS EC2 api calls resulting in errors. diff --git a/docs/images/aws_setup.png b/docs/images/aws_setup.png new file mode 100644 index 0000000000000000000000000000000000000000..cf7100ce1a1fc98ad5e5c5113764108c75d652c1 GIT binary patch literal 44494 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 literal 0 HcmV?d00001 diff --git a/docs/images/deploy_pattern_3.png b/docs/images/deploy_pattern_3.png new file mode 100644 index 0000000000000000000000000000000000000000..579e5fc618f9fc8ff362d27e1fbb852ae7c42fea GIT binary patch literal 10897 zcmeHtcU05a_AicC9UIJy5)}!|j8aAji1e-^A|fD2O(+rs5=cO*5D1wOfr}9sML-Be zK$?VLLI5EG0~(Pg9Ri_<5PIk&gcM%DdH23q@7~}0fMMaY#f&^=VNUf~F~;coe>cUJ})9NcUwe6=b4DeiMts!R(isT_?tIujJLP91w8Hs{lk3uLwe9JG|yvW zzv(k@9*cFjt@-iK305o3CH&3xnWE6O?i%ju{N`leAOgt9@Engb9S%Fc(u|)c#3!b;zEwDryRj`IBLBhk$|al7A?CPyvUxZ zbu!5EaU1#rKKnz zFBoJK0u$@Pwz`80Rt8^3<1l&LrInSiXFS`n;9B739heGvj^28EIe@-l_vuy+wUe|I z)ety9|J5=YU_{S45GC#Gx7Xkp_(*BE#G9bngPT{R;40+B;PbaVkN8RnrI#ZAU;yr_ zzMyv2=@Ke`%`PLK9AfLdUP2vP4tt$^62wa5ojg)A`nL!v@=3)`=&}*E;TzC?!+${p z7KaNq)}}jnC5Dj#o+|fqN4DYihZ8c1{1U-QP|OjbRBFw1$EtR{ukWe>H!N^X!S$&g z%Zsl9K5`0tBoe0L@SOT777(@XUlY@J!uf6dWIegp(-K z>!Scs(%>Tin961j_4aT7AZCrQ{LqL99xEq1B7%LHr%FD_lYH7PkS(h>7>06jb%K=~ zK)94>>=@e>V_GN5SWl0*2Z>}qi(tENP*7m2Kw}~y5dUbmF zhhxi}w`-krg;pj2AM7Y^!CwytFKB>L%D8@!XI*3a!%XYKLG|I+ycNPf-eQQ?)oIwV zLtmjgsE73>xz%*H>V=K{yLy)bxYjzJn%N;6a6`1xjwQRI+BcjdJ+%wo5?OJj>ITW0kriw-x}?8d8~jD%j(wl&FYTa>d6 z&Ia^GYAmR8NYBXEb>@6H7em@ups}r+PP5w6?4d0SvO`GQhEpW>CSGQ?(#-)gfKeTY zUx|_~22ji_C0tu#s!MyVt@Xy<-fIrb@?A|iR2!@-IofCWizS(6l(!heVV5PxzwuJ$ z=|7U@$yYcGfy-w7Z~9MdT4&K@#PrdJ3&Yr9J^7#wM%y8w2%0gh2H}_q3>Xs=K1A3D zm(B2#hMQL2#HBFGaOxU&tQBzmCG#h3DY|7+e$pMsB{go*cqkTK-H9%iV>OPw-E2s- z#-hzj&gqWRr329lm<1*N=bX*O>Srv=!p4|F{pmG(Yxt17XBtFSC)vDdPaHfRR!X>C?&7fgI3OjJ=+QMs-i z=BRyc%5=)?4c>^k_MqUIWs<&nnXh^J^u|v}5?8t+Uv8TEF;m%K^cJ)}5fGJqD|pg? zvON@vioBlcs>cG8htONjj5$I2`ra!WQTPcE>%3MKR$nscCxc&wxJv_s!}ZYHdzhC{ z9<)IJ<&7t-fz(?|Dx>OUiCXAdS~0wge&RY_T$sE?WLni!58F67Q(qL@aVyf&lC}7- zRXM%xNaM7UY7;~eUA(m}a9^prs3XIAH=+pMG5R`If57+JV?etPktgF6wRMJ1cvHDe zPmOh}2#~^+1$;hsO*uY_Nar6&C$)CS6YYeRG_M=#I^Sf%DE05AJL$zV)yYnWdldE= z9?7x{?bDgLg3SkV0wCc;{D~e7?)A8e`$Ds$NV8J{sH=qKw}JMq<&Xq%KZ$~SwciGA zn(~KZtB&=YDQ34}8NqCutniejLoe>hyIbIaJBZZ%md?E`tX;F^pnjgs&9En_!8iEt zp*cMyw)zt5o-Qxa5ax0|B@@~|0`QR@Hhd%N!(Wl9y?jPD{!P0bAc$`t*e3*J7mT?4IK@> zqs>bfb)I##82rddkMzd@{gPXIDp7|l!+~}#<34LL7L~O2cJ^{ZK6jdzU*7{|x@~@% zwgGZJF4iB36^q5L-fD@Ryf$Cq#H(e^(|$0_Dj#F51aIEe$`9Ve4aYE&>d;}Q3bKC| z(|ott9qK)RQMZp5#yuWjH1= zyA%3K81lam9cchAy9~29Mm}jRAw2${UD2A&V-J*}J^0fDWjsxWU}b&) z!=kc;zw?P*&<{fEg^hX02#c2e*Si13kpFMOvRlc*mmXNU?@D&FCtttboYlioBx8i}1$IJDoiY4uU9A8+Xz|pSLB6^~SXQEM$kr`vXNTX_<4AY! z-%CC@>ZbkCTfSzJc_XeCWWTGQQo70DhHFV{$0G|{hsceSyE~dqHGkia5)cZndYxN{ zAvf%=Zj>>NLymEJ-vUls7UYPS?x^W>9y>7@o_!i<0o5b&7IF6KGkGN6@^|6GFj0 z*^?er3oQIFb*Q?i9l-g!Wf!#hHyWKhAKzJd{XI-&38j#b)Gi+24sIJgn{dzBFRCto zLB3eXZhHfphNG^K=8>=WH^Zx@eyM^HUTNl07iy_kg66nRk?2zxVZFVOVtm{$w;7+I zKwAu<+(~pT&1(-OmMn4e7&;PuxyGR^^VJ2%RU@qYxlvvkD7R`81U@lV7Q%TkN5~k@ z_Pdv~@XJPpTv-O*^MbxM@^Nj1q%mdLZ&op!gJYf@9D<=y)Q2v5VCbJz6Ol zr+V6w>$(ae4xnzS8EqL1C}rx?>Q#Y)#_4e+&wub>vu0PjP6Bo?#`!fEp2VhUylJmR z)_EWAuZwT&o`dAjs_r&F|p@XT6@{T26VeUQVoEQBxrJyO<1$U8T&zG@2_JcLnN@3`B28T5`Xv zYdYbK_SJB@4Ce2My_Ola8}(ETV27T|WB6k@D81Uc>Y&`CwJOrdkiKKh!C_aDl{>bN zwu)lkb5y&_o>&iMH%Ipk<2Ax$-fb*WmBxNjfi^JO;(Z8H1k` zkKf9tdiEvt;_ppy`yOH67Fn%Yx8HkAVk&f~rZQgU&9&OQ5NB1%4g-+8`f5$rRqa1} z*M9fV%|7i6-O{_AjWw!xk(nYLX!~*T3AJ^E5Yerd5pM^^BU;<=Gta%NMjnYy&!qOH zHoFGf;KTIF-asRGVrJN%Iwcj_o!}60z70o%54F#BmRLG0|Ip`zEdeW0EOl7@fyF^k zzhe4&=z0Ejft`D-Y@ep=n$bIF;#4qGJkjz|7xdd*ZH5Zsd&*YoQbUZ1lMlZc>Mb0; zNBzmxNw@N)c0ZK9HB=r)Uf)jIjEJwLR!E&oJd!uGhoNQAXGcEyO*^r>ExFP;kNU6x zuBhz&v+mTP+?L|oe2K=skm`h77{>VudW7@KWC1CwT`&WG>gvg=H1Kctaat-w5+7S3 z!>(Y*fnSseiPv1WAk%cpN^B#F5X2Vn)cjW9+`O+#Zc3|ETFRJG0&qyZh13sq(+0(` zzt~P!mnA=pE89|wTp=?bv$VURJyYnbZ)=pvw03Vt7hB38Q1bU(p$)^mpcu11ciP~r z-fDvO0qA`mwKq#rc~7=sHJJm_FRN$}cYQ_#59WM)5K{ggMl@4n-!M;i&ref8(l?#g zpx6i(1ik37H8Ko*8OIzq+5qUX!|MAkY`>bSA-++eSYm;kV zHKbhN{{PIUI7mOL_)P?PwRj2lW|$;b>fPj6vJ(2efr+Cc&^BAO^v$+IwMIo`qYhSc z-b(Ob)%_{QMB{W5bi<>Eqy#4}%PpV|6Jna<|uXg12}%+HbU=ozGgx~<0bo2e^Q5MnSHaEY^}PZ*OEiQ z$Wz;)0Ns%o`^>Sn!g+uH+bQiuwM-JSIlFV1hUdR8pENcZG=?X0v0Z=NYmKjO&9;BJ zsO_?>3;#I^N2z z!)HqwTSLfMEV3N~gBN8flk zo!WPp*Lr%X^eGk0NK@lZCW8G^_{OTpea z^!o;tXG}6Hzg@a;HLgX~j&rn-U9WW~O%R!nUrK@Rt7T#k6!9|`&R7H6s+#;(`W#RTjwQ^H z@bOecya8*z1*Y=1op_YYlOK6t_VN6l|CzXio)iG(qAD^yyg+tW+u&TPQr8XKR93O+ z4j6(nci_}hQ0?X2y;HZG<-iL+w3FeBY3aMD9Ebx@uKZWxcGTLUaN&c1U0v^sf{*;R zlVgW;{!~aoc(nWiq32Ownf}MPV4>EN@^0a_e@#%;)`yw|2c~pMsoztB3#Ceq?fq7s znwe$^wiLC%eLylT+}IyF?4)C!Dwt4d>kobxvh5Iz&hsaID($u_H{USHWeEf@6(?a{ z)~-&$r5>5%A$Uhm=eTV^?ed>X9 z3(z8~Ac(ek??_7%#`)yR!GoS>|G8c=*-Xte9bbb;R{KyhXtc&;@tdxCK@b8vWg^ZP z?6@J7Y0^|MuOn3wvWo`c!Vm=R_|@Pa&LLmHD`M>^{7EjP)ZQ)$A?j0}CFMBZ4A7M) z_7Nuth;h2Pg3zu!6%fk;jvYA*tRN_wSu6wS*TuvGmmEE!y>T{x4$*$pORDH~MU2t< z?Qi<=Zn!Wn+uHz2}>m{k{D-e`6DibI})zQ5U6=tgT->rsBN;0CisA(RW?>8rE2mq2-gkW9QI@aAHfSsM*ui`*%{5jvyL7v2;1uSP zWa7FNva|`Th-(^COQ3|vjXu%~e$+p*yW7oGKs~<)av|e+T4e}CQUBTy<`5jm?qzM> zu^ogCBKn&pbVs4w4nQrCusXvVSVBkqaT`A*JZi|Fitms-$2*e__o=29FnGMP+jFm| zSLyFwWbA46i}C*~r6at#|7iF_r`DihA9Sr>sp2{HZvNFlTT2lBnko`h<$#h(b5&IN zc%m1KIBk&UTk=HF`lg#?tR8Gt`RAHQ(me*HiZI zEvbYcY7KjEOs@~;KpL)1>Ri#G6wRJN$zjVC;WfUYHr(ZGxHlks5dGs|hsDUbf)hih z@^Y!&3Ub{&Ra8M%Tn@E>8%2WWK*S*EWAomkvRuXVne1@cU_ zQSdLSXZSdu4hsVFgCaxR}HZOr3wcZYpUa< zUU=;DE&Aok2`%6GIz95q=V=;IfL7(hbo+L~<^^3|OJY)Y$D#5^gkZR zSDw`oD=8WCOuyUu&Xyrz@X~&(Et~?@M8e0}*&!ea^P^fseL5%?#se5P8MqSu>FWP| zWD(!-`&Z(t55DF>+gB_I&vXB=Jn+|>LmeYfQvh$hgf-G%kGVr>j0)d*YJyE2;=f3% zG}W8k8B55YKY7Y+l{BXe8uTdqv`m!Os04WuConugSLts`k_dcKl=@i3aNJ@ZyxC!bjEaXHPeLG7-Mf z7Rd=Qd_9jky9MRKw6;1dx7r;GSXsULejW%u^^J|BoGl;7E_aAUEXQe=jp?=TN=l OB2#0FD}>9p@Bc4jmHz4g literal 0 HcmV?d00001 diff --git a/init.sh b/init.sh new file mode 100644 index 0000000..ec492f1 --- /dev/null +++ b/init.sh @@ -0,0 +1,11 @@ +python3 -m venv .venv + +source ./.venv/bin/activate + +python3 -m pip install --upgrade pip + +pip3 install -r requirements.txt -r requirements.dev.txt + +./scripts/git-hooks/install-hooks.sh + +tox diff --git a/mastko/__init__.py b/mastko/__init__.py new file mode 100644 index 0000000..e516444 --- /dev/null +++ b/mastko/__init__.py @@ -0,0 +1,6 @@ +from pathlib import Path + +from mastko.config.configs import Configs + +# create cache folder +Path.mkdir(Configs.mastko_cache_location, exist_ok=True) diff --git a/mastko/cli/__init__.py b/mastko/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mastko/cli/commands/__init__.py b/mastko/cli/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mastko/cli/commands/bruteforce.py b/mastko/cli/commands/bruteforce.py new file mode 100644 index 0000000..18c4b43 --- /dev/null +++ b/mastko/cli/commands/bruteforce.py @@ -0,0 +1,64 @@ +from argparse import ArgumentParser, Namespace, _SubParsersAction + +from mastko.data.target import Target +from mastko.lib.bruteforcer import Bruteforcer +from mastko.lib.exceptions import BruteforceCommandException +from mastko.lib.logger import get_logger + +log = get_logger("mastko.cli.commands.bruteforce") + + +def bruteforce_parser(subparser: _SubParsersAction) -> None: + bruteforce_parser: ArgumentParser = subparser.add_parser( + name="bruteforce", help="runs subdomain takeover bruteforce service" + ) + bruteforce_parser.add_argument( + "--iterations", + help="Specify the number of bruteforce iterations", + metavar="iterations", + dest="iterations", + type=int, + required=True, + ) + bruteforce_parser.add_argument( + "--elastic-ip", + help="Specify the Elastic IP to use for bruteforcing", + metavar="eip_ip", + dest="eip_ip", + type=str, + required=True, + ) + bruteforce_parser.add_argument( + "--ec2-instance-id", + help="Specify the EC2 instance-id to use for bruteforcing", + metavar="instance_id", + dest="instance_id", + type=str, + required=True, + ) + bruteforce_parser.add_argument( + "--region", + help="Specify the AWS region to use for bruteforcing", + metavar="region", + dest="region", + type=str, + required=True, + ) + + +def bruteforce_executer(args: Namespace) -> None: + log.info(f"Initiating bruteforce for {args.iterations} iterations") + + if not Target.target_available(): + raise BruteforceCommandException( + "Prequisite for bruteforce not met, there are no targets available in DB. " + "Please run `mastko validate_targets --help` for more infromation." + ) + + bruteforcer = Bruteforcer( + targets=Target.get_all_targets_from_db(), + region=args.region, + instance_id=args.instance_id, + eip_ip=args.eip_ip, + ) + bruteforcer.run(iterations=args.iterations) diff --git a/mastko/cli/commands/validate_targets.py b/mastko/cli/commands/validate_targets.py new file mode 100644 index 0000000..3fa6e67 --- /dev/null +++ b/mastko/cli/commands/validate_targets.py @@ -0,0 +1,52 @@ +from argparse import ArgumentParser, Namespace, _SubParsersAction + +from mastko.data.target import Target +from mastko.lib.logger import get_logger +from mastko.lib.target_generator import TargetGenerator + +log = get_logger("mastko.cli.commands.validate_targets") + + +def validate_targets_parser(subparser: _SubParsersAction) -> None: + validate_targets_parser: ArgumentParser = subparser.add_parser( + name="validate_targets", help="validates and loads targets to DB" + ) + validate_targets_parser.add_argument( + "--hosts-file", + help="A newline delimited file with all interested domains.", + metavar="hosts_file", + dest="hosts_file", + type=str, + required=True, + ) + validate_targets_parser.add_argument( + "--region", + help="Filter target for a given AWS Region.", + metavar="region", + dest="region", + type=str, + required=False, + ) + + +def validate_targets_executer(args: Namespace) -> None: + generator = TargetGenerator(hosts_file=args.hosts_file, region=args.region) + targets = generator.generate() + filtered_targets = [] + if args.region is not None: + filtered_targets = generator.filter_targets_to_aws_region(targets) + else: + filtered_targets = targets + + if len(filtered_targets) > 0: + generator.load_targets(filtered_targets) + output_file_name = "mastko_targets.csv" + Target.to_csv(targets, output_file_name) + log.info( + f"Target Validation Complete. Targets loaded to Database and also written to {output_file_name}" + ) + else: + log.warning( + "NO TARGETS FOUND for given criteria. Please rerun with more Hosts or remove the AWS region" + " filter." + ) diff --git a/mastko/cli/executer.py b/mastko/cli/executer.py new file mode 100644 index 0000000..dc86f6b --- /dev/null +++ b/mastko/cli/executer.py @@ -0,0 +1,20 @@ +from argparse import ArgumentParser + +from mastko.cli.commands.bruteforce import bruteforce_executer +from mastko.cli.commands.validate_targets import validate_targets_executer +from mastko.lib.logger import get_logger + +log = get_logger("mastko.cli.executer") + + +def cmd_executer(parser: ArgumentParser) -> None: + try: + args = parser.parse_args() + if args.command == "bruteforce": + bruteforce_executer(args) + + if args.command == "validate_targets": + validate_targets_executer(args) + except Exception as ex: + log.error(ex) + parser.exit(1) diff --git a/mastko/cli/parser.py b/mastko/cli/parser.py new file mode 100644 index 0000000..005effb --- /dev/null +++ b/mastko/cli/parser.py @@ -0,0 +1,29 @@ +import sys +from argparse import ArgumentParser, _SubParsersAction + +from mastko.cli.commands.bruteforce import bruteforce_parser +from mastko.cli.commands.validate_targets import validate_targets_parser +from mastko.version import __version__ + + +def get_parser() -> ArgumentParser: + parser = ArgumentParser(prog="mastko") + + subparsers: _SubParsersAction = parser.add_subparsers( + description="Please select a subcommand. For more information run `mastko {sub-command} --help`.", + help="sub-commands:", + dest="command", + ) + + bruteforce_parser(subparsers) + validate_targets_parser(subparsers) + + # global + parser.add_argument( + "--version", help="print out cli version", action="version", version=f"v{__version__}" + ) + + # handle no args + parser.parse_args(args=None if sys.argv[1:] else ["--help"]) + + return parser diff --git a/mastko/config/__init__.py b/mastko/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mastko/config/configs.py b/mastko/config/configs.py new file mode 100644 index 0000000..6da5a93 --- /dev/null +++ b/mastko/config/configs.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class Configs: + mastko_cache_location: Path = Path.joinpath(Path.home(), ".mastko") + successful_takeover_ec2_name: str = "dns_takeover_instance" + db_name: str = "mastko.db" + db_path_uri: str = str(Path.joinpath(mastko_cache_location, db_name)) + aws_api_retry_count: int = 5 diff --git a/mastko/data/__init__.py b/mastko/data/__init__.py new file mode 100644 index 0000000..00f36d7 --- /dev/null +++ b/mastko/data/__init__.py @@ -0,0 +1,4 @@ +from mastko.data.target import Target + +# init DB +Target.init_db_table() diff --git a/mastko/data/ec2.py b/mastko/data/ec2.py new file mode 100644 index 0000000..b0e2164 --- /dev/null +++ b/mastko/data/ec2.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class Ec2: + instance_id: str diff --git a/mastko/data/eip.py b/mastko/data/eip.py new file mode 100644 index 0000000..3162bc3 --- /dev/null +++ b/mastko/data/eip.py @@ -0,0 +1,7 @@ +from dataclasses import dataclass + + +@dataclass +class EIP: + allocation_id: str + ip_address: str diff --git a/mastko/data/host.py b/mastko/data/host.py new file mode 100644 index 0000000..f99011e --- /dev/null +++ b/mastko/data/host.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Dict + +from mastko.lib.logger import get_logger + +log = get_logger("mastko.data.host") + +__domain__ = "domain" +__ip_address__ = "ip_address" + + +@dataclass +class Host: + domain: str + ip_address: str + + @staticmethod + def from_dict(dct: Dict) -> Host: + if not (type(dct.get(__domain__)) is str and type(dct.get(__ip_address__)) is str): + raise TypeError(f"{dct} is not a valid MasTKO Host") + + return Host(domain=dct[__domain__], ip_address=dct[__ip_address__]) + + @staticmethod + def from_json(js: str) -> Host: + return Host.from_dict(dct=json.loads(js)) + + def to_dict(self) -> Dict[str, str]: + return { + __domain__: self.domain, + __ip_address__: self.ip_address, + } diff --git a/mastko/data/target.py b/mastko/data/target.py new file mode 100644 index 0000000..4301a31 --- /dev/null +++ b/mastko/data/target.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import csv +import json +import sqlite3 +from dataclasses import dataclass +from typing import Dict, List, Set + +from tqdm import tqdm # type: ignore + +from mastko.config.configs import Configs +from mastko.lib.exceptions import DatabaseException +from mastko.lib.logger import get_logger + +log = get_logger("mastko.data.target") + +__domain__ = "domain" +__ip_address__ = "ip_address" +__region__ = "region" + + +@dataclass +class Target: + domain: str + ip_address: str + region: str + + @staticmethod + def from_dict(dct: Dict) -> Target: + if not ( + type(dct.get(__domain__)) is str + and type(dct.get(__ip_address__)) is str + and type(dct.get(__region__)) is str + ): + raise TypeError(f"{dct} is not a valid MasTKO Target") + + return Target(domain=dct[__domain__], ip_address=dct[__ip_address__], region=dct[__region__]) + + @staticmethod + def from_json(js: str) -> Target: + return Target.from_dict(dct=json.loads(js)) + + def to_dict(self) -> Dict[str, str]: + return { + __domain__: self.domain, + __ip_address__: self.ip_address, + __region__: self.region, + } + + @staticmethod + def to_csv(targets: List[Target], output_file_name: str) -> str: + with open(f"{output_file_name}", "w+", newline="") as output_csv: + headers = [__domain__, __ip_address__, __region__] + writer = csv.DictWriter(output_csv, fieldnames=headers) + writer.writeheader() + for target in targets: + writer.writerow(target.to_dict()) + + return output_file_name + + @staticmethod + def init_db_table() -> None: + try: + db = sqlite3.connect(Configs.db_path_uri) + cursor = db.cursor() + res = cursor.execute("SELECT name FROM sqlite_master WHERE name='targets'") + if res.fetchone() is None: + log.debug("targets table does not exist, creating one now.") + sql = f"""CREATE TABLE targets ({__domain__} CHAR(500), + {__ip_address__} CHAR(250) NOT NULL, + {__region__} CHAR(250))""" + cursor.execute(sql) + db.commit() + db.close() + except sqlite3.Error as ex: + raise DatabaseException(f"Failed to create targets table in database, error: {ex}") + + @staticmethod + def target_available() -> bool: + try: + db = sqlite3.connect(Configs.db_path_uri) + cursor = db.cursor() + sql = "SELECT COUNT(*) > 0 FROM targets" + results = cursor.execute(sql) + return bool(results.fetchone()[0]) + except sqlite3.Error as ex: + raise DatabaseException(f"Failed to check for targets in database, error: {ex}") + + @staticmethod + def get_all_targets_from_db() -> List[Target]: + try: + db = sqlite3.connect(Configs.db_path_uri) + cursor = db.cursor() + sql = "SELECT * FROM targets" + results = cursor.execute(sql) + db_rows = results.fetchall() + targets: List[Target] = [] + for row in db_rows: + targets.append(Target(domain=row[0], ip_address=row[1], region=row[2])) + db.commit() + db.close() + return targets + except sqlite3.Error as ex: + raise DatabaseException(f"Failed to get targets from database, error: {ex}") + + @staticmethod + def insert_targets_to_db(targets: List[Target]) -> None: + # perform cleanup + distinct_domains: Set[str] = set() + for target in targets: + distinct_domains.add(target.domain) + Target.delete_records_with_domains(domains=list(distinct_domains)) + + # Load new targets + for target in tqdm(targets, desc="loading targets to DB"): + Target.__insert_target_to_db(target) + + # Private method, should not be used outside the class. + @staticmethod + def __insert_target_to_db(target: Target) -> None: + try: + db = sqlite3.connect(Configs.db_path_uri) + cursor = db.cursor() + sql = "INSERT INTO targets (domain, ip_address, region) VALUES (?,?,?)" + args = (target.domain, target.ip_address, target.region) + cursor.execute(sql, args) + except sqlite3.Error as ex: + raise DatabaseException(f"Failed to insert target: {target} into database, error: {ex}") + finally: + db.commit() + cursor.close() + db.close() + + @staticmethod + def delete_records_with_domains(domains: List[str]) -> None: + for domain in domains: + Target.delete_record_with_domain(domain) + + @staticmethod + def delete_record_with_domain(domain: str) -> None: + try: + db = sqlite3.connect(Configs.db_path_uri) + cursor = db.cursor() + sql = "DELETE FROM targets WHERE domain = ?" + args = (domain,) + cursor.execute(sql, args) + except sqlite3.Error as ex: + raise DatabaseException( + f"Failed to delete record with domain: {domain} from database, error: {ex}" + ) + finally: + db.commit() + cursor.close() + db.close() diff --git a/mastko/lib/__init__.py b/mastko/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mastko/lib/aws_cidr.py b/mastko/lib/aws_cidr.py new file mode 100644 index 0000000..d9da2b8 --- /dev/null +++ b/mastko/lib/aws_cidr.py @@ -0,0 +1,89 @@ +import ipaddress +import json +import os +import sys +from typing import List, Optional, Tuple + +import requests + +from mastko.config.configs import Configs +from mastko.lib.logger import get_logger + +log = get_logger("mastko.lib.aws_cidr") + + +class AwsCidr: + """Helper class to retrieve AWS CIDR range""" + + def __init__(self) -> None: + self.aws_ranges_url = "https://ip-ranges.amazonaws.com/ip-ranges.json" + self.aws_ranges_file_location = f"{Configs.mastko_cache_location}/aws-ip-ranges.json" + self.get_file() + self.aws_ranges = self.get_aws_ec2_ipv4_ranges() + + def get_file(self) -> str: + """Check if AWS Ranges file exists, if not download from AWS""" + if os.path.isfile(self.aws_ranges_file_location): + return self.aws_ranges_file_location + with requests.get(self.aws_ranges_url, stream=True, timeout=10) as request: + request.raise_for_status() + with open(self.aws_ranges_file_location, "w", encoding="utf-8") as loc_fi: + loc_fi.write(request.text) + + return self.aws_ranges_file_location + + def cidr_to_range(self, cidr: str) -> str: + """Turns a CIDR notation into 'lower_bound,higher_bound' strings.""" + net = ipaddress.ip_network(cidr) + return str(net[0]) + "," + str(net[-1]) + + def get_range_tuple(self, dic: dict) -> Tuple: + """Takes a host dict and returns host tuple.""" + ip_prefix = dic["ip_prefix"] + cidr_range = self.cidr_to_range(ip_prefix) + return (cidr_range, ip_prefix, dic["region"]) + + def get_range_min(self, range_tuple: Tuple[str, ...]) -> Tuple: + """Gets a CIDR range lower bound from a (lower_bound,higher_bound) tuple.""" + cidr_range, *_ = range_tuple + return self.convert_ipv4(cidr_range.split(",")[0]) + + def convert_ipv4(self, ip_address: str) -> Tuple: + """Turns an IP string into a tuple of ints.""" + return tuple(int(n) for n in ip_address.split(".")) + + def get_aws_ec2_ipv4_ranges(self) -> List[Tuple]: + """Get AWS CIDR range to region allocation as an ordered list of tuples.""" + try: + aws_ranges = [] + + with open(self.aws_ranges_file_location, "r") as ips: + data = ips.read().strip() + # restrict the lookup to ipv4 ranges + for range in json.loads(data)["prefixes"]: + if range["service"] == "EC2": + range_alloc = self.get_range_tuple(range) + aws_ranges.append(range_alloc) + return sorted(aws_ranges, key=self.get_range_min) + except Exception as err: + log.error("%s: %s", err.__class__.__name__, err) + log.error(err.args[0]) + sys.exit(1) + + def find_alloc_group(self, ip_address: str) -> Optional[str]: + """Binary search to find an IPs allocation group.""" + low = 0 + mid = 0 + high = len(self.aws_ranges) - 1 + while high >= low: + mid = (high + low) // 2 + cidr_range, *attrs = self.aws_ranges[mid] + cidr_low, cidr_high = cidr_range.split(",") + if self.convert_ipv4(cidr_high) < self.convert_ipv4(ip_address): + low = mid + 1 + elif self.convert_ipv4(cidr_low) > self.convert_ipv4(ip_address): + high = mid - 1 + else: + return attrs[1] + + return None diff --git a/mastko/lib/bruteforcer.py b/mastko/lib/bruteforcer.py new file mode 100644 index 0000000..c3d6e98 --- /dev/null +++ b/mastko/lib/bruteforcer.py @@ -0,0 +1,114 @@ +import time +from datetime import datetime +from typing import Dict, List, Mapping, Sequence + +from mastko.config.configs import Configs +from mastko.data.ec2 import Ec2 +from mastko.data.eip import EIP +from mastko.data.target import Target +from mastko.lib.ec2_client import Ec2Client +from mastko.lib.exceptions import BruteforcerException +from mastko.lib.logger import get_logger + +log = get_logger("mastko.lib.bruteforcer") + + +class Bruteforcer: + """ + The Bruteforcer is capable of rotating the public IP on AWS EC2 and comparing against target list to + check for takeovers. + """ + + def __init__(self, targets: Sequence[Target], region: str, instance_id: str, eip_ip: str): + self.targets = targets + self.target_hash = self._get_targets_grouped_by_ip(self.targets) + self.region = region + self.ec2_client = Ec2Client(aws_region=self.region) + self.ec2 = Ec2(instance_id=instance_id) + self.eip = self.ec2_client.get_eip(eip_ip) + + def _get_targets_grouped_by_ip(self, targets: Sequence[Target]) -> Mapping[str, Sequence[Target]]: + target_hash: Dict[str, List[Target]] = {} + for target in targets: + if target.ip_address not in target_hash: + target_hash[target.ip_address] = [] + + target_hash[target.ip_address].append(target) + + return target_hash + + def rotate_ip(self, ec2_instance_id: str, eip_allocation_id: str) -> None: + try: + eip_association_id = self.ec2_client.associate_eip( + ec2_instance_id=ec2_instance_id, eip_id=eip_allocation_id + ) + self.ec2_client.disassociate_eip(eip_association_id=eip_association_id) + except Exception: + for attempt in range(Configs.aws_api_retry_count): + try: + log.warning("Failed to rotate IP, trying again.") + log.info(f"sleeping for {attempt * 2} seconds") + time.sleep(attempt * 2) + eip_association_id = self.ec2_client.associate_eip( + ec2_instance_id=ec2_instance_id, eip_id=eip_allocation_id + ) + self.ec2_client.disassociate_eip(eip_association_id=eip_association_id) + except Exception as err: + if attempt == Configs.aws_api_retry_count - 1: + message = ( + f"Failed to rotate IP for ec2_instance_id: {ec2_instance_id}, " + f"eip_allocation_id: {eip_allocation_id}. ERROR: {err}" + ) + log.error(message) + log.exception(err) + raise BruteforcerException(message) + else: + break + + def _is_takeover(self, ec2_public_ip: str) -> bool: + return ec2_public_ip in self.target_hash + + def _attempt_takeover(self, ec2: Ec2, eip: EIP) -> str: + self.rotate_ip(ec2.instance_id, eip.allocation_id) + return self.ec2_client.get_ec2_public_ip(ec2.instance_id) + + def _process_successful_takeover(self, ec2: Ec2, takeover_ip: str) -> None: + self.ec2_client.rename_ec2_instance(ec2.instance_id, Configs.successful_takeover_ec2_name) + successful_takeover_targets = self.target_hash[takeover_ip] + associated_dns_names = [target.domain for target in successful_takeover_targets] + tags = [] + for index in range(len(associated_dns_names)): + log.info( + f"SUCCESSFUL TAKEOVER. takeover_ip: {takeover_ip}, " + f"target: {associated_dns_names[index]}, ec2 used: {ec2}" + ) + tags.append({"Key": f"domain_{index}", "Value": associated_dns_names[index]}) + self.ec2_client.tag_instance(instance_id=ec2.instance_id, tags=tags) + + def run(self, iterations: int) -> None: + try: + iteration_counter: int = 0 + start_time: datetime = datetime.now().replace(microsecond=0) + while iteration_counter < iterations: + new_ip = self._attempt_takeover(self.ec2, self.eip) + + if self._is_takeover(new_ip): + log.info( + f"SUCESSFULL ATTEMPT, takeover match found. ec2_used: {self.ec2}, public_ip: {new_ip}" + ) + self._process_successful_takeover(self.ec2, new_ip) + return # exit on success + else: + log.info(f"Unsuccessful attempt, ec2_used: {self.ec2}, public_ip_used: {new_ip}") + + iteration_counter += 1 + end_time: datetime = datetime.now().replace(microsecond=0) + log.info( + f"MasTKO finished executing {iterations} iterations in {end_time - start_time} (HH:MM:SS)" + " time." + ) + except Exception as ex: + message = f"Exception caught in bruteforce handler, error: {ex}" + log.error(message) + log.exception(ex) + raise BruteforcerException(message) diff --git a/mastko/lib/ec2_client.py b/mastko/lib/ec2_client.py new file mode 100644 index 0000000..a4344c3 --- /dev/null +++ b/mastko/lib/ec2_client.py @@ -0,0 +1,124 @@ +from typing import Dict, List, Optional + +import boto3 # type: ignore + +from mastko.data.eip import EIP +from mastko.lib.exceptions import Ec2ClientException +from mastko.lib.logger import get_logger + +log = get_logger("mastko.lib.ec2_client") + + +class Ec2Client: + """ + Helper class for EC2 functions + """ + + def __init__(self, aws_region: str): + self.aws_region = aws_region + self.ec2_client = boto3.client("ec2", region_name=self.aws_region) + + def get_eip(self, ip: str) -> EIP: + try: + results = self.ec2_client.describe_addresses( + Filters=[{"Name": "public-ip", "Values": [ip]}, {"Name": "domain", "Values": ["vpc"]}] + ) + if len(results["Addresses"]) > 1: + raise Ec2ClientException( + f"Ambiguous result returned by AWS, got more than one results for public-ip: {ip}, " + f"AWS response: {results}" + ) + + if len(results["Addresses"]) == 0: + raise Ec2ClientException( + f"No EIP found for public-ip: {ip}. It might not be allocated or cannot be used with " + "a AWS VPC. Please refer: " + "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/" + "elastic-ip-addresses-eip.html#using-instance-addressing-eips-allocating" + ) + + return EIP(allocation_id=results["Addresses"][0]["AllocationId"], ip_address=ip) + except Exception as ex: + msg = f"Failed to get eip with public-ip: {ip}. ERROR: {ex}" + log.error(msg) + log.exception(ex) + raise Ec2ClientException(msg) + + def tag_instance(self, instance_id: str, tags: List[Dict[str, str]]) -> Optional[dict]: + try: + response = self.ec2_client.create_tags( + Resources=[ + instance_id, + ], + Tags=tags, + ) + return response + except Exception as ex: + msg = f"Failed to tag an instance: {instance_id}. ERROR: {ex}" + log.error(msg) + log.exception(ex) + raise Ec2ClientException(msg) + + def rename_ec2_instance(self, instance_id: str, new_name: str) -> Optional[dict]: + try: + response = self.ec2_client.create_tags( + Resources=[ + instance_id, + ], + Tags=[ + { + "Key": "Name", + "Value": new_name, + }, + ], + ) + return response + except Exception as ex: + msg = f"Failed to rename instance: {instance_id}, new_name: {new_name}. ERROR: {ex}" + log.error(msg) + log.exception(ex) + raise Ec2ClientException(msg) + + def associate_eip(self, ec2_instance_id: str, eip_id: str) -> str: + try: + association = self.ec2_client.associate_address(AllocationId=eip_id, InstanceId=ec2_instance_id) + return association["AssociationId"] + except Exception as err: + message = ( + f"Failed to associate eip for ec2_instance_id: {ec2_instance_id}, " + f"eip_id: {eip_id}. ERROR: {err}" + ) + log.error(message) + raise Ec2ClientException(message) + + def disassociate_eip(self, eip_association_id: str) -> None: + try: + self.ec2_client.disassociate_address(AssociationId=eip_association_id) + except Exception as err: + message = f"Failed to disassociate eip_association_id: {eip_association_id}. ERROR: {err}" + log.error(message) + raise Ec2ClientException(message) + + def get_ec2_public_ip(self, instance_id: str) -> str: + try: + response = self.ec2_client.describe_instances( + Filters=[ + { + "Name": "instance-id", + "Values": [instance_id], + }, + ], + ) + + if len(response["Reservations"]) != 1 or len(response["Reservations"][0]["Instances"]) != 1: + raise Ec2ClientException( + f"Describe instance with filter for instance_id: {instance_id} " + f"resulted in ambiguous response: {response}" + ) + + return response["Reservations"][0]["Instances"][0]["PublicIpAddress"] + except Exception as err: + message = f"Failed to get public ip_address of intance: {instance_id}. ERROR: {err}" + log.error(message) + log.exception(err) + raise Ec2ClientException(message) diff --git a/mastko/lib/exceptions.py b/mastko/lib/exceptions.py new file mode 100644 index 0000000..d26ee53 --- /dev/null +++ b/mastko/lib/exceptions.py @@ -0,0 +1,38 @@ +class ConfigException(Exception): + pass + + +class Ec2ClientException(Exception): + pass + + +class BruteforcerException(Exception): + pass + + +class DatabaseException(Exception): + pass + + +class BruteforceCommandException(Exception): + pass + + +class Ec2Exception(Exception): + pass + + +class TargetValidatorException(Exception): + pass + + +class FileImportException(Exception): + pass + + +class NoTargetsFound(Exception): + pass + + +class TargetGeneratorException(Exception): + pass diff --git a/mastko/lib/logger.py b/mastko/lib/logger.py new file mode 100644 index 0000000..f153c3a --- /dev/null +++ b/mastko/lib/logger.py @@ -0,0 +1,42 @@ +import logging +import os +from typing import Any, MutableMapping, Tuple + +__handler = logging.StreamHandler() + + +def set_process_name(process_name: str) -> None: + __handler.setFormatter(logging.Formatter(f"%(levelname)s {process_name} %(name)s %(message)s")) + + +set_process_name("mastko") + + +class MemoryLoggingAdapter(logging.LoggerAdapter): + def process(self, msg: Any, kwargs: MutableMapping[str, Any]) -> Tuple[Any, MutableMapping[str, Any]]: + import psutil # type: ignore + + process = psutil.Process(os.getpid()) + return (f"{msg} [{process.memory_info().rss / 1024 ** 2} MB used]", kwargs) + + +def get_logger(name: str) -> logging.Logger: + """Get the pre-configured logger object + + Returns: + logging.Logger: pre-configured logging object + """ + + # configure the logger + logger = logging.getLogger(name) + + if not logger.hasHandlers(): + logger.addHandler(__handler) + + if os.environ.get("DEBUG"): + logger.level = logging.DEBUG + # mypy is uppity about this interface. + return MemoryLoggingAdapter(logger, {}) # type: ignore + + logger.level = logging.INFO + return logger diff --git a/mastko/lib/target_generator.py b/mastko/lib/target_generator.py new file mode 100644 index 0000000..a89dd2e --- /dev/null +++ b/mastko/lib/target_generator.py @@ -0,0 +1,255 @@ +import os +import re +import socket +import subprocess +import sys +from concurrent import futures +from pathlib import Path +from typing import Dict, List, Optional + +from tqdm import tqdm + +from mastko.config.configs import Configs +from mastko.data.host import Host +from mastko.data.target import Target +from mastko.lib.aws_cidr import AwsCidr +from mastko.lib.exceptions import ( + FileImportException, + NoTargetsFound, + TargetGeneratorException, +) +from mastko.lib.logger import get_logger + +log = get_logger("mastko.lib.target_generator") + + +class TargetGenerator: + """Produces a list of EC2 IP subdomain takeover opportunities.""" + + def __init__(self, hosts_file: str, region: Optional[str] = None): + self.hosts = self.import_hosts_from_file(hosts_file) + self.region = self.validate_region(region) + self.threads = 20 + self.aws_cidr = AwsCidr() + + def import_hosts_from_file(self, file_name: str) -> List[str]: + try: + file = Path(file_name) + + if not file.is_file(): + raise FileImportException( + f"The file_name: {file_name} does not exists. Please verify the file path." + ) + + hosts: List[str] = [] + + with open(file_name, encoding="utf-8") as host_file: + hosts = host_file.read().splitlines() + + return hosts + except Exception as ex: + if ex.__class__ == "FileImportException": + raise ex + + raise FileImportException(f"Failed to import hosts from file_name: {file_name}, ERROR: {ex}") + + def validate_region(self, region: Optional[str]) -> Optional[str]: + if not region: + log.info("Region not specified, will produce targets for all AWS Regions.") + return None + elif region and not bool(re.match(r"^[a-zA-Z]{2}-[a-zA-Z]+-[0-9]$", region)): + log.error("Invalid AWS Region specified: %s", region) + sys.exit(1) + else: + log.info("Producing ec2 takeover targets in '%s'.", region.lower()) + return region.lower() + + def valid_domain(self, domain: str) -> bool: + """Determines a string is of valid domain format.""" + try: + domain_re = r"^((?!-)[A-Za-z0-9-]{1,63}(? Optional[List[str]]: + """Takes a domain and outputs any A records in the chain.""" + try: + if ip_list is None: + # https://docs.python-guide.org/writing/gotchas/#mutable-default-arguments + ip_list = [] + socket.setdefaulttimeout(0.01) + _, cnames, ips = socket.gethostbyname_ex(domain) + if ips: + ip_list.extend(ips) + elif cnames: + for dom in cnames: + self.get_ips(dom, ip_list) + return ip_list + except (socket.gaierror, socket.herror): + return ip_list + + def clean_host(self, domain: str) -> Optional[List[Host]]: + """Returns a domains and the IPs they map to as separate candidates.""" + if not self.valid_domain(domain): + return None + + ip_list = self.get_ips(domain) + + if not ip_list: + return None + + candidates = [Host(domain=domain, ip_address=ip) for ip in ip_list] + return candidates + + def clean_hosts(self) -> List[Host]: + """For all domains, return a list of distinct domain to IP mappings.""" + results: tqdm[Optional[List[Host]]] + with futures.ThreadPoolExecutor(self.threads) as executor: + results = tqdm( + executor.map(self.clean_host, self.hosts), + desc="Cleaning Input Domains", + total=len(self.hosts), + ncols=100, + ) + cleaned_hosts: List[Host] = [] + for result in results: + if result: + cleaned_hosts.extend(result) + + return cleaned_hosts + + def get_inactive_hosts(self, hosts: List[Host]) -> List[Host]: + masscan_input_file_location = f"{Configs.mastko_cache_location}/masscan_input_ips_file" + nmap_input_file_location = f"{Configs.mastko_cache_location}/nmap_input_ips_file" + try: + """Scan top 100 ports for signs of life, mark hosts inactive no response.""" + log.info(f"Scanning {len(hosts)} hosts to identify inactive ones.") + ips = [host.ip_address for host in hosts] + with open(masscan_input_file_location, "w", encoding="utf-8") as masscan_input: + masscan_input.write("\n".join([ip for ip in ips])) + r_active_ips_ms = r"Discovered open port [0-9]{2,5}\/tcp on ([0-9\.\:]+)" + masscan_command = [ + "sudo", + "masscan", + "--top-ports", + "100", + "--max-rate", + "100000", + "-iL", + masscan_input_file_location, + ] + log.info(f"Running masscan command, might require password for sudo: {' '.join(masscan_command)}") + with subprocess.Popen(masscan_command, stdout=subprocess.PIPE) as ms_proc: + lofi_scan = str(ms_proc.stdout.read()) if ms_proc.stdout else "" + active_ips = set(re.findall(r_active_ips_ms, lofi_scan)) + inactive_hosts = [host for host in hosts if host.ip_address not in active_ips] + inactive_ips = {host.ip_address for host in inactive_hosts} + with open(nmap_input_file_location, "w", encoding="utf-8") as nmap_input: + nmap_input.write("\n".join([ip for ip in inactive_ips])) + nmap_command = [ + "sudo", + "nmap", + "--max-retries", + "3", + "--host-timeout", + "5m", + "-T4", + "-sn", + "-iL", + nmap_input_file_location, + ] + log.info(f"Running nmap command, might require password for sudo: {' '.join(nmap_command)}") + r_active_ips_nm = r"Nmap scan report for .+?\(([0-9\.\:]+)\)" + with subprocess.Popen(nmap_command, stdout=subprocess.PIPE) as nm_proc: + hifi_scan = str(nm_proc.stdout.read()) if nm_proc.stdout else "" + active_ips2 = set(re.findall(r_active_ips_nm, hifi_scan)) + inactive_hosts2 = [host for host in inactive_hosts if host.ip_address not in active_ips2] + log.info(f"Detected {len(inactive_hosts2)} inactive hosts.") + if not inactive_hosts2: + raise NoTargetsFound("our scanner did not find any inactive hosts in the input file.") + return inactive_hosts2 + except Exception as ex: + if ex.__class__ != NoTargetsFound: + err_msg = f"Error while scanning for inactive targets. Error: {ex}" + log.exception(ex) + raise TargetGeneratorException(err_msg) + raise ex + finally: + os.remove(masscan_input_file_location) + + def create_target(self, inactive_host: Host) -> Optional[Target]: + try: + region = self.aws_cidr.find_alloc_group(inactive_host.ip_address) + log.debug(f"{inactive_host.ip_address} is associated to {region}") + if region: + return Target( + domain=inactive_host.domain, + ip_address=inactive_host.ip_address, + region=region, + ) + return None + except Exception as ex: + raise TargetGeneratorException(f"Error while creating a target, Error: {ex}") + + def load_targets(self, targets: List[Target]) -> None: + try: + if self.region is None: + Target.insert_targets_to_db(list(targets)) + else: + filtered_targets: List[Target] = [] + for target in targets: + if target.region == self.region: + filtered_targets.append(target) + + Target.insert_targets_to_db(filtered_targets) + except Exception as ex: + raise TargetGeneratorException(f"Error while loading targets to DB, Error: {ex}") + + def display_target_distribution(self, targets: List[Target]) -> None: + """Prints a table with validated target count and thier repective AWS region.""" + target_map: Dict[str, int] = {} + for target in targets: + if target.region not in target_map: + target_map[target.region] = 1 + else: + target_map[target.region] += 1 + + print("AWS REGION\t\tCOUNT OF Targets") + for key, value in target_map.items(): + print(f"{key}\t\t{value}") + + def filter_targets_to_aws_region(self, targets: List[Target]) -> List[Target]: + filtered_targets: List[Target] = [] + for target in targets: + if target.region == self.region: + filtered_targets.append(target) + + log.info(f"Filter: {len(filtered_targets)} found for {self.region} AWS Region") + return filtered_targets + + def generate(self) -> List[Target]: + try: + cleaned_hosts = self.clean_hosts() + inactive_hosts = self.get_inactive_hosts(cleaned_hosts) + results: tqdm[Optional[Target]] + with futures.ThreadPoolExecutor(self.threads) as executor: + results = tqdm( + executor.map(self.create_target, inactive_hosts), + desc="Associating Domains to AWS Region", + total=len(inactive_hosts), + ncols=100, + ) + + list_of_targets: List[Target] = [] + for result in results: + if result: + list_of_targets.append(result) + + if len(list_of_targets) > 0: + self.display_target_distribution(list(list_of_targets)) + + return list_of_targets + except Exception as ex: + log.error(ex) + sys.exit(1) diff --git a/mastko/mastko.py b/mastko/mastko.py new file mode 100644 index 0000000..d2ea7eb --- /dev/null +++ b/mastko/mastko.py @@ -0,0 +1,18 @@ +from mastko.cli.executer import cmd_executer +from mastko.cli.parser import get_parser +from mastko.lib.logger import get_logger + +log = get_logger("mastko.main_handler") + + +def main() -> None: + try: + parser = get_parser() + cmd_executer(parser) + except Exception as ex: + log.error(ex) + parser.exit(1, str(ex)) + + +if __name__ == "__main__": + main() diff --git a/mastko/version.py b/mastko/version.py new file mode 100644 index 0000000..5becc17 --- /dev/null +++ b/mastko/version.py @@ -0,0 +1 @@ +__version__ = "1.0.0" diff --git a/requirements.dev.txt b/requirements.dev.txt new file mode 100644 index 0000000..1ae643a --- /dev/null +++ b/requirements.dev.txt @@ -0,0 +1,5 @@ +moto==4.1.8 +pytest==7.3.1 +pytest-mock==3.10.0 +types-requests==2.29.0.0 +tox==4.5.1 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0a1ff14 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +boto3==1.26.120 +requests==2.29.0 +tqdm==4.65.0 diff --git a/scripts/git-hooks/hooks-wrapper.sh b/scripts/git-hooks/hooks-wrapper.sh new file mode 100755 index 0000000..aec62c9 --- /dev/null +++ b/scripts/git-hooks/hooks-wrapper.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +BASE_DIR=$(git rev-parse --show-toplevel) +HOOK_DIR=$BASE_DIR/.git/hooks + +if [ -x $0.local ]; then + $0.local "$@" || exit $? +fi +if [ -x $BASE_DIR/scripts/git-hooks/$(basename $0) ]; then + $BASE_DIR/scripts/git-hooks/$(basename $0) "$@" || exit $? +fi \ No newline at end of file diff --git a/scripts/git-hooks/install-hooks.sh b/scripts/git-hooks/install-hooks.sh new file mode 100755 index 0000000..a1b1249 --- /dev/null +++ b/scripts/git-hooks/install-hooks.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +HOOK_NAMES="pre-commit pre-merge-commit prepare-commit-msg commit-msg post-commit pre-rebase post-checkout post-merge pre-push" + +HOOK_DIR=$(git rev-parse --show-toplevel)/.git/hooks + +for hook in $HOOK_NAMES; do + if [ ! -h $HOOK_DIR/$hook -a -x $HOOK_DIR/$hook ]; then + mv $HOOK_DIR/$hook $HOOK_DIR/$hook.local + fi + ln -s -f $(git rev-parse --show-toplevel)/scripts/git-hooks/hooks-wrapper.sh $HOOK_DIR/$hook +done \ No newline at end of file diff --git a/scripts/git-hooks/pre-push b/scripts/git-hooks/pre-push new file mode 100755 index 0000000..cfdbc95 --- /dev/null +++ b/scripts/git-hooks/pre-push @@ -0,0 +1,7 @@ +#!/bin/sh + +BASE_DIR=$(git rev-parse --show-toplevel) + +tox || exit $? + +exit 0 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..8030cba --- /dev/null +++ b/setup.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python3 + +from setuptools import setup, find_packages # type:ignore +from mastko.version import __version__ + +with open("requirements.txt") as f: + required = f.read().splitlines() + +setup( + name="mastko", + version=__version__, + entry_points={"console_scripts": ["mastko=mastko.mastko:main"]}, + packages=find_packages(), + include_package_data=True, + long_description=open("README.md").read(), + python_requires=">=3.9", + install_requires=required, +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..0172a45 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,24 @@ +import os + +import boto3 +import pytest +from moto import mock_ec2 + +pytest.aws_region = "us-east-1" + + +@pytest.fixture +def aws_credentials(): + """Mocked AWS Credentials for moto.""" + os.environ["AWS_ACCESS_KEY_ID"] = "fake_id" + os.environ["AWS_SECRET_ACCESS_KEY"] = "fake_key" + os.environ["AWS_SECURITY_TOKEN"] = "fake_security_token" + os.environ["AWS_SESSION_TOKEN"] = "fake_session_token" + os.environ["AWS_DEFAULT_REGION"] = "us-east-1" + + +@pytest.fixture() +def ec2(aws_credentials): + with mock_ec2(): + conn = boto3.client("ec2") + yield conn diff --git a/tests/mastko/cli/commands/test_bruteforce.py b/tests/mastko/cli/commands/test_bruteforce.py new file mode 100644 index 0000000..648788d --- /dev/null +++ b/tests/mastko/cli/commands/test_bruteforce.py @@ -0,0 +1,48 @@ +from argparse import Namespace + +from mastko.cli.commands.bruteforce import bruteforce_executer, bruteforce_parser +from mastko.lib.exceptions import BruteforceCommandException + + +import pytest + + +def test_bruteforce_parser(mocker): + mock_subparsers = mocker.MagicMock() + bruteforce_parser(mock_subparsers) + + mock_subparsers.add_parser.assert_called_once_with( + name="bruteforce", help="runs subdomain takeover bruteforce service" + ) + + +def test_bruteforce_executer(mocker): + mock_args = Namespace(iterations=1, eip_ip="0.0.0.0", instance_id="test-id", region="us-west-2") + mock_target_available = mocker.patch( + "mastko.cli.commands.bruteforce.Target.target_available", return_value=True + ) + mock_target_gat_all_targets_from_db = mocker.patch( + "mastko.cli.commands.bruteforce.Target.get_all_targets_from_db", return_value=["fake-target"] + ) + mock_bruteforcer = mocker.patch("mastko.cli.commands.bruteforce.Bruteforcer") + bruteforce_executer(args=mock_args) + + mock_target_available.assert_called_once() + mock_target_gat_all_targets_from_db.assert_called_once() + mock_bruteforcer.assert_called_once_with( + targets=["fake-target"], region="us-west-2", instance_id="test-id", eip_ip="0.0.0.0" + ) + mock_bruteforcer().run.assert_called_once_with(iterations=1) + + +def test_bruteforce_executer_exception(mocker): + mock_args = Namespace(iterations=1, eip_ip="0.0.0.0", instance_id="test-id", region="us-west-2") + mock_target_available = mocker.patch( + "mastko.cli.commands.bruteforce.Target.target_available", return_value=False + ) + + with pytest.raises(Exception) as ex: + bruteforce_executer(args=mock_args) + + mock_target_available.assert_called_once() + assert ex.type is BruteforceCommandException diff --git a/tests/mastko/cli/commands/test_validate_targets.py b/tests/mastko/cli/commands/test_validate_targets.py new file mode 100644 index 0000000..b4d615f --- /dev/null +++ b/tests/mastko/cli/commands/test_validate_targets.py @@ -0,0 +1,42 @@ +from argparse import Namespace + +from mastko.cli.commands.validate_targets import validate_targets_executer, validate_targets_parser +from mastko.data.target import Target +from mastko.lib.target_generator import TargetGenerator + + +def test_validate_targets_parser(mocker): + mock_subparsers = mocker.MagicMock() + validate_targets_parser(mock_subparsers) + + mock_subparsers.add_parser.assert_called_once_with( + name="validate_targets", help="validates and loads targets to DB" + ) + + +def return_targets(mocker): + return [Target(domain="example.com", ip_address="1.2.3.4", region="us-west-2")] + + +def test_validate_targets_executer_with_region(mocker): + mock_args = Namespace(hosts_file="fake-file", region="us-west-2") + mocker.patch.object(TargetGenerator, "generate", return_targets) + mocker.patch.object(TargetGenerator, "import_hosts_from_file") + mock_target_generator_filter_targets_to_aws_region = mocker.patch.object( + TargetGenerator, "filter_targets_to_aws_region" + ) + validate_targets_executer(args=mock_args) + mock_target_generator_filter_targets_to_aws_region.assert_called_once_with( + [Target(domain="example.com", ip_address="1.2.3.4", region="us-west-2")] + ) + + +def test_validate_targets_executer_without_region(mocker): + mock_args = Namespace(hosts_file="fake-file", region=None) + mocker.patch.object(TargetGenerator, "generate", return_targets) + mocker.patch.object(TargetGenerator, "import_hosts_from_file") + mock_target_generator_filter_targets_to_aws_region = mocker.patch.object( + TargetGenerator, "filter_targets_to_aws_region" + ) + validate_targets_executer(args=mock_args) + mock_target_generator_filter_targets_to_aws_region.assert_not_called() diff --git a/tests/mastko/cli/test_executer.py b/tests/mastko/cli/test_executer.py new file mode 100644 index 0000000..0bf23d8 --- /dev/null +++ b/tests/mastko/cli/test_executer.py @@ -0,0 +1,38 @@ +import pytest + +from mastko.cli.executer import cmd_executer + + +def test_cmd_executer_bruteforce(mocker): + mock_parser = mocker.MagicMock() + mock_parser.parse_args.return_value = mocker.MagicMock(command="bruteforce") + mock_bruteforce_executer = mocker.patch("mastko.cli.executer.bruteforce_executer") + cmd_executer(mock_parser) + + mock_parser.parse_args.assert_called_once() + mock_bruteforce_executer.assert_called_once_with(mock_parser.parse_args.return_value) + + +def test_cmd_executer_validate_targets(mocker): + mock_parser = mocker.MagicMock() + mock_parser.parse_args.return_value = mocker.MagicMock(command="validate_targets") + mock_validate_targets_executer = mocker.patch("mastko.cli.executer.validate_targets_executer") + cmd_executer(mock_parser) + + mock_parser.parse_args.assert_called_once() + mock_validate_targets_executer.assert_called_once_with(mock_parser.parse_args.return_value) + + +def test_cmd_executer_raise_exception(mocker): + mock_parser = mocker.MagicMock() + mock_parser.parse_args.side_effect = Exception("fake-exception") + mock_parser.exit = mocker.MagicMock() + + mock_bruteforce_executer = mocker.patch("mastko.cli.executer.bruteforce_executer") + mock_validate_targets_executer = mocker.patch("mastko.cli.executer.validate_targets_executer") + + cmd_executer(mock_parser) + + mock_parser.exit.assert_called_once_with(1) + mock_bruteforce_executer.assert_not_called() + mock_validate_targets_executer.assert_not_called() diff --git a/tests/mastko/cli/test_parser.py b/tests/mastko/cli/test_parser.py new file mode 100644 index 0000000..671981a --- /dev/null +++ b/tests/mastko/cli/test_parser.py @@ -0,0 +1,33 @@ +from mastko.cli.parser import get_parser +from mastko.version import __version__ + + +def test_get_parser(mocker): + mock_argparse = mocker.patch("mastko.cli.parser.ArgumentParser") + mock_add_subparsers = mock_argparse.return_value.add_subparsers + mock_add_subparsers.return_value = mocker.MagicMock() + + mock_bruteforce_parser = mocker.patch("mastko.cli.parser.bruteforce_parser") + mock_validate_targets_parser = mocker.patch("mastko.cli.parser.validate_targets_parser") + + mock_add_argument = mock_argparse.return_value.add_argument + mock_parse_args = mock_argparse.return_value.parse_args + + expected_parser = get_parser() + + mock_argparse.assert_called_once_with(prog="mastko") + mock_add_subparsers.assert_called_once_with( + description="Please select a subcommand. For more information run `mastko {sub-command} --help`.", + help="sub-commands:", + dest="command", + ) + + mock_bruteforce_parser.assert_called_once_with(mock_add_subparsers.return_value) + mock_validate_targets_parser.assert_called_once_with(mock_add_subparsers.return_value) + + mock_add_argument.assert_called_once_with( + "--version", help="print out cli version", action="version", version=f"v{__version__}" + ) + mock_parse_args.assert_called_once_with(args=None if mocker.ANY else ["--help"]) + + assert expected_parser == mock_argparse.return_value diff --git a/tests/mastko/data/__init__.py b/tests/mastko/data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/mastko/data/test_host.py b/tests/mastko/data/test_host.py new file mode 100644 index 0000000..1322f8a --- /dev/null +++ b/tests/mastko/data/test_host.py @@ -0,0 +1,32 @@ +import json +import pytest + +from mastko.data.host import Host + + +def test_dumpingAndLoadingDict_shouldClonesClass(): + host = Host(domain="example.com", ip_address="1.2.3.4") + + dictionary = host.to_dict() + cloned_target = Host.from_dict(dictionary) + + assert host == cloned_target + assert host is not cloned_target + + +def test_jsonSerializeAndDeserialize_shouldClonesClass(): + host = Host(domain="example.com", ip_address="1.2.3.4") + + json_str = json.dumps(host.to_dict()) + cloned_target = Host.from_json(json_str) + + assert host == cloned_target + assert host is not cloned_target + + +def test_host_from_dict_TypeError(): + with pytest.raises(TypeError) as ex: + Host.from_dict({"domain": "fake-domain", "ip_address": 1234}) + + assert ex.type is TypeError + assert "is not a valid MasTKO Host" in str(ex.value) diff --git a/tests/mastko/data/test_target.py b/tests/mastko/data/test_target.py new file mode 100644 index 0000000..7e74247 --- /dev/null +++ b/tests/mastko/data/test_target.py @@ -0,0 +1,70 @@ +import json +import pytest +import sqlite3 +import os + +from mastko.data.target import Target +from mastko.config.configs import Configs + + +def test_dumpingAndLoadingDict_shouldClonesClass(): + target = Target(domain="example.com", ip_address="1.2.3.4", region="us-west-2") + + dictionary = target.to_dict() + cloned_target = Target.from_dict(dictionary) + + assert target == cloned_target + assert target is not cloned_target + + +def test_jsonSerializeAndDeserialize_shouldClonesClass(): + target = Target(domain="example.com", ip_address="1.2.3.4", region="us-west-2") + + json_str = json.dumps(target.to_dict()) + cloned_target = Target.from_json(json_str) + + assert target == cloned_target + assert target is not cloned_target + + +def test_target_from_dict_TypeError(): + with pytest.raises(TypeError) as ex: + Target.from_dict({"domain": "fake-domain", "ip_address": 1234, "region": "us-west-2"}) + + assert ex.type is TypeError + assert "is not a valid MasTKO Target" in str(ex.value) + + +def test_target_table_creation(mocker): + try: + mocker.patch.object(Configs, "db_path_uri", "/tmp/mastko.db") + Target.init_db_table() + db = sqlite3.connect(Configs.db_path_uri) + cursor = db.cursor() + res = cursor.execute("SELECT name FROM sqlite_master WHERE name='targets'") + + assert res.fetchone is not None + finally: + os.remove("/tmp/mastko.db") + + +def test_get_all_target_from_db(mocker): + try: + targets = [Target(domain="example.com", ip_address="1.2.3.4", region="us-west-2")] + mocker.patch.object(Configs, "db_path_uri", "/tmp/mastko.db") + Target.init_db_table() + Target.insert_targets_to_db(targets) + + assert Target.get_all_targets_from_db() == targets + finally: + os.remove("/tmp/mastko.db") + + +def test_target_available(mocker): + try: + mocker.patch.object(Configs, "db_path_uri", "/tmp/mastko.db") + Target.init_db_table() + + assert Target.target_available() is False + finally: + os.remove("/tmp/mastko.db") diff --git a/tests/mastko/lib/__init__.py b/tests/mastko/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/mastko/lib/test_aws_cidr.py b/tests/mastko/lib/test_aws_cidr.py new file mode 100644 index 0000000..0ea58dd --- /dev/null +++ b/tests/mastko/lib/test_aws_cidr.py @@ -0,0 +1,72 @@ +from mastko.lib.aws_cidr import AwsCidr +from mastko.config.configs import Configs +import os +import json + +def mocked_requests_get(*args, **kwargs): + class MockResponse: + def __init__(self, json_data, status_code): + self.json_data = json_data + self.status_code = status_code + self.text = json_data + + def raise_for_status(self): + return True + + def __enter__(*args): + return MockResponse( + json.dumps({"prefixes": [ + { + "ip_prefix": "1.2.3.4/32", + "region": "us-east-2", + "service": "EC2", + "network_border_group": "us-east-2" + } + ]}), + 200 + ) + + def __exit__(*args): + pass + + return MockResponse( + json.dumps({"prefixes": [ + { + "ip_prefix": "1.2.3.4/32", + "region": "us-east-2", + "service": "EC2", + "network_border_group": "us-east-2" + } + ]}), + 200 + ) + + + +def test_get_region_for_aws_ip(mocker): + try: + mocker.patch.object(Configs, "mastko_cache_location", "/tmp") + mocker.patch('requests.get', mocked_requests_get) + aws_cidr = AwsCidr() + aws_cidr.get_file() + + associated_region = aws_cidr.find_alloc_group("1.2.3.4") + + assert associated_region == "us-east-2" + finally: + os.remove("/tmp/aws-ip-ranges.json") + + +def test_get_region_for_non_aws_ip(mocker): + try: + mocker.patch.object(Configs, "mastko_cache_location", "/tmp") + mocker.patch('requests.get', mocked_requests_get) + aws_cidr = AwsCidr() + aws_cidr.get_file() + print(aws_cidr.aws_ranges) + + associated_region = aws_cidr.find_alloc_group("4.3.2.1") + + assert associated_region is None + finally: + os.remove("/tmp/aws-ip-ranges.json") \ No newline at end of file diff --git a/tests/mastko/lib/test_bruteforcer.py b/tests/mastko/lib/test_bruteforcer.py new file mode 100644 index 0000000..6d1fbb2 --- /dev/null +++ b/tests/mastko/lib/test_bruteforcer.py @@ -0,0 +1,136 @@ +from mastko.config.configs import Configs +from mastko.data.target import Target +from mastko.lib.bruteforcer import Bruteforcer, BruteforcerException + +import pytest + + +@pytest.fixture +def mock_ec2_client(mocker): + return mocker.patch("mastko.lib.bruteforcer.Ec2Client") + + +@pytest.fixture +def brute_forcer(mock_ec2_client): + return Bruteforcer( + targets=[Target(domain="example.com", ip_address="1.2.3.4", region="us-west-2")], + region="us-west-2", + instance_id="i-1234567890abcdef0", + eip_ip="1.2.3.4", + ) + + +def test_BruteForcer_init(mock_ec2_client): + targets = [Target(domain="example.com", ip_address="1.2.3.4", region="us-west-2")] + region = "us-west-2" + instance_id = "i-1234567890abcdef0" + eip_ip = "1.2.3.4" + get_eip_return_value = "eip-1234567890abcdef0" + + mock_ec2_client().get_eip.return_value = get_eip_return_value + + bf = Bruteforcer(targets=targets, region=region, instance_id=instance_id, eip_ip=eip_ip) + + assert bf.targets == targets + assert bf.region == region + assert bf.eip == get_eip_return_value + assert bf.ec2.instance_id == instance_id + assert bf.target_hash == { + "1.2.3.4": [Target(domain="example.com", ip_address="1.2.3.4", region="us-west-2")] + } + + +def test_get_targets_grouped_by_ip(brute_forcer): + targets = [ + Target(ip_address="2.3.4.5", domain="get_targets_1.com", region="us-west-2"), + Target(ip_address="3.3.4.5", domain="get_targets_2.com", region="us-west-2"), + Target(ip_address="4.3.4.5", domain="sub.get_targets_3.com", region="us-west-2"), + Target(ip_address="4.3.4.5", domain="sub.get_targets_3.com", region="us-west-2"), + ] + + results = brute_forcer._get_targets_grouped_by_ip(targets) + + assert list(results.keys()) == ["2.3.4.5", "3.3.4.5", "4.3.4.5"] + assert results["2.3.4.5"] == [targets[0]] + assert results["3.3.4.5"] == [targets[1]] + assert results["4.3.4.5"] == [targets[2], targets[3]] + + +def test_rotate_ip(brute_forcer): + brute_forcer.ec2_client.associate_eip.return_value = "fake-eip-allocation-id-return" + brute_forcer.rotate_ip(ec2_instance_id="fake-instance-id", eip_allocation_id="fake-eip-id") + + brute_forcer.ec2_client.associate_eip.assert_called_once_with( + ec2_instance_id="fake-instance-id", eip_id="fake-eip-id" + ) + brute_forcer.ec2_client.disassociate_eip.assert_called_once_with( + eip_association_id="fake-eip-allocation-id-return" + ) + + +def test_rotate_ip_raise_exception(brute_forcer): + brute_forcer.ec2_client.associate_eip.side_effect = Exception("fake-exception") + with pytest.raises(Exception) as ex: + brute_forcer.rotate_ip(ec2_instance_id="fake-instance-id", eip_allocation_id="fake-eip-id") + assert ex.type is BruteforcerException + + +def test_is_takeover_true(brute_forcer): + assert brute_forcer._is_takeover(ec2_public_ip="1.2.3.4") is True + + +def test_is_takeover_false(brute_forcer): + assert brute_forcer._is_takeover(ec2_public_ip="8.8.8.8") is False + + +def test_attempt_takeover(brute_forcer, mocker): + brute_forcer.ec2_client.get_ec2_public_ip.return_value = "fake-public-ip" + brute_forcer.ec2_client.get_eip.return_value = mocker.Mock(allocation_id="fake-allocation-id") + + ip_str = brute_forcer._attempt_takeover(ec2=brute_forcer.ec2, eip=brute_forcer.eip) + + brute_forcer.ec2_client.get_ec2_public_ip.assert_called_once_with("i-1234567890abcdef0") + assert ip_str == "fake-public-ip" + + +def test_process_successful_takeover(brute_forcer): + expected_tags = [{"Key": "domain_0", "Value": "example.com"}] + brute_forcer._process_successful_takeover(ec2=brute_forcer.ec2, takeover_ip="1.2.3.4") + + brute_forcer.ec2_client.rename_ec2_instance.assert_called_once_with( + "i-1234567890abcdef0", Configs.successful_takeover_ec2_name + ) + brute_forcer.ec2_client.tag_instance.assert_called_once_with( + instance_id="i-1234567890abcdef0", tags=expected_tags + ) + + +def test_run_takeover_exists(brute_forcer, mocker): + brute_forcer._attempt_takeover = mocker.Mock(return_value="fake-new-ip") + brute_forcer._is_takeover = mocker.Mock(return_value=True) + brute_forcer._process_successful_takeover = mocker.Mock() + + brute_forcer.run(iterations=1) + + brute_forcer._attempt_takeover.assert_called_once_with(brute_forcer.ec2, brute_forcer.eip) + brute_forcer._is_takeover.assert_called_once_with("fake-new-ip") + brute_forcer._process_successful_takeover.assert_called_once_with(brute_forcer.ec2, "fake-new-ip") + + +def test_run_takeover_does_not_exist(brute_forcer, mocker): + brute_forcer._attempt_takeover = mocker.Mock(return_value="fake-new-ip") + brute_forcer._is_takeover = mocker.Mock(return_value=False) + brute_forcer._process_successful_takeover = mocker.Mock() + + brute_forcer.run(iterations=1) + + brute_forcer._attempt_takeover.assert_called_once_with(brute_forcer.ec2, brute_forcer.eip) + brute_forcer._is_takeover.assert_called_once_with("fake-new-ip") + brute_forcer._process_successful_takeover.assert_not_called() + + +def test_run_raises_exception(brute_forcer, mocker): + brute_forcer._attempt_takeover = mocker.Mock(side_effect=Exception("fake-exception")) + with pytest.raises(Exception) as ex: + brute_forcer.run(iterations=1) + assert ex.type is BruteforcerException diff --git a/tests/mastko/lib/test_ec2_client.py b/tests/mastko/lib/test_ec2_client.py new file mode 100644 index 0000000..65216e9 --- /dev/null +++ b/tests/mastko/lib/test_ec2_client.py @@ -0,0 +1,186 @@ +from contextlib import contextmanager + +import pytest +from moto import mock_ec2 # type: ignore + +from mastko.lib.ec2_client import Ec2Client +from mastko.lib.exceptions import Ec2ClientException + + +@mock_ec2 +def test_non_existant_get_eip(aws_credentials): + ec2_client = Ec2Client(pytest.aws_region) + try: + ec2_client.get_eip(ip="1.2.3.4") + assert False + except Ec2ClientException: + assert True + + +@contextmanager +def create_eip(ec2, ip): + ec2.allocate_address(Address=ip) + yield + + +def test_existing_get_eip(ec2, ip_address="1.2.3.4"): + with create_eip(ec2, ip_address): + ec2_client = Ec2Client(aws_region=pytest.aws_region) + eip = ec2_client.get_eip(ip_address) + + assert eip.ip_address == ip_address + + +def test_get_eip_addresses_greater_than_one_raise_exception(ec2, ip_address="1.2.3.4"): + with create_eip(ec2, ip_address): + ec2.allocate_address(Address="1.2.3.4") # allocating second address + ec2_client = Ec2Client(aws_region=pytest.aws_region) + + with pytest.raises(Ec2ClientException) as ex: + ec2_client.get_eip(ip_address) + + assert "Ambiguous result returned by AWS, got more than one results for public-ip: 1.2.3.4" in str( + ex.value + ) + + +def test_get_eip_zero_addresses_raise_exception(ec2, ip_address="1.2.3.4"): + ec2_client = Ec2Client(aws_region=pytest.aws_region) + + with pytest.raises(Ec2ClientException) as ex: + ec2_client.get_eip(ip_address) + + assert "No EIP found for public-ip: 1.2.3.4" in str(ex.value) + + +def test_associate_eip(ec2, ip_address="1.2.3.4"): + with create_eip(ec2, ip_address): + ec2_client = Ec2Client(pytest.aws_region) + eip = ec2_client.get_eip(ip_address) + ami_id = ec2.describe_images()["Images"][0]["ImageId"] + instance_id = ec2.run_instances(ImageId=ami_id, MinCount=1, MaxCount=1)["Instances"][0]["InstanceId"] + ec2_client.associate_eip(ec2_instance_id=instance_id, eip_id=eip.allocation_id) + associated_eip = ec2.describe_instances()["Reservations"][0]["Instances"][0]["PublicIpAddress"] + + assert eip.ip_address == associated_eip + + +def test_associate_eip_raise_exception(ec2, mocker): + ec2_client = Ec2Client(aws_region=pytest.aws_region) + ec2_client.ec2_client.associate_address = mocker.MagicMock(side_effect=Exception("Association Error")) + + with pytest.raises( + Ec2ClientException, + match=( + "Failed to associate eip for ec2_instance_id: i-1234567890, eip_id: eipalloc-1234567890. ERROR:" + " Association Error" + ), + ): + ec2_client.associate_eip("i-1234567890", "eipalloc-1234567890") + + +def test_disassociate_eip(ec2, ip_address="1.2.3.4"): + with create_eip(ec2, ip_address): + ec2_client = Ec2Client(pytest.aws_region) + eip = ec2_client.get_eip(ip_address) + ami_id = ec2.describe_images()["Images"][0]["ImageId"] + instance_id = ec2.run_instances(ImageId=ami_id, MinCount=1, MaxCount=1)["Instances"][0]["InstanceId"] + association_id = ec2_client.associate_eip(ec2_instance_id=instance_id, eip_id=eip.allocation_id) + ec2_client.disassociate_eip(association_id) + associated_ip = ec2.describe_instances()["Reservations"][0]["Instances"][0]["PublicIpAddress"] + + assert eip.ip_address != associated_ip + + +def test_disassociate_eip_raise_exception(ec2, mocker): + ec2_client = Ec2Client(aws_region=pytest.aws_region) + ec2_client.ec2_client.disassociate_address = mocker.MagicMock( + side_effect=Exception("Disassociation Error") + ) + + with pytest.raises( + Ec2ClientException, + match="Failed to disassociate eip_association_id: eipassoc-1234567890. ERROR: Disassociation Error", + ): + ec2_client.disassociate_eip("eipassoc-1234567890") + + +def test_tag_instance(ec2, tag_value="test_instance"): + ami_id = ec2.describe_images()["Images"][0]["ImageId"] + instance_id = ec2.run_instances(ImageId=ami_id, MinCount=1, MaxCount=1)["Instances"][0]["InstanceId"] + ec2_client = Ec2Client(pytest.aws_region) + ec2_client.tag_instance(instance_id, [{"Key": "Name", "Value": tag_value}]) + ec2_name_from_boto3 = ec2.describe_instances()["Reservations"][0]["Instances"][0]["Tags"][0]["Value"] + + assert ec2_name_from_boto3 == tag_value + + +def test_tag_instance_raise_exception(ec2, mocker): + ec2_client = Ec2Client(aws_region=pytest.aws_region) + ec2_client.ec2_client.create_tags = mocker.MagicMock(side_effect=Exception("Fake create tags error")) + + with pytest.raises( + Ec2ClientException, match="Failed to tag an instance: i-1234567890. ERROR: Fake create tags error" + ): + ec2_client.tag_instance("i-1234567890", [{"Key": "Name", "Value": "test_instance"}]) + + +def test_rename_instance(ec2, ec2_name="test_instance"): + ami_id = ec2.describe_images()["Images"][0]["ImageId"] + instance_id = ec2.run_instances(ImageId=ami_id, MinCount=1, MaxCount=1)["Instances"][0]["InstanceId"] + ec2_client = Ec2Client(pytest.aws_region) + ec2_client.rename_ec2_instance(instance_id, ec2_name) + ec2_name_from_boto3 = ec2.describe_instances()["Reservations"][0]["Instances"][0]["Tags"][0]["Value"] + + assert ec2_name_from_boto3 == ec2_name + + +def test_rename_ec2_instance_raise_exception(ec2, mocker): + ec2_client = Ec2Client(aws_region=pytest.aws_region) + ec2_client.ec2_client.create_tags = mocker.MagicMock(side_effect=Exception("Fake rename tags error")) + + with pytest.raises( + Ec2ClientException, + match=( + "Failed to rename instance: i-1234567890, new_name: test_instance. ERROR: Fake rename tags error" + ), + ): + ec2_client.rename_ec2_instance("i-1234567890", "test_instance") + + +def test_get_ec2_public_ip(ec2, ip="1.2.3.4"): + with create_eip(ec2, ip): + ami_id = ec2.describe_images()["Images"][0]["ImageId"] + instance_id = ec2.run_instances(ImageId=ami_id, MinCount=1, MaxCount=1)["Instances"][0]["InstanceId"] + ec2_client = Ec2Client(pytest.aws_region) + eip = ec2_client.get_eip(ip) + ec2_client.associate_eip(ec2_instance_id=instance_id, eip_id=eip.allocation_id) + associated_eip = ec2.describe_instances()["Reservations"][0]["Instances"][0]["PublicIpAddress"] + ec2_public_ip = ec2_client.get_ec2_public_ip(instance_id) + + assert associated_eip == ec2_public_ip + + +def test_get_ec2_public_ip_ambiguous_response_raise_exception(ec2, mocker): + ec2_client = Ec2Client(aws_region=pytest.aws_region) + ec2_client.ec2_client.describe_instances = mocker.MagicMock( + return_value={"Reservations": ["fake-1", "fake-2"]} + ) + + with pytest.raises(Ec2ClientException) as ex: + ec2_client.get_ec2_public_ip("i-1234567890") + + assert "Describe instance with filter for instance_id: i-1234567890" in str(ex.value) + + +def test_get_ec2_public_ip_raise_exception(ec2, mocker): + ec2_client = Ec2Client(aws_region=pytest.aws_region) + ec2_client.ec2_client.describe_instances = mocker.MagicMock( + side_effect=Exception("Describe Instances Error") + ) + + with pytest.raises( + Ec2ClientException, + match="Failed to get public ip_address of intance: i-1234567890. ERROR: Describe Instances Error", + ): + ec2_client.get_ec2_public_ip("i-1234567890") diff --git a/tests/mastko/lib/test_logger.py b/tests/mastko/lib/test_logger.py new file mode 100644 index 0000000..0b74ea3 --- /dev/null +++ b/tests/mastko/lib/test_logger.py @@ -0,0 +1,11 @@ +import os +from mastko.lib.logger import get_logger + + +def test_debug_setting(): + try: + os.environ["DEBUG"] = "true" + logger = get_logger("test_logger") + assert "DEBUG" in str(logger) + finally: + os.environ.pop("DEBUG") diff --git a/tests/mastko/test_mastko.py b/tests/mastko/test_mastko.py new file mode 100644 index 0000000..3c1d98d --- /dev/null +++ b/tests/mastko/test_mastko.py @@ -0,0 +1,23 @@ +from mastko.mastko import main + + +def test_main(mocker): + mock_get_parser = mocker.patch("mastko.mastko.get_parser") + mock_cmd_executer = mocker.patch("mastko.mastko.cmd_executer") + + main() + + mock_get_parser.assert_called_once() + mock_cmd_executer.assert_called_once_with(mock_get_parser.return_value) + + +def test_main_raise_exception(mocker): + mock_get_parser = mocker.patch("mastko.mastko.get_parser") + mock_cmd_executer = mocker.patch("mastko.mastko.cmd_executer", side_effect=Exception("fake-exception")) + mock_exit = mock_get_parser.return_value.exit = mocker.MagicMock() + + main() + + mock_get_parser.assert_called_once() + mock_cmd_executer.assert_called_once_with(mock_get_parser.return_value) + mock_exit.assert_called_once_with(1, "fake-exception") diff --git a/tests/mastko/test_version.py b/tests/mastko/test_version.py new file mode 100644 index 0000000..02dcbc3 --- /dev/null +++ b/tests/mastko/test_version.py @@ -0,0 +1,5 @@ +from mastko.version import __version__ + + +def test_version(): + assert type(__version__) is str diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..cdb8f66 --- /dev/null +++ b/tox.ini @@ -0,0 +1,54 @@ +[tox] +envlist = black,isort,autoformat,flake8,mypy,py39 + +[testenv] +deps = + -r{toxinidir}/requirements.txt + -r{toxinidir}/requirements.dev.txt + pytest-cov==4.0.0 +setenv = + PYTHONPATH=./mastko: +commands = + pytest --cov={toxinidir}/mastko --cov-branch --cov-report=term-missing {posargs} + +[testenv:black] +skip_install = true +basepython = python3 +deps = + black==23.3.0 +commands = + black --check --line-length 110 {toxinidir}/mastko/ + +[testenv:isort] +skip_install = true +basepython = python3 +deps = + isort==5.12.0 +commands = + isort --profile black --check-only --diff --src {toxinidir}/mastko/ {toxinidir}/mastko/ + +[testenv:flake8] +skip_install = true +basepython = python3 +deps = + flake8==6.0.0 +commands = + flake8 --max-line-length=110 --max-complexity=10 {toxinidir}/mastko/ + +[testenv:mypy] +skip_install = true +basepython = python3 +deps = + mypy==1.2.0 +commands = + mypy --disallow-untyped-defs --check-untyped-defs --install-types --non-interactive {toxinidir}/mastko/ --exclude '(^|/)tests/' + +[testenv:autoformat] +skip_install = true +basepython = python3 +deps = + {[testenv:black]deps} + {[testenv:isort]deps} +commands = + black --line-length 110 {toxinidir}/mastko/ + isort --atomic --profile black --src {toxinidir}/mastko/ {toxinidir}/mastko/ \ No newline at end of file