pax_global_header00006660000000000000000000000064151506517750014526gustar00rootroot0000000000000052 comment=5c6fc37b0859096ae20e51ae79dd4e74fa728e60 dnsrecon-1.6.0/000077500000000000000000000000001515065177500133455ustar00rootroot00000000000000dnsrecon-1.6.0/.dockerignore000066400000000000000000000077241515065177500160330ustar00rootroot00000000000000.Python [Bb]in [Ii]nclude [Ll]ib [Ll]ib64 [Ll]ocal [Ss]cripts pyvenv.cfg .venv pip-selfcheck.json __pycache__/ *.py[cod] *$py.class *.so build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST pip-log.txt pip-delete-this-directory.txt htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ cover/ *.mo *.pot __pypackages__/ *foo *bar *fubar *foobar *baz *qux *quux *bongo *bazola *ztesch *wibble *wobble *wubble *flob *blep *blah *boop *beep *hoge *piyo *fuga *hogera *hogehoge *fulano *sicrano *beltrano *mengano *perengano *zutano *toto *titi *tata *tutu *pipppo *pluto *paperino *aap *noot *mies *tests *testsdir *testsfile *testsfiles *test *testdir *testfile *testfiles *testing *testingdir *testingfile *testingfiles *temp *tempdir *tempfile *tempfiles *tmp *tmpdir *tmpfile *tmpfiles *lol *.rsuser *.suo *.user *.userosscache *.sln.docstates *.userprefs mono_crash.* [Dd]ebug/ [Dd]ebugPublic/ [Rr]elease/ [Rr]eleases/ x64/ x86/ [Ww][Ii][Nn]32/ [Aa][Rr][Mm]/ [Aa][Rr][Mm]64/ bld/ [Bb]in/ [Oo]bj/ [Ll]og/ [Ll]ogs/ .vs/ Generated\ Files/ [Tt]est[Rr]esult*/ [Bb]uild[Ll]og.* *.VisualState.xml TestResult.xml nunit-*.xml [Dd]ebugPS/ [Rr]eleasePS/ dlldata.c BenchmarkDotNet.Artifacts/ project.lock.json project.fragment.lock.json artifacts/ ScaffoldingReadMe.txt StyleCopReport.xml *_i.c *_p.c *_h.h *.ilk *.meta *.obj *.iobj *.pch *.pdb *.ipdb *.pgc *.pgd *.rsp *.sbr *.tlb *.tli *.tlh *.tmp *.tmp_proj *_wpftmp.csproj *.log *.tlog *.vspscc *.vssscc .builds *.pidb *.svclog *.scc _Chutzpah* ipch/ *.aps *.ncb *.opendb *.opensdf *.sdf *.cachefile *.VC.db *.VC.VC.opendb *.psess *.vsp *.vspx *.sap *.e2e $tf/ *.gpState _ReSharper*/ *.[Rr]e[Ss]harper *.DotSettings.user _TeamCity* *.dotCover .axoCover/* !.axoCover/settings.json coverage*.json coverage*.xml coverage*.info *.coverage *.coveragexml _NCrunch_* .*crunch*.local.xml nCrunchTemp_* *.mm.* AutoTest.Net/ .sass-cache/ [Ee]xpress/ DocProject/buildhelp/ DocProject/Help/*.HxT DocProject/Help/*.HxC DocProject/Help/*.hhc DocProject/Help/*.hhk DocProject/Help/*.hhp DocProject/Help/Html2 DocProject/Help/html publish/ *.[Pp]ublish.xml *.azurePubxml *.pubxml *.publishproj PublishScripts/ *.nupkg *.snupkg **/[Pp]ackages/* !**/[Pp]ackages/build/ *.nuget.props *.nuget.targets csx/ *.build.csdef ecf/ rcf/ AppPackages/ BundleArtifacts/ Package.StoreAssociation.xml _pkginfo.txt *.appx *.appxbundle *.appxupload *.[Cc]ache !?*.[Cc]ache/ ClientBin/ ~$* *~ *.dbmdl *.dbproj.schemaview *.jfm *.pfx *.publishsettings orleans.codegen.cs Generated_Code/ _UpgradeReport_Files/ Backup*/ UpgradeLog*.XML UpgradeLog*.htm ServiceFabricBackup/ *.rptproj.bak *.mdf *.ldf *.ndf *.rdl.data *.bim.layout *.bim_*.settings *.rptproj.rsuser *- [Bb]ackup.rdl *- [Bb]ackup ([0-9]).rdl *- [Bb]ackup ([0-9][0-9]).rdl FakesAssemblies/ *.GhostDoc.xml .ntvs_analysis.dat node_modules/ *.plg *.opt *.vbw *.vbp *.dsw *.dsp **/*.HTMLClient/GeneratedArtifacts **/*.DesktopClient/GeneratedArtifacts **/*.DesktopClient/ModelManifest.xml **/*.Server/GeneratedArtifacts **/*.Server/ModelManifest.xml _Pvt_Extensions .paket/paket.exe paket-files/ .fake/ .cr/personal *.pyc *.tss *.jmconfig *.btp.cs *.btm.cs *.odx.cs *.xsd.cs OpenCover/ ASALocalRun/ *.binlog *.nvuser .mfractor/ .localhistory/ .vshistory/ healthchecksdb MigrationBackup/ .ionide/ FodyWeavers.xsd .vscode/* !.vscode/settings.json !.vscode/tasks.json !.vscode/launch.json !.vscode/extensions.json *.code-workspace .history/ *.cab *.msi *.msix *.msm *.msp *.sln.iml *.manifest *.spec local_settings.py db.sqlite3 db.sqlite3-journal instance/ .webassets-cache .scrapy docs/_build/ .pybuilder/ target/ .ipynb_checkpoints profile_default/ ipython_config.py .pdm.toml .pdm-python .pdm-build/ celerybeat-schedule celerybeat.pid *.sage.py .env env/ venv/ ENV/ env.bak/ venv.bak/ .spyderproject .spyproject .ropeproject /site .mypy_cache/ .dmypy.json dmypy.json .pyre/ .pytype/ cython_debug/ CHANGELOG.md .junie/ dnsrecon-1.6.0/.gitattributes000066400000000000000000000012521515065177500162400ustar00rootroot00000000000000# Set the default behavior, which is to have git automatically determine # whether a file is a text or binary, unless otherwise specified. * text=auto # Basic .gitattributes for a python repo. # Source files # ============ *.pxd text diff=python *.py text diff=python *.py3 text diff=python *.pyw text diff=python *.pyx text diff=python # Binary files # ============ *.db binary *.p binary *.pkl binary *.pyc binary *.pyd binary *.pyo binary # Note: .db, .p, and .pkl files are associated with the python modules # ``pickle``, ``dbm.*``, # ``shelve``, ``marshal``, ``anydbm``, & ``bsddb`` # (among others). dnsrecon-1.6.0/.github/000077500000000000000000000000001515065177500147055ustar00rootroot00000000000000dnsrecon-1.6.0/.github/FUNDING.yml000066400000000000000000000010561515065177500165240ustar00rootroot00000000000000# These are supported funding model platforms github: [L1ghtn1ng] open_collective: # Replace with a single Open Collective username ko_fi: # tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry liberapay: # Replace with a single Liberapay username issuehunt: # Replace with a single IssueHunt username otechie: # Replace with a single Otechie username custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] dnsrecon-1.6.0/.github/ISSUE_TEMPLATE/000077500000000000000000000000001515065177500170705ustar00rootroot00000000000000dnsrecon-1.6.0/.github/ISSUE_TEMPLATE/issue-template.md000066400000000000000000000014141515065177500223530ustar00rootroot00000000000000--- name: Issue Template about: A template for new issues. title: "[Bug|Feature Request|Other] Short Description of Issue" labels: '' --- **Feature Request or Bug or Other** Feature Request | Bug | Other **Describe the feature request or bug or other** A clear and concise description of what the bug, feature request, or other request is. **To Reproduce** Steps to reproduce the behaviour: 1. Run tool like this: '...' 2. See error **Expected behaviour** A clear and concise description of what you expected to happen. **Screenshots** If possible please add screenshots to help explain your problem. **System Information (System that tool is running on):** - OS: [e.g. Windows10] - Version [e.g. 2.7] **Additional context** Add any other context about the problem here.dnsrecon-1.6.0/.github/dependabot.yml000066400000000000000000000005431515065177500175370ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: github-actions directory: "/" schedule: interval: daily timezone: Europe/London - package-ecosystem: uv directory: "/" schedule: interval: daily timezone: Europe/London open-pull-requests-limit: 10 target-branch: master allow: - dependency-type: direct - dependency-type: indirectdnsrecon-1.6.0/.github/workflows/000077500000000000000000000000001515065177500167425ustar00rootroot00000000000000dnsrecon-1.6.0/.github/workflows/DNSrecon.yml000066400000000000000000000023061515065177500211410ustar00rootroot00000000000000name: DNSrecon CI on: push: branches: - '*' pull_request: branches: - '*' jobs: Python: runs-on: ${{ matrix.os }} strategy: max-parallel: 8 matrix: os: [ubuntu-latest] python-version: [3.12, 3.13, 3.14] steps: - uses: actions/checkout@v6 - name: Install uv uses: astral-sh/setup-uv@v7 with: enable-cache: true cache-dependency-glob: "uv.lock" python-version: ${{ matrix.python-version }} - name: Install dependencies run: uv sync --extra dev - name: Lint with ruff run: | uv run ruff check --fix - name: Format with ruff run: | uv run ruff format - name: Commit changes for ruff formating and linting if: github.event_name == 'push' run: | git config user.name github-actions git config user.email github-actions@github.com git add . git commit -m "Apply ruff fixes and formatting" || true # Use || true to prevent failure if no changes git push origin HEAD:${{ github.ref_name }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Test with pytest run: | uv run pytest dnsrecon-1.6.0/.github/workflows/codeql-analysis.yml000066400000000000000000000044711515065177500225630ustar00rootroot00000000000000# For most projects, this workflow file will not need changing; you simply need # to commit it to your repository. # # You may wish to alter this file to override the set of languages analyzed, # or to provide custom queries or build logic. # # ******** NOTE ******** # We have attempted to detect the languages in your repository. Please check # the `language` matrix defined below to confirm you have the correct set of # supported CodeQL languages. # name: "CodeQL" on: push: branches: [ master ] pull_request: # The branches below must be a subset of the branches above branches: [ master ] schedule: - cron: '42 19 * * 1' jobs: analyze: name: Analyze runs-on: ubuntu-latest strategy: fail-fast: false matrix: language: [ 'python' ] # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] # Learn more: # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed steps: - name: Checkout repository uses: actions/checkout@v6 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. # By default, queries listed here will override any specified in a config file. # Prefix the list here with "+" to use these queries and those in the config file. # queries: ./path/to/local/query, your-org/your-repo/queries@main # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild uses: github/codeql-action/autobuild@v4 # â„šī¸ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl # âœī¸ If the Autobuild fails above, remove it and uncomment the following three lines # and modify them (or add more) to build your code if your project # uses a compiled language #- run: | # make bootstrap # make release - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v4 dnsrecon-1.6.0/.github/workflows/dockerci.yml000066400000000000000000000003701515065177500212500ustar00rootroot00000000000000name: DNSrecon Docker Image CI on: [push, pull_request] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Build the Docker image run: docker build . --file Dockerfile --tag dnsrecon:$(date +%s)dnsrecon-1.6.0/.gitignore000066400000000000000000000001311515065177500153300ustar00rootroot00000000000000*.idea/ *.orig *.pyc *.pyo venv/ .venv/ *.pytest_cache/ *.egg-info build/ dist/ *.junie/ dnsrecon-1.6.0/CHANGELOG.md000066400000000000000000000106231515065177500151600ustar00rootroot00000000000000# Changelog ## [1.6.0] - 2026-02-28 ### Added - Added Shodan support for netblock expansion during DNS enumeration and API enhancements to leverage it (Fixes #104). ([12a5b15](https://github.com/darkoperator/dnsrecon/commit/12a5b15)) ### Changed - Bumped DNSRecon version to `1.6.0`. - Updated FastAPI to 0.134.0 and replaced `UJSONResponse` with `JSONResponse` for compatibility. ([8360ec0](https://github.com/darkoperator/dnsrecon/commit/8360ec0)) - Bumped `fastapi` from 0.129.2 to 0.133.1. ([8476e35](https://github.com/darkoperator/dnsrecon/commit/8476e35)) - Bumped `ruff` from 0.15.2 to 0.15.4. ([0c8bc7e](https://github.com/darkoperator/dnsrecon/commit/0c8bc7e)) - Applied Ruff fixes and formatting. ([56c9c35](https://github.com/darkoperator/dnsrecon/commit/56c9c35)) - Updated dependencies. ([5db0595](https://github.com/darkoperator/dnsrecon/commit/5db0595)) - Bumped `uvicorn[standard]` from 0.40.0 to 0.41.0. ([78137d2](https://github.com/darkoperator/dnsrecon/commit/78137d2)) - Bumped `ruff` from 0.15.1 to 0.15.2. ([5fecb97](https://github.com/darkoperator/dnsrecon/commit/5fecb97)) - Updated dependencies. ([1cad9df](https://github.com/darkoperator/dnsrecon/commit/1cad9df)) - Updated dependencies. ([506d8d3](https://github.com/darkoperator/dnsrecon/commit/506d8d3)) - Bumped `fastapi` from 0.128.5 to 0.128.6. ([ed58132](https://github.com/darkoperator/dnsrecon/commit/ed58132)) - Bumped `fastapi` from 0.128.2 to 0.128.5. ([08c6dc4](https://github.com/darkoperator/dnsrecon/commit/08c6dc4)) - Merged pull request #454. ([1b90c5a](https://github.com/darkoperator/dnsrecon/commit/1b90c5a)) - Bumped `fastapi` from 0.128.0 to 0.128.2. ([09b5f09](https://github.com/darkoperator/dnsrecon/commit/09b5f09)) - Applied fix related to issue #453. ([32d2382](https://github.com/darkoperator/dnsrecon/commit/32d2382)) ## [1.5.3] - 2025-12-30 ### Removed - Removed `lxml` dependency as it is no longer required for `crt.sh` scraping. ([1a3efd6](https://github.com/darkoperator/dnsrecon/commit/1a3efd6)) ### Added - Added recursion control to `DnsHelper` and corresponding CLI options this is in relation to #308. ([a27b244](https://github.com/darkoperator/dnsrecon/commit/a27b244)) - Added `uv.lock` for dependency management and improved project isolation. ([0e1f4bc](https://github.com/darkoperator/dnsrecon/commit/0e1f4bc)) - Added `FUNDING.yml` to enable project sponsorship. ([e9aef30](https://github.com/darkoperator/dnsrecon/commit/e9aef30)) ### Changed - Migrated CI/CD workflows to use `uv` for faster and more reliable builds. ([0e1f4bc](https://github.com/darkoperator/dnsrecon/commit/0e1f4bc)) - Updated multiple dependencies (`fastapi`, `uvicorn`, `stamina`, `pytest`, `ruff`) to their latest versions. - Added type ignore comment for `CORSMiddleware` validation in API implementation. ([beda5fe](https://github.com/darkoperator/dnsrecon/commit/beda5fe)) - Refactored code across parser and DNS utility modules, including adding type annotations and improving error handling. ([e402af2](https://github.com/darkoperator/dnsrecon/commit/e402af2)) - Switched `crt.sh` enumeration to use the JSON API query instead of HTML scraping for improved reliability. ([1a3efd6](https://github.com/darkoperator/dnsrecon/commit/1a3efd6)) - Updated `fastapi` to version 0.128.0. ([1a3efd6](https://github.com/darkoperator/dnsrecon/commit/1a3efd6)) ### Fixed - Resolved issue #308 regarding recursion control. ([a27b244](https://github.com/darkoperator/dnsrecon/commit/a27b244)) - Adjusted zone transfer test threshold to improve test stability. ([c3ef676](https://github.com/darkoperator/dnsrecon/commit/c3ef676)) - Applied code style fixes and formatting using Ruff. ([302279f](https://github.com/darkoperator/dnsrecon/commit/302279f)) ## [1.5.2] - 2025-12-23 ### Added - Added support for Python 3.14. ([12827ab](https://github.com/darkoperator/dnsrecon/commit/12827ab)) ### Changed - Replaced `requests` with `httpx` to modernize HTTP handling. ([12827ab](https://github.com/darkoperator/dnsrecon/commit/12827ab)) - Updated dependencies including `ruff`. ([67cd7f6](https://github.com/darkoperator/dnsrecon/commit/67cd7f6)) ### Fixed - Resolved issue #432 to actually fix python 3.14 support. ([880e76b](https://github.com/darkoperator/dnsrecon/commit/880e76b)) [1.6.0]: https://github.com/darkoperator/dnsrecon/compare/1.5.3...1.6.0 [1.5.3]: https://github.com/darkoperator/dnsrecon/compare/1.5.2...1.5.3 [1.5.2]: https://github.com/darkoperator/dnsrecon/compare/1.5.1...1.5.2 dnsrecon-1.6.0/Dockerfile000066400000000000000000000004061515065177500153370ustar00rootroot00000000000000FROM python:alpine RUN apk add --no-cache build-base libffi-dev libxml2-dev libxslt-dev WORKDIR /app COPY . /app RUN /usr/local/bin/python -m pip install --upgrade pip RUN /usr/local/bin/python --version RUN pip3 install --no-cache-dir . ENTRYPOINT ["dnsrecon"] dnsrecon-1.6.0/LICENSE000066400000000000000000000432721515065177500143620ustar00rootroot00000000000000 GNU GENERAL PUBLIC LICENSE Version 2, June 1991 Copyright (C) 1989, 1991 Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Copyright (C) 2021 Carlos Perez The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. This General Public License applies to most of the Free Software Foundation's software and to any other program whose authors commit to using it. (Some other Free Software Foundation software is covered by the GNU Lesser General Public License instead.) You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs; and that you know you can do these things. To protect your rights, we need to make restrictions that forbid anyone to deny you these rights or to ask you to surrender the rights. These restrictions translate to certain responsibilities for you if you distribute copies of the software, or if you modify it. For example, if you distribute copies of such a program, whether gratis or for a fee, you must give the recipients all the rights that you have. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. We protect your rights with two steps: (1) copyright the software, and (2) offer you this license which gives you legal permission to copy, distribute and/or modify the software. Also, for each author's protection and ours, we want to make certain that everyone understands that there is no warranty for this free software. If the software is modified by someone else and passed on, we want its recipients to know that what they have is not the original, so that any problems introduced by others will not reflect on the original authors' reputations. Finally, any free program is threatened constantly by software patents. We wish to avoid the danger that redistributors of a free program will individually obtain patent licenses, in effect making the program proprietary. To prevent this, we have made it clear that any patent must be licensed for everyone's free use or not licensed at all. The precise terms and conditions for copying, distribution and modification follow. GNU GENERAL PUBLIC LICENSE TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 0. This License applies to any program or other work which contains a notice placed by the copyright holder saying it may be distributed under the terms of this General Public License. The "Program", below, refers to any such program or work, and a "work based on the Program" means either the Program or any derivative work under copyright law: that is to say, a work containing the Program or a portion of it, either verbatim or with modifications and/or translated into another language. (Hereinafter, translation is included without limitation in the term "modification".) Each licensee is addressed as "you". Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running the Program is not restricted, and the output from the Program is covered only if its contents constitute a work based on the Program (independent of having been made by running the Program). Whether that is true depends on what the Program does. 1. You may copy and distribute verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and give any other recipients of the Program a copy of this License along with the Program. You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. 2. You may modify your copy or copies of the Program or any portion of it, thus forming a work based on the Program, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: a) You must cause the modified files to carry prominent notices stating that you changed the files and the date of any change. b) You must cause any work that you distribute or publish, that in whole or in part contains or is derived from the Program or any part thereof, to be licensed as a whole at no charge to all third parties under the terms of this License. c) If the modified program normally reads commands interactively when run, you must cause it, when started running for such interactive use in the most ordinary way, to print or display an announcement including an appropriate copyright notice and a notice that there is no warranty (or else, saying that you provide a warranty) and that users may redistribute the program under these conditions, and telling the user how to view a copy of this License. (Exception: if the Program itself is interactive but does not normally print such an announcement, your work based on the Program is not required to print an announcement.) These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Program, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Program, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Program. In addition, mere aggregation of another work not based on the Program with the Program (or with a work based on the Program) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. 3. You may copy and distribute the Program (or a work based on it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you also do one of the following: a) Accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, b) Accompany it with a written offer, valid for at least three years, to give any third party, for a charge no more than your cost of physically performing source distribution, a complete machine-readable copy of the corresponding source code, to be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, c) Accompany it with the information you received as to the offer to distribute corresponding source code. (This alternative is allowed only for noncommercial distribution and only if you received the program in object code or executable form with such an offer, in accord with Subsection b above.) The source code for a work means the preferred form of the work for making modifications to it. For an executable work, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the executable. However, as a special exception, the source code distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. If distribution of executable or object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place counts as distribution of the source code, even though third parties are not compelled to copy the source along with the object code. 4. You may not copy, modify, sublicense, or distribute the Program except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense or distribute the Program is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. 5. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Program or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Program (or any work based on the Program), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Program or works based on it. 6. Each time you redistribute the Program (or any work based on the Program), the recipient automatically receives a license from the original licensor to copy, distribute or modify the Program subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties to this License. 7. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Program at all. For example, if a patent license would not permit royalty-free redistribution of the Program by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Program. If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply and the section as a whole is intended to apply in other circumstances. It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system, which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. 8. If the distribution and/or use of the Program is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Program under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. 9. The Free Software Foundation may publish revised and/or new versions of the General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of this License, you may choose any version ever published by the Free Software Foundation. 10. If you wish to incorporate parts of the Program into other free programs whose distribution conditions are different, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. NO WARRANTY 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. Also add information on how to contact you by electronic and paper mail. If the program is interactive, make it output a short notice like this when it starts in an interactive mode: Gnomovision version 69, Copyright (C) year name of author Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, the commands you use may be called something other than `show w' and `show c'; they could even be mouse-clicks or menu items--whatever suits your program. You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the program, if necessary. Here is a sample; alter the names: Yoyodyne, Inc., hereby disclaims all copyright interest in the program `Gnomovision' (which makes passes at compilers) written by James Hacker. , 1 April 1989 Ty Coon, President of Vice This General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. dnsrecon-1.6.0/README.md000066400000000000000000000056711515065177500146350ustar00rootroot00000000000000 # DNSRecon DNSRecon is a Python port of a Ruby script that I wrote to learn the language and about DNS in early 2007. This time I wanted to learn about Python and extend the functionality of the original tool and in the process re-learn how DNS works and how could it be used in the process of a security assessment and network troubleshooting. This script provides the ability to perform: * Check all NS Records for Zone Transfers. * Enumerate General DNS Records for a given Domain (MX, SOA, NS, A, AAAA, SPF and TXT). * Perform common SRV Record Enumeration. * Top Level Domain (TLD) Expansion. * Check for Wildcard Resolution. * Brute Force subdomain and host A and AAAA records given a domain and a wordlist. * Perform a PTR Record lookup for a given IP Range or CIDR. * Check a DNS Server Cached records for A, AAAA and CNAME Records provided a list of host records in a text file to check.. # Installation ## Requirements DNSRecon requires Python 3.12 or higher. ## Using uv (Recommended) 1. Install uv if you haven't already: ```bash curl -LsSf https://astral.sh/uv/install.sh | sh ``` 2. Clone the repository: ```bash git clone https://github.com/darkoperator/dnsrecon.git cd dnsrecon ``` 3. Install dependencies and create virtual environment: ```bash uv sync ``` 4. Run DNSRecon: ```bash uv run dnsrecon ``` ## Development To install development dependencies: ```bash uv sync --extra dev ``` To run tests: ```bash uv run pytest ``` To run linting and formatting: ```bash uv run ruff check ``` ```bash uv run ruff format ``` ## Shodan Netblock Expansion DNSRecon can use Shodan to expand netblocks discovered during standard enumeration from SPF (`-s`) and/or WHOIS (`-w`) data. ### CLI examples Passive Shodan enrichment (uses SPF + WHOIS netblocks): ```bash uv run dnsrecon -d example.com -t std -s -w --shodan --shodan-key "$SHODAN_API_KEY" ``` Active validation of Shodan results (re-resolves hosts and confirms they still match the queried netblock): ```bash uv run dnsrecon -d example.com -t std -s -w --shodan --shodan-active --shodan-key "$SHODAN_API_KEY" ``` You can also set the API key via environment variable instead of `--shodan-key`: ```bash export SHODAN_API_KEY="your-shodan-api-key" uv run dnsrecon -d example.com -t std -s -w --shodan ``` ### REST API examples Start the REST API: ```bash uv run restdnsrecon ``` Call `/general_enum` with Shodan expansion enabled: ```bash curl -s \ -H "X-Shodan-Api-Key: $SHODAN_API_KEY" \ "http://127.0.0.1:5000/general_enum?domain=example.com&do_spf=true&do_whois=true&do_shodan=true" ``` Enable active validation in the API: ```bash curl -s \ -H "X-Shodan-Api-Key: $SHODAN_API_KEY" \ "http://127.0.0.1:5000/general_enum?domain=example.com&do_spf=true&do_whois=true&do_shodan=true&shodan_active=true" ``` ## Packaging Versions [![Packaging status](https://repology.org/badge/vertical-allrepos/dnsrecon.svg)](https://repology.org/project/dnsrecon/versions) dnsrecon-1.6.0/dnsrecon.py000077500000000000000000000002101515065177500155260ustar00rootroot00000000000000#!/usr/bin/env python3 # Note: This script runs dnsrecon from dnsrecon import __main__ if __name__ == '__main__': __main__.main() dnsrecon-1.6.0/dnsrecon/000077500000000000000000000000001515065177500151605ustar00rootroot00000000000000dnsrecon-1.6.0/dnsrecon/__init__.py000066400000000000000000000000001515065177500172570ustar00rootroot00000000000000dnsrecon-1.6.0/dnsrecon/__main__.py000066400000000000000000000001031515065177500172440ustar00rootroot00000000000000from .cli import main if __name__ == '__main__': exit(main()) dnsrecon-1.6.0/dnsrecon/api.py000066400000000000000000001300711515065177500163050ustar00rootroot00000000000000import os import traceback from fastapi import FastAPI, Header, HTTPException, Query, Request, status from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response from pydantic import BaseModel, Field from slowapi import Limiter, _rate_limit_exceeded_handler from slowapi.errors import RateLimitExceeded from slowapi.util import get_remote_address from dnsrecon.cli import ( brute_domain, brute_reverse, brute_srv, brute_tlds, check_bindversion, check_nxdomain_hijack, check_recursive, check_wildcard, ds_zone_walk, general_enum, in_cache, ) from dnsrecon.lib.dnshelper import DnsHelper API_RATE_LIMIT = os.getenv('API_RATE_LIMIT', '5/minute') # Define Pydantic models for request and response validation class DnsRecord(BaseModel): name: str = Field(..., description='DNS record name') type: str = Field(..., description='DNS record type') address: str = Field(..., description='DNS record address/value') target: str | None = Field(None, description='DNS record target (for SRV records)') port: int | None = Field(None, description='Port number (for SRV records)') class GeneralEnumResponse(BaseModel): domain: str = Field(..., description='Target domain') records: list[DnsRecord] = Field(default_factory=list, description='List of DNS records found') subdomains: list[str] = Field(default_factory=list, description='List of subdomains found') ips: list[str] = Field(default_factory=list, description='List of IP addresses found') class BruteForceResponse(BaseModel): domain: str = Field(..., description='Target domain') subdomains: list[str] = Field(default_factory=list, description='List of subdomains found') records: list[DnsRecord] = Field(default_factory=list, description='List of DNS records found') class ReverseResponse(BaseModel): ip_range: str = Field(..., description='IP range queried') records: list[DnsRecord] = Field(default_factory=list, description='List of reverse DNS records found') class DnsSecResponse(BaseModel): domain: str = Field(..., description='Target domain') dnssec_enabled: bool = Field(..., description='Whether DNSSEC is enabled') ds_records: list[DnsRecord] = Field(default_factory=list, description='List of DS records') dnskey_records: list[DnsRecord] = Field(default_factory=list, description='List of DNSKEY records') class ZoneWalkResponse(BaseModel): domain: str = Field(..., description='Target domain') records: list[DnsRecord] = Field(default_factory=list, description='List of records found via zone walking') class WildcardResponse(BaseModel): domain: str = Field(..., description='Target domain') wildcard_enabled: bool = Field(..., description='Whether wildcard DNS is enabled') wildcard_ips: list[str] = Field(default_factory=list, description='List of wildcard IP addresses') class ErrorResponse(BaseModel): detail: str = Field(..., description='Error message') error_type: str | None = Field(None, description='Type of error') traceback: str | None = Field(None, description='Error traceback') limiter = Limiter(key_func=get_remote_address) app = FastAPI( title='DNSRecon REST API', description='REST API for DNSRecon powered by FastAPI', version='1.0.0', docs_url='/docs', redoc_url='/redoc', ) app.state.limiter = limiter app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) # type: ignore # Add CORS middleware app.add_middleware( CORSMiddleware, # ty:ignore[invalid-argument-type] allow_origins=['*'], allow_credentials=True, allow_methods=['*'], allow_headers=['*'], ) @app.get('/', response_class=HTMLResponse) async def root(*, user_agent: str = Header(None)) -> Response: """ Root endpoint that displays the DNSRecon logo and links to the API documentation. Also performs basic user agent filtering to redirect suspicious bots. """ # Basic user agent filtering if user_agent and ('gobuster' in user_agent or 'sqlmap' in user_agent or 'rustbuster' in user_agent): response = RedirectResponse(app.url_path_for('bot')) return response return HTMLResponse( """ DNSRecon API

DNSRecon REST API

""" ) class BotResponse(BaseModel): bot: str = Field(..., description='Bot message') @app.get('/nicebot', response_model=BotResponse) async def bot() -> Response: """ Easter egg endpoint for bots. Returns a message when accessed by suspicious user agents. """ return JSONResponse({'bot': 'These are not the DNS records you are looking for'}) class CapabilitiesResponse(BaseModel): capabilities: list[str] = Field(..., description='List of supported DNS reconnaissance capabilities') @app.get( '/capabilities', response_model=CapabilitiesResponse, responses={ status.HTTP_500_INTERNAL_SERVER_ERROR: {'model': ErrorResponse}, status.HTTP_429_TOO_MANY_REQUESTS: {'model': ErrorResponse}, }, ) @limiter.limit(API_RATE_LIMIT) async def get_capabilities(request: Request) -> Response: """ Endpoint to query for available DNS reconnaissance capabilities. Returns a list of all supported operations that can be performed via the API. Rate limit is configurable via CLI argument (default: 5 requests per minute). """ try: capabilities = [ 'general_enum - General DNS enumeration with multiple sources (supports do_shodan, shodan_active, X-Shodan-Api-Key header)', 'brute_domain - Domain brute forcing', 'brute_srv - SRV record brute forcing', 'brute_tlds - TLD brute forcing', 'brute_reverse - Reverse DNS lookup', 'zone_walk - DNS zone walking', 'wildcard_check - Wildcard DNS detection', 'bind_version - BIND version detection', 'recursive_check - Recursive DNS server check', 'axfr_test - Zone transfer testing', 'caa_records - CAA record lookup', 'cache_snoop - DNS cache snooping', 'nxdomain_hijack - NXDOMAIN hijacking check', ] return JSONResponse({'capabilities': capabilities}) except Exception as e: error_traceback = traceback.format_exc() print(f'Error in get_capabilities endpoint: {e!s}\n{error_traceback}') return JSONResponse( { 'detail': f'An error occurred while retrieving capabilities: {e!s}', 'error_type': type(e).__name__, 'traceback': error_traceback if os.getenv('DEBUG') == '1' else None, }, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) @app.get( '/general_enum', response_model=GeneralEnumResponse, responses={ status.HTTP_500_INTERNAL_SERVER_ERROR: {'model': ErrorResponse}, status.HTTP_400_BAD_REQUEST: {'model': ErrorResponse}, status.HTTP_429_TOO_MANY_REQUESTS: {'model': ErrorResponse}, }, ) @limiter.limit(API_RATE_LIMIT) async def general_enumeration( request: Request, user_agent: str | None = Header(None), shodan_api_key_header: str | None = Header( None, alias='X-Shodan-Api-Key', description='Optional Shodan API key header for Shodan-backed netblock expansion', ), domain: str = Query(..., description='Domain to enumerate'), do_axfr: bool = Query(False, description='Perform zone transfer'), do_bing: bool = Query(False, description='Use Bing search'), do_yandex: bool = Query(False, description='Use Yandex search'), do_spf: bool = Query(False, description='Check SPF records'), do_whois: bool = Query(False, description='Perform WHOIS lookup'), do_crt: bool = Query(False, description='Check certificate transparency'), do_shodan: bool = Query(False, description='Use Shodan to expand SPF/Whois-discovered netblocks'), shodan_active: bool = Query(False, description='Actively validate Shodan-discovered hosts using DNS resolution'), zw: bool = Query(False, description='Perform zone walking'), request_timeout: int = Query(3, description='Request timeout in seconds'), thread_num: int = Query(10, description='Number of threads to use'), recursion_desired: bool = Query(True, description='Enable recursion desired flag in queries'), ) -> Response: """ Endpoint for general DNS enumeration. Performs comprehensive DNS reconnaissance using multiple techniques and sources. Rate limit is configurable via CLI argument (default: 5 requests per minute). """ # Basic user agent filtering if user_agent and ('gobuster' in user_agent or 'sqlmap' in user_agent or 'rustbuster' in user_agent): response = RedirectResponse(app.url_path_for('bot')) return response try: # Validate domain if not domain or len(domain) < 3: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Domain must be at least 3 characters long') # Create DNS resolver res = DnsHelper(domain, recursion_desired=recursion_desired) shodan_api_key = shodan_api_key_header or os.getenv('SHODAN_API_KEY') # Perform general enumeration results = general_enum( res=res, domain=domain, do_axfr=do_axfr, do_bing=do_bing, do_yandex=do_yandex, do_spf=do_spf, do_whois=do_whois, do_crt=do_crt, do_shodan=do_shodan, shodan_api_key=shodan_api_key, shodan_active=shodan_active, zw=zw, request_timeout=request_timeout, thread_num=thread_num, ) # Process results into a response format records = [] subdomains = [] ips = [] if results: for result in results: if isinstance(result, dict): record_type = result.get('type', 'Unknown') name = result.get('name', '') address = result.get('address', '') records.append(DnsRecord(name=name, type=record_type, address=address)) if record_type == 'A' and address: ips.append(address) if name and name != domain: subdomains.append(name) return JSONResponse( { 'domain': domain, 'records': [record.model_dump() for record in records], 'subdomains': list(set(subdomains)), 'ips': list(set(ips)), } ) except HTTPException as e: raise e except Exception as e: error_traceback = traceback.format_exc() print(f'Error in general_enumeration endpoint: {e!s}\n{error_traceback}') return JSONResponse( { 'detail': f'An error occurred while processing your request: {e!s}', 'error_type': type(e).__name__, 'traceback': error_traceback if os.getenv('DEBUG') == '1' else None, }, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) @app.get( '/brute_domain', response_model=BruteForceResponse, responses={ status.HTTP_500_INTERNAL_SERVER_ERROR: {'model': ErrorResponse}, status.HTTP_400_BAD_REQUEST: {'model': ErrorResponse}, status.HTTP_429_TOO_MANY_REQUESTS: {'model': ErrorResponse}, }, ) @limiter.limit(API_RATE_LIMIT) async def brute_force_domain( request: Request, user_agent: str = Header(None), domain: str = Query(..., description='Domain to brute force'), wordlist: str = Query('', description='Path to wordlist file (optional)'), filter_wildcards: bool = Query(True, description='Filter wildcard responses'), thread_num: int = Query(10, description='Number of threads to use'), recursion_desired: bool = Query(True, description='Enable recursion desired flag in queries'), ) -> Response: """ Endpoint for domain brute forcing. Performs subdomain brute force attack using a wordlist. Rate limit is configurable via CLI argument (default: 5 requests per minute). """ # Basic user agent filtering if user_agent and ('gobuster' in user_agent or 'sqlmap' in user_agent or 'rustbuster' in user_agent): response = RedirectResponse(app.url_path_for('bot')) return response try: # Validate domain if not domain or len(domain) < 3: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Domain must be at least 3 characters long') # Create a DNS resolver res = DnsHelper(domain, recursion_desired=recursion_desired) # Use default wordlist if none provided safe_root = os.path.join(os.path.dirname(__file__), 'data') if not wordlist: wordlist = os.path.join(safe_root, 'subdomains-top1mil-5000.txt') else: wordlist = os.path.normpath(os.path.join(safe_root, wordlist)) if not wordlist.startswith(safe_root): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid wordlist path') results = brute_domain( res=res, dictfile=wordlist, dom=domain, filter_=None, verbose=False, ignore_wildcard=not filter_wildcards, thread_num=thread_num, ) # Process results records = [] subdomains = [] if results: for result in results: if isinstance(result, dict): name = result.get('name', '') record_type = result.get('type', 'A') address = result.get('address', '') records.append(DnsRecord(name=name, type=record_type, address=address)) if name: subdomains.append(name) return JSONResponse( {'domain': domain, 'subdomains': list(set(subdomains)), 'records': [record.model_dump() for record in records]} ) except HTTPException as e: raise e except Exception as e: error_traceback = traceback.format_exc() print(f'Error in brute_force_domain endpoint: {e!s}\n{error_traceback}') return JSONResponse( { 'detail': f'An error occurred while processing your request: {e!s}', 'error_type': type(e).__name__, 'traceback': error_traceback if os.getenv('DEBUG') == '1' else None, }, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) @app.get( '/brute_reverse', response_model=ReverseResponse, responses={ status.HTTP_500_INTERNAL_SERVER_ERROR: {'model': ErrorResponse}, status.HTTP_400_BAD_REQUEST: {'model': ErrorResponse}, status.HTTP_429_TOO_MANY_REQUESTS: {'model': ErrorResponse}, }, ) @limiter.limit(API_RATE_LIMIT) async def brute_force_reverse( request: Request, user_agent: str = Header(None), ip_range: str = Query(..., description='IP range to perform reverse DNS lookup (e.g., 192.168.1.1-192.168.1.254)'), thread_num: int = Query(10, description='Number of threads to use'), recursion_desired: bool = Query(True, description='Enable recursion desired flag in queries'), ) -> Response: """ Endpoint for reverse DNS brute forcing. Performs reverse DNS lookups on a range of IP addresses. Rate limit is configurable via CLI argument (default: 5 requests per minute). """ # Basic user agent filtering if user_agent and ('gobuster' in user_agent or 'sqlmap' in user_agent or 'rustbuster' in user_agent): response = RedirectResponse(app.url_path_for('bot')) return response try: # Validate IP range if not ip_range: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='IP range is required') # Parse IP range if '-' in ip_range: start_ip, end_ip = ip_range.split('-', 1) ip_list = [ f'{start_ip.rsplit(".", 1)[0]}.{i}' for i in range(int(start_ip.split('.')[-1]), int(end_ip.split('.')[-1]) + 1) ] else: ip_list = [ip_range] res = DnsHelper('example.com', recursion_desired=recursion_desired) # Domain not used for reverse lookups # Perform reverse brute force results = brute_reverse(res=res, ip_list=ip_list, verbose=False, thread_num=thread_num) # Process results records = [] if results: for result in results: if isinstance(result, dict): name = result.get('name', '') address = result.get('address', '') records.append(DnsRecord(name=name, type='PTR', address=address)) return JSONResponse({'ip_range': ip_range, 'records': [record.model_dump() for record in records]}) except HTTPException as e: raise e except Exception as e: error_traceback = traceback.format_exc() print(f'Error in brute_force_reverse endpoint: {e!s}\n{error_traceback}') return JSONResponse( { 'detail': f'An error occurred while processing your request: {e!s}', 'error_type': type(e).__name__, 'traceback': error_traceback if os.getenv('DEBUG') == '1' else None, }, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) @app.get( '/wildcard_check', response_model=WildcardResponse, responses={ status.HTTP_500_INTERNAL_SERVER_ERROR: {'model': ErrorResponse}, status.HTTP_400_BAD_REQUEST: {'model': ErrorResponse}, status.HTTP_429_TOO_MANY_REQUESTS: {'model': ErrorResponse}, }, ) @limiter.limit(API_RATE_LIMIT) async def wildcard_check( request: Request, user_agent: str = Header(None), domain: str = Query(..., description='Domain to check for wildcard DNS'), ) -> Response: """ Endpoint for wildcard DNS detection. Checks if the domain has wildcard DNS configured. Rate limit is configurable via CLI argument (default: 5 requests per minute). """ # Basic user agent filtering if user_agent and ('gobuster' in user_agent or 'sqlmap' in user_agent or 'rustbuster' in user_agent): response = RedirectResponse(app.url_path_for('bot')) return response try: # Validate domain if not domain or len(domain) < 3: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Domain must be at least 3 characters long') res = DnsHelper(domain) wildcard_ips = check_wildcard(res, domain) wildcard_enabled = bool(wildcard_ips) if not wildcard_ips: wildcard_ips = [] return JSONResponse({'domain': domain, 'wildcard_enabled': wildcard_enabled, 'wildcard_ips': wildcard_ips}) except HTTPException as e: raise e except Exception as e: error_traceback = traceback.format_exc() print(f'Error in wildcard_check endpoint: {e!s}\n{error_traceback}') return JSONResponse( { 'detail': f'An error occurred while processing your request: {e!s}', 'error_type': type(e).__name__, 'traceback': error_traceback if os.getenv('DEBUG') == '1' else None, }, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) class SrvResponse(BaseModel): domain: str = Field(..., description='Target domain') srv_records: list[DnsRecord] = Field(default_factory=list, description='List of SRV records found') @app.get( '/brute_srv', response_model=SrvResponse, responses={ status.HTTP_500_INTERNAL_SERVER_ERROR: {'model': ErrorResponse}, status.HTTP_400_BAD_REQUEST: {'model': ErrorResponse}, status.HTTP_429_TOO_MANY_REQUESTS: {'model': ErrorResponse}, }, ) @limiter.limit(API_RATE_LIMIT) async def brute_force_srv( request: Request, user_agent: str = Header(None), domain: str = Query(..., description='Domain to enumerate SRV records for'), thread_num: int = Query(10, description='Number of threads to use'), recursion_desired: bool = Query(True, description='Enable recursion desired flag in queries'), ) -> Response: """ Endpoint for SRV record enumeration. Performs SRV record brute forcing to discover services. Rate limit is configurable via CLI argument (default: 5 requests per minute). """ # Basic user agent filtering if user_agent and ('gobuster' in user_agent or 'sqlmap' in user_agent or 'rustbuster' in user_agent): response = RedirectResponse(app.url_path_for('bot')) return response try: # Validate domain if not domain or len(domain) < 3: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Domain must be at least 3 characters long') res = DnsHelper(domain, recursion_desired=recursion_desired) # Perform SRV enumeration results = brute_srv(res=res, domain=domain, verbose=False, thread_num=thread_num) srv_records = [] if results: for result in results: if isinstance(result, dict): name = result.get('name', '') record_type = result.get('type', 'SRV') address = result.get('address', '') target = result.get('target', '') port = result.get('port', None) srv_records.append(DnsRecord(name=name, type=record_type, address=address, target=target, port=port)) return JSONResponse({'domain': domain, 'srv_records': [record.model_dump() for record in srv_records]}) except HTTPException as e: raise e except Exception as e: error_traceback = traceback.format_exc() print(f'Error in brute_force_srv endpoint: {e!s}\n{error_traceback}') return JSONResponse( { 'detail': f'An error occurred while processing your request: {e!s}', 'error_type': type(e).__name__, 'traceback': error_traceback if os.getenv('DEBUG') == '1' else None, }, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) class TldResponse(BaseModel): domain: str = Field(..., description='Base domain') tld_records: list[DnsRecord] = Field(default_factory=list, description='List of TLD enumeration results') @app.get( '/brute_tlds', response_model=TldResponse, responses={ status.HTTP_500_INTERNAL_SERVER_ERROR: {'model': ErrorResponse}, status.HTTP_400_BAD_REQUEST: {'model': ErrorResponse}, status.HTTP_429_TOO_MANY_REQUESTS: {'model': ErrorResponse}, }, ) @limiter.limit(API_RATE_LIMIT) async def brute_force_tlds( request: Request, user_agent: str = Header(None), domain: str = Query(..., description='Base domain to test against different TLDs'), thread_num: int = Query(10, description='Number of threads to use'), recursion_desired: bool = Query(True, description='Enable recursion desired flag in queries'), ) -> Response: """ Endpoint for TLD enumeration. Tests the base domain against all registered TLDs to find variations. Rate limit is configurable via CLI argument (default: 5 requests per minute). """ # Basic user agent filtering if user_agent and ('gobuster' in user_agent or 'sqlmap' in user_agent or 'rustbuster' in user_agent): response = RedirectResponse(app.url_path_for('bot')) return response try: # Validate domain if not domain or len(domain) < 3: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Domain must be at least 3 characters long') res = DnsHelper(domain, recursion_desired=recursion_desired) # Perform TLD enumeration results = brute_tlds(res=res, domain=domain, verbose=False, thread_num=thread_num) tld_records = [] if results: for result in results: if isinstance(result, dict): name = result.get('name', '') record_type = result.get('type', 'A') address = result.get('address', '') tld_records.append(DnsRecord(name=name, type=record_type, address=address)) return JSONResponse({'domain': domain, 'tld_records': [record.model_dump() for record in tld_records]}) except HTTPException as e: raise e except Exception as e: error_traceback = traceback.format_exc() print(f'Error in brute_force_tlds endpoint: {e!s}\n{error_traceback}') return JSONResponse( { 'detail': f'An error occurred while processing your request: {e!s}', 'error_type': type(e).__name__, 'traceback': error_traceback if os.getenv('DEBUG') == '1' else None, }, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) class AxfrResponse(BaseModel): domain: str = Field(..., description='Target domain') zone_transfer_successful: bool = Field(..., description='Whether zone transfer was successful') records: list[DnsRecord] = Field(default_factory=list, description='List of records from zone transfer') @app.get( '/axfr_test', response_model=AxfrResponse, responses={ status.HTTP_500_INTERNAL_SERVER_ERROR: {'model': ErrorResponse}, status.HTTP_400_BAD_REQUEST: {'model': ErrorResponse}, status.HTTP_429_TOO_MANY_REQUESTS: {'model': ErrorResponse}, }, ) @limiter.limit(API_RATE_LIMIT) async def axfr_test( request: Request, user_agent: str = Header(None), domain: str = Query(..., description='Domain to test for zone transfer'), ) -> Response: """ Endpoint for zone transfer testing. Tests if zone transfer (AXFR) is possible for the domain. Rate limit is configurable via CLI argument (default: 5 requests per minute). """ # Basic user agent filtering if user_agent and ('gobuster' in user_agent or 'sqlmap' in user_agent or 'rustbuster' in user_agent): response = RedirectResponse(app.url_path_for('bot')) return response try: # Validate domain if not domain or len(domain) < 3: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Domain must be at least 3 characters long') # Create DNS resolver res = DnsHelper(domain) # Perform zone transfer test results = res.zone_transfer() # Process results records = [] zone_transfer_successful = bool(results) if results: for result in results: if isinstance(result, dict): name = result.get('name', '') record_type = result.get('type', 'Unknown') address = result.get('address', '') target = result.get('target', '') records.append(DnsRecord(name=name, type=record_type, address=address, target=target)) return JSONResponse( { 'domain': domain, 'zone_transfer_successful': zone_transfer_successful, 'records': [record.model_dump() for record in records], } ) except HTTPException as e: raise e except Exception as e: error_traceback = traceback.format_exc() print(f'Error in axfr_test endpoint: {e!s}\n{error_traceback}') return JSONResponse( { 'detail': f'An error occurred while processing your request: {e!s}', 'error_type': type(e).__name__, 'traceback': error_traceback if os.getenv('DEBUG') == '1' else None, }, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) class CaaResponse(BaseModel): domain: str = Field(..., description='Target domain') caa_records: list[DnsRecord] = Field(default_factory=list, description='List of CAA records found') @app.get( '/caa_records', response_model=CaaResponse, responses={ status.HTTP_500_INTERNAL_SERVER_ERROR: {'model': ErrorResponse}, status.HTTP_400_BAD_REQUEST: {'model': ErrorResponse}, status.HTTP_429_TOO_MANY_REQUESTS: {'model': ErrorResponse}, }, ) @limiter.limit(API_RATE_LIMIT) async def caa_records( request: Request, user_agent: str = Header(None), domain: str = Query(..., description='Domain to query for CAA records'), ) -> Response: """ Endpoint for CAA record enumeration. Retrieves Certificate Authority Authorization (CAA) records for the domain. Rate limit is configurable via CLI argument (default: 5 requests per minute). """ # Basic user agent filtering if user_agent and ('gobuster' in user_agent or 'sqlmap' in user_agent or 'rustbuster' in user_agent): response = RedirectResponse(app.url_path_for('bot')) return response try: # Validate domain if not domain or len(domain) < 3: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Domain must be at least 3 characters long') res = DnsHelper(domain) results = res.get_caa() caa_records_list = [] if results: for result in results: if isinstance(result, (list, tuple)) and len(result) >= 3: record_type, name, value = result[:3] caa_records_list.append(DnsRecord(name=name, type=record_type, address=value)) return JSONResponse({'domain': domain, 'caa_records': [record.model_dump() for record in caa_records_list]}) except HTTPException as e: raise e except Exception as e: error_traceback = traceback.format_exc() print(f'Error in caa_records endpoint: {e!s}\n{error_traceback}') return JSONResponse( { 'detail': f'An error occurred while processing your request: {e!s}', 'error_type': type(e).__name__, 'traceback': error_traceback if os.getenv('DEBUG') == '1' else None, }, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) class CacheSnoopResponse(BaseModel): nameserver: str = Field(..., description='Name server tested') cached_records: list[DnsRecord] = Field(default_factory=list, description='List of cached records found') @app.get( '/cache_snoop', response_model=CacheSnoopResponse, responses={ status.HTTP_500_INTERNAL_SERVER_ERROR: {'model': ErrorResponse}, status.HTTP_400_BAD_REQUEST: {'model': ErrorResponse}, status.HTTP_429_TOO_MANY_REQUESTS: {'model': ErrorResponse}, }, ) @limiter.limit(API_RATE_LIMIT) async def cache_snoop( request: Request, user_agent: str = Header(None), nameserver: str = Query(..., description='Name server to test for cache snooping'), wordlist: str = Query('', description='Path to wordlist file for cache snooping'), ) -> Response: """ Endpoint for DNS cache snooping. Tests if the name server has cached records for domains in the wordlist. Rate limit is configurable via CLI argument (default: 5 requests per minute). """ # Basic user agent filtering if user_agent and ('gobuster' in user_agent or 'sqlmap' in user_agent or 'rustbuster' in user_agent): response = RedirectResponse(app.url_path_for('bot')) return response try: # Validate nameserver if not nameserver: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Name server is required') # Use default wordlist if none provided if not wordlist: wordlist = os.path.join(os.path.dirname(__file__), 'data', 'namelist.txt') else: # Only allow wordlists within the data directory data_dir = os.path.join(os.path.dirname(__file__), 'data') requested_path = os.path.normpath(os.path.join(data_dir, wordlist)) if not requested_path.startswith(data_dir): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid wordlist path') wordlist = requested_path if not os.path.exists(wordlist): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Wordlist file not found') res = DnsHelper('example.com') # Domain not critical for cache snooping # Perform cache snooping results = in_cache(res=res, dict_file=wordlist, ns=nameserver) cached_records = [] if results: for result in results: if isinstance(result, dict): name = result.get('name', '') record_type = result.get('type', 'A') address = result.get('address', '') cached_records.append(DnsRecord(name=name, type=record_type, address=address)) return JSONResponse({'nameserver': nameserver, 'cached_records': [record.model_dump() for record in cached_records]}) except HTTPException as e: raise e except Exception as e: error_traceback = traceback.format_exc() print(f'Error in cache_snoop endpoint: {e!s}\n{error_traceback}') return JSONResponse( { 'detail': f'An error occurred while processing your request: {e!s}', 'error_type': type(e).__name__, 'traceback': error_traceback if os.getenv('DEBUG') == '1' else None, }, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) class ZoneWalkResponse(BaseModel): domain: str = Field(..., description='Target domain') zone_walk_records: list[DnsRecord] = Field(default_factory=list, description='List of records found via DNSSEC zone walking') @app.get( '/zone_walk', response_model=ZoneWalkResponse, responses={ status.HTTP_500_INTERNAL_SERVER_ERROR: {'model': ErrorResponse}, status.HTTP_400_BAD_REQUEST: {'model': ErrorResponse}, status.HTTP_429_TOO_MANY_REQUESTS: {'model': ErrorResponse}, }, ) @limiter.limit(API_RATE_LIMIT) async def zone_walk( request: Request, user_agent: str = Header(None), domain: str = Query(..., description='Domain to perform DNSSEC zone walking on'), timeout: float = Query(3.0, description='Request timeout in seconds'), ) -> Response: """ Endpoint for DNSSEC zone walking. Performs DNSSEC zone walking using NSEC records to enumerate domain records. Rate limit is configurable via CLI argument (default: 5 requests per minute). """ # Basic user agent filtering if user_agent and ('gobuster' in user_agent or 'sqlmap' in user_agent or 'rustbuster' in user_agent): response = RedirectResponse(app.url_path_for('bot')) return response try: # Validate domain if not domain or len(domain) < 3: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Domain must be at least 3 characters long') res = DnsHelper(domain) # Perform DNSSEC zone walking results = ds_zone_walk(res=res, domain=domain, request_timeout=timeout) zone_walk_records = [] if results: for result in results: if isinstance(result, dict): name = result.get('name', '') record_type = result.get('type', 'Unknown') address = result.get('address', '') target = result.get('target', '') zone_walk_records.append(DnsRecord(name=name, type=record_type, address=address, target=target)) return JSONResponse({'domain': domain, 'zone_walk_records': [record.model_dump() for record in zone_walk_records]}) except HTTPException as e: raise e except Exception as e: error_traceback = traceback.format_exc() print(f'Error in zone_walk endpoint: {e!s}\n{error_traceback}') return JSONResponse( { 'detail': f'An error occurred while processing your request: {e!s}', 'error_type': type(e).__name__, 'traceback': error_traceback if os.getenv('DEBUG') == '1' else None, }, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) class BindVersionResponse(BaseModel): nameserver: str = Field(..., description='Name server tested') bind_version: str = Field(..., description='BIND version detected (if any)') version_detected: bool = Field(..., description='Whether BIND version was successfully detected') @app.get( '/bind_version', response_model=BindVersionResponse, responses={ status.HTTP_500_INTERNAL_SERVER_ERROR: {'model': ErrorResponse}, status.HTTP_400_BAD_REQUEST: {'model': ErrorResponse}, status.HTTP_429_TOO_MANY_REQUESTS: {'model': ErrorResponse}, }, ) @limiter.limit(API_RATE_LIMIT) async def bind_version( request: Request, user_agent: str = Header(None), nameserver: str = Query(..., description='Name server to check for BIND version'), timeout: float = Query(3.0, description='Request timeout in seconds'), ) -> Response: """ Endpoint for BIND version detection. Attempts to detect the BIND version of the specified name server. Rate limit is configurable via CLI argument (default: 5 requests per minute). """ # Basic user agent filtering if user_agent and ('gobuster' in user_agent or 'sqlmap' in user_agent or 'rustbuster' in user_agent): response = RedirectResponse(app.url_path_for('bot')) return response try: # Validate nameserver if not nameserver: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Name server is required') res = DnsHelper('example.com') # Check BIND version version_info = check_bindversion(res=res, ns_server=nameserver, timeout=timeout) version_detected = bool(version_info) bind_version = version_info if version_info else 'Version not detected' return JSONResponse({'nameserver': nameserver, 'bind_version': bind_version, 'version_detected': version_detected}) except HTTPException as e: raise e except Exception as e: error_traceback = traceback.format_exc() print(f'Error in bind_version endpoint: {e!s}\n{error_traceback}') return JSONResponse( { 'detail': f'An error occurred while processing your request: {e!s}', 'error_type': type(e).__name__, 'traceback': error_traceback if os.getenv('DEBUG') == '1' else None, }, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) class RecursiveResponse(BaseModel): nameserver: str = Field(..., description='Name server tested') recursive_enabled: bool = Field(..., description='Whether recursion is enabled') test_result: str = Field(..., description='Result of the recursion test') @app.get( '/recursive_check', response_model=RecursiveResponse, responses={ status.HTTP_500_INTERNAL_SERVER_ERROR: {'model': ErrorResponse}, status.HTTP_400_BAD_REQUEST: {'model': ErrorResponse}, status.HTTP_429_TOO_MANY_REQUESTS: {'model': ErrorResponse}, }, ) @limiter.limit(API_RATE_LIMIT) async def recursive_check( request: Request, user_agent: str = Header(None), nameserver: str = Query(..., description='Name server to check for recursion'), timeout: float = Query(3.0, description='Request timeout in seconds'), ) -> Response: """ Endpoint for DNS recursion check. Tests if the specified name server allows recursive queries. Rate limit is configurable via CLI argument (default: 5 requests per minute). """ # Basic user agent filtering if user_agent and ('gobuster' in user_agent or 'sqlmap' in user_agent or 'rustbuster' in user_agent): response = RedirectResponse(app.url_path_for('bot')) return response try: # Validate nameserver if not nameserver: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Name server is required') res = DnsHelper('example.com') # Check recursion recursion_result = check_recursive(res=res, ns_server=nameserver, timeout=timeout) recursive_enabled = bool(recursion_result) test_result = recursion_result if recursion_result else 'Recursion not enabled or test failed' return JSONResponse({'nameserver': nameserver, 'recursive_enabled': recursive_enabled, 'test_result': test_result}) except HTTPException as e: raise e except Exception as e: error_traceback = traceback.format_exc() print(f'Error in recursive_check endpoint: {e!s}\n{error_traceback}') return JSONResponse( { 'detail': f'An error occurred while processing your request: {e!s}', 'error_type': type(e).__name__, 'traceback': error_traceback if os.getenv('DEBUG') == '1' else None, }, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) class NxdomainHijackResponse(BaseModel): nameserver: str = Field(..., description='Name server tested') hijack_detected: bool = Field(..., description='Whether NXDOMAIN hijacking was detected') hijack_details: str = Field(..., description='Details about the hijacking test') @app.get( '/nxdomain_hijack', response_model=NxdomainHijackResponse, responses={ status.HTTP_500_INTERNAL_SERVER_ERROR: {'model': ErrorResponse}, status.HTTP_400_BAD_REQUEST: {'model': ErrorResponse}, status.HTTP_429_TOO_MANY_REQUESTS: {'model': ErrorResponse}, }, ) @limiter.limit(API_RATE_LIMIT) async def nxdomain_hijack( request: Request, user_agent: str = Header(None), nameserver: str = Query(..., description='Name server to check for NXDOMAIN hijacking'), ) -> Response: """ Endpoint for NXDOMAIN hijacking detection. Tests if the specified name server hijacks NXDOMAIN responses. Rate limit is configurable via CLI argument (default: 5 requests per minute). """ # Basic user agent filtering if user_agent and ('gobuster' in user_agent or 'sqlmap' in user_agent or 'rustbuster' in user_agent): response = RedirectResponse(app.url_path_for('bot')) return response try: # Validate nameserver if not nameserver: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Name server is required') hijack_result = check_nxdomain_hijack(nameserver=nameserver) hijack_detected = bool(hijack_result) hijack_details = hijack_result if hijack_result else 'No NXDOMAIN hijacking detected' return JSONResponse({'nameserver': nameserver, 'hijack_detected': hijack_detected, 'hijack_details': hijack_details}) except HTTPException as e: raise e except Exception as e: error_traceback = traceback.format_exc() print(f'Error in nxdomain_hijack endpoint: {e!s}\n{error_traceback}') return JSONResponse( { 'detail': f'An error occurred while processing your request: {e!s}', 'error_type': type(e).__name__, 'traceback': error_traceback if os.getenv('DEBUG') == '1' else None, }, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) dnsrecon-1.6.0/dnsrecon/api_cli.py000077500000000000000000000025151515065177500171400ustar00rootroot00000000000000#!/usr/bin/env python3 import argparse import os import uvicorn def main(): parser = argparse.ArgumentParser() parser.add_argument( '-H', '--host', default='127.0.0.1', help='IP address to listen on default is 127.0.0.1', ) parser.add_argument( '-p', '--port', default=5000, help='Port to bind the web server to, default is 5000', type=int, ) parser.add_argument( '-l', '--log-level', default='info', help='Set logging level, default is info but [critical|error|warning|info|debug|trace] can be set', ) parser.add_argument( '-r', '--reload', default=False, help='Enable automatic reload used during development of the api', action='store_true', ) parser.add_argument( '--rate-limit', default='5/minute', help='Set API rate limit (e.g., "10/minute", "100/hour"), default is 5/minute', ) args: argparse.Namespace = parser.parse_args() # Set environment variable for API rate limit os.environ['API_RATE_LIMIT'] = args.rate_limit uvicorn.run( 'dnsrecon.api:app', host=args.host, port=args.port, log_level=args.log_level, reload=args.reload, ) if __name__ == '__main__': main() dnsrecon-1.6.0/dnsrecon/cli.py000077500000000000000000002430251515065177500163120ustar00rootroot00000000000000#!/usr/bin/env python3 # DNSRecon # # Copyright (C) 2023 Carlos Perez # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; Applies version 2 of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. # See the GNU General Public License for more details.XML # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA import datetime import ipaddress import json import os import re import socket import sqlite3 import sys from argparse import ArgumentError, ArgumentParser, RawTextHelpFormatter from concurrent import futures from pathlib import Path from random import SystemRandom from string import ascii_letters, digits from typing import Any from xml.dom import minidom from xml.etree import ElementTree from xml.etree.ElementTree import Element import dns.exception import dns.flags import dns.message import dns.query import dns.rdata import dns.rdataclass import dns.rdatatype import dns.resolver import dns.reversename import dns.zone import httpx import netaddr from dns.dnssec import algorithm_to_text from loguru import logger from dnsrecon.lib.bingenum import * from dnsrecon.lib.crtenum import scrape_crtsh from dnsrecon.lib.dnshelper import DnsHelper from dnsrecon.lib.shodan import ShodanClientError, make_shodan_client from dnsrecon.lib.whois import * from dnsrecon.lib.yandexenum import * __version__ = '1.6.0' __author__ = 'Carlos Perez, Carlos_Perez@darkoperator.com' __name__ = 'cli' __doc__ = """ DNSRecon https://www.darkoperator.com by Carlos Perez, Darkoperator """ # Global Variables for Brute force Threads brtdata = [] CONFIG = {'disable_check_recursion': False, 'disable_check_bindversion': False} DATA_DIR = Path(__file__).parent / 'data' def process_range(arg): """ This function will take a string representation of a range for IPv4 or IPv6 in CIDR or Range format and return a list of individual IP addresses. """ ip_list = [] ranges_raw_list = list(set(arg.strip().split(','))) for entry in ranges_raw_list: try: if re.match(r'\S*/\S*', entry): # If CIDR, expand to individual IPs ip_list.extend(list(netaddr.IPNetwork(entry))) elif re.match(r'\S*-\S*', entry): # If range, expand to individual IPs start, end = entry.split('-') ip_list.extend(list(netaddr.iter_iprange(start, end))) else: logger.error(f'Range: {entry} provided is not valid') except Exception as e: logger.error(f'Error processing range: {entry} - {e}') return ip_list def process_spf_data(res, data): """ This function will take the text info of a TXT or SPF record, extract the IPv4, IPv6 addresses and ranges, a request process includes records and returns a list of IP Addresses for the records specified in the SPF Record. """ # Declare lists that will be used in the function. ipv4 = [] ipv6 = [] includes = [] ip_list = [] # check first if it is a sfp record if not re.search(r'v=spf', data): return # Parse the record for IPv4 Ranges, individual IPs and include TXT Records. ipv4.extend(re.findall(r'ip4:(\S*)', ''.join(data))) ipv6.extend(re.findall(r'ip6:(\S*)', ''.join(data))) # Create a list of IPNetwork objects. for ip in ipv4: for i in netaddr.IPNetwork(ip): ip_list.append(i) for ip in ipv6: for i in netaddr.IPNetwork(ip): ip_list.append(i) # Extract and process include values. includes.extend(re.findall(r'include:(\S*)', ''.join(data))) for inc_ranges in includes: for spr_rec in res.get_txt(inc_ranges): spf_data = process_spf_data(res, spr_rec[2]) if spf_data is not None: ip_list.extend(spf_data) # Return a list of IP Addresses return [str(ip) for ip in ip_list] def get_spf_networks(res, data, processed_includes=None): """ Extract ip4/ip6 netblocks from SPF/TXT data (including nested include values) without expanding them to individual IP addresses. Returns unique networks. """ if processed_includes is None: processed_includes = set() if not re.search(r'v=spf', data): return [] networks = [] seen_networks = set() record_data = ''.join(data) for network in re.findall(r'ip[46]:(\S+)', record_data): if network not in seen_networks: seen_networks.add(network) networks.append(network) for include_name in re.findall(r'include:(\S+)', record_data): if include_name in processed_includes: continue processed_includes.add(include_name) for txt_record in res.get_txt(include_name): if len(txt_record) < 3: continue for network in get_spf_networks(res, txt_record[2], processed_includes): if network not in seen_networks: seen_networks.add(network) networks.append(network) return networks def whois_netranges_to_cidrs(netranges): """ Convert Whois start/end net ranges into a de-duplicated list of CIDR strings. """ cidrs = [] seen_cidrs = set() for netrange in netranges: start = netrange.get('start') end = netrange.get('end') if not start or not end: continue try: for cidr in netaddr.iprange_to_cidrs(start, end): cidr_str = str(cidr) if cidr_str not in seen_cidrs: seen_cidrs.add(cidr_str) cidrs.append(cidr_str) except (netaddr.AddrFormatError, TypeError, ValueError) as e: logger.error(f'Could not convert Whois range {start}-{end} to CIDR: {e!s}') return cidrs def shodan_search_net(api_key: str, cidr: str, timeout: float = 5.0) -> list[dict[str, Any]]: """ Perform a Shodan host search for a single netblock and return match entries. """ try: return make_shodan_client(api_key, backend='httpx').search_net(cidr, timeout=timeout) except ShodanClientError as e: logger.error(f'{e!s}') return [] def shodan_active_record_matches(res, hostname: str, shodan_ip: str, cidr: str) -> bool: """ Actively validate a Shodan-discovered hostname by resolving it and ensuring the returned address still matches the Shodan IP (or at least remains in the same net). """ try: expected_ip = ipaddress.ip_address(shodan_ip) network = ipaddress.ip_network(cidr, strict=False) except ValueError: return False try: resolved_records = res.get_ip(hostname) except Exception: return False resolved_ips = [] for record in resolved_records: if len(record) < 3: continue if record[0] not in ['A', 'AAAA']: continue try: resolved_ips.append(ipaddress.ip_address(record[2])) except ValueError: continue if not resolved_ips: return False if expected_ip in resolved_ips: return True return any(ip in network for ip in resolved_ips) def shodan_expand_netranges( res, domain: str, cidrs: list[str], api_key: str, active_check: bool = False, timeout: float = 5.0, ): """ Query Shodan for hostnames/domains in the supplied netblocks and return records that fit dnsrecon's output format. Results are constrained to the target domain and its subdomains to keep the output aligned with dnsrecon's domain-centric flow. """ found_records = [] seen_records = set() for cidr in cidrs: logger.info(f'Querying Shodan for net:{cidr}') matches = shodan_search_net(api_key, cidr, timeout=timeout) for match in matches: shodan_ip = str(match.get('ip_str', '')).strip() if not shodan_ip: continue match_org = str(match.get('org', '')).strip() candidates = [] for key in ['hostnames', 'domains']: values = match.get(key, []) if not isinstance(values, list): continue for value in values: if isinstance(value, str): hostname = value.strip().rstrip('.') if hostname: candidates.append(hostname) for hostname in candidates: if hostname != domain and not hostname.endswith(f'.{domain}'): continue if active_check and not shodan_active_record_matches(res, hostname, shodan_ip, cidr): continue dedupe_key = (hostname, shodan_ip, cidr) if dedupe_key in seen_records: continue seen_records.add(dedupe_key) found_record = { 'domain': domain, 'type': 'A', 'name': hostname, 'address': shodan_ip, 'source': 'shodan', 'source_mode': 'active' if active_check else 'passive', 'netblock': cidr, } if match_org: found_record['org'] = match_org logger.info(f'\t SHODAN {hostname} {shodan_ip} ({cidr})') found_records.append(found_record) return found_records def expand_cidr(cidr_to_expand): """ Function to expand a given CIDR and return an Array of IP Addresses that form the range covered by the CIDR. """ return netaddr.IPNetwork(cidr_to_expand) def expand_range(startip, endip): """ Function to expand a given range and return an Array of IP Addresses that form the range. """ return netaddr.IPRange(startip, endip) def range2cidr(ip1, ip2): """ Function to return the maximum CIDR given a range of IP's """ r1 = netaddr.IPRange(ip1, ip2) return str(r1.cidrs()[-1]) def write_to_file(data, target_file): """ Function for writing returned data to a file """ with open(target_file, 'w') as fd: fd.write(data) def generate_testname(name_len, name_suffix): """ This function easily allows generating a testname to be used within the wildcard resolution and the NXDOMAIN hijacking checks """ testname = SystemRandom().sample(ascii_letters + digits, name_len) return ''.join(testname) + '.' + name_suffix def check_wildcard(res, domain_trg): """ Function for checking if Wildcard resolution is configured for a Domain """ testname = generate_testname(12, domain_trg) ips = res.get_a(testname) if not ips: return None wildcard_set = set() logger.debug('Wildcard resolution is enabled on this domain') for ip in ips: logger.debug(f'It is resolving to {ip[2]}') wildcard_set.add(ip[2]) logger.debug('All queries will resolve to this list of addresses!!') return wildcard_set def check_nxdomain_hijack(nameserver): """ Function for checking if a name server performs NXDOMAIN hijacking """ testname = generate_testname(20, 'com') res = dns.resolver.Resolver(configure=False) res.nameservers = [nameserver] res.timeout = 5.0 address = [] for record_type in ('A', 'AAAA'): try: answers = res.resolve(testname, record_type, tcp=True) except ( OSError, dns.resolver.NoNameservers, dns.resolver.NXDOMAIN, dns.exception.Timeout, dns.resolver.NoAnswer, dns.query.BadResponse, ): continue if answers: for ardata in answers.response.answer: for rdata in ardata: if rdata.rdtype == 5: target_ = rdata.target.to_text() if target_.endswith('.'): target_ = target_[:-1] address.append(target_) else: address.append(rdata.address) if not address: return False addresses = ', '.join(address) logger.error(f'Nameserver {nameserver} performs NXDOMAIN hijacking') logger.error(f'It resolves nonexistent domains to {addresses}') logger.error('This server has been removed from the name server list!') return True def brute_tlds(res, domain, verbose=False, thread_num=None): """ This function performs a check of a given domain for known TLD values. Prints and returns a dictionary of the results. """ total_tlds = [] try: tlds_list = httpx.get( 'https://raw.githubusercontent.com/publicsuffix/list/master/public_suffix_list.dat', timeout=30.0 ).text except Exception as e: tlds_list = '' logger.error(f'Error {e} retrieving TLDs list') for tld in tlds_list.split('\n'): if '/' not in tld.strip() and tld.strip() != '': total_tlds.append(tld.strip().lower().replace('*.', '')) # Let the user know how long it could take total_combinations = len(total_tlds) duration = time.strftime('%H:%M:%S', time.gmtime(total_combinations / 3)) logger.info(f'The operation could take up to: {duration}') found_tlds = [] try: with futures.ThreadPoolExecutor(max_workers=thread_num) as executor: future_results = {} for tld in total_tlds: full_domain = f'{domain}.{tld}' future_results[executor.submit(res.get_ip, full_domain)] = full_domain if verbose: logger.info(f'Queuing: {full_domain}') # Display results as soon as threads are completed for future in futures.as_completed(future_results): full_domain = future_results[future] try: res_ = future.result() if res_: for type_, name_, addr_ in res_: if type_ in ['A', 'AAAA']: logger.info(f'\t {type_} {name_} {addr_}') found_tlds.append({'type': type_, 'name': name_, 'address': addr_}) except Exception as e: logger.error(f'Error resolving domain {full_domain}: {e}') except Exception as e: logger.error(f'Error during brute force: {e}') logger.info(f'{len(found_tlds)} Records Found') return found_tlds def brute_srv(res, domain, verbose=False, thread_num=None): """ Brute-force most common SRV records for a given Domain. Returns an Array with records found. """ global brtdata brtdata = [] returned_records = [] srvrcd = [ '_gc._tcp.', '_kerberos._tcp.', '_kerberos._udp.', '_ldap._tcp.', '_test._tcp.', '_sips._tcp.', '_sip._udp.', '_sip._tcp.', '_aix._tcp.', '_aix._tcp.', '_finger._tcp.', '_ftp._tcp.', '_http._tcp.', '_nntp._tcp.', '_telnet._tcp.', '_whois._tcp.', '_h323cs._tcp.', '_h323cs._udp.', '_h323be._tcp.', '_h323be._udp.', '_h323ls._tcp.', '_https._tcp.', '_h323ls._udp.', '_sipinternal._tcp.', '_sipinternaltls._tcp.', '_sip._tls.', '_sipfederationtls._tcp.', '_jabber._tcp.', '_xmpp-server._tcp.', '_xmpp-client._tcp.', '_imap.tcp.', '_certificates._tcp.', '_crls._tcp.', '_pgpkeys._tcp.', '_pgprevokations._tcp.', '_cmp._tcp.', '_svcp._tcp.', '_crl._tcp.', '_ocsp._tcp.', '_PKIXREP._tcp.', '_smtp._tcp.', '_hkp._tcp.', '_hkps._tcp.', '_jabber._udp.', '_xmpp-server._udp.', '_xmpp-client._udp.', '_jabber-client._tcp.', '_jabber-client._udp.', '_kerberos.tcp.dc._msdcs.', '_ldap._tcp.ForestDNSZones.', '_ldap._tcp.dc._msdcs.', '_ldap._tcp.pdc._msdcs.', '_ldap._tcp.gc._msdcs.', '_kerberos._tcp.dc._msdcs.', '_kpasswd._tcp.', '_kpasswd._udp.', '_imap._tcp.', '_imaps._tcp.', '_submission._tcp.', '_pop3._tcp.', '_pop3s._tcp.', '_caldav._tcp.', '_caldavs._tcp.', '_carddav._tcp.', '_carddavs._tcp.', '_x-puppet._tcp.', '_x-puppet-ca._tcp.', '_autodiscover._tcp.', ] try: with futures.ThreadPoolExecutor(max_workers=thread_num) as executor: if verbose: for srvtype in srvrcd: srvtype_domain = srvtype + domain logger.info(f'Trying {srvtype_domain}...') future_results = {executor.submit(res.get_srv, srvtype + domain): srvtype for srvtype in srvrcd} # Display logs as soon as a thread is finished for future in futures.as_completed(future_results): result = future.result() for type_, name_, target_, addr_, port_, priority_ in result: returned_records.append( { 'type': type_, 'name': name_, 'target': target_, 'address': addr_, 'port': port_, } ) logger.info(f'\t {type_} {name_} {target_} {addr_} {port_}') except Exception as e: logger.error(e) if len(returned_records) > 0: logger.info(f'{len(returned_records)} Records Found') else: logger.error(f'No SRV Records Found for {domain}') return returned_records def brute_reverse(res, ip_list, verbose=False, thread_num=None): """ Reverse look-up brute force for given CIDR example 192.168.1.1/24. Returns an Array of found records. """ global brtdata brtdata = [] returned_records = [] # Ensure that ip_list contains individual IPs instead of IPNetwork or IPRange objects. expanded_ips = [] for entry in ip_list: if isinstance(entry, netaddr.IPNetwork) or isinstance(entry, netaddr.IPRange): expanded_ips.extend(list(entry)) else: expanded_ips.append(entry) start_ip = expanded_ips[0] end_ip = expanded_ips[-1] logger.info(f'Performing Reverse Lookup from {start_ip} to {end_ip}') ip_group_size = 255 for ip_group in [expanded_ips[j : j + ip_group_size] for j in range(0, len(expanded_ips), ip_group_size)]: try: if verbose: for ip in ip_group: logger.info(f'Trying {ip}') with futures.ThreadPoolExecutor(max_workers=thread_num) as executor: # Submit each IP for reverse lookup, converting to string as necessary future_results = {executor.submit(res.get_ptr, str(ip)): ip for ip in ip_group} # Display logs as soon as a thread is finished for future in futures.as_completed(future_results): ip_address = future_results[future] try: res_ = future.result() if res_: for type_, name_, addr_ in res_: returned_records.append({'type': type_, 'name': name_, 'address': addr_}) logger.info(f'\t {type_} {name_} {addr_}') except Exception as e: logger.error(f'Error resolving IP {ip_address}: {e}') except Exception as e: logger.error(f'Error with thread executor: {e}') logger.info(f'{len(returned_records)} Records Found') return returned_records def brute_domain( res, dictfile, dom, filter_=None, verbose=False, ignore_wildcard=False, thread_num=None, ): """ Main Function for domain brute forcing """ global brtdata brtdata = [] # Check if wildcard resolution is enabled wildcard_set = check_wildcard(res, dom) if wildcard_set and not ignore_wildcard: logger.info('Do you wish to continue? [Y/n]') i = input().lower().strip() if i not in ['y', 'yes']: logger.error('Domain bruteforcing aborted.') return None found_hosts = [] dictfile = os.path.abspath(os.path.expanduser(dictfile)) # Check if the Dictionary file exists if not os.path.isfile(dictfile): logger.error(f'Dictionary file not found: {dictfile}') return [] try: with open(dictfile) as fd: targets = [f'{line.strip()}.{dom.strip()}' for line in fd] if verbose: for target in targets: logger.info(f'Trying {target}') except OSError as e: logger.error(f'Failed to read dictionary file {dictfile}: {e}') return [] with futures.ThreadPoolExecutor(max_workers=thread_num) as executor: future_results = {executor.submit(res.get_ip, target): target for target in targets} # Display logs as soon as a thread is finished for future in futures.as_completed(future_results): result = future.result() for type_, name_, address_or_target_ in result: print_and_append = False found_dict = {'type': type_, 'name': name_} if type_ in ['A', 'AAAA']: # Filter Records if filtering was enabled if filter_: if not wildcard_set or address_or_target_ not in wildcard_set: print_and_append = True found_dict['address'] = address_or_target_ else: print_and_append = True found_dict['address'] = address_or_target_ elif type_ == 'CNAME': print_and_append = True found_dict['target'] = address_or_target_ if print_and_append: logger.info(f'\t {type_} {name_} {address_or_target_}') found_hosts.append(found_dict) brtdata.append(res) logger.info(f'{len(found_hosts)} Records Found') return found_hosts def in_cache(res, dict_file, ns): """ Function for Cache Snooping, it will check a given NS server for a specific type of records for a given domain are in its cache. """ found_records = [] with open(dict_file) as f: for zone in f: dom_to_query = zone.strip() query = dns.message.make_query(dom_to_query, dns.rdatatype.A, dns.rdataclass.IN) query.flags ^= dns.flags.RD answer = res.query(query, ns) for an in answer.answer: for rcd in an: if rcd.rdtype not in [1, 5]: continue found_record = {'name': an.name, 'ttl': an.ttl} status = f'\tName: {an.name} TTL: {an.ttl} ' if rcd.rdtype == 1: found_record['type'] = 'A' found_record['address'] = rcd.address status += f'Address: {rcd.address} Type: A' elif rcd.rdtype == 5: found_record['type'] = 'CNAME' found_record['target'] = rcd.target status += f'Target: {rcd.target} Type: CNAME' logger.info(status) found_records.append(found_record) return found_records def se_result_process(res, domain, se_entries): """ This function processes the results returned from a Search Engine and does an A and AAAA query for the IP of the found host. Prints and returns a dictionary with all the results found. """ if not se_entries: return None resolved_se_entries = [] for se_entry in se_entries: for type_, name_, address_or_target_ in res.get_ip(se_entry): if type_ not in ['A', 'CNAME']: continue logger.info(f'\t {type_} {name_} {address_or_target_}') resolved_se_entry = {'type': type_, 'name': name_, 'domain': domain} if type_ == 'A': resolved_se_entry['address'] = address_or_target_ elif type_ == 'CNAME': resolved_se_entry['target'] = address_or_target_ resolved_se_entries.append(resolved_se_entry) logger.info(f'{len(resolved_se_entries)} Records Found') return resolved_se_entries def get_whois_nets_iplist(ip_list): """ This function will perform whois queries against a list of IP's and extract the net ranges and if available the organization list of each and remover any duplicate entries. """ seen = {} idfun = repr found_nets = [] for ip in ip_list: if ip != 'no_ip': # Find appropriate Whois Server for the IP whois_server = get_whois(ip) # If we get a Whois server Process get the whois and process. if whois_server: whois_data = whois(ip, whois_server) arin_style = re.search('NetRange', whois_data) ripe_apic_style = re.search('netname', whois_data) if arin_style or ripe_apic_style: net = get_whois_nets(whois_data) if net: for network in net: org = get_whois_orgname(whois_data) found_nets.append( { 'start': network[0], 'end': network[1], 'orgname': ''.join(org), } ) else: for line in whois_data.splitlines(): recordentrie = re.match(r'^(.*)\s\S*-\w*\s\S*\s(\S*\s-\s\S*)', line) if recordentrie: org = recordentrie.group(1) net = get_whois_nets(recordentrie.group(2)) for network in net: found_nets.append( { 'start': network[0], 'end': network[1], 'orgname': ''.join(org), } ) # Remove Duplicates return [seen.setdefault(idfun(e), e) for e in found_nets if idfun(e) not in seen] def whois_ips(res, ip_list, whois_ranges=None): """ This function will process the results of the whois lookups and present the user with the list of net ranges found and ask the user if he wishes to perform a reverse lookup on any of the ranges or all the ranges. """ found_records = [] logger.info('Performing Whois lookup against records found.') list_whois = whois_ranges if whois_ranges is not None else get_whois_nets_iplist(unique(ip_list)) if len(list_whois) > 0: logger.info('The following IP Ranges were found:') for i in range(len(list_whois)): logger.info( '\t {} {}-{} {}'.format( str(i) + ')', list_whois[i]['start'], list_whois[i]['end'], list_whois[i]['orgname'], ) ) logger.info('What Range do you wish to do a Reverse Lookup for?') logger.info('number, comma separated list, a for all or n for none') val = sys.stdin.readline()[:-1] answer = str(val).split(',') if 'a' in answer: for i in range(len(list_whois)): logger.info('Performing Reverse Lookup of range {}-{}'.format(list_whois[i]['start'], list_whois[i]['end'])) found_records.append(brute_reverse(res, expand_range(list_whois[i]['start'], list_whois[i]['end']))) elif 'n' in answer: logger.info('No Reverse Lookups will be performed.') else: for a in answer: net_selected = list_whois[int(a)] logger.info(net_selected['orgname']) logger.info('Performing Reverse Lookup of range {}-{}'.format(net_selected['start'], net_selected['end'])) found_records.append(brute_reverse(res, expand_range(net_selected['start'], net_selected['end']))) else: logger.error('No IP Ranges were found in the Whois query results') return found_records def prettify(elem): """ Return a pretty-printed XML string for the Element. """ rough_string = ElementTree.tostring(elem, 'utf-8') reparsed = minidom.parseString(rough_string) return reparsed.toprettyxml(indent=' ') def dns_record_from_dict(record_dict_list, scan_info, domains): """ Saves DNS Records to XML Given a list of dictionaries, each representing a record to be saved, returns the XML Document formatted. """ xml_doc = Element('dnsrecon') # Add scan information scanelem = Element('scaninfo') scanelem.attrib['arguments'] = scan_info[0] scanelem.attrib['time'] = scan_info[1] xml_doc.append(scanelem) for domain in domains: domelem = Element('domain') domelem.attrib['domain_name'] = domain xml_doc.append(domelem) # Filter records for the current domain domain_records = [r for r in record_dict_list if r.get('domain') == domain] for r in domain_records: elem = Element('record') for k, v in r.items(): if k != 'domain': # Domain already represented by domelem elem.attrib[k] = str(v) domelem.append(elem) return prettify(xml_doc) def create_db(db): """ This function will create the specified database if not present, and it will create the table needed for storing the data returned by the modules. """ # Connect to the DB con = sqlite3.connect(db) # Create SQL Queries to be used in the script make_table = """CREATE TABLE data ( serial integer Primary Key Autoincrement, domain TEXT(256), type TEXT(8), name TEXT(32), address TEXT(32), target TEXT(32), port TEXT(8), text TEXT(256), zt_dns TEXT(32) )""" # Set the cursor for connection con.isolation_level = None cur = con.cursor() # Connect and create table cur.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='data';") if cur.fetchone() is None: cur.execute(make_table) con.commit() else: pass def make_csv(data): csv_data = 'Domain,Type,Name,Address,Target,Port,String\n' for record_tmp in data: record = record_tmp # make sure that we are working with a dictionary. if not isinstance(record, dict): # the representation of data[i] is a list of one dictionary # we want to exploit this dictionary record = record_tmp[0] domain = record.get('domain', '') type_ = record['type'].upper() csv_data += f'{domain},{type_},' if type_ in ['PTR', 'A', 'AAAA', 'NS', 'SOA', 'MX']: if type_ in ['PTR', 'A', 'AAAA']: csv_data += record.get('name', '') elif type_ == 'NS': csv_data += record.get('target', '') elif type_ == 'SOA': csv_data += record.get('mname', '') elif type_ == 'MX': csv_data += record.get('exchange', '') csv_data += ',' + record.get('address', '') + (',' * 3) + '\n' elif type_ in ['TXT', 'SPF']: if 'zone_server' not in record: if type_ == 'SPF': csv_data += record.get('domain', '') else: csv_data += record.get('name', '') csv_data += (',' * 4) + f"'{record.get('strings', '')}'\n" elif type_ == 'SRV': items = [ record.get('name', ''), record.get('address', ''), record.get('target', ''), record.get('port', ''), ] csv_data += ','.join(items) + ',\n' elif type_ == 'CNAME': csv_data += record.get('name', '') + (',' * 2) if 'target' in record: csv_data += record['target'] csv_data += (',' * 2) + '\n' else: # Handle not common records del record['type'] s = '; '.join([f'{k}={v}' for k, v in record.items()]) csv_data += (',' * 4) + f"'{s}'\n" return csv_data def write_json(jsonfile, data, scan_info): """ Function to write DNS Records SOA, PTR, NS, A, AAAA, MX, TXT, SPF and SRV to JSON file. """ scaninfo = {'type': 'ScanInfo', 'arguments': scan_info[0], 'date': scan_info[1]} data.insert(0, scaninfo) json_data = json.dumps(data, sort_keys=True, indent=4, separators=(',', ': ')) write_to_file(json_data, jsonfile) def write_db(db, data): """ Function to write DNS Records SOA, PTR, NS, A, AAAA, MX, TXT, SPF and SRV to DB. """ con = sqlite3.connect(db) # Set the cursor for connection con.isolation_level = None cur = con.cursor() # Normalize the dictionary data for n in data: if re.match(r'PTR|^[A]$|AAAA', n['type']): query = ( 'insert into data( domain, type, name, address ) ' + 'values( "{domain}", "{type}", "{name}", "{address}" )'.format(**n) ) elif re.match(r'NS$', n['type']): query = ( 'insert into data( domain, type, name, address ) ' + 'values( "{domain}", "{type}", "{target}", "{address}" )'.format(**n) ) elif re.match(r'SOA', n['type']): query = ( 'insert into data( domain, type, name, address ) ' + 'values( "{domain}", "{type}", "{mname}", "{address}" )'.format(**n) ) elif re.match(r'MX', n['type']): query = ( 'insert into data( domain, type, name, address ) ' + 'values( "{domain}", "{type}", "{exchange}", "{address}" )'.format(**n) ) elif re.match(r'TXT', n['type']): query = 'insert into data( domain, type, text) ' + 'values( "{domain}", "{type}", "{strings}" )'.format(**n) elif re.match(r'SPF', n['type']): query = 'insert into data( domain, type, text) ' + 'values( "{domain}", "{type}", "{strings}" )'.format(**n) elif re.match(r'SRV', n['type']): query = ( 'insert into data( domain, type, name, target, address, port ) ' + 'values( "{domain}", "{type}", "{name}", "{target}", "{address}", "{port}" )'.format(**n) ) elif re.match(r'CNAME', n['type']): query = ( 'insert into data( domain, type, name, target ) ' + 'values( "{domain}", "{type}", "{name}", "{target}" )'.format(**n) ) elif re.match(r'CAA', n['type']): # Preserve some of the previous behavior when writing CAA records to the database would fall into the else clause text = ','.join([f'{key}={value}' for key, value in n.items() if key != 'type']) query = "insert into data( domain, type, name, target, address, text ) values ('{domain}', '{type}', '{name}', '{target}', '{address}', '{text}')".format( **n, text=text ) else: # Handle not common records t = n['type'] del n['type'] record_data = ''.join([f'{key}={value},' for key, value in n.items()]) records = [t, record_data] query = 'insert into data( domain, type, text ) values ("%(domain)", \'' + records[0] + "', '" + records[1] + "')" # Execute Query and commit cur.execute(query) con.commit() def get_nsec_type(domain, res): target = '0.' + domain answer = get_a_answer(res, target, res._res.nameservers[0], res._res.timeout) for a in answer.authority: if a.rdtype == 50: return 'NSEC3' elif a.rdtype == 47: return 'NSEC' def dns_sec_check(domain, res): """ Check if a zone is configured for DNSSEC and if so is NSEC or NSEC3 is used. """ try: answer = res.resolve(domain, 'DNSKEY', res._res.nameservers[0]) logger.info(f'DNSSEC is configured for {domain}') nsectype = get_nsec_type(domain, res) logger.info('DNSKEYs:') for rdata in answer: if rdata.flags == 256: key_type = 'ZSK' if rdata.flags == 257: key_type = 'KSk' logger.info(f'\t{nsectype} {key_type} {algorithm_to_text(rdata.algorithm)} {dns.rdata._hexify(rdata.key)}') except dns.resolver.NXDOMAIN: logger.error(f'Could not resolve domain: {domain}') sys.exit(1) except dns.resolver.NoNameservers: logger.error(f'All nameservers failed to answer the DNSSEC query for {domain}') except dns.exception.Timeout: logger.error('A timeout error occurred please make sure you can reach the target DNS Servers') logger.error(f'directly and requests are not being filtered. Increase the timeout from {res._res.timeout} second') logger.error('to a higher number with --lifetime