mirror of
https://github.com/trustedsec/hate_crack.git
synced 2026-07-28 14:47:22 -07:00
Merge pull request #132 from trustedsec/feat/ui-polish
feat(llm): cracked-password mode, target research, and LLM attack UI polish
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
3.13
|
||||||
@@ -22,9 +22,37 @@ Dates are omitted for releases predating this file; see the git tags for exact t
|
|||||||
- **Wordlist (denylist) generation mode** for the LLM attack is now reachable from the
|
- **Wordlist (denylist) generation mode** for the LLM attack is now reachable from the
|
||||||
menu: select the LLM attack (option 12), then choose "Wordlist" to derive basewords from
|
menu: select the LLM attack (option 12), then choose "Wordlist" to derive basewords from
|
||||||
a sample wordlist.
|
a sample wordlist.
|
||||||
|
- **Cracked-password generation mode** for the LLM attack. Once a session has recovered
|
||||||
|
plaintexts, option 3 feeds them back to the model, which infers the organization's own
|
||||||
|
password conventions and generates new candidates in that style. Offered only when
|
||||||
|
`<hashfile>.out` has content, and it uses a dedicated prompt that tells the model not to
|
||||||
|
re-emit passwords already cracked.
|
||||||
|
- **Target research pre-fills the industry and location prompts.** In target mode, entering
|
||||||
|
the company name asks the local model to recall that organization's industry and location,
|
||||||
|
then offers them as editable defaults (Enter accepts, typing overrides). Values are
|
||||||
|
labelled as model guesses rather than verified OSINT, whitespace-collapsed, and capped at
|
||||||
|
80 characters. Research runs entirely against the configured local Ollama server, so the
|
||||||
|
client name is never sent to a third party. Any failure or timeout falls back to blank
|
||||||
|
prompts and never blocks the attack. Disable with `ollamaAutoResearch: false`.
|
||||||
|
- **Live progress spinner** with an elapsed-seconds counter during Ollama generation, so a
|
||||||
|
model loading into VRAM is distinguishable from a hang. Automatically suppressed when
|
||||||
|
stdout is not a TTY.
|
||||||
|
- **`ollamaMaxSampleLines`** (default 500) caps how many sample passwords are sent to the
|
||||||
|
model, for both wordlist and cracked-password modes.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- **A large sample wordlist no longer stalls the LLM attack.** Wordlist mode read every line
|
||||||
|
into memory and pasted all of them into the prompt, so pointing it at `rockyou.txt`
|
||||||
|
materialized hundreds of megabytes and overran the model's context window — which looked
|
||||||
|
like a hang. The file is now streamed and evenly sampled across its whole length, and the
|
||||||
|
count actually used is reported (`Sampled 500 of 14,344,391 passwords from wordlist.`).
|
||||||
|
- **`HATE_CRACK_ARROW_MENU=1` now works in the LLM and OMEN submenus.** They hand-rolled
|
||||||
|
`print()` + `input()` instead of the shared menu helper, so arrow-key navigation silently
|
||||||
|
did nothing there.
|
||||||
|
- **A typo in a wordlist or generation-mode prompt no longer aborts the whole attack.** The
|
||||||
|
pickers and submenus re-prompt instead of dropping back to the main menu, and offer an
|
||||||
|
explicit cancel.
|
||||||
- **The LLM attack no longer hangs forever waiting on Ollama.** Generation requests are now
|
- **The LLM attack no longer hangs forever waiting on Ollama.** Generation requests are now
|
||||||
bounded by a configurable timeout (`ollamaTimeout` in `config.json`, default 300 seconds).
|
bounded by a configurable timeout (`ollamaTimeout` in `config.json`, default 300 seconds).
|
||||||
Previously, if Ollama accepted the connection but never replied — most commonly a large
|
Previously, if Ollama accepted the connection but never replied — most commonly a large
|
||||||
|
|||||||
@@ -472,9 +472,36 @@ 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 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.
|
||||||
|
- **`ollamaAutoResearch`** — When `true` (default), **Target info** mode asks the local model to suggest the industry and location as soon as you have typed the company name, and offers them as editable prompt defaults. Set to `false` to always get blank prompts (useful with a slow model, since research costs one extra round-trip before the attack starts).
|
||||||
- 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 three generation modes:
|
||||||
|
|
||||||
|
1. **Target info** — company / industry / location; the model derives candidates from those details.
|
||||||
|
|
||||||
|
After you type the company name, hate_crack asks the same local model what it already knows about that organization and pre-fills the **Industry** and **Location** prompts with the answers, shown in parentheses:
|
||||||
|
|
||||||
|
```
|
||||||
|
Company name: Acme Rail Services
|
||||||
|
|
||||||
|
[!] The values in parentheses below are the local model's GUESSES, not verified OSINT.
|
||||||
|
Press Enter to accept, or type your own value to override.
|
||||||
|
Industry (freight rail maintenance):
|
||||||
|
Location (Omaha, Nebraska):
|
||||||
|
```
|
||||||
|
|
||||||
|
Press Enter to accept a suggestion or type over it. These values are the model's recollection, **not OSINT** — treat them as a starting point, not intelligence about the client. The lookup uses only the local Ollama server, so the client name never leaves the host; there are no web or third-party API calls. If the model does not recognize the organization (the common case for small clients), it returns nothing and you get plain blank prompts:
|
||||||
|
|
||||||
|
```
|
||||||
|
Company name: Acme Rail Services
|
||||||
|
Industry:
|
||||||
|
Location:
|
||||||
|
```
|
||||||
|
|
||||||
|
A research failure — timeout, Ollama not running, empty answer — never blocks the attack; it just falls back to blank prompts. Set `ollamaAutoResearch` to `false` to skip research entirely.
|
||||||
|
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)
|
### Notifications (menu option 82)
|
||||||
|
|
||||||
@@ -824,6 +851,7 @@ Uses a local Ollama instance to generate password candidates for a capture-the-f
|
|||||||
* Requires a running Ollama instance (default: `http://localhost:11434`)
|
* Requires a running Ollama instance (default: `http://localhost:11434`)
|
||||||
* Configurable model and context window via `config.json` (see Ollama Configuration below)
|
* Configurable model and context window via `config.json` (see Ollama Configuration below)
|
||||||
* Prompts for target company name, industry, and location
|
* 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
|
#### 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.
|
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.
|
||||||
|
|||||||
@@ -28,6 +28,8 @@
|
|||||||
"ollamaModel": "qwen2.5:32b",
|
"ollamaModel": "qwen2.5:32b",
|
||||||
"ollamaNumCtx": 2048,
|
"ollamaNumCtx": 2048,
|
||||||
"ollamaTimeout": 300,
|
"ollamaTimeout": 300,
|
||||||
|
"ollamaMaxSampleLines": 500,
|
||||||
|
"ollamaAutoResearch": true,
|
||||||
"omenTrainingList": "rockyou.txt",
|
"omenTrainingList": "rockyou.txt",
|
||||||
"omenMaxCandidates": 50000000,
|
"omenMaxCandidates": 50000000,
|
||||||
"pcfgRuleset": "DEFAULT",
|
"pcfgRuleset": "DEFAULT",
|
||||||
|
|||||||
+145
-69
@@ -7,6 +7,7 @@ from typing import Any
|
|||||||
from hate_crack import notify as _notify
|
from hate_crack import notify as _notify
|
||||||
from hate_crack.api import download_hashmob_rules
|
from hate_crack.api import download_hashmob_rules
|
||||||
from hate_crack.formatting import print_multicolumn_list
|
from hate_crack.formatting import print_multicolumn_list
|
||||||
|
from hate_crack.llm import clean_research_field
|
||||||
from hate_crack.menu import interactive_menu
|
from hate_crack.menu import interactive_menu
|
||||||
|
|
||||||
|
|
||||||
@@ -510,35 +511,99 @@ def bandrel_method(ctx: Any) -> None:
|
|||||||
ctx.hcatBandrel(ctx.hcatHashType, ctx.hcatHashFile)
|
ctx.hcatBandrel(ctx.hcatHashType, ctx.hcatHashFile)
|
||||||
|
|
||||||
|
|
||||||
|
def _research_target_suggestions(ctx: Any, company: str) -> dict[str, str]:
|
||||||
|
"""Ask the local model for industry/location suggestions for *company*.
|
||||||
|
|
||||||
|
Returns a dict of cleaned suggestion strings (values may be ''). Research is
|
||||||
|
a convenience only, so any failure is swallowed here as well as in
|
||||||
|
``hcatOllamaResearchTarget``: the operator still gets blank prompts and the
|
||||||
|
attack proceeds.
|
||||||
|
"""
|
||||||
|
if not company:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = ctx.hcatOllamaResearchTarget(company)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Note: target research unavailable ({e}) — enter the details manually.")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
suggestions = {}
|
||||||
|
if isinstance(raw, dict):
|
||||||
|
for key in ("industry", "location"):
|
||||||
|
value = clean_research_field(raw.get(key, ""))
|
||||||
|
if value:
|
||||||
|
suggestions[key] = value
|
||||||
|
|
||||||
|
if suggestions:
|
||||||
|
print(
|
||||||
|
"\n[!] The values in parentheses below are the local model's GUESSES, "
|
||||||
|
"not verified OSINT."
|
||||||
|
)
|
||||||
|
print(" Press Enter to accept, or type your own value to override.")
|
||||||
|
return suggestions
|
||||||
|
|
||||||
|
|
||||||
|
def _prompt_with_default(label: str, default: Any) -> str:
|
||||||
|
"""Prompt for *label*, showing *default* in parentheses when there is one."""
|
||||||
|
suggestion = clean_research_field(default)
|
||||||
|
if suggestion:
|
||||||
|
return input(f"{label} ({suggestion}): ").strip() or suggestion
|
||||||
|
return input(f"{label}: ").strip()
|
||||||
|
|
||||||
|
|
||||||
def ollama_attack(ctx: Any) -> None:
|
def ollama_attack(ctx: Any) -> None:
|
||||||
_notify.prompt_notify_for_attack("LLM")
|
_notify.prompt_notify_for_attack("LLM")
|
||||||
print("\n\tLLM Attack")
|
# Cracked-password mode is only offered when this session actually has
|
||||||
print("\t1. Target info (company / industry / location)")
|
# plaintexts to learn from, matching _markov_pick_training_source.
|
||||||
print("\t2. Wordlist (generate basewords from a sample wordlist)")
|
out_path = f"{ctx.hcatHashFile}.out"
|
||||||
choice = input("\n\tSelect generation mode: ").strip()
|
has_cracked = os.path.isfile(out_path) and os.path.getsize(out_path) > 0
|
||||||
|
|
||||||
if choice == "1":
|
items: list[tuple[str, str]] = [
|
||||||
company = input("Company name: ").strip()
|
("1", "Target info (company / industry / location)"),
|
||||||
industry = input("Industry: ").strip()
|
("2", "Wordlist (generate basewords from a sample wordlist)"),
|
||||||
location = input("Location: ").strip()
|
]
|
||||||
ctx.hcatOllama(
|
if has_cracked:
|
||||||
ctx.hcatHashType,
|
items.append(("3", "Cracked passwords (current session)"))
|
||||||
ctx.hcatHashFile,
|
items.append(("99", "Cancel"))
|
||||||
"target",
|
|
||||||
{"company": company, "industry": industry, "location": location},
|
while True:
|
||||||
)
|
choice = interactive_menu(items, title="\nLLM Attack", prompt="\n\tSelect generation mode: ")
|
||||||
elif choice == "2":
|
if choice is None or choice == "99":
|
||||||
path = _omen_pick_training_wordlist(ctx, title="LLM Sample Wordlists")
|
|
||||||
if not path:
|
|
||||||
return
|
return
|
||||||
ctx.hcatOllama(ctx.hcatHashType, ctx.hcatHashFile, "wordlist", path)
|
if choice == "1":
|
||||||
else:
|
company = input("Company name: ").strip()
|
||||||
print("\t[!] Invalid selection.")
|
suggestions = _research_target_suggestions(ctx, company)
|
||||||
|
industry = _prompt_with_default("Industry", suggestions.get("industry"))
|
||||||
|
location = _prompt_with_default("Location", suggestions.get("location"))
|
||||||
|
ctx.hcatOllama(
|
||||||
|
ctx.hcatHashType,
|
||||||
|
ctx.hcatHashFile,
|
||||||
|
"target",
|
||||||
|
{"company": company, "industry": industry, "location": location},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
elif choice == "2":
|
||||||
|
path = _omen_pick_training_wordlist(ctx, title="LLM Sample Wordlists")
|
||||||
|
if not path:
|
||||||
|
return
|
||||||
|
ctx.hcatOllama(ctx.hcatHashType, ctx.hcatHashFile, "wordlist", path)
|
||||||
|
return
|
||||||
|
elif choice == "3" and has_cracked:
|
||||||
|
ctx.hcatOllama(ctx.hcatHashType, ctx.hcatHashFile, "cracked", out_path)
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
# Without this the menu just silently redraws and the user cannot
|
||||||
|
# tell a rejected key from a repainted prompt.
|
||||||
|
print("\t[!] Invalid selection.")
|
||||||
|
|
||||||
|
|
||||||
def _omen_pick_training_wordlist(ctx: Any, title: str = "Training Wordlists"):
|
def _omen_pick_training_wordlist(ctx: Any, title: str = "Training Wordlists"):
|
||||||
"""Show wordlist picker. Returns path or None."""
|
"""Show wordlist picker. Returns path or None (user cancelled with 'q')."""
|
||||||
wordlist_files = ctx.list_wordlist_files(ctx.hcatWordlists)
|
wordlist_files = ctx.list_wordlist_files(ctx.hcatWordlists)
|
||||||
|
# Print the grid once, outside the retry loop: a wordlists directory can
|
||||||
|
# hold dozens of entries, and repainting the whole thing after every typo
|
||||||
|
# buries the error message.
|
||||||
if wordlist_files:
|
if wordlist_files:
|
||||||
entries = [f"{i}) {f}" for i, f in enumerate(wordlist_files, start=1)]
|
entries = [f"{i}) {f}" for i, f in enumerate(wordlist_files, start=1)]
|
||||||
max_len = max((len(e) for e in entries), default=24)
|
max_len = max((len(e) for e in entries), default=24)
|
||||||
@@ -549,18 +614,21 @@ def _omen_pick_training_wordlist(ctx: Any, title: str = "Training Wordlists"):
|
|||||||
max_col_width=max_len,
|
max_col_width=max_len,
|
||||||
)
|
)
|
||||||
print("\tp. Enter a custom path")
|
print("\tp. Enter a custom path")
|
||||||
sel = input("\n\tSelect wordlist: ").strip()
|
print("\tq. Cancel")
|
||||||
if sel.lower() == "p":
|
while True:
|
||||||
path = input("\n\tPath to wordlist: ").strip()
|
sel = input("\n\tSelect wordlist: ").strip()
|
||||||
return path if path else None
|
if sel.lower() == "q":
|
||||||
try:
|
return None
|
||||||
idx = int(sel)
|
if sel.lower() == "p":
|
||||||
if 1 <= idx <= len(wordlist_files):
|
path = input("\n\tPath to wordlist: ").strip()
|
||||||
return os.path.join(ctx.hcatWordlists, wordlist_files[idx - 1])
|
return path if path else None
|
||||||
except (ValueError, IndexError):
|
try:
|
||||||
pass
|
idx = int(sel)
|
||||||
print("\t[!] Invalid selection.")
|
if 1 <= idx <= len(wordlist_files):
|
||||||
return None
|
return os.path.join(ctx.hcatWordlists, wordlist_files[idx - 1])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
pass
|
||||||
|
print("\t[!] Invalid selection.")
|
||||||
|
|
||||||
|
|
||||||
def omen_attack(ctx: Any) -> None:
|
def omen_attack(ctx: Any) -> None:
|
||||||
@@ -582,16 +650,20 @@ def omen_attack(ctx: Any) -> None:
|
|||||||
info = ctx._omen_model_info(model_dir)
|
info = ctx._omen_model_info(model_dir)
|
||||||
trained_with = info.get("training_file", "unknown") if info else "unknown"
|
trained_with = info.get("training_file", "unknown") if info else "unknown"
|
||||||
print(f"\n\tOMEN model found (trained with: {trained_with})")
|
print(f"\n\tOMEN model found (trained with: {trained_with})")
|
||||||
print("\t1. Use existing model")
|
model_items = [
|
||||||
print("\t2. Train new model (overwrites existing)")
|
("1", "Use existing model"),
|
||||||
print("\t3. Cancel")
|
("2", "Train new model (overwrites existing)"),
|
||||||
choice = input("\n\tChoice: ").strip()
|
("99", "Cancel"),
|
||||||
if choice == "1":
|
]
|
||||||
need_training = False
|
while True:
|
||||||
elif choice == "3":
|
choice = interactive_menu(model_items, title="\nOMEN Attack (Ordered Markov ENumerator)", prompt="\n\tChoice: ")
|
||||||
return
|
if choice is None or choice == "99":
|
||||||
elif choice != "2":
|
return
|
||||||
return
|
if choice == "1":
|
||||||
|
need_training = False
|
||||||
|
break
|
||||||
|
elif choice == "2":
|
||||||
|
break
|
||||||
else:
|
else:
|
||||||
print("\n\tNo valid OMEN model found. Training is required.")
|
print("\n\tNo valid OMEN model found. Training is required.")
|
||||||
|
|
||||||
@@ -618,11 +690,12 @@ def omen_attack(ctx: Any) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _markov_pick_training_source(ctx: Any):
|
def _markov_pick_training_source(ctx: Any):
|
||||||
"""Prompt user to select markov training source. Returns file path or None."""
|
"""Prompt user to select markov training source. Returns file path or None (user cancelled with 'q')."""
|
||||||
out_path = f"{ctx.hcatHashFile}.out"
|
out_path = f"{ctx.hcatHashFile}.out"
|
||||||
has_cracked = os.path.isfile(out_path) and os.path.getsize(out_path) > 0
|
has_cracked = os.path.isfile(out_path) and os.path.getsize(out_path) > 0
|
||||||
|
|
||||||
wordlist_files = ctx.list_wordlist_files(ctx.hcatWordlists)
|
wordlist_files = ctx.list_wordlist_files(ctx.hcatWordlists)
|
||||||
|
# Print the grid once, outside the retry loop — see _omen_pick_training_wordlist.
|
||||||
entries = []
|
entries = []
|
||||||
if has_cracked:
|
if has_cracked:
|
||||||
entries.append("0) Cracked passwords (current session)")
|
entries.append("0) Cracked passwords (current session)")
|
||||||
@@ -636,20 +709,23 @@ def _markov_pick_training_source(ctx: Any):
|
|||||||
max_col_width=max_len,
|
max_col_width=max_len,
|
||||||
)
|
)
|
||||||
print("\tp. Enter a custom path")
|
print("\tp. Enter a custom path")
|
||||||
sel = input("\n\tSelect training source: ").strip()
|
print("\tq. Cancel")
|
||||||
if sel == "0" and has_cracked:
|
while True:
|
||||||
return out_path
|
sel = input("\n\tSelect training source: ").strip()
|
||||||
if sel.lower() == "p":
|
if sel.lower() == "q":
|
||||||
path = input("\n\tPath to training file: ").strip()
|
return None
|
||||||
return path if path else None
|
if sel == "0" and has_cracked:
|
||||||
try:
|
return out_path
|
||||||
idx = int(sel)
|
if sel.lower() == "p":
|
||||||
if 1 <= idx <= len(wordlist_files):
|
path = input("\n\tPath to training file: ").strip()
|
||||||
return os.path.join(ctx.hcatWordlists, wordlist_files[idx - 1])
|
return path if path else None
|
||||||
except (ValueError, IndexError):
|
try:
|
||||||
pass
|
idx = int(sel)
|
||||||
print("\t[!] Invalid selection.")
|
if 1 <= idx <= len(wordlist_files):
|
||||||
return None
|
return os.path.join(ctx.hcatWordlists, wordlist_files[idx - 1])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
pass
|
||||||
|
print("\t[!] Invalid selection.")
|
||||||
|
|
||||||
|
|
||||||
def adhoc_mask_crack(ctx: Any) -> None:
|
def adhoc_mask_crack(ctx: Any) -> None:
|
||||||
@@ -719,7 +795,7 @@ def combipow_crack(ctx: Any) -> None:
|
|||||||
_notify.prompt_notify_for_attack("Combipow")
|
_notify.prompt_notify_for_attack("Combipow")
|
||||||
wordlist = None
|
wordlist = None
|
||||||
while wordlist is None:
|
while wordlist is None:
|
||||||
path = input("\n[*] Enter path to wordlist (max 63 lines recommended): ").strip()
|
path = input("\nEnter path to wordlist (max 63 lines recommended): ").strip()
|
||||||
if not path:
|
if not path:
|
||||||
continue
|
continue
|
||||||
if not os.path.isfile(path):
|
if not os.path.isfile(path):
|
||||||
@@ -737,7 +813,7 @@ def combipow_crack(ctx: Any) -> None:
|
|||||||
f"[*] Warning: {line_count} lines will generate a large number of combinations."
|
f"[*] Warning: {line_count} lines will generate a large number of combinations."
|
||||||
)
|
)
|
||||||
wordlist = path
|
wordlist = path
|
||||||
use_space_sep = input("[*] Add spaces between words? (Y/n): ").strip().lower() != "n"
|
use_space_sep = input("\nAdd spaces between words? (Y/n): ").strip().lower() != "n"
|
||||||
ctx.hcatCombipow(ctx.hcatHashType, ctx.hcatHashFile, wordlist, use_space_sep)
|
ctx.hcatCombipow(ctx.hcatHashType, ctx.hcatHashFile, wordlist, use_space_sep)
|
||||||
|
|
||||||
|
|
||||||
@@ -837,7 +913,7 @@ def ngram_attack(ctx: Any) -> None:
|
|||||||
print("No corpus selected. Aborting ngram attack.")
|
print("No corpus selected. Aborting ngram attack.")
|
||||||
return
|
return
|
||||||
|
|
||||||
group_size_raw = input("\nEnter n-gram group size (default 3): ").strip()
|
group_size_raw = input("\nEnter n-gram group size (3): ").strip()
|
||||||
try:
|
try:
|
||||||
group_size = int(group_size_raw) if group_size_raw else 3
|
group_size = int(group_size_raw) if group_size_raw else 3
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -1042,8 +1118,8 @@ def wordlist_filter_length(ctx: Any) -> None:
|
|||||||
if not outfile:
|
if not outfile:
|
||||||
print("[!] Output path cannot be empty.")
|
print("[!] Output path cannot be empty.")
|
||||||
return
|
return
|
||||||
min_len = int(input("[*] Minimum length: ").strip() or "0")
|
min_len = int(input("Minimum length: ").strip() or "0")
|
||||||
max_len = int(input("[*] Maximum length: ").strip() or "0")
|
max_len = int(input("Maximum length: ").strip() or "0")
|
||||||
if ctx.wordlist_filter_len(infile, outfile, min_len, max_len):
|
if ctx.wordlist_filter_len(infile, outfile, min_len, max_len):
|
||||||
print(f"\n[*] Filtered wordlist written to: {outfile}")
|
print(f"\n[*] Filtered wordlist written to: {outfile}")
|
||||||
else:
|
else:
|
||||||
@@ -1063,7 +1139,7 @@ def wordlist_filter_charclass_include(ctx: Any) -> None:
|
|||||||
print("[!] Output path cannot be empty.")
|
print("[!] Output path cannot be empty.")
|
||||||
return
|
return
|
||||||
print("[*] Char class mask: 1=lowercase, 2=uppercase, 4=digit, 8=symbol (additive, e.g. 3=lower+upper)")
|
print("[*] Char class mask: 1=lowercase, 2=uppercase, 4=digit, 8=symbol (additive, e.g. 3=lower+upper)")
|
||||||
mask = int(input("[*] Enter mask value: ").strip() or "0")
|
mask = int(input("Mask value: ").strip() or "0")
|
||||||
if ctx.wordlist_filter_req_include(infile, outfile, mask):
|
if ctx.wordlist_filter_req_include(infile, outfile, mask):
|
||||||
print(f"\n[*] Filtered wordlist written to: {outfile}")
|
print(f"\n[*] Filtered wordlist written to: {outfile}")
|
||||||
else:
|
else:
|
||||||
@@ -1083,7 +1159,7 @@ def wordlist_filter_charclass_exclude(ctx: Any) -> None:
|
|||||||
print("[!] Output path cannot be empty.")
|
print("[!] Output path cannot be empty.")
|
||||||
return
|
return
|
||||||
print("[*] Char class mask: 1=lowercase, 2=uppercase, 4=digit, 8=symbol (additive)")
|
print("[*] Char class mask: 1=lowercase, 2=uppercase, 4=digit, 8=symbol (additive)")
|
||||||
mask = int(input("[*] Enter mask value: ").strip() or "0")
|
mask = int(input("Mask value: ").strip() or "0")
|
||||||
if ctx.wordlist_filter_req_exclude(infile, outfile, mask):
|
if ctx.wordlist_filter_req_exclude(infile, outfile, mask):
|
||||||
print(f"\n[*] Filtered wordlist written to: {outfile}")
|
print(f"\n[*] Filtered wordlist written to: {outfile}")
|
||||||
else:
|
else:
|
||||||
@@ -1102,8 +1178,8 @@ def wordlist_cut_substring(ctx: Any) -> None:
|
|||||||
if not outfile:
|
if not outfile:
|
||||||
print("[!] Output path cannot be empty.")
|
print("[!] Output path cannot be empty.")
|
||||||
return
|
return
|
||||||
offset = int(input("[*] Byte offset to start from: ").strip() or "0")
|
offset = int(input("Byte offset to start from: ").strip() or "0")
|
||||||
raw_length = input("[*] Length (leave blank for rest of line): ").strip()
|
raw_length = input("Length (leave blank for rest of line): ").strip()
|
||||||
length = int(raw_length) if raw_length else None
|
length = int(raw_length) if raw_length else None
|
||||||
if ctx.wordlist_cutb(infile, outfile, offset, length):
|
if ctx.wordlist_cutb(infile, outfile, offset, length):
|
||||||
print(f"\n[*] Output written to: {outfile}")
|
print(f"\n[*] Output written to: {outfile}")
|
||||||
@@ -1135,7 +1211,7 @@ def wordlist_subtract_words(ctx: Any) -> None:
|
|||||||
print("\n[*] Subtract mode:")
|
print("\n[*] Subtract mode:")
|
||||||
print(" 1. Single remove file (rli2 - faster for one file)")
|
print(" 1. Single remove file (rli2 - faster for one file)")
|
||||||
print(" 2. Multiple remove files (rli)")
|
print(" 2. Multiple remove files (rli)")
|
||||||
mode = input("[*] Choose mode (1/2): ").strip()
|
mode = input("Choose mode (1/2): ").strip()
|
||||||
|
|
||||||
if mode == "1":
|
if mode == "1":
|
||||||
infile = ctx.select_file_with_autocomplete(
|
infile = ctx.select_file_with_autocomplete(
|
||||||
@@ -1198,7 +1274,7 @@ def wordlist_shard(ctx: Any) -> None:
|
|||||||
if not outbase:
|
if not outbase:
|
||||||
print("[!] Output path cannot be empty.")
|
print("[!] Output path cannot be empty.")
|
||||||
return
|
return
|
||||||
mod = int(input("[*] Shard count (e.g. 4 to split into 4 parts): ").strip() or "0")
|
mod = int(input("Shard count (e.g. 4 to split into 4 parts): ").strip() or "0")
|
||||||
if mod < 2:
|
if mod < 2:
|
||||||
print("[!] Shard count must be at least 2.")
|
print("[!] Shard count must be at least 2.")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -22,6 +22,11 @@
|
|||||||
"hashview_url": "http://localhost:8443",
|
"hashview_url": "http://localhost:8443",
|
||||||
"hashview_api_key": "",
|
"hashview_api_key": "",
|
||||||
"hashmob_api_key": "",
|
"hashmob_api_key": "",
|
||||||
|
"ollamaModel": "qwen2.5:32b",
|
||||||
|
"ollamaNumCtx": 2048,
|
||||||
|
"ollamaTimeout": 300,
|
||||||
|
"ollamaMaxSampleLines": 500,
|
||||||
|
"ollamaAutoResearch": true,
|
||||||
"passgptModel": "javirandor/passgpt-10characters",
|
"passgptModel": "javirandor/passgpt-10characters",
|
||||||
"passgptMaxCandidates": 1000000,
|
"passgptMaxCandidates": 1000000,
|
||||||
"passgptBatchSize": 1024,
|
"passgptBatchSize": 1024,
|
||||||
|
|||||||
+165
-6
@@ -1,7 +1,7 @@
|
|||||||
"""Structured LLM password-candidate generation via Atomic Agents + Ollama.
|
"""Structured LLM password-candidate generation via Atomic Agents + Ollama.
|
||||||
|
|
||||||
Isolates the atomic-agents / instructor dependency. The rest of hate_crack talks
|
Isolates the atomic-agents / instructor dependency. The rest of hate_crack talks
|
||||||
to this module only through ``generate_candidates``.
|
to this module only through ``generate_candidates`` and ``research_target``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import instructor
|
import instructor
|
||||||
@@ -14,6 +14,10 @@ from atomic_agents.context import SystemPromptGenerator
|
|||||||
MAX_CANDIDATE_LEN = 128
|
MAX_CANDIDATE_LEN = 128
|
||||||
DEFAULT_TIMEOUT_SECONDS = 300.0
|
DEFAULT_TIMEOUT_SECONDS = 300.0
|
||||||
|
|
||||||
|
# Researched target fields are pasted into an interactive prompt as an editable
|
||||||
|
# default, so they must stay short enough to fit on one terminal line.
|
||||||
|
MAX_RESEARCH_FIELD_LEN = 80
|
||||||
|
|
||||||
|
|
||||||
class LLMTimeoutError(Exception):
|
class LLMTimeoutError(Exception):
|
||||||
"""The LLM server accepted the request but did not respond in time.
|
"""The LLM server accepted the request but did not respond in time.
|
||||||
@@ -43,6 +47,60 @@ class PasswordCandidatesOutput(BaseIOSchema):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TargetResearchInput(BaseIOSchema):
|
||||||
|
"""The company name to recall industry and location details for."""
|
||||||
|
|
||||||
|
company: str = Field(
|
||||||
|
..., description="The name of the target organization, exactly as typed."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TargetResearchOutput(BaseIOSchema):
|
||||||
|
"""Recalled industry and location for a named organization, or empty strings."""
|
||||||
|
|
||||||
|
industry: str = Field(
|
||||||
|
...,
|
||||||
|
description=(
|
||||||
|
"The organization's industry or sector as a short phrase, e.g. "
|
||||||
|
"'regional healthcare provider' or 'commercial construction'. Return "
|
||||||
|
"an empty string if you do not actually recognize this organization."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
location: str = Field(
|
||||||
|
...,
|
||||||
|
description=(
|
||||||
|
"The organization's primary location as 'City, State/Country'. Return "
|
||||||
|
"an empty string if you do not actually recognize this organization."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_RESEARCH_PROMPT = SystemPromptGenerator(
|
||||||
|
background=[
|
||||||
|
"You are assisting a security professional on an authorized penetration "
|
||||||
|
"test who is about to generate password candidates for a named client "
|
||||||
|
"organization.",
|
||||||
|
"You have no internet access. You may only answer from what you already "
|
||||||
|
"know about the organization.",
|
||||||
|
"Most client organizations are small and will be completely unknown to "
|
||||||
|
"you. That is the expected case, not a failure.",
|
||||||
|
],
|
||||||
|
steps=[
|
||||||
|
"Decide whether you genuinely recognize this specific organization by name.",
|
||||||
|
"If you do, recall its industry or sector and its primary location.",
|
||||||
|
"If you do not recognize it, or you are not reasonably confident, do not "
|
||||||
|
"guess and do not infer anything from the words in the name.",
|
||||||
|
],
|
||||||
|
output_instructions=[
|
||||||
|
"Return an empty string for any field you are not reasonably confident "
|
||||||
|
"about. An empty field is correct and useful; a fabricated one is harmful "
|
||||||
|
"because the operator may mistake it for real intelligence.",
|
||||||
|
"Keep each field under 80 characters.",
|
||||||
|
"Return only the industry and location fields — no explanations, "
|
||||||
|
"hedging, caveats, or commentary.",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
_TARGET_PROMPT = SystemPromptGenerator(
|
_TARGET_PROMPT = SystemPromptGenerator(
|
||||||
background=[
|
background=[
|
||||||
"You are a security professional generating password candidates during an "
|
"You are a security professional generating password candidates during an "
|
||||||
@@ -74,6 +132,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:
|
def _build_request(mode: str, context_data: dict) -> str:
|
||||||
"""Build the natural-language request string for the given mode."""
|
"""Build the natural-language request string for the given mode."""
|
||||||
@@ -93,9 +182,81 @@ def _build_request(mode: str, context_data: dict) -> str:
|
|||||||
"Here are sample passwords. Study their patterns and generate basewords "
|
"Here are sample passwords. Study their patterns and generate basewords "
|
||||||
"for a denylist:\n" + sample
|
"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}")
|
raise ValueError(f"Unknown LLM generation mode: {mode}")
|
||||||
|
|
||||||
|
|
||||||
|
def _build_client(url: str, timeout: float) -> instructor.Instructor:
|
||||||
|
"""Build the instructor-wrapped OpenAI client pointed at an Ollama server."""
|
||||||
|
return instructor.from_openai(
|
||||||
|
OpenAI(base_url=f"{url}/v1", api_key="ollama", timeout=timeout),
|
||||||
|
mode=instructor.Mode.JSON,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def clean_research_field(value: object) -> str:
|
||||||
|
"""Strip and length-cap one researched field; return '' for no suggestion.
|
||||||
|
|
||||||
|
Anything that is not a non-empty string after stripping — including a model
|
||||||
|
that echoed whitespace or an over-long ramble — becomes '' so the caller
|
||||||
|
falls back to a plain blank prompt instead of pasting model noise into it.
|
||||||
|
"""
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return ""
|
||||||
|
cleaned = " ".join(value.split())
|
||||||
|
if len(cleaned) > MAX_RESEARCH_FIELD_LEN:
|
||||||
|
cleaned = cleaned[:MAX_RESEARCH_FIELD_LEN].rstrip()
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
def research_target(
|
||||||
|
url: str,
|
||||||
|
model: str,
|
||||||
|
num_ctx: int,
|
||||||
|
company: str,
|
||||||
|
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
||||||
|
) -> TargetResearchOutput:
|
||||||
|
"""Ask the local model what it already knows about *company*.
|
||||||
|
|
||||||
|
Returns a ``TargetResearchOutput`` whose ``industry`` and ``location`` are
|
||||||
|
stripped and capped at ``MAX_RESEARCH_FIELD_LEN``; either may be '' when the
|
||||||
|
model is not confident, which callers must treat as "no suggestion".
|
||||||
|
|
||||||
|
Uses only the configured local Ollama server — no web lookups, so the client
|
||||||
|
name never leaves the host. Raises LLMTimeoutError if the request exceeds
|
||||||
|
``timeout``; other client/connection errors propagate to the caller.
|
||||||
|
"""
|
||||||
|
client = _build_client(url, timeout)
|
||||||
|
|
||||||
|
agent = AtomicAgent[TargetResearchInput, TargetResearchOutput](
|
||||||
|
config=AgentConfig(
|
||||||
|
client=client,
|
||||||
|
model=model,
|
||||||
|
system_prompt_generator=_RESEARCH_PROMPT,
|
||||||
|
model_api_parameters={"extra_body": {"options": {"num_ctx": num_ctx}}},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = agent.run(TargetResearchInput(company=company))
|
||||||
|
except APITimeoutError as e:
|
||||||
|
raise LLMTimeoutError(
|
||||||
|
f"no response from {url} within {timeout:g} seconds"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
return TargetResearchOutput(
|
||||||
|
industry=clean_research_field(getattr(result, "industry", "")),
|
||||||
|
location=clean_research_field(getattr(result, "location", "")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def generate_candidates(
|
def generate_candidates(
|
||||||
url: str,
|
url: str,
|
||||||
model: str,
|
model: str,
|
||||||
@@ -117,11 +278,9 @@ def generate_candidates(
|
|||||||
"""
|
"""
|
||||||
request = _build_request(mode, context_data)
|
request = _build_request(mode, context_data)
|
||||||
|
|
||||||
client = instructor.from_openai(
|
client = _build_client(url, timeout)
|
||||||
OpenAI(base_url=f"{url}/v1", api_key="ollama", timeout=timeout),
|
# _build_request has already rejected unknown modes, so this lookup is safe.
|
||||||
mode=instructor.Mode.JSON,
|
prompt_generator = _PROMPTS[mode]
|
||||||
)
|
|
||||||
prompt_generator = _TARGET_PROMPT if mode == "target" else _WORDLIST_PROMPT
|
|
||||||
|
|
||||||
agent = AtomicAgent[GenerationInput, PasswordCandidatesOutput](
|
agent = AtomicAgent[GenerationInput, PasswordCandidatesOutput](
|
||||||
config=AgentConfig(
|
config=AgentConfig(
|
||||||
|
|||||||
+158
-25
@@ -74,6 +74,7 @@ from hate_crack.cli import ( # noqa: E402
|
|||||||
)
|
)
|
||||||
from hate_crack import attacks as _attacks # noqa: E402
|
from hate_crack import attacks as _attacks # noqa: E402
|
||||||
from hate_crack import llm # noqa: E402
|
from hate_crack import llm # noqa: E402
|
||||||
|
from hate_crack.progress import spinner # noqa: E402
|
||||||
from hate_crack.menu import interactive_menu # noqa: E402
|
from hate_crack.menu import interactive_menu # noqa: E402
|
||||||
from hate_crack.username_detect import detect_username_hash_format # noqa: E402
|
from hate_crack.username_detect import detect_username_hash_format # noqa: E402
|
||||||
|
|
||||||
@@ -453,6 +454,8 @@ ollamaUrl = "http://" + os.environ.get("OLLAMA_HOST", "localhost:11434")
|
|||||||
ollamaModel = config_parser.get("ollamaModel", "qwen2.5:32b")
|
ollamaModel = config_parser.get("ollamaModel", "qwen2.5:32b")
|
||||||
ollamaNumCtx = int(config_parser.get("ollamaNumCtx", 2048))
|
ollamaNumCtx = int(config_parser.get("ollamaNumCtx", 2048))
|
||||||
ollamaTimeout = float(config_parser.get("ollamaTimeout", 300))
|
ollamaTimeout = float(config_parser.get("ollamaTimeout", 300))
|
||||||
|
ollamaMaxSampleLines = int(config_parser.get("ollamaMaxSampleLines", 500))
|
||||||
|
ollamaAutoResearch = bool(config_parser.get("ollamaAutoResearch", True))
|
||||||
|
|
||||||
omenTrainingList = config_parser.get("omenTrainingList", "rockyou.txt")
|
omenTrainingList = config_parser.get("omenTrainingList", "rockyou.txt")
|
||||||
omenMaxCandidates = int(config_parser.get("omenMaxCandidates", 1000000))
|
omenMaxCandidates = int(config_parser.get("omenMaxCandidates", 1000000))
|
||||||
@@ -895,6 +898,23 @@ def _wordlist_path(path: str):
|
|||||||
yield path
|
yield path
|
||||||
|
|
||||||
|
|
||||||
|
def _usable_plaintext(raw: str) -> str:
|
||||||
|
"""Return the usable plaintext from a raw wordlist line, or empty string.
|
||||||
|
|
||||||
|
Blank/whitespace-only lines are discarded. Lines in ``hash:password``
|
||||||
|
format (as produced by hashcat ``--show``) are split on the first colon
|
||||||
|
so only the plaintext portion is returned; lines with no colon are
|
||||||
|
returned as-is. A ``hash:`` line whose plaintext is empty after
|
||||||
|
stripping returns an empty string and is therefore also discarded.
|
||||||
|
"""
|
||||||
|
stripped = raw.strip()
|
||||||
|
if not stripped:
|
||||||
|
return ""
|
||||||
|
if ":" in stripped:
|
||||||
|
stripped = stripped.split(":", 1)[1]
|
||||||
|
return stripped
|
||||||
|
|
||||||
|
|
||||||
def _add_debug_mode_for_rules(cmd):
|
def _add_debug_mode_for_rules(cmd):
|
||||||
"""Add debug mode arguments to hashcat command if rules are being used.
|
"""Add debug mode arguments to hashcat command if rules are being used.
|
||||||
|
|
||||||
@@ -2036,6 +2056,111 @@ def hcatBandrel(hcatHashType, hcatHashFile):
|
|||||||
_run_hcat_cmd(cmd, attack_name="Bandrel", hash_file=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
|
||||||
|
|
||||||
|
|
||||||
|
def hcatOllamaResearchTarget(company):
|
||||||
|
"""Ask the local Ollama model what it knows about *company*.
|
||||||
|
|
||||||
|
Returns a dict with "industry" and "location" keys; either value may be an
|
||||||
|
empty string when the model is not confident or the request failed. Never
|
||||||
|
raises: research is a convenience, so any failure degrades to empty
|
||||||
|
suggestions (blank prompts) rather than blocking the attack.
|
||||||
|
|
||||||
|
Uses only the configured local Ollama server — the company name is never
|
||||||
|
sent to a third-party service.
|
||||||
|
"""
|
||||||
|
blank = {"industry": "", "location": ""}
|
||||||
|
if not ollamaAutoResearch or not company:
|
||||||
|
return blank
|
||||||
|
|
||||||
|
try:
|
||||||
|
with spinner(f"Researching {company} via Ollama ({ollamaModel})..."):
|
||||||
|
result = llm.research_target(
|
||||||
|
ollamaUrl,
|
||||||
|
ollamaModel,
|
||||||
|
ollamaNumCtx,
|
||||||
|
company,
|
||||||
|
timeout=ollamaTimeout,
|
||||||
|
)
|
||||||
|
except llm.LLMTimeoutError:
|
||||||
|
print(
|
||||||
|
f"Note: target research timed out after {ollamaTimeout:g} seconds — "
|
||||||
|
"enter the details manually."
|
||||||
|
)
|
||||||
|
return blank
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Note: target research unavailable ({e}) — enter the details manually.")
|
||||||
|
return blank
|
||||||
|
|
||||||
|
return {"industry": result.industry, "location": result.location}
|
||||||
|
|
||||||
|
|
||||||
# LLM Ollama Attack
|
# LLM Ollama Attack
|
||||||
def hcatOllama(hcatHashType, hcatHashFile, mode, context_data):
|
def hcatOllama(hcatHashType, hcatHashFile, mode, context_data):
|
||||||
candidates_path = f"{hcatHashFile}.ollama_candidates"
|
candidates_path = f"{hcatHashFile}.ollama_candidates"
|
||||||
@@ -2046,23 +2171,31 @@ def hcatOllama(hcatHashType, hcatHashFile, mode, context_data):
|
|||||||
if not os.path.isfile(wordlist_path):
|
if not os.path.isfile(wordlist_path):
|
||||||
print(f"Error: Wordlist not found: {wordlist_path}")
|
print(f"Error: Wordlist not found: {wordlist_path}")
|
||||||
return
|
return
|
||||||
lines = []
|
|
||||||
try:
|
sampled = _sample_plaintext_file(wordlist_path, ollamaMaxSampleLines)
|
||||||
with open(wordlist_path, "r", errors="ignore") as f:
|
if sampled is None:
|
||||||
for line in f:
|
|
||||||
stripped = line.strip()
|
|
||||||
if not stripped:
|
|
||||||
continue
|
|
||||||
# hash:password -> password
|
|
||||||
if ":" in stripped:
|
|
||||||
stripped = stripped.split(":", 1)[1]
|
|
||||||
if stripped:
|
|
||||||
lines.append(stripped)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error reading wordlist: {e}")
|
|
||||||
return
|
return
|
||||||
print(f"Loaded {len(lines)} passwords from wordlist.")
|
gen_context = {"sample": "\n".join(sampled)}
|
||||||
gen_context = {"sample": "\n".join(lines)}
|
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
|
||||||
|
|
||||||
|
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":
|
elif mode == "target":
|
||||||
gen_context = context_data
|
gen_context = context_data
|
||||||
else:
|
else:
|
||||||
@@ -2070,16 +2203,16 @@ def hcatOllama(hcatHashType, hcatHashFile, mode, context_data):
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Step B: generate candidates via the Atomic Agents module.
|
# Step B: generate candidates via the Atomic Agents module.
|
||||||
print(f"Generating password candidates via Ollama ({ollamaModel})...")
|
|
||||||
try:
|
try:
|
||||||
candidates = llm.generate_candidates(
|
with spinner(f"Generating password candidates via Ollama ({ollamaModel})..."):
|
||||||
ollamaUrl,
|
candidates = llm.generate_candidates(
|
||||||
ollamaModel,
|
ollamaUrl,
|
||||||
ollamaNumCtx,
|
ollamaModel,
|
||||||
mode,
|
ollamaNumCtx,
|
||||||
gen_context,
|
mode,
|
||||||
timeout=ollamaTimeout,
|
gen_context,
|
||||||
)
|
timeout=ollamaTimeout,
|
||||||
|
)
|
||||||
except llm.LLMTimeoutError:
|
except llm.LLMTimeoutError:
|
||||||
print(f"Error: the Ollama request timed out after {ollamaTimeout:g} seconds.")
|
print(f"Error: the Ollama request timed out after {ollamaTimeout:g} seconds.")
|
||||||
print(
|
print(
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""Generic terminal progress utilities for hate_crack.
|
||||||
|
|
||||||
|
Provides a context manager that shows a live spinner with elapsed-seconds
|
||||||
|
counter while a blocking operation runs. Safe to use in non-TTY environments:
|
||||||
|
if stdout is not a TTY the message is printed once and no background thread is
|
||||||
|
started.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections.abc import Generator
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
_SPINNER_FRAMES = ["|", "/", "-", "\\"]
|
||||||
|
_TICK_INTERVAL = 0.12 # seconds between repaints (~120 ms)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def spinner(message: str) -> "Generator[None, None, None]":
|
||||||
|
"""Context manager that shows *message* plus a live elapsed-seconds counter.
|
||||||
|
|
||||||
|
While the body executes a daemon thread repaints a single terminal line
|
||||||
|
roughly every 120 ms showing:
|
||||||
|
|
||||||
|
| Generating password candidates via Ollama (model)... 3s
|
||||||
|
|
||||||
|
On exit (normal or exceptional) the spinner line is erased so subsequent
|
||||||
|
output starts on a clean line.
|
||||||
|
|
||||||
|
TTY guard: if ``sys.stdout.isatty()`` is False the message is printed once
|
||||||
|
via ``print()`` and no thread is started, keeping piped output and the test
|
||||||
|
suite clean.
|
||||||
|
"""
|
||||||
|
if not sys.stdout.isatty():
|
||||||
|
print(message)
|
||||||
|
yield
|
||||||
|
return
|
||||||
|
|
||||||
|
stop_event = threading.Event()
|
||||||
|
start_time = time.monotonic()
|
||||||
|
|
||||||
|
def _run() -> None:
|
||||||
|
frame_idx = 0
|
||||||
|
while not stop_event.is_set():
|
||||||
|
elapsed = int(time.monotonic() - start_time)
|
||||||
|
frame = _SPINNER_FRAMES[frame_idx % len(_SPINNER_FRAMES)]
|
||||||
|
line = f"\r{frame} {message} {elapsed}s"
|
||||||
|
sys.stdout.write(line)
|
||||||
|
sys.stdout.flush()
|
||||||
|
frame_idx += 1
|
||||||
|
stop_event.wait(_TICK_INTERVAL)
|
||||||
|
|
||||||
|
thread = threading.Thread(target=_run, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
stop_event.set()
|
||||||
|
# Clear *after* the join, so a thread sitting just past its is_set()
|
||||||
|
# check cannot repaint the line after we erase it. The nested finally
|
||||||
|
# keeps that guarantee even when join() is interrupted: hate_crack's
|
||||||
|
# _sigint_handler raises DoubleInterrupt on a second SIGINT within 2 s,
|
||||||
|
# and join() can block up to _TICK_INTERVAL (0.12 s).
|
||||||
|
try:
|
||||||
|
thread.join()
|
||||||
|
finally:
|
||||||
|
sys.stdout.write("\033[2K\r")
|
||||||
|
sys.stdout.flush()
|
||||||
+232
-10
@@ -329,7 +329,10 @@ class TestOllamaAttack:
|
|||||||
def test_calls_hcatOllama_with_context(self) -> None:
|
def test_calls_hcatOllama_with_context(self) -> None:
|
||||||
ctx = _make_ctx()
|
ctx = _make_ctx()
|
||||||
|
|
||||||
with patch("builtins.input", side_effect=["1", "ACME", "tech", "NYC"]):
|
with (
|
||||||
|
patch("hate_crack.attacks.interactive_menu", return_value="1"),
|
||||||
|
patch("builtins.input", side_effect=["ACME", "tech", "NYC"]),
|
||||||
|
):
|
||||||
ollama_attack(ctx)
|
ollama_attack(ctx)
|
||||||
|
|
||||||
ctx.hcatOllama.assert_called_once_with(
|
ctx.hcatOllama.assert_called_once_with(
|
||||||
@@ -342,7 +345,10 @@ class TestOllamaAttack:
|
|||||||
def test_passes_hash_type_and_file(self) -> None:
|
def test_passes_hash_type_and_file(self) -> None:
|
||||||
ctx = _make_ctx(hash_type="1800", hash_file="/tmp/sha512.txt")
|
ctx = _make_ctx(hash_type="1800", hash_file="/tmp/sha512.txt")
|
||||||
|
|
||||||
with patch("builtins.input", side_effect=["1", "Corp", "finance", "London"]):
|
with (
|
||||||
|
patch("hate_crack.attacks.interactive_menu", return_value="1"),
|
||||||
|
patch("builtins.input", side_effect=["Corp", "finance", "London"]),
|
||||||
|
):
|
||||||
ollama_attack(ctx)
|
ollama_attack(ctx)
|
||||||
|
|
||||||
call_args = ctx.hcatOllama.call_args[0]
|
call_args = ctx.hcatOllama.call_args[0]
|
||||||
@@ -352,7 +358,10 @@ class TestOllamaAttack:
|
|||||||
def test_strips_whitespace_from_inputs(self) -> None:
|
def test_strips_whitespace_from_inputs(self) -> None:
|
||||||
ctx = _make_ctx()
|
ctx = _make_ctx()
|
||||||
|
|
||||||
with patch("builtins.input", side_effect=["1", " ACME ", " tech ", " NYC "]):
|
with (
|
||||||
|
patch("hate_crack.attacks.interactive_menu", return_value="1"),
|
||||||
|
patch("builtins.input", side_effect=[" ACME ", " tech ", " NYC "]),
|
||||||
|
):
|
||||||
ollama_attack(ctx)
|
ollama_attack(ctx)
|
||||||
|
|
||||||
target_info = ctx.hcatOllama.call_args[0][3]
|
target_info = ctx.hcatOllama.call_args[0][3]
|
||||||
@@ -363,7 +372,10 @@ class TestOllamaAttack:
|
|||||||
def test_target_string_is_literal_target(self) -> None:
|
def test_target_string_is_literal_target(self) -> None:
|
||||||
ctx = _make_ctx()
|
ctx = _make_ctx()
|
||||||
|
|
||||||
with patch("builtins.input", side_effect=["1", "X", "Y", "Z"]):
|
with (
|
||||||
|
patch("hate_crack.attacks.interactive_menu", return_value="1"),
|
||||||
|
patch("builtins.input", side_effect=["X", "Y", "Z"]),
|
||||||
|
):
|
||||||
ollama_attack(ctx)
|
ollama_attack(ctx)
|
||||||
|
|
||||||
assert ctx.hcatOllama.call_args[0][2] == "target"
|
assert ctx.hcatOllama.call_args[0][2] == "target"
|
||||||
@@ -373,24 +385,234 @@ class TestOllamaAttack:
|
|||||||
ctx.list_wordlist_files.return_value = ["rockyou.txt"]
|
ctx.list_wordlist_files.return_value = ["rockyou.txt"]
|
||||||
ctx.hcatWordlists = "/tmp/wl"
|
ctx.hcatWordlists = "/tmp/wl"
|
||||||
|
|
||||||
# mode "2", then pick wordlist "1"
|
# mode "2" from interactive_menu, then pick wordlist "1" via input
|
||||||
with patch("builtins.input", side_effect=["2", "1"]):
|
with (
|
||||||
|
patch("hate_crack.attacks.interactive_menu", return_value="2"),
|
||||||
|
patch("builtins.input", side_effect=["1"]),
|
||||||
|
):
|
||||||
ollama_attack(ctx)
|
ollama_attack(ctx)
|
||||||
|
|
||||||
args = ctx.hcatOllama.call_args[0]
|
args = ctx.hcatOllama.call_args[0]
|
||||||
assert args[2] == "wordlist"
|
assert args[2] == "wordlist"
|
||||||
assert args[3].endswith("rockyou.txt")
|
assert args[3].endswith("rockyou.txt")
|
||||||
|
|
||||||
def test_invalid_mode_does_not_call_hcatOllama(self) -> None:
|
def test_escape_cancels_without_calling_hcatOllama(self) -> None:
|
||||||
|
"""None from interactive_menu (Escape / 99) cancels the attack."""
|
||||||
ctx = _make_ctx()
|
ctx = _make_ctx()
|
||||||
with patch("builtins.input", side_effect=["9"]):
|
with patch("hate_crack.attacks.interactive_menu", return_value=None):
|
||||||
|
ollama_attack(ctx)
|
||||||
|
ctx.hcatOllama.assert_not_called()
|
||||||
|
|
||||||
|
def test_cancel_key_cancels_without_calling_hcatOllama(self) -> None:
|
||||||
|
"""Selecting '99' (Cancel) cancels the attack."""
|
||||||
|
ctx = _make_ctx()
|
||||||
|
with patch("hate_crack.attacks.interactive_menu", return_value="99"):
|
||||||
ollama_attack(ctx)
|
ollama_attack(ctx)
|
||||||
ctx.hcatOllama.assert_not_called()
|
ctx.hcatOllama.assert_not_called()
|
||||||
|
|
||||||
def test_wordlist_mode_aborts_when_no_wordlist_picked(self) -> None:
|
def test_wordlist_mode_aborts_when_no_wordlist_picked(self) -> None:
|
||||||
ctx = _make_ctx()
|
ctx = _make_ctx()
|
||||||
# mode "2", then an invalid picker selection -> picker returns None
|
# mode "2" from interactive_menu, then user cancels the file picker
|
||||||
ctx.list_wordlist_files.return_value = []
|
ctx.list_wordlist_files.return_value = []
|
||||||
with patch("builtins.input", side_effect=["2", "nonsense"]):
|
with (
|
||||||
|
patch("hate_crack.attacks.interactive_menu", return_value="2"),
|
||||||
|
patch("builtins.input", side_effect=["q"]),
|
||||||
|
):
|
||||||
ollama_attack(ctx)
|
ollama_attack(ctx)
|
||||||
ctx.hcatOllama.assert_not_called()
|
ctx.hcatOllama.assert_not_called()
|
||||||
|
|
||||||
|
def test_cracked_mode_offered_when_out_file_has_content(
|
||||||
|
self, tmp_path: Path
|
||||||
|
) -> 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))
|
||||||
|
|
||||||
|
captured_items: list[list[tuple[str, str]]] = []
|
||||||
|
|
||||||
|
def capture_menu(items, **kwargs):
|
||||||
|
captured_items.append(list(items))
|
||||||
|
return "3"
|
||||||
|
|
||||||
|
with patch("hate_crack.attacks.interactive_menu", side_effect=capture_menu):
|
||||||
|
ollama_attack(ctx)
|
||||||
|
|
||||||
|
# Option 3 must be present in the items list when cracked file exists
|
||||||
|
keys = [k for k, _ in captured_items[0]]
|
||||||
|
assert "3" in keys
|
||||||
|
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
|
||||||
|
) -> None:
|
||||||
|
"""Option 3 must NOT appear in items when no cracked file exists."""
|
||||||
|
hash_file = tmp_path / "hashes.txt"
|
||||||
|
hash_file.touch()
|
||||||
|
ctx = _make_ctx(hash_file=str(hash_file))
|
||||||
|
|
||||||
|
captured_items: list[list[tuple[str, str]]] = []
|
||||||
|
|
||||||
|
def capture_menu(items, **kwargs):
|
||||||
|
captured_items.append(list(items))
|
||||||
|
return "99" # cancel
|
||||||
|
|
||||||
|
with patch("hate_crack.attacks.interactive_menu", side_effect=capture_menu):
|
||||||
|
ollama_attack(ctx)
|
||||||
|
|
||||||
|
keys = [k for k, _ in captured_items[0]]
|
||||||
|
assert "3" not in keys
|
||||||
|
ctx.hcatOllama.assert_not_called()
|
||||||
|
|
||||||
|
def test_cracked_mode_not_offered_when_out_file_empty(
|
||||||
|
self, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""Option 3 must NOT appear in items when cracked file exists but is empty."""
|
||||||
|
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))
|
||||||
|
|
||||||
|
captured_items: list[list[tuple[str, str]]] = []
|
||||||
|
|
||||||
|
def capture_menu(items, **kwargs):
|
||||||
|
captured_items.append(list(items))
|
||||||
|
return "99" # cancel
|
||||||
|
|
||||||
|
with patch("hate_crack.attacks.interactive_menu", side_effect=capture_menu):
|
||||||
|
ollama_attack(ctx)
|
||||||
|
|
||||||
|
keys = [k for k, _ in captured_items[0]]
|
||||||
|
assert "3" not in keys
|
||||||
|
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("hate_crack.attacks.interactive_menu", return_value="1"),
|
||||||
|
patch("builtins.input", side_effect=["ACME", "tech", "NYC"]),
|
||||||
|
):
|
||||||
|
ollama_attack(ctx)
|
||||||
|
|
||||||
|
assert ctx.hcatOllama.call_args[0][2] == "target"
|
||||||
|
|
||||||
|
def test_arrow_menu_env_reaches_ollama_attack(self) -> None:
|
||||||
|
"""HATE_CRACK_ARROW_MENU=1 routes through interactive_menu in ollama_attack."""
|
||||||
|
import os
|
||||||
|
from hate_crack.attacks import interactive_menu as real_im
|
||||||
|
|
||||||
|
ctx = _make_ctx()
|
||||||
|
calls: list[tuple] = []
|
||||||
|
|
||||||
|
def spy_menu(items, **kwargs):
|
||||||
|
calls.append(tuple(items))
|
||||||
|
return "99" # cancel immediately
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("hate_crack.attacks.interactive_menu", side_effect=spy_menu),
|
||||||
|
):
|
||||||
|
ollama_attack(ctx)
|
||||||
|
|
||||||
|
# interactive_menu was called — confirms the arrow-menu code path is reachable
|
||||||
|
assert len(calls) == 1
|
||||||
|
keys = [k for k, _ in calls[0]]
|
||||||
|
assert "1" in keys
|
||||||
|
assert "2" in keys
|
||||||
|
assert "99" in keys
|
||||||
|
|
||||||
|
|
||||||
|
class TestOmenPickTrainingWordlistReprompt:
|
||||||
|
"""_omen_pick_training_wordlist re-prompts on invalid input instead of aborting."""
|
||||||
|
|
||||||
|
def _make_ctx(self, wordlist_files=None):
|
||||||
|
ctx = MagicMock()
|
||||||
|
ctx.list_wordlist_files.return_value = wordlist_files or ["rockyou.txt"]
|
||||||
|
ctx.hcatWordlists = "/tmp/wl"
|
||||||
|
ctx.hcatHashFile = "/tmp/hashes.txt"
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
def test_invalid_input_reprompts_then_valid_pick(self) -> None:
|
||||||
|
from hate_crack.attacks import _omen_pick_training_wordlist
|
||||||
|
|
||||||
|
ctx = self._make_ctx(["rockyou.txt"])
|
||||||
|
# First input is invalid, second is valid
|
||||||
|
with patch("builtins.input", side_effect=["bad", "1"]):
|
||||||
|
result = _omen_pick_training_wordlist(ctx)
|
||||||
|
assert result is not None
|
||||||
|
assert "rockyou.txt" in result
|
||||||
|
|
||||||
|
def test_cancel_with_q_returns_none(self) -> None:
|
||||||
|
from hate_crack.attacks import _omen_pick_training_wordlist
|
||||||
|
|
||||||
|
ctx = self._make_ctx(["rockyou.txt"])
|
||||||
|
with patch("builtins.input", return_value="q"):
|
||||||
|
result = _omen_pick_training_wordlist(ctx)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_multiple_invalid_inputs_then_cancel(self) -> None:
|
||||||
|
from hate_crack.attacks import _omen_pick_training_wordlist
|
||||||
|
|
||||||
|
ctx = self._make_ctx(["rockyou.txt"])
|
||||||
|
with patch("builtins.input", side_effect=["99", "abc", "q"]):
|
||||||
|
result = _omen_pick_training_wordlist(ctx)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestMarkovPickTrainingSourceReprompt:
|
||||||
|
"""_markov_pick_training_source re-prompts on invalid input instead of aborting."""
|
||||||
|
|
||||||
|
def _make_ctx(self, tmp_path, has_cracked=False, wordlist_files=None):
|
||||||
|
ctx = MagicMock()
|
||||||
|
hash_file = str(tmp_path / "hashes.txt")
|
||||||
|
ctx.hcatHashFile = hash_file
|
||||||
|
ctx.list_wordlist_files.return_value = wordlist_files or ["rockyou.txt"]
|
||||||
|
ctx.hcatWordlists = str(tmp_path / "wordlists")
|
||||||
|
if has_cracked:
|
||||||
|
(tmp_path / "hashes.txt.out").write_text("cracked_pw\n")
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
def test_invalid_input_reprompts_then_valid_pick(self, tmp_path: Path) -> None:
|
||||||
|
from hate_crack.attacks import _markov_pick_training_source
|
||||||
|
|
||||||
|
ctx = self._make_ctx(tmp_path, wordlist_files=["rockyou.txt"])
|
||||||
|
with patch("builtins.input", side_effect=["bad", "1"]):
|
||||||
|
result = _markov_pick_training_source(ctx)
|
||||||
|
assert result is not None
|
||||||
|
assert "rockyou.txt" in result
|
||||||
|
|
||||||
|
def test_cancel_with_q_returns_none(self, tmp_path: Path) -> None:
|
||||||
|
from hate_crack.attacks import _markov_pick_training_source
|
||||||
|
|
||||||
|
ctx = self._make_ctx(tmp_path)
|
||||||
|
with patch("builtins.input", return_value="q"):
|
||||||
|
result = _markov_pick_training_source(ctx)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_multiple_invalid_then_cancel(self, tmp_path: Path) -> None:
|
||||||
|
from hate_crack.attacks import _markov_pick_training_source
|
||||||
|
|
||||||
|
ctx = self._make_ctx(tmp_path, wordlist_files=["rockyou.txt"])
|
||||||
|
with patch("builtins.input", side_effect=["99", "abc", "q"]):
|
||||||
|
result = _markov_pick_training_source(ctx)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_caller_handles_none_correctly(self, tmp_path: Path) -> None:
|
||||||
|
"""markov_brute_force returns early without crashing when picker returns None."""
|
||||||
|
from hate_crack.attacks import markov_brute_force
|
||||||
|
|
||||||
|
ctx = self._make_ctx(tmp_path, wordlist_files=["rockyou.txt"])
|
||||||
|
# No .hcstat2 file → goes straight to picker; user cancels
|
||||||
|
with patch("builtins.input", return_value="q"):
|
||||||
|
markov_brute_force(ctx)
|
||||||
|
ctx.hcatMarkovTrain.assert_not_called()
|
||||||
|
ctx.hcatMarkovBruteForce.assert_not_called()
|
||||||
|
|||||||
@@ -228,3 +228,89 @@ def test_unknown_mode_prints_error(ollama_env, capsys):
|
|||||||
captured = capsys.readouterr()
|
captured = capsys.readouterr()
|
||||||
assert "Unknown LLM generation mode" in captured.out
|
assert "Unknown LLM generation mode" in captured.out
|
||||||
gen.assert_not_called()
|
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
|
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():
|
def test_dedupes_and_caps_length():
|
||||||
long_pw = "A" * 129
|
long_pw = "A" * 129
|
||||||
p_instr, p_openai, p_agent, agent_cls, agent_instance = _patch_agent(
|
p_instr, p_openai, p_agent, agent_cls, agent_instance = _patch_agent(
|
||||||
@@ -145,3 +210,113 @@ def test_api_timeout_reraised_as_domain_error():
|
|||||||
"target", {"company": "X", "industry": "Y", "location": "Z"},
|
"target", {"company": "X", "industry": "Y", "location": "Z"},
|
||||||
timeout=1.0,
|
timeout=1.0,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# research_target
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_research_agent(industry, location):
|
||||||
|
"""Patch client builders + AtomicAgent for a research call. No network."""
|
||||||
|
result = mock.MagicMock()
|
||||||
|
result.industry = industry
|
||||||
|
result.location = location
|
||||||
|
|
||||||
|
agent_instance = mock.MagicMock()
|
||||||
|
agent_instance.run.return_value = result
|
||||||
|
|
||||||
|
agent_cls = mock.MagicMock()
|
||||||
|
agent_cls.__getitem__.return_value.return_value = agent_instance
|
||||||
|
|
||||||
|
return (
|
||||||
|
mock.patch(
|
||||||
|
"hate_crack.llm.instructor.from_openai",
|
||||||
|
return_value=mock.MagicMock(spec=instructor.Instructor),
|
||||||
|
),
|
||||||
|
mock.patch("hate_crack.llm.OpenAI"),
|
||||||
|
mock.patch("hate_crack.llm.AtomicAgent", agent_cls),
|
||||||
|
agent_cls,
|
||||||
|
agent_instance,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_research_target_returns_fields_and_passes_company():
|
||||||
|
p_instr, p_openai, p_agent, agent_cls, agent_instance = _patch_research_agent(
|
||||||
|
"freight rail maintenance", "Omaha, Nebraska"
|
||||||
|
)
|
||||||
|
with p_instr, p_openai, p_agent:
|
||||||
|
out = llm.research_target(
|
||||||
|
"http://localhost:11434", "qwen2.5:32b", 2048, "Acme Rail Services"
|
||||||
|
)
|
||||||
|
assert out.industry == "freight rail maintenance"
|
||||||
|
assert out.location == "Omaha, Nebraska"
|
||||||
|
assert agent_instance.run.call_args[0][0].company == "Acme Rail Services"
|
||||||
|
|
||||||
|
|
||||||
|
def test_research_target_uses_research_prompt_and_num_ctx():
|
||||||
|
p_instr, p_openai, p_agent, agent_cls, agent_instance = _patch_research_agent(
|
||||||
|
"x", "y"
|
||||||
|
)
|
||||||
|
with p_instr, p_openai, p_agent:
|
||||||
|
llm.research_target("http://localhost:11434", "qwen2.5:32b", 4096, "Acme")
|
||||||
|
config = agent_cls.__getitem__.return_value.call_args.kwargs["config"]
|
||||||
|
assert config.system_prompt_generator is llm._RESEARCH_PROMPT
|
||||||
|
assert config.model_api_parameters["extra_body"]["options"]["num_ctx"] == 4096
|
||||||
|
|
||||||
|
|
||||||
|
def test_research_prompt_tells_model_to_return_empty_when_unsure():
|
||||||
|
rendered = llm._RESEARCH_PROMPT.generate_prompt().lower()
|
||||||
|
assert "empty string" in rendered
|
||||||
|
assert "no internet access" in rendered
|
||||||
|
|
||||||
|
|
||||||
|
def test_research_target_strips_and_blanks_whitespace_only():
|
||||||
|
p_instr, p_openai, p_agent, agent_cls, agent_instance = _patch_research_agent(
|
||||||
|
" healthcare ", " "
|
||||||
|
)
|
||||||
|
with p_instr, p_openai, p_agent:
|
||||||
|
out = llm.research_target("http://localhost:11434", "m", 2048, "Acme")
|
||||||
|
assert out.industry == "healthcare"
|
||||||
|
assert out.location == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_research_target_caps_overlong_values():
|
||||||
|
p_instr, p_openai, p_agent, agent_cls, agent_instance = _patch_research_agent(
|
||||||
|
"A" * 500, "B" * (llm.MAX_RESEARCH_FIELD_LEN + 1)
|
||||||
|
)
|
||||||
|
with p_instr, p_openai, p_agent:
|
||||||
|
out = llm.research_target("http://localhost:11434", "m", 2048, "Acme")
|
||||||
|
assert len(out.industry) == llm.MAX_RESEARCH_FIELD_LEN
|
||||||
|
assert len(out.location) == llm.MAX_RESEARCH_FIELD_LEN
|
||||||
|
|
||||||
|
|
||||||
|
def test_research_target_tolerates_non_string_fields():
|
||||||
|
p_instr, p_openai, p_agent, agent_cls, agent_instance = _patch_research_agent(
|
||||||
|
None, 42
|
||||||
|
)
|
||||||
|
with p_instr, p_openai, p_agent:
|
||||||
|
out = llm.research_target("http://localhost:11434", "m", 2048, "Acme")
|
||||||
|
assert out.industry == ""
|
||||||
|
assert out.location == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_research_target_timeout_forwarded_and_translated():
|
||||||
|
p_instr, p_openai, p_agent, agent_cls, agent_instance = _patch_research_agent(
|
||||||
|
"x", "y"
|
||||||
|
)
|
||||||
|
agent_instance.run.side_effect = openai.APITimeoutError(
|
||||||
|
request=httpx.Request("POST", "http://localhost:11434/v1/chat/completions")
|
||||||
|
)
|
||||||
|
with p_instr, p_openai as openai_cls, p_agent:
|
||||||
|
with pytest.raises(llm.LLMTimeoutError):
|
||||||
|
llm.research_target(
|
||||||
|
"http://localhost:11434", "m", 2048, "Acme", timeout=7.5
|
||||||
|
)
|
||||||
|
assert openai_cls.call_args.kwargs["timeout"] == 7.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_research_field_collapses_internal_whitespace():
|
||||||
|
assert llm.clean_research_field("commercial \n construction") == (
|
||||||
|
"commercial construction"
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
"""Tests for LLM target-research pre-fill (menu 12, Target info mode).
|
||||||
|
|
||||||
|
Covers both layers: hcatOllamaResearchTarget in main (spinner + failure
|
||||||
|
degradation) and ollama_attack's editable-default prompts in attacks. The LLM is
|
||||||
|
always mocked; no network.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from unittest import mock
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
os.environ["HATE_CRACK_SKIP_INIT"] = "1"
|
||||||
|
from hate_crack import llm # noqa: E402
|
||||||
|
from hate_crack import main as hc_main # noqa: E402
|
||||||
|
from hate_crack.attacks import ollama_attack # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _make_ctx(hash_type: str = "1000", hash_file: str = "/tmp/hashes.txt") -> MagicMock:
|
||||||
|
ctx = MagicMock()
|
||||||
|
ctx.hcatHashType = hash_type
|
||||||
|
ctx.hcatHashFile = hash_file
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
class _InputRecorder:
|
||||||
|
"""input() stub that records prompts and replays canned answers."""
|
||||||
|
|
||||||
|
def __init__(self, answers):
|
||||||
|
self.answers = list(answers)
|
||||||
|
self.prompts: list[str] = []
|
||||||
|
|
||||||
|
def __call__(self, prompt=""):
|
||||||
|
self.prompts.append(prompt)
|
||||||
|
return self.answers.pop(0)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_target_mode(ctx, answers):
|
||||||
|
recorder = _InputRecorder(answers)
|
||||||
|
with (
|
||||||
|
patch("hate_crack.attacks.interactive_menu", return_value="1"),
|
||||||
|
patch("builtins.input", recorder),
|
||||||
|
):
|
||||||
|
ollama_attack(ctx)
|
||||||
|
return recorder
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# attacks layer: prompt defaults
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestResearchPrefill:
|
||||||
|
def test_successful_research_prefills_defaults(self) -> None:
|
||||||
|
ctx = _make_ctx()
|
||||||
|
ctx.hcatOllamaResearchTarget.return_value = {
|
||||||
|
"industry": "freight rail maintenance",
|
||||||
|
"location": "Omaha, Nebraska",
|
||||||
|
}
|
||||||
|
|
||||||
|
recorder = _run_target_mode(ctx, ["Acme Rail", "", ""])
|
||||||
|
|
||||||
|
ctx.hcatOllamaResearchTarget.assert_called_once_with("Acme Rail")
|
||||||
|
assert recorder.prompts == [
|
||||||
|
"Company name: ",
|
||||||
|
"Industry (freight rail maintenance): ",
|
||||||
|
"Location (Omaha, Nebraska): ",
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_enter_accepts_the_suggested_defaults(self) -> None:
|
||||||
|
ctx = _make_ctx()
|
||||||
|
ctx.hcatOllamaResearchTarget.return_value = {
|
||||||
|
"industry": "healthcare",
|
||||||
|
"location": "Austin, Texas",
|
||||||
|
}
|
||||||
|
|
||||||
|
_run_target_mode(ctx, ["Acme", "", ""])
|
||||||
|
|
||||||
|
assert ctx.hcatOllama.call_args[0][3] == {
|
||||||
|
"company": "Acme",
|
||||||
|
"industry": "healthcare",
|
||||||
|
"location": "Austin, Texas",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_typed_input_overrides_the_default(self) -> None:
|
||||||
|
ctx = _make_ctx()
|
||||||
|
ctx.hcatOllamaResearchTarget.return_value = {
|
||||||
|
"industry": "healthcare",
|
||||||
|
"location": "Austin, Texas",
|
||||||
|
}
|
||||||
|
|
||||||
|
_run_target_mode(ctx, ["Acme", " banking ", "Berlin"])
|
||||||
|
|
||||||
|
assert ctx.hcatOllama.call_args[0][3] == {
|
||||||
|
"company": "Acme",
|
||||||
|
"industry": "banking",
|
||||||
|
"location": "Berlin",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_partial_research_prefills_only_known_field(self) -> None:
|
||||||
|
ctx = _make_ctx()
|
||||||
|
ctx.hcatOllamaResearchTarget.return_value = {
|
||||||
|
"industry": "mining",
|
||||||
|
"location": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
recorder = _run_target_mode(ctx, ["Acme", "", "Perth"])
|
||||||
|
|
||||||
|
assert recorder.prompts[1] == "Industry (mining): "
|
||||||
|
assert recorder.prompts[2] == "Location: "
|
||||||
|
assert ctx.hcatOllama.call_args[0][3]["location"] == "Perth"
|
||||||
|
|
||||||
|
def test_empty_research_falls_back_to_blank_prompts(self) -> None:
|
||||||
|
ctx = _make_ctx()
|
||||||
|
ctx.hcatOllamaResearchTarget.return_value = {"industry": "", "location": ""}
|
||||||
|
|
||||||
|
recorder = _run_target_mode(ctx, ["Acme", "tech", "NYC"])
|
||||||
|
|
||||||
|
assert recorder.prompts == ["Company name: ", "Industry: ", "Location: "]
|
||||||
|
assert ctx.hcatOllama.call_args[0][3] == {
|
||||||
|
"company": "Acme",
|
||||||
|
"industry": "tech",
|
||||||
|
"location": "NYC",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_whitespace_only_research_is_treated_as_no_suggestion(self) -> None:
|
||||||
|
ctx = _make_ctx()
|
||||||
|
ctx.hcatOllamaResearchTarget.return_value = {
|
||||||
|
"industry": " ",
|
||||||
|
"location": "\t\n",
|
||||||
|
}
|
||||||
|
|
||||||
|
recorder = _run_target_mode(ctx, ["Acme", "", ""])
|
||||||
|
|
||||||
|
assert recorder.prompts == ["Company name: ", "Industry: ", "Location: "]
|
||||||
|
|
||||||
|
def test_overlong_suggestion_is_capped_in_the_prompt(self) -> None:
|
||||||
|
ctx = _make_ctx()
|
||||||
|
ctx.hcatOllamaResearchTarget.return_value = {
|
||||||
|
"industry": "z" * 500,
|
||||||
|
"location": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
recorder = _run_target_mode(ctx, ["Acme", "", ""])
|
||||||
|
|
||||||
|
assert recorder.prompts[1] == f"Industry ({'z' * llm.MAX_RESEARCH_FIELD_LEN}): "
|
||||||
|
industry = ctx.hcatOllama.call_args[0][3]["industry"]
|
||||||
|
assert len(industry) == llm.MAX_RESEARCH_FIELD_LEN
|
||||||
|
|
||||||
|
def test_research_exception_falls_back_and_does_not_abort(self) -> None:
|
||||||
|
"""Even an unexpected raise from the research call must not block."""
|
||||||
|
ctx = _make_ctx()
|
||||||
|
ctx.hcatOllamaResearchTarget.side_effect = RuntimeError("boom")
|
||||||
|
|
||||||
|
recorder = _run_target_mode(ctx, ["Acme", "tech", "NYC"])
|
||||||
|
|
||||||
|
assert recorder.prompts == ["Company name: ", "Industry: ", "Location: "]
|
||||||
|
ctx.hcatOllama.assert_called_once()
|
||||||
|
|
||||||
|
def test_non_dict_research_result_falls_back(self) -> None:
|
||||||
|
ctx = _make_ctx()
|
||||||
|
ctx.hcatOllamaResearchTarget.return_value = None
|
||||||
|
|
||||||
|
recorder = _run_target_mode(ctx, ["Acme", "tech", "NYC"])
|
||||||
|
|
||||||
|
assert recorder.prompts == ["Company name: ", "Industry: ", "Location: "]
|
||||||
|
|
||||||
|
def test_blank_company_skips_research_entirely(self) -> None:
|
||||||
|
ctx = _make_ctx()
|
||||||
|
|
||||||
|
recorder = _run_target_mode(ctx, ["", "tech", "NYC"])
|
||||||
|
|
||||||
|
ctx.hcatOllamaResearchTarget.assert_not_called()
|
||||||
|
assert recorder.prompts == ["Company name: ", "Industry: ", "Location: "]
|
||||||
|
|
||||||
|
def test_suggestions_are_labelled_as_guesses(self, capsys) -> None:
|
||||||
|
ctx = _make_ctx()
|
||||||
|
ctx.hcatOllamaResearchTarget.return_value = {
|
||||||
|
"industry": "healthcare",
|
||||||
|
"location": "Austin, Texas",
|
||||||
|
}
|
||||||
|
|
||||||
|
_run_target_mode(ctx, ["Acme", "", ""])
|
||||||
|
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "GUESSES" in out
|
||||||
|
assert "not verified OSINT" in out
|
||||||
|
|
||||||
|
def test_no_guess_banner_when_research_returns_nothing(self, capsys) -> None:
|
||||||
|
ctx = _make_ctx()
|
||||||
|
ctx.hcatOllamaResearchTarget.return_value = {"industry": "", "location": ""}
|
||||||
|
|
||||||
|
_run_target_mode(ctx, ["Acme", "", ""])
|
||||||
|
|
||||||
|
assert "GUESSES" not in capsys.readouterr().out
|
||||||
|
|
||||||
|
def test_hcatOllama_call_shape_unchanged(self) -> None:
|
||||||
|
ctx = _make_ctx(hash_type="1800", hash_file="/tmp/sha512.txt")
|
||||||
|
ctx.hcatOllamaResearchTarget.return_value = {
|
||||||
|
"industry": "healthcare",
|
||||||
|
"location": "Austin",
|
||||||
|
}
|
||||||
|
|
||||||
|
_run_target_mode(ctx, ["Acme", "", ""])
|
||||||
|
|
||||||
|
args = ctx.hcatOllama.call_args[0]
|
||||||
|
assert args[0] == "1800"
|
||||||
|
assert args[1] == "/tmp/sha512.txt"
|
||||||
|
assert args[2] == "target"
|
||||||
|
assert set(args[3]) == {"company", "industry", "location"}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# main layer: hcatOllamaResearchTarget
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestHcatOllamaResearchTarget:
|
||||||
|
def test_returns_researched_fields_and_shows_progress(self) -> None:
|
||||||
|
result = hc_main.llm.TargetResearchOutput(industry="mining", location="Perth")
|
||||||
|
with (
|
||||||
|
mock.patch.object(hc_main, "ollamaAutoResearch", True),
|
||||||
|
mock.patch.object(hc_main, "ollamaModel", "qwen2.5:32b"),
|
||||||
|
mock.patch.object(hc_main, "ollamaTimeout", 30.0),
|
||||||
|
mock.patch.object(hc_main.llm, "research_target", return_value=result),
|
||||||
|
mock.patch.object(hc_main, "spinner") as spin,
|
||||||
|
):
|
||||||
|
out = hc_main.hcatOllamaResearchTarget("Acme")
|
||||||
|
|
||||||
|
assert out == {"industry": "mining", "location": "Perth"}
|
||||||
|
# The call must be wrapped in the progress spinner, not look like a hang.
|
||||||
|
assert spin.call_count == 1
|
||||||
|
assert "Acme" in spin.call_args[0][0]
|
||||||
|
|
||||||
|
def test_forwards_config_values_to_research_target(self) -> None:
|
||||||
|
result = hc_main.llm.TargetResearchOutput(industry="", location="")
|
||||||
|
with (
|
||||||
|
mock.patch.object(hc_main, "ollamaAutoResearch", True),
|
||||||
|
mock.patch.object(hc_main, "ollamaUrl", "http://localhost:11434"),
|
||||||
|
mock.patch.object(hc_main, "ollamaModel", "m"),
|
||||||
|
mock.patch.object(hc_main, "ollamaNumCtx", 4096),
|
||||||
|
mock.patch.object(hc_main, "ollamaTimeout", 12.5),
|
||||||
|
mock.patch.object(
|
||||||
|
hc_main.llm, "research_target", return_value=result
|
||||||
|
) as research,
|
||||||
|
):
|
||||||
|
hc_main.hcatOllamaResearchTarget("Acme")
|
||||||
|
|
||||||
|
assert research.call_args[0] == ("http://localhost:11434", "m", 4096, "Acme")
|
||||||
|
assert research.call_args.kwargs["timeout"] == 12.5
|
||||||
|
|
||||||
|
def test_timeout_degrades_to_blank_suggestions(self, capsys) -> None:
|
||||||
|
with (
|
||||||
|
mock.patch.object(hc_main, "ollamaAutoResearch", True),
|
||||||
|
mock.patch.object(hc_main, "ollamaTimeout", 5.0),
|
||||||
|
mock.patch.object(
|
||||||
|
hc_main.llm,
|
||||||
|
"research_target",
|
||||||
|
side_effect=hc_main.llm.LLMTimeoutError("no response"),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
out = hc_main.hcatOllamaResearchTarget("Acme")
|
||||||
|
|
||||||
|
assert out == {"industry": "", "location": ""}
|
||||||
|
assert "timed out" in capsys.readouterr().out
|
||||||
|
|
||||||
|
def test_connection_failure_degrades_to_blank_suggestions(self, capsys) -> None:
|
||||||
|
with (
|
||||||
|
mock.patch.object(hc_main, "ollamaAutoResearch", True),
|
||||||
|
mock.patch.object(
|
||||||
|
hc_main.llm,
|
||||||
|
"research_target",
|
||||||
|
side_effect=ConnectionError("connection refused"),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
out = hc_main.hcatOllamaResearchTarget("Acme")
|
||||||
|
|
||||||
|
assert out == {"industry": "", "location": ""}
|
||||||
|
assert "unavailable" in capsys.readouterr().out
|
||||||
|
|
||||||
|
def test_disabled_by_config_skips_the_model_call(self) -> None:
|
||||||
|
with (
|
||||||
|
mock.patch.object(hc_main, "ollamaAutoResearch", False),
|
||||||
|
mock.patch.object(hc_main.llm, "research_target") as research,
|
||||||
|
):
|
||||||
|
out = hc_main.hcatOllamaResearchTarget("Acme")
|
||||||
|
|
||||||
|
research.assert_not_called()
|
||||||
|
assert out == {"industry": "", "location": ""}
|
||||||
|
|
||||||
|
def test_blank_company_skips_the_model_call(self) -> None:
|
||||||
|
with (
|
||||||
|
mock.patch.object(hc_main, "ollamaAutoResearch", True),
|
||||||
|
mock.patch.object(hc_main.llm, "research_target") as research,
|
||||||
|
):
|
||||||
|
out = hc_main.hcatOllamaResearchTarget("")
|
||||||
|
|
||||||
|
research.assert_not_called()
|
||||||
|
assert out == {"industry": "", "location": ""}
|
||||||
|
|
||||||
|
def test_auto_research_default_is_enabled(self) -> None:
|
||||||
|
assert hc_main.ollamaAutoResearch is True
|
||||||
@@ -0,0 +1,461 @@
|
|||||||
|
"""Tests for the capped / evenly-sampled wordlist path in hcatOllama.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- ollamaMaxSampleLines config default (500)
|
||||||
|
- Small wordlist (< cap): all lines included, "Loaded N" message
|
||||||
|
- Large wordlist (> cap): exactly `cap` lines sampled, "Sampled N of M" message
|
||||||
|
- Evenly-spaced sample covers the whole file (first and last entries present)
|
||||||
|
- hash:password splitting and blank-line skipping still work
|
||||||
|
- spinner is called with the right message regardless of TTY
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
os.environ["HATE_CRACK_SKIP_INIT"] = "1"
|
||||||
|
from hate_crack import main as hc_main # noqa: E402
|
||||||
|
|
||||||
|
OLLAMA_URL = "http://localhost:11434"
|
||||||
|
MODEL = "test-model"
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _ollama_globals(tmp_path, *, max_sample: int = 500):
|
||||||
|
rules_dir = str(tmp_path / "rules")
|
||||||
|
os.makedirs(rules_dir, exist_ok=True)
|
||||||
|
with (
|
||||||
|
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, "ollamaMaxSampleLines", max_sample),
|
||||||
|
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", rules_dir),
|
||||||
|
mock.patch("hate_crack.main.generate_session_id", return_value="s"),
|
||||||
|
):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
def _make_proc(rc: int = 0):
|
||||||
|
proc = mock.MagicMock()
|
||||||
|
proc.wait.return_value = rc
|
||||||
|
proc.communicate.return_value = (b"", b"")
|
||||||
|
proc.returncode = rc
|
||||||
|
return proc
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def env(tmp_path):
|
||||||
|
hash_file = tmp_path / "hashes.txt"
|
||||||
|
hash_file.touch()
|
||||||
|
return SimpleNamespace(tmp_path=tmp_path, hash_file=str(hash_file))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Config default
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Small wordlist — no capping
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_small_wordlist_loads_all(env, capsys):
|
||||||
|
"""When total lines < cap, all are included and message says 'Loaded'."""
|
||||||
|
wl = env.tmp_path / "small.txt"
|
||||||
|
wl.write_text("alpha\nbeta\ngamma\n")
|
||||||
|
|
||||||
|
with _ollama_globals(env.tmp_path), 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()
|
||||||
|
assert "Loaded 3" in captured.out
|
||||||
|
# All three words must appear in the sample passed to the LLM
|
||||||
|
sample = gen.call_args[0][4]["sample"]
|
||||||
|
assert "alpha" in sample
|
||||||
|
assert "beta" in sample
|
||||||
|
assert "gamma" in sample
|
||||||
|
|
||||||
|
|
||||||
|
def test_small_wordlist_no_sampled_message(env, capsys):
|
||||||
|
"""'Sampled N of M' must NOT appear when no capping occurred."""
|
||||||
|
wl = env.tmp_path / "small.txt"
|
||||||
|
wl.write_text("a\nb\nc\n")
|
||||||
|
|
||||||
|
with _ollama_globals(env.tmp_path, max_sample=500), 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))
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "Sampled" not in captured.out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Large wordlist — capping
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_large_wordlist(path, n: int) -> None:
|
||||||
|
"""Write n unique numbered lines to path."""
|
||||||
|
lines = "\n".join(f"word{i:06d}" for i in range(n))
|
||||||
|
path.write_text(lines + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def test_large_wordlist_caps_to_max(env, capsys):
|
||||||
|
"""When total > cap, exactly cap lines are sampled."""
|
||||||
|
wl = env.tmp_path / "big.txt"
|
||||||
|
_make_large_wordlist(wl, 1000)
|
||||||
|
|
||||||
|
captured_ctx: list[dict] = []
|
||||||
|
|
||||||
|
def _capture_gen(*args, **kwargs):
|
||||||
|
captured_ctx.append(args[4])
|
||||||
|
return ["x"]
|
||||||
|
|
||||||
|
with _ollama_globals(env.tmp_path, max_sample=50), mock.patch.object(
|
||||||
|
hc_main.llm, "generate_candidates", side_effect=_capture_gen
|
||||||
|
), mock.patch("subprocess.Popen", return_value=_make_proc()):
|
||||||
|
hc_main.hcatOllama("0", env.hash_file, "wordlist", str(wl))
|
||||||
|
|
||||||
|
sample_lines = captured_ctx[0]["sample"].splitlines()
|
||||||
|
assert len(sample_lines) == 50
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "Sampled 50 of 1,000" in captured.out
|
||||||
|
|
||||||
|
|
||||||
|
def test_large_wordlist_covers_full_range(env):
|
||||||
|
"""Evenly-spaced sample must contain entries from both the start and end of the file."""
|
||||||
|
wl = env.tmp_path / "range.txt"
|
||||||
|
_make_large_wordlist(wl, 1000)
|
||||||
|
|
||||||
|
captured_ctx: list[dict] = []
|
||||||
|
|
||||||
|
def _capture_gen(*args, **kwargs):
|
||||||
|
captured_ctx.append(args[4])
|
||||||
|
return ["x"]
|
||||||
|
|
||||||
|
# Use a small cap (10) over 1000 lines so the stride is clearly 100.
|
||||||
|
with _ollama_globals(env.tmp_path, max_sample=10), mock.patch.object(
|
||||||
|
hc_main.llm, "generate_candidates", side_effect=_capture_gen
|
||||||
|
), mock.patch("subprocess.Popen", return_value=_make_proc()):
|
||||||
|
hc_main.hcatOllama("0", env.hash_file, "wordlist", str(wl))
|
||||||
|
|
||||||
|
sample_lines = captured_ctx[0]["sample"].splitlines()
|
||||||
|
# The sampled words should come from across the file, not just the head.
|
||||||
|
# word000000..word000099 are the first 100; word000900..word000999 are the last 100.
|
||||||
|
indices = [int(w.replace("word", "")) for w in sample_lines]
|
||||||
|
assert min(indices) < 100, "sample should include entries from the start 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
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Invalid-cap guard — zero/negative ollamaMaxSampleLines 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
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_wordlist_sampling_strips_hash_prefix(env):
|
||||||
|
"""hash:password lines must contribute only the plaintext in capped mode."""
|
||||||
|
wl = env.tmp_path / "dump.txt"
|
||||||
|
# 20 lines: half with colon prefix, half plain; more than max_sample=5
|
||||||
|
lines = [f"hash{i}:plain{i:02d}" if i % 2 == 0 else f"plain{i:02d}" for i in range(20)]
|
||||||
|
wl.write_text("\n".join(lines) + "\n")
|
||||||
|
|
||||||
|
captured_ctx: list[dict] = []
|
||||||
|
|
||||||
|
def _capture_gen(*args, **kwargs):
|
||||||
|
captured_ctx.append(args[4])
|
||||||
|
return ["x"]
|
||||||
|
|
||||||
|
with _ollama_globals(env.tmp_path, max_sample=5), mock.patch.object(
|
||||||
|
hc_main.llm, "generate_candidates", side_effect=_capture_gen
|
||||||
|
), mock.patch("subprocess.Popen", return_value=_make_proc()):
|
||||||
|
hc_main.hcatOllama("0", env.hash_file, "wordlist", str(wl))
|
||||||
|
|
||||||
|
sample = captured_ctx[0]["sample"]
|
||||||
|
# No hash prefix should appear in the sample
|
||||||
|
assert "hash" not in sample
|
||||||
|
|
||||||
|
|
||||||
|
def test_wordlist_sampling_skips_blank_lines(env, capsys):
|
||||||
|
"""Blank lines must not count toward total or be included in the sample."""
|
||||||
|
wl = env.tmp_path / "blanks.txt"
|
||||||
|
# 3 real words surrounded by blank lines
|
||||||
|
wl.write_text("\nalpha\n\nbeta\n\ngamma\n\n")
|
||||||
|
|
||||||
|
with _ollama_globals(env.tmp_path, max_sample=500), 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()
|
||||||
|
assert "Loaded 3" in captured.out
|
||||||
|
sample = gen.call_args[0][4]["sample"]
|
||||||
|
for line in sample.splitlines():
|
||||||
|
assert line.strip() != "", "blank lines leaked into sample"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Spinner is invoked (via the TTY guard — non-TTY in test suite)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_spinner_called_with_model_message(env, capsys):
|
||||||
|
"""The spinner message must include the model name (non-TTY: just print)."""
|
||||||
|
wl = env.tmp_path / "s.txt"
|
||||||
|
wl.write_text("pw\n")
|
||||||
|
|
||||||
|
with _ollama_globals(env.tmp_path), 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))
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert MODEL in captured.out
|
||||||
|
assert "Generating password candidates via Ollama" in captured.out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _usable_plaintext unit tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_usable_plaintext_blank_line():
|
||||||
|
"""A blank line must return an empty string (discarded)."""
|
||||||
|
assert hc_main._usable_plaintext("") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_usable_plaintext_whitespace_only():
|
||||||
|
"""A whitespace-only line must return an empty string (discarded)."""
|
||||||
|
assert hc_main._usable_plaintext(" \t ") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_usable_plaintext_plain_password():
|
||||||
|
"""A plain password line (no colon) is returned stripped."""
|
||||||
|
assert hc_main._usable_plaintext("hunter2\n") == "hunter2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_usable_plaintext_hash_colon_password():
|
||||||
|
"""A hash:password line returns only the password portion."""
|
||||||
|
assert hc_main._usable_plaintext("aabbcc:hunter2") == "hunter2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_usable_plaintext_multiple_colons():
|
||||||
|
"""A line with multiple colons splits only on the first colon."""
|
||||||
|
assert hc_main._usable_plaintext("aabbcc:p@ss:word") == "p@ss:word"
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
+40
-14
@@ -277,8 +277,10 @@ class TestOmenAttackHandler:
|
|||||||
def test_use_existing_model(self, tmp_path):
|
def test_use_existing_model(self, tmp_path):
|
||||||
ctx = self._make_ctx(tmp_path, model_valid=True)
|
ctx = self._make_ctx(tmp_path, model_valid=True)
|
||||||
self._setup_rules_dir(tmp_path)
|
self._setup_rules_dir(tmp_path)
|
||||||
with patch("os.path.isfile", return_value=True), patch(
|
with (
|
||||||
"builtins.input", side_effect=["1", "", "0"]
|
patch("os.path.isfile", return_value=True),
|
||||||
|
patch("hate_crack.attacks.interactive_menu", return_value="1"),
|
||||||
|
patch("builtins.input", side_effect=["", "0"]),
|
||||||
):
|
):
|
||||||
from hate_crack.attacks import omen_attack
|
from hate_crack.attacks import omen_attack
|
||||||
|
|
||||||
@@ -289,8 +291,10 @@ class TestOmenAttackHandler:
|
|||||||
def test_train_new_model_with_wordlist_pick(self, tmp_path):
|
def test_train_new_model_with_wordlist_pick(self, tmp_path):
|
||||||
ctx = self._make_ctx(tmp_path, model_valid=True)
|
ctx = self._make_ctx(tmp_path, model_valid=True)
|
||||||
self._setup_rules_dir(tmp_path)
|
self._setup_rules_dir(tmp_path)
|
||||||
with patch("os.path.isfile", return_value=True), patch(
|
with (
|
||||||
"builtins.input", side_effect=["2", "1", "", "0"]
|
patch("os.path.isfile", return_value=True),
|
||||||
|
patch("hate_crack.attacks.interactive_menu", return_value="2"),
|
||||||
|
patch("builtins.input", side_effect=["1", "", "0"]),
|
||||||
):
|
):
|
||||||
from hate_crack.attacks import omen_attack
|
from hate_crack.attacks import omen_attack
|
||||||
|
|
||||||
@@ -302,8 +306,22 @@ class TestOmenAttackHandler:
|
|||||||
|
|
||||||
def test_cancel_aborts(self, tmp_path):
|
def test_cancel_aborts(self, tmp_path):
|
||||||
ctx = self._make_ctx(tmp_path, model_valid=True)
|
ctx = self._make_ctx(tmp_path, model_valid=True)
|
||||||
with patch("os.path.isfile", return_value=True), patch(
|
with (
|
||||||
"builtins.input", side_effect=["3"]
|
patch("os.path.isfile", return_value=True),
|
||||||
|
patch("hate_crack.attacks.interactive_menu", return_value="99"),
|
||||||
|
):
|
||||||
|
from hate_crack.attacks import omen_attack
|
||||||
|
|
||||||
|
omen_attack(ctx)
|
||||||
|
ctx.hcatOmenTrain.assert_not_called()
|
||||||
|
ctx.hcatOmen.assert_not_called()
|
||||||
|
|
||||||
|
def test_escape_cancels(self, tmp_path):
|
||||||
|
"""None from interactive_menu (Escape) cancels the attack."""
|
||||||
|
ctx = self._make_ctx(tmp_path, model_valid=True)
|
||||||
|
with (
|
||||||
|
patch("os.path.isfile", return_value=True),
|
||||||
|
patch("hate_crack.attacks.interactive_menu", return_value=None),
|
||||||
):
|
):
|
||||||
from hate_crack.attacks import omen_attack
|
from hate_crack.attacks import omen_attack
|
||||||
|
|
||||||
@@ -348,8 +366,10 @@ class TestOmenAttackHandler:
|
|||||||
def test_rules_passed_to_hcatOmen(self, tmp_path):
|
def test_rules_passed_to_hcatOmen(self, tmp_path):
|
||||||
ctx = self._make_ctx(tmp_path, model_valid=True)
|
ctx = self._make_ctx(tmp_path, model_valid=True)
|
||||||
self._setup_rules_dir(tmp_path, ["best64.rule"])
|
self._setup_rules_dir(tmp_path, ["best64.rule"])
|
||||||
with patch("os.path.isfile", return_value=True), patch(
|
with (
|
||||||
"builtins.input", side_effect=["1", "", "1"]
|
patch("os.path.isfile", return_value=True),
|
||||||
|
patch("hate_crack.attacks.interactive_menu", return_value="1"),
|
||||||
|
patch("builtins.input", side_effect=["", "1"]),
|
||||||
):
|
):
|
||||||
from hate_crack.attacks import omen_attack
|
from hate_crack.attacks import omen_attack
|
||||||
|
|
||||||
@@ -361,8 +381,10 @@ class TestOmenAttackHandler:
|
|||||||
def test_multiple_rule_chains_spawn_multiple_calls(self, tmp_path):
|
def test_multiple_rule_chains_spawn_multiple_calls(self, tmp_path):
|
||||||
ctx = self._make_ctx(tmp_path, model_valid=True)
|
ctx = self._make_ctx(tmp_path, model_valid=True)
|
||||||
self._setup_rules_dir(tmp_path, ["best64.rule", "dive.rule"])
|
self._setup_rules_dir(tmp_path, ["best64.rule", "dive.rule"])
|
||||||
with patch("os.path.isfile", return_value=True), patch(
|
with (
|
||||||
"builtins.input", side_effect=["1", "", "1,2"]
|
patch("os.path.isfile", return_value=True),
|
||||||
|
patch("hate_crack.attacks.interactive_menu", return_value="1"),
|
||||||
|
patch("builtins.input", side_effect=["", "1,2"]),
|
||||||
):
|
):
|
||||||
from hate_crack.attacks import omen_attack
|
from hate_crack.attacks import omen_attack
|
||||||
|
|
||||||
@@ -372,8 +394,10 @@ class TestOmenAttackHandler:
|
|||||||
def test_cancel_from_rules_aborts(self, tmp_path):
|
def test_cancel_from_rules_aborts(self, tmp_path):
|
||||||
ctx = self._make_ctx(tmp_path, model_valid=True)
|
ctx = self._make_ctx(tmp_path, model_valid=True)
|
||||||
self._setup_rules_dir(tmp_path, ["best64.rule"])
|
self._setup_rules_dir(tmp_path, ["best64.rule"])
|
||||||
with patch("os.path.isfile", return_value=True), patch(
|
with (
|
||||||
"builtins.input", side_effect=["1", "", "99"]
|
patch("os.path.isfile", return_value=True),
|
||||||
|
patch("hate_crack.attacks.interactive_menu", return_value="1"),
|
||||||
|
patch("builtins.input", side_effect=["", "99"]),
|
||||||
):
|
):
|
||||||
from hate_crack.attacks import omen_attack
|
from hate_crack.attacks import omen_attack
|
||||||
|
|
||||||
@@ -383,8 +407,10 @@ class TestOmenAttackHandler:
|
|||||||
def test_no_rules_passes_empty_chain(self, tmp_path):
|
def test_no_rules_passes_empty_chain(self, tmp_path):
|
||||||
ctx = self._make_ctx(tmp_path, model_valid=True)
|
ctx = self._make_ctx(tmp_path, model_valid=True)
|
||||||
self._setup_rules_dir(tmp_path, ["best64.rule"])
|
self._setup_rules_dir(tmp_path, ["best64.rule"])
|
||||||
with patch("os.path.isfile", return_value=True), patch(
|
with (
|
||||||
"builtins.input", side_effect=["1", "", "0"]
|
patch("os.path.isfile", return_value=True),
|
||||||
|
patch("hate_crack.attacks.interactive_menu", return_value="1"),
|
||||||
|
patch("builtins.input", side_effect=["", "0"]),
|
||||||
):
|
):
|
||||||
from hate_crack.attacks import omen_attack
|
from hate_crack.attacks import omen_attack
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"""Tests for hate_crack/progress.py — the generic spinner context manager."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from hate_crack.progress import spinner
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TTY guard: non-TTY path must print message once and not start a thread
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
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."""
|
||||||
|
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 must never construct a Thread."""
|
||||||
|
buf = io.StringIO()
|
||||||
|
buf.isatty = lambda: False # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
with mock.patch("sys.stdout", buf), mock.patch(
|
||||||
|
"hate_crack.progress.threading.Thread"
|
||||||
|
) as mock_thread:
|
||||||
|
with spinner("No threads please"):
|
||||||
|
pass
|
||||||
|
mock_thread.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TTY path: spinner starts a thread, clears the line on exit
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_tty_clears_line_on_exit() -> None:
|
||||||
|
"""After the context exits the spinner line must be erased."""
|
||||||
|
buf = io.StringIO()
|
||||||
|
buf.isatty = lambda: True # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
with mock.patch("sys.stdout", buf):
|
||||||
|
with spinner("Testing..."):
|
||||||
|
pass # no sleep needed — the finally block writes the escape regardless
|
||||||
|
|
||||||
|
output = buf.getvalue()
|
||||||
|
# Line clear sequence must be present somewhere after the spinner started.
|
||||||
|
assert "\033[2K\r" in output or ("\033[2K" in output)
|
||||||
|
|
||||||
|
|
||||||
|
def test_tty_shows_elapsed_seconds() -> None:
|
||||||
|
"""The spinner must render the elapsed-seconds counter in the expected format.
|
||||||
|
|
||||||
|
time.monotonic is patched so the test is deterministic regardless of
|
||||||
|
scheduling: the first call returns 0.0 (start), the second returns 3.0
|
||||||
|
(first tick), giving a stable "3s" label without sleeping.
|
||||||
|
"""
|
||||||
|
buf = io.StringIO()
|
||||||
|
buf.isatty = lambda: True # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
# Patch monotonic: start=0.0, then 3.0 at the first tick, then keep
|
||||||
|
# returning 3.0 so stop_event.wait() ends quickly.
|
||||||
|
mono_values = iter([0.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0])
|
||||||
|
|
||||||
|
with mock.patch("sys.stdout", buf), mock.patch(
|
||||||
|
"hate_crack.progress.time.monotonic", side_effect=mono_values
|
||||||
|
):
|
||||||
|
with spinner("Counting..."):
|
||||||
|
pass # body exits immediately; the thread gets one tick then stops
|
||||||
|
|
||||||
|
output = buf.getvalue()
|
||||||
|
assert "Counting..." in output
|
||||||
|
# At least one frame must match the "Ns" elapsed format (e.g. "3s").
|
||||||
|
assert re.search(r"\d+s", output), f"Expected elapsed counter in output: {output!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tty_exception_still_clears_line() -> None:
|
||||||
|
"""Spinner must clear the line even when the body raises."""
|
||||||
|
buf = io.StringIO()
|
||||||
|
buf.isatty = lambda: True # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
with mock.patch("sys.stdout", buf):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
with spinner("Will raise"):
|
||||||
|
raise ValueError("boom")
|
||||||
|
|
||||||
|
output = buf.getvalue()
|
||||||
|
assert "\033[2K\r" in output or ("\033[2K" in output)
|
||||||
|
|
||||||
|
|
||||||
|
def test_spinner_does_not_swallow_exception() -> None:
|
||||||
|
"""The spinner context manager must re-raise exceptions from the body."""
|
||||||
|
buf = io.StringIO()
|
||||||
|
buf.isatty = lambda: True # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
with mock.patch("sys.stdout", buf):
|
||||||
|
with pytest.raises(RuntimeError, match="test error"):
|
||||||
|
with spinner("Should propagate"):
|
||||||
|
raise RuntimeError("test error")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Teardown robustness: line must be cleared even if join() is interrupted
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_tty_clears_line_even_if_join_raises() -> None:
|
||||||
|
"""Line-clear must happen even when thread.join() raises (e.g. DoubleInterrupt).
|
||||||
|
|
||||||
|
The fix moves the write("\033[2K\r") *before* thread.join() so an
|
||||||
|
exception during join cannot prevent the terminal from being cleaned up.
|
||||||
|
"""
|
||||||
|
buf = io.StringIO()
|
||||||
|
buf.isatty = lambda: True # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
class FakeThread:
|
||||||
|
def __init__(self, *args, **kwargs) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def join(self, *args, **kwargs) -> None:
|
||||||
|
raise KeyboardInterrupt("simulated double Ctrl-C during join")
|
||||||
|
|
||||||
|
with mock.patch("hate_crack.progress.threading.Thread", FakeThread), mock.patch(
|
||||||
|
"sys.stdout", buf
|
||||||
|
):
|
||||||
|
with pytest.raises(KeyboardInterrupt):
|
||||||
|
with spinner("Robust teardown"):
|
||||||
|
pass
|
||||||
|
|
||||||
|
output = buf.getvalue()
|
||||||
|
assert "\033[2K\r" in output, (
|
||||||
|
"Line-clear escape sequence must be written before join() is called"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user