mirror of
https://github.com/trustedsec/hate_crack.git
synced 2026-07-28 14:47:22 -07:00
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:
co-authored by
Claude Sonnet 4.6
parent
9dd6ad746f
commit
4df1121051
+15
-136
@@ -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.")
|
||||
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user