diff --git a/README.md b/README.md index 5749108..6b10e8c 100644 --- a/README.md +++ b/README.md @@ -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 (`.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 (`.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. diff --git a/hate_crack/attacks.py b/hate_crack/attacks.py index b63b17c..c6252e2 100644 --- a/hate_crack/attacks.py +++ b/hate_crack/attacks.py @@ -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.") diff --git a/hate_crack/llm.py b/hate_crack/llm.py index 184d4b0..f4cf5f5 100644 --- a/hate_crack/llm.py +++ b/hate_crack/llm.py @@ -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( diff --git a/hate_crack/main.py b/hate_crack/main.py index 2cce2d2..b0818eb 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -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 diff --git a/tests/test_attacks_behavior.py b/tests/test_attacks_behavior.py index 2ce1bab..edee3aa 100644 --- a/tests/test_attacks_behavior.py +++ b/tests/test_attacks_behavior.py @@ -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" diff --git a/tests/test_hcat_ollama.py b/tests/test_hcat_ollama.py index 7786535..95b0a5a 100644 --- a/tests/test_hcat_ollama.py +++ b/tests/test_hcat_ollama.py @@ -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 .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" diff --git a/tests/test_llm.py b/tests/test_llm.py index f34b3b8..70c58ed 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -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( diff --git a/tests/test_ollama_wordlist_sampling.py b/tests/test_ollama_wordlist_sampling.py index b123247..d4e193a 100644 --- a/tests/test_ollama_wordlist_sampling.py +++ b/tests/test_ollama_wordlist_sampling.py @@ -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