diff --git a/.gitignore b/.gitignore index 9efc18a..7fdf8f2 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,9 @@ research/ 4_char_all all_hashes.enabled some_histories + +# Local agent tooling and instructions - intentionally not published +CLAUDE.md +.claude/ +docs/plans/ +docs/superpowers/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c8f972..f244fe1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,59 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Dates are omitted for releases predating this file; see the git tags for exact timing. +## [2.14.3] - 2026-07-25 + +### Added + +- **Private-key commit gate.** `prek.toml` now runs the `detect-private-key` + hook at the pre-commit stage. The repo previously had no secret-scanning gate + of any kind — bandit only covers `hate_crack/`, so nothing inspected config + files, docs, or test fixtures for committed key material. + +### Removed + +- **Local agent tooling is no longer published.** `CLAUDE.md`, `.claude/`, + `docs/plans/`, and `docs/superpowers/` were development aids rather than part + of the shipped project. They are now gitignored and were removed from the + repository, including from its history. +- **`audit-docs` post-commit hook.** Dropped from `prek.toml` along with the + `.claude/audit-docs.sh` script it invoked. + +## [2.14.2] - 2026-07-25 + +### Fixed + +- **Pipal base-word parsing.** `pipal()` built one rigid regex that required + *exactly* `pipal_count` consecutive base-word lines, so any cracked set with + fewer unique base words than `pipal_count` (default 10 — the common case on + small cracks) matched nothing and returned no base words. The `Top N base + words` section is now parsed line by line, returning up to `pipal_count` + words and stopping at the end of the section. +- **Shell-safe pipal invocation.** The pipal subprocess is now spawned with + list-form arguments instead of a `shell=True` formatted string, so hash-file + paths containing shell metacharacters can no longer be interpreted as + commands. + +### Changed + +- Renamed the internal `_omen_pick_training_wordlist` helper to + `_pick_training_wordlist`, since it is shared by the OMEN, Markov-adjacent, + and LLM (wordlist mode) attacks rather than being OMEN-specific. + +## [2.14.1] - 2026-07-25 + +### Fixed + +- **Tab completion on custom file-path prompts.** The `p. Enter a custom path` + branches of the OMEN and Markov training pickers, the combipow wordlist + prompt, and the rule cleanup/optimize output-path prompts used a bare + `input()` with no readline completer, so TAB did nothing. They now route + through `select_file_with_autocomplete` for consistent path autocompletion. +- **Stale completer leak.** `select_file_with_autocomplete` and the + `_configure_readline`-based pickers now drop the path completer after a + selection, so later numeric-menu and y/n prompts no longer inherit file-path + tab completion. + ## [2.14.0] - 2026-07-24 ### Added diff --git a/README.md b/README.md index 792fa5f..636349f 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ ``` - ___ ___ __ _________ __ + ___ ___ __ _________ __ / | \_____ _/ |_ ____ \_ ___ \____________ ____ | | __ / ~ \__ \\ __\/ __ \ / \ \/\_ __ \__ \ _/ ___\| |/ / -\ Y // __ \| | \ ___/ \ \____| | \// __ \\ \___| < +\ Y // __ \| | \ ___/ \ \____| | \// __ \\ \___| < \___|_ /(____ /__| \___ >____\______ /|__| (____ /\___ >__|_ \ \/ \/ \/_____/ \/ \/ \/ \/ ``` @@ -27,7 +27,7 @@ Or download a pre-built binary from https://hashcat.net/hashcat/ and set `hcatPa ### 2. Download hate_crack -Clone with submodules (required for hashcat-utils, princeprocessor, and optionally omen): +Clone with submodules (required for hashcat-utils, princeprocessor, pcfg_cracker, and optionally omen): ```bash git clone --recurse-submodules https://github.com/trustedsec/hate_crack.git @@ -46,7 +46,7 @@ Then customize configuration in `config.json` if needed (wordlist paths, API key The easiest way is to run `make` (or `make install`), which auto-detects your OS and installs: - External dependencies (p7zip, transmission-daemon / transmission-remote) -- Builds submodules (hashcat-utils, princeprocessor, and optionally omen) +- Builds submodules (hashcat-utils, princeprocessor, pcfg_cracker, and optionally omen) - Python dependencies via uv and a CLI shim at `~/.local/bin/hate_crack` ```bash @@ -96,6 +96,12 @@ Core logic is now split into modules under `hate_crack/`: - `hate_crack/api.py`: Hashview, Weakpass, and Hashmob integrations (downloads/menus/helpers). - `hate_crack/attacks.py`: menu attack handlers. - `hate_crack/hashmob_wordlist.py`: Hashmob wordlist utilities (thin wrapper; calls into api.py). +- `hate_crack/llm.py`: structured (JSON) LLM candidate generation via Atomic Agents. +- `hate_crack/menu.py`: shared menu renderer, including optional arrow-key navigation. +- `hate_crack/noninteractive.py`: dispatcher for the scripted attack subcommands. +- `hate_crack/notify/`: notification package (Pushover backend, per-crack tailer). +- `hate_crack/username_detect.py`: detects `username:hash` input files to decide on hashcat's `--username`. +- `hate_crack/formatting.py`, `hate_crack/progress.py`: output formatting and progress display helpers. - `hate_crack/main.py`: main CLI implementation. The top-level `hate_crack.py` remains the main entry point and orchestrates these modules. @@ -143,7 +149,7 @@ Config is also searched in: - The repo root and package directory - `~/hate_crack`, `~/hate-crack`, or `~/.hate_crack` -**Note:** The `hcatPath` in `config.json` is for the hashcat binary location only (optional if hashcat is in PATH). Hate_crack assets (hashcat-utils, princeprocessor, omen) are loaded from the repository directory and bundled automatically by `make install`. +**Note:** The `hcatPath` in `config.json` is for the hashcat binary location only (optional if hashcat is in PATH). Hate_crack assets (hashcat-utils, princeprocessor, pcfg_cracker, omen) are loaded from the repository directory and bundled automatically by `make install`. ### Run as a script The script uses a `uv` shebang. Make it executable and run: @@ -511,22 +517,38 @@ The attack offers three generation modes: [!] The values in parentheses below are the local model's GUESSES, not verified OSINT. Press Enter to accept, or type your own value to override. - Industry (freight rail maintenance): - Location (Omaha, Nebraska): + Industry (freight rail maintenance): + Location (Omaha, Nebraska): ``` Press Enter to accept a suggestion or type over it. These values are the model's recollection, **not OSINT** — treat them as a starting point, not intelligence about the client. The lookup uses only the local Ollama server, so the client name never leaves the host; there are no web or third-party API calls. If the model does not recognize the organization (the common case for small clients), it returns nothing and you get plain blank prompts: ``` Company name: Acme Rail Services - Industry: - Location: + Industry: + Location: ``` A research failure — timeout, Ollama not running, empty answer — never blocks the attack; it just falls back to blank prompts. Set `ollamaAutoResearch` to `false` to skip research entirely. 2. **Wordlist** — derive basewords from a sample wordlist. 3. **Cracked passwords** — feed the plaintexts already recovered this session (`.out`) back to the model so it can infer the target organization's own password conventions (basewords, seasons, years, suffixes, leetspeak) and generate *new* candidates in the same style. This option is only listed once at least one hash has been cracked; the sample is capped by `ollamaMaxSampleLines` exactly like Wordlist mode. +#### PCFG Configuration + +The PCFG Attack (option 20) and PRINCE-LING Attack (option 21) use the `pcfg_cracker` submodule. Configure them in `config.json`: + +```json +{ + "pcfgRuleset": "DEFAULT", + "pcfgMaxCandidates": 50000000, + "pcfgPrinceLingMaxCandidates": 10000000 +} +``` + +- **`pcfgRuleset`** — Name of the trained grammar to use (default: `DEFAULT`), resolved to `pcfg_cracker/Rules//`. Train your own with pcfg_cracker's `trainer.py` and set this to the ruleset name. +- **`pcfgMaxCandidates`** — Maximum candidates `pcfg_guesser.py` emits for the PCFG attack (default: `50000000`). +- **`pcfgPrinceLingMaxCandidates`** — Maximum base words `prince_ling.py` writes into the cached PRINCE base wordlist (default: `10000000`). + ### Notifications (menu option 82) hate_crack can send Pushover push notifications when attacks complete and, @@ -606,10 +628,10 @@ $ hashcat --help |grep -i ntlm ``` $ ./hate_crack.py 1000 - ___ ___ __ _________ __ + ___ ___ __ _________ __ / | \_____ _/ |_ ____ \_ ___ \____________ ____ | | __ / ~ \__ \\ __\/ __ \ / \ \/\_ __ \__ \ _/ ___\| |/ / -\ Y // __ \| | \ ___/ \ \____| | \// __ \\ \___| < +\ Y // __ \| | \ ___/ \ \____| | \// __ \\ \___| < \___|_ /(____ /__| \___ >____\______ /|__| (____ /\___ >__|_ \ \/ \/ \/_____/ \/ \/ \/ \/ Version 2.0 @@ -730,20 +752,22 @@ All tests use mocked API calls, so they can run without connectivity to a Hashvi (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 (90) Download rules from Hashmob.net (91) Analyze Hashcat Rules @@ -758,6 +782,10 @@ All tests use mocked API calls, so they can run without connectivity to a Hashvi Select a task: ``` + +Option `94 — Hashview API` is only listed when `hashview_api_key` is set in `config.json`. + +The YOLO, Middle, and Thorough Combinator attacks were previously at keys 10-12. They now live in the Combinator Attacks submenu (option 6) along with Combinator3 and CombinatorX. ------------------------------------------------------------------- #### Quick Crack Runs a dictionary attack against wordlists in your `hcatOptimizedWordlists` directory (falls back to `hcatWordlists` if not configured) and optionally applies rules. Multiple rules can be selected by comma-separated list, and chains can be created with the '+' symbol. Pressing Enter at the wordlist prompt uses the configured optimized wordlists directory as the default. @@ -771,9 +799,9 @@ Which rule(s) would you like to run? (99) YOLO...run all of the rules Enter Comma separated list of rules you would like to run. To run rules chained use the + symbol. For example 1+1 will run best64.rule chained twice and 1,2 would run best64.rule and then d3ad0ne.rule sequentially. -Choose wisely: +Choose wisely: ``` - + @@ -790,7 +818,7 @@ Runs several attack methods provided by Martin Bos (formerly known as pure_hate) * Hybrid Attack * Extra - Just For Good Measure - Runs a dictionary attack using `rockyou.txt` with chained `combinator.rule` and `InsidePro-PasswordsPro.rule` rules - + #### Brute Force Attack Brute forces all characters with the choice of a minimum and maximum password length. @@ -872,10 +900,12 @@ Uses hashcat's loopback mode to feed cracked passwords from the current session #### LLM Attack Uses a local Ollama instance to generate password candidates for a capture-the-flag scenario. Prompts for the fake company name, industry, and location, then sends these details to the configured LLM model to produce likely password candidates using industry terms and company name permutations. The generated candidates are fed into a hashcat wordlist+rules attack. -* Requires a running Ollama instance (default: `http://localhost:11434`) -* Configurable model and context window via `config.json` (see Ollama Configuration below) -* Prompts for target company name, industry, and location +* Requires a running Ollama instance (default: `http://localhost:11434`, override with `OLLAMA_HOST`) with the model already pulled — hate_crack does not auto-pull +* Candidate generation uses structured (JSON) output via Atomic Agents, so pick a model with good schema adherence (default: `qwen2.5:32b`) +* Configurable model, context window, request timeout, and sample size via `config.json` (see Ollama Configuration below) +* Prompts for target company name, industry, and location. The industry and location prompts are pre-filled with the local model's guesses about the named organization (editable, and clearly labelled as guesses rather than verified OSINT); disable with `ollamaAutoResearch: false` * Alternatively derives basewords from a sample **wordlist**, or from the **cracked passwords** of the current session (`.out`) so the model mirrors the target organization's own password conventions and produces new candidates in that style (only offered once something has been cracked) +* A live spinner with an elapsed-seconds counter runs during generation, and requests are bounded by `ollamaTimeout` so a model stuck loading into VRAM reports a timeout instead of hanging #### OMEN Attack Uses the Ordered Markov ENumerator (OMEN) to train a statistical password model from a wordlist and generate password candidates. This attack learns patterns from known passwords and generates new candidates based on those patterns. @@ -918,6 +948,14 @@ Generates password candidates using Markov chain statistical models. Similar to * Markov table persists with hash file (filename.out.hcstat2) for fast subsequent runs * Faster than OMEN for general-purpose brute forcing +#### N-gram Attack +Generates n-gram candidates from a corpus file using `ngramX.bin` from hashcat-utils and pipes them into hashcat. + +* Prompts for a corpus file with tab completion, defaulting to the configured wordlist directory +* Prompts for an n-gram group size (default 3) +* Gzip-compressed corpus files are auto-detected and decompressed on the fly +* Useful when you have target-relevant prose (scraped site copy, leaked documents, internal wiki exports) rather than a password list + #### Permutation Attack Generates all character permutations of each word in a targeted wordlist and pipes them to hashcat via `permute.bin` from hashcat-utils. @@ -943,6 +981,23 @@ Generates all unique non-empty subset combinations from a short wordlist using ` * Aborts with a clear message if the wordlist exceeds 63 lines (hard limit) * Candidates are piped directly to hashcat stdin +#### PCFG Attack +Uses [pcfg_cracker](https://github.com/lakiw/pcfg_cracker) to generate candidates from a Probabilistic Context-Free Grammar, piping `pcfg_guesser.py` output directly into hashcat's stdin mode. A PCFG models password *structure* (baseword + digits + symbol, capitalization habits, keyboard walks) with learned probabilities, so candidates come out roughly in descending likelihood order. + +* Requires the `pcfg_cracker` submodule. Presence is checked at startup and reported non-fatally: if it is missing, the PCFG attacks are simply unavailable. Run `make` to fetch it. +* Uses the trained grammar named by `pcfgRuleset` in `config.json` (default `DEFAULT`), read from `pcfg_cracker/Rules//` +* Candidate count is capped by `pcfgMaxCandidates` (default 50,000,000) +* hate_crack does not wrap grammar training. To build a grammar from a target-specific password set, run pcfg_cracker's own `trainer.py` and point `pcfgRuleset` at the resulting ruleset name + +#### PRINCE-LING Attack +Uses pcfg_cracker's `prince_ling.py` to derive an optimized PRINCE base wordlist from a trained grammar, then hands it to the existing PRINCE attack. PRINCE-LING picks base words the grammar says are actually productive, so the PRINCE combination space is far less wasteful than pointing PRINCE at a generic wordlist. + +* Requires the `pcfg_cracker` submodule and a trained ruleset directory, same as the PCFG attack +* The generated wordlist is cached at `/pcfg_prince_ling_.txt` and reused across sessions +* Regenerates only when the ruleset directory is newer than the cached wordlist, so retraining a grammar invalidates the cache automatically +* Generation is written to a temporary file and atomically moved into place; a failed or interrupted run cleans up its partial file and leaves any existing cache intact +* Base wordlist size is capped by `pcfgPrinceLingMaxCandidates` (default 10,000,000) + #### Wordlist Tools (option 80) A submenu of wordlist preprocessing utilities using hashcat-utils binaries. All tools read from and write to files on disk. All file and directory path prompts support tab completion. @@ -997,7 +1052,7 @@ Interactive menu for downloading and managing wordlists from Weakpass.com via Bi * Download specific wordlists or entire collections * Automatic extraction of compressed archives * Progress tracking for torrent downloads - + ------------------------------------------------------------------- ### Version History diff --git a/hate_crack/attacks.py b/hate_crack/attacks.py index ce3f1d4..837bbce 100644 --- a/hate_crack/attacks.py +++ b/hate_crack/attacks.py @@ -181,6 +181,7 @@ def quick_crack(ctx: Any) -> None: print("Please enter a valid wordlist or wordlist directory.") except ValueError: print("Please enter a valid number.") + readline.set_completer(None) selected_rules = _select_rules(ctx) if selected_rules is None: @@ -492,6 +493,7 @@ def _prompt_wordlist_paths(ctx, max_count: int) -> list[str]: count += 1 else: print(f"Not found: {resolved}") + readline.set_completer(None) return collected @@ -590,7 +592,7 @@ def ollama_attack(ctx: Any) -> None: ) return elif choice == "2": - path = _omen_pick_training_wordlist(ctx, title="LLM Sample Wordlists") + path = _pick_training_wordlist(ctx, title="LLM Sample Wordlists") if not path: return ctx.hcatOllama(ctx.hcatHashType, ctx.hcatHashFile, "wordlist", path) @@ -604,7 +606,7 @@ def ollama_attack(ctx: Any) -> None: print("\t[!] Invalid selection.") -def _omen_pick_training_wordlist(ctx: Any, title: str = "Training Wordlists"): +def _pick_training_wordlist(ctx: Any, title: str = "Training Wordlists"): """Show wordlist picker. Returns path or None (user cancelled with 'q').""" wordlist_files = ctx.list_wordlist_files(ctx.hcatWordlists) # Print the grid once, outside the retry loop: a wordlists directory can @@ -626,8 +628,10 @@ def _omen_pick_training_wordlist(ctx: Any, title: str = "Training Wordlists"): if sel.lower() == "q": return None if sel.lower() == "p": - path = input("\n\tPath to wordlist: ").strip() - return path if path else None + path = ctx.select_file_with_autocomplete( + "\tPath to wordlist (tab to autocomplete)" + ) + return path.strip() if path else None try: idx = int(sel) if 1 <= idx <= len(wordlist_files): @@ -678,7 +682,7 @@ def omen_attack(ctx: Any) -> None: print("\n\tNo valid OMEN model found. Training is required.") if need_training: - training_file = _omen_pick_training_wordlist(ctx) + training_file = _pick_training_wordlist(ctx) if not training_file: return if not ctx.hcatOmenTrain(training_file): @@ -705,7 +709,7 @@ def _markov_pick_training_source(ctx: Any): has_cracked = os.path.isfile(out_path) and os.path.getsize(out_path) > 0 wordlist_files = ctx.list_wordlist_files(ctx.hcatWordlists) - # Print the grid once, outside the retry loop — see _omen_pick_training_wordlist. + # Print the grid once, outside the retry loop — see _pick_training_wordlist. entries = [] if has_cracked: entries.append("0) Cracked passwords (current session)") @@ -727,8 +731,10 @@ def _markov_pick_training_source(ctx: Any): if sel == "0" and has_cracked: return out_path if sel.lower() == "p": - path = input("\n\tPath to training file: ").strip() - return path if path else None + path = ctx.select_file_with_autocomplete( + "\tPath to training file (tab to autocomplete)" + ) + return path.strip() if path else None try: idx = int(sel) if 1 <= idx <= len(wordlist_files): @@ -805,7 +811,10 @@ def combipow_crack(ctx: Any) -> None: _notify.prompt_notify_for_attack("Combipow") wordlist = None while wordlist is None: - path = input("\nEnter path to wordlist (max 63 lines recommended): ").strip() + path = ctx.select_file_with_autocomplete( + "Enter path to wordlist (max 63 lines recommended, tab to autocomplete)" + ) + path = path.strip() if path else "" if not path: continue if not os.path.isfile(path): @@ -901,9 +910,11 @@ def generate_rules_crack(ctx: Any) -> None: wordlist_choice = raw_choice else: print("[!] Wordlist not found. Please enter a valid path.") + readline.set_completer(None) return except ValueError: print("Please enter a valid number.") + readline.set_completer(None) ctx.hcatGenerateRules( ctx.hcatHashType, ctx.hcatHashFile, rule_count, wordlist_choice @@ -981,6 +992,7 @@ def permute_crack(ctx: Any) -> None: print("[!] A directory was provided. Please enter a single wordlist file.") continue wordlist_path = raw + readline.set_completer(None) ctx.hcatPermute(ctx.hcatHashType, ctx.hcatHashFile, wordlist_path) @@ -1028,7 +1040,10 @@ def _rule_select_file(ctx: Any, prompt: str = "Rule file: ") -> str: return None _configure_readline(rule_completer) - return input(prompt).strip() + try: + return input(prompt).strip() + finally: + readline.set_completer(None) def rule_cleanup_handler(ctx: Any) -> None: @@ -1039,7 +1054,10 @@ def rule_cleanup_handler(ctx: Any) -> None: if not infile or not os.path.isfile(infile): print(f"[!] File not found: {infile}") return - outfile = input("Output file path: ").strip() + outfile = ctx.select_file_with_autocomplete( + "Output file path (tab to autocomplete)" + ) + outfile = outfile.strip() if outfile else "" if not outfile: print("[!] Output path required.") return @@ -1057,7 +1075,10 @@ def rule_optimize_handler(ctx: Any) -> None: if not infile or not os.path.isfile(infile): print(f"[!] File not found: {infile}") return - outfile = input("Output file path: ").strip() + outfile = ctx.select_file_with_autocomplete( + "Output file path (tab to autocomplete)" + ) + outfile = outfile.strip() if outfile else "" if not outfile: print("[!] Output path required.") return @@ -1077,7 +1098,10 @@ def rule_cleanup_and_optimize_handler(ctx: Any) -> None: if not infile or not os.path.isfile(infile): print(f"[!] File not found: {infile}") return - outfile = input("Output file path: ").strip() + outfile = ctx.select_file_with_autocomplete( + "Output file path (tab to autocomplete)" + ) + outfile = outfile.strip() if outfile else "" if not outfile: print("[!] Output path required.") return diff --git a/hate_crack/main.py b/hate_crack/main.py index fb67a85..2ac9602 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -1238,7 +1238,12 @@ def select_file_with_autocomplete( full_prompt += f" (default: {default})" full_prompt += ": " - result = input(full_prompt).strip() + try: + result = input(full_prompt).strip() + finally: + # Drop the path completer so later plain prompts (numeric menus, y/n) + # don't inherit stale file-path tab completion. + readline.set_completer(None) if not result and base_dir: result = base_dir @@ -4438,15 +4443,18 @@ def pipal(): pipalFile.write(clearTextPass) pipalFile.close() - pipalProcess = subprocess.Popen( - "{pipal_path} {pipal_file} -t {pipal_count} --output {pipal_out}".format( - pipal_path=pipalPath, - pipal_file=hcatHashFilePipal + ".passwords", - pipal_out=hcatHashFilePipal + ".pipal", - pipal_count=pipal_count, - ), - shell=True, - ) + # List-form Popen (no shell=True) so paths/filenames containing + # shell metacharacters can't be interpreted as commands. shlex.split + # on pipalPath still allows an interpreter prefix (e.g. "ruby + # /opt/pipal/pipal.rb") to be configured. + pipal_cmd = shlex.split(pipalPath) + [ + hcatHashFilePipal + ".passwords", + "-t", + str(pipal_count), + "--output", + hcatHashFilePipal + ".pipal", + ] + pipalProcess = subprocess.Popen(pipal_cmd) try: pipalProcess.wait() except KeyboardInterrupt: @@ -4469,25 +4477,31 @@ def pipal(): print(pipalfile.read()) print("\n--- Pipal Output End ---\n") with open(hcatHashFilePipal + ".pipal") as pipalfile: - pipal_content = pipalfile.readlines() - raw_pipal = "\n".join(pipal_content) - raw_pipal = re.sub("\n+", "\n", raw_pipal) - raw_regex = r"Top [0-9]+ base words\n" - for word in range(pipal_count): - raw_regex += r"(\S+).*\n" - basewords_re = re.compile(raw_regex) - results = re.search(basewords_re, raw_pipal) + pipal_content = pipalfile.read() + # Parse the "Top N base words" section line by line rather than + # with one rigid regex. The old approach required *exactly* + # pipal_count baseword lines, so any cracked set with fewer + # unique base words than pipal_count (the common case on small + # cracks) matched nothing and returned []. Collect up to + # pipal_count base words and stop at the end of the section. top_basewords = [] - if results: - if results.lastindex is not None: - for i in range(1, results.lastindex + 1): - if i is not None: - top_basewords.append(results.group(i)) - else: - pass - return top_basewords - else: - return [] + in_section = False + for line in pipal_content.splitlines(): + if re.match(r"\s*Top\s+[0-9]+\s+base words", line): + in_section = True + continue + if in_section: + if not line.strip(): + # blank line terminates the base words section + break + # Capture the base word (first token); tolerate both + # "word = 5 (5%)" and "word 5" separators. + match = re.match(r"\s*(\S+)", line) + if match: + top_basewords.append(match.group(1)) + if len(top_basewords) >= pipal_count: + break + return top_basewords else: print("No hashes were cracked :(") return [] diff --git a/prek.toml b/prek.toml index 1b2a642..9ce02d2 100644 --- a/prek.toml +++ b/prek.toml @@ -55,18 +55,9 @@ stages = ["pre-push"] pass_filenames = false always_run = true -[[repos.hooks]] -id = "audit-docs" -name = "audit-docs" -entry = "bash .claude/audit-docs.sh HEAD" -language = "system" -stages = ["post-commit"] -pass_filenames = false -always_run = true - # General hygiene hooks (mirrors hashview's .pre-commit-config.yaml). These run -# at the pre-commit stage, so `prek install` must include `--hook-type pre-commit` -# (see CLAUDE.md). Auto-fixers modify files in place; re-stage and commit again. +# at the pre-commit stage, so `prek install` must include `--hook-type pre-commit`. +# Auto-fixers modify files in place; re-stage and commit again. [[repos]] repo = "https://github.com/pre-commit/pre-commit-hooks" rev = "v5.0.0" @@ -86,3 +77,8 @@ id = "check-merge-conflict" [[repos.hooks]] id = "check-added-large-files" args = ["--maxkb=1024"] + +# Blocks committing PEM/OpenSSH private keys. The repo had no secret-scanning +# gate at all before this; bandit only covers hate_crack/. +[[repos.hooks]] +id = "detect-private-key" diff --git a/pyproject.toml b/pyproject.toml index b03c5bc..1964d00 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=69", "setuptools-scm>=8"] +requires = ["setuptools>=83.0.0", "setuptools-scm>=8"] build-backend = "setuptools.build_meta" [project] @@ -10,7 +10,7 @@ readme = "README.md" requires-python = ">=3.13" dependencies = [ "requests>=2.34.2", - "beautifulsoup4>=4.12.0", + "beautifulsoup4>=4.15.0", "openpyxl>=3.0.0", "packaging>=26.2", "simple-term-menu==1.6.6", diff --git a/tests/test_attacks_behavior.py b/tests/test_attacks_behavior.py index ae7186b..5436dba 100644 --- a/tests/test_attacks_behavior.py +++ b/tests/test_attacks_behavior.py @@ -532,7 +532,7 @@ class TestOllamaAttack: class TestOmenPickTrainingWordlistReprompt: - """_omen_pick_training_wordlist re-prompts on invalid input instead of aborting.""" + """_pick_training_wordlist re-prompts on invalid input instead of aborting.""" def _make_ctx(self, wordlist_files=None): ctx = MagicMock() @@ -542,29 +542,29 @@ class TestOmenPickTrainingWordlistReprompt: return ctx def test_invalid_input_reprompts_then_valid_pick(self) -> None: - from hate_crack.attacks import _omen_pick_training_wordlist + from hate_crack.attacks import _pick_training_wordlist ctx = self._make_ctx(["rockyou.txt"]) # First input is invalid, second is valid with patch("builtins.input", side_effect=["bad", "1"]): - result = _omen_pick_training_wordlist(ctx) + result = _pick_training_wordlist(ctx) assert result is not None assert "rockyou.txt" in result def test_cancel_with_q_returns_none(self) -> None: - from hate_crack.attacks import _omen_pick_training_wordlist + from hate_crack.attacks import _pick_training_wordlist ctx = self._make_ctx(["rockyou.txt"]) with patch("builtins.input", return_value="q"): - result = _omen_pick_training_wordlist(ctx) + result = _pick_training_wordlist(ctx) assert result is None def test_multiple_invalid_inputs_then_cancel(self) -> None: - from hate_crack.attacks import _omen_pick_training_wordlist + from hate_crack.attacks import _pick_training_wordlist ctx = self._make_ctx(["rockyou.txt"]) with patch("builtins.input", side_effect=["99", "abc", "q"]): - result = _omen_pick_training_wordlist(ctx) + result = _pick_training_wordlist(ctx) assert result is None diff --git a/tests/test_combipow_attack.py b/tests/test_combipow_attack.py index 7d6f0eb..8f30e16 100644 --- a/tests/test_combipow_attack.py +++ b/tests/test_combipow_attack.py @@ -66,7 +66,8 @@ class TestCombipowCrack: ctx = _make_ctx() wl = tmp_path / "words.txt" wl.write_text("correct\nhorse\nbattery\n") - with patch("builtins.input", side_effect=[str(wl), ""]): + ctx.select_file_with_autocomplete.side_effect = [str(wl)] + with patch("builtins.input", side_effect=[""]): attacks.combipow_crack(ctx) ctx.hcatCombipow.assert_called_once() call_args = ctx.hcatCombipow.call_args @@ -75,12 +76,23 @@ class TestCombipowCrack: ) assert use_space is True + def test_wordlist_path_uses_autocomplete_not_input(self, tmp_path): + attacks = _load_attacks() + ctx = _make_ctx() + wl = tmp_path / "words.txt" + wl.write_text("correct\nhorse\n") + ctx.select_file_with_autocomplete.side_effect = [str(wl)] + with patch("builtins.input", side_effect=[""]): + attacks.combipow_crack(ctx) + ctx.select_file_with_autocomplete.assert_called_once() + def test_calls_hcatCombipow_without_space_sep(self, tmp_path): attacks = _load_attacks() ctx = _make_ctx() wl = tmp_path / "words.txt" wl.write_text("correct\nhorse\nbattery\n") - with patch("builtins.input", side_effect=[str(wl), "n"]): + ctx.select_file_with_autocomplete.side_effect = [str(wl)] + with patch("builtins.input", side_effect=["n"]): attacks.combipow_crack(ctx) ctx.hcatCombipow.assert_called_once() call_args = ctx.hcatCombipow.call_args @@ -94,7 +106,8 @@ class TestCombipowCrack: ctx = _make_ctx() wl = tmp_path / "words.txt" wl.write_text("\n".join(f"word{i}" for i in range(64)) + "\n") - with patch("builtins.input", return_value=str(wl)): + ctx.select_file_with_autocomplete.side_effect = [str(wl)] + with patch("builtins.input", side_effect=[]): attacks.combipow_crack(ctx) ctx.hcatCombipow.assert_not_called() @@ -103,7 +116,8 @@ class TestCombipowCrack: ctx = _make_ctx() wl = tmp_path / "words.txt" wl.write_text("\n".join(f"word{i}" for i in range(63)) + "\n") - with patch("builtins.input", side_effect=[str(wl), ""]): + ctx.select_file_with_autocomplete.side_effect = [str(wl)] + with patch("builtins.input", side_effect=[""]): attacks.combipow_crack(ctx) ctx.hcatCombipow.assert_called_once() @@ -112,7 +126,8 @@ class TestCombipowCrack: ctx = _make_ctx() wl = tmp_path / "words.txt" wl.write_text("test\n") - with patch("builtins.input", side_effect=["/nonexistent.txt", str(wl), ""]): + ctx.select_file_with_autocomplete.side_effect = ["/nonexistent.txt", str(wl)] + with patch("builtins.input", side_effect=[""]): attacks.combipow_crack(ctx) ctx.hcatCombipow.assert_called_once() @@ -121,7 +136,8 @@ class TestCombipowCrack: ctx = _make_ctx(hash_type="3200", hash_file="/tmp/bcrypt.txt") wl = tmp_path / "words.txt" wl.write_text("word1\nword2\n") - with patch("builtins.input", side_effect=[str(wl), ""]): + ctx.select_file_with_autocomplete.side_effect = [str(wl)] + with patch("builtins.input", side_effect=[""]): attacks.combipow_crack(ctx) ctx.hcatCombipow.assert_called_once() args = ctx.hcatCombipow.call_args[0] @@ -134,7 +150,8 @@ class TestCombipowCrack: ctx = _make_ctx() wl = tmp_path / "words.txt" wl.write_text("\n".join(f"word{i}" for i in range(31)) + "\n") - with patch("builtins.input", side_effect=[str(wl), ""]): + ctx.select_file_with_autocomplete.side_effect = [str(wl)] + with patch("builtins.input", side_effect=[""]): attacks.combipow_crack(ctx) captured = capsys.readouterr() assert "large" in captured.out.lower() or "warning" in captured.out.lower() diff --git a/tests/test_custom_path_autocomplete.py b/tests/test_custom_path_autocomplete.py new file mode 100644 index 0000000..271aad3 --- /dev/null +++ b/tests/test_custom_path_autocomplete.py @@ -0,0 +1,80 @@ +"""Regression tests: custom-path prompts must offer tab autocomplete. + +Previously the "p. Enter a custom path" branches (OMEN, Markov) and the +combipow wordlist prompt used a bare ``input()`` with no readline completer, +so TAB did nothing. They now route through ``select_file_with_autocomplete``. +Also covers the canonical selector dropping its completer afterward so later +plain prompts don't inherit stale file-path completion. +""" + +import os +import readline +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +os.environ["HATE_CRACK_SKIP_INIT"] = "1" + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +def _load_attacks(): + if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + import hate_crack.attacks as attacks # noqa: PLC0415 + + return attacks + + +def _make_ctx(): + ctx = MagicMock() + ctx.hcatHashType = "1000" + ctx.hcatHashFile = "/tmp/hashes.txt" + ctx.hcatWordlists = "/tmp/wordlists" + ctx.list_wordlist_files.return_value = [] + return ctx + + +class TestOmenCustomPath: + def test_custom_path_uses_autocomplete(self): + attacks = _load_attacks() + ctx = _make_ctx() + ctx.select_file_with_autocomplete.return_value = "/data/custom.txt" + with patch("builtins.input", side_effect=["p"]): + result = attacks._pick_training_wordlist(ctx) + ctx.select_file_with_autocomplete.assert_called_once() + assert result == "/data/custom.txt" + + def test_custom_path_blank_returns_none(self): + attacks = _load_attacks() + ctx = _make_ctx() + ctx.select_file_with_autocomplete.return_value = None + with patch("builtins.input", side_effect=["p"]): + result = attacks._pick_training_wordlist(ctx) + assert result is None + + +class TestMarkovCustomPath: + def test_custom_path_uses_autocomplete(self): + attacks = _load_attacks() + ctx = _make_ctx() + ctx.select_file_with_autocomplete.return_value = "/data/train.txt" + # out file absent -> "0" option not offered + with patch("builtins.input", side_effect=["p"]): + result = attacks._markov_pick_training_source(ctx) + ctx.select_file_with_autocomplete.assert_called_once() + assert result == "/data/train.txt" + + +class TestSelectorResetsCompleter: + def test_completer_reset_after_selection(self): + if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + import hate_crack.main as m # noqa: PLC0415 + + sentinel = object() + readline.set_completer(lambda t, s: None) + with patch("builtins.input", return_value="/some/path"): + m.select_file_with_autocomplete("Pick a file") + assert readline.get_completer() is None + del sentinel diff --git a/tests/test_main_pcfg.py b/tests/test_main_pcfg.py index 15eb7a3..c71112e 100644 --- a/tests/test_main_pcfg.py +++ b/tests/test_main_pcfg.py @@ -17,6 +17,15 @@ class TestHcatPCFG: hash_file = str(tmp_path / "hashes.txt") Path(hash_file).write_text("dummy") + # hcatPCFG resolves pcfg_guesser.py under the module global hate_path + # and bails early if it's missing. Pin hate_path to a tmp dir with a + # stub script so the test is hermetic — independent of the real + # pcfg_cracker submodule being checked out and of any hate_path value + # leaked by an earlier test (hate_crack.main is shared session-wide). + pcfg_dir = tmp_path / "pcfg_cracker" + pcfg_dir.mkdir() + (pcfg_dir / "pcfg_guesser.py").write_text("# stub") + captured_calls = [] class FakeProc: @@ -27,6 +36,7 @@ class TestHcatPCFG: with patch("hate_crack.main.subprocess.Popen", side_effect=FakeProc), \ patch("hate_crack.main._run_hcat_cmd") as mock_run, \ + patch.object(main_module, "hate_path", str(tmp_path)), \ patch.object(main_module, "hcatBin", "hashcat"), \ patch.object(main_module, "hcatTuning", ""), \ patch.object(main_module, "hcatPotfilePath", ""), \ diff --git a/tests/test_markov_e2e.py b/tests/test_markov_e2e.py index a302876..853ebda 100644 --- a/tests/test_markov_e2e.py +++ b/tests/test_markov_e2e.py @@ -10,14 +10,18 @@ import pytest class TestMarkovE2E: """End-to-end tests for complete markov attack workflow.""" - def test_markov_training_plain_text(self, tmp_path: Path) -> None: + def test_markov_training_plain_text(self, tmp_path: Path, monkeypatch) -> None: """Test markov training with plain text wordlist.""" from hate_crack import main - # Setup paths - main.hate_path = Path(__file__).resolve().parents[1] - main.hcatHcstat2genBin = "hcstat2gen.bin" - bin_path = main.hate_path / "hashcat-utils" / "bin" / "hcstat2gen.bin" + # Setup paths. Use monkeypatch so these module globals are restored + # after the test — hate_crack.main is imported once and shared across + # the whole session, so a raw assignment here would leak into every + # later test (e.g. test_main_pcfg, which reads the ambient hate_path). + repo_root = Path(__file__).resolve().parents[1] + monkeypatch.setattr(main, "hate_path", repo_root) + monkeypatch.setattr(main, "hcatHcstat2genBin", "hcstat2gen.bin") + bin_path = repo_root / "hashcat-utils" / "bin" / "hcstat2gen.bin" if not bin_path.is_file(): pytest.skip(f"hcstat2gen.bin not compiled: {bin_path}") @@ -39,14 +43,16 @@ class TestMarkovE2E: assert hcstat2_path.exists(), ".hcstat2 file should be created" assert hcstat2_path.stat().st_size > 0, ".hcstat2 file should not be empty" - def test_markov_training_gzipped(self, tmp_path: Path) -> None: + def test_markov_training_gzipped(self, tmp_path: Path, monkeypatch) -> None: """Test markov training with gzipped wordlist.""" from hate_crack import main - # Setup paths - main.hate_path = Path(__file__).resolve().parents[1] - main.hcatHcstat2genBin = "hcstat2gen.bin" - bin_path = main.hate_path / "hashcat-utils" / "bin" / "hcstat2gen.bin" + # Setup paths (monkeypatch so these session-shared module globals are + # restored after the test — see test_markov_training_plain_text). + repo_root = Path(__file__).resolve().parents[1] + monkeypatch.setattr(main, "hate_path", repo_root) + monkeypatch.setattr(main, "hcatHcstat2genBin", "hcstat2gen.bin") + bin_path = repo_root / "hashcat-utils" / "bin" / "hcstat2gen.bin" if not bin_path.is_file(): pytest.skip(f"hcstat2gen.bin not compiled: {bin_path}") diff --git a/tests/test_omen_attack.py b/tests/test_omen_attack.py index cc61f9e..7239df1 100644 --- a/tests/test_omen_attack.py +++ b/tests/test_omen_attack.py @@ -355,12 +355,14 @@ class TestOmenAttackHandler: def test_custom_path_for_training(self, tmp_path): ctx = self._make_ctx(tmp_path, model_valid=False) self._setup_rules_dir(tmp_path) + ctx.select_file_with_autocomplete.return_value = "/custom/wordlist.txt" with patch("os.path.isfile", return_value=True), patch( - "builtins.input", side_effect=["p", "/custom/wordlist.txt", "", "0"] + "builtins.input", side_effect=["p", "", "0"] ): from hate_crack.attacks import omen_attack omen_attack(ctx) + ctx.select_file_with_autocomplete.assert_called_once() ctx.hcatOmenTrain.assert_called_once_with("/custom/wordlist.txt") def test_rules_passed_to_hcatOmen(self, tmp_path): diff --git a/tests/test_pipal_e2e.py b/tests/test_pipal_e2e.py new file mode 100644 index 0000000..9b3139f --- /dev/null +++ b/tests/test_pipal_e2e.py @@ -0,0 +1,211 @@ +"""End-to-end tests for ``hate_crack.main.pipal``. + +These are hermetic e2e tests: instead of requiring a real Ruby + pipal.rb +install, they drop a small fake ``pipal`` executable on disk that consumes the +same CLI (`` -t --output ``) and emits a pipal-shaped +report. ``pipal()`` is then driven through its real code path — writing the +``.passwords`` file (including ``$HEX[...]`` decoding), spawning the subprocess +via ``subprocess.Popen(..., shell=True)``, and parsing the ``Top N base words`` +section back out. + +A real-tool variant is gated behind ``HATE_CRACK_PIPAL_REAL=1`` + +``HATE_CRACK_PIPAL_PATH`` for anyone who wants to run against genuine pipal.rb. +""" + +import os +import stat +import sys +import textwrap +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +def _get_main_module(): + if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + os.environ["HATE_CRACK_SKIP_INIT"] = "1" + import hate_crack.main as m # noqa: PLC0415 + + return m + + +# A fake pipal that mimics the real tool closely enough for the parser: +# base word = password lowercased with trailing non-alphabetic characters +# stripped, ranked by descending frequency, and only the top -t entries are +# emitted under the "Top N base words" header. +_FAKE_PIPAL = textwrap.dedent( + """\ + #!/usr/bin/env python3 + import re + import sys + + args = sys.argv[1:] + pw_file = args[0] + top = 10 + out = None + i = 1 + while i < len(args): + if args[i] in ("-t", "--top"): + top = int(args[i + 1]); i += 2 + elif args[i] in ("-o", "--output"): + out = args[i + 1]; i += 2 + elif args[i] == "--output": + out = args[i + 1]; i += 2 + else: + i += 1 + + counts = {} + with open(pw_file, encoding="utf-8", errors="replace") as fh: + for line in fh: + pw = line.rstrip("\\n") + if not pw: + continue + base = re.sub(r"[^a-zA-Z]+$", "", pw).lower() + if not base: + continue + counts[base] = counts.get(base, 0) + 1 + + ranked = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0])) + total = sum(counts.values()) + + lines = [] + lines.append("Basic Results") + lines.append("") + lines.append("Total entries = %d" % total) + lines.append("") + lines.append("Top %d base words" % top) + for word, c in ranked[:top]: + pct = 100.0 * c / total if total else 0.0 + lines.append("%s = %d (%.2f%%)" % (word, c, pct)) + lines.append("") + + with open(out, "w", encoding="utf-8") as fh: + fh.write("\\n".join(lines) + "\\n") + """ +) + + +def _install_fake_pipal(tmp_path: Path) -> Path: + fake = tmp_path / "pipal" + fake.write_text(_FAKE_PIPAL) + fake.chmod(fake.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) + return fake + + +def _run_pipal(monkeypatch, m, tmp_path, cracked_lines, pipal_count): + """Wire up main-module globals and run the real ``pipal()``.""" + fake = _install_fake_pipal(tmp_path) + hashfile = tmp_path / "hashes.txt" + hashfile.write_text("dummy\n") + (tmp_path / "hashes.txt.out").write_text("".join(cracked_lines)) + + monkeypatch.setattr(m, "pipalPath", str(fake)) + monkeypatch.setattr(m, "pipal_count", pipal_count) + # hcatHashFile / hcatHashType only exist after main() runs; create them. + monkeypatch.setattr(m, "hcatHashFile", str(hashfile), raising=False) + monkeypatch.setattr(m, "hcatHashType", "0", raising=False) # not NTLM + return m.pipal(), hashfile + + +class TestPipalE2E: + def test_returns_top_basewords(self, monkeypatch, tmp_path, capsys): + m = _get_main_module() + cracked = [ + "hash1:password1\n", + "hash2:password2\n", + "hash3:Password!\n", + "hash4:summer2021\n", + "hash5:summer99\n", + "hash6:winter1\n", + ] + result, hashfile = _run_pipal(monkeypatch, m, tmp_path, cracked, pipal_count=3) + + assert result == ["password", "summer", "winter"] + # pipal report and intermediate passwords file were produced + assert (Path(str(hashfile) + ".pipal")).is_file() + assert (Path(str(hashfile) + ".passwords")).is_file() + + def test_passwords_file_decodes_hex(self, monkeypatch, tmp_path): + m = _get_main_module() + # 70617373776f7264 == "password" + cracked = [ + "hash1:$HEX[70617373776f7264]\n", + "hash2:hunter2\n", + ] + _run_pipal(monkeypatch, m, tmp_path, cracked, pipal_count=2) + + pw_path = tmp_path / "hashes.txt.passwords" + contents = pw_path.read_text(encoding="utf-8").splitlines() + assert "password" in contents # $HEX decoded, not written literally + assert "hunter2" in contents + assert not any(line.startswith("$HEX[") for line in contents) + + def test_no_cracked_output_returns_empty(self, monkeypatch, tmp_path): + m = _get_main_module() + fake = _install_fake_pipal(tmp_path) + hashfile = tmp_path / "hashes.txt" + hashfile.write_text("dummy\n") # note: no .out file created + + monkeypatch.setattr(m, "pipalPath", str(fake)) + monkeypatch.setattr(m, "pipal_count", 3) + monkeypatch.setattr(m, "hcatHashFile", str(hashfile), raising=False) + monkeypatch.setattr(m, "hcatHashType", "0", raising=False) + assert m.pipal() == [] + + def test_missing_pipal_path_returns_none(self, monkeypatch, tmp_path): + m = _get_main_module() + hashfile = tmp_path / "hashes.txt" + hashfile.write_text("dummy\n") + (tmp_path / "hashes.txt.out").write_text("hash1:password1\n") + + monkeypatch.setattr(m, "pipalPath", str(tmp_path / "does-not-exist")) + monkeypatch.setattr(m, "pipal_count", 3) + monkeypatch.setattr(m, "hcatHashFile", str(hashfile), raising=False) + monkeypatch.setattr(m, "hcatHashType", "0", raising=False) + assert m.pipal() is None + + def test_handles_shell_metacharacters_in_path(self, monkeypatch, tmp_path): + """The subprocess call must not use a shell (no command injection). + + A hash-file path containing shell metacharacters would, under the old + ``shell=True`` string command, either break or execute the injected + fragment. With list-form Popen it is handled as a literal path. + """ + m = _get_main_module() + weird_dir = tmp_path / "a b; touch INJECTED" + weird_dir.mkdir() + cracked = ["hash1:password1\n", "hash2:summer2021\n"] + + fake = _install_fake_pipal(tmp_path) + hashfile = weird_dir / "hashes.txt" + hashfile.write_text("dummy\n") + (weird_dir / "hashes.txt.out").write_text("".join(cracked)) + + monkeypatch.setattr(m, "pipalPath", str(fake)) + monkeypatch.setattr(m, "pipal_count", 3) + monkeypatch.setattr(m, "hcatHashFile", str(hashfile), raising=False) + monkeypatch.setattr(m, "hcatHashType", "0", raising=False) + + result = m.pipal() + + assert result == ["password", "summer"] + # the injected `touch INJECTED` must NOT have run + assert not (tmp_path / "INJECTED").exists() + assert not Path("INJECTED").exists() + + def test_fewer_basewords_than_count(self, monkeypatch, tmp_path): + """Real-world case: fewer unique base words than ``pipal_count``. + + A small cracked set should still surface the base words it *does* + have, rather than silently returning nothing. + """ + m = _get_main_module() + cracked = [ + "hash1:password1\n", + "hash2:summer2021\n", + "hash3:winter1\n", + ] + # default pipal_count (10) is larger than the 3 available base words + result, _ = _run_pipal(monkeypatch, m, tmp_path, cracked, pipal_count=10) + assert result == ["password", "summer", "winter"] diff --git a/tests/test_rule_tools.py b/tests/test_rule_tools.py index 051f01b..19b5b90 100644 --- a/tests/test_rule_tools.py +++ b/tests/test_rule_tools.py @@ -26,7 +26,8 @@ class TestRuleCleanupHandler: infile = tmp_path / "test.rule" infile.write_text("l\nu\n") outfile = tmp_path / "clean.rule" - with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + ctx.select_file_with_autocomplete.return_value = str(outfile) + with patch("builtins.input", side_effect=[str(infile)]): rule_cleanup_handler(ctx) ctx.rules_cleanup.assert_called_once_with(str(infile), str(outfile)) @@ -40,7 +41,8 @@ class TestRuleCleanupHandler: ctx = _make_ctx() infile = tmp_path / "test.rule" infile.write_text("l\n") - with patch("builtins.input", side_effect=[str(infile), ""]): + ctx.select_file_with_autocomplete.return_value = "" + with patch("builtins.input", side_effect=[str(infile)]): rule_cleanup_handler(ctx) ctx.rules_cleanup.assert_not_called() @@ -49,7 +51,8 @@ class TestRuleCleanupHandler: infile = tmp_path / "test.rule" infile.write_text("l\n") outfile = tmp_path / "clean.rule" - with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + ctx.select_file_with_autocomplete.return_value = str(outfile) + with patch("builtins.input", side_effect=[str(infile)]): rule_cleanup_handler(ctx) assert "[+] Done." in capsys.readouterr().out @@ -59,7 +62,8 @@ class TestRuleCleanupHandler: infile = tmp_path / "test.rule" infile.write_text("l\n") outfile = tmp_path / "clean.rule" - with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + ctx.select_file_with_autocomplete.return_value = str(outfile) + with patch("builtins.input", side_effect=[str(infile)]): rule_cleanup_handler(ctx) assert "[!] Cleanup failed." in capsys.readouterr().out @@ -70,7 +74,8 @@ class TestRuleOptimizeHandler: infile = tmp_path / "test.rule" infile.write_text("l\nu\n") outfile = tmp_path / "optimized.rule" - with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + ctx.select_file_with_autocomplete.return_value = str(outfile) + with patch("builtins.input", side_effect=[str(infile)]): rule_optimize_handler(ctx) ctx.rules_optimize.assert_called_once_with(str(infile), str(outfile)) @@ -84,7 +89,8 @@ class TestRuleOptimizeHandler: ctx = _make_ctx() infile = tmp_path / "test.rule" infile.write_text("l\n") - with patch("builtins.input", side_effect=[str(infile), ""]): + ctx.select_file_with_autocomplete.return_value = "" + with patch("builtins.input", side_effect=[str(infile)]): rule_optimize_handler(ctx) ctx.rules_optimize.assert_not_called() @@ -93,7 +99,8 @@ class TestRuleOptimizeHandler: infile = tmp_path / "test.rule" infile.write_text("l\n") outfile = tmp_path / "optimized.rule" - with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + ctx.select_file_with_autocomplete.return_value = str(outfile) + with patch("builtins.input", side_effect=[str(infile)]): rule_optimize_handler(ctx) assert "[+] Done." in capsys.readouterr().out @@ -103,7 +110,8 @@ class TestRuleOptimizeHandler: infile = tmp_path / "test.rule" infile.write_text("l\n") outfile = tmp_path / "optimized.rule" - with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + ctx.select_file_with_autocomplete.return_value = str(outfile) + with patch("builtins.input", side_effect=[str(infile)]): rule_optimize_handler(ctx) assert "[!] Optimize failed." in capsys.readouterr().out @@ -114,7 +122,8 @@ class TestRuleCleanupAndOptimize: infile = tmp_path / "test.rule" infile.write_text("l\nu\n") outfile = tmp_path / "final.rule" - with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + ctx.select_file_with_autocomplete.return_value = str(outfile) + with patch("builtins.input", side_effect=[str(infile)]): rule_cleanup_and_optimize_handler(ctx) ctx.rules_cleanup.assert_called_once() ctx.rules_optimize.assert_called_once() @@ -125,7 +134,8 @@ class TestRuleCleanupAndOptimize: infile = tmp_path / "test.rule" infile.write_text("l\n") outfile = tmp_path / "out.rule" - with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + ctx.select_file_with_autocomplete.return_value = str(outfile) + with patch("builtins.input", side_effect=[str(infile)]): rule_cleanup_and_optimize_handler(ctx) ctx.rules_optimize.assert_not_called() @@ -139,7 +149,8 @@ class TestRuleCleanupAndOptimize: ctx = _make_ctx() infile = tmp_path / "test.rule" infile.write_text("l\n") - with patch("builtins.input", side_effect=[str(infile), ""]): + ctx.select_file_with_autocomplete.return_value = "" + with patch("builtins.input", side_effect=[str(infile)]): rule_cleanup_and_optimize_handler(ctx) ctx.rules_cleanup.assert_not_called() @@ -155,7 +166,8 @@ class TestRuleCleanupAndOptimize: infile = tmp_path / "test.rule" infile.write_text("l\n") outfile = tmp_path / "final.rule" - with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + ctx.select_file_with_autocomplete.return_value = str(outfile) + with patch("builtins.input", side_effect=[str(infile)]): rule_cleanup_and_optimize_handler(ctx) assert captured_tmp, "rules_cleanup should have been called" assert not os.path.exists(captured_tmp[0]), "temp file should be cleaned up" @@ -172,7 +184,8 @@ class TestRuleCleanupAndOptimize: infile = tmp_path / "test.rule" infile.write_text("l\n") outfile = tmp_path / "out.rule" - with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + ctx.select_file_with_autocomplete.return_value = str(outfile) + with patch("builtins.input", side_effect=[str(infile)]): rule_cleanup_and_optimize_handler(ctx) if captured_tmp: assert not os.path.exists(captured_tmp[0]), "temp file should be cleaned up" @@ -182,7 +195,8 @@ class TestRuleCleanupAndOptimize: infile = tmp_path / "test.rule" infile.write_text("l\n") outfile = tmp_path / "final.rule" - with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + ctx.select_file_with_autocomplete.return_value = str(outfile) + with patch("builtins.input", side_effect=[str(infile)]): rule_cleanup_and_optimize_handler(ctx) assert "[+] Done." in capsys.readouterr().out