├── MANIFEST.in ├── hibpwned ├── py.typed ├── __init__.pyi └── __init__.py ├── .coveragerc ├── pyproject.toml ├── .github └── workflows │ ├── tests.yml │ ├── python-publish.yml │ └── codeql.yml ├── .gitignore ├── README.md ├── test.py └── LICENSE /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include hibpwned/py.typed 2 | -------------------------------------------------------------------------------- /hibpwned/py.typed: -------------------------------------------------------------------------------- 1 | # Marker file for PEP 561. 2 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | omit = 3 | */site-packages/* 4 | */distutils/* 5 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "hibpwned" 7 | version = "1.3.9" 8 | authors = [ 9 | { name="plasticuproject", email="plasticuproject@pm.me" }, 10 | ] 11 | description = "A human friendly Python API wrapper for haveibeenpwned.com" 12 | readme = "README.md" 13 | keywords = ["hibp", "haveibeenpwned", "api", "wrapper"] 14 | requires-python = ">=3.11" 15 | classifiers = [ 16 | "Programming Language :: Python :: 3", 17 | "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", 18 | "License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)", 19 | "Operating System :: OS Independent", 20 | "Topic :: Utilities", "Topic :: Utilities" 21 | ] 22 | dependencies = ["requests>=2.32.3"] 23 | 24 | [project.urls] 25 | "Homepage" = "https://github.com/plasticuproject/hibpwned" 26 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: ["push", "pull_request"] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: Set up Python 3.11 13 | uses: actions/setup-python@v4 14 | with: 15 | python-version: "3.11" 16 | - name: Install dependencies 17 | run: | 18 | python -m pip install --upgrade pip 19 | pip install requests 20 | pip install flake8 coverage>=6.3 mypy types-requests 21 | - name: Lint with flake8 22 | run: | 23 | # stop the build if there are Python syntax errors or undefined names 24 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 25 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 26 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 27 | - name: Static type checking with mypy 28 | run: | 29 | python -m mypy --strict . 30 | - name: Unit tests 31 | run: | 32 | python -m unittest -v 33 | - name: Generate code coverage reports 34 | run: | 35 | python -m coverage run test.py -v 36 | python -m coverage lcov -o ./coverage/lcov.info 37 | - name: Coveralls 38 | uses: coverallsapp/github-action@master 39 | with: 40 | github-token: ${{ secrets.GITHUB_TOKEN }} 41 | -------------------------------------------------------------------------------- /hibpwned/__init__.pyi: -------------------------------------------------------------------------------- 1 | """__init__.pyi""" 2 | 3 | from __future__ import annotations 4 | import requests 5 | 6 | ReturnAlias = int | list[dict[str, str | int | bool]] 7 | 8 | AltReturnAlias = int | list[dict[str, str | int | bool]] | list[str] 9 | 10 | DataAlias = list[dict[str, str | int | bool]] | dict[str, str | int | bool] 11 | 12 | AltDataAlias = (list[dict[str, str | int | bool]] 13 | | dict[str, str | int | bool] | list[str]) 14 | 15 | 16 | def _check(resp: requests.models.Response) -> None: 17 | ... 18 | 19 | 20 | class Pwned: 21 | 22 | url: str 23 | resp: requests.models.Response 24 | truncate_string: str 25 | domain_string: str 26 | unverified_string: str 27 | classes: list[str] 28 | hashes: str 29 | hash_list: list[str] 30 | hexdig: str 31 | hsh: str 32 | pnum: str 33 | data: DataAlias 34 | alt_data: AltDataAlias 35 | 36 | def __init__(self, account: str, agent: str, key: str) -> None: 37 | ... 38 | 39 | def search_all_breaches(self, 40 | truncate: bool | None = False, 41 | domain: str | None = None, 42 | unverified: bool | None = False) -> AltReturnAlias: 43 | ... 44 | 45 | def all_breaches(self, domain: str | None = None) -> ReturnAlias: 46 | ... 47 | 48 | def single_breach(self, name: str) -> ReturnAlias: 49 | ... 50 | 51 | def data_classes(self) -> int | list[str]: 52 | ... 53 | 54 | def search_pastes(self) -> ReturnAlias: 55 | ... 56 | 57 | def search_password(self, password: str) -> int | str: 58 | ... 59 | 60 | def search_hashes(self, hsh: str) -> int | str: 61 | ... 62 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # name: Publish Python 🐍 distribution 📦 to PyPI 2 | name: Build Python distribution and Release 3 | 4 | on: push 5 | 6 | jobs: 7 | build: 8 | name: Build distribution 📦 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Set up Python 14 | uses: actions/setup-python@v4 15 | with: 16 | python-version: "3.11" 17 | - name: Install pypa/build 18 | run: >- 19 | python3 -m 20 | pip install 21 | build 22 | --user 23 | - name: Build a binary wheel and a source tarball 24 | run: python3 -m build 25 | - name: Store the distribution packages 26 | uses: actions/upload-artifact@v4 27 | with: 28 | name: python-package-distributions 29 | path: dist/ 30 | 31 | publish-to-pypi: 32 | name: >- 33 | Publish Python 🐍 distribution 📦 to PyPI 34 | if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes 35 | needs: 36 | - build 37 | runs-on: ubuntu-latest 38 | environment: 39 | name: Pypi 40 | url: https://pypi.org/p/hibpwned 41 | permissions: 42 | id-token: write # IMPORTANT: mandatory for trusted publishing 43 | 44 | steps: 45 | - name: Download all the dists 46 | uses: actions/download-artifact@v4 47 | with: 48 | name: python-package-distributions 49 | path: dist/ 50 | - name: Publish distribution 📦 to PyPI 51 | uses: pypa/gh-action-pypi-publish@release/v1 52 | 53 | github-release: 54 | name: >- 55 | Sign the Python 🐍 distribution 📦 with Sigstore 56 | and upload them to GitHub Release 57 | if: startsWith(github.ref, 'refs/tags/') 58 | needs: 59 | - publish-to-pypi 60 | runs-on: ubuntu-latest 61 | 62 | permissions: 63 | contents: write # IMPORTANT: mandatory for making GitHub Releases 64 | id-token: write # IMPORTANT: mandatory for sigstore 65 | 66 | steps: 67 | - name: Download all the dists 68 | uses: actions/download-artifact@v4 69 | with: 70 | name: python-package-distributions 71 | path: dist/ 72 | - name: Sign the dists with Sigstore 73 | uses: sigstore/gh-action-sigstore-python@v3.0.0 74 | with: 75 | inputs: >- 76 | ./dist/*.tar.gz 77 | ./dist/*.whl 78 | - name: Upload artifact signatures to GitHub Release 79 | env: 80 | GITHUB_TOKEN: ${{ github.token }} 81 | # Upload to GitHub Release using the `gh` CLI. 82 | # `dist/` contains the built packages, and the 83 | # sigstore-produced signatures and certificates. 84 | run: >- 85 | gh release create 86 | '${{ github.ref_name }}' dist/** 87 | --repo '${{ github.repository }}' 88 | -------------------------------------------------------------------------------- /.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 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # pytype static type analyzer 135 | .pytype/ 136 | 137 | # Cython debug symbols 138 | cython_debug/ 139 | 140 | .idea/ 141 | 142 | docs/_build 143 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![build](https://github.com/plasticuproject/hibpwned/actions/workflows/tests.yml/badge.svg)](https://github.com/plasticuproject/hibpwned/actions/workflows/tests.yml) 2 | [![Python 3.11](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/release/python-311/) 3 | [![License: LGPL v3](https://img.shields.io/badge/License-LGPL%20v3-blue.svg)](https://www.gnu.org/licenses/lgpl-3.0) 4 | [![PyPI version](https://badge.fury.io/py/hibpwned.svg)](https://badge.fury.io/py/hibpwned) 5 | [![Downloads](https://pepy.tech/badge/hibpwned)](https://pepy.tech/project/hibpwned) 6 | [![Coverage Status](https://coveralls.io/repos/github/plasticuproject/hibpwned/badge.svg?branch=master)](https://coveralls.io/github/plasticuproject/hibpwned?branch=master) 7 | [![CodeQL](https://github.com/plasticuproject/hibpwned/actions/workflows/codeql.yml/badge.svg)](https://github.com/plasticuproject/hibpwned/actions/workflows/codeql.yml) 8 | [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=plasticuproject_hibpwned&metric=alert_status)](https://sonarcloud.io/dashboard?id=plasticuproject_hibpwned) 9 | [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=plasticuproject_hibpwned&metric=security_rating)](https://sonarcloud.io/dashboard?id=plasticuproject_hibpwned) 10 | # hibpwned 11 | A friendly, low-level, fully functional, Python API wrapper for haveibeenpwned.com
12 | All data sourced from https://haveibeenpwned.com
13 | Visit https://haveibeenpwned.com/API/v3 to read the Acceptable Use Policy
14 | for rules regarding acceptable usage of this API.
15 | 16 | 17 | ## Installation 18 | ``` 19 | pip install hibpwned 20 | ``` 21 | Making calls to the HIBP API requires a key. You can purchase an HIBP-API-Key at
22 | https://haveibeenpwned.com/API/Key 23 | 24 | 25 | ## Usage 26 | This module contains the class Pwned with functions:
27 | 28 | search_all_breaches
29 | all_breaches
30 | single_breach
31 | data_classes
32 | search_pastes
33 | search_password
34 | search_hashes
35 | 36 | All functions return a list of JSON objects containing relevent data, with the exception
37 | of search_password and search_hashes, which returns an integer and a string object,
38 | respectively.
39 | 40 | See module DocStrings for function descriptions and parameters
41 | 42 | 43 | ## Examples 44 | ```python 45 | import hibpwned 46 | 47 | my_app = hibpwned.Pwned("test@example.com", "My_App", "My_API_Key") 48 | 49 | my_breaches = my_app.search_all_breaches() 50 | breaches = my_app.all_breaches() 51 | adobe = my_app.single_breach("adobe") 52 | data = my_app.data_classes() 53 | my_pastes = my_app.search_pastes() 54 | password = my_app.search_password("BadPassword") 55 | my_hashes = my_app.search_hashes("21BD1") 56 | ``` 57 | 58 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "master" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "master" ] 20 | schedule: 21 | - cron: '45 2 * * 6' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'python' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Use only 'java' to analyze code written in Java, Kotlin or both 38 | # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both 39 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 40 | 41 | steps: 42 | - name: Checkout repository 43 | uses: actions/checkout@v3 44 | 45 | # Initializes the CodeQL tools for scanning. 46 | - name: Initialize CodeQL 47 | uses: github/codeql-action/init@v2 48 | with: 49 | languages: ${{ matrix.language }} 50 | # If you wish to specify custom queries, you can do so here or in a config file. 51 | # By default, queries listed here will override any specified in a config file. 52 | # Prefix the list here with "+" to use these queries and those in the config file. 53 | 54 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 55 | # queries: security-extended,security-and-quality 56 | 57 | 58 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). 59 | # If this step fails, then you should remove it and run the build manually (see below) 60 | - name: Autobuild 61 | uses: github/codeql-action/autobuild@v2 62 | 63 | # ℹ️ Command-line programs to run using the OS shell. 64 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 65 | 66 | # If the Autobuild fails above, remove it and uncomment the following three lines. 67 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 68 | 69 | # - run: | 70 | # echo "Run, Build Application using script" 71 | # ./location_of_script_within_repo/buildscript.sh 72 | 73 | - name: Perform CodeQL Analysis 74 | uses: github/codeql-action/analyze@v2 75 | with: 76 | category: "/language:${{matrix.language}}" 77 | -------------------------------------------------------------------------------- /test.py: -------------------------------------------------------------------------------- 1 | """A human friendly python API wrapper for https://haveibeenpwned.com 2 | All data is sourced from https://haveibeenpwned.com 3 | Visit https://haveibeenpwned.com/API/v3 to read the Acceptable Use Policy 4 | for rules regarding acceptable usage of this API. 5 | 6 | Copyright (C) 2020 plasticuproject@pm.me 7 | 8 | This program is free software: you can redistribute it and/or modify 9 | it under the terms of the GNU General Public License as published by 10 | the Free Software Foundation, either version 3 of the License. 11 | This program is distributed in the hope that it will be useful, 12 | but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | GNU General Public License for more details. 15 | """ 16 | from __future__ import annotations 17 | import unittest 18 | import random 19 | from unittest import mock 20 | from typing import Any 21 | import requests 22 | import hibpwned 23 | 24 | 25 | # pylint: disable=unused-argument 26 | def mocked_requests_get(*args: Any, **kwargs: Any) -> Any: 27 | """This method will be used by the mock to replace requests.get.""" 28 | fake_email: str = "test@example.com" 29 | fake_email_two: str = "test.two@example.com" 30 | 31 | class MockResponse: # pylint: disable=too-few-public-methods 32 | """Mock API responses.""" 33 | 34 | def __init__(self, response_data: list[dict[str, str]] | list[str] 35 | | dict[str, str] | None, status_code: int) -> None: 36 | self.response_data = response_data 37 | self.status_code = status_code 38 | 39 | def json( 40 | self 41 | ) -> list[dict[str, str]] | list[str] | dict[str, str] | None: 42 | """Returns mocked API response data.""" 43 | return self.response_data 44 | 45 | if args[0] == ("https://haveibeenpwned.com/api/v3/breachedaccount/" + 46 | fake_email): 47 | return MockResponse(["FakeSite"], 200) 48 | if args[0] == ("https://haveibeenpwned.com/api/v3/breachedaccount/" + 49 | fake_email + "?truncateResponse=false"): 50 | return MockResponse({"testKey": "testValue"}, 200) 51 | if args[0] == ("https://haveibeenpwned.com/api/v3/pasteaccount/" + 52 | fake_email): 53 | return MockResponse({"testKey": "testValue"}, 200) 54 | if args[0] == ("https://haveibeenpwned.com/api/v3/pasteaccount/" + 55 | fake_email_two): 56 | return MockResponse([{ 57 | "testKey": "testValue" 58 | }, { 59 | "testKeyTwo": "testValueTwo" 60 | }], 200) 61 | return MockResponse(None, 404) 62 | 63 | 64 | def password_generator() -> str: 65 | """Generate a unique randomish password.""" 66 | password = '' 67 | for _ in range(random.randint(25, 30)): 68 | rand_int = random.randint(97, 97 + 26 - 1) 69 | if random.randint(0, 1): 70 | rand_int = rand_int - 32 71 | password += chr(rand_int) 72 | return password 73 | 74 | 75 | class TestApiCalls(unittest.TestCase): 76 | """Test all module API calls.""" 77 | email: str = "test@example.com" 78 | email_two: str = "test.two@example.com" 79 | app_name: str = "wrapper_test" 80 | key: str = "No Key" 81 | pwned = hibpwned.Pwned(email, app_name, key) 82 | pwned_two = hibpwned.Pwned(email_two, app_name, key) 83 | 84 | def test_search_password(self) -> None: 85 | """Test search_password function.""" 86 | weak = self.pwned.search_password("password") 87 | strong = self.pwned.search_password(password_generator()) 88 | if isinstance(weak, str): 89 | self.assertTrue(weak.isdigit()) 90 | self.assertEqual(strong, '0') 91 | 92 | def test_single_breach(self) -> None: 93 | """Test single_breach function.""" 94 | names = self.pwned.single_breach("adobe") 95 | if isinstance(names, list): 96 | name = names[0]["Name"] 97 | if name: 98 | self.assertEqual(name, "Adobe") 99 | bad_name = self.pwned.single_breach("bullshit") 100 | self.assertEqual(bad_name, 404) 101 | 102 | def test_all_breaches(self) -> None: 103 | """Test all_breaches function.""" 104 | length_test = self.pwned.all_breaches() 105 | if isinstance(length_test, list): 106 | list_length = len(length_test) 107 | if list_length: 108 | self.assertTrue(list_length > 439) 109 | names = self.pwned.all_breaches(domain="adobe.com") 110 | if isinstance(names, list): 111 | domain_name = names[0] 112 | if domain_name: 113 | self.assertEqual(domain_name["Name"], "Adobe") 114 | 115 | def test_search_hashes(self) -> None: 116 | """Test search_hashes function.""" 117 | password_hash = self.pwned.search_hashes("5baa6") 118 | self.assertFalse(str(password_hash).isdigit()) 119 | bad_hash = self.pwned.search_hashes("beef") 120 | self.assertEqual(bad_hash, 400) 121 | 122 | def test_data_classes(self) -> None: 123 | """Test data_classes function.""" 124 | lst = type(self.pwned.data_classes()) 125 | self.assertTrue(lst, "list") 126 | 127 | def test_search_all_breaches(self) -> None: 128 | """Test search_all_breaches function.""" 129 | bad_key = self.pwned.search_all_breaches() 130 | bad_key_name = self.pwned.search_all_breaches(domain="adobe.com") 131 | bad_key_trunc_unver = self.pwned.search_all_breaches(truncate=True, 132 | unverified=True) 133 | self.assertEqual(bad_key, 401) 134 | self.assertEqual(bad_key_name, 401) 135 | self.assertEqual(bad_key_trunc_unver, 401) 136 | 137 | def test_search_pastes(self) -> None: 138 | """Test search_pastes function.""" 139 | bad_key = self.pwned.search_pastes() 140 | self.assertEqual(bad_key, 401) 141 | 142 | @mock.patch("requests.get", side_effect=mocked_requests_get) 143 | def test_mock_error(self, mock_get: mock.MagicMock) -> None: 144 | """Test a non-recognized mock endpoint will return a 145 | status code 404.""" 146 | bad_url = requests.get("https://www.fart.com", timeout=300) 147 | self.assertEqual(bad_url.status_code, 404) 148 | 149 | @mock.patch("hibpwned.requests.get", side_effect=mocked_requests_get) 150 | def test_mock_search_all_breaches(self, mock_get: mock.MagicMock) -> None: 151 | """Test search_all_breaches against mock API, since we do not 152 | have a valid API-Key to test againt the live API.""" 153 | no_trunc_data = self.pwned.search_all_breaches() 154 | if isinstance(no_trunc_data, list): 155 | self.assertEqual(no_trunc_data[0], {"testKey": "testValue"}) 156 | trunc_data = self.pwned.search_all_breaches(truncate=True) 157 | if isinstance(trunc_data, list): 158 | self.assertEqual(trunc_data[0], "FakeSite") 159 | 160 | @mock.patch("hibpwned.requests.get", side_effect=mocked_requests_get) 161 | def test_mock_search_pastes(self, mock_get: mock.MagicMock) -> None: 162 | """Test search_pastes against mock API, since we do not 163 | have a valid API-Key to test againt the live API.""" 164 | pastes = self.pwned.search_pastes() 165 | if isinstance(pastes, list): 166 | self.assertEqual(pastes[0], {"testKey": "testValue"}) 167 | pastes_two = self.pwned_two.search_pastes() 168 | if isinstance(pastes_two, list): 169 | self.assertEqual(pastes_two[1], {"testKeyTwo": "testValueTwo"}) 170 | 171 | 172 | if __name__ == "__main__": 173 | unittest.main() 174 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. 166 | -------------------------------------------------------------------------------- /hibpwned/__init__.py: -------------------------------------------------------------------------------- 1 | """A human friendly python API wrapper for https://haveibeenpwned.com 2 | All data is sourced from https://haveibeenpwned.com 3 | Visit https://haveibeenpwned.com/API/v3 to read the Acceptable Use Policy 4 | for rules regarding acceptable usage of this API. 5 | 6 | Copyright (C) 2022 plasticuproject@pm.me 7 | 8 | This program is free software: you can redistribute it and/or modify 9 | it under the terms of the GNU General Public License as published by 10 | the Free Software Foundation, either version 3 of the License. 11 | This program is distributed in the hope that it will be useful, 12 | but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | GNU General Public License for more details. 15 | """ 16 | from __future__ import annotations 17 | import hashlib 18 | import requests 19 | 20 | ReturnAlias = int | list[dict[str, str | int | bool]] 21 | 22 | AltReturnAlias = int | list[dict[str, str | int | bool]] | list[str] 23 | 24 | DataAlias = list[dict[str, str | int | bool]] | dict[str, str | int | bool] 25 | 26 | AltDataAlias = (list[dict[str, str | int | bool]] 27 | | dict[str, str | int | bool] | list[str]) 28 | 29 | 30 | def _check(resp: requests.models.Response) -> None: 31 | """Helper function to check the response code and prints anything 32 | other than a 200 OK.""" 33 | 34 | try: 35 | if resp.status_code == 400: 36 | print("Bad request: The account does not comply with an" + 37 | " acceptable format (i.e. it's an empty string)") 38 | elif resp.status_code == 401: 39 | print("Unauthorized — the API key provided was not valid") 40 | elif resp.status_code == 403: 41 | print("Forbidden: No user agent has" + 42 | " been specified in the request") 43 | elif resp.status_code == 404: 44 | print("Not found: The account could not be found and" + 45 | " has therefore not been pwned") 46 | elif resp.status_code == 429: 47 | print("Too many requests: The rate limit has been exceeded\n") 48 | print(resp.text) 49 | except requests.RequestException: 50 | print("ERROR: Could not connect to server") 51 | 52 | 53 | class Pwned: 54 | """All functions, with the exception of search_password and 55 | search_hashes, return JSON formated data related to the function and 56 | arguments/search terms from https://haveibeenpwned.com. The 57 | search_password and search_hashes functions will return an integer 58 | and plaintext string of hashes, respectively. 59 | 60 | Authorisation is required for all API requests. A HIBP subscription 61 | key is required to make an authorised call and can be obtained on 62 | the API key page at https://haveibeenpwned.com/API/Key. 63 | 64 | User must initialize the Pwned class with the account (email 65 | address being queried) and a User-Agent (the name of your 66 | application), even if an account is not applicable to the 67 | function. 68 | 69 | A "breach" is an instance of a system having been compromised by 70 | an attacker and the data disclosed. Each breach contains a number 71 | of attributes describing the incident. In the future these 72 | attributes may expand. The current attributes are: 73 | 74 | ATTRIBUTE TYPE DESCRIPTION 75 | 76 | Name string A Pascal-cased name representing the breach 77 | which is unique across all other breaches. 78 | This value never changes and may be used to 79 | name dependent assets (such as images) but 80 | should not be shown directly to end users 81 | (see the "Title" attribute instead). 82 | 83 | Title string A descriptive title for the breach suitable 84 | for displaying to end users. It's unique 85 | across all breaches but individual values 86 | may change in the future (i.e. if another 87 | breach occurs against an organisation 88 | already in the system). If a stable value is 89 | required to reference the breach, refer to 90 | the "Name" attribute instead. 91 | 92 | Domain string The domain of the primary website the breach 93 | occurred on. This may be used for 94 | identifying other assets external systems may 95 | have for the site. 96 | 97 | BreachDate date The date (with no time) the breach 98 | originally occurred on in ISO 8601 format. 99 | This is not always accurate — frequently 100 | breaches are discovered and reported long 101 | after the original incident. Use this 102 | attribute as a guide only. 103 | 104 | AddedDate datetime The date and time (precision to the minute) 105 | the breach was added to the system in ISO 106 | 8601 format. 107 | 108 | ModifiedDate datetime The date and time (precision to the minute) 109 | the breach was modified in ISO 8601 format. 110 | This will only differ from the AddedDate 111 | attribute if other attributes represented 112 | here are changed or data in the breach 113 | itself is changed (i.e. additional data is 114 | identified and loaded). It is always either 115 | equal to or greater then the AddedDate 116 | attribute, never less than. 117 | 118 | PwnCount integer The total number of accounts loaded into the 119 | system. This is usually less than the total 120 | number reported by the media due to 121 | duplication or other data integrity issues 122 | in the source data. 123 | 124 | Description string Contains an overview of the breach 125 | represented in HTML markup. The description 126 | may include markup such as emphasis and 127 | strong tags as well as hyperlinks. 128 | 129 | DataClasses string This attribute describes the nature of the 130 | data compromised in the breach and contains 131 | an alphabetically ordered string array of 132 | impacted data classes. 133 | 134 | IsVerified boolean Indicates that the breach is considered 135 | unverified. An unverified breach may not 136 | have been hacked from the indicated website. 137 | An unverified breach is still loaded into 138 | HIBP when there's sufficient confidence that 139 | a significant portion of the data is 140 | legitimate. 141 | 142 | IsFabricated boolean Indicates that the breach is considered 143 | fabricated. A fabricated breach is unlikely 144 | to have been hacked from the indicated 145 | website and usually contains a large amount 146 | of manufactured data. However, it still 147 | contains legitimate email addresses and 148 | asserts that the account owners were 149 | compromised in the alleged breach. 150 | 151 | IsSensitive boolean Indicates if the breach is considered 152 | sensitive. The public API will not return 153 | any accounts for a breach flagged as 154 | sensitive. 155 | 156 | IsRetired boolean Indicates if the breach has been retired. 157 | This data has been permanently removed and 158 | will not be returned by the API. 159 | 160 | IsSpamList boolean Indicates if the breach is considered a spam 161 | list. This flag has no impact on any other 162 | attributes but it means that the data has 163 | not come as a result of a security compromise. 164 | 165 | 166 | RESPONSE CODES: 167 | 168 | Semantic HTTP response codes are used to indicate the result of the 169 | search: 170 | 171 | CODE DESCRIPTION 172 | 173 | 200 Ok — everything worked and there's a string array of pwned 174 | sites for the account. 175 | 176 | 400 Bad request — the account does not comply with an acceptable 177 | format (i.e. it's an empty string). 178 | 179 | 401 Unauthorized — the API key provided was not valid 180 | 181 | 403 Forbidden — no user agent has been specified in the request. 182 | 183 | 404 Not found — the account could not be found and has therefore 184 | not been pwned. 185 | 186 | 429 Too many requests — the rate limit has been exceeded. 187 | 188 | 189 | ___________________________________________________________________ 190 | ------------------------------------------------------------------- 191 | 192 | Class Functions:: (see function DocStrings for details) 193 | 194 | search_all_breaches 195 | all_breaches 196 | single_breach 197 | data_classes 198 | search_pastes 199 | search_password 200 | search_hashes 201 | 202 | 203 | Usage:: 204 | 205 | >>> foo = Pwned("test@example.com", "My_App", "My_API_Key") 206 | >>> data = foo.search_password("BadPassword") 207 | """ 208 | url: str 209 | resp: requests.models.Response 210 | truncate_string: str 211 | domain_string: str 212 | unverified_string: str 213 | classes: list[str] 214 | hashes: str 215 | hash_list: list[str] 216 | hexdig: str 217 | hsh: str 218 | pnum: str 219 | data: DataAlias 220 | alt_data: AltDataAlias 221 | 222 | def __init__(self, account: str, agent: str, key: str) -> None: 223 | self.account = account 224 | self.agent = agent 225 | self.key = key 226 | self.header: dict[str, str] = { 227 | "User-Agent": self.agent, 228 | "hibp-api-key": self.key 229 | } 230 | self.timeout = 300 231 | 232 | def search_all_breaches(self, 233 | truncate: bool | None = False, 234 | domain: str | None = None, 235 | unverified: bool | None = False) -> AltReturnAlias: 236 | """The most common use of the API is to return a list of all 237 | breaches a particular account has been involved in. 238 | 239 | By default, only the name of the breach is returned rather than the 240 | complete breach data, thus reducing the response body size by 241 | approximately 98%. The name can then be used to either retrieve a 242 | single breach or it can be found in the list of all breaches in the 243 | system. If you'd like complete breach data returned in the API call, 244 | a non-truncated response can be specified via query string parameter: 245 | 246 | `?truncateResponse=false` 247 | 248 | Note: In version 2 of the API this behaviour was the opposite - 249 | responses were not truncated by default. 250 | 251 | The result set can also be filtered by domain by passing the 252 | "domain='example.com'" argument. This filters the result set to 253 | only breaches against the domain specified. It is possible that 254 | one site (and consequently domain), is compromised on multiple 255 | occasions. 256 | 257 | The public API will not return accounts from any breaches 258 | flagged as sensitive or retired. By default, the API also won't 259 | return breaches flagged as unverified, however these can be 260 | included by passing the argument "unverified=True". This 261 | returns breaches that have been flagged as "unverified". By 262 | default, only verified breaches are returned when performing a 263 | search. 264 | 265 | 266 | Usage:: 267 | 268 | >>> foo = Pwned('test@example.com', 'My_App', "My_API_Key") 269 | >>> data = foo.search_all_breaches() 270 | >>> data = foo.search_all_breaches(domain='adobe.com') 271 | >>> data = foo.search_all_breaches(truncate=True, unverified=True) 272 | """ 273 | url = "https://haveibeenpwned.com/api/v3/breachedaccount/" 274 | if truncate: 275 | truncate_string = "" 276 | else: 277 | truncate_string = "?truncateResponse=false" 278 | if not domain: 279 | domain_string = "" 280 | else: 281 | domain_string = "?domain=" + domain 282 | if unverified: 283 | unverified_string = "?includeUnverified=true" 284 | else: 285 | unverified_string = "" 286 | resp = requests.get(url + self.account + truncate_string + 287 | domain_string + unverified_string, 288 | timeout=self.timeout, 289 | headers=self.header) 290 | _check(resp) 291 | if resp.status_code == 200: 292 | alt_data = resp.json() 293 | if not isinstance(alt_data, list): 294 | return [alt_data] 295 | return alt_data 296 | return resp.status_code 297 | 298 | def all_breaches(self, domain: str | None = None) -> ReturnAlias: 299 | """Retrieves all breached sites from the system. The result set 300 | can also be filtered by domain by passing the argument 301 | "domain='example.com'". This filters the result set to only 302 | breaches against the domain specified. It is possible that one 303 | site (and consequently domain), is compromised on multiple 304 | occasions. 305 | 306 | 307 | Usage:: 308 | 309 | >>> foo = Pwned("test@example.com", "My_App", "My_API_Key") 310 | >>> data = foo.all_breaches() 311 | >>> data = foo.all_breaches(domain="adobe.com") 312 | """ 313 | url = "https://haveibeenpwned.com/api/v3/breaches" 314 | if not domain: 315 | domain_string = "" 316 | else: 317 | domain_string = "?domain=" + domain 318 | resp = requests.get(url + domain_string, 319 | headers=self.header, 320 | timeout=self.timeout) 321 | _check(resp) 322 | if resp.status_code == 200: 323 | data = resp.json() 324 | if isinstance(data, list): 325 | return data 326 | return resp.status_code 327 | 328 | def single_breach(self, name: str) -> ReturnAlias: 329 | """ 330 | Returns a single breached site queried by name. Sometimes just 331 | a single breach is required and this can be retrieved by the 332 | breach "name". This is the stable value which may or may not be 333 | the same as the breach "title" (which can change). 334 | 335 | 336 | Usage:: 337 | 338 | >>> foo = Pwned("test@example.com", "My_App", "My_API_Key") 339 | >>> data = foo.single_breach("adobe") 340 | """ 341 | url = "https://haveibeenpwned.com/api/v3/breach/" 342 | resp = requests.get(url + name, 343 | headers=self.header, 344 | timeout=self.timeout) 345 | _check(resp) 346 | if resp.status_code == 200: 347 | data = resp.json() 348 | if not isinstance(data, list): 349 | return [data] 350 | # return data # Pretty sure will never hit 351 | return resp.status_code 352 | 353 | def data_classes(self) -> int | list[str]: 354 | """Returns all data classes in the system. 355 | 356 | 357 | Usage:: 358 | 359 | >>> foo = Pwned("test@example.com", "My_App", "My_API_Key") 360 | >>> data = foo.data_classes() 361 | """ 362 | url = "https://haveibeenpwned.com/api/v3/dataclasses" 363 | resp = requests.get(url, headers=self.header, timeout=self.timeout) 364 | _check(resp) 365 | if resp.status_code == 200: 366 | classes = resp.json() 367 | if isinstance(classes, list): 368 | return classes 369 | return resp.status_code 370 | 371 | def search_pastes(self) -> ReturnAlias: 372 | """Returns all pastes for an account. Unlike searching for 373 | breaches, usernames that are not email addresses cannot be 374 | searched for. Searching an account for pastes always returns a 375 | collection of the paste entity. The collection is sorted 376 | chronologically with the newest paste first. 377 | 378 | Each paste contains a number of attributes describing it. In 379 | the future, these attributes may expand. The current 380 | attributes are: 381 | 382 | ATTRIBUTE TYPE DESCRIPTION 383 | 384 | Source string The paste service the record was 385 | retrieved from. Current values are: 386 | Pastebin, Pastie, Slexy, Ghostbin, 387 | QuickLeak, JustPaste, AdHocUrl, OptOut 388 | 389 | Id string The ID of the paste as it was given at 390 | the source service. Combined with the 391 | "Source" attribute, this can be used to 392 | resolve the URL of the paste. 393 | 394 | Title string The title of the paste as observed on 395 | the source site. This may be null and 396 | if so will be omitted from the 397 | response. 398 | 399 | Date date The date and time (precision to the 400 | second) that the paste was posted. This 401 | is taken directly from the paste site 402 | when this information is available but 403 | may be null if no date is published. 404 | 405 | EmailCount integer The number of emails that were found 406 | when processing the paste. Emails are 407 | extracted by using the regular 408 | expression \b+(?!^.{256})[a-zA-Z0-9\\.\\- 409 | _\\+]+@[a-zA-Z0-9\\.\\-_]+\\.[a-zA-Z]+\b 410 | 411 | 412 | Usage:: 413 | 414 | >>> foo = Pwned("test@example.com", "My_App", "My_API_Key") 415 | >>> data = foo.search_pastes() 416 | """ 417 | url = "https://haveibeenpwned.com/api/v3/pasteaccount/" 418 | resp = requests.get(url + self.account, 419 | headers=self.header, 420 | timeout=self.timeout) 421 | _check(resp) 422 | if resp.status_code == 200: 423 | data = resp.json() 424 | if not isinstance(data, list): 425 | return [data] 426 | return data 427 | return resp.status_code 428 | 429 | def search_password(self, password: str) -> int | str: 430 | """Returns an integer of how many times the password appears in 431 | the Pwned Passwords repository, where each password is stored 432 | as a SHA-1 hash of a UTF-8 encoded password. When a password 433 | hash with the same first 5 characters is found in the Pwned 434 | Passwords repository, the API will respond with an HTTP 200 435 | and include the suffix of every hash beginning with the 436 | specified prefix, followed by a count of how many times it 437 | appears in the data set. The function then searches the results 438 | of the response for the presence of the source hash and if not 439 | found, the password does not exist in the data set. 440 | 441 | In order to protect the value of the source password being 442 | searched for, Pwned Passwords also implements a k-Anonymity 443 | model that allows a password to be searched for by partial 444 | hash. This allows the first 5 characters of a SHA-1 password 445 | hash (not case-sensitive) to be passed to the API. 446 | 447 | 448 | Usage:: 449 | 450 | >>> foo = Pwned("test@example.com", "My_App", "My_API_Key") 451 | >>> data = foo.search_password("BadPassword") 452 | """ 453 | url = "https://api.pwnedpasswords.com/range/" 454 | hash_object = hashlib.sha1(bytes(password, encoding="utf-8")) 455 | hexdig = hash_object.hexdigest() 456 | hexdig = hexdig.upper() 457 | hsh = hexdig[:5] 458 | pnum = '0' 459 | resp = requests.get(url + hsh, 460 | headers=self.header, 461 | timeout=self.timeout) 462 | _check(resp) 463 | if resp.status_code == 200: 464 | hash_list = resp.text.splitlines() 465 | for item in hash_list: 466 | if item[0:35] == hexdig[5:]: 467 | pnum = item[36:] 468 | return pnum 469 | return resp.status_code 470 | 471 | def search_hashes(self, hsh: str) -> int | str: 472 | """Returns a string of plaintext hashes which are suffixes to the 473 | first 5 characters of the searched hash argument. When a 474 | password hash with the same first 5 characters is found in the 475 | Pwned Passwords repository, the API will respond with an HTTP 476 | 200 and include the suffix of every hash beginning with the 477 | specified prefix, followed by a count of how many times it 478 | appears in the data set. The API consumer can then search the 479 | results of the response for the presence of their source hash 480 | and if not found, the password does not exist in the data set. 481 | 482 | In order to protect the value of the source password being 483 | searched for, Pwned Passwords also implements a k-Anonymity 484 | model that allows a password to be searched for by partial 485 | hash. This allows the first 5 characters of a SHA-1 password 486 | hash (not case-sensitive) to be passed to the API. 487 | 488 | Each password is stored as a SHA-1 hash of a UTF-8 encoded 489 | password. The hash and password count are delimited with a 490 | colon (:). 491 | 492 | Usage:: 493 | 494 | >>> foo = Pwned("test@example.com", "My_App", "My_API_Key") 495 | >>> data = foo.search_hashes("21BD1") 496 | """ 497 | url = "https://api.pwnedpasswords.com/range/" 498 | hsh = hsh[:5] 499 | resp = requests.get(url + hsh, 500 | headers=self.header, 501 | timeout=self.timeout) 502 | _check(resp) 503 | if resp.status_code == 200: 504 | hashes = resp.text 505 | return hashes 506 | return resp.status_code 507 | --------------------------------------------------------------------------------