Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions breach-check/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.venv/
__pycache__/
*.egg-info/
.pytest_cache/
.mypy_cache/
.env
23 changes: 23 additions & 0 deletions breach-check/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "breach-check"
version = "0.1.0"
description = "Проверка паролей и email по Have I Been Pwned: пароли через k-anonymity range API. Zero runtime dependencies."
requires-python = ">=3.11"
authors = [{ name = "h4root" }]
dependencies = []

[project.optional-dependencies]
dev = ["pytest>=8.0"]

[project.scripts]
breach-check = "breach_check.cli:main"

[tool.hatch.build.targets.wheel]
packages = ["src/breach_check"]

[tool.pytest.ini_options]
testpaths = ["tests"]
17 changes: 17 additions & 0 deletions breach-check/src/breach_check/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from breach_check.accounts import AccountResult, Breach, check_account, read_accounts
from breach_check.hibp import HibpError
from breach_check.passwords import PasswordResult, check_password, parse_range, sha1_hex

__all__ = [
"AccountResult",
"Breach",
"HibpError",
"PasswordResult",
"check_account",
"check_password",
"parse_range",
"read_accounts",
"sha1_hex",
"__version__",
]
__version__ = "0.1.0"
6 changes: 6 additions & 0 deletions breach-check/src/breach_check/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import sys

from breach_check.cli import main

if __name__ == "__main__":
sys.exit(main())
81 changes: 81 additions & 0 deletions breach-check/src/breach_check/accounts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
from __future__ import annotations

import json
import time
from dataclasses import dataclass
from urllib.parse import quote

from breach_check.apikey import API_KEY_ENV
from breach_check.hibp import DEFAULT_TIMEOUT, HibpError, http_get

ACCOUNT_URL = "https://haveibeenpwned.com/api/v3/breachedaccount/"
DEFAULT_DELAY = 1.6
MAX_RETRY_WAIT = 60.0


@dataclass(frozen=True)
class Breach:
name: str
date: str | None = None
data_classes: tuple[str, ...] = ()


@dataclass(frozen=True)
class AccountResult:
account: str
breaches: tuple[Breach, ...] = ()
error: str | None = None


def validate_account(raw: str) -> str:
account = raw.strip()
local, _, domain = account.partition("@")
if not local or not domain:
raise ValueError(f"не похоже на email: {raw!r}")
return account


def read_accounts(text: str) -> list[str]:
lines = (line.split("#", 1)[0].strip() for line in text.splitlines())
return [validate_account(line) for line in lines if line]


def parse_breaches(body: str) -> tuple[Breach, ...]:
try:
payload = json.loads(body)
except json.JSONDecodeError as error:
raise HibpError(f"HIBP вернул не JSON: {error}") from error
return tuple(
Breach(
item.get("Name") or item.get("Title") or "?",
item.get("BreachDate"),
tuple(item.get("DataClasses") or ()),
)
for item in payload
)


def check_account(
account: str,
api_key: str,
timeout: float = DEFAULT_TIMEOUT,
) -> AccountResult:
url = f"{ACCOUNT_URL}{quote(account, safe='')}?truncateResponse=false"
headers = {"hibp-api-key": api_key}
try:
response = http_get(url, headers, timeout)
if response.status == 429:
time.sleep(min(response.retry_after or DEFAULT_DELAY, MAX_RETRY_WAIT))
response = http_get(url, headers, timeout)
except OSError as error:
return AccountResult(account, error=str(error))

if response.status == 404:
return AccountResult(account)
if response.status == 200:
return AccountResult(account, parse_breaches(response.body))
if response.status in (401, 403):
raise HibpError(
f"HIBP отклонил запрос (HTTP {response.status}): проверьте {API_KEY_ENV}"
)
return AccountResult(account, error=f"HTTP {response.status}")
47 changes: 47 additions & 0 deletions breach-check/src/breach_check/apikey.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from __future__ import annotations

import os
import sys
from getpass import getpass
from pathlib import Path

API_KEY_ENV = "HIBP_API_KEY"
KEY_URL = "https://haveibeenpwned.com/API/Key"
PROMPT = "Ключ HIBP (ввод не отображается): "


class MissingApiKey(RuntimeError):
pass


def parse_key_file(text: str) -> str:
for raw in text.splitlines():
line = raw.split("#", 1)[0].strip()
if not line:
continue
name, separator, value = line.partition("=")
if separator and name.strip().upper() != API_KEY_ENV:
continue
key = (value if separator else line).strip().strip("\"'")
if key:
return key
raise MissingApiKey(f"ключ не найден в файле, ожидалась строка вида {API_KEY_ENV}=...")


def resolve_api_key(key_file: Path | None = None, prompt: bool = True) -> str:
if key_file:
return parse_key_file(key_file.read_text(encoding="utf-8"))

from_env = os.environ.get(API_KEY_ENV, "").strip()
if from_env:
return from_env

if prompt and sys.stdin.isatty():
key = getpass(PROMPT).strip()
if key:
return key

raise MissingApiKey(
f"нужен ключ HIBP: {API_KEY_ENV}=..., --key-file или ввод с клавиатуры "
f"({KEY_URL})"
)
Loading