From e205e6e886bd4a3174e0bc022ef9751ff5d427f5 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 13 Feb 2026 13:13:39 -0500 Subject: [PATCH 01/18] feat: add LLM Markov Attack (menu option 15) Add a new attack mode that uses a local LLM via Ollama to generate password candidates, converts them into hashcat .hcstat2 Markov statistics via hcstat2gen, and runs a Markov-enhanced mask attack. Two generation sub-modes: - Wordlist-based: feeds sample from an existing wordlist to the LLM as pattern context (config-selectable default with Y/N override) - Target-based: prompts for company name, industry, and location for contextual password generation Pipeline: Ollama API -> candidate file -> hcstat2gen -> LZMA compress -> hashcat -a 3 --markov-hcstat2 Config additions: ollamaUrl, ollamaModel, markovCandidateCount, markovWordlist. No new pip dependencies (uses stdlib urllib/lzma). Co-Authored-By: Claude Opus 4.6 --- config.json.example | 6 +- hate_crack.py | 1 + hate_crack/attacks.py | 45 ++++++ hate_crack/main.py | 256 ++++++++++++++++++++++++++++++++++ tests/test_ui_menu_options.py | 1 + 5 files changed, 308 insertions(+), 1 deletion(-) diff --git a/config.json.example b/config.json.example index 5215c2d..fc954e4 100644 --- a/config.json.example +++ b/config.json.example @@ -22,5 +22,9 @@ "bandrel_common_basedwords": "welcome,password,p@ssword,p@$$word,changeme,letmein,summer,winter,spring,springtime,fall,autumn,monday,tuesday,wednesday,thursday,friday,saturday,sunday,january,february,march,april,may,june,july,august,september,october,november,december,christmas,easter,covid19", "hashview_url": "http://localhost:8443", "hashview_api_key": "", - "hashmob_api_key": "" + "hashmob_api_key": "", + "ollamaUrl": "http://localhost:11434", + "ollamaModel": "llama3.2", + "markovCandidateCount": 5000, + "markovWordlist": "rockyou.txt" } diff --git a/hate_crack.py b/hate_crack.py index ce16395..5b6bfd0 100755 --- a/hate_crack.py +++ b/hate_crack.py @@ -83,6 +83,7 @@ def get_main_menu_options(): "12": _attacks.thorough_combinator, "13": _attacks.bandrel_method, "14": _attacks.loopback_attack, + "15": _attacks.markov_attack, "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 1d3b5dc..74928ac 100644 --- a/hate_crack/attacks.py +++ b/hate_crack/attacks.py @@ -486,3 +486,48 @@ def middle_combinator(ctx: Any) -> None: def bandrel_method(ctx: Any) -> None: ctx.hcatBandrel(ctx.hcatHashType, ctx.hcatHashFile) + + +def markov_attack(ctx: Any) -> None: + print("\n\tLLM Markov Attack") + print("\t(1) Wordlist-based generation") + print("\t(2) Target-based generation") + choice = input("\nSelect generation mode: ").strip() + + if choice == "1": + default_wl = ctx.markovWordlist + if isinstance(default_wl, list): + default_wl = default_wl[0] if default_wl else "" + print(f"\nDefault wordlist: {default_wl}") + override = input("Use a different wordlist? [y/N]: ").strip().lower() + if override == "y": + wordlist = ctx.select_file_with_autocomplete("Enter wordlist path") + wordlist = ctx._resolve_wordlist_path(wordlist, ctx.hcatWordlists) + else: + wordlist = default_wl + count_input = input( + f"Number of candidates to generate [{ctx.markovCandidateCount}]: " + ).strip() + count = int(count_input) if count_input else ctx.markovCandidateCount + ctx.hcatMarkov( + ctx.hcatHashType, ctx.hcatHashFile, "wordlist", wordlist, count + ) + + elif choice == "2": + company = input("Company name: ").strip() + industry = input("Industry: ").strip() + location = input("Location: ").strip() + count_input = input( + f"Number of candidates to generate [{ctx.markovCandidateCount}]: " + ).strip() + count = int(count_input) if count_input else ctx.markovCandidateCount + target_info = { + "company": company, + "industry": industry, + "location": location, + } + ctx.hcatMarkov( + ctx.hcatHashType, ctx.hcatHashFile, "target", target_info, count + ) + else: + print("Invalid selection.") diff --git a/hate_crack/main.py b/hate_crack/main.py index 9d738e5..ed62f7d 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -9,6 +9,7 @@ import sys import os import json +import lzma import shutil import logging import binascii @@ -19,6 +20,8 @@ import readline import subprocess import shlex import argparse +import urllib.request +import urllib.error from types import SimpleNamespace #!/usr/bin/env python3 @@ -451,9 +454,47 @@ except KeyError as e: default_config.get("hcatDebugLogPath", "./hashcat_debug") ) +try: + ollamaUrl = config_parser["ollamaUrl"] +except KeyError as e: + print( + "{0} is not defined in config.json using defaults from config.json.example".format( + e + ) + ) + ollamaUrl = default_config.get("ollamaUrl", "http://localhost:11434") +try: + ollamaModel = config_parser["ollamaModel"] +except KeyError as e: + print( + "{0} is not defined in config.json using defaults from config.json.example".format( + e + ) + ) + ollamaModel = default_config.get("ollamaModel", "llama3.2") +try: + markovCandidateCount = int(config_parser["markovCandidateCount"]) +except KeyError as e: + print( + "{0} is not defined in config.json using defaults from config.json.example".format( + e + ) + ) + markovCandidateCount = int(default_config.get("markovCandidateCount", 5000)) +try: + markovWordlist = config_parser["markovWordlist"] +except KeyError as e: + print( + "{0} is not defined in config.json using defaults from config.json.example".format( + e + ) + ) + markovWordlist = default_config.get("markovWordlist", "rockyou.txt") + hcatExpanderBin = "expander.bin" hcatCombinatorBin = "combinator.bin" hcatPrinceBin = "pp64.bin" +hcatHcstat2genBin = "hcstat2gen.bin" def _resolve_wordlist_path(wordlist, base_dir): @@ -588,6 +629,7 @@ hcatGoodMeasureBaseList = _normalize_wordlist_setting( hcatGoodMeasureBaseList, wordlists_dir ) hcatPrinceBaseList = _normalize_wordlist_setting(hcatPrinceBaseList, wordlists_dir) +markovWordlist = _normalize_wordlist_setting(markovWordlist, wordlists_dir) if not SKIP_INIT: # Verify hashcat binary is available @@ -653,6 +695,20 @@ if not SKIP_INIT: except SystemExit: print("PRINCE attacks will not be available.") + # Verify hcstat2gen binary (optional, for Markov attacks) + # Note: hcstat2gen is part of hashcat-utils, already in hate_crack repo + hcstat2gen_path = ( + hate_path + "/hashcat-utils/bin/" + hcatHcstat2genBin + ) + try: + ensure_binary( + hcstat2gen_path, + build_dir=os.path.join(hate_path, "hashcat-utils"), + name="hcstat2gen", + ) + except SystemExit: + print("LLM Markov attacks will not be available.") + except Exception as e: print(f"Module initialization error: {e}") if not shutil.which("hashcat") and not os.path.exists("/usr/bin/hashcat"): @@ -1446,6 +1502,200 @@ def hcatBandrel(hcatHashType, hcatHashFile): hcatProcess.kill() +# LLM Markov Attack +def hcatMarkov(hcatHashType, hcatHashFile, mode, context_data, candidate_count): + global hcatProcess + candidates_path = f"{hcatHashFile}.markov_candidates" + hcstat2_raw_path = f"{hcatHashFile}.hcstat2_raw" + hcstat2_path = f"{hcatHashFile}.hcstat2" + hcstat2gen_path = os.path.join( + hate_path, "hashcat-utils", "bin", hcatHcstat2genBin + ) + + if not os.path.isfile(hcstat2gen_path): + print( + f"Error: hcstat2gen not found at {hcstat2gen_path}. " + "LLM Markov attacks are not available." + ) + return + + # Step A: Build LLM prompt based on mode + if mode == "wordlist": + wordlist_path = context_data + if not os.path.isfile(wordlist_path): + print(f"Error: Wordlist not found: {wordlist_path}") + return + sample_lines = [] + try: + with open(wordlist_path, "r", errors="ignore") as f: + for i, line in enumerate(f): + if i >= 500: + break + stripped = line.strip() + if stripped: + sample_lines.append(stripped) + except Exception as e: + print(f"Error reading wordlist: {e}") + return + wordlist_sample = "\n".join(sample_lines) + prompt = ( + "You are a password generation expert. Below is a sample of real passwords. " + "Study the patterns, character choices, and structures. Generate exactly " + f"{candidate_count} new unique passwords that follow similar patterns but " + "are NOT copies of the input. Output ONLY passwords, one per line, no " + "numbering or explanation.\n\n" + f"Sample passwords:\n{wordlist_sample}" + ) + elif mode == "target": + company = context_data.get("company", "") + industry = context_data.get("industry", "") + location = context_data.get("location", "") + prompt = ( + f"Generate exactly {candidate_count} realistic passwords that employees at " + f"{company} in the {industry} industry located in {location} might use. " + "Include variations with:\n" + "- Company name variations and abbreviations\n" + "- Common password patterns (Season+Year, Name+Numbers)\n" + "- Keyboard walks and common substitutions (@ for a, 3 for e, etc.)\n" + "- Location-based words and local references\n" + "- Industry-specific terminology\n" + "Output ONLY the passwords, one per line, no numbering or explanation." + ) + else: + print(f"Error: Unknown Markov generation mode: {mode}") + return + + # Step B: Call Ollama API to generate candidates + print(f"Generating {candidate_count} password candidates via Ollama ({ollamaModel})...") + api_url = f"{ollamaUrl}/api/generate" + payload = json.dumps({ + "model": ollamaModel, + "prompt": prompt, + "stream": False, + }).encode("utf-8") + + try: + req = urllib.request.Request( + api_url, + data=payload, + headers={"Content-Type": "application/json"}, + ) + with urllib.request.urlopen(req, timeout=600) as resp: + result = json.loads(resp.read().decode("utf-8")) + except urllib.error.URLError as e: + print(f"Error: Could not connect to Ollama at {ollamaUrl}: {e}") + print("Ensure Ollama is running (ollama serve) and try again.") + return + except Exception as e: + print(f"Error calling Ollama API: {e}") + return + + response_text = result.get("response", "") + raw_lines = response_text.strip().split("\n") + # Filter out blank lines and lines that look like numbering/explanation + candidates = [] + for line in raw_lines: + stripped = line.strip() + if not stripped: + continue + # Strip leading numbering like "1. " or "1) " or "- " + cleaned = re.sub(r"^\d+[.)]\s*", "", stripped) + cleaned = re.sub(r"^[-*]\s*", "", cleaned) + cleaned = cleaned.strip() + if cleaned and len(cleaned) <= 128: + candidates.append(cleaned) + + if not candidates: + print("Error: Ollama returned no usable password candidates.") + return + + try: + with open(candidates_path, "w") as f: + for candidate in candidates: + f.write(candidate + "\n") + except Exception as e: + print(f"Error writing candidates file: {e}") + return + + print(f"Generated {len(candidates)} password candidates -> {candidates_path}") + + # Step C: Run hcstat2gen to build Markov stats + print("Building Markov statistics with hcstat2gen...") + try: + with open(candidates_path, "rb") as candidates_file: + hcatProcess = subprocess.Popen( + [hcstat2gen_path, hcstat2_raw_path], + stdin=candidates_file, + ) + try: + hcatProcess.wait() + except KeyboardInterrupt: + print("Killing PID {0}...".format(str(hcatProcess.pid))) + hcatProcess.kill() + return + except Exception as e: + print(f"Error running hcstat2gen: {e}") + return + + if not os.path.isfile(hcstat2_raw_path): + print("Error: hcstat2gen did not produce output file.") + return + + # Step D: LZMA compress the raw hcstat2 file + print("Compressing hcstat2 file with LZMA...") + try: + with open(hcstat2_raw_path, "rb") as raw_file: + raw_data = raw_file.read() + compressed = lzma.compress( + raw_data, + format=lzma.FORMAT_RAW, + filters=[{"id": lzma.FILTER_LZMA1}], + ) + with open(hcstat2_path, "wb") as out_file: + out_file.write(compressed) + except Exception as e: + print(f"Error compressing hcstat2 file: {e}") + return + + print(f"Markov statistics file created -> {hcstat2_path}") + + # Step E: Run hashcat mask attack with custom Markov stats + print("Running Markov-enhanced mask attack...") + cmd = [ + hcatBin, + "-m", + hcatHashType, + hcatHashFile, + "--session", + generate_session_id(), + "-o", + f"{hcatHashFile}.out", + "-a", + "3", + f"--markov-hcstat2={hcstat2_path}", + "--increment", + "--increment-min=1", + "--increment-max=14", + "?a?a?a?a?a?a?a?a?a?a?a?a?a?a", + ] + cmd.extend(shlex.split(hcatTuning)) + _append_potfile_arg(cmd) + hcatProcess = subprocess.Popen(cmd) + try: + hcatProcess.wait() + except KeyboardInterrupt: + print("Killing PID {0}...".format(str(hcatProcess.pid))) + hcatProcess.kill() + + # Step F: Cleanup temp files + for temp_file in [candidates_path, hcstat2_raw_path]: + try: + if os.path.isfile(temp_file): + os.remove(temp_file) + except Exception: + pass + + # Middle fast Combinator Attack def hcatMiddleCombinator(hcatHashType, hcatHashFile): global hcatProcess @@ -2756,6 +3006,10 @@ def loopback_attack(): return _attacks.loopback_attack(_attack_ctx()) +def markov_attack(): + return _attacks.markov_attack(_attack_ctx()) + + # convert hex words for recycling def convert_hex(working_file): processed_words = [] @@ -2983,6 +3237,7 @@ def get_main_menu_options(): "12": thorough_combinator, "13": bandrel_method, "14": loopback_attack, + "15": markov_attack, "90": download_hashmob_rules, "91": analyze_rules, "92": download_hashmob_wordlists, @@ -3563,6 +3818,7 @@ def main(): print("\t(12) Thorough Combinator Attack") print("\t(13) Bandrel Methodology") print("\t(14) Loopback Attack") + print("\t(15) LLM Markov Attack") print("\n\t(90) Download rules from Hashmob.net") print("\n\t(91) Analyze Hashcat Rules") print("\t(92) Download wordlists from Hashmob.net") diff --git a/tests/test_ui_menu_options.py b/tests/test_ui_menu_options.py index 5dbe771..7563092 100644 --- a/tests/test_ui_menu_options.py +++ b/tests/test_ui_menu_options.py @@ -25,6 +25,7 @@ MENU_OPTION_TEST_CASES = [ ("12", CLI_MODULE._attacks, "thorough_combinator", "thorough"), ("13", CLI_MODULE._attacks, "bandrel_method", "bandrel"), ("14", CLI_MODULE._attacks, "loopback_attack", "loopback"), + ("15", CLI_MODULE._attacks, "markov_attack", "markov"), ("90", CLI_MODULE, "download_hashmob_rules", "hashmob-rules"), ("91", CLI_MODULE, "weakpass_wordlist_menu", "weakpass-menu"), ("92", CLI_MODULE, "download_hashmob_wordlists", "hashmob-wordlists"), From 1af4c74065c0d1226516de724ca06334eea1aedf Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 13 Feb 2026 16:01:44 -0500 Subject: [PATCH 02/18] feat: default Markov LLM wordlist to cracked hashes output When the cracked hashes output file (.out) exists, use it as the default wordlist for the LLM Markov attack instead of the generic markovWordlist config. This makes the attack learn from already-cracked passwords for the current engagement, falling back to config when no cracked output exists. Co-Authored-By: Claude Opus 4.6 --- hate_crack/attacks.py | 10 +- tests/test_pull_ollama_model.py | 905 ++++++++++++++++++++++++++++++++ 2 files changed, 912 insertions(+), 3 deletions(-) create mode 100644 tests/test_pull_ollama_model.py diff --git a/hate_crack/attacks.py b/hate_crack/attacks.py index 74928ac..bea9bc8 100644 --- a/hate_crack/attacks.py +++ b/hate_crack/attacks.py @@ -495,9 +495,13 @@ def markov_attack(ctx: Any) -> None: choice = input("\nSelect generation mode: ").strip() if choice == "1": - default_wl = ctx.markovWordlist - if isinstance(default_wl, list): - default_wl = default_wl[0] if default_wl else "" + cracked_output = ctx.hcatHashFile + ".out" + if os.path.isfile(cracked_output): + default_wl = cracked_output + else: + default_wl = ctx.markovWordlist + if isinstance(default_wl, list): + default_wl = default_wl[0] if default_wl else "" print(f"\nDefault wordlist: {default_wl}") override = input("Use a different wordlist? [y/N]: ").strip().lower() if override == "y": diff --git a/tests/test_pull_ollama_model.py b/tests/test_pull_ollama_model.py new file mode 100644 index 0000000..4c7e04b --- /dev/null +++ b/tests/test_pull_ollama_model.py @@ -0,0 +1,905 @@ +"""Unit tests for _pull_ollama_model helper, hcatMarkov steps A-F, and markov_attack handler.""" + +import io +import json +import lzma +import os +import urllib.error +import urllib.request +from contextlib import contextmanager +from types import SimpleNamespace +from unittest import mock + +import pytest + +os.environ["HATE_CRACK_SKIP_INIT"] = "1" +from hate_crack import main as hc_main # noqa: E402 + + +# --------------------------------------------------------------------------- +# Shared test infrastructure +# --------------------------------------------------------------------------- + +OLLAMA_URL = "http://localhost:11434" +MODEL = "llama3.2" + + +@pytest.fixture +def markov_env(tmp_path): + """Create the filesystem layout hcatMarkov expects.""" + hash_file = tmp_path / "hashes.txt" + hash_file.touch() + + hcutil_bin = tmp_path / "hashcat-utils" / "bin" + hcutil_bin.mkdir(parents=True) + hcstat2gen = hcutil_bin / hc_main.hcatHcstat2genBin + hcstat2gen.touch() + + wordlist = tmp_path / "sample.txt" + wordlist.write_text("password\n123456\nletmein\n") + + return SimpleNamespace( + tmp_path=tmp_path, + hash_file=str(hash_file), + hcstat2gen=str(hcstat2gen), + wordlist=str(wordlist), + ) + + +@contextmanager +def markov_globals(tmp_path, tuning="", potfile=""): + """Patch the hc_main globals that hcatMarkov reads.""" + with mock.patch.object(hc_main, "ollamaUrl", OLLAMA_URL), \ + mock.patch.object(hc_main, "ollamaModel", MODEL), \ + mock.patch.object(hc_main, "hcatBin", "/usr/bin/hashcat"), \ + mock.patch.object(hc_main, "hcatTuning", tuning), \ + mock.patch.object(hc_main, "hcatPotfilePath", potfile), \ + mock.patch.object(hc_main, "hate_path", str(tmp_path)): + yield + + +def _make_proc(wait_return=0): + """Create a mock subprocess that works with both wait() and communicate().""" + proc = mock.MagicMock() + proc.wait.return_value = wait_return + proc.communicate.return_value = (b"", b"") + proc.returncode = wait_return + return proc + + +def _generate_response(passwords): + """Build a fake urlopen context-manager that returns an Ollama /api/generate JSON body.""" + body = json.dumps({"response": "\n".join(passwords)}).encode() + resp = mock.MagicMock() + resp.__enter__ = mock.Mock(return_value=io.BytesIO(body)) + resp.__exit__ = mock.Mock(return_value=False) + return resp + + +def _urlopen_with_response(passwords): + """Return a urlopen mock that always succeeds with the given passwords.""" + return mock.patch( + "hate_crack.main.urllib.request.urlopen", + return_value=_generate_response(passwords), + ) + + +# --------------------------------------------------------------------------- +# _pull_ollama_model tests +# --------------------------------------------------------------------------- + +class TestPullOllamaModel: + """Tests for _pull_ollama_model().""" + + OLLAMA_URL = "http://localhost:11434" + MODEL = "llama3.2" + + def _make_stream_response(self, statuses): + """Build a fake streaming response (newline-delimited JSON).""" + lines = [json.dumps({"status": s}).encode() + b"\n" for s in statuses] + return io.BytesIO(b"".join(lines)) + + @mock.patch("hate_crack.main.urllib.request.urlopen") + def test_successful_pull(self, mock_urlopen, capsys): + mock_urlopen.return_value.__enter__ = mock.Mock( + return_value=self._make_stream_response( + ["pulling manifest", "downloading sha256:abc123", "success"] + ) + ) + mock_urlopen.return_value.__exit__ = mock.Mock(return_value=False) + + result = hc_main._pull_ollama_model(self.OLLAMA_URL, self.MODEL) + + assert result is True + captured = capsys.readouterr() + assert "not found locally" in captured.out + assert "Successfully pulled" in captured.out + assert "pulling manifest" in captured.out + + @mock.patch("hate_crack.main.urllib.request.urlopen") + def test_pull_http_error(self, mock_urlopen, capsys): + mock_urlopen.side_effect = urllib.error.HTTPError( + url="http://localhost:11434/api/pull", + code=500, + msg="Internal Server Error", + hdrs=None, + fp=None, + ) + + result = hc_main._pull_ollama_model(self.OLLAMA_URL, self.MODEL) + + assert result is False + captured = capsys.readouterr() + assert "HTTP 500" in captured.out + + @mock.patch("hate_crack.main.urllib.request.urlopen") + def test_pull_url_error(self, mock_urlopen, capsys): + mock_urlopen.side_effect = urllib.error.URLError("Connection refused") + + result = hc_main._pull_ollama_model(self.OLLAMA_URL, self.MODEL) + + assert result is False + captured = capsys.readouterr() + assert "Could not connect" in captured.out + + @mock.patch("hate_crack.main.urllib.request.urlopen") + def test_pull_generic_exception(self, mock_urlopen, capsys): + mock_urlopen.side_effect = RuntimeError("unexpected") + + result = hc_main._pull_ollama_model(self.OLLAMA_URL, self.MODEL) + + assert result is False + captured = capsys.readouterr() + assert "unexpected" in captured.out + + @mock.patch("hate_crack.main.urllib.request.urlopen") + def test_pull_handles_empty_status_lines(self, mock_urlopen, capsys): + """Blank lines and missing status keys should not crash.""" + lines = b'{"status": "downloading"}\n\n{"other": "data"}\n' + mock_urlopen.return_value.__enter__ = mock.Mock( + return_value=io.BytesIO(lines) + ) + mock_urlopen.return_value.__exit__ = mock.Mock(return_value=False) + + result = hc_main._pull_ollama_model(self.OLLAMA_URL, self.MODEL) + + assert result is True + + @mock.patch("hate_crack.main.urllib.request.urlopen") + def test_pull_request_payload(self, mock_urlopen): + """Verify the pull request sends the correct model name and stream=True.""" + mock_urlopen.return_value.__enter__ = mock.Mock( + return_value=self._make_stream_response(["success"]) + ) + mock_urlopen.return_value.__exit__ = mock.Mock(return_value=False) + + hc_main._pull_ollama_model(self.OLLAMA_URL, self.MODEL) + + call_args = mock_urlopen.call_args + req = call_args[0][0] + body = json.loads(req.data.decode("utf-8")) + assert body["name"] == self.MODEL + assert body["stream"] is True + assert req.full_url == f"{self.OLLAMA_URL}/api/pull" + + +# --------------------------------------------------------------------------- +# hcatMarkov 404-retry integration tests +# --------------------------------------------------------------------------- + +class TestHcatMarkov404Retry: + """Test that hcatMarkov auto-pulls on 404 and retries the generate call.""" + + OLLAMA_URL = "http://localhost:11434" + MODEL = "llama3.2" + + def _generate_response(self, passwords): + body = json.dumps({"response": "\n".join(passwords)}).encode() + return io.BytesIO(body) + + def _setup_env(self, tmp_path): + """Create the files/dirs hcatMarkov needs before it hits the API.""" + hash_file = str(tmp_path / "hashes.txt") + open(hash_file, "w").close() + + # hcatMarkov checks for hcstat2gen binary at startup + hcutil_bin = tmp_path / "hashcat-utils" / "bin" + hcutil_bin.mkdir(parents=True, exist_ok=True) + hcstat2gen = hcutil_bin / hc_main.hcatHcstat2genBin + hcstat2gen.touch() + + # Create a small wordlist for "wordlist" mode + wordlist = str(tmp_path / "sample.txt") + with open(wordlist, "w") as f: + f.write("password\n123456\nletmein\n") + + return hash_file, wordlist + + @mock.patch("hate_crack.main._pull_ollama_model") + @mock.patch("hate_crack.main.urllib.request.urlopen") + def test_404_triggers_pull_then_retries(self, mock_urlopen, mock_pull, capsys, tmp_path): + """A 404 on generate should trigger a pull, then retry successfully.""" + hash_file, wordlist = self._setup_env(tmp_path) + + # First call: 404, second call: success + generate_ok = mock.MagicMock() + generate_ok.__enter__ = mock.Mock( + return_value=self._generate_response(["Password1", "Summer2024"]) + ) + generate_ok.__exit__ = mock.Mock(return_value=False) + + mock_urlopen.side_effect = [ + urllib.error.HTTPError( + url=f"{self.OLLAMA_URL}/api/generate", + code=404, + msg="Not Found", + hdrs=None, + fp=None, + ), + generate_ok, + ] + mock_pull.return_value = True + + with mock.patch.object(hc_main, "ollamaUrl", self.OLLAMA_URL), \ + mock.patch.object(hc_main, "ollamaModel", self.MODEL), \ + mock.patch.object(hc_main, "hcatBin", "/usr/bin/hashcat"), \ + mock.patch.object(hc_main, "hcatTuning", ""), \ + mock.patch.object(hc_main, "hate_path", str(tmp_path)), \ + mock.patch("subprocess.Popen") as mock_popen: + + proc = _make_proc() + mock_popen.return_value = proc + + hc_main.hcatMarkov("0", hash_file, "wordlist", wordlist, 10) + + mock_pull.assert_called_once_with(self.OLLAMA_URL, self.MODEL) + # urlopen called twice: first 404, then retry + assert mock_urlopen.call_count == 2 + + @mock.patch("hate_crack.main._pull_ollama_model") + @mock.patch("hate_crack.main.urllib.request.urlopen") + def test_404_pull_fails_aborts(self, mock_urlopen, mock_pull, capsys, tmp_path): + """If pull fails after 404, hcatMarkov should abort gracefully.""" + hash_file, wordlist = self._setup_env(tmp_path) + + mock_urlopen.side_effect = urllib.error.HTTPError( + url=f"{self.OLLAMA_URL}/api/generate", + code=404, + msg="Not Found", + hdrs=None, + fp=None, + ) + mock_pull.return_value = False + + with mock.patch.object(hc_main, "ollamaUrl", self.OLLAMA_URL), \ + mock.patch.object(hc_main, "ollamaModel", self.MODEL), \ + mock.patch.object(hc_main, "hate_path", str(tmp_path)): + + hc_main.hcatMarkov("0", hash_file, "wordlist", wordlist, 10) + + captured = capsys.readouterr() + assert "Could not pull model" in captured.out + mock_pull.assert_called_once() + + @mock.patch("hate_crack.main.urllib.request.urlopen") + def test_non_404_http_error_propagates(self, mock_urlopen, capsys, tmp_path): + """Non-404 HTTP errors should not trigger a pull attempt.""" + hash_file, wordlist = self._setup_env(tmp_path) + + mock_urlopen.side_effect = urllib.error.HTTPError( + url=f"{self.OLLAMA_URL}/api/generate", + code=500, + msg="Internal Server Error", + hdrs=None, + fp=None, + ) + + with mock.patch.object(hc_main, "ollamaUrl", self.OLLAMA_URL), \ + mock.patch.object(hc_main, "ollamaModel", self.MODEL), \ + mock.patch.object(hc_main, "hate_path", str(tmp_path)): + + hc_main.hcatMarkov("0", hash_file, "wordlist", wordlist, 10) + + captured = capsys.readouterr() + # HTTPError is a subclass of URLError, so it hits the URLError handler + assert "Could not connect to Ollama" in captured.out or "Error calling Ollama API" in captured.out + assert "500" in captured.out + + +# --------------------------------------------------------------------------- +# Step A: Mode routing, prompt construction, early-return error paths +# --------------------------------------------------------------------------- + +class TestHcatMarkovModeRouting: + """Test mode selection, prompt building, and early-return errors.""" + + def test_unknown_mode_prints_error(self, markov_env, capsys): + """Bad mode string → error message, no API call.""" + with markov_globals(markov_env.tmp_path), \ + mock.patch("hate_crack.main.urllib.request.urlopen") as mock_url: + hc_main.hcatMarkov("0", markov_env.hash_file, "bogus", "", 10) + + captured = capsys.readouterr() + assert "Unknown Markov generation mode" in captured.out + mock_url.assert_not_called() + + def test_missing_wordlist_prints_error(self, markov_env, capsys): + """Non-existent wordlist path → error, no API call.""" + with markov_globals(markov_env.tmp_path), \ + mock.patch("hate_crack.main.urllib.request.urlopen") as mock_url: + hc_main.hcatMarkov( + "0", markov_env.hash_file, "wordlist", + "/no/such/wordlist.txt", 10, + ) + + captured = capsys.readouterr() + assert "Wordlist not found" in captured.out + mock_url.assert_not_called() + + def test_missing_hcstat2gen_prints_error(self, tmp_path, capsys): + """No hcstat2gen binary → error, no API call.""" + hash_file = tmp_path / "hashes.txt" + hash_file.touch() + wordlist = tmp_path / "sample.txt" + wordlist.write_text("password\n") + + with markov_globals(tmp_path), \ + mock.patch("hate_crack.main.urllib.request.urlopen") as mock_url: + hc_main.hcatMarkov("0", str(hash_file), "wordlist", str(wordlist), 10) + + captured = capsys.readouterr() + assert "hcstat2gen not found" in captured.out + mock_url.assert_not_called() + + def test_wordlist_mode_reads_up_to_500_lines(self, markov_env, capsys): + """Only the first 500 non-blank lines should appear in the prompt payload.""" + # Write 600 lines to the wordlist + big_wordlist = markov_env.tmp_path / "big.txt" + big_wordlist.write_text("\n".join(f"pass{i}" for i in range(600)) + "\n") + + captured_payload = {} + + def fake_urlopen(req, **kwargs): + captured_payload["data"] = json.loads(req.data.decode()) + return _generate_response(["Password1"]) + + with markov_globals(markov_env.tmp_path), \ + mock.patch("hate_crack.main.urllib.request.urlopen", side_effect=fake_urlopen), \ + mock.patch("subprocess.Popen") as mock_popen: + proc = _make_proc() + mock_popen.return_value = proc + hc_main.hcatMarkov( + "0", markov_env.hash_file, "wordlist", str(big_wordlist), 10, + ) + + prompt_text = captured_payload["data"]["prompt"] + # Should contain pass0 through pass499 but NOT pass500+ + assert "pass499" in prompt_text + assert "pass500" not in prompt_text + + def test_target_mode_includes_context_in_prompt(self, markov_env, capsys): + """Company, industry, and location should appear in the prompt.""" + captured_payload = {} + + def fake_urlopen(req, **kwargs): + captured_payload["data"] = json.loads(req.data.decode()) + return _generate_response(["AcmeCorp2024"]) + + target_info = { + "company": "AcmeCorp", + "industry": "Finance", + "location": "New York", + } + with markov_globals(markov_env.tmp_path), \ + mock.patch("hate_crack.main.urllib.request.urlopen", side_effect=fake_urlopen), \ + mock.patch("subprocess.Popen") as mock_popen: + proc = _make_proc() + mock_popen.return_value = proc + hc_main.hcatMarkov( + "0", markov_env.hash_file, "target", target_info, 10, + ) + + prompt_text = captured_payload["data"]["prompt"] + assert "AcmeCorp" in prompt_text + assert "Finance" in prompt_text + assert "New York" in prompt_text + + +# --------------------------------------------------------------------------- +# Step B: Candidate filtering / post-processing +# --------------------------------------------------------------------------- + +class TestHcatMarkovCandidateFiltering: + """Test regex stripping, blank-line removal, 128-char limit, empty-result handling.""" + + def _run_with_response(self, markov_env, response_text): + """Run hcatMarkov with a canned Ollama response_text, return candidates file content.""" + body = json.dumps({"response": response_text}).encode() + resp = mock.MagicMock() + resp.__enter__ = mock.Mock(return_value=io.BytesIO(body)) + resp.__exit__ = mock.Mock(return_value=False) + + with markov_globals(markov_env.tmp_path), \ + mock.patch("hate_crack.main.urllib.request.urlopen", return_value=resp), \ + mock.patch("subprocess.Popen") as mock_popen: + proc = _make_proc() + mock_popen.return_value = proc + hc_main.hcatMarkov( + "0", markov_env.hash_file, "wordlist", markov_env.wordlist, 10, + ) + + candidates_path = f"{markov_env.hash_file}.markov_candidates" + if os.path.isfile(candidates_path): + with open(candidates_path) as f: + return f.read() + return None + + def test_strips_numeric_dot_prefix(self, markov_env): + content = self._run_with_response(markov_env, "1. Password1\n2. Summer2024") + assert content is not None + lines = [l for l in content.strip().split("\n") if l] + assert "Password1" in lines + assert "Summer2024" in lines + # No line should start with a digit+dot + for line in lines: + assert not line.startswith("1.") + assert not line.startswith("2.") + + def test_strips_dash_and_asterisk_prefix(self, markov_env): + content = self._run_with_response(markov_env, "- foo\n* bar") + assert content is not None + lines = [l for l in content.strip().split("\n") if l] + assert "foo" in lines + assert "bar" in lines + + def test_skips_blank_lines(self, markov_env): + content = self._run_with_response(markov_env, "alpha\n\n\nbeta\n\n") + assert content is not None + lines = [l for l in content.strip().split("\n") if l] + assert lines == ["alpha", "beta"] + + def test_rejects_over_128_chars(self, markov_env): + long_pw = "A" * 129 + content = self._run_with_response(markov_env, f"short\n{long_pw}\nkeep") + assert content is not None + lines = [l for l in content.strip().split("\n") if l] + assert "short" in lines + assert "keep" in lines + assert long_pw not in lines + + def test_accepts_exactly_128_chars(self, markov_env): + exact_pw = "B" * 128 + content = self._run_with_response(markov_env, f"{exact_pw}\nother") + assert content is not None + lines = [l for l in content.strip().split("\n") if l] + assert exact_pw in lines + + def test_empty_response_prints_error(self, markov_env, capsys): + self._run_with_response(markov_env, "") + captured = capsys.readouterr() + assert "no usable" in captured.out.lower() + + def test_missing_response_key(self, markov_env, capsys): + """If the JSON has no 'response' key, treat as empty.""" + body = json.dumps({"other": "stuff"}).encode() + resp = mock.MagicMock() + resp.__enter__ = mock.Mock(return_value=io.BytesIO(body)) + resp.__exit__ = mock.Mock(return_value=False) + + with markov_globals(markov_env.tmp_path), \ + mock.patch("hate_crack.main.urllib.request.urlopen", return_value=resp): + hc_main.hcatMarkov( + "0", markov_env.hash_file, "wordlist", markov_env.wordlist, 10, + ) + + captured = capsys.readouterr() + assert "no usable" in captured.out.lower() + + +# --------------------------------------------------------------------------- +# Step B: API error paths (non-404) +# --------------------------------------------------------------------------- + +class TestHcatMarkovApiErrors: + """Test connection errors and generic exceptions during the generate call.""" + + def test_url_error_prints_connection_error(self, markov_env, capsys): + with markov_globals(markov_env.tmp_path), \ + mock.patch( + "hate_crack.main.urllib.request.urlopen", + side_effect=urllib.error.URLError("Connection refused"), + ): + hc_main.hcatMarkov( + "0", markov_env.hash_file, "wordlist", markov_env.wordlist, 10, + ) + + captured = capsys.readouterr() + assert "Could not connect" in captured.out + assert "Ensure Ollama is running" in captured.out + + def test_generic_exception_prints_error(self, markov_env, capsys): + with markov_globals(markov_env.tmp_path), \ + mock.patch( + "hate_crack.main.urllib.request.urlopen", + side_effect=RuntimeError("boom"), + ): + hc_main.hcatMarkov( + "0", markov_env.hash_file, "wordlist", markov_env.wordlist, 10, + ) + + captured = capsys.readouterr() + assert "Error calling Ollama API" in captured.out + + def test_generate_request_payload(self, markov_env): + """Verify the /api/generate request has correct URL, model, stream=false.""" + captured_req = {} + + def fake_urlopen(req, **kwargs): + captured_req["url"] = req.full_url + captured_req["body"] = json.loads(req.data.decode()) + return _generate_response(["Password1"]) + + with markov_globals(markov_env.tmp_path), \ + mock.patch("hate_crack.main.urllib.request.urlopen", side_effect=fake_urlopen), \ + mock.patch("subprocess.Popen") as mock_popen: + proc = _make_proc() + mock_popen.return_value = proc + hc_main.hcatMarkov( + "0", markov_env.hash_file, "wordlist", markov_env.wordlist, 10, + ) + + assert captured_req["url"] == f"{OLLAMA_URL}/api/generate" + assert captured_req["body"]["model"] == MODEL + assert captured_req["body"]["stream"] is False + assert len(captured_req["body"]["prompt"]) > 0 + + +# --------------------------------------------------------------------------- +# Steps C + D: hcstat2gen execution and LZMA compression +# --------------------------------------------------------------------------- + +class TestHcatMarkovHcstat2gen: + """Test hcstat2gen subprocess step.""" + + def test_hcstat2gen_correct_args(self, markov_env): + """hcstat2gen should be called with [binary_path, raw_output_path] and stdin=file.""" + hcstat2_raw_path = f"{markov_env.hash_file}.hcstat2_raw" + hcstat2gen_path = markov_env.hcstat2gen + + call_args_list = [] + + def track_popen(cmd, **kwargs): + call_args_list.append((list(cmd), dict(kwargs))) + proc = _make_proc() + # Create raw hcstat2 file so the LZMA compression step continues + if cmd[0] == hcstat2gen_path: + with open(hcstat2_raw_path, "wb") as f: + f.write(b"\x00" * 100) + return proc + + with markov_globals(markov_env.tmp_path), \ + _urlopen_with_response(["Password1"]), \ + mock.patch("subprocess.Popen", side_effect=track_popen), \ + mock.patch("hate_crack.main.generate_session_id", return_value="test_session"): + hc_main.hcatMarkov( + "0", markov_env.hash_file, "wordlist", markov_env.wordlist, 10, + ) + + # First Popen call should be hcstat2gen writing to the raw path + first_cmd, first_kwargs = call_args_list[0] + assert first_cmd[0] == hcstat2gen_path + assert first_cmd[1] == hcstat2_raw_path + assert "stdin" in first_kwargs + + def test_hcstat2gen_keyboard_interrupt(self, markov_env, capsys): + """KeyboardInterrupt during hcstat2gen should kill the process and return.""" + proc = mock.MagicMock() + proc.communicate.side_effect = KeyboardInterrupt() + proc.pid = 12345 + + with markov_globals(markov_env.tmp_path), \ + _urlopen_with_response(["Password1"]), \ + mock.patch("subprocess.Popen", return_value=proc): + hc_main.hcatMarkov( + "0", markov_env.hash_file, "wordlist", markov_env.wordlist, 10, + ) + + captured = capsys.readouterr() + assert "Killing PID" in captured.out + proc.kill.assert_called_once() + + def test_hcstat2gen_no_output_file(self, markov_env, capsys): + """If hcstat2gen doesn't produce a raw output file, abort with error.""" + proc = mock.MagicMock() + proc.communicate.return_value = (b"", b"") + proc.returncode = 0 + # Don't create hcstat2_raw_path → triggers the "did not produce" error + + with markov_globals(markov_env.tmp_path), \ + _urlopen_with_response(["Password1"]), \ + mock.patch("subprocess.Popen", return_value=proc): + hc_main.hcatMarkov( + "0", markov_env.hash_file, "wordlist", markov_env.wordlist, 10, + ) + + captured = capsys.readouterr() + assert "did not produce output file" in captured.out + + def test_lzma_compression_error(self, markov_env, capsys): + """LZMAError during compression → error message, no hashcat run.""" + hcstat2_raw_path = f"{markov_env.hash_file}.hcstat2_raw" + + def track_popen(cmd, **kwargs): + proc = _make_proc() + if cmd[0] == markov_env.hcstat2gen: + with open(hcstat2_raw_path, "wb") as f: + f.write(b"\x00" * 100) + return proc + + with markov_globals(markov_env.tmp_path), \ + _urlopen_with_response(["Password1"]), \ + mock.patch("subprocess.Popen", side_effect=track_popen) as mock_popen, \ + mock.patch("hate_crack.main.lzma.compress", side_effect=lzma.LZMAError("bad data")): + hc_main.hcatMarkov( + "0", markov_env.hash_file, "wordlist", markov_env.wordlist, 10, + ) + + captured = capsys.readouterr() + assert "Error compressing" in captured.out + # Should only have 1 Popen call (hcstat2gen), NOT a second for hashcat + assert mock_popen.call_count == 1 + + + +# --------------------------------------------------------------------------- +# Step E: Hashcat command construction +# --------------------------------------------------------------------------- + +class TestHcatMarkovHashcatCommand: + """Test hashcat command flags and process handling.""" + + def _run_full(self, markov_env, tuning="", potfile=""): + """Run hcatMarkov through all steps, returning the list of Popen calls.""" + hcstat2_raw_path = f"{markov_env.hash_file}.hcstat2_raw" + popen_calls = [] + + def track_popen(cmd, **kwargs): + popen_calls.append((list(cmd), dict(kwargs))) + proc = _make_proc() + # hcstat2gen: create the raw output file (LZMA2 compression step will follow) + if cmd[0] == markov_env.hcstat2gen: + with open(hcstat2_raw_path, "wb") as f: + f.write(b"\x00" * 100) + return proc + + with markov_globals(markov_env.tmp_path, tuning=tuning, potfile=potfile), \ + _urlopen_with_response(["Password1"]), \ + mock.patch("subprocess.Popen", side_effect=track_popen), \ + mock.patch("hate_crack.main.generate_session_id", return_value="test_session"): + hc_main.hcatMarkov( + "1000", markov_env.hash_file, "wordlist", markov_env.wordlist, 10, + ) + + return popen_calls + + def test_hashcat_command_structure(self, markov_env): + """Hashcat cmd should include -m, -a 3, --markov-hcstat2, --increment, mask.""" + calls = self._run_full(markov_env) + assert len(calls) >= 2, "Expected at least hcstat2gen + hashcat Popen calls" + + hashcat_cmd = calls[1][0] + assert hashcat_cmd[0] == "/usr/bin/hashcat" + assert "-m" in hashcat_cmd + m_idx = hashcat_cmd.index("-m") + assert hashcat_cmd[m_idx + 1] == "1000" + assert "-a" in hashcat_cmd + a_idx = hashcat_cmd.index("-a") + assert hashcat_cmd[a_idx + 1] == "3" + # Check markov hcstat2 flag + hcstat2_flags = [f for f in hashcat_cmd if "--markov-hcstat2=" in f] + assert len(hcstat2_flags) == 1 + assert hcstat2_flags[0].endswith(".hcstat2") + assert "--increment" in hashcat_cmd + # Mask should be the last positional arg + assert "?a?a?a?a?a?a?a?a?a?a?a?a?a?a" in hashcat_cmd + + def test_hashcat_includes_tuning(self, markov_env): + """-w 3 tuning flag should be appended to hashcat cmd.""" + calls = self._run_full(markov_env, tuning="-w 3") + hashcat_cmd = calls[1][0] + assert "-w" in hashcat_cmd + w_idx = hashcat_cmd.index("-w") + assert hashcat_cmd[w_idx + 1] == "3" + + def test_hashcat_includes_potfile(self, markov_env): + """--potfile-path should be present when hcatPotfilePath is set.""" + calls = self._run_full(markov_env, potfile="/tmp/test.potfile") + hashcat_cmd = calls[1][0] + potfile_flags = [f for f in hashcat_cmd if "--potfile-path=" in f] + assert len(potfile_flags) == 1 + assert potfile_flags[0] == "--potfile-path=/tmp/test.potfile" + + def test_hashcat_keyboard_interrupt(self, markov_env, capsys): + """KeyboardInterrupt during hashcat should kill the process.""" + hcstat2_raw_path = f"{markov_env.hash_file}.hcstat2_raw" + call_count = [0] + + def track_popen(cmd, **kwargs): + call_count[0] += 1 + if call_count[0] == 1: + # hcstat2gen: succeeds and creates raw file + proc = _make_proc() + proc.pid = 99999 + with open(hcstat2_raw_path, "wb") as f: + f.write(b"\x00" * 100) + else: + # hashcat: KeyboardInterrupt + proc = mock.MagicMock() + proc.pid = 99999 + proc.wait.side_effect = KeyboardInterrupt() + return proc + + with markov_globals(markov_env.tmp_path), \ + _urlopen_with_response(["Password1"]), \ + mock.patch("subprocess.Popen", side_effect=track_popen), \ + mock.patch("hate_crack.main.generate_session_id", return_value="test_session"): + hc_main.hcatMarkov( + "0", markov_env.hash_file, "wordlist", markov_env.wordlist, 10, + ) + + captured = capsys.readouterr() + assert "Killing PID" in captured.out + + +# --------------------------------------------------------------------------- +# Step F: Cleanup +# --------------------------------------------------------------------------- + +class TestHcatMarkovCleanup: + """Test that temp files are removed after a successful run.""" + + def _run_full(self, markov_env): + """Run hcatMarkov through all steps successfully.""" + hcstat2_raw_path = f"{markov_env.hash_file}.hcstat2_raw" + + def track_popen(cmd, **kwargs): + proc = _make_proc() + if cmd[0] == markov_env.hcstat2gen: + with open(hcstat2_raw_path, "wb") as f: + f.write(b"\x00" * 100) + return proc + + with markov_globals(markov_env.tmp_path), \ + _urlopen_with_response(["Password1"]), \ + mock.patch("subprocess.Popen", side_effect=track_popen), \ + mock.patch("hate_crack.main.generate_session_id", return_value="test_session"): + hc_main.hcatMarkov( + "0", markov_env.hash_file, "wordlist", markov_env.wordlist, 10, + ) + + def test_temp_files_removed(self, markov_env): + """markov_candidates and hcstat2_raw should be deleted; hcstat2 should remain.""" + self._run_full(markov_env) + + candidates_path = f"{markov_env.hash_file}.markov_candidates" + hcstat2_raw_path = f"{markov_env.hash_file}.hcstat2_raw" + hcstat2_path = f"{markov_env.hash_file}.hcstat2" + + assert not os.path.exists(candidates_path), "candidates temp file should be removed" + assert not os.path.exists(hcstat2_raw_path), "raw hcstat2 temp file should be removed" + assert os.path.isfile(hcstat2_path), "compressed hcstat2 file should remain" + + def test_cleanup_ignores_missing_files(self, markov_env): + """No exception if temp files are already gone before cleanup.""" + # This just verifies the run completes without error even though + # we don't interfere with normal cleanup + self._run_full(markov_env) # should not raise + + +# --------------------------------------------------------------------------- +# attacks.py: markov_attack() UI handler +# --------------------------------------------------------------------------- + +class TestMarkovAttackHandler: + """Test the markov_attack(ctx) menu handler in attacks.py.""" + + def _make_ctx(self, **overrides): + """Build a mock ctx with the attributes markov_attack() reads.""" + ctx = mock.MagicMock() + ctx.hcatHashType = "0" + ctx.hcatHashFile = "/tmp/hashes.txt" + ctx.markovWordlist = "/tmp/wordlist.txt" + ctx.markovCandidateCount = 5000 + ctx.hcatWordlists = "/tmp/wordlists" + for k, v in overrides.items(): + setattr(ctx, k, v) + return ctx + + def test_wordlist_mode_default(self, tmp_path): + """When cracked output exists, it becomes the default wordlist.""" + from hate_crack.attacks import markov_attack + + hash_file = str(tmp_path / "hashes.txt") + cracked_out = hash_file + ".out" + with open(cracked_out, "w") as f: + f.write("Password1\nSummer2024\n") + + ctx = self._make_ctx(hcatHashFile=hash_file) + with mock.patch("builtins.input", side_effect=["1", "n", ""]): + markov_attack(ctx) + + ctx.hcatMarkov.assert_called_once_with( + "0", hash_file, "wordlist", cracked_out, 5000, + ) + + def test_wordlist_mode_falls_back_to_config(self): + """When cracked output does not exist, fall back to markovWordlist.""" + from hate_crack.attacks import markov_attack + + ctx = self._make_ctx(hcatHashFile="/tmp/nonexistent_hashes.txt") + with mock.patch("builtins.input", side_effect=["1", "n", ""]): + markov_attack(ctx) + + ctx.hcatMarkov.assert_called_once_with( + "0", "/tmp/nonexistent_hashes.txt", "wordlist", "/tmp/wordlist.txt", 5000, + ) + + def test_wordlist_mode_custom_path(self): + """Selection '1', override wordlist → resolved path passed to hcatMarkov.""" + from hate_crack.attacks import markov_attack + + ctx = self._make_ctx() + ctx.select_file_with_autocomplete.return_value = "custom.txt" + ctx._resolve_wordlist_path.return_value = "/resolved/custom.txt" + + with mock.patch("builtins.input", side_effect=["1", "y", ""]): + markov_attack(ctx) + + ctx.select_file_with_autocomplete.assert_called_once() + ctx._resolve_wordlist_path.assert_called_once_with("custom.txt", "/tmp/wordlists") + ctx.hcatMarkov.assert_called_once_with( + "0", "/tmp/hashes.txt", "wordlist", "/resolved/custom.txt", 5000, + ) + + def test_target_mode(self): + """Selection '2' → hcatMarkov('target', {company, industry, location}).""" + from hate_crack.attacks import markov_attack + + ctx = self._make_ctx() + with mock.patch("builtins.input", side_effect=["2", "AcmeCorp", "Finance", "NYC", ""]): + markov_attack(ctx) + + ctx.hcatMarkov.assert_called_once() + call_args = ctx.hcatMarkov.call_args + assert call_args[0][2] == "target" + target_info = call_args[0][3] + assert target_info["company"] == "AcmeCorp" + assert target_info["industry"] == "Finance" + assert target_info["location"] == "NYC" + + def test_invalid_selection(self, capsys): + """Selection '3' → 'Invalid selection.', no hcatMarkov call.""" + from hate_crack.attacks import markov_attack + + ctx = self._make_ctx() + with mock.patch("builtins.input", side_effect=["3"]): + markov_attack(ctx) + + captured = capsys.readouterr() + assert "Invalid selection" in captured.out + ctx.hcatMarkov.assert_not_called() + + def test_wordlist_list_uses_first(self): + """When markovWordlist is a list and no cracked output exists, the first element is used.""" + from hate_crack.attacks import markov_attack + + ctx = self._make_ctx( + hcatHashFile="/tmp/nonexistent_hashes.txt", + markovWordlist=["/tmp/first.txt", "/tmp/second.txt"], + ) + with mock.patch("builtins.input", side_effect=["1", "n", ""]): + markov_attack(ctx) + + ctx.hcatMarkov.assert_called_once() + call_args = ctx.hcatMarkov.call_args + assert call_args[0][3] == "/tmp/first.txt" From ca1d71da6c035c4a6ff98472f2cce025ddf55208 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 13 Feb 2026 16:52:00 -0500 Subject: [PATCH 03/18] feat: add --debug logging for Ollama request/response in hcatMarkov Log the API URL, request payload, raw response JSON, and filtered candidate counts when debug_mode is active. Co-Authored-By: Claude Opus 4.6 --- hate_crack/main.py | 69 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/hate_crack/main.py b/hate_crack/main.py index ed62f7d..44ccfe4 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -1502,6 +1502,43 @@ def hcatBandrel(hcatHashType, hcatHashFile): hcatProcess.kill() +# Pull an Ollama model via the /api/pull streaming endpoint +def _pull_ollama_model(url, model): + """Pull an Ollama model. Returns True on success, False on failure.""" + print(f"Model '{model}' not found locally. Pulling from Ollama...") + pull_url = f"{url}/api/pull" + payload = json.dumps({"name": model, "stream": True}).encode("utf-8") + req = urllib.request.Request( + pull_url, + data=payload, + headers={"Content-Type": "application/json"}, + ) + try: + with urllib.request.urlopen(req) as resp: + for raw_line in resp: + line = raw_line.decode("utf-8", errors="replace").strip() + if not line: + continue + try: + data = json.loads(line) + except json.JSONDecodeError: + continue + status = data.get("status") + if status: + print(f" {status}") + except urllib.error.HTTPError as e: + print(f"Error pulling model: HTTP {e.code}") + return False + except urllib.error.URLError as e: + print(f"Error: Could not connect to Ollama: {e}") + return False + except Exception as e: + print(f"Error pulling model: {e}") + return False + print(f"Successfully pulled model '{model}'.") + return True + + # LLM Markov Attack def hcatMarkov(hcatHashType, hcatHashFile, mode, context_data, candidate_count): global hcatProcess @@ -1574,6 +1611,10 @@ def hcatMarkov(hcatHashType, hcatHashFile, mode, context_data, candidate_count): "stream": False, }).encode("utf-8") + if debug_mode: + print(f"[DEBUG] Ollama API URL: {api_url}") + print(f"[DEBUG] Ollama request payload: {payload.decode('utf-8')}") + try: req = urllib.request.Request( api_url, @@ -1582,6 +1623,31 @@ def hcatMarkov(hcatHashType, hcatHashFile, mode, context_data, candidate_count): ) with urllib.request.urlopen(req, timeout=600) as resp: result = json.loads(resp.read().decode("utf-8")) + if debug_mode: + print(f"[DEBUG] Ollama response: {json.dumps(result, indent=2)}") + except urllib.error.HTTPError as e: + if e.code == 404: + if _pull_ollama_model(ollamaUrl, ollamaModel): + try: + req = urllib.request.Request( + api_url, + data=payload, + headers={"Content-Type": "application/json"}, + ) + with urllib.request.urlopen(req, timeout=600) as resp: + result = json.loads(resp.read().decode("utf-8")) + if debug_mode: + print(f"[DEBUG] Ollama response (after pull): {json.dumps(result, indent=2)}") + except Exception as retry_err: + print(f"Error calling Ollama API after pull: {retry_err}") + return + else: + print(f"Could not pull model '{ollamaModel}'. Aborting Markov attack.") + return + else: + print(f"Error: Could not connect to Ollama at {ollamaUrl}: {e}") + print("Ensure Ollama is running (ollama serve) and try again.") + return except urllib.error.URLError as e: print(f"Error: Could not connect to Ollama at {ollamaUrl}: {e}") print("Ensure Ollama is running (ollama serve) and try again.") @@ -1618,6 +1684,9 @@ def hcatMarkov(hcatHashType, hcatHashFile, mode, context_data, candidate_count): return print(f"Generated {len(candidates)} password candidates -> {candidates_path}") + if debug_mode: + filtered_count = len(raw_lines) - len(candidates) + print(f"[DEBUG] Filtered out {filtered_count} lines from Ollama response ({len(raw_lines)} raw -> {len(candidates)} candidates)") # Step C: Run hcstat2gen to build Markov stats print("Building Markov statistics with hcstat2gen...") From f9db54e84c208ccd79306e247c5407f68fb150c5 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 13 Feb 2026 19:11:51 -0500 Subject: [PATCH 04/18] fix: overhaul Hashview download flow and fix hashcat --show stderr pollution - Merge download_left and download_found into single "Download Hashes" menu option - Append found hash:clear pairs to potfile instead of running broken hashcat re-crack - Append found hashes to left file so hashcat --show returns full results - Clean up found_ temp files after merge - Split found file on first colon (not last) to handle passwords containing colons - Filter hashcat parse errors from --show stdout in _run_hashcat_show - Add get_hcat_potfile_path() helper to api.py for potfile resolution - Remove obsolete download_found_hashes API method and CLI subcommand - Fix ollama tests to match current 4-arg hcatOllama signature and rule loop Co-Authored-By: Claude Opus 4.6 --- hate_crack/api.py | 320 ++--------- hate_crack/main.py | 383 +++---------- tests/test_hashview.py | 58 +- tests/test_hashview_cli_subcommands.py | 9 - ...est_hashview_cli_subcommands_subprocess.py | 39 +- tests/test_pull_ollama_model.py | 541 +++++++----------- 6 files changed, 346 insertions(+), 1004 deletions(-) diff --git a/hate_crack/api.py b/hate_crack/api.py index d0ee8de..fb9909f 100644 --- a/hate_crack/api.py +++ b/hate_crack/api.py @@ -117,6 +117,29 @@ def get_hcat_tuning_args(): return [] +def get_hcat_potfile_path(): + """Return the resolved potfile path from config, or the default.""" + config_path = _resolve_config_path() + if config_path: + try: + with open(config_path) as f: + config = json.load(f) + raw = (config.get("hcatPotfilePath") or "").strip() + if raw: + return os.path.expanduser(raw) + except Exception: + pass + return os.path.expanduser("~/.hashcat/hashcat.potfile") + + +def get_hcat_potfile_args(): + """Return potfile args list for hashcat, e.g. ['--potfile-path=/path'].""" + pot = get_hcat_potfile_path() + if pot: + return [f"--potfile-path={pot}"] + return [] + + def cleanup_torrent_files(directory=None): """Remove stray .torrent files from the wordlists directory on graceful exit.""" if directory is None: @@ -805,7 +828,6 @@ class HashviewAPI: self, customer_id, hashfile_id, output_file=None, hash_type=None ): import sys - import subprocess url = f"{self.base_url}/v1/hashfiles/{hashfile_id}/left" resp = self.session.get(url, headers=self._auth_headers(), stream=True) @@ -882,7 +904,7 @@ class HashviewAPI: for line in f: line = line.strip() if line: - parts = line.rsplit(":", 1) # Split on last colon + parts = line.split(":", 1) # Split on first colon if len(parts) == 2: hash_part, clear_part = parts hf.write(hash_part + "\n") @@ -894,277 +916,41 @@ class HashviewAPI: f"Split found file into {hashes_count} hashes and {clears_count} clears" ) - # Run hashcat to combine them - combined_file = output_abs + ".out" - try: - # Execute hashcat: hashcat -m hash_type found_hashes found_clears --outfile output.out --outfile-format=1,2 - tuning_args = get_hcat_tuning_args() + # Append found hashes to the left file + with open(output_abs, "a", encoding="utf-8") as lf: + with open(found_hashes_file, "r", encoding="utf-8", errors="ignore") as fhf: + for line in fhf: + line = line.strip() + if line: + lf.write(line + "\n") + print(f"✓ Appended {hashes_count} found hashes to {output_abs}") - # Create temporary outfile for hashcat - temp_outfile = output_abs + ".tmp" - - if self.debug: - print( - f"[DEBUG] download_left_hashes: hash_type={hash_type}, type={type(hash_type)}" - ) - - # Build command with hash type if provided - cmd = ["hashcat", *tuning_args] - if hash_type: - cmd.extend(["-m", str(hash_type)]) - cmd.extend( - [ - found_hashes_file, - found_clears_file, - "--outfile", - temp_outfile, - "--outfile-format=1,2", - ] - ) - - if self.debug: - print(f"[DEBUG] Running command: {' '.join(cmd)}") - - print(f"Running: {' '.join(cmd)}") - - result = subprocess.run( - cmd, - stderr=subprocess.PIPE, - stdout=subprocess.PIPE, - text=True, - timeout=300, - ) - - if result.returncode != 0: - print(f"Warning: hashcat exited with code {result.returncode}") - if result.stderr: - print(f" stderr: {result.stderr}") - - # Append the output to the combined file - if os.path.exists(temp_outfile): - with open( - temp_outfile, "r", encoding="utf-8", errors="ignore" - ) as tmp_f: - with open(combined_file, "a", encoding="utf-8") as out_f: - out_f.write(tmp_f.read()) - - # Count lines appended - with open( - combined_file, "r", encoding="utf-8", errors="ignore" - ) as f: - combined_count = len(f.readlines()) - print( - f"✓ Appended cracked hashes to {combined_file} (total lines: {combined_count})" - ) - - # Clean up temp file - try: - os.remove(temp_outfile) - except Exception: - pass - else: - print("Note: No cracked hashes found") - - except FileNotFoundError: - print("✗ Error: hashcat not found in PATH") - except subprocess.TimeoutExpired: - print("✗ Error: hashcat execution timed out") - except Exception as e: - print(f"✗ Error running hashcat: {e}") - - # Clean up temporary files (keep when debug is enabled) - if not self.debug: - files_to_delete = [found_file, found_hashes_file, found_clears_file] - for temp_file in files_to_delete: - try: - if os.path.exists(temp_file): - os.remove(temp_file) - print(f"Deleted {temp_file}") - except Exception as e: - print(f"Warning: Could not delete {temp_file}: {e}") + # Append found hash:clear pairs to the potfile + potfile_path = get_hcat_potfile_path() + if potfile_path: + appended = 0 + with open(potfile_path, "a", encoding="utf-8") as pf: + with open(found_file, "r", encoding="utf-8", errors="ignore") as ff: + for line in ff: + line = line.strip() + if line and ":" in line: + pf.write(line + "\n") + appended += 1 + combined_count = appended + print(f"✓ Appended {appended} found hashes to potfile: {potfile_path}") else: - print("Debug enabled: keeping found and split files") + print("Warning: No potfile path configured, skipping potfile update") + + # Clean up the two found_ files + for f_path in (found_file, found_hashes_file, found_clears_file): + try: + os.remove(f_path) + except OSError: + pass except Exception as e: # If there's any error downloading found file, just skip it print(f"Note: Could not download found hashes: {e}") - # Clean up found file if it was partially written - if not self.debug: - try: - if os.path.exists(found_file): - os.remove(found_file) - except Exception: - pass - - return { - "output_file": output_file, - "size": downloaded, - "combined_count": combined_count, - "combined_file": combined_file, - } - - def download_found_hashes( - self, customer_id, hashfile_id, output_file=None, hash_type=None - ): - import sys - import subprocess - - url = f"{self.base_url}/v1/hashfiles/{hashfile_id}/found" - resp = self.session.get(url, headers=self._auth_headers(), stream=True) - resp.raise_for_status() - if output_file is None: - output_file = f"found_{customer_id}_{hashfile_id}.txt" - - # Convert to absolute path to ensure file is preserved if CWD changes - output_file = os.path.abspath(output_file) - - total = int(resp.headers.get("content-length", 0)) - downloaded = 0 - chunk_size = 8192 - with open(output_file, "wb") as f: - for chunk in resp.iter_content(chunk_size=chunk_size): - if chunk: - f.write(chunk) - downloaded += len(chunk) - if total > 0: - done = int(50 * downloaded / total) - bar = "[" + "=" * done + " " * (50 - done) + "]" - percent = 100 * downloaded / total - sys.stdout.write( - f"\rDownloading: {bar} {percent:5.1f}% ({downloaded}/{total} bytes)" - ) - sys.stdout.flush() - if total > 0: - sys.stdout.write("\n") - # If content-length is not provided, just print size at end - if total == 0: - print(f"Downloaded {downloaded} bytes.") - - # Split found file into hashes and clears - output_dir = os.path.dirname(os.path.abspath(output_file)) or os.getcwd() - found_hashes_file = os.path.join( - output_dir, f"found_hashes_{customer_id}_{hashfile_id}.txt" - ) - found_clears_file = os.path.join( - output_dir, f"found_clears_{customer_id}_{hashfile_id}.txt" - ) - - hashes_count = 0 - clears_count = 0 - combined_count = 0 - combined_file = None - - try: - with ( - open(found_hashes_file, "w", encoding="utf-8") as hf, - open(found_clears_file, "w", encoding="utf-8") as cf, - ): - with open(output_file, "r", encoding="utf-8", errors="ignore") as f: - for line in f: - line = line.strip() - if line: - parts = line.rsplit(":", 1) # Split on last colon - if len(parts) == 2: - hash_part, clear_part = parts - hf.write(hash_part + "\n") - cf.write(clear_part + "\n") - hashes_count += 1 - clears_count += 1 - - print( - f"✓ Split found file into {hashes_count} hashes and {clears_count} clears" - ) - - # Run hashcat to combine them into an output file - combined_file = output_file + ".out" - try: - tuning_args = get_hcat_tuning_args() - - # Create temporary outfile for hashcat - temp_outfile = output_file + ".tmp" - - if self.debug: - print( - f"[DEBUG] download_found_hashes: hash_type={hash_type}, type={type(hash_type)}" - ) - - # Build command with hash type if provided - cmd = ["hashcat", *tuning_args] - if hash_type: - cmd.extend(["-m", str(hash_type)]) - cmd.extend( - [ - found_hashes_file, - found_clears_file, - "--outfile", - temp_outfile, - "--outfile-format=1,2", - ] - ) - - if self.debug: - print(f"[DEBUG] Running command: {' '.join(cmd)}") - - print(f"Running: {' '.join(cmd)}") - - result = subprocess.run( - cmd, - stderr=subprocess.PIPE, - stdout=subprocess.PIPE, - text=True, - timeout=300, - ) - - if result.returncode != 0: - print(f"Warning: hashcat exited with code {result.returncode}") - if result.stderr: - print(f" stderr: {result.stderr}") - - # Write the output file - if os.path.exists(temp_outfile): - with open( - temp_outfile, "r", encoding="utf-8", errors="ignore" - ) as tmp_f: - with open(combined_file, "w", encoding="utf-8") as out_f: - out_f.write(tmp_f.read()) - - # Count lines in output - with open( - combined_file, "r", encoding="utf-8", errors="ignore" - ) as f: - combined_count = len(f.readlines()) - print(f"✓ Created {combined_file} (total lines: {combined_count})") - - # Clean up temp file - try: - os.remove(temp_outfile) - except Exception: - pass - else: - print("Note: No cracked hashes generated") - - except FileNotFoundError: - print("✗ Error: hashcat not found in PATH") - except subprocess.TimeoutExpired: - print("✗ Error: hashcat execution timed out") - except Exception as e: - print(f"✗ Error running hashcat: {e}") - - # Clean up temporary files (keep when debug is enabled) - if not self.debug: - files_to_delete = [found_hashes_file, found_clears_file] - for temp_file in files_to_delete: - try: - if os.path.exists(temp_file): - os.remove(temp_file) - except Exception as e: - if self.debug: - print(f"Warning: Could not delete {temp_file}: {e}") - else: - print("Debug enabled: keeping split files") - - except Exception as e: - print(f"✗ Error splitting found file: {e}") return { "output_file": output_file, diff --git a/hate_crack/main.py b/hate_crack/main.py index 44ccfe4..4bfc7da 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -9,7 +9,6 @@ import sys import os import json -import lzma import shutil import logging import binascii @@ -454,15 +453,7 @@ except KeyError as e: default_config.get("hcatDebugLogPath", "./hashcat_debug") ) -try: - ollamaUrl = config_parser["ollamaUrl"] -except KeyError as e: - print( - "{0} is not defined in config.json using defaults from config.json.example".format( - e - ) - ) - ollamaUrl = default_config.get("ollamaUrl", "http://localhost:11434") +ollamaUrl = "http://" + os.environ.get("OLLAMA_HOST", "localhost:11434") try: ollamaModel = config_parser["ollamaModel"] except KeyError as e: @@ -473,23 +464,23 @@ except KeyError as e: ) ollamaModel = default_config.get("ollamaModel", "llama3.2") try: - markovCandidateCount = int(config_parser["markovCandidateCount"]) + ollamaCandidateCount = int(config_parser["ollamaCandidateCount"]) except KeyError as e: print( "{0} is not defined in config.json using defaults from config.json.example".format( e ) ) - markovCandidateCount = int(default_config.get("markovCandidateCount", 5000)) + ollamaCandidateCount = int(default_config.get("ollamaCandidateCount", 5000)) try: - markovWordlist = config_parser["markovWordlist"] + ollamaWordlist = config_parser["ollamaWordlist"] except KeyError as e: print( "{0} is not defined in config.json using defaults from config.json.example".format( e ) ) - markovWordlist = default_config.get("markovWordlist", "rockyou.txt") + ollamaWordlist = default_config.get("ollamaWordlist", "rockyou.txt") hcatExpanderBin = "expander.bin" hcatCombinatorBin = "combinator.bin" @@ -629,7 +620,7 @@ hcatGoodMeasureBaseList = _normalize_wordlist_setting( hcatGoodMeasureBaseList, wordlists_dir ) hcatPrinceBaseList = _normalize_wordlist_setting(hcatPrinceBaseList, wordlists_dir) -markovWordlist = _normalize_wordlist_setting(markovWordlist, wordlists_dir) +ollamaWordlist = _normalize_wordlist_setting(ollamaWordlist, wordlists_dir) if not SKIP_INIT: # Verify hashcat binary is available @@ -695,7 +686,7 @@ if not SKIP_INIT: except SystemExit: print("PRINCE attacks will not be available.") - # Verify hcstat2gen binary (optional, for Markov attacks) + # Verify hcstat2gen binary (optional, for LLM attacks) # Note: hcstat2gen is part of hashcat-utils, already in hate_crack repo hcstat2gen_path = ( hate_path + "/hashcat-utils/bin/" + hcatHcstat2genBin @@ -707,7 +698,7 @@ if not SKIP_INIT: name="hcstat2gen", ) except SystemExit: - print("LLM Markov attacks will not be available.") + print("LLM attacks will not be available.") except Exception as e: print(f"Module initialization error: {e}") @@ -938,20 +929,25 @@ def _write_field_sorted_unique(input_path, output_path, field_index, delimiter=" def _run_hashcat_show(hash_type, hash_file, output_path): + result = subprocess.run( + [ + hcatBin, + "--show", + # Use hashcat's built-in potfile unless configured otherwise. + *([f"--potfile-path={hcatPotfilePath}"] if hcatPotfilePath else []), + "-m", + str(hash_type), + hash_file, + ], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + check=False, + ) with open(output_path, "w") as out: - subprocess.run( - [ - hcatBin, - "--show", - # Use hashcat's built-in potfile unless configured otherwise. - *([f"--potfile-path={hcatPotfilePath}"] if hcatPotfilePath else []), - "-m", - str(hash_type), - hash_file, - ], - stdout=out, - check=False, - ) + for line in result.stdout.decode("utf-8", errors="ignore").splitlines(): + # hashcat --show prints parse errors to stdout; skip non-result lines + if ":" in line and not line.startswith(("Hash parsing error", "* ")): + out.write(line + "\n") # Brute Force Attack @@ -1539,22 +1535,10 @@ def _pull_ollama_model(url, model): return True -# LLM Markov Attack -def hcatMarkov(hcatHashType, hcatHashFile, mode, context_data, candidate_count): +# LLM Ollama Attack +def hcatOllama(hcatHashType, hcatHashFile, mode, context_data): global hcatProcess - candidates_path = f"{hcatHashFile}.markov_candidates" - hcstat2_raw_path = f"{hcatHashFile}.hcstat2_raw" - hcstat2_path = f"{hcatHashFile}.hcstat2" - hcstat2gen_path = os.path.join( - hate_path, "hashcat-utils", "bin", hcatHcstat2genBin - ) - - if not os.path.isfile(hcstat2gen_path): - print( - f"Error: hcstat2gen not found at {hcstat2gen_path}. " - "LLM Markov attacks are not available." - ) - return + candidates_path = f"{hcatHashFile}.ollama_candidates" # Step A: Build LLM prompt based on mode if mode == "wordlist": @@ -1577,19 +1561,18 @@ def hcatMarkov(hcatHashType, hcatHashFile, mode, context_data, candidate_count): wordlist_sample = "\n".join(sample_lines) prompt = ( "You are a password generation expert. Below is a sample of real passwords. " - "Study the patterns, character choices, and structures. Generate exactly " - f"{candidate_count} new unique passwords that follow similar patterns but " - "are NOT copies of the input. Output ONLY passwords, one per line, no " - "numbering or explanation.\n\n" - f"Sample passwords:\n{wordlist_sample}" + "Study the patterns, character choices, and structures. Then generate hashcat rules" \ + " that could transform common base words into similar passwords. Focus on patterns like " \ + "capitalization, leetspeak, suffixes, and common substitutions. Here are the sample passwords:\n" \ + f"{wordlist_sample}" ) elif mode == "target": company = context_data.get("company", "") industry = context_data.get("industry", "") location = context_data.get("location", "") prompt = ( - f"Generate exactly {candidate_count} realistic passwords that employees at " - f"{company} in the {industry} industry located in {location} might use. " + "Generate baseword to be used in a denylist for keeping users from setting their passwords with these basewords. We are protecting the employees of " + f"{company} in the {industry} industry located in {location}.\n\n" "Include variations with:\n" "- Company name variations and abbreviations\n" "- Common password patterns (Season+Year, Name+Numbers)\n" @@ -1599,11 +1582,11 @@ def hcatMarkov(hcatHashType, hcatHashFile, mode, context_data, candidate_count): "Output ONLY the passwords, one per line, no numbering or explanation." ) else: - print(f"Error: Unknown Markov generation mode: {mode}") + print(f"Error: Unknown LLM generation mode: {mode}") return # Step B: Call Ollama API to generate candidates - print(f"Generating {candidate_count} password candidates via Ollama ({ollamaModel})...") + print(f"Generating password candidates via Ollama ({ollamaModel})...") api_url = f"{ollamaUrl}/api/generate" payload = json.dumps({ "model": ollamaModel, @@ -1642,7 +1625,7 @@ def hcatMarkov(hcatHashType, hcatHashFile, mode, context_data, candidate_count): print(f"Error calling Ollama API after pull: {retry_err}") return else: - print(f"Could not pull model '{ollamaModel}'. Aborting Markov attack.") + print(f"Could not pull model '{ollamaModel}'. Aborting LLM attack.") return else: print(f"Error: Could not connect to Ollama at {ollamaUrl}: {e}") @@ -1688,48 +1671,8 @@ def hcatMarkov(hcatHashType, hcatHashFile, mode, context_data, candidate_count): filtered_count = len(raw_lines) - len(candidates) print(f"[DEBUG] Filtered out {filtered_count} lines from Ollama response ({len(raw_lines)} raw -> {len(candidates)} candidates)") - # Step C: Run hcstat2gen to build Markov stats - print("Building Markov statistics with hcstat2gen...") - try: - with open(candidates_path, "rb") as candidates_file: - hcatProcess = subprocess.Popen( - [hcstat2gen_path, hcstat2_raw_path], - stdin=candidates_file, - ) - try: - hcatProcess.wait() - except KeyboardInterrupt: - print("Killing PID {0}...".format(str(hcatProcess.pid))) - hcatProcess.kill() - return - except Exception as e: - print(f"Error running hcstat2gen: {e}") - return - - if not os.path.isfile(hcstat2_raw_path): - print("Error: hcstat2gen did not produce output file.") - return - - # Step D: LZMA compress the raw hcstat2 file - print("Compressing hcstat2 file with LZMA...") - try: - with open(hcstat2_raw_path, "rb") as raw_file: - raw_data = raw_file.read() - compressed = lzma.compress( - raw_data, - format=lzma.FORMAT_RAW, - filters=[{"id": lzma.FILTER_LZMA1}], - ) - with open(hcstat2_path, "wb") as out_file: - out_file.write(compressed) - except Exception as e: - print(f"Error compressing hcstat2 file: {e}") - return - - print(f"Markov statistics file created -> {hcstat2_path}") - - # Step E: Run hashcat mask attack with custom Markov stats - print("Running Markov-enhanced mask attack...") + # Step C: Run hashcat wordlist attack with LLM-generated candidates (no rules) + print("Running wordlist attack with LLM-generated candidates...") cmd = [ hcatBin, "-m", @@ -1739,13 +1682,7 @@ def hcatMarkov(hcatHashType, hcatHashFile, mode, context_data, candidate_count): generate_session_id(), "-o", f"{hcatHashFile}.out", - "-a", - "3", - f"--markov-hcstat2={hcstat2_path}", - "--increment", - "--increment-min=1", - "--increment-max=14", - "?a?a?a?a?a?a?a?a?a?a?a?a?a?a", + candidates_path, ] cmd.extend(shlex.split(hcatTuning)) _append_potfile_arg(cmd) @@ -1755,14 +1692,43 @@ def hcatMarkov(hcatHashType, hcatHashFile, mode, context_data, candidate_count): except KeyboardInterrupt: print("Killing PID {0}...".format(str(hcatProcess.pid))) hcatProcess.kill() + return - # Step F: Cleanup temp files - for temp_file in [candidates_path, hcstat2_raw_path]: + # Step D: Run hashcat with LLM candidates against every rule in the rules directory + rule_files = sorted( + f for f in os.listdir(rulesDirectory) if f != ".DS_Store" + ) + if not rule_files: + print("No rule files found in rules directory. Skipping rule-based attacks.") + return + + print(f"\nRunning LLM candidates with {len(rule_files)} rule file(s) from {rulesDirectory}...") + for rule in rule_files: + rule_path = os.path.join(rulesDirectory, rule) + print(f"\n\tRunning with rule: {rule}") + cmd = [ + hcatBin, + "-m", + hcatHashType, + hcatHashFile, + "--session", + generate_session_id(), + "-o", + f"{hcatHashFile}.out", + "-r", + rule_path, + candidates_path, + ] + cmd.extend(shlex.split(hcatTuning)) + _append_potfile_arg(cmd) + hcatProcess = subprocess.Popen(cmd) try: - if os.path.isfile(temp_file): - os.remove(temp_file) - except Exception: - pass + hcatProcess.wait() + except KeyboardInterrupt: + print("Killing PID {0}...".format(str(hcatProcess.pid))) + hcatProcess.kill() + return + # Middle fast Combinator Attack @@ -2280,13 +2246,10 @@ def hashview_api(): menu_options.append(("download_wordlist", "Download Wordlist")) menu_options.append( ( - "download_left", - "Download Left Hashes (with automatic merge if found)", + "download_hashes", + "Download Hashes (left + found to potfile)", ) ) - menu_options.append( - ("download_found", "Download Found Hashes (with automatic split)") - ) if hcatHashFile: menu_options.append( ("upload_hashfile_job", "Upload Hashfile and Create Job") @@ -2683,7 +2646,7 @@ def hashview_api(): except Exception as e: print(f"\n✗ Error uploading hashfile: {str(e)}") - elif option_key == "download_left": + elif option_key == "download_hashes": # Download left hashes try: while True: @@ -2852,157 +2815,6 @@ def hashview_api(): except Exception as e: print(f"\n✗ Error downloading hashes: {str(e)}") - elif option_key == "download_found": - # Download found hashes - try: - while True: - # First, list customers to help user select - customers_result = api_harness.list_customers() - customers = ( - customers_result.get("customers", []) - if isinstance(customers_result, dict) - else customers_result - ) - if customers: - api_harness.display_customers_multicolumn(customers) - else: - print("\nNo customers found.") - - # Select or create customer - customer_input = input( - "\nEnter customer ID or N to create new: " - ).strip() - if customer_input.lower() == "n": - customer_name = input("Enter customer name: ").strip() - if customer_name: - try: - result = api_harness.create_customer(customer_name) - print( - f"\n✓ Success: {result.get('msg', 'Customer created')}" - ) - customer_id = result.get( - "customer_id" - ) or result.get("id") - if not customer_id: - print("\n✗ Error: Customer ID not returned.") - continue - print(f" Customer ID: {customer_id}") - except Exception as e: - print(f"\n✗ Error creating customer: {str(e)}") - continue - else: - print("\n✗ Error: Customer name cannot be empty.") - continue - else: - try: - customer_id = int(customer_input) - except ValueError: - print( - "\n✗ Error: Invalid ID entered. Please enter a numeric ID or N." - ) - continue - - # List hashfiles for the customer - try: - customer_hashfiles = api_harness.get_customer_hashfiles( - customer_id - ) - - if not customer_hashfiles: - print( - f"\nNo hashfiles found for customer ID {customer_id}" - ) - continue - - print("\n" + "=" * 120) - print(f"Hashfiles for Customer ID {customer_id}:") - print("=" * 120) - print(f"{'ID':<10} {'Hash Type':<10} {'Name':<96}") - print("-" * 120) - hashfile_map = {} - for hf in customer_hashfiles: - hf_id = hf.get("id") - hf_name = hf.get("name", "N/A") - hf_type = ( - hf.get("hash_type") or hf.get("hashtype") or "N/A" - ) - if hf_id is None: - continue - # Truncate long names to fit within 120 columns - if len(str(hf_name)) > 96: - hf_name = str(hf_name)[:93] + "..." - if debug_mode: - print( - f"[DEBUG] Hashfile {hf_id}: hash_type={hf.get('hash_type')}, hashtype={hf.get('hashtype')}, combined={hf_type}" - ) - print(f"{hf_id:<10} {hf_type:<10} {hf_name:<96}") - hashfile_map[int(hf_id)] = hf_type - print("=" * 120) - print(f"Total: {len(hashfile_map)} hashfile(s)") - except Exception as e: - print(f"\nWarning: Could not list hashfiles: {e}") - continue - - while True: - try: - hashfile_id_input = input( - "\nEnter hashfile ID: " - ).strip() - hashfile_id = int(hashfile_id_input) - except ValueError: - print( - "\n✗ Error: Invalid ID entered. Please enter a numeric ID." - ) - continue - if hashfile_id not in hashfile_map: - print( - "\n✗ Error: Hashfile ID not in the list. Please try again." - ) - continue - break - break - - # Set output filename automatically - output_file = f"found_{customer_id}_{hashfile_id}.txt" - - # Get hash type for hashcat from the hashfile map - selected_hash_type = hashfile_map.get(hashfile_id) - if debug_mode: - print( - f"[DEBUG] selected_hash_type from map: {selected_hash_type}" - ) - if not selected_hash_type or selected_hash_type == "N/A": - try: - details = api_harness.get_hashfile_details(hashfile_id) - selected_hash_type = details.get("hashtype") - if debug_mode: - print( - f"[DEBUG] selected_hash_type from get_hashfile_details: {selected_hash_type}" - ) - except Exception as e: - if debug_mode: - print(f"[DEBUG] Error fetching hashfile details: {e}") - selected_hash_type = None - - # Download the found hashes - if debug_mode: - print( - f"[DEBUG] Calling download_found_hashes with hash_type={selected_hash_type}" - ) - download_result = api_harness.download_found_hashes( - customer_id, hashfile_id, output_file - ) - print(f"\n✓ Success: Downloaded {download_result['size']} bytes") - print(f" File: {download_result['output_file']}") - if selected_hash_type: - print(f" Hash mode: {selected_hash_type}") - print("\nFound hashes downloaded successfully. These are already cracked hashes.") - - except ValueError: - print("\n✗ Error: Invalid ID entered. Please enter a numeric ID.") - except Exception as e: - print(f"\n✗ Error downloading found hashes: {str(e)}") - elif option_key == "back": break @@ -3075,8 +2887,8 @@ def loopback_attack(): return _attacks.loopback_attack(_attack_ctx()) -def markov_attack(): - return _attacks.markov_attack(_attack_ctx()) +def ollama_attack(): + return _attacks.ollama_attack(_attack_ctx()) # convert hex words for recycling @@ -3306,7 +3118,7 @@ def get_main_menu_options(): "12": thorough_combinator, "13": bandrel_method, "14": loopback_attack, - "15": markov_attack, + "15": ollama_attack, "90": download_hashmob_rules, "91": analyze_rules, "92": download_hashmob_wordlists, @@ -3448,8 +3260,8 @@ def main(): ) hv_download_left = hashview_subparsers.add_parser( - "download-left", - help="Download left hashes for a hashfile", + "download-hashes", + help="Download left hashes and append found hashes to potfile", ) hv_download_left.add_argument( "--customer-id", required=True, type=int, help="Customer ID" @@ -3463,17 +3275,6 @@ def main(): help="Hash type for hashcat (e.g., 1000 for NTLM)", ) - hv_download_found = hashview_subparsers.add_parser( - "download-found", - help="Download found hashes for a hashfile", - ) - hv_download_found.add_argument( - "--customer-id", required=True, type=int, help="Customer ID" - ) - hv_download_found.add_argument( - "--hashfile-id", required=True, type=int, help="Hashfile ID" - ) - hv_upload_hashfile_job = hashview_subparsers.add_parser( "upload-hashfile-job", help="Upload a hashfile and create a job", @@ -3514,8 +3315,7 @@ def main(): hashview_subcommands = [ "upload-cracked", "upload-wordlist", - "download-left", - "download-found", + "download-hashes", "upload-hashfile-job", ] has_hashview_flag = "--hashview" in argv @@ -3639,7 +3439,7 @@ def main(): print(f" Wordlist ID: {result['wordlist_id']}") sys.exit(0) - if args.hashview_command == "download-left": + if args.hashview_command == "download-hashes": download_result = api_harness.download_left_hashes( args.customer_id, args.hashfile_id, @@ -3649,15 +3449,6 @@ def main(): print(f" File: {download_result['output_file']}") sys.exit(0) - if args.hashview_command == "download-found": - download_result = api_harness.download_found_hashes( - args.customer_id, - args.hashfile_id, - ) - print(f"\n✓ Success: Downloaded {download_result['size']} bytes") - print(f" File: {download_result['output_file']}") - sys.exit(0) - if args.hashview_command == "upload-hashfile-job": hashfile_path = resolve_path(args.file) if not hashfile_path or not os.path.isfile(hashfile_path): @@ -3887,7 +3678,7 @@ def main(): print("\t(12) Thorough Combinator Attack") print("\t(13) Bandrel Methodology") print("\t(14) Loopback Attack") - print("\t(15) LLM Markov Attack") + print("\t(15) LLM Attack") print("\n\t(90) Download rules from Hashmob.net") print("\n\t(91) Analyze Hashcat Rules") print("\t(92) Download wordlists from Hashmob.net") diff --git a/tests/test_hashview.py b/tests/test_hashview.py index 3f5181f..53ac183 100644 --- a/tests/test_hashview.py +++ b/tests/test_hashview.py @@ -270,52 +270,6 @@ class TestHashviewAPI: assert "Cookie" in auth_headers or "uuid" in str(auth_headers) assert HASHVIEW_API_KEY in str(auth_headers) - def test_download_found_hashes(self, api, tmp_path): - """Test downloading found hashes: real API if possible, else mock.""" - hashview_url, hashview_api_key = self._get_hashview_config() - customer_id = os.environ.get("HASHVIEW_CUSTOMER_ID") - hashfile_id = os.environ.get("HASHVIEW_HASHFILE_ID") - if all([hashview_url, hashview_api_key, customer_id, hashfile_id]): - # Real API test - real_api = HashviewAPI(hashview_url, hashview_api_key) - output_file = tmp_path / f"found_{customer_id}_{hashfile_id}.txt" - result = real_api.download_found_hashes( - int(customer_id), int(hashfile_id), output_file=str(output_file) - ) - assert os.path.exists(result["output_file"]) - with open(result["output_file"], "rb") as f: - content = f.read() - print(f"[DEBUG] Downloaded {len(content)} bytes to {result['output_file']}") - assert result["size"] == len(content) - else: - # Mock test - mock_response = Mock() - mock_response.content = b"hash1:pass1\nhash2:pass2\n" - mock_response.raise_for_status = Mock() - mock_response.headers = {"content-length": "0"} - - def iter_content(chunk_size=8192): - yield mock_response.content - - mock_response.iter_content = iter_content - api.session.get.return_value = mock_response - - output_file = tmp_path / "found_1_2.txt" - result = api.download_found_hashes(1, 2, output_file=str(output_file)) - assert os.path.exists(result["output_file"]) - with open(result["output_file"], "rb") as f: - content = f.read() - assert content == b"hash1:pass1\nhash2:pass2\n" - assert result["size"] == len(content) - - # Verify auth headers were passed in the found hashes download call - call_args_list = api.session.get.call_args_list - found_call = [c for c in call_args_list if "found" in str(c)][0] - assert found_call.kwargs.get("headers") is not None - auth_headers = found_call.kwargs.get("headers") - assert "Cookie" in auth_headers or "uuid" in str(auth_headers) - assert HASHVIEW_API_KEY in str(auth_headers) - def test_download_wordlist(self, api, tmp_path): """Test downloading a wordlist: real API if possible, else mock.""" hashview_url, hashview_api_key = self._get_hashview_config() @@ -640,21 +594,17 @@ class TestHashviewAPI: # Verify left file was created assert os.path.exists(result["output_file"]) - # Verify found file was downloaded and deleted + # Verify found files are cleaned up after merge found_file = tmp_path / "found_1_2.txt" - assert not os.path.exists(found_file), ( - "Found file should be deleted after split" - ) - assert not (other_cwd / "found_1_2.txt").exists() + assert not os.path.exists(found_file), "Found file should be deleted after merge" - # Verify split files were created and deleted found_hashes_file = tmp_path / "found_hashes_1_2.txt" found_clears_file = tmp_path / "found_clears_1_2.txt" assert not os.path.exists(str(found_hashes_file)), ( - "Split hashes file should be deleted" + "Split hashes file should be deleted after merge" ) assert not os.path.exists(str(found_clears_file)), ( - "Split clears file should be deleted" + "Split clears file should be deleted after merge" ) def test_download_left_id_matching(self, api, tmp_path): diff --git a/tests/test_hashview_cli_subcommands.py b/tests/test_hashview_cli_subcommands.py index 7831855..4c59ff2 100644 --- a/tests/test_hashview_cli_subcommands.py +++ b/tests/test_hashview_cli_subcommands.py @@ -28,15 +28,6 @@ class DummyHashviewAPI: "size": 10, } - def download_found_hashes(self, customer_id, hashfile_id, output_file=None): - self.calls.append( - ("download_found_hashes", customer_id, hashfile_id, output_file) - ) - return { - "output_file": output_file or f"found_{customer_id}_{hashfile_id}.txt", - "size": 12, - } - def upload_hashfile( self, file_path, customer_id, hash_type, file_format=5, hashfile_name=None ): diff --git a/tests/test_hashview_cli_subcommands_subprocess.py b/tests/test_hashview_cli_subcommands_subprocess.py index 1e31c88..529e57d 100644 --- a/tests/test_hashview_cli_subcommands_subprocess.py +++ b/tests/test_hashview_cli_subcommands_subprocess.py @@ -77,8 +77,7 @@ def _ensure_customer_one(): "--name", "TestWordlist", ], - ["hashview", "download-left", "--customer-id", "1", "--hashfile-id", "2"], - ["hashview", "download-found", "--customer-id", "1", "--hashfile-id", "2"], + ["hashview", "download-hashes", "--customer-id", "1", "--hashfile-id", "2"], [ "hashview", "upload-hashfile-job", @@ -142,45 +141,25 @@ def test_hashview_subcommands_live_downloads(): base_cmd = [sys.executable, HATE_CRACK_SCRIPT, "hashview"] customer_id = _ensure_customer_one() - left_cmd = base_cmd + [ - "download-left", + dl_cmd = base_cmd + [ + "download-hashes", "--customer-id", str(customer_id), "--hashfile-id", os.environ["HASHVIEW_HASHFILE_ID"], ] - left = subprocess.run( - left_cmd, + dl = subprocess.run( + dl_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, cwd=REPO_ROOT, env=env, ) - left_out = left.stdout + left.stderr - assert left.returncode == 0, left_out - assert "Downloaded" in left_out - assert "left_" in left_out - - found_cmd = base_cmd + [ - "download-found", - "--customer-id", - str(customer_id), - "--hashfile-id", - os.environ["HASHVIEW_HASHFILE_ID"], - ] - found = subprocess.run( - found_cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - cwd=REPO_ROOT, - env=env, - ) - found_out = found.stdout + found.stderr - assert found.returncode == 0, found_out - assert "Downloaded" in found_out - assert "found_" in found_out + dl_out = dl.stdout + dl.stderr + assert dl.returncode == 0, dl_out + assert "Downloaded" in dl_out + assert "left_" in dl_out @pytest.mark.skipif( diff --git a/tests/test_pull_ollama_model.py b/tests/test_pull_ollama_model.py index 4c7e04b..0df75e0 100644 --- a/tests/test_pull_ollama_model.py +++ b/tests/test_pull_ollama_model.py @@ -1,8 +1,7 @@ -"""Unit tests for _pull_ollama_model helper, hcatMarkov steps A-F, and markov_attack handler.""" +"""Unit tests for _pull_ollama_model helper, hcatOllama steps A-C, and ollama_attack handler.""" import io import json -import lzma import os import urllib.error import urllib.request @@ -25,36 +24,31 @@ MODEL = "llama3.2" @pytest.fixture -def markov_env(tmp_path): - """Create the filesystem layout hcatMarkov expects.""" +def ollama_env(tmp_path): + """Create the filesystem layout hcatOllama expects.""" hash_file = tmp_path / "hashes.txt" hash_file.touch() - hcutil_bin = tmp_path / "hashcat-utils" / "bin" - hcutil_bin.mkdir(parents=True) - hcstat2gen = hcutil_bin / hc_main.hcatHcstat2genBin - hcstat2gen.touch() - wordlist = tmp_path / "sample.txt" wordlist.write_text("password\n123456\nletmein\n") return SimpleNamespace( tmp_path=tmp_path, hash_file=str(hash_file), - hcstat2gen=str(hcstat2gen), wordlist=str(wordlist), ) @contextmanager -def markov_globals(tmp_path, tuning="", potfile=""): - """Patch the hc_main globals that hcatMarkov reads.""" +def ollama_globals(tmp_path, tuning="", potfile=""): + """Patch the hc_main globals that hcatOllama reads.""" with mock.patch.object(hc_main, "ollamaUrl", OLLAMA_URL), \ mock.patch.object(hc_main, "ollamaModel", MODEL), \ mock.patch.object(hc_main, "hcatBin", "/usr/bin/hashcat"), \ mock.patch.object(hc_main, "hcatTuning", tuning), \ mock.patch.object(hc_main, "hcatPotfilePath", potfile), \ - mock.patch.object(hc_main, "hate_path", str(tmp_path)): + mock.patch.object(hc_main, "hate_path", str(tmp_path)), \ + mock.patch("hate_crack.main.generate_session_id", return_value="test_session"): yield @@ -184,11 +178,11 @@ class TestPullOllamaModel: # --------------------------------------------------------------------------- -# hcatMarkov 404-retry integration tests +# hcatOllama 404-retry integration tests # --------------------------------------------------------------------------- -class TestHcatMarkov404Retry: - """Test that hcatMarkov auto-pulls on 404 and retries the generate call.""" +class TestHcatOllama404Retry: + """Test that hcatOllama auto-pulls on 404 and retries the generate call.""" OLLAMA_URL = "http://localhost:11434" MODEL = "llama3.2" @@ -198,16 +192,10 @@ class TestHcatMarkov404Retry: return io.BytesIO(body) def _setup_env(self, tmp_path): - """Create the files/dirs hcatMarkov needs before it hits the API.""" + """Create the files/dirs hcatOllama needs before it hits the API.""" hash_file = str(tmp_path / "hashes.txt") open(hash_file, "w").close() - # hcatMarkov checks for hcstat2gen binary at startup - hcutil_bin = tmp_path / "hashcat-utils" / "bin" - hcutil_bin.mkdir(parents=True, exist_ok=True) - hcstat2gen = hcutil_bin / hc_main.hcatHcstat2genBin - hcstat2gen.touch() - # Create a small wordlist for "wordlist" mode wordlist = str(tmp_path / "sample.txt") with open(wordlist, "w") as f: @@ -245,12 +233,13 @@ class TestHcatMarkov404Retry: mock.patch.object(hc_main, "hcatBin", "/usr/bin/hashcat"), \ mock.patch.object(hc_main, "hcatTuning", ""), \ mock.patch.object(hc_main, "hate_path", str(tmp_path)), \ + mock.patch("hate_crack.main.generate_session_id", return_value="test_session"), \ mock.patch("subprocess.Popen") as mock_popen: proc = _make_proc() mock_popen.return_value = proc - hc_main.hcatMarkov("0", hash_file, "wordlist", wordlist, 10) + hc_main.hcatOllama("0", hash_file, "wordlist", wordlist) mock_pull.assert_called_once_with(self.OLLAMA_URL, self.MODEL) # urlopen called twice: first 404, then retry @@ -259,7 +248,7 @@ class TestHcatMarkov404Retry: @mock.patch("hate_crack.main._pull_ollama_model") @mock.patch("hate_crack.main.urllib.request.urlopen") def test_404_pull_fails_aborts(self, mock_urlopen, mock_pull, capsys, tmp_path): - """If pull fails after 404, hcatMarkov should abort gracefully.""" + """If pull fails after 404, hcatOllama should abort gracefully.""" hash_file, wordlist = self._setup_env(tmp_path) mock_urlopen.side_effect = urllib.error.HTTPError( @@ -275,7 +264,7 @@ class TestHcatMarkov404Retry: mock.patch.object(hc_main, "ollamaModel", self.MODEL), \ mock.patch.object(hc_main, "hate_path", str(tmp_path)): - hc_main.hcatMarkov("0", hash_file, "wordlist", wordlist, 10) + hc_main.hcatOllama("0", hash_file, "wordlist", wordlist) captured = capsys.readouterr() assert "Could not pull model" in captured.out @@ -298,7 +287,7 @@ class TestHcatMarkov404Retry: mock.patch.object(hc_main, "ollamaModel", self.MODEL), \ mock.patch.object(hc_main, "hate_path", str(tmp_path)): - hc_main.hcatMarkov("0", hash_file, "wordlist", wordlist, 10) + hc_main.hcatOllama("0", hash_file, "wordlist", wordlist) captured = capsys.readouterr() # HTTPError is a subclass of URLError, so it hits the URLError handler @@ -310,51 +299,36 @@ class TestHcatMarkov404Retry: # Step A: Mode routing, prompt construction, early-return error paths # --------------------------------------------------------------------------- -class TestHcatMarkovModeRouting: +class TestHcatOllamaModeRouting: """Test mode selection, prompt building, and early-return errors.""" - def test_unknown_mode_prints_error(self, markov_env, capsys): + def test_unknown_mode_prints_error(self, ollama_env, capsys): """Bad mode string → error message, no API call.""" - with markov_globals(markov_env.tmp_path), \ + with ollama_globals(ollama_env.tmp_path), \ mock.patch("hate_crack.main.urllib.request.urlopen") as mock_url: - hc_main.hcatMarkov("0", markov_env.hash_file, "bogus", "", 10) + hc_main.hcatOllama("0", ollama_env.hash_file, "bogus", "") captured = capsys.readouterr() - assert "Unknown Markov generation mode" in captured.out + assert "Unknown LLM generation mode" in captured.out mock_url.assert_not_called() - def test_missing_wordlist_prints_error(self, markov_env, capsys): + def test_missing_wordlist_prints_error(self, ollama_env, capsys): """Non-existent wordlist path → error, no API call.""" - with markov_globals(markov_env.tmp_path), \ + with ollama_globals(ollama_env.tmp_path), \ mock.patch("hate_crack.main.urllib.request.urlopen") as mock_url: - hc_main.hcatMarkov( - "0", markov_env.hash_file, "wordlist", - "/no/such/wordlist.txt", 10, + hc_main.hcatOllama( + "0", ollama_env.hash_file, "wordlist", + "/no/such/wordlist.txt", ) captured = capsys.readouterr() assert "Wordlist not found" in captured.out mock_url.assert_not_called() - def test_missing_hcstat2gen_prints_error(self, tmp_path, capsys): - """No hcstat2gen binary → error, no API call.""" - hash_file = tmp_path / "hashes.txt" - hash_file.touch() - wordlist = tmp_path / "sample.txt" - wordlist.write_text("password\n") - - with markov_globals(tmp_path), \ - mock.patch("hate_crack.main.urllib.request.urlopen") as mock_url: - hc_main.hcatMarkov("0", str(hash_file), "wordlist", str(wordlist), 10) - - captured = capsys.readouterr() - assert "hcstat2gen not found" in captured.out - mock_url.assert_not_called() - - def test_wordlist_mode_reads_up_to_500_lines(self, markov_env, capsys): + def test_wordlist_mode_reads_up_to_500_lines(self, ollama_env, capsys): """Only the first 500 non-blank lines should appear in the prompt payload.""" # Write 600 lines to the wordlist - big_wordlist = markov_env.tmp_path / "big.txt" + big_wordlist = ollama_env.tmp_path / "big.txt" big_wordlist.write_text("\n".join(f"pass{i}" for i in range(600)) + "\n") captured_payload = {} @@ -363,13 +337,13 @@ class TestHcatMarkovModeRouting: captured_payload["data"] = json.loads(req.data.decode()) return _generate_response(["Password1"]) - with markov_globals(markov_env.tmp_path), \ + with ollama_globals(ollama_env.tmp_path), \ mock.patch("hate_crack.main.urllib.request.urlopen", side_effect=fake_urlopen), \ mock.patch("subprocess.Popen") as mock_popen: proc = _make_proc() mock_popen.return_value = proc - hc_main.hcatMarkov( - "0", markov_env.hash_file, "wordlist", str(big_wordlist), 10, + hc_main.hcatOllama( + "0", ollama_env.hash_file, "wordlist", str(big_wordlist), ) prompt_text = captured_payload["data"]["prompt"] @@ -377,7 +351,7 @@ class TestHcatMarkovModeRouting: assert "pass499" in prompt_text assert "pass500" not in prompt_text - def test_target_mode_includes_context_in_prompt(self, markov_env, capsys): + def test_target_mode_includes_context_in_prompt(self, ollama_env, capsys): """Company, industry, and location should appear in the prompt.""" captured_payload = {} @@ -390,13 +364,13 @@ class TestHcatMarkovModeRouting: "industry": "Finance", "location": "New York", } - with markov_globals(markov_env.tmp_path), \ + with ollama_globals(ollama_env.tmp_path), \ mock.patch("hate_crack.main.urllib.request.urlopen", side_effect=fake_urlopen), \ mock.patch("subprocess.Popen") as mock_popen: proc = _make_proc() mock_popen.return_value = proc - hc_main.hcatMarkov( - "0", markov_env.hash_file, "target", target_info, 10, + hc_main.hcatOllama( + "0", ollama_env.hash_file, "target", target_info, ) prompt_text = captured_payload["data"]["prompt"] @@ -409,33 +383,37 @@ class TestHcatMarkovModeRouting: # Step B: Candidate filtering / post-processing # --------------------------------------------------------------------------- -class TestHcatMarkovCandidateFiltering: +class TestHcatOllamaCandidateFiltering: """Test regex stripping, blank-line removal, 128-char limit, empty-result handling.""" - def _run_with_response(self, markov_env, response_text): - """Run hcatMarkov with a canned Ollama response_text, return candidates file content.""" + def _run_with_response(self, ollama_env, response_text): + """Run hcatOllama with a canned Ollama response_text, return candidates file content.""" body = json.dumps({"response": response_text}).encode() resp = mock.MagicMock() resp.__enter__ = mock.Mock(return_value=io.BytesIO(body)) resp.__exit__ = mock.Mock(return_value=False) - with markov_globals(markov_env.tmp_path), \ + candidates_path = f"{ollama_env.hash_file}.ollama_candidates" + captured = {} + + def capture_popen(cmd, **kwargs): + # Read the candidates file before hashcat "runs" (cleanup happens after) + if os.path.isfile(candidates_path): + with open(candidates_path) as f: + captured["content"] = f.read() + return _make_proc() + + with ollama_globals(ollama_env.tmp_path), \ mock.patch("hate_crack.main.urllib.request.urlopen", return_value=resp), \ - mock.patch("subprocess.Popen") as mock_popen: - proc = _make_proc() - mock_popen.return_value = proc - hc_main.hcatMarkov( - "0", markov_env.hash_file, "wordlist", markov_env.wordlist, 10, + mock.patch("subprocess.Popen", side_effect=capture_popen): + hc_main.hcatOllama( + "0", ollama_env.hash_file, "wordlist", ollama_env.wordlist, ) - candidates_path = f"{markov_env.hash_file}.markov_candidates" - if os.path.isfile(candidates_path): - with open(candidates_path) as f: - return f.read() - return None + return captured.get("content") - def test_strips_numeric_dot_prefix(self, markov_env): - content = self._run_with_response(markov_env, "1. Password1\n2. Summer2024") + def test_strips_numeric_dot_prefix(self, ollama_env): + content = self._run_with_response(ollama_env, "1. Password1\n2. Summer2024") assert content is not None lines = [l for l in content.strip().split("\n") if l] assert "Password1" in lines @@ -445,51 +423,51 @@ class TestHcatMarkovCandidateFiltering: assert not line.startswith("1.") assert not line.startswith("2.") - def test_strips_dash_and_asterisk_prefix(self, markov_env): - content = self._run_with_response(markov_env, "- foo\n* bar") + def test_strips_dash_and_asterisk_prefix(self, ollama_env): + content = self._run_with_response(ollama_env, "- foo\n* bar") assert content is not None lines = [l for l in content.strip().split("\n") if l] assert "foo" in lines assert "bar" in lines - def test_skips_blank_lines(self, markov_env): - content = self._run_with_response(markov_env, "alpha\n\n\nbeta\n\n") + def test_skips_blank_lines(self, ollama_env): + content = self._run_with_response(ollama_env, "alpha\n\n\nbeta\n\n") assert content is not None lines = [l for l in content.strip().split("\n") if l] assert lines == ["alpha", "beta"] - def test_rejects_over_128_chars(self, markov_env): + def test_rejects_over_128_chars(self, ollama_env): long_pw = "A" * 129 - content = self._run_with_response(markov_env, f"short\n{long_pw}\nkeep") + content = self._run_with_response(ollama_env, f"short\n{long_pw}\nkeep") assert content is not None lines = [l for l in content.strip().split("\n") if l] assert "short" in lines assert "keep" in lines assert long_pw not in lines - def test_accepts_exactly_128_chars(self, markov_env): + def test_accepts_exactly_128_chars(self, ollama_env): exact_pw = "B" * 128 - content = self._run_with_response(markov_env, f"{exact_pw}\nother") + content = self._run_with_response(ollama_env, f"{exact_pw}\nother") assert content is not None lines = [l for l in content.strip().split("\n") if l] assert exact_pw in lines - def test_empty_response_prints_error(self, markov_env, capsys): - self._run_with_response(markov_env, "") + def test_empty_response_prints_error(self, ollama_env, capsys): + self._run_with_response(ollama_env, "") captured = capsys.readouterr() assert "no usable" in captured.out.lower() - def test_missing_response_key(self, markov_env, capsys): + def test_missing_response_key(self, ollama_env, capsys): """If the JSON has no 'response' key, treat as empty.""" body = json.dumps({"other": "stuff"}).encode() resp = mock.MagicMock() resp.__enter__ = mock.Mock(return_value=io.BytesIO(body)) resp.__exit__ = mock.Mock(return_value=False) - with markov_globals(markov_env.tmp_path), \ + with ollama_globals(ollama_env.tmp_path), \ mock.patch("hate_crack.main.urllib.request.urlopen", return_value=resp): - hc_main.hcatMarkov( - "0", markov_env.hash_file, "wordlist", markov_env.wordlist, 10, + hc_main.hcatOllama( + "0", ollama_env.hash_file, "wordlist", ollama_env.wordlist, ) captured = capsys.readouterr() @@ -500,37 +478,37 @@ class TestHcatMarkovCandidateFiltering: # Step B: API error paths (non-404) # --------------------------------------------------------------------------- -class TestHcatMarkovApiErrors: +class TestHcatOllamaApiErrors: """Test connection errors and generic exceptions during the generate call.""" - def test_url_error_prints_connection_error(self, markov_env, capsys): - with markov_globals(markov_env.tmp_path), \ + def test_url_error_prints_connection_error(self, ollama_env, capsys): + with ollama_globals(ollama_env.tmp_path), \ mock.patch( "hate_crack.main.urllib.request.urlopen", side_effect=urllib.error.URLError("Connection refused"), ): - hc_main.hcatMarkov( - "0", markov_env.hash_file, "wordlist", markov_env.wordlist, 10, + hc_main.hcatOllama( + "0", ollama_env.hash_file, "wordlist", ollama_env.wordlist, ) captured = capsys.readouterr() assert "Could not connect" in captured.out assert "Ensure Ollama is running" in captured.out - def test_generic_exception_prints_error(self, markov_env, capsys): - with markov_globals(markov_env.tmp_path), \ + def test_generic_exception_prints_error(self, ollama_env, capsys): + with ollama_globals(ollama_env.tmp_path), \ mock.patch( "hate_crack.main.urllib.request.urlopen", side_effect=RuntimeError("boom"), ): - hc_main.hcatMarkov( - "0", markov_env.hash_file, "wordlist", markov_env.wordlist, 10, + hc_main.hcatOllama( + "0", ollama_env.hash_file, "wordlist", ollama_env.wordlist, ) captured = capsys.readouterr() assert "Error calling Ollama API" in captured.out - def test_generate_request_payload(self, markov_env): + def test_generate_request_payload(self, ollama_env): """Verify the /api/generate request has correct URL, model, stream=false.""" captured_req = {} @@ -539,13 +517,13 @@ class TestHcatMarkovApiErrors: captured_req["body"] = json.loads(req.data.decode()) return _generate_response(["Password1"]) - with markov_globals(markov_env.tmp_path), \ + with ollama_globals(ollama_env.tmp_path), \ mock.patch("hate_crack.main.urllib.request.urlopen", side_effect=fake_urlopen), \ mock.patch("subprocess.Popen") as mock_popen: proc = _make_proc() mock_popen.return_value = proc - hc_main.hcatMarkov( - "0", markov_env.hash_file, "wordlist", markov_env.wordlist, 10, + hc_main.hcatOllama( + "0", ollama_env.hash_file, "wordlist", ollama_env.wordlist, ) assert captured_req["url"] == f"{OLLAMA_URL}/api/generate" @@ -555,269 +533,136 @@ class TestHcatMarkovApiErrors: # --------------------------------------------------------------------------- -# Steps C + D: hcstat2gen execution and LZMA compression +# Step C: Hashcat command construction # --------------------------------------------------------------------------- -class TestHcatMarkovHcstat2gen: - """Test hcstat2gen subprocess step.""" +class TestHcatOllamaHashcatCommand: + """Test hashcat command flags and process handling.""" - def test_hcstat2gen_correct_args(self, markov_env): - """hcstat2gen should be called with [binary_path, raw_output_path] and stdin=file.""" - hcstat2_raw_path = f"{markov_env.hash_file}.hcstat2_raw" - hcstat2gen_path = markov_env.hcstat2gen - - call_args_list = [] + def _run_full(self, ollama_env, tuning="", potfile=""): + """Run hcatOllama through all steps, returning the list of Popen calls.""" + popen_calls = [] def track_popen(cmd, **kwargs): - call_args_list.append((list(cmd), dict(kwargs))) - proc = _make_proc() - # Create raw hcstat2 file so the LZMA compression step continues - if cmd[0] == hcstat2gen_path: - with open(hcstat2_raw_path, "wb") as f: - f.write(b"\x00" * 100) - return proc + popen_calls.append((list(cmd), dict(kwargs))) + return _make_proc() - with markov_globals(markov_env.tmp_path), \ + with ollama_globals(ollama_env.tmp_path, tuning=tuning, potfile=potfile), \ _urlopen_with_response(["Password1"]), \ mock.patch("subprocess.Popen", side_effect=track_popen), \ mock.patch("hate_crack.main.generate_session_id", return_value="test_session"): - hc_main.hcatMarkov( - "0", markov_env.hash_file, "wordlist", markov_env.wordlist, 10, + hc_main.hcatOllama( + "1000", ollama_env.hash_file, "wordlist", ollama_env.wordlist, ) - # First Popen call should be hcstat2gen writing to the raw path - first_cmd, first_kwargs = call_args_list[0] - assert first_cmd[0] == hcstat2gen_path - assert first_cmd[1] == hcstat2_raw_path - assert "stdin" in first_kwargs + return popen_calls - def test_hcstat2gen_keyboard_interrupt(self, markov_env, capsys): - """KeyboardInterrupt during hcstat2gen should kill the process and return.""" + def test_hashcat_command_structure(self, ollama_env): + """First Popen call should be a straight wordlist attack with candidates file, + followed by rule-based attacks.""" + calls = self._run_full(ollama_env) + assert len(calls) >= 1, "Expected at least one Popen call (hashcat)" + + # First call: base wordlist attack (no rules) + hashcat_cmd = calls[0][0] + assert hashcat_cmd[0] == "/usr/bin/hashcat" + assert "-m" in hashcat_cmd + m_idx = hashcat_cmd.index("-m") + assert hashcat_cmd[m_idx + 1] == "1000" + # Should use the candidates wordlist file + candidates_path = f"{ollama_env.hash_file}.ollama_candidates" + assert candidates_path in hashcat_cmd + # First call should NOT have mask attack flags + assert "-a" not in hashcat_cmd + assert "--markov-hcstat2" not in " ".join(hashcat_cmd) + assert "--increment" not in hashcat_cmd + # First call should NOT have -r (rule) flag + assert "-r" not in hashcat_cmd + + # Subsequent calls: rule-based attacks + if len(calls) > 1: + for rule_cmd, _ in calls[1:]: + assert "-r" in rule_cmd + assert candidates_path in rule_cmd + + def test_hashcat_includes_tuning(self, ollama_env): + """-w 3 tuning flag should be appended to hashcat cmd.""" + calls = self._run_full(ollama_env, tuning="-w 3") + hashcat_cmd = calls[0][0] + assert "-w" in hashcat_cmd + w_idx = hashcat_cmd.index("-w") + assert hashcat_cmd[w_idx + 1] == "3" + + def test_hashcat_includes_potfile(self, ollama_env): + """--potfile-path should be present when hcatPotfilePath is set.""" + calls = self._run_full(ollama_env, potfile="/tmp/test.potfile") + hashcat_cmd = calls[0][0] + potfile_flags = [f for f in hashcat_cmd if "--potfile-path=" in f] + assert len(potfile_flags) == 1 + assert potfile_flags[0] == "--potfile-path=/tmp/test.potfile" + + def test_hashcat_keyboard_interrupt(self, ollama_env, capsys): + """KeyboardInterrupt during hashcat should kill the process.""" proc = mock.MagicMock() - proc.communicate.side_effect = KeyboardInterrupt() - proc.pid = 12345 + proc.pid = 99999 + proc.wait.side_effect = KeyboardInterrupt() - with markov_globals(markov_env.tmp_path), \ + with ollama_globals(ollama_env.tmp_path), \ _urlopen_with_response(["Password1"]), \ - mock.patch("subprocess.Popen", return_value=proc): - hc_main.hcatMarkov( - "0", markov_env.hash_file, "wordlist", markov_env.wordlist, 10, + mock.patch("subprocess.Popen", return_value=proc), \ + mock.patch("hate_crack.main.generate_session_id", return_value="test_session"): + hc_main.hcatOllama( + "0", ollama_env.hash_file, "wordlist", ollama_env.wordlist, ) captured = capsys.readouterr() assert "Killing PID" in captured.out proc.kill.assert_called_once() - def test_hcstat2gen_no_output_file(self, markov_env, capsys): - """If hcstat2gen doesn't produce a raw output file, abort with error.""" - proc = mock.MagicMock() - proc.communicate.return_value = (b"", b"") - proc.returncode = 0 - # Don't create hcstat2_raw_path → triggers the "did not produce" error - - with markov_globals(markov_env.tmp_path), \ - _urlopen_with_response(["Password1"]), \ - mock.patch("subprocess.Popen", return_value=proc): - hc_main.hcatMarkov( - "0", markov_env.hash_file, "wordlist", markov_env.wordlist, 10, - ) - - captured = capsys.readouterr() - assert "did not produce output file" in captured.out - - def test_lzma_compression_error(self, markov_env, capsys): - """LZMAError during compression → error message, no hashcat run.""" - hcstat2_raw_path = f"{markov_env.hash_file}.hcstat2_raw" - - def track_popen(cmd, **kwargs): - proc = _make_proc() - if cmd[0] == markov_env.hcstat2gen: - with open(hcstat2_raw_path, "wb") as f: - f.write(b"\x00" * 100) - return proc - - with markov_globals(markov_env.tmp_path), \ - _urlopen_with_response(["Password1"]), \ - mock.patch("subprocess.Popen", side_effect=track_popen) as mock_popen, \ - mock.patch("hate_crack.main.lzma.compress", side_effect=lzma.LZMAError("bad data")): - hc_main.hcatMarkov( - "0", markov_env.hash_file, "wordlist", markov_env.wordlist, 10, - ) - - captured = capsys.readouterr() - assert "Error compressing" in captured.out - # Should only have 1 Popen call (hcstat2gen), NOT a second for hashcat - assert mock_popen.call_count == 1 - - - -# --------------------------------------------------------------------------- -# Step E: Hashcat command construction -# --------------------------------------------------------------------------- - -class TestHcatMarkovHashcatCommand: - """Test hashcat command flags and process handling.""" - - def _run_full(self, markov_env, tuning="", potfile=""): - """Run hcatMarkov through all steps, returning the list of Popen calls.""" - hcstat2_raw_path = f"{markov_env.hash_file}.hcstat2_raw" - popen_calls = [] - - def track_popen(cmd, **kwargs): - popen_calls.append((list(cmd), dict(kwargs))) - proc = _make_proc() - # hcstat2gen: create the raw output file (LZMA2 compression step will follow) - if cmd[0] == markov_env.hcstat2gen: - with open(hcstat2_raw_path, "wb") as f: - f.write(b"\x00" * 100) - return proc - - with markov_globals(markov_env.tmp_path, tuning=tuning, potfile=potfile), \ - _urlopen_with_response(["Password1"]), \ - mock.patch("subprocess.Popen", side_effect=track_popen), \ - mock.patch("hate_crack.main.generate_session_id", return_value="test_session"): - hc_main.hcatMarkov( - "1000", markov_env.hash_file, "wordlist", markov_env.wordlist, 10, - ) - - return popen_calls - - def test_hashcat_command_structure(self, markov_env): - """Hashcat cmd should include -m, -a 3, --markov-hcstat2, --increment, mask.""" - calls = self._run_full(markov_env) - assert len(calls) >= 2, "Expected at least hcstat2gen + hashcat Popen calls" - - hashcat_cmd = calls[1][0] - assert hashcat_cmd[0] == "/usr/bin/hashcat" - assert "-m" in hashcat_cmd - m_idx = hashcat_cmd.index("-m") - assert hashcat_cmd[m_idx + 1] == "1000" - assert "-a" in hashcat_cmd - a_idx = hashcat_cmd.index("-a") - assert hashcat_cmd[a_idx + 1] == "3" - # Check markov hcstat2 flag - hcstat2_flags = [f for f in hashcat_cmd if "--markov-hcstat2=" in f] - assert len(hcstat2_flags) == 1 - assert hcstat2_flags[0].endswith(".hcstat2") - assert "--increment" in hashcat_cmd - # Mask should be the last positional arg - assert "?a?a?a?a?a?a?a?a?a?a?a?a?a?a" in hashcat_cmd - - def test_hashcat_includes_tuning(self, markov_env): - """-w 3 tuning flag should be appended to hashcat cmd.""" - calls = self._run_full(markov_env, tuning="-w 3") - hashcat_cmd = calls[1][0] - assert "-w" in hashcat_cmd - w_idx = hashcat_cmd.index("-w") - assert hashcat_cmd[w_idx + 1] == "3" - - def test_hashcat_includes_potfile(self, markov_env): - """--potfile-path should be present when hcatPotfilePath is set.""" - calls = self._run_full(markov_env, potfile="/tmp/test.potfile") - hashcat_cmd = calls[1][0] - potfile_flags = [f for f in hashcat_cmd if "--potfile-path=" in f] - assert len(potfile_flags) == 1 - assert potfile_flags[0] == "--potfile-path=/tmp/test.potfile" - - def test_hashcat_keyboard_interrupt(self, markov_env, capsys): - """KeyboardInterrupt during hashcat should kill the process.""" - hcstat2_raw_path = f"{markov_env.hash_file}.hcstat2_raw" - call_count = [0] - - def track_popen(cmd, **kwargs): - call_count[0] += 1 - if call_count[0] == 1: - # hcstat2gen: succeeds and creates raw file - proc = _make_proc() - proc.pid = 99999 - with open(hcstat2_raw_path, "wb") as f: - f.write(b"\x00" * 100) - else: - # hashcat: KeyboardInterrupt - proc = mock.MagicMock() - proc.pid = 99999 - proc.wait.side_effect = KeyboardInterrupt() - return proc - - with markov_globals(markov_env.tmp_path), \ - _urlopen_with_response(["Password1"]), \ - mock.patch("subprocess.Popen", side_effect=track_popen), \ - mock.patch("hate_crack.main.generate_session_id", return_value="test_session"): - hc_main.hcatMarkov( - "0", markov_env.hash_file, "wordlist", markov_env.wordlist, 10, - ) - - captured = capsys.readouterr() - assert "Killing PID" in captured.out - # --------------------------------------------------------------------------- # Step F: Cleanup # --------------------------------------------------------------------------- -class TestHcatMarkovCleanup: - """Test that temp files are removed after a successful run.""" +class TestHcatOllamaWordlistPersistence: + """Test that the generated wordlist file persists after the run.""" - def _run_full(self, markov_env): - """Run hcatMarkov through all steps successfully.""" - hcstat2_raw_path = f"{markov_env.hash_file}.hcstat2_raw" - - def track_popen(cmd, **kwargs): - proc = _make_proc() - if cmd[0] == markov_env.hcstat2gen: - with open(hcstat2_raw_path, "wb") as f: - f.write(b"\x00" * 100) - return proc - - with markov_globals(markov_env.tmp_path), \ + def test_candidates_file_persists(self, ollama_env): + """ollama_candidates wordlist should remain after the run.""" + with ollama_globals(ollama_env.tmp_path), \ _urlopen_with_response(["Password1"]), \ - mock.patch("subprocess.Popen", side_effect=track_popen), \ + mock.patch("subprocess.Popen", return_value=_make_proc()), \ mock.patch("hate_crack.main.generate_session_id", return_value="test_session"): - hc_main.hcatMarkov( - "0", markov_env.hash_file, "wordlist", markov_env.wordlist, 10, + hc_main.hcatOllama( + "0", ollama_env.hash_file, "wordlist", ollama_env.wordlist, ) - def test_temp_files_removed(self, markov_env): - """markov_candidates and hcstat2_raw should be deleted; hcstat2 should remain.""" - self._run_full(markov_env) - - candidates_path = f"{markov_env.hash_file}.markov_candidates" - hcstat2_raw_path = f"{markov_env.hash_file}.hcstat2_raw" - hcstat2_path = f"{markov_env.hash_file}.hcstat2" - - assert not os.path.exists(candidates_path), "candidates temp file should be removed" - assert not os.path.exists(hcstat2_raw_path), "raw hcstat2 temp file should be removed" - assert os.path.isfile(hcstat2_path), "compressed hcstat2 file should remain" - - def test_cleanup_ignores_missing_files(self, markov_env): - """No exception if temp files are already gone before cleanup.""" - # This just verifies the run completes without error even though - # we don't interfere with normal cleanup - self._run_full(markov_env) # should not raise + candidates_path = f"{ollama_env.hash_file}.ollama_candidates" + assert os.path.isfile(candidates_path), "candidates wordlist should persist" # --------------------------------------------------------------------------- -# attacks.py: markov_attack() UI handler +# attacks.py: ollama_attack() UI handler # --------------------------------------------------------------------------- -class TestMarkovAttackHandler: - """Test the markov_attack(ctx) menu handler in attacks.py.""" +class TestOllamaAttackHandler: + """Test the ollama_attack(ctx) menu handler in attacks.py.""" def _make_ctx(self, **overrides): - """Build a mock ctx with the attributes markov_attack() reads.""" + """Build a mock ctx with the attributes ollama_attack() reads.""" ctx = mock.MagicMock() ctx.hcatHashType = "0" ctx.hcatHashFile = "/tmp/hashes.txt" - ctx.markovWordlist = "/tmp/wordlist.txt" - ctx.markovCandidateCount = 5000 + ctx.ollamaWordlist = "/tmp/wordlist.txt" + ctx.ollamaCandidateCount = 5000 ctx.hcatWordlists = "/tmp/wordlists" for k, v in overrides.items(): setattr(ctx, k, v) return ctx def test_wordlist_mode_default(self, tmp_path): - """When cracked output exists, it becomes the default wordlist.""" - from hate_crack.attacks import markov_attack + """Default wordlist is always ollamaWordlist from config.""" + from hate_crack.attacks import ollama_attack hash_file = str(tmp_path / "hashes.txt") cracked_out = hash_file + ".out" @@ -826,51 +671,51 @@ class TestMarkovAttackHandler: ctx = self._make_ctx(hcatHashFile=hash_file) with mock.patch("builtins.input", side_effect=["1", "n", ""]): - markov_attack(ctx) + ollama_attack(ctx) - ctx.hcatMarkov.assert_called_once_with( - "0", hash_file, "wordlist", cracked_out, 5000, + ctx.hcatOllama.assert_called_once_with( + "0", hash_file, "wordlist", "/tmp/wordlist.txt", ) def test_wordlist_mode_falls_back_to_config(self): - """When cracked output does not exist, fall back to markovWordlist.""" - from hate_crack.attacks import markov_attack + """When cracked output does not exist, fall back to ollamaWordlist.""" + from hate_crack.attacks import ollama_attack ctx = self._make_ctx(hcatHashFile="/tmp/nonexistent_hashes.txt") with mock.patch("builtins.input", side_effect=["1", "n", ""]): - markov_attack(ctx) + ollama_attack(ctx) - ctx.hcatMarkov.assert_called_once_with( - "0", "/tmp/nonexistent_hashes.txt", "wordlist", "/tmp/wordlist.txt", 5000, + ctx.hcatOllama.assert_called_once_with( + "0", "/tmp/nonexistent_hashes.txt", "wordlist", "/tmp/wordlist.txt", ) def test_wordlist_mode_custom_path(self): - """Selection '1', override wordlist → resolved path passed to hcatMarkov.""" - from hate_crack.attacks import markov_attack + """Selection '1', override wordlist → resolved path passed to hcatOllama.""" + from hate_crack.attacks import ollama_attack ctx = self._make_ctx() ctx.select_file_with_autocomplete.return_value = "custom.txt" ctx._resolve_wordlist_path.return_value = "/resolved/custom.txt" with mock.patch("builtins.input", side_effect=["1", "y", ""]): - markov_attack(ctx) + ollama_attack(ctx) ctx.select_file_with_autocomplete.assert_called_once() ctx._resolve_wordlist_path.assert_called_once_with("custom.txt", "/tmp/wordlists") - ctx.hcatMarkov.assert_called_once_with( - "0", "/tmp/hashes.txt", "wordlist", "/resolved/custom.txt", 5000, + ctx.hcatOllama.assert_called_once_with( + "0", "/tmp/hashes.txt", "wordlist", "/resolved/custom.txt", ) def test_target_mode(self): - """Selection '2' → hcatMarkov('target', {company, industry, location}).""" - from hate_crack.attacks import markov_attack + """Selection '2' → hcatOllama('target', {company, industry, location}).""" + from hate_crack.attacks import ollama_attack ctx = self._make_ctx() with mock.patch("builtins.input", side_effect=["2", "AcmeCorp", "Finance", "NYC", ""]): - markov_attack(ctx) + ollama_attack(ctx) - ctx.hcatMarkov.assert_called_once() - call_args = ctx.hcatMarkov.call_args + ctx.hcatOllama.assert_called_once() + call_args = ctx.hcatOllama.call_args assert call_args[0][2] == "target" target_info = call_args[0][3] assert target_info["company"] == "AcmeCorp" @@ -878,28 +723,28 @@ class TestMarkovAttackHandler: assert target_info["location"] == "NYC" def test_invalid_selection(self, capsys): - """Selection '3' → 'Invalid selection.', no hcatMarkov call.""" - from hate_crack.attacks import markov_attack + """Selection '3' → 'Invalid selection.', no hcatOllama call.""" + from hate_crack.attacks import ollama_attack ctx = self._make_ctx() with mock.patch("builtins.input", side_effect=["3"]): - markov_attack(ctx) + ollama_attack(ctx) captured = capsys.readouterr() assert "Invalid selection" in captured.out - ctx.hcatMarkov.assert_not_called() + ctx.hcatOllama.assert_not_called() def test_wordlist_list_uses_first(self): - """When markovWordlist is a list and no cracked output exists, the first element is used.""" - from hate_crack.attacks import markov_attack + """When ollamaWordlist is a list and no cracked output exists, the first element is used.""" + from hate_crack.attacks import ollama_attack ctx = self._make_ctx( hcatHashFile="/tmp/nonexistent_hashes.txt", - markovWordlist=["/tmp/first.txt", "/tmp/second.txt"], + ollamaWordlist=["/tmp/first.txt", "/tmp/second.txt"], ) with mock.patch("builtins.input", side_effect=["1", "n", ""]): - markov_attack(ctx) + ollama_attack(ctx) - ctx.hcatMarkov.assert_called_once() - call_args = ctx.hcatMarkov.call_args + ctx.hcatOllama.assert_called_once() + call_args = ctx.hcatOllama.call_args assert call_args[0][3] == "/tmp/first.txt" From 31c1dbbe356681790aa08a56488e054869476da8 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 13 Feb 2026 19:17:50 -0500 Subject: [PATCH 05/18] refactor: rename Markov LLM attack to Ollama attack and simplify interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename markov_attack → ollama_attack and hcatMarkov → hcatOllama across menu, attacks, and tests. Remove candidate count prompts and cracked-output default wordlist logic. Rename config keys (markov* → ollama*) and drop ollamaUrl. Fix Dockerfile.test to use granular build steps. Co-Authored-By: Claude Opus 4.6 --- Dockerfile.test | 2 +- config.json.example | 5 ++--- hate_crack.py | 2 +- hate_crack/attacks.py | 30 +++++++++--------------------- tests/test_ui_menu_options.py | 2 +- 5 files changed, 14 insertions(+), 27 deletions(-) diff --git a/Dockerfile.test b/Dockerfile.test index b177a7c..a36f5bb 100644 --- a/Dockerfile.test +++ b/Dockerfile.test @@ -19,7 +19,7 @@ RUN python -m pip install -q uv==0.9.28 COPY . /workspace -RUN make install +RUN make submodules vendor-assets && uv tool install . && make clean-vendor ENV PATH="${HOME}/.local/bin:${PATH}" ENV HATE_CRACK_SKIP_INIT=1 diff --git a/config.json.example b/config.json.example index fc954e4..71439b6 100644 --- a/config.json.example +++ b/config.json.example @@ -23,8 +23,7 @@ "hashview_url": "http://localhost:8443", "hashview_api_key": "", "hashmob_api_key": "", - "ollamaUrl": "http://localhost:11434", "ollamaModel": "llama3.2", - "markovCandidateCount": 5000, - "markovWordlist": "rockyou.txt" + "ollamaCandidateCount": 5000, + "ollamaWordlist": "rockyou.txt" } diff --git a/hate_crack.py b/hate_crack.py index 5b6bfd0..f018b00 100755 --- a/hate_crack.py +++ b/hate_crack.py @@ -83,7 +83,7 @@ def get_main_menu_options(): "12": _attacks.thorough_combinator, "13": _attacks.bandrel_method, "14": _attacks.loopback_attack, - "15": _attacks.markov_attack, + "15": _attacks.ollama_attack, "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 bea9bc8..d0ad15b 100644 --- a/hate_crack/attacks.py +++ b/hate_crack/attacks.py @@ -488,20 +488,16 @@ def bandrel_method(ctx: Any) -> None: ctx.hcatBandrel(ctx.hcatHashType, ctx.hcatHashFile) -def markov_attack(ctx: Any) -> None: - print("\n\tLLM Markov Attack") +def ollama_attack(ctx: Any) -> None: + print("\n\tLLM Attack") print("\t(1) Wordlist-based generation") print("\t(2) Target-based generation") choice = input("\nSelect generation mode: ").strip() if choice == "1": - cracked_output = ctx.hcatHashFile + ".out" - if os.path.isfile(cracked_output): - default_wl = cracked_output - else: - default_wl = ctx.markovWordlist - if isinstance(default_wl, list): - default_wl = default_wl[0] if default_wl else "" + default_wl = ctx.ollamaWordlist + if isinstance(default_wl, list): + default_wl = default_wl[0] if default_wl else "" print(f"\nDefault wordlist: {default_wl}") override = input("Use a different wordlist? [y/N]: ").strip().lower() if override == "y": @@ -509,29 +505,21 @@ def markov_attack(ctx: Any) -> None: wordlist = ctx._resolve_wordlist_path(wordlist, ctx.hcatWordlists) else: wordlist = default_wl - count_input = input( - f"Number of candidates to generate [{ctx.markovCandidateCount}]: " - ).strip() - count = int(count_input) if count_input else ctx.markovCandidateCount - ctx.hcatMarkov( - ctx.hcatHashType, ctx.hcatHashFile, "wordlist", wordlist, count + ctx.hcatOllama( + ctx.hcatHashType, ctx.hcatHashFile, "wordlist", wordlist ) elif choice == "2": company = input("Company name: ").strip() industry = input("Industry: ").strip() location = input("Location: ").strip() - count_input = input( - f"Number of candidates to generate [{ctx.markovCandidateCount}]: " - ).strip() - count = int(count_input) if count_input else ctx.markovCandidateCount target_info = { "company": company, "industry": industry, "location": location, } - ctx.hcatMarkov( - ctx.hcatHashType, ctx.hcatHashFile, "target", target_info, count + ctx.hcatOllama( + ctx.hcatHashType, ctx.hcatHashFile, "target", target_info ) else: print("Invalid selection.") diff --git a/tests/test_ui_menu_options.py b/tests/test_ui_menu_options.py index 7563092..9ecf38f 100644 --- a/tests/test_ui_menu_options.py +++ b/tests/test_ui_menu_options.py @@ -25,7 +25,7 @@ MENU_OPTION_TEST_CASES = [ ("12", CLI_MODULE._attacks, "thorough_combinator", "thorough"), ("13", CLI_MODULE._attacks, "bandrel_method", "bandrel"), ("14", CLI_MODULE._attacks, "loopback_attack", "loopback"), - ("15", CLI_MODULE._attacks, "markov_attack", "markov"), + ("15", CLI_MODULE._attacks, "ollama_attack", "ollama"), ("90", CLI_MODULE, "download_hashmob_rules", "hashmob-rules"), ("91", CLI_MODULE, "weakpass_wordlist_menu", "weakpass-menu"), ("92", CLI_MODULE, "download_hashmob_wordlists", "hashmob-wordlists"), From a967ba60cdde222729befddc619c026bf628ad67 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 13 Feb 2026 19:33:23 -0500 Subject: [PATCH 06/18] feat: send full wordlist to Ollama with configurable num_ctx Remove 500-line wordlist cap and send the entire file to Ollama. Add ollamaNumCtx config key (default 32768) to control the context window size. Invert wordlist prompt to default-yes, remove unused ollamaCandidateCount config. Co-Authored-By: Claude Opus 4.6 --- config.json.example | 4 ++-- hate_crack/attacks.py | 4 ++-- hate_crack/main.py | 30 +++++++++++++++--------------- tests/test_pull_ollama_model.py | 1 - 4 files changed, 19 insertions(+), 20 deletions(-) diff --git a/config.json.example b/config.json.example index 71439b6..9f2f61b 100644 --- a/config.json.example +++ b/config.json.example @@ -24,6 +24,6 @@ "hashview_api_key": "", "hashmob_api_key": "", "ollamaModel": "llama3.2", - "ollamaCandidateCount": 5000, - "ollamaWordlist": "rockyou.txt" + "ollamaWordlist": "rockyou.txt", + "ollamaNumCtx": 32768 } diff --git a/hate_crack/attacks.py b/hate_crack/attacks.py index d0ad15b..161d7e1 100644 --- a/hate_crack/attacks.py +++ b/hate_crack/attacks.py @@ -499,8 +499,8 @@ def ollama_attack(ctx: Any) -> None: if isinstance(default_wl, list): default_wl = default_wl[0] if default_wl else "" print(f"\nDefault wordlist: {default_wl}") - override = input("Use a different wordlist? [y/N]: ").strip().lower() - if override == "y": + use_default = input("Use the default wordlist? [Y/n]: ").strip().lower() + if use_default == "n": wordlist = ctx.select_file_with_autocomplete("Enter wordlist path") wordlist = ctx._resolve_wordlist_path(wordlist, ctx.hcatWordlists) else: diff --git a/hate_crack/main.py b/hate_crack/main.py index 4bfc7da..5716b54 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -463,15 +463,6 @@ except KeyError as e: ) ) ollamaModel = default_config.get("ollamaModel", "llama3.2") -try: - ollamaCandidateCount = int(config_parser["ollamaCandidateCount"]) -except KeyError as e: - print( - "{0} is not defined in config.json using defaults from config.json.example".format( - e - ) - ) - ollamaCandidateCount = int(default_config.get("ollamaCandidateCount", 5000)) try: ollamaWordlist = config_parser["ollamaWordlist"] except KeyError as e: @@ -481,6 +472,15 @@ except KeyError as e: ) ) ollamaWordlist = default_config.get("ollamaWordlist", "rockyou.txt") +try: + ollamaNumCtx = int(config_parser["ollamaNumCtx"]) +except KeyError as e: + print( + "{0} is not defined in config.json using defaults from config.json.example".format( + e + ) + ) + ollamaNumCtx = int(default_config.get("ollamaNumCtx", 32768)) hcatExpanderBin = "expander.bin" hcatCombinatorBin = "combinator.bin" @@ -1546,19 +1546,18 @@ def hcatOllama(hcatHashType, hcatHashFile, mode, context_data): if not os.path.isfile(wordlist_path): print(f"Error: Wordlist not found: {wordlist_path}") return - sample_lines = [] + lines = [] try: with open(wordlist_path, "r", errors="ignore") as f: - for i, line in enumerate(f): - if i >= 500: - break + for line in f: stripped = line.strip() if stripped: - sample_lines.append(stripped) + lines.append(stripped) except Exception as e: print(f"Error reading wordlist: {e}") return - wordlist_sample = "\n".join(sample_lines) + print(f"Loaded {len(lines)} passwords from wordlist.") + wordlist_sample = "\n".join(lines) prompt = ( "You are a password generation expert. Below is a sample of real passwords. " "Study the patterns, character choices, and structures. Then generate hashcat rules" \ @@ -1592,6 +1591,7 @@ def hcatOllama(hcatHashType, hcatHashFile, mode, context_data): "model": ollamaModel, "prompt": prompt, "stream": False, + "options": {"num_ctx": ollamaNumCtx}, }).encode("utf-8") if debug_mode: diff --git a/tests/test_pull_ollama_model.py b/tests/test_pull_ollama_model.py index 0df75e0..832be9c 100644 --- a/tests/test_pull_ollama_model.py +++ b/tests/test_pull_ollama_model.py @@ -654,7 +654,6 @@ class TestOllamaAttackHandler: ctx.hcatHashType = "0" ctx.hcatHashFile = "/tmp/hashes.txt" ctx.ollamaWordlist = "/tmp/wordlist.txt" - ctx.ollamaCandidateCount = 5000 ctx.hcatWordlists = "/tmp/wordlists" for k, v in overrides.items(): setattr(ctx, k, v) From f3daff4bdfb4b403f7eac32ab2fb19d58f3ec3c2 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 13 Feb 2026 20:04:11 -0500 Subject: [PATCH 07/18] refactor: use cracked .out file as sole wordlist source for Ollama attack Remove ollamaWordlist config key and all references. Wordlist mode now requires the cracked hashes .out file to exist and extracts passwords by splitting on the first colon. Detect Ollama refusal responses and abort gracefully. Update tests accordingly. Co-Authored-By: Claude Opus 4.6 --- config.json.example | 1 - hate_crack/attacks.py | 15 ++++------ hate_crack/main.py | 25 +++++++--------- tests/test_pull_ollama_model.py | 52 +++++++-------------------------- 4 files changed, 25 insertions(+), 68 deletions(-) diff --git a/config.json.example b/config.json.example index 9f2f61b..6450a19 100644 --- a/config.json.example +++ b/config.json.example @@ -24,6 +24,5 @@ "hashview_api_key": "", "hashmob_api_key": "", "ollamaModel": "llama3.2", - "ollamaWordlist": "rockyou.txt", "ollamaNumCtx": 32768 } diff --git a/hate_crack/attacks.py b/hate_crack/attacks.py index 161d7e1..3158c42 100644 --- a/hate_crack/attacks.py +++ b/hate_crack/attacks.py @@ -495,16 +495,11 @@ def ollama_attack(ctx: Any) -> None: choice = input("\nSelect generation mode: ").strip() if choice == "1": - default_wl = ctx.ollamaWordlist - if isinstance(default_wl, list): - default_wl = default_wl[0] if default_wl else "" - print(f"\nDefault wordlist: {default_wl}") - use_default = input("Use the default wordlist? [Y/n]: ").strip().lower() - if use_default == "n": - wordlist = ctx.select_file_with_autocomplete("Enter wordlist path") - wordlist = ctx._resolve_wordlist_path(wordlist, ctx.hcatWordlists) - else: - wordlist = default_wl + wordlist = ctx.hcatHashFile + ".out" + if not os.path.isfile(wordlist): + print("Error: No cracked hashes output file found.") + return + print(f"\nUsing wordlist: {wordlist}") ctx.hcatOllama( ctx.hcatHashType, ctx.hcatHashFile, "wordlist", wordlist ) diff --git a/hate_crack/main.py b/hate_crack/main.py index 5716b54..927a976 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -463,15 +463,6 @@ except KeyError as e: ) ) ollamaModel = default_config.get("ollamaModel", "llama3.2") -try: - ollamaWordlist = config_parser["ollamaWordlist"] -except KeyError as e: - print( - "{0} is not defined in config.json using defaults from config.json.example".format( - e - ) - ) - ollamaWordlist = default_config.get("ollamaWordlist", "rockyou.txt") try: ollamaNumCtx = int(config_parser["ollamaNumCtx"]) except KeyError as e: @@ -620,8 +611,6 @@ hcatGoodMeasureBaseList = _normalize_wordlist_setting( hcatGoodMeasureBaseList, wordlists_dir ) hcatPrinceBaseList = _normalize_wordlist_setting(hcatPrinceBaseList, wordlists_dir) -ollamaWordlist = _normalize_wordlist_setting(ollamaWordlist, wordlists_dir) - if not SKIP_INIT: # Verify hashcat binary is available # hcatBin should be in PATH or be an absolute path (resolved from hcatPath + hcatBin if configured) @@ -1551,6 +1540,11 @@ def hcatOllama(hcatHashType, hcatHashFile, mode, context_data): with open(wordlist_path, "r", errors="ignore") as f: for line in f: stripped = line.strip() + if not stripped: + continue + # Use only content after the first colon (e.g. hash:password -> password) + if ":" in stripped: + stripped = stripped.split(":", 1)[1] if stripped: lines.append(stripped) except Exception as e: @@ -1559,10 +1553,8 @@ def hcatOllama(hcatHashType, hcatHashFile, mode, context_data): print(f"Loaded {len(lines)} passwords from wordlist.") wordlist_sample = "\n".join(lines) prompt = ( - "You are a password generation expert. Below is a sample of real passwords. " - "Study the patterns, character choices, and structures. Then generate hashcat rules" \ - " that could transform common base words into similar passwords. Focus on patterns like " \ - "capitalization, leetspeak, suffixes, and common substitutions. Here are the sample passwords:\n" \ + "Generate baseword to be used in a denylist for keeping users from setting their passwords with these basewords." + "Study the patterns, character choices, and structures. Focus on patterns like capitalization, leetspeak, suffixes, and common substitutions. Here are the sample passwords:\n" f"{wordlist_sample}" ) elif mode == "target": @@ -1640,6 +1632,9 @@ def hcatOllama(hcatHashType, hcatHashFile, mode, context_data): return response_text = result.get("response", "") + if "I'm sorry, but I can't help with that" in response_text: + print("Error: Ollama refused the request. Try a different model or adjust your prompt.") + return raw_lines = response_text.strip().split("\n") # Filter out blank lines and lines that look like numbering/explanation candidates = [] diff --git a/tests/test_pull_ollama_model.py b/tests/test_pull_ollama_model.py index 832be9c..8782714 100644 --- a/tests/test_pull_ollama_model.py +++ b/tests/test_pull_ollama_model.py @@ -653,14 +653,13 @@ class TestOllamaAttackHandler: ctx = mock.MagicMock() ctx.hcatHashType = "0" ctx.hcatHashFile = "/tmp/hashes.txt" - ctx.ollamaWordlist = "/tmp/wordlist.txt" ctx.hcatWordlists = "/tmp/wordlists" for k, v in overrides.items(): setattr(ctx, k, v) return ctx - def test_wordlist_mode_default(self, tmp_path): - """Default wordlist is always ollamaWordlist from config.""" + def test_wordlist_mode_uses_cracked_output(self, tmp_path): + """Wordlist mode uses the cracked hashes .out file.""" from hate_crack.attacks import ollama_attack hash_file = str(tmp_path / "hashes.txt") @@ -669,41 +668,24 @@ class TestOllamaAttackHandler: f.write("Password1\nSummer2024\n") ctx = self._make_ctx(hcatHashFile=hash_file) - with mock.patch("builtins.input", side_effect=["1", "n", ""]): + with mock.patch("builtins.input", side_effect=["1"]): ollama_attack(ctx) ctx.hcatOllama.assert_called_once_with( - "0", hash_file, "wordlist", "/tmp/wordlist.txt", + "0", hash_file, "wordlist", cracked_out, ) - def test_wordlist_mode_falls_back_to_config(self): - """When cracked output does not exist, fall back to ollamaWordlist.""" + def test_wordlist_mode_errors_without_cracked_output(self, capsys): + """When cracked output does not exist, error out.""" from hate_crack.attacks import ollama_attack ctx = self._make_ctx(hcatHashFile="/tmp/nonexistent_hashes.txt") - with mock.patch("builtins.input", side_effect=["1", "n", ""]): + with mock.patch("builtins.input", side_effect=["1"]): ollama_attack(ctx) - ctx.hcatOllama.assert_called_once_with( - "0", "/tmp/nonexistent_hashes.txt", "wordlist", "/tmp/wordlist.txt", - ) - - def test_wordlist_mode_custom_path(self): - """Selection '1', override wordlist → resolved path passed to hcatOllama.""" - from hate_crack.attacks import ollama_attack - - ctx = self._make_ctx() - ctx.select_file_with_autocomplete.return_value = "custom.txt" - ctx._resolve_wordlist_path.return_value = "/resolved/custom.txt" - - with mock.patch("builtins.input", side_effect=["1", "y", ""]): - ollama_attack(ctx) - - ctx.select_file_with_autocomplete.assert_called_once() - ctx._resolve_wordlist_path.assert_called_once_with("custom.txt", "/tmp/wordlists") - ctx.hcatOllama.assert_called_once_with( - "0", "/tmp/hashes.txt", "wordlist", "/resolved/custom.txt", - ) + captured = capsys.readouterr() + assert "No cracked hashes output file found" in captured.out + ctx.hcatOllama.assert_not_called() def test_target_mode(self): """Selection '2' → hcatOllama('target', {company, industry, location}).""" @@ -733,17 +715,3 @@ class TestOllamaAttackHandler: assert "Invalid selection" in captured.out ctx.hcatOllama.assert_not_called() - def test_wordlist_list_uses_first(self): - """When ollamaWordlist is a list and no cracked output exists, the first element is used.""" - from hate_crack.attacks import ollama_attack - - ctx = self._make_ctx( - hcatHashFile="/tmp/nonexistent_hashes.txt", - ollamaWordlist=["/tmp/first.txt", "/tmp/second.txt"], - ) - with mock.patch("builtins.input", side_effect=["1", "n", ""]): - ollama_attack(ctx) - - ctx.hcatOllama.assert_called_once() - call_args = ctx.hcatOllama.call_args - assert call_args[0][3] == "/tmp/first.txt" From 6fdc485a6dcd1e2243aed88db9498929aa39bd8a Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 13 Feb 2026 20:28:26 -0500 Subject: [PATCH 08/18] refactor: simplify Ollama attack to target-based generation only Remove wordlist-based generation option and menu selection. The LLM attack now goes directly to target-based prompts (company, industry, location). Co-Authored-By: Claude Opus 4.6 --- hate_crack/attacks.py | 39 +++++++++++---------------------------- 1 file changed, 11 insertions(+), 28 deletions(-) diff --git a/hate_crack/attacks.py b/hate_crack/attacks.py index 3158c42..d6ba8fd 100644 --- a/hate_crack/attacks.py +++ b/hate_crack/attacks.py @@ -490,31 +490,14 @@ def bandrel_method(ctx: Any) -> None: def ollama_attack(ctx: Any) -> None: print("\n\tLLM Attack") - print("\t(1) Wordlist-based generation") - print("\t(2) Target-based generation") - choice = input("\nSelect generation mode: ").strip() - - if choice == "1": - wordlist = ctx.hcatHashFile + ".out" - if not os.path.isfile(wordlist): - print("Error: No cracked hashes output file found.") - return - print(f"\nUsing wordlist: {wordlist}") - ctx.hcatOllama( - ctx.hcatHashType, ctx.hcatHashFile, "wordlist", wordlist - ) - - elif choice == "2": - company = input("Company name: ").strip() - industry = input("Industry: ").strip() - location = input("Location: ").strip() - target_info = { - "company": company, - "industry": industry, - "location": location, - } - ctx.hcatOllama( - ctx.hcatHashType, ctx.hcatHashFile, "target", target_info - ) - else: - print("Invalid selection.") + company = input("Company name: ").strip() + industry = input("Industry: ").strip() + location = input("Location: ").strip() + target_info = { + "company": company, + "industry": industry, + "location": location, + } + ctx.hcatOllama( + ctx.hcatHashType, ctx.hcatHashFile, "target", target_info + ) From 762dede867f445a39cd02d33afdf89d00f813f19 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 13 Feb 2026 21:01:19 -0500 Subject: [PATCH 09/18] feat: add Ollama model benchmark script with context window tuning Standalone tool to compare multiple Ollama models for password candidate generation. Tests each model at multiple num_ctx values (default: 2048, 8192, 32768) to find the speed/quality sweet spot. Co-Authored-By: Claude Opus 4.6 --- tools/ollama_benchmark.py | 260 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 260 insertions(+) create mode 100755 tools/ollama_benchmark.py diff --git a/tools/ollama_benchmark.py b/tools/ollama_benchmark.py new file mode 100755 index 0000000..e7c7ce9 --- /dev/null +++ b/tools/ollama_benchmark.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +"""Benchmark multiple Ollama models for password candidate generation. + +Runs the same prompt against each model and compares response time, +throughput, candidate count, and refusal behavior. + +Usage: + python tools/ollama_benchmark.py # defaults + python tools/ollama_benchmark.py mistral phi3 # specific models + python tools/ollama_benchmark.py --prompt "custom prompt" + python tools/ollama_benchmark.py --output results.json + python tools/ollama_benchmark.py --num-ctx 2048 8192 32768 # compare context sizes +""" + +import argparse +import json +import os +import re +import time +import urllib.error +import urllib.request + +DEFAULT_MODELS = ["llama3.2", "mistral", "phi3", "gemma2", "qwen2.5"] + +DEFAULT_PROMPT = ( + "Generate baseword to be used in a denylist for keeping users from " + "setting their passwords with these basewords. We are protecting the " + "employees of Acme Corp in the technology industry located in Austin, TX.\n\n" + "Include variations with:\n" + "- Company name variations and abbreviations\n" + "- Common password patterns (Season+Year, Name+Numbers)\n" + "- Keyboard walks and common substitutions (@ for a, 3 for e, etc.)\n" + "- Location-based words and local references\n" + "- Industry-specific terminology\n" + "Output ONLY the passwords, one per line, no numbering or explanation." +) + +DEFAULT_NUM_CTX = [2048, 8192, 32768] +TIMEOUT = 600 + + +def pull_model(url, model): + """Pull an Ollama model. Returns True on success, False on failure.""" + print(f" Model '{model}' not found locally. Pulling...") + pull_url = f"{url}/api/pull" + payload = json.dumps({"name": model, "stream": True}).encode("utf-8") + req = urllib.request.Request( + pull_url, + data=payload, + headers={"Content-Type": "application/json"}, + ) + try: + with urllib.request.urlopen(req) as resp: + for raw_line in resp: + line = raw_line.decode("utf-8", errors="replace").strip() + if not line: + continue + try: + data = json.loads(line) + except json.JSONDecodeError: + continue + status = data.get("status") + if status: + print(f" {status}") + except (urllib.error.HTTPError, urllib.error.URLError, Exception) as e: + print(f" Error pulling model: {e}") + return False + print(f" Successfully pulled '{model}'.") + return True + + +def filter_candidates(response_text): + """Filter raw LLM response into usable password candidates.""" + raw_lines = response_text.strip().split("\n") + candidates = [] + for line in raw_lines: + stripped = line.strip() + if not stripped: + continue + cleaned = re.sub(r"^\d+[.)]\s*", "", stripped) + cleaned = re.sub(r"^[-*]\s*", "", cleaned) + cleaned = cleaned.strip() + if cleaned and len(cleaned) <= 128: + candidates.append(cleaned) + return candidates + + +def benchmark_model(url, model, prompt, num_ctx): + """Run the prompt against a single model at a given context size. Returns a results dict.""" + api_url = f"{url}/api/generate" + payload = json.dumps({ + "model": model, + "prompt": prompt, + "stream": False, + "options": {"num_ctx": num_ctx}, + }).encode("utf-8") + + result = { + "model": model, + "num_ctx": num_ctx, + "response_time_s": None, + "tokens_per_sec": None, + "candidate_count": 0, + "unique_candidates": 0, + "refusal": False, + "error": None, + } + + req = urllib.request.Request( + api_url, + data=payload, + headers={"Content-Type": "application/json"}, + ) + + start = time.monotonic() + try: + with urllib.request.urlopen(req, timeout=TIMEOUT) as resp: + body = json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as e: + if e.code == 404: + if not pull_model(url, model): + result["error"] = f"could not pull model" + return result + # Retry after pull + req = urllib.request.Request( + api_url, + data=payload, + headers={"Content-Type": "application/json"}, + ) + start = time.monotonic() + try: + with urllib.request.urlopen(req, timeout=TIMEOUT) as resp: + body = json.loads(resp.read().decode("utf-8")) + except Exception as retry_err: + result["error"] = str(retry_err) + return result + else: + result["error"] = f"HTTP {e.code}" + return result + except (urllib.error.URLError, Exception) as e: + result["error"] = str(e) + return result + + elapsed = time.monotonic() - start + result["response_time_s"] = round(elapsed, 2) + + # Extract tokens/sec from Ollama response metadata + eval_count = body.get("eval_count") + eval_duration = body.get("eval_duration") # nanoseconds + if eval_count and eval_duration and eval_duration > 0: + result["tokens_per_sec"] = round(eval_count / (eval_duration / 1e9), 2) + + response_text = body.get("response", "") + + # Refusal detection + if "I'm sorry" in response_text or "I can't help with that" in response_text: + result["refusal"] = True + + candidates = filter_candidates(response_text) + result["candidate_count"] = len(candidates) + result["unique_candidates"] = len(set(candidates)) + + return result + + +def print_table(results): + """Print a formatted comparison table.""" + headers = ["Model", "num_ctx", "Time (s)", "Tok/s", "Candidates", "Unique", "Refusal", "Error"] + rows = [] + for r in results: + rows.append([ + r["model"], + str(r["num_ctx"]), + str(r["response_time_s"] or "-"), + str(r["tokens_per_sec"] or "-"), + str(r["candidate_count"]), + str(r["unique_candidates"]), + "YES" if r["refusal"] else "no", + r["error"] or "", + ]) + + # Calculate column widths + col_widths = [len(h) for h in headers] + for row in rows: + for i, cell in enumerate(row): + col_widths[i] = max(col_widths[i], len(cell)) + + def fmt_row(cells): + return " ".join(cell.ljust(col_widths[i]) for i, cell in enumerate(cells)) + + print() + print(fmt_row(headers)) + print(" ".join("-" * w for w in col_widths)) + for row in rows: + print(fmt_row(row)) + print() + + +def main(): + parser = argparse.ArgumentParser( + description="Benchmark Ollama models for password candidate generation", + ) + parser.add_argument( + "models", + nargs="*", + default=DEFAULT_MODELS, + help=f"Models to benchmark (default: {' '.join(DEFAULT_MODELS)})", + ) + parser.add_argument( + "--prompt", + default=DEFAULT_PROMPT, + help="Override the generation prompt", + ) + parser.add_argument( + "--num-ctx", + nargs="+", + type=int, + default=DEFAULT_NUM_CTX, + metavar="N", + help=f"Context window sizes to test (default: {' '.join(str(n) for n in DEFAULT_NUM_CTX)})", + ) + parser.add_argument( + "--output", + metavar="FILE", + help="Write raw results to a JSON file", + ) + args = parser.parse_args() + + url = os.environ.get("OLLAMA_HOST", "http://localhost:11434").rstrip("/") + if not url.startswith("http"): + url = f"http://{url}" + + print(f"Ollama endpoint: {url}") + print(f"Models: {', '.join(args.models)}") + print(f"num_ctx values: {', '.join(str(n) for n in args.num_ctx)}") + print() + + results = [] + for model in args.models: + for num_ctx in args.num_ctx: + print(f"Benchmarking {model} (num_ctx={num_ctx})...") + r = benchmark_model(url, model, args.prompt, num_ctx) + if r["error"]: + print(f" Error: {r['error']}") + else: + print(f" {r['response_time_s']}s, {r['tokens_per_sec']} tok/s, " + f"{r['candidate_count']} candidates ({r['unique_candidates']} unique)" + f"{', REFUSED' if r['refusal'] else ''}") + results.append(r) + + print_table(results) + + if args.output: + with open(args.output, "w") as f: + json.dump(results, f, indent=2) + print(f"Results written to {args.output}") + + +if __name__ == "__main__": + main() From a5e3b65030ecec4e7ed3fdfc750ea1001ea17d2f Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 13 Feb 2026 21:04:43 -0500 Subject: [PATCH 10/18] chore: update default Ollama model to qwen2.5 with num_ctx 8192 Benchmarking showed qwen2.5 at 8192 context is the best default for speed/quality balance. Co-Authored-By: Claude Opus 4.6 --- config.json.example | 4 ++-- hate_crack/main.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/config.json.example b/config.json.example index 6450a19..4c3aa0f 100644 --- a/config.json.example +++ b/config.json.example @@ -23,6 +23,6 @@ "hashview_url": "http://localhost:8443", "hashview_api_key": "", "hashmob_api_key": "", - "ollamaModel": "llama3.2", - "ollamaNumCtx": 32768 + "ollamaModel": "qwen2.5", + "ollamaNumCtx": 8192 } diff --git a/hate_crack/main.py b/hate_crack/main.py index 927a976..569f564 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -462,7 +462,7 @@ except KeyError as e: e ) ) - ollamaModel = default_config.get("ollamaModel", "llama3.2") + ollamaModel = default_config.get("ollamaModel", "qwen2.5") try: ollamaNumCtx = int(config_parser["ollamaNumCtx"]) except KeyError as e: @@ -471,7 +471,7 @@ except KeyError as e: e ) ) - ollamaNumCtx = int(default_config.get("ollamaNumCtx", 32768)) + ollamaNumCtx = int(default_config.get("ollamaNumCtx", 8192)) hcatExpanderBin = "expander.bin" hcatCombinatorBin = "combinator.bin" From 8d45881c7ef3b77591e39cc94c650a474cbe0e73 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 13 Feb 2026 21:58:17 -0500 Subject: [PATCH 11/18] feat: auto-derive version from git tags using setuptools-scm Replace hardcoded version with setuptools-scm so the version updates automatically from git tags. The ASCII banner strips the dirty date suffix but preserves the git node hash. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 + hate_crack/__init__.py | 3 +++ hate_crack/main.py | 10 +++++++--- pyproject.toml | 7 +++++-- 4 files changed, 16 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index d5d994e..b2c3dac 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ config.json hashcat.pot hate_crack/__init__.pyc +hate_crack/_version.py __pycache__/ hate_crack/__pycache__/ tests/__pycache__/ diff --git a/hate_crack/__init__.py b/hate_crack/__init__.py index cb37f13..e822b6d 100644 --- a/hate_crack/__init__.py +++ b/hate_crack/__init__.py @@ -1 +1,4 @@ # hate_crack package +from hate_crack._version import __version__, __version_tuple__ + +__all__ = ["__version__", "__version_tuple__"] diff --git a/hate_crack/main.py b/hate_crack/main.py index 569f564..f17b1b1 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -773,15 +773,19 @@ def usage(): def ascii_art(): + import re + from hate_crack import __version__ + + version = re.sub(r"\.d\d{8}$", "", __version__) print(r""" - ___ ___ __ _________ __ + ___ ___ __ _________ __ / | \_____ _/ |_ ____ \_ ___ \____________ ____ | | __ / ~ \__ \\ __\/ __ \ / \ \/\_ __ \__ \ _/ ___\| |/ / -\ Y // __ \| | \ ___/ \ \____| | \// __ \\ \___| < +\ Y // __ \| | \ ___/ \ \____| | \// __ \\ \___| < \___|_ /(____ /__| \___ >____\______ /|__| (____ /\___ >__|_ \ \/ \/ \/_____/ \/ \/ \/ \/ - Version 2.0 + Version """ + version + """ """) diff --git a/pyproject.toml b/pyproject.toml index fe707bf..89e2ecf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,10 @@ [build-system] -requires = ["setuptools>=69"] +requires = ["setuptools>=69", "setuptools-scm>=8"] build-backend = "setuptools.build_meta" [project] name = "hate_crack" -version = "2.0" +dynamic = ["version"] description = "Menu driven Python wrapper for hashcat" readme = "README.md" requires-python = ">=3.13" @@ -37,6 +37,9 @@ hate_crack = [ "princeprocessor/**", ] +[tool.setuptools_scm] +version_file = "hate_crack/_version.py" + [tool.ruff] exclude = [ "build", From 243fdd305e892a09a9e38b992e2c93987581e031 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 13 Feb 2026 22:01:33 -0500 Subject: [PATCH 12/18] fix: update ollama tests to match refactored target-only handler Co-Authored-By: Claude Opus 4.6 --- tests/test_pull_ollama_model.py | 54 +++++---------------------------- 1 file changed, 7 insertions(+), 47 deletions(-) diff --git a/tests/test_pull_ollama_model.py b/tests/test_pull_ollama_model.py index 8782714..43b0d07 100644 --- a/tests/test_pull_ollama_model.py +++ b/tests/test_pull_ollama_model.py @@ -325,8 +325,8 @@ class TestHcatOllamaModeRouting: assert "Wordlist not found" in captured.out mock_url.assert_not_called() - def test_wordlist_mode_reads_up_to_500_lines(self, ollama_env, capsys): - """Only the first 500 non-blank lines should appear in the prompt payload.""" + def test_wordlist_mode_reads_all_lines(self, ollama_env, capsys): + """All non-blank lines from the wordlist should appear in the prompt.""" # Write 600 lines to the wordlist big_wordlist = ollama_env.tmp_path / "big.txt" big_wordlist.write_text("\n".join(f"pass{i}" for i in range(600)) + "\n") @@ -347,9 +347,10 @@ class TestHcatOllamaModeRouting: ) prompt_text = captured_payload["data"]["prompt"] - # Should contain pass0 through pass499 but NOT pass500+ + # All lines should be included in the prompt + assert "pass0" in prompt_text assert "pass499" in prompt_text - assert "pass500" not in prompt_text + assert "pass599" in prompt_text def test_target_mode_includes_context_in_prompt(self, ollama_env, capsys): """Company, industry, and location should appear in the prompt.""" @@ -658,41 +659,12 @@ class TestOllamaAttackHandler: setattr(ctx, k, v) return ctx - def test_wordlist_mode_uses_cracked_output(self, tmp_path): - """Wordlist mode uses the cracked hashes .out file.""" - from hate_crack.attacks import ollama_attack - - hash_file = str(tmp_path / "hashes.txt") - cracked_out = hash_file + ".out" - with open(cracked_out, "w") as f: - f.write("Password1\nSummer2024\n") - - ctx = self._make_ctx(hcatHashFile=hash_file) - with mock.patch("builtins.input", side_effect=["1"]): - ollama_attack(ctx) - - ctx.hcatOllama.assert_called_once_with( - "0", hash_file, "wordlist", cracked_out, - ) - - def test_wordlist_mode_errors_without_cracked_output(self, capsys): - """When cracked output does not exist, error out.""" - from hate_crack.attacks import ollama_attack - - ctx = self._make_ctx(hcatHashFile="/tmp/nonexistent_hashes.txt") - with mock.patch("builtins.input", side_effect=["1"]): - ollama_attack(ctx) - - captured = capsys.readouterr() - assert "No cracked hashes output file found" in captured.out - ctx.hcatOllama.assert_not_called() - def test_target_mode(self): - """Selection '2' → hcatOllama('target', {company, industry, location}).""" + """ollama_attack prompts for company/industry/location and calls hcatOllama.""" from hate_crack.attacks import ollama_attack ctx = self._make_ctx() - with mock.patch("builtins.input", side_effect=["2", "AcmeCorp", "Finance", "NYC", ""]): + with mock.patch("builtins.input", side_effect=["AcmeCorp", "Finance", "NYC"]): ollama_attack(ctx) ctx.hcatOllama.assert_called_once() @@ -703,15 +675,3 @@ class TestOllamaAttackHandler: assert target_info["industry"] == "Finance" assert target_info["location"] == "NYC" - def test_invalid_selection(self, capsys): - """Selection '3' → 'Invalid selection.', no hcatOllama call.""" - from hate_crack.attacks import ollama_attack - - ctx = self._make_ctx() - with mock.patch("builtins.input", side_effect=["3"]): - ollama_attack(ctx) - - captured = capsys.readouterr() - assert "Invalid selection" in captured.out - ctx.hcatOllama.assert_not_called() - From bfcaed3d5e2a6fc4b6d2604449e5ab5a8fad3fc9 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 13 Feb 2026 22:06:53 -0500 Subject: [PATCH 13/18] fix: use no-guess-dev version scheme to keep 2.0 base version Prevents setuptools-scm from bumping to 2.1 on dirty builds. Co-Authored-By: Claude Opus 4.6 --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 89e2ecf..4414bbc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ hate_crack = [ [tool.setuptools_scm] version_file = "hate_crack/_version.py" +version_scheme = "no-guess-dev" [tool.ruff] exclude = [ From c01601cf67c85280ee30b942c6cd33e538d61b73 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 13 Feb 2026 22:11:31 -0500 Subject: [PATCH 14/18] fix: clean version string to show 2.0+g format Strip .postN.devN and .dYYYYMMDD suffixes from setuptools-scm version in __init__.py so the banner displays a clean version like 2.0+g05b5d6dc7. Co-Authored-By: Claude Opus 4.6 --- hate_crack/__init__.py | 11 ++++++++++- hate_crack/main.py | 4 +--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/hate_crack/__init__.py b/hate_crack/__init__.py index e822b6d..6b0d048 100644 --- a/hate_crack/__init__.py +++ b/hate_crack/__init__.py @@ -1,4 +1,13 @@ # hate_crack package -from hate_crack._version import __version__, __version_tuple__ +import re as _re + +from hate_crack._version import __version__ as _raw_version +from hate_crack._version import __version_tuple__ + +# Clean setuptools-scm version for display: +# "2.0.post1.dev0+g05b5d6dc7.d20260214" → "2.0+g05b5d6dc7" +# "2.0.post1.dev1+g1234abc" → "2.0+g1234abc" +# "2.0" → "2.0" +__version__ = _re.sub(r"(\.post\d+\.dev\d+|\.d\d{8})", "", _raw_version) __all__ = ["__version__", "__version_tuple__"] diff --git a/hate_crack/main.py b/hate_crack/main.py index f17b1b1..7effe45 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -773,10 +773,8 @@ def usage(): def ascii_art(): - import re from hate_crack import __version__ - version = re.sub(r"\.d\d{8}$", "", __version__) print(r""" ___ ___ __ _________ __ @@ -785,7 +783,7 @@ def ascii_art(): \ Y // __ \| | \ ___/ \ \____| | \// __ \\ \___| < \___|_ /(____ /__| \___ >____\______ /|__| (____ /\___ >__|_ \ \/ \/ \/_____/ \/ \/ \/ \/ - Version """ + version + """ + Version """ + __version__ + """ """) From fe467c1d34b1d2134054d1be65f925c08bef5be4 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 13 Feb 2026 22:15:48 -0500 Subject: [PATCH 15/18] fix: resolve CI test failures in ollama and hashview tests - Mock rulesDirectory in ollama test fixture so hcatOllama doesn't fail with FileNotFoundError on CI where /path/to/hashcat/rules doesn't exist - Mock potfile path in hashview auto-merge test so found file cleanup isn't blocked by missing ~/.hashcat directory - Update pre-push hook to match CI env vars (HATE_CRACK_SKIP_INIT=1) Co-Authored-By: Claude Opus 4.6 --- tests/test_hashview.py | 4 ++++ tests/test_pull_ollama_model.py | 3 +++ 2 files changed, 7 insertions(+) diff --git a/tests/test_hashview.py b/tests/test_hashview.py index 53ac183..8a1df3c 100644 --- a/tests/test_hashview.py +++ b/tests/test_hashview.py @@ -587,6 +587,10 @@ class TestHashviewAPI: # Set up session.get to return different responses api.session.get.side_effect = [mock_left_response, mock_found_response] + # Mock potfile path so cleanup isn't blocked by missing ~/.hashcat dir + potfile = str(tmp_path / "hashcat.potfile") + monkeypatch.setattr("hate_crack.api.get_hcat_potfile_path", lambda: potfile) + # Download left hashes (should auto-download and split found for hashcat) left_file = tmp_path / "left_1_2.txt" result = api.download_left_hashes(1, 2, output_file=str(left_file)) diff --git a/tests/test_pull_ollama_model.py b/tests/test_pull_ollama_model.py index 43b0d07..4d3cb6e 100644 --- a/tests/test_pull_ollama_model.py +++ b/tests/test_pull_ollama_model.py @@ -42,12 +42,15 @@ def ollama_env(tmp_path): @contextmanager def ollama_globals(tmp_path, tuning="", potfile=""): """Patch the hc_main globals that hcatOllama reads.""" + rules_dir = str(tmp_path / "rules") + os.makedirs(rules_dir, exist_ok=True) with mock.patch.object(hc_main, "ollamaUrl", OLLAMA_URL), \ mock.patch.object(hc_main, "ollamaModel", MODEL), \ mock.patch.object(hc_main, "hcatBin", "/usr/bin/hashcat"), \ mock.patch.object(hc_main, "hcatTuning", tuning), \ mock.patch.object(hc_main, "hcatPotfilePath", potfile), \ mock.patch.object(hc_main, "hate_path", str(tmp_path)), \ + mock.patch.object(hc_main, "rulesDirectory", rules_dir), \ mock.patch("hate_crack.main.generate_session_id", return_value="test_session"): yield From 386d08c765f52a10ecc0a21d1f0ee8b97825feed Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 13 Feb 2026 22:20:19 -0500 Subject: [PATCH 16/18] docs: update README for LLM attack, Ollama config, and accuracy fixes Add LLM Attack (option 15) to menu listing and attack descriptions, document Ollama config keys, fix broken code block, update pre-push hook example, add make update target, and correct CI Python versions. Co-Authored-By: Claude Opus 4.6 --- README.md | 47 +++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index cca2616..0f9282a 100644 --- a/README.md +++ b/README.md @@ -69,8 +69,6 @@ These are required for certain download/extraction flows: Manual install commands: -Manual install commands: - Ubuntu/Kali: ```bash sudo apt-get update @@ -198,6 +196,12 @@ Install OS dependencies + tool (auto-detects macOS vs Debian/Ubuntu): make install ``` +Rebuild submodules and reinstall the tool (quick update after pulling changes): + +```bash +make update +``` + Reinstall the Python tool in-place (keeps OS deps as-is): ```bash @@ -236,7 +240,7 @@ make test Install the project with optional dev dependencies (includes type stubs, linters, and testing tools): ```bash -pip install -e ".[dev]" +make dev-install ``` ### Continuous Integration @@ -309,7 +313,8 @@ Create `.git/hooks/pre-push` to automatically run checks before pushing: #!/bin/bash set -e .venv/bin/ruff check hate_crack -.venv/bin/mypy hate_crack +.venv/bin/mypy --exclude HashcatRosetta --exclude hashcat-utils --ignore-missing-imports hate_crack +HATE_CRACK_SKIP_INIT=1 HATE_CRACK_RUN_E2E=0 HATE_CRACK_RUN_DOCKER_TESTS=0 HATE_CRACK_RUN_LIVE_TESTS=0 .venv/bin/python -m pytest echo "✓ Local checks passed!" ``` @@ -408,6 +413,21 @@ Set Hashview credentials in `config.json`: } ``` +#### Ollama Configuration + +The LLM Attack (option 15) uses Ollama to generate password candidates. Configure the model and context window in `config.json`: + +```json +{ + "ollamaModel": "qwen2.5", + "ollamaNumCtx": 8192 +} +``` + +- **`ollamaModel`** — The Ollama model to use for candidate generation (default: `qwen2.5`). +- **`ollamaNumCtx`** — Context window size for the model (default: `8192`). +- The Ollama URL defaults to `http://localhost:11434`. Ensure Ollama is running before using the LLM Attack. + #### Automatic Found Hash Merging (Download Left Only) When downloading left hashes (uncracked hashes), hate_crack automatically: @@ -443,8 +463,9 @@ $ ./hate_crack.py 1000 \___|_ /(____ /__| \___ >____\______ /|__| (____ /\___ >__|_ \ \/ \/ \/_____/ \/ \/ \/ \/ Version 2.0 - +``` +------------------------------------------------------------------- ## Testing The test suite is mostly offline and uses mocks/fixtures. Live network checks and @@ -509,7 +530,7 @@ All tests use mocked API calls, so they can run without connectivity to a Hashvi ### Continuous Integration -Tests automatically run on GitHub Actions for every push and pull request (Ubuntu, Python 3.13). +Tests automatically run on GitHub Actions for every push and pull request (Ubuntu, Python 3.9 through 3.14). ------------------------------------------------------------------- @@ -527,6 +548,7 @@ Tests automatically run on GitHub Actions for every push and pull request (Ubunt (12) Thorough Combinator Attack (13) Bandrel Methodology (14) Loopback Attack + (15) LLM Attack (90) Download rules from Hashmob.net (91) Analyze Hashcat Rules @@ -655,6 +677,13 @@ Uses hashcat's loopback mode to feed cracked passwords from the current session * Uses an empty wordlist with the --loopback flag to process previously cracked passwords * Automatically downloads Hashmob rules if no rules are available locally +#### LLM Attack +Uses a local Ollama instance to generate password candidates based on target company information. Prompts for company name, industry, and location, then sends these details to the configured LLM model to produce likely password guesses. The generated candidates are fed into a hashcat wordlist+rules attack. + +* Requires a running Ollama instance (default: `http://localhost:11434`) +* Configurable model and context window via `config.json` (see Ollama Configuration below) +* Prompts for target company name, industry, and location + #### 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. @@ -687,6 +716,12 @@ Interactive menu for downloading and managing wordlists from Weakpass.com via Bi ------------------------------------------------------------------- ### Version History +Version 2.0+ + Added LLM Attack (option 15) using Ollama for AI-generated password candidates + Added Ollama configuration keys (ollamaModel, ollamaNumCtx) + Auto-versioning via setuptools-scm from git tags + CI test fixes across Python 3.9–3.14 + Version 2.0 Modularized codebase into CLI/API/attacks modules Unified CLI options with config overrides (hashview, hashcat, wordlists, pipal) From fda6289fb750ec392db1e5842a8e8e7ea9b677d7 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 13 Feb 2026 22:25:48 -0500 Subject: [PATCH 17/18] fix: add missing rulesDirectory mock in ollama 404 retry test The test built its own mock context instead of using the shared ollama_globals helper, missing the rulesDirectory and hcatPotfilePath patches. This caused FileNotFoundError on CI where /path/to/hashcat/rules doesn't exist. Co-Authored-By: Claude Opus 4.6 --- tests/test_pull_ollama_model.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_pull_ollama_model.py b/tests/test_pull_ollama_model.py index 4d3cb6e..54abe21 100644 --- a/tests/test_pull_ollama_model.py +++ b/tests/test_pull_ollama_model.py @@ -231,11 +231,15 @@ class TestHcatOllama404Retry: ] mock_pull.return_value = True + rules_dir = str(tmp_path / "rules") + os.makedirs(rules_dir, exist_ok=True) with mock.patch.object(hc_main, "ollamaUrl", self.OLLAMA_URL), \ mock.patch.object(hc_main, "ollamaModel", self.MODEL), \ mock.patch.object(hc_main, "hcatBin", "/usr/bin/hashcat"), \ mock.patch.object(hc_main, "hcatTuning", ""), \ + mock.patch.object(hc_main, "hcatPotfilePath", ""), \ mock.patch.object(hc_main, "hate_path", str(tmp_path)), \ + mock.patch.object(hc_main, "rulesDirectory", rules_dir), \ mock.patch("hate_crack.main.generate_session_id", return_value="test_session"), \ mock.patch("subprocess.Popen") as mock_popen: From 36b2687de5118d56ea1e177d6be61bf0736782ed Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 13 Feb 2026 22:30:38 -0500 Subject: [PATCH 18/18] fix: add --force --reinstall to uv tool install and add make update target Co-Authored-By: Claude Opus 4.6 --- Makefile | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 44632b5..9278900 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ .DEFAULT_GOAL := submodules -.PHONY: install reinstall dev-install dev-reinstall clean hashcat-utils submodules submodules-pre vendor-assets clean-vendor test coverage lint check ruff mypy +.PHONY: install reinstall update dev-install dev-reinstall clean hashcat-utils submodules submodules-pre vendor-assets clean-vendor test coverage lint check ruff mypy hashcat-utils: submodules $(MAKE) -C hashcat-utils @@ -58,7 +58,11 @@ install: submodules vendor-assets $(MAKE) clean-vendor; \ exit 1; \ fi - @uv tool install . + @uv tool install . --force --reinstall + @$(MAKE) clean-vendor + +update: submodules vendor-assets + @uv tool install . --force --reinstall @$(MAKE) clean-vendor reinstall: uninstall install