├── .dockerignore ├── .github ├── actions │ └── pytest │ │ └── action.yml └── workflows │ ├── black.yml │ ├── build_nightly.yml │ ├── build_preview.yml │ └── build_release.yml ├── .gitignore ├── .terraform.lock.hcl ├── Dockerfile ├── LICENSE ├── README.md ├── argparsing.py ├── detection_enums.py ├── dev ├── docker-compose.yml ├── docker │ └── bind │ │ ├── db.punksecurity.io │ │ ├── named.conf.local │ │ └── named.conf.options └── readme.md ├── docker-bake.hcl ├── docs ├── README.md ├── aws.md ├── azure.md ├── bind.md ├── cloudflare.md ├── digitalocean.md ├── godaddy.md ├── googlecloud.md ├── projectdiscovery.md ├── reaper_detection.png ├── securitytrails.md └── zonetransfer.md ├── domain.py ├── finding.py ├── lambda-requirements.txt ├── lambda.py ├── lambda.tf ├── main.py ├── output.py ├── providers ├── __init__.py ├── aws.py ├── azure.py ├── bind.py ├── cloudflare.py ├── digitalocean.py ├── file.py ├── godaddy.py ├── googlecloud.py ├── projectdiscovery.py ├── readme.md ├── securitytrails.py ├── single.py └── zonetransfer.py ├── requirements.txt ├── resolver.py ├── scan.py ├── signatures ├── 000domains.py ├── __init__.py ├── _generic_cname_found_but_404_http.py ├── _generic_cname_found_but_404_https.py ├── _generic_cname_found_but_unregistered.py ├── _generic_cname_found_doesnt_resolve.py ├── _generic_zone_missing_on_ns.py ├── agilecrm.py ├── aha.py ├── airee_ru.py ├── anima.py ├── announcekit.py ├── aws_ns.py ├── bigcartel.py ├── bizland.py ├── brandpad.py ├── brightcove.py ├── campaign_monitor.py ├── cargo_collective.py ├── checks │ ├── A.py │ ├── AAAA.py │ ├── CNAME.py │ ├── COMBINED.py │ ├── NS.py │ ├── WEB.py │ ├── __init__.py │ └── helpers.py ├── convertkit.py ├── digitalocean.py ├── dnsmadeeasy.py ├── dnssimple.py ├── domain.py ├── dotster.py ├── elastic_beanstalk.py ├── frontify.py ├── getresponse.py ├── github_pages.py ├── googlecloud.py ├── hatenablog.py ├── helpscout.py ├── hostinger.py ├── hurricane_electric.py ├── jetbrains.py ├── launchrock_cname.py ├── linode.py ├── mashery.py ├── mediatemplate.py ├── microsoft_azure.py ├── mysmartjobboard.py ├── name.py ├── netlify.py ├── ngrok.py ├── nsone.py ├── readme.md ├── reg.py ├── shopify.py ├── short.py ├── simplebooklet.py ├── smartjobboard.py ├── surge.py ├── surveysparrow.py ├── teamwork.py ├── templates │ ├── __init__.py │ ├── base.py │ ├── cname_found_but_NX_DOMAIN.py │ ├── cname_found_but_status_code.py │ ├── cname_found_but_string_in_body.py │ ├── cname_or_ip_found_but_string_in_body.py │ ├── ip_found_but_string_in_body.py │ └── ns_found_but_no_SOA.py ├── thinkific.py ├── tierranet.py ├── tribe.py ├── tumblr.py ├── vendhq.py ├── webflow.py ├── wishpond.py ├── wix.py.old ├── wordpress_com_cname.py ├── wordpress_com_ns.py ├── zohoforms.py └── zohoforms_in.py ├── test-requirements.txt └── tests ├── __init__.py ├── mocks.py ├── readme.md ├── signatures_tests ├── __init__.py ├── checks │ ├── __init__.py │ ├── test_A.py │ ├── test_AAAA.py │ ├── test_CNAME.py │ ├── test_COMBINED.py │ ├── test_NS.py │ ├── test_WEB.py │ └── test_helpers.py ├── templates │ ├── __init__.py │ ├── test_cname_found_but_NX_DOMAIN.py │ ├── test_cname_found_but_status_code.py │ ├── test_cname_found_but_string_in_body.py │ ├── test_cname_or_ip_found_but_string_in_body.py │ ├── test_ip_found_but_string_in_body.py │ └── test_ns_found_but_no_SOA.py ├── test_generic_cname_found_but_404_http.py ├── test_generic_cname_found_but_404_https.py ├── test_generic_cname_found_but_unregistered.py ├── test_generic_cname_found_doesnt_resolve.py └── test_generic_zone_missing_on_ns.py └── test_signatures.py /.dockerignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # Exclude Output 7 | results.csv 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | pip-wheel-metadata/ 27 | share/python-wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .nox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | *.py,cover 54 | .hypothesis/ 55 | .pytest_cache/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | db.sqlite3 65 | db.sqlite3-journal 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | target/ 79 | 80 | # Jupyter Notebook 81 | .ipynb_checkpoints 82 | 83 | # IPython 84 | profile_default/ 85 | ipython_config.py 86 | 87 | # pyenv 88 | .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # Pycharm 135 | .idea 136 | 137 | /build 138 | 139 | .terraform 140 | 141 | .terraform.lock.hcl 142 | 143 | terraform.tfstate 144 | .terraform.tfstate.lock.info 145 | *.zip 146 | 147 | pdkey 148 | 149 | # exclude state 150 | terraform.tfstate 151 | terraform.tfstate.backup 152 | 153 | #exclude other stuff 154 | .github 155 | .vscode 156 | __pycache__ 157 | .git 158 | dev 159 | docs 160 | #tests 161 | -------------------------------------------------------------------------------- /.github/actions/pytest/action.yml: -------------------------------------------------------------------------------- 1 | name: 'Python runtime test' 2 | description: 'Test pwnSpoof against a given Python version' 3 | inputs: 4 | python-version: 5 | description: 'Python version' 6 | required: true 7 | runs: 8 | using: composite 9 | steps: 10 | - uses: actions/checkout@v2 11 | - name: Set up Python ${{ inputs.python-version }} 12 | uses: actions/setup-python@v1 13 | with: 14 | python-version: ${{ inputs.python-version }} 15 | - name: Python version 16 | id: python-version 17 | run: python --version 18 | shell: bash 19 | - name: install requirements 20 | run: python -m pip install -r requirements.txt -r test-requirements.txt 21 | shell: bash 22 | - name: output tests 23 | run: python -m pytest -v 24 | shell: bash 25 | -------------------------------------------------------------------------------- /.github/workflows/black.yml: -------------------------------------------------------------------------------- 1 | name: black 2 | on: [pull_request] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v3 8 | - name: Set up Python 3.11 9 | uses: actions/setup-python@v4 10 | with: 11 | python-version: '3.11' 12 | - name: Install Black 13 | run: pip install black 14 | - name: Run black --check . 15 | run: black --check . 16 | -------------------------------------------------------------------------------- /.github/workflows/build_nightly.yml: -------------------------------------------------------------------------------- 1 | name: Deploy nightly release 2 | on: 3 | schedule: 4 | - cron: '0 3 * * *' 5 | 6 | jobs: 7 | buildx: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v2 12 | - name: Login to DockerHub 13 | uses: docker/login-action@v1 14 | with: 15 | # TODO: replace username with action variable 16 | username: punksecurity 17 | password: ${{ secrets.DOCKER_ACCESS_TOKEN }} 18 | - name: Set up Docker Buildx 19 | id: buildx 20 | uses: docker/setup-buildx-action@v1 21 | - name: Build and push 22 | uses: docker/bake-action@v5 23 | with: 24 | push: true 25 | targets: "nightly" 26 | 27 | -------------------------------------------------------------------------------- /.github/workflows/build_preview.yml: -------------------------------------------------------------------------------- 1 | name: pytest 2 | 3 | on: 4 | pull_request: 5 | branches: [ main ] 6 | types: 7 | - synchronize 8 | - opened 9 | - reopened 10 | 11 | jobs: 12 | pytest: 13 | strategy: 14 | max-parallel: 1 15 | matrix: 16 | python-version: ["3.9", "3.10", "3.11", "3.12"] 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v2 20 | - name: Python runtime test 21 | uses: ./.github/actions/pytest 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | buildx: 25 | runs-on: ubuntu-latest 26 | steps: 27 | - name: Checkout 28 | uses: actions/checkout@v2 29 | - name: Set up Docker Buildx 30 | id: buildx 31 | uses: docker/setup-buildx-action@v1 32 | - name: Build and push 33 | uses: docker/bake-action@v5 34 | env: 35 | VERSION: ${{ github.run_id }} 36 | with: 37 | targets: "preview" 38 | -------------------------------------------------------------------------------- /.github/workflows/build_release.yml: -------------------------------------------------------------------------------- 1 | name: Build and publish release on new tag 2 | on: 3 | push: 4 | tags: 5 | - '*' 6 | 7 | jobs: 8 | buildx: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v2 13 | - name: Login to DockerHub 14 | uses: docker/login-action@v1 15 | with: 16 | # TODO: replace username with action variable 17 | username: punksecurity 18 | password: ${{ secrets.DOCKER_ACCESS_TOKEN }} 19 | - name: version 20 | run: echo ::set-output name=version::$(echo $GITHUB_REF | cut -d / -f 3) 21 | id: version 22 | - name: Set up Docker Buildx 23 | id: buildx 24 | uses: docker/setup-buildx-action@v1 25 | - name: Build and push 26 | uses: docker/bake-action@v5 27 | env: 28 | VERSION: ${{ steps.version.outputs.version }} 29 | with: 30 | push: true 31 | targets: "release" 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # Exclude Output 7 | results.csv 8 | 9 | # exclude pdkey 10 | pdkey 11 | 12 | # exclude state 13 | terraform.tfstate 14 | terraform.tfstate.backup 15 | 16 | # C extensions 17 | *.so 18 | 19 | # Distribution / packaging 20 | .Python 21 | build/ 22 | develop-eggs/ 23 | dist/ 24 | downloads/ 25 | eggs/ 26 | .eggs/ 27 | lib/ 28 | lib64/ 29 | parts/ 30 | sdist/ 31 | var/ 32 | wheels/ 33 | pip-wheel-metadata/ 34 | share/python-wheels/ 35 | *.egg-info/ 36 | .installed.cfg 37 | *.egg 38 | MANIFEST 39 | 40 | # PyInstaller 41 | # Usually these files are written by a python script from a template 42 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 43 | *.manifest 44 | *.spec 45 | 46 | # Installer logs 47 | pip-log.txt 48 | pip-delete-this-directory.txt 49 | 50 | # Unit test / coverage reports 51 | htmlcov/ 52 | .tox/ 53 | .nox/ 54 | .coverage 55 | .coverage.* 56 | .cache 57 | nosetests.xml 58 | coverage.xml 59 | *.cover 60 | *.py,cover 61 | .hypothesis/ 62 | .pytest_cache/ 63 | 64 | # Translations 65 | *.mo 66 | *.pot 67 | 68 | # Django stuff: 69 | *.log 70 | local_settings.py 71 | db.sqlite3 72 | db.sqlite3-journal 73 | 74 | # Flask stuff: 75 | instance/ 76 | .webassets-cache 77 | 78 | # Scrapy stuff: 79 | .scrapy 80 | 81 | # Sphinx documentation 82 | docs/_build/ 83 | 84 | # PyBuilder 85 | target/ 86 | 87 | # Jupyter Notebook 88 | .ipynb_checkpoints 89 | 90 | # IPython 91 | profile_default/ 92 | ipython_config.py 93 | 94 | # pyenv 95 | .python-version 96 | 97 | # pipenv 98 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 99 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 100 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 101 | # install all needed dependencies. 102 | #Pipfile.lock 103 | 104 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 105 | __pypackages__/ 106 | 107 | # Celery stuff 108 | celerybeat-schedule 109 | celerybeat.pid 110 | 111 | # SageMath parsed files 112 | *.sage.py 113 | 114 | # Environments 115 | .env 116 | .venv 117 | env/ 118 | venv/ 119 | ENV/ 120 | env.bak/ 121 | venv.bak/ 122 | 123 | # Spyder project settings 124 | .spyderproject 125 | .spyproject 126 | 127 | # Rope project settings 128 | .ropeproject 129 | 130 | # mkdocs documentation 131 | /site 132 | 133 | # mypy 134 | .mypy_cache/ 135 | .dmypy.json 136 | dmypy.json 137 | 138 | # Pyre type checker 139 | .pyre/ 140 | 141 | # Pycharm 142 | .idea 143 | -------------------------------------------------------------------------------- /.terraform.lock.hcl: -------------------------------------------------------------------------------- 1 | # This file is maintained automatically by "terraform init". 2 | # Manual edits may be lost in future updates. 3 | 4 | provider "registry.terraform.io/hashicorp/aws" { 5 | version = "5.40.0" 6 | constraints = ">= 5.16.0, < 6.0.0" 7 | hashes = [ 8 | "h1:M28qqKLKQrRTC99lWmytVdDyAk0FgTIgH3TI9yPYhjY=", 9 | "zh:11f177a2385703740bd26d0652d3dba08575101d7639f386ce5637bdb0e29a13", 10 | "zh:203fc43e69634f1bd487a9dc24b01944dfd568beac78e491f26677d103d343ed", 11 | "zh:3697ebad4929da30ea98276a85d4ce5ebfc48508f4dd149e17e1dcdc7f306c6e", 12 | "zh:421e0799756587e728f75a9024b8d4e38707cd6d65cf0710cb8d189062c85a58", 13 | "zh:4be2adcd4c32a66159c532908f0d425d793c814b3686832e9af549b1515ae032", 14 | "zh:55778b32470212ce6bbfd402529c88e7ea6ba34b0882f85d6ea001ff5c6255a5", 15 | "zh:689a4c1fd1e1d5dab7b169759389c76f25e366f19a470971674321d6fca09791", 16 | "zh:68a23eda608573a053e8738894457bd0c11766bc243e68826c78ab6b5a144710", 17 | "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", 18 | "zh:a1580115c22564e5752e569dc40482503de6cced44da3e9431885cd9d4bf18ea", 19 | "zh:b127756d7ee513691e76c211570580c10eaa2f7a7e4fd27c3566a48ec214991c", 20 | "zh:b7ccea7a759940c8dcf8726272eed6653eed0b31f7223f71e829a344627afd39", 21 | "zh:bb130fc50494fd45406e04b44d242da9a8f138a4a43feb65cf9e86d13aa13629", 22 | "zh:cf1c972c90d5f22c9705274a33792275e284a0a3fcac12ce4083b5a4480463f4", 23 | "zh:ebe60d3887b23703ca6a4c65b15c6d7b8d93ba27a028d996d17882fe6e98d5c0", 24 | ] 25 | } 26 | 27 | provider "registry.terraform.io/hashicorp/null" { 28 | version = "3.2.2" 29 | hashes = [ 30 | "h1:JViWrgF7Ks2GqB6UfcLDUbusXeSfhfhFymo4c0N5e+I=", 31 | "zh:3248aae6a2198f3ec8394218d05bd5e42be59f43a3a7c0b71c66ec0df08b69e7", 32 | "zh:32b1aaa1c3013d33c245493f4a65465eab9436b454d250102729321a44c8ab9a", 33 | "zh:38eff7e470acb48f66380a73a5c7cdd76cc9b9c9ba9a7249c7991488abe22fe3", 34 | "zh:4c2f1faee67af104f5f9e711c4574ff4d298afaa8a420680b0cb55d7bbc65606", 35 | "zh:544b33b757c0b954dbb87db83a5ad921edd61f02f1dc86c6186a5ea86465b546", 36 | "zh:696cf785090e1e8cf1587499516b0494f47413b43cb99877ad97f5d0de3dc539", 37 | "zh:6e301f34757b5d265ae44467d95306d61bef5e41930be1365f5a8dcf80f59452", 38 | "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", 39 | "zh:913a929070c819e59e94bb37a2a253c228f83921136ff4a7aa1a178c7cce5422", 40 | "zh:aa9015926cd152425dbf86d1abdbc74bfe0e1ba3d26b3db35051d7b9ca9f72ae", 41 | "zh:bb04798b016e1e1d49bcc76d62c53b56c88c63d6f2dfe38821afef17c416a0e1", 42 | "zh:c23084e1b23577de22603cff752e59128d83cfecc2e6819edadd8cf7a10af11e", 43 | ] 44 | } 45 | 46 | provider "registry.terraform.io/hashicorp/time" { 47 | version = "0.10.0" 48 | hashes = [ 49 | "h1:XiRMsGFEe6VTWGL0O32l8viW2fI8wXyJFRJYfdQR8os=", 50 | "zh:0ab31efe760cc86c9eef9e8eb070ae9e15c52c617243bbd9041632d44ea70781", 51 | "zh:0ee4e906e28f23c598632eeac297ab098d6d6a90629d15516814ab90ad42aec8", 52 | "zh:3bbb3e9da728b82428c6f18533b5b7c014e8ff1b8d9b2587107c966b985e5bcc", 53 | "zh:6771c72db4e4486f2c2603c81dfddd9e28b6554d1ded2996b4cb37f887b467de", 54 | "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", 55 | "zh:833c636d86c2c8f23296a7da5d492bdfd7260e22899fc8af8cc3937eb41a7391", 56 | "zh:c545f1497ae0978ffc979645e594b57ff06c30b4144486f4f362d686366e2e42", 57 | "zh:def83c6a85db611b8f1d996d32869f59397c23b8b78e39a978c8a2296b0588b2", 58 | "zh:df9579b72cc8e5fac6efee20c7d0a8b72d3d859b50828b1c473d620ab939e2c7", 59 | "zh:e281a8ecbb33c185e2d0976dc526c93b7359e3ffdc8130df7422863f4952c00e", 60 | "zh:ecb1af3ae67ac7933b5630606672c94ec1f54b119bf77d3091f16d55ab634461", 61 | "zh:f8109f13e07a741e1e8a52134f84583f97a819e33600be44623a21f6424d6593", 62 | ] 63 | } 64 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11-alpine AS builder 2 | RUN apk add gcc libffi musl-dev libffi-dev 3 | 4 | # Create app directory 5 | RUN python -m venv /opt/venv 6 | ENV PATH "/opt/venv/bin:$PATH" 7 | COPY requirements.txt . 8 | RUN pip install --no-cache-dir -r requirements.txt 9 | 10 | # Prepares a zipped version of the application suitable for running on lambda 11 | FROM amazon/aws-lambda-python:3.11 AS lambda 12 | 13 | COPY requirements.txt /app/ 14 | COPY lambda-requirements.txt /app/ 15 | WORKDIR /app 16 | RUN pip install -r requirements.txt -r lambda-requirements.txt --target . 17 | COPY . /app 18 | 19 | RUN yum install zip -y && zip -r9 /packaged_app.zip . 20 | 21 | # Main application target 22 | FROM python:3.11-alpine 23 | COPY --from=builder /opt/venv /opt/venv 24 | ENV PATH "/opt/venv/bin:$PATH" 25 | 26 | RUN mkdir -p /app/results 27 | WORKDIR /app 28 | 29 | COPY . . 30 | 31 | # Exports 32 | ENV SM_COMMAND "docker run punksecurity/dnsreaper --" 33 | ENTRYPOINT [ "python3", "/app/main.py" ] 34 | 35 | -------------------------------------------------------------------------------- /argparsing.py: -------------------------------------------------------------------------------- 1 | import colorama 2 | import argparse 3 | from os import linesep, environ 4 | import sys 5 | 6 | import providers 7 | import inspect 8 | 9 | runtime = environ.get("SM_COMMAND", f"{sys.argv[0]}") 10 | 11 | banner = """\ 12 | ____ __ _____ _ __ 13 | / __ \__ ______ / /__/ ___/___ _______ _______(_) /___ __ 14 | / /_/ / / / / __ \/ //_/\__ \/ _ \/ ___/ / / / ___/ / __/ / / / 15 | / ____/ /_/ / / / / ,< ___/ / __/ /__/ /_/ / / / / /_/ /_/ / 16 | /_/ \__,_/_/ /_/_/|_|/____/\___/\___/\__,_/_/ /_/\__/\__, / 17 | PRESENTS /____/ 18 | DNS Reaper ☠️ 19 | 20 | Scan all your DNS records for subdomain takeovers! 21 | """ 22 | 23 | banner_with_colour = ( 24 | colorama.Fore.GREEN 25 | + """\ 26 | ____ __ _____ _ __ 27 | / __ \__ ______ / /__/ ___/___ _______ _______(_) /___ __ 28 | / /_/ / / / / __ \/ //_/\__ \/ _ \/ ___/ / / / ___/ / __/ / / / 29 | / ____/ /_/ / / / / ,< ___/ / __/ /__/ /_/ / / / / /_/ /_/ / 30 | /_/ \__,_/_/ /_/_/|_|/____/\___/\___/\__,_/_/ /_/\__/\__, / 31 | PRESENTS /____/""" 32 | + colorama.Fore.RED 33 | + """ 34 | DNS Reaper ☠️""" 35 | + colorama.Fore.CYAN 36 | + """ 37 | 38 | Scan all your DNS records for subdomain takeovers! 39 | """ 40 | + colorama.Fore.RESET 41 | ) 42 | 43 | 44 | class CustomParser(argparse.ArgumentParser): 45 | def error(self, message): 46 | sys.stdout.write(f" ❌ error: {message}{linesep}{linesep}") 47 | self.print_usage() 48 | sys.exit(2) 49 | 50 | 51 | parser = CustomParser( 52 | usage=f""" 53 | {runtime} provider [options] 54 | 55 | output: 56 | findings output to screen and (by default) results.csv 57 | 58 | help: 59 | {runtime} --help 60 | 61 | providers: 62 | { linesep.join([f" > {provider} - {getattr(providers, provider).description}" for provider in providers.__all__ ]) } 63 | """, 64 | formatter_class=argparse.RawDescriptionHelpFormatter, 65 | description="", 66 | add_help=False, 67 | ) 68 | 69 | parser.add_argument( 70 | "provider", 71 | type=str, 72 | choices=providers.__all__, 73 | ) 74 | 75 | for provider in providers.__all__: 76 | group = parser.add_argument_group(provider) 77 | module = getattr(providers, provider) 78 | group.description = module.description 79 | signature = inspect.signature(module.fetch_domains) 80 | parameters = signature.parameters.items() 81 | for parameter in [ 82 | x[1] 83 | for x in parameters 84 | if x[1].kind != x[1].VAR_KEYWORD and x[1].kind != x[1].VAR_POSITIONAL 85 | ]: 86 | group.add_argument( 87 | f"--{parameter.name.replace('_','-')}", 88 | type=str, 89 | help=( 90 | "Required" 91 | if isinstance(parameter.default, type(parameter.empty)) 92 | else "Optional" 93 | ), 94 | ) 95 | 96 | parser.add_argument( 97 | "-h", 98 | "--help", 99 | action="help", 100 | help="Show this help message and exit", 101 | ) 102 | 103 | parser.add_argument( 104 | "--out", 105 | type=str, 106 | default="results", 107 | help="Output file (default: %(default)s) - use 'stdout' to stream out", 108 | ) 109 | 110 | parser.add_argument( 111 | "--out-format", 112 | type=str, 113 | default="csv", 114 | choices=["csv", "json"], 115 | ) 116 | 117 | parser.add_argument( 118 | "--resolver", 119 | type=str, 120 | default="8.8.8.8,8.8.4.4,1.1.1.1,1.0.0.1,208.67.222.2,208.67.220.2", 121 | help="Provide a custom DNS resolver (or multiple seperated by commas), or '' to use system resolver. We loadbalance a public resolver list by default", 122 | ) 123 | 124 | 125 | parser.add_argument( 126 | "--parallelism", 127 | type=int, 128 | default=30, 129 | help="Number of domains to test in parallel - too high and you may see odd DNS results (default: %(default)s)", 130 | ) 131 | 132 | parser.add_argument( 133 | "--disable-probable", 134 | action="store_true", 135 | help="Do not check for probable conditions", 136 | ) 137 | 138 | parser.add_argument( 139 | "--enable-unlikely", 140 | action="store_true", 141 | help="Check for more conditions, but with a high false positive rate", 142 | ) 143 | 144 | parser.add_argument( 145 | "--signature", 146 | action="append", 147 | help="Only scan with this signature (multiple accepted)", 148 | ) 149 | 150 | parser.add_argument( 151 | "--exclude-signature", 152 | action="append", 153 | help="Do not scan with this signature (multiple accepted)", 154 | ) 155 | 156 | parser.add_argument( 157 | "--pipeline", 158 | action="store_true", 159 | help="Exit Non-Zero on detection (used to fail a pipeline)", 160 | ) 161 | 162 | parser.add_argument( 163 | "-v", 164 | "--verbose", 165 | action="count", 166 | default=0, 167 | help="-v for verbose, -vv for extra verbose", 168 | ) 169 | 170 | parser.add_argument("--nocolour", help="Turns off coloured text", action="store_true") 171 | 172 | 173 | def parse_args(): 174 | args = parser.parse_args() 175 | module = getattr(providers, args.provider) 176 | signature = inspect.signature(module.fetch_domains) 177 | parameters = signature.parameters.items() 178 | for parameter in [ 179 | x[1] 180 | for x in parameters 181 | if x[1].kind != x[1].VAR_KEYWORD and x[1].kind != x[1].VAR_POSITIONAL 182 | ]: 183 | # If provider function signature has a default value, the command line option is optional! 184 | if args.__dict__[parameter.name] is None and isinstance( 185 | parameter.default, type(parameter.empty) 186 | ): 187 | parser.error( 188 | f" {args.provider} provider requires --{parameter.name.replace('_', '-')}" 189 | ) 190 | return args 191 | -------------------------------------------------------------------------------- /detection_enums.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class CONFIDENCE(Enum): 5 | CONFIRMED = "is confirmed possible" 6 | POTENTIAL = "may be possible" 7 | UNLIKELY = "maybe be possible (although unlikely)" 8 | -------------------------------------------------------------------------------- /dev/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | services: 3 | bind: 4 | image: ubuntu/bind9 5 | environment: 6 | TZ: UTC 7 | ports: 8 | - "53:53" 9 | volumes: 10 | - "./docker/bind/db.punksecurity.io:/etc/bind/db.punksecurity.io" 11 | - "./docker/bind/named.conf.local:/etc/bind/named.conf.local" 12 | - "./docker/bind/named.conf.options:/etc/bind/named.conf.options" -------------------------------------------------------------------------------- /dev/docker/bind/db.punksecurity.io: -------------------------------------------------------------------------------- 1 | $ORIGIN punksecurity.io. 2 | @ 3600 IN SOA punksecurity.io. root.punksecurity.io. 2041230773 7200 3600 86400 3600 3 | IN NS ns1.localhost.net. 4 | 5 | ;; NS Records 6 | vulnerable 1 IN NS ns-40.awsdns-05.com. 7 | -------------------------------------------------------------------------------- /dev/docker/bind/named.conf.local: -------------------------------------------------------------------------------- 1 | zone "punksecurity.io" { 2 | type master; 3 | file "/etc/bind/db.punksecurity.io"; # zone file path 4 | }; 5 | -------------------------------------------------------------------------------- /dev/docker/bind/named.conf.options: -------------------------------------------------------------------------------- 1 | options { 2 | directory "/var/cache/bind"; 3 | 4 | // If there is a firewall between you and nameservers you want 5 | // to talk to, you may need to fix the firewall to allow multiple 6 | // ports to talk. See http://www.kb.cert.org/vuls/id/800113 7 | 8 | // If your ISP provided one or more IP addresses for stable 9 | // nameservers, you probably want to use them as forwarders. 10 | // Uncomment the following block, and insert the addresses replacing 11 | // the all-0's placeholder. 12 | 13 | // forwarders { 14 | // 0.0.0.0; 15 | // }; 16 | 17 | //======================================================================== 18 | // If BIND logs error messages about the root key being expired, 19 | // you will need to update your keys. See https://www.isc.org/bind-keys 20 | //======================================================================== 21 | dnssec-validation auto; 22 | 23 | listen-on-v6 { any; }; 24 | }; 25 | -------------------------------------------------------------------------------- /dev/readme.md: -------------------------------------------------------------------------------- 1 | # Development 2 | This directory contains resources to aid in the development of dnsReaper. 3 | 4 | ## Docker Compose 5 | Currently, only a BIND server is provided. 6 | 7 | Start dev services: 8 | `docker-compose up -d` -------------------------------------------------------------------------------- /docker-bake.hcl: -------------------------------------------------------------------------------- 1 | variable "VERSION" { 2 | 3 | } 4 | 5 | target "base" { 6 | context = "." 7 | dockerfile = "Dockerfile" 8 | platforms = ["linux/amd64", "linux/arm64"] 9 | } 10 | 11 | target "nightly" { 12 | inherits = ["base"] 13 | tags = ["docker.io/punksecurity/dnsreaper:nightly"] 14 | } 15 | 16 | target "release" { 17 | inherits = ["base"] 18 | tags = ["docker.io/punksecurity/dnsreaper:${VERSION}", "docker.io/punksecurity/dnsreaper:latest"] 19 | } 20 | 21 | target "preview" { 22 | inherits = ["base"] 23 | tags = ["docker.io/punksecurity/dnsreaper:preview-${VERSION}"] 24 | } 25 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Index 2 | 3 | ## Providers 4 | - [AWS](aws.md) 5 | - [Azure](azure.md) 6 | - [BIND](bind.md) 7 | - [CloudFlare](cloudflare.md) 8 | - [Digital Ocean](digitalocean.md) 9 | - [GoDaddy](godaddy.md) 10 | - [Google Cloud](googlecloud.md) 11 | - [SecurityTrails](securitytrails.md) 12 | - [Project Discovery](projectdiscovery.md) 13 | - [Zone Transfer](zonetransfer.md) 14 | -------------------------------------------------------------------------------- /docs/aws.md: -------------------------------------------------------------------------------- 1 | # AWS 2 | 3 | ## Description 4 | The AWS provider connects to AWS and fetches all public zones and then enumerates the records in these zones. 5 | 6 | To enumerate Route53 zones, you need to provide an access key id and secret with IAM permissions 7 | to list and get Route53 zones. This is done through standard switches like all other options. 8 | 9 | 10 | ## Usage 11 | The command-line options `--aws-access-key-id` and `--aws-access-key-secret` can be used to specify credentials. 12 | 13 | If you do not provide these options, the AWS provider will use the following ways of obtaining credentials, in order: 14 | 1. Environment variables 15 | 2. Shared credential file (~/.aws/credentials) 16 | 3. AWS config file (~/.aws/config) 17 | 4. Assume Role provider 18 | 5. Boto2 config file (/etc/boto.cfg and ~/.boto) 19 | 6. Instance metadata service on an Amazon EC2 instance that has an IAM role configured. 20 | 21 | For more information, please see the 22 | [boto3 documentation](https://boto3.amazonaws.com/v1/documentation/api/latest/guide/credentials.html). 23 | 24 | ## Requirements 25 | As a minimum you need: 26 | * GetHostedZone 27 | * ListHostedZones 28 | * ListResourceRecordSets 29 | 30 | A suggested inline policy for the account would be: 31 | 32 | ``` 33 | { 34 | "Version": "2012-10-17", 35 | "Statement": [ 36 | { 37 | "Effect": "Allow", 38 | "Action": [ 39 | "route53:GetHostedZone", 40 | "route53:ListHostedZones", 41 | "route53:ListResourceRecordSets" 42 | ], 43 | "Resource": "*" 44 | } 45 | ] 46 | } 47 | ``` -------------------------------------------------------------------------------- /docs/azure.md: -------------------------------------------------------------------------------- 1 | # Azure DNS 2 | Azure DNS is a hosting service for DNS domains that provides DNS resolution via the Azure infrastructure. 3 | [Docs](https://docs.microsoft.com/en-us/python/api/overview/azure/dns?view=azure-python) 4 | 5 | There are multiple ways to authenticate with Azure. We only endorse the use of Service Princples, as username and passwords should only be backed with MFA. 6 | 7 | To interact with Azure DNS zone the following are required: 8 | - Subsciption ID 9 | - Tenant ID 10 | - Client ID 11 | - Client Secret 12 | 13 | ## Create Application registration 14 | We use application registration to create a service principle , which can be used instead of a user account for a very specific task in an automated/non-interactive function. 15 | 16 | Please review [Microsoft Docs](https://docs.microsoft.com/en-us/azure/active-directory/develop/quickstart-register-app) for more details. 17 | 18 | 1. Sign in to the [Azure portal](https://portal.azure.com/). 19 | 2. Search for and select **Azure Active Directory** 20 | 3. Under Manage, select **App registrations** > **New registration** 21 | 4. Enter a display Name for your application e.g. dnsReaper 22 | 6. Specify who can use the application, sometimes called its sign-in audience. We recommend using **Accounts in this organizational directory only** 23 | 7. Don't enter anything for Redirect URI. 24 | 8. Select **Register** to complete the initial app registration. 25 | 9. Select **Overview** 26 | 10. Make a record of the following fields 27 | - Application (Client) ID 28 | - Directory (tenant) ID 29 | 11. Select **Certificates & secrets** > **Client secrets** > **New client secret** 30 | 12. Add a description for your client secret. 31 | Select an expiration for the secret or specify a custom lifetime. 32 | - Client secret lifetime is limited to two years (24 months) or less. You can't specify a custom lifetime longer than 24 months. 33 | - Microsoft recommends that you set an expiration value of less than 12 months. 34 | 13. Select Add. 35 | 14. Record the secret's value for use in your client application code. This secret value is never displayed again after you leave this page, so make a note or save it in a password vault. 36 | 15. Select **API permission** > **Client secrets** > **New client secret** 37 | 16. Click on **Crant admin consent** 38 | 39 | ## Assign Service Principle to DNS zones 40 | We now need to assign the service principle permissions on the DNS zones which are in scope. 41 | 42 | 1. Sign in to the [Azure portal](https://portal.azure.com/). 43 | 2. Search for and select **DNS zones** 44 | 3. For each of the DNS zones do the following 45 | 1. Click on the DNS name 46 | 2. Select **Access control (IAM)** > **Add role assignment** 47 | 3. Select **Reader** 48 | 4. Click **next** 49 | 5. Click on **+ Select members** 50 | 6. Type in the Application resgitration name created above e.g. dnsReaper 51 | 7. Click on **Review + assign** 52 | 8. Click on **Review + assign** 53 | 54 | ## TODO - Future enhancements 55 | - Client certificates 56 | - Enhancements to README -------------------------------------------------------------------------------- /docs/bind.md: -------------------------------------------------------------------------------- 1 | # BIND 2 | 3 | The BIND provider reads in domains from either a single bind zone file or a folder containing multiple zone files. If targeting a directory, it should contain ONLY zone files. 4 | 5 | Lots of DNS providers have a mechanism to export to a BIND zone file, which means this provider can provide a standard interface to lots of providers (such as GoDaddy) 6 | 7 | An example zonefile is provided below: 8 | 9 | ``` 10 | $ORIGIN example.com. ; designates the start of this zone file in the namespace 11 | $TTL 3600 ; default expiration time (in seconds) of all RRs without their own TTL value 12 | example.com. IN SOA ns.example.com. username.example.com. ( 2020091025 7200 3600 1209600 3600 ) 13 | example.com. IN NS ns ; ns.example.com is a nameserver for example.com 14 | example.com. IN NS ns.somewhere.example. ; ns.somewhere.example is a backup nameserver for example.com 15 | example.com. IN MX 10 mail.example.com. ; mail.example.com is the mailserver for example.com 16 | @ IN MX 20 mail2.example.com. ; equivalent to above line, "@" represents zone origin 17 | @ IN MX 50 mail3 ; equivalent to above line, but using a relative host name 18 | example.com. IN A 192.0.2.1 ; IPv4 address for example.com 19 | IN AAAA 2001:db8:10::1 ; IPv6 address for example.com 20 | ns IN A 192.0.2.2 ; IPv4 address for ns.example.com 21 | IN AAAA 2001:db8:10::2 ; IPv6 address for ns.example.com 22 | www IN CNAME example.com. ; www.example.com is an alias for example.com 23 | wwwtest IN CNAME www ; wwwtest.example.com is another alias for www.example.com 24 | mail IN A 192.0.2.3 ; IPv4 address for mail.example.com 25 | mail2 IN A 192.0.2.4 ; IPv4 address for mail2.example.com 26 | mail3 IN A 192.0.2.5 ; IPv4 address for mail3.example.com 27 | ``` -------------------------------------------------------------------------------- /docs/cloudflare.md: -------------------------------------------------------------------------------- 1 | # Cloudflare 2 | 3 | This page contains specific information about the Cloudflare provider module. 4 | 5 | Our Cloudflare provider will utilise the Cloudflare client provider located in [github](https://github.com/cloudflare/python-cloudflare) 6 | 7 | The Cloudflare module will require read only rights to the Domain zones in Cloudflare, in order to read the DNS records. 8 | 9 | ## Create Cloudflare token 10 | 11 | To get started creating an API Token, 12 | 13 | 1. Log in to the Cloudflare dashboard 14 | 2. Go to User Profile -> API Tokens 15 | 3. From the API Token home screen select **Create Token**. 16 | 4. Select **Use Template** on the **Edit zone DNS** 17 | 5. Change the **Token Name** from **Edit zone DNS** to **Read zone DNS** 18 | 5. Change the permission from **Edit** to **Read** 19 | 6. This step is optional, but you can edit the **Zone Resource** to restrict to a specific account or Domain DNS. 20 | 7. Click **Continue to summary** 21 | 8. Extract the API token and store in a safe location 22 | 23 | -------------------------------------------------------------------------------- /docs/digitalocean.md: -------------------------------------------------------------------------------- 1 | # DigitalOcean 2 | 3 | ## Description 4 | The DigitalOcean provider connects to the DigitalOcean API and retrieves domains and records. 5 | It can enumerate all available domains, or alternatively you can supply a comma-separated list of domains to limit 6 | the scope to. 7 | 8 | # Usage 9 | The `--do-api-key` option is used to provide your DigitalOcean API Key. API keys are available from the DigitalOcean 10 | control panel (click API in the sidebar, or [here for a direct link](https://cloud.digitalocean.com/account/api/tokens)). 11 | 12 | The API key should be limited to read-only access. 13 | 14 | The `--do-domains` option is used to limit the domains that are being scanned. Multiple domains can be provided by separating 15 | each domain with a comma, eg: 16 | `--do-domains first.domain.example,second.domain.example` 17 | -------------------------------------------------------------------------------- /docs/godaddy.md: -------------------------------------------------------------------------------- 1 | # GoDaddy 2 | 3 | ## WARNING: GoDaddy now block API access unless you have 10 domains and email their support. Craaaazy! 4 | 5 | ## Description 6 | The GoDaddy provider connects to the GoDaddy API and retrieves domains and records. 7 | 8 | It can enumerate all available domains, or alternatively you can supply a comma-separated list of domains to limit 9 | the scope to. 10 | 11 | ## Create a GoDaddy API Key and Secret 12 | To get started creating an API Key and Secret, 13 | 14 | 1. Log in to your GoDaddy account 15 | 2. Navigate to `https://developer.godaddy.com/` 16 | 3. Select API Keys 17 | 4. Click the **Create New API key button** 18 | 5. Give the API Key a name (if required) and under `Environment`, select an appropriate option. If you are unsure, select `Production` 19 | 6. Make a note of the `API Key` and `API Secret`. **The secret is only viewable once, so make sure the note it down** 20 | 21 | ## Usage 22 | The `--gd-api-key` option is used to provide your GoDaddy API Key. API keys are available from the GoDaddy 23 | developer console ([click here for a direct link](https://developer.godaddy.com/keys)). 24 | 25 | The `--gd-api-secret` option is used to provide your GoDaddy API Secret. 26 | 27 | The `--gd-domains` option is used to limit the domains that are being scanned. Multiple domains can be provided by separating 28 | each domain with a comma, eg: 29 | `--gd-domains first.domain.example,second.domain.example` 30 | -------------------------------------------------------------------------------- /docs/googlecloud.md: -------------------------------------------------------------------------------- 1 | # Google Cloud 2 | 3 | ## Description 4 | The Google Cloud provider connects to Google Cloud and retrieves all records associated with a domain. 5 | 6 | To enumerate a domain's subdomains, you need to provide a valid project ID and set the `GOOGLE_APPLICATION_CREDENTIALS` to your JSON credential file's full path. 7 | 8 | ## Create Google Cloud Private Key 9 | To to get the Private key: 10 | 11 | 1. Navigate to https://cloud.google.com/ and select **Go to console**, or [click here](https://console.cloud.google.com/) 12 | 1. Make sure the correct project by checking the drop down at top left of the page. If the project needs to be changed, click the drop down and select the correct project from the popup 13 | 1. Select on `API & Services` under Quick Access 14 | 1. On the side bar, select `Enabled APIs & services` 15 | 1. At the top of the page, select **+ Enable APIs and Services** 16 | 1. In the search bar, search for `Cloud DNS API`, and select `Cloud DNS API` 17 | 1. On the `Cloud DNS API`, select **Enable**. 18 | 1. Then, on the `Cloud DNS API` page, select the `Credentials` tab 19 | 1. Below the `Credentials compatible with this API` section, go to `Service Accounts`, and click on the `Manage service accounts` 20 | 1. At the top of the page, select **+ Create Service Account** 21 | 1. Give the service a `Service account ID` 22 | 1. Click **Create and Continue** 23 | 1. Click the `Select a role` dropdown, scroll down to `DNS`, then select `DNS Reader` 24 | 1. Then, click **Done** 25 | 1. Back on the Service Account page, select the service account you just made 26 | 1. Select the `Keys` tab at the top 27 | 1. Click the `Add Key` dropdown and select `Create new key` 28 | 1. Ensure the type `JSON` is selected and click `Create` 29 | 1. This will download a JSON file to your computer, which can be moved to a suitable location 30 | 1. Copy down the full path of the credential file's location, including the file's name and extension 31 | 1. Save the file path to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable 32 | a. On Linux or MacOS: 33 | ``` 34 | export GOOGLE_APPLICATION_CREDENTIALS="/path/to/json/file.json" 35 | ``` 36 | 37 | b. On Windows: 38 | Powershell 39 | ``` 40 | $ENV:GOOGLE_APPLICATION_CREDENTIALS="C:\\path\\to\\file\\file.json" 41 | ``` 42 | 43 | CMD 44 | ``` 45 | set GOOGLE_APPLICATION_CREDENTIALS="C:\\path\\to\\file\\file.json" 46 | ``` 47 | 48 | To get the project id: 49 | 50 | 1. Navigate to https://cloud.google.com/ and select **Go to console**, or [click here](https://console.cloud.google.com/) 51 | 2. The project idea will be shown on the `Welcome page`. If the correct project isn't selected, click on the dropdown at the top left of the page. From here, you can search for your project and copy the project id under the ID column. 52 | 53 | ## Usage 54 | The `--project-id` option is used to provide your Google Cloud project's ID. 55 | 56 | The `GOOGLE_APPLICATION_CREDENTIALS` is an environment variable used to tell DNSReaper the location of your JSON credential file. 57 | 58 | ## Docker Usage 59 | To set up Google Cloud with docker, you will need to mount the JSON credential file. 60 | 61 | To mount the file: 62 | On Windows: 63 | ``` 64 | docker run -v C:\file\path\containing\credentials.json:/app/credentials.json 65 | ``` 66 | On Linux and MacOS 67 | ``` 68 | docker run -v /local/path/to/credentials.json:/app/credentials.json 69 | ``` 70 | 71 | To pass the environment variable: 72 | ``` 73 | -e GOOGLE_APPLICATION_CREDENTIALS='/app/credentials.json' 74 | ``` 75 | 76 | The full command would look like this: 77 | ``` 78 | docker run punksecurity/dnsreaper -v /local/path/to/credentials.json:/app/credentials.json -e GOOGLE_APPLICATION_CREDENTIALS='/app/credentials.json' 79 | ``` 80 | -------------------------------------------------------------------------------- /docs/projectdiscovery.md: -------------------------------------------------------------------------------- 1 | # Project Discovery 2 | 3 | ## Description 4 | The Project Discovery provider connects to the Project Discovery API and retrieves subdomains associated with a domain. 5 | 6 | To enumerate a domain's subdomains, you need to provide a valid API key. 7 | 8 | ## Acquire a Project Discovery API Key 9 | To get a Project Discovery API Key, 10 | 11 | 1. Navigate to the [Project Discovery](https://projectdiscovery.io/) webpage 12 | 2. Scrolling down to the `Chaos` section 13 | 3. Click the **Request key** button 14 | 4. Complete the form 15 | 5. If they grant you access, an email containing your API key will be sent to your email. 16 | 17 | 18 | ## Usage 19 | The `--pd-api-key` option is used to provide you Project Discovery API Key. An API keys can be acquired by visiting the Project Discovery webpage and requesting one. 20 | 21 | The `--pd-domains` option is used to list the domains that are to be scanned. Multiple domains can be provided by separating each domain with a comma, e.g: 22 | 23 | `--pd-domains first.domain.example,second.domain.example` 24 | -------------------------------------------------------------------------------- /docs/reaper_detection.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/punk-security/dnsReaper/5dd7141f8e0c5679386ea749b109cd0eb1b86a05/docs/reaper_detection.png -------------------------------------------------------------------------------- /docs/securitytrails.md: -------------------------------------------------------------------------------- 1 | # Security Trails 2 | 3 | ## Description 4 | The SecurityTrails provider connects to the SecurityTrails API and retrieves subdomains associated with a domain. 5 | 6 | To enumerate a domain's subdomains, you need to provide a valid API key. 7 | 8 | ## Usage 9 | The `--st-api-key` option is used to provide you SecurityTrail API Key. API keys are available from the SecurityTrail user account page (click on API in the sidebar, then the API Keys tab, or [click here](https://securitytrails.com/app/account/credentials) for a direct link). 10 | 11 | The `--st-domains` option is used to list the domains that are to be scanned. Multiple domains can be provided by separating each domain with a comma, e.g: 12 | 13 | `--st-domains first.domain.example,second.domain.example` 14 | -------------------------------------------------------------------------------- /docs/zonetransfer.md: -------------------------------------------------------------------------------- 1 | # Zone Transfer 2 | 3 | ## Description 4 | The ZoneTransfer provider connects to a DNS Servers and attempts to fetch all records for a given domain. 5 | 6 | It requires 2 parameters, and both are mandatory. Zone Transfers must fetch a single DNS zone, there is no mechanism in the spec to enumerate all zones on the DNS server. 7 | 8 | The DNS server should permit Zone Transfers to your IP. There is no auth mechanism in the zone transfer spec so it operates on an ip allowlist. You also need TCP Port 53 access to the server, not UDP. 9 | 10 | # Usage 11 | zonetransfer_nameserver, zonetransfer_domain 12 | The `--zonetransfer-nameserver` option is used to provide your DNS server fqdn (such as ns1.domain.com) or DNS server IP. ). 13 | 14 | 15 | The `--zonetransfer-domain` option is used to specify the domain to fetch. This should be the root domain, i.e. a domain of punksecurity.co.uk would be used to fetch all subdomains such as www.punksecurity.co.uk. 16 | 17 | -------------------------------------------------------------------------------- /domain.py: -------------------------------------------------------------------------------- 1 | import ipaddress 2 | from collections import namedtuple 3 | from typing import Optional 4 | 5 | import dns.asyncresolver 6 | 7 | import logging 8 | import collections 9 | 10 | collections.Iterable = collections.abc.Iterable 11 | collections.Mapping = collections.abc.Mapping 12 | 13 | import asyncwhois 14 | import aiohttp 15 | import ssl 16 | 17 | from resolver import Resolver 18 | 19 | 20 | class Domain: 21 | resolver = Resolver() 22 | 23 | @property 24 | async def SOA(self): 25 | return await self.query("SOA") 26 | 27 | @property 28 | async def NX_DOMAIN(self): 29 | return (await self.resolver.resolve(self.domain, "A"))["NX_DOMAIN"] 30 | 31 | async def query(self, type): 32 | try: 33 | resp = await self.resolver.resolve(self.domain, type) 34 | return resp[type] 35 | except: 36 | return [] 37 | 38 | async def fetch_std_records(self): 39 | # TODO: is this recursive? 40 | self.CNAME = await self.query("CNAME") 41 | self.A = await self.query("A") 42 | self.AAAA = await self.query("AAAA") 43 | if self.CNAME or self.A or self.AAAA: 44 | # return early if we get a CNAME otherwise we get records for the cname aswell 45 | # this is actually desirable for A/AAAA but not NS as the next zone 46 | # will be queried based on the CNAME value, not the original domain 47 | return 48 | self.NS = await self.query("NS") 49 | 50 | async def fetch_external_records(self): 51 | for cname in self.CNAME: 52 | split_cname = cname.split(".", 1) 53 | if len(split_cname) == 1: 54 | continue # This cname has no zone to assess 55 | if self.base_domain == split_cname[1]: 56 | continue # Same zone, dont fetch 57 | d = Domain(cname) 58 | await d.fetch_std_records() 59 | self.A += d.A 60 | self.AAAA += d.AAAA 61 | self.CNAME += d.CNAME 62 | for ns in self.NS: 63 | try: 64 | d = Domain(self.domain) 65 | await d.set_resolver_nameserver(ns) 66 | self.A += d.A 67 | self.AAAA += d.AAAA 68 | except: 69 | logging.debug( 70 | f"We could not resolve the provided NS record '{ns}' to an ip" 71 | ) 72 | 73 | async def set_resolver_nameserver(self, ns: Optional[str] = None): 74 | if ns is None: 75 | self.resolver = dns.asyncresolver 76 | self.resolver.timeout = 1 77 | 78 | return 79 | 80 | if type(ns) != str: 81 | logging.error(f"Cannot set custom NS as {ns} not a string") 82 | 83 | raise RuntimeError(f"Invalid NS type - expected str got {type(ns)}") 84 | 85 | self.resolver = dns.asyncresolver.Resolver() 86 | self.resolver.timeout = 1 87 | 88 | try: 89 | ipaddress.ip_address(ns) 90 | self.resolver.nameservers = [ns] 91 | return 92 | except ValueError: 93 | # if ns isn't a valid IP address, attempt to resolve it 94 | try: 95 | nameservers = list( 96 | map( 97 | lambda rr: rr.address, 98 | (await self.resolver.resolve(ns.rstrip("."))).rrset, 99 | ) 100 | ) 101 | self.resolver.nameservers = nameservers 102 | except: 103 | # TODO: document why this gets set to an empty list 104 | self.resolver.nameservers = [] 105 | 106 | def set_base_domain(self): 107 | split_domain = self.domain.split(".", 1) 108 | if len(split_domain) > 1: 109 | self.base_domain = split_domain[1] 110 | else: 111 | self.base_domain = "." 112 | 113 | def __init__(self, domain, fetch_standard_records=True): 114 | self.domain = domain.rstrip(".") 115 | self.NS = [] 116 | self.A = [] 117 | self.AAAA = [] 118 | self.CNAME = [] 119 | self.set_base_domain() 120 | self.should_fetch_std_records = fetch_standard_records 121 | self.base_domain = None 122 | 123 | def get_session(self): 124 | return aiohttp.ClientSession() 125 | 126 | async def fetch_web(self, uri="", https=True): 127 | protocol = "https" if https else "http" 128 | url = f"{protocol}://{self.domain}/{uri}" 129 | 130 | # We must disable SSL validation because vulnerable domains probably won't have a valid cert on the other end 131 | # e.g. github 132 | ssl_context = ssl.create_default_context() 133 | ssl_context.check_hostname = False 134 | ssl_context.verify_mode = ssl.CERT_NONE 135 | headers = { 136 | "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36" 137 | } 138 | try: 139 | async with self.get_session() as session: 140 | resp = await session.get(url, ssl=ssl_context, headers=headers) 141 | web_status = resp.status 142 | web_body = await resp.text() 143 | except: 144 | web_status = 0 145 | web_body = "" 146 | return namedtuple("web_response", ["status_code", "body"])(web_status, web_body) 147 | 148 | @property 149 | async def is_registered(self): 150 | try: 151 | await asyncwhois.aio_whois(self.domain) 152 | return True 153 | except asyncwhois.NotFoundError: 154 | return False 155 | except Exception: 156 | return True 157 | 158 | def __repr__(self): 159 | return self.domain 160 | -------------------------------------------------------------------------------- /finding.py: -------------------------------------------------------------------------------- 1 | class Finding(object): 2 | def __init__(self, domain, signature, info, confidence, more_info_url): 3 | self.domain = domain.domain 4 | self.signature = signature 5 | self.info = info.replace("\n", " ").replace("\r", "").rstrip() 6 | self.confidence = confidence.name 7 | self.a_records = domain.A 8 | self.aaaa_records = domain.AAAA 9 | self.cname_records = domain.CNAME 10 | self.ns_records = domain.NS 11 | self.more_info_url = more_info_url 12 | 13 | def populated_records(self): 14 | resp = "" 15 | if self.a_records: 16 | resp += f"A: {self.a_records}," 17 | if self.aaaa_records: 18 | resp += f"AAAA: {self.aaaa_records}," 19 | if self.cname_records: 20 | resp += f"CNAME: {self.cname_records}," 21 | if self.ns_records: 22 | resp += f"NS: {self.ns_records}," 23 | return resp.rstrip(",") 24 | -------------------------------------------------------------------------------- /lambda-requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi==0.87.0 2 | mangum==0.17 3 | -------------------------------------------------------------------------------- /lambda.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import datetime 3 | import logging 4 | import os 5 | import random 6 | import re 7 | import sys 8 | import time 9 | import urllib.parse 10 | import uuid 11 | from functools import partial 12 | from sys import stdout 13 | 14 | import boto3 15 | 16 | import detection_enums 17 | import output 18 | import signatures 19 | from domain import Domain 20 | from providers import projectdiscovery 21 | from resolver import Resolver 22 | from scan import scan_domain 23 | 24 | sys.path.append(os.getcwd()) 25 | 26 | logger = logging.getLogger() 27 | logger.setLevel("INFO") 28 | 29 | from fastapi import FastAPI 30 | from mangum import Mangum 31 | 32 | app = FastAPI() 33 | 34 | ###### signatures 35 | 36 | signatures = [getattr(signatures, signature) for signature in signatures.__all__] 37 | 38 | # replace name for each signature 39 | for signature in signatures: 40 | signature.__name__ = signature.__name__.replace("signatures.", "") 41 | 42 | signatures = [ 43 | s for s in signatures if s.test.CONFIDENCE != detection_enums.CONFIDENCE.UNLIKELY 44 | ] 45 | 46 | 47 | @app.get("/") 48 | async def root(): 49 | return {"message": "Hello Punk!"} 50 | 51 | 52 | @app.get("/check") 53 | async def check(domain: str): 54 | try: 55 | logging.warning(f"Received: {domain}") 56 | domain = domain.replace(" ", "") 57 | domains = domain.split(",") 58 | return (await process_domains(domains))["findings"] 59 | except Exception as e: 60 | logging.error(f"Caught exception when checking: {e}") 61 | 62 | return {"error": True} 63 | 64 | 65 | @app.get("/scan") 66 | async def scan(domain: str): 67 | try: 68 | dynamodb = boto3.resource("dynamodb") 69 | table = dynamodb.Table(DYNAMO_TABLE) 70 | logging.warning(f"Received: {domain}") 71 | domain = domain.replace(" ", "") 72 | domains = domain.split(",") 73 | results = await process_domains(domains) 74 | guid = str(uuid.uuid4()) 75 | table.put_item(Item={"guid": guid, "results": results}) 76 | return {"results": guid} 77 | except Exception as e: 78 | logging.error(e) 79 | return {"error": True} 80 | 81 | 82 | @app.get("/result") 83 | async def result(id: str): 84 | try: 85 | dynamodb = boto3.resource("dynamodb") 86 | table = dynamodb.Table(DYNAMO_TABLE) 87 | logging.warning(f"Received id: {id}") 88 | return table.get_item(Key={"guid": id})["Item"]["results"] 89 | except Exception as e: 90 | logging.error(e) 91 | return {"error": True} 92 | 93 | 94 | ###### scanning 95 | 96 | PD_API_KEY = os.environ.get("PD_API_KEY", None) 97 | DYNAMO_TABLE = os.environ.get("DYNAMO_TABLE", None) 98 | SCAN_DOMAIN_LIMIT = os.environ.get("SCAN_DOMAIN_LIMIT", 2000) 99 | 100 | 101 | async def process_domains(domains: list[str]): 102 | Domain.resolver = Resolver( 103 | nameservers=[ 104 | "8.8.8.8", 105 | "8.8.4.4", 106 | "1.1.1.1", 107 | "1.0.0.1", 108 | "208.67.222.2", 109 | "208.67.220.2", 110 | ], 111 | parallelism=4000, 112 | ) 113 | findings = [] 114 | domain_objs = [Domain(domain) for domain in domains] 115 | if len(domains) == 1: 116 | # Project Discovery! 117 | 118 | primary_domain = domains[0].strip() 119 | 120 | if "://" not in primary_domain: 121 | # If no scheme is present, add a prefix so urlsplit treats it as absolute 122 | primary_domain = f"//{primary_domain}" 123 | 124 | # Use urlsplit to remove any URL gubbins 125 | primary_domain = urllib.parse.urlsplit(primary_domain).hostname 126 | 127 | # urlsplit doesn't do validation. Double check that the hostname doesn't have any undesired chars 128 | invalid_char_match = re.search("[^a-zA-Z0-9.-]", primary_domain) 129 | 130 | if invalid_char_match is not None: 131 | return {"error": True, "message": "Invalid domain"} 132 | 133 | # Remove any remaining nonsense characters 134 | pd_domains = projectdiscovery.fetch_domains(PD_API_KEY, primary_domain) 135 | 136 | logging.warning(f"Got {len(pd_domains)} domains from PD") 137 | domain_objs += pd_domains 138 | 139 | random.shuffle(domain_objs) 140 | domain_objs = domain_objs[:SCAN_DOMAIN_LIMIT] 141 | logging.warning(domain_objs) 142 | start_time = time.time() 143 | with output.Output("json", stdout) as o: 144 | scan = partial( 145 | scan_domain, 146 | signatures=signatures, 147 | output_handler=o, 148 | findings=findings, 149 | ) 150 | await asyncio.wait( 151 | [asyncio.create_task(scan(domain)) for domain in domain_objs], 152 | timeout=20, 153 | return_when=asyncio.ALL_COMPLETED, 154 | ) 155 | return { 156 | "domains": domains, 157 | "findings": [f.__dict__ for f in findings], 158 | "timestamp": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), 159 | "count_scanned_domains": len(domain_objs), 160 | "count_signatures": len(signatures), 161 | "execution_time": str(round(time.time() - start_time, 2)), 162 | } 163 | 164 | 165 | def handler(event, context): 166 | asgi_handler = Mangum(app) 167 | response = asgi_handler( 168 | event, context 169 | ) # Call the instance with the event arguments 170 | 171 | return response 172 | -------------------------------------------------------------------------------- /lambda.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | aws = { 4 | source = "hashicorp/aws" 5 | version = ">=5.16, <6.0" 6 | } 7 | } 8 | } 9 | 10 | 11 | ### Gets caller's ID 12 | data "aws_caller_identity" "current" { 13 | # Retrieves information about the AWS account corresponding to the 14 | # access key being used to run Terraform, which we need to populate 15 | # the "source_account" on the permission resource. 16 | } 17 | 18 | data "aws_region" "region" {} 19 | 20 | resource "aws_dynamodb_table" "results" { 21 | name = "dnsreaper-results" 22 | billing_mode = "PAY_PER_REQUEST" 23 | 24 | attribute { 25 | name = "guid" 26 | type = "S" 27 | } 28 | hash_key = "guid" 29 | } 30 | 31 | resource "time_static" "src" { 32 | triggers = { 33 | src : sha1(join("", [for f in fileset("${path.root}/","*.py"): filesha1(f)])) 34 | } 35 | } 36 | 37 | resource "null_resource" "build_venv" { 38 | triggers = { 39 | id = time_static.src.unix 40 | } 41 | 42 | provisioner "local-exec" { 43 | command = "docker build -t ${time_static.src.unix} ${path.root} -f ${path.root}/Dockerfile --target lambda && docker create --name ${time_static.src.unix} ${time_static.src.unix} && docker cp ${time_static.src.unix}:/packaged_app.zip ${path.root}/${time_static.src.unix}.zip && docker rm -v ${time_static.src.unix}" 44 | } 45 | } 46 | 47 | ### Logs 48 | resource "aws_cloudwatch_log_group" "logs" { 49 | name = "/aws/lambda/dnsReaper-public-lambda" 50 | retention_in_days = 14 51 | } 52 | 53 | resource "aws_lambda_function" "serverless-dnsreaper" { 54 | depends_on = [ "null_resource.build_venv" ] 55 | description = time_static.src.unix 56 | filename = "${path.root}/${time_static.src.unix}.zip" 57 | function_name = "dnsReaper-public-lambda" 58 | role = aws_iam_role.iam_for_lambda.arn 59 | handler = "lambda.handler" 60 | 61 | runtime = "python3.11" 62 | 63 | timeout = 30 64 | memory_size = 256 65 | 66 | environment { 67 | variables = { 68 | PD_API_KEY = file("./pdkey"), 69 | DYNAMO_TABLE = aws_dynamodb_table.results.name 70 | } 71 | } 72 | } 73 | 74 | data "aws_iam_policy_document" "assume_role" { 75 | statement { 76 | effect = "Allow" 77 | 78 | principals { 79 | type = "Service" 80 | identifiers = ["lambda.amazonaws.com"] 81 | } 82 | 83 | actions = ["sts:AssumeRole"] 84 | } 85 | } 86 | 87 | data "aws_iam_policy_document" "execution" { 88 | statement { 89 | effect = "Allow" 90 | actions = [ 91 | "ec2:CreateNetworkInterface", 92 | "ec2:DescribeNetworkInterfaces", 93 | "ec2:DeleteNetworkInterface" 94 | ] 95 | resources = ["*"] 96 | } 97 | statement { 98 | actions = [ 99 | "logs:CreateLogGroup", 100 | ] 101 | resources = ["arn:aws:logs:${data.aws_region.region.name}:${data.aws_caller_identity.current.account_id}:*"] 102 | } 103 | statement { 104 | actions = [ 105 | "logs:CreateLogStream", 106 | "logs:PutLogEvents" 107 | ] 108 | resources = ["${aws_cloudwatch_log_group.logs.arn}:*"] 109 | } 110 | 111 | statement { 112 | actions = [ 113 | "dynamodb:PutItem", 114 | "dynamodb:GetItem", 115 | "dynamodb:UpdateItem", 116 | "dynamodb:DeleteItem", 117 | "dynamodb:BatchGetItem", 118 | "dynamodb:BatchWriteItem", 119 | "dynamodb:Scan", 120 | "dynamodb:Query" 121 | ] 122 | resources = [aws_dynamodb_table.results.arn] 123 | } 124 | 125 | } 126 | 127 | resource "aws_iam_role" "iam_for_lambda" { 128 | name = "iam_for_lambda" 129 | assume_role_policy = data.aws_iam_policy_document.assume_role.json 130 | inline_policy { 131 | name = "execution_policy" 132 | policy = data.aws_iam_policy_document.execution.json 133 | } 134 | } 135 | 136 | 137 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from scan import scan_domain 3 | import signatures 4 | import output 5 | import detection_enums 6 | import providers 7 | from os import linesep 8 | from domain import Domain 9 | from resolver import Resolver 10 | 11 | from functools import partial 12 | 13 | import logging 14 | from sys import stderr, exit, argv 15 | 16 | import argparsing 17 | 18 | import colorama 19 | 20 | import time 21 | 22 | import asyncio 23 | 24 | import dns.resolver 25 | 26 | start_time = time.time() 27 | 28 | if "--nocolour" in argv: 29 | print(argparsing.banner, file=stderr) 30 | else: 31 | colorama.init() 32 | print(argparsing.banner_with_colour, file=stderr) 33 | 34 | args = argparsing.parse_args() 35 | 36 | ###### verbosity 37 | 38 | if args.verbose == 0: 39 | verbosity_level = logging.WARN 40 | if args.verbose == 1: 41 | verbosity_level = logging.INFO 42 | if args.verbose > 1: 43 | verbosity_level = logging.DEBUG 44 | 45 | logging.basicConfig(format="%(message)s", level=verbosity_level) 46 | logging.StreamHandler(stderr) 47 | 48 | if not args.verbose > 2: 49 | for module in ["boto", "requests"]: 50 | logger = logging.getLogger(module) 51 | logger.setLevel(logging.CRITICAL) 52 | 53 | ###### signatures 54 | 55 | signatures = [getattr(signatures, signature) for signature in signatures.__all__] 56 | 57 | # replace name for each signature 58 | for signature in signatures: 59 | signature.__name__ = signature.__name__.replace("signatures.", "") 60 | 61 | if args.signature: 62 | signatures = [s for s in signatures if s.__name__ in args.signature] 63 | 64 | if args.exclude_signature: 65 | signatures = [s for s in signatures if s.__name__ not in args.exclude_signature] 66 | 67 | if not args.enable_unlikely: 68 | signatures = [ 69 | s 70 | for s in signatures 71 | if s.test.CONFIDENCE != detection_enums.CONFIDENCE.UNLIKELY 72 | ] 73 | 74 | if args.disable_probable: 75 | signatures = [ 76 | s 77 | for s in signatures 78 | if s.test.CONFIDENCE != detection_enums.CONFIDENCE.POTENTIAL 79 | ] 80 | 81 | logging.warning(f"Testing with {len(signatures)} signatures") 82 | 83 | 84 | ###### scanning 85 | 86 | findings = [] 87 | 88 | if "--out" not in argv: 89 | # using default out location, need to append our format 90 | args.out = f"{args.out}.{args.out_format}" 91 | 92 | 93 | async def main(): 94 | ###### domain ingestion 95 | nameservers = ( 96 | dns.resolver.Resolver().nameservers 97 | if args.resolver == "" 98 | else args.resolver.replace(" ", "").split(",") 99 | ) 100 | Domain.resolver = Resolver(nameservers=nameservers, parallelism=args.parallelism) 101 | provider = getattr(providers, args.provider) 102 | domains = list(provider.fetch_domains(**args.__dict__)) 103 | 104 | if len(domains) == 0: 105 | logging.error("ERROR: No domains to scan") 106 | exit(-1) 107 | 108 | with output.Output(args.out_format, args.out) as o: 109 | scan = partial( 110 | scan_domain, 111 | signatures=signatures, 112 | output_handler=o, 113 | findings=findings, 114 | ) 115 | 116 | await asyncio.gather(*[asyncio.create_task(scan(domain)) for domain in domains]) 117 | 118 | ###### exit 119 | logging.warning(f"\n\nWe found {len(findings)} takeovers ☠️") 120 | for finding in findings: 121 | msg = f"-- DOMAIN '{finding.domain}' :: SIGNATURE '{finding.signature}' :: CONFIDENCE '{finding.confidence}'" 122 | msg += f"{linesep}{finding.populated_records()}" 123 | if args.nocolour == False: 124 | msg = colorama.Fore.RED + msg + colorama.Fore.RESET 125 | logging.warning(msg) 126 | logging.warning( 127 | f"\n⏱️ We completed in {round(time.time() - start_time, 2)} seconds" 128 | ) 129 | logging.warning(f"...Thats all folks!") 130 | if args.pipeline: 131 | logging.debug(f"Pipeline flag set - Exit code: {len(findings)}") 132 | exit(len(findings)) 133 | 134 | 135 | asyncio.run(main()) 136 | -------------------------------------------------------------------------------- /output.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import json 3 | import os 4 | from sys import stdout 5 | 6 | 7 | class Output: 8 | supported_formats = ["csv", "json"] 9 | 10 | def __init__(self, format, path): 11 | if format not in self.supported_formats: 12 | raise Exception("Unsupported output format") 13 | self.path = path 14 | if path == "stdout": 15 | self.path = stdout 16 | self.format = format 17 | self.writer = None 18 | 19 | def __enter__(self): 20 | if self.path == stdout: 21 | self.fd = stdout 22 | return self 23 | if self.format == "csv": 24 | self.fd = open(self.path, "w", 1, encoding="utf-8", newline="") 25 | if self.format == "json": 26 | self.fd = open(self.path, "w", 1, encoding="utf-8", newline="") 27 | return self 28 | 29 | def __exit__(self, *args, **kwargs): 30 | if self.format == "json" and self.writer: 31 | self.fd.write(f"{os.linesep}]") 32 | if self.path == stdout: 33 | return 34 | self.fd.close() 35 | 36 | def write(self, finding): 37 | if self.format == "csv": 38 | self.write_csv(finding) 39 | if self.format == "json": 40 | self.write_json(finding) 41 | 42 | def write_csv(self, finding): 43 | if self.writer == None: 44 | self.writer = csv.DictWriter( 45 | self.fd, fieldnames=finding.__dict__.keys(), dialect="excel" 46 | ) 47 | self.writer.writeheader() 48 | row = finding.__dict__ 49 | for k in row.keys(): 50 | if type(row[k]) == list: 51 | row[k] = ",".join(row[k]) 52 | self.writer.writerow(row) 53 | 54 | def write_json(self, finding): 55 | sep = "," 56 | if self.writer == None: 57 | self.fd.write("[") 58 | self.writer = True 59 | sep = "" # no seperator for the first run 60 | json_payload = json.dumps(finding.__dict__) 61 | self.fd.write(f"{sep}{os.linesep}{json_payload}") 62 | self.fd.flush() 63 | -------------------------------------------------------------------------------- /providers/__init__.py: -------------------------------------------------------------------------------- 1 | from os.path import dirname, basename, isfile, join 2 | import glob 3 | 4 | modules = glob.glob(join(dirname(__file__), "*.py")) 5 | __all__ = [ 6 | basename(f)[:-3] for f in modules if isfile(f) and not f.endswith("__init__.py") 7 | ] 8 | 9 | from . import * 10 | -------------------------------------------------------------------------------- /providers/aws.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import boto3 4 | from botocore.exceptions import NoCredentialsError, ClientError 5 | 6 | from domain import Domain 7 | 8 | description = "Scan multiple domains by fetching them from AWS Route53" 9 | 10 | 11 | def get_records(client, zone_id): 12 | records = [] 13 | 14 | try: 15 | paginator = client.get_paginator("list_resource_record_sets") 16 | source_zone_records = paginator.paginate(HostedZoneId=zone_id) 17 | except ClientError as e: 18 | logging.critical( 19 | f"Failed to fetch zone records. Ensure you have the 'ListResourceRecordSets' permission on '{zone_id}'. {e}" 20 | ) 21 | 22 | return [] 23 | 24 | for record_set in source_zone_records: 25 | records = [*records, *record_set["ResourceRecordSets"]] 26 | return records 27 | 28 | 29 | def convert_records_to_domains(records): 30 | buf = {} 31 | for record in records: 32 | if record["Name"] not in buf.keys(): 33 | buf[record["Name"]] = {} 34 | if "ResourceRecords" in record: 35 | buf[record["Name"]][record["Type"]] = [ 36 | r["Value"] for r in record["ResourceRecords"] 37 | ] 38 | elif "AliasTarget" in record: 39 | buf[record["Name"]][record["Type"]] = [record["AliasTarget"]["DNSName"]] 40 | for subdomain in buf.keys(): 41 | domain = Domain(subdomain.rstrip("."), fetch_standard_records=False) 42 | if "A" in buf[subdomain].keys(): 43 | domain.A = [r.rstrip(".") for r in buf[subdomain]["A"]] 44 | if "AAAA" in buf[subdomain].keys(): 45 | domain.AAAA = [r.rstrip(".") for r in buf[subdomain]["AAAA"]] 46 | if "CNAME" in buf[subdomain].keys(): 47 | domain.CNAME = [r.rstrip(".") for r in buf[subdomain]["CNAME"]] 48 | if "NS" in buf[subdomain].keys(): 49 | domain.NS = [r.rstrip(".") for r in buf[subdomain]["NS"]] 50 | yield domain 51 | 52 | 53 | def get_zones(client): 54 | hosted_zones = [] 55 | try: 56 | hosted_zones = client.list_hosted_zones()["HostedZones"] 57 | except ClientError as e: 58 | logging.critical( 59 | f"Failed to fetch zones from AWS. Ensure you have the 'ListHostedZones' permission. {e}" 60 | ) 61 | exit(-1) 62 | 63 | logging.debug(f"Got {len(hosted_zones)} zones from aws") 64 | if len(hosted_zones) == 0: 65 | return [] 66 | public_zones = [zone for zone in hosted_zones if not zone["Config"]["PrivateZone"]] 67 | logging.info(f"Got {len(hosted_zones)} public zones from aws") 68 | if len(public_zones) == 0: 69 | return [] 70 | return public_zones 71 | 72 | 73 | def validate_args(aws_access_key_id, aws_access_key_secret): 74 | if aws_access_key_id is not None and aws_access_key_secret is None: 75 | raise ValueError( 76 | "--aws-access-key-secret must be specified if --aws-access-key-id is specified" 77 | ) 78 | if aws_access_key_secret is not None and aws_access_key_id is None: 79 | raise ValueError( 80 | "--aws-access-key-id must be specified if --aws-access-key-secret is specified" 81 | ) 82 | 83 | 84 | def fetch_domains( 85 | aws_access_key_id=None, aws_access_key_secret=None, aws_session_token=None, **args 86 | ): 87 | validate_args(aws_access_key_id, aws_access_key_secret) 88 | domains = [] 89 | 90 | sts_client = boto3.client( 91 | "sts", 92 | aws_access_key_id=aws_access_key_id, 93 | aws_secret_access_key=aws_access_key_secret, 94 | aws_session_token=aws_session_token, 95 | ) 96 | 97 | try: 98 | caller_id = sts_client.get_caller_identity() 99 | logging.warning(f"Using IAM identity: {caller_id['Arn']}") 100 | except NoCredentialsError: 101 | logging.critical( 102 | """ 103 | ERROR - Could not locate valid AWS provider credentials. Please provide IAM access keys via the '--aws-access-key-id' 104 | and '--aws-access-key-secret' command-line options or consult the AWS provider documentation for alternative 105 | authentication methods 106 | """ 107 | ) 108 | exit(-1) 109 | 110 | client = boto3.client( 111 | "route53", 112 | aws_access_key_id=aws_access_key_id, 113 | aws_secret_access_key=aws_access_key_secret, 114 | aws_session_token=aws_session_token, 115 | ) 116 | zones = get_zones(client) 117 | for zone in zones: 118 | try: 119 | records = get_records(client, zone["Id"].replace("/hostedzone/", "")) 120 | except: 121 | logging.warning(f"Could not retrieve records for aws zone '{zone['Name']}'") 122 | records = [] 123 | logging.debug(f"Got {len(records)} records for aws zone '{zone['Name']}'") 124 | for domain in convert_records_to_domains(records): 125 | domains.append(domain) 126 | logging.warning(f"Got {len(domains)} records from aws") 127 | return domains 128 | -------------------------------------------------------------------------------- /providers/azure.py: -------------------------------------------------------------------------------- 1 | from azure.mgmt.dns import DnsManagementClient 2 | from azure.identity import ClientSecretCredential 3 | 4 | import logging 5 | 6 | from domain import Domain 7 | 8 | description = "Scan multiple domains by fetching them from Azure DNS services" 9 | 10 | 11 | def get_records(client, zone): 12 | records = [] 13 | zone_name = zone[0] 14 | rg = zone[1].split("/")[4] 15 | # request the DNS records from that zone 16 | try: 17 | records = [ 18 | [ 19 | x.name, 20 | x.fqdn, 21 | x.type.split("/")[2], 22 | x.a_records, 23 | x.aaaa_records, 24 | x.caa_records, 25 | x.cname_record, 26 | x.mx_records, 27 | x.ns_records, 28 | x.ptr_records, 29 | x.soa_record, 30 | x.srv_records, 31 | x.txt_records, 32 | ] 33 | for x in client.record_sets.list_by_dns_zone(rg, zone_name) 34 | ] 35 | except Exception as e: 36 | exit(f"/zones/dns_records.get api call failed {e}") 37 | 38 | return records 39 | 40 | 41 | def convert_records_to_domains(records): 42 | buf = {} 43 | 44 | for record in records: 45 | if record[1] not in buf.keys(): 46 | buf[record[1]] = {} 47 | if record[2] not in buf[record[1]].keys(): 48 | buf[record[1]][record[2]] = [] 49 | if record[2] == "A": 50 | buf[record[1]][record[2]].append([x.ipv4_address for x in record[3]]) 51 | if record[2] == "AAAA": 52 | buf[record[1]][record[2]].append([x.ipv6_address for x in record[4]]) 53 | if record[2] == "CNAME": 54 | buf[record[1]][record[2]].append(record[6]) 55 | if record[2] == "NS": 56 | buf[record[1]][record[2]].append([x.nsdname for x in record[8]]) 57 | 58 | for subdomain in buf.keys(): 59 | domain = Domain(subdomain.rstrip("."), fetch_standard_records=False) 60 | if "A" in buf[subdomain].keys(): 61 | domain.A = [r.rstrip(".") for r in buf[subdomain]["A"][0]] 62 | if "AAAA" in buf[subdomain].keys(): 63 | domain.AAAA = [r.rstrip(".") for r in buf[subdomain]["AAAA"][0]] 64 | if "CNAME" in buf[subdomain].keys(): 65 | domain.CNAME = [r.cname.rstrip(".") for r in buf[subdomain]["CNAME"]] 66 | if "NS" in buf[subdomain].keys(): 67 | domain.NS = [r.rstrip(".") for r in buf[subdomain]["NS"][0]] 68 | yield domain 69 | 70 | 71 | def get_zones(client): 72 | try: 73 | zones = [[x.name, x.id] for x in client.zones.list()] 74 | except Exception as e: 75 | exit(f"/zones.get api call failed {e}") 76 | 77 | logging.debug(f"Got {len(zones)} zones from Azure") 78 | 79 | if len(zones) == 0: 80 | return [] 81 | 82 | return zones 83 | 84 | 85 | def fetch_domains( 86 | az_subscription_id, az_tenant_id, az_client_id, az_client_secret, **args 87 | ): 88 | domains = [] 89 | credentials = ClientSecretCredential(az_tenant_id, az_client_id, az_client_secret) 90 | client = DnsManagementClient( 91 | credentials, az_subscription_id, api_version="2018-05-01" 92 | ) 93 | zones = get_zones(client) 94 | for zone in zones: 95 | records = get_records(client, zone) 96 | logging.debug(f"Got {len(records)} records for Azure zone '{zone[0]}'") 97 | for record in convert_records_to_domains(records): 98 | domains.append(record) 99 | return domains 100 | -------------------------------------------------------------------------------- /providers/bind.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from domain import Domain 3 | import dns.zone, dns.rdatatype 4 | from os import listdir 5 | from os.path import isfile, join 6 | 7 | description = "Read domains from a dns BIND zone file, or path to multiple" 8 | 9 | 10 | def bind_file_to_domains(bind_zone_file): 11 | logging.debug(f"Reading domains from zonefile '{bind_zone_file}'") 12 | domains = [] 13 | zone = dns.zone.from_file(bind_zone_file) 14 | root_domain = str(zone.origin).rstrip(".") 15 | for name, node in zone.nodes.items(): 16 | domain = root_domain if str(name) == "@" else f"{str(name)}.{root_domain}" 17 | domain = Domain(domain, fetch_standard_records=False) 18 | for record in node.rdatasets: 19 | if record.rdtype == dns.rdatatype.A: 20 | domain.A = [str(r) for r in record.items] 21 | if record.rdtype == dns.rdatatype.AAAA: 22 | domain.AAAA = [str(r) for r in record.items] 23 | if record.rdtype == dns.rdatatype.CNAME: 24 | domain.CNAME = [str(r) for r in record.items] 25 | if record.rdtype == dns.rdatatype.NS and str(name) != "@": 26 | domain.NS = [str(r) for r in record.items] 27 | domains.append(domain) 28 | logging.info(f"Read {len(domains)} domains from zonefile '{bind_zone_file}'") 29 | return domains 30 | 31 | 32 | def fetch_domains(bind_zone_file, **args): 33 | if isfile(bind_zone_file): 34 | domains = bind_file_to_domains(bind_zone_file) 35 | logging.warning(f"Read {len(domains)} domains from zone file") 36 | else: 37 | domains = [] 38 | files = [ 39 | join(bind_zone_file, f) 40 | for f in listdir(bind_zone_file) 41 | if isfile(join(bind_zone_file, f)) 42 | ] 43 | for file in files: 44 | logging.debug("Reading file '{file}'") 45 | domains = [*domains, *bind_file_to_domains(file)] 46 | logging.warning(f"Read {len(domains)} domains from zone file dir") 47 | return domains 48 | -------------------------------------------------------------------------------- /providers/cloudflare.py: -------------------------------------------------------------------------------- 1 | import CloudFlare, logging 2 | 3 | from domain import Domain 4 | 5 | description = "Scan multiple domains by fetching them from Cloudflare" 6 | 7 | 8 | def get_records(client, zone_id): 9 | records = [] 10 | 11 | page_number = 0 12 | while True: 13 | page_number += 1 14 | 15 | # request the DNS records from that zone 16 | try: 17 | raw_results = client.zones.dns_records.get( 18 | zone_id, params={"page": page_number} 19 | ) 20 | except CloudFlare.exceptions.CloudFlareAPIError as e: 21 | exit(f"/zones/dns_records.get api call failed {e}") 22 | 23 | records.extend(raw_results["result"]) 24 | 25 | total_pages = raw_results["result_info"]["total_pages"] 26 | if page_number == total_pages: 27 | break 28 | 29 | return records 30 | 31 | 32 | def convert_records_to_domains(records): 33 | buf = {} 34 | 35 | for record in records: 36 | if record["name"] not in buf.keys(): 37 | buf[record["name"]] = {} 38 | if record["type"] not in buf[record["name"]].keys(): 39 | buf[record["name"]][record["type"]] = [] 40 | buf[record["name"]][record["type"]].append(record["content"]) 41 | 42 | for subdomain in buf.keys(): 43 | domain = Domain(subdomain.rstrip("."), fetch_standard_records=False) 44 | if "A" in buf[subdomain].keys(): 45 | domain.A = [r.rstrip(".") for r in buf[subdomain]["A"]] 46 | if "AAAA" in buf[subdomain].keys(): 47 | domain.AAAA = [r.rstrip(".") for r in buf[subdomain]["AAAA"]] 48 | if "CNAME" in buf[subdomain].keys(): 49 | domain.CNAME = [r.rstrip(".") for r in buf[subdomain]["CNAME"]] 50 | if "NS" in buf[subdomain].keys(): 51 | domain.NS = [r.rstrip(".") for r in buf[subdomain]["NS"]] 52 | yield domain 53 | 54 | 55 | def get_zones(client): 56 | zones = [] 57 | 58 | page_number = 0 59 | while True: 60 | page_number += 1 61 | try: 62 | raw_results = client.zones.get(params={"page": page_number}) 63 | except CloudFlare.exceptions.CloudFlareAPIError as e: 64 | exit(f"/zones.get api call failed {e}") 65 | except Exception as e: 66 | exit(f"/zones.get api call failed {e}") 67 | 68 | zones.extend(raw_results["result"]) 69 | 70 | total_pages = raw_results["result_info"]["total_pages"] 71 | if page_number == total_pages: 72 | break 73 | 74 | logging.info(f"Got {len(zones)} zones ({total_pages} pages) from cloudflare") 75 | 76 | if len(zones) == 0: 77 | return [] 78 | 79 | return zones 80 | 81 | 82 | def fetch_domains(cloudflare_token, **args): 83 | domains = [] 84 | 85 | client = CloudFlare.CloudFlare(token=cloudflare_token, raw=True) 86 | zones = get_zones(client) 87 | for zone in zones: 88 | records = get_records(client, zone["id"]) 89 | logging.debug( 90 | f"Got {len(records)} records for cloudflare zone '{zone['name']}'" 91 | ) 92 | for record in convert_records_to_domains(records): 93 | domains.append(record) 94 | logging.warning(f"Got {len(domains)} records from cloudflare") 95 | return domains 96 | -------------------------------------------------------------------------------- /providers/digitalocean.py: -------------------------------------------------------------------------------- 1 | import boto3 2 | import logging 3 | 4 | import requests 5 | 6 | from domain import Domain 7 | 8 | description = "Scan multiple domains by fetching them from Digital Ocean" 9 | 10 | 11 | class DomainNotFoundError(Exception): 12 | def __init__(self, domain): 13 | self.message = "Domain not found: " + domain 14 | super().__init__(self.message) 15 | 16 | 17 | class DoApi: 18 | def __init__(self, api_key): 19 | self.session = requests.session() 20 | self.session.headers.update( 21 | {"Content-Type": "application/json", "Authorization": "Bearer " + api_key} 22 | ) 23 | 24 | @staticmethod 25 | def check_response(response: requests.Response): 26 | if response.status_code == 401: 27 | raise ValueError("Invalid API key specified.") 28 | 29 | if response.status_code < 200 or response.status_code >= 300: 30 | raise ValueError("Invalid response received from API: " + response.json()) 31 | 32 | return response 33 | 34 | def make_request(self, endpoint): 35 | return self.session.prepare_request( 36 | requests.Request("GET", "https://api.digitalocean.com/v2/" + endpoint) 37 | ) 38 | 39 | def list_domains(self): 40 | req = self.make_request("domains") 41 | 42 | return self.check_response(self.session.send(req)) 43 | 44 | def get_records(self, domain): 45 | req = self.make_request(f"domains/{domain}/records") 46 | res = self.session.send(req) 47 | 48 | if 404 == res.status_code: 49 | raise DomainNotFoundError(domain) 50 | 51 | return self.check_response(res) 52 | 53 | 54 | def convert_records_to_domains(records, root_domain): 55 | buf = {} 56 | for record in records: 57 | if "@" == record["name"]: 58 | continue 59 | 60 | record_name = f"{record['name']}.{root_domain}" 61 | 62 | if record_name not in buf.keys(): 63 | buf[record_name] = {} 64 | 65 | if record["type"] not in buf[record_name].keys(): 66 | buf[record_name][record["type"]] = [] 67 | 68 | if "data" in record.keys(): 69 | buf[record_name][record["type"]].append(record["data"]) 70 | 71 | def extract_records(desired_type): 72 | return [r.rstrip(".") for r in buf[subdomain][desired_type]] 73 | 74 | for subdomain in buf.keys(): 75 | domain = Domain(subdomain.rstrip("."), fetch_standard_records=False) 76 | 77 | if "A" in buf[subdomain].keys(): 78 | domain.A = extract_records("A") 79 | if "AAAA" in buf[subdomain].keys(): 80 | domain.AAAA = extract_records("AAAA") 81 | if "CNAME" in buf[subdomain].keys(): 82 | domain.CNAME = extract_records("CNAME") 83 | if "NS" in buf[subdomain].keys(): 84 | domain.NS = extract_records("NS") 85 | 86 | yield domain 87 | 88 | 89 | def validate_args(do_api_key: str): 90 | if not do_api_key.startswith("dop_v1"): 91 | raise ValueError("DigitalOcean: Invalid API key specified") 92 | 93 | 94 | def fetch_domains(do_api_key: str, do_domains: str = None, **args): # NOSONAR 95 | validate_args(do_api_key) 96 | root_domains = [] 97 | domains = [] 98 | api = DoApi(do_api_key) 99 | 100 | if do_domains is not None and len(do_domains): 101 | root_domains = [domain.strip(" ") for domain in do_domains.split(",")] 102 | else: 103 | resp_data = api.list_domains().json() 104 | root_domains = [domain["name"] for domain in resp_data["domains"]] 105 | 106 | for domain in root_domains: 107 | if "" == domain or domain is None: 108 | continue 109 | 110 | records = api.get_records(domain).json() 111 | domains.extend(convert_records_to_domains(records["domain_records"], domain)) 112 | 113 | return domains 114 | -------------------------------------------------------------------------------- /providers/file.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from domain import Domain 3 | from os import listdir 4 | from os.path import isfile, join 5 | 6 | description = "Read domains from a file (or folder of files), one per line" 7 | 8 | 9 | def fetch_domains(filename, **args): 10 | if isfile(filename): 11 | with open(filename) as file: 12 | try: 13 | domains = file.readlines() 14 | logging.warning( 15 | f"Ingested {len(domains)} domains from file '{filename}'" 16 | ) 17 | except Exception as e: 18 | logging.error(f"Could not read any domains from file {filename} -- {e}") 19 | exit(-1) 20 | else: 21 | domains = [] 22 | files = fetch_nested_files(filename) 23 | for f in files: 24 | try: 25 | with open(f) as file: 26 | domains += file.readlines() 27 | logging.debug(f"Ingested domains from file '{file}'") 28 | except: 29 | logging.debug(f"Could not read file '{file}'") 30 | logging.warning(f"Ingested {len(domains)} domains from folder '{filename}'") 31 | return [Domain(domain.rstrip()) for domain in domains] 32 | 33 | 34 | def fetch_nested_files(dir): 35 | files = [] 36 | for item in listdir(dir): 37 | path = join(dir, item) 38 | if isfile(path): 39 | files.append(path) 40 | else: 41 | files = [*files, *fetch_nested_files(path)] 42 | return files 43 | -------------------------------------------------------------------------------- /providers/godaddy.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | from domain import Domain 4 | 5 | description = "Scan multiple domains by fetching them from GoDaddy" 6 | 7 | 8 | class DomainNotFoundError(Exception): 9 | def __init__(self, domain): 10 | self.message = "Domain not found: " + domain 11 | super().__init__(self.message) 12 | 13 | 14 | class GDApi: 15 | def __init__(self, api_key, api_secret): 16 | self.session = requests.session() 17 | self.session.headers.update( 18 | { 19 | "Content-Type": "application/json", 20 | "Authorization": "sso-key " + api_key + ":" + api_secret, 21 | } 22 | ) 23 | 24 | @staticmethod 25 | def check_response(response: requests.Response): 26 | if response.status_code == 401: 27 | raise ValueError("Invalid API key specified.") 28 | if response.status_code == 403: 29 | raise ValueError( 30 | "API key valid but access denied. GoDaddy now block API access unless you have at least 10 domains" 31 | ) 32 | if response.status_code < 200 or response.status_code >= 300: 33 | raise ValueError( 34 | "Invalid response received from API: " + str(response.json()) 35 | ) 36 | 37 | return response 38 | 39 | def make_request(self, endpoint): 40 | return self.session.prepare_request( 41 | requests.Request("GET", "https://api.godaddy.com/v1/" + endpoint) 42 | ) 43 | 44 | def list_domains(self): 45 | req = self.make_request("domains") 46 | 47 | return self.check_response(self.session.send(req)) 48 | 49 | def get_records(self, domain): 50 | req = self.make_request(f"domains/{domain}/records") 51 | res = self.session.send(req) 52 | 53 | if 404 == res.status_code: 54 | raise DomainNotFoundError(domain) 55 | 56 | return self.check_response(res) 57 | 58 | 59 | def convert_records_to_domains(records, root_domain): 60 | buf = {} 61 | for record in records: 62 | if "@" == record["name"]: 63 | continue 64 | 65 | record_name = f"{record['name']}.{root_domain}" 66 | 67 | if record_name not in buf.keys(): 68 | buf[record_name] = {} 69 | 70 | if record["type"] not in buf[record_name].keys(): 71 | buf[record_name][record["type"]] = [] 72 | 73 | if "data" in record.keys(): 74 | buf[record_name][record["type"]].append(record["data"]) 75 | 76 | def extract_records(desired_type): 77 | return [r.rstrip(".") for r in buf[subdomain][desired_type]] 78 | 79 | for subdomain in buf.keys(): 80 | domain = Domain(subdomain.rstrip("."), fetch_standard_records=False) 81 | 82 | if "A" in buf[subdomain].keys(): 83 | domain.A = extract_records("A") 84 | if "AAAA" in buf[subdomain].keys(): 85 | domain.AAAA = extract_records("AAAA") 86 | if "CNAME" in buf[subdomain].keys(): 87 | domain.CNAME = [ 88 | x.replace("@", root_domain) for x in extract_records("CNAME") 89 | ] 90 | if "NS" in buf[subdomain].keys(): 91 | domain.NS = extract_records("NS") 92 | yield domain 93 | 94 | 95 | def fetch_domains(gd_api_key: str, gd_api_secret: str, gd_domains: str = None, **args): 96 | root_domains = [] 97 | domains = [] 98 | api = GDApi(gd_api_key, gd_api_secret) 99 | 100 | if gd_domains is not None and len(gd_domains): 101 | root_domains = [domain.strip(" ") for domain in gd_domains.split(",")] 102 | else: 103 | resp_data = api.list_domains().json() 104 | root_domains = [domain["domain"] for domain in resp_data] 105 | 106 | for domain in root_domains: 107 | if "" == domain or domain is None: 108 | continue 109 | 110 | records = api.get_records(domain).json() 111 | domains.extend(convert_records_to_domains(records, domain)) 112 | 113 | return domains 114 | -------------------------------------------------------------------------------- /providers/googlecloud.py: -------------------------------------------------------------------------------- 1 | import logging, os 2 | 3 | from domain import Domain 4 | from google.cloud import dns 5 | 6 | description = "Scan multiple domains by fetching them from Google Cloud. Requires GOOGLE_APPLICATION_CREDENTIALS environment variable." 7 | 8 | 9 | def get_records(zone): 10 | records = [] 11 | 12 | try: 13 | records = zone.list_resource_record_sets(max_results=None, page_token=None) 14 | except Exception as e: 15 | logging.critical(f"Failed to fetch zone records. {e}") 16 | return [] 17 | 18 | return list(records) 19 | 20 | 21 | def convert_records_to_domains(records): 22 | buf = {} 23 | for record in records: 24 | if record.name not in buf.keys(): 25 | buf[record.name] = {} 26 | 27 | buf[record.name][record.record_type] = record.rrdatas 28 | 29 | for subdomain in buf.keys(): 30 | domain = Domain(subdomain.rstrip("."), fetch_standard_records=False) 31 | if "A" in buf[subdomain].keys(): 32 | domain.A = [r.rstrip(".") for r in buf[subdomain]["A"]] 33 | if "AAAA" in buf[subdomain].keys(): 34 | domain.AAAA = [r.rstrip(".") for r in buf[subdomain]["AAAA"]] 35 | if "CNAME" in buf[subdomain].keys(): 36 | domain.CNAME = [r.rstrip(".") for r in buf[subdomain]["CNAME"]] 37 | if "NS" in buf[subdomain].keys(): 38 | domain.NS = [r.rstrip(".") for r in buf[subdomain]["NS"]] 39 | yield domain 40 | 41 | 42 | def get_zones(client): 43 | zones = [] 44 | try: 45 | zones = client.list_zones() 46 | zones = list(zones) 47 | except Exception as e: 48 | logging.critical( 49 | f"""Failed to fetch zones from Google Cloud. Could not discover credentials. 50 | Ensure that the environment variable `GOOGLE_APPLICATION_CREDENTIALS` is set to the JSON credential file's location, 51 | or that the JSON file is in the default location. Also, ensure that the correct project id has been passed. 52 | {e}""" 53 | ) 54 | exit(-1) 55 | 56 | logging.debug(f"Got {len(zones)} zones from Google Cloud") 57 | if len(zones) == 0: 58 | return [] 59 | 60 | return zones 61 | 62 | 63 | def fetch_domains(project_id, **args): 64 | domains = [] 65 | 66 | logger = logging.getLogger("google") 67 | logger.setLevel(logging.CRITICAL) 68 | 69 | client = dns.Client(project_id) 70 | 71 | zones = get_zones(client) 72 | 73 | for zone in zones: 74 | try: 75 | records = get_records(zone) 76 | except: 77 | logging.warning( 78 | f"Could not retrieve records for Google Cloud zone '{zone.dns_name}'" 79 | ) 80 | records = [] 81 | logging.debug( 82 | f"Got {len(records)} records for Google Cloud zone '{zone.dns_name}'" 83 | ) 84 | 85 | for domain in convert_records_to_domains(records): 86 | domains.append(domain) 87 | logging.warning(f"Got {len(domains)} records from Google Cloud") 88 | return domains 89 | -------------------------------------------------------------------------------- /providers/projectdiscovery.py: -------------------------------------------------------------------------------- 1 | import requests, logging, json 2 | 3 | from domain import Domain 4 | 5 | description = "Scan multiple domains by fetching them from ProjectDiscovery" 6 | 7 | 8 | class DomainNotFoundError(Exception): 9 | def __init__(self, domain): 10 | self.message = "Domain not found: " + domain 11 | super().__init__(self.message) 12 | 13 | 14 | class PDApi: 15 | def __init__(self, api_key): 16 | self.session = requests.session() 17 | self.session.headers.update( 18 | {"Content-Type": "application/json", "Authorization": api_key} 19 | ) 20 | 21 | @staticmethod 22 | def check_response(response: requests.Response): 23 | if response.status_code == 401: 24 | raise ValueError("Invalid API key specified.") 25 | 26 | if response.status_code < 200 or response.status_code >= 300: 27 | raise ValueError("Invalid response received from API: " + response.json()) 28 | 29 | return response 30 | 31 | def make_request(self, endpoint): 32 | return self.session.prepare_request( 33 | requests.Request("GET", "https://dns.projectdiscovery.io/dns/" + endpoint) 34 | ) 35 | 36 | def list_domains(self): 37 | req = self.make_request("domains") 38 | 39 | return self.check_response(self.session.send(req)) 40 | 41 | def get_subdomains(self, domain): 42 | domain = domain.lower() 43 | req = self.make_request(f"{domain}/subdomains") 44 | res = self.session.send(req) 45 | 46 | if 404 == res.status_code: 47 | raise DomainNotFoundError(domain) 48 | 49 | return self.check_response(res) 50 | 51 | 52 | def fetch_domains(pd_api_key: str, pd_domains: str, **args): 53 | root_domains = [] 54 | domains = [] 55 | api = PDApi(pd_api_key) 56 | 57 | root_domains = [domain.strip(" ") for domain in pd_domains.split(",")] 58 | 59 | for domain in root_domains: 60 | if "" == domain or domain is None: 61 | continue 62 | 63 | raw_domains = api.get_subdomains(domain).json() 64 | logging.warning(f"Testing {len(raw_domains['subdomains'])} subdomains") 65 | domains.extend( 66 | [ 67 | Domain(f"{sb}.{domain}") 68 | for sb in raw_domains["subdomains"] 69 | if "*" not in sb 70 | ] 71 | ) 72 | 73 | return domains 74 | -------------------------------------------------------------------------------- /providers/readme.md: -------------------------------------------------------------------------------- 1 | # Providers 2 | 3 | Providers provide the domains to the scanning engine, whether they are read from the cli, a local file or a service provider. 4 | 5 | All providers have two mandatory items, a description and a fetch_domains function. 6 | 7 | ``` 8 | description = "This is a description of the provider" 9 | 10 | def fetch_domains(argumentA, argumentB **args): 11 | #do sometyhing 12 | return [Domain(domain)] 13 | ``` 14 | 15 | The description and provider file name are used to configure the argument parser and help menu. 16 | 17 | The fetch_domains function can take any number of arguments, and these are automatically added to the argument parsers and help menu. 18 | To make an argument optional, give it a default value. 19 | 20 | For full details on individual providers, please visit [docs](../docs/README.md) 21 | 22 | #### IMPORTANT: All providers must have unique argument names (so considering namespacing them such as ```_access_key``` rather than ```access_key``` ) 23 | 24 | ## Adding a new provider 25 | 26 | 1. Create a new .py file in providers 27 | 2. Add a description 28 | 3. Add a fetch_domains function that returns a list of ```domain.Domain``` objects or an empty list 29 | -------------------------------------------------------------------------------- /providers/securitytrails.py: -------------------------------------------------------------------------------- 1 | import requests, logging 2 | 3 | from domain import Domain 4 | 5 | description = "Scan multiple domains by fetching them from Security Trails" 6 | 7 | 8 | class DomainNotFoundError(Exception): 9 | def __init__(self, domain): 10 | self.message = "Domain not found: " + domain 11 | super().__init__(self.message) 12 | 13 | 14 | class STApi: 15 | def __init__(self, api_key): 16 | self.session = requests.session() 17 | self.session.headers.update({"accept": "application/json", "APIKEY": api_key}) 18 | 19 | @staticmethod 20 | def check_response(response: requests.Response): 21 | if response.status_code == 403 and ( 22 | "Invalid authentication credentials" in (response.content).decode("utf-8") 23 | ): 24 | raise ValueError( 25 | "Invalid authentication credentials. Please ensure the API key entered vis correct." 26 | ) 27 | 28 | if response.status_code == 403: 29 | raise ValueError( 30 | "This feature is not available for your subscription package. Consider upgrading your package or contact support@securitytrails.com." 31 | ) 32 | 33 | if response.status_code < 200 or response.status_code >= 300: 34 | raise ValueError("Invalid response received from API: " + response.json()) 35 | 36 | return response 37 | 38 | def make_request(self, endpoint): 39 | return self.session.prepare_request( 40 | requests.Request("GET", "https://api.securitytrails.com/v1/" + endpoint) 41 | ) 42 | 43 | def get_subdomains(self, domain): 44 | req = self.make_request(f"domain/{domain}/subdomains") 45 | res = self.session.send(req) 46 | 47 | if 404 == res.status_code: 48 | raise DomainNotFoundError(domain) 49 | 50 | return self.check_response(res) 51 | 52 | 53 | def fetch_domains(st_api_key: str, st_domains: str, **args): 54 | root_domains = [] 55 | domains = [] 56 | api = STApi(st_api_key) 57 | 58 | root_domains = [domain.strip(" ") for domain in st_domains.split(",")] 59 | 60 | for domain in root_domains: 61 | if "" == domain or domain is None: 62 | continue 63 | 64 | raw_domains = api.get_subdomains(domain).json() 65 | 66 | logging.warning(f"Testing {raw_domains['subdomain_count']} subdomains") 67 | domains.extend([Domain(f"{sb}.{domain}") for sb in raw_domains["subdomains"]]) 68 | 69 | return domains 70 | -------------------------------------------------------------------------------- /providers/single.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from domain import Domain 3 | 4 | description = "Scan a single domain by providing a domain on the commandline" 5 | 6 | 7 | def fetch_domains(domain, **args): 8 | logging.warning(f"Domain '{domain}' provided on commandline") 9 | return [Domain(domain)] 10 | -------------------------------------------------------------------------------- /providers/zonetransfer.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import socket 3 | 4 | import dns.rdatatype as record_types 5 | import dns.resolver 6 | import dns.zone 7 | 8 | from domain import Domain 9 | 10 | description = "Scan multiple domains by fetching records via DNS zone transfer" 11 | 12 | 13 | def convert_records_to_domains(root_domain, records): 14 | buf = {} 15 | 16 | for record in records: 17 | fqdn = f"{record[0]}.{root_domain}" if "@" != str(record[0]) else root_domain 18 | 19 | if fqdn not in buf.keys(): 20 | buf[fqdn] = {} 21 | 22 | record_type = record[1].rdtype 23 | record_items = record[1].items 24 | 25 | if record_type == record_types.A: 26 | buf[fqdn]["A"] = [str(x) for x in record_items] 27 | continue 28 | 29 | if record_type == record_types.AAAA: 30 | buf[fqdn]["AAAA"] = [str(x) for x in record_items] 31 | continue 32 | 33 | if record_type == record_types.CNAME: 34 | buf[fqdn]["CNAME"] = [str(x) for x in record_items] 35 | continue 36 | 37 | if record_type == record_types.NS: 38 | buf[fqdn]["NS"] = [str(x) for x in record_items] 39 | continue 40 | 41 | for subdomain in buf.keys(): 42 | 43 | def extract_records(desired_type): 44 | return [r.rstrip(".") for r in buf[subdomain][desired_type]] 45 | 46 | domain = Domain(subdomain.rstrip("."), fetch_standard_records=False) 47 | if "A" in buf[subdomain].keys(): 48 | domain.A = extract_records("A") 49 | if "AAAA" in buf[subdomain].keys(): 50 | domain.AAAA = extract_records("AAAA") 51 | if "CNAME" in buf[subdomain].keys(): 52 | domain.CNAME = extract_records("CNAME") 53 | if "NS" in buf[subdomain].keys(): 54 | domain.NS = extract_records("NS") 55 | 56 | yield domain 57 | 58 | 59 | def fetch_domains(zonetransfer_nameserver, zonetransfer_domain, **args): # NOSONAR 60 | ns_address = socket.gethostbyname(zonetransfer_nameserver) 61 | transfer_result = dns.query.xfr(ns_address, zonetransfer_domain) 62 | try: 63 | zone = dns.zone.from_xfr(transfer_result) 64 | except dns.xfr.TransferError as e: 65 | logging.error( 66 | f"ERROR: Nameserver {zonetransfer_nameserver} does not allow zone transfer: {e}" 67 | ) 68 | 69 | return [] 70 | 71 | return convert_records_to_domains( 72 | zonetransfer_domain, list(zone.iterate_rdatasets()) 73 | ) 74 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | dnspython==2.2.1 2 | requests==2.31.0 3 | asyncwhois==1.1.4 4 | boto3==1.35.19 5 | cloudflare==2.9.11 6 | colorama==0.4.5 7 | azure-mgmt-dns==8.0.0 8 | azure-identity==1.10.0 9 | msrestazure==0.6.4 10 | google-cloud-dns==0.34.1 11 | aiohttp 12 | -------------------------------------------------------------------------------- /resolver.py: -------------------------------------------------------------------------------- 1 | import dns.asyncresolver 2 | import asyncio 3 | import random 4 | import time 5 | import os 6 | 7 | import dns.resolver 8 | 9 | import socket 10 | import struct 11 | 12 | import logging 13 | 14 | # DNS query types 15 | TYPE_A = 1 16 | TYPE_NS = 2 17 | TYPE_CNAME = 5 18 | TYPE_SOA = 6 19 | TYPE_AAAA = 28 20 | TYPE_MX = 15 21 | 22 | QUERY_TYPES = { 23 | "A": TYPE_A, 24 | "NS": TYPE_NS, 25 | "CNAME": TYPE_CNAME, 26 | "SOA": TYPE_SOA, 27 | "AAAA": TYPE_AAAA, 28 | "MX": TYPE_MX, 29 | } 30 | 31 | 32 | class DNSException(Exception): 33 | pass 34 | 35 | 36 | class NXDomainException(DNSException): 37 | pass 38 | 39 | 40 | class NoAnswerException(DNSException): 41 | pass 42 | 43 | 44 | def build_dns_query(domain, qtype): 45 | # Transaction ID 46 | transaction_id = 0x1234 47 | 48 | # Flags: standard query with recursion 49 | flags = 0x0100 50 | 51 | # Questions 52 | questions = 1 53 | 54 | # Answer RRs 55 | answer_rrs = 0 56 | 57 | # Authority RRs 58 | authority_rrs = 0 59 | 60 | # Additional RRs 61 | additional_rrs = 0 62 | 63 | # Header 64 | header = struct.pack( 65 | ">HHHHHH", 66 | transaction_id, 67 | flags, 68 | questions, 69 | answer_rrs, 70 | authority_rrs, 71 | additional_rrs, 72 | ) 73 | 74 | # Question 75 | question = b"" 76 | for part in domain.split("."): 77 | question += struct.pack("B", len(part)) + part.encode() 78 | 79 | question += struct.pack("B", 0) # End of the domain name 80 | 81 | # Type and Class 82 | question += struct.pack(">HH", qtype, 1) # QTYPE = qtype, QCLASS = IN 83 | 84 | return header + question 85 | 86 | 87 | def parse_dns_response(response): 88 | def decode_name(offset): 89 | labels = [] 90 | while True: 91 | length = response[offset] 92 | if length & 0xC0 == 0xC0: 93 | pointer = struct.unpack_from("!H", response, offset)[0] 94 | pointer &= 0x3FFF 95 | labels.append(decode_name(pointer)[0]) 96 | offset += 2 97 | break 98 | elif length == 0: 99 | offset += 1 100 | break 101 | else: 102 | offset += 1 103 | labels.append(response[offset : offset + length].decode()) 104 | offset += length 105 | return ".".join(labels), offset 106 | 107 | header = struct.unpack(">HHHHHH", response[:12]) 108 | rcode = header[1] & 0x000F # Response code 109 | question_count = header[2] 110 | answer_count = header[3] 111 | 112 | if rcode == 3: # NXDOMAIN 113 | raise NXDomainException("The domain does not exist (NXDOMAIN)") 114 | 115 | if answer_count == 0: 116 | raise NoAnswerException("No answer found in the DNS response") 117 | 118 | offset = 12 119 | for _ in range(question_count): 120 | while response[offset] != 0: 121 | offset += response[offset] + 1 122 | offset += 5 # null byte + QTYPE + QCLASS 123 | 124 | records = { 125 | "A": [], 126 | "NS": [], 127 | "CNAME": [], 128 | "SOA": [], 129 | "AAAA": [], 130 | "MX": [], 131 | "NX_DOMAIN": False, 132 | } 133 | 134 | for _ in range(answer_count): 135 | name, offset = decode_name(offset) 136 | rtype, rclass, ttl, data_length = struct.unpack_from(">HHIH", response, offset) 137 | offset += 10 138 | 139 | if rtype == TYPE_A: 140 | ip = socket.inet_ntoa(response[offset : offset + data_length]) 141 | records["A"].append(ip) 142 | elif rtype == TYPE_NS: 143 | ns, _ = decode_name(offset) 144 | records["NS"].append(ns) 145 | elif rtype == TYPE_CNAME: 146 | cname, _ = decode_name(offset) 147 | records["CNAME"].append(cname) 148 | elif rtype == TYPE_SOA: 149 | mname, offset = decode_name(offset) 150 | rname, offset = decode_name(offset) 151 | serial, refresh, retry, expire, minimum = struct.unpack_from( 152 | ">IIIII", response, offset 153 | ) 154 | soa = { 155 | "mname": mname, 156 | "rname": rname, 157 | "serial": serial, 158 | "refresh": refresh, 159 | "retry": retry, 160 | "expire": expire, 161 | "minimum": minimum, 162 | } 163 | records["SOA"].append(soa) 164 | offset += 20 165 | elif rtype == TYPE_AAAA: 166 | ip = socket.inet_ntop( 167 | socket.AF_INET6, response[offset : offset + data_length] 168 | ) 169 | records["AAAA"].append(ip) 170 | elif rtype == TYPE_MX: 171 | preference = struct.unpack_from(">H", response, offset)[0] 172 | offset += 2 173 | exchange, _ = decode_name(offset) 174 | records["MX"].append((preference, exchange)) 175 | 176 | offset += data_length 177 | 178 | return records 179 | 180 | 181 | class DnsClientProtocol(asyncio.DatagramProtocol): 182 | def __init__(self, query, future): 183 | self.query = query 184 | self.future = future 185 | self.transport = None 186 | 187 | def connection_made(self, transport): 188 | self.transport = transport 189 | self.transport.sendto(self.query) 190 | 191 | def datagram_received(self, data, addr): 192 | if not self.future.done(): 193 | self.future.set_result(data) 194 | self.transport.close() 195 | 196 | def error_received(self, exc): 197 | if not self.future.done(): 198 | self.future.set_exception(exc) 199 | self.transport.close() 200 | 201 | def connection_lost(self, exc): 202 | if not self.future.done(): 203 | self.future.set_exception(exc) 204 | 205 | 206 | async def resolve_dns(domain, qtype, server="8.8.8.8", port=53): 207 | query = build_dns_query(domain, qtype) 208 | loop = asyncio.get_running_loop() 209 | future = loop.create_future() 210 | transport, protocol = await loop.create_datagram_endpoint( 211 | lambda: DnsClientProtocol(query, future), remote_addr=(server, port) 212 | ) 213 | 214 | try: 215 | response = await asyncio.wait_for(future, timeout=0.5) 216 | records = parse_dns_response(response) 217 | return records 218 | finally: 219 | transport.close() 220 | 221 | 222 | class Resolver: 223 | attempted_resolutions = 0 224 | resolutions = 0 225 | errors = 0 226 | no_records = 0 227 | nx_domains = 0 228 | 229 | def __init__(self, parallelism=200, nameservers=None): 230 | self.nameservers = nameservers if nameservers is not None else ["8.8.8.8"] 231 | self.semaphore = asyncio.Semaphore(parallelism) 232 | 233 | async def resolve(self, fqdn, type=None, retry=3): 234 | async with self.semaphore: 235 | if self.attempted_resolutions == 0: 236 | write_when_on_warn("\nDNS Resolving: (Feedback per 100 resolves)\n") 237 | write_when_on_warn( 238 | "[ . = success, + = non-existent domain, x = error (likely rate-limiting)]\n" 239 | ) 240 | self.attempted_resolutions += 1 241 | qtype = QUERY_TYPES[type] 242 | start = time.time() 243 | resp = { 244 | "A": [], 245 | "NS": [], 246 | "CNAME": [], 247 | "SOA": [], 248 | "AAAA": [], 249 | "MX": [], 250 | "NX_DOMAIN": False, 251 | } 252 | try: 253 | resp = await resolve_dns(fqdn, qtype, random.choice(self.nameservers)) 254 | if self.resolutions % 100 == 99: 255 | write_when_on_warn(".") 256 | self.resolutions += 1 257 | except NoAnswerException: 258 | if self.resolutions % 100 == 99: 259 | write_when_on_warn(".") 260 | self.resolutions += 1 261 | except NXDomainException: 262 | if self.nx_domains % 100 == 99: 263 | write_when_on_warn("+") 264 | self.nx_domains += 1 265 | resp["NX_DOMAIN"] = True 266 | except: 267 | if self.errors % 100 == 99: 268 | write_when_on_warn("x") 269 | self.errors += 1 270 | if retry > 0: 271 | await asyncio.sleep(0.1) 272 | resp = await self.resolve(fqdn, type=type, retry=retry - 1) 273 | time_delta = time.time() - start 274 | if time_delta < 0.125: 275 | await asyncio.sleep(0.125 - time_delta) 276 | return resp 277 | 278 | @staticmethod 279 | async def resolve_with_ns(fqdn, ns, type=None): 280 | qtype = QUERY_TYPES[type] 281 | try: 282 | return await resolve_dns(fqdn, qtype, ns) 283 | except: 284 | return {"A": [], "NS": [], "CNAME": [], "SOA": [], "AAAA": [], "MX": []} 285 | 286 | 287 | def write_when_on_warn(msg): 288 | if logging.root.level == logging.WARN: 289 | # Only in warning, as we need a clean output 290 | os.write(2, msg.encode()) 291 | -------------------------------------------------------------------------------- /scan.py: -------------------------------------------------------------------------------- 1 | from finding import Finding 2 | 3 | import logging 4 | 5 | 6 | async def scan_domain(domain, signatures, findings, output_handler): 7 | if domain.should_fetch_std_records: 8 | await domain.fetch_std_records() 9 | else: 10 | await domain.fetch_external_records() 11 | for signature in signatures: 12 | logging.debug( 13 | f"Testing domain '{domain.domain}' with signature '{signature.__name__}'" 14 | ) 15 | if signature.test.potential(domain=domain): 16 | logging.debug( 17 | f"Potential takeover found on DOMAIN '{domain}' using signature '{signature.__name__}'" 18 | ) 19 | if await signature.test.check(domain=domain): 20 | status = signature.test.CONFIDENCE.value 21 | logging.info( 22 | f"Takeover {status} on {domain} using signature '{signature.__name__}'" 23 | ) 24 | finding = Finding( 25 | domain=domain, 26 | signature=signature.__name__, 27 | info=signature.test.INFO, 28 | confidence=signature.test.CONFIDENCE, 29 | more_info_url=signature.test.more_info_url, 30 | ) 31 | findings.append(finding) 32 | output_handler.write(finding) 33 | else: 34 | logging.debug( 35 | f"Takeover not possible on DOMAIN '{domain}' using signature '{signature.__name__}'" 36 | ) 37 | -------------------------------------------------------------------------------- /signatures/000domains.py: -------------------------------------------------------------------------------- 1 | from .templates.ns_found_but_no_SOA import ns_found_but_no_SOA 2 | 3 | test = ns_found_but_no_SOA( 4 | [ 5 | "ns1.000domains.com", 6 | "ns2.000domains.com", 7 | "fwns1.000domains.com", 8 | "fwns2.000domains.com", 9 | ], 10 | service="000domains.com", 11 | ) 12 | -------------------------------------------------------------------------------- /signatures/__init__.py: -------------------------------------------------------------------------------- 1 | from os.path import dirname, basename, isfile, join 2 | import glob 3 | 4 | modules = glob.glob(join(dirname(__file__), "*.py")) 5 | __all__ = [ 6 | basename(f)[:-3] for f in modules if isfile(f) and not f.endswith("__init__.py") 7 | ] 8 | 9 | from . import * 10 | -------------------------------------------------------------------------------- /signatures/_generic_cname_found_but_404_http.py: -------------------------------------------------------------------------------- 1 | from domain import Domain 2 | from . import checks 3 | import detection_enums 4 | 5 | from .templates.base import Base 6 | 7 | 8 | def potential(domain: Domain, **kwargs) -> bool: 9 | if domain.CNAME != []: 10 | for cname in domain.CNAME: 11 | if domain.domain.split(".")[-2:] != cname.split(".")[-2:]: 12 | # last 2 parts of domain dont match, doesnt belong to same org 13 | return True 14 | return False 15 | 16 | 17 | async def check(domain: Domain, **kwargs) -> bool: 18 | return await checks.WEB.status_code_404(domain, False) 19 | 20 | 21 | INFO = """ 22 | The defined domain has a CNAME record configured but the website returns a 404 over HTTP. \ 23 | You should investigate this 404 response. 24 | """ 25 | 26 | test = Base(INFO, detection_enums.CONFIDENCE.UNLIKELY) 27 | test.potential = potential 28 | test.check = check 29 | -------------------------------------------------------------------------------- /signatures/_generic_cname_found_but_404_https.py: -------------------------------------------------------------------------------- 1 | from domain import Domain 2 | from . import checks 3 | import detection_enums 4 | 5 | from .templates.base import Base 6 | 7 | 8 | def potential(domain: Domain, **kwargs) -> bool: 9 | if domain.CNAME != []: 10 | for cname in domain.CNAME: 11 | if domain.domain.split(".")[-2:] != cname.split(".")[-2:]: 12 | # last 2 parts of domain dont match, doesnt belong to same org 13 | return True 14 | return False 15 | 16 | 17 | async def check(domain: Domain, **kwargs) -> bool: 18 | return await checks.WEB.status_code_404(domain, True) 19 | 20 | 21 | INFO = """ 22 | The defined domain has a CNAME record configured but the website returns a 404 over HTTPS. \ 23 | You should investigate this 404 response. 24 | """ 25 | 26 | test = Base(INFO, detection_enums.CONFIDENCE.UNLIKELY) 27 | test.potential = potential 28 | test.check = check 29 | -------------------------------------------------------------------------------- /signatures/_generic_cname_found_but_unregistered.py: -------------------------------------------------------------------------------- 1 | from domain import Domain 2 | from . import checks 3 | 4 | from .templates.base import Base 5 | 6 | 7 | def potential(domain: Domain, **kwargs) -> bool: 8 | if domain.CNAME != []: 9 | for cname in domain.CNAME: 10 | if cname.count(".") == 1: 11 | # This is a 2 part domain, i.e. foo.bar 12 | return True 13 | return False 14 | 15 | 16 | async def check(domain: Domain, **kwargs) -> bool: 17 | return await checks.CNAME.is_unregistered(domain) 18 | 19 | 20 | INFO = """ 21 | The defined domain has a CNAME record configured but the CNAME is not registered. \ 22 | You should look to see if you can register this CNAME. 23 | """ 24 | 25 | test = Base(INFO) 26 | test.potential = potential 27 | test.check = check 28 | -------------------------------------------------------------------------------- /signatures/_generic_cname_found_doesnt_resolve.py: -------------------------------------------------------------------------------- 1 | from domain import Domain 2 | from . import checks 3 | import detection_enums 4 | 5 | from .templates.base import Base 6 | 7 | 8 | filtered_cname_substrings = [ 9 | "elb.amazonaws.com", 10 | ".cloudfront.net", 11 | ".oracle.com", 12 | ".invalid", 13 | "online.lync.com", 14 | ] 15 | 16 | 17 | def cname_should_be_filtered(cname): 18 | for f in filtered_cname_substrings: 19 | if f in cname: 20 | return True 21 | return False 22 | 23 | 24 | def potential(domain: Domain, **kwargs) -> bool: 25 | if domain.CNAME != []: 26 | for cname in domain.CNAME: 27 | if cname_should_be_filtered(cname): 28 | continue 29 | if domain.domain in cname: 30 | # the entire domain is in the cname so its probably not customer provided input 31 | continue 32 | if domain.domain.split(".")[-2:] != cname.split(".")[-2:]: 33 | # last 2 parts of domain dont match, doesnt belong to same org 34 | return True 35 | return False 36 | 37 | 38 | async def check(domain: Domain, **kwargs) -> bool: 39 | return await checks.CNAME.NX_DOMAIN_on_resolve(domain) 40 | 41 | 42 | INFO = """ 43 | The defined domain has a CNAME record configured but the CNAME does not resolve. \ 44 | You should look to see if you can register or takeover this CNAME. 45 | """ 46 | 47 | test = Base(INFO, detection_enums.CONFIDENCE.POTENTIAL) 48 | test.potential = potential 49 | test.check = check 50 | -------------------------------------------------------------------------------- /signatures/_generic_zone_missing_on_ns.py: -------------------------------------------------------------------------------- 1 | from domain import Domain 2 | from . import checks 3 | import detection_enums 4 | 5 | from .templates.base import Base 6 | 7 | 8 | def potential(domain: Domain, **kwargs) -> bool: 9 | if domain.NS != []: 10 | for ns in domain.NS: 11 | if domain.domain.split(".")[-2:] != ns.split(".")[-2:]: 12 | # last 2 parts of domain dont match, otherwise belongs to same org 13 | return True 14 | return False 15 | 16 | 17 | async def check(domain: Domain, **kwargs) -> bool: 18 | return await checks.NS.no_SOA_detected(domain) 19 | 20 | 21 | INFO = """ 22 | The defined domain has NS records configured but these nameservers do not host a zone for this domain. \ 23 | An attacker may be able to register this domain on with the service managing the nameserver. 24 | """ 25 | 26 | test = Base(INFO, detection_enums.CONFIDENCE.POTENTIAL) 27 | test.potential = potential 28 | test.check = check 29 | -------------------------------------------------------------------------------- /signatures/agilecrm.py: -------------------------------------------------------------------------------- 1 | from .templates.cname_found_but_string_in_body import cname_found_but_string_in_body 2 | 3 | test = cname_found_but_string_in_body( 4 | cname="cname.agilecrm.com", 5 | domain_not_configured_message="No landing page found.", 6 | service="agilecrm.com", 7 | ) 8 | -------------------------------------------------------------------------------- /signatures/aha.py: -------------------------------------------------------------------------------- 1 | from .templates.cname_found_but_string_in_body import cname_found_but_string_in_body 2 | 3 | test = cname_found_but_string_in_body( 4 | cname=".aha.io", 5 | domain_not_configured_message="Unable to load ideas portal", 6 | service="aha.io", 7 | ) 8 | -------------------------------------------------------------------------------- /signatures/airee_ru.py: -------------------------------------------------------------------------------- 1 | from .templates.cname_found_but_string_in_body import cname_found_but_string_in_body 2 | 3 | test = cname_found_but_string_in_body( 4 | cname="cdn.airee.ru", 5 | domain_not_configured_message="Ошибка 402. Сервис Айри.рф не оплачен", 6 | service="airee.ru", 7 | ) 8 | -------------------------------------------------------------------------------- /signatures/anima.py: -------------------------------------------------------------------------------- 1 | from .templates.cname_or_ip_found_but_string_in_body import ( 2 | cname_or_ip_found_but_string_in_body, 3 | ) 4 | 5 | ipv4 = ["35.164.217.247"] 6 | ipv6 = [] 7 | 8 | cname = "ns1.animaapp.com" 9 | 10 | test = cname_or_ip_found_but_string_in_body( 11 | cname=cname, 12 | ips=ipv4 + ipv6, 13 | domain_not_configured_message="""

404 Not Found

\r\n
nginx
""", 14 | service="anima app", 15 | ) 16 | -------------------------------------------------------------------------------- /signatures/announcekit.py: -------------------------------------------------------------------------------- 1 | from .templates.cname_found_but_status_code import cname_found_but_status_code 2 | 3 | test = cname_found_but_status_code( 4 | cname="cname.announcekit.app", 5 | code=0, # status code 0 on tls falure 6 | service="announcekit", 7 | ) 8 | -------------------------------------------------------------------------------- /signatures/aws_ns.py: -------------------------------------------------------------------------------- 1 | from .templates.ns_found_but_no_SOA import ns_found_but_no_SOA 2 | 3 | test = ns_found_but_no_SOA( 4 | ns="awsdns", 5 | service="AWS Route53", 6 | sample_ns="ns-40.awsdns-05.com", 7 | more_info_url="https://github.com/punk-security/dnsReaper/issues/122", 8 | ) 9 | 10 | test.INFO = ( 11 | test.INFO 12 | + " AWS has some validation that may prevent some takeovers, so this may or may not be vulnerable." 13 | ) 14 | -------------------------------------------------------------------------------- /signatures/bigcartel.py: -------------------------------------------------------------------------------- 1 | from .templates.cname_found_but_string_in_body import cname_found_but_string_in_body 2 | 3 | test = cname_found_but_string_in_body( 4 | cname=".bigcartel.com", 5 | domain_not_configured_message="DNS resolution error", 6 | service="bigcartel.com", 7 | ) 8 | -------------------------------------------------------------------------------- /signatures/bizland.py: -------------------------------------------------------------------------------- 1 | from .templates.ns_found_but_no_SOA import ns_found_but_no_SOA 2 | 3 | test = ns_found_but_no_SOA( 4 | [ 5 | "ns1.bizland.com", 6 | "ns2.bizland.com", 7 | "clickme.click2site.com", 8 | "clickme2.click2site.com", 9 | ], 10 | service="bizland.com", 11 | ) 12 | -------------------------------------------------------------------------------- /signatures/brandpad.py: -------------------------------------------------------------------------------- 1 | from .templates.cname_found_but_string_in_body import cname_found_but_string_in_body 2 | 3 | test = cname_found_but_string_in_body( 4 | cname="brandpad.io", 5 | domain_not_configured_message="is not registered as whitelabel or custom domain.", 6 | service="brandpad.io", 7 | ) 8 | -------------------------------------------------------------------------------- /signatures/brightcove.py: -------------------------------------------------------------------------------- 1 | from .templates.cname_found_but_string_in_body import cname_found_but_string_in_body 2 | 3 | test = cname_found_but_string_in_body( 4 | cname=[".bcvp0rtal.com", ".brightcovegallery.com", ".gallery.video"], 5 | domain_not_configured_message="Page Not Found", 6 | service="BrightCove", 7 | ) 8 | -------------------------------------------------------------------------------- /signatures/campaign_monitor.py: -------------------------------------------------------------------------------- 1 | from .templates.cname_found_but_string_in_body import cname_found_but_string_in_body 2 | 3 | test = cname_found_but_string_in_body( 4 | cname=".campaignmonitor.com", 5 | domain_not_configured_message="WHOOPS! The page you're looking for does not exist.", 6 | service="campaignmonitor.com", 7 | ) 8 | -------------------------------------------------------------------------------- /signatures/cargo_collective.py: -------------------------------------------------------------------------------- 1 | from .templates.cname_found_but_string_in_body import cname_found_but_string_in_body 2 | 3 | test = cname_found_but_string_in_body( 4 | cname=".cargo.site", 5 | domain_not_configured_message="""
\n\t\t\t404 Not Found
\n\t\t\t str: 6 | if type(ipv4) == str: 7 | ipv4 = [ipv4] 8 | for address in ipv4: 9 | if address in domain.A: 10 | logging.debug(f"IPv4 address '{address}' detected for domain '{domain}'") 11 | return True 12 | return False 13 | -------------------------------------------------------------------------------- /signatures/checks/AAAA.py: -------------------------------------------------------------------------------- 1 | from domain import Domain 2 | import logging 3 | 4 | 5 | def match(domain: Domain, ipv6) -> str: 6 | if type(ipv6) == str: 7 | ipv6 = [ipv6] 8 | for address in ipv6: 9 | if address in domain.AAAA: 10 | logging.debug(f"IPv6 address detected for domain '{domain}'") 11 | return True 12 | return False 13 | -------------------------------------------------------------------------------- /signatures/checks/CNAME.py: -------------------------------------------------------------------------------- 1 | from domain import Domain 2 | from . import helpers 3 | import logging 4 | 5 | 6 | def match(domain: Domain, strings) -> str: 7 | match = helpers.substrings_in_strings(strings, domain.CNAME) 8 | if match: 9 | logging.debug(f"Match detected in CNAME '{match} for domain '{domain}'") 10 | return True 11 | return False 12 | 13 | 14 | async def NX_DOMAIN_on_resolve(domain: Domain) -> bool: 15 | for cname in domain.CNAME: 16 | cname = Domain(cname, fetch_standard_records=False) 17 | if await cname.NX_DOMAIN: 18 | logging.info(f"NX_Domain for cname {cname}") 19 | return True 20 | return False 21 | 22 | 23 | async def is_unregistered(domain: Domain) -> bool: 24 | for cname in domain.CNAME: 25 | cname = Domain(cname, fetch_standard_records=False) 26 | if not await cname.is_registered: 27 | logging.info(f"The domain '{cname}' is NOT registered") 28 | return True 29 | return False 30 | -------------------------------------------------------------------------------- /signatures/checks/COMBINED.py: -------------------------------------------------------------------------------- 1 | from . import A, AAAA, CNAME 2 | from domain import Domain 3 | 4 | 5 | def matching_ipv4_or_ipv6(domain: Domain, ipv4, ipv6) -> bool: 6 | if A.match(domain, ipv4): 7 | return True 8 | if AAAA.match(domain, ipv6): 9 | return True 10 | return False 11 | 12 | 13 | def matching_ipv4_or_cname(domain: Domain, ipv4, strings) -> bool: 14 | if A.match(domain, ipv4): 15 | return True 16 | if CNAME.match(domain, strings): 17 | return True 18 | return False 19 | 20 | 21 | def matching_ip_or_cname(domain: Domain, strings, ips) -> bool: 22 | if A.match(domain, ips): 23 | return True 24 | if AAAA.match(domain, ips): 25 | return True 26 | if CNAME.match(domain, strings): 27 | return True 28 | return False 29 | -------------------------------------------------------------------------------- /signatures/checks/NS.py: -------------------------------------------------------------------------------- 1 | from domain import Domain 2 | from . import helpers 3 | import logging 4 | import resolver as resolver 5 | 6 | 7 | def match(domain: Domain, strings) -> str: 8 | match = helpers.substrings_in_strings(strings, domain.NS) 9 | if match: 10 | logging.debug(f"Match detected in NS '{match} for domain '{domain}'") 11 | return True 12 | return False 13 | 14 | 15 | async def no_SOA_detected(domain: Domain) -> bool: 16 | for ns in domain.NS: 17 | ns_ip = await Domain(ns, fetch_standard_records=False).query("A") 18 | if ns_ip == []: 19 | logging.debug(f"Could not resolve NS '{ns}'") 20 | continue 21 | if ( 22 | (await resolver.Resolver.resolve_with_ns(domain.domain, ns_ip[0], "SOA"))[ 23 | "SOA" 24 | ] 25 | ) == []: 26 | logging.info(f"NAMESERVER at {ns} does not have this zone.") 27 | return True 28 | else: 29 | logging.debug(f"SOA record found on NAMESERVER '{ns}'") 30 | return False # Never found the condition 31 | -------------------------------------------------------------------------------- /signatures/checks/WEB.py: -------------------------------------------------------------------------------- 1 | from domain import Domain 2 | from math import floor 3 | import logging 4 | import sys 5 | 6 | 7 | async def string_in_body( 8 | domain: Domain, string: str, https: bool, custom_uri: str = "" 9 | ) -> bool: 10 | if string in (await domain.fetch_web(https=https, uri=custom_uri)).body: 11 | logging.info(f"Message observed in response for '{domain}'") 12 | return True 13 | logging.debug(f"Message not found in response for '{domain}'") 14 | # Uncomment to debug and identify a string match issue 15 | if "pytest" in sys.modules: 16 | logging.warning((await domain.fetch_web(https=https, uri=custom_uri)).body) 17 | return False 18 | 19 | 20 | async def string_in_body_http( 21 | domain: Domain, string: str, custom_uri: str = "" 22 | ) -> bool: 23 | return await string_in_body(domain, string, False, custom_uri) 24 | 25 | 26 | async def string_in_body_https( 27 | domain: Domain, string: str, custom_uri: str = "" 28 | ) -> bool: 29 | return await string_in_body(domain, string, True, custom_uri) 30 | 31 | 32 | async def status_code_match(domain: Domain, status_code: int, https: bool) -> bool: 33 | response_code = (await domain.fetch_web(https=https)).status_code 34 | if status_code < 10: # match the first int 35 | if floor(response_code / 100) == status_code: 36 | logging.info(f"Response code {response_code} observed for '{domain}'") 37 | return True 38 | else: 39 | if response_code == status_code: 40 | logging.info(f"Response code {response_code} observed for '{domain}'") 41 | return True 42 | logging.debug(f"Response code {response_code} observed for '{domain}'") 43 | return False 44 | 45 | 46 | async def status_code_404(domain: Domain, https: bool) -> bool: 47 | return await status_code_match(domain, 404, https) 48 | -------------------------------------------------------------------------------- /signatures/checks/__init__.py: -------------------------------------------------------------------------------- 1 | from os.path import dirname, basename, isfile, join 2 | import glob 3 | 4 | modules = glob.glob(join(dirname(__file__), "*.py")) 5 | __all__ = [ 6 | basename(f)[:-3] for f in modules if isfile(f) and not f.endswith("__init__.py") 7 | ] 8 | 9 | from . import * 10 | -------------------------------------------------------------------------------- /signatures/checks/helpers.py: -------------------------------------------------------------------------------- 1 | def substrings_in_strings(substrings, strings): 2 | if type(strings) != list: 3 | strings = [strings] 4 | if type(substrings) != list: 5 | substrings = [substrings] 6 | for string in strings: 7 | for substring in substrings: 8 | if substring == "": 9 | continue 10 | if substring in string: 11 | return string 12 | return "" 13 | -------------------------------------------------------------------------------- /signatures/convertkit.py: -------------------------------------------------------------------------------- 1 | from .templates.ip_found_but_string_in_body import ip_found_but_string_in_body 2 | 3 | convertkit_pages_ipv4 = [ 4 | "3.13.222.255", 5 | "3.13.246.91", 6 | "3.130.60.26", 7 | ] 8 | 9 | test = ip_found_but_string_in_body( 10 | ips=convertkit_pages_ipv4, 11 | domain_not_configured_message="The page you were looking for doesn", 12 | service="Convert Pages", 13 | ) 14 | -------------------------------------------------------------------------------- /signatures/digitalocean.py: -------------------------------------------------------------------------------- 1 | from .templates.ns_found_but_no_SOA import ns_found_but_no_SOA 2 | 3 | test = ns_found_but_no_SOA( 4 | [ 5 | "ns1.digitalocean.com", 6 | "ns2.digitalocean.com", 7 | "ns3.digitalocean.com", 8 | ], 9 | service="digitalocean.com", 10 | ) 11 | -------------------------------------------------------------------------------- /signatures/dnsmadeeasy.py: -------------------------------------------------------------------------------- 1 | from .templates.ns_found_but_no_SOA import ns_found_but_no_SOA 2 | 3 | test = ns_found_but_no_SOA( 4 | ns="dnsmadeeasy", service="dnsmadeeasy.com", sample_ns="ns11.dnsmadeeasy.com" 5 | ) 6 | -------------------------------------------------------------------------------- /signatures/dnssimple.py: -------------------------------------------------------------------------------- 1 | from .templates.ns_found_but_no_SOA import ns_found_but_no_SOA 2 | 3 | test = ns_found_but_no_SOA( 4 | [ 5 | "ns1.dnsimple.com", 6 | "ns2.dnsimple.com", 7 | "ns3.dnsimple.com", 8 | "ns4.dnsimple.com", 9 | ], 10 | service="dnsimple.com", 11 | ) 12 | -------------------------------------------------------------------------------- /signatures/domain.py: -------------------------------------------------------------------------------- 1 | from .templates.ns_found_but_no_SOA import ns_found_but_no_SOA 2 | 3 | test = ns_found_but_no_SOA( 4 | [ 5 | "ns1.domain.com", 6 | "ns2.domain.com", 7 | ], 8 | service="domain.com", 9 | ) 10 | -------------------------------------------------------------------------------- /signatures/dotster.py: -------------------------------------------------------------------------------- 1 | from .templates.ns_found_but_no_SOA import ns_found_but_no_SOA 2 | 3 | test = ns_found_but_no_SOA( 4 | [ 5 | "ns1.dotster.com", 6 | "ns2.dotster.com", 7 | "ns1.nameresolve.com", 8 | "ns2.nameresolve.com", 9 | ], 10 | service="nameresolve.com", 11 | ) 12 | -------------------------------------------------------------------------------- /signatures/elastic_beanstalk.py: -------------------------------------------------------------------------------- 1 | from .templates.cname_found_but_NX_DOMAIN import cname_found_but_NX_DOMAIN 2 | 3 | cnames = [ 4 | ".us-east-1.elasticbeanstalk.com", 5 | ".us-east-2.elasticbeanstalk.com", 6 | ".us-west-2.elasticbeanstalk.com", 7 | ".af-south-1.elasticbeanstalk.com", 8 | ".ap-east-1.elasticbeanstalk.com", 9 | ".ap-southeast-3.elasticbeanstalk.com", 10 | ".ap-south-1.elasticbeanstalk.com", 11 | ".ap-northeast-3.elasticbeanstalk.com", 12 | ".ap-northeast-2.elasticbeanstalk.com", 13 | ".ap-northeast-1.elasticbeanstalk.com", 14 | ".ca-central-1.elasticbeanstalk.com", 15 | ".eu-central-1.elasticbeanstalk.com", 16 | ".eu-west-1.elasticbeanstalk.com", 17 | ".eu-west-2.elasticbeanstalk.com", 18 | ".eu-south-1.elasticbeanstalk.com", 19 | ".eu-west-2.elasticbeanstalk.com", 20 | ".eu-west-3.elasticbeanstalk.com", 21 | ".eu-north-1.elasticbeanstalk.com", 22 | ".me-south-1.elasticbeanstalk.com", 23 | ".sa-east-1.elasticbeanstalk.com", 24 | ] 25 | 26 | test = cname_found_but_NX_DOMAIN( 27 | cname=cnames, 28 | service="AWS Elastic Beanstalk", 29 | ) 30 | -------------------------------------------------------------------------------- /signatures/frontify.py: -------------------------------------------------------------------------------- 1 | from .templates.cname_found_but_string_in_body import cname_found_but_string_in_body 2 | 3 | test = cname_found_but_string_in_body( 4 | cname="cname.frontify.com", 5 | domain_not_configured_message="404 - Not Found - Frontify", 6 | service="frontify.com", 7 | ) 8 | -------------------------------------------------------------------------------- /signatures/getresponse.py: -------------------------------------------------------------------------------- 1 | from .templates.cname_found_but_string_in_body import cname_found_but_string_in_body 2 | 3 | test = cname_found_but_string_in_body( 4 | cname=".gr8.com", 5 | domain_not_configured_message="is no longer available", 6 | service="getresponse", 7 | ) 8 | -------------------------------------------------------------------------------- /signatures/github_pages.py: -------------------------------------------------------------------------------- 1 | from .templates.cname_or_ip_found_but_string_in_body import ( 2 | cname_or_ip_found_but_string_in_body, 3 | ) 4 | 5 | github_pages_ipv4 = [ 6 | "185.199.108.153", 7 | "185.199.109.153", 8 | "185.199.110.153", 9 | "185.199.111.153", 10 | ] 11 | github_pages_ipv6 = [ 12 | "2606:50c0:8000::153", 13 | "2606:50c0:8001::153", 14 | "2606:50c0:8002::153", 15 | "2606:50c0:8003::153", 16 | ] 17 | 18 | github_pages_cname = ".github.io" 19 | 20 | test = cname_or_ip_found_but_string_in_body( 21 | cname=github_pages_cname, 22 | ips=github_pages_ipv4 + github_pages_ipv6, 23 | domain_not_configured_message="Site not found ", 24 | service="Github Pages", 25 | https=True, 26 | ) 27 | -------------------------------------------------------------------------------- /signatures/googlecloud.py: -------------------------------------------------------------------------------- 1 | from .templates.ns_found_but_no_SOA import ns_found_but_no_SOA 2 | 3 | test = ns_found_but_no_SOA( 4 | ns="googledomains", 5 | service="cloud.google.com", 6 | sample_ns="ns-cloud-b1.googledomains.com", 7 | ) 8 | -------------------------------------------------------------------------------- /signatures/hatenablog.py: -------------------------------------------------------------------------------- 1 | from .templates.cname_found_but_string_in_body import cname_found_but_string_in_body 2 | 3 | test = cname_found_but_string_in_body( 4 | cname=".hatenablog.com", 5 | domain_not_configured_message="404 Blog is not found", 6 | service="hatenablog.com", 7 | https=True, 8 | ) 9 | -------------------------------------------------------------------------------- /signatures/helpscout.py: -------------------------------------------------------------------------------- 1 | from .templates.cname_found_but_string_in_body import cname_found_but_string_in_body 2 | 3 | test = cname_found_but_string_in_body( 4 | cname="cname.helpscoutdocs.com", 5 | domain_not_configured_message="Not Found", 6 | service="helpscoutdocs.com", 7 | https=True, 8 | ) 9 | -------------------------------------------------------------------------------- /signatures/hostinger.py: -------------------------------------------------------------------------------- 1 | from .templates.ns_found_but_no_SOA import ns_found_but_no_SOA 2 | 3 | test = ns_found_but_no_SOA( 4 | [ 5 | "ns1.hostinger.com", 6 | "ns2.hostinger.com", 7 | ], 8 | service="hostinger.com", 9 | ) 10 | -------------------------------------------------------------------------------- /signatures/hurricane_electric.py: -------------------------------------------------------------------------------- 1 | from .templates.ns_found_but_no_SOA import ns_found_but_no_SOA 2 | 3 | test = ns_found_but_no_SOA( 4 | [ 5 | "ns5.he.net", 6 | "ns4.he.net", 7 | "ns3.he.net", 8 | "ns2.he.net", 9 | "ns1.he.net", 10 | ], 11 | service="he.net", 12 | ) 13 | -------------------------------------------------------------------------------- /signatures/jetbrains.py: -------------------------------------------------------------------------------- 1 | from .templates.cname_found_but_string_in_body import cname_found_but_string_in_body 2 | 3 | test = cname_found_but_string_in_body( 4 | cname="cname.myjetbrains.com", 5 | domain_not_configured_message="YouTrack Starting Page", 6 | service="myjetbrains.com", 7 | ) 8 | -------------------------------------------------------------------------------- /signatures/launchrock_cname.py: -------------------------------------------------------------------------------- 1 | from .templates.cname_found_but_string_in_body import cname_found_but_string_in_body 2 | 3 | test = cname_found_but_string_in_body( 4 | cname="host.launchrock.com", 5 | domain_not_configured_message="It looks like you may have taken a wrong turn somewhere", 6 | service="Launchrock", 7 | https=True, 8 | ) 9 | -------------------------------------------------------------------------------- /signatures/linode.py: -------------------------------------------------------------------------------- 1 | from .templates.ns_found_but_no_SOA import ns_found_but_no_SOA 2 | 3 | test = ns_found_but_no_SOA( 4 | [ 5 | "ns1.linode.com", 6 | "ns2.linode.com", 7 | ], 8 | service="linode.com", 9 | ) 10 | -------------------------------------------------------------------------------- /signatures/mashery.py: -------------------------------------------------------------------------------- 1 | from .templates.cname_found_but_string_in_body import cname_found_but_string_in_body 2 | 3 | test = cname_found_but_string_in_body( 4 | cname=".mashery.com", 5 | domain_not_configured_message="Unrecognized domain", 6 | service="mashery.com", 7 | https=True, 8 | ) 9 | -------------------------------------------------------------------------------- /signatures/mediatemplate.py: -------------------------------------------------------------------------------- 1 | from .templates.ns_found_but_no_SOA import ns_found_but_no_SOA 2 | 3 | test = ns_found_but_no_SOA( 4 | [ 5 | "ns1.mediatemple.net", 6 | "ns2.mediatemple.net", 7 | ], 8 | service="mediatemple.net", 9 | ) 10 | -------------------------------------------------------------------------------- /signatures/microsoft_azure.py: -------------------------------------------------------------------------------- 1 | from .templates.cname_found_but_NX_DOMAIN import cname_found_but_NX_DOMAIN 2 | 3 | cnames = [ 4 | ".cloudapp.net", 5 | ".cloudapp.azure.com", 6 | ".azurewebsites.net", 7 | ".blob.core.windows.net", 8 | ".cloudapp.azure.com", 9 | ".azure-api.net", 10 | ".azurehdinsight.net", 11 | ".azureedge.net", 12 | ".azurecontainer.io", 13 | ".database.windows.net", 14 | ".azuredatalakestore.net", 15 | ".search.windows.net", 16 | ".azurecr.io", 17 | ".redis.cache.windows.net", 18 | ".azurehdinsight.net", 19 | ".servicebus.windows.net", 20 | ".trafficmanager.net", 21 | ] 22 | 23 | test = cname_found_but_NX_DOMAIN( 24 | cname=cnames, 25 | service="Microsoft Azure", 26 | ) 27 | -------------------------------------------------------------------------------- /signatures/mysmartjobboard.py: -------------------------------------------------------------------------------- 1 | from .templates.cname_found_but_string_in_body import cname_found_but_string_in_body 2 | 3 | test = cname_found_but_string_in_body( 4 | cname="cname.mysmartjobboard.com", 5 | domain_not_configured_message="Job Board Is Unavailable", 6 | service="mysmartjobboard.com", 7 | https=True, 8 | ) 9 | -------------------------------------------------------------------------------- /signatures/name.py: -------------------------------------------------------------------------------- 1 | from .templates.ns_found_but_no_SOA import ns_found_but_no_SOA 2 | 3 | test = ns_found_but_no_SOA( 4 | ns=".name.com", service="name.com", sample_ns="ns3nrz.name.com" 5 | ) 6 | -------------------------------------------------------------------------------- /signatures/netlify.py: -------------------------------------------------------------------------------- 1 | from .templates.cname_found_but_string_in_body import cname_found_but_string_in_body 2 | 3 | test = cname_found_but_string_in_body( 4 | cname=".netlify.app", 5 | domain_not_configured_message="Not Found - Request ID:", 6 | service="Netlify", 7 | ) 8 | -------------------------------------------------------------------------------- /signatures/ngrok.py: -------------------------------------------------------------------------------- 1 | from .templates.cname_found_but_string_in_body import cname_found_but_string_in_body 2 | 3 | test = cname_found_but_string_in_body( 4 | cname=[ 5 | "cname.us.ngrok.io", 6 | "cname.eu.ngrok.io", 7 | "cname.uk.ngrok.io", 8 | "cname.ngrok.io", 9 | ], 10 | domain_not_configured_message="ERR_NGROK_3200", 11 | service="ngrok.io", 12 | ) 13 | -------------------------------------------------------------------------------- /signatures/nsone.py: -------------------------------------------------------------------------------- 1 | from .templates.ns_found_but_no_SOA import ns_found_but_no_SOA 2 | 3 | test = ns_found_but_no_SOA( 4 | ns=".nsone.net", service="ns1.com", sample_ns="dns1.p05.nsone.net" 5 | ) 6 | -------------------------------------------------------------------------------- /signatures/readme.md: -------------------------------------------------------------------------------- 1 | # Signatures 2 | 3 | Signatures in this directory are auto-imported into the scanning engine. The scanning engine then checks every domain with every signature, unless that signature is excluded from the scan due to its ```CONFIDENCE``` or name. 4 | 5 | Each signature has to inherit from the [Base Class](templates/base.py) 6 | 7 | Most signatures inherit from another template class, which is meant to standardise their implementation. 8 | 9 | We heavily test signatures using the pytest framework, testing that they work as expected with dummy and live checks. 10 | 11 | Each signature has two components, a ```potential``` function and a ```check``` function. This is to reduce the processing time and number of active checks, such as DNS and web queries. 12 | 13 | The potential function is used to identify if this signature might be relevant for this domain, and typically should not contain any active check such as a web request or DNS request. We try to only check data we already have, such as pattern matching the cnames. 14 | 15 | The check function is used to validate the takeover and will only run if the domain has passed the potential function. This check can be slower / more intensive as it will not run for every domain. 16 | 17 | ## Adding a new signature 18 | 19 | It is probably best copying another signature that resembles the one you are looking to implement. This also reduces or negates the need for you to add your own tests. 20 | 21 | If a signature uses a standard template such as [cname_found_but_string_in_body](templates/cname_found_but_string_in_body.py) then there are no further tests required. 22 | 23 | If you need to use a custom approach, you should use the components in the [checks](checks/) directory. These are sorted by the domain component you are testing. These checks are all heavily tested for edge cases. If you need to add a new check component, ensure you add the relevant edge case tests. -------------------------------------------------------------------------------- /signatures/reg.py: -------------------------------------------------------------------------------- 1 | from .templates.ns_found_but_no_SOA import ns_found_but_no_SOA 2 | 3 | test = ns_found_but_no_SOA( 4 | [ 5 | "ns1.reg.ru", 6 | "ns2.reg.ru", 7 | ], 8 | service="reg.ru", 9 | ) 10 | -------------------------------------------------------------------------------- /signatures/shopify.py: -------------------------------------------------------------------------------- 1 | from .templates.cname_or_ip_found_but_string_in_body import ( 2 | cname_or_ip_found_but_string_in_body, 3 | ) 4 | 5 | ipv4 = ["23.227.38.65"] 6 | ipv6 = [] 7 | 8 | cname = "shops.myshopify.com" 9 | 10 | test = cname_or_ip_found_but_string_in_body( 11 | cname=cname, 12 | ips=ipv4 + ipv6, 13 | domain_not_configured_message="This domain points to Shopify but isn't configured properly", 14 | service="Shopify", 15 | ) 16 | 17 | # https://help.shopify.com/en/manual/domains/add-a-domain/connecting-domains/connect-domain-manual#step-1-change-your-dns-records-in-your-third-party-domain-provider-account 18 | -------------------------------------------------------------------------------- /signatures/short.py: -------------------------------------------------------------------------------- 1 | from .templates.cname_found_but_string_in_body import cname_found_but_string_in_body 2 | 3 | test = cname_found_but_string_in_body( 4 | cname="cname.short.io", 5 | domain_not_configured_message="This domain is not configured on Short.io.", 6 | service="short", 7 | https=True, 8 | ) 9 | -------------------------------------------------------------------------------- /signatures/simplebooklet.py: -------------------------------------------------------------------------------- 1 | from .templates.cname_found_but_string_in_body import cname_found_but_string_in_body 2 | 3 | test = cname_found_but_string_in_body( 4 | cname="cname.simplebooklet.com", 5 | domain_not_configured_message="The link to this Simplebooklet may have changed", 6 | service="simplebooklet.com", 7 | https=True, 8 | ) 9 | -------------------------------------------------------------------------------- /signatures/smartjobboard.py: -------------------------------------------------------------------------------- 1 | from .templates.ip_found_but_string_in_body import ip_found_but_string_in_body 2 | 3 | test = ip_found_but_string_in_body( 4 | ips=["52.16.160.97"], 5 | domain_not_configured_message="job board website is either expired or its domain name is invalid.", 6 | service="smartjobboard.com", 7 | https=True, 8 | ) 9 | -------------------------------------------------------------------------------- /signatures/surge.py: -------------------------------------------------------------------------------- 1 | from .templates.cname_found_but_string_in_body import cname_found_but_string_in_body 2 | 3 | test = cname_found_but_string_in_body( 4 | cname="cname.surge.sh", 5 | domain_not_configured_message="project not found", 6 | service="surge.sh", 7 | ) 8 | -------------------------------------------------------------------------------- /signatures/surveysparrow.py: -------------------------------------------------------------------------------- 1 | from .templates.cname_found_but_string_in_body import cname_found_but_string_in_body 2 | 3 | test = cname_found_but_string_in_body( 4 | cname=".surveysparrow.com", 5 | domain_not_configured_message="<title>DNS resolution error ", 6 | service="survey sparrow", 7 | ) 8 | -------------------------------------------------------------------------------- /signatures/teamwork.py: -------------------------------------------------------------------------------- 1 | from .templates.cname_found_but_string_in_body import cname_found_but_string_in_body 2 | 3 | test = cname_found_but_string_in_body( 4 | cname=".teamwork.com", 5 | domain_not_configured_message="""Unable to determine installationID from domain""", 6 | service="teamwork", 7 | custom_uri="launchpad/v1/info.json", 8 | https=True, 9 | ) 10 | -------------------------------------------------------------------------------- /signatures/templates/__init__.py: -------------------------------------------------------------------------------- 1 | from os.path import dirname, basename, isfile, join 2 | import glob 3 | 4 | modules = glob.glob(join(dirname(__file__), "*.py")) 5 | __all__ = [ 6 | basename(f)[:-3] for f in modules if isfile(f) and not f.endswith("__init__.py") 7 | ] 8 | 9 | from . import * 10 | -------------------------------------------------------------------------------- /signatures/templates/base.py: -------------------------------------------------------------------------------- 1 | import detection_enums 2 | 3 | 4 | class Base: 5 | def potential(self, *args, **kwargs): 6 | raise NotImplementedError() 7 | 8 | async def check(self, *args, **kwargs): 9 | raise NotImplementedError() 10 | 11 | def __init__( 12 | self, info, confidence=detection_enums.CONFIDENCE.CONFIRMED, more_info_url="" 13 | ): 14 | if type(info) != str: 15 | raise ValueError("INFO is not string") 16 | self.INFO = info 17 | 18 | if type(confidence) != detection_enums.CONFIDENCE: 19 | raise ValueError("CONFIDENCE is not a valid enum") 20 | self.CONFIDENCE = confidence 21 | 22 | self.more_info_url = more_info_url 23 | -------------------------------------------------------------------------------- /signatures/templates/cname_found_but_NX_DOMAIN.py: -------------------------------------------------------------------------------- 1 | from . import base 2 | 3 | import signatures.checks 4 | 5 | from detection_enums import CONFIDENCE 6 | 7 | INFO = """ 8 | The defined domain has CNAME records configured for {service} but these records do not resolve. \ 9 | An attacker can register this domain on {service} and serve their own web content. 10 | """ 11 | 12 | 13 | class cname_found_but_NX_DOMAIN(base.Base): 14 | def potential(self, domain, **kwargs) -> bool: 15 | return signatures.checks.CNAME.match(domain, self.cname) 16 | 17 | async def check(self, domain, **kwargs) -> bool: 18 | return await signatures.checks.CNAME.NX_DOMAIN_on_resolve(domain) 19 | 20 | def __init__(self, cname, service, info=None, **kwargs): 21 | self.cname = cname 22 | info = info if info else INFO 23 | super().__init__(info.format(service=service), **kwargs) 24 | -------------------------------------------------------------------------------- /signatures/templates/cname_found_but_status_code.py: -------------------------------------------------------------------------------- 1 | from . import base 2 | 3 | import signatures.checks 4 | 5 | from detection_enums import CONFIDENCE 6 | 7 | INFO = """ 8 | The defined domain has a CNAME record configured for {service} but the website returns a {code}. \ 9 | You should investigate this 404 response. 10 | """ 11 | 12 | 13 | class cname_found_but_status_code(base.Base): 14 | def potential(self, domain, **kwargs) -> bool: 15 | return signatures.checks.CNAME.match(domain, self.cname) 16 | 17 | async def check(self, domain, **kwargs) -> bool: 18 | return await signatures.checks.WEB.status_code_match( 19 | domain, self.code, self.https 20 | ) 21 | 22 | def __init__(self, cname, code, service, info=None, https=False, **kwargs): 23 | self.cname = cname 24 | self.https = https 25 | self.code = code 26 | if code < 10: 27 | code = f"{code}XX" 28 | info = info if info else INFO 29 | super().__init__(info.format(service=service, code=code), **kwargs) 30 | -------------------------------------------------------------------------------- /signatures/templates/cname_found_but_string_in_body.py: -------------------------------------------------------------------------------- 1 | from . import base 2 | 3 | import signatures.checks 4 | 5 | from detection_enums import CONFIDENCE 6 | 7 | INFO = """ 8 | The defined domain has CNAME records configured for {service} but a web request shows the domain is unclaimed. \ 9 | An attacker can register this domain on {service} and serve their own web content. 10 | """ 11 | 12 | 13 | class cname_found_but_string_in_body(base.Base): 14 | def potential(self, domain, **kwargs) -> bool: 15 | return signatures.checks.CNAME.match(domain, self.cname) 16 | 17 | async def check(self, domain, **kwargs) -> bool: 18 | if self.https: 19 | return await signatures.checks.WEB.string_in_body_https( 20 | domain, self.domain_not_configured_message, custom_uri=self.custom_uri 21 | ) 22 | return await signatures.checks.WEB.string_in_body_http( 23 | domain, self.domain_not_configured_message, custom_uri=self.custom_uri 24 | ) 25 | 26 | def __init__( 27 | self, 28 | cname, 29 | domain_not_configured_message, 30 | service, 31 | info=None, 32 | https=False, 33 | custom_uri="", 34 | **kwargs, 35 | ): 36 | self.cname = cname 37 | self.domain_not_configured_message = domain_not_configured_message 38 | self.https = https 39 | self.custom_uri = custom_uri 40 | info = info if info else INFO 41 | super().__init__(info.format(service=service), **kwargs) 42 | -------------------------------------------------------------------------------- /signatures/templates/cname_or_ip_found_but_string_in_body.py: -------------------------------------------------------------------------------- 1 | from . import base 2 | 3 | import signatures.checks 4 | 5 | from detection_enums import CONFIDENCE 6 | 7 | INFO = """ 8 | The defined domain has CNAME or A/AAAA records configured for {service} but a web request shows the domain is unclaimed. \ 9 | An attacker can register this domain on {service} and serve their own web content. 10 | """ 11 | 12 | 13 | class cname_or_ip_found_but_string_in_body(base.Base): 14 | def potential(self, domain, **kwargs) -> bool: 15 | return signatures.checks.COMBINED.matching_ip_or_cname( 16 | domain, self.cname, self.ips 17 | ) 18 | 19 | async def check(self, domain, **kwargs) -> bool: 20 | if self.https: 21 | return await signatures.checks.WEB.string_in_body_https( 22 | domain, self.domain_not_configured_message, custom_uri=self.custom_uri 23 | ) 24 | return await signatures.checks.WEB.string_in_body_http( 25 | domain, self.domain_not_configured_message, custom_uri=self.custom_uri 26 | ) 27 | 28 | def __init__( 29 | self, 30 | cname, 31 | ips, 32 | domain_not_configured_message, 33 | service, 34 | info=None, 35 | https=False, 36 | custom_uri="", 37 | **kwargs 38 | ): 39 | self.cname = cname 40 | self.ips = ips 41 | self.domain_not_configured_message = domain_not_configured_message 42 | self.https = https 43 | self.custom_uri = custom_uri 44 | info = info if info else INFO 45 | super().__init__(info.format(service=service), **kwargs) 46 | -------------------------------------------------------------------------------- /signatures/templates/ip_found_but_string_in_body.py: -------------------------------------------------------------------------------- 1 | from . import base 2 | 3 | import signatures.checks 4 | 5 | from detection_enums import CONFIDENCE 6 | 7 | INFO = """ 8 | The defined domain has A/AAAA records configured for {service} but a web request shows the domain is unclaimed. \ 9 | An attacker can register this domain on {service} and serve their own web content. 10 | """ 11 | 12 | 13 | class ip_found_but_string_in_body(base.Base): 14 | def potential(self, domain, **kwargs) -> bool: 15 | if signatures.checks.A.match(domain, self.ips): 16 | return True 17 | return signatures.checks.AAAA.match(domain, self.ips) 18 | 19 | async def check(self, domain, **kwargs) -> bool: 20 | if self.https: 21 | return await signatures.checks.WEB.string_in_body_https( 22 | domain, self.domain_not_configured_message, custom_uri=self.custom_uri 23 | ) 24 | return await signatures.checks.WEB.string_in_body_http( 25 | domain, self.domain_not_configured_message, custom_uri=self.custom_uri 26 | ) 27 | 28 | def __init__( 29 | self, 30 | ips, 31 | domain_not_configured_message, 32 | service, 33 | info=None, 34 | https=False, 35 | custom_uri="", 36 | **kwargs 37 | ): 38 | self.ips = ips 39 | self.domain_not_configured_message = domain_not_configured_message 40 | self.https = https 41 | self.custom_uri = custom_uri 42 | info = info if info else INFO 43 | super().__init__(info.format(service=service), **kwargs) 44 | -------------------------------------------------------------------------------- /signatures/templates/ns_found_but_no_SOA.py: -------------------------------------------------------------------------------- 1 | from . import base 2 | 3 | import signatures.checks 4 | 5 | from detection_enums import CONFIDENCE 6 | 7 | INFO = """ 8 | The defined domain has {service} NS records configured but these nameservers do not host a zone for this domain. \ 9 | An attacker can register this domain with {service} so they get provisioned onto a matching nameserver.""" 10 | 11 | 12 | class ns_found_but_no_SOA(base.Base): 13 | def potential(self, domain, **kwargs) -> bool: 14 | return signatures.checks.NS.match(domain, self.ns) 15 | 16 | async def check(self, domain, **kwargs) -> bool: 17 | return await signatures.checks.NS.no_SOA_detected(domain) 18 | 19 | def __init__(self, ns, service, sample_ns=None, info=None, **kwargs): 20 | self.ns = ns 21 | if sample_ns: 22 | self.sample_ns = sample_ns 23 | info = info if info else INFO 24 | super().__init__(info.format(service=service), **kwargs) 25 | -------------------------------------------------------------------------------- /signatures/thinkific.py: -------------------------------------------------------------------------------- 1 | from .templates.cname_found_but_string_in_body import cname_found_but_string_in_body 2 | 3 | test = cname_found_but_string_in_body( 4 | cname=".thinkific.com", 5 | domain_not_configured_message="Cloudflare is currently unable to resolve your requested domain", 6 | service="thinkific.com", 7 | ) 8 | -------------------------------------------------------------------------------- /signatures/tierranet.py: -------------------------------------------------------------------------------- 1 | from .templates.ns_found_but_no_SOA import ns_found_but_no_SOA 2 | 3 | test = ns_found_but_no_SOA( 4 | ["ns1.domaindiscover.com", "ns2.domaindiscover.com"], 5 | service="tierra.net", 6 | ) 7 | -------------------------------------------------------------------------------- /signatures/tribe.py: -------------------------------------------------------------------------------- 1 | from .templates.cname_found_but_status_code import cname_found_but_status_code 2 | 3 | test = cname_found_but_status_code( 4 | cname="domains.tribeplatform.com", 5 | code=0, 6 | service="tribe.so", 7 | ) 8 | -------------------------------------------------------------------------------- /signatures/tumblr.py: -------------------------------------------------------------------------------- 1 | from .templates.cname_found_but_string_in_body import cname_found_but_string_in_body 2 | 3 | test = cname_found_but_string_in_body( 4 | cname=".tumblr.com", 5 | domain_not_configured_message="There's nothing here.", 6 | service="tumblr.com", 7 | ) 8 | -------------------------------------------------------------------------------- /signatures/vendhq.py: -------------------------------------------------------------------------------- 1 | from .templates.cname_found_but_string_in_body import cname_found_but_string_in_body 2 | 3 | test = cname_found_but_string_in_body( 4 | cname="cname.vendhq.com", 5 | domain_not_configured_message="Sign in to Lightspeed Retail POS Software | Lightspeed Retail", 6 | service="vendhq.com", 7 | https=True, 8 | ) 9 | -------------------------------------------------------------------------------- /signatures/webflow.py: -------------------------------------------------------------------------------- 1 | from .templates.cname_found_but_string_in_body import cname_found_but_string_in_body 2 | 3 | test = cname_found_but_string_in_body( 4 | cname=["cname.proxy.webflow.com", "cname.proxy-ssl.webflow.com"], 5 | domain_not_configured_message="404 - Page not found", 6 | service="webflow.com", 7 | ) 8 | -------------------------------------------------------------------------------- /signatures/wishpond.py: -------------------------------------------------------------------------------- 1 | from .templates.cname_found_but_string_in_body import cname_found_but_string_in_body 2 | 3 | test = cname_found_but_string_in_body( 4 | cname="cname.wishpond.com", 5 | domain_not_configured_message="https://www.wishpond.com/404?campaign=true", 6 | service="wishpond.com", 7 | https=True, 8 | ) 9 | -------------------------------------------------------------------------------- /signatures/wix.py.old: -------------------------------------------------------------------------------- 1 | from detection_enums import CONFIDENCE 2 | from .templates.ip_found_but_string_in_body import ip_found_but_string_in_body 3 | 4 | 5 | test = ip_found_but_string_in_body( 6 | ips=["34.149.87.45"], 7 | domain_not_configured_message="ConnectYourDomain", 8 | service="Wix", 9 | ) 10 | 11 | test.CONFIDENCE = CONFIDENCE.POTENTIAL 12 | -------------------------------------------------------------------------------- /signatures/wordpress_com_cname.py: -------------------------------------------------------------------------------- 1 | from .templates.cname_found_but_string_in_body import cname_found_but_string_in_body 2 | 3 | test = cname_found_but_string_in_body( 4 | cname=".wordpress.com", 5 | domain_not_configured_message="Do you want to register", 6 | service="wordpress.com", 7 | https=True, 8 | ) 9 | -------------------------------------------------------------------------------- /signatures/wordpress_com_ns.py: -------------------------------------------------------------------------------- 1 | from .templates.ns_found_but_no_SOA import ns_found_but_no_SOA 2 | 3 | test = ns_found_but_no_SOA( 4 | ["ns1.wordpress.com", "ns2.wordpress.com", "ns3.wordpress.com"], 5 | service="wordpress.com", 6 | ) 7 | -------------------------------------------------------------------------------- /signatures/zohoforms.py: -------------------------------------------------------------------------------- 1 | from .templates.cname_found_but_status_code import cname_found_but_status_code 2 | 3 | test = cname_found_but_status_code( 4 | cname=[ 5 | "forms.cs.zohohost.com", 6 | "forms.cs.zohohost.eu", 7 | ], 8 | service="zoho forms", 9 | code=400, 10 | ) 11 | -------------------------------------------------------------------------------- /signatures/zohoforms_in.py: -------------------------------------------------------------------------------- 1 | from .templates.cname_found_but_status_code import cname_found_but_status_code 2 | 3 | test = cname_found_but_status_code( 4 | cname=[ 5 | "forms.cs.zohohost.in", 6 | ], 7 | service="zoho forms india", 8 | code=403, 9 | ) 10 | -------------------------------------------------------------------------------- /test-requirements.txt: -------------------------------------------------------------------------------- 1 | pytest==8.3.2 2 | pytest-asyncio 3 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/punk-security/dnsReaper/5dd7141f8e0c5679386ea749b109cd0eb1b86a05/tests/__init__.py -------------------------------------------------------------------------------- /tests/mocks.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | import socket 3 | from domain import Domain 4 | import requests 5 | import dns.resolver 6 | import ipaddress 7 | from uuid import uuid4 8 | 9 | import aiohttp 10 | 11 | 12 | def random_string(): 13 | return f"a{uuid4().hex[:8]}" 14 | 15 | 16 | def mock_web_response_with_static_value( 17 | domain: Domain, body: str = "", status_code: int = 0 18 | ) -> Domain: 19 | async def mock_fetch_web(**kwargs): 20 | return namedtuple("web_response", ["body", "status_code"])(body, status_code) 21 | 22 | domain.fetch_web = mock_fetch_web 23 | 24 | 25 | def mock_web_request_by_providing_static_host_resolution( 26 | domain: Domain, hostname: str 27 | ) -> Domain: 28 | requests_adapter = HostHeaderAdapter() 29 | requests_adapter.set_static_host_resolution(hostname) 30 | patched_requests = requests.session() 31 | patched_requests.mount("https://", requests_adapter) 32 | patched_requests.mount("http://", requests_adapter) 33 | domain.requests = patched_requests 34 | 35 | 36 | def mock_web_request_by_providing_custom_ns(domain: Domain, ns: str) -> Domain: 37 | requests_adapter = HostHeaderAdapter() 38 | requests_adapter.set_ns_for_resolution(ns) 39 | patched_requests = requests.session() 40 | patched_requests.mount("https://", requests_adapter) 41 | patched_requests.mount("http://", requests_adapter) 42 | domain.requests = patched_requests 43 | 44 | 45 | class HostHeaderAdapter(requests.adapters.HTTPAdapter): 46 | def set_static_host_resolution(self, host): 47 | self.host = self.resolve_to_ip(host) 48 | 49 | def set_ns_for_resolution(self, ns): 50 | self.ns = self.resolve_to_ip(ns) 51 | 52 | def resolve_via_ns(self, domain): 53 | resolver = dns.resolver.Resolver() 54 | resolver.nameservers = [self.ns] 55 | response = resolver.resolve(domain) 56 | return [record.to_text() for record in response][0] 57 | 58 | def resolve_to_ip(self, name): 59 | try: 60 | # return the name if its already an ip 61 | ipaddress.ip_address(name) 62 | return name 63 | except: 64 | response = dns.resolver.resolve(name) 65 | ip = [record.to_text() for record in response][0] 66 | return self.resolve_to_ip(ip) 67 | 68 | def wrap_if_ipv6(self, ip): 69 | if ip.count(":") > 1: 70 | return f"[{ip}]" 71 | return ip 72 | 73 | def send(self, request, **kwargs): 74 | from urllib.parse import urlparse 75 | 76 | connection_pool_kwargs = self.poolmanager.connection_pool_kw 77 | result = urlparse(request.url) 78 | try: 79 | # Try use a static ip 80 | resolved_ip = self.host 81 | except: 82 | # If that fails, try and resolve 83 | resolved_ip = self.resolve_to_ip(result.hostname) 84 | 85 | resolved_ip = self.wrap_if_ipv6(resolved_ip) 86 | 87 | request.url = request.url.replace( 88 | "//" + result.hostname, 89 | "//" + resolved_ip, 90 | ) 91 | if "https" in request.url: 92 | connection_pool_kwargs["server_hostname"] = result.hostname # SNI 93 | connection_pool_kwargs["assert_hostname"] = result.hostname 94 | 95 | # overwrite the host header 96 | request.headers["Host"] = result.hostname 97 | return super(HostHeaderAdapter, self).send(request, **kwargs) 98 | 99 | 100 | class CustomResolver: 101 | def __init__(self, ip): 102 | self.ip = ip 103 | 104 | async def resolve(self, host, port=0, family=socket.AF_INET): 105 | return [ 106 | { 107 | "hostname": host, 108 | "host": self.ip, 109 | "port": port, 110 | "family": family, 111 | "proto": socket.IPPROTO_TCP, 112 | "flags": 0, 113 | } 114 | ] 115 | 116 | 117 | def generate_mock_aiohttp_session_with_forced_ip_resolution(ip): 118 | def mock_aiohttp_session_with_forced_resolution(): 119 | resolver = CustomResolver(ip) 120 | conn = aiohttp.TCPConnector(resolver=resolver) 121 | return aiohttp.ClientSession(connector=conn) 122 | 123 | return mock_aiohttp_session_with_forced_resolution 124 | 125 | 126 | def generate_mock_aiohttp_session_with_forced_cname_resolution(cname): 127 | ip = [record.to_text() for record in dns.resolver.resolve(cname)][0] 128 | 129 | def mock_aiohttp_session_with_forced_resolution(): 130 | resolver = CustomResolver(ip) 131 | conn = aiohttp.TCPConnector(resolver=resolver) 132 | return aiohttp.ClientSession(connector=conn) 133 | 134 | return mock_aiohttp_session_with_forced_resolution 135 | -------------------------------------------------------------------------------- /tests/readme.md: -------------------------------------------------------------------------------- 1 | # Testing 2 | 3 | We test this tool really heavily, ensuring that the signatures we design actually detect the takeover conditions we plan for. 4 | 5 | We use the pytest framework to do this, which discovers tests in this folder. 6 | 7 | Every signature of the standard classes is tested, and this is automatic without any test configuration needed. 8 | 9 | Some of our tests are active, meaning that we perform a modified request to simulate the actual takeover condition. 10 | 11 | ``` Example: We will send a web request to Github pages for a random host, and then check to make sure we detect that we can take it over ``` 12 | 13 | These can be disabled when running the pytest by using the param: ``` -k "not active ``` 14 | 15 | Testing is also performed on the individual components, and we mock different functions to validate edge cases such as if we provided a single item, multiple items or no items. 16 | 17 | ## Adding a test 18 | 19 | The test structure here is loosely organised, so please put tests in a relevant location. 20 | 21 | All test files should begin ```test_``` and all functions should also begin ```test_```. All test functions should run without a parameter, unless using paramaterised tests. 22 | 23 | It may be easier copying the tests that resemble what you want to achieve as we quite often replace functions within the tool to force a condition. This can be quite complex. -------------------------------------------------------------------------------- /tests/signatures_tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/punk-security/dnsReaper/5dd7141f8e0c5679386ea749b109cd0eb1b86a05/tests/signatures_tests/__init__.py -------------------------------------------------------------------------------- /tests/signatures_tests/checks/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/punk-security/dnsReaper/5dd7141f8e0c5679386ea749b109cd0eb1b86a05/tests/signatures_tests/checks/__init__.py -------------------------------------------------------------------------------- /tests/signatures_tests/checks/test_A.py: -------------------------------------------------------------------------------- 1 | from domain import Domain 2 | from signatures.checks import A 3 | import pytest 4 | 5 | 6 | @pytest.mark.asyncio 7 | async def test_match_for_single_ip(): 8 | domain = Domain("mock.local", fetch_standard_records=False) 9 | domain.A = ["1.1.1.1", "2.2.2.2"] 10 | assert A.match(domain, "1.1.1.1") == True 11 | 12 | 13 | @pytest.mark.asyncio 14 | async def test_match_for_multiple_ip(): 15 | domain = Domain("mock.local", fetch_standard_records=False) 16 | domain.A = ["1.1.1.1", "2.2.2.2"] 17 | assert A.match(domain, ["1.1.1.1", "2.2.2.2"]) == True 18 | 19 | 20 | @pytest.mark.asyncio 21 | async def test_match_for_one_of_multiple_ip(): 22 | domain = Domain("mock.local", fetch_standard_records=False) 23 | domain.A = ["1.1.1.1", "3.3.3.3"] 24 | assert A.match(domain, ["1.1.1.1", "2.2.2.2"]) == True 25 | 26 | 27 | @pytest.mark.asyncio 28 | async def test_match_for_none_of_multiple_ip(): 29 | domain = Domain("mock.local", fetch_standard_records=False) 30 | domain.A = ["1.1.1.1", "2.2.2.2"] 31 | assert A.match(domain, ["3.3.3.3", "4.4.4.4"]) == False 32 | 33 | 34 | @pytest.mark.asyncio 35 | async def test_match_for_none_matching_ip(): 36 | domain = Domain("mock.local", fetch_standard_records=False) 37 | domain.A = ["1.1.1.1", "2.2.2.2"] 38 | assert A.match(domain, "3.3.3.3") == False 39 | 40 | 41 | @pytest.mark.asyncio 42 | async def test_match_with_no_A_records(): 43 | domain = Domain("mock.local", fetch_standard_records=False) 44 | domain.A = [] 45 | assert A.match(domain, "1.1.1.1") == False 46 | 47 | 48 | @pytest.mark.asyncio 49 | async def test_match_multiple_with_no_A_records(): 50 | domain = Domain("mock.local", fetch_standard_records=False) 51 | domain.A = [] 52 | assert A.match(domain, ["1.1.1.1", "2.2.2.2"]) == False 53 | -------------------------------------------------------------------------------- /tests/signatures_tests/checks/test_AAAA.py: -------------------------------------------------------------------------------- 1 | from domain import Domain 2 | from signatures.checks import AAAA 3 | import pytest 4 | 5 | 6 | @pytest.mark.asyncio 7 | async def test_ipv6_in_AAAA_for_single_ip(): 8 | domain = Domain("mock.local", fetch_standard_records=False) 9 | domain.AAAA = ["::1", "::2"] 10 | assert AAAA.match(domain, "::1") == True 11 | 12 | 13 | @pytest.mark.asyncio 14 | async def test_ipv6_in_AAAA_for_multiple_ip(): 15 | domain = Domain("mock.local", fetch_standard_records=False) 16 | domain.AAAA = ["::1", "::2"] 17 | assert AAAA.match(domain, ["::1", "::2"]) == True 18 | 19 | 20 | @pytest.mark.asyncio 21 | async def test_ipv6_in_AAAA_for_one_of_multiple_ip(): 22 | domain = Domain("mock.local", fetch_standard_records=False) 23 | domain.AAAA = ["::1", "::3"] 24 | assert AAAA.match(domain, ["::1", "::2"]) == True 25 | 26 | 27 | @pytest.mark.asyncio 28 | async def test_ipv6_in_AAAA_for_none_of_multiple_ip(): 29 | domain = Domain("mock.local", fetch_standard_records=False) 30 | domain.AAAA = ["::1", "::2"] 31 | assert AAAA.match(domain, ["::3", "::4"]) == False 32 | 33 | 34 | @pytest.mark.asyncio 35 | async def test_ipv6_in_AAAA_for_none_matching_ip(): 36 | domain = Domain("mock.local", fetch_standard_records=False) 37 | domain.AAAA = ["::1", "::2"] 38 | assert AAAA.match(domain, "::3") == False 39 | 40 | 41 | @pytest.mark.asyncio 42 | async def test_ipv6_in_AAAA_with_no_AAAA_records(): 43 | domain = Domain("mock.local", fetch_standard_records=False) 44 | domain.AAAA = [] 45 | assert AAAA.match(domain, "::1") == False 46 | 47 | 48 | @pytest.mark.asyncio 49 | async def test_ipv6_in_AAAA_multiple_with_no_AAAA_records(): 50 | domain = Domain("mock.local", fetch_standard_records=False) 51 | domain.AAAA = [] 52 | assert AAAA.match(domain, ["::1", "::2"]) == False 53 | -------------------------------------------------------------------------------- /tests/signatures_tests/checks/test_CNAME.py: -------------------------------------------------------------------------------- 1 | from domain import Domain 2 | from signatures.checks import CNAME 3 | from unittest.mock import patch, AsyncMock 4 | from asyncwhois.errors import NotFoundError 5 | from ... import mocks 6 | import pytest 7 | 8 | 9 | @pytest.mark.asyncio 10 | async def test_match_for_single_string(): 11 | domain = Domain("mock.local", fetch_standard_records=False) 12 | domain.CNAME = ["abc", "def"] 13 | assert CNAME.match(domain, "abc") == True 14 | 15 | 16 | @pytest.mark.asyncio 17 | async def test_match_for_single_substring(): 18 | domain = Domain("mock.local", fetch_standard_records=False) 19 | domain.CNAME = ["abc", "def"] 20 | assert CNAME.match(domain, "b") == True 21 | 22 | 23 | @pytest.mark.asyncio 24 | async def test_match_where_cname_is_subtring_of_string(): 25 | domain = Domain("mock.local", fetch_standard_records=False) 26 | domain.CNAME = ["abc", "def"] 27 | assert CNAME.match(domain, "abcd") == False 28 | 29 | 30 | @pytest.mark.asyncio 31 | async def test_match_for_multiple_string(): 32 | domain = Domain("mock.local", fetch_standard_records=False) 33 | domain.CNAME = ["abc", "def"] 34 | assert CNAME.match(domain, ["abc", "def"]) == True 35 | 36 | 37 | @pytest.mark.asyncio 38 | async def test_match_for_one_of_multiple_string(): 39 | domain = Domain("mock.local", fetch_standard_records=False) 40 | domain.CNAME = ["abc", "hij"] 41 | assert CNAME.match(domain, ["abc", "def"]) == True 42 | 43 | 44 | @pytest.mark.asyncio 45 | async def test_match_for_one_of_multiple_substring(): 46 | domain = Domain("mock.local", fetch_standard_records=False) 47 | domain.CNAME = ["abc", "hij"] 48 | assert CNAME.match(domain, ["def", "h"]) == True 49 | 50 | 51 | @pytest.mark.asyncio 52 | async def test_match_for_none_of_multiple_string(): 53 | domain = Domain("mock.local", fetch_standard_records=False) 54 | domain.CNAME = ["abc", "def"] 55 | assert CNAME.match(domain, ["hij", "klm"]) == False 56 | 57 | 58 | @pytest.mark.asyncio 59 | async def test_match_for_none_matching_string(): 60 | domain = Domain("mock.local", fetch_standard_records=False) 61 | domain.CNAME = ["abc", "def"] 62 | assert CNAME.match(domain, "hij") == False 63 | 64 | 65 | @pytest.mark.asyncio 66 | async def test_match_with_no_CNAME_records(): 67 | domain = Domain("mock.local", fetch_standard_records=False) 68 | domain.CNAME = [] 69 | assert CNAME.match(domain, "abc") == False 70 | 71 | 72 | @pytest.mark.asyncio 73 | async def test_match_multiple_with_no_CNAME_records(): 74 | domain = Domain("mock.local", fetch_standard_records=False) 75 | domain.CNAME = [] 76 | assert CNAME.match(domain, ["abc", "def"]) == False 77 | 78 | 79 | ## NX_DOMAIN_on_resolve 80 | 81 | domain_with_cname = Domain("mock.local", fetch_standard_records=False) 82 | domain_with_cname.CNAME = ["cname"] 83 | 84 | 85 | @pytest.mark.asyncio 86 | async def test_NX_DOMAIN_on_resolve_success(): 87 | with patch( 88 | "resolver.Resolver.resolve", 89 | side_effect=AsyncMock(return_value={"NX_DOMAIN": True}), 90 | ): 91 | assert await CNAME.NX_DOMAIN_on_resolve(domain_with_cname) == True 92 | 93 | 94 | @pytest.mark.asyncio 95 | async def test_NX_DOMAIN_on_resolve_failure_no_cname(): 96 | domain = Domain("mock.local", fetch_standard_records=False) 97 | with patch( 98 | "resolver.Resolver.resolve", 99 | side_effect=AsyncMock(return_value={"NX_DOMAIN": False}), 100 | ): 101 | assert await CNAME.NX_DOMAIN_on_resolve(domain_with_cname) == False 102 | 103 | 104 | @pytest.mark.asyncio 105 | async def test_is_unregistered_failure_no_cname(): 106 | domain = Domain("mock.local", fetch_standard_records=False) 107 | assert await CNAME.is_unregistered(domain) == False 108 | 109 | 110 | @pytest.mark.asyncio 111 | async def test_is_unregistered_failure_cname_registered(): 112 | domain = Domain("mock.local", fetch_standard_records=False) 113 | domain.CNAME = ["something"] 114 | with patch( 115 | "domain.asyncwhois.aio_whois", 116 | side_effect=AsyncMock(return_value={"registrar": "something"}), 117 | ): 118 | assert await CNAME.is_unregistered(domain) == False 119 | 120 | 121 | @pytest.mark.asyncio 122 | async def test_is_unregistered_failure_whois_failure(): 123 | async def whois(domain): 124 | raise ValueError("BOOK") 125 | 126 | domain = Domain("mock.local", fetch_standard_records=False) 127 | domain.CNAME = ["something"] 128 | with patch("domain.asyncwhois.aio_whois", new=whois): 129 | assert await CNAME.is_unregistered(domain) == False 130 | 131 | 132 | @pytest.mark.asyncio 133 | async def test_is_unregistered_success_cname_unregistered(): 134 | async def whois(domain): 135 | raise NotFoundError("Domain not found!") 136 | 137 | domain = Domain("mock.local", fetch_standard_records=False) 138 | domain.CNAME = ["something"] 139 | with patch("domain.asyncwhois.aio_whois", new=whois): 140 | assert await CNAME.is_unregistered(domain) == True 141 | 142 | 143 | @pytest.mark.asyncio 144 | async def test_is_unregistered_success_cname_unregistered_ACTIVE(): 145 | domain = Domain("mock.local", fetch_standard_records=False) 146 | domain.CNAME = [f"{mocks.random_string()}.com"] 147 | assert await CNAME.is_unregistered(domain) == True 148 | -------------------------------------------------------------------------------- /tests/signatures_tests/checks/test_COMBINED.py: -------------------------------------------------------------------------------- 1 | from xml import dom 2 | from domain import Domain 3 | from signatures.checks import COMBINED 4 | import pytest 5 | 6 | 7 | ## matching_ipv4_or_ipv6 8 | @pytest.mark.asyncio 9 | async def test_matching_ipv4_or_ipv6_ipv4_match(): 10 | domain = Domain("mock.local", fetch_standard_records=False) 11 | domain.A = ["1.1.1.1"] 12 | domain.AAAA = [] 13 | assert COMBINED.matching_ipv4_or_ipv6(domain, "1.1.1.1", "::1") == True 14 | 15 | 16 | @pytest.mark.asyncio 17 | async def test_matching_ipv4_or_ipv6_ipv6_match(): 18 | domain = Domain("mock.local", fetch_standard_records=False) 19 | domain.A = [] 20 | domain.AAAA = ["::1"] 21 | assert COMBINED.matching_ipv4_or_ipv6(domain, "1.1.1.1", "::1") == True 22 | 23 | 24 | @pytest.mark.asyncio 25 | async def test_matching_ipv4_or_ipv6_no_match(): 26 | domain = Domain("mock.local", fetch_standard_records=False) 27 | domain.A = [] 28 | domain.AAAA = [] 29 | assert COMBINED.matching_ipv4_or_ipv6(domain, "1.1.1.1", "::1") == False 30 | 31 | 32 | ## macthing_ip_or_cname 33 | @pytest.mark.asyncio 34 | async def test_matching_ip_or_cname_single_ipv4_match(): 35 | domain = Domain("mock.local", fetch_standard_records=False) 36 | domain.A = ["1.1.1.1"] 37 | domain.CNAME = [] 38 | assert COMBINED.matching_ip_or_cname(domain, "", ips="1.1.1.1") == True 39 | 40 | 41 | @pytest.mark.asyncio 42 | async def test_matching_ip_or_cname_multiple_ipv4_match(): 43 | domain = Domain("mock.local", fetch_standard_records=False) 44 | domain.A = ["2.2.2.2"] 45 | domain.CNAME = [] 46 | assert COMBINED.matching_ip_or_cname(domain, "", ips=["1.1.1.1", "2.2.2.2"]) == True 47 | 48 | 49 | @pytest.mark.asyncio 50 | async def test_matching_ip_or_cname_single_ipv6_match(): 51 | domain = Domain("mock.local", fetch_standard_records=False) 52 | domain.AAAA = ["::1"] 53 | domain.CNAME = [] 54 | assert COMBINED.matching_ip_or_cname(domain, "", ips="::1") == True 55 | 56 | 57 | @pytest.mark.asyncio 58 | async def test_matching_ip_or_cname_multiple_ipv6_match(): 59 | domain = Domain("mock.local", fetch_standard_records=False) 60 | domain.AAAA = ["::2"] 61 | domain.CNAME = [] 62 | assert COMBINED.matching_ip_or_cname(domain, "", ips=["::1", "::2"]) == True 63 | 64 | 65 | @pytest.mark.asyncio 66 | async def test_matching_ip_or_cname_multiple_ips_match_ipv6(): 67 | domain = Domain("mock.local", fetch_standard_records=False) 68 | domain.AAAA = ["::2"] 69 | domain.CNAME = [] 70 | assert COMBINED.matching_ip_or_cname(domain, "", ips=["1.1.1.1", "::2"]) == True 71 | 72 | 73 | @pytest.mark.asyncio 74 | async def test_matching_ip_or_cname_multiple_ips_match_ipv4(): 75 | domain = Domain("mock.local", fetch_standard_records=False) 76 | domain.A = ["1.1.1.1"] 77 | domain.CNAME = [] 78 | assert COMBINED.matching_ip_or_cname(domain, "", ips=["1.1.1.1", "::2"]) == True 79 | 80 | 81 | @pytest.mark.asyncio 82 | async def test_matching_ip_or_cname_cname_match(): 83 | domain = Domain("mock.local", fetch_standard_records=False) 84 | domain.A = [] 85 | domain.CNAME = ["goose"] 86 | assert ( 87 | COMBINED.matching_ip_or_cname(domain, "goose", ips=["1.1.1.1", "::1"]) == True 88 | ) 89 | 90 | 91 | @pytest.mark.asyncio 92 | async def test_matching_ip_or_cname_no_match(): 93 | domain = Domain("mock.local", fetch_standard_records=False) 94 | domain.A = [] 95 | domain.AAAA = [] 96 | domain.CNAME = [] 97 | assert ( 98 | COMBINED.matching_ip_or_cname(domain, "goose", ips=["1.1.1.1", "::1"]) == False 99 | ) 100 | 101 | 102 | ## macthing_ipv4_or_cname 103 | @pytest.mark.asyncio 104 | async def test_matching_ipv4_or_cname_ipv4_match(): 105 | domain = Domain("mock.local", fetch_standard_records=False) 106 | domain.A = ["1.1.1.1"] 107 | domain.CNAME = [] 108 | assert COMBINED.matching_ipv4_or_cname(domain, "1.1.1.1", "goose") == True 109 | 110 | 111 | @pytest.mark.asyncio 112 | async def test_matching_ipv4_or_cname_cname_match(): 113 | domain = Domain("mock.local", fetch_standard_records=False) 114 | domain.A = [] 115 | domain.CNAME = ["goose"] 116 | assert COMBINED.matching_ipv4_or_cname(domain, "1.1.1.1", "goose") == True 117 | 118 | 119 | @pytest.mark.asyncio 120 | async def test_matching_ipv4_or_cname_no_match(): 121 | domain = Domain("mock.local", fetch_standard_records=False) 122 | domain.A = [] 123 | domain.AAAA = [] 124 | assert COMBINED.matching_ipv4_or_cname(domain, "1.1.1.1", "goose") == False 125 | -------------------------------------------------------------------------------- /tests/signatures_tests/checks/test_NS.py: -------------------------------------------------------------------------------- 1 | from domain import Domain 2 | from signatures.checks import NS 3 | from unittest.mock import patch, PropertyMock 4 | import pytest 5 | 6 | 7 | @pytest.mark.asyncio 8 | async def test_match_for_single_string(): 9 | domain = Domain("mock.local", fetch_standard_records=False) 10 | domain.NS = ["abc", "def"] 11 | assert NS.match(domain, "abc") == True 12 | 13 | 14 | @pytest.mark.asyncio 15 | async def test_match_for_single_substring(): 16 | domain = Domain("mock.local", fetch_standard_records=False) 17 | domain.NS = ["abc", "def"] 18 | assert NS.match(domain, "b") == True 19 | 20 | 21 | @pytest.mark.asyncio 22 | async def test_match_where_ns_is_subtring_of_string(): 23 | domain = Domain("mock.local", fetch_standard_records=False) 24 | domain.NS = ["abc", "def"] 25 | assert NS.match(domain, "abcd") == False 26 | 27 | 28 | @pytest.mark.asyncio 29 | async def test_match_for_multiple_string(): 30 | domain = Domain("mock.local", fetch_standard_records=False) 31 | domain.NS = ["abc", "def"] 32 | assert NS.match(domain, ["abc", "def"]) == True 33 | 34 | 35 | @pytest.mark.asyncio 36 | async def test_match_for_one_of_multiple_string(): 37 | domain = Domain("mock.local", fetch_standard_records=False) 38 | domain.NS = ["abc", "hij"] 39 | assert NS.match(domain, ["abc", "def"]) == True 40 | 41 | 42 | @pytest.mark.asyncio 43 | async def test_match_for_one_of_multiple_substring(): 44 | domain = Domain("mock.local", fetch_standard_records=False) 45 | domain.NS = ["abc", "hij"] 46 | assert NS.match(domain, ["def", "h"]) == True 47 | 48 | 49 | @pytest.mark.asyncio 50 | async def test_match_for_none_of_multiple_string(): 51 | domain = Domain("mock.local", fetch_standard_records=False) 52 | domain.NS = ["abc", "def"] 53 | assert NS.match(domain, ["hij", "klm"]) == False 54 | 55 | 56 | @pytest.mark.asyncio 57 | async def test_match_for_none_matching_string(): 58 | domain = Domain("mock.local", fetch_standard_records=False) 59 | domain.NS = ["abc", "def"] 60 | assert NS.match(domain, "hij") == False 61 | 62 | 63 | @pytest.mark.asyncio 64 | async def test_match_with_no_ns_records(): 65 | domain = Domain("mock.local", fetch_standard_records=False) 66 | domain.NS = [] 67 | assert NS.match(domain, "abc") == False 68 | 69 | 70 | @pytest.mark.asyncio 71 | async def test_match_multiple_with_no_ns_records(): 72 | domain = Domain("mock.local", fetch_standard_records=False) 73 | domain.NS = [] 74 | assert NS.match(domain, ["abc", "def"]) == False 75 | 76 | 77 | @pytest.mark.asyncio 78 | async def test_no_SOA_detected_on_NS_with_matching_nameservers(): 79 | domain = Domain("mock.local", fetch_standard_records=False) 80 | domain.NS = ["ns"] 81 | with patch("resolver.Resolver.resolve_with_ns", return_value={"SOA": []}): 82 | with patch("domain.Domain.query", return_value=["10.10.10.10"]): 83 | assert (await NS.no_SOA_detected(domain)) == True 84 | 85 | 86 | @pytest.mark.asyncio 87 | async def test_no_SOA_detected_on_NS_with_no_matching_nameservers(): 88 | domain = Domain("mock.local", fetch_standard_records=False) 89 | domain.NS = ["ns"] 90 | 91 | with patch( 92 | "resolver.Resolver.resolve_with_ns", return_value={"SOA": ["SOA RECORD HERE"]} 93 | ): 94 | with patch("domain.Domain.query", return_value=["10.10.10.10"]): 95 | assert (await NS.no_SOA_detected(domain)) == False 96 | 97 | 98 | @pytest.mark.asyncio 99 | async def test_no_SOA_detected_on_NS_with_no_nameservers(): 100 | domain = Domain("mock.local", fetch_standard_records=False) 101 | domain.NS = [] 102 | assert (await NS.no_SOA_detected(domain)) == False 103 | -------------------------------------------------------------------------------- /tests/signatures_tests/checks/test_WEB.py: -------------------------------------------------------------------------------- 1 | from domain import Domain 2 | from signatures.checks import WEB 3 | 4 | from collections import namedtuple 5 | 6 | import pytest 7 | 8 | ## string_in_body 9 | 10 | string_in_body_response = "This is a response" 11 | string_in_body_full_match = "This is a response" 12 | string_in_body_partial_match = "response" 13 | string_in_body_no_match = "goose" 14 | 15 | 16 | @pytest.mark.asyncio 17 | async def test_string_in_body_success_on_full_match(): 18 | async def mock_fetch_web(**kwargs): 19 | return namedtuple("web_response", ["body"])(string_in_body_response) 20 | 21 | domain = Domain("mock.local", fetch_standard_records=False) 22 | domain.fetch_web = mock_fetch_web 23 | assert await WEB.string_in_body(domain, string_in_body_full_match, False) == True 24 | 25 | 26 | @pytest.mark.asyncio 27 | async def test_string_in_body_success_on_partial_match(): 28 | async def mock_fetch_web(**kwargs): 29 | return namedtuple("web_response", ["body"])(string_in_body_response) 30 | 31 | domain = Domain("mock.local", fetch_standard_records=False) 32 | domain.fetch_web = mock_fetch_web 33 | assert await WEB.string_in_body(domain, string_in_body_partial_match, False) == True 34 | 35 | 36 | @pytest.mark.asyncio 37 | async def test_string_in_body_failure_on_no_match(): 38 | async def mock_fetch_web(**kwargs): 39 | return namedtuple("web_response", ["body"])(string_in_body_response) 40 | 41 | domain = Domain("mock.local", fetch_standard_records=False) 42 | domain.fetch_web = mock_fetch_web 43 | assert await WEB.string_in_body(domain, string_in_body_no_match, False) == False 44 | 45 | 46 | @pytest.mark.asyncio 47 | async def test_string_in_body_failure_on_no_body(): 48 | async def mock_fetch_web(**kwargs): 49 | return namedtuple("web_response", ["body"])("") 50 | 51 | domain = Domain("mock.local", fetch_standard_records=False) 52 | domain.fetch_web = mock_fetch_web 53 | assert await WEB.string_in_body(domain, string_in_body_full_match, False) == False 54 | 55 | 56 | ## status_code_match 57 | 58 | 59 | @pytest.mark.asyncio 60 | async def test_status_code_match_success_specific(): 61 | async def mock_fetch_web(**kwargs): 62 | return namedtuple("web_response", ["status_code"])(200) 63 | 64 | domain = Domain("mock.local", fetch_standard_records=False) 65 | domain.fetch_web = mock_fetch_web 66 | assert await WEB.status_code_match(domain, 200, False) == True 67 | 68 | 69 | @pytest.mark.asyncio 70 | async def test_status_code_match_success_partial(): 71 | async def mock_fetch_web(**kwargs): 72 | return namedtuple("web_response", ["status_code"])(302) 73 | 74 | domain = Domain("mock.local", fetch_standard_records=False) 75 | domain.fetch_web = mock_fetch_web 76 | assert await WEB.status_code_match(domain, 3, False) == True 77 | 78 | 79 | @pytest.mark.asyncio 80 | async def test_status_code_match_fail_specific(): 81 | async def mock_fetch_web(**kwargs): 82 | return namedtuple("web_response", ["status_code"])(302) 83 | 84 | domain = Domain("mock.local", fetch_standard_records=False) 85 | domain.fetch_web = mock_fetch_web 86 | assert await WEB.status_code_match(domain, 401, False) == False 87 | 88 | 89 | @pytest.mark.asyncio 90 | async def test_status_code_match_fail_partial(): 91 | async def mock_fetch_web(**kwargs): 92 | return namedtuple("web_response", ["status_code"])(404) 93 | 94 | domain = Domain("mock.local", fetch_standard_records=False) 95 | domain.fetch_web = mock_fetch_web 96 | assert await WEB.status_code_match(domain, 3, False) == False 97 | 98 | 99 | @pytest.mark.asyncio 100 | async def test_status_code_match_fail_partial(): 101 | async def mock_fetch_web(**kwargs): 102 | return namedtuple("web_response", ["status_code"])(404) 103 | 104 | domain = Domain("mock.local", fetch_standard_records=False) 105 | domain.fetch_web = mock_fetch_web 106 | assert await WEB.status_code_match(domain, 3, False) == False 107 | 108 | 109 | @pytest.mark.asyncio 110 | async def test_status_code_404_success(): 111 | async def mock_fetch_web(**kwargs): 112 | return namedtuple("web_response", ["status_code"])(404) 113 | 114 | domain = Domain("mock.local", fetch_standard_records=False) 115 | domain.fetch_web = mock_fetch_web 116 | assert await WEB.status_code_404(domain, False) == True 117 | 118 | 119 | @pytest.mark.asyncio 120 | async def test_status_code_404_failure(): 121 | async def mock_fetch_web(**kwargs): 122 | return namedtuple("web_response", ["status_code"])(200) 123 | 124 | domain = Domain("mock.local", fetch_standard_records=False) 125 | domain.fetch_web = mock_fetch_web 126 | assert await WEB.status_code_404(domain, False) == False 127 | -------------------------------------------------------------------------------- /tests/signatures_tests/checks/test_helpers.py: -------------------------------------------------------------------------------- 1 | from signatures.checks import helpers 2 | 3 | 4 | ## substrings_in_strings 5 | def test_substrings_in_strings_for_single_string(): 6 | assert helpers.substrings_in_strings("abc", "abc") == "abc" 7 | 8 | 9 | def test_substrings_in_strings_for_single_substring(): 10 | assert helpers.substrings_in_strings("b", "abc") == "abc" 11 | 12 | 13 | def test_substrings_in_strings_for_multiple_substring(): 14 | assert helpers.substrings_in_strings(["a", "b", "c"], "abc") == "abc" 15 | 16 | 17 | def test_substrings_in_strings_returns_first_for_multiple_strings(): 18 | assert helpers.substrings_in_strings("a", ["abc", "aed"]) == "abc" 19 | 20 | 21 | def test_substrings_in_strings_no_subtrings(): 22 | assert helpers.substrings_in_strings("", ["abc", "aed"]) == "" 23 | 24 | 25 | def test_substrings_in_strings_no_strings(): 26 | assert helpers.substrings_in_strings("a", "") == "" 27 | 28 | 29 | def test_substrings_in_strings_nothing(): 30 | assert helpers.substrings_in_strings("", "") == "" 31 | -------------------------------------------------------------------------------- /tests/signatures_tests/templates/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/punk-security/dnsReaper/5dd7141f8e0c5679386ea749b109cd0eb1b86a05/tests/signatures_tests/templates/__init__.py -------------------------------------------------------------------------------- /tests/signatures_tests/templates/test_cname_found_but_NX_DOMAIN.py: -------------------------------------------------------------------------------- 1 | from domain import Domain 2 | import signatures 3 | from signatures.templates.cname_found_but_NX_DOMAIN import ( 4 | cname_found_but_NX_DOMAIN, 5 | ) 6 | 7 | from ... import mocks 8 | from unittest.mock import patch 9 | import pytest 10 | 11 | test = cname_found_but_NX_DOMAIN("cname", "INFO") 12 | 13 | 14 | @pytest.mark.asyncio 15 | async def test_potential_success_with_matching_CNAME(): 16 | domain = Domain("mock.local", fetch_standard_records=False) 17 | domain.CNAME = ["cname"] 18 | assert test.potential(domain) == True 19 | 20 | 21 | @pytest.mark.asyncio 22 | async def test_potential_failure_no_matching_CNAME(): 23 | domain = Domain("mock.local", fetch_standard_records=False) 24 | assert test.potential(domain) == False 25 | 26 | 27 | @pytest.mark.asyncio 28 | async def test_check_success(): 29 | domain = Domain("mock.local", fetch_standard_records=False) 30 | domain.CNAME = ["cname"] 31 | with patch("resolver.Resolver.resolve", return_value={"NX_DOMAIN": True}): 32 | assert await test.check(domain) == True 33 | 34 | 35 | @pytest.mark.asyncio 36 | async def test_check_failure(): 37 | domain = Domain("mock.local", fetch_standard_records=False) 38 | domain.CNAME = ["cname"] 39 | with patch("resolver.Resolver.resolve", return_value={"NX_DOMAIN": False}): 40 | assert await test.check(domain) == False 41 | 42 | 43 | signatures = [getattr(signatures, signature) for signature in signatures.__all__] 44 | 45 | 46 | @pytest.mark.parametrize( 47 | "signature", 48 | [s for s in signatures if isinstance(s.test, cname_found_but_NX_DOMAIN)], 49 | ) 50 | @pytest.mark.asyncio 51 | async def test_check_success_ACTIVE(signature): 52 | cnames = ( 53 | signature.test.cname 54 | if type(signature.test.cname) == list 55 | else [signature.test.cname] 56 | ) 57 | for cname in cnames: 58 | test_cname = f"{mocks.random_string()}{cname}" if cname[0] == "." else cname 59 | domain = Domain(f"{mocks.random_string()}.com", fetch_standard_records=False) 60 | domain.CNAME = [test_cname] 61 | print(f"Testing cname {test_cname}") 62 | assert await signature.test.check(domain) == True 63 | -------------------------------------------------------------------------------- /tests/signatures_tests/templates/test_cname_found_but_status_code.py: -------------------------------------------------------------------------------- 1 | from domain import Domain 2 | import signatures 3 | from signatures.templates.cname_found_but_status_code import ( 4 | cname_found_but_status_code, 5 | ) 6 | 7 | import pytest 8 | from ... import mocks 9 | 10 | test = cname_found_but_status_code("cname", 404, "mock") 11 | 12 | 13 | @pytest.mark.asyncio 14 | async def test_potential_success_with_matching_CNAME(): 15 | domain = Domain("mock.local", fetch_standard_records=False) 16 | domain.CNAME = ["cname"] 17 | assert test.potential(domain) == True 18 | 19 | 20 | @pytest.mark.asyncio 21 | async def test_potential_failure_no_matching_CNAME(): 22 | domain = Domain("mock.local", fetch_standard_records=False) 23 | assert test.potential(domain) == False 24 | 25 | 26 | @pytest.mark.asyncio 27 | async def test_check_success(): 28 | domain = Domain("mock.local", fetch_standard_records=False) 29 | mocks.mock_web_response_with_static_value(domain, status_code=test.code) 30 | assert await test.check(domain) == True 31 | 32 | 33 | @pytest.mark.asyncio 34 | async def test_check_failure(): 35 | domain = Domain("mock.local", fetch_standard_records=False) 36 | mocks.mock_web_response_with_static_value(domain, status_code=200) 37 | assert await test.check(domain) == False 38 | 39 | 40 | signatures = [getattr(signatures, signature) for signature in signatures.__all__] 41 | 42 | 43 | @pytest.mark.parametrize( 44 | "signature", 45 | [s for s in signatures if isinstance(s.test, cname_found_but_status_code)], 46 | ) 47 | @pytest.mark.asyncio 48 | async def test_check_success_ACTIVE(signature): 49 | cnames = ( 50 | signature.test.cname 51 | if type(signature.test.cname) == list 52 | else [signature.test.cname] 53 | ) 54 | for cname in cnames: 55 | test_cname = f"{mocks.random_string()}{cname}" if cname[0] == "." else cname 56 | domain = Domain(f"{mocks.random_string()}.com", fetch_standard_records=False) 57 | domain.get_session = ( 58 | mocks.generate_mock_aiohttp_session_with_forced_cname_resolution(test_cname) 59 | ) 60 | assert await signature.test.check(domain) == True 61 | -------------------------------------------------------------------------------- /tests/signatures_tests/templates/test_cname_found_but_string_in_body.py: -------------------------------------------------------------------------------- 1 | from domain import Domain 2 | import signatures 3 | from signatures.templates.cname_found_but_string_in_body import ( 4 | cname_found_but_string_in_body, 5 | ) 6 | 7 | from ... import mocks 8 | import pytest 9 | 10 | test = cname_found_but_string_in_body("cname", "No domain found here", "INFO") 11 | 12 | 13 | @pytest.mark.asyncio 14 | async def test_potential_success_with_matching_CNAME(): 15 | domain = Domain("mock.local", fetch_standard_records=False) 16 | domain.CNAME = ["cname"] 17 | assert test.potential(domain) == True 18 | 19 | 20 | @pytest.mark.asyncio 21 | async def test_potential_failure_no_matching_CNAME(): 22 | domain = Domain("mock.local", fetch_standard_records=False) 23 | assert test.potential(domain) == False 24 | 25 | 26 | @pytest.mark.asyncio 27 | async def test_check_success(): 28 | domain = Domain("mock.local", fetch_standard_records=False) 29 | mocks.mock_web_response_with_static_value( 30 | domain, test.domain_not_configured_message 31 | ) 32 | assert await test.check(domain) == True 33 | 34 | 35 | @pytest.mark.asyncio 36 | async def test_check_failure(): 37 | domain = Domain("mock.local", fetch_standard_records=False) 38 | mocks.mock_web_response_with_static_value(domain, "Welcome to my site!") 39 | assert await test.check(domain) == False 40 | 41 | 42 | signatures = [getattr(signatures, signature) for signature in signatures.__all__] 43 | 44 | 45 | @pytest.mark.parametrize( 46 | "signature", 47 | [s for s in signatures if isinstance(s.test, cname_found_but_string_in_body)], 48 | ) 49 | @pytest.mark.asyncio 50 | async def test_check_success_ACTIVE(signature): 51 | cnames = ( 52 | signature.test.cname 53 | if type(signature.test.cname) == list 54 | else [signature.test.cname] 55 | ) 56 | for cname in cnames: 57 | test_cname = f"{mocks.random_string()}{cname}" if cname[0] == "." else cname 58 | domain = Domain(f"{mocks.random_string()}.com", fetch_standard_records=False) 59 | domain.get_session = ( 60 | mocks.generate_mock_aiohttp_session_with_forced_cname_resolution(test_cname) 61 | ) 62 | assert await signature.test.check(domain) == True 63 | -------------------------------------------------------------------------------- /tests/signatures_tests/templates/test_cname_or_ip_found_but_string_in_body.py: -------------------------------------------------------------------------------- 1 | from domain import Domain 2 | import signatures 3 | from signatures.templates.cname_or_ip_found_but_string_in_body import ( 4 | cname_or_ip_found_but_string_in_body, 5 | ) 6 | 7 | from ... import mocks 8 | import pytest 9 | 10 | test = cname_or_ip_found_but_string_in_body( 11 | "cname", ["1.1.1.1", "::1"], "No domain found here", "INFO" 12 | ) 13 | 14 | 15 | @pytest.mark.asyncio 16 | async def test_potential_success_with_matching_CNAME(): 17 | domain = Domain("mock.local", fetch_standard_records=False) 18 | domain.CNAME = ["cname"] 19 | assert test.potential(domain) == True 20 | 21 | 22 | @pytest.mark.asyncio 23 | async def test_potential_success_with_matching_ipv4(): 24 | domain = Domain("mock.local", fetch_standard_records=False) 25 | domain.A = ["1.1.1.1"] 26 | assert test.potential(domain) == True 27 | 28 | 29 | @pytest.mark.asyncio 30 | async def test_potential_success_with_matching_ipv6(): 31 | domain = Domain("mock.local", fetch_standard_records=False) 32 | domain.AAAA = ["::1"] 33 | assert test.potential(domain) == True 34 | 35 | 36 | @pytest.mark.asyncio 37 | async def test_potential_failure_no_matching(): 38 | domain = Domain("mock.local", fetch_standard_records=False) 39 | domain.CNAME = ["wrong"] 40 | domain.A = ["2.2.2.2"] 41 | domain.AAAA = ["::2"] 42 | assert test.potential(domain) == False 43 | 44 | 45 | @pytest.mark.asyncio 46 | async def test_check_success(): 47 | domain = Domain("mock.local", fetch_standard_records=False) 48 | mocks.mock_web_response_with_static_value( 49 | domain, test.domain_not_configured_message 50 | ) 51 | assert await test.check(domain) == True 52 | 53 | 54 | @pytest.mark.asyncio 55 | async def test_check_failure(): 56 | domain = Domain("mock.local", fetch_standard_records=False) 57 | mocks.mock_web_response_with_static_value(domain, "Welcome to my site!") 58 | assert await test.check(domain) == False 59 | 60 | 61 | signatures = [getattr(signatures, signature) for signature in signatures.__all__] 62 | 63 | 64 | @pytest.mark.parametrize( 65 | "signature", 66 | [s for s in signatures if isinstance(s.test, cname_or_ip_found_but_string_in_body)], 67 | ) 68 | @pytest.mark.asyncio 69 | async def test_check_success_ACTIVE(signature): 70 | cnames = ( 71 | signature.test.cname 72 | if type(signature.test.cname) == list 73 | else [signature.test.cname] 74 | ) 75 | for cname in cnames: 76 | test_cname = f"{mocks.random_string()}{cname}" if cname[0] == "." else cname 77 | domain = Domain(f"{mocks.random_string()}.com", fetch_standard_records=False) 78 | domain.get_session = ( 79 | mocks.generate_mock_aiohttp_session_with_forced_cname_resolution(test_cname) 80 | ) 81 | assert await signature.test.check(domain) == True 82 | 83 | ips = ( 84 | signature.test.ips if type(signature.test.ips) == list else [signature.test.ips] 85 | ) 86 | for ip in ips: 87 | if ":" in ip: 88 | continue # skip IPv6 89 | domain = Domain(f"{mocks.random_string()}.com", fetch_standard_records=False) 90 | domain.get_session = ( 91 | mocks.generate_mock_aiohttp_session_with_forced_ip_resolution(ip) 92 | ) 93 | assert await signature.test.check(domain) == True 94 | -------------------------------------------------------------------------------- /tests/signatures_tests/templates/test_ip_found_but_string_in_body.py: -------------------------------------------------------------------------------- 1 | from domain import Domain 2 | import signatures 3 | from signatures.templates.ip_found_but_string_in_body import ( 4 | ip_found_but_string_in_body, 5 | ) 6 | from ... import mocks 7 | import pytest 8 | 9 | test = ip_found_but_string_in_body(["::1", "1.1.1.1"], "No domain found here", "INFO") 10 | 11 | 12 | @pytest.mark.asyncio 13 | async def test_potential_success_with_matching_ipv4(): 14 | domain = Domain("mock.local", fetch_standard_records=False) 15 | domain.A = ["1.1.1.1"] 16 | assert test.potential(domain) == True 17 | 18 | 19 | @pytest.mark.asyncio 20 | async def test_potential_success_with_matching_ipv6(): 21 | domain = Domain("mock.local", fetch_standard_records=False) 22 | domain.AAAA = ["::1"] 23 | assert test.potential(domain) == True 24 | 25 | 26 | @pytest.mark.asyncio 27 | async def test_potential_failure_no_matching_CNAME(): 28 | domain = Domain("mock.local", fetch_standard_records=False) 29 | assert test.potential(domain) == False 30 | 31 | 32 | @pytest.mark.asyncio 33 | async def test_check_success(): 34 | domain = Domain("mock.local", fetch_standard_records=False) 35 | mocks.mock_web_response_with_static_value( 36 | domain, test.domain_not_configured_message 37 | ) 38 | assert await test.check(domain) == True 39 | 40 | 41 | @pytest.mark.asyncio 42 | async def test_check_failure(): 43 | domain = Domain("mock.local", fetch_standard_records=False) 44 | mocks.mock_web_response_with_static_value(domain, "Welcome to my site!") 45 | assert await test.check(domain) == False 46 | 47 | 48 | signatures = [getattr(signatures, signature) for signature in signatures.__all__] 49 | 50 | 51 | @pytest.mark.parametrize( 52 | "signature", 53 | [s for s in signatures if isinstance(s.test, ip_found_but_string_in_body)], 54 | ) 55 | @pytest.mark.asyncio 56 | async def test_check_success_ACTIVE(signature): 57 | ips = ( 58 | signature.test.ips if type(signature.test.ips) == list else [signature.test.ips] 59 | ) 60 | for ip in ips: 61 | if ":" in ip: 62 | continue # skip IPv6 63 | domain = Domain(f"{mocks.random_string()}.com", fetch_standard_records=False) 64 | domain.get_session = ( 65 | mocks.generate_mock_aiohttp_session_with_forced_ip_resolution(ip) 66 | ) 67 | assert await signature.test.check(domain) == True 68 | -------------------------------------------------------------------------------- /tests/signatures_tests/templates/test_ns_found_but_no_SOA.py: -------------------------------------------------------------------------------- 1 | from domain import Domain 2 | import signatures 3 | from signatures.templates.ns_found_but_no_SOA import ( 4 | ns_found_but_no_SOA, 5 | ) 6 | 7 | import pytest 8 | 9 | test = ns_found_but_no_SOA("ns1", "mock") 10 | 11 | 12 | @pytest.mark.asyncio 13 | async def test_potential_success_with_matching_nameserver(): 14 | domain = Domain("mock.local", fetch_standard_records=False) 15 | domain.NS = ["ns1"] 16 | assert test.potential(domain) == True 17 | 18 | 19 | @pytest.mark.asyncio 20 | async def test_potential_failure(): 21 | domain = Domain("mock.local", fetch_standard_records=False) 22 | assert test.potential(domain) == False 23 | 24 | 25 | signatures = [getattr(signatures, signature) for signature in signatures.__all__] 26 | 27 | 28 | @pytest.mark.asyncio 29 | @pytest.mark.parametrize( 30 | "signature", 31 | [s for s in signatures if isinstance(s.test, ns_found_but_no_SOA)], 32 | ) 33 | async def test_check_success_ACTIVE(signature): 34 | try: 35 | ns = signature.test.sample_ns 36 | except: 37 | ns = signature.test.ns 38 | ns = ns if type(ns) == list else [ns] 39 | domain = Domain(f"mock.local", fetch_standard_records=False) 40 | for nameserver in ns: 41 | domain.NS = [nameserver] 42 | assert await signature.test.check(domain) == True 43 | -------------------------------------------------------------------------------- /tests/signatures_tests/test_generic_cname_found_but_404_http.py: -------------------------------------------------------------------------------- 1 | from domain import Domain 2 | from signatures import _generic_cname_found_but_404_http 3 | 4 | from collections import namedtuple 5 | import pytest 6 | 7 | 8 | @pytest.mark.asyncio 9 | async def test_potential_success_with_a_cname(): 10 | domain = Domain("mock.local", fetch_standard_records=False) 11 | domain.CNAME = ["cname"] 12 | assert _generic_cname_found_but_404_http.test.potential(domain) == True 13 | 14 | 15 | @pytest.mark.asyncio 16 | async def test_potential_failure(): 17 | domain = Domain("mock.local", fetch_standard_records=False) 18 | assert _generic_cname_found_but_404_http.test.potential(domain) == False 19 | 20 | 21 | @pytest.mark.asyncio 22 | async def test_potential_failure_with_same_root_For_both_domain_and_cname(): 23 | domain = Domain("foo.mock.local", fetch_standard_records=False) 24 | domain.CNAME = ["bar.mock.local"] 25 | assert _generic_cname_found_but_404_http.test.potential(domain) == False 26 | 27 | 28 | @pytest.mark.asyncio 29 | async def test_check_success(): 30 | async def mock_fetch_web(**kwargs): 31 | return namedtuple("web_response", ["status_code"])(404) 32 | 33 | domain = Domain("mock.local", fetch_standard_records=False) 34 | domain.fetch_web = mock_fetch_web 35 | assert await _generic_cname_found_but_404_http.test.check(domain) == True 36 | 37 | 38 | @pytest.mark.asyncio 39 | async def test_check_failure(): 40 | async def mock_fetch_web(**kwargs): 41 | return namedtuple("web_response", ["status_code"])(200) 42 | 43 | domain = Domain("mock.local", fetch_standard_records=False) 44 | domain.fetch_web = mock_fetch_web 45 | assert await _generic_cname_found_but_404_http.test.check(domain) == False 46 | -------------------------------------------------------------------------------- /tests/signatures_tests/test_generic_cname_found_but_404_https.py: -------------------------------------------------------------------------------- 1 | from domain import Domain 2 | from signatures import _generic_cname_found_but_404_https 3 | 4 | from collections import namedtuple 5 | import pytest 6 | 7 | 8 | @pytest.mark.asyncio 9 | async def test_potential_success_with_a_cname(): 10 | domain = Domain("mock.local", fetch_standard_records=False) 11 | domain.CNAME = ["cname"] 12 | assert _generic_cname_found_but_404_https.test.potential(domain) == True 13 | 14 | 15 | @pytest.mark.asyncio 16 | async def test_potential_failure(): 17 | domain = Domain("mock.local", fetch_standard_records=False) 18 | assert _generic_cname_found_but_404_https.test.potential(domain) == False 19 | 20 | 21 | @pytest.mark.asyncio 22 | async def test_potential_failure_with_same_root_For_both_domain_and_cname(): 23 | domain = Domain("foo.mock.local", fetch_standard_records=False) 24 | domain.CNAME = ["bar.mock.local"] 25 | assert _generic_cname_found_but_404_https.test.potential(domain) == False 26 | 27 | 28 | @pytest.mark.asyncio 29 | async def test_check_success(): 30 | async def mock_fetch_web(**kwargs): 31 | return namedtuple("web_response", ["status_code"])(404) 32 | 33 | domain = Domain("mock.local", fetch_standard_records=False) 34 | domain.fetch_web = mock_fetch_web 35 | assert await _generic_cname_found_but_404_https.test.check(domain) == True 36 | 37 | 38 | @pytest.mark.asyncio 39 | async def test_check_failure(): 40 | async def mock_fetch_web(**kwargs): 41 | return namedtuple("web_response", ["status_code"])(200) 42 | 43 | domain = Domain("mock.local", fetch_standard_records=False) 44 | domain.fetch_web = mock_fetch_web 45 | assert await _generic_cname_found_but_404_https.test.check(domain) == False 46 | -------------------------------------------------------------------------------- /tests/signatures_tests/test_generic_cname_found_but_unregistered.py: -------------------------------------------------------------------------------- 1 | from domain import Domain 2 | from signatures import _generic_cname_found_but_unregistered 3 | from unittest.mock import patch, PropertyMock, AsyncMock 4 | from .. import mocks 5 | import pytest 6 | 7 | 8 | @pytest.mark.asyncio 9 | async def test_potential_success_with_a_cname_of_two_parts(): 10 | domain = Domain("mock.local", fetch_standard_records=False) 11 | domain.CNAME = ["cname.tld"] 12 | assert _generic_cname_found_but_unregistered.test.potential(domain) == True 13 | 14 | 15 | @pytest.mark.asyncio 16 | async def test_potential_failure(): 17 | domain = Domain("mock.local", fetch_standard_records=False) 18 | assert _generic_cname_found_but_unregistered.test.potential(domain) == False 19 | 20 | 21 | @pytest.mark.asyncio 22 | async def test_potential_failure_with_three_part_domain(): 23 | domain = Domain("foo.mock.local", fetch_standard_records=False) 24 | domain.CNAME = ["subdomain.cname.tld"] 25 | assert _generic_cname_found_but_unregistered.test.potential(domain) == False 26 | 27 | 28 | @pytest.mark.asyncio 29 | async def test_check_success(): 30 | domain = Domain("mock.local", fetch_standard_records=False) 31 | domain.CNAME = ["cname.tld"] 32 | with patch( 33 | "domain.Domain.is_registered", 34 | new=PropertyMock(spec=AsyncMock()(), return_value=False), 35 | ) as mock: 36 | assert await _generic_cname_found_but_unregistered.test.check(domain) == True 37 | assert mock.await_count == 1 38 | 39 | 40 | @pytest.mark.asyncio 41 | async def test_check_failure(): 42 | domain = Domain("mock.local", fetch_standard_records=False) 43 | domain.CNAME = ["cname.tld"] 44 | with patch( 45 | "domain.Domain.is_registered", 46 | new=PropertyMock(spec=AsyncMock()(), return_value=True), 47 | ) as mock: 48 | assert await _generic_cname_found_but_unregistered.test.check(domain) == False 49 | assert mock.await_count == 1 50 | 51 | 52 | @pytest.mark.asyncio 53 | async def test_check_success_ACTIVE(): 54 | test_cname = f"{mocks.random_string()}.co.uk" 55 | domain = Domain(f"{mocks.random_string()}.com", fetch_standard_records=False) 56 | domain.CNAME = [test_cname] 57 | print(f"Testing cname {test_cname}") 58 | assert await _generic_cname_found_but_unregistered.test.check(domain) == True 59 | -------------------------------------------------------------------------------- /tests/signatures_tests/test_generic_cname_found_doesnt_resolve.py: -------------------------------------------------------------------------------- 1 | from domain import Domain 2 | from signatures import _generic_cname_found_doesnt_resolve 3 | from unittest.mock import patch 4 | from .. import mocks 5 | import pytest 6 | 7 | 8 | @pytest.mark.asyncio 9 | async def test_potential_success_with_a_cname(): 10 | domain = Domain("mock.local", fetch_standard_records=False) 11 | domain.CNAME = ["cname"] 12 | assert _generic_cname_found_doesnt_resolve.test.potential(domain) == True 13 | 14 | 15 | @pytest.mark.asyncio 16 | async def test_potential_failure(): 17 | domain = Domain("mock.local", fetch_standard_records=False) 18 | assert _generic_cname_found_doesnt_resolve.test.potential(domain) == False 19 | 20 | 21 | @pytest.mark.asyncio 22 | async def test_potential_failure_with_same_root_For_both_domain_and_cname(): 23 | domain = Domain("foo.mock.local", fetch_standard_records=False) 24 | domain.CNAME = ["bar.mock.local"] 25 | assert _generic_cname_found_doesnt_resolve.test.potential(domain) == False 26 | 27 | 28 | @pytest.mark.asyncio 29 | async def test_potential_failure_with_filtered_cname(): 30 | for cname in _generic_cname_found_doesnt_resolve.filtered_cname_substrings: 31 | domain = Domain("foo.mock.local", fetch_standard_records=False) 32 | domain.CNAME = [cname] 33 | assert _generic_cname_found_doesnt_resolve.test.potential(domain) == False 34 | 35 | 36 | @pytest.mark.asyncio 37 | async def test_potential_failure_with_domain_in_cname(): 38 | domain = Domain("foo.mock.local", fetch_standard_records=False) 39 | domain.CNAME = ["foo.mock.local.cdn"] 40 | assert _generic_cname_found_doesnt_resolve.test.potential(domain) == False 41 | 42 | 43 | @pytest.mark.asyncio 44 | async def test_check_success(): 45 | domain = Domain("mock.local", fetch_standard_records=False) 46 | domain.CNAME = ["cname"] 47 | with patch("resolver.Resolver.resolve", return_value={"NX_DOMAIN": True}): 48 | assert (await _generic_cname_found_doesnt_resolve.test.check(domain)) == True 49 | 50 | 51 | @pytest.mark.asyncio 52 | async def test_check_failure(): 53 | domain = Domain("mock.local", fetch_standard_records=False) 54 | domain.CNAME = ["cname"] 55 | with patch("resolver.Resolver.resolve", return_value={"NX_DOMAIN": False}): 56 | assert (await _generic_cname_found_doesnt_resolve.test.check(domain)) == False 57 | 58 | 59 | @pytest.mark.asyncio 60 | async def test_check_success_ACTIVE(): 61 | test_cname = f"{mocks.random_string()}.io" 62 | domain = Domain(f"{mocks.random_string()}.com", fetch_standard_records=False) 63 | domain.CNAME = [test_cname] 64 | print(f"Testing cname {test_cname}") 65 | assert (await _generic_cname_found_doesnt_resolve.test.check(domain)) == True 66 | -------------------------------------------------------------------------------- /tests/signatures_tests/test_generic_zone_missing_on_ns.py: -------------------------------------------------------------------------------- 1 | from domain import Domain 2 | from signatures import _generic_zone_missing_on_ns 3 | import pytest 4 | 5 | 6 | @pytest.mark.asyncio 7 | async def test_potential_success_with_a_nameserver(): 8 | domain = Domain("mock.local", fetch_standard_records=False) 9 | domain.NS = ["ns"] 10 | assert _generic_zone_missing_on_ns.test.potential(domain) == True 11 | 12 | 13 | @pytest.mark.asyncio 14 | async def test_potential_success_with_multiple_nameservers(): 15 | domain = Domain("mock.local", fetch_standard_records=False) 16 | domain.NS = ["ns1", "ns2"] 17 | assert _generic_zone_missing_on_ns.test.potential(domain) == True 18 | 19 | 20 | @pytest.mark.asyncio 21 | async def test_potential_failure(): 22 | domain = Domain("mock.local", fetch_standard_records=False) 23 | assert _generic_zone_missing_on_ns.test.potential(domain) == False 24 | 25 | 26 | @pytest.mark.asyncio 27 | async def test_potential_failure_with_same_root_for_both_domain_and_ns(): 28 | domain = Domain("something.mock.local", fetch_standard_records=False) 29 | domain.NS = ["ns1.mock.local"] 30 | assert _generic_zone_missing_on_ns.test.potential(domain) == False 31 | -------------------------------------------------------------------------------- /tests/test_signatures.py: -------------------------------------------------------------------------------- 1 | import signatures 2 | import pytest 3 | 4 | all_signatures = [getattr(signatures, signature) for signature in signatures.__all__] 5 | 6 | 7 | @pytest.mark.parametrize("signature", all_signatures) 8 | def test_signatures_have_a_test_defined(signature): 9 | assert hasattr(signature, "test") == True 10 | 11 | 12 | @pytest.mark.parametrize("signature", all_signatures) 13 | def test_signatures_inherit_from_Base(signature): 14 | assert issubclass(type(signature.test), signatures.templates.base.Base) 15 | 16 | 17 | def test_signatures_INFO_strings_are_unique(): 18 | INFOs = [signature.test.INFO for signature in all_signatures] 19 | assert len(INFOs) == len(set(INFOs)) 20 | --------------------------------------------------------------------------------