refactor(llm): delegate candidate generation to llm module; drop auto-pull

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Justin Bollinger
2026-07-24 11:59:04 -04:00
co-authored by Claude Sonnet 4.6
parent 9dd6ad746f
commit 4df1121051
3 changed files with 156 additions and 820 deletions
+15 -136
View File
@@ -21,8 +21,6 @@ import subprocess
import shlex
import time
import argparse
import urllib.request
import urllib.error
import contextlib
import gzip
import lzma
@@ -75,6 +73,7 @@ from hate_crack.cli import ( # noqa: E402
setup_logging,
)
from hate_crack import attacks as _attacks # noqa: E402
from hate_crack import llm # noqa: E402
from hate_crack.menu import interactive_menu # noqa: E402
from hate_crack.username_detect import detect_username_hash_format # noqa: E402
@@ -2036,49 +2035,12 @@ def hcatBandrel(hcatHashType, hcatHashFile):
_run_hcat_cmd(cmd, attack_name="Bandrel", hash_file=hcatHashFile)
# 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 Ollama Attack
def hcatOllama(hcatHashType, hcatHashFile, mode, context_data):
global hcatProcess
candidates_path = f"{hcatHashFile}.ollama_candidates"
# Step A: Build LLM prompt based on mode
# Step A: normalize context into the dict generate_candidates expects.
if mode == "wordlist":
wordlist_path = context_data
if not os.path.isfile(wordlist_path):
@@ -2091,7 +2053,7 @@ def hcatOllama(hcatHashType, hcatHashFile, mode, context_data):
stripped = line.strip()
if not stripped:
continue
# Use only content after the first colon (e.g. hash:password -> password)
# hash:password -> password
if ":" in stripped:
stripped = stripped.split(":", 1)[1]
if stripped:
@@ -2100,107 +2062,29 @@ def hcatOllama(hcatHashType, hcatHashFile, mode, context_data):
print(f"Error reading wordlist: {e}")
return
print(f"Loaded {len(lines)} passwords from wordlist.")
wordlist_sample = "\n".join(lines)
prompt = (
"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}"
)
gen_context = {"sample": "\n".join(lines)}
elif mode == "target":
company = context_data.get("company", "")
industry = context_data.get("industry", "")
location = context_data.get("location", "")
prompt = (
"You are participating in a capture the flag event as a security professional. "
"You are my partner in the competition. You need to recover the password to a system to retrieve the flag. "
"Output as many possible password combinations you think might help us. "
f"The name of the fake company is {company}. They are a {industry} in {location}. "
"Use terms related to the industry as basewords and also use permutations of the company name combined with common suffixes. "
"Only output the candidate password each on a new line. Dont output any explanation. "
"Only output the password candidate. Do not number the lines or add any extra information to the output"
)
gen_context = context_data
else:
print(f"Error: Unknown LLM generation mode: {mode}")
return
# Step B: Call Ollama API to generate candidates
# Step B: generate candidates via the Atomic Agents module.
print(f"Generating password candidates via Ollama ({ollamaModel})...")
api_url = f"{ollamaUrl}/api/generate"
payload = json.dumps(
{
"model": ollamaModel,
"prompt": prompt,
"stream": False,
"options": {"num_ctx": ollamaNumCtx},
}
).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,
data=payload,
headers={"Content-Type": "application/json"},
candidates = llm.generate_candidates(
ollamaUrl, ollamaModel, ollamaNumCtx, mode, gen_context
)
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 LLM 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.")
except ValueError as e:
print(f"Error: {e}")
return
except Exception as e:
print(f"Error calling Ollama API: {e}")
return
response_text = result.get("response", "")
if "I'm sorry, but I can't help with that" in response_text:
print(f"Error generating candidates: {e}")
print(
"Error: Ollama refused the request. Try a different model or adjust your prompt."
"Ensure Ollama is running (ollama serve) and the model is pulled "
f"(ollama pull {ollamaModel})."
)
return
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.")
@@ -2215,13 +2099,8 @@ def hcatOllama(hcatHashType, hcatHashFile, mode, context_data):
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 hashcat wordlist attack with LLM-generated candidates (no rules)
# Step C: hashcat wordlist attack with the generated candidates (no rules).
print("Running wordlist attack with LLM-generated candidates...")
cmd = [
hcatBin,
@@ -2246,7 +2125,7 @@ def hcatOllama(hcatHashType, hcatHashFile, mode, context_data):
except KeyboardInterrupt:
return
# Step D: Run hashcat with LLM candidates against every rule in the rules directory
# Step D: hashcat with 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.")
+141
View File
@@ -0,0 +1,141 @@
"""Orchestration tests for hcatOllama (candidate generation is mocked)."""
import os
from types import SimpleNamespace
from contextlib import contextmanager
from unittest import mock
import pytest
os.environ["HATE_CRACK_SKIP_INIT"] = "1"
from hate_crack import main as hc_main # noqa: E402
OLLAMA_URL = "http://localhost:11434"
MODEL = "qwen2.5:32b"
@pytest.fixture
def ollama_env(tmp_path):
hash_file = tmp_path / "hashes.txt"
hash_file.touch()
wordlist = tmp_path / "sample.txt"
wordlist.write_text("password\n123456\nletmein\n")
return SimpleNamespace(
tmp_path=tmp_path, hash_file=str(hash_file), wordlist=str(wordlist)
)
@contextmanager
def ollama_globals(tmp_path, tuning="", potfile=""):
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, "ollamaNumCtx", 2048), \
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, "rulesDirectory", rules_dir), \
mock.patch("hate_crack.main.generate_session_id", return_value="s"):
yield
def _make_proc(wait_return=0):
proc = mock.MagicMock()
proc.wait.return_value = wait_return
proc.communicate.return_value = (b"", b"")
proc.returncode = wait_return
return proc
def test_pull_ollama_model_is_gone():
assert not hasattr(hc_main, "_pull_ollama_model")
def test_target_mode_passes_dict_through(ollama_env):
with ollama_globals(ollama_env.tmp_path), \
mock.patch("hate_crack.main.llm.generate_candidates",
return_value=["Password1"]) as gen, \
mock.patch("subprocess.Popen", return_value=_make_proc()):
hc_main.hcatOllama(
"0", ollama_env.hash_file, "target",
{"company": "ACME", "industry": "tech", "location": "NYC"},
)
gen.assert_called_once()
args = gen.call_args[0]
assert args[0] == OLLAMA_URL and args[1] == MODEL and args[3] == "target"
assert args[4] == {"company": "ACME", "industry": "tech", "location": "NYC"}
def test_wordlist_mode_reads_file_and_passes_sample(ollama_env):
with ollama_globals(ollama_env.tmp_path), \
mock.patch("hate_crack.main.llm.generate_candidates",
return_value=["Password1"]) as gen, \
mock.patch("subprocess.Popen", return_value=_make_proc()):
hc_main.hcatOllama("0", ollama_env.hash_file, "wordlist", ollama_env.wordlist)
ctx_data = gen.call_args[0][4]
assert "letmein" in ctx_data["sample"]
def test_missing_wordlist_prints_error(ollama_env, capsys):
with ollama_globals(ollama_env.tmp_path), \
mock.patch("hate_crack.main.llm.generate_candidates") as gen:
hc_main.hcatOllama("0", ollama_env.hash_file, "wordlist", "/no/such.txt")
captured = capsys.readouterr()
assert "Wordlist not found" in captured.out
gen.assert_not_called()
def test_writes_candidates_and_runs_hashcat(ollama_env):
calls = []
def track_popen(cmd, **kwargs):
calls.append(list(cmd))
return _make_proc()
with ollama_globals(ollama_env.tmp_path), \
mock.patch("hate_crack.main.llm.generate_candidates",
return_value=["Password1", "Summer2024"]), \
mock.patch("subprocess.Popen", side_effect=track_popen):
hc_main.hcatOllama("1000", ollama_env.hash_file, "target",
{"company": "X", "industry": "Y", "location": "Z"})
candidates_path = f"{ollama_env.hash_file}.ollama_candidates"
assert os.path.isfile(candidates_path)
with open(candidates_path) as f:
assert f.read().splitlines() == ["Password1", "Summer2024"]
# First hashcat call is the plain wordlist run with the candidates file.
assert calls and candidates_path in calls[0]
assert "-r" not in calls[0]
def test_empty_candidates_skips_hashcat(ollama_env, capsys):
with ollama_globals(ollama_env.tmp_path), \
mock.patch("hate_crack.main.llm.generate_candidates", return_value=[]), \
mock.patch("subprocess.Popen") as popen:
hc_main.hcatOllama("0", ollama_env.hash_file, "target",
{"company": "X", "industry": "Y", "location": "Z"})
captured = capsys.readouterr()
assert "no usable" in captured.out.lower()
popen.assert_not_called()
def test_generation_error_reports_and_aborts(ollama_env, capsys):
with ollama_globals(ollama_env.tmp_path), \
mock.patch("hate_crack.main.llm.generate_candidates",
side_effect=Exception("connection refused")), \
mock.patch("subprocess.Popen") as popen:
hc_main.hcatOllama("0", ollama_env.hash_file, "target",
{"company": "X", "industry": "Y", "location": "Z"})
captured = capsys.readouterr()
assert "Ensure Ollama is running" in captured.out
popen.assert_not_called()
def test_unknown_mode_prints_error(ollama_env, capsys):
with ollama_globals(ollama_env.tmp_path), \
mock.patch("hate_crack.main.llm.generate_candidates") as gen:
hc_main.hcatOllama("0", ollama_env.hash_file, "bogus", {})
captured = capsys.readouterr()
assert "Unknown LLM generation mode" in captured.out
gen.assert_not_called()
-684
View File
@@ -1,684 +0,0 @@
"""Unit tests for _pull_ollama_model helper, hcatOllama steps A-C, and ollama_attack handler."""
import io
import json
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 ollama_env(tmp_path):
"""Create the filesystem layout hcatOllama expects."""
hash_file = tmp_path / "hashes.txt"
hash_file.touch()
wordlist = tmp_path / "sample.txt"
wordlist.write_text("password\n123456\nletmein\n")
return SimpleNamespace(
tmp_path=tmp_path,
hash_file=str(hash_file),
wordlist=str(wordlist),
)
@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
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"
# ---------------------------------------------------------------------------
# hcatOllama 404-retry integration tests
# ---------------------------------------------------------------------------
class TestHcatOllama404Retry:
"""Test that hcatOllama 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 hcatOllama needs before it hits the API."""
hash_file = str(tmp_path / "hashes.txt")
open(hash_file, "w").close()
# 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
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:
proc = _make_proc()
mock_popen.return_value = proc
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
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, hcatOllama 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.hcatOllama("0", hash_file, "wordlist", wordlist)
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.hcatOllama("0", hash_file, "wordlist", wordlist)
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 TestHcatOllamaModeRouting:
"""Test mode selection, prompt building, and early-return errors."""
def test_unknown_mode_prints_error(self, ollama_env, capsys):
"""Bad mode string → error message, no API call."""
with ollama_globals(ollama_env.tmp_path), \
mock.patch("hate_crack.main.urllib.request.urlopen") as mock_url:
hc_main.hcatOllama("0", ollama_env.hash_file, "bogus", "")
captured = capsys.readouterr()
assert "Unknown LLM generation mode" in captured.out
mock_url.assert_not_called()
def test_missing_wordlist_prints_error(self, ollama_env, capsys):
"""Non-existent wordlist path → error, no API call."""
with ollama_globals(ollama_env.tmp_path), \
mock.patch("hate_crack.main.urllib.request.urlopen") as mock_url:
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_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")
captured_payload = {}
def fake_urlopen(req, **kwargs):
captured_payload["data"] = json.loads(req.data.decode())
return _generate_response(["Password1"])
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.hcatOllama(
"0", ollama_env.hash_file, "wordlist", str(big_wordlist),
)
prompt_text = captured_payload["data"]["prompt"]
# All lines should be included in the prompt
assert "pass0" in prompt_text
assert "pass499" 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."""
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 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.hcatOllama(
"0", ollama_env.hash_file, "target", target_info,
)
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 TestHcatOllamaCandidateFiltering:
"""Test regex stripping, blank-line removal, 128-char limit, empty-result handling."""
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)
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", side_effect=capture_popen):
hc_main.hcatOllama(
"0", ollama_env.hash_file, "wordlist", ollama_env.wordlist,
)
return captured.get("content")
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
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, 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, 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, ollama_env):
long_pw = "A" * 129
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, ollama_env):
exact_pw = "B" * 128
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, 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, 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 ollama_globals(ollama_env.tmp_path), \
mock.patch("hate_crack.main.urllib.request.urlopen", return_value=resp):
hc_main.hcatOllama(
"0", ollama_env.hash_file, "wordlist", ollama_env.wordlist,
)
captured = capsys.readouterr()
assert "no usable" in captured.out.lower()
# ---------------------------------------------------------------------------
# Step B: API error paths (non-404)
# ---------------------------------------------------------------------------
class TestHcatOllamaApiErrors:
"""Test connection errors and generic exceptions during the generate call."""
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.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, ollama_env, capsys):
with ollama_globals(ollama_env.tmp_path), \
mock.patch(
"hate_crack.main.urllib.request.urlopen",
side_effect=RuntimeError("boom"),
):
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, ollama_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 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.hcatOllama(
"0", ollama_env.hash_file, "wordlist", ollama_env.wordlist,
)
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
# ---------------------------------------------------------------------------
# Step C: Hashcat command construction
# ---------------------------------------------------------------------------
class TestHcatOllamaHashcatCommand:
"""Test hashcat command flags and process handling."""
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):
popen_calls.append((list(cmd), dict(kwargs)))
return _make_proc()
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.hcatOllama(
"1000", ollama_env.hash_file, "wordlist", ollama_env.wordlist,
)
return popen_calls
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.pid = 99999
proc.wait.side_effect = KeyboardInterrupt()
with ollama_globals(ollama_env.tmp_path), \
_urlopen_with_response(["Password1"]), \
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()
# ---------------------------------------------------------------------------
# Step F: Cleanup
# ---------------------------------------------------------------------------
class TestHcatOllamaWordlistPersistence:
"""Test that the generated wordlist file persists after the run."""
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", return_value=_make_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,
)
candidates_path = f"{ollama_env.hash_file}.ollama_candidates"
assert os.path.isfile(candidates_path), "candidates wordlist should persist"
# ---------------------------------------------------------------------------
# attacks.py: ollama_attack() UI handler
# ---------------------------------------------------------------------------
class TestOllamaAttackHandler:
"""Test the ollama_attack(ctx) menu handler in attacks.py."""
def _make_ctx(self, **overrides):
"""Build a mock ctx with the attributes ollama_attack() reads."""
ctx = mock.MagicMock()
ctx.hcatHashType = "0"
ctx.hcatHashFile = "/tmp/hashes.txt"
ctx.hcatWordlists = "/tmp/wordlists"
for k, v in overrides.items():
setattr(ctx, k, v)
return ctx
def test_target_mode(self):
"""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=["AcmeCorp", "Finance", "NYC"]):
ollama_attack(ctx)
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"
assert target_info["industry"] == "Finance"
assert target_info["location"] == "NYC"