initial commit (#1)
* initial commit * updates to the README --------- Co-authored-by: Anuj Bhandar <anuj_bhandar@intuit.com> Co-authored-by: diophant0x <84159007+diophant0x@users.noreply.github.com>
This commit is contained in:
parent
2717a68262
commit
7323bf9db2
|
|
@ -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
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"recommendations": ["ms-python.black-formatter",]
|
||||
}
|
||||
|
|
@ -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",
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
```
|
||||
|
||||
|
|
@ -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]
|
||||
|
|
@ -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
|
||||
|
||||

|
||||
|
||||
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)
|
||||
|
|
@ -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<AWS::EC2::Image::Id>
|
||||
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
|
||||
|
|
@ -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
|
||||
|
|
@ -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):
|
||||
|
||||

|
||||
|
||||
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:
|
||||
|
||||

|
||||
|
||||
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:
|
||||
|
||||

|
||||
|
||||
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.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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."
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
from mastko.data.target import Target
|
||||
|
||||
# init DB
|
||||
Target.init_db_table()
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class Ec2:
|
||||
instance_id: str
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class EIP:
|
||||
allocation_id: str
|
||||
ip_address: str
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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}(?<!-)\.)+[A-Za-z]{2,63}\.?$"
|
||||
return bool(re.match(domain_re, domain))
|
||||
except TypeError:
|
||||
return False
|
||||
|
||||
def get_ips(self, domain: str, ip_list: Optional[List[str]] = None) -> 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)
|
||||
|
|
@ -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()
|
||||
|
|
@ -0,0 +1 @@
|
|||
__version__ = "1.0.0"
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
boto3==1.26.120
|
||||
requests==2.29.0
|
||||
tqdm==4.65.0
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
#!/bin/sh
|
||||
|
||||
BASE_DIR=$(git rev-parse --show-toplevel)
|
||||
|
||||
tox || exit $?
|
||||
|
||||
exit 0
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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")
|
||||
|
|
@ -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")
|
||||
|
|
@ -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
|
||||
|
|
@ -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")
|
||||
|
|
@ -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")
|
||||
|
|
@ -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")
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
from mastko.version import __version__
|
||||
|
||||
|
||||
def test_version():
|
||||
assert type(__version__) is str
|
||||
|
|
@ -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/
|
||||
Loading…
Reference in New Issue