├── .coveragerc ├── .dockerignore ├── .gitattributes ├── .github ├── dependabot.yml └── workflows │ ├── changelog_reminder.yml │ ├── python_lint_test.yml │ └── python_pypi_publish.yml ├── .gitignore ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── README.md ├── certleak ├── __init__.py ├── actions │ ├── __init__.py │ ├── basicaction.py │ ├── databaseaction.py │ ├── logaction.py │ ├── savefileaction.py │ ├── savejsonaction.py │ ├── telegramaction.py │ ├── tests │ │ ├── __init__.py │ │ ├── basicaction_test.py │ │ ├── savefileaction_test.py │ │ ├── savejsonaction_test.py │ │ └── webhookaction_test.py │ └── webhookaction.py ├── analyzers │ ├── README.md │ ├── __init__.py │ ├── alwaystrueanalyzer.py │ ├── authoritykeyidanalyzer.py │ ├── basicanalyzer.py │ ├── cafingerprintanalyzer.py │ ├── dnstwistanalyzer.py │ ├── domainregexanalyzer.py │ ├── fulldomainanalyzer.py │ ├── letsencryptanalyzer.py │ ├── precertanalyzer.py │ ├── regexdomainanalyzer.py │ ├── subdomainanalyzer.py │ ├── tests │ │ ├── __init__.py │ │ ├── alwaystrueanalyzer_test.py │ │ ├── authoritykeyidanalyzer_test.py │ │ ├── basicanalyzer_test.py │ │ ├── domainregexanalyzer_test.py │ │ ├── fulldomainanalyzer_test.py │ │ ├── mergedanalyzer_test.py │ │ ├── precertanalyzer_test.py │ │ ├── subdomainanalyzer_test.py │ │ ├── wildcardcertanalyzer_test.py │ │ └── x509analyzer_test.py │ ├── tldanalyzer.py │ ├── wildcardcertanalyzer.py │ └── x509analyzer.py ├── core │ ├── __init__.py │ ├── actionhandler.py │ ├── analyzerhandler.py │ ├── certleak.py │ ├── certstreamdata │ │ ├── __init__.py │ │ ├── certstreamobject.py │ │ ├── chain.py │ │ ├── extensions.py │ │ ├── leafcert.py │ │ ├── message.py │ │ ├── subject.py │ │ └── update.py │ └── certstreamwrapper.py ├── database │ ├── __init__.py │ ├── abstractdb.py │ └── sqlitedb.py ├── errors │ ├── __init__.py │ ├── errors.py │ └── tests │ │ ├── __init__.py │ │ └── errors_test.py ├── util │ ├── __init__.py │ ├── dictwrapper.py │ ├── listify.py │ ├── request.py │ ├── templatingengine.py │ ├── tests │ │ ├── __init__.py │ │ ├── dictwrapper_test.py │ │ ├── listify_test.py │ │ ├── templatingengine_test.py │ │ └── test.json │ └── threadingutils.py └── version.py ├── docs └── certleak_logo.png ├── examples └── example.py ├── pyproject.toml ├── requirements.txt └── uv.lock /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = *_test.py 3 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .gitignore 2 | .gitattributes 3 | .github 4 | .coveragerc 5 | /.git 6 | /.venv 7 | /venv 8 | /examples 9 | LICENSE 10 | **/*_test.py 11 | /docs 12 | __pycache__/ 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "04:00" 8 | open-pull-requests-limit: 10 9 | target-branch: dev 10 | -------------------------------------------------------------------------------- /.github/workflows/changelog_reminder.yml: -------------------------------------------------------------------------------- 1 | name: Changelog Reminder 2 | 3 | on: pull_request 4 | 5 | jobs: 6 | remind: 7 | name: Changelog Reminder 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v3 12 | - name: Changelog Reminder 13 | uses: peterjgrainger/action-changelog-reminder@v1 14 | with: 15 | changelog_regex: '/CHANGELOG.md' 16 | customPrMessage: "We couldn't find any modification to the CHANGELOG.md file. If your changes are not suitable for the changelog, that's fine. Otherwise please add them to the changelog!" 17 | env: 18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 19 | -------------------------------------------------------------------------------- /.github/workflows/python_lint_test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Run tests and lint 5 | 6 | on: 7 | push: 8 | branches: [ main, dev ] 9 | pull_request: 10 | branches: [ main ] 11 | 12 | jobs: 13 | test-and-lint: 14 | 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | python-version: [ '3.9', '3.10', '3.11' ] 19 | 20 | steps: 21 | - uses: actions/checkout@v3 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v4 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | python -m pip install pytest coveralls wheel 30 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 31 | - name: Lint with ruff 32 | uses: astral-sh/ruff-action@v2 33 | with: 34 | src: "./certleak" 35 | - name: Test with pytest 36 | run: | 37 | coverage run --omit='*/virtualenv/*,*/site-packages/*' -m pytest 38 | - name: Publish coverage 39 | env: 40 | COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} 41 | run: | 42 | coveralls 43 | -------------------------------------------------------------------------------- /.github/workflows/python_pypi_publish.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [ created ] 9 | 10 | jobs: 11 | deploy: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | - name: Set up Python 18 | uses: actions/setup-python@v4 19 | with: 20 | python-version: '3.11' 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install build wheel twine pytest 25 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 26 | - name: Lint with ruff 27 | uses: astral-sh/ruff-action@v2 28 | with: 29 | src: "./certleak" 30 | - name: Test with pytest 31 | run: | 32 | pytest 33 | - name: Build and publish 34 | env: 35 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 36 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 37 | run: | 38 | python -m build 39 | twine upload dist/* 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # PyCharm 107 | .idea 108 | venv 109 | .vscode/* 110 | 111 | *.db 112 | 113 | start.py -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [unreleased] 8 | ### Added 9 | ### Changed 10 | ### Fixed 11 | 12 | ## [0.1.1] - 2025-05-25 13 | 14 | ### Changed 15 | 16 | - Add more logging when stopping the app 17 | 18 | ### Fixed 19 | 20 | - Fix for on_error and on_close callbacks in WebSocketClient 21 | - Remove wrong call to idle() in example.py 22 | 23 | ## [0.1.0] - 2024-12-20 24 | 25 | ### Added 26 | 27 | - Dockerfile for easy deployment 28 | - New AuthorityKeyIDAnalyzer for matching certificates based on CA 29 | 30 | ### Fixed 31 | 32 | - Use right module for DNStwister 33 | - Properly handle empty updates 34 | 35 | ### Changed 36 | 37 | - Switch to pyproject.toml for configuration 38 | - Improve code based on ruff linting suggestions 39 | 40 | ## [0.0.1] - 2021-05-13 41 | 42 | Initial release 43 | 44 | [unreleased]: https://github.com/d-Rickyy-b/certleak/compare/v0.1.1...HEAD 45 | [0.1.1]: https://github.com/d-Rickyy-b/certleak/compare/v0.1.0...v0.1.1 46 | [0.1.0]: https://github.com/d-Rickyy-b/certleak/compare/v0.0.1...v0.1.0 47 | [0.0.1]: https://github.com/d-Rickyy-b/certleak/tree/v0.0.1 48 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3-slim 2 | 3 | LABEL maintainer="d-Rickyy-b " 4 | LABEL site="https://github.com/d-Rickyy-b/certleak" 5 | 6 | # Create bot & log directories 7 | RUN mkdir -p /certleak/logs && mkdir -p /certleak/src 8 | 9 | # Copy the source code to the container 10 | COPY . /certleak/src 11 | 12 | RUN pip3 install --upgrade --no-cache pip \ 13 | && pip3 install --no-cache setuptools wheel \ 14 | && cd /certleak/src && python3 /certleak/src/setup.py install \ 15 | && rm -rf /certleak/src/build 16 | 17 | WORKDIR /certleak 18 | 19 | # Start the main file when the container is started 20 | ENTRYPOINT ["python3", "/certleak/main.py"] 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 d-Rickyy-b 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![certleak logo created by https://t.me/AboutTheDot](https://raw.githubusercontent.com/d-Rickyy-b/certleak/main/docs/certleak_logo.png)](https://github.com/d-Rickyy-b/certleak) 2 | 3 | # certleak - Cert-Monitoring Python Framework 4 | 5 | [![Run tests and lint](https://github.com/d-Rickyy-b/certleak/workflows/Run%20tests%20and%20lint/badge.svg)](https://github.com/d-Rickyy-b/certleak/actions?query=workflow%3A%22Run+tests+and+lint%22) 6 | [![PyPI version](https://badge.fury.io/py/certleak.svg)](https://pypi.org/project/certleak/) 7 | [![Coverage Status](https://coveralls.io/repos/github/d-Rickyy-b/certleak/badge.svg?branch=main)](https://coveralls.io/github/d-Rickyy-b/certleak?branch=main) 8 | 9 | Certleak is a tool to monitor and analyze TLS certificates as they are issued. 10 | It is heavily inspired by [Phishing Catcher](https://github.com/x0rz/phishing_catcher) by [x0rz](https://twitter.com/x0rz). 11 | 12 | It utilizes the [Certificate Transparency Network](https://www.certificate-transparency.org/what-is-ct), which is an ecosystem for publicly monitoring issuance of TLS certificates. 13 | 14 | A regular use case of this tool is to find phishing domains before they are actively used in the wild. 15 | 16 | Instead of querying the single transparency log servers individually, certleak uses [certstream](https://certstream.calidog.io/) for analyzing certificates in real time. 17 | To do that, it uses about 2600-3000 kbit/s of bandwidth. 18 | Since certleak uses certstream, it only enables you to analyze live data. 19 | There is no way to use this tool to analyze certificates that have been issued in the past or while being offline. 20 | 21 | ## Extensibility 22 | 23 | Creating new analyzers or actions is as easy as creating a new python file. 24 | Certleak is built with extensibility in mind. 25 | Check the [analyzer docs](https://github.com/d-Rickyy-b/certleak/tree/main/certleak/analyzers/README.md) as well as the [actions docs](https://github.com/d-Rickyy-b/certleak/tree/main/certleak/actions/README.md). 26 | 27 | ## Installation 28 | 29 | Simply use pip to install this tool. 30 | 31 | ```bash 32 | pip install certleak 33 | ``` 34 | 35 | ## Usage 36 | 37 | After downloading and installing the package, you only need to create a small python script in which you import certleak and set up the analyzers and the belonging actions. 38 | Below you'll find an example configuration. Keep in mind that it's fully up to you what analyzers you want to add and which actions you want to be executed. 39 | 40 | In general the workflow is as follows: `New Certificate -> Analyzer matches -> Actions are executed` 41 | 42 | ```python 43 | import logging 44 | from pathlib import Path 45 | 46 | from certleak import CertLeak 47 | from certleak.actions import LogAction, DatabaseAction 48 | from certleak.analyzers import (FullDomainAnalyzer, TLDAnalyzer, WildcardCertAnalyzer, X509Analyzer, LetsEncryptAnalyzer, 49 | RegexDomainAnalyzer, DNStwistAnalyzer) 50 | from certleak.database import SQLiteDB 51 | 52 | certleak = CertLeak() 53 | 54 | # Set up database 55 | path = Path.cwd().absolute() / "phish.db" 56 | db = SQLiteDB(str(path)) 57 | 58 | # Set up actions 59 | db_action = DatabaseAction(db) 60 | logaction = LogAction(level=logging.INFO, template="${analyzer_name} found: ${leaf_cert.subject.CN} () - ${leaf_cert.all_domains}") 61 | 62 | # Set up analyzers 63 | xyz_tld_analyzer = TLDAnalyzer(logaction, ["xyz"], blacklist="acmetestbykeychestdotnet") & X509Analyzer() 64 | phishing_analyzer = FullDomainAnalyzer([db_action, logaction], ["paypal", "amazon"]) 65 | regex_analyzer = RegexDomainAnalyzer([db_action, logaction], r"([^.]*-)?pay[-_]?pa[l1i][-.].*") 66 | 67 | wildcard_analyzer = WildcardCertAnalyzer([db_action, logaction]) & X509Analyzer() 68 | letsencrypt_analyzer = LetsEncryptAnalyzer(db_action) & X509Analyzer() 69 | 70 | # Set up DNStwist Analyzer - generates a list of potential phishing domains at start. Based on the DNStwist module. 71 | dns = DNStwistAnalyzer(logaction, "paypal.com") & X509Analyzer() 72 | 73 | certleak.add_analyzer(dns) 74 | certleak.add_analyzer(xyz_tld_analyzer) 75 | certleak.add_analyzer(phishing_analyzer) 76 | certleak.add_analyzer(regex_analyzer) 77 | certleak.add_analyzer(wildcard_analyzer) 78 | certleak.add_analyzer(letsencrypt_analyzer) 79 | 80 | certleak.start() 81 | ``` 82 | 83 | You can find [full example files](https://github.com/d-Rickyy-b/certleak/tree/main/certleak/examples) in this repo as well. 84 | 85 | ### License 86 | 87 | This tool is released under the MIT license. 88 | 89 | If you found this tool helpful and want to support me, drop me a coffee at the link below. 90 | 91 | [![Buy me a coffee](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://buymeacoffee.com/0rickyy0) 92 | -------------------------------------------------------------------------------- /certleak/__init__.py: -------------------------------------------------------------------------------- 1 | # Do not mess with the order of the imports 2 | # Otherwise there will be circular imports -> bad 3 | 4 | from .core.certleak import CertLeak 5 | from .version import __version__ 6 | 7 | __author__ = "d-Rickyy-b (certleak@rico-j.de)" 8 | 9 | __all__ = ["CertLeak", "__version__"] 10 | -------------------------------------------------------------------------------- /certleak/actions/__init__.py: -------------------------------------------------------------------------------- 1 | from .basicaction import BasicAction 2 | from .databaseaction import DatabaseAction 3 | from .logaction import LogAction 4 | from .savefileaction import SaveFileAction 5 | from .savejsonaction import SaveJSONAction 6 | 7 | __all__ = ["BasicAction", "DatabaseAction", "LogAction", "SaveFileAction", "SaveJSONAction"] 8 | -------------------------------------------------------------------------------- /certleak/actions/basicaction.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | 4 | class BasicAction: 5 | """Base class for actions which can be performed on updates.""" 6 | 7 | name = "BasicAction" 8 | 9 | def __init__(self): 10 | self.logger = logging.getLogger(__name__) 11 | 12 | def perform(self, update, analyzer_name=None, matches=None): 13 | """Perform the action on the passed update.""" 14 | raise NotImplementedError 15 | -------------------------------------------------------------------------------- /certleak/actions/databaseaction.py: -------------------------------------------------------------------------------- 1 | from .basicaction import BasicAction 2 | 3 | 4 | class DatabaseAction(BasicAction): 5 | """Action to save a cert update to a database.""" 6 | 7 | name = "DatabaseAction" 8 | 9 | def __init__(self, database): 10 | super().__init__() 11 | self.database = database 12 | 13 | def perform(self, update, analyzer_name=None, matches=None): 14 | """Store an incoming cert update in the database.""" 15 | self.database.store(update) 16 | -------------------------------------------------------------------------------- /certleak/actions/logaction.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from certleak.util import TemplatingEngine 4 | 5 | from .basicaction import BasicAction 6 | 7 | 8 | class LogAction(BasicAction): 9 | """Action to log a cert update to console.""" 10 | 11 | name = "LogAction" 12 | 13 | def __init__(self, level, template=None): 14 | super().__init__() 15 | self.logger = logging.getLogger(__name__) 16 | self.level = level 17 | self.template = template 18 | 19 | def perform(self, update, analyzer_name=None, matches=None): 20 | text = TemplatingEngine.fill_template(update, analyzer_name, template_string=self.template, matches=matches) 21 | self.logger.log(self.level, text) 22 | -------------------------------------------------------------------------------- /certleak/actions/savefileaction.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | 3 | from certleak.util import TemplatingEngine 4 | 5 | from .basicaction import BasicAction 6 | 7 | 8 | class SaveFileAction(BasicAction): 9 | """Action to save each certificate update as a file named '.txt'.""" 10 | 11 | name = "SaveFileAction" 12 | 13 | def __init__(self, path, file_ending=".txt", template=None): 14 | """Action to save each update as a file named '.txt'. 15 | 16 | If you want to store metadata within the file, use template strings (https://github.com/d-Rickyy-b/certleak/wiki/Templating-in-actions). 17 | 18 | :param path: The directory in which the file(s) should be stored 19 | :param template: A template string describing how the update variables should be filled in 20 | """ 21 | super().__init__() 22 | self.path = pathlib.Path(path) 23 | self.file_ending = file_ending 24 | self.template = template or "${data}" 25 | 26 | @staticmethod 27 | def _remove_prefix(input_string, prefix): 28 | """Remove a prefix from a certain string (e.g. remove '.' as prefix from '.txt').""" 29 | if input_string.startswith(prefix): 30 | return input_string[len(prefix) :] 31 | return input_string 32 | 33 | def get_file_content(self, update, analyzer_name, matches): 34 | """Return the content to be written to the file.""" 35 | return TemplatingEngine.fill_template(update, analyzer_name, template_string=self.template, matches=matches) 36 | 37 | def perform(self, update, analyzer_name=None, matches=None): 38 | """Store the update as a file. 39 | 40 | :param update: The cert update passed by the ActionHandler 41 | :param analyzer_name: The name of the analyzer which matched the update 42 | :param matches: List of matches returned by the analyzer 43 | :return: None 44 | """ 45 | if not self.path.exists(): 46 | self.path.mkdir(parents=True, exist_ok=True) 47 | 48 | self.file_ending = self._remove_prefix(self.file_ending, ".") 49 | 50 | file_name = str(update.cert_index) if self.file_ending == "" else f"{update.cert_index}.{self.file_ending}" 51 | 52 | file_path = self.path / file_name 53 | content = self.get_file_content(update, analyzer_name, matches) 54 | 55 | self.logger.debug("Writing file at '%s'", file_path) 56 | 57 | with open(file_path, "w", encoding="utf-8") as file: 58 | file.write(content) 59 | -------------------------------------------------------------------------------- /certleak/actions/savejsonaction.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from .savefileaction import SaveFileAction 4 | 5 | 6 | class SaveJSONAction(SaveFileAction): 7 | """Action to save a json formatted file to the disk.""" 8 | 9 | name = "SaveJSONAction" 10 | 11 | def __init__(self, path): 12 | super().__init__(path, file_ending=".json") 13 | 14 | def get_file_content(self, update, analyzer_name, matches): 15 | """Returns the content to be written to the file.""" 16 | return json.dumps(update.to_dict()) 17 | -------------------------------------------------------------------------------- /certleak/actions/telegramaction.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | 4 | from certleak.util import Request, TemplatingEngine 5 | 6 | from .basicaction import BasicAction 7 | 8 | 9 | class TelegramAction(BasicAction): 10 | """Action to send a Telegram message to a certain user or group/channel.""" 11 | 12 | name = "TelegramAction" 13 | 14 | def __init__(self, token, receiver, template=None): 15 | """Action to send a Telegram message to a certain user or group/channel. 16 | 17 | :param token: The Telegram API token for your bot obtained by @BotFather 18 | :param receiver: The userID/groupID or channelID of the receiving entity 19 | :param template: A template string describing how the update variables should be filled in. 20 | """ 21 | super().__init__() 22 | self.logger = logging.getLogger(__name__) 23 | 24 | if token is None or not re.match(r"[0-9]+:[a-zA-Z0-9\-_]+", token): 25 | msg = "Bot token not correct or None!" 26 | raise ValueError(msg) 27 | 28 | self.token = token 29 | self.receiver = receiver 30 | self.template = template 31 | 32 | def perform(self, update, analyzer_name=None, matches=None): 33 | """Send a message via a Telegram bot to a specified user, without checking for errors.""" 34 | r = Request() 35 | text = TemplatingEngine.fill_template(update, analyzer_name, template_string=self.template, matches=matches) 36 | 37 | api_url = f"https://api.telegram.org/bot{self.token}/sendMessage?chat_id={self.receiver}&text={text}" 38 | r.get(api_url) 39 | -------------------------------------------------------------------------------- /certleak/actions/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d-Rickyy-b/certleak/91472d77b695a1aa0e384133bc9342cf82e50c5e/certleak/actions/tests/__init__.py -------------------------------------------------------------------------------- /certleak/actions/tests/basicaction_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import Mock 3 | 4 | from certleak.actions import BasicAction 5 | 6 | 7 | class TestBasicAction(unittest.TestCase): 8 | def setUp(self): 9 | self.action = BasicAction() 10 | 11 | def test_name(self): 12 | """Make sure the action has the correct name.""" 13 | self.assertEqual("BasicAction", self.action.name) 14 | 15 | def test_perform(self): 16 | """Test if calling perform will raise a NotImplementedError.""" 17 | update = Mock() 18 | with self.assertRaises(NotImplementedError): 19 | self.action.perform(update) 20 | 21 | 22 | if __name__ == "__main__": 23 | unittest.main() 24 | -------------------------------------------------------------------------------- /certleak/actions/tests/savefileaction_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import Mock, patch 3 | 4 | from certleak.actions import SaveFileAction 5 | 6 | 7 | class TestSaveFileAction(unittest.TestCase): 8 | def setUp(self): 9 | """Sets up the test case.""" 10 | self.action = SaveFileAction(path="") 11 | self.update = Mock() 12 | self.path_mock = Mock() 13 | self.path_mock.__truediv__ = Mock(side_effect=self.side_effect) 14 | self.path_mock.exists = Mock(return_value=True) 15 | 16 | @staticmethod 17 | def side_effect(path): 18 | return path 19 | 20 | def test_init(self): 21 | """Check if parameters are saved correctly.""" 22 | self.action = SaveFileAction(path="/this/is/a/test") 23 | self.assertEqual("/this/is/a/test", self.action.path.as_posix()) 24 | 25 | self.action = SaveFileAction(path="Test") 26 | self.assertEqual("Test", self.action.path.as_posix()) 27 | 28 | # Check if multiple values for init are stored correctly 29 | self.action = SaveFileAction(path="/test", file_ending=".txt", template="Template string") 30 | self.assertEqual("/test", self.action.path.as_posix()) 31 | self.assertEqual(".txt", self.action.file_ending) 32 | self.assertEqual("Template string", self.action.template) 33 | 34 | # Check if 35 | self.action = SaveFileAction(path="/test") 36 | self.assertEqual(".txt", self.action.file_ending) 37 | self.assertEqual("${data}", self.action.template) 38 | 39 | @patch("builtins.open") 40 | @patch("certleak.actions.savefileaction.TemplatingEngine") 41 | def test_perform_path_not_exists(self, te_mock, open_mock): 42 | """Check if calling perform with non existing path works as intended.""" 43 | te_mock.fill_template = Mock(return_value="This is some content") 44 | 45 | # Mock pathlib to be independent of filesystem for this test 46 | self.path_mock.exists = Mock(return_value=False) 47 | self.action.path = self.path_mock 48 | 49 | # We need to make sure that the file's name is the cert_index 50 | self.update.cert_index = "thisIsTheRythmOfTheFile" 51 | 52 | self.action.perform(self.update, "", []) 53 | 54 | # Since the path didn't exist, we must have called "mkdir" once 55 | self.action.path.mkdir.assert_called_once_with(parents=True, exist_ok=True) 56 | 57 | # fill_template must be called for the perform method 58 | te_mock.fill_template.assert_called_once() 59 | 60 | # Make sure that the file was opened and written 61 | open_mock.assert_called_with("thisIsTheRythmOfTheFile.txt", "w", encoding="utf-8") 62 | open_mock().__enter__().write.assert_called_with("This is some content") 63 | 64 | @patch("builtins.open") 65 | @patch("certleak.actions.savefileaction.TemplatingEngine") 66 | def test_perform_path_exists(self, te_mock, open_mock): 67 | """Check if calling perform with non existing path works as intended.""" 68 | te_mock.fill_template = Mock(return_value="Some other content") 69 | 70 | # Mock pathlib to be independent of filesystem and write rights for this test 71 | self.action.path = self.path_mock 72 | 73 | # We need to make sure that the file's name is the cert_index 74 | self.update.cert_index = "myFile" 75 | 76 | self.action.perform(self.update, "", []) 77 | 78 | # Since the path did exist, we mustn't call "mkdir" 79 | self.action.path.mkdir.assert_not_called() 80 | 81 | # fill_template must be called for the perform method 82 | te_mock.fill_template.assert_called_once() 83 | 84 | # Make sure that the file was opened and written 85 | open_mock.assert_called_with("myFile.txt", "w", encoding="utf-8") 86 | open_mock().__enter__().write.assert_called_with("Some other content") 87 | 88 | @patch("builtins.open") 89 | @patch("certleak.actions.savefileaction.TemplatingEngine") 90 | def test_perform_file_ending_empty(self, te_mock, open_mock): 91 | """Check if calling perform with non existing path works as intended.""" 92 | te_mock.fill_template = Mock(return_value="Again another content") 93 | 94 | # Mock pathlib to be independent of filesystem and write rights for this test 95 | self.action.path = self.path_mock 96 | self.action.file_ending = "" 97 | 98 | # We need to make sure that the file's name is the cert_index 99 | file_name = "nameOfAFile" 100 | self.update.cert_index = Mock() 101 | self.update.cert_index.__repr__ = Mock(return_value=file_name) 102 | 103 | self.action.perform(self.update, "", []) 104 | 105 | # Since the path did exist, we mustn't call "mkdir" 106 | self.action.path.mkdir.assert_not_called() 107 | 108 | # fill_template must be called for the perform method 109 | te_mock.fill_template.assert_called_once() 110 | 111 | # We called str(update.cert_index) once 112 | self.update.cert_index.__repr__.assert_called_once() 113 | 114 | # Make sure that the file was opened and written 115 | open_mock.assert_called_with(file_name, "w", encoding="utf-8") 116 | open_mock().__enter__().write.assert_called_with("Again another content") 117 | 118 | @patch("builtins.open") 119 | @patch("certleak.actions.savefileaction.TemplatingEngine") 120 | def test_perform_file_ending(self, te_mock, open_mock): 121 | """Check if calling perform with non existing path works as intended.""" 122 | te_mock.fill_template = Mock(return_value="This is some content") 123 | 124 | self.action.path = self.path_mock 125 | self.action.file_ending = ".asdf" 126 | 127 | # We need to make sure that the file's name is the cert_index 128 | self.update.cert_index = Mock() 129 | self.update.cert_index.__repr__ = Mock(return_value="123456") 130 | 131 | self.action.perform(self.update, "", []) 132 | 133 | self.action.path.mkdir.assert_not_called() 134 | te_mock.fill_template.assert_called_once() 135 | self.update.cert_index.__repr__.assert_called_once() 136 | 137 | # Make sure that the file was opened and written 138 | open_mock.assert_called_with("123456.asdf", "w", encoding="utf-8") 139 | open_mock().__enter__().write.assert_called_with("This is some content") 140 | 141 | @patch("certleak.actions.savefileaction.TemplatingEngine") 142 | def test_get_file_content(self, te_mock): 143 | """Check if the content of the file is returned correctly.""" 144 | te_mock.fill_template = Mock(return_value="This is the content") 145 | content = self.action.get_file_content(self.update, "", []) 146 | self.assertEqual("This is the content", content) 147 | 148 | def test__remove_prefix(self): 149 | """Check if removing prefixes from a string works fine.""" 150 | input_string = ".txt" 151 | prefix = "." 152 | res = self.action._remove_prefix(input_string, prefix) 153 | self.assertEqual("txt", res) 154 | 155 | input_string = ".json" 156 | prefix = "." 157 | res = self.action._remove_prefix(input_string, prefix) 158 | self.assertEqual("json", res) 159 | 160 | input_string = ".txt" 161 | prefix = "_" 162 | res = self.action._remove_prefix(input_string, prefix) 163 | self.assertEqual(".txt", res) 164 | 165 | input_string = "" 166 | prefix = "." 167 | res = self.action._remove_prefix(input_string, prefix) 168 | self.assertEqual("", res) 169 | 170 | input_string = "..txt" 171 | prefix = "." 172 | res = self.action._remove_prefix(input_string, prefix) 173 | self.assertEqual(".txt", res) 174 | 175 | input_string = ".txt." 176 | prefix = "." 177 | res = self.action._remove_prefix(input_string, prefix) 178 | self.assertEqual("txt.", res) 179 | 180 | input_string = "This is a very long string without prefix" 181 | prefix = "A" 182 | res = self.action._remove_prefix(input_string, prefix) 183 | self.assertEqual("This is a very long string without prefix", res) 184 | 185 | input_string = "AAABBBCCC" 186 | prefix = "A" 187 | res = self.action._remove_prefix(input_string, prefix) 188 | self.assertEqual("AABBBCCC", res) 189 | 190 | 191 | if __name__ == "__main__": 192 | unittest.main() 193 | -------------------------------------------------------------------------------- /certleak/actions/tests/savejsonaction_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import Mock, patch 3 | 4 | from certleak.actions import SaveJSONAction 5 | 6 | 7 | class TestSaveJSONAction(unittest.TestCase): 8 | def setUp(self): 9 | """Set up the test case.""" 10 | self.action = SaveJSONAction(path="") 11 | self.update = Mock() 12 | self.path_mock = Mock() 13 | self.path_mock.__truediv__ = Mock(side_effect=self.side_effect) 14 | self.path_mock.exists = Mock(return_value=True) 15 | 16 | @staticmethod 17 | def side_effect(path): 18 | return path 19 | 20 | def test_get_file_content(self): 21 | """Check if the content of the file is returned correctly.""" 22 | self.update.to_dict = Mock(return_value={"test": "content", "another": "item"}) 23 | content_string = """{"test": "content", "another": "item"}""" 24 | content = self.action.get_file_content(self.update, "", []) 25 | self.assertEqual(content_string, content) 26 | 27 | @patch("builtins.open") 28 | @patch("certleak.actions.savejsonaction.json") 29 | def test_file_ending(self, json_mock, open_mock): 30 | """Check that the file ending is actually json, not txt.""" 31 | json_mock.dumps = Mock(return_value="json content!") 32 | 33 | self.action.path = self.path_mock 34 | 35 | self.update.cert_index = Mock() 36 | self.update.cert_index.__repr__ = Mock(return_value="123456") 37 | self.action.perform(self.update) 38 | 39 | open_mock.assert_called_with("123456.json", "w", encoding="utf-8") 40 | open_mock().__enter__().write.assert_called_with("json content!") 41 | 42 | 43 | if __name__ == "__main__": 44 | unittest.main() 45 | -------------------------------------------------------------------------------- /certleak/actions/tests/webhookaction_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import Mock, patch 3 | 4 | from certleak.actions.webhookaction import WebhookAction 5 | 6 | 7 | class TestWebhookAction(unittest.TestCase): 8 | def setUp(self): 9 | self.url = "https://example.com" 10 | self.action = WebhookAction(url=self.url) 11 | 12 | def test_init(self): 13 | """Check if initialization works as intended.""" 14 | self.assertEqual(self.action.url, self.url) 15 | self.assertEqual(self.action.post_data, True) 16 | 17 | self.action = WebhookAction(url=self.url, post_data=False) 18 | self.assertEqual(self.action.post_data, False) 19 | 20 | @patch("certleak.actions.webhookaction.Request") 21 | def test_perform_data(self, request_mock): 22 | """Check that running perform with post_data set to True sends a webrequest to the specified webhook url.""" 23 | update = Mock() 24 | update.to_dict = Mock(return_value="This is a test") 25 | analyzer_name = "name" 26 | matches = [] 27 | 28 | request_object_mock = Mock() 29 | request_mock.return_value = request_object_mock 30 | 31 | self.action.perform(update, analyzer_name, matches) 32 | 33 | request_mock.assert_called_once() 34 | request_object_mock.post.assert_called_once_with(url=self.url, data="This is a test") 35 | update.to_dict.assert_called_once() 36 | 37 | @patch("certleak.actions.webhookaction.Request") 38 | def test_perform_no_data(self, request_mock): 39 | """Check that running perform with post_data set to False sends a webrequest to the specified webhook url.""" 40 | self.action = WebhookAction(url=self.url, post_data=False) 41 | 42 | update = Mock() 43 | analyzer_name = "name" 44 | matches = [] 45 | 46 | request_object_mock = Mock() 47 | request_mock.return_value = request_object_mock 48 | 49 | self.action.perform(update, analyzer_name, matches) 50 | 51 | request_mock.assert_called_once() 52 | request_object_mock.post.assert_called_once_with(url=self.url, data=None) 53 | update.to_dict.assert_not_called() 54 | 55 | @patch("certleak.actions.webhookaction.Request") 56 | def test_perform_None(self, request_mock): 57 | """Check that passing None as update will not actually post the webhook.""" 58 | update = None 59 | analyzer_name = "name" 60 | matches = [] 61 | 62 | request_object_mock = Mock() 63 | request_mock.return_value = request_object_mock 64 | 65 | self.action.perform(update, analyzer_name, matches) 66 | 67 | request_mock.assert_not_called() 68 | request_object_mock.post.assert_not_called() 69 | 70 | 71 | if __name__ == "__main__": 72 | unittest.main() 73 | -------------------------------------------------------------------------------- /certleak/actions/webhookaction.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from certleak.util import Request 4 | 5 | from .basicaction import BasicAction 6 | 7 | 8 | class WebhookAction(BasicAction): 9 | """Action to perform a Webhook on a matched update.""" 10 | 11 | name = "WebhookAction" 12 | logger = logging.getLogger(__name__) 13 | 14 | def __init__(self, url, post_data=True): 15 | """Init method for the WebhookAction. 16 | 17 | :param url: string, URL to POST against 18 | :param post_data: boolean, to decide wheather a update should be sent in the body 19 | """ 20 | super().__init__() 21 | self.url = url 22 | self.post_data = post_data 23 | 24 | def perform(self, update, analyzer_name=None, matches=None): 25 | """Trigger the webhook. 26 | 27 | :param update: The update passed by the ActionHandler 28 | :param analyzer_name: The name of the analyzer which matched the update 29 | :param matches: List of matches returned by the analyzer 30 | :return: None 31 | """ 32 | if update is None: 33 | self.logger.warning("Update is None!") 34 | return 35 | 36 | update_dict = update.to_dict() if self.post_data else None 37 | 38 | r = Request() 39 | r.post(url=self.url, data=update_dict) 40 | -------------------------------------------------------------------------------- /certleak/analyzers/README.md: -------------------------------------------------------------------------------- 1 | # Analyzers 2 | 3 | When CertStream found a new certificate update, it will be passed to all the registered analyzers. 4 | Each analyzer either returns a boolean value, or a list of matches. 5 | 6 | ## Available Analyzers 7 | 8 | ### [AlwaysTrueAnalyzer](https://github.com/d-Rickyy-b/certleak/tree/main/certleak/analyzers/alwaystrueanalyzer.py) 9 | 10 | Analyzer that returns `True` for every certificate update 11 | 12 | ### [BasicAnalyzer](https://github.com/d-Rickyy-b/certleak/tree/main/certleak/analyzers/basicanalyzer.py) 13 | 14 | Base class for all analyzers 15 | 16 | ### [CAFingerprintAnalyzer](https://github.com/d-Rickyy-b/certleak/blob/main/certleak/analyzers/cafingerprintanalyzer.py) 17 | 18 | Finds certificate updates that are signed by a CA with a specified fingerprint. 19 | 20 | ### [DNSTwistAnalyzer](https://github.com/d-Rickyy-b/certleak/tree/main/certleak/analyzers/dnstwistanalyzer.py) 21 | 22 | Built on top of [dnstwist](https://github.com/elceef/dnstwist), this analyzer generates lists of permutated domans and matches the domains in each certificate update against them. 23 | 24 | ### [DomainRegexAnalyzer](https://github.com/d-Rickyy-b/certleak/tree/main/certleak/analyzers/domainregexanalyzer.py) 25 | 26 | Matches a given regex pattern against all the domain names contained in the certificate. 27 | 28 | ### [FullDomainAnalyzer](https://github.com/d-Rickyy-b/certleak/blob/main/certleak/analyzers/fulldomainanalyzer.py) 29 | 30 | Matches certificate updates that contain a specified word. 31 | 32 | ### [LetsEncryptAnalyzer](https://github.com/d-Rickyy-b/certleak/blob/main/certleak/analyzers/letsencryptanalyzer.py) 33 | 34 | Analyzer for finding certificate updates that are signed by Let's Encrypt. 35 | 36 | ### [PreCertAnalyzer](https://github.com/d-Rickyy-b/certleak/blob/main/certleak/analyzers/precertanalyzer.py) 37 | 38 | Finds pre certificate updates. Can be used to exclude precerts. 39 | 40 | ### [RegexDomainAnalyzer](https://github.com/d-Rickyy-b/certleak/tree/main/certleak/analyzers/regexdomainanalyzer.py) 41 | 42 | Probably the same as "DomainRegexAnalyzer" - TBD 43 | 44 | ### [SubDomainAnalyzer](https://github.com/d-Rickyy-b/certleak/tree/main/certleak/analyzers/subdomainanalyzer.py) 45 | 46 | Filters certificate updates for certain subdomains. For example the subdomains `imap.` or `blog.` 47 | 48 | ### [TLDAnalyzer](https://github.com/d-Rickyy-b/certleak/tree/main/certleak/analyzers/tldanalyzer.py) 49 | 50 | Finds certificate updates for domains of given TLDs. For example all domains ending with `.com`. 51 | 52 | ### [WildcardCertAnalyzer](https://github.com/d-Rickyy-b/certleak/tree/main/certleak/analyzers/wildcardcertanalyzer.py) 53 | 54 | Finds all certificate updates with wildcard domains - For example `*.example.com`. 55 | 56 | ### [x509Analyzer](https://github.com/d-Rickyy-b/certleak/tree/main/certleak/analyzers/x509analyzer.py) 57 | 58 | Not all the certificates are x509 certs. This analyzer fiulters them. Best to be used in combination with other analyzers. 59 | 60 | ## Combining analyzers 61 | 62 | You can combine analyzers logically via AND, OR and a NOT operator. 63 | 64 | ### AND 65 | 66 | Use the ampersand (`&`) char to combine two analyzers with the logical AND operator. 67 | 68 | ### OR 69 | 70 | Use the pipe (`|`) char to combine two analyzers with the logical OR operator. 71 | 72 | ### NOT 73 | 74 | Us the tilde (`~`) char to negate the result of an analyzer. 75 | For example: you want all matches of the TLD `.com` but not the ones matching `example.com`. 76 | 77 | ```python 78 | dotcomAnalyzer = TLDAnalyzer(actions, ".com") 79 | examplecomAnalyzer = FullDomainAnalyzer(actions=None, contained_words="example.com"): 80 | combined_analyzer = dotcomAnalyzer & ~examplecomAnalyzer 81 | ``` 82 | -------------------------------------------------------------------------------- /certleak/analyzers/__init__.py: -------------------------------------------------------------------------------- 1 | from .alwaystrueanalyzer import AlwaysTrueAnalyzer 2 | from .authoritykeyidanalyzer import AuthorityKeyIDAnalyzer 3 | from .basicanalyzer import BasicAnalyzer 4 | from .dnstwistanalyzer import DNStwistAnalyzer 5 | from .domainregexanalyzer import DomainRegexAnalyzer 6 | from .fulldomainanalyzer import FullDomainAnalyzer 7 | from .letsencryptanalyzer import LetsEncryptAnalyzer 8 | from .precertanalyzer import PreCertAnalyzer 9 | from .regexdomainanalyzer import RegexDomainAnalyzer 10 | from .subdomainanalyzer import SubDomainAnalyzer 11 | from .tldanalyzer import TLDAnalyzer 12 | from .wildcardcertanalyzer import WildcardCertAnalyzer 13 | from .x509analyzer import X509Analyzer 14 | 15 | __all__ = [ 16 | "AlwaysTrueAnalyzer", 17 | "AuthorityKeyIDAnalyzer", 18 | "BasicAnalyzer", 19 | "DNStwistAnalyzer", 20 | "DomainRegexAnalyzer", 21 | "FullDomainAnalyzer", 22 | "LetsEncryptAnalyzer", 23 | "PreCertAnalyzer", 24 | "RegexDomainAnalyzer", 25 | "SubDomainAnalyzer", 26 | "TLDAnalyzer", 27 | "WildcardCertAnalyzer", 28 | "X509Analyzer", 29 | ] 30 | -------------------------------------------------------------------------------- /certleak/analyzers/alwaystrueanalyzer.py: -------------------------------------------------------------------------------- 1 | from .basicanalyzer import BasicAnalyzer 2 | 3 | 4 | class AlwaysTrueAnalyzer(BasicAnalyzer): 5 | """Analyzer which always matches an certificate update to perform actions on every certificate update.""" 6 | 7 | name = "AlwaysTrueAnalyzer" 8 | 9 | def __init__(self, actions): 10 | """Analyzer which always matches a certificate update to perform actions on every certificate update. 11 | 12 | :param actions: A single action or a list of actions to be executed on every certificate update. 13 | """ 14 | super().__init__(actions) 15 | 16 | def match(self, update): 17 | """Always returns True to match every update available.""" 18 | return True 19 | -------------------------------------------------------------------------------- /certleak/analyzers/authoritykeyidanalyzer.py: -------------------------------------------------------------------------------- 1 | from .basicanalyzer import BasicAnalyzer 2 | 3 | 4 | class AuthorityKeyIDAnalyzer(BasicAnalyzer): 5 | """Analyzer for finding certificate updates that are signed by a CA with a specified subject key identifier.""" 6 | 7 | name = "AuthorityKeyIDAnalyzer" 8 | 9 | def __init__(self, actions, authority_key_id): 10 | """Find a CA with a specific authority_key_id. 11 | 12 | :param actions: 13 | :param authority_key_id: Certificate authorityKeyID, string separated by colons. 14 | """ 15 | super().__init__(actions) 16 | 17 | authority_key_id = authority_key_id.replace("keyid:", "") 18 | authority_key_id = authority_key_id.replace(r"\n", "") 19 | self.authority_key_id = authority_key_id.lower() 20 | 21 | def match(self, update): 22 | if not update: 23 | return False 24 | 25 | authority_key_identifier = update.extensions.authorityKeyIdentifier 26 | if authority_key_identifier is None: 27 | return False 28 | 29 | authority_key_identifier = authority_key_identifier.replace("keyid:", "") 30 | authority_key_identifier.replace(r"\n", "") 31 | 32 | return authority_key_identifier.lower() == self.authority_key_id 33 | -------------------------------------------------------------------------------- /certleak/analyzers/basicanalyzer.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from certleak.actions import BasicAction 4 | from certleak.errors import InvalidActionError 5 | from certleak.util import listify 6 | 7 | 8 | class BasicAnalyzer: 9 | """Basic analyzer class.""" 10 | 11 | name = "BasicAnalyzer" 12 | 13 | def __init__(self, actions, identifier=None): 14 | """Basic analyzer which is extended to create other analyzer subclasses. 15 | 16 | :param actions: A single action or a list of actions to be executed on every update 17 | :param identifier: The name or unique identifier for this specific analyzer. 18 | """ 19 | self.logger = logging.getLogger(__name__) 20 | self.actions = listify(actions) 21 | self.identifier = identifier or self.name 22 | 23 | # Check if passed action is an instance of an analyzer and not a class 24 | # Raises an error if any action is not an object inheriting from BasicAaction 25 | for action in self.actions: 26 | self._check_action(action) 27 | 28 | def add_action(self, action): 29 | """Add a new action to the already present actions. 30 | 31 | :param action: New action to add to the present actions 32 | :return: None 33 | """ 34 | self._check_action(action) 35 | self.actions.append(action) 36 | 37 | def match(self, update): 38 | """Check if a certain update is matched by the conditions set for this analyzer. 39 | 40 | :param update: A :class:`certleak.core.certstreamdata.update` object which should be matched 41 | :return: :obj:`bool` if the update has been matched 42 | """ 43 | msg = "Your analyzer must implement the match method!" 44 | raise NotImplementedError(msg) 45 | 46 | def _check_action(self, action): 47 | """Check if a passed action is a subclass of BasicAction.""" 48 | if not isinstance(action, BasicAction): 49 | if isinstance(action, type): 50 | error_msg = f"You passed a class as action for '{self.identifier}' but an instance of an action was expected!" 51 | else: 52 | error_msg = f"You did not pass an action object - inheriting from BasicAction - to '{self.identifier}'" 53 | 54 | self.logger.error(error_msg) 55 | raise InvalidActionError(error_msg) 56 | 57 | def __and__(self, other): 58 | """Return a new analyzer which is the logical AND of the current one and another one.""" 59 | return MergedAnalyzer(self, and_analyzer=other) 60 | 61 | def __or__(self, other): 62 | """Return a new analyzer which is the logical OR of the current one and another one.""" 63 | return MergedAnalyzer(self, or_analyzer=other) 64 | 65 | def __invert__(self): 66 | """Return a new analyzer which is the negation of the current one.""" 67 | return MergedAnalyzer(base_analyzer=None, not_analyzer=self) 68 | 69 | def __repr__(self): 70 | """Return a string representation of the analyzer.""" 71 | if self.identifier is None: 72 | self.identifier = self.__class__.__name__ 73 | return self.identifier 74 | 75 | 76 | class MergedAnalyzer(BasicAnalyzer): 77 | """Merged analyzer class.""" 78 | 79 | name = "MergedAnalyzer" 80 | 81 | def __init__(self, base_analyzer, and_analyzer=None, or_analyzer=None, not_analyzer=None): 82 | self._base_analyzer = base_analyzer 83 | self._and_analyzer = and_analyzer 84 | self._or_analyzer = or_analyzer 85 | self._not_analyzer = not_analyzer 86 | 87 | if self._and_analyzer: 88 | actions = base_analyzer.actions + self._and_analyzer.actions 89 | identifier = f"({base_analyzer.identifier} && {self._and_analyzer})" 90 | elif self._or_analyzer: 91 | actions = base_analyzer.actions + self._or_analyzer.actions 92 | identifier = f"({base_analyzer.identifier} || {self._or_analyzer})" 93 | elif self._not_analyzer: 94 | actions = self._not_analyzer.actions 95 | identifier = f"~({self._not_analyzer})" 96 | else: 97 | msg = "Neither and_analyzer, or_analyzer nor not_analyzer are set!" 98 | raise ValueError(msg) 99 | 100 | super().__init__(actions, identifier=identifier) 101 | 102 | def match(self, update): 103 | """Checks if a certain update is matched by the conditions set for this analyzer. 104 | 105 | :param update: A :class:`certleak.core.certstreamdata.update` object which should be matched 106 | :return: :obj:`bool` if the update has been matched 107 | """ 108 | if self._and_analyzer: 109 | return bool(self._base_analyzer.match(update)) and bool(self._and_analyzer.match(update)) 110 | elif self._or_analyzer: 111 | return bool(self._base_analyzer.match(update)) or bool(self._or_analyzer.match(update)) 112 | elif self._not_analyzer: 113 | return not bool(self._not_analyzer.match(update)) 114 | 115 | return False 116 | -------------------------------------------------------------------------------- /certleak/analyzers/cafingerprintanalyzer.py: -------------------------------------------------------------------------------- 1 | from .basicanalyzer import BasicAnalyzer 2 | 3 | 4 | class CAFingerprintAnalyzer(BasicAnalyzer): 5 | """Analyzer for finding certificate updates that are signed by a CA with a specified fingerprint.""" 6 | 7 | name = "CAFingerprintAnalyzer" 8 | 9 | def __init__(self, actions, fingerprint): 10 | """Find a CA with a specific fingerprint. 11 | 12 | :param actions: 13 | :param fingerprint: Certificate fingerprint, bytes separated by colons 14 | """ 15 | super().__init__(actions) 16 | self.fingerprint = fingerprint 17 | 18 | def match(self, update): 19 | # TODO chain doesn't work! 20 | return any(cert.extensions.basicConstraints == "CA:TRUE" and cert.fingerprint.lower() == self.fingerprint.lower() for cert in update.chain) 21 | -------------------------------------------------------------------------------- /certleak/analyzers/dnstwistanalyzer.py: -------------------------------------------------------------------------------- 1 | import dnstwist 2 | 3 | from certleak.util import listify 4 | 5 | from .basicanalyzer import BasicAnalyzer 6 | 7 | 8 | class DNStwistAnalyzer(BasicAnalyzer): 9 | """Analyzer for finding certificate updates for permutated domains (generated by dnstwist).""" 10 | 11 | name = "DNStwistAnalyzer" 12 | 13 | def __init__(self, actions, domainname, fuzzers=None, exceptions=None): 14 | """Analyzer for finding certificate updates for permutated domains (generated by dnstwist). 15 | 16 | Check https://github.com/elceef/dnstwist for more details! 17 | 18 | :param actions: A single action or a list of actions to be executed on every update 19 | :param domainname: The domainname of the target website 20 | :param fuzzers: A list of dnstwist fuzzers to use, e.g. "addition", "bitsquatting" 21 | :param exceptions: A list of domains to exclude from being matched 22 | """ 23 | super().__init__(actions) 24 | self.domainname = domainname 25 | self.fuzzers = fuzzers 26 | self.exceptions = listify(exceptions) 27 | self.exceptions.append(domainname) 28 | 29 | # Generate pot. phishing domain names via dnstwist 30 | self.logger.info("Generating phishing domains via dnstwist for domain '%s'", domainname) 31 | fuzz = dnstwist.Fuzzer(domainname) 32 | fuzz.generate() 33 | res = fuzz.domains 34 | 35 | self.logger.info("Generated %s permutated domains", len(res)) 36 | 37 | # dnstwist returns a list of dicts containing "fuzzer" and "domain-name" as elements 38 | # We need to generate a set of domain names out of it 39 | if fuzzers: 40 | # Skip certain fuzzers if the user wants to filter these out 41 | self.generated_domains = {domain.get("domain-name") for domain in res if domain.get("fuzzer") in self.fuzzers} 42 | else: 43 | self.generated_domains = {domain.get("domain-name") for domain in res} 44 | 45 | # Remove original domain and exceptions from set 46 | for exception_domain in self.exceptions: 47 | try: 48 | self.generated_domains.remove(exception_domain) 49 | except KeyError: 50 | self.logger.debug("Could not find domain %s in dnstwist output!", exception_domain) 51 | 52 | self.logger.info("Using %s permutated domains", len(self.generated_domains)) 53 | 54 | def match(self, update): 55 | """Check if any of the certificate domain names are contained in the list of generated pot. phishing domains. 56 | 57 | :param update: An update object which should be matched 58 | :return: `bool` if the update has been matched 59 | """ 60 | if not update or not update.all_domains: 61 | return False 62 | 63 | return any(domain in self.generated_domains for domain in update.all_domains) 64 | # TODO implement "contained in" feature - e.g. for subdomains 65 | -------------------------------------------------------------------------------- /certleak/analyzers/domainregexanalyzer.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from .basicanalyzer import BasicAnalyzer 4 | 5 | 6 | class DomainRegexAnalyzer(BasicAnalyzer): 7 | """Analyzer for matching certificate domains against a regex.""" 8 | 9 | name = "DomainRegexAnalyzer" 10 | 11 | def __init__(self, actions, pattern, flags=0): 12 | """Analyzer for matching certificate domains against a regex. 13 | 14 | :param actions: A single action or a list of actions to be executed on every update 15 | :param pattern: The pattern to match the domains against 16 | :param flags: Flags for the re.compile method 17 | """ 18 | super().__init__(actions) 19 | self._pattern = pattern 20 | self.flags = flags 21 | self.regex = re.compile(pattern, flags) 22 | 23 | @property 24 | def pattern(self): 25 | return self._pattern 26 | 27 | @pattern.setter 28 | def pattern(self, value): 29 | self._pattern = value 30 | self.regex = re.compile(pattern=value, flags=self.flags) 31 | 32 | def match(self, update): 33 | """Check if any of the certificate domain names match the given regex. 34 | 35 | :param update: An update object which should be matched 36 | :return: `bool` if the update has been matched 37 | """ 38 | if update is None: 39 | return [] 40 | 41 | matches = [] 42 | for domain in update.all_domains: 43 | matches += self.regex.findall(domain) 44 | 45 | return matches 46 | -------------------------------------------------------------------------------- /certleak/analyzers/fulldomainanalyzer.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from certleak.util import listify 4 | 5 | from .basicanalyzer import BasicAnalyzer 6 | 7 | 8 | class FullDomainAnalyzer(BasicAnalyzer): 9 | """Analyzer to match domains containing certain strings.""" 10 | 11 | name = "FullDomainAnalyzer" 12 | logger = logging.getLogger(__name__) 13 | 14 | def __init__(self, actions, contained_words=None, exact_match=False): 15 | """Analyzer that searches the full cert domain names (subdomain.domain.tld) for the given words. 16 | 17 | :param actions: A single action or a list of actions to be executed on every update 18 | :param contained_words: Words to search within the full domain 19 | :param exact_match: If set to True this analyzer will only match if the domain matches exactly any of the given words 20 | """ 21 | super().__init__(actions) 22 | # Remove empty words 23 | self.contained_words = [word for word in listify(contained_words) if word != ""] 24 | self.exact_match = exact_match 25 | 26 | def match(self, update): 27 | matches = [] 28 | 29 | if update is None or update.all_domains is None: 30 | self.logger.warning("Update is None!") 31 | return matches 32 | 33 | for word in self.contained_words: 34 | for full_domain in update.all_domains: 35 | if self.exact_match: 36 | # If we only want exact matches, we check on equality 37 | if word == full_domain: 38 | matches.append(full_domain) 39 | # If we want partial matches as well, we check if it contains the word 40 | elif word in full_domain: 41 | matches.append(full_domain) 42 | 43 | return list(set(matches)) 44 | -------------------------------------------------------------------------------- /certleak/analyzers/letsencryptanalyzer.py: -------------------------------------------------------------------------------- 1 | from .basicanalyzer import BasicAnalyzer 2 | 3 | 4 | class LetsEncryptAnalyzer(BasicAnalyzer): 5 | """Analyzer for finding certificate updates that are signed by Let's Encrypt.""" 6 | 7 | name = "LetsEncryptAnalyzer" 8 | 9 | def __init__(self, actions=None): 10 | super().__init__(actions) 11 | 12 | def match(self, update): 13 | return update.leaf_cert.issuer.O == "Let's Encrypt" 14 | -------------------------------------------------------------------------------- /certleak/analyzers/precertanalyzer.py: -------------------------------------------------------------------------------- 1 | from .basicanalyzer import BasicAnalyzer 2 | 3 | 4 | class PreCertAnalyzer(BasicAnalyzer): 5 | """Analyzer for finding pre certificate updates.""" 6 | 7 | name = "PreCertAnalyzer" 8 | 9 | def __init__(self, actions=None): 10 | super().__init__(actions) 11 | 12 | def match(self, update): 13 | return update.update_type == "PrecertLogEntry" 14 | -------------------------------------------------------------------------------- /certleak/analyzers/regexdomainanalyzer.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from .basicanalyzer import BasicAnalyzer 4 | 5 | 6 | class RegexDomainAnalyzer(BasicAnalyzer): 7 | """Analyzer for using regex to find certificate updates for domains matching the pattern.""" 8 | 9 | name = "RegexDomainAnalyzer" 10 | 11 | def __init__(self, actions, pattern, flags=0): 12 | """Analyzer for using regex to find certificate updates for domains matching the pattern. 13 | 14 | :param actions: 15 | :param pattern: pattern to match a certain domain name 16 | :param flags: the flags found in the 're' module 17 | """ 18 | super().__init__(actions) 19 | self.regex = re.compile(pattern, flags) 20 | 21 | def match(self, update): 22 | """Match the domains of a cert update against the given regex.""" 23 | if not update or not update.all_domains: 24 | return False 25 | 26 | matches = [] 27 | 28 | for domain in update.all_domains: 29 | match = self.regex.findall(domain) 30 | matches += match 31 | 32 | if len(matches) > 0: 33 | return matches 34 | 35 | return False 36 | -------------------------------------------------------------------------------- /certleak/analyzers/subdomainanalyzer.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import tldextract 4 | 5 | from certleak.util import listify 6 | 7 | from .basicanalyzer import BasicAnalyzer 8 | 9 | 10 | class SubDomainAnalyzer(BasicAnalyzer): 11 | """Analyzer to match subdomains containing certain strings.""" 12 | 13 | name = "SubDomainAnalyzer" 14 | logger = logging.getLogger(__name__) 15 | 16 | def __init__(self, actions, subdomains, exact_match=True): 17 | """Analyzer that searches the full cert domain names (subdomain.domain.tld) for the given words. 18 | 19 | :param actions: A single action or a list of actions to be executed on every update 20 | :param subdomains: The subdomains to search for 21 | :param exact_match: If set to True (default) it will only search for exact subdomain matches, else it will allow for partly matches 22 | """ 23 | super().__init__(actions) 24 | self.subdomains = listify(subdomains) 25 | self.exact_match = exact_match 26 | 27 | def match(self, update): 28 | matches = [] 29 | 30 | if update is None or update.all_domains is None: 31 | self.logger.warning("Update is None!") 32 | return matches 33 | 34 | for domain in update.all_domains: 35 | extracted = tldextract.extract(domain) 36 | subdomains_full = extracted.subdomain 37 | subdomains = subdomains_full.split(".") 38 | 39 | for subdomain in subdomains: 40 | if self.exact_match: 41 | if subdomain in self.subdomains: 42 | matches.append(domain) 43 | continue 44 | else: 45 | # If not in exact mode, check if the subdomain of the certificate 46 | # contains any of the the words specified by self.subdomains 47 | matches.extend([domain for word in self.subdomains if word in subdomain]) 48 | 49 | return list(set(matches)) 50 | -------------------------------------------------------------------------------- /certleak/analyzers/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d-Rickyy-b/certleak/91472d77b695a1aa0e384133bc9342cf82e50c5e/certleak/analyzers/tests/__init__.py -------------------------------------------------------------------------------- /certleak/analyzers/tests/alwaystrueanalyzer_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest import mock 3 | 4 | from certleak.actions.basicaction import BasicAction 5 | from certleak.analyzers import AlwaysTrueAnalyzer 6 | 7 | 8 | class TestAlwaysTrueAnalyzer(unittest.TestCase): 9 | def setUp(self): 10 | self.analyzer = AlwaysTrueAnalyzer(None) 11 | self.update = mock.Mock() 12 | 13 | def test_match(self): 14 | """Check if AlwaysTrueAnalyzer returns always True.""" 15 | self.update.body = "Test" 16 | self.assertTrue(self.analyzer.match(self.update)) 17 | 18 | self.update.body = None 19 | self.assertTrue(self.analyzer.match(self.update)) 20 | 21 | self.update.body = "" 22 | self.assertTrue(self.analyzer.match(self.update)) 23 | 24 | self.update = None 25 | self.assertTrue(self.analyzer.match(self.update)) 26 | 27 | def test_actions_present(self): 28 | """Check if the actions are stored for the AlwaysTrueAnalyzer.""" 29 | action = mock.MagicMock(spec=BasicAction) 30 | analyzer = AlwaysTrueAnalyzer(action) 31 | self.assertEqual([action], analyzer.actions) 32 | 33 | 34 | if __name__ == "__main__": 35 | unittest.main() 36 | -------------------------------------------------------------------------------- /certleak/analyzers/tests/authoritykeyidanalyzer_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest import mock 3 | 4 | from certleak.actions.basicaction import BasicAction 5 | from certleak.analyzers import AuthorityKeyIDAnalyzer 6 | 7 | 8 | class TestAuthorityKeyIDAnalyzer(unittest.TestCase): 9 | def setUp(self): 10 | self.analyzer = AuthorityKeyIDAnalyzer(None, "keyid:14:2E:B3:17:B7:58:56:CB:AE:50:09:40:E6:1F:AF:9D:8B:14:C2:C6") 11 | self.update = mock.Mock() 12 | 13 | def test_match(self): 14 | """Check if AuthorityKeyIDAnalyzer matches the authorityKeyIdentifier.""" 15 | self.update.extensions.authorityKeyIdentifier = "keyid:14:2E:B3:17:B7:58:56:CB:AE:50:09:40:E6:1F:AF:9D:8B:14:C2:C6" 16 | self.assertTrue(self.analyzer.match(self.update)) 17 | 18 | self.update.extensions.authorityKeyIdentifier = None 19 | self.assertFalse(self.analyzer.match(self.update)) 20 | 21 | self.update.extensions.authorityKeyIdentifier = "" 22 | self.assertFalse(self.analyzer.match(self.update)) 23 | 24 | self.update = None 25 | self.assertFalse(self.analyzer.match(self.update)) 26 | 27 | def test_actions_present(self): 28 | """Check if the actions are stored for the AlwaysTrueAnalyzer.""" 29 | action = mock.MagicMock(spec=BasicAction) 30 | analyzer = AuthorityKeyIDAnalyzer(action, "SomeKey") 31 | self.assertEqual([action], analyzer.actions) 32 | 33 | 34 | if __name__ == "__main__": 35 | unittest.main() 36 | -------------------------------------------------------------------------------- /certleak/analyzers/tests/basicanalyzer_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import Mock, patch 3 | 4 | from certleak.actions import BasicAction 5 | from certleak.analyzers import BasicAnalyzer 6 | from certleak.errors import InvalidActionError 7 | 8 | 9 | class TestBasicAnalyzer(unittest.TestCase): 10 | def setUp(self): 11 | self.analyzer = BasicAnalyzer(None) 12 | 13 | def test_name(self): 14 | """Make sure the analyzer has the correct name.""" 15 | self.assertEqual("BasicAnalyzer", self.analyzer.name) 16 | 17 | def test_init(self): 18 | """Check if the initialization process works as intended.""" 19 | self.assertTrue(isinstance(self.analyzer.actions, list)) 20 | self.assertEqual(0, len(self.analyzer.actions)) 21 | 22 | def test_init_action(self): 23 | """Check if initialization with a single action works fine.""" 24 | action = Mock(spec=BasicAction) 25 | self.analyzer = BasicAnalyzer(actions=action) 26 | self.assertTrue(isinstance(self.analyzer.actions, list)) 27 | self.assertEqual(1, len(self.analyzer.actions)) 28 | self.assertEqual(action, self.analyzer.actions[0]) 29 | 30 | def test_init_actions(self): 31 | """Check if initialization with multiple actions works fine.""" 32 | action1 = Mock(spec=BasicAction) 33 | action2 = Mock(spec=BasicAction) 34 | self.analyzer = BasicAnalyzer(actions=[action1, action2]) 35 | self.assertTrue(isinstance(self.analyzer.actions, list)) 36 | self.assertEqual(2, len(self.analyzer.actions)) 37 | self.assertIn(action1, self.analyzer.actions) 38 | self.assertIn(action2, self.analyzer.actions) 39 | 40 | def test_add_action(self): 41 | """Check if adding a new action to the analyzer.""" 42 | action1 = Mock(spec=BasicAction) 43 | self.analyzer.add_action(action1) 44 | 45 | self.assertEqual(1, len(self.analyzer.actions)) 46 | 47 | action2 = Mock(spec=BasicAction) 48 | self.analyzer.add_action(action2) 49 | 50 | self.assertEqual(2, len(self.analyzer.actions)) 51 | self.assertIn(action1, self.analyzer.actions) 52 | self.assertIn(action2, self.analyzer.actions) 53 | 54 | def test_match(self): 55 | """Test if calling match will raise a NotImplementedError.""" 56 | with self.assertRaises(NotImplementedError): 57 | self.analyzer.match(Mock()) 58 | 59 | @patch("certleak.analyzers.basicanalyzer.MergedAnalyzer") 60 | def test_andanalyzer(self, mergedanalyzer_mock): 61 | """Check if using logical AND works as expected.""" 62 | analyzer1 = BasicAnalyzer(None) 63 | analyzer2 = BasicAnalyzer(None) 64 | analyzer = analyzer1 & analyzer2 65 | 66 | mergedanalyzer_mock.assert_called_once_with(analyzer1, and_analyzer=analyzer2) 67 | self.assertEqual(mergedanalyzer_mock(), analyzer) 68 | 69 | @patch("certleak.analyzers.basicanalyzer.MergedAnalyzer") 70 | def test_oranalyzer(self, mergedanalyzer_mock): 71 | """Check if using logical OR works as expected.""" 72 | analyzer1 = BasicAnalyzer(None) 73 | analyzer2 = BasicAnalyzer(None) 74 | analyzer = analyzer1 | analyzer2 75 | 76 | mergedanalyzer_mock.assert_called_once_with(analyzer1, or_analyzer=analyzer2) 77 | self.assertEqual(mergedanalyzer_mock(), analyzer) 78 | 79 | @patch("certleak.analyzers.basicanalyzer.MergedAnalyzer") 80 | def test_invertanalyzer(self, mergedanalyzer_mock): 81 | """Check if using inversion works as expected.""" 82 | analyzer1 = BasicAnalyzer(None) 83 | analyzer = ~analyzer1 84 | 85 | mergedanalyzer_mock.assert_called_once_with(base_analyzer=None, not_analyzer=analyzer1) 86 | self.assertEqual(mergedanalyzer_mock(), analyzer) 87 | 88 | def test_repr(self): 89 | """Check if generating a representation of an analyzer works fine.""" 90 | self.analyzer.identifier = "This is a test" 91 | self.assertEqual("This is a test", str(self.analyzer)) 92 | 93 | def test_repr_none(self): 94 | """Check if generating a representation of an analyzer works fine if identifier is None.""" 95 | self.analyzer.identifier = None 96 | self.assertEqual("BasicAnalyzer", str(self.analyzer)) 97 | 98 | def test_repr_no_identifier(self): 99 | """Check if generating a representation of an analyzer works fine when no identifier is set.""" 100 | self.analyzer.identifier = "This is a test" 101 | self.assertEqual("This is a test", str(self.analyzer)) 102 | 103 | def test__check_action(self): 104 | """Check if passing something else than an instance of BasicAction to _check_action raises an error.""" 105 | action = Mock() 106 | with self.assertRaises(InvalidActionError): 107 | self.analyzer._check_action(action) 108 | 109 | def test__check_action2(self): 110 | """Check if passing a reference to the BasicAction class to _check_action raises an error.""" 111 | action = BasicAction 112 | with self.assertRaises(InvalidActionError): 113 | self.analyzer._check_action(action) 114 | 115 | 116 | if __name__ == "__main__": 117 | unittest.main() 118 | -------------------------------------------------------------------------------- /certleak/analyzers/tests/domainregexanalyzer_test.py: -------------------------------------------------------------------------------- 1 | import re 2 | import unittest 3 | from unittest.mock import Mock 4 | 5 | from certleak.analyzers import DomainRegexAnalyzer 6 | 7 | 8 | class TestDomainRegexAnalyzer(unittest.TestCase): 9 | def setUp(self): 10 | """Set up the test case.""" 11 | self.update = Mock() 12 | self.path_mock = Mock() 13 | self.path_mock.exists = Mock(return_value=True) 14 | self.analyzer = DomainRegexAnalyzer(actions=None, pattern="") 15 | 16 | def test_match_negative(self): 17 | """Check if the regex analyzer returns no results if the pattern doesn't match.""" 18 | self.analyzer.pattern = r"^www\..*" 19 | self.update.all_domains = ["test.com", "subdomain.example.com"] 20 | matches = self.analyzer.match(self.update) 21 | 22 | self.assertFalse(matches) 23 | 24 | def test_match_positive(self): 25 | """Check if the regex analyzer returns correct results.""" 26 | self.analyzer.pattern = r"^www\..*" 27 | self.update.all_domains = ["www.test.com", "subdomain.example.com"] 28 | matches = self.analyzer.match(self.update) 29 | 30 | self.assertTrue(matches) 31 | self.assertEqual(1, len(matches)) 32 | self.assertEqual("www.test.com", matches[0]) 33 | 34 | def test_match_positive_multiple(self): 35 | """Check if the regex analyzer returns multiple correct results.""" 36 | self.analyzer.pattern = r"^www\..*" 37 | self.update.all_domains = ["www.test.com", "subdomain.example.com", "www.example.com"] 38 | matches = self.analyzer.match(self.update) 39 | 40 | self.assertTrue(matches) 41 | self.assertEqual(2, len(matches)) 42 | self.assertEqual("www.test.com", matches[0]) 43 | self.assertEqual("www.example.com", matches[1]) 44 | 45 | def test_match_empty_domains(self): 46 | """Check if the regex analyzer returns False for an empty list of domains.""" 47 | self.analyzer.pattern = r"^www\..*" 48 | self.update.all_domains = [] 49 | matches = self.analyzer.match(self.update) 50 | 51 | self.assertFalse(matches) 52 | 53 | def test_match_domains_None(self): 54 | """Check if the regex analyzer returns False for a None-type update.""" 55 | self.analyzer.pattern = r"^www\..*" 56 | self.update = None 57 | matches = self.analyzer.match(self.update) 58 | 59 | self.assertFalse(matches) 60 | 61 | def test_flags(self): 62 | """Test some regex flags. Not all, because as long as we use the re module, this should work fine.""" 63 | self.analyzer.pattern = r"ThIsiSmIxEd\.com" 64 | self.update.all_domains = ["thisismixed.com"] 65 | self.assertFalse(self.analyzer.match(self.update), "The regex does match, although it shouldn't!") 66 | 67 | # Test case independend flag / ignorecase 68 | self.analyzer = DomainRegexAnalyzer(None, r"ThIsiSmIxEd\.com", flags=re.IGNORECASE) 69 | self.update.all_domains = ["thisismixed.com"] 70 | self.assertTrue(self.analyzer.match(self.update), "The regex does not match, although it should!") 71 | 72 | self.analyzer = DomainRegexAnalyzer(None, r"thisismixed\.com", flags=re.IGNORECASE) 73 | self.update.all_domains = ["ThIsiSmIxEd.com"] 74 | self.assertTrue(self.analyzer.match(self.update), "The regex does not match, although it should!") 75 | 76 | def test_set_pattern(self): 77 | """Check if changing the pattern works fine.""" 78 | self.assertEqual("", self.analyzer.pattern) 79 | self.analyzer.pattern = r"[a-zA-Z0-9]\.(com)" 80 | 81 | self.assertEqual(r"[a-zA-Z0-9]\.(com)", self.analyzer.pattern) 82 | 83 | 84 | if __name__ == "__main__": 85 | unittest.main() 86 | -------------------------------------------------------------------------------- /certleak/analyzers/tests/fulldomainanalyzer_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest import mock 3 | 4 | from certleak.actions.basicaction import BasicAction 5 | from certleak.analyzers import FullDomainAnalyzer 6 | 7 | 8 | class TestFullDomainAnalyzer(unittest.TestCase): 9 | def setUp(self): 10 | self.update = mock.Mock() 11 | self.action = mock.MagicMock(spec=BasicAction) 12 | 13 | def test_setup(self): 14 | """Check if the word list is initialized correctly.""" 15 | analyzer = FullDomainAnalyzer(self.action, "test") 16 | self.assertEqual(1, len(analyzer.contained_words)) 17 | self.assertEqual("test", analyzer.contained_words[0]) 18 | 19 | analyzer = FullDomainAnalyzer(self.action, ["test", "123"]) 20 | self.assertEqual(2, len(analyzer.contained_words)) 21 | self.assertEqual("test", analyzer.contained_words[0]) 22 | self.assertEqual("123", analyzer.contained_words[1]) 23 | 24 | def test_single_word(self): 25 | """Check if the analyzer matches if passing a single word to it.""" 26 | analyzer = FullDomainAnalyzer(self.action, "test") 27 | update = mock.Mock() 28 | update.all_domains = ["test.example.com", "test2.example.com", "test.beispiel.de", "nosubdomain.de"] 29 | self.assertTrue(analyzer.match(update)) 30 | self.assertEqual(3, len(analyzer.match(update))) 31 | 32 | analyzer = FullDomainAnalyzer(self.action, "2.ex") 33 | self.assertTrue(analyzer.match(update)) 34 | self.assertEqual(1, len(analyzer.match(update))) 35 | 36 | analyzer = FullDomainAnalyzer(self.action, "test2") 37 | self.assertTrue(analyzer.match(update)) 38 | 39 | analyzer = FullDomainAnalyzer(self.action, "mytest") 40 | self.assertFalse(analyzer.match(update)) 41 | 42 | def test_empty_string(self): 43 | """Check if the analyzer matches if the matched word is an empty string.""" 44 | analyzer = FullDomainAnalyzer(self.action, "") 45 | 46 | update = mock.Mock() 47 | update.all_domains = ["test.example.com", "test2.example.com", "test.beispiel.de", "nosubdomain.de"] 48 | 49 | self.assertFalse(analyzer.match(update)) 50 | 51 | analyzer = FullDomainAnalyzer(self.action, [""]) 52 | 53 | self.assertFalse(analyzer.match(update)) 54 | 55 | def test_none_update(self): 56 | """Check if the analyzer matches if the update object is None.""" 57 | analyzer = FullDomainAnalyzer(self.action, "test") 58 | update = None 59 | self.assertFalse(analyzer.match(update)) 60 | self.assertEqual(0, len(analyzer.match(update))) 61 | 62 | def test_empty_update_domains(self): 63 | """Check if the analyzer matches if the list of domains is empty.""" 64 | analyzer = FullDomainAnalyzer(self.action, "test") 65 | update = mock.Mock() 66 | update.all_domains = [] 67 | self.assertFalse(analyzer.match(update)) 68 | self.assertEqual(0, len(analyzer.match(update))) 69 | 70 | def test_multiple_words(self): 71 | """Test if the analyzer matches multiple words correctly.""" 72 | analyzer = FullDomainAnalyzer(self.action, ["test", "example"]) 73 | update = mock.Mock() 74 | update.all_domains = ["test.example.com", "test2.example.com", "test.beispiel.de", "nosubdomain.de"] 75 | self.assertTrue(analyzer.match(update)) 76 | self.assertEqual(3, len(analyzer.match(update))) # We still expect 3 results, because we are returning domains 77 | 78 | analyzer = FullDomainAnalyzer(self.action, ["Nothing", "Matches"]) 79 | self.assertFalse(analyzer.match(update)) 80 | self.assertEqual(0, len(analyzer.match(update))) 81 | 82 | analyzer = FullDomainAnalyzer(self.action, ["Nothing", "Matches", "anotherone", "ThisIsFun", "nosubdomain", "lastone"]) 83 | self.assertTrue(analyzer.match(update)) 84 | self.assertEqual(1, len(analyzer.match(update))) 85 | 86 | def test_actions_present(self): 87 | """Check if the action passed to the analyzer is being stored.""" 88 | action = mock.MagicMock(spec=BasicAction) 89 | analyzer = FullDomainAnalyzer(action) 90 | self.assertEqual([action], analyzer.actions) 91 | 92 | def test_exact_match(self): 93 | """Check if the analyzer matches if passing a single domain to it using the exact_match param.""" 94 | analyzer = FullDomainAnalyzer(self.action, "test", exact_match=True) 95 | update = mock.Mock() 96 | update.all_domains = ["test.example.com", "test2.example.com", "test.beispiel.de", "nosubdomain.de"] 97 | matches = analyzer.match(update) 98 | self.assertFalse(matches) 99 | self.assertEqual(0, len(matches)) 100 | 101 | analyzer = FullDomainAnalyzer(self.action, "test.example.com", exact_match=True) 102 | update = mock.Mock() 103 | update.all_domains = ["test.example.com", "test2.example.com", "test.beispiel.de", "nosubdomain.de"] 104 | matches = analyzer.match(update) 105 | self.assertTrue(matches) 106 | self.assertEqual(1, len(matches)) 107 | self.assertEqual("test.example.com", matches[0]) 108 | 109 | 110 | if __name__ == "__main__": 111 | unittest.main() 112 | -------------------------------------------------------------------------------- /certleak/analyzers/tests/mergedanalyzer_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import Mock 3 | 4 | from certleak.analyzers.basicanalyzer import BasicAnalyzer, MergedAnalyzer 5 | 6 | 7 | class TestMergedAnalyzer(unittest.TestCase): 8 | class NewAnalyzer(BasicAnalyzer): 9 | """Test Analyzer for testing mergedAnalyzer.""" 10 | 11 | def __init__(self, return_value): 12 | super().__init__(actions=None) 13 | self.return_value = return_value 14 | 15 | def match(self, update): 16 | """Match func.""" 17 | return self.return_value 18 | 19 | def setUp(self): 20 | """Set up test case.""" 21 | self.true_analyzer = self.NewAnalyzer(True) 22 | self.false_analyzer = self.NewAnalyzer(False) 23 | self.update_mock = Mock() 24 | self.update_mock.body = "This is a mock update" 25 | 26 | def test_and(self): 27 | """Check if logical and between analyzers works fine.""" 28 | and_analyzer = self.true_analyzer & self.false_analyzer 29 | 30 | # One analyzer returns False, the other True, this should evaluate to False 31 | self.assertFalse(and_analyzer.match(self.update_mock)) 32 | 33 | and_analyzer2 = self.true_analyzer & self.true_analyzer 34 | # Since both analyzers return true, this should now return True as well 35 | self.assertTrue(and_analyzer2.match(self.update_mock)) 36 | 37 | def test_or(self): 38 | """Check if logical or between analyzers works fine.""" 39 | # One analyzer returns False, the other True, this should evaluate to True 40 | or_analyzer = self.true_analyzer | self.false_analyzer 41 | self.assertTrue(or_analyzer.match(self.update_mock)) 42 | 43 | # Since both return true, this should return True as well 44 | or_analyzer2 = self.true_analyzer | self.false_analyzer 45 | self.assertTrue(or_analyzer2.match(self.update_mock)) 46 | 47 | or_analyzer3 = self.false_analyzer | self.true_analyzer 48 | self.assertTrue(or_analyzer3.match(self.update_mock)) 49 | 50 | def test_both(self): 51 | """Check if logical and/or both work fine in combination with each other.""" 52 | # One analyzer returns False, the other True, this should evaluate to False 53 | and_analyzer = self.true_analyzer & self.false_analyzer 54 | self.assertFalse(and_analyzer.match(self.update_mock)) 55 | 56 | # Since both return true, this should return True as well 57 | or_analyzer = self.true_analyzer | self.false_analyzer 58 | self.assertTrue(or_analyzer.match(self.update_mock)) 59 | 60 | def test_not(self): 61 | """Check if inversion of analyzers works fine.""" 62 | not_analyzer = ~self.true_analyzer 63 | self.assertFalse(not_analyzer.match(self.update_mock)) 64 | 65 | not_analyzer = ~self.false_analyzer 66 | self.assertTrue(not_analyzer.match(self.update_mock)) 67 | 68 | def test_none(self): 69 | """Check that error is raised in case no value is given for and/or/not_analyzer.""" 70 | with self.assertRaises(ValueError): 71 | MergedAnalyzer(base_analyzer=None) 72 | 73 | def test_long_chain(self): 74 | """Check if logical and/or both work fine in long combinations with each other.""" 75 | # Long chain of true_analyzers must evaluate to True 76 | and_analyzer = ( 77 | self.true_analyzer & self.true_analyzer & self.true_analyzer & self.true_analyzer & self.true_analyzer & self.true_analyzer & self.true_analyzer & self.true_analyzer 78 | ) 79 | self.assertTrue(and_analyzer.match(self.update_mock)) 80 | 81 | # A single false_analyzer must make the term evaluate to false 82 | and_analyzer2 = ( 83 | self.true_analyzer & self.true_analyzer & self.true_analyzer & self.false_analyzer & self.true_analyzer & self.true_analyzer & self.true_analyzer & self.true_analyzer 84 | ) 85 | self.assertFalse(and_analyzer2.match(self.update_mock)) 86 | 87 | # Since one returns true, this should return True as well 88 | or_analyzer = ( 89 | self.false_analyzer 90 | | self.false_analyzer 91 | | self.false_analyzer 92 | | self.false_analyzer 93 | | self.false_analyzer 94 | | self.true_analyzer 95 | | self.true_analyzer 96 | | self.true_analyzer 97 | ) 98 | self.assertTrue(or_analyzer.match(self.update_mock)) 99 | 100 | def test_list_and(self): 101 | """Check if other return values than booleans are handled correctly with logical and.""" 102 | and_analyzer = self.NewAnalyzer(["Test", 123]) & self.NewAnalyzer([]) 103 | res = and_analyzer.match(self.update_mock) 104 | self.assertIsInstance(res, bool) 105 | self.assertFalse(res) 106 | 107 | def test_list_or(self): 108 | """Check if other return values than booleans are handled correctly with logical or.""" 109 | and_analyzer = self.NewAnalyzer(["Test", 123]) | self.NewAnalyzer([]) 110 | res = and_analyzer.match(self.update_mock) 111 | self.assertIsInstance(res, bool) 112 | self.assertTrue(res) 113 | 114 | 115 | if __name__ == "__main__": 116 | unittest.main() 117 | -------------------------------------------------------------------------------- /certleak/analyzers/tests/precertanalyzer_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import Mock 3 | 4 | from certleak.actions import BasicAction 5 | from certleak.analyzers import PreCertAnalyzer 6 | 7 | 8 | class PreCertAnalyzerTest(unittest.TestCase): 9 | def setUp(self): 10 | """Set up the test case.""" 11 | self.analyzer = PreCertAnalyzer(None) 12 | 13 | def test_positive(self): 14 | """Check if analyzer matches updates of type PreCertAnalyzer.""" 15 | update = Mock() 16 | update.update_type = "PrecertLogEntry" 17 | self.assertTrue(self.analyzer.match(update)) 18 | 19 | def test_negative(self): 20 | """Check if analyzer doesn't match anything else.""" 21 | update = Mock() 22 | update.update_type = "OtherEntry" 23 | self.assertFalse(self.analyzer.match(update)) 24 | 25 | def test_actions(self): 26 | """Check if actions are set up properly.""" 27 | action1 = Mock(spec=BasicAction) 28 | action2 = Mock(spec=BasicAction) 29 | self.analyzer = PreCertAnalyzer(action1) 30 | self.assertEqual(1, len(self.analyzer.actions)) 31 | self.assertEqual(action1, self.analyzer.actions[0]) 32 | 33 | self.analyzer = PreCertAnalyzer(actions=[action1, action2]) 34 | self.assertEqual(2, len(self.analyzer.actions)) 35 | self.assertIn(action1, self.analyzer.actions) 36 | self.assertIn(action2, self.analyzer.actions) 37 | 38 | 39 | if __name__ == "__main__": 40 | unittest.main() 41 | -------------------------------------------------------------------------------- /certleak/analyzers/tests/subdomainanalyzer_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest import mock 3 | 4 | from certleak.actions.basicaction import BasicAction 5 | from certleak.analyzers import SubDomainAnalyzer 6 | 7 | 8 | class TestSubDomainAnalyzer(unittest.TestCase): 9 | def setUp(self): 10 | self.update = mock.Mock() 11 | self.action = mock.MagicMock(spec=BasicAction) 12 | 13 | def test_setup(self): 14 | """Check if the word list is initialized correctly.""" 15 | analyzer = SubDomainAnalyzer(self.action, "test") 16 | self.assertEqual("test", analyzer.subdomains[0]) 17 | self.assertEqual(True, analyzer.exact_match) 18 | 19 | analyzer = SubDomainAnalyzer(self.action, "test2", exact_match=False) 20 | self.assertEqual("test2", analyzer.subdomains[0]) 21 | self.assertEqual(False, analyzer.exact_match) 22 | 23 | def test_single_word_exact(self): 24 | """Check if the analyzer matches in "exact" mode if passing a single word to it.""" 25 | analyzer = SubDomainAnalyzer(self.action, "test", exact_match=True) 26 | update = mock.Mock() 27 | update.all_domains = ["test.example.com", "test2.example.com", "test.beispiel.de", "nosubdomain.de"] 28 | matches = analyzer.match(update) 29 | self.assertTrue(matches) 30 | self.assertEqual(2, len(matches)) 31 | self.assertIn("test.example.com", matches) 32 | self.assertIn("test.beispiel.de", matches) 33 | 34 | analyzer = SubDomainAnalyzer(self.action, "test2") 35 | matches = analyzer.match(update) 36 | self.assertTrue(matches) 37 | self.assertEqual(1, len(matches)) 38 | self.assertEqual("test2.example.com", matches[0]) 39 | 40 | analyzer = SubDomainAnalyzer(self.action, "mytest") 41 | self.assertFalse(analyzer.match(update)) 42 | 43 | def test_single_word_contained(self): 44 | """Check if the analyzer matches substrings if not in exact mode.""" 45 | analyzer = SubDomainAnalyzer(self.action, "test", exact_match=False) 46 | update = mock.Mock() 47 | update.all_domains = ["test.example.com", "test2.example.com", "test.beispiel.de", "nosubdomain.de"] 48 | matches = analyzer.match(update) 49 | self.assertTrue(matches) 50 | self.assertEqual(3, len(matches)) 51 | self.assertIn("test.example.com", matches) 52 | self.assertIn("test.beispiel.de", matches) 53 | self.assertIn("test2.example.com", matches) 54 | 55 | analyzer = SubDomainAnalyzer(self.action, "test2") 56 | matches = analyzer.match(update) 57 | self.assertTrue(matches) 58 | self.assertEqual(1, len(matches)) 59 | self.assertEqual("test2.example.com", matches[0]) 60 | 61 | analyzer = SubDomainAnalyzer(self.action, "mytest") 62 | self.assertFalse(analyzer.match(update)) 63 | 64 | def test_empty_string(self): 65 | """Check if the analyzer matches domains without subdomains if the subdomain param is an empty string.""" 66 | analyzer = SubDomainAnalyzer(self.action, "") 67 | 68 | update = mock.Mock() 69 | update.all_domains = ["test.example.com", "test2.example.com", "test.beispiel.de", "nosubdomain.de"] 70 | 71 | matches = analyzer.match(update) 72 | self.assertTrue(matches) 73 | self.assertEqual("nosubdomain.de", matches[0]) 74 | 75 | def test_none_update(self): 76 | """Check if the analyzer matches if the update object is None.""" 77 | analyzer = SubDomainAnalyzer(self.action, "test") 78 | update = None 79 | matches = analyzer.match(update) 80 | self.assertFalse(matches) 81 | self.assertEqual(0, len(matches)) 82 | 83 | def test_empty_update_domains(self): 84 | """Check if the analyzer matches if the list of domains is empty.""" 85 | analyzer = SubDomainAnalyzer(self.action, "test") 86 | update = mock.Mock() 87 | update.all_domains = [] 88 | matches = analyzer.match(update) 89 | self.assertFalse(matches) 90 | self.assertEqual(0, len(matches)) 91 | 92 | def test_multiple_words(self): 93 | """Test if the analyzer matches multiple words correctly.""" 94 | analyzer = SubDomainAnalyzer(self.action, ["test", "example"]) 95 | update = mock.Mock() 96 | update.all_domains = ["test.example.com", "test2.example.com", "example.beispiel.de", "nosubdomain.de"] 97 | matches = analyzer.match(update) 98 | self.assertTrue(matches) 99 | self.assertEqual(2, len(matches)) 100 | self.assertIn("test.example.com", matches) 101 | self.assertIn("example.beispiel.de", matches) 102 | 103 | analyzer = SubDomainAnalyzer(self.action, ["Nothing", "Matches"]) 104 | matches = analyzer.match(update) 105 | self.assertFalse(matches) 106 | self.assertEqual(0, len(matches)) 107 | 108 | analyzer = SubDomainAnalyzer(self.action, ["Nothing", "Matches", "anotherone", "ThisIsFun", "nosubdomain", "lastone"]) 109 | matches = analyzer.match(update) 110 | self.assertFalse(matches) 111 | self.assertEqual(0, len(matches)) 112 | 113 | def test_actions_present(self): 114 | """Check if the action passed to the analyzer is being stored.""" 115 | action = mock.MagicMock(spec=BasicAction) 116 | analyzer = SubDomainAnalyzer(action, "word") 117 | self.assertEqual([action], analyzer.actions) 118 | 119 | 120 | if __name__ == "__main__": 121 | unittest.main() 122 | -------------------------------------------------------------------------------- /certleak/analyzers/tests/wildcardcertanalyzer_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import Mock, patch 3 | 4 | from certleak.analyzers import WildcardCertAnalyzer 5 | 6 | 7 | class WildcardCertAnalyzerTest(unittest.TestCase): 8 | def setUp(self): 9 | """Set up the test case.""" 10 | self.analyzer = WildcardCertAnalyzer(None) 11 | 12 | def test_None(self): 13 | """Check if an empty list of domains returns False.""" 14 | update = None 15 | self.assertFalse(self.analyzer.match(update)) 16 | 17 | def test_empty_domain_list(self): 18 | """Check if an empty list of domains returns False.""" 19 | update = Mock() 20 | update.all_domains = [] 21 | self.assertFalse(self.analyzer.match(update)) 22 | 23 | def test_no_wildcard(self): 24 | """Check if a list of non-wildcard domains returns False.""" 25 | update = Mock() 26 | update.all_domains = ["test.de", "example.com", "subdomain.example.com"] 27 | self.assertFalse(self.analyzer.match(update)) 28 | 29 | def test_no_domain(self): 30 | """Check if a list of non-domains returns False.""" 31 | update = Mock() 32 | update.all_domains = ["this is no domain", "idk, smth weird", "yes"] 33 | self.assertFalse(self.analyzer.match(update)) 34 | 35 | def test_one_wildcard_mixed(self): 36 | """Check if a list with a single wildcard domain mixed with other domains returns True.""" 37 | update = Mock() 38 | update.all_domains = ["test.de", "example.com", "subdomain.example.com", "*.example.com"] 39 | self.assertTrue(self.analyzer.match(update)) 40 | 41 | def test_multiple_wildcards_mixed(self): 42 | """Check if a list with multiple wildcard domains mixed with other domains returns True.""" 43 | update = Mock() 44 | update.all_domains = ["test.de", "example.com", "*.test.com", "subdomain.example.com", "*.example.com"] 45 | self.assertTrue(self.analyzer.match(update)) 46 | 47 | def test_multiple_wildcards(self): 48 | """Check if a list with multiple wildcard domains with no other domains returns True.""" 49 | update = Mock() 50 | update.all_domains = ["*.test.com", "*.example.com"] 51 | self.assertTrue(self.analyzer.match(update)) 52 | 53 | def test_single_wildcard(self): 54 | """Check if a list with multiple wildcard domains with no other domains returns True.""" 55 | update = Mock() 56 | update.all_domains = ["*.example.com"] 57 | self.assertTrue(self.analyzer.match(update)) 58 | 59 | def test_blacklist_multiple(self): 60 | """Check if blacklisting multiple word works as intended.""" 61 | self.analyzer = WildcardCertAnalyzer(None, blacklist=["example", "test"]) 62 | update = Mock() 63 | update.all_domains = ["*.example.com", "*.test.com"] 64 | self.assertFalse(self.analyzer.match(update)) 65 | 66 | def test_blacklist_single(self): 67 | """Check if blacklisting a single word works as intended.""" 68 | self.analyzer = WildcardCertAnalyzer(None, blacklist=["example"]) 69 | update = Mock() 70 | update.all_domains = ["*.example.com"] 71 | self.assertFalse(self.analyzer.match(update)) 72 | 73 | def test_blacklist_valid_domain(self): 74 | """Check if blacklisting a single word but with a valid domain works as intended.""" 75 | self.analyzer = WildcardCertAnalyzer(None, blacklist=["example"]) 76 | update = Mock() 77 | update.all_domains = ["*.example.com", "*.test.com"] 78 | match = self.analyzer.match(update) 79 | self.assertTrue(match) 80 | 81 | self.assertEqual(len(match), 1) 82 | self.assertEqual(match[0], "*.test.com") 83 | 84 | def test_match_content(self): 85 | """Check if blacklisting a single word but with a valid domain works as intended.""" 86 | update = Mock() 87 | update.all_domains = ["*.example.com", "*.test.com", "*.asdf.com"] 88 | match = self.analyzer.match(update) 89 | self.assertTrue(match) 90 | 91 | self.assertEqual(len(match), 3) 92 | self.assertIn("*.example.com", match) 93 | self.assertIn("*.test.com", match) 94 | self.assertIn("*.asdf.com", match) 95 | 96 | @patch("certleak.analyzers.wildcardcertanalyzer.tldextract") 97 | def test_match_exception(self, tldextract_mock): 98 | """Check if exception in tldextract 'extract' method is handled correctly.""" 99 | tldextract_mock.extract = Mock(side_effect=Exception("Test exception for tldextract")) 100 | 101 | update = Mock() 102 | update.all_domains = ["*.example.com", "*.test.com", "*.asdf.com"] 103 | 104 | # The extract method will raise an exception that must be catched 105 | matches = self.analyzer.match(update) 106 | tldextract_mock.extract.assert_called() 107 | self.assertFalse(matches) 108 | self.assertEqual(0, len(matches)) 109 | 110 | 111 | if __name__ == "__main__": 112 | unittest.main() 113 | -------------------------------------------------------------------------------- /certleak/analyzers/tests/x509analyzer_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import Mock 3 | 4 | from certleak.analyzers import X509Analyzer 5 | 6 | 7 | class X509AnalyzerTest(unittest.TestCase): 8 | def setUp(self): 9 | """Set up the test case.""" 10 | self.analyzer = X509Analyzer(None) 11 | 12 | def test_positive(self): 13 | """Check if analyzer matches updates of type X509LogEntry.""" 14 | update = Mock() 15 | update.update_type = "X509LogEntry" 16 | self.assertTrue(self.analyzer.match(update)) 17 | 18 | def test_negative(self): 19 | """Check if analyzer doesn't match anything else.""" 20 | update = Mock() 21 | update.update_type = "OtherEntry" 22 | self.assertFalse(self.analyzer.match(update)) 23 | 24 | 25 | if __name__ == "__main__": 26 | unittest.main() 27 | -------------------------------------------------------------------------------- /certleak/analyzers/tldanalyzer.py: -------------------------------------------------------------------------------- 1 | import tldextract 2 | 3 | from certleak.util import listify 4 | 5 | from .basicanalyzer import BasicAnalyzer 6 | 7 | 8 | class TLDAnalyzer(BasicAnalyzer): 9 | """Analyzer to check for certain TLDs.""" 10 | 11 | name = "TLDAnalyzer" 12 | 13 | def __init__(self, actions, filtered_tlds, blacklist=None): 14 | super().__init__(actions) 15 | self.filtered_tlds = listify(filtered_tlds) 16 | self.blacklist = listify(blacklist) 17 | 18 | def match(self, update): 19 | """Matches if the TLD of any domain contains any of the passed TLDs. 20 | 21 | :param update: The certificate Update object 22 | :return: 23 | """ 24 | matches = [] 25 | if update is None or update.all_domains is None: 26 | self.logger.warning("Update is None!") 27 | return matches 28 | 29 | for full_domain in update.all_domains: 30 | try: 31 | extract_result = tldextract.extract(full_domain) 32 | 33 | for tld in self.filtered_tlds: 34 | if extract_result.suffix == tld and not any(word in full_domain for word in self.blacklist): 35 | matches.append(full_domain) 36 | except Exception: 37 | self.logger.exception("During matching of an update, an exception occurred") 38 | 39 | return set(matches) 40 | -------------------------------------------------------------------------------- /certleak/analyzers/wildcardcertanalyzer.py: -------------------------------------------------------------------------------- 1 | import tldextract 2 | 3 | from certleak.util import listify 4 | 5 | from .basicanalyzer import BasicAnalyzer 6 | 7 | 8 | class WildcardCertAnalyzer(BasicAnalyzer): 9 | """Analyzer for finding wildcard certificates.""" 10 | 11 | name = "WildcardCertAnalyzer" 12 | 13 | def __init__(self, actions, blacklist=None): 14 | super().__init__(actions) 15 | self.blacklist = listify(blacklist) 16 | 17 | def match(self, update): 18 | """Matches if at least one of the subdomains of this certificate is '*', which is a wildcard domain. 19 | 20 | :param update: The certificate Update object 21 | :return: 22 | """ 23 | matches = [] 24 | if update is None or update.all_domains is None: 25 | self.logger.warning("Update is None!") 26 | return matches 27 | 28 | for full_domain in update.all_domains: 29 | try: 30 | extract_result = tldextract.extract(full_domain) 31 | except Exception: 32 | self.logger.exception("During matching of an update, an exception occurred") 33 | continue 34 | 35 | if extract_result.subdomain == "*" and not any(word in full_domain for word in self.blacklist): 36 | matches.append(full_domain) 37 | 38 | return list(set(matches)) 39 | -------------------------------------------------------------------------------- /certleak/analyzers/x509analyzer.py: -------------------------------------------------------------------------------- 1 | from .basicanalyzer import BasicAnalyzer 2 | 3 | 4 | class X509Analyzer(BasicAnalyzer): 5 | """Analyzer for finding X509 certificate updates.""" 6 | 7 | name = "X509Analyzer" 8 | 9 | def __init__(self, actions=None): 10 | super().__init__(actions) 11 | 12 | def match(self, update): 13 | if not update or not update.update_type: 14 | return False 15 | return update.update_type == "X509LogEntry" 16 | -------------------------------------------------------------------------------- /certleak/core/__init__.py: -------------------------------------------------------------------------------- 1 | # Do not mess with the order of the imports 2 | # Otherwise there will be circular imports -> bad 3 | 4 | from .actionhandler import ActionHandler 5 | from .analyzerhandler import AnalyzerHandler 6 | from .certleak import CertLeak 7 | from .certstreamdata.message import Message 8 | from .certstreamdata.update import Update 9 | from .certstreamwrapper import CertstreamWrapper 10 | 11 | __all__ = ["ActionHandler", "AnalyzerHandler", "CertLeak", "CertstreamWrapper", "Message", "Update"] 12 | -------------------------------------------------------------------------------- /certleak/core/actionhandler.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from queue import Empty, Queue 3 | from threading import Event, Lock 4 | from time import sleep 5 | 6 | from certleak.util import join_threads, start_thread 7 | 8 | 9 | class ActionHandler: 10 | """Handler to execute all the actions, if an analyzer matches an update.""" 11 | 12 | def __init__(self, action_queue=None, exception_event=None, stop_event=None): 13 | self.logger = logging.getLogger(__name__) 14 | self.action_queue = action_queue or Queue() 15 | self.__exception_event = exception_event or Event() 16 | self.__stop_event = stop_event or Event() 17 | self.__threads = [] 18 | 19 | self.running = False 20 | self.__lock = Lock() 21 | 22 | def start(self, ready=None): 23 | """Start the actionhandler to execute actions if updates are matched. 24 | 25 | :param ready: Event to check from the outside if the actionhandler has been started 26 | :return: None 27 | """ 28 | with self.__lock: 29 | if not self.running: 30 | self.running = True 31 | 32 | thread = start_thread(self._start, "ActionHandler", self.__exception_event) 33 | self.__threads.append(thread) 34 | 35 | if ready is not None: 36 | ready.set() 37 | 38 | def stop(self): 39 | """Stop the actionhandler. 40 | 41 | :return: None 42 | """ 43 | self.logger.info("Orderly stopping ActionHandler!") 44 | with self.__lock: 45 | self.__stop_event.set() 46 | while self.running: 47 | sleep(0.1) 48 | self.__stop_event.clear() 49 | 50 | join_threads(self.__threads) 51 | self.__threads = [] 52 | self.logger.info("ActionHandler stopped!") 53 | 54 | def _start(self): 55 | while self.running: 56 | try: 57 | # Get update from queue 58 | actions, update, analyzer, matches = self.action_queue.get(True, 1) 59 | if actions is None: 60 | continue 61 | 62 | for action in actions: 63 | self._perform_action_wrapper(action, update, analyzer, matches) 64 | except Empty: 65 | if self.__stop_event.is_set(): 66 | self.logger.debug("orderly stopping ActionHandler") 67 | self.running = False 68 | break 69 | elif self.__exception_event.is_set(): 70 | self.logger.critical("stopping ActionHandler due to exception in another thread") 71 | self.running = False 72 | break 73 | 74 | def _perform_action_wrapper(self, action, update, analyzer, matches): 75 | """A wrapper around the perform method to catch exceptions.""" 76 | try: 77 | self.logger.debug("Performing action '%s' on update '%s' matched by analyzer '%s'!", action.name, update.all_domains, analyzer.identifier) 78 | action.perform(update, analyzer.identifier, matches) 79 | except Exception: 80 | self.logger.exception("While performing the action '%s' an exception occurred", action.name) 81 | -------------------------------------------------------------------------------- /certleak/core/analyzerhandler.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from queue import Empty, Queue 3 | from threading import Event, Lock 4 | from time import sleep 5 | 6 | from certleak.util import join_threads, start_thread 7 | 8 | 9 | class AnalyzerHandler: 10 | """The AnalyzerHandler dispatches certificate updates to the analyzers.""" 11 | 12 | def __init__(self, update_queue, action_queue=None, exception_event=None): 13 | self.logger = logging.getLogger(__name__) 14 | self.update_queue = update_queue 15 | self.action_queue = action_queue or Queue() 16 | self.analyzers = [] 17 | self.running = False 18 | 19 | self.__lock = Lock() 20 | self.__threads = [] 21 | self.__thread_pool = set() 22 | self.__exception_event = exception_event or Event() 23 | self.__stop_event = Event() 24 | 25 | def _pool_thread(self): 26 | while True: 27 | pass 28 | 29 | def add_analyzer(self, analyzer): 30 | """Add an analyzer to the list of analyzers.""" 31 | with self.__lock: 32 | if analyzer not in self.analyzers: 33 | self.analyzers.append(analyzer) 34 | 35 | def start(self, workers=4, ready=None): 36 | """Start dispatching the certificate updates to the list of analyzers.""" 37 | with self.__lock: 38 | if not self.running: 39 | if len(self.analyzers) == 0: 40 | self.logger.warning("No analyzers added! At least one analyzer must be added prior to use!") 41 | return None 42 | 43 | self.running = True 44 | thread = start_thread(self._start_analyzing, "AnalyzerHandler", exception_event=self.__exception_event) 45 | self.__threads.append(thread) 46 | 47 | if ready is not None: 48 | ready.set() 49 | 50 | return self.action_queue 51 | 52 | def _start_analyzing(self): 53 | while self.running: 54 | try: 55 | # Get cert update from queue 56 | update = self.update_queue.get(block=True, timeout=1) 57 | 58 | # TODO implement thread pool to limit number of parallel executed threads 59 | # Don't add these threads to the list. Otherwise they will just block the list 60 | start_thread(self._process_update, "process_update", update=update, exception_event=self.__exception_event) 61 | except Empty: 62 | if self.__stop_event.is_set(): 63 | self.logger.debug("orderly stopping") 64 | self.running = False 65 | break 66 | elif self.__exception_event.is_set(): 67 | self.logger.critical("stopping due to exception in another thread") 68 | self.running = False 69 | break 70 | continue 71 | 72 | def _process_update(self, update): 73 | if update is None: 74 | self.logger.warning("Update is None, skipping!") 75 | 76 | self.logger.debug("Analyzing update: %s", update.all_domains) 77 | for analyzer in self.analyzers: 78 | matches = analyzer.match(update) 79 | 80 | if matches: 81 | # If the analyzer just returns a boolean, we pass an empty list 82 | if isinstance(matches, bool): 83 | # matches == True, hence we pass an empty list 84 | matches = [] 85 | elif not isinstance(matches, list): 86 | # when matches is not a bool, we pass the object as list 87 | matches = [matches] 88 | actions = analyzer.actions 89 | self.action_queue.put((actions, update, analyzer, matches)) 90 | 91 | def stop(self): 92 | """Stop dispatching updates to the analyzers.""" 93 | self.logger.info("Orderly stopping AnalyzerHandler!") 94 | self.__stop_event.set() 95 | while self.running: 96 | sleep(0.1) 97 | self.__stop_event.clear() 98 | 99 | join_threads(self.__threads) 100 | self.__threads = [] 101 | self.logger.info("AnalyzerHandler stopped!") 102 | -------------------------------------------------------------------------------- /certleak/core/certleak.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from queue import Queue 3 | from signal import SIGABRT, SIGINT, SIGTERM, signal 4 | from threading import Event 5 | from time import sleep 6 | 7 | from certleak.core.actionhandler import ActionHandler 8 | from certleak.core.analyzerhandler import AnalyzerHandler 9 | from certleak.core.certstreamwrapper import CertstreamWrapper 10 | from certleak.version import __version__ 11 | 12 | 13 | class CertLeak: 14 | def __init__(self, certstream_url="wss://certstream.calidog.io/"): 15 | """Basic CertLeak object, handling the connection to the certstream network and all the analyzers and actions. 16 | 17 | :param certstream_url: The websocket URL from which certstream fetches new cert updates 18 | """ 19 | self.logger = logging.getLogger(__name__) 20 | self.is_idle = True 21 | self.update_queue = Queue() 22 | self.action_queue = Queue() 23 | self.exception_event = Event() 24 | self.certstream_url = certstream_url 25 | self.c = None 26 | 27 | self.analyzer_handler = AnalyzerHandler(update_queue=self.update_queue, action_queue=self.action_queue, exception_event=self.exception_event) 28 | self.action_handler = ActionHandler(action_queue=self.action_queue, exception_event=self.exception_event) 29 | self.certstream_wrapper = CertstreamWrapper(update_queue=self.update_queue, certstream_url=certstream_url, exception_event=self.exception_event) 30 | 31 | def start(self): 32 | """Start CertLeak. 33 | 34 | :return: 35 | """ 36 | self.logger.info(f"Starting certleak v{__version__}!") 37 | self.certstream_wrapper.start() 38 | self.analyzer_handler.start() 39 | self.action_handler.start() 40 | # Run until signal is received 41 | self.idle() 42 | 43 | def stop(self): 44 | """Stop CertLeak. 45 | 46 | :return: 47 | """ 48 | self.logger.info("Orderly stopping certleak!") 49 | self.certstream_wrapper.stop() 50 | self.analyzer_handler.stop() 51 | self.action_handler.stop() 52 | self.logger.info("Certleak stopped!") 53 | 54 | def signal_handler(self, signum, frame): 55 | """Handler method to handle signals.""" 56 | self.logger.info("Received signal %s, stopping...", signum) 57 | self.stop() 58 | self.is_idle = False 59 | 60 | def add_analyzer(self, analyzer): 61 | """Add a new analyzer to the list of analyzers. 62 | 63 | :param analyzer: Instance of a BasicAnalyzer 64 | :return: None 65 | """ 66 | self.analyzer_handler.add_analyzer(analyzer) 67 | 68 | def idle(self, stop_signals=(SIGINT, SIGTERM, SIGABRT)): 69 | """Blocks until one of the signals are received and stops the updater. 70 | 71 | Thanks to the python-telegram-bot developers - https://github.com/python-telegram-bot/python-telegram-bot/blob/2cde878d1e5e0bb552aaf41d5ab5df695ec4addb/telegram/ext/updater.py#L514-L529 72 | 73 | :param stop_signals: The signals to which the code reacts to 74 | """ 75 | self.is_idle = True 76 | 77 | for sig in stop_signals: 78 | signal(sig, self.signal_handler) 79 | 80 | while self.is_idle: 81 | if self.exception_event.is_set(): 82 | self.logger.warning("An exception occurred. Calling exception handlers and going down!") 83 | 84 | self.is_idle = False 85 | self.stop() 86 | return 87 | 88 | sleep(1) 89 | -------------------------------------------------------------------------------- /certleak/core/certstreamdata/__init__.py: -------------------------------------------------------------------------------- 1 | from .certstreamobject import CertstreamObject 2 | from .chain import Chain 3 | from .extensions import Extensions 4 | from .leafcert import LeafCert 5 | from .message import Message 6 | from .subject import Subject 7 | from .update import Update 8 | 9 | __all__ = ["CertstreamObject", "Chain", "Extensions", "LeafCert", "Message", "Subject", "Update"] 10 | -------------------------------------------------------------------------------- /certleak/core/certstreamdata/certstreamobject.py: -------------------------------------------------------------------------------- 1 | class CertstreamObject: 2 | """Base class for all the certstream data classes.""" 3 | 4 | @classmethod 5 | def from_dict(cls, data): 6 | """Return a copy of the passed data. 7 | 8 | :param data: The dict from which an object should be created from 9 | :return: copy of data or None. 10 | """ 11 | if not data: 12 | return None 13 | 14 | return data.copy() 15 | -------------------------------------------------------------------------------- /certleak/core/certstreamdata/chain.py: -------------------------------------------------------------------------------- 1 | from .certstreamobject import CertstreamObject 2 | from .leafcert import LeafCert 3 | 4 | 5 | class Chain(CertstreamObject): 6 | """Data class representing a certificate chain.""" 7 | 8 | def __init__(self, cert_list): 9 | """Data class representing a certificate chain. 10 | 11 | :param cert_list: List of parent certificates used to create the actual certificate of the Update. 12 | """ 13 | super().__init__() 14 | self._chain = cert_list 15 | self._index = 0 16 | 17 | @classmethod 18 | def from_dict(cls, data): 19 | """Create a Chain object from a dict. 20 | 21 | :param data: dictionary data type containing the necessary data 22 | :return: 23 | """ 24 | if not data: 25 | return None 26 | 27 | data = super().from_dict(data) 28 | cert_list = [] 29 | 30 | for cert in data: 31 | c = LeafCert.from_dict(cert) 32 | cert_list.append(c) 33 | 34 | return cls(cert_list=cert_list) 35 | 36 | def __iter__(self): 37 | """Return the iterator object.""" 38 | return self 39 | 40 | def __next__(self): 41 | """Return the next certificate in the chain.""" 42 | if self._index < len(self._chain): 43 | result = self._chain[self._index] 44 | self._index += 1 45 | return result 46 | self._index = 0 47 | raise StopIteration 48 | 49 | def __repr__(self): 50 | """Return a string representation of the object.""" 51 | return f"{self._chain}" 52 | -------------------------------------------------------------------------------- /certleak/core/certstreamdata/extensions.py: -------------------------------------------------------------------------------- 1 | from .certstreamobject import CertstreamObject 2 | 3 | 4 | class Extensions(CertstreamObject): 5 | """Data class representing the certificate extensions.""" 6 | 7 | def __init__( 8 | self, 9 | keyUsage, 10 | extendedKeyUsage, 11 | basicConstraints, 12 | subjectKeyIdentifier, 13 | authorityKeyIdentifier, 14 | authorityInfoAccess, 15 | subjectAltName, 16 | certificatePolicies, 17 | ctlSignedCertificateTimestamp, 18 | ): 19 | """Data class representing the certificate extensions. 20 | 21 | :param keyUsage: keyUsage extension 22 | :param extendedKeyUsage: extendedKeyUsage extension 23 | :param basicConstraints: basicConstraints extension 24 | :param subjectKeyIdentifier: subjectKeyIdentifier extension 25 | :param authorityKeyIdentifier: authorityKeyIdentifier extension 26 | :param authorityInfoAccess: authorityInfoAccess extension 27 | :param subjectAltName: subjectAltName extension 28 | :param certificatePolicies: certificatePolicies extension 29 | :param ctlSignedCertificateTimestamp: ctlSignedCertificateTimestamp extension. 30 | """ 31 | super().__init__() 32 | self.keyUsage = keyUsage 33 | self.extendedKeyUsage = extendedKeyUsage 34 | self.basicConstraints = basicConstraints 35 | self.subjectKeyIdentifier = subjectKeyIdentifier 36 | self.authorityKeyIdentifier = authorityKeyIdentifier 37 | self.authorityInfoAccess = authorityInfoAccess 38 | self.subjectAltName = subjectAltName 39 | self.certificatePolicies = certificatePolicies 40 | self.ctlSignedCertificateTimestamp = ctlSignedCertificateTimestamp 41 | 42 | @classmethod 43 | def from_dict(cls, data): 44 | """Create an Extensions object from a dict. 45 | 46 | :param data: dictionary data type containing the necessary data 47 | :return: 48 | """ 49 | if not data: 50 | return None 51 | 52 | data = super().from_dict(data) 53 | keyUsage = data.get("keyUsage") 54 | extendedKeyUsage = data.get("extendedKeyUsage") 55 | basicConstraints = data.get("basicConstraints") 56 | subjectKeyIdentifier = data.get("subjectKeyIdentifier") 57 | authorityKeyIdentifier = data.get("authorityKeyIdentifier") 58 | authorityInfoAccess = data.get("authorityInfoAccess") 59 | subjectAltName = data.get("subjectAltName") 60 | certificatePolicies = data.get("certificatePolicies") 61 | ctlSignedCertificateTimestamp = data.get("ctlSignedCertificateTimestamp") 62 | 63 | return cls( 64 | keyUsage=keyUsage, 65 | extendedKeyUsage=extendedKeyUsage, 66 | basicConstraints=basicConstraints, 67 | subjectKeyIdentifier=subjectKeyIdentifier, 68 | authorityKeyIdentifier=authorityKeyIdentifier, 69 | authorityInfoAccess=authorityInfoAccess, 70 | subjectAltName=subjectAltName, 71 | certificatePolicies=certificatePolicies, 72 | ctlSignedCertificateTimestamp=ctlSignedCertificateTimestamp, 73 | ) 74 | 75 | def __repr__(self): 76 | """Return a string representation of the object.""" 77 | return ( 78 | f"keyUsage: {self.keyUsage}, extendedKeyUsage: {self.extendedKeyUsage}, basicContstraints: {self.basicConstraints}, subjectKeyIdentifier: " 79 | f"{self.subjectKeyIdentifier}, authorityKeyIdentifier: {self.authorityKeyIdentifier}, subjectAltName: {self.subjectAltName}, " 80 | f"certificatePolicies: {self.certificatePolicies}, ctlSignedCertificateTimestamp: {self.ctlSignedCertificateTimestamp}" 81 | ) 82 | -------------------------------------------------------------------------------- /certleak/core/certstreamdata/leafcert.py: -------------------------------------------------------------------------------- 1 | from .certstreamobject import CertstreamObject 2 | from .extensions import Extensions 3 | from .subject import Subject 4 | 5 | 6 | class LeafCert(CertstreamObject): 7 | """Data class for the LeafCert data structure of certstream.""" 8 | 9 | def __init__(self, subject, issuer, extensions, not_before, not_after, serial_number, fingerprint, signature_algorithm, all_domains, as_der): 10 | """Data class for the LeafCert data structure of certstream. 11 | 12 | :param subject: Certificate subject 13 | :param extensions: Certificate extensions 14 | :param not_before: 'Not before' validity field 15 | :param not_after: 'Not after' validity field 16 | :param serial_number: Serial number of the certificate 17 | :param fingerprint: Certificate fingerprint 18 | :param signature_algorithm: Signature algorithm 19 | :param all_domains: List of all domains contained in this cert 20 | :param as_der: DER (Distinguished Encoding Rules) encoded binary representation of the certificate, only 21 | available for the /full-stream endpoint. Will be None otherwise. 22 | """ 23 | super().__init__() 24 | self.subject = subject 25 | self.issuer = issuer 26 | self.extensions = extensions 27 | self.not_before = not_before 28 | self.not_after = not_after 29 | self.serial_number = serial_number 30 | self.fingerprint = fingerprint 31 | self.signature_algorithm = signature_algorithm 32 | self.all_domains = all_domains 33 | self.as_der = as_der 34 | 35 | @classmethod 36 | def from_dict(cls, data): 37 | """Create a LeafCert object from a dict. 38 | 39 | :param data: dictionary data type containing the necessary data 40 | :return: 41 | """ 42 | if not data: 43 | return None 44 | 45 | data = super().from_dict(data) 46 | subject = Subject.from_dict(data.get("subject")) 47 | issuer = Subject.from_dict(data.get("issuer")) 48 | extensions = Extensions.from_dict(data.get("extensions")) 49 | not_before = data.get("not_before") 50 | not_after = data.get("not_after") 51 | serial_number = data.get("serial_number") 52 | fingerprint = data.get("fingerprint") 53 | all_domains = data.get("all_domains") 54 | signature_algorithm = data.get("signature_algorithm") 55 | as_der = data.get("as_der") 56 | 57 | return cls( 58 | subject=subject, 59 | issuer=issuer, 60 | extensions=extensions, 61 | not_before=not_before, 62 | not_after=not_after, 63 | serial_number=serial_number, 64 | fingerprint=fingerprint, 65 | signature_algorithm=signature_algorithm, 66 | all_domains=all_domains, 67 | as_der=as_der, 68 | ) 69 | 70 | def __repr__(self): 71 | """Return a string representation of the object.""" 72 | return f"(subject: {self.subject}, issuer: {self.issuer}, extensions: {self.extensions}, not_before: {self.not_before}, not_after: {self.not_after}, serial_number: {self.serial_number}, fingerprint: {self.fingerprint}, all_domains: {self.all_domains}, as_der: {self.as_der})" 73 | -------------------------------------------------------------------------------- /certleak/core/certstreamdata/message.py: -------------------------------------------------------------------------------- 1 | from .certstreamobject import CertstreamObject 2 | from .update import Update 3 | 4 | 5 | class Message(CertstreamObject): 6 | """Data class for the Message data structure of certstream.""" 7 | 8 | def __init__(self, message_type, update): 9 | """Data class for the Message data structure of certstream. 10 | 11 | :param message_type: Type of the message (e.g. 'heartbeat' or 'certificate_update') 12 | :param update: Actual certificate update (called 'data'). 13 | """ 14 | super().__init__() 15 | self.message_type = message_type 16 | self.update = update 17 | 18 | @classmethod 19 | def from_dict(cls, data): 20 | """Create a Message object from a dict. 21 | 22 | :param data: dictionary data type containing the necessary data 23 | :return: 24 | """ 25 | if not data: 26 | return None 27 | 28 | data = super().from_dict(data) 29 | message_type = data.get("message_type") 30 | update = Update.from_dict(data.get("data")) 31 | 32 | return cls(message_type=message_type, update=update) 33 | 34 | def __repr__(self): 35 | """Return a string representation of the object.""" 36 | return "" 37 | -------------------------------------------------------------------------------- /certleak/core/certstreamdata/subject.py: -------------------------------------------------------------------------------- 1 | from .certstreamobject import CertstreamObject 2 | 3 | 4 | class Subject(CertstreamObject): 5 | """Data class for the Subject field of a certificate.""" 6 | 7 | def __init__(self, aggregated, c, st, l, o, ou, cn, email_address): 8 | """Data class for the Subject field of a certificate. 9 | 10 | :param aggregated: Aggregated string of the other RDNs 11 | :param c: CountryName 12 | :param st: StateOrProvinceName 13 | :param l: Locality 14 | :param o: Organization 15 | :param ou: OrganizationalUnit 16 | :param cn: CommonName. 17 | """ 18 | super().__init__() 19 | self.aggregated = aggregated 20 | self.C = c 21 | self.ST = st 22 | self.L = l 23 | self.O = o 24 | self.OU = ou 25 | self.CN = cn 26 | self.email_address = email_address 27 | 28 | @classmethod 29 | def from_dict(cls, data): 30 | """Create a Subject object from a dict. 31 | 32 | :param data: dictionary data type containing the necessary data 33 | :return: 34 | """ 35 | if not data: 36 | return None 37 | 38 | data = super().from_dict(data) 39 | aggregated = data.get("aggregated") 40 | c = data.get("c") 41 | st = data.get("ST") 42 | l = data.get("L") 43 | o = data.get("O") 44 | ou = data.get("OU") 45 | cn = data.get("CN") 46 | email_address = data.get("emailAddress") 47 | 48 | return cls(aggregated=aggregated, c=c, st=st, l=l, o=o, ou=ou, cn=cn, email_address=email_address) 49 | 50 | def __repr__(self): 51 | """Return a string representation of the object.""" 52 | return f"Subject(C='{self.C}', ST='{self.ST}', L='{self.L}', O='{self.O}', OU='{self.OU}', CN='{self.CN}' | aggregated='{self.aggregated}')" 53 | -------------------------------------------------------------------------------- /certleak/core/certstreamdata/update.py: -------------------------------------------------------------------------------- 1 | from .certstreamobject import CertstreamObject 2 | from .chain import Chain 3 | from .leafcert import LeafCert 4 | 5 | 6 | class Update(CertstreamObject): 7 | """Data class for the certificate Update type from certstream.""" 8 | 9 | def __init__(self, update_type, leaf_cert, cert_index, cert_link, seen, source, chain, raw_dict): 10 | """Data class for the certificate Update type from certstream. 11 | 12 | :param update_type: The type of the certificate update 13 | :param leaf_cert: The LeafCert object of this update 14 | :param chain: The cert Chain object of this update 15 | :param cert_index: The cert index of this update 16 | :param seen: When this certificate update has been seen 17 | :param source: The certificate transparency log this update is coming from 18 | :param chain: The full chain to this certificate 19 | :param raw_dict: The raw dict generated by deserializing the json from CertStream. 20 | """ 21 | super().__init__() 22 | self.update_type = update_type 23 | self.leaf_cert = leaf_cert 24 | self.cert_index = cert_index 25 | self.cert_link = cert_link 26 | self.seen = seen 27 | self.source = source 28 | self.all_domains = leaf_cert.all_domains if leaf_cert is not None else "" 29 | self.chain = chain 30 | self.raw_dict = raw_dict 31 | 32 | @classmethod 33 | def from_dict(cls, data): 34 | """Create an Update object from a dict. 35 | 36 | :param data: dictionary data type containing the necessary data 37 | :return: 38 | """ 39 | if not data: 40 | return None 41 | 42 | raw_data = data.copy() 43 | data = super().from_dict(data) 44 | update_type = data.get("update_type") 45 | leaf_cert = LeafCert.from_dict(data.get("leaf_cert")) 46 | cert_index = data.get("cert_index") 47 | cert_link = data.get("cert_link") 48 | seen = data.get("seen") 49 | source = data.get("source") 50 | chain = Chain.from_dict(data.get("chain")) 51 | 52 | return cls(update_type=update_type, leaf_cert=leaf_cert, cert_index=cert_index, cert_link=cert_link, seen=seen, source=source, chain=chain, raw_dict=raw_data) 53 | 54 | def to_dict(self): 55 | return self.raw_dict 56 | 57 | def __repr__(self): 58 | """Return a string representation of the object.""" 59 | return ( 60 | f"(update_type: {self.update_type}, leaf_cert: {self.leaf_cert}, chain: {self.cert_index}, cert_index: {self.cert_link}, seen: {self.seen}, " 61 | f"source: {self.source}, chain: {self.chain})" 62 | ) 63 | 64 | # def __eq__(self, other): 65 | # try: 66 | # if self.update_type == other.update_type and 67 | # self.leaf_cert == other.leaf_cert and 68 | # 69 | # except AttributeError: 70 | # return False 71 | -------------------------------------------------------------------------------- /certleak/core/certstreamwrapper.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | from threading import Event, Lock 4 | 5 | from certstream.core import CertStreamClient 6 | 7 | from certleak.core.certstreamdata.message import Message 8 | from certleak.util import join_threads, start_thread 9 | 10 | 11 | class CertstreamWrapper: 12 | def __init__(self, update_queue, certstream_url, exception_event): 13 | """The CertstreamWrapper is a wrapper around the python certstream module, allowing it to run in its own thread and filling new cert updates into a queue. 14 | 15 | :param update_queue: The queue into which new updates are sent 16 | :param certstream_url: The websocket URL from which certstream fetches new cert updates 17 | :param exception_event: An event that gets set when an unexpected exception occurs. Causes the thread to halt if set. 18 | """ 19 | self.logger = logging.getLogger(__name__) 20 | self.update_queue = update_queue 21 | self.certstream_url = certstream_url 22 | self.certstream_client = None 23 | self.__exception_event = exception_event or Event() 24 | self.__stop_event = Event() 25 | self.__lock = Lock() 26 | self.__threads = [] 27 | self.running = False 28 | 29 | # Statistics 30 | self.last_info = 0 31 | self.update_counter = 0 32 | self.error_counter = 0 33 | self.processed_domains = set() 34 | 35 | def _handle_message(self, message, context): 36 | try: 37 | self._fill_queue(message, context) 38 | except Exception: 39 | self.logger.exception("Exception while handling certstream message!") 40 | 41 | def _fill_queue(self, message, context): 42 | """This method is being used as a callback function for the certstream module. It get's called on each new message. 43 | 44 | :param message: dict 45 | :param context: context 46 | :return: 47 | """ 48 | if message["message_type"] == "heartbeat": 49 | self.logger.debug("New heartbeat received!") 50 | return 51 | 52 | self.update_counter += 1 53 | try: 54 | msg = Message.from_dict(message) 55 | update = msg.update 56 | except Exception: 57 | self.logger.exception("Something went wrong while de_jsoning") 58 | self.error_counter += 1 59 | return 60 | else: 61 | self.update_queue.put(update) 62 | 63 | if msg.update and msg.update.all_domains: 64 | for domain in msg.update.all_domains: 65 | self.processed_domains.add(domain) 66 | 67 | time_passed = int(time.time()) - self.last_info 68 | minute_in_seconds = 60 69 | if time_passed >= minute_in_seconds: 70 | format_str = "Processed {0} updates ({1} unique domains) during the last {2} seconds. {3:.1f} domains/s, {4:.1f} certs/s" 71 | formatted_str = format_str.format( 72 | self.update_counter, len(self.processed_domains), time_passed, len(self.processed_domains) / time_passed, self.update_counter / time_passed 73 | ) 74 | self.logger.info(formatted_str) 75 | 76 | self.logger.info("Queue length: %s", self.update_queue.qsize()) 77 | 78 | self.update_counter = 0 79 | self.error_counter = 0 80 | self.last_info = int(time.time()) 81 | self.processed_domains = set() 82 | 83 | def _on_error(self, _, ex): 84 | """Error handler for the certstream module. 85 | 86 | :return: 87 | """ 88 | self.logger.error("An error occurred: %s", ex) 89 | 90 | def _on_close(self, _, status_code, close_msg): 91 | """Close handler for the certstream module. 92 | 93 | :return: 94 | """ 95 | self.logger.info("Closing the websocket - %s - %s", status_code, close_msg) 96 | 97 | def _run(self): 98 | """Internal method that starts the CertStreamClient and continouusly downloads cert updates as long as neither the stop nor exception event are set. 99 | 100 | :return: 101 | """ 102 | while not self.__stop_event.is_set() and not self.__exception_event.is_set(): 103 | self.certstream_client = CertStreamClient(self._handle_message, self.certstream_url, skip_heartbeats=True) 104 | self.certstream_client._on_error = self._on_error 105 | self.certstream_client.on_close = self._on_close 106 | self.certstream_client.run_forever(ping_interval=30) 107 | time.sleep(1) 108 | 109 | def start(self): 110 | """Start certstream in own thread. 111 | 112 | :return: 113 | """ 114 | with self.__lock: 115 | if not self.running: 116 | self.running = True 117 | thread = start_thread(self._run, "CertstreamWrapper", exception_event=self.__exception_event) 118 | self.__threads.append(thread) 119 | 120 | def stop(self): 121 | """Stop dispatching updates to the analyzers.""" 122 | self.logger.info("Orderly stopping CertstreamWrapper!") 123 | with self.__lock: 124 | self.__stop_event.set() 125 | self.certstream_client.close() 126 | join_threads(self.__threads) 127 | self.__threads = [] 128 | self.running = False 129 | self.__stop_event.clear() 130 | -------------------------------------------------------------------------------- /certleak/database/__init__.py: -------------------------------------------------------------------------------- 1 | from .sqlitedb import SQLiteDB 2 | 3 | __all__ = ["SQLiteDB"] 4 | -------------------------------------------------------------------------------- /certleak/database/abstractdb.py: -------------------------------------------------------------------------------- 1 | class AbstractDB: 2 | def __init__(self): 3 | pass 4 | 5 | def store(self, update): 6 | """Store a cert update in the database.""" 7 | raise NotImplementedError 8 | -------------------------------------------------------------------------------- /certleak/database/sqlitedb.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import pathlib 3 | import sqlite3 4 | import time 5 | from threading import Lock 6 | 7 | from .abstractdb import AbstractDB 8 | 9 | 10 | class SQLiteDB(AbstractDB): 11 | def __init__(self, dbpath="certleak"): 12 | super().__init__() 13 | self.lock = Lock() 14 | self.db_path = pathlib.Path(dbpath) 15 | self.logger = logging.getLogger(__name__) 16 | self.logger.debug("Initializing SQLite - %s", dbpath) 17 | 18 | # Check if the folder path exists 19 | if not self.db_path.exists(): 20 | # If not, create the path and the file 21 | if not self.db_path.parent.exists(): 22 | self.db_path.parent.mkdir(parents=True) 23 | 24 | self.db_path.touch() 25 | elif self.db_path.is_dir(): 26 | msg = f"'{self.db_path}' is a directory. Use different path/name for database." 27 | raise ValueError(msg) 28 | 29 | try: 30 | self.db = sqlite3.connect(str(self.db_path.absolute()), check_same_thread=False) 31 | self.db.text_factory = lambda x: str(x, "utf-8", "ignore") 32 | self.cursor = self.db.cursor() 33 | self._create_tables() 34 | except Exception: 35 | self.logger.exception("An exception occurred when initializing the database") 36 | raise 37 | 38 | self.logger.debug("Connected to database!") 39 | 40 | def _create_tables(self): 41 | self.cursor.execute("""CREATE TABLE IF NOT EXISTS "certs" ( 42 | "fingerprint" TEXT, 43 | "not_before" INTEGER, 44 | "not_after" INTEGER, 45 | "serial_number" TEXT, 46 | "subjectC" TEXT, 47 | "subjectCN" TEXT, 48 | "subjectL" TEXT, 49 | "subjectO" TEXT, 50 | "subjectOU" TEXT, 51 | "subjectST" TEXT, 52 | "subjectAggregated" TEXT, 53 | "extensionsAuthorityInfoAccess" TEXT, 54 | "extensionsAuthorityKeyIdentifier" TEXT, 55 | "extensionsBasicConstraints" TEXT, 56 | "extensionsCertificatePolicies" TEXT, 57 | "extensionsCtlSignedCertificateTimestamp" TEXT, 58 | "extensionsExtendedKeyUsage" TEXT, 59 | "extensionsKeyUsage" TEXT, 60 | "extensionsSubjectAltName" TEXT, 61 | "extensionsSubjectKeyIdentifier" TEXT, 62 | "all_domains" TEXT, 63 | "issuerAggregated" TEXT, 64 | "stored_at" INTEGER, 65 | PRIMARY KEY("fingerprint") 66 | );""") 67 | 68 | self.cursor.execute("""CREATE TABLE IF NOT EXISTS "domains" ( 69 | "domain" TEXT NOT NULL, 70 | "cert_fingerprint" TEXT NOT NULL, 71 | "stored_at" INTEGER, 72 | PRIMARY KEY("domain","cert_fingerprint") 73 | );""") 74 | self.db.commit() 75 | 76 | def _insert_data(self, update): 77 | # self.cursor.execute("INSERT INTO ") 78 | cert = update.leaf_cert 79 | now = int(time.time()) 80 | for domain in update.all_domains: 81 | self.cursor.execute("INSERT INTO domains (domain, cert_fingerprint, stored_at) VALUES (?, ?, ?)", (domain, cert.fingerprint, now)) 82 | self.db.commit() 83 | self.cursor.execute( 84 | "INSERT INTO certs (fingerprint, not_before, not_after, serial_number, all_domains, subjectCN, stored_at) VALUES (?, ?, ?, ?, ?, ?, ?)", 85 | (cert.fingerprint, cert.not_before, cert.not_after, cert.serial_number, ", ".join(cert.all_domains), cert.subject.CN, now), 86 | ) 87 | self.db.commit() 88 | 89 | def store(self, update): 90 | self.logger.debug("Storing cert_update %s", update.cert_index) 91 | 92 | try: 93 | with self.lock: 94 | self._insert_data(update) 95 | except Exception as e: 96 | self.logger.debug("Exception '%s'", e) 97 | -------------------------------------------------------------------------------- /certleak/errors/__init__.py: -------------------------------------------------------------------------------- 1 | from .errors import CertleakError, InvalidActionError 2 | 3 | __all__ = ["CertleakError", "InvalidActionError"] 4 | -------------------------------------------------------------------------------- /certleak/errors/errors.py: -------------------------------------------------------------------------------- 1 | class CertleakError(Exception): 2 | """Representation of a certleak error object.""" 3 | 4 | def __init__(self, message): 5 | super().__init__(message) 6 | self.message = message 7 | 8 | def __str__(self): 9 | """Return the error message as a string.""" 10 | return str(self.message) 11 | 12 | 13 | class InvalidActionError(CertleakError): 14 | """Representation of an error for invalid actions passed to analyzers.""" 15 | 16 | pass 17 | -------------------------------------------------------------------------------- /certleak/errors/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d-Rickyy-b/certleak/91472d77b695a1aa0e384133bc9342cf82e50c5e/certleak/errors/tests/__init__.py -------------------------------------------------------------------------------- /certleak/errors/tests/errors_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import Mock 3 | 4 | from certleak.errors.errors import CertleakError 5 | 6 | 7 | class TestErrors(unittest.TestCase): 8 | def setUp(self): 9 | pass 10 | 11 | def test_CertleakError(self): 12 | """Test if the CertleakError returns its string message.""" 13 | msg = "This is a test message" 14 | error = CertleakError(msg) 15 | self.assertEqual(msg, error.message) 16 | self.assertEqual(msg, str(error)) 17 | 18 | mock = Mock() 19 | mock.__str__ = Mock(return_value="This is just another test message") 20 | error = CertleakError(mock) 21 | self.assertEqual(mock, error.message) 22 | self.assertEqual(str(mock), str(error)) 23 | 24 | 25 | if __name__ == "__main__": 26 | unittest.main() 27 | -------------------------------------------------------------------------------- /certleak/util/__init__.py: -------------------------------------------------------------------------------- 1 | from .dictwrapper import DictWrapper 2 | from .listify import listify 3 | from .request import Request 4 | from .templatingengine import TemplatingEngine 5 | from .threadingutils import join_threads, start_thread 6 | 7 | __all__ = ["DictWrapper", "Request", "TemplatingEngine", "join_threads", "listify", "start_thread"] 8 | -------------------------------------------------------------------------------- /certleak/util/dictwrapper.py: -------------------------------------------------------------------------------- 1 | # https://stackoverflow.com/questions/19799609/leaving-values-blank-if-not-passed-in-str-format 2 | class DictWrapper(dict): 3 | """A wrapper around dicts which returns the key as string when missing. Used for the templating engine.""" 4 | 5 | def __missing__(self, key): 6 | """Return the key as string when missing.""" 7 | return "${" + key + "}" 8 | -------------------------------------------------------------------------------- /certleak/util/listify.py: -------------------------------------------------------------------------------- 1 | def listify(obj): 2 | """Make sure the given object is a list. 3 | 4 | :param obj: Any object - either None, a list of objects or a single object 5 | :return: The given object formatted as list. 6 | """ 7 | if obj is None: 8 | # When the object is None, an empty list will be returned 9 | return [] 10 | elif isinstance(obj, list): 11 | # When the object is already a list, that list will be returned 12 | return obj 13 | else: 14 | # When a single object is passed to the method, a list with the 15 | # object as single item will be returned 16 | return [obj] 17 | -------------------------------------------------------------------------------- /certleak/util/request.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from threading import Lock 3 | 4 | from requests import Session, utils 5 | from requests.exceptions import Timeout 6 | 7 | 8 | class Request: 9 | _instance = None 10 | _initialized = False 11 | _lock = Lock() 12 | 13 | def __new__(cls, *args, **kwargs): 14 | # override method to implement singleton 15 | # source: http://alacret.blogspot.com/2015/04/python-thread-safe-singleton-pattern.html 16 | if Request._instance is None: 17 | with Request._lock: 18 | if Request._instance is None: 19 | Request._instance = super().__new__(cls) 20 | return Request._instance 21 | 22 | def __init__(self, proxies=None, headers=None): 23 | if not self._initialized: 24 | self.logger = logging.getLogger(__name__) 25 | self.session = Session() 26 | self.proxies = proxies 27 | self.headers = headers 28 | self.logger.info("Using the following custom proxies: %s", proxies) 29 | system_proxies = utils.get_environ_proxies("https://example.com") 30 | self.logger.info("Using the following system proxies: %s", system_proxies) 31 | self._initialized = True 32 | 33 | def _request_wrapper(self, data, timeout, *args, **kwargs): 34 | headers = {"User-Agent": "certleak (https://github.com/d-Rickyy-b/certleak)"} 35 | 36 | if self.headers is not None: 37 | headers.update(self.headers) 38 | 39 | try: 40 | response = self.session.request(*args, headers=headers, proxies=self.proxies, data=data, timeout=timeout, **kwargs) 41 | response_data = response.content.decode("utf-8") 42 | except Timeout: 43 | url = kwargs.get("url") 44 | self.logger.warning("Timeout while requesting %s!", url) 45 | return "" 46 | 47 | return response_data 48 | 49 | def get(self, url, data=None, timeout=5): 50 | return self._request_wrapper(method="GET", url=url, data=data, timeout=timeout) 51 | 52 | def post(self, url, data=None, timeout=5): 53 | return self._request_wrapper(method="POST", url=url, data=data, timeout=timeout) 54 | 55 | def put(self, url, data=None, timeout=5): 56 | return self._request_wrapper(method="PUT", url=url, data=data, timeout=timeout) 57 | 58 | def delete(self, url, data=None, timeout=5): 59 | return self._request_wrapper(method="DELETE", url=url, data=data, timeout=timeout) 60 | -------------------------------------------------------------------------------- /certleak/util/templatingengine.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | from collections.abc import MutableMapping 4 | from string import Template 5 | 6 | from certleak.util import DictWrapper 7 | 8 | 9 | class TemplatingEngine: 10 | """Wrapper class around the python templating feature.""" 11 | 12 | @staticmethod 13 | def fill_template(update, analyzer_name, template_string, matches=None, **kwargs): 14 | """Return a templated text with update contents inserted into the template string. 15 | 16 | Use ${key_name} in the template_string to insert update contents into it. 17 | 18 | :param update: A update which serves as the source for template filling 19 | :param analyzer_name: Name of the analyzer 20 | :param template_string: A template string describing how the variables should be filled in 21 | :param matches: A list of matches that was returned from the analyzer 22 | :return: Filled template. 23 | """ 24 | logger = logging.getLogger(__name__) 25 | if update is None: 26 | logger.error("Update is None!") 27 | return None 28 | 29 | update_dict = update.to_dict() 30 | update_dict["analyzer_name"] = analyzer_name 31 | 32 | if matches is None: 33 | update_dict["matches"] = "" 34 | elif not isinstance(matches, list): 35 | logger.error("Matches object passed to fill_template is not of type 'list'!") 36 | else: 37 | # When there are elements in the matches object, we want them to be formatted as single string 38 | matches_str = "\n".join(matches) 39 | update_dict["matches"] = matches_str 40 | 41 | # Possibility to insert own/custom values into the update_dict thus gives more control over the template string 42 | for name, value in kwargs.items(): 43 | update_dict[name] = value 44 | 45 | # Fallback if the template string is empty or non existent 46 | if template_string is None or template_string == "": 47 | template_string = "New update matched by analyzer '${analyzer_name}' - Domains: ${data.leaf_cert.subject.CN}\n\nMatches:\n${matches}" 48 | 49 | template_string = TemplatingEngine._normalize_placeholders(template_string) 50 | template = Template(template_string) 51 | 52 | flattened_dict = TemplatingEngine._flatten_update_dict(update_dict) 53 | 54 | return template.safe_substitute(DictWrapper(flattened_dict)) 55 | 56 | @staticmethod 57 | def _flatten_update_dict(d, parent_key="", sep="__"): 58 | """Flattens and returns any given dict. 59 | 60 | :param d: The dictionary to be flattened 61 | :param parent_key: The key of the parent object 62 | :param sep: The separator element to separate original keys from each other 63 | :return: 64 | """ 65 | # https://stackoverflow.com/questions/6027558/flatten-nested-dictionaries-compressing-keys 66 | items = [] 67 | for key, value in d.items(): 68 | new_key = parent_key + sep + key if parent_key else key 69 | if isinstance(value, MutableMapping): 70 | items.extend(TemplatingEngine._flatten_update_dict(value, new_key, sep=sep).items()) 71 | else: 72 | items.append((new_key, value)) 73 | return dict(items) 74 | 75 | @staticmethod 76 | def _normalize_placeholders(template_string, sep="__"): 77 | """Normalize placeholders from "dot-form" (data.element1.element2) into the flat dict format (keys joined with "__"). 78 | 79 | :param template_string: The original template string to normalize 80 | :param sep: The separator string 81 | :return: 82 | """ 83 | pattern = re.compile(r"(\${[a-zA-Z0-9_.]+})") 84 | matches = pattern.findall(template_string) 85 | for placeholder in matches: 86 | normalized_placeholder = placeholder.replace(".", sep) 87 | template_string = template_string.replace(placeholder, normalized_placeholder) 88 | return template_string 89 | -------------------------------------------------------------------------------- /certleak/util/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d-Rickyy-b/certleak/91472d77b695a1aa0e384133bc9342cf82e50c5e/certleak/util/tests/__init__.py -------------------------------------------------------------------------------- /certleak/util/tests/dictwrapper_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from certleak.util.dictwrapper import DictWrapper 4 | 5 | 6 | class TestDictwrapper(unittest.TestCase): 7 | def setUp(self): 8 | self.test_dict = {"key1": "value1", "key2": "value2"} 9 | 10 | def test_existing_key(self): 11 | test_wrapped = DictWrapper(self.test_dict) 12 | self.assertEqual(test_wrapped["key1"], "value1") 13 | 14 | def test_non_existing_key(self): 15 | test_wrapped = DictWrapper(self.test_dict) 16 | # Make sure that retreiving a valid key works 17 | self.assertEqual(test_wrapped["key1"], "value1") 18 | # Check if retreiving a nonexistent value works as expected 19 | self.assertEqual(test_wrapped["key3"], "${key3}") 20 | # Make sure that retreiving a valid key still works 21 | self.assertEqual(test_wrapped["key2"], "value2") 22 | 23 | 24 | if __name__ == "__main__": 25 | unittest.main() 26 | -------------------------------------------------------------------------------- /certleak/util/tests/listify_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import Mock 3 | 4 | from certleak.util.listify import listify 5 | 6 | 7 | class ListifyTest(unittest.TestCase): 8 | def test_None(self): 9 | self.assertEqual([], listify(None), "Listify did not return empty list!") 10 | 11 | def test_list(self): 12 | obj = Mock() 13 | obj2 = Mock() 14 | obj3 = Mock() 15 | obj_list = [obj, obj2, obj3] 16 | 17 | self.assertEqual(obj_list, listify(obj_list), "Listify did not return the given list!") 18 | 19 | def test_single(self): 20 | obj = Mock() 21 | self.assertEqual([obj], listify(obj), "Listify did not return single object as list!") 22 | 23 | 24 | if __name__ == "__main__": 25 | unittest.main() 26 | -------------------------------------------------------------------------------- /certleak/util/tests/templatingengine_test.py: -------------------------------------------------------------------------------- 1 | import json 2 | import pathlib 3 | import unittest 4 | from unittest.mock import Mock 5 | 6 | from certleak.util.templatingengine import TemplatingEngine 7 | 8 | 9 | class TestTemplatingEngine(unittest.TestCase): 10 | def setUp(self): 11 | """Set up the test case.""" 12 | self.update = Mock() 13 | self.update.to_dict = Mock() 14 | test_file = pathlib.Path(__file__).parent.absolute() / "test.json" 15 | with open(test_file, "r", encoding="utf-8") as f: 16 | self.update.to_dict.return_value = json.load(f) 17 | 18 | def test_fill_template(self): 19 | """Checks if templating engine inserts cert data correctly into the template.""" 20 | analyzer_name = "TestAnalyzer" 21 | template = "New update matched by analyzer '${analyzer_name}' - Domains: ${data.leaf_cert.subject.CN}\n\nMatches:\n${matches}" 22 | expected = "New update matched by analyzer '{}' - Domains: {}\n\nMatches:\n{}".format(analyzer_name, "www.mail.casamarket.ro", "") 23 | 24 | result = TemplatingEngine.fill_template(update=self.update, analyzer_name=analyzer_name, template_string=template) 25 | 26 | self.assertEqual(expected, result, msg="Filled template string is not the same as the expected result!") 27 | 28 | def test_fill_template_default_template(self): 29 | """Checks if templating engine inserts cert data correctly into the template for the default template.""" 30 | analyzer_name = "TestAnalyzer" 31 | expected = "New update matched by analyzer '{}' - Domains: {}\n\nMatches:\n{}".format(analyzer_name, "www.mail.casamarket.ro", "") 32 | 33 | result = TemplatingEngine.fill_template(update=self.update, analyzer_name=analyzer_name, template_string=None) 34 | self.assertEqual(expected, result, msg="Filled template string is not the same as the expected result!") 35 | 36 | result = TemplatingEngine.fill_template(update=self.update, analyzer_name=analyzer_name, template_string="") 37 | self.assertEqual(expected, result, msg="Filled template string is not the same as the expected result!") 38 | 39 | def test__normalize_placeholders_no_placeholder(self): 40 | """Check if '_normalize_placeholders' works for strings without placeholders.""" 41 | template_string = "This is a test template with no placeholder" 42 | expected = "This is a test template with no placeholder" 43 | result = TemplatingEngine._normalize_placeholders(template_string) 44 | self.assertEqual(expected, result, msg="Filled template string is not the same as the expected result!") 45 | 46 | def test__normalize_placeholders_two_placeholders(self): 47 | """Check if '_normalize_placeholders' works for strings with two placeholders.""" 48 | template_string = "This is a ${analyzer_name} test template with ${data.leaf_cert} two placeholders" 49 | expected = "This is a ${analyzer_name} test template with ${data__leaf_cert} two placeholders" 50 | result = TemplatingEngine._normalize_placeholders(template_string) 51 | self.assertEqual(expected, result, msg="Filled template string is not the same as the expected result!") 52 | 53 | def test__normalize_placeholders_two_placeholders_decoy(self): 54 | """Check if '_normalize_placeholders' works for strings with two placeholders and some decoy placeholder.""" 55 | template_string = "This is a ${analyzer_name} test template with ${data.leaf_cert} two placeholders and data.leaf_cert some decoy data." 56 | expected = "This is a ${analyzer_name} test template with ${data__leaf_cert} two placeholders and data.leaf_cert some decoy data." 57 | result = TemplatingEngine._normalize_placeholders(template_string) 58 | self.assertEqual(expected, result, msg="Filled template string is not the same as the expected result!") 59 | 60 | def test__normalize_placeholders_separator(self): 61 | """Check if '_normalize_placeholders' works with any separator.""" 62 | template_string = "This is a ${analyzer_name} test template with ${data.leaf_cert} two placeholders and data.leaf_cert some decoy data." 63 | expected = "This is a ${analyzer_name} test template with ${data###leaf_cert} two placeholders and data.leaf_cert some decoy data." 64 | separator = "###" 65 | result = TemplatingEngine._normalize_placeholders(template_string, sep=separator) 66 | self.assertEqual(expected, result, msg="Filled template string is not the same as the expected result!") 67 | 68 | def test__flatten_update_dict(self): 69 | """Check if flattening dicts works as intended.""" 70 | d = {"test": "asdf", "nested": {"inner": "content", "another": "other content"}, "double_nested": {"inside_double_nested": {"totally_inner": "final"}}, "outer": "yes"} 71 | new_d = TemplatingEngine._flatten_update_dict(d, parent_key="", sep="__") 72 | 73 | # Make sure new_d contains 5 elements on the root level 74 | self.assertEqual(len(new_d), 5) 75 | 76 | # Check if combined keys are contained in the dict 77 | self.assertIn("test", new_d) 78 | self.assertIn("nested__inner", new_d) 79 | self.assertIn("nested__another", new_d) 80 | self.assertIn("double_nested__inside_double_nested__totally_inner", new_d) 81 | self.assertIn("outer", new_d) 82 | 83 | # Make sure that "nested" doesn't exist anymore 84 | nested = new_d.get("nested") 85 | self.assertIsNone(nested) 86 | 87 | # Check if nested elements have been pulled up correctly 88 | nested_inner = new_d.get("nested__inner") 89 | self.assertEqual(nested_inner, "content") 90 | 91 | # Check for double nested content 92 | double_nested_inner = new_d.get("double_nested__inside_double_nested__totally_inner") 93 | self.assertEqual(double_nested_inner, "final") 94 | 95 | def test__flatten_update_dict_separator(self): 96 | """Check if changing separators works as intended.""" 97 | d = {"test": "asdf", "nested": {"inner": "content", "another": "other content"}, "double_nested": {"inside_double_nested": {"totally_inner": "final"}}, "outer": "yes"} 98 | new_d = TemplatingEngine._flatten_update_dict(d, parent_key="", sep="###") 99 | 100 | # Make sure new_d contains 5 elements on the root level 101 | self.assertEqual(len(new_d), 5) 102 | 103 | # Check if changing separators works as intended 104 | self.assertIn("test", new_d) 105 | self.assertIn("nested###inner", new_d) 106 | self.assertIn("nested###another", new_d) 107 | self.assertIn("double_nested###inside_double_nested###totally_inner", new_d) 108 | self.assertIn("outer", new_d) 109 | 110 | 111 | if __name__ == "__main__": 112 | unittest.main() 113 | -------------------------------------------------------------------------------- /certleak/util/tests/test.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "cert_index": 382104397, 4 | "cert_link": "http://testflume.ct.letsencrypt.org/2021/ct/v1/get-entries?start=382104397&end=382104397", 5 | "leaf_cert": { 6 | "all_domains": [ 7 | "mail.casamarket.ro", 8 | "www.mail.casamarket.ro" 9 | ], 10 | "extensions": { 11 | "authorityInfoAccess": "CA Issuers - URI:http://r3.i.lencr.org/\nOCSP - URI:http://r3.o.lencr.org\n", 12 | "authorityKeyIdentifier": "keyid:14:2E:B3:17:B7:58:56:CB:AE:50:09:40:E6:1F:AF:9D:8B:14:C2:C6\n", 13 | "basicConstraints": "CA:FALSE", 14 | "certificatePolicies": "Policy: 1.3.6.1.4.1.44947.1.1.1\n CPS: http://cps.letsencrypt.org", 15 | "ctlPoisonByte": true, 16 | "extendedKeyUsage": "TLS Web server authentication, TLS Web client authentication", 17 | "keyUsage": "Digital Signature, Key Encipherment", 18 | "subjectAltName": "DNS:www.mail.casamarket.ro, DNS:mail.casamarket.ro", 19 | "subjectKeyIdentifier": "8E:5E:D9:A4:2E:8E:7E:28:F1:9F:2B:7A:3E:2C:97:F1:56:29:BC:05" 20 | }, 21 | "fingerprint": "AB:48:4E:1F:5A:8F:F2:58:F1:37:3C:DF:7E:80:27:24:1F:5D:A2:16", 22 | "issuer": { 23 | "C": "US", 24 | "CN": "R3", 25 | "L": null, 26 | "O": "Let's Encrypt", 27 | "OU": null, 28 | "ST": null, 29 | "aggregated": "/C=US/CN=R3/O=Let's Encrypt", 30 | "emailAddress": null 31 | }, 32 | "not_after": 1618273806, 33 | "not_before": 1610497806, 34 | "serial_number": "36044CA7B7D84417289B520982C8B7D17AA", 35 | "signature_algorithm": "sha256, rsa", 36 | "subject": { 37 | "C": null, 38 | "CN": "www.mail.casamarket.ro", 39 | "L": null, 40 | "O": null, 41 | "OU": null, 42 | "ST": null, 43 | "aggregated": "/CN=www.mail.casamarket.ro", 44 | "emailAddress": null 45 | } 46 | }, 47 | "seen": 1610501423.744186, 48 | "source": { 49 | "name": "Let's Encrypt 'Testflume2021' log", 50 | "url": "testflume.ct.letsencrypt.org/2021/" 51 | }, 52 | "update_type": "PrecertLogEntry" 53 | }, 54 | "message_type": "certificate_update" 55 | } 56 | -------------------------------------------------------------------------------- /certleak/util/threadingutils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from threading import Thread, current_thread 3 | 4 | 5 | def start_thread(target, name, exception_event, *args, **kwargs): 6 | """Start a thread passed as argument and catches exceptions that happens during execution. 7 | 8 | :param target: Method to be executed in the thread 9 | :param name: Name of the thread 10 | :param exception_event: An event that will be set if an exception occurred 11 | :param args: Arguments to be passed to the threaded method 12 | :param kwargs: Keyword-Arguments to be passed to the threaded method 13 | :return: 14 | """ 15 | thread = Thread(target=thread_wrapper, name=name, args=(target, exception_event, *args), kwargs=kwargs) 16 | thread.start() 17 | return thread 18 | 19 | 20 | def thread_wrapper(target, exception_event, *args, **kwargs): 21 | """Wrapper around the execution of a passed method, that catches and logs exceptions. 22 | 23 | :param target: Method to be executed 24 | :param exception_event: An event that will be set if an exception occurred 25 | :param args: Arguments to be passed to the target method 26 | :param kwargs: Keyword-Arguments to be passed to the target method 27 | :return: 28 | """ 29 | thread_name = current_thread().name 30 | logger = logging.getLogger(__name__) 31 | logger.debug("%s - thread started", thread_name) 32 | try: 33 | target(*args, **kwargs) 34 | except Exception: 35 | exception_event.set() 36 | logger.exception("unhandled exception in %s", thread_name) 37 | raise 38 | logger.debug("%s - thread ended", thread_name) 39 | 40 | 41 | def join_threads(threads): 42 | """End all threads and join them back into the main thread. 43 | 44 | :param threads: List of threads to be joined 45 | :return: 46 | """ 47 | logger = logging.getLogger(__name__) 48 | for thread in threads: 49 | logger.debug("Joining thread %s", thread.name) 50 | thread.join() 51 | logger.debug("Thread %s has ended", thread.name) 52 | -------------------------------------------------------------------------------- /certleak/version.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.1.1" 2 | -------------------------------------------------------------------------------- /docs/certleak_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d-Rickyy-b/certleak/91472d77b695a1aa0e384133bc9342cf82e50c5e/docs/certleak_logo.png -------------------------------------------------------------------------------- /examples/example.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from pathlib import Path 3 | 4 | from certleak import CertLeak 5 | from certleak.actions import DatabaseAction, LogAction 6 | from certleak.analyzers import AuthorityKeyIDAnalyzer, DNStwistAnalyzer, FullDomainAnalyzer, LetsEncryptAnalyzer, TLDAnalyzer, WildcardCertAnalyzer, X509Analyzer 7 | from certleak.database import SQLiteDB 8 | 9 | logging.basicConfig(format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO) 10 | logging.getLogger("certleak.util.threadingutils").setLevel(logging.ERROR) 11 | logaction = LogAction(level=logging.INFO, template="${analyzer_name} found: ${leaf_cert.subject.CN} () - ${leaf_cert.all_domains}") 12 | 13 | # Initialize CertLeak object 14 | certleak = CertLeak(certstream_url="ws://127.0.0.1:8080/") 15 | # certleak = CertLeak() 16 | 17 | db_path = Path.cwd().resolve() / "phish.db" 18 | db = SQLiteDB(str(db_path)) 19 | db_action = DatabaseAction(db) 20 | 21 | xyz_analyzer = TLDAnalyzer(logaction, ["xyz"], blacklist="acmetestbykeychestdotnet") & X509Analyzer() 22 | phishing_analyzer = FullDomainAnalyzer([db_action, logaction], ["idcheck", "logins"]) 23 | paypal_analyzer = FullDomainAnalyzer(db_action, ["paypal"]) 24 | 25 | analyzer6 = AuthorityKeyIDAnalyzer(logaction, "E6:A3:B4:5B:06:2D:50:9B:33:82:28:2D:19:6E:FE:97:D5:95:6C:CB") 26 | 27 | w1 = WildcardCertAnalyzer([db_action, logaction]) & X509Analyzer() 28 | l1 = LetsEncryptAnalyzer(db_action) & X509Analyzer() 29 | # ata = AlwaysTrueAnalyzer(actions=db_action) & X509Analyzer() 30 | 31 | dns3 = DNStwistAnalyzer(logaction, "paypal.com") & X509Analyzer() 32 | 33 | certleak.add_analyzer(dns3) 34 | 35 | certleak.add_analyzer(paypal_analyzer) 36 | certleak.add_analyzer(phishing_analyzer) 37 | # certleak.add_analyzer(xyz_analyzer) 38 | 39 | certleak.start() 40 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=64", "setuptools_scm>=8"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "certleak" 7 | description = "Python tool to monitor and analyze TLS certificates as they are issued via certstream." 8 | authors = [ 9 | {name = "d-Rickyy-b", email = "certleak@rico-j.de"}, 10 | ] 11 | readme = "README.md" 12 | license = { file = "LICENSE" } 13 | #version = "0.0.1" 14 | dynamic = ["version"] 15 | keywords = ["python", "certificate", "tls", "osint", "framework"] 16 | requires-python = ">=3.9" 17 | classifiers = [ 18 | "Development Status :: 5 - Production/Stable", 19 | "Environment :: Console", 20 | "Intended Audience :: Developers", 21 | "Intended Audience :: Science/Research", 22 | "License :: OSI Approved :: MIT License", 23 | "Operating System :: OS Independent", 24 | "Topic :: Software Development :: Libraries :: Python Modules", 25 | "Topic :: Security", 26 | "Topic :: Internet", 27 | "Programming Language :: Python", 28 | "Programming Language :: Python :: 3", 29 | "Programming Language :: Python :: 3.9", 30 | "Programming Language :: Python :: 3.10", 31 | "Programming Language :: Python :: 3.11", 32 | "Programming Language :: Python :: 3.12" 33 | ] 34 | dependencies = [ 35 | "certstream>=1.12", 36 | "dnstwist>=20240812", 37 | "requests>=2.32", 38 | "tldextract>=5.0", 39 | ] 40 | 41 | [project.optional-dependencies] 42 | dev = ["ruff"] 43 | 44 | [project.urls] 45 | Homepage = "https://github.com/d-Rickyy-b/certleak" 46 | Issues = "https://github.com/d-Rickyy-b/certleak/issues" 47 | 48 | [tool.setuptools.dynamic] 49 | version = {attr = "certleak.version.__version__"} 50 | 51 | [tool.ruff] 52 | target-version = "py39" 53 | line-length = 180 54 | # Exclude a variety of commonly ignored directories. 55 | exclude = [ 56 | ".bzr", 57 | ".direnv", 58 | ".eggs", 59 | ".git", 60 | ".git-rewrite", 61 | ".hg", 62 | ".ipynb_checkpoints", 63 | ".mypy_cache", 64 | ".pyenv", 65 | ".pytest_cache", 66 | ".pytype", 67 | ".ruff_cache", 68 | ".tox", 69 | ".venv", 70 | ".vscode", 71 | "__pypackages__", 72 | "_build", 73 | "build", 74 | "dist", 75 | "node_modules", 76 | "site-packages", 77 | "venv", 78 | ] 79 | 80 | [tool.ruff.lint] 81 | select = ["ALL"] 82 | ignore = [ 83 | "ANN", # Annotations 84 | "ARG002", # unused-method-argument 85 | "ARG004", # Unused static method argument 86 | "BLE001", # Do not catch blind exception: `Exception` 87 | "E501", # Line too long 88 | "UP012", 89 | "UP015", 90 | "G004", 91 | "COM812", 92 | "T201", 93 | "PLR1722", 94 | "RET505", 95 | "RET508", 96 | "SLF001", 97 | "PTH123", 98 | "FBT001", 99 | "FBT002", 100 | "FBT003", 101 | "D100", # undocumented-public-module 102 | "D101", # undocumented-public-class 103 | "D102", # undocumented-public-method 104 | "D104", # undocumented-public-package 105 | "D401", # non-imperative-mood 106 | "D404", # docstring-starts-with-this 107 | "D107", # undocumented-public-init 108 | "D213", # multi-line-summary-second-line 109 | "D203", # one-blank-line-before-class 110 | "ERA001", # Found commented-out code 111 | "FIX", # Disable all flake8-fixme warnings 112 | "TD", # Disable all flake8-todos warnings 113 | "SIM108", # Use ternary operator 114 | "PT009", # pytest-unittest-assertion 115 | "PT027", # pytest-unittest-raises-assertion 116 | "PLR0911", # too-many-return-statements 117 | "PLR0913", # too-many-arguments 118 | "PIE790", # unnecessary-placeholder 119 | "PERF203", # try-except-in-loop 120 | ] 121 | 122 | [tool.ruff.format] 123 | # Use `\n` line endings for all files 124 | line-ending = "lf" 125 | 126 | [tool.ruff.lint.per-file-ignores] 127 | "certleak/core/certstreamdata/subject.py" = [ 128 | "E741", # Magic value used in comparison 129 | ] 130 | "certleak/core/certstreamdata/extensions.py" = [ 131 | "N803", # Argument name should be lowercase 132 | "N806", # Variable in function should be lowercase 133 | ] 134 | "**_test.py" = [ 135 | "N802", # Function name should be lowercase 136 | ] 137 | "certleak/util/request.py" = [ 138 | "ARG003", # Unused class method argument 139 | ] 140 | "examples/example.py" = [ 141 | "INP001", # implicit-namespace-package 142 | ] 143 | "certleak/analyzers/tldanalyzer.py" = [ 144 | "PERF401", # manual-list-comprehension 145 | ] 146 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | certstream>=1.12,<2 2 | dnstwist>=20240812 3 | requests>=2.32 4 | tldextract>=5.0 5 | -------------------------------------------------------------------------------- /uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | revision = 2 3 | requires-python = ">=3.9" 4 | 5 | [[package]] 6 | name = "certifi" 7 | version = "2024.12.14" 8 | source = { registry = "https://pypi.org/simple" } 9 | sdist = { url = "https://files.pythonhosted.org/packages/0f/bd/1d41ee578ce09523c81a15426705dd20969f5abf006d1afe8aeff0dd776a/certifi-2024.12.14.tar.gz", hash = "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db", size = 166010, upload-time = "2024-12-14T13:52:38.02Z" } 10 | wheels = [ 11 | { url = "https://files.pythonhosted.org/packages/a5/32/8f6669fc4798494966bf446c8c4a162e0b5d893dff088afddf76414f70e1/certifi-2024.12.14-py3-none-any.whl", hash = "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56", size = 164927, upload-time = "2024-12-14T13:52:36.114Z" }, 12 | ] 13 | 14 | [[package]] 15 | name = "certleak" 16 | source = { editable = "." } 17 | dependencies = [ 18 | { name = "certstream" }, 19 | { name = "dnstwist" }, 20 | { name = "requests" }, 21 | { name = "tldextract" }, 22 | ] 23 | 24 | [package.optional-dependencies] 25 | dev = [ 26 | { name = "ruff" }, 27 | ] 28 | 29 | [package.metadata] 30 | requires-dist = [ 31 | { name = "certstream", specifier = ">=1.12" }, 32 | { name = "dnstwist", specifier = ">=20240812" }, 33 | { name = "requests", specifier = ">=2.32" }, 34 | { name = "ruff", marker = "extra == 'dev'" }, 35 | { name = "tldextract", specifier = ">=5.0" }, 36 | ] 37 | provides-extras = ["dev"] 38 | 39 | [[package]] 40 | name = "certstream" 41 | version = "1.12" 42 | source = { registry = "https://pypi.org/simple" } 43 | dependencies = [ 44 | { name = "termcolor" }, 45 | { name = "websocket-client" }, 46 | ] 47 | sdist = { url = "https://files.pythonhosted.org/packages/77/29/635b36ffca2108f66407ab3b71061b001ff5f4133ea0a0addb6783cf1df1/certstream-1.12.tar.gz", hash = "sha256:e692d65ea9447a5db6cd146c5969af6434edd87163fe0b100962bb5d7d223ddb", size = 9560, upload-time = "2021-04-28T20:09:55.459Z" } 48 | 49 | [[package]] 50 | name = "charset-normalizer" 51 | version = "3.4.0" 52 | source = { registry = "https://pypi.org/simple" } 53 | sdist = { url = "https://files.pythonhosted.org/packages/f2/4f/e1808dc01273379acc506d18f1504eb2d299bd4131743b9fc54d7be4df1e/charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e", size = 106620, upload-time = "2024-10-09T07:40:20.413Z" } 54 | wheels = [ 55 | { url = "https://files.pythonhosted.org/packages/69/8b/825cc84cf13a28bfbcba7c416ec22bf85a9584971be15b21dd8300c65b7f/charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6", size = 196363, upload-time = "2024-10-09T07:38:02.622Z" }, 56 | { url = "https://files.pythonhosted.org/packages/23/81/d7eef6a99e42c77f444fdd7bc894b0ceca6c3a95c51239e74a722039521c/charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b", size = 125639, upload-time = "2024-10-09T07:38:04.044Z" }, 57 | { url = "https://files.pythonhosted.org/packages/21/67/b4564d81f48042f520c948abac7079356e94b30cb8ffb22e747532cf469d/charset_normalizer-3.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99", size = 120451, upload-time = "2024-10-09T07:38:04.997Z" }, 58 | { url = "https://files.pythonhosted.org/packages/c2/72/12a7f0943dd71fb5b4e7b55c41327ac0a1663046a868ee4d0d8e9c369b85/charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca", size = 140041, upload-time = "2024-10-09T07:38:06.676Z" }, 59 | { url = "https://files.pythonhosted.org/packages/67/56/fa28c2c3e31217c4c52158537a2cf5d98a6c1e89d31faf476c89391cd16b/charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d", size = 150333, upload-time = "2024-10-09T07:38:08.626Z" }, 60 | { url = "https://files.pythonhosted.org/packages/f9/d2/466a9be1f32d89eb1554cf84073a5ed9262047acee1ab39cbaefc19635d2/charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7", size = 142921, upload-time = "2024-10-09T07:38:10.301Z" }, 61 | { url = "https://files.pythonhosted.org/packages/f8/01/344ec40cf5d85c1da3c1f57566c59e0c9b56bcc5566c08804a95a6cc8257/charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3", size = 144785, upload-time = "2024-10-09T07:38:12.019Z" }, 62 | { url = "https://files.pythonhosted.org/packages/73/8b/2102692cb6d7e9f03b9a33a710e0164cadfce312872e3efc7cfe22ed26b4/charset_normalizer-3.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907", size = 146631, upload-time = "2024-10-09T07:38:13.701Z" }, 63 | { url = "https://files.pythonhosted.org/packages/d8/96/cc2c1b5d994119ce9f088a9a0c3ebd489d360a2eb058e2c8049f27092847/charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b", size = 140867, upload-time = "2024-10-09T07:38:15.403Z" }, 64 | { url = "https://files.pythonhosted.org/packages/c9/27/cde291783715b8ec30a61c810d0120411844bc4c23b50189b81188b273db/charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912", size = 149273, upload-time = "2024-10-09T07:38:16.433Z" }, 65 | { url = "https://files.pythonhosted.org/packages/3a/a4/8633b0fc1a2d1834d5393dafecce4a1cc56727bfd82b4dc18fc92f0d3cc3/charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95", size = 152437, upload-time = "2024-10-09T07:38:18.013Z" }, 66 | { url = "https://files.pythonhosted.org/packages/64/ea/69af161062166b5975ccbb0961fd2384853190c70786f288684490913bf5/charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e", size = 150087, upload-time = "2024-10-09T07:38:19.089Z" }, 67 | { url = "https://files.pythonhosted.org/packages/3b/fd/e60a9d9fd967f4ad5a92810138192f825d77b4fa2a557990fd575a47695b/charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe", size = 145142, upload-time = "2024-10-09T07:38:20.78Z" }, 68 | { url = "https://files.pythonhosted.org/packages/6d/02/8cb0988a1e49ac9ce2eed1e07b77ff118f2923e9ebd0ede41ba85f2dcb04/charset_normalizer-3.4.0-cp310-cp310-win32.whl", hash = "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc", size = 94701, upload-time = "2024-10-09T07:38:21.851Z" }, 69 | { url = "https://files.pythonhosted.org/packages/d6/20/f1d4670a8a723c46be695dff449d86d6092916f9e99c53051954ee33a1bc/charset_normalizer-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749", size = 102191, upload-time = "2024-10-09T07:38:23.467Z" }, 70 | { url = "https://files.pythonhosted.org/packages/9c/61/73589dcc7a719582bf56aae309b6103d2762b526bffe189d635a7fcfd998/charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c", size = 193339, upload-time = "2024-10-09T07:38:24.527Z" }, 71 | { url = "https://files.pythonhosted.org/packages/77/d5/8c982d58144de49f59571f940e329ad6e8615e1e82ef84584c5eeb5e1d72/charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944", size = 124366, upload-time = "2024-10-09T07:38:26.488Z" }, 72 | { url = "https://files.pythonhosted.org/packages/bf/19/411a64f01ee971bed3231111b69eb56f9331a769072de479eae7de52296d/charset_normalizer-3.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee", size = 118874, upload-time = "2024-10-09T07:38:28.115Z" }, 73 | { url = "https://files.pythonhosted.org/packages/4c/92/97509850f0d00e9f14a46bc751daabd0ad7765cff29cdfb66c68b6dad57f/charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c", size = 138243, upload-time = "2024-10-09T07:38:29.822Z" }, 74 | { url = "https://files.pythonhosted.org/packages/e2/29/d227805bff72ed6d6cb1ce08eec707f7cfbd9868044893617eb331f16295/charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6", size = 148676, upload-time = "2024-10-09T07:38:30.869Z" }, 75 | { url = "https://files.pythonhosted.org/packages/13/bc/87c2c9f2c144bedfa62f894c3007cd4530ba4b5351acb10dc786428a50f0/charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea", size = 141289, upload-time = "2024-10-09T07:38:32.557Z" }, 76 | { url = "https://files.pythonhosted.org/packages/eb/5b/6f10bad0f6461fa272bfbbdf5d0023b5fb9bc6217c92bf068fa5a99820f5/charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc", size = 142585, upload-time = "2024-10-09T07:38:33.649Z" }, 77 | { url = "https://files.pythonhosted.org/packages/3b/a0/a68980ab8a1f45a36d9745d35049c1af57d27255eff8c907e3add84cf68f/charset_normalizer-3.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5", size = 144408, upload-time = "2024-10-09T07:38:34.687Z" }, 78 | { url = "https://files.pythonhosted.org/packages/d7/a1/493919799446464ed0299c8eef3c3fad0daf1c3cd48bff9263c731b0d9e2/charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594", size = 139076, upload-time = "2024-10-09T07:38:36.417Z" }, 79 | { url = "https://files.pythonhosted.org/packages/fb/9d/9c13753a5a6e0db4a0a6edb1cef7aee39859177b64e1a1e748a6e3ba62c2/charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c", size = 146874, upload-time = "2024-10-09T07:38:37.59Z" }, 80 | { url = "https://files.pythonhosted.org/packages/75/d2/0ab54463d3410709c09266dfb416d032a08f97fd7d60e94b8c6ef54ae14b/charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365", size = 150871, upload-time = "2024-10-09T07:38:38.666Z" }, 81 | { url = "https://files.pythonhosted.org/packages/8d/c9/27e41d481557be53d51e60750b85aa40eaf52b841946b3cdeff363105737/charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129", size = 148546, upload-time = "2024-10-09T07:38:40.459Z" }, 82 | { url = "https://files.pythonhosted.org/packages/ee/44/4f62042ca8cdc0cabf87c0fc00ae27cd8b53ab68be3605ba6d071f742ad3/charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236", size = 143048, upload-time = "2024-10-09T07:38:42.178Z" }, 83 | { url = "https://files.pythonhosted.org/packages/01/f8/38842422988b795220eb8038745d27a675ce066e2ada79516c118f291f07/charset_normalizer-3.4.0-cp311-cp311-win32.whl", hash = "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99", size = 94389, upload-time = "2024-10-09T07:38:43.339Z" }, 84 | { url = "https://files.pythonhosted.org/packages/0b/6e/b13bd47fa9023b3699e94abf565b5a2f0b0be6e9ddac9812182596ee62e4/charset_normalizer-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27", size = 101752, upload-time = "2024-10-09T07:38:44.276Z" }, 85 | { url = "https://files.pythonhosted.org/packages/d3/0b/4b7a70987abf9b8196845806198975b6aab4ce016632f817ad758a5aa056/charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6", size = 194445, upload-time = "2024-10-09T07:38:45.275Z" }, 86 | { url = "https://files.pythonhosted.org/packages/50/89/354cc56cf4dd2449715bc9a0f54f3aef3dc700d2d62d1fa5bbea53b13426/charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf", size = 125275, upload-time = "2024-10-09T07:38:46.449Z" }, 87 | { url = "https://files.pythonhosted.org/packages/fa/44/b730e2a2580110ced837ac083d8ad222343c96bb6b66e9e4e706e4d0b6df/charset_normalizer-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db", size = 119020, upload-time = "2024-10-09T07:38:48.88Z" }, 88 | { url = "https://files.pythonhosted.org/packages/9d/e4/9263b8240ed9472a2ae7ddc3e516e71ef46617fe40eaa51221ccd4ad9a27/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1", size = 139128, upload-time = "2024-10-09T07:38:49.86Z" }, 89 | { url = "https://files.pythonhosted.org/packages/6b/e3/9f73e779315a54334240353eaea75854a9a690f3f580e4bd85d977cb2204/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03", size = 149277, upload-time = "2024-10-09T07:38:52.306Z" }, 90 | { url = "https://files.pythonhosted.org/packages/1a/cf/f1f50c2f295312edb8a548d3fa56a5c923b146cd3f24114d5adb7e7be558/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284", size = 142174, upload-time = "2024-10-09T07:38:53.458Z" }, 91 | { url = "https://files.pythonhosted.org/packages/16/92/92a76dc2ff3a12e69ba94e7e05168d37d0345fa08c87e1fe24d0c2a42223/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15", size = 143838, upload-time = "2024-10-09T07:38:54.691Z" }, 92 | { url = "https://files.pythonhosted.org/packages/a4/01/2117ff2b1dfc61695daf2babe4a874bca328489afa85952440b59819e9d7/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8", size = 146149, upload-time = "2024-10-09T07:38:55.737Z" }, 93 | { url = "https://files.pythonhosted.org/packages/f6/9b/93a332b8d25b347f6839ca0a61b7f0287b0930216994e8bf67a75d050255/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2", size = 140043, upload-time = "2024-10-09T07:38:57.44Z" }, 94 | { url = "https://files.pythonhosted.org/packages/ab/f6/7ac4a01adcdecbc7a7587767c776d53d369b8b971382b91211489535acf0/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719", size = 148229, upload-time = "2024-10-09T07:38:58.782Z" }, 95 | { url = "https://files.pythonhosted.org/packages/9d/be/5708ad18161dee7dc6a0f7e6cf3a88ea6279c3e8484844c0590e50e803ef/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631", size = 151556, upload-time = "2024-10-09T07:39:00.467Z" }, 96 | { url = "https://files.pythonhosted.org/packages/5a/bb/3d8bc22bacb9eb89785e83e6723f9888265f3a0de3b9ce724d66bd49884e/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b", size = 149772, upload-time = "2024-10-09T07:39:01.5Z" }, 97 | { url = "https://files.pythonhosted.org/packages/f7/fa/d3fc622de05a86f30beea5fc4e9ac46aead4731e73fd9055496732bcc0a4/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565", size = 144800, upload-time = "2024-10-09T07:39:02.491Z" }, 98 | { url = "https://files.pythonhosted.org/packages/9a/65/bdb9bc496d7d190d725e96816e20e2ae3a6fa42a5cac99c3c3d6ff884118/charset_normalizer-3.4.0-cp312-cp312-win32.whl", hash = "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7", size = 94836, upload-time = "2024-10-09T07:39:04.607Z" }, 99 | { url = "https://files.pythonhosted.org/packages/3e/67/7b72b69d25b89c0b3cea583ee372c43aa24df15f0e0f8d3982c57804984b/charset_normalizer-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9", size = 102187, upload-time = "2024-10-09T07:39:06.247Z" }, 100 | { url = "https://files.pythonhosted.org/packages/f3/89/68a4c86f1a0002810a27f12e9a7b22feb198c59b2f05231349fbce5c06f4/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114", size = 194617, upload-time = "2024-10-09T07:39:07.317Z" }, 101 | { url = "https://files.pythonhosted.org/packages/4f/cd/8947fe425e2ab0aa57aceb7807af13a0e4162cd21eee42ef5b053447edf5/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed", size = 125310, upload-time = "2024-10-09T07:39:08.353Z" }, 102 | { url = "https://files.pythonhosted.org/packages/5b/f0/b5263e8668a4ee9becc2b451ed909e9c27058337fda5b8c49588183c267a/charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250", size = 119126, upload-time = "2024-10-09T07:39:09.327Z" }, 103 | { url = "https://files.pythonhosted.org/packages/ff/6e/e445afe4f7fda27a533f3234b627b3e515a1b9429bc981c9a5e2aa5d97b6/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920", size = 139342, upload-time = "2024-10-09T07:39:10.322Z" }, 104 | { url = "https://files.pythonhosted.org/packages/a1/b2/4af9993b532d93270538ad4926c8e37dc29f2111c36f9c629840c57cd9b3/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64", size = 149383, upload-time = "2024-10-09T07:39:12.042Z" }, 105 | { url = "https://files.pythonhosted.org/packages/fb/6f/4e78c3b97686b871db9be6f31d64e9264e889f8c9d7ab33c771f847f79b7/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23", size = 142214, upload-time = "2024-10-09T07:39:13.059Z" }, 106 | { url = "https://files.pythonhosted.org/packages/2b/c9/1c8fe3ce05d30c87eff498592c89015b19fade13df42850aafae09e94f35/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc", size = 144104, upload-time = "2024-10-09T07:39:14.815Z" }, 107 | { url = "https://files.pythonhosted.org/packages/ee/68/efad5dcb306bf37db7db338338e7bb8ebd8cf38ee5bbd5ceaaaa46f257e6/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d", size = 146255, upload-time = "2024-10-09T07:39:15.868Z" }, 108 | { url = "https://files.pythonhosted.org/packages/0c/75/1ed813c3ffd200b1f3e71121c95da3f79e6d2a96120163443b3ad1057505/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88", size = 140251, upload-time = "2024-10-09T07:39:16.995Z" }, 109 | { url = "https://files.pythonhosted.org/packages/7d/0d/6f32255c1979653b448d3c709583557a4d24ff97ac4f3a5be156b2e6a210/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90", size = 148474, upload-time = "2024-10-09T07:39:18.021Z" }, 110 | { url = "https://files.pythonhosted.org/packages/ac/a0/c1b5298de4670d997101fef95b97ac440e8c8d8b4efa5a4d1ef44af82f0d/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b", size = 151849, upload-time = "2024-10-09T07:39:19.243Z" }, 111 | { url = "https://files.pythonhosted.org/packages/04/4f/b3961ba0c664989ba63e30595a3ed0875d6790ff26671e2aae2fdc28a399/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d", size = 149781, upload-time = "2024-10-09T07:39:20.397Z" }, 112 | { url = "https://files.pythonhosted.org/packages/d8/90/6af4cd042066a4adad58ae25648a12c09c879efa4849c705719ba1b23d8c/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482", size = 144970, upload-time = "2024-10-09T07:39:21.452Z" }, 113 | { url = "https://files.pythonhosted.org/packages/cc/67/e5e7e0cbfefc4ca79025238b43cdf8a2037854195b37d6417f3d0895c4c2/charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67", size = 94973, upload-time = "2024-10-09T07:39:22.509Z" }, 114 | { url = "https://files.pythonhosted.org/packages/65/97/fc9bbc54ee13d33dc54a7fcf17b26368b18505500fc01e228c27b5222d80/charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b", size = 102308, upload-time = "2024-10-09T07:39:23.524Z" }, 115 | { url = "https://files.pythonhosted.org/packages/54/2f/28659eee7f5d003e0f5a3b572765bf76d6e0fe6601ab1f1b1dd4cba7e4f1/charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:980b4f289d1d90ca5efcf07958d3eb38ed9c0b7676bf2831a54d4f66f9c27dfa", size = 196326, upload-time = "2024-10-09T07:39:59.619Z" }, 116 | { url = "https://files.pythonhosted.org/packages/d1/18/92869d5c0057baa973a3ee2af71573be7b084b3c3d428fe6463ce71167f8/charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f28f891ccd15c514a0981f3b9db9aa23d62fe1a99997512b0491d2ed323d229a", size = 125614, upload-time = "2024-10-09T07:40:00.776Z" }, 117 | { url = "https://files.pythonhosted.org/packages/d6/27/327904c5a54a7796bb9f36810ec4173d2df5d88b401d2b95ef53111d214e/charset_normalizer-3.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8aacce6e2e1edcb6ac625fb0f8c3a9570ccc7bfba1f63419b3769ccf6a00ed0", size = 120450, upload-time = "2024-10-09T07:40:02.621Z" }, 118 | { url = "https://files.pythonhosted.org/packages/a4/23/65af317914a0308495133b2d654cf67b11bbd6ca16637c4e8a38f80a5a69/charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd7af3717683bea4c87acd8c0d3d5b44d56120b26fd3f8a692bdd2d5260c620a", size = 140135, upload-time = "2024-10-09T07:40:05.719Z" }, 119 | { url = "https://files.pythonhosted.org/packages/f2/41/6190102ad521a8aa888519bb014a74251ac4586cde9b38e790901684f9ab/charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ff2ed8194587faf56555927b3aa10e6fb69d931e33953943bc4f837dfee2242", size = 150413, upload-time = "2024-10-09T07:40:06.777Z" }, 120 | { url = "https://files.pythonhosted.org/packages/7b/ab/f47b0159a69eab9bd915591106859f49670c75f9a19082505ff16f50efc0/charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e91f541a85298cf35433bf66f3fab2a4a2cff05c127eeca4af174f6d497f0d4b", size = 142992, upload-time = "2024-10-09T07:40:07.921Z" }, 121 | { url = "https://files.pythonhosted.org/packages/28/89/60f51ad71f63aaaa7e51a2a2ad37919985a341a1d267070f212cdf6c2d22/charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309a7de0a0ff3040acaebb35ec45d18db4b28232f21998851cfa709eeff49d62", size = 144871, upload-time = "2024-10-09T07:40:09.035Z" }, 122 | { url = "https://files.pythonhosted.org/packages/0c/48/0050550275fea585a6e24460b42465020b53375017d8596c96be57bfabca/charset_normalizer-3.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:285e96d9d53422efc0d7a17c60e59f37fbf3dfa942073f666db4ac71e8d726d0", size = 146756, upload-time = "2024-10-09T07:40:10.186Z" }, 123 | { url = "https://files.pythonhosted.org/packages/dc/b5/47f8ee91455946f745e6c9ddbb0f8f50314d2416dd922b213e7d5551ad09/charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5d447056e2ca60382d460a604b6302d8db69476fd2015c81e7c35417cfabe4cd", size = 141034, upload-time = "2024-10-09T07:40:11.386Z" }, 124 | { url = "https://files.pythonhosted.org/packages/84/79/5c731059ebab43e80bf61fa51666b9b18167974b82004f18c76378ed31a3/charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:20587d20f557fe189b7947d8e7ec5afa110ccf72a3128d61a2a387c3313f46be", size = 149434, upload-time = "2024-10-09T07:40:12.513Z" }, 125 | { url = "https://files.pythonhosted.org/packages/ca/f3/0719cd09fc4dc42066f239cb3c48ced17fc3316afca3e2a30a4756fe49ab/charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:130272c698667a982a5d0e626851ceff662565379baf0ff2cc58067b81d4f11d", size = 152443, upload-time = "2024-10-09T07:40:13.655Z" }, 126 | { url = "https://files.pythonhosted.org/packages/f7/0e/c6357297f1157c8e8227ff337e93fd0a90e498e3d6ab96b2782204ecae48/charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ab22fbd9765e6954bc0bcff24c25ff71dcbfdb185fcdaca49e81bac68fe724d3", size = 150294, upload-time = "2024-10-09T07:40:14.883Z" }, 127 | { url = "https://files.pythonhosted.org/packages/54/9a/acfa96dc4ea8c928040b15822b59d0863d6e1757fba8bd7de3dc4f761c13/charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7782afc9b6b42200f7362858f9e73b1f8316afb276d316336c0ec3bd73312742", size = 145314, upload-time = "2024-10-09T07:40:16.043Z" }, 128 | { url = "https://files.pythonhosted.org/packages/73/1c/b10a63032eaebb8d7bcb8544f12f063f41f5f463778ac61da15d9985e8b6/charset_normalizer-3.4.0-cp39-cp39-win32.whl", hash = "sha256:2de62e8801ddfff069cd5c504ce3bc9672b23266597d4e4f50eda28846c322f2", size = 94724, upload-time = "2024-10-09T07:40:17.199Z" }, 129 | { url = "https://files.pythonhosted.org/packages/c5/77/3a78bf28bfaa0863f9cfef278dbeadf55efe064eafff8c7c424ae3c4c1bf/charset_normalizer-3.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:95c3c157765b031331dd4db3c775e58deaee050a3042fcad72cbc4189d7c8dca", size = 102159, upload-time = "2024-10-09T07:40:18.264Z" }, 130 | { url = "https://files.pythonhosted.org/packages/bf/9b/08c0432272d77b04803958a4598a51e2a4b51c06640af8b8f0f908c18bf2/charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079", size = 49446, upload-time = "2024-10-09T07:40:19.383Z" }, 131 | ] 132 | 133 | [[package]] 134 | name = "dnstwist" 135 | version = "20240812" 136 | source = { registry = "https://pypi.org/simple" } 137 | sdist = { url = "https://files.pythonhosted.org/packages/3f/df/9c62d9e40d374fd1311de3c761670771615101e0a0b31968b31289882db7/dnstwist-20240812.tar.gz", hash = "sha256:eb109cfcede027fe97b72d2f176f11d2ab633b5aadb833bb7f9b785d2e0ae1a1", size = 22637, upload-time = "2024-08-12T07:06:41.231Z" } 138 | wheels = [ 139 | { url = "https://files.pythonhosted.org/packages/fc/02/40c2962011b4ba5ed29a8c75ff3b65a4698a9f721338fbce8c50c3372340/dnstwist-20240812-py3-none-any.whl", hash = "sha256:1b3df3da5bb03066b1ce0fe360171fc5817a77ee507c5d5369f57d4537c476df", size = 22194, upload-time = "2024-08-12T07:06:39.966Z" }, 140 | ] 141 | 142 | [[package]] 143 | name = "filelock" 144 | version = "3.16.1" 145 | source = { registry = "https://pypi.org/simple" } 146 | sdist = { url = "https://files.pythonhosted.org/packages/9d/db/3ef5bb276dae18d6ec2124224403d1d67bccdbefc17af4cc8f553e341ab1/filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435", size = 18037, upload-time = "2024-09-17T19:02:01.779Z" } 147 | wheels = [ 148 | { url = "https://files.pythonhosted.org/packages/b9/f8/feced7779d755758a52d1f6635d990b8d98dc0a29fa568bbe0625f18fdf3/filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0", size = 16163, upload-time = "2024-09-17T19:02:00.268Z" }, 149 | ] 150 | 151 | [[package]] 152 | name = "idna" 153 | version = "3.10" 154 | source = { registry = "https://pypi.org/simple" } 155 | sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } 156 | wheels = [ 157 | { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, 158 | ] 159 | 160 | [[package]] 161 | name = "requests" 162 | version = "2.32.3" 163 | source = { registry = "https://pypi.org/simple" } 164 | dependencies = [ 165 | { name = "certifi" }, 166 | { name = "charset-normalizer" }, 167 | { name = "idna" }, 168 | { name = "urllib3" }, 169 | ] 170 | sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218, upload-time = "2024-05-29T15:37:49.536Z" } 171 | wheels = [ 172 | { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928, upload-time = "2024-05-29T15:37:47.027Z" }, 173 | ] 174 | 175 | [[package]] 176 | name = "requests-file" 177 | version = "2.1.0" 178 | source = { registry = "https://pypi.org/simple" } 179 | dependencies = [ 180 | { name = "requests" }, 181 | ] 182 | sdist = { url = "https://files.pythonhosted.org/packages/72/97/bf44e6c6bd8ddbb99943baf7ba8b1a8485bcd2fe0e55e5708d7fee4ff1ae/requests_file-2.1.0.tar.gz", hash = "sha256:0f549a3f3b0699415ac04d167e9cb39bccfb730cb832b4d20be3d9867356e658", size = 6891, upload-time = "2024-05-21T16:28:00.24Z" } 183 | wheels = [ 184 | { url = "https://files.pythonhosted.org/packages/d7/25/dd878a121fcfdf38f52850f11c512e13ec87c2ea72385933818e5b6c15ce/requests_file-2.1.0-py2.py3-none-any.whl", hash = "sha256:cf270de5a4c5874e84599fc5778303d496c10ae5e870bfa378818f35d21bda5c", size = 4244, upload-time = "2024-05-21T16:27:57.733Z" }, 185 | ] 186 | 187 | [[package]] 188 | name = "ruff" 189 | version = "0.8.4" 190 | source = { registry = "https://pypi.org/simple" } 191 | sdist = { url = "https://files.pythonhosted.org/packages/34/37/9c02181ef38d55b77d97c68b78e705fd14c0de0e5d085202bb2b52ce5be9/ruff-0.8.4.tar.gz", hash = "sha256:0d5f89f254836799af1615798caa5f80b7f935d7a670fad66c5007928e57ace8", size = 3402103, upload-time = "2024-12-19T13:36:26.286Z" } 192 | wheels = [ 193 | { url = "https://files.pythonhosted.org/packages/05/67/f480bf2f2723b2e49af38ed2be75ccdb2798fca7d56279b585c8f553aaab/ruff-0.8.4-py3-none-linux_armv6l.whl", hash = "sha256:58072f0c06080276804c6a4e21a9045a706584a958e644353603d36ca1eb8a60", size = 10546415, upload-time = "2024-12-19T13:35:24.958Z" }, 194 | { url = "https://files.pythonhosted.org/packages/eb/7a/5aba20312c73f1ce61814e520d1920edf68ca3b9c507bd84d8546a8ecaa8/ruff-0.8.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ffb60904651c00a1e0b8df594591770018a0f04587f7deeb3838344fe3adabac", size = 10346113, upload-time = "2024-12-19T13:35:29.922Z" }, 195 | { url = "https://files.pythonhosted.org/packages/76/f4/c41de22b3728486f0aa95383a44c42657b2db4062f3234ca36fc8cf52d8b/ruff-0.8.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ddf5d654ac0d44389f6bf05cee4caeefc3132a64b58ea46738111d687352296", size = 9943564, upload-time = "2024-12-19T13:35:33.455Z" }, 196 | { url = "https://files.pythonhosted.org/packages/0e/f0/afa0d2191af495ac82d4cbbfd7a94e3df6f62a04ca412033e073b871fc6d/ruff-0.8.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e248b1f0fa2749edd3350a2a342b67b43a2627434c059a063418e3d375cfe643", size = 10805522, upload-time = "2024-12-19T13:35:36.514Z" }, 197 | { url = "https://files.pythonhosted.org/packages/12/57/5d1e9a0fd0c228e663894e8e3a8e7063e5ee90f8e8e60cf2085f362bfa1a/ruff-0.8.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf197b98ed86e417412ee3b6c893f44c8864f816451441483253d5ff22c0e81e", size = 10306763, upload-time = "2024-12-19T13:35:39.257Z" }, 198 | { url = "https://files.pythonhosted.org/packages/04/df/f069fdb02e408be8aac6853583572a2873f87f866fe8515de65873caf6b8/ruff-0.8.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c41319b85faa3aadd4d30cb1cffdd9ac6b89704ff79f7664b853785b48eccdf3", size = 11359574, upload-time = "2024-12-19T13:35:44.519Z" }, 199 | { url = "https://files.pythonhosted.org/packages/d3/04/37c27494cd02e4a8315680debfc6dfabcb97e597c07cce0044db1f9dfbe2/ruff-0.8.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:9f8402b7c4f96463f135e936d9ab77b65711fcd5d72e5d67597b543bbb43cf3f", size = 12094851, upload-time = "2024-12-19T13:35:48.975Z" }, 200 | { url = "https://files.pythonhosted.org/packages/81/b1/c5d7fb68506cab9832d208d03ea4668da9a9887a4a392f4f328b1bf734ad/ruff-0.8.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e4e56b3baa9c23d324ead112a4fdf20db9a3f8f29eeabff1355114dd96014604", size = 11655539, upload-time = "2024-12-19T13:35:52.865Z" }, 201 | { url = "https://files.pythonhosted.org/packages/ef/38/8f8f2c8898dc8a7a49bc340cf6f00226917f0f5cb489e37075bcb2ce3671/ruff-0.8.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:736272574e97157f7edbbb43b1d046125fce9e7d8d583d5d65d0c9bf2c15addf", size = 12912805, upload-time = "2024-12-19T13:35:57.234Z" }, 202 | { url = "https://files.pythonhosted.org/packages/06/dd/fa6660c279f4eb320788876d0cff4ea18d9af7d9ed7216d7bd66877468d0/ruff-0.8.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5fe710ab6061592521f902fca7ebcb9fabd27bc7c57c764298b1c1f15fff720", size = 11205976, upload-time = "2024-12-19T13:36:01.27Z" }, 203 | { url = "https://files.pythonhosted.org/packages/a8/d7/de94cc89833b5de455750686c17c9e10f4e1ab7ccdc5521b8fe911d1477e/ruff-0.8.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:13e9ec6d6b55f6da412d59953d65d66e760d583dd3c1c72bf1f26435b5bfdbae", size = 10792039, upload-time = "2024-12-19T13:36:04.459Z" }, 204 | { url = "https://files.pythonhosted.org/packages/6d/15/3e4906559248bdbb74854af684314608297a05b996062c9d72e0ef7c7097/ruff-0.8.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:97d9aefef725348ad77d6db98b726cfdb075a40b936c7984088804dfd38268a7", size = 10400088, upload-time = "2024-12-19T13:36:08.362Z" }, 205 | { url = "https://files.pythonhosted.org/packages/a2/21/9ed4c0e8133cb4a87a18d470f534ad1a8a66d7bec493bcb8bda2d1a5d5be/ruff-0.8.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ab78e33325a6f5374e04c2ab924a3367d69a0da36f8c9cb6b894a62017506111", size = 10900814, upload-time = "2024-12-19T13:36:12.877Z" }, 206 | { url = "https://files.pythonhosted.org/packages/0d/5d/122a65a18955bd9da2616b69bc839351f8baf23b2805b543aa2f0aed72b5/ruff-0.8.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8ef06f66f4a05c3ddbc9121a8b0cecccd92c5bf3dd43b5472ffe40b8ca10f0f8", size = 11268828, upload-time = "2024-12-19T13:36:15.718Z" }, 207 | { url = "https://files.pythonhosted.org/packages/43/a9/1676ee9106995381e3d34bccac5bb28df70194167337ed4854c20f27c7ba/ruff-0.8.4-py3-none-win32.whl", hash = "sha256:552fb6d861320958ca5e15f28b20a3d071aa83b93caee33a87b471f99a6c0835", size = 8805621, upload-time = "2024-12-19T13:36:18.551Z" }, 208 | { url = "https://files.pythonhosted.org/packages/10/98/ed6b56a30ee76771c193ff7ceeaf1d2acc98d33a1a27b8479cbdb5c17a23/ruff-0.8.4-py3-none-win_amd64.whl", hash = "sha256:f21a1143776f8656d7f364bd264a9d60f01b7f52243fbe90e7670c0dfe0cf65d", size = 9660086, upload-time = "2024-12-19T13:36:21.323Z" }, 209 | { url = "https://files.pythonhosted.org/packages/13/9f/026e18ca7d7766783d779dae5e9c656746c6ede36ef73c6d934aaf4a6dec/ruff-0.8.4-py3-none-win_arm64.whl", hash = "sha256:9183dd615d8df50defa8b1d9a074053891ba39025cf5ae88e8bcb52edcc4bf08", size = 9074500, upload-time = "2024-12-19T13:36:23.92Z" }, 210 | ] 211 | 212 | [[package]] 213 | name = "termcolor" 214 | version = "2.5.0" 215 | source = { registry = "https://pypi.org/simple" } 216 | sdist = { url = "https://files.pythonhosted.org/packages/37/72/88311445fd44c455c7d553e61f95412cf89054308a1aa2434ab835075fc5/termcolor-2.5.0.tar.gz", hash = "sha256:998d8d27da6d48442e8e1f016119076b690d962507531df4890fcd2db2ef8a6f", size = 13057, upload-time = "2024-10-06T19:50:04.115Z" } 217 | wheels = [ 218 | { url = "https://files.pythonhosted.org/packages/7f/be/df630c387a0a054815d60be6a97eb4e8f17385d5d6fe660e1c02750062b4/termcolor-2.5.0-py3-none-any.whl", hash = "sha256:37b17b5fc1e604945c2642c872a3764b5d547a48009871aea3edd3afa180afb8", size = 7755, upload-time = "2024-10-06T19:50:02.097Z" }, 219 | ] 220 | 221 | [[package]] 222 | name = "tldextract" 223 | version = "5.1.3" 224 | source = { registry = "https://pypi.org/simple" } 225 | dependencies = [ 226 | { name = "filelock" }, 227 | { name = "idna" }, 228 | { name = "requests" }, 229 | { name = "requests-file" }, 230 | ] 231 | sdist = { url = "https://files.pythonhosted.org/packages/4a/4f/eee4bebcbad25a798bf55601d3a4aee52003bebcf9e55fce08b91ca541a9/tldextract-5.1.3.tar.gz", hash = "sha256:d43c7284c23f5dc8a42fd0fee2abede2ff74cc622674e4cb07f514ab3330c338", size = 125033, upload-time = "2024-11-05T00:03:00.009Z" } 232 | wheels = [ 233 | { url = "https://files.pythonhosted.org/packages/c6/86/aebe15fa40a992c446be5cf14e70e58a251277494c14d26bdbcff0e658fd/tldextract-5.1.3-py3-none-any.whl", hash = "sha256:78de310cc2ca018692de5ddf320f9d6bd7c5cf857d0fd4f2175f0cdf4440ea75", size = 104923, upload-time = "2024-11-05T00:02:58.009Z" }, 234 | ] 235 | 236 | [[package]] 237 | name = "urllib3" 238 | version = "2.2.3" 239 | source = { registry = "https://pypi.org/simple" } 240 | sdist = { url = "https://files.pythonhosted.org/packages/ed/63/22ba4ebfe7430b76388e7cd448d5478814d3032121827c12a2cc287e2260/urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9", size = 300677, upload-time = "2024-09-12T10:52:18.401Z" } 241 | wheels = [ 242 | { url = "https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", size = 126338, upload-time = "2024-09-12T10:52:16.589Z" }, 243 | ] 244 | 245 | [[package]] 246 | name = "websocket-client" 247 | version = "1.8.0" 248 | source = { registry = "https://pypi.org/simple" } 249 | sdist = { url = "https://files.pythonhosted.org/packages/e6/30/fba0d96b4b5fbf5948ed3f4681f7da2f9f64512e1d303f94b4cc174c24a5/websocket_client-1.8.0.tar.gz", hash = "sha256:3239df9f44da632f96012472805d40a23281a991027ce11d2f45a6f24ac4c3da", size = 54648, upload-time = "2024-04-23T22:16:16.976Z" } 250 | wheels = [ 251 | { url = "https://files.pythonhosted.org/packages/5a/84/44687a29792a70e111c5c477230a72c4b957d88d16141199bf9acb7537a3/websocket_client-1.8.0-py3-none-any.whl", hash = "sha256:17b44cc997f5c498e809b22cdf2d9c7a9e71c02c8cc2b6c56e7c2d1239bfa526", size = 58826, upload-time = "2024-04-23T22:16:14.422Z" }, 252 | ] 253 | --------------------------------------------------------------------------------