mirror of
https://github.com/trustedsec/hate_crack.git
synced 2026-04-28 12:03:11 -07:00
Introduce hate_crack.notify package with a small functional public API and a CrackTailer thread for polling hashcat output files. Package layout keeps the HTTP call (_send_pushover) isolated so future backends (Slack, generic webhooks) can be added as a sibling function rather than a framework rewrite. Core pieces: - settings.py: NotifySettings dataclass plus atomic config persistence (save_enabled, add_to_allowlist) via read-modify-write + os.replace. - pushover.py: single _send_pushover() that never raises; network errors, missing requests, and missing creds all funnel to False. - _suppress.py: thread-local suppression context manager so orchestrator attacks can chain primitives without flooding notifications. - tailer.py: CrackTailer(threading.Thread) that seeks to EOF on start, polls at a user-configurable interval, and collapses per-tick bursts into a single aggregate notification when they exceed the cap. - __init__.py: public API (init, prompt_notify_for_attack, notify_job_done, notify_crack, start_tailer, stop_tailer, toggle_enabled, suppressed_notifications). Privacy guarantee: notification payloads contain only attack name, counts, and hash path, never plaintexts. 72 new tests cover dataclass defaults, atomic config writes, idempotent allowlist updates, HTTP payload privacy, suppression nesting and thread-locality, tailer EOF seek, burst cap, truncation recovery, and the per-attack prompt's [y/n/always] flow. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
292 lines
9.4 KiB
Python
292 lines
9.4 KiB
Python
"""Public notification API for hate_crack.
|
|
|
|
Overview
|
|
========
|
|
|
|
This package wires per-attack and per-crack notifications into the core
|
|
hashcat runner. The design is intentionally small and functional: a single
|
|
module-level ``_settings`` object, a handful of helper functions, and one
|
|
``CrackTailer`` thread class where polling state genuinely wants OO.
|
|
|
|
Wiring
|
|
======
|
|
|
|
At startup ``main.py`` calls :func:`init` with the resolved config path and
|
|
parsed config dict. After that, the rest of the codebase interacts with
|
|
this package via:
|
|
|
|
- :func:`prompt_notify_for_attack` -- called by attacks.py before an attack
|
|
starts; asks the user ``[y/n/always]`` and stashes per-run consent.
|
|
- :func:`start_tailer` / :func:`stop_tailer` -- called by the hashcat
|
|
command wrapper to spin a background watcher on ``{hashfile}.out``.
|
|
- :func:`notify_job_done` -- called by the hashcat command wrapper after
|
|
the subprocess exits, fires one summary notification.
|
|
- :func:`suppressed_notifications` -- context manager for orchestrator
|
|
attacks that chain many primitives; collapses all nested notifications
|
|
so the orchestrator can fire a single aggregate at the end.
|
|
|
|
Adding a new backend
|
|
====================
|
|
|
|
The single concrete HTTP call lives in :mod:`hate_crack.notify.pushover`.
|
|
To add Slack/webhooks, write a sibling ``_send_slack()`` there (or in a
|
|
new module) and dispatch from :func:`notify_job_done` /
|
|
:func:`notify_crack`. No framework, no ABC — one function per transport.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Callable
|
|
|
|
from hate_crack.notify._suppress import (
|
|
is_suppressed,
|
|
suppressed_notifications,
|
|
)
|
|
from hate_crack.notify.pushover import _send_pushover
|
|
from hate_crack.notify.settings import (
|
|
NotifySettings,
|
|
add_to_allowlist,
|
|
load_settings,
|
|
save_enabled,
|
|
)
|
|
from hate_crack.notify.tailer import (
|
|
CrackTailer,
|
|
extract_username_from_out_line,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# Module-level runtime state. Treated as a singleton because the CLI is a
|
|
# single-process tool with a single user; no need to pass a context object
|
|
# through every attack signature.
|
|
_settings: NotifySettings | None = None
|
|
_config_path: str | None = None
|
|
|
|
# Per-run consent cache: attack_name -> bool. Populated by
|
|
# ``prompt_notify_for_attack`` and consulted by ``notify_job_done`` so we
|
|
# don't need to re-prompt mid-chain.
|
|
_run_consent: dict[str, bool] = {}
|
|
|
|
# Input function indirection so tests can inject answers without pulling
|
|
# in a terminal. Swap via ``set_input_func``.
|
|
_input_func: Callable[[str], str] = input
|
|
|
|
|
|
__all__ = [
|
|
"CrackTailer",
|
|
"NotifySettings",
|
|
"add_to_allowlist",
|
|
"clear_state_for_tests",
|
|
"extract_username_from_out_line",
|
|
"get_settings",
|
|
"init",
|
|
"is_suppressed",
|
|
"notify_crack",
|
|
"notify_job_done",
|
|
"prompt_notify_for_attack",
|
|
"set_input_func",
|
|
"start_tailer",
|
|
"stop_tailer",
|
|
"suppressed_notifications",
|
|
"toggle_enabled",
|
|
"_send_pushover",
|
|
]
|
|
|
|
|
|
def init(config_path: str | None, config_parser: dict | None) -> None:
|
|
"""Bootstrap the notify subsystem from the resolved config.
|
|
|
|
Called once from ``main.py`` after its config-loading block. Safe to
|
|
call multiple times — the second call replaces settings but does not
|
|
reset per-run consent (the user may already have answered prompts).
|
|
"""
|
|
global _settings, _config_path
|
|
_config_path = config_path
|
|
_settings = load_settings(config_parser)
|
|
|
|
|
|
def get_settings() -> NotifySettings:
|
|
"""Return the active settings, or fresh defaults if ``init`` never ran."""
|
|
return _settings if _settings is not None else NotifySettings()
|
|
|
|
|
|
def set_input_func(func: Callable[[str], str]) -> None:
|
|
"""Test hook: swap the ``input()`` used by :func:`prompt_notify_for_attack`."""
|
|
global _input_func
|
|
_input_func = func
|
|
|
|
|
|
def clear_state_for_tests() -> None:
|
|
"""Reset module state. Only used by the test suite."""
|
|
global _settings, _config_path, _input_func
|
|
_settings = None
|
|
_config_path = None
|
|
_run_consent.clear()
|
|
_input_func = input
|
|
|
|
|
|
def toggle_enabled() -> bool:
|
|
"""Flip ``notify_enabled``, persist to ``config.json``, return new state.
|
|
|
|
If ``init`` was never called we still toggle an in-memory default — the
|
|
UI update must not crash even if the config file is unreachable.
|
|
"""
|
|
global _settings
|
|
if _settings is None:
|
|
_settings = NotifySettings()
|
|
_settings.enabled = not _settings.enabled
|
|
if _config_path:
|
|
try:
|
|
save_enabled(_config_path, _settings.enabled)
|
|
except OSError as exc:
|
|
logger.warning("Could not persist notify_enabled: %s", exc)
|
|
return _settings.enabled
|
|
|
|
|
|
def _in_allowlist(attack_name: str) -> bool:
|
|
return attack_name in get_settings().attack_allowlist
|
|
|
|
|
|
def prompt_notify_for_attack(attack_name: str) -> bool:
|
|
"""Ask the user whether this attack should fire a notification.
|
|
|
|
Returns ``True`` if notifications should fire for this run.
|
|
|
|
Flow:
|
|
|
|
1. Notifications disabled globally -> return False silently (no prompt).
|
|
2. Attack already in allowlist -> return True (no prompt, auto-on).
|
|
3. Otherwise -> prompt ``[y/n/always]``:
|
|
* ``y`` -> consent for this run only.
|
|
* ``n`` / "" -> no consent.
|
|
* ``always`` -> persist to allowlist and consent for this run.
|
|
|
|
Per-run consent is stashed in ``_run_consent[attack_name]`` so the
|
|
hashcat wrapper can query it at job-done time without re-prompting.
|
|
"""
|
|
settings = get_settings()
|
|
if not settings.enabled:
|
|
_run_consent[attack_name] = False
|
|
return False
|
|
if _in_allowlist(attack_name):
|
|
_run_consent[attack_name] = True
|
|
return True
|
|
|
|
try:
|
|
raw = _input_func(
|
|
f"\n[notify] Send Pushover notifications for '{attack_name}'? [y/N/always]: "
|
|
)
|
|
except EOFError:
|
|
raw = ""
|
|
answer = (raw or "").strip().lower()
|
|
if answer == "always":
|
|
_run_consent[attack_name] = True
|
|
if _config_path:
|
|
try:
|
|
add_to_allowlist(_config_path, attack_name)
|
|
# Also update the in-memory settings so a later call in the
|
|
# same session sees the allowlist without re-reading config.
|
|
if attack_name not in settings.attack_allowlist:
|
|
settings.attack_allowlist.append(attack_name)
|
|
except OSError as exc:
|
|
logger.warning("Could not persist allowlist entry: %s", exc)
|
|
return True
|
|
if answer in ("y", "yes"):
|
|
_run_consent[attack_name] = True
|
|
return True
|
|
_run_consent[attack_name] = False
|
|
return False
|
|
|
|
|
|
def _should_fire(attack_name: str) -> bool:
|
|
if is_suppressed():
|
|
return False
|
|
settings = get_settings()
|
|
if not settings.enabled:
|
|
return False
|
|
if _in_allowlist(attack_name):
|
|
return True
|
|
return _run_consent.get(attack_name, False)
|
|
|
|
|
|
def notify_job_done(
|
|
attack_name: str,
|
|
cracked_count: int,
|
|
hash_file: str | None = None,
|
|
) -> None:
|
|
"""Fire a single "attack complete" notification.
|
|
|
|
No-op when suppressed, disabled, or the user declined at the prompt.
|
|
"""
|
|
if not _should_fire(attack_name):
|
|
return
|
|
settings = get_settings()
|
|
title = f"hate_crack: {attack_name} complete"
|
|
if hash_file:
|
|
message = (
|
|
f"Attack '{attack_name}' finished.\n"
|
|
f"Cracked so far: {cracked_count}\n"
|
|
f"Hash file: {hash_file}"
|
|
)
|
|
else:
|
|
message = f"Attack '{attack_name}' finished.\nCracked so far: {cracked_count}"
|
|
_send_pushover(settings.pushover_token, settings.pushover_user, title, message)
|
|
|
|
|
|
def notify_crack(label: str, attack_name: str) -> None:
|
|
"""Fire a per-crack notification (called from :class:`CrackTailer`)."""
|
|
if not _should_fire(attack_name):
|
|
return
|
|
settings = get_settings()
|
|
title = "hate_crack: new crack"
|
|
message = f"{label} cracked ({attack_name})"
|
|
_send_pushover(settings.pushover_token, settings.pushover_user, title, message)
|
|
|
|
|
|
def _notify_aggregate(count: int, attack_name: str) -> None:
|
|
"""Aggregated "N accounts cracked" notification for burst-capped ticks."""
|
|
if not _should_fire(attack_name):
|
|
return
|
|
settings = get_settings()
|
|
title = "hate_crack: crack burst"
|
|
message = f"{count} new accounts cracked ({attack_name})"
|
|
_send_pushover(settings.pushover_token, settings.pushover_user, title, message)
|
|
|
|
|
|
def start_tailer(out_path: str, attack_name: str) -> CrackTailer | None:
|
|
"""Start a :class:`CrackTailer` if per-crack notifications are enabled.
|
|
|
|
Returns the running tailer (so the caller can stop it later), or
|
|
``None`` when suppression/disabled/disallowed mean we shouldn't tail.
|
|
"""
|
|
if is_suppressed():
|
|
return None
|
|
settings = get_settings()
|
|
if not settings.enabled:
|
|
return None
|
|
if not settings.per_crack_enabled:
|
|
return None
|
|
if not _should_fire(attack_name):
|
|
return None
|
|
tailer = CrackTailer(
|
|
out_path=out_path,
|
|
attack_name=attack_name,
|
|
settings=settings,
|
|
notify_callback=notify_crack,
|
|
aggregate_callback=_notify_aggregate,
|
|
)
|
|
tailer.start()
|
|
return tailer
|
|
|
|
|
|
def stop_tailer(tailer: CrackTailer | None) -> None:
|
|
"""Stop a tailer started by :func:`start_tailer`. ``None`` is a no-op."""
|
|
if tailer is None:
|
|
return
|
|
try:
|
|
tailer.stop()
|
|
except Exception as exc:
|
|
logger.warning("CrackTailer.stop() failed: %s", exc)
|