From 6101013108b893f6bd1e8ed4860b32f22557990c Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Thu, 19 Mar 2026 12:12:12 -0400 Subject: [PATCH] feat: add rule file management tools using cleanup-rules.bin and rules_optimize.bin (closes #93) Adds Rule File Tools submenu (menu option 81) with three operations: - Clean: removes invalid/duplicate rules via cleanup-rules.bin - Optimize: consolidates redundant operations via rules_optimize.bin - Clean and optimize: both in sequence with temp file handling Wired through the standard three-layer pattern: main.py utility functions + dispatcher, attacks.py handlers + submenu, root hate_crack.py menu registration. --- README.md | 11 ++ hate_crack.py | 1 + hate_crack/attacks.py | 112 ++++++++++++++++ hate_crack/main.py | 22 +++ tests/test_rule_tools.py | 244 ++++++++++++++++++++++++++++++++++ tests/test_rule_wrappers.py | 116 ++++++++++++++++ tests/test_ui_menu_options.py | 1 + 7 files changed, 507 insertions(+) create mode 100644 tests/test_rule_tools.py create mode 100644 tests/test_rule_wrappers.py diff --git a/README.md b/README.md index 0a69f8b..247daf3 100644 --- a/README.md +++ b/README.md @@ -619,6 +619,8 @@ All tests use mocked API calls, so they can run without connectivity to a Hashvi (17) Ad-hoc Mask Attack (18) Markov Brute Force Attack + (81) Rule File Tools + (90) Download rules from Hashmob.net (91) Analyze Hashcat Rules (92) Download wordlists from Hashmob.net @@ -789,6 +791,15 @@ Generates password candidates using Markov chain statistical models. Similar to * Markov table persists with hash file (filename.out.hcstat2) for fast subsequent runs * Faster than OMEN for general-purpose brute forcing +#### Rule File Tools +Preprocesses hashcat rule files using `cleanup-rules.bin` and `rules_optimize.bin` from hashcat-utils. + +* **Clean** - removes invalid syntax and duplicate rules using `cleanup-rules.bin`. Useful after combining rule files or downloading rules from external sources. +* **Optimize** - consolidates redundant operations using `rules_optimize.bin`. Reduces rule file size and improves cracking speed. +* **Clean and optimize** - runs both operations in sequence via a temporary file, then writes the final result. + +All three operations read from an input file and write to a separate output file (original is never modified). + #### Download Rules from Hashmob.net Downloads the latest rule files from Hashmob.net's rule repository. These rules are curated and optimized for password cracking and can be used with the Quick Crack and Loopback Attack modes. diff --git a/hate_crack.py b/hate_crack.py index de0124b..ee4b994 100755 --- a/hate_crack.py +++ b/hate_crack.py @@ -88,6 +88,7 @@ def get_main_menu_options(): "16": _attacks.omen_attack, "17": _attacks.adhoc_mask_crack, "18": _attacks.markov_brute_force, + "81": _attacks.rule_tools_submenu, "90": download_hashmob_rules, "91": weakpass_wordlist_menu, "92": download_hashmob_wordlists, diff --git a/hate_crack/attacks.py b/hate_crack/attacks.py index d06e250..7074e44 100644 --- a/hate_crack/attacks.py +++ b/hate_crack/attacks.py @@ -642,3 +642,115 @@ def combinator_submenu(ctx: Any) -> None: middle_combinator(ctx) elif choice == "4": thorough_combinator(ctx) + + +def _rule_select_file(ctx: Any, prompt: str = "Rule file: ") -> str: + """Prompt for a rule file path with tab-autocomplete.""" + import glob as _glob + + def rule_completer(text: str, state: int) -> str | None: + base = ctx.rulesDirectory + if not text: + pattern = os.path.join(base, "*.rule") + else: + text = os.path.expanduser(text) + if text.startswith(("/", "./", "../", "~")): + pattern = text + "*" + else: + pattern = os.path.join(base, text + "*") + matches = _glob.glob(pattern) + try: + return matches[state] + except IndexError: + return None + + _configure_readline(rule_completer) + return input(prompt).strip() + + +def rule_cleanup_handler(ctx: Any) -> None: + """Clean a rule file using cleanup-rules.bin.""" + print("\nClean rule file - removes invalid and duplicate rules.") + print("Reads an input rule file and writes cleaned rules to an output file.\n") + infile = _rule_select_file(ctx, "Input rule file (tab to autocomplete): ") + if not infile or not os.path.isfile(infile): + print(f"[!] File not found: {infile}") + return + outfile = input("Output file path: ").strip() + if not outfile: + print("[!] Output path required.") + return + print(f"\nCleaning {infile} -> {outfile}") + if ctx.rules_cleanup(infile, outfile): + print("[+] Done.") + else: + print("[!] Cleanup failed.") + + +def rule_optimize_handler(ctx: Any) -> None: + """Optimize a rule file using rules_optimize.bin.""" + print("\nOptimize rule file - consolidates redundant operations.") + infile = _rule_select_file(ctx, "Input rule file: ") + if not infile or not os.path.isfile(infile): + print(f"[!] File not found: {infile}") + return + outfile = input("Output file path: ").strip() + if not outfile: + print("[!] Output path required.") + return + print(f"\nOptimizing {infile} -> {outfile}") + if ctx.rules_optimize(infile, outfile): + print("[+] Done.") + else: + print("[!] Optimize failed.") + + +def rule_cleanup_and_optimize_handler(ctx: Any) -> None: + """Clean then optimize a rule file.""" + import tempfile + + print("\nClean and optimize rule file (both operations in sequence).") + infile = _rule_select_file(ctx, "Input rule file: ") + if not infile or not os.path.isfile(infile): + print(f"[!] File not found: {infile}") + return + outfile = input("Output file path: ").strip() + if not outfile: + print("[!] Output path required.") + return + with tempfile.NamedTemporaryFile(suffix=".rule", delete=False) as tmp: + tmp_path = tmp.name + try: + print(f"\nStep 1/2: Cleaning {infile}...") + if not ctx.rules_cleanup(infile, tmp_path): + print("[!] Cleanup failed.") + return + print(f"Step 2/2: Optimizing -> {outfile}...") + if ctx.rules_optimize(tmp_path, outfile): + print("[+] Done.") + else: + print("[!] Optimize failed.") + finally: + if os.path.exists(tmp_path): + os.unlink(tmp_path) + + +def rule_tools_submenu(ctx: Any) -> None: + from hate_crack.menu import interactive_menu + + items = [ + ("1", "Clean rule file (remove invalid/duplicate rules)"), + ("2", "Optimize rule file (consolidate redundant operations)"), + ("3", "Clean and optimize rule file (both)"), + ("99", "Back to Main Menu"), + ] + while True: + choice = interactive_menu(items, title="\nRule File Tools:") + if choice is None or choice == "99": + break + elif choice == "1": + rule_cleanup_handler(ctx) + elif choice == "2": + rule_optimize_handler(ctx) + elif choice == "3": + rule_cleanup_and_optimize_handler(ctx) diff --git a/hate_crack/main.py b/hate_crack/main.py index e36bc14..03f953d 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -3329,6 +3329,26 @@ def omen_attack(): return _attacks.omen_attack(_attack_ctx()) +def rules_cleanup(infile: str, outfile: str) -> bool: + """Clean a rule file using cleanup-rules.bin. Returns True on success.""" + cleanup_path = os.path.join(hate_path, "hashcat-utils", "bin", "cleanup-rules.bin") + with open(infile, "rb") as fin, open(outfile, "wb") as fout: + result = subprocess.run([cleanup_path], stdin=fin, stdout=fout) + return result.returncode == 0 + + +def rules_optimize(infile: str, outfile: str) -> bool: + """Optimize a rule file using rules_optimize.bin. Returns True on success.""" + optimize_path = os.path.join(hate_path, "hashcat-utils", "bin", "rules_optimize.bin") + with open(infile, "rb") as fin, open(outfile, "wb") as fout: + result = subprocess.run([optimize_path], stdin=fin, stdout=fout) + return result.returncode == 0 + + +def rule_tools_submenu(): + return _attacks.rule_tools_submenu(_attack_ctx()) + + # convert hex words for recycling def convert_hex(working_file): processed_words = [] @@ -3557,6 +3577,7 @@ def get_main_menu_items(): ("16", "OMEN Attack"), ("17", "Ad-hoc Mask Attack"), ("18", "Markov Brute Force Attack"), + ("81", "Rule File Tools"), ("90", "Download rules from Hashmob.net"), ("91", "Analyze Hashcat Rules"), ("92", "Download wordlists from Hashmob.net"), @@ -3594,6 +3615,7 @@ def get_main_menu_options(): "16": omen_attack, "17": adhoc_mask_crack, "18": markov_brute_force, + "81": rule_tools_submenu, "90": lambda: download_hashmob_rules(rules_dir=rulesDirectory), "91": analyze_rules, "92": download_hashmob_wordlists, diff --git a/tests/test_rule_tools.py b/tests/test_rule_tools.py new file mode 100644 index 0000000..051f01b --- /dev/null +++ b/tests/test_rule_tools.py @@ -0,0 +1,244 @@ +import os +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from hate_crack.attacks import ( + rule_cleanup_and_optimize_handler, + rule_cleanup_handler, + rule_optimize_handler, + rule_tools_submenu, +) + + +def _make_ctx(): + ctx = MagicMock() + ctx.rules_cleanup.return_value = True + ctx.rules_optimize.return_value = True + ctx.rulesDirectory = "/tmp/rules" + return ctx + + +class TestRuleCleanupHandler: + def test_calls_rules_cleanup_with_correct_paths(self, tmp_path): + ctx = _make_ctx() + infile = tmp_path / "test.rule" + infile.write_text("l\nu\n") + outfile = tmp_path / "clean.rule" + with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + rule_cleanup_handler(ctx) + ctx.rules_cleanup.assert_called_once_with(str(infile), str(outfile)) + + def test_rejects_nonexistent_infile(self, tmp_path): + ctx = _make_ctx() + with patch("builtins.input", return_value="/nonexistent.rule"): + rule_cleanup_handler(ctx) + ctx.rules_cleanup.assert_not_called() + + def test_rejects_empty_outfile(self, tmp_path): + ctx = _make_ctx() + infile = tmp_path / "test.rule" + infile.write_text("l\n") + with patch("builtins.input", side_effect=[str(infile), ""]): + rule_cleanup_handler(ctx) + ctx.rules_cleanup.assert_not_called() + + def test_prints_done_on_success(self, tmp_path, capsys): + ctx = _make_ctx() + infile = tmp_path / "test.rule" + infile.write_text("l\n") + outfile = tmp_path / "clean.rule" + with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + rule_cleanup_handler(ctx) + assert "[+] Done." in capsys.readouterr().out + + def test_prints_failure_on_error(self, tmp_path, capsys): + ctx = _make_ctx() + ctx.rules_cleanup.return_value = False + infile = tmp_path / "test.rule" + infile.write_text("l\n") + outfile = tmp_path / "clean.rule" + with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + rule_cleanup_handler(ctx) + assert "[!] Cleanup failed." in capsys.readouterr().out + + +class TestRuleOptimizeHandler: + def test_calls_rules_optimize_with_correct_paths(self, tmp_path): + ctx = _make_ctx() + infile = tmp_path / "test.rule" + infile.write_text("l\nu\n") + outfile = tmp_path / "optimized.rule" + with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + rule_optimize_handler(ctx) + ctx.rules_optimize.assert_called_once_with(str(infile), str(outfile)) + + def test_rejects_nonexistent_infile(self, tmp_path): + ctx = _make_ctx() + with patch("builtins.input", return_value="/nonexistent.rule"): + rule_optimize_handler(ctx) + ctx.rules_optimize.assert_not_called() + + def test_rejects_empty_outfile(self, tmp_path): + ctx = _make_ctx() + infile = tmp_path / "test.rule" + infile.write_text("l\n") + with patch("builtins.input", side_effect=[str(infile), ""]): + rule_optimize_handler(ctx) + ctx.rules_optimize.assert_not_called() + + def test_prints_done_on_success(self, tmp_path, capsys): + ctx = _make_ctx() + infile = tmp_path / "test.rule" + infile.write_text("l\n") + outfile = tmp_path / "optimized.rule" + with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + rule_optimize_handler(ctx) + assert "[+] Done." in capsys.readouterr().out + + def test_prints_failure_on_error(self, tmp_path, capsys): + ctx = _make_ctx() + ctx.rules_optimize.return_value = False + infile = tmp_path / "test.rule" + infile.write_text("l\n") + outfile = tmp_path / "optimized.rule" + with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + rule_optimize_handler(ctx) + assert "[!] Optimize failed." in capsys.readouterr().out + + +class TestRuleCleanupAndOptimize: + def test_calls_cleanup_then_optimize(self, tmp_path): + ctx = _make_ctx() + infile = tmp_path / "test.rule" + infile.write_text("l\nu\n") + outfile = tmp_path / "final.rule" + with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + rule_cleanup_and_optimize_handler(ctx) + ctx.rules_cleanup.assert_called_once() + ctx.rules_optimize.assert_called_once() + + def test_stops_if_cleanup_fails(self, tmp_path): + ctx = _make_ctx() + ctx.rules_cleanup.return_value = False + infile = tmp_path / "test.rule" + infile.write_text("l\n") + outfile = tmp_path / "out.rule" + with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + rule_cleanup_and_optimize_handler(ctx) + ctx.rules_optimize.assert_not_called() + + def test_rejects_nonexistent_infile(self, tmp_path): + ctx = _make_ctx() + with patch("builtins.input", return_value="/nonexistent.rule"): + rule_cleanup_and_optimize_handler(ctx) + ctx.rules_cleanup.assert_not_called() + + def test_rejects_empty_outfile(self, tmp_path): + ctx = _make_ctx() + infile = tmp_path / "test.rule" + infile.write_text("l\n") + with patch("builtins.input", side_effect=[str(infile), ""]): + rule_cleanup_and_optimize_handler(ctx) + ctx.rules_cleanup.assert_not_called() + + def test_temp_file_cleaned_up_on_success(self, tmp_path): + ctx = _make_ctx() + captured_tmp = [] + + def capture_cleanup(infile, tmpfile): + captured_tmp.append(tmpfile) + return True + + ctx.rules_cleanup.side_effect = capture_cleanup + infile = tmp_path / "test.rule" + infile.write_text("l\n") + outfile = tmp_path / "final.rule" + with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + rule_cleanup_and_optimize_handler(ctx) + assert captured_tmp, "rules_cleanup should have been called" + assert not os.path.exists(captured_tmp[0]), "temp file should be cleaned up" + + def test_temp_file_cleaned_up_on_cleanup_failure(self, tmp_path): + ctx = _make_ctx() + captured_tmp = [] + + def capture_cleanup(infile, tmpfile): + captured_tmp.append(tmpfile) + return False + + ctx.rules_cleanup.side_effect = capture_cleanup + infile = tmp_path / "test.rule" + infile.write_text("l\n") + outfile = tmp_path / "out.rule" + with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + rule_cleanup_and_optimize_handler(ctx) + if captured_tmp: + assert not os.path.exists(captured_tmp[0]), "temp file should be cleaned up" + + def test_prints_done_on_full_success(self, tmp_path, capsys): + ctx = _make_ctx() + infile = tmp_path / "test.rule" + infile.write_text("l\n") + outfile = tmp_path / "final.rule" + with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + rule_cleanup_and_optimize_handler(ctx) + assert "[+] Done." in capsys.readouterr().out + + +class TestRuleToolsSubmenu: + def test_dispatches_to_cleanup(self): + ctx = _make_ctx() + with ( + patch("hate_crack.attacks.rule_cleanup_handler") as mock_fn, + patch( + "hate_crack.menu.interactive_menu", side_effect=["1", "99"] + ), + ): + rule_tools_submenu(ctx) + mock_fn.assert_called_once_with(ctx) + + def test_dispatches_to_optimize(self): + ctx = _make_ctx() + with ( + patch("hate_crack.attacks.rule_optimize_handler") as mock_fn, + patch( + "hate_crack.menu.interactive_menu", side_effect=["2", "99"] + ), + ): + rule_tools_submenu(ctx) + mock_fn.assert_called_once_with(ctx) + + def test_dispatches_to_cleanup_and_optimize(self): + ctx = _make_ctx() + with ( + patch("hate_crack.attacks.rule_cleanup_and_optimize_handler") as mock_fn, + patch( + "hate_crack.menu.interactive_menu", side_effect=["3", "99"] + ), + ): + rule_tools_submenu(ctx) + mock_fn.assert_called_once_with(ctx) + + def test_exits_on_99(self): + ctx = _make_ctx() + with patch("hate_crack.menu.interactive_menu", return_value="99"): + rule_tools_submenu(ctx) + + def test_exits_on_none(self): + ctx = _make_ctx() + with patch("hate_crack.menu.interactive_menu", return_value=None): + rule_tools_submenu(ctx) + + def test_loops_until_exit(self): + ctx = _make_ctx() + with ( + patch("hate_crack.attacks.rule_cleanup_handler") as mock_fn, + patch( + "hate_crack.menu.interactive_menu", + side_effect=["1", "1", "99"], + ), + ): + rule_tools_submenu(ctx) + assert mock_fn.call_count == 2 diff --git a/tests/test_rule_wrappers.py b/tests/test_rule_wrappers.py new file mode 100644 index 0000000..e7be6c4 --- /dev/null +++ b/tests/test_rule_wrappers.py @@ -0,0 +1,116 @@ +"""Tests for rules_cleanup and rules_optimize subprocess wrappers in main.py.""" +import subprocess +from unittest.mock import MagicMock, mock_open, patch + +import pytest + + +def _load_main(): + import importlib + import os + import sys + + os.environ.setdefault("HATE_CRACK_SKIP_INIT", "1") + if "hate_crack.main" in sys.modules: + return sys.modules["hate_crack.main"] + return importlib.import_module("hate_crack.main") + + +class TestRulesCleanupWrapper: + def test_runs_cleanup_binary_with_file_io(self, tmp_path): + main = _load_main() + infile = tmp_path / "input.rule" + infile.write_text("l\nu\n") + outfile = tmp_path / "output.rule" + + fake_result = MagicMock() + fake_result.returncode = 0 + + with patch("subprocess.run", return_value=fake_result) as mock_run: + result = main.rules_cleanup(str(infile), str(outfile)) + + assert result is True + mock_run.assert_called_once() + call_args = mock_run.call_args + cmd = call_args[0][0] + assert cmd[0].endswith("cleanup-rules.bin") + + def test_returns_false_on_nonzero_exit(self, tmp_path): + main = _load_main() + infile = tmp_path / "input.rule" + infile.write_text("l\n") + outfile = tmp_path / "output.rule" + + fake_result = MagicMock() + fake_result.returncode = 1 + + with patch("subprocess.run", return_value=fake_result): + result = main.rules_cleanup(str(infile), str(outfile)) + + assert result is False + + def test_binary_path_uses_hate_path(self, tmp_path): + main = _load_main() + infile = tmp_path / "input.rule" + infile.write_text("l\n") + outfile = tmp_path / "output.rule" + + fake_result = MagicMock() + fake_result.returncode = 0 + + with patch("subprocess.run", return_value=fake_result) as mock_run: + main.rules_cleanup(str(infile), str(outfile)) + + cmd = mock_run.call_args[0][0] + expected_suffix = "hashcat-utils/bin/cleanup-rules.bin" + assert cmd[0].endswith(expected_suffix), f"Expected path ending with {expected_suffix}, got {cmd[0]}" + + +class TestRulesOptimizeWrapper: + def test_runs_optimize_binary_with_file_io(self, tmp_path): + main = _load_main() + infile = tmp_path / "input.rule" + infile.write_text("l\nu\n") + outfile = tmp_path / "output.rule" + + fake_result = MagicMock() + fake_result.returncode = 0 + + with patch("subprocess.run", return_value=fake_result) as mock_run: + result = main.rules_optimize(str(infile), str(outfile)) + + assert result is True + mock_run.assert_called_once() + call_args = mock_run.call_args + cmd = call_args[0][0] + assert cmd[0].endswith("rules_optimize.bin") + + def test_returns_false_on_nonzero_exit(self, tmp_path): + main = _load_main() + infile = tmp_path / "input.rule" + infile.write_text("l\n") + outfile = tmp_path / "output.rule" + + fake_result = MagicMock() + fake_result.returncode = 1 + + with patch("subprocess.run", return_value=fake_result): + result = main.rules_optimize(str(infile), str(outfile)) + + assert result is False + + def test_binary_path_uses_hate_path(self, tmp_path): + main = _load_main() + infile = tmp_path / "input.rule" + infile.write_text("l\n") + outfile = tmp_path / "output.rule" + + fake_result = MagicMock() + fake_result.returncode = 0 + + with patch("subprocess.run", return_value=fake_result) as mock_run: + main.rules_optimize(str(infile), str(outfile)) + + cmd = mock_run.call_args[0][0] + expected_suffix = "hashcat-utils/bin/rules_optimize.bin" + assert cmd[0].endswith(expected_suffix), f"Expected path ending with {expected_suffix}, got {cmd[0]}" diff --git a/tests/test_ui_menu_options.py b/tests/test_ui_menu_options.py index fd3bfa6..2713a87 100644 --- a/tests/test_ui_menu_options.py +++ b/tests/test_ui_menu_options.py @@ -26,6 +26,7 @@ MENU_OPTION_TEST_CASES = [ ("16", CLI_MODULE._attacks, "omen_attack", "omen"), ("17", CLI_MODULE._attacks, "adhoc_mask_crack", "adhoc-mask"), ("18", CLI_MODULE._attacks, "markov_brute_force", "markov-brute"), + ("81", CLI_MODULE._attacks, "rule_tools_submenu", "rule-tools"), ("90", CLI_MODULE, "download_hashmob_rules", "hashmob-rules"), ("91", CLI_MODULE, "weakpass_wordlist_menu", "weakpass-menu"), ("92", CLI_MODULE, "download_hashmob_wordlists", "hashmob-wordlists"),