Merge pull request #95 from trustedsec/feat/rule-tools

feat: add rule file management submenu (#93)
This commit is contained in:
Justin Bollinger
2026-03-19 16:04:12 -04:00
committed by GitHub
7 changed files with 507 additions and 0 deletions
+11
View File
@@ -643,6 +643,8 @@ All tests use mocked API calls, so they can run without connectivity to a Hashvi
(80) Wordlist Tools
(81) Rule File Tools
(90) Download rules from Hashmob.net
(91) Analyze Hashcat Rules
(92) Download wordlists from Hashmob.net
@@ -855,6 +857,15 @@ A submenu of wordlist preprocessing utilities using hashcat-utils binaries. All
All binaries are in `hate_crack/hashcat-utils/bin/`.
#### Rule File Tools (option 81)
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.
+1
View File
@@ -93,6 +93,7 @@ def get_main_menu_options():
"21": _attacks.generate_rules_crack,
"22": _attacks.combipow_crack,
"80": _attacks.wordlist_tools_submenu,
"81": _attacks.rule_tools_submenu,
"90": download_hashmob_rules,
"91": weakpass_wordlist_menu,
"92": download_hashmob_wordlists,
+112
View File
@@ -837,6 +837,118 @@ def combinator_submenu(ctx: Any) -> None:
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)
def wordlist_filter_length(ctx: Any) -> None:
"""Prompt for paths and lengths, then filter wordlist by word length."""
infile = input("\n[*] Enter path to input wordlist: ").strip()
+22
View File
@@ -3738,6 +3738,26 @@ def wordlist_tools_submenu():
return _attacks.wordlist_tools_submenu(_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 = []
@@ -3971,6 +3991,7 @@ def get_main_menu_items():
("21", "Random Rules Attack"),
("22", "Combipow Passphrase Attack"),
("80", "Wordlist Tools"),
("81", "Rule File Tools"),
("90", "Download rules from Hashmob.net"),
("91", "Analyze Hashcat Rules"),
("92", "Download wordlists from Hashmob.net"),
@@ -4013,6 +4034,7 @@ def get_main_menu_options():
"21": generate_rules_crack,
"22": combipow_crack,
"80": wordlist_tools_submenu,
"81": rule_tools_submenu,
"90": lambda: download_hashmob_rules(rules_dir=rulesDirectory),
"91": analyze_rules,
"92": download_hashmob_wordlists,
+244
View File
@@ -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
+116
View File
@@ -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]}"
+1
View File
@@ -31,6 +31,7 @@ MENU_OPTION_TEST_CASES = [
("21", CLI_MODULE._attacks, "generate_rules_crack", "random-rules"),
("22", CLI_MODULE._attacks, "combipow_crack", "combipow"),
("80", CLI_MODULE._attacks, "wordlist_tools_submenu", "wordlist-tools"),
("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"),