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]} ") + + +def main(): + try: + if not os.path.isfile(sys.argv[1]): + print(f"{sys.argv[1]} is not a valid file.") + sys.exit(1) + if not os.path.isdir(sys.argv[2]): + create_directory = input( + f"{sys.argv[2]} is not a directory. Do you want to create it? (Y or N) " + ) + if create_directory.upper() == "Y": + try: + pathlib.Path(sys.argv[2]).mkdir(parents=True, exist_ok=True) + except PermissionError: + print( + "You do not have the correct permissions to create the directory. " + "Please try a different path or create manually." + ) + sys.exit(1) + else: + print("Please specify a valid directory and try again.") + sys.exit(1) + input_list = open(sys.argv[1], "r") + destination = sys.argv[2] + except IndexError: + usage() + sys.exit(1) + + # Resolve binary paths relative to script location + script_dir = os.path.dirname(os.path.realpath(__file__)) + ext = ".app" if sys.platform == "darwin" else ".bin" + splitlen_bin = os.path.join(script_dir, "hashcat-utils", "bin", f"splitlen{ext}") + rli_bin = os.path.join(script_dir, "hashcat-utils", "bin", f"rli{ext}") + + # Verify binaries exist + for binary_path, binary_name in [ + (splitlen_bin, "splitlen"), + (rli_bin, "rli"), + ]: + if not os.path.isfile(binary_path): + print( + f"Error: {binary_name} binary not found at {binary_path}. " + "Ensure hashcat-utils is built." + ) + sys.exit(1) + + # Get list of wordlists from argument + for wordlist in input_list: + wordlist = wordlist.strip() + if not wordlist: + continue + print(wordlist) + + # Parse wordlists by password length into "optimized" + if len(os.listdir(destination)) == 0: + with open(wordlist, "r") as wl: + try: + subprocess.run( + [splitlen_bin, destination], + stdin=wl, + check=True, + capture_output=True, + ) + except subprocess.CalledProcessError as e: + print(f"Error running splitlen on {wordlist}: {e.stderr.decode()}") + sys.exit(1) + else: + if not os.path.isdir("/tmp/splitlen"): + os.mkdir("/tmp/splitlen") + with open(wordlist, "r") as wl: + try: + subprocess.run( + [splitlen_bin, "/tmp/splitlen"], + stdin=wl, + check=True, + capture_output=True, + ) + except subprocess.CalledProcessError as e: + print(f"Error running splitlen on {wordlist}: {e.stderr.decode()}") + sys.exit(1) + + # Copy unique passwords into "optimized" + for file in os.listdir("/tmp/splitlen"): + src_file = os.path.join("/tmp/splitlen", file) + dst_file = os.path.join(destination, file) + if not os.path.isfile(dst_file): + shutil.copyfile(src_file, dst_file) + else: + try: + subprocess.run( + [rli_bin, src_file, "/tmp/splitlen.out", dst_file], + check=True, + capture_output=True, + ) + except subprocess.CalledProcessError as e: + print(f"Error running rli on {file}: {e.stderr.decode()}") + sys.exit(1) + if os.path.getsize("/tmp/splitlen.out") > 0: + with open(dst_file, "a") as dst: + with open("/tmp/splitlen.out", "r") as src: + dst.write(src.read()) + + # Clean Up + if os.path.isdir("/tmp/splitlen"): + shutil.rmtree("/tmp/splitlen") + if os.path.isfile("/tmp/splitlen.out"): + os.remove("/tmp/splitlen.out") + + +if __name__ == "__main__": + main()