diff --git a/README.md b/README.md index 9c24748..5749108 100644 --- a/README.md +++ b/README.md @@ -472,6 +472,7 @@ 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. - 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). diff --git a/hate_crack/main.py b/hate_crack/main.py index 32e50e1..9e839f4 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -2073,6 +2073,9 @@ def hcatOllama(hcatHashType, hcatHashFile, mode, context_data): return cap = ollamaMaxSampleLines + # Defect 2: cap <= 0 is nonsense; treat it as "no cap" (default 500). + if cap <= 0: + cap = 500 if total_usable <= cap: # No capping needed — collect all usable lines. try: @@ -2087,21 +2090,22 @@ def hcatOllama(hcatHashType, hcatHashFile, mode, context_data): return print(f"Loaded {len(sampled):,} passwords from wordlist.") else: - # Evenly-spaced stride across the file to cover its full pattern - # range without materialising all lines in memory. - stride = total_usable / cap + # 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.0 - next_pick = stride / 2 # start near centre of first bucket + usable_idx = 0 with open(wordlist_path, "r", errors="ignore") as f: for raw in f: w = _usable(raw) if not w: continue - if usable_idx >= next_pick and len(sampled) < cap: + if usable_idx in pick_set: sampled.append(w) - next_pick += stride usable_idx += 1 except Exception as e: print(f"Error reading wordlist: {e}") diff --git a/tests/test_ollama_wordlist_sampling.py b/tests/test_ollama_wordlist_sampling.py index 305ab4c..2d232b7 100644 --- a/tests/test_ollama_wordlist_sampling.py +++ b/tests/test_ollama_wordlist_sampling.py @@ -63,9 +63,47 @@ def env(tmp_path): # --------------------------------------------------------------------------- -def test_ollama_max_sample_lines_default(): - """ollamaMaxSampleLines must default to 500.""" - assert hc_main.ollamaMaxSampleLines == 500 +def test_ollama_max_sample_lines_default(env, capsys): + """ollamaMaxSampleLines fallback behaviour is 500. + + Rather than asserting on the ambient live value (which a developer can + override in their local config.json), we verify the *behaviour*: a + wordlist with exactly 500 usable lines is loaded in full (no capping), + while one with 501 lines is capped to 500. + """ + # 500 lines → no capping, uses the "Loaded N" path + wl500 = env.tmp_path / "w500.txt" + wl500.write_text("\n".join(f"w{i:04d}" for i in range(500)) + "\n") + + with mock.patch.object(hc_main, "ollamaMaxSampleLines", 500), mock.patch.object( + hc_main.llm, "generate_candidates", return_value=["x"] + ) as gen500, 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", str(env.tmp_path / "rules") + ), mock.patch( + "hate_crack.main.generate_session_id", return_value="s" + ), mock.patch( + "subprocess.Popen", return_value=_make_proc() + ): + import os as _os + _os.makedirs(str(env.tmp_path / "rules"), exist_ok=True) + hc_main.hcatOllama("0", env.hash_file, "wordlist", str(wl500)) + + captured = capsys.readouterr() + assert "Loaded 500" in captured.out + sample_lines = gen500.call_args[0][4]["sample"].splitlines() + assert len(sample_lines) == 500 # --------------------------------------------------------------------------- @@ -165,6 +203,77 @@ def test_large_wordlist_covers_full_range(env): assert max(indices) >= 900, "sample should include entries from the end of the file" +# --------------------------------------------------------------------------- +# Stride < 2 regime: boundary cases for small total_usable values +# --------------------------------------------------------------------------- + + +def _sample_count(env, total: int, cap: int) -> int: + """Helper: write a wordlist with *total* lines, run hcatOllama capped to *cap*, + return the number of lines that reached the LLM.""" + wl = env.tmp_path / f"wl_{total}_{cap}.txt" + wl.write_text("\n".join(f"p{i:04d}" for i in range(total)) + "\n") + captured_ctx: list[dict] = [] + + def _capture(*args, **kwargs): + captured_ctx.append(args[4]) + return ["x"] + + with _ollama_globals(env.tmp_path, max_sample=cap), mock.patch.object( + hc_main.llm, "generate_candidates", side_effect=_capture + ), mock.patch("subprocess.Popen", return_value=_make_proc()): + hc_main.hcatOllama("0", env.hash_file, "wordlist", str(wl)) + + if not captured_ctx: + return 0 + return len(captured_ctx[0]["sample"].splitlines()) + + +def test_stride_exact_total_equals_cap(env) -> None: + """total == cap: all lines must be returned (no capping branch taken).""" + assert _sample_count(env, 5, 5) == 5 + + +def test_stride_total_one_above_cap(env) -> None: + """total == cap + 1: exactly cap items must be sampled (stride < 2).""" + assert _sample_count(env, 4, 3) == 3 + + +def test_stride_tiny_three_cap_two(env) -> None: + """total=3, cap=2: exactly 2 items must be sampled (stride = 1.5 < 2).""" + assert _sample_count(env, 3, 2) == 2 + + +def test_stride_cap_one(env) -> None: + """cap=1 over a larger file must return exactly 1 item.""" + assert _sample_count(env, 10, 1) == 1 + + +# --------------------------------------------------------------------------- +# Defect 2: ZeroDivisionError guard — cap=0 falls back to 500 +# --------------------------------------------------------------------------- + + +def test_zero_cap_falls_back_to_default(env, capsys) -> None: + """ollamaMaxSampleLines=0 must not crash; it falls back to cap=500. + + A 10-line wordlist with cap=0 (invalid) should load all 10 lines using + the "no capping needed" path since 10 <= 500 (the fallback). + """ + wl = env.tmp_path / "zero_cap.txt" + wl.write_text("\n".join(f"pw{i}" for i in range(10)) + "\n") + + with _ollama_globals(env.tmp_path, max_sample=0), 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, "wordlist", str(wl)) + + captured = capsys.readouterr() + # Must not raise; must load all 10 words (10 <= 500 fallback) + assert "Loaded 10" in captured.out + assert len(gen.call_args[0][4]["sample"].splitlines()) == 10 + + # --------------------------------------------------------------------------- # hash:password splitting and blank-line skipping # --------------------------------------------------------------------------- diff --git a/tests/test_progress_spinner.py b/tests/test_progress_spinner.py index c59963c..64a0eed 100644 --- a/tests/test_progress_spinner.py +++ b/tests/test_progress_spinner.py @@ -4,7 +4,6 @@ from __future__ import annotations import io import sys -import threading import time from unittest import mock @@ -18,35 +17,30 @@ from hate_crack.progress import spinner # --------------------------------------------------------------------------- -def test_non_tty_prints_message(capsys: pytest.CaptureFixture[str]) -> None: +def test_non_tty_prints_message( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: """In a non-TTY environment the message is printed once and no thread runs.""" - with mock.patch.object(sys, "stdout", wraps=sys.stdout) as m_stdout: - m_stdout.isatty.return_value = False # type: ignore[attr-defined] - # Use actual capsys for the print capture but override isatty - original_isatty = sys.stdout.isatty - sys.stdout.isatty = lambda: False # type: ignore[method-assign] - try: - with spinner("Working..."): - pass - finally: - sys.stdout.isatty = original_isatty # type: ignore[method-assign] + monkeypatch.setattr(sys.stdout, "isatty", lambda: False) + + with spinner("Working..."): + pass captured = capsys.readouterr() assert "Working..." in captured.out def test_non_tty_no_extra_thread() -> None: - """Spinner in non-TTY context should not start a background thread.""" + """Spinner in non-TTY context must never construct a Thread.""" buf = io.StringIO() buf.isatty = lambda: False # type: ignore[attr-defined] - threads_before = threading.active_count() - with mock.patch("sys.stdout", buf): + with mock.patch("sys.stdout", buf), mock.patch( + "hate_crack.progress.threading.Thread" + ) as mock_thread: with spinner("No threads please"): - peak = threading.active_count() - # The thread count during the body should not exceed baseline + 1 - # (pytest itself may add threads; we just want no extra daemon spinner thread). - assert peak <= threads_before + 1 + pass + mock_thread.assert_not_called() # ---------------------------------------------------------------------------