Merge branch 'dev' into main

This commit is contained in:
Justin Bollinger
2026-05-05 18:51:14 -04:00
22 changed files with 959 additions and 149 deletions
+4
View File
@@ -14,3 +14,7 @@
path = princeprocessor
url = https://github.com/hashcat/princeprocessor.git
ignore = dirty
[submodule "pcfg_cracker"]
path = pcfg_cracker
url = https://github.com/lakiw/pcfg_cracker.git
ignore = dirty
+4 -1
View File
@@ -13,6 +13,7 @@ RUN apt-get update \
ocl-icd-libopencl1 \
pocl-opencl-icd \
p7zip-full \
transmission-cli \
transmission-daemon \
&& rm -rf /var/lib/apt/lists/*
@@ -20,9 +21,11 @@ RUN python -m pip install -q uv==0.9.28
COPY . /workspace
ENV SETUPTOOLS_SCM_PRETEND_VERSION=0.0.0
RUN make install
ENV PATH="${HOME}/.local/bin:${PATH}"
ENV PATH="/workspace/.venv/bin:/root/.local/bin:${PATH}"
ENV HATE_CRACK_SKIP_INIT=1
CMD ["bash", "-lc", "${HOME}/.local/bin/hate_crack --help >/tmp/hc_help.txt && ./hate_crack.py --help >/tmp/hc_script_help.txt"]
+1
View File
@@ -36,6 +36,7 @@ submodules-pre:
@test -d hashcat-utils || { echo "Error: missing required directory: hashcat-utils"; exit 1; }
@test -d princeprocessor || { echo "Error: missing required directory: princeprocessor"; exit 1; }
@test -d omen || { echo "Warning: missing directory: omen (OMEN attacks will not be available)"; }
@test -d pcfg_cracker || { echo "Warning: missing directory: pcfg_cracker (PCFG attacks will not be available)"; }
@# Generate per-length expander sources (expander8.c..expander36.c) and patch
@# hashcat-utils Makefiles to compile them. Skips if expander8.c already exists.
@for base in hashcat-utils; do \
+9 -9
View File
@@ -358,17 +358,11 @@ This installs hooks defined in `prek.toml` using the pre-commit local-repo TOML
Note: prek 0.3.3 expects `repos = [...]` at the top level. The old `[hooks.<stage>] commands = [...]` format is not supported.
### Arrow-Key Menu Navigation (Optional)
### Arrow-Key Menu Navigation
Install the `[tui]` extra to enable arrow-key menu navigation via `simple-term-menu`:
Arrow-key menu navigation is enabled by default via the `simple-term-menu` dependency. When running in a terminal (TTY), menus render with arrow-key navigation and number-key shortcuts.
```bash
uv pip install '.[tui]'
```
When installed and running in a terminal (TTY), menus render with arrow-key navigation and number-key shortcuts. Without it, the classic numbered `print()` + `input()` menu is used.
To force the plain numbered menu even when `simple-term-menu` is installed, set `HATE_CRACK_PLAIN_MENU=1`.
To force the classic numbered `print()` + `input()` menu, set `HATE_CRACK_PLAIN_MENU=1`.
### Dev Dependencies
@@ -918,6 +912,12 @@ Interactive menu for downloading and managing wordlists from Weakpass.com via Bi
-------------------------------------------------------------------
### Version History
Version 2.9.3
- Transmission daemon now watches `/tmp/hate_crack/` for new `.torrent` files; wordlist content still downloads to the configured wordlist directory
- Suppressed `transmission-daemon` stdout/stderr so daemon log output no longer appears in the terminal
- Increased watch-dir polling window to 30s to account for transmission's ~10s scan interval
- Store downloaded `.torrent` files in `/tmp/hate_crack/` instead of `/tmp/` root
Version 2.5.0
- Added tab autocomplete to all file and directory path prompts in the Wordlist Tools submenu (option 80)
- Restored `hcatOptimizedWordlists` config key (directory for pre-optimized wordlists); defaults to `./optimized_wordlists`, falls back to `hcatWordlists` if not found
+4 -1
View File
@@ -29,6 +29,9 @@
"ollamaNumCtx": 2048,
"omenTrainingList": "rockyou.txt",
"omenMaxCandidates": 50000000,
"pcfgRuleset": "DEFAULT",
"pcfgMaxCandidates": 50000000,
"pcfgPrinceLingMaxCandidates": 10000000,
"check_for_updates": true,
"optimizedKernelAttacks": [
"hcatDictionary", "hcatQuickDictionary", "hcatBandrel", "hcatGoodMeasure",
@@ -36,7 +39,7 @@
"hcatAdHocMask", "hcatMarkovBruteForce", "hcatFingerprint", "hcatCombination",
"hcatCombinator3", "hcatCombinatorX", "hcatHybrid", "hcatYoloCombination",
"hcatMiddleCombinator", "hcatThoroughCombinator", "hcatCombipow", "hcatPrince",
"hcatPermute"
"hcatPermute", "hcatPCFG", "hcatPrinceLing"
],
"notify_enabled": false,
"notify_pushover_token": "",
+12 -10
View File
@@ -83,16 +83,18 @@ def get_main_menu_options():
"7": _attacks.hybrid_crack,
"8": _attacks.pathwell_crack,
"9": _attacks.prince_attack,
"13": _attacks.bandrel_method,
"14": _attacks.loopback_attack,
"15": _attacks.ollama_attack,
"16": _attacks.omen_attack,
"17": _attacks.adhoc_mask_crack,
"18": _attacks.markov_brute_force,
"19": _attacks.ngram_attack,
"20": _attacks.permute_crack,
"21": _attacks.generate_rules_crack,
"22": _attacks.combipow_crack,
"10": _attacks.bandrel_method,
"11": _attacks.loopback_attack,
"12": _attacks.ollama_attack,
"13": _attacks.omen_attack,
"14": _attacks.adhoc_mask_crack,
"15": _attacks.markov_brute_force,
"16": _attacks.ngram_attack,
"17": _attacks.permute_crack,
"18": _attacks.generate_rules_crack,
"19": _attacks.combipow_crack,
"20": _attacks.pcfg_attack,
"21": _attacks.prince_ling_attack,
"80": _attacks.wordlist_tools_submenu,
"81": _attacks.rule_tools_submenu,
"82": notifications_submenu,
+19 -20
View File
@@ -2,10 +2,11 @@ import concurrent.futures
import json
import sys
import os
import shutil
import tempfile
import threading
import time
from queue import Queue
import shutil
from typing import Callable, Optional, Tuple
import requests # type: ignore[import-untyped]
@@ -266,7 +267,6 @@ class TransmissionSession:
def __enter__(self):
import atexit
import subprocess
import tempfile
self._cfg_dir = tempfile.mkdtemp(prefix="hate_crack_transmission_")
self._port = _pick_free_port()
@@ -286,7 +286,9 @@ class TransmissionSession:
self.save_dir,
"--no-portmap",
"--no-watch-dir",
]
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
deadline = time.monotonic() + self.startup_timeout
while time.monotonic() < deadline:
@@ -347,7 +349,6 @@ class TransmissionSession:
[
"transmission-remote",
self._rpc,
"--no-auth",
"-a",
torrent_path,
],
@@ -371,7 +372,7 @@ class TransmissionSession:
import subprocess
result = subprocess.run(
["transmission-remote", self._rpc, "--no-auth", "-l"],
["transmission-remote", self._rpc, "-l"],
capture_output=True,
text=True,
)
@@ -428,7 +429,6 @@ class TransmissionSession:
[
"transmission-remote",
self._rpc,
"--no-auth",
f"-t{torrent_id}",
"--info-files",
],
@@ -472,7 +472,6 @@ class TransmissionSession:
[
"transmission-remote",
self._rpc,
"--no-auth",
f"-t{torrent_id}",
"--remove",
],
@@ -582,9 +581,9 @@ def get_hcat_potfile_args():
def cleanup_torrent_files(directory=None):
"""Remove stray .torrent files from the wordlists directory on graceful exit."""
"""Remove stray .torrent files left in the hate_crack temp directory on graceful exit."""
if directory is None:
directory = get_hcat_wordlists_dir()
directory = os.path.join(tempfile.gettempdir(), "hate_crack")
try:
for name in os.listdir(directory):
if name.endswith(".torrent"):
@@ -791,18 +790,12 @@ def fetch_torrent_metadata(torrent_url, save_dir=None, wordlist_id=None):
"""Download the .torrent metadata file from Weakpass and return its local path.
Returns the path to the saved .torrent file, or None on failure.
The .torrent file is stored in the system temp directory, not the wordlist dir.
"""
register_torrent_cleanup()
if not save_dir:
save_dir = get_hcat_wordlists_dir()
else:
save_dir = os.path.expanduser(save_dir)
if not os.path.isabs(save_dir):
save_dir = os.path.join(
os.path.dirname(os.path.abspath(__file__)), save_dir
)
os.makedirs(save_dir, exist_ok=True)
torrent_dir = os.path.join(tempfile.gettempdir(), "hate_crack")
os.makedirs(torrent_dir, exist_ok=True)
# Optionally include hashmob_api_key in headers if present
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
@@ -899,7 +892,7 @@ def fetch_torrent_metadata(torrent_url, save_dir=None, wordlist_id=None):
r2 = requests.get(torrent_link, headers=headers, stream=True)
content_type = r2.headers.get("Content-Type", "")
local_filename = os.path.join(
save_dir, filename if filename.endswith(".torrent") else filename + ".torrent"
torrent_dir, filename if filename.endswith(".torrent") else filename + ".torrent"
)
if r2.status_code == 200 and not content_type.startswith("text/html"):
with open(local_filename, "wb") as f:
@@ -1428,7 +1421,7 @@ class HashviewAPI:
for line in f:
line = line.strip()
if line:
parts = line.split(":", 1) # Split on first colon
parts = line.rsplit(":", 1)
if len(parts) == 2:
hash_part, clear_part = parts
hf.write(hash_part + "\n")
@@ -1436,6 +1429,12 @@ class HashviewAPI:
hashes_count += 1
clears_count += 1
# Append found hashes to the left file to reconstruct the full hashlist
with open(output_abs, "a", encoding="utf-8") as lf:
with open(found_hashes_file, "r", encoding="utf-8") as hf:
for line in hf:
lf.write(line)
print(
f"Split found file into {hashes_count} hashes and {clears_count} clears"
)
+76 -11
View File
@@ -49,10 +49,10 @@ def _select_rules(ctx) -> list[str] | None:
return [""]
print("\nWhich rule(s) would you like to run?")
rule_entries = ["0. To run without any rules"]
rule_entries.extend([f"{i}. {file}" for i, file in enumerate(rule_files, start=1)])
rule_entries.append("98. YOLO...run all of the rules")
rule_entries.append("99. Back to Main Menu")
rule_entries = ["0) To run without any rules"]
rule_entries.extend([f"{i}) {file}" for i, file in enumerate(rule_files, start=1)])
rule_entries.append("98) YOLO...run all of the rules")
rule_entries.append("99) Back to Main Menu")
max_rule_len = max((len(e) for e in rule_entries), default=26)
print_multicolumn_list(
"Available Rules",
@@ -76,7 +76,21 @@ def _select_rules(ctx) -> list[str] | None:
if raw_choice.strip() == "99":
return None
if raw_choice != "":
rule_choice = raw_choice.split(",")
tokens = raw_choice.split(",")
expanded = []
for tok in tokens:
tok = tok.strip()
if "+" not in tok and "-" in tok:
parts = tok.split("-", 1)
try:
start, end = int(parts[0]), int(parts[1])
if start <= end:
expanded.extend(str(i) for i in range(start, end + 1))
continue
except ValueError:
pass
expanded.append(tok)
rule_choice = expanded
if "99" in rule_choice:
return None
@@ -101,7 +115,7 @@ def _select_rules(ctx) -> list[str] | None:
try:
rule_path = os.path.join(rules_dir, rule_files[int(choice) - 1])
selected_rules.append(f"-r {rule_path}")
except IndexError:
except (IndexError, ValueError):
continue
return selected_rules
@@ -114,7 +128,7 @@ def quick_crack(ctx: Any) -> None:
wordlist_files = ctx.list_wordlist_files(default_dir)
wordlist_entries = [
f"{i}. {file}" for i, file in enumerate(wordlist_files, start=1)
f"{i}) {file}" for i, file in enumerate(wordlist_files, start=1)
]
max_entry_len = max((len(e) for e in wordlist_entries), default=24)
print_multicolumn_list(
@@ -400,6 +414,16 @@ def prince_attack(ctx: Any) -> None:
ctx.hcatPrince(ctx.hcatHashType, ctx.hcatHashFile)
def pcfg_attack(ctx: Any) -> None:
_notify.prompt_notify_for_attack("PCFG")
ctx.hcatPCFG(ctx.hcatHashType, ctx.hcatHashFile)
def prince_ling_attack(ctx: Any) -> None:
_notify.prompt_notify_for_attack("PRINCE-LING")
ctx.hcatPrinceLing(ctx.hcatHashType, ctx.hcatHashFile)
def yolo_combination(ctx: Any) -> None:
_notify.prompt_notify_for_attack("YOLO Combination")
ctx.hcatYoloCombination(ctx.hcatHashType, ctx.hcatHashFile)
@@ -498,7 +522,7 @@ def _omen_pick_training_wordlist(ctx: Any):
"""Show wordlist picker for OMEN training. Returns path or None."""
wordlist_files = ctx.list_wordlist_files(ctx.hcatWordlists)
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)
print_multicolumn_list(
"Training Wordlists",
@@ -583,8 +607,8 @@ def _markov_pick_training_source(ctx: Any):
wordlist_files = ctx.list_wordlist_files(ctx.hcatWordlists)
entries = []
if has_cracked:
entries.append("0. Cracked passwords (current session)")
entries.extend([f"{i}. {f}" for i, f in enumerate(wordlist_files, start=1)])
entries.append("0) Cracked passwords (current session)")
entries.extend([f"{i}) {f}" for i, f in enumerate(wordlist_files, start=1)])
if entries:
max_len = max((len(e) for e in entries), default=24)
print_multicolumn_list(
@@ -720,7 +744,7 @@ def generate_rules_crack(ctx: Any) -> None:
wordlist_files = ctx.list_wordlist_files(ctx.hcatWordlists)
wordlist_entries = [
f"{i}. {file}" for i, file in enumerate(wordlist_files, start=1)
f"{i}) {file}" for i, file in enumerate(wordlist_files, start=1)
]
max_entry_len = max((len(e) for e in wordlist_entries), default=24)
print_multicolumn_list(
@@ -1168,6 +1192,44 @@ def wordlist_shard(ctx: Any) -> None:
print("[!] Shard failed.")
def wordlist_optimize(ctx: Any) -> None:
"""Prompt for input wordlists and output directory, then optimize."""
raw = ctx.select_file_with_autocomplete(
"\n[*] Enter input wordlist paths (comma-separated files or directories)",
base_dir=ctx.hcatWordlists,
).strip()
raw_entries = [p.strip() for p in raw.split(",") if p.strip()]
if not raw_entries:
print("[!] No input wordlists provided.")
return
inputs: list[str] = []
not_found: list[str] = []
for entry in raw_entries:
if os.path.isfile(entry):
inputs.append(entry)
elif os.path.isdir(entry):
files = [os.path.join(entry, f) for f in ctx.list_wordlist_files(entry)]
if not files:
print(f"[!] No wordlist files found in: {entry}")
return
inputs.extend(files)
else:
not_found.append(entry)
if not_found:
print("[!] Not found (not a file or directory):")
for p in not_found:
print(f" {p}")
return
outdir = ctx.select_file_with_autocomplete("[*] Enter output directory path").strip()
if not outdir:
print("[!] Output directory cannot be empty.")
return
if ctx.wordlist_optimize(inputs, outdir):
print(f"\n[*] Optimized wordlists written to: {outdir}")
else:
print("[!] Optimization failed.")
def wordlist_tools_submenu(ctx: Any) -> None:
"""Display the Wordlist Tools submenu and dispatch to the selected handler."""
items = [
@@ -1178,6 +1240,7 @@ def wordlist_tools_submenu(ctx: Any) -> None:
("5", "Split by Length"),
("6", "Subtract Wordlist"),
("7", "Shard Wordlist"),
("8", "Optimize Wordlists"),
("99", "Back to Main Menu"),
]
while True:
@@ -1198,3 +1261,5 @@ def wordlist_tools_submenu(ctx: Any) -> None:
wordlist_subtract_words(ctx)
elif choice == "7":
wordlist_shard(ctx)
elif choice == "8":
wordlist_optimize(ctx)
+200 -24
View File
@@ -450,6 +450,9 @@ ollamaNumCtx = int(config_parser.get("ollamaNumCtx", 2048))
omenTrainingList = config_parser.get("omenTrainingList", "rockyou.txt")
omenMaxCandidates = int(config_parser.get("omenMaxCandidates", 1000000))
pcfgRuleset = config_parser.get("pcfgRuleset", "DEFAULT")
pcfgMaxCandidates = int(config_parser.get("pcfgMaxCandidates", 50000000))
pcfgPrinceLingMaxCandidates = int(config_parser.get("pcfgPrinceLingMaxCandidates", 10000000))
try:
_cfg_optimized = config_parser["optimizedKernelAttacks"]
@@ -713,6 +716,16 @@ if not SKIP_INIT:
except SystemExit:
print("OMEN attacks will not be available.")
# Verify pcfg_cracker presence (optional, for PCFG attacks)
# pcfg_cracker is pure-Python; we just check the script files exist.
pcfg_guesser_script = os.path.join(hate_path, "pcfg_cracker", "pcfg_guesser.py")
pcfg_prince_ling_script = os.path.join(hate_path, "pcfg_cracker", "prince_ling.py")
if not os.path.isfile(pcfg_guesser_script) or not os.path.isfile(pcfg_prince_ling_script):
print("pcfg_cracker not found at " + os.path.join(hate_path, "pcfg_cracker"))
print("PCFG attacks will not be available. Run 'make' to fetch submodules.")
elif not shutil.which("python3"):
print("python3 not on PATH. PCFG attacks will not be available.")
except Exception as e:
print(f"Module initialization error: {e}")
if not shutil.which("hashcat") and not os.path.exists("/usr/bin/hashcat"):
@@ -1066,12 +1079,17 @@ def select_file_with_autocomplete(
except IndexError:
return None
def display_matches(substitution, matches, longest_match_length):
print()
for match in matches:
print(f" {match}")
readline.redisplay()
# Configure readline for tab completion
readline.set_completer_delims(" \t\n;")
# Disable the "Display all X possibilities?" prompt
try:
readline.parse_and_bind("set completion-query-items -1")
except Exception:
readline.set_completion_display_matches_hook(display_matches)
except AttributeError:
pass
try:
readline.parse_and_bind("tab: complete")
@@ -2601,6 +2619,110 @@ def hcatPrince(hcatHashType, hcatHashFile):
prince_proc.stdout.close()
def hcatPCFG(hcatHashType, hcatHashFile):
"""Mode A: pipe pcfg_guesser.py output into hashcat in stdin mode."""
pcfg_guesser_script = os.path.join(hate_path, "pcfg_cracker", "pcfg_guesser.py")
if not os.path.isfile(pcfg_guesser_script):
print(f"pcfg_guesser.py not found at {pcfg_guesser_script}")
return
pcfg_cmd = [
"python3",
pcfg_guesser_script,
"--rule",
pcfgRuleset,
"--limit",
str(pcfgMaxCandidates),
]
hashcat_cmd = [
hcatBin,
"-m",
hcatHashType,
hcatHashFile,
"--session",
generate_session_id(),
"-o",
f"{hcatHashFile}.out",
]
if _should_use_optimized_kernel("hcatPCFG"):
_insert_optimized_flag(hashcat_cmd)
hashcat_cmd.extend(shlex.split(hcatTuning))
_append_potfile_arg(hashcat_cmd)
pcfg_proc = subprocess.Popen(pcfg_cmd, stdout=subprocess.PIPE)
_run_hcat_cmd(
hashcat_cmd,
attack_name="PCFG",
hash_file=hcatHashFile,
stdin=pcfg_proc.stdout,
companion_procs=[pcfg_proc],
)
if pcfg_proc.stdout:
pcfg_proc.stdout.close()
def hcatPrinceLing(hcatHashType, hcatHashFile):
"""Mode B: prince_ling generates a wordlist (with cache+staleness check),
then we delegate to the existing hcatPrince attack with hcatPrinceBaseList
temporarily rebound to the cached wordlist.
"""
global hcatPrinceBaseList
pcfg_root = os.path.join(hate_path, "pcfg_cracker")
prince_ling_script = os.path.join(pcfg_root, "prince_ling.py")
ruleset_dir = os.path.join(pcfg_root, "Rules", pcfgRuleset)
if not os.path.isfile(prince_ling_script):
print(f"prince_ling.py not found at {prince_ling_script}")
return
if not os.path.isdir(ruleset_dir):
print(f"PCFG ruleset not found: {ruleset_dir}")
return
cache_dir = hcatOptimizedWordlists if isinstance(hcatOptimizedWordlists, str) \
else str(hcatOptimizedWordlists)
os.makedirs(cache_dir, exist_ok=True)
cache_path = os.path.join(cache_dir, f"pcfg_prince_ling_{pcfgRuleset}.txt")
tmp_path = cache_path + ".tmp"
# Staleness check: regenerate iff ruleset dir mtime > cache mtime (strict)
needs_regen = True
if os.path.isfile(cache_path):
ruleset_mtime = os.path.getmtime(ruleset_dir)
cache_mtime = os.path.getmtime(cache_path)
if ruleset_mtime <= cache_mtime:
needs_regen = False
if needs_regen:
print(f"[*] Generating prince_ling wordlist -> {cache_path}")
cmd = [
"python3",
prince_ling_script,
"--rule",
pcfgRuleset,
"--output",
tmp_path,
"--size",
str(pcfgPrinceLingMaxCandidates),
]
try:
subprocess.run(cmd, check=True)
os.replace(tmp_path, cache_path)
except (subprocess.CalledProcessError, KeyboardInterrupt, OSError) as e:
# Clean up partial tmp file
if os.path.isfile(tmp_path):
try:
os.remove(tmp_path)
except OSError:
pass
print(f"prince_ling generation failed: {e}")
return
# Delegate to existing PRINCE attack with rebound base list
original_base = hcatPrinceBaseList
hcatPrinceBaseList = [cache_path]
try:
hcatPrince(hcatHashType, hcatHashFile)
finally:
hcatPrinceBaseList = original_base
def hcatPermute(hcatHashType, hcatHashFile, wordlist):
global hcatProcess, hcatPermuteCount
permute_path = os.path.join(hate_path, "hashcat-utils", "bin", "permute.bin")
@@ -3816,6 +3938,14 @@ def permute_crack():
return _attacks.permute_crack(_attack_ctx())
def pcfg_attack():
return _attacks.pcfg_attack(_attack_ctx())
def prince_ling_attack():
return _attacks.prince_ling_attack(_attack_ctx())
def wordlist_filter_len(infile: str, outfile: str, min_len: int, max_len: int) -> bool:
"""Filter wordlist keeping only words between min_len and max_len (inclusive)."""
len_bin = os.path.join(hate_path, "hashcat-utils/bin/len.bin")
@@ -3886,6 +4016,42 @@ def wordlist_gate(infile: str, outfile: str, mod: int, offset: int) -> bool:
return result.returncode == 0
def wordlist_optimize(input_wordlists: list[str], outdir: str) -> bool:
"""Consolidate wordlists into per-length deduplicated files in outdir."""
os.makedirs(outdir, exist_ok=True)
for wl in input_wordlists:
if not os.path.isfile(wl):
print(f"[!] Skipping missing wordlist: {wl}")
continue
if not os.listdir(outdir):
if not wordlist_splitlen(wl, outdir):
return False
continue
with tempfile.TemporaryDirectory(prefix="hc_optimize_") as tmp:
if not wordlist_splitlen(wl, tmp):
return False
for fname in os.listdir(tmp):
src = os.path.join(tmp, fname)
dst = os.path.join(outdir, fname)
if not os.path.isfile(dst):
shutil.copyfile(src, dst)
continue
with tempfile.NamedTemporaryFile(
delete=False, prefix="hc_optimize_", suffix=".out"
) as out_fh:
out_path = out_fh.name
try:
if not wordlist_subtract(src, out_path, dst):
return False
if os.path.getsize(out_path) > 0:
with open(dst, "ab") as df, open(out_path, "rb") as sf:
df.write(sf.read())
finally:
if os.path.isfile(out_path):
os.remove(out_path)
return True
def wordlist_tools_submenu():
return _attacks.wordlist_tools_submenu(_attack_ctx())
@@ -4240,16 +4406,18 @@ def get_main_menu_items():
("7", "Hybrid Attack"),
("8", "Pathwell Top 100 Mask Brute Force Crack"),
("9", "PRINCE Attack"),
("13", "Bandrel Methodology"),
("14", "Loopback Attack"),
("15", "LLM Attack"),
("16", "OMEN Attack"),
("17", "Ad-hoc Mask Attack"),
("18", "Markov Brute Force Attack"),
("19", "N-gram Attack"),
("20", "Permutation Attack"),
("21", "Random Rules Attack"),
("22", "Combipow Passphrase Attack"),
("10", "Bandrel Methodology"),
("11", "Loopback Attack"),
("12", "LLM Attack"),
("13", "OMEN Attack"),
("14", "Ad-hoc Mask Attack"),
("15", "Markov Brute Force Attack"),
("16", "N-gram Attack"),
("17", "Permutation Attack"),
("18", "Random Rules Attack"),
("19", "Combipow Passphrase Attack"),
("20", "PCFG Attack"),
("21", "PRINCE-LING Attack"),
("80", "Wordlist Tools"),
("81", "Rule File Tools"),
("82", "Notifications"),
@@ -4284,16 +4452,18 @@ def get_main_menu_options():
"7": hybrid_crack,
"8": pathwell_crack,
"9": prince_attack,
"13": bandrel_method,
"14": loopback_attack,
"15": ollama_attack,
"16": omen_attack,
"17": adhoc_mask_crack,
"18": markov_brute_force,
"19": ngram_attack,
"20": permute_crack,
"21": generate_rules_crack,
"22": combipow_crack,
"10": bandrel_method,
"11": loopback_attack,
"12": ollama_attack,
"13": omen_attack,
"14": adhoc_mask_crack,
"15": markov_brute_force,
"16": ngram_attack,
"17": permute_crack,
"18": generate_rules_crack,
"19": combipow_crack,
"20": pcfg_attack,
"21": prince_ling_attack,
"80": wordlist_tools_submenu,
"81": rule_tools_submenu,
"82": notifications_submenu,
@@ -4727,7 +4897,9 @@ def main():
("2", "Download wordlists from Weakpass"),
("3", "Download wordlists from Hashmob.net"),
("4", "Download rules from Hashmob.net"),
("5", "Exit"),
("5", "Wordlist Tools"),
("6", "Rule File Tools"),
("7", "Exit"),
]
menu_loop = True
while menu_loop:
@@ -4765,6 +4937,10 @@ def main():
sys.exit(0)
# Otherwise continue the menu loop
elif choice == "5":
wordlist_tools_submenu()
elif choice == "6":
rule_tools_submenu()
elif choice == "7":
sys.exit(0)
else:
if (
+10 -13
View File
@@ -1,10 +1,10 @@
"""Reusable interactive menu with optional arrow-key navigation.
When ``simple-term-menu`` is installed AND stdout is a TTY, renders an
arrow-key navigable menu. Otherwise falls back to classic numbered
``print()`` + ``input()`` selection.
Default: classic numbered ``print()`` + ``input()`` selection (full number
entry for all keys).
Set ``HATE_CRACK_PLAIN_MENU=1`` to force the plain numbered menu.
Set ``HATE_CRACK_ARROW_MENU=1`` to enable arrow-key navigation via
``simple-term-menu`` (single-digit shortcut keys only; 10+ require arrows).
"""
from __future__ import annotations
@@ -21,7 +21,7 @@ except ImportError:
def _use_arrow_menu() -> bool:
if os.environ.get("HATE_CRACK_PLAIN_MENU", "") == "1":
if os.environ.get("HATE_CRACK_ARROW_MENU", "") != "1":
return False
if not _HAS_TERM_MENU:
return False
@@ -34,15 +34,11 @@ def _arrow_menu(
items: list[tuple[str, str]],
title: str | None,
) -> str | None:
menu_entries = [f"[{key}] {label}" for key, label in items]
w = max(len(key) for key, _ in items)
menu_entries = [f"[{key:>{w}}] {label}" for key, label in items]
shortcuts = [key for key, _ in items]
# Build shortcut_key_highlight_style so pressing a number jumps there
menu = TerminalMenu(
menu_entries,
title=title,
shortcut_key_highlight_style=("standout",),
)
menu = TerminalMenu(menu_entries, title=title)
idx = menu.show()
if idx is None:
return None
@@ -53,8 +49,9 @@ def _numbered_menu(
items: list[tuple[str, str]],
prompt: str,
) -> str | None:
w = max(len(key) for key, _ in items)
for key, label in items:
print(f"\t({key}) {label}")
print(f"\t[{key:>{w}}] {label}")
choice = input(prompt).strip()
if not choice:
return None
Submodule
+1
Submodule pcfg_cracker added at b04bbdadfe
+1 -3
View File
@@ -13,14 +13,12 @@ dependencies = [
"beautifulsoup4>=4.12.0",
"openpyxl>=3.0.0",
"packaging>=21.0",
"simple-term-menu==1.6.6",
]
[project.scripts]
hate_crack = "hate_crack.__main__:main"
[project.optional-dependencies]
tui = ["simple-term-menu==1.6.6"]
[tool.setuptools.packages.find]
include = ["hate_crack*"]
+22 -29
View File
@@ -140,48 +140,41 @@ class TestTransmissionSession:
with pytest.raises(RuntimeError, match="Transmission daemon failed"):
ts.__enter__()
def test_add_parses_id_from_stdout(self, tmp_path):
def test_add_uses_transmission_remote_and_returns_new_id(self, tmp_path):
ts = TransmissionSession(str(tmp_path))
ts._rpc = "127.0.0.1:9999"
result = MagicMock(
returncode=0, stdout="Added torrent foo.torrent\n ID: 7\n", stderr=""
)
with patch("subprocess.run", return_value=result):
# Before: IDs 3 and 5. After add: ID 7 appears.
list_calls = iter([
[{"id": 3}, {"id": 5}],
[{"id": 3}, {"id": 5}, {"id": 7}],
])
run_result = MagicMock(returncode=0, stdout="", stderr="")
with patch("subprocess.run", return_value=run_result), \
patch.object(ts, "list", side_effect=list_calls):
tid = ts.add("/tmp/foo.torrent")
assert tid == 7
def test_add_parses_lowercase_alt_format(self, tmp_path):
def test_add_parses_id_from_output(self, tmp_path):
ts = TransmissionSession(str(tmp_path))
ts._rpc = "127.0.0.1:9999"
result = MagicMock(
returncode=0, stdout="torrent added (id 42)\n", stderr=""
before_list = [{"id": 1}]
run_result = MagicMock(
returncode=0,
stdout="torrent added (id 42)\n",
stderr="",
)
with patch("subprocess.run", return_value=result):
with patch("subprocess.run", return_value=run_result), \
patch.object(ts, "list", return_value=before_list):
tid = ts.add("/tmp/foo.torrent")
assert tid == 42
def test_add_falls_back_to_list(self, tmp_path):
def test_add_raises_when_torrent_not_added(self, tmp_path):
ts = TransmissionSession(str(tmp_path))
ts._rpc = "127.0.0.1:9999"
result = MagicMock(returncode=0, stdout="garbage output\n", stderr="")
# Before: IDs 3 and 5 exist. After: ID 7 appears as the newly added torrent.
list_calls = iter([
[{"id": 3}, {"id": 5}], # before snapshot
[{"id": 3}, {"id": 5}, {"id": 7}], # after snapshot
])
with patch("subprocess.run", return_value=result), patch.object(
ts, "list", side_effect=list_calls
):
tid = ts.add("/tmp/foo.torrent")
assert tid == 7
def test_add_raises_when_list_empty(self, tmp_path):
ts = TransmissionSession(str(tmp_path))
ts._rpc = "127.0.0.1:9999"
result = MagicMock(returncode=0, stdout="garbage\n", stderr="")
with patch("subprocess.run", return_value=result), patch.object(
ts, "list", return_value=[]
):
# list returns the same IDs before and after; output has no ID.
run_result = MagicMock(returncode=1, stdout="", stderr="error")
with patch("subprocess.run", return_value=run_result), \
patch.object(ts, "list", return_value=[{"id": 1}]):
with pytest.raises(RuntimeError):
ts.add("/tmp/foo.torrent")
+22
View File
@@ -0,0 +1,22 @@
from unittest.mock import MagicMock
from hate_crack.attacks import pcfg_attack, prince_ling_attack
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
def test_pcfg_attack_invokes_hcatPCFG():
ctx = _make_ctx()
pcfg_attack(ctx)
ctx.hcatPCFG.assert_called_once_with("1000", "/tmp/hashes.txt")
def test_prince_ling_attack_invokes_hcatPrinceLing():
ctx = _make_ctx()
prince_ling_attack(ctx)
ctx.hcatPrinceLing.assert_called_once_with("1000", "/tmp/hashes.txt")
+3 -3
View File
@@ -46,16 +46,16 @@ def _make_ctx(hash_type="1000", hash_file="/tmp/hashes.txt"):
def test_combipow_crack_in_main_menu(cli):
options = cli.get_main_menu_options()
assert "22" in options
assert "19" in options
def test_combipow_crack_menu_item_label():
cli = _load_cli()
items = cli.get_main_menu_items()
keys = [k for k, _ in items]
assert "22" in keys
assert "19" in keys
labels = {k: label for k, label in items}
assert "passphrase" in labels["22"].lower() or "combipow" in labels["22"].lower()
assert "passphrase" in labels["19"].lower() or "combipow" in labels["19"].lower()
# --- combipow_crack handler tests ---
+42
View File
@@ -97,3 +97,45 @@ def test_docker_hashcat_cracks_simple_password(docker_image):
assert run.returncode == 0, (
f"Docker hashcat crack failed. stdout={run.stdout} stderr={run.stderr}"
)
@pytest.mark.timeout(300)
def test_docker_torrent_downloads_wordlists(docker_image, tmp_path):
downloads_dir = tmp_path / "downloads"
downloads_dir.mkdir()
py_cmd = (
"from hate_crack.api import fetch_torrent_metadata, run_torrent_session; "
"t1 = fetch_torrent_metadata('ignis-10K.txt'); "
"t2 = fetch_torrent_metadata('hashmob.net_2025.micro.found'); "
"files = [f for f in (t1, t2) if f]; "
"run_torrent_session(files, '/downloads')"
)
try:
run = subprocess.run(
[
"docker", "run", "--rm",
"-v", f"{downloads_dir}:/downloads",
docker_image,
"bash", "-lc", f"/workspace/.venv/bin/python -c \"{py_cmd}\"",
],
capture_output=True,
text=True,
timeout=300,
)
except subprocess.TimeoutExpired as exc:
pytest.fail(f"Docker torrent test timed out after {exc.timeout}s")
assert run.returncode == 0, (
f"Torrent session failed. stdout={run.stdout} stderr={run.stderr}"
)
ignis10k = downloads_dir / "ignis-10K.txt"
micro = downloads_dir / "hashmob.net_2025.micro.found"
assert ignis10k.exists() and ignis10k.stat().st_size > 0, (
f"ignis-10K.txt missing/empty. stdout={run.stdout} stderr={run.stderr}"
)
assert micro.exists() and micro.stat().st_size > 0, (
f"hashmob.net_2025.micro.found missing/empty. stdout={run.stdout} stderr={run.stderr}"
)
+47 -5
View File
@@ -644,14 +644,20 @@ class TestHashviewAPI:
# Verify left file was created
assert os.path.exists(result["output_file"])
# Verify left file contains only the original uncracked hashes
# Verify left file contains the full original hashlist (left + found)
with open(result["output_file"], "r") as f:
left_contents = f.read()
assert "found_hash1" not in left_contents, (
"Found hashes must NOT be written back into the left file"
assert "found_hash1\n" in left_contents, (
"Found hashes must be appended as hash-only lines"
)
assert "found_hash2" not in left_contents, (
"Found hashes must NOT be written back into the left file"
assert "found_password1" not in left_contents, (
"Plaintext passwords must not appear in the left file"
)
assert "found_hash2\n" in left_contents, (
"Found hashes must be appended as hash-only lines"
)
assert "found_password2" not in left_contents, (
"Plaintext passwords must not appear in the left file"
)
assert "uncracked_hash1" in left_contents
assert "uncracked_hash2" in left_contents
@@ -677,6 +683,42 @@ class TestHashviewAPI:
assert "found_hash1:found_password1" in potfile_contents
assert "found_hash2:found_password2" in potfile_contents
def test_download_left_rsplit_ntlmv2(self, api, tmp_path, monkeypatch):
"""rsplit correctly extracts the full NTLMv2 hash (which contains colons) from a found line."""
potfile = str(tmp_path / "hashcat.potfile")
monkeypatch.setattr("hate_crack.api.get_hcat_potfile_path", lambda: potfile)
ntlmv2_hash = "alice::DOMAIN:aabbccdd:ntproofstr:blob"
ntlmv2_found_line = f"{ntlmv2_hash}:s3cr3t\n"
mock_left = Mock()
mock_left.content = b"some_other_hash\n"
mock_left.raise_for_status = Mock()
mock_left.headers = {"content-length": "0"}
mock_left.iter_content = lambda chunk_size=8192: iter([mock_left.content])
mock_found = Mock()
mock_found.content = ntlmv2_found_line.encode()
mock_found.raise_for_status = Mock()
mock_found.headers = {"content-length": "0"}
mock_found.iter_content = lambda chunk_size=8192: iter([mock_found.content])
mock_found.status_code = 200
api.session.get.side_effect = [mock_left, mock_found]
left_file = tmp_path / "left_1_2.txt"
api.download_left_hashes(1, 2, output_file=str(left_file))
with open(str(left_file), "r") as f:
contents = f.read()
assert ntlmv2_hash + "\n" in contents, (
"Full NTLMv2 hash (with colons) must be appended to the left file"
)
assert "s3cr3t" not in contents, (
"Plaintext password must not appear in the left file"
)
def test_download_left_potfile_path_param_overrides_config(self, api, tmp_path):
"""Test that a passed-in potfile_path is used instead of re-reading config."""
mock_left_response = Mock()
+168
View File
@@ -0,0 +1,168 @@
"""Tests for PCFG attack subprocess construction in hate_crack.main."""
import os
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
@pytest.fixture
def main_module(hc_module):
"""Return the underlying hate_crack.main module for direct patching."""
return hc_module._main
class TestHcatPCFG:
def test_builds_expected_subprocess(self, main_module, tmp_path):
hash_file = str(tmp_path / "hashes.txt")
Path(hash_file).write_text("dummy")
captured_calls = []
class FakeProc:
def __init__(self, *args, **kwargs):
captured_calls.append((args, kwargs))
self.stdout = MagicMock()
self.stdout.close = MagicMock()
with patch("hate_crack.main.subprocess.Popen", side_effect=FakeProc), \
patch("hate_crack.main._run_hcat_cmd") as mock_run, \
patch.object(main_module, "hcatBin", "hashcat"), \
patch.object(main_module, "hcatTuning", ""), \
patch.object(main_module, "hcatPotfilePath", ""), \
patch.object(main_module, "generate_session_id", return_value="test_session"):
main_module.hcatPCFG("0", hash_file)
# First Popen call is the pcfg_guesser producer
producer_args, producer_kwargs = captured_calls[0]
producer_cmd = producer_args[0]
assert "python3" in producer_cmd[0] or producer_cmd[0].endswith("python3")
assert any("pcfg_guesser.py" in part for part in producer_cmd)
assert "--rule" in producer_cmd
assert producer_cmd[producer_cmd.index("--rule") + 1] == main_module.pcfgRuleset
assert "--limit" in producer_cmd
assert producer_cmd[producer_cmd.index("--limit") + 1] == str(main_module.pcfgMaxCandidates)
# _run_hcat_cmd was called with attack_name='PCFG' and the hashcat command
assert mock_run.called
kwargs = mock_run.call_args.kwargs
hashcat_cmd = mock_run.call_args.args[0]
assert kwargs["attack_name"] == "PCFG"
assert kwargs["hash_file"] == hash_file
# Hashcat does NOT carry --limit (cap is producer-side)
assert "--limit" not in hashcat_cmd
# Hashcat is in stdin mode (no -a flag)
assert "-a" not in hashcat_cmd
assert "-m" in hashcat_cmd
assert hashcat_cmd[hashcat_cmd.index("-m") + 1] == "0"
# Verify the producer is wired into hashcat's stdin via _run_hcat_cmd
assert kwargs["stdin"] is not None
assert kwargs["companion_procs"] is not None
assert len(kwargs["companion_procs"]) == 1
class TestHcatPrinceLing:
def _setup_pcfg_dirs(self, tmp_path, main_module, monkeypatch):
"""Lay out fake pcfg_cracker/Rules/<ruleset>/ and optimized_wordlists/."""
pcfg_root = tmp_path / "pcfg_cracker"
rules_dir = pcfg_root / "Rules" / "DEFAULT"
rules_dir.mkdir(parents=True)
(rules_dir / "config.txt").write_text("dummy")
# prince_ling script must "exist" for the function to proceed
(pcfg_root / "prince_ling.py").write_text("# stub")
opt_dir = tmp_path / "optimized_wordlists"
opt_dir.mkdir()
monkeypatch.setattr(main_module, "hate_path", str(tmp_path))
monkeypatch.setattr(main_module, "hcatOptimizedWordlists", str(opt_dir))
return rules_dir, opt_dir
def test_regenerates_when_cache_stale(self, main_module, tmp_path, monkeypatch):
rules_dir, opt_dir = self._setup_pcfg_dirs(tmp_path, main_module, monkeypatch)
cache = opt_dir / "pcfg_prince_ling_DEFAULT.txt"
# Cache exists but is older than ruleset
cache.write_text("stale")
old = (rules_dir.stat().st_mtime - 100)
os.utime(cache, (old, old))
run_calls = []
def fake_run(cmd, **kwargs):
run_calls.append(cmd)
# Simulate prince_ling writing the .tmp file
for i, part in enumerate(cmd):
if part == "--output":
Path(cmd[i + 1]).write_text("regenerated")
class R:
returncode = 0
return R()
with patch("hate_crack.main.subprocess.run", side_effect=fake_run), \
patch("hate_crack.main.hcatPrince") as mock_prince:
main_module.hcatPrinceLing("0", str(tmp_path / "hashes.txt"))
# prince_ling subprocess.run was invoked
assert len(run_calls) == 1
cmd = run_calls[0]
assert any("prince_ling.py" in p for p in cmd)
assert "--rule" in cmd
assert cmd[cmd.index("--rule") + 1] == "DEFAULT"
# Uses --size, NOT --limit
assert "--size" in cmd
assert "--limit" not in cmd
# hcatPrince delegated
assert mock_prince.called
def test_skips_regen_when_cache_fresh(self, main_module, tmp_path, monkeypatch):
rules_dir, opt_dir = self._setup_pcfg_dirs(tmp_path, main_module, monkeypatch)
cache = opt_dir / "pcfg_prince_ling_DEFAULT.txt"
cache.write_text("fresh")
# Cache is newer than ruleset
future = rules_dir.stat().st_mtime + 1000
os.utime(cache, (future, future))
with patch("hate_crack.main.subprocess.run") as mock_run, \
patch("hate_crack.main.hcatPrince"):
main_module.hcatPrinceLing("0", str(tmp_path / "hashes.txt"))
# subprocess.run was NOT called for prince_ling
assert not mock_run.called
def test_atomic_cache_write_cleans_tmp_on_failure(self, main_module, tmp_path, monkeypatch):
import subprocess as real_subprocess
rules_dir, opt_dir = self._setup_pcfg_dirs(tmp_path, main_module, monkeypatch)
def boom(cmd, **kwargs):
# Touch the .tmp file then fail (simulates partial write + crash)
for i, part in enumerate(cmd):
if part == "--output":
Path(cmd[i + 1]).write_text("partial")
raise real_subprocess.CalledProcessError(1, cmd)
with patch("hate_crack.main.subprocess.run", side_effect=boom), \
patch("hate_crack.main.hcatPrince"):
main_module.hcatPrinceLing("0", str(tmp_path / "hashes.txt"))
# No real cache file created; tmp file cleaned up
assert not (opt_dir / "pcfg_prince_ling_DEFAULT.txt").exists()
assert not (opt_dir / "pcfg_prince_ling_DEFAULT.txt.tmp").exists()
def test_restores_hcatPrinceBaseList_on_exception(self, main_module, tmp_path, monkeypatch):
rules_dir, opt_dir = self._setup_pcfg_dirs(tmp_path, main_module, monkeypatch)
cache = opt_dir / "pcfg_prince_ling_DEFAULT.txt"
cache.write_text("fresh")
future = rules_dir.stat().st_mtime + 1000
os.utime(cache, (future, future))
original = ["original_base.txt"]
monkeypatch.setattr(main_module, "hcatPrinceBaseList", original)
def boom(*a, **kw):
raise RuntimeError("hcatPrince exploded")
with patch("hate_crack.main.hcatPrince", side_effect=boom), \
pytest.raises(RuntimeError):
main_module.hcatPrinceLing("0", str(tmp_path / "hashes.txt"))
assert main_module.hcatPrinceBaseList == original
+10 -8
View File
@@ -19,29 +19,30 @@ class TestUseArrowMenu:
import hate_crack.menu as mod
monkeypatch.setattr(mod, "_HAS_TERM_MENU", False)
monkeypatch.delenv("HATE_CRACK_PLAIN_MENU", raising=False)
monkeypatch.setenv("HATE_CRACK_ARROW_MENU", "1")
assert _use_arrow_menu() is False
def test_falls_back_on_non_tty(self, monkeypatch):
import hate_crack.menu as mod
monkeypatch.setattr(mod, "_HAS_TERM_MENU", True)
monkeypatch.delenv("HATE_CRACK_PLAIN_MENU", raising=False)
monkeypatch.setenv("HATE_CRACK_ARROW_MENU", "1")
monkeypatch.setattr("sys.stdout.isatty", lambda: False)
assert _use_arrow_menu() is False
def test_falls_back_with_env_var(self, monkeypatch):
def test_falls_back_without_env_var(self, monkeypatch):
import hate_crack.menu as mod
monkeypatch.setattr(mod, "_HAS_TERM_MENU", True)
monkeypatch.setenv("HATE_CRACK_PLAIN_MENU", "1")
monkeypatch.delenv("HATE_CRACK_ARROW_MENU", raising=False)
monkeypatch.setattr("sys.stdout.isatty", lambda: True)
assert _use_arrow_menu() is False
def test_enabled_when_all_conditions_met(self, monkeypatch):
import hate_crack.menu as mod
monkeypatch.setattr(mod, "_HAS_TERM_MENU", True)
monkeypatch.delenv("HATE_CRACK_PLAIN_MENU", raising=False)
monkeypatch.setenv("HATE_CRACK_ARROW_MENU", "1")
monkeypatch.setattr("sys.stdout.isatty", lambda: True)
assert _use_arrow_menu() is True
@@ -56,8 +57,9 @@ class TestNumberedMenu:
monkeypatch.setattr("builtins.input", lambda _: "1")
_numbered_menu(SAMPLE_ITEMS, "\nSelect: ")
captured = capsys.readouterr().out
w = max(len(key) for key, _ in SAMPLE_ITEMS)
for key, label in SAMPLE_ITEMS:
assert f"({key}) {label}" in captured
assert f"[{key:>{w}}] {label}" in captured
def test_returns_none_on_empty_input(self, monkeypatch):
monkeypatch.setattr("builtins.input", lambda _: "")
@@ -94,7 +96,7 @@ class TestInteractiveMenu:
import hate_crack.menu as mod
monkeypatch.setattr(mod, "_HAS_TERM_MENU", False)
monkeypatch.delenv("HATE_CRACK_PLAIN_MENU", raising=False)
monkeypatch.delenv("HATE_CRACK_ARROW_MENU", raising=False)
monkeypatch.setattr("builtins.input", lambda _: "99")
result = interactive_menu(SAMPLE_ITEMS)
assert result == "99"
@@ -103,7 +105,7 @@ class TestInteractiveMenu:
import hate_crack.menu as mod
monkeypatch.setattr(mod, "_HAS_TERM_MENU", True)
monkeypatch.delenv("HATE_CRACK_PLAIN_MENU", raising=False)
monkeypatch.setenv("HATE_CRACK_ARROW_MENU", "1")
monkeypatch.setattr("sys.stdout.isatty", lambda: True)
mock_menu_instance = MagicMock()
mock_menu_instance.show.return_value = 0
+1 -1
View File
@@ -36,7 +36,7 @@ def cli():
def test_generate_rules_crack_in_main_menu(cli):
options = cli.get_main_menu_options()
assert "21" in options
assert "18" in options
def test_generate_rules_crack_handler_calls_main(cli, tmp_path):
+12 -10
View File
@@ -20,16 +20,18 @@ MENU_OPTION_TEST_CASES = [
("7", CLI_MODULE._attacks, "hybrid_crack", "hybrid"),
("8", CLI_MODULE._attacks, "pathwell_crack", "pathwell"),
("9", CLI_MODULE._attacks, "prince_attack", "prince"),
("13", CLI_MODULE._attacks, "bandrel_method", "bandrel"),
("14", CLI_MODULE._attacks, "loopback_attack", "loopback"),
("15", CLI_MODULE._attacks, "ollama_attack", "ollama"),
("16", CLI_MODULE._attacks, "omen_attack", "omen"),
("17", CLI_MODULE._attacks, "adhoc_mask_crack", "adhoc-mask"),
("18", CLI_MODULE._attacks, "markov_brute_force", "markov-brute"),
("19", CLI_MODULE._attacks, "ngram_attack", "ngram"),
("20", CLI_MODULE._attacks, "permute_crack", "permute"),
("21", CLI_MODULE._attacks, "generate_rules_crack", "random-rules"),
("22", CLI_MODULE._attacks, "combipow_crack", "combipow"),
("10", CLI_MODULE._attacks, "bandrel_method", "bandrel"),
("11", CLI_MODULE._attacks, "loopback_attack", "loopback"),
("12", CLI_MODULE._attacks, "ollama_attack", "ollama"),
("13", CLI_MODULE._attacks, "omen_attack", "omen"),
("14", CLI_MODULE._attacks, "adhoc_mask_crack", "adhoc-mask"),
("15", CLI_MODULE._attacks, "markov_brute_force", "markov-brute"),
("16", CLI_MODULE._attacks, "ngram_attack", "ngram"),
("17", CLI_MODULE._attacks, "permute_crack", "permute"),
("18", CLI_MODULE._attacks, "generate_rules_crack", "random-rules"),
("19", CLI_MODULE._attacks, "combipow_crack", "combipow"),
("20", CLI_MODULE._attacks, "pcfg_attack", "pcfg"),
("21", CLI_MODULE._attacks, "prince_ling_attack", "prince-ling"),
("80", CLI_MODULE._attacks, "wordlist_tools_submenu", "wordlist-tools"),
("81", CLI_MODULE._attacks, "rule_tools_submenu", "rule-tools"),
("82", CLI_MODULE, "notifications_submenu", "notifications-submenu"),
+291 -1
View File
@@ -1,6 +1,7 @@
import os
import shutil
from pathlib import Path
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock, call, patch
import pytest
@@ -9,6 +10,7 @@ from hate_crack.attacks import (
wordlist_filter_charclass_exclude,
wordlist_filter_charclass_include,
wordlist_filter_length,
wordlist_optimize,
wordlist_shard,
wordlist_split_by_length,
wordlist_subtract_words,
@@ -26,6 +28,7 @@ def _make_ctx():
ctx.wordlist_subtract.return_value = True
ctx.wordlist_subtract_single.return_value = True
ctx.wordlist_gate.return_value = True
ctx.wordlist_optimize.return_value = True
return ctx
@@ -297,6 +300,13 @@ class TestWordlistToolsSubmenu:
wordlist_tools_submenu(ctx)
mock_fn.assert_called_once_with(ctx)
def test_submenu_dispatches_to_optimize(self):
ctx = _make_ctx()
with patch("hate_crack.attacks.wordlist_optimize") as mock_fn, \
patch("hate_crack.attacks.interactive_menu", side_effect=["8", "99"]):
wordlist_tools_submenu(ctx)
mock_fn.assert_called_once_with(ctx)
def test_submenu_exits_on_99(self):
ctx = _make_ctx()
with patch("hate_crack.attacks.interactive_menu", return_value="99"):
@@ -306,3 +316,283 @@ class TestWordlistToolsSubmenu:
ctx = _make_ctx()
with patch("hate_crack.attacks.interactive_menu", return_value=None):
wordlist_tools_submenu(ctx)
class TestWordlistOptimize:
def test_happy_path(self, tmp_path, capsys):
ctx = _make_ctx()
wl_a = tmp_path / "a.txt"
wl_a.write_text("word1\n")
wl_b = tmp_path / "b.txt"
wl_b.write_text("word2\n")
outdir = str(tmp_path / "out")
ctx.select_file_with_autocomplete.side_effect = [
f"{wl_a},{wl_b}",
outdir,
]
with (
patch("hate_crack.attacks.os.path.isfile", return_value=True),
patch("hate_crack.attacks.os.path.isdir", return_value=False),
):
wordlist_optimize(ctx)
ctx.wordlist_optimize.assert_called_once_with(
[str(wl_a), str(wl_b)], outdir
)
out = capsys.readouterr().out
assert outdir in out
def test_directory_expansion(self, tmp_path, capsys):
ctx = _make_ctx()
wl_dir = str(tmp_path / "wls")
outdir = str(tmp_path / "out")
ctx.select_file_with_autocomplete.side_effect = [wl_dir, outdir]
ctx.list_wordlist_files.return_value = ["a.txt", "b.txt"]
with (
patch("hate_crack.attacks.os.path.isfile", return_value=False),
patch("hate_crack.attacks.os.path.isdir", return_value=True),
):
wordlist_optimize(ctx)
ctx.wordlist_optimize.assert_called_once_with(
[os.path.join(wl_dir, "a.txt"), os.path.join(wl_dir, "b.txt")], outdir
)
def test_empty_directory_rejection(self, tmp_path, capsys):
ctx = _make_ctx()
wl_dir = str(tmp_path / "wls")
ctx.select_file_with_autocomplete.return_value = wl_dir
ctx.list_wordlist_files.return_value = []
with (
patch("hate_crack.attacks.os.path.isfile", return_value=False),
patch("hate_crack.attacks.os.path.isdir", return_value=True),
):
wordlist_optimize(ctx)
out = capsys.readouterr().out
assert "No wordlist files found" in out
ctx.wordlist_optimize.assert_not_called()
def test_empty_input_rejection(self, capsys):
ctx = _make_ctx()
ctx.select_file_with_autocomplete.return_value = ","
wordlist_optimize(ctx)
out = capsys.readouterr().out
assert "No input wordlists provided" in out
ctx.wordlist_optimize.assert_not_called()
def test_blank_input_rejection(self, capsys):
ctx = _make_ctx()
ctx.select_file_with_autocomplete.return_value = ""
wordlist_optimize(ctx)
out = capsys.readouterr().out
assert "No input wordlists provided" in out
ctx.wordlist_optimize.assert_not_called()
def test_missing_file_rejection(self, tmp_path, capsys):
ctx = _make_ctx()
existing = tmp_path / "a.txt"
existing.write_text("word\n")
ctx.select_file_with_autocomplete.return_value = f"{existing},/nonexistent/missing.txt"
with (
patch("hate_crack.attacks.os.path.isfile", side_effect=lambda p: p == str(existing)),
patch("hate_crack.attacks.os.path.isdir", return_value=False),
):
wordlist_optimize(ctx)
out = capsys.readouterr().out
assert "Not found" in out
ctx.wordlist_optimize.assert_not_called()
def test_empty_outdir_rejection(self, tmp_path, capsys):
ctx = _make_ctx()
wl = tmp_path / "a.txt"
wl.write_text("word\n")
ctx.select_file_with_autocomplete.side_effect = [str(wl), ""]
with (
patch("hate_crack.attacks.os.path.isfile", return_value=True),
patch("hate_crack.attacks.os.path.isdir", return_value=False),
):
wordlist_optimize(ctx)
out = capsys.readouterr().out
assert "Output directory cannot be empty" in out
ctx.wordlist_optimize.assert_not_called()
def test_failure_path(self, tmp_path, capsys):
ctx = _make_ctx()
ctx.wordlist_optimize.return_value = False
wl = tmp_path / "a.txt"
wl.write_text("word\n")
outdir = str(tmp_path / "out")
ctx.select_file_with_autocomplete.side_effect = [str(wl), outdir]
with (
patch("hate_crack.attacks.os.path.isfile", return_value=True),
patch("hate_crack.attacks.os.path.isdir", return_value=False),
):
wordlist_optimize(ctx)
out = capsys.readouterr().out
assert "Optimization failed" in out
class TestWordlistOptimizeWorker:
"""Tests for the wordlist_optimize worker function in hate_crack.main.
All binary-calling helpers (wordlist_splitlen, wordlist_subtract_single)
are mocked so no real binaries are required.
"""
def _get_worker(self):
import importlib
import sys
# Import the module fresh; SKIP_INIT is already active via conftest.
mod = sys.modules.get("hate_crack.main")
if mod is None:
mod = importlib.import_module("hate_crack.main")
return mod.wordlist_optimize
# ------------------------------------------------------------------
# (a) fast-path: empty outdir → wordlist_splitlen called directly
# ------------------------------------------------------------------
def test_fast_path_empty_outdir(self, tmp_path):
worker = self._get_worker()
wl = tmp_path / "words.txt"
wl.write_text("word\n")
outdir = tmp_path / "out"
outdir.mkdir()
with patch("hate_crack.main.wordlist_splitlen", return_value=True) as mock_split, \
patch("hate_crack.main.wordlist_subtract") as mock_sub:
result = worker([str(wl)], str(outdir))
assert result is True
mock_split.assert_called_once_with(str(wl), str(outdir))
mock_sub.assert_not_called()
# ------------------------------------------------------------------
# (b) merge-path: existing per-length file → wordlist_subtract + append
# ------------------------------------------------------------------
def test_merge_path_existing_length_file(self, tmp_path):
worker = self._get_worker()
wl_a = tmp_path / "a.txt"
wl_a.write_text("word1\n")
wl_b = tmp_path / "b.txt"
wl_b.write_text("word2\n")
outdir = tmp_path / "out"
outdir.mkdir()
# After the first wordlist, outdir contains "len5.txt"
len_file = outdir / "len5.txt"
len_file.write_text("word1\n")
# The second call goes through the merge path for the tmp subdir.
# wordlist_splitlen for wl_b produces "len5.txt" in a temp dir.
# wordlist_subtract produces a non-empty output → append.
new_words = b"word2\n"
def fake_splitlen(infile: str, outdir_arg: str) -> bool:
# Write a len5.txt into wherever we are called to write to
(Path(outdir_arg) / "len5.txt").write_bytes(b"word2\n")
return True
def fake_subtract(src: str, out: str, *remove_files: str) -> bool:
# Simulate: the diff is "word2\n" — write to outfile (second arg)
with open(out, "wb") as f:
f.write(new_words)
return True
with patch("hate_crack.main.wordlist_splitlen", side_effect=fake_splitlen), \
patch("hate_crack.main.wordlist_subtract", side_effect=fake_subtract):
# outdir is already non-empty (len_file exists), so wl_b goes to merge path
result = worker([str(wl_b)], str(outdir))
assert result is True
# len5.txt should now contain the appended new words
contents = len_file.read_bytes()
assert b"word2\n" in contents
# ------------------------------------------------------------------
# (c) new length file in subsequent wordlist is just copied
# ------------------------------------------------------------------
def test_new_length_file_is_copied(self, tmp_path):
worker = self._get_worker()
wl_b = tmp_path / "b.txt"
wl_b.write_text("verylongword\n")
outdir = tmp_path / "out"
outdir.mkdir()
# Seed outdir with one length file (len5) so it is non-empty
(outdir / "len5.txt").write_text("hello\n")
# The second wordlist produces only "len12.txt" (no clash)
def fake_splitlen(infile: str, outdir_arg: str) -> bool:
(Path(outdir_arg) / "len12.txt").write_bytes(b"verylongword\n")
return True
with patch("hate_crack.main.wordlist_splitlen", side_effect=fake_splitlen), \
patch("hate_crack.main.wordlist_subtract") as mock_sub:
result = worker([str(wl_b)], str(outdir))
assert result is True
assert (outdir / "len12.txt").exists()
assert (outdir / "len12.txt").read_bytes() == b"verylongword\n"
mock_sub.assert_not_called()
# ------------------------------------------------------------------
# (d) wordlist_splitlen failure returns False
# ------------------------------------------------------------------
def test_splitlen_failure_returns_false(self, tmp_path):
worker = self._get_worker()
wl = tmp_path / "words.txt"
wl.write_text("word\n")
outdir = tmp_path / "out"
outdir.mkdir()
with patch("hate_crack.main.wordlist_splitlen", return_value=False):
result = worker([str(wl)], str(outdir))
assert result is False
# ------------------------------------------------------------------
# (e) wordlist_subtract failure returns False
# ------------------------------------------------------------------
def test_subtract_failure_returns_false(self, tmp_path):
worker = self._get_worker()
wl_b = tmp_path / "b.txt"
wl_b.write_text("word2\n")
outdir = tmp_path / "out"
outdir.mkdir()
# Seed outdir so it's non-empty and has a clashing length file
(outdir / "len5.txt").write_text("word1\n")
def fake_splitlen(infile: str, outdir_arg: str) -> bool:
(Path(outdir_arg) / "len5.txt").write_bytes(b"word2\n")
return True
with patch("hate_crack.main.wordlist_splitlen", side_effect=fake_splitlen), \
patch("hate_crack.main.wordlist_subtract", return_value=False):
result = worker([str(wl_b)], str(outdir))
assert result is False
# ------------------------------------------------------------------
# (f) missing input file is skipped and processing continues
# ------------------------------------------------------------------
def test_missing_input_skipped_processing_continues(self, tmp_path, capsys):
worker = self._get_worker()
good_wl = tmp_path / "good.txt"
good_wl.write_text("word\n")
missing = str(tmp_path / "nonexistent.txt")
outdir = tmp_path / "out"
outdir.mkdir()
call_count = {"n": 0}
def fake_splitlen(infile: str, outdir_arg: str) -> bool:
call_count["n"] += 1
return True
with patch("hate_crack.main.wordlist_splitlen", side_effect=fake_splitlen):
result = worker([missing, str(good_wl)], str(outdir))
assert result is True
# Only the good wordlist should have been processed
assert call_count["n"] == 1
out = capsys.readouterr().out
assert "Skipping" in out