Merge branch 'dev' into main

This commit is contained in:
Justin Bollinger
2026-05-28 11:44:46 -04:00
4 changed files with 324 additions and 10 deletions
+3
View File
@@ -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
+6 -1
View File
@@ -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",
)
+12 -9
View File
@@ -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
+303
View File
@@ -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"