Files
hate_crack/tests/test_hcat_ollama.py
T
Justin BollingerandClaude 39e0dd956a feat(llm): add cracked-password generation mode to the LLM attack
Adds a third LLM Attack generation mode ("cracked") that samples the
plaintexts already recovered this session from <hashfile>.out and asks the
model to infer the target organization's password conventions and emit new
candidates in the same style.

- llm.py: new _CRACKED_PROMPT (offensive candidate generation, explicitly
  not the denylist-oriented _WORDLIST_PROMPT), a _PROMPTS mode->prompt map,
  and a "cracked" branch in _build_request that tells the model not to
  repeat already-cracked passwords.
- main.py: extract the wordlist sampling logic into the shared module-level
  helper _sample_plaintext_file(path, cap, source_label) and call it from
  both the wordlist and cracked branches of hcatOllama; guard missing and
  empty .out files.
- attacks.py: offer mode 3 only when <hashfile>.out exists and is non-empty
  (matching _markov_pick_training_source), with a clear message otherwise.
- README: document the three modes and the widened ollamaMaxSampleLines
  scope.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-24 18:18:52 -04:00

317 lines
13 KiB
Python

"""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_wordlist_mode_strips_hash_prefix(ollama_env):
# hash:password lines should contribute only the post-colon plaintext.
dump = ollama_env.tmp_path / "dump.txt"
dump.write_text("aad3b435:Winter2024\nnocolonline\n")
with ollama_globals(ollama_env.tmp_path), \
mock.patch("hate_crack.main.llm.generate_candidates",
return_value=["x"]) as gen, \
mock.patch("subprocess.Popen", return_value=_make_proc()):
hc_main.hcatOllama("0", ollama_env.hash_file, "wordlist", str(dump))
sample = gen.call_args[0][4]["sample"]
assert "Winter2024" in sample
assert "aad3b435" not in sample
assert "nocolonline" in sample
def test_per_rule_runs_hashcat_with_rule_flag(ollama_env):
rules_dir = str(ollama_env.tmp_path / "rules")
os.makedirs(rules_dir, exist_ok=True)
rule_file = os.path.join(rules_dir, "test.rule")
open(rule_file, "w").close()
calls = []
def track_popen(cmd, **kwargs):
calls.append(list(cmd))
return _make_proc()
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", ""), \
mock.patch.object(hc_main, "hcatPotfilePath", ""), \
mock.patch.object(hc_main, "rulesDirectory", rules_dir), \
mock.patch("hate_crack.main.generate_session_id", return_value="s"), \
mock.patch("hate_crack.main.llm.generate_candidates",
return_value=["Password1"]), \
mock.patch("subprocess.Popen", side_effect=track_popen):
hc_main.hcatOllama("1000", ollama_env.hash_file, "target",
{"company": "X", "industry": "Y", "location": "Z"})
# call 0 = plain wordlist run, call 1 = rule run
assert len(calls) == 2
rule_call = calls[1]
assert "-r" in rule_call
assert rule_file in rule_call
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_timeout_error_reports_timeout_guidance(ollama_env, capsys):
with ollama_globals(ollama_env.tmp_path), \
mock.patch.object(hc_main, "ollamaTimeout", 300.0), \
mock.patch("hate_crack.main.llm.generate_candidates",
side_effect=hc_main.llm.LLMTimeoutError("timed out")), \
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 "timed out" in captured.out.lower()
assert "300" in captured.out
assert "ollamaTimeout" in captured.out
assert "Ensure Ollama is running" not in captured.out
popen.assert_not_called()
assert not os.path.isfile(f"{ollama_env.hash_file}.ollama_candidates")
def test_value_error_reports_message_and_aborts(ollama_env, capsys):
"""Covers the defensive `except ValueError` handler in hcatOllama."""
with ollama_globals(ollama_env.tmp_path), \
mock.patch("hate_crack.main.llm.generate_candidates",
side_effect=ValueError("boom")), \
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 "Error: boom" in captured.out
popen.assert_not_called()
assert not os.path.isfile(f"{ollama_env.hash_file}.ollama_candidates")
def test_timeout_config_forwarded_to_generate_candidates(ollama_env):
with ollama_globals(ollama_env.tmp_path), \
mock.patch.object(hc_main, "ollamaTimeout", 77.0), \
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": "X", "industry": "Y", "location": "Z"})
assert gen.call_args.kwargs["timeout"] == 77.0
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()
# ---------------------------------------------------------------------------
# cracked mode
# ---------------------------------------------------------------------------
def test_cracked_mode_samples_out_file(ollama_env):
"""cracked mode reads <hashfile>.out and passes the plaintexts as the sample."""
with open(f"{ollama_env.hash_file}.out", "w") as f:
f.write("aad3b435:Summer2024!\nbbccddee:Acme2023\n")
with ollama_globals(ollama_env.tmp_path), \
mock.patch("hate_crack.main.llm.generate_candidates",
return_value=["Winter2025!"]) as gen, \
mock.patch("subprocess.Popen", return_value=_make_proc()):
hc_main.hcatOllama("1000", ollama_env.hash_file, "cracked", None)
args = gen.call_args[0]
assert args[3] == "cracked"
sample = args[4]["sample"].splitlines()
assert sample == ["Summer2024!", "Acme2023"]
# Hash portions must not leak into the prompt.
assert "aad3b435" not in args[4]["sample"]
def test_cracked_mode_accepts_explicit_path(ollama_env):
"""An explicit path in context_data is honoured (what attacks.py passes)."""
out_path = f"{ollama_env.hash_file}.out"
with open(out_path, "w") as f:
f.write("hash:Falcons2024\n")
with ollama_globals(ollama_env.tmp_path), \
mock.patch("hate_crack.main.llm.generate_candidates",
return_value=["Falcons2025"]) as gen, \
mock.patch("subprocess.Popen", return_value=_make_proc()):
hc_main.hcatOllama("1000", ollama_env.hash_file, "cracked", out_path)
assert "Falcons2024" in gen.call_args[0][4]["sample"]
def test_cracked_mode_missing_out_file_prints_error(ollama_env, capsys):
with ollama_globals(ollama_env.tmp_path), \
mock.patch("hate_crack.main.llm.generate_candidates") as gen, \
mock.patch("subprocess.Popen") as popen:
hc_main.hcatOllama("0", ollama_env.hash_file, "cracked", None)
captured = capsys.readouterr()
assert "No cracked passwords found" in captured.out
gen.assert_not_called()
popen.assert_not_called()
def test_cracked_mode_empty_out_file_prints_error(ollama_env, capsys):
"""An existing but empty/blank .out must abort before calling the LLM."""
with open(f"{ollama_env.hash_file}.out", "w") as f:
f.write("\n \n")
with ollama_globals(ollama_env.tmp_path), \
mock.patch("hate_crack.main.llm.generate_candidates") as gen, \
mock.patch("subprocess.Popen") as popen:
hc_main.hcatOllama("0", ollama_env.hash_file, "cracked", None)
captured = capsys.readouterr()
assert "No cracked passwords yet" in captured.out
gen.assert_not_called()
popen.assert_not_called()
def test_cracked_mode_writes_candidates_to_separate_file(ollama_env):
"""The candidate file must be distinct from the .out file it samples."""
out_path = f"{ollama_env.hash_file}.out"
with open(out_path, "w") as f:
f.write("hash:Summer2024!\n")
with ollama_globals(ollama_env.tmp_path), \
mock.patch("hate_crack.main.llm.generate_candidates",
return_value=["Winter2025!"]), \
mock.patch("subprocess.Popen", return_value=_make_proc()):
hc_main.hcatOllama("1000", ollama_env.hash_file, "cracked", None)
candidates_path = f"{ollama_env.hash_file}.ollama_candidates"
assert candidates_path != out_path
with open(candidates_path) as f:
assert f.read().splitlines() == ["Winter2025!"]
# The sampled source file is untouched by candidate writing.
with open(out_path) as f:
assert f.read() == "hash:Summer2024!\n"