mirror of
https://github.com/trustedsec/hate_crack.git
synced 2026-06-12 19:11:46 -07:00
Merge branch 'dev' into main
This commit is contained in:
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user