mirror of
https://github.com/trustedsec/hate_crack.git
synced 2026-07-28 14:47:22 -07:00
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:
co-authored by
Claude
parent
86fac864a0
commit
39e0dd956a
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user