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>
160 lines
6.3 KiB
Python
160 lines
6.3 KiB
Python
"""Unit tests for hate_crack.notify.settings."""
|
|
import json
|
|
from pathlib import Path
|
|
|
|
from hate_crack.notify.settings import (
|
|
NotifySettings,
|
|
add_to_allowlist,
|
|
load_settings,
|
|
save_enabled,
|
|
)
|
|
|
|
|
|
class TestNotifySettingsDataclass:
|
|
def test_defaults(self) -> None:
|
|
s = NotifySettings()
|
|
assert s.enabled is False
|
|
assert s.pushover_token == ""
|
|
assert s.pushover_user == ""
|
|
assert s.per_crack_enabled is False
|
|
assert s.attack_allowlist == []
|
|
assert s.suppress_in_orchestrators is True
|
|
assert s.max_cracks_per_burst == 5
|
|
assert s.poll_interval_seconds == 5.0
|
|
|
|
def test_allowlist_default_is_fresh_per_instance(self) -> None:
|
|
# field(default_factory=list) must not share state.
|
|
a = NotifySettings()
|
|
b = NotifySettings()
|
|
a.attack_allowlist.append("Brute Force")
|
|
assert b.attack_allowlist == []
|
|
|
|
|
|
class TestLoadSettings:
|
|
def test_load_from_empty_dict_returns_defaults(self) -> None:
|
|
s = load_settings({})
|
|
assert s == NotifySettings()
|
|
|
|
def test_load_from_none_returns_defaults(self) -> None:
|
|
assert load_settings(None) == NotifySettings()
|
|
|
|
def test_load_full_dict(self) -> None:
|
|
s = load_settings({
|
|
"notify_enabled": True,
|
|
"notify_pushover_token": "tok",
|
|
"notify_pushover_user": "usr",
|
|
"notify_per_crack_enabled": True,
|
|
"notify_attack_allowlist": ["Brute Force", "Dictionary"],
|
|
"notify_suppress_in_orchestrators": False,
|
|
"notify_max_cracks_per_burst": 20,
|
|
"notify_poll_interval_seconds": 2.5,
|
|
})
|
|
assert s.enabled is True
|
|
assert s.pushover_token == "tok"
|
|
assert s.pushover_user == "usr"
|
|
assert s.per_crack_enabled is True
|
|
assert s.attack_allowlist == ["Brute Force", "Dictionary"]
|
|
assert s.suppress_in_orchestrators is False
|
|
assert s.max_cracks_per_burst == 20
|
|
assert s.poll_interval_seconds == 2.5
|
|
|
|
def test_load_tolerates_bad_types(self) -> None:
|
|
s = load_settings({
|
|
"notify_enabled": "true",
|
|
"notify_max_cracks_per_burst": "not-a-number",
|
|
"notify_poll_interval_seconds": "also-bad",
|
|
"notify_attack_allowlist": "not-a-list",
|
|
})
|
|
# string "true" -> True
|
|
assert s.enabled is True
|
|
# bad ints fall back to defaults (5, 5.0)
|
|
assert s.max_cracks_per_burst == 5
|
|
assert s.poll_interval_seconds == 5.0
|
|
# non-list allowlist becomes empty list
|
|
assert s.attack_allowlist == []
|
|
|
|
|
|
class TestSaveEnabled:
|
|
def test_writes_new_config(self, tmp_path: Path) -> None:
|
|
config_path = tmp_path / "config.json"
|
|
save_enabled(str(config_path), True)
|
|
data = json.loads(config_path.read_text())
|
|
assert data["notify_enabled"] is True
|
|
|
|
def test_preserves_existing_keys(self, tmp_path: Path) -> None:
|
|
config_path = tmp_path / "config.json"
|
|
initial = {
|
|
"hcatBin": "hashcat",
|
|
"hashview_api_key": "secret",
|
|
"notify_enabled": False,
|
|
}
|
|
config_path.write_text(json.dumps(initial))
|
|
save_enabled(str(config_path), True)
|
|
data = json.loads(config_path.read_text())
|
|
assert data["hcatBin"] == "hashcat"
|
|
assert data["hashview_api_key"] == "secret"
|
|
assert data["notify_enabled"] is True
|
|
|
|
def test_toggles_back_and_forth(self, tmp_path: Path) -> None:
|
|
config_path = tmp_path / "config.json"
|
|
save_enabled(str(config_path), True)
|
|
save_enabled(str(config_path), False)
|
|
data = json.loads(config_path.read_text())
|
|
assert data["notify_enabled"] is False
|
|
|
|
def test_invalid_existing_config_replaced(self, tmp_path: Path) -> None:
|
|
config_path = tmp_path / "config.json"
|
|
config_path.write_text("this is not json")
|
|
save_enabled(str(config_path), True)
|
|
data = json.loads(config_path.read_text())
|
|
assert data == {"notify_enabled": True}
|
|
|
|
def test_atomic_no_half_write(self, tmp_path: Path) -> None:
|
|
# A partial write should never leave the main file invalid. We
|
|
# check that after save_enabled, parsing always succeeds.
|
|
config_path = tmp_path / "config.json"
|
|
for flag in (True, False, True, False, True):
|
|
save_enabled(str(config_path), flag)
|
|
json.loads(config_path.read_text()) # must not raise
|
|
|
|
|
|
class TestAddToAllowlist:
|
|
def test_adds_to_empty_list(self, tmp_path: Path) -> None:
|
|
config_path = tmp_path / "config.json"
|
|
add_to_allowlist(str(config_path), "Brute Force")
|
|
data = json.loads(config_path.read_text())
|
|
assert data["notify_attack_allowlist"] == ["Brute Force"]
|
|
|
|
def test_idempotent(self, tmp_path: Path) -> None:
|
|
config_path = tmp_path / "config.json"
|
|
add_to_allowlist(str(config_path), "Brute Force")
|
|
add_to_allowlist(str(config_path), "Brute Force")
|
|
add_to_allowlist(str(config_path), "Brute Force")
|
|
data = json.loads(config_path.read_text())
|
|
assert data["notify_attack_allowlist"] == ["Brute Force"]
|
|
|
|
def test_preserves_other_entries(self, tmp_path: Path) -> None:
|
|
config_path = tmp_path / "config.json"
|
|
config_path.write_text(json.dumps({
|
|
"hcatBin": "hashcat",
|
|
"notify_attack_allowlist": ["Existing"],
|
|
}))
|
|
add_to_allowlist(str(config_path), "Brute Force")
|
|
data = json.loads(config_path.read_text())
|
|
assert data["hcatBin"] == "hashcat"
|
|
assert data["notify_attack_allowlist"] == ["Existing", "Brute Force"]
|
|
|
|
def test_empty_attack_name_is_noop(self, tmp_path: Path) -> None:
|
|
config_path = tmp_path / "config.json"
|
|
config_path.write_text(json.dumps({"notify_attack_allowlist": ["A"]}))
|
|
add_to_allowlist(str(config_path), "")
|
|
data = json.loads(config_path.read_text())
|
|
assert data["notify_attack_allowlist"] == ["A"]
|
|
|
|
def test_repairs_non_list_allowlist(self, tmp_path: Path) -> None:
|
|
config_path = tmp_path / "config.json"
|
|
config_path.write_text(json.dumps({"notify_attack_allowlist": "bogus"}))
|
|
add_to_allowlist(str(config_path), "Brute Force")
|
|
data = json.loads(config_path.read_text())
|
|
assert data["notify_attack_allowlist"] == ["Brute Force"]
|