diff --git a/README.md b/README.md index ab3d820..a7d51c3 100644 --- a/README.md +++ b/README.md @@ -912,6 +912,9 @@ Interactive menu for downloading and managing wordlists from Weakpass.com via Bi ------------------------------------------------------------------- ### Version History +Version 2.10.4 + - Pushover notifications fire correctly for Quick Crack, Loopback, Combinator, PRINCE-LING, and N-gram attacks (#110). The handlers prompted the user under one name (e.g. "Quick Crack") while the underlying hashcat wrapper passed a different `attack_name` to `_should_fire` ("Quick Dictionary"), so the per-run consent lookup always missed. The prompt name now flows down to `_run_hcat_cmd` for both the job-done summary and the per-crack tailer + Version 2.10.3 - Auto-upgrade no longer loops infinitely when invoked from a non-main branch (e.g. `dev`). Release tags live on main-side merge commits, so `git pull` on `dev` was a no-op and setuptools-scm kept regenerating the version as `X.Y.Z.postN.devM` — the update check then re-fired forever. `_run_upgrade()` now switches to `main` before pulling, with safety guards: refuses to clobber uncommitted work, surfaces clear errors when `main` is checked out in another worktree, and leaves detached-HEAD checkouts untouched diff --git a/hate_crack/attacks.py b/hate_crack/attacks.py index 9112363..33e01a9 100644 --- a/hate_crack/attacks.py +++ b/hate_crack/attacks.py @@ -188,7 +188,11 @@ def quick_crack(ctx: Any) -> None: for chain in selected_rules: ctx.hcatQuickDictionary( - ctx.hcatHashType, ctx.hcatHashFile, chain, wordlist_choice + ctx.hcatHashType, + ctx.hcatHashFile, + chain, + wordlist_choice, + attack_name="Quick Crack", ) @@ -213,6 +217,7 @@ def loopback_attack(ctx: Any) -> None: chain, empty_wordlist, loopback=True, + attack_name="Loopback", ) diff --git a/hate_crack/main.py b/hate_crack/main.py index a388c7b..7e4c0fd 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -1452,6 +1452,7 @@ def hcatQuickDictionary( loopback=False, use_potfile_path=True, potfile_path=None, + attack_name="Quick Dictionary", ): global hcatProcess cmd = [ @@ -1480,7 +1481,7 @@ def hcatQuickDictionary( ) cmd = _add_debug_mode_for_rules(cmd) _debug_cmd(cmd) - _run_hcat_cmd(cmd, attack_name="Quick Dictionary", hash_file=hcatHashFile) + _run_hcat_cmd(cmd, attack_name=attack_name, hash_file=hcatHashFile) # Top Mask Attack @@ -1689,7 +1690,7 @@ def hcatCombination(hcatHashType, hcatHashFile, wordlists=None): _insert_optimized_flag(cmd) cmd.extend(shlex.split(hcatTuning)) _append_potfile_arg(cmd) - _run_hcat_cmd(cmd, attack_name="Combination", hash_file=hcatHashFile) + _run_hcat_cmd(cmd, attack_name="Combinator", hash_file=hcatHashFile) hcatCombinationCount = lineCount(hcatHashFile + ".out") - hcatHashCracked @@ -1725,7 +1726,7 @@ def hcatCombinator3(hcatHashType, hcatHashFile, wordlists): assert generator_proc.stdout is not None _run_hcat_cmd( hashcat_cmd, - attack_name="Combinator3", + attack_name="Combinator", hash_file=hcatHashFile, stdin=generator_proc.stdout, companion_procs=[generator_proc], @@ -1771,7 +1772,7 @@ def hcatCombinatorX(hcatHashType, hcatHashFile, wordlists, separator=None): assert generator_proc.stdout is not None _run_hcat_cmd( hashcat_cmd, - attack_name="CombinatorX", + attack_name="Combinator", hash_file=hcatHashFile, stdin=generator_proc.stdout, companion_procs=[generator_proc], @@ -1806,7 +1807,7 @@ def hcatNgramX(hcatHashType, hcatHashFile, corpus, group_size=3): assert generator_proc.stdout is not None _run_hcat_cmd( hashcat_cmd, - attack_name="NgramX", + attack_name="N-gram", hash_file=hcatHashFile, stdin=generator_proc.stdout, companion_procs=[generator_proc], @@ -2624,7 +2625,7 @@ def hcatCombipow(hcatHashType, hcatHashFile, wordlist, use_space_sep=True): # PRINCE Attack -def hcatPrince(hcatHashType, hcatHashFile): +def hcatPrince(hcatHashType, hcatHashFile, attack_name="PRINCE"): global hcatProcess prince_rules_dir = os.path.join(hate_path, "princeprocessor", "rules") prince_rule = get_rule_path("prince_optimized.rule", fallback_dir=prince_rules_dir) @@ -2664,7 +2665,7 @@ def hcatPrince(hcatHashType, hcatHashFile): prince_proc = subprocess.Popen(prince_cmd, stdin=base, stdout=subprocess.PIPE) _run_hcat_cmd( hashcat_cmd, - attack_name="PRINCE", + attack_name=attack_name, hash_file=hcatHashFile, stdin=prince_proc.stdout, companion_procs=[prince_proc], @@ -2768,11 +2769,13 @@ def hcatPrinceLing(hcatHashType, hcatHashFile): print(f"prince_ling generation failed: {e}") return - # Delegate to existing PRINCE attack with rebound base list + # Delegate to existing PRINCE attack with rebound base list. The + # ``attack_name`` override keeps notifications matched to the + # "PRINCE-LING" prompt the user consented to (see issue #110). original_base = hcatPrinceBaseList hcatPrinceBaseList = [cache_path] try: - hcatPrince(hcatHashType, hcatHashFile) + hcatPrince(hcatHashType, hcatHashFile, attack_name="PRINCE-LING") finally: hcatPrinceBaseList = original_base diff --git a/tests/test_notify_name_alignment.py b/tests/test_notify_name_alignment.py new file mode 100644 index 0000000..b0c7548 --- /dev/null +++ b/tests/test_notify_name_alignment.py @@ -0,0 +1,303 @@ +"""Regression tests for issue #110: pushover notifications were silently +dropped because the name passed to ``prompt_notify_for_attack`` (and +cached in ``_run_consent``) differed from the ``attack_name`` later +passed to ``_run_hcat_cmd``. + +Each test below pins the contract: the same string the user consented +to under is the string that ``_run_hcat_cmd`` receives. +""" +import os +from contextlib import ExitStack +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.fixture +def main_module(hc_module): + return hc_module._main + + +def _common_patches(main_module): + return [ + patch.object(main_module, "hcatBin", "hashcat"), + patch.object(main_module, "hcatTuning", ""), + patch.object(main_module, "hcatPotfilePath", ""), + patch.object(main_module, "generate_session_id", return_value="test_session"), + ] + + +def _enter_all(stack: ExitStack, ctxmgrs): + return [stack.enter_context(cm) for cm in ctxmgrs] + + +class TestQuickDictionaryAttackName: + """``hcatQuickDictionary`` is shared by Quick Crack and Loopback. + Both must surface their own prompt name to ``_run_hcat_cmd``. + """ + + def test_default_attack_name_preserved(self, main_module, tmp_path: Path) -> None: + hash_file = str(tmp_path / "hashes.txt") + wordlist = str(tmp_path / "words.txt") + with ExitStack() as stack: + run = stack.enter_context(patch("hate_crack.main._run_hcat_cmd")) + stack.enter_context( + patch("hate_crack.main._add_debug_mode_for_rules", side_effect=lambda c: c) + ) + stack.enter_context(patch("hate_crack.main._debug_cmd")) + _enter_all(stack, _common_patches(main_module)) + main_module.hcatQuickDictionary("1000", hash_file, "", wordlist) + assert run.call_args.kwargs["attack_name"] == "Quick Dictionary" + + def test_quick_crack_override(self, main_module, tmp_path: Path) -> None: + hash_file = str(tmp_path / "hashes.txt") + wordlist = str(tmp_path / "words.txt") + with ExitStack() as stack: + run = stack.enter_context(patch("hate_crack.main._run_hcat_cmd")) + stack.enter_context( + patch("hate_crack.main._add_debug_mode_for_rules", side_effect=lambda c: c) + ) + stack.enter_context(patch("hate_crack.main._debug_cmd")) + _enter_all(stack, _common_patches(main_module)) + main_module.hcatQuickDictionary( + "1000", hash_file, "", wordlist, attack_name="Quick Crack" + ) + assert run.call_args.kwargs["attack_name"] == "Quick Crack" + + def test_loopback_override(self, main_module, tmp_path: Path) -> None: + hash_file = str(tmp_path / "hashes.txt") + wordlist = str(tmp_path / "words.txt") + with ExitStack() as stack: + run = stack.enter_context(patch("hate_crack.main._run_hcat_cmd")) + stack.enter_context( + patch("hate_crack.main._add_debug_mode_for_rules", side_effect=lambda c: c) + ) + stack.enter_context(patch("hate_crack.main._debug_cmd")) + _enter_all(stack, _common_patches(main_module)) + main_module.hcatQuickDictionary( + "1000", + hash_file, + "", + wordlist, + loopback=True, + attack_name="Loopback", + ) + assert run.call_args.kwargs["attack_name"] == "Loopback" + + +class TestPrinceAttackName: + """``hcatPrince`` is shared by PRINCE (direct) and PRINCE-LING + (delegated via ``hcatPrinceLing``). Both must surface their own + name. + """ + + def _stub_prince_env(self, main_module, tmp_path: Path): + prince_base = tmp_path / "prince_base.txt" + prince_base.write_text("password\n") + prince_dir = tmp_path / "princeprocessor" + prince_dir.mkdir() + (prince_dir / "pp64.bin").touch() + return [ + patch.object(main_module, "hate_path", str(tmp_path)), + patch.object(main_module, "hcatPrinceBaseList", [str(prince_base)]), + ] + + def test_default_attack_name_is_prince(self, main_module, tmp_path: Path) -> None: + hash_file = str(tmp_path / "hashes.txt") + with ExitStack() as stack: + run = stack.enter_context(patch("hate_crack.main._run_hcat_cmd")) + popen = stack.enter_context(patch("hate_crack.main.subprocess.Popen")) + popen.return_value = MagicMock(stdout=MagicMock(), wait=MagicMock()) + _enter_all(stack, _common_patches(main_module)) + _enter_all(stack, self._stub_prince_env(main_module, tmp_path)) + main_module.hcatPrince("1000", hash_file) + assert run.call_args.kwargs["attack_name"] == "PRINCE" + + def test_prince_ling_override(self, main_module, tmp_path: Path) -> None: + hash_file = str(tmp_path / "hashes.txt") + with ExitStack() as stack: + run = stack.enter_context(patch("hate_crack.main._run_hcat_cmd")) + popen = stack.enter_context(patch("hate_crack.main.subprocess.Popen")) + popen.return_value = MagicMock(stdout=MagicMock(), wait=MagicMock()) + _enter_all(stack, _common_patches(main_module)) + _enter_all(stack, self._stub_prince_env(main_module, tmp_path)) + main_module.hcatPrince("1000", hash_file, attack_name="PRINCE-LING") + assert run.call_args.kwargs["attack_name"] == "PRINCE-LING" + + +class TestSingleCallerWrapperNames: + """Wrappers that have a single user-facing handler should pass the + name the user saw at the prompt, not the internal function-style + label. + """ + + def test_ngram_x_uses_ngram_label(self, main_module, tmp_path: Path) -> None: + hash_file = str(tmp_path / "hashes.txt") + corpus = tmp_path / "corpus.txt" + corpus.write_text("word\n") + ngram_dir = tmp_path / "hashcat-utils" / "bin" + ngram_dir.mkdir(parents=True) + (ngram_dir / "ngramX.bin").touch() + with ExitStack() as stack: + run = stack.enter_context(patch("hate_crack.main._run_hcat_cmd")) + popen = stack.enter_context(patch("hate_crack.main.subprocess.Popen")) + popen.return_value = MagicMock(stdout=MagicMock(), wait=MagicMock()) + stack.enter_context(patch.object(main_module, "hate_path", str(tmp_path))) + _enter_all(stack, _common_patches(main_module)) + main_module.hcatNgramX("1000", hash_file, str(corpus), 3) + assert run.call_args.kwargs["attack_name"] == "N-gram" + + def test_combination_uses_combinator_label( + self, main_module, tmp_path: Path + ) -> None: + hash_file = str(tmp_path / "hashes.txt") + wl1 = tmp_path / "w1.txt" + wl2 = tmp_path / "w2.txt" + wl1.write_text("a\n") + wl2.write_text("b\n") + with ExitStack() as stack: + run = stack.enter_context(patch("hate_crack.main._run_hcat_cmd")) + stack.enter_context(patch.object(main_module, "hcatWordlists", str(tmp_path))) + stack.enter_context(patch.object(main_module, "lineCount", return_value=0)) + stack.enter_context( + patch.object(main_module, "hcatHashCracked", 0, create=True) + ) + _enter_all(stack, _common_patches(main_module)) + main_module.hcatCombination( + "1000", hash_file, wordlists=[str(wl1), str(wl2)] + ) + assert run.call_args.kwargs["attack_name"] == "Combinator" + + def test_combinator3_uses_combinator_label( + self, main_module, tmp_path: Path + ) -> None: + hash_file = str(tmp_path / "hashes.txt") + wls = [] + for i in range(3): + p = tmp_path / f"w{i}.txt" + p.write_text(f"x{i}\n") + wls.append(str(p)) + combinator3 = tmp_path / "hashcat-utils" / "bin" / "combinator3.bin" + combinator3.parent.mkdir(parents=True) + combinator3.touch() + with ExitStack() as stack: + run = stack.enter_context(patch("hate_crack.main._run_hcat_cmd")) + popen = stack.enter_context(patch("hate_crack.main.subprocess.Popen")) + popen.return_value = MagicMock(stdout=MagicMock(), wait=MagicMock()) + stack.enter_context(patch.object(main_module, "hate_path", str(tmp_path))) + stack.enter_context(patch.object(main_module, "lineCount", return_value=0)) + stack.enter_context( + patch.object(main_module, "hcatHashCracked", 0, create=True) + ) + _enter_all(stack, _common_patches(main_module)) + main_module.hcatCombinator3("1000", hash_file, wls) + assert run.call_args.kwargs["attack_name"] == "Combinator" + + def test_combinatorX_uses_combinator_label( + self, main_module, tmp_path: Path + ) -> None: + hash_file = str(tmp_path / "hashes.txt") + wls = [] + for i in range(2): + p = tmp_path / f"w{i}.txt" + p.write_text(f"x{i}\n") + wls.append(str(p)) + combinatorX = tmp_path / "hashcat-utils" / "bin" / "combinatorX.bin" + combinatorX.parent.mkdir(parents=True) + combinatorX.touch() + with ExitStack() as stack: + run = stack.enter_context(patch("hate_crack.main._run_hcat_cmd")) + popen = stack.enter_context(patch("hate_crack.main.subprocess.Popen")) + popen.return_value = MagicMock(stdout=MagicMock(), wait=MagicMock()) + stack.enter_context(patch.object(main_module, "hate_path", str(tmp_path))) + stack.enter_context(patch.object(main_module, "lineCount", return_value=0)) + stack.enter_context( + patch.object(main_module, "hcatHashCracked", 0, create=True) + ) + _enter_all(stack, _common_patches(main_module)) + main_module.hcatCombinatorX("1000", hash_file, wls) + assert run.call_args.kwargs["attack_name"] == "Combinator" + + +class TestHandlersPassThroughPromptName: + """High-level: the attack-handler functions in ``attacks.py`` must + pass the same name they prompted with down to the wrapper, so that + consent set by the prompt aligns with the consent check inside + ``_should_fire``. + """ + + def _make_ctx(self, tmp_path: Path) -> MagicMock: + ctx = MagicMock() + ctx.hcatHashType = "1000" + ctx.hcatHashFile = str(tmp_path / "hashes.txt") + ctx.hcatWordlists = str(tmp_path / "wordlists") + ctx.rulesDirectory = str(tmp_path / "rules") + return ctx + + def test_quick_crack_handler_passes_quick_crack_label( + self, tmp_path: Path + ) -> None: + from hate_crack.attacks import quick_crack + + ctx = self._make_ctx(tmp_path) + rules_dir = tmp_path / "rules" + rules_dir.mkdir() + (rules_dir / "best66.rule").write_text("") + wordlist_dir = tmp_path / "wordlists" + wordlist_dir.mkdir(parents=True, exist_ok=True) + wordlist = wordlist_dir / "rockyou.txt" + wordlist.write_text("password\n") + ctx.hcatOptimizedWordlists = str(wordlist_dir) + ctx.list_wordlist_files.return_value = ["rockyou.txt"] + with patch("builtins.input", side_effect=[str(wordlist), "1"]): + quick_crack(ctx) + ctx.hcatQuickDictionary.assert_called_once() + assert ( + ctx.hcatQuickDictionary.call_args.kwargs.get("attack_name") + == "Quick Crack" + ) + + def test_loopback_handler_passes_loopback_label(self, tmp_path: Path) -> None: + from hate_crack.attacks import loopback_attack + + ctx = self._make_ctx(tmp_path) + rules_dir = tmp_path / "rules" + rules_dir.mkdir() + (rules_dir / "best66.rule").write_text("") + with patch("builtins.input", return_value="1"): + loopback_attack(ctx) + ctx.hcatQuickDictionary.assert_called_once() + kw = ctx.hcatQuickDictionary.call_args.kwargs + assert kw.get("attack_name") == "Loopback" + assert kw.get("loopback") is True + + def test_prince_ling_hcatPrinceLing_passes_prince_ling_label( + self, main_module, tmp_path: Path + ) -> None: + """``hcatPrinceLing`` delegates to ``hcatPrince``. The delegated + call must carry ``attack_name="PRINCE-LING"`` so per-run consent + keyed on "PRINCE-LING" actually matches. + """ + hash_file = str(tmp_path / "hashes.txt") + pcfg_root = tmp_path / "pcfg_cracker" + (pcfg_root / "Rules" / "DEFAULT").mkdir(parents=True) + (pcfg_root / "prince_ling.py").write_text("# stub") + cache_dir = tmp_path / "optimized" + cache_dir.mkdir(parents=True) + cache_path = cache_dir / "pcfg_prince_ling_DEFAULT.txt" + cache_path.write_text("password\n") + # Make ruleset_dir older than cache so no regen runs. + os.utime(pcfg_root / "Rules" / "DEFAULT", (0, 0)) + with ExitStack() as stack: + stack.enter_context(patch.object(main_module, "hate_path", str(tmp_path))) + stack.enter_context( + patch.object( + main_module, "hcatOptimizedWordlists", str(cache_dir) + ) + ) + stack.enter_context(patch.object(main_module, "pcfgRuleset", "DEFAULT")) + prince = stack.enter_context(patch.object(main_module, "hcatPrince")) + main_module.hcatPrinceLing("1000", hash_file) + prince.assert_called_once() + assert prince.call_args.kwargs.get("attack_name") == "PRINCE-LING"