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>
This commit is contained in:
Justin Bollinger
2026-07-24 18:18:52 -04:00
co-authored by Claude
parent 86fac864a0
commit 39e0dd956a
8 changed files with 446 additions and 55 deletions
+7 -2
View File
@@ -472,10 +472,14 @@ The LLM Attack (option 12) uses Ollama to generate password candidates. Configur
- **`ollamaModel`** — The Ollama model used for candidate generation (default: `qwen2.5:32b`). The LLM attack uses structured (JSON) output, so choose a model with good tool/JSON support.
- **`ollamaNumCtx`** — Context window size for the model (default: `2048`).
- **`ollamaTimeout`** — Seconds to wait for a generation response before giving up (default: `300`). Raise this if a large model is still loading into VRAM on the first request, which can otherwise exceed the timeout; hate_crack prints the elapsed timeout and this setting's name when it fires.
- **`ollamaMaxSampleLines`** — Maximum number of lines drawn from the source wordlist and included in the LLM prompt when using **Wordlist** mode (default: `500`). Lines are sampled evenly across the whole file so the prompt reflects the wordlist's full character range rather than just the head. Set to a larger value if the model has a big context window (`ollamaNumCtx`) and you want richer coverage; set it lower to reduce prompt size and generation latency. Values ≤ 0 are treated as 500.
- **`ollamaMaxSampleLines`** — Maximum number of lines drawn from the source file and included in the LLM prompt when using **Wordlist** or **Cracked passwords** mode (default: `500`). Lines are sampled evenly across the whole file so the prompt reflects the wordlist's full character range rather than just the head. Set to a larger value if the model has a big context window (`ollamaNumCtx`) and you want richer coverage; set it lower to reduce prompt size and generation latency. Values ≤ 0 are treated as 500.
- The Ollama URL defaults to `http://localhost:11434` (override via the `OLLAMA_HOST` env var). Ensure Ollama is running and the model is pulled (`ollama pull qwen2.5:32b`) before using the LLM Attack — hate_crack no longer auto-pulls missing models.
The attack offers two generation modes: **Target info** (company / industry / location) and **Wordlist** (derive denylist basewords from a sample wordlist).
The attack offers three generation modes:
1. **Target info** — company / industry / location; the model derives candidates from those details.
2. **Wordlist** — derive basewords from a sample wordlist.
3. **Cracked passwords** — feed the plaintexts already recovered this session (`<hashfile>.out`) back to the model so it can infer the target organization's own password conventions (basewords, seasons, years, suffixes, leetspeak) and generate *new* candidates in the same style. This option is only listed once at least one hash has been cracked; the sample is capped by `ollamaMaxSampleLines` exactly like Wordlist mode.
### Notifications (menu option 82)
@@ -825,6 +829,7 @@ Uses a local Ollama instance to generate password candidates for a capture-the-f
* Requires a running Ollama instance (default: `http://localhost:11434`)
* Configurable model and context window via `config.json` (see Ollama Configuration below)
* Prompts for target company name, industry, and location
* Alternatively derives basewords from a sample **wordlist**, or from the **cracked passwords** of the current session (`<hashfile>.out`) so the model mirrors the target organization's own password conventions and produces new candidates in that style (only offered once something has been cracked)
#### OMEN Attack
Uses the Ordered Markov ENumerator (OMEN) to train a statistical password model from a wordlist and generate password candidates. This attack learns patterns from known passwords and generates new candidates based on those patterns.
+10
View File
@@ -515,6 +515,12 @@ def ollama_attack(ctx: Any) -> None:
print("\n\tLLM Attack")
print("\t1. Target info (company / industry / location)")
print("\t2. Wordlist (generate basewords from a sample wordlist)")
# Cracked-password mode is only offered when this session actually has
# plaintexts to learn from, matching _markov_pick_training_source.
out_path = f"{ctx.hcatHashFile}.out"
has_cracked = os.path.isfile(out_path) and os.path.getsize(out_path) > 0
if has_cracked:
print("\t3. Cracked passwords (current session)")
choice = input("\n\tSelect generation mode: ").strip()
if choice == "1":
@@ -532,6 +538,10 @@ def ollama_attack(ctx: Any) -> None:
if not path:
return
ctx.hcatOllama(ctx.hcatHashType, ctx.hcatHashFile, "wordlist", path)
elif choice == "3" and has_cracked:
ctx.hcatOllama(ctx.hcatHashType, ctx.hcatHashFile, "cracked", out_path)
elif choice == "3":
print("\t[!] No cracked passwords yet — crack some hashes first.")
else:
print("\t[!] Invalid selection.")
+41 -1
View File
@@ -74,6 +74,37 @@ _WORDLIST_PROMPT = SystemPromptGenerator(
],
)
_CRACKED_PROMPT = SystemPromptGenerator(
background=[
"You are a security professional generating password candidates during an "
"authorized penetration test.",
"The passwords you are shown were already recovered from this specific "
"target organization, so they reveal that organization's real password "
"conventions.",
],
steps=[
"Study the recovered plaintexts for the organization's conventions: "
"basewords, capitalization, seasons and months, years, separators, "
"suffixes, and leetspeak substitutions.",
"Infer the naming habits behind them (company and product names, local "
"sports teams, site or department names, keyboard walks).",
"Generate NEW candidates that follow the same conventions, varying the "
"basewords, years, and suffixes the organization clearly favours.",
],
output_instructions=[
"Return only candidate passwords in the candidates list.",
"Do not repeat any password that appears in the input — those are already "
"cracked and retrying them is wasted work.",
"Do not include explanations, numbering, or duplicate entries.",
],
)
_PROMPTS = {
"target": _TARGET_PROMPT,
"wordlist": _WORDLIST_PROMPT,
"cracked": _CRACKED_PROMPT,
}
def _build_request(mode: str, context_data: dict) -> str:
"""Build the natural-language request string for the given mode."""
@@ -93,6 +124,14 @@ def _build_request(mode: str, context_data: dict) -> str:
"Here are sample passwords. Study their patterns and generate basewords "
"for a denylist:\n" + sample
)
if mode == "cracked":
sample = context_data.get("sample", "")
return (
"These passwords were already cracked from the target organization. "
"Study the conventions they reveal and generate as many NEW password "
"candidates as you can that follow the same conventions. Do not repeat "
"any of these:\n" + sample
)
raise ValueError(f"Unknown LLM generation mode: {mode}")
@@ -121,7 +160,8 @@ def generate_candidates(
OpenAI(base_url=f"{url}/v1", api_key="ollama", timeout=timeout),
mode=instructor.Mode.JSON,
)
prompt_generator = _TARGET_PROMPT if mode == "target" else _WORDLIST_PROMPT
# _build_request has already rejected unknown modes, so this lookup is safe.
prompt_generator = _PROMPTS[mode]
agent = AtomicAgent[GenerationInput, PasswordCandidatesOutput](
config=AgentConfig(
+89 -52
View File
@@ -2055,6 +2055,74 @@ def hcatBandrel(hcatHashType, hcatHashFile):
_run_hcat_cmd(cmd, attack_name="Bandrel", hash_file=hcatHashFile)
def _sample_plaintext_file(path, cap, source_label="wordlist"):
"""Return an evenly-spaced sample of usable plaintexts from ``path``.
``cap`` is the maximum number of lines to keep (values <= 0 fall back to the
built-in default of 500). ``source_label`` is used only in the progress and
error messages so callers can say "wordlist" or "cracked passwords".
Returns a list of plaintexts (possibly empty when the file has no usable
lines), or ``None`` if the file could not be read — in which case an error
has already been printed.
"""
# Two-pass evenly-spaced sample: first count usable lines so we can
# stride-select across the whole file rather than taking a head slice.
# A head-only sample misses the pattern variation across large wordlists
# (e.g. rockyou.txt becomes more random further in).
try:
total_usable = 0
with open(path, "r", errors="ignore") as f:
for raw in f:
if _usable_plaintext(raw):
total_usable += 1
except Exception as e:
print(f"Error reading {source_label}: {e}")
return None
# Invalid cap (zero or negative): fall back to the built-in default of 500.
if cap <= 0:
cap = 500
if total_usable <= cap:
# No capping needed — collect all usable lines.
try:
sampled: list[str] = []
with open(path, "r", errors="ignore") as f:
for raw in f:
w = _usable_plaintext(raw)
if w:
sampled.append(w)
except Exception as e:
print(f"Error reading {source_label}: {e}")
return None
print(f"Loaded {len(sampled):,} passwords from {source_label}.")
return sampled
# Evenly-spaced sample: the k-th pick targets index floor(k * total / cap),
# which yields EXACTLY cap distinct indices spanning the full range for any
# 1 <= cap <= total_usable.
try:
pick_set = {(k * total_usable) // cap for k in range(cap)}
sampled = []
usable_idx = 0
with open(path, "r", errors="ignore") as f:
for raw in f:
w = _usable_plaintext(raw)
if not w:
continue
if usable_idx in pick_set:
sampled.append(w)
usable_idx += 1
except Exception as e:
print(f"Error reading {source_label}: {e}")
return None
print(
f"Sampled {len(sampled):,} of {total_usable:,} passwords from {source_label}."
)
return sampled
# LLM Ollama Attack
def hcatOllama(hcatHashType, hcatHashFile, mode, context_data):
candidates_path = f"{hcatHashFile}.ollama_candidates"
@@ -2066,60 +2134,29 @@ def hcatOllama(hcatHashType, hcatHashFile, mode, context_data):
print(f"Error: Wordlist not found: {wordlist_path}")
return
# Two-pass evenly-spaced sample: first count usable lines so we can
# stride-select across the whole file rather than taking a head slice.
# A head-only sample misses the pattern variation across large wordlists
# (e.g. rockyou.txt becomes more random further in).
try:
total_usable = 0
with open(wordlist_path, "r", errors="ignore") as f:
for raw in f:
if _usable_plaintext(raw):
total_usable += 1
except Exception as e:
print(f"Error reading wordlist: {e}")
sampled = _sample_plaintext_file(wordlist_path, ollamaMaxSampleLines)
if sampled is None:
return
gen_context = {"sample": "\n".join(sampled)}
elif mode == "cracked":
# context_data may carry an explicit path; default to this session's
# cracked-output file.
cracked_path = context_data or f"{hcatHashFile}.out"
if not os.path.isfile(cracked_path):
print(f"Error: No cracked passwords found: {cracked_path}")
return
cap = ollamaMaxSampleLines
# Invalid cap (zero or negative): fall back to the built-in default of 500.
if cap <= 0:
cap = 500
if total_usable <= cap:
# No capping needed — collect all usable lines.
try:
sampled: list[str] = []
with open(wordlist_path, "r", errors="ignore") as f:
for raw in f:
w = _usable_plaintext(raw)
if w:
sampled.append(w)
except Exception as e:
print(f"Error reading wordlist: {e}")
return
print(f"Loaded {len(sampled):,} passwords from wordlist.")
else:
# Evenly-spaced sample: the k-th pick targets index floor(k * total / cap),
# which yields EXACTLY cap distinct indices spanning the full range for any
# 1 <= cap <= total_usable.
try:
pick_set = {
(k * total_usable) // cap for k in range(cap)
}
sampled = []
usable_idx = 0
with open(wordlist_path, "r", errors="ignore") as f:
for raw in f:
w = _usable_plaintext(raw)
if not w:
continue
if usable_idx in pick_set:
sampled.append(w)
usable_idx += 1
except Exception as e:
print(f"Error reading wordlist: {e}")
return
print(f"Sampled {len(sampled):,} of {total_usable:,} passwords from wordlist.")
sampled = _sample_plaintext_file(
cracked_path, ollamaMaxSampleLines, source_label="cracked passwords"
)
if sampled is None:
return
if not sampled:
print(
"Error: No cracked passwords yet — crack some hashes first, then "
"use this mode to generate more candidates in the same style."
)
return
gen_context = {"sample": "\n".join(sampled)}
elif mode == "target":
gen_context = context_data
+62
View File
@@ -394,3 +394,65 @@ class TestOllamaAttack:
with patch("builtins.input", side_effect=["2", "nonsense"]):
ollama_attack(ctx)
ctx.hcatOllama.assert_not_called()
def test_cracked_mode_offered_when_out_file_has_content(
self, tmp_path: Path, capsys
) -> None:
hash_file = tmp_path / "hashes.txt"
hash_file.touch()
out_file = tmp_path / "hashes.txt.out"
out_file.write_text("hash:Summer2024!\n")
ctx = _make_ctx(hash_file=str(hash_file))
with patch("builtins.input", side_effect=["3"]):
ollama_attack(ctx)
assert "3. Cracked passwords" in capsys.readouterr().out
ctx.hcatOllama.assert_called_once_with(
ctx.hcatHashType, str(hash_file), "cracked", str(out_file)
)
def test_cracked_mode_not_offered_when_out_file_missing(
self, tmp_path: Path, capsys
) -> None:
hash_file = tmp_path / "hashes.txt"
hash_file.touch()
ctx = _make_ctx(hash_file=str(hash_file))
with patch("builtins.input", side_effect=["3"]):
ollama_attack(ctx)
out = capsys.readouterr().out
assert "3. Cracked passwords" not in out
assert "No cracked passwords yet" in out
ctx.hcatOllama.assert_not_called()
def test_cracked_mode_not_offered_when_out_file_empty(
self, tmp_path: Path, capsys
) -> None:
hash_file = tmp_path / "hashes.txt"
hash_file.touch()
(tmp_path / "hashes.txt.out").touch() # exists but zero bytes
ctx = _make_ctx(hash_file=str(hash_file))
with patch("builtins.input", side_effect=["3"]):
ollama_attack(ctx)
out = capsys.readouterr().out
assert "3. Cracked passwords" not in out
assert "No cracked passwords yet" in out
ctx.hcatOllama.assert_not_called()
def test_target_and_wordlist_modes_unaffected_by_cracked_option(
self, tmp_path: Path
) -> None:
"""Existing modes still work when a cracked file is present."""
hash_file = tmp_path / "hashes.txt"
hash_file.touch()
(tmp_path / "hashes.txt.out").write_text("hash:Summer2024!\n")
ctx = _make_ctx(hash_file=str(hash_file))
with patch("builtins.input", side_effect=["1", "ACME", "tech", "NYC"]):
ollama_attack(ctx)
assert ctx.hcatOllama.call_args[0][2] == "target"
+86
View File
@@ -228,3 +228,89 @@ def test_unknown_mode_prints_error(ollama_env, capsys):
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"
+65
View File
@@ -70,6 +70,71 @@ def test_wordlist_mode_includes_sample_in_request():
assert "letmein" in run_arg.request
def test_cracked_mode_includes_sample_in_request():
p_instr, p_openai, p_agent, agent_cls, agent_instance = _patch_agent(["Winter2025!"])
with p_instr, p_openai, p_agent:
out = llm.generate_candidates(
"http://localhost:11434",
"qwen2.5:32b",
2048,
"cracked",
{"sample": "Summer2024!\nAcme2023\nP@ssw0rd1"},
)
assert out == ["Winter2025!"]
run_arg = agent_instance.run.call_args[0][0]
assert "Acme2023" in run_arg.request
# The request must tell the model not to regenerate what is already cracked.
assert "NEW" in run_arg.request
assert "Do not repeat" in run_arg.request
def test_cracked_mode_uses_its_own_prompt_not_the_denylist_one():
"""cracked mode must select _CRACKED_PROMPT, never the denylist _WORDLIST_PROMPT."""
p_instr, p_openai, p_agent, agent_cls, agent_instance = _patch_agent(["x"])
with p_instr, p_openai, p_agent:
llm.generate_candidates(
"http://localhost:11434", "qwen2.5:32b", 2048,
"cracked", {"sample": "Summer2024!"},
)
config = agent_cls.__getitem__.return_value.call_args.kwargs["config"]
assert config.system_prompt_generator is llm._CRACKED_PROMPT
assert config.system_prompt_generator is not llm._WORDLIST_PROMPT
def test_wordlist_mode_still_uses_wordlist_prompt():
p_instr, p_openai, p_agent, agent_cls, agent_instance = _patch_agent(["x"])
with p_instr, p_openai, p_agent:
llm.generate_candidates(
"http://localhost:11434", "qwen2.5:32b", 2048,
"wordlist", {"sample": "password"},
)
config = agent_cls.__getitem__.return_value.call_args.kwargs["config"]
assert config.system_prompt_generator is llm._WORDLIST_PROMPT
def test_target_mode_still_uses_target_prompt():
p_instr, p_openai, p_agent, agent_cls, agent_instance = _patch_agent(["x"])
with p_instr, p_openai, p_agent:
llm.generate_candidates(
"http://localhost:11434", "qwen2.5:32b", 2048,
"target", {"company": "X", "industry": "Y", "location": "Z"},
)
config = agent_cls.__getitem__.return_value.call_args.kwargs["config"]
assert config.system_prompt_generator is llm._TARGET_PROMPT
def test_cracked_prompt_is_offensive_not_denylist():
"""_CRACKED_PROMPT's objective is candidate generation, not denylist building."""
rendered = llm._CRACKED_PROMPT.generate_prompt()
assert "denylist" not in rendered.lower()
assert "authorized penetration test" in rendered.lower()
assert "already recovered" in rendered.lower()
def test_prompts_map_covers_every_supported_mode():
assert set(llm._PROMPTS) == {"target", "wordlist", "cracked"}
def test_dedupes_and_caps_length():
long_pw = "A" * 129
p_instr, p_openai, p_agent, agent_cls, agent_instance = _patch_agent(
+86
View File
@@ -373,3 +373,89 @@ def test_usable_plaintext_multiple_colons():
def test_usable_plaintext_hash_colon_empty():
"""A hash: line with no plaintext after the colon returns empty string."""
assert hc_main._usable_plaintext("aabbcc:") == ""
# ---------------------------------------------------------------------------
# Shared sampling helper — used by both wordlist and cracked modes
# ---------------------------------------------------------------------------
def test_sample_helper_is_module_level():
"""The sampling logic lives in one shared, module-level helper."""
assert callable(hc_main._sample_plaintext_file)
def test_sample_helper_caps_and_labels_source(tmp_path, capsys):
src = tmp_path / "src.txt"
src.write_text("\n".join(f"p{i:04d}" for i in range(100)) + "\n")
sampled = hc_main._sample_plaintext_file(str(src), 10, source_label="cracked passwords")
assert sampled is not None
assert len(sampled) == 10
captured = capsys.readouterr()
assert "Sampled 10 of 100 passwords from cracked passwords." in captured.out
def test_sample_helper_returns_none_on_read_error(tmp_path, capsys):
missing = tmp_path / "nope.txt"
with mock.patch("builtins.open", side_effect=OSError("boom")):
assert hc_main._sample_plaintext_file(str(missing), 10) is None
assert "Error reading wordlist: boom" in capsys.readouterr().out
def test_sample_helper_empty_file_returns_empty_list(tmp_path):
src = tmp_path / "empty.txt"
src.write_text("\n\n")
assert hc_main._sample_plaintext_file(str(src), 10) == []
def test_cracked_mode_uses_shared_sampling_helper(env):
"""cracked mode must route through _sample_plaintext_file, not its own copy."""
out_path = env.hash_file + ".out"
with open(out_path, "w") as f:
f.write("hash:Summer2024!\n")
with _ollama_globals(env.tmp_path, max_sample=123), mock.patch.object(
hc_main, "_sample_plaintext_file", return_value=["Summer2024!"]
) as sampler, mock.patch.object(
hc_main.llm, "generate_candidates", return_value=["Winter2025!"]
), mock.patch("subprocess.Popen", return_value=_make_proc()):
hc_main.hcatOllama("0", env.hash_file, "cracked", None)
sampler.assert_called_once_with(
out_path, 123, source_label="cracked passwords"
)
def test_wordlist_mode_uses_shared_sampling_helper(env):
wl = env.tmp_path / "wl.txt"
wl.write_text("alpha\n")
with _ollama_globals(env.tmp_path, max_sample=77), mock.patch.object(
hc_main, "_sample_plaintext_file", return_value=["alpha"]
) as sampler, mock.patch.object(
hc_main.llm, "generate_candidates", return_value=["x"]
), mock.patch("subprocess.Popen", return_value=_make_proc()):
hc_main.hcatOllama("0", env.hash_file, "wordlist", str(wl))
sampler.assert_called_once_with(str(wl), 77)
def test_cracked_mode_caps_large_out_file(env, capsys):
"""A large .out file gets the same evenly-spaced capping as a wordlist."""
out_path = env.hash_file + ".out"
with open(out_path, "w") as f:
f.write("\n".join(f"h{i}:pw{i:06d}" for i in range(1000)) + "\n")
with _ollama_globals(env.tmp_path, max_sample=25), mock.patch.object(
hc_main.llm, "generate_candidates", return_value=["x"]
) as gen, mock.patch("subprocess.Popen", return_value=_make_proc()):
hc_main.hcatOllama("0", env.hash_file, "cracked", None)
sample_lines = gen.call_args[0][4]["sample"].splitlines()
assert len(sample_lines) == 25
indices = [int(w.replace("pw", "")) for w in sample_lines]
assert min(indices) < 100
assert max(indices) >= 900
assert "Sampled 25 of 1,000 passwords from cracked passwords." in capsys.readouterr().out