diff --git a/Makefile b/Makefile
index 1c63273..639850b 100644
--- a/Makefile
+++ b/Makefile
@@ -99,7 +99,7 @@ install: submodules
@UV_BIN=$$(command -v uv 2>/dev/null || echo "$$HOME/.local/bin/uv"); \
"$$UV_BIN" sync
@mkdir -p "$${XDG_BIN_HOME:-$$HOME/.local/bin}"
- @printf '#!/usr/bin/env bash\nset -euo pipefail\nexec uv run --directory %s python -m hate_crack "$$@"\n' "$(CURDIR)" \
+ @printf '#!/usr/bin/env bash\nset -euo pipefail\nexport HATE_CRACK_ORIG_CWD="$$PWD"\nexec uv run --directory %s python -m hate_crack "$$@"\n' "$(CURDIR)" \
> "$${XDG_BIN_HOME:-$$HOME/.local/bin}/hate_crack"
@chmod +x "$${XDG_BIN_HOME:-$$HOME/.local/bin}/hate_crack"
@echo "Installed hate_crack shim to $${XDG_BIN_HOME:-$$HOME/.local/bin}/hate_crack"
@@ -107,7 +107,7 @@ install: submodules
update: submodules
@uv sync
@mkdir -p "$${XDG_BIN_HOME:-$$HOME/.local/bin}"
- @printf '#!/usr/bin/env bash\nset -euo pipefail\nexec uv run --directory %s python -m hate_crack "$$@"\n' "$(CURDIR)" \
+ @printf '#!/usr/bin/env bash\nset -euo pipefail\nexport HATE_CRACK_ORIG_CWD="$$PWD"\nexec uv run --directory %s python -m hate_crack "$$@"\n' "$(CURDIR)" \
> "$${XDG_BIN_HOME:-$$HOME/.local/bin}/hate_crack"
@chmod +x "$${XDG_BIN_HOME:-$$HOME/.local/bin}/hate_crack"
@echo "Updated hate_crack shim at $${XDG_BIN_HOME:-$$HOME/.local/bin}/hate_crack"
diff --git a/config.json.example b/config.json.example
index df7896e..a2804ca 100644
--- a/config.json.example
+++ b/config.json.example
@@ -25,7 +25,7 @@
"ollamaModel": "mistral",
"ollamaNumCtx": 2048,
"omenTrainingList": "rockyou.txt",
- "omenMaxCandidates": 1000000,
+ "omenMaxCandidates": 50000000,
"check_for_updates": true,
"optimizedKernelAttacks": ["hcatDictionary", "hcatQuickDictionary", "hcatBandrel", "hcatGoodMeasure", "hcatRecycle", "hcatBruteForce", "hcatTopMask", "hcatPathwellBruteForce"]
}
diff --git a/hate_crack/attacks.py b/hate_crack/attacks.py
index 7a787a2..550f940 100644
--- a/hate_crack/attacks.py
+++ b/hate_crack/attacks.py
@@ -24,10 +24,88 @@ def _configure_readline(completer):
readline.set_completer(completer)
+def _select_rules(ctx) -> list[str] | None:
+ """Prompt user to select rules. Returns list of rule chain strings, or None if cancelled."""
+ rule_choice = None
+ selected_rules = []
+
+ rules_dir = ctx.rulesDirectory
+ rule_files = sorted(f for f in os.listdir(rules_dir) if f != ".DS_Store")
+ if not rule_files:
+ download_rules = (
+ input("\nNo rules found. Download rules from Hashmob now? (Y/n): ")
+ .strip()
+ .lower()
+ )
+ if download_rules in ("", "y", "yes"):
+ download_hashmob_rules(print_fn=print, rules_dir=rules_dir)
+ rule_files = sorted(os.listdir(rules_dir))
+
+ if not rule_files:
+ print("No rules available. Proceeding without rules.")
+ return [""]
+
+ print("\nWhich rule(s) would you like to run?")
+ rule_entries = ["0. To run without any rules"]
+ rule_entries.extend([f"{i}. {file}" for i, file in enumerate(rule_files, start=1)])
+ rule_entries.append("98. YOLO...run all of the rules")
+ rule_entries.append("99. Back to Main Menu")
+ max_rule_len = max((len(e) for e in rule_entries), default=26)
+ print_multicolumn_list(
+ "Available Rules",
+ rule_entries,
+ min_col_width=max_rule_len,
+ max_col_width=max_rule_len,
+ )
+
+ example_line = ""
+ if len(rule_files) >= 2:
+ example_line = f"For example 1+1 will run {rule_files[0]} chained twice and 1,2 would run {rule_files[0]} and then {rule_files[1]} sequentially.\n"
+ elif len(rule_files) == 1:
+ example_line = f"For example 1+1 will run {rule_files[0]} chained twice.\n"
+
+ while rule_choice is None:
+ raw_choice = input(
+ "Enter Comma separated list of rules you would like to run. To run rules chained use the + symbol.\n"
+ f"{example_line}"
+ "Choose wisely: "
+ )
+ if raw_choice.strip() == "99":
+ return None
+ if raw_choice != "":
+ rule_choice = raw_choice.split(",")
+
+ if "99" in rule_choice:
+ return None
+ if "98" in rule_choice:
+ for rule in rule_files:
+ selected_rules.append(f"-r {os.path.join(rules_dir, rule)}")
+ elif "0" in rule_choice:
+ selected_rules = [""]
+ else:
+ for choice in rule_choice:
+ if "+" in choice:
+ combined_choice = ""
+ choices = choice.split("+")
+ for rule in choices:
+ try:
+ rule_path = os.path.join(rules_dir, rule_files[int(rule) - 1])
+ combined_choice = f"{combined_choice} -r {rule_path}"
+ except Exception:
+ continue
+ selected_rules.append(combined_choice)
+ else:
+ try:
+ rule_path = os.path.join(rules_dir, rule_files[int(choice) - 1])
+ selected_rules.append(f"-r {rule_path}")
+ except IndexError:
+ continue
+
+ return selected_rules
+
+
def quick_crack(ctx: Any) -> None:
wordlist_choice = None
- rule_choice = None
- selected_hcatRules = []
wordlist_files = ctx.list_wordlist_files(ctx.hcatWordlists)
wordlist_entries = [
@@ -85,81 +163,11 @@ def quick_crack(ctx: Any) -> None:
except ValueError:
print("Please enter a valid number.")
- rules_dir = ctx.rulesDirectory
- rule_files = sorted(f for f in os.listdir(rules_dir) if f != ".DS_Store")
- if not rule_files:
- download_rules = (
- input("\nNo rules found. Download rules from Hashmob now? (Y/n): ")
- .strip()
- .lower()
- )
- if download_rules in ("", "y", "yes"):
- download_hashmob_rules(print_fn=print, rules_dir=rules_dir)
- rule_files = sorted(os.listdir(rules_dir))
-
- if not rule_files:
- print("No rules available. Proceeding without rules.")
- rule_choice = ["0"]
- else:
- print("\nWhich rule(s) would you like to run?")
- rule_entries = ["0. To run without any rules"]
- rule_entries.extend(
- [f"{i}. {file}" for i, file in enumerate(rule_files, start=1)]
- )
- rule_entries.append("98. YOLO...run all of the rules")
- rule_entries.append("99. Back to Main Menu")
- max_rule_len = max((len(e) for e in rule_entries), default=26)
- print_multicolumn_list(
- "Available Rules",
- rule_entries,
- min_col_width=max_rule_len,
- max_col_width=max_rule_len,
- )
-
- example_line = ""
- if len(rule_files) >= 2:
- example_line = f"For example 1+1 will run {rule_files[0]} chained twice and 1,2 would run {rule_files[0]} and then {rule_files[1]} sequentially.\n"
- elif len(rule_files) == 1:
- example_line = f"For example 1+1 will run {rule_files[0]} chained twice.\n"
-
- while rule_choice is None:
- raw_choice = input(
- "Enter Comma separated list of rules you would like to run. To run rules chained use the + symbol.\n"
- f"{example_line}"
- "Choose wisely: "
- )
- if raw_choice.strip() == "99":
- return
- if raw_choice != "":
- rule_choice = raw_choice.split(",")
-
- if "99" in rule_choice:
+ selected_rules = _select_rules(ctx)
+ if selected_rules is None:
return
- if "98" in rule_choice:
- for rule in rule_files:
- selected_hcatRules.append(f"-r {os.path.join(rules_dir, rule)}")
- elif "0" in rule_choice:
- selected_hcatRules = [""]
- else:
- for choice in rule_choice:
- if "+" in choice:
- combined_choice = ""
- choices = choice.split("+")
- for rule in choices:
- try:
- rule_path = os.path.join(rules_dir, rule_files[int(rule) - 1])
- combined_choice = f"{combined_choice} -r {rule_path}"
- except Exception:
- continue
- selected_hcatRules.append(combined_choice)
- else:
- try:
- rule_path = os.path.join(rules_dir, rule_files[int(choice) - 1])
- selected_hcatRules.append(f"-r {rule_path}")
- except IndexError:
- continue
- for chain in selected_hcatRules:
+ for chain in selected_rules:
ctx.hcatQuickDictionary(
ctx.hcatHashType, ctx.hcatHashFile, chain, wordlist_choice
)
@@ -174,84 +182,11 @@ def loopback_attack(ctx: Any) -> None:
print(f"\nUsing loopback attack with wordlist: {empty_wordlist}")
- rule_choice = None
- selected_hcatRules = []
-
- rules_dir = ctx.rulesDirectory
- rule_files = sorted(f for f in os.listdir(rules_dir) if f != ".DS_Store")
- if not rule_files:
- download_rules = (
- input("\nNo rules found. Download rules from Hashmob now? (Y/n): ")
- .strip()
- .lower()
- )
- if download_rules in ("", "y", "yes"):
- download_hashmob_rules(print_fn=print, rules_dir=rules_dir)
- rule_files = sorted(os.listdir(rules_dir))
-
- if not rule_files:
- print("No rules available. Proceeding without rules.")
- rule_choice = ["0"]
- else:
- print("\nWhich rule(s) would you like to run?")
- rule_entries = ["0. To run without any rules"]
- rule_entries.extend(
- [f"{i}. {file}" for i, file in enumerate(rule_files, start=1)]
- )
- rule_entries.append("98. YOLO...run all of the rules")
- rule_entries.append("99. Back to Main Menu")
- max_rule_len = max((len(e) for e in rule_entries), default=26)
- print_multicolumn_list(
- "Available Rules",
- rule_entries,
- min_col_width=max_rule_len,
- max_col_width=max_rule_len,
- )
-
- example_line = ""
- if len(rule_files) >= 2:
- example_line = f"For example 1+1 will run {rule_files[0]} chained twice and 1,2 would run {rule_files[0]} and then {rule_files[1]} sequentially.\n"
- elif len(rule_files) == 1:
- example_line = f"For example 1+1 will run {rule_files[0]} chained twice.\n"
-
- while rule_choice is None:
- raw_choice = input(
- "Enter Comma separated list of rules you would like to run. To run rules chained use the + symbol.\n"
- f"{example_line}"
- "Choose wisely: "
- )
- if raw_choice.strip() == "99":
- return
- if raw_choice != "":
- rule_choice = raw_choice.split(",")
-
- if "99" in rule_choice:
+ selected_rules = _select_rules(ctx)
+ if selected_rules is None:
return
- if "98" in rule_choice:
- for rule in rule_files:
- selected_hcatRules.append(f"-r {os.path.join(rules_dir, rule)}")
- elif "0" in rule_choice:
- selected_hcatRules = [""]
- else:
- for choice in rule_choice:
- if "+" in choice:
- combined_choice = ""
- choices = choice.split("+")
- for rule in choices:
- try:
- rule_path = os.path.join(rules_dir, rule_files[int(rule) - 1])
- combined_choice = f"{combined_choice} -r {rule_path}"
- except Exception:
- continue
- selected_hcatRules.append(combined_choice)
- else:
- try:
- rule_path = os.path.join(rules_dir, rule_files[int(choice) - 1])
- selected_hcatRules.append(f"-r {rule_path}")
- except IndexError:
- continue
- for chain in selected_hcatRules:
+ for chain in selected_rules:
ctx.hcatQuickDictionary(
ctx.hcatHashType,
ctx.hcatHashFile,
@@ -580,4 +515,10 @@ def omen_attack(ctx: Any) -> None:
).strip()
if not max_candidates:
max_candidates = str(ctx.omenMaxCandidates)
- ctx.hcatOmen(ctx.hcatHashType, ctx.hcatHashFile, int(max_candidates))
+
+ selected_rules = _select_rules(ctx)
+ if selected_rules is None:
+ return
+
+ for chain in selected_rules:
+ ctx.hcatOmen(ctx.hcatHashType, ctx.hcatHashFile, int(max_candidates), chain)
diff --git a/hate_crack/cli.py b/hate_crack/cli.py
index cfc042f..1922351 100644
--- a/hate_crack/cli.py
+++ b/hate_crack/cli.py
@@ -5,10 +5,22 @@ from typing import Optional
def resolve_path(value: Optional[str]) -> Optional[str]:
- """Expand user and return an absolute path, or None."""
+ """Expand user and return an absolute path, or None.
+
+ When invoked via the bash shim (``uv run --directory``), the working
+ directory is changed to the repo root before Python starts.
+ ``HATE_CRACK_ORIG_CWD`` preserves the caller's real working directory
+ so that relative paths on the command line resolve correctly.
+ """
if not value:
return None
- return os.path.abspath(os.path.expanduser(value))
+ value = os.path.expanduser(value)
+ if os.path.isabs(value):
+ return value
+ orig_cwd = os.environ.get("HATE_CRACK_ORIG_CWD")
+ if orig_cwd:
+ return os.path.normpath(os.path.join(orig_cwd, value))
+ return os.path.abspath(value)
def add_common_args(parser) -> None:
diff --git a/hate_crack/main.py b/hate_crack/main.py
index d5f91ba..d5f052a 100755
--- a/hate_crack/main.py
+++ b/hate_crack/main.py
@@ -2181,7 +2181,7 @@ def hcatOmenTrain(training_file):
# OMEN Attack - Generate candidates and pipe to hashcat
-def hcatOmen(hcatHashType, hcatHashFile, max_candidates):
+def hcatOmen(hcatHashType, hcatHashFile, max_candidates, hcatChains=""):
global hcatProcess
omen_dir = _omen_dir
enum_bin = os.path.join(omen_dir, hcatOmenEnumBin)
@@ -2205,8 +2205,11 @@ def hcatOmen(hcatHashType, hcatHashFile, max_candidates):
"-o",
f"{hcatHashFile}.out",
]
+ if hcatChains:
+ hashcat_cmd.extend(shlex.split(hcatChains))
hashcat_cmd.extend(shlex.split(hcatTuning))
_append_potfile_arg(hashcat_cmd)
+ hashcat_cmd = _add_debug_mode_for_rules(hashcat_cmd)
print(f"[*] Running: {_format_cmd(enum_cmd)} | {_format_cmd(hashcat_cmd)}")
_debug_cmd(hashcat_cmd)
enum_proc = subprocess.Popen(
diff --git a/tests/test_omen_attack.py b/tests/test_omen_attack.py
index 9fde1d6..ef33507 100644
--- a/tests/test_omen_attack.py
+++ b/tests/test_omen_attack.py
@@ -252,10 +252,11 @@ class TestOmenAttackHandler:
ctx.hcatOmenCreateBin = "createNG"
ctx.hcatOmenEnumBin = "enumNG"
ctx.omenTrainingList = "/default/rockyou.txt"
- ctx.omenMaxCandidates = 1000000
+ ctx.omenMaxCandidates = 50000000
ctx.hcatHashType = "1000"
ctx.hcatHashFile = "/tmp/hashes.txt"
ctx.hcatWordlists = str(tmp_path / "wordlists")
+ ctx.rulesDirectory = str(tmp_path / "rules")
ctx._omen_model_is_valid.return_value = model_valid
ctx._omen_model_info.return_value = (
{"training_file": "/old/rockyou.txt"} if model_valid else None
@@ -265,10 +266,19 @@ class TestOmenAttackHandler:
ctx.list_wordlist_files.return_value = ["rockyou.txt", "custom.txt"]
return ctx
+ def _setup_rules_dir(self, tmp_path, rule_names=None):
+ rules_dir = tmp_path / "rules"
+ rules_dir.mkdir(exist_ok=True)
+ if rule_names:
+ for name in rule_names:
+ (rules_dir / name).write_text(":")
+ return rules_dir
+
def test_use_existing_model(self, tmp_path):
ctx = self._make_ctx(tmp_path, model_valid=True)
+ self._setup_rules_dir(tmp_path)
with patch("os.path.isfile", return_value=True), patch(
- "builtins.input", side_effect=["1", ""]
+ "builtins.input", side_effect=["1", "", "0"]
):
from hate_crack.attacks import omen_attack
@@ -278,8 +288,9 @@ class TestOmenAttackHandler:
def test_train_new_model_with_wordlist_pick(self, tmp_path):
ctx = self._make_ctx(tmp_path, model_valid=True)
+ self._setup_rules_dir(tmp_path)
with patch("os.path.isfile", return_value=True), patch(
- "builtins.input", side_effect=["2", "1", ""]
+ "builtins.input", side_effect=["2", "1", "", "0"]
):
from hate_crack.attacks import omen_attack
@@ -302,8 +313,9 @@ class TestOmenAttackHandler:
def test_no_model_goes_straight_to_training(self, tmp_path):
ctx = self._make_ctx(tmp_path, model_valid=False)
+ self._setup_rules_dir(tmp_path)
with patch("os.path.isfile", return_value=True), patch(
- "builtins.input", side_effect=["1", ""]
+ "builtins.input", side_effect=["1", "", "0"]
):
from hate_crack.attacks import omen_attack
@@ -324,14 +336,166 @@ class TestOmenAttackHandler:
def test_custom_path_for_training(self, tmp_path):
ctx = self._make_ctx(tmp_path, model_valid=False)
+ self._setup_rules_dir(tmp_path)
with patch("os.path.isfile", return_value=True), patch(
- "builtins.input", side_effect=["p", "/custom/wordlist.txt", ""]
+ "builtins.input", side_effect=["p", "/custom/wordlist.txt", "", "0"]
):
from hate_crack.attacks import omen_attack
omen_attack(ctx)
ctx.hcatOmenTrain.assert_called_once_with("/custom/wordlist.txt")
+ def test_rules_passed_to_hcatOmen(self, tmp_path):
+ ctx = self._make_ctx(tmp_path, model_valid=True)
+ self._setup_rules_dir(tmp_path, ["best64.rule"])
+ with patch("os.path.isfile", return_value=True), patch(
+ "builtins.input", side_effect=["1", "", "1"]
+ ):
+ from hate_crack.attacks import omen_attack
+
+ omen_attack(ctx)
+ call_args = ctx.hcatOmen.call_args
+ assert "-r" in call_args[0][3]
+ assert "best64.rule" in call_args[0][3]
+
+ def test_multiple_rule_chains_spawn_multiple_calls(self, tmp_path):
+ ctx = self._make_ctx(tmp_path, model_valid=True)
+ self._setup_rules_dir(tmp_path, ["best64.rule", "dive.rule"])
+ with patch("os.path.isfile", return_value=True), patch(
+ "builtins.input", side_effect=["1", "", "1,2"]
+ ):
+ from hate_crack.attacks import omen_attack
+
+ omen_attack(ctx)
+ assert ctx.hcatOmen.call_count == 2
+
+ def test_cancel_from_rules_aborts(self, tmp_path):
+ ctx = self._make_ctx(tmp_path, model_valid=True)
+ self._setup_rules_dir(tmp_path, ["best64.rule"])
+ with patch("os.path.isfile", return_value=True), patch(
+ "builtins.input", side_effect=["1", "", "99"]
+ ):
+ from hate_crack.attacks import omen_attack
+
+ omen_attack(ctx)
+ ctx.hcatOmen.assert_not_called()
+
+ def test_no_rules_passes_empty_chain(self, tmp_path):
+ ctx = self._make_ctx(tmp_path, model_valid=True)
+ self._setup_rules_dir(tmp_path, ["best64.rule"])
+ with patch("os.path.isfile", return_value=True), patch(
+ "builtins.input", side_effect=["1", "", "0"]
+ ):
+ from hate_crack.attacks import omen_attack
+
+ omen_attack(ctx)
+ ctx.hcatOmen.assert_called_once()
+ assert ctx.hcatOmen.call_args[0][3] == ""
+
+
+class TestHcatOmenWithRules:
+ def test_rule_flags_appear_in_hashcat_command(self, main_module, tmp_path):
+ omen_dir = tmp_path / "omen"
+ omen_dir.mkdir()
+ enum_bin = omen_dir / "enumNG"
+ enum_bin.touch()
+ enum_bin.chmod(0o755)
+ model_dir = tmp_path / "model"
+ model_dir.mkdir()
+ (model_dir / "createConfig").write_text("# test config\n")
+
+ with patch.object(main_module, "_omen_dir", str(omen_dir)), \
+ patch.object(main_module, "hcatOmenEnumBin", "enumNG"), \
+ patch.object(main_module, "hcatBin", "hashcat"), \
+ patch.object(main_module, "hcatTuning", ""), \
+ patch.object(main_module, "hcatPotfilePath", ""), \
+ patch.object(main_module, "hcatDebugLogPath", str(tmp_path / "debug")), \
+ patch.object(main_module, "hcatHashFile", "/tmp/hashes.txt", create=True), \
+ patch("hate_crack.main._omen_model_dir", return_value=str(model_dir)), \
+ patch("hate_crack.main.subprocess.Popen") as mock_popen:
+ mock_enum_proc = MagicMock()
+ mock_enum_proc.stdout = MagicMock()
+ mock_enum_proc.stderr = MagicMock()
+ mock_enum_proc.stderr.read.return_value = b""
+ mock_enum_proc.returncode = 0
+ mock_enum_proc.wait.return_value = None
+ mock_hashcat_proc = MagicMock()
+ mock_hashcat_proc.wait.return_value = None
+ mock_popen.side_effect = [mock_enum_proc, mock_hashcat_proc]
+
+ main_module.hcatOmen("1000", "/tmp/hashes.txt", 500000, "-r /tmp/best64.rule")
+
+ hashcat_cmd = mock_popen.call_args_list[1][0][0]
+ assert "-r" in hashcat_cmd
+ assert "/tmp/best64.rule" in hashcat_cmd
+
+ def test_debug_mode_added_when_rules_present(self, main_module, tmp_path):
+ omen_dir = tmp_path / "omen"
+ omen_dir.mkdir()
+ enum_bin = omen_dir / "enumNG"
+ enum_bin.touch()
+ enum_bin.chmod(0o755)
+ model_dir = tmp_path / "model"
+ model_dir.mkdir()
+ (model_dir / "createConfig").write_text("# test config\n")
+
+ with patch.object(main_module, "_omen_dir", str(omen_dir)), \
+ patch.object(main_module, "hcatOmenEnumBin", "enumNG"), \
+ patch.object(main_module, "hcatBin", "hashcat"), \
+ patch.object(main_module, "hcatTuning", ""), \
+ patch.object(main_module, "hcatPotfilePath", ""), \
+ patch.object(main_module, "hcatDebugLogPath", str(tmp_path / "debug")), \
+ patch.object(main_module, "hcatHashFile", "/tmp/hashes.txt", create=True), \
+ patch("hate_crack.main._omen_model_dir", return_value=str(model_dir)), \
+ patch("hate_crack.main.subprocess.Popen") as mock_popen:
+ mock_enum_proc = MagicMock()
+ mock_enum_proc.stdout = MagicMock()
+ mock_enum_proc.stderr = MagicMock()
+ mock_enum_proc.stderr.read.return_value = b""
+ mock_enum_proc.returncode = 0
+ mock_enum_proc.wait.return_value = None
+ mock_hashcat_proc = MagicMock()
+ mock_hashcat_proc.wait.return_value = None
+ mock_popen.side_effect = [mock_enum_proc, mock_hashcat_proc]
+
+ main_module.hcatOmen("1000", "/tmp/hashes.txt", 500000, "-r /tmp/best64.rule")
+
+ hashcat_cmd = mock_popen.call_args_list[1][0][0]
+ assert "--debug-mode" in hashcat_cmd
+
+ def test_no_rules_no_debug_mode(self, main_module, tmp_path):
+ omen_dir = tmp_path / "omen"
+ omen_dir.mkdir()
+ enum_bin = omen_dir / "enumNG"
+ enum_bin.touch()
+ enum_bin.chmod(0o755)
+ model_dir = tmp_path / "model"
+ model_dir.mkdir()
+ (model_dir / "createConfig").write_text("# test config\n")
+
+ with patch.object(main_module, "_omen_dir", str(omen_dir)), \
+ patch.object(main_module, "hcatOmenEnumBin", "enumNG"), \
+ patch.object(main_module, "hcatBin", "hashcat"), \
+ patch.object(main_module, "hcatTuning", ""), \
+ patch.object(main_module, "hcatPotfilePath", ""), \
+ patch.object(main_module, "hcatHashFile", "/tmp/hashes.txt", create=True), \
+ patch("hate_crack.main._omen_model_dir", return_value=str(model_dir)), \
+ patch("hate_crack.main.subprocess.Popen") as mock_popen:
+ mock_enum_proc = MagicMock()
+ mock_enum_proc.stdout = MagicMock()
+ mock_enum_proc.stderr = MagicMock()
+ mock_enum_proc.stderr.read.return_value = b""
+ mock_enum_proc.returncode = 0
+ mock_enum_proc.wait.return_value = None
+ mock_hashcat_proc = MagicMock()
+ mock_hashcat_proc.wait.return_value = None
+ mock_popen.side_effect = [mock_enum_proc, mock_hashcat_proc]
+
+ main_module.hcatOmen("1000", "/tmp/hashes.txt", 500000)
+
+ hashcat_cmd = mock_popen.call_args_list[1][0][0]
+ assert "--debug-mode" not in hashcat_cmd
+
class TestOmenModelValidation:
@pytest.fixture
diff --git a/tests/test_utils.py b/tests/test_utils.py
index eda4b26..af553f3 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -15,6 +15,27 @@ def test_resolve_path_none_and_expand():
assert os.path.isabs(resolved)
+def test_resolve_path_uses_orig_cwd_for_relative_paths(monkeypatch, tmp_path):
+ """When HATE_CRACK_ORIG_CWD is set, relative paths resolve against it."""
+ monkeypatch.setenv("HATE_CRACK_ORIG_CWD", str(tmp_path))
+ result = cli.resolve_path("hashes.txt")
+ assert result == os.path.join(str(tmp_path), "hashes.txt")
+
+
+def test_resolve_path_ignores_orig_cwd_for_absolute_paths(monkeypatch, tmp_path):
+ """Absolute paths are returned as-is regardless of HATE_CRACK_ORIG_CWD."""
+ monkeypatch.setenv("HATE_CRACK_ORIG_CWD", str(tmp_path))
+ result = cli.resolve_path("/absolute/path/hashes.txt")
+ assert result == "/absolute/path/hashes.txt"
+
+
+def test_resolve_path_without_orig_cwd_uses_abspath(monkeypatch):
+ """Without HATE_CRACK_ORIG_CWD, falls back to os.path.abspath."""
+ monkeypatch.delenv("HATE_CRACK_ORIG_CWD", raising=False)
+ result = cli.resolve_path("hashes.txt")
+ assert result == os.path.abspath("hashes.txt")
+
+
def test_setup_logging_adds_single_streamhandler(tmp_path):
logger = logging.getLogger("hate_crack_test")
logger.handlers.clear()
diff --git a/wordlist_optimizer.py b/wordlist_optimizer.py
new file mode 100644
index 0000000..b231973
--- /dev/null
+++ b/wordlist_optimizer.py
@@ -0,0 +1,123 @@
+#!/usr/bin/env python3
+
+import sys
+import os
+import subprocess
+import shutil
+import pathlib
+
+
+def usage():
+ print(f"usage: python {sys.argv[0]}