diff --git a/Makefile b/Makefile index 77588fe..9071855 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ .DEFAULT_GOAL := submodules -.PHONY: install reinstall dev-install dev-reinstall clean hashcat-utils submodules test coverage lint check ruff mypy +.PHONY: install reinstall dev-install dev-reinstall clean hashcat-utils submodules submodules-pre test coverage lint check ruff mypy hashcat-utils: submodules $(MAKE) -C hashcat-utils @@ -8,12 +8,25 @@ submodules: @# Initialize submodules when present @if [ -f .gitmodules ] && command -v git >/dev/null 2>&1; then \ git submodule update --init --recursive; \ + fi; \ + $(MAKE) submodules-pre; \ + if [ -f .gitmodules ] && command -v git >/dev/null 2>&1; then \ for path in $$(git config --file .gitmodules --get-regexp path | awk '{print $$2}'); do \ if [ -f "$$path/Makefile" ]; then \ $(MAKE) -C "$$path"; \ fi; \ done; \ fi + + +submodules-pre: + @# Pre-step: basic sanity checks and file generation before building submodules. + @# Ensure required directories exist (whether as submodules or vendored copies). + @test -d hashcat-utils || { echo "Error: missing required directory: hashcat-utils"; exit 1; } + @test -d princeprocessor || { echo "Error: missing required directory: princeprocessor"; exit 1; } + @# Keep per-length expander sources in sync (expander8.c..expander24.c). + @# Patch hashcat-utils/src/Makefile so these new expanders are compiled by default. + @bases="hashcat-utils hate_crack/hashcat-utils"; for base in $$bases; do src="$$base/src/expander.c"; test -f "$$src" || continue; for i in $$(seq 8 36); do dst="$$base/src/expander$$i.c"; if [ ! -f "$$dst" ]; then cp "$$src" "$$dst"; perl -pi -e "s/#define LEN_MAX 7/#define LEN_MAX $$i/g" "$$dst"; fi; done; mk="$$base/src/Makefile"; test -f "$$mk" || continue; exp_bins=""; exp_exes=""; for i in $$(seq 8 36); do exp_bins="$$exp_bins expander$$i.bin"; exp_exes="$$exp_exes expander$$i.exe"; done; EXP_BINS="$$exp_bins" perl -pi -e 'if(/^native:/ && index($$_, "expander8.bin") < 0){chomp; $$_ .= "$$ENV{EXP_BINS}"; $$_ .= "\n";}' "$$mk"; EXP_EXES="$$exp_exes" perl -pi -e 'if(/^windows:/ && index($$_, "expander8.exe") < 0){chomp; $$_ .= "$$ENV{EXP_EXES}"; $$_ .= "\n";}' "$$mk"; perl -0777 -pi -e 's/\n# Auto-added by hate_crack \\(submodules-pre\\)\n.*\z/\n/s' "$$mk"; printf '%s\n' '' '# Auto-added by hate_crack (submodules-pre)' 'expander%.bin: src/expander%.c' >> "$$mk"; printf '\t%s\n' '$${CC_NATIVE} $${CFLAGS_NATIVE} $${LDFLAGS_NATIVE} -o bin/$$@ $$<' >> "$$mk"; printf '%s\n' '' 'expander%.exe: src/expander%.c' >> "$$mk"; printf '\t%s\n' '$${CC_WINDOWS} $${CFLAGS_WINDOWS} -o bin/$$@ $$<' >> "$$mk"; done install: @echo "Detecting OS and installing dependencies..." @@ -31,6 +44,8 @@ install: cp -R princeprocessor hate_crack/; \ rm -rf hate_crack/hashcat-utils/.git hate_crack/princeprocessor/.git; \ uv tool install .; \ + echo "Cleaning up vendored assets from working tree..."; \ + rm -rf hate_crack/hashcat-utils hate_crack/princeprocessor; \ elif [ -f /etc/debian_version ]; then \ echo "Detected Debian/Ubuntu"; \ sudo apt-get update; \ @@ -41,6 +56,8 @@ install: cp -R princeprocessor hate_crack/; \ rm -rf hate_crack/hashcat-utils/.git hate_crack/princeprocessor/.git; \ uv tool install .; \ + echo "Cleaning up vendored assets from working tree..."; \ + rm -rf hate_crack/hashcat-utils hate_crack/princeprocessor; \ else \ echo "Unsupported OS. Please install dependencies manually."; \ exit 1; \ diff --git a/hate_crack.py b/hate_crack.py index d76b49f..ce16395 100755 --- a/hate_crack.py +++ b/hate_crack.py @@ -68,7 +68,7 @@ def pipal(): def get_main_menu_options(): - return { + options = { "1": _attacks.quick_crack, "2": _attacks.extensive_crack, "3": _attacks.brute_force_crack, @@ -87,13 +87,16 @@ def get_main_menu_options(): "91": weakpass_wordlist_menu, "92": download_hashmob_wordlists, "93": weakpass_wordlist_menu, - "94": hashview_api, "95": pipal, "96": export_excel, "97": show_results, "98": show_readme, "99": quit_hc, } + # Only show Hashview API when configured. + if globals().get("hashview_api_key"): + options["94"] = hashview_api + return options if __name__ == "__main__": diff --git a/hate_crack/api.py b/hate_crack/api.py index 598b9d0..b021b07 100644 --- a/hate_crack/api.py +++ b/hate_crack/api.py @@ -1104,11 +1104,11 @@ class HashviewAPI: except (json.JSONDecodeError, ValueError): raise Exception(f"Invalid API response: {resp.text[:200]}") - def download_wordlist(self, wordlist_id, output_file=None): + def download_wordlist(self, wordlist_id, output_file=None, *, update_dynamic: bool = False): import sys import re - if int(wordlist_id) == 1: + if int(wordlist_id) == 1 and update_dynamic: update_url = f"{self.base_url}/v1/updateWordlist/{wordlist_id}" try: update_resp = self.session.get(update_url, headers=self._auth_headers(), timeout=30) diff --git a/hate_crack/attacks.py b/hate_crack/attacks.py index 9e71f89..deb104c 100644 --- a/hate_crack/attacks.py +++ b/hate_crack/attacks.py @@ -305,18 +305,18 @@ def top_mask_crack(ctx: Any) -> None: def fingerprint_crack(ctx: Any) -> None: while True: - raw = input("\nEnter expander max length (7-24) (7): ").strip() + raw = input("\nEnter expander max length (7-36) (7): ").strip() if raw == "": expander_len = 7 break try: expander_len = int(raw) except ValueError: - print("Please enter an integer between 7 and 24.") + print("Please enter an integer between 7 and 36.") continue - if 7 <= expander_len <= 24: + if 7 <= expander_len <= 36: break - print("Please enter an integer between 7 and 24.") + print("Please enter an integer between 7 and 36.") ctx.hcatFingerprint( ctx.hcatHashType, diff --git a/hate_crack/main.py b/hate_crack/main.py index ff9c7d8..c26aed6 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -675,7 +675,7 @@ hcatCombinationCount = 0 hcatHybridCount = 0 hcatExtraCount = 0 hcatRecycleCount = 0 -hcatProcess = 0 +hcatProcess: subprocess.Popen[Any] | None = None debug_mode = False @@ -1107,8 +1107,8 @@ def hcatFingerprint( expander_len = int(expander_len) except Exception: expander_len = 7 - if expander_len < 7 or expander_len > 24: - raise ValueError("expander_len must be an integer between 7 and 24") + if expander_len < 7 or expander_len > 36: + raise ValueError("expander_len must be an integer between 7 and 36") crackedBefore = lineCount(hcatHashFile + ".out") crackedAfter = 0 @@ -1131,16 +1131,18 @@ def hcatFingerprint( expander_proc = subprocess.Popen( [expander_path], stdin=src, stdout=subprocess.PIPE ) - hcatProcess = subprocess.Popen( - ["sort", "-u"], stdin=expander_proc.stdout, stdout=dst - ) - expander_proc.stdout.close() + expander_stdout = expander_proc.stdout + if expander_stdout is None: + raise RuntimeError("expander stdout pipe was not created") + sort_proc = subprocess.Popen(["sort", "-u"], stdin=expander_stdout, stdout=dst) + hcatProcess = sort_proc + expander_stdout.close() try: - hcatProcess.wait() + sort_proc.wait() expander_proc.wait() except KeyboardInterrupt: - print("Killing PID {0}...".format(str(hcatProcess.pid))) - hcatProcess.kill() + print("Killing PID {0}...".format(str(sort_proc.pid))) + sort_proc.kill() expander_proc.kill() hcatProcess = subprocess.Popen( [ @@ -2664,7 +2666,7 @@ def quit_hc(): def get_main_menu_options(): """Return the mapping of main menu keys to their handler functions.""" - return { + options = { "1": quick_crack, "2": extensive_crack, "3": brute_force_crack, @@ -2683,13 +2685,16 @@ def get_main_menu_options(): "91": weakpass_wordlist_menu, "92": download_hashmob_wordlists, "93": weakpass_wordlist_menu, - "94": hashview_api, "95": pipal, "96": export_excel, "97": show_results, "98": show_readme, "99": quit_hc, } + # Only show this when Hashview API is configured (requested behavior). + if hashview_api_key: + options["94"] = hashview_api + return options # The Main Guts @@ -3246,7 +3251,8 @@ def main(): print("\n\t(91) Download wordlists from Weakpass") print("\t(92) Download wordlists from Hashmob.net") print("\t(93) Weakpass Wordlist Menu") - print("\t(94) Hashview API") + if hashview_api_key: + print("\t(94) Hashview API") print("\t(95) Analyze hashes with Pipal") print("\t(96) Export Output to Excel Format") print("\t(97) Display Cracked Hashes") diff --git a/tests/test_fingerprint_expander_and_hybrid.py b/tests/test_fingerprint_expander_and_hybrid.py new file mode 100644 index 0000000..9d1c36a --- /dev/null +++ b/tests/test_fingerprint_expander_and_hybrid.py @@ -0,0 +1,99 @@ +import builtins +import importlib +import io +from types import SimpleNamespace + + +def test_fingerprint_crack_prompts_for_expander_len_and_enables_hybrid(monkeypatch): + from hate_crack import attacks + + seen = {} + + def fake_hcatFingerprint(hash_type, hash_file, expander_len, run_hybrid_on_expanded=False): + seen["hash_type"] = hash_type + seen["hash_file"] = hash_file + seen["expander_len"] = expander_len + seen["run_hybrid_on_expanded"] = run_hybrid_on_expanded + + ctx = SimpleNamespace( + hcatHashType="1000", + hcatHashFile="dummy.hash", + hcatFingerprint=fake_hcatFingerprint, + ) + + monkeypatch.setattr(builtins, "input", lambda _prompt="": "24") + attacks.fingerprint_crack(ctx) + + assert seen["expander_len"] == 24 + assert seen["run_hybrid_on_expanded"] is True + + +def test_hcatFingerprint_uses_selected_expander_and_calls_hybrid(monkeypatch, tmp_path): + monkeypatch.setenv("HATE_CRACK_SKIP_INIT", "1") + + import hate_crack.main as hc_main + + importlib.reload(hc_main) + + hashfile = tmp_path / "hashes.txt" + out_path = tmp_path / "hashes.txt.out" + out_path.write_text("deadbeef:Accordbookkeeping2025!:x\n") + + # Make the loop run exactly one iteration. + counts = iter([1, 1, 1, 1]) + monkeypatch.setattr(hc_main, "lineCount", lambda _p: next(counts)) + monkeypatch.setattr(hc_main, "hcatHashCracked", 0) + + # Avoid any filesystem/executable checks in unit test. + monkeypatch.setattr(hc_main, "ensure_binary", lambda binary_path, **_k: binary_path) + + seen = {"popen_args": [], "hybrid_calls": []} + + def fake_hybrid(hash_type, hash_file, wordlists=None): + seen["hybrid_calls"].append((hash_type, hash_file, wordlists)) + + monkeypatch.setattr(hc_main, "hcatHybrid", fake_hybrid) + + class FakePopen: + def __init__(self, args, stdin=None, stdout=None, text=False, **_kwargs): + self.args = args + self.pid = 123 + self.stdout = None + seen["popen_args"].append(args) + + cmd0 = args[0] + if cmd0 == "sort": + data = stdin.read() if stdin is not None else b"" + lines = sorted(set(data.splitlines())) + for ln in lines: + stdout.write(ln + b"\n") + stdout.flush() + elif isinstance(cmd0, str) and "expander" in cmd0: + data = stdin.read() if stdin is not None else b"" + # Identity "expansion" is enough for this test; we just need the + # pipeline to create the .expanded file and complete. + self.stdout = io.BytesIO(data) + else: + # hashcat invocation: do nothing + pass + + def wait(self, timeout=None): + return 0 + + def kill(self): + return None + + monkeypatch.setattr(hc_main.subprocess, "Popen", FakePopen) + + # Run with expander24 and ensure secondary hybrid gets the expanded file. + monkeypatch.setattr(hc_main, "hcatHashFile", str(hashfile), raising=False) + hc_main.hcatFingerprint("1000", str(hashfile), expander_len=24, run_hybrid_on_expanded=True) + + assert any( + isinstance(args[0], str) and args[0].endswith("expander24.bin") + for args in seen["popen_args"] + ) + + assert seen["hybrid_calls"] == [ + ("1000", str(hashfile), [f"{hashfile}.expanded"]), + ] diff --git a/tests/test_hashview.py b/tests/test_hashview.py index 4c76eeb..dae489e 100644 --- a/tests/test_hashview.py +++ b/tests/test_hashview.py @@ -366,15 +366,26 @@ class TestHashviewAPI: # Only run this test if explicitly enabled if os.environ.get("HASHVIEW_TEST_REAL", "").lower() not in ("1", "true", "yes"): pytest.skip("Set HASHVIEW_TEST_REAL=1 to run live Hashview list_wordlists test.") - - hashview_url, hashview_api_key = self._get_hashview_config() + + # For live tests, prefer explicit env vars so developers don't accidentally + # hit a config.json default/localhost target. + hashview_url = os.environ.get("HASHVIEW_URL") + hashview_api_key = os.environ.get("HASHVIEW_API_KEY") if not hashview_url or not hashview_api_key: - pytest.skip("Missing hashview_url/hashview_api_key in config.json or env.") + pytest.skip("Missing HASHVIEW_URL/HASHVIEW_API_KEY env vars.") # Only proceed if the server is actually reachable try: import socket - host, port = "127.0.0.1", 5000 + from urllib.parse import urlparse + + parsed = urlparse(hashview_url) + host = parsed.hostname + port = parsed.port + if not host: + pytest.skip(f"Could not parse hostname from hashview_url: {hashview_url!r}") + if port is None: + port = 443 if parsed.scheme == "https" else 80 sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(2) result = sock.connect_ex((host, port)) diff --git a/tests/test_ui_menu_options.py b/tests/test_ui_menu_options.py index 798d5a1..5dbe771 100644 --- a/tests/test_ui_menu_options.py +++ b/tests/test_ui_menu_options.py @@ -29,7 +29,6 @@ MENU_OPTION_TEST_CASES = [ ("91", CLI_MODULE, "weakpass_wordlist_menu", "weakpass-menu"), ("92", CLI_MODULE, "download_hashmob_wordlists", "hashmob-wordlists"), ("93", CLI_MODULE, "weakpass_wordlist_menu", "weakpass-menu-secondary"), - ("94", CLI_MODULE, "hashview_api", "hashview"), ("95", CLI_MODULE, "pipal", "pipal"), ("96", CLI_MODULE, "export_excel", "export-excel"), ("97", CLI_MODULE, "show_results", "show-results"), @@ -56,3 +55,18 @@ def test_main_menu_option_returns_expected( assert option_key in options, f"Menu option {option_key} must exist" handler = options[option_key] assert handler() == sentinel + + +def test_main_menu_option_94_hashview_hidden_without_hashview_api_key(monkeypatch): + monkeypatch.setattr(CLI_MODULE, "hashview_api_key", "") + options = CLI_MODULE.get_main_menu_options() + assert "94" not in options + + +def test_main_menu_option_94_hashview_visible_with_hashview_api_key(monkeypatch): + monkeypatch.setattr(CLI_MODULE, "hashview_api_key", "test-key") + sentinel = "hashview-94" + monkeypatch.setattr(CLI_MODULE, "hashview_api", lambda *a, **k: sentinel) + options = CLI_MODULE.get_main_menu_options() + assert "94" in options + assert options["94"]() == sentinel