diff --git a/.gitmodules b/.gitmodules index 68ef19c..5ae216f 100644 --- a/.gitmodules +++ b/.gitmodules @@ -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 diff --git a/Dockerfile.test b/Dockerfile.test index 775be7d..869bc53 100644 --- a/Dockerfile.test +++ b/Dockerfile.test @@ -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"] diff --git a/Makefile b/Makefile index 8ab3887..203c6f4 100644 --- a/Makefile +++ b/Makefile @@ -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 \ diff --git a/README.md b/README.md index 0b3a2bb..ccb72e8 100644 --- a/README.md +++ b/README.md @@ -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.] 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 diff --git a/config.json.example b/config.json.example index 27bf100..c696519 100644 --- a/config.json.example +++ b/config.json.example @@ -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": "", diff --git a/hate_crack.py b/hate_crack.py index 7a56b99..d42fbad 100755 --- a/hate_crack.py +++ b/hate_crack.py @@ -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, diff --git a/hate_crack/api.py b/hate_crack/api.py index f40121b..47813c8 100644 --- a/hate_crack/api.py +++ b/hate_crack/api.py @@ -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" ) diff --git a/hate_crack/attacks.py b/hate_crack/attacks.py index 9a26d32..9112363 100644 --- a/hate_crack/attacks.py +++ b/hate_crack/attacks.py @@ -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) diff --git a/hate_crack/main.py b/hate_crack/main.py index 82fff3f..e06e7ca 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -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 ( diff --git a/hate_crack/menu.py b/hate_crack/menu.py index 2f5504c..f99b81d 100644 --- a/hate_crack/menu.py +++ b/hate_crack/menu.py @@ -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 diff --git a/pcfg_cracker b/pcfg_cracker new file mode 160000 index 0000000..b04bbda --- /dev/null +++ b/pcfg_cracker @@ -0,0 +1 @@ +Subproject commit b04bbdadfe8928fd1287fa73ad1aa46a297ff83a diff --git a/pyproject.toml b/pyproject.toml index e9301e0..72a0ba9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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*"] diff --git a/tests/test_api_downloads.py b/tests/test_api_downloads.py index ef27a5a..42d56bc 100644 --- a/tests/test_api_downloads.py +++ b/tests/test_api_downloads.py @@ -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") diff --git a/tests/test_attacks_pcfg.py b/tests/test_attacks_pcfg.py new file mode 100644 index 0000000..c489105 --- /dev/null +++ b/tests/test_attacks_pcfg.py @@ -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") diff --git a/tests/test_combipow_attack.py b/tests/test_combipow_attack.py index 4952ef6..7d6f0eb 100644 --- a/tests/test_combipow_attack.py +++ b/tests/test_combipow_attack.py @@ -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 --- diff --git a/tests/test_docker_script_install.py b/tests/test_docker_script_install.py index 1df1df7..4a26082 100644 --- a/tests/test_docker_script_install.py +++ b/tests/test_docker_script_install.py @@ -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}" + ) diff --git a/tests/test_hashview.py b/tests/test_hashview.py index ed01f2a..b8d6905 100644 --- a/tests/test_hashview.py +++ b/tests/test_hashview.py @@ -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() diff --git a/tests/test_main_pcfg.py b/tests/test_main_pcfg.py new file mode 100644 index 0000000..15eb7a3 --- /dev/null +++ b/tests/test_main_pcfg.py @@ -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// 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 diff --git a/tests/test_menu.py b/tests/test_menu.py index 31ebc6c..3ef46bc 100644 --- a/tests/test_menu.py +++ b/tests/test_menu.py @@ -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 diff --git a/tests/test_random_rules_attack.py b/tests/test_random_rules_attack.py index c2d7761..d9c8ebc 100644 --- a/tests/test_random_rules_attack.py +++ b/tests/test_random_rules_attack.py @@ -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): diff --git a/tests/test_ui_menu_options.py b/tests/test_ui_menu_options.py index 77f4294..ab938d6 100644 --- a/tests/test_ui_menu_options.py +++ b/tests/test_ui_menu_options.py @@ -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"), diff --git a/tests/test_wordlist_tools.py b/tests/test_wordlist_tools.py index 36cd5b8..86a7508 100644 --- a/tests/test_wordlist_tools.py +++ b/tests/test_wordlist_tools.py @@ -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