├── 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 | [](https://github.com/plasticuproject/hibpwned/actions/workflows/tests.yml)
2 | [](https://www.python.org/downloads/release/python-311/)
3 | [](https://www.gnu.org/licenses/lgpl-3.0)
4 | [](https://badge.fury.io/py/hibpwned)
5 | [](https://pepy.tech/project/hibpwned)
6 | [](https://coveralls.io/github/plasticuproject/hibpwned?branch=master)
7 | [](https://github.com/plasticuproject/hibpwned/actions/workflows/codeql.yml)
8 | [](https://sonarcloud.io/dashboard?id=plasticuproject_hibpwned)
9 | [](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 |
--------------------------------------------------------------------------------