mirror of
https://github.com/trustedsec/hate_crack.git
synced 2026-07-28 14:47:22 -07:00
fix(llm): fix stride sampling exactness, zero-cap crash, and test quality
- Defect 1 (main.py): Replace floating-point stride loop with exact floor(k * total / cap) index formula so sampling always returns EXACTLY cap items for any 1 <= cap <= total_usable, including the stride < 2 regime where the old approach returned cap-1 items. - Defect 2 (main.py): Validate ollamaMaxSampleLines before computing the stride; values <= 0 are nonsensical and fall back to 500 (same as the default) rather than raising ZeroDivisionError. - Defect 3 (test_progress_spinner.py): Rewrite test_non_tty_no_extra_thread to patch threading.Thread and assert it was never called, so the test actually fails when the spinner starts a thread in the non-TTY path. - Defect 4 (test_progress_spinner.py): Simplify test_non_tty_prints_message to use monkeypatch.setattr instead of the dead mock.patch.object + manual assign/restore layering; removes two # type: ignore comments. - docs(README.md): Document ollamaMaxSampleLines alongside ollamaNumCtx / ollamaTimeout in the Ollama Configuration section. - tests: Add boundary-case tests for stride < 2 (total==cap, total==cap+1, total=3/cap=2, cap=1) and zero-cap fallback; make test_ollama_max_sample_lines_default exercise behaviour rather than the ambient config value. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude
parent
e5e1107359
commit
fef606dbf4
@@ -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.
|
- **`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`).
|
- **`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.
|
- **`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 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 two generation modes: **Target info** (company / industry / location) and **Wordlist** (derive denylist basewords from a sample wordlist).
|
||||||
|
|||||||
+11
-7
@@ -2073,6 +2073,9 @@ def hcatOllama(hcatHashType, hcatHashFile, mode, context_data):
|
|||||||
return
|
return
|
||||||
|
|
||||||
cap = ollamaMaxSampleLines
|
cap = ollamaMaxSampleLines
|
||||||
|
# Defect 2: cap <= 0 is nonsense; treat it as "no cap" (default 500).
|
||||||
|
if cap <= 0:
|
||||||
|
cap = 500
|
||||||
if total_usable <= cap:
|
if total_usable <= cap:
|
||||||
# No capping needed — collect all usable lines.
|
# No capping needed — collect all usable lines.
|
||||||
try:
|
try:
|
||||||
@@ -2087,21 +2090,22 @@ def hcatOllama(hcatHashType, hcatHashFile, mode, context_data):
|
|||||||
return
|
return
|
||||||
print(f"Loaded {len(sampled):,} passwords from wordlist.")
|
print(f"Loaded {len(sampled):,} passwords from wordlist.")
|
||||||
else:
|
else:
|
||||||
# Evenly-spaced stride across the file to cover its full pattern
|
# Evenly-spaced sample: the k-th pick targets index floor(k * total / cap),
|
||||||
# range without materialising all lines in memory.
|
# which yields EXACTLY cap distinct indices spanning the full range for any
|
||||||
stride = total_usable / cap
|
# 1 <= cap <= total_usable.
|
||||||
try:
|
try:
|
||||||
|
pick_set = {
|
||||||
|
(k * total_usable) // cap for k in range(cap)
|
||||||
|
}
|
||||||
sampled = []
|
sampled = []
|
||||||
usable_idx = 0.0
|
usable_idx = 0
|
||||||
next_pick = stride / 2 # start near centre of first bucket
|
|
||||||
with open(wordlist_path, "r", errors="ignore") as f:
|
with open(wordlist_path, "r", errors="ignore") as f:
|
||||||
for raw in f:
|
for raw in f:
|
||||||
w = _usable(raw)
|
w = _usable(raw)
|
||||||
if not w:
|
if not w:
|
||||||
continue
|
continue
|
||||||
if usable_idx >= next_pick and len(sampled) < cap:
|
if usable_idx in pick_set:
|
||||||
sampled.append(w)
|
sampled.append(w)
|
||||||
next_pick += stride
|
|
||||||
usable_idx += 1
|
usable_idx += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error reading wordlist: {e}")
|
print(f"Error reading wordlist: {e}")
|
||||||
|
|||||||
@@ -63,9 +63,47 @@ def env(tmp_path):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_ollama_max_sample_lines_default():
|
def test_ollama_max_sample_lines_default(env, capsys):
|
||||||
"""ollamaMaxSampleLines must default to 500."""
|
"""ollamaMaxSampleLines fallback behaviour is 500.
|
||||||
assert hc_main.ollamaMaxSampleLines == 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"
|
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
|
# hash:password splitting and blank-line skipping
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import io
|
import io
|
||||||
import sys
|
import sys
|
||||||
import threading
|
|
||||||
import time
|
import time
|
||||||
from unittest import mock
|
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."""
|
"""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:
|
monkeypatch.setattr(sys.stdout, "isatty", lambda: False)
|
||||||
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..."):
|
with spinner("Working..."):
|
||||||
pass
|
pass
|
||||||
finally:
|
|
||||||
sys.stdout.isatty = original_isatty # type: ignore[method-assign]
|
|
||||||
|
|
||||||
captured = capsys.readouterr()
|
captured = capsys.readouterr()
|
||||||
assert "Working..." in captured.out
|
assert "Working..." in captured.out
|
||||||
|
|
||||||
|
|
||||||
def test_non_tty_no_extra_thread() -> None:
|
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 = io.StringIO()
|
||||||
buf.isatty = lambda: False # type: ignore[attr-defined]
|
buf.isatty = lambda: False # type: ignore[attr-defined]
|
||||||
|
|
||||||
threads_before = threading.active_count()
|
with mock.patch("sys.stdout", buf), mock.patch(
|
||||||
with mock.patch("sys.stdout", buf):
|
"hate_crack.progress.threading.Thread"
|
||||||
|
) as mock_thread:
|
||||||
with spinner("No threads please"):
|
with spinner("No threads please"):
|
||||||
peak = threading.active_count()
|
pass
|
||||||
# The thread count during the body should not exceed baseline + 1
|
mock_thread.assert_not_called()
|
||||||
# (pytest itself may add threads; we just want no extra daemon spinner thread).
|
|
||||||
assert peak <= threads_before + 1
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user