mirror of
https://github.com/trustedsec/hate_crack.git
synced 2026-04-28 12:03:11 -07:00
feat: add wordlist tools submenu (len, req-include, req-exclude, cutb, rli, rli2, splitlen, gate) (#90, #91, #92, #94)
Add Wordlist Tools submenu (key 80) with 7 preprocessing utilities backed by hashcat-utils binaries: - Filter by length (len.bin) - #90 - Require/exclude character classes (req-include.bin, req-exclude.bin) - #90 - Extract substring (cutb.bin) - #90 - Remove matching lines (rli.bin, rli2.bin) - #91 - Split by length (splitlen.bin) - #92 - Shard wordlist (gate.bin) - #94 Three-layer pattern: - main.py: low-level wrappers (wordlist_filter_len, wordlist_filter_req_include, wordlist_filter_req_exclude, wordlist_cutb, wordlist_splitlen, wordlist_subtract, wordlist_subtract_single, wordlist_gate) return bool for testability - attacks.py: UI handlers with input validation and the wordlist_tools_submenu dispatcher - hate_crack.py + main.py: menu item "80" wired in both get_main_menu_items and get_main_menu_options Move interactive_menu import to attacks.py module level (was local import in combinator_submenu) to support patching in tests.
This commit is contained in:
17
README.md
17
README.md
@@ -619,6 +619,8 @@ All tests use mocked API calls, so they can run without connectivity to a Hashvi
|
||||
(17) Ad-hoc Mask Attack
|
||||
(18) Markov Brute Force Attack
|
||||
|
||||
(80) Wordlist Tools
|
||||
|
||||
(90) Download rules from Hashmob.net
|
||||
(91) Analyze Hashcat Rules
|
||||
(92) Download wordlists from Hashmob.net
|
||||
@@ -789,6 +791,21 @@ 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
|
||||
|
||||
#### Wordlist Tools (option 80)
|
||||
A submenu of wordlist preprocessing utilities using hashcat-utils binaries. All tools read from and write to files on disk.
|
||||
|
||||
| Key | Tool | Description |
|
||||
|-----|------|-------------|
|
||||
| 1 | Filter by Length | Keep only words between a min and max length (`len.bin`) |
|
||||
| 2 | Require Char Classes | Keep words that include all char classes in mask (`req-include.bin`). Mask: 1=lower, 2=upper, 4=digit, 8=symbol (additive) |
|
||||
| 3 | Exclude Char Classes | Remove words containing any char class in mask (`req-exclude.bin`). Same mask encoding |
|
||||
| 4 | Extract Substring | Cut bytes from each word at a given offset and optional length (`cutb.bin`) |
|
||||
| 5 | Split by Length | Create per-length files in an output directory (`splitlen.bin`) |
|
||||
| 6 | Subtract Wordlist | Remove lines from a wordlist that appear in one or more remove files. Mode 1 uses `rli2.bin` (single file); mode 2 uses `rli.bin` (multiple files) |
|
||||
| 7 | Shard Wordlist | Extract every mod-th line at a given offset to create equal-sized shards (`gate.bin`) |
|
||||
|
||||
All binaries are in `hate_crack/hashcat-utils/bin/`.
|
||||
|
||||
#### Download Rules from Hashmob.net
|
||||
Downloads the latest rule files from Hashmob.net's rule repository. These rules are curated and optimized for password cracking and can be used with the Quick Crack and Loopback Attack modes.
|
||||
|
||||
|
||||
@@ -88,6 +88,7 @@ def get_main_menu_options():
|
||||
"16": _attacks.omen_attack,
|
||||
"17": _attacks.adhoc_mask_crack,
|
||||
"18": _attacks.markov_brute_force,
|
||||
"80": _attacks.wordlist_tools_submenu,
|
||||
"90": download_hashmob_rules,
|
||||
"91": weakpass_wordlist_menu,
|
||||
"92": download_hashmob_wordlists,
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Any
|
||||
|
||||
from hate_crack.api import download_hashmob_rules
|
||||
from hate_crack.formatting import print_multicolumn_list
|
||||
from hate_crack.menu import interactive_menu
|
||||
|
||||
|
||||
def _configure_readline(completer):
|
||||
@@ -621,8 +622,6 @@ def markov_brute_force(ctx: Any) -> None:
|
||||
|
||||
|
||||
def combinator_submenu(ctx: Any) -> None:
|
||||
from hate_crack.menu import interactive_menu
|
||||
|
||||
items = [
|
||||
("1", "Combinator Attack"),
|
||||
("2", "YOLO Combinator Attack"),
|
||||
@@ -642,3 +641,195 @@ def combinator_submenu(ctx: Any) -> None:
|
||||
middle_combinator(ctx)
|
||||
elif choice == "4":
|
||||
thorough_combinator(ctx)
|
||||
|
||||
|
||||
def wordlist_filter_length(ctx: Any) -> None:
|
||||
"""Prompt for paths and lengths, then filter wordlist by word length."""
|
||||
infile = input("\n[*] Enter path to input wordlist: ").strip()
|
||||
if not os.path.isfile(infile):
|
||||
print(f"[!] File not found: {infile}")
|
||||
return
|
||||
outfile = input("[*] Enter path to output wordlist: ").strip()
|
||||
if not outfile:
|
||||
print("[!] Output path cannot be empty.")
|
||||
return
|
||||
min_len = int(input("[*] Minimum length: ").strip() or "0")
|
||||
max_len = int(input("[*] Maximum length: ").strip() or "0")
|
||||
if ctx.wordlist_filter_len(infile, outfile, min_len, max_len):
|
||||
print(f"\n[*] Filtered wordlist written to: {outfile}")
|
||||
else:
|
||||
print("[!] Filter failed.")
|
||||
|
||||
|
||||
def wordlist_filter_charclass_include(ctx: Any) -> None:
|
||||
"""Prompt for paths and mask, then keep only words matching required char classes."""
|
||||
infile = input("\n[*] Enter path to input wordlist: ").strip()
|
||||
if not os.path.isfile(infile):
|
||||
print(f"[!] File not found: {infile}")
|
||||
return
|
||||
outfile = input("[*] Enter path to output wordlist: ").strip()
|
||||
if not outfile:
|
||||
print("[!] Output path cannot be empty.")
|
||||
return
|
||||
print("[*] Char class mask: 1=lowercase, 2=uppercase, 4=digit, 8=symbol (additive, e.g. 3=lower+upper)")
|
||||
mask = int(input("[*] Enter mask value: ").strip() or "0")
|
||||
if ctx.wordlist_filter_req_include(infile, outfile, mask):
|
||||
print(f"\n[*] Filtered wordlist written to: {outfile}")
|
||||
else:
|
||||
print("[!] Filter failed.")
|
||||
|
||||
|
||||
def wordlist_filter_charclass_exclude(ctx: Any) -> None:
|
||||
"""Prompt for paths and mask, then remove words containing excluded char classes."""
|
||||
infile = input("\n[*] Enter path to input wordlist: ").strip()
|
||||
if not os.path.isfile(infile):
|
||||
print(f"[!] File not found: {infile}")
|
||||
return
|
||||
outfile = input("[*] Enter path to output wordlist: ").strip()
|
||||
if not outfile:
|
||||
print("[!] Output path cannot be empty.")
|
||||
return
|
||||
print("[*] Char class mask: 1=lowercase, 2=uppercase, 4=digit, 8=symbol (additive)")
|
||||
mask = int(input("[*] Enter mask value: ").strip() or "0")
|
||||
if ctx.wordlist_filter_req_exclude(infile, outfile, mask):
|
||||
print(f"\n[*] Filtered wordlist written to: {outfile}")
|
||||
else:
|
||||
print("[!] Filter failed.")
|
||||
|
||||
|
||||
def wordlist_cut_substring(ctx: Any) -> None:
|
||||
"""Prompt for paths, offset, and optional length, then extract substring from each word."""
|
||||
infile = input("\n[*] Enter path to input wordlist: ").strip()
|
||||
if not os.path.isfile(infile):
|
||||
print(f"[!] File not found: {infile}")
|
||||
return
|
||||
outfile = input("[*] Enter path to output wordlist: ").strip()
|
||||
if not outfile:
|
||||
print("[!] Output path cannot be empty.")
|
||||
return
|
||||
offset = int(input("[*] Byte offset to start from: ").strip() or "0")
|
||||
raw_length = input("[*] Length (leave blank for rest of line): ").strip()
|
||||
length = int(raw_length) if raw_length else None
|
||||
if ctx.wordlist_cutb(infile, outfile, offset, length):
|
||||
print(f"\n[*] Output written to: {outfile}")
|
||||
else:
|
||||
print("[!] Cut failed.")
|
||||
|
||||
|
||||
def wordlist_split_by_length(ctx: Any) -> None:
|
||||
"""Prompt for input wordlist and output directory, then split by word length."""
|
||||
infile = input("\n[*] Enter path to input wordlist: ").strip()
|
||||
if not os.path.isfile(infile):
|
||||
print(f"[!] File not found: {infile}")
|
||||
return
|
||||
outdir = input("[*] Enter output directory path: ").strip()
|
||||
if not outdir:
|
||||
print("[!] Output directory cannot be empty.")
|
||||
return
|
||||
os.makedirs(outdir, exist_ok=True)
|
||||
if ctx.wordlist_splitlen(infile, outdir):
|
||||
print(f"\n[*] Split wordlists written to: {outdir}")
|
||||
else:
|
||||
print("[!] Split failed.")
|
||||
|
||||
|
||||
def wordlist_subtract_words(ctx: Any) -> None:
|
||||
"""Prompt for mode then remove matching lines from a wordlist."""
|
||||
print("\n[*] Subtract mode:")
|
||||
print(" 1. Single remove file (rli2 - faster for one file)")
|
||||
print(" 2. Multiple remove files (rli)")
|
||||
mode = input("[*] Choose mode (1/2): ").strip()
|
||||
|
||||
if mode == "1":
|
||||
infile = input("[*] Enter path to input wordlist: ").strip()
|
||||
if not os.path.isfile(infile):
|
||||
print(f"[!] File not found: {infile}")
|
||||
return
|
||||
remove_file = input("[*] Enter path to wordlist to subtract: ").strip()
|
||||
if not os.path.isfile(remove_file):
|
||||
print(f"[!] File not found: {remove_file}")
|
||||
return
|
||||
outfile = input("[*] Enter path to output wordlist: ").strip()
|
||||
if not outfile:
|
||||
print("[!] Output path cannot be empty.")
|
||||
return
|
||||
if ctx.wordlist_subtract_single(infile, remove_file, outfile):
|
||||
print(f"\n[*] Result written to: {outfile}")
|
||||
else:
|
||||
print("[!] Subtraction failed.")
|
||||
elif mode == "2":
|
||||
infile = input("[*] Enter path to input wordlist: ").strip()
|
||||
if not os.path.isfile(infile):
|
||||
print(f"[!] File not found: {infile}")
|
||||
return
|
||||
outfile = input("[*] Enter path to output wordlist: ").strip()
|
||||
if not outfile:
|
||||
print("[!] Output path cannot be empty.")
|
||||
return
|
||||
raw = input("[*] Enter remove file paths (comma-separated): ").strip()
|
||||
remove_files = [r.strip() for r in raw.split(",") if r.strip()]
|
||||
if not remove_files:
|
||||
print("[!] No remove files provided.")
|
||||
return
|
||||
if ctx.wordlist_subtract(infile, outfile, *remove_files):
|
||||
print(f"\n[*] Deduplicated wordlist written to: {outfile}")
|
||||
else:
|
||||
print("[!] Subtraction failed.")
|
||||
else:
|
||||
print("[!] Invalid mode.")
|
||||
|
||||
|
||||
def wordlist_shard(ctx: Any) -> None:
|
||||
"""Prompt for input/output paths, modulus, and offset, then shard the wordlist."""
|
||||
infile = input("\n[*] Enter path to input wordlist: ").strip()
|
||||
if not os.path.isfile(infile):
|
||||
print(f"[!] File not found: {infile}")
|
||||
return
|
||||
outfile = input("[*] Enter path to output wordlist: ").strip()
|
||||
if not outfile:
|
||||
print("[!] Output path cannot be empty.")
|
||||
return
|
||||
mod = int(input("[*] Modulus (shard count, e.g. 4 for 4 shards): ").strip() or "0")
|
||||
if mod < 2:
|
||||
print("[!] Modulus must be at least 2.")
|
||||
return
|
||||
offset = int(input("[*] Offset (shard index, 0-based, e.g. 0 for first shard): ").strip() or "0")
|
||||
if offset >= mod:
|
||||
print(f"[!] Offset must be less than modulus ({mod}).")
|
||||
return
|
||||
if ctx.wordlist_gate(infile, outfile, mod, offset):
|
||||
print(f"\n[*] Shard written to: {outfile}")
|
||||
else:
|
||||
print("[!] Shard failed.")
|
||||
|
||||
|
||||
def wordlist_tools_submenu(ctx: Any) -> None:
|
||||
"""Display the Wordlist Tools submenu and dispatch to the selected handler."""
|
||||
items = [
|
||||
("1", "Filter by Length"),
|
||||
("2", "Require Character Classes"),
|
||||
("3", "Exclude Character Classes"),
|
||||
("4", "Extract Substring"),
|
||||
("5", "Split by Length"),
|
||||
("6", "Subtract Wordlist"),
|
||||
("7", "Shard Wordlist"),
|
||||
("99", "Back to Main Menu"),
|
||||
]
|
||||
while True:
|
||||
choice = interactive_menu(items, title="\nWordlist Tools:")
|
||||
if choice is None or choice == "99":
|
||||
break
|
||||
elif choice == "1":
|
||||
wordlist_filter_length(ctx)
|
||||
elif choice == "2":
|
||||
wordlist_filter_charclass_include(ctx)
|
||||
elif choice == "3":
|
||||
wordlist_filter_charclass_exclude(ctx)
|
||||
elif choice == "4":
|
||||
wordlist_cut_substring(ctx)
|
||||
elif choice == "5":
|
||||
wordlist_split_by_length(ctx)
|
||||
elif choice == "6":
|
||||
wordlist_subtract_words(ctx)
|
||||
elif choice == "7":
|
||||
wordlist_shard(ctx)
|
||||
|
||||
@@ -3329,6 +3329,80 @@ def omen_attack():
|
||||
return _attacks.omen_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")
|
||||
with open(infile, "rb") as fin, open(outfile, "wb") as fout:
|
||||
result = subprocess.run(
|
||||
[len_bin, str(min_len), str(max_len)], stdin=fin, stdout=fout
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def wordlist_filter_req_include(infile: str, outfile: str, mask: int) -> bool:
|
||||
"""Filter wordlist keeping only words that include all char classes in mask."""
|
||||
req_bin = os.path.join(hate_path, "hashcat-utils/bin/req-include.bin")
|
||||
with open(infile, "rb") as fin, open(outfile, "wb") as fout:
|
||||
result = subprocess.run([req_bin, str(mask)], stdin=fin, stdout=fout)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def wordlist_filter_req_exclude(infile: str, outfile: str, mask: int) -> bool:
|
||||
"""Filter wordlist removing words that contain any char class in mask."""
|
||||
req_bin = os.path.join(hate_path, "hashcat-utils/bin/req-exclude.bin")
|
||||
with open(infile, "rb") as fin, open(outfile, "wb") as fout:
|
||||
result = subprocess.run([req_bin, str(mask)], stdin=fin, stdout=fout)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def wordlist_cutb(
|
||||
infile: str, outfile: str, offset: int, length: int | None
|
||||
) -> bool:
|
||||
"""Extract a substring from each word starting at offset, optionally limited to length bytes."""
|
||||
cutb_bin = os.path.join(hate_path, "hashcat-utils/bin/cutb.bin")
|
||||
cmd = [cutb_bin, str(offset)]
|
||||
if length is not None:
|
||||
cmd.append(str(length))
|
||||
with open(infile, "rb") as fin, open(outfile, "wb") as fout:
|
||||
result = subprocess.run(cmd, stdin=fin, stdout=fout)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def wordlist_splitlen(infile: str, outdir: str) -> bool:
|
||||
"""Split wordlist into per-length files in outdir."""
|
||||
splitlen_bin = os.path.join(hate_path, "hashcat-utils/bin/splitlen.bin")
|
||||
with open(infile, "rb") as fin:
|
||||
result = subprocess.run([splitlen_bin, outdir], stdin=fin)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def wordlist_subtract(infile: str, outfile: str, *remove_files: str) -> bool:
|
||||
"""Remove lines from infile that appear in any of remove_files, write to outfile."""
|
||||
rli_bin = os.path.join(hate_path, "hashcat-utils/bin/rli.bin")
|
||||
result = subprocess.run([rli_bin, infile, outfile, *remove_files])
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def wordlist_subtract_single(infile: str, remove_file: str, outfile: str) -> bool:
|
||||
"""Subtract remove_file from infile, writing result to stdout captured in outfile."""
|
||||
rli2_bin = os.path.join(hate_path, "hashcat-utils/bin/rli2.bin")
|
||||
with open(outfile, "wb") as fout:
|
||||
result = subprocess.run([rli2_bin, infile, remove_file], stdout=fout)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def wordlist_gate(infile: str, outfile: str, mod: int, offset: int) -> bool:
|
||||
"""Shard wordlist: keep every mod-th line starting at offset."""
|
||||
gate_bin = os.path.join(hate_path, "hashcat-utils/bin/gate.bin")
|
||||
with open(infile, "rb") as fin, open(outfile, "wb") as fout:
|
||||
result = subprocess.run([gate_bin, str(mod), str(offset)], stdin=fin, stdout=fout)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def wordlist_tools_submenu():
|
||||
return _attacks.wordlist_tools_submenu(_attack_ctx())
|
||||
|
||||
|
||||
# convert hex words for recycling
|
||||
def convert_hex(working_file):
|
||||
processed_words = []
|
||||
@@ -3557,6 +3631,7 @@ def get_main_menu_items():
|
||||
("16", "OMEN Attack"),
|
||||
("17", "Ad-hoc Mask Attack"),
|
||||
("18", "Markov Brute Force Attack"),
|
||||
("80", "Wordlist Tools"),
|
||||
("90", "Download rules from Hashmob.net"),
|
||||
("91", "Analyze Hashcat Rules"),
|
||||
("92", "Download wordlists from Hashmob.net"),
|
||||
@@ -3594,6 +3669,7 @@ def get_main_menu_options():
|
||||
"16": omen_attack,
|
||||
"17": adhoc_mask_crack,
|
||||
"18": markov_brute_force,
|
||||
"80": wordlist_tools_submenu,
|
||||
"90": lambda: download_hashmob_rules(rules_dir=rulesDirectory),
|
||||
"91": analyze_rules,
|
||||
"92": download_hashmob_wordlists,
|
||||
|
||||
@@ -26,6 +26,7 @@ MENU_OPTION_TEST_CASES = [
|
||||
("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"),
|
||||
("80", CLI_MODULE._attacks, "wordlist_tools_submenu", "wordlist-tools"),
|
||||
("90", CLI_MODULE, "download_hashmob_rules", "hashmob-rules"),
|
||||
("91", CLI_MODULE, "weakpass_wordlist_menu", "weakpass-menu"),
|
||||
("92", CLI_MODULE, "download_hashmob_wordlists", "hashmob-wordlists"),
|
||||
|
||||
296
tests/test_wordlist_tools.py
Normal file
296
tests/test_wordlist_tools.py
Normal file
@@ -0,0 +1,296 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hate_crack.attacks import (
|
||||
wordlist_cut_substring,
|
||||
wordlist_filter_charclass_exclude,
|
||||
wordlist_filter_charclass_include,
|
||||
wordlist_filter_length,
|
||||
wordlist_shard,
|
||||
wordlist_split_by_length,
|
||||
wordlist_subtract_words,
|
||||
wordlist_tools_submenu,
|
||||
)
|
||||
|
||||
|
||||
def _make_ctx():
|
||||
ctx = MagicMock()
|
||||
ctx.wordlist_filter_len.return_value = True
|
||||
ctx.wordlist_filter_req_include.return_value = True
|
||||
ctx.wordlist_filter_req_exclude.return_value = True
|
||||
ctx.wordlist_cutb.return_value = True
|
||||
ctx.wordlist_splitlen.return_value = True
|
||||
ctx.wordlist_subtract.return_value = True
|
||||
ctx.wordlist_subtract_single.return_value = True
|
||||
ctx.wordlist_gate.return_value = True
|
||||
return ctx
|
||||
|
||||
|
||||
class TestWordlistFilterLength:
|
||||
def test_calls_wordlist_filter_len(self, tmp_path):
|
||||
ctx = _make_ctx()
|
||||
infile = tmp_path / "in.txt"
|
||||
infile.write_text("test\n")
|
||||
outfile = tmp_path / "out.txt"
|
||||
with patch("builtins.input", side_effect=[str(infile), str(outfile), "4", "8"]):
|
||||
wordlist_filter_length(ctx)
|
||||
ctx.wordlist_filter_len.assert_called_once_with(str(infile), str(outfile), 4, 8)
|
||||
|
||||
def test_rejects_nonexistent_infile(self, tmp_path):
|
||||
ctx = _make_ctx()
|
||||
with patch("builtins.input", return_value="/nonexistent/file.txt"):
|
||||
wordlist_filter_length(ctx)
|
||||
ctx.wordlist_filter_len.assert_not_called()
|
||||
|
||||
def test_rejects_empty_outfile(self, tmp_path):
|
||||
ctx = _make_ctx()
|
||||
infile = tmp_path / "in.txt"
|
||||
infile.write_text("test\n")
|
||||
with patch("builtins.input", side_effect=[str(infile), ""]):
|
||||
wordlist_filter_length(ctx)
|
||||
ctx.wordlist_filter_len.assert_not_called()
|
||||
|
||||
def test_prints_success_message(self, tmp_path, capsys):
|
||||
ctx = _make_ctx()
|
||||
infile = tmp_path / "in.txt"
|
||||
infile.write_text("test\n")
|
||||
outfile = tmp_path / "out.txt"
|
||||
with patch("builtins.input", side_effect=[str(infile), str(outfile), "4", "8"]):
|
||||
wordlist_filter_length(ctx)
|
||||
out = capsys.readouterr().out
|
||||
assert "success" in out.lower() or "wrote" in out.lower() or str(outfile) in out
|
||||
|
||||
def test_prints_failure_message(self, tmp_path, capsys):
|
||||
ctx = _make_ctx()
|
||||
ctx.wordlist_filter_len.return_value = False
|
||||
infile = tmp_path / "in.txt"
|
||||
infile.write_text("test\n")
|
||||
outfile = tmp_path / "out.txt"
|
||||
with patch("builtins.input", side_effect=[str(infile), str(outfile), "4", "8"]):
|
||||
wordlist_filter_length(ctx)
|
||||
out = capsys.readouterr().out
|
||||
assert "fail" in out.lower() or "error" in out.lower() or "!" in out
|
||||
|
||||
|
||||
class TestWordlistFilterCharclassInclude:
|
||||
def test_calls_wordlist_filter_req_include(self, tmp_path):
|
||||
ctx = _make_ctx()
|
||||
infile = tmp_path / "in.txt"
|
||||
infile.write_text("test\n")
|
||||
outfile = tmp_path / "out.txt"
|
||||
with patch("builtins.input", side_effect=[str(infile), str(outfile), "7"]):
|
||||
wordlist_filter_charclass_include(ctx)
|
||||
ctx.wordlist_filter_req_include.assert_called_once_with(str(infile), str(outfile), 7)
|
||||
|
||||
def test_rejects_nonexistent_infile(self, tmp_path):
|
||||
ctx = _make_ctx()
|
||||
with patch("builtins.input", return_value="/nonexistent/file.txt"):
|
||||
wordlist_filter_charclass_include(ctx)
|
||||
ctx.wordlist_filter_req_include.assert_not_called()
|
||||
|
||||
|
||||
class TestWordlistFilterCharclassExclude:
|
||||
def test_calls_wordlist_filter_req_exclude(self, tmp_path):
|
||||
ctx = _make_ctx()
|
||||
infile = tmp_path / "in.txt"
|
||||
infile.write_text("test\n")
|
||||
outfile = tmp_path / "out.txt"
|
||||
with patch("builtins.input", side_effect=[str(infile), str(outfile), "8"]):
|
||||
wordlist_filter_charclass_exclude(ctx)
|
||||
ctx.wordlist_filter_req_exclude.assert_called_once_with(str(infile), str(outfile), 8)
|
||||
|
||||
def test_rejects_nonexistent_infile(self, tmp_path):
|
||||
ctx = _make_ctx()
|
||||
with patch("builtins.input", return_value="/nonexistent/file.txt"):
|
||||
wordlist_filter_charclass_exclude(ctx)
|
||||
ctx.wordlist_filter_req_exclude.assert_not_called()
|
||||
|
||||
|
||||
class TestWordlistCutSubstring:
|
||||
def test_calls_wordlist_cutb_with_length(self, tmp_path):
|
||||
ctx = _make_ctx()
|
||||
infile = tmp_path / "in.txt"
|
||||
infile.write_text("test\n")
|
||||
outfile = tmp_path / "out.txt"
|
||||
with patch("builtins.input", side_effect=[str(infile), str(outfile), "2", "4"]):
|
||||
wordlist_cut_substring(ctx)
|
||||
ctx.wordlist_cutb.assert_called_once_with(str(infile), str(outfile), 2, 4)
|
||||
|
||||
def test_calls_wordlist_cutb_without_length(self, tmp_path):
|
||||
ctx = _make_ctx()
|
||||
infile = tmp_path / "in.txt"
|
||||
infile.write_text("test\n")
|
||||
outfile = tmp_path / "out.txt"
|
||||
with patch("builtins.input", side_effect=[str(infile), str(outfile), "2", ""]):
|
||||
wordlist_cut_substring(ctx)
|
||||
ctx.wordlist_cutb.assert_called_once_with(str(infile), str(outfile), 2, None)
|
||||
|
||||
def test_rejects_nonexistent_infile(self, tmp_path):
|
||||
ctx = _make_ctx()
|
||||
with patch("builtins.input", return_value="/nonexistent/file.txt"):
|
||||
wordlist_cut_substring(ctx)
|
||||
ctx.wordlist_cutb.assert_not_called()
|
||||
|
||||
|
||||
class TestWordlistSplitByLength:
|
||||
def test_calls_wordlist_splitlen(self, tmp_path):
|
||||
ctx = _make_ctx()
|
||||
infile = tmp_path / "in.txt"
|
||||
infile.write_text("test\n")
|
||||
outdir = tmp_path / "split"
|
||||
with patch("builtins.input", side_effect=[str(infile), str(outdir)]):
|
||||
wordlist_split_by_length(ctx)
|
||||
ctx.wordlist_splitlen.assert_called_once_with(str(infile), str(outdir))
|
||||
|
||||
def test_creates_outdir_if_missing(self, tmp_path):
|
||||
ctx = _make_ctx()
|
||||
infile = tmp_path / "in.txt"
|
||||
infile.write_text("test\n")
|
||||
outdir = tmp_path / "split" / "nested"
|
||||
with patch("builtins.input", side_effect=[str(infile), str(outdir)]):
|
||||
wordlist_split_by_length(ctx)
|
||||
assert outdir.exists()
|
||||
|
||||
def test_rejects_nonexistent_infile(self, tmp_path):
|
||||
ctx = _make_ctx()
|
||||
with patch("builtins.input", return_value="/nonexistent/file.txt"):
|
||||
wordlist_split_by_length(ctx)
|
||||
ctx.wordlist_splitlen.assert_not_called()
|
||||
|
||||
|
||||
class TestWordlistSubtractWords:
|
||||
def test_single_remove_calls_wordlist_subtract_single(self, tmp_path):
|
||||
ctx = _make_ctx()
|
||||
infile = tmp_path / "in.txt"
|
||||
infile.write_text("word1\n")
|
||||
removefile = tmp_path / "remove.txt"
|
||||
removefile.write_text("word1\n")
|
||||
outfile = tmp_path / "out.txt"
|
||||
# "1" = single remove
|
||||
with patch("builtins.input", side_effect=["1", str(infile), str(removefile), str(outfile)]):
|
||||
wordlist_subtract_words(ctx)
|
||||
ctx.wordlist_subtract_single.assert_called_once_with(str(infile), str(removefile), str(outfile))
|
||||
|
||||
def test_multi_remove_calls_wordlist_subtract(self, tmp_path):
|
||||
ctx = _make_ctx()
|
||||
infile = tmp_path / "in.txt"
|
||||
infile.write_text("word1\nword2\n")
|
||||
removefile1 = tmp_path / "remove1.txt"
|
||||
removefile1.write_text("word1\n")
|
||||
removefile2 = tmp_path / "remove2.txt"
|
||||
removefile2.write_text("word2\n")
|
||||
outfile = tmp_path / "out.txt"
|
||||
# "2" = multiple removes, then two remove files separated by comma or newline
|
||||
with patch(
|
||||
"builtins.input",
|
||||
side_effect=["2", str(infile), str(outfile), f"{removefile1},{removefile2}"],
|
||||
):
|
||||
wordlist_subtract_words(ctx)
|
||||
ctx.wordlist_subtract.assert_called_once_with(
|
||||
str(infile), str(outfile), str(removefile1), str(removefile2)
|
||||
)
|
||||
|
||||
def test_single_remove_rejects_nonexistent_infile(self, tmp_path):
|
||||
ctx = _make_ctx()
|
||||
with patch("builtins.input", side_effect=["1", "/nonexistent.txt"]):
|
||||
wordlist_subtract_words(ctx)
|
||||
ctx.wordlist_subtract_single.assert_not_called()
|
||||
|
||||
|
||||
class TestWordlistShard:
|
||||
def test_calls_wordlist_gate_with_correct_args(self, tmp_path):
|
||||
ctx = _make_ctx()
|
||||
infile = tmp_path / "in.txt"
|
||||
infile.write_text("word1\nword2\nword3\n")
|
||||
outfile = tmp_path / "shard.txt"
|
||||
with patch("builtins.input", side_effect=[str(infile), str(outfile), "3", "0"]):
|
||||
wordlist_shard(ctx)
|
||||
ctx.wordlist_gate.assert_called_once_with(str(infile), str(outfile), 3, 0)
|
||||
|
||||
def test_rejects_offset_gte_mod(self, tmp_path):
|
||||
ctx = _make_ctx()
|
||||
infile = tmp_path / "in.txt"
|
||||
infile.write_text("word1\n")
|
||||
outfile = tmp_path / "shard.txt"
|
||||
with patch("builtins.input", side_effect=[str(infile), str(outfile), "3", "3"]):
|
||||
wordlist_shard(ctx)
|
||||
ctx.wordlist_gate.assert_not_called()
|
||||
|
||||
def test_rejects_nonexistent_infile(self, tmp_path):
|
||||
ctx = _make_ctx()
|
||||
with patch("builtins.input", return_value="/nonexistent/file.txt"):
|
||||
wordlist_shard(ctx)
|
||||
ctx.wordlist_gate.assert_not_called()
|
||||
|
||||
def test_rejects_mod_less_than_2(self, tmp_path):
|
||||
ctx = _make_ctx()
|
||||
infile = tmp_path / "in.txt"
|
||||
infile.write_text("word1\n")
|
||||
outfile = tmp_path / "shard.txt"
|
||||
with patch("builtins.input", side_effect=[str(infile), str(outfile), "1", "0"]):
|
||||
wordlist_shard(ctx)
|
||||
ctx.wordlist_gate.assert_not_called()
|
||||
|
||||
|
||||
class TestWordlistToolsSubmenu:
|
||||
def test_submenu_dispatches_to_filter_length(self):
|
||||
ctx = _make_ctx()
|
||||
with patch("hate_crack.attacks.wordlist_filter_length") as mock_fn, \
|
||||
patch("hate_crack.attacks.interactive_menu", side_effect=["1", "99"]):
|
||||
wordlist_tools_submenu(ctx)
|
||||
mock_fn.assert_called_once_with(ctx)
|
||||
|
||||
def test_submenu_dispatches_to_filter_charclass_include(self):
|
||||
ctx = _make_ctx()
|
||||
with patch("hate_crack.attacks.wordlist_filter_charclass_include") as mock_fn, \
|
||||
patch("hate_crack.attacks.interactive_menu", side_effect=["2", "99"]):
|
||||
wordlist_tools_submenu(ctx)
|
||||
mock_fn.assert_called_once_with(ctx)
|
||||
|
||||
def test_submenu_dispatches_to_filter_charclass_exclude(self):
|
||||
ctx = _make_ctx()
|
||||
with patch("hate_crack.attacks.wordlist_filter_charclass_exclude") as mock_fn, \
|
||||
patch("hate_crack.attacks.interactive_menu", side_effect=["3", "99"]):
|
||||
wordlist_tools_submenu(ctx)
|
||||
mock_fn.assert_called_once_with(ctx)
|
||||
|
||||
def test_submenu_dispatches_to_cut_substring(self):
|
||||
ctx = _make_ctx()
|
||||
with patch("hate_crack.attacks.wordlist_cut_substring") as mock_fn, \
|
||||
patch("hate_crack.attacks.interactive_menu", side_effect=["4", "99"]):
|
||||
wordlist_tools_submenu(ctx)
|
||||
mock_fn.assert_called_once_with(ctx)
|
||||
|
||||
def test_submenu_dispatches_to_split_by_length(self):
|
||||
ctx = _make_ctx()
|
||||
with patch("hate_crack.attacks.wordlist_split_by_length") as mock_fn, \
|
||||
patch("hate_crack.attacks.interactive_menu", side_effect=["5", "99"]):
|
||||
wordlist_tools_submenu(ctx)
|
||||
mock_fn.assert_called_once_with(ctx)
|
||||
|
||||
def test_submenu_dispatches_to_subtract_words(self):
|
||||
ctx = _make_ctx()
|
||||
with patch("hate_crack.attacks.wordlist_subtract_words") as mock_fn, \
|
||||
patch("hate_crack.attacks.interactive_menu", side_effect=["6", "99"]):
|
||||
wordlist_tools_submenu(ctx)
|
||||
mock_fn.assert_called_once_with(ctx)
|
||||
|
||||
def test_submenu_dispatches_to_shard(self):
|
||||
ctx = _make_ctx()
|
||||
with patch("hate_crack.attacks.wordlist_shard") as mock_fn, \
|
||||
patch("hate_crack.attacks.interactive_menu", side_effect=["7", "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"):
|
||||
wordlist_tools_submenu(ctx)
|
||||
|
||||
def test_submenu_exits_on_none(self):
|
||||
ctx = _make_ctx()
|
||||
with patch("hate_crack.attacks.interactive_menu", return_value=None):
|
||||
wordlist_tools_submenu(ctx)
|
||||
177
tests/test_wordlist_wrappers.py
Normal file
177
tests/test_wordlist_wrappers.py
Normal file
@@ -0,0 +1,177 @@
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch, call
|
||||
import importlib.util
|
||||
|
||||
import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
CLI_SPEC = importlib.util.spec_from_file_location(
|
||||
"hate_crack_cli", PROJECT_ROOT / "hate_crack.py"
|
||||
)
|
||||
CLI_MODULE = importlib.util.module_from_spec(CLI_SPEC)
|
||||
CLI_SPEC.loader.exec_module(CLI_MODULE)
|
||||
|
||||
|
||||
def _get_main():
|
||||
import hate_crack.main as m
|
||||
return m
|
||||
|
||||
|
||||
class TestWordlistFilterLenWrapper:
|
||||
def test_calls_len_bin_with_correct_args(self, tmp_path):
|
||||
m = _get_main()
|
||||
infile = tmp_path / "in.txt"
|
||||
infile.write_text("test\n")
|
||||
outfile = tmp_path / "out.txt"
|
||||
with patch("hate_crack.main.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
result = m.wordlist_filter_len(str(infile), str(outfile), 4, 8)
|
||||
assert result is True
|
||||
mock_run.assert_called_once()
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert cmd[1] == "4"
|
||||
assert cmd[2] == "8"
|
||||
assert "len.bin" in cmd[0]
|
||||
|
||||
def test_returns_false_on_nonzero_returncode(self, tmp_path):
|
||||
m = _get_main()
|
||||
infile = tmp_path / "in.txt"
|
||||
infile.write_text("test\n")
|
||||
outfile = tmp_path / "out.txt"
|
||||
with patch("hate_crack.main.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=1)
|
||||
result = m.wordlist_filter_len(str(infile), str(outfile), 4, 8)
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestWordlistFilterReqIncludeWrapper:
|
||||
def test_calls_req_include_bin(self, tmp_path):
|
||||
m = _get_main()
|
||||
infile = tmp_path / "in.txt"
|
||||
infile.write_text("test\n")
|
||||
outfile = tmp_path / "out.txt"
|
||||
with patch("hate_crack.main.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
result = m.wordlist_filter_req_include(str(infile), str(outfile), 7)
|
||||
assert result is True
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "req-include.bin" in cmd[0]
|
||||
assert cmd[1] == "7"
|
||||
|
||||
|
||||
class TestWordlistFilterReqExcludeWrapper:
|
||||
def test_calls_req_exclude_bin(self, tmp_path):
|
||||
m = _get_main()
|
||||
infile = tmp_path / "in.txt"
|
||||
infile.write_text("test\n")
|
||||
outfile = tmp_path / "out.txt"
|
||||
with patch("hate_crack.main.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
result = m.wordlist_filter_req_exclude(str(infile), str(outfile), 8)
|
||||
assert result is True
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "req-exclude.bin" in cmd[0]
|
||||
assert cmd[1] == "8"
|
||||
|
||||
|
||||
class TestWordlistCutbWrapper:
|
||||
def test_calls_cutb_bin_with_offset_and_length(self, tmp_path):
|
||||
m = _get_main()
|
||||
infile = tmp_path / "in.txt"
|
||||
infile.write_text("test\n")
|
||||
outfile = tmp_path / "out.txt"
|
||||
with patch("hate_crack.main.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
result = m.wordlist_cutb(str(infile), str(outfile), 2, 4)
|
||||
assert result is True
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "cutb.bin" in cmd[0]
|
||||
assert cmd[1] == "2"
|
||||
assert cmd[2] == "4"
|
||||
|
||||
def test_calls_cutb_bin_without_length(self, tmp_path):
|
||||
m = _get_main()
|
||||
infile = tmp_path / "in.txt"
|
||||
infile.write_text("test\n")
|
||||
outfile = tmp_path / "out.txt"
|
||||
with patch("hate_crack.main.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
result = m.wordlist_cutb(str(infile), str(outfile), 2, None)
|
||||
assert result is True
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "cutb.bin" in cmd[0]
|
||||
assert cmd[1] == "2"
|
||||
assert len(cmd) == 2 # only binary + offset, no length arg
|
||||
|
||||
|
||||
class TestWordlistSplitlenWrapper:
|
||||
def test_calls_splitlen_bin(self, tmp_path):
|
||||
m = _get_main()
|
||||
infile = tmp_path / "in.txt"
|
||||
infile.write_text("test\n")
|
||||
outdir = tmp_path / "split"
|
||||
with patch("hate_crack.main.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
result = m.wordlist_splitlen(str(infile), str(outdir))
|
||||
assert result is True
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "splitlen.bin" in cmd[0]
|
||||
assert cmd[1] == str(outdir)
|
||||
|
||||
|
||||
class TestWordlistSubtractWrapper:
|
||||
def test_calls_rli_bin_with_multiple_files(self, tmp_path):
|
||||
m = _get_main()
|
||||
infile = tmp_path / "in.txt"
|
||||
infile.write_text("word1\n")
|
||||
outfile = tmp_path / "out.txt"
|
||||
remove1 = tmp_path / "r1.txt"
|
||||
remove1.write_text("word1\n")
|
||||
remove2 = tmp_path / "r2.txt"
|
||||
remove2.write_text("word2\n")
|
||||
with patch("hate_crack.main.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
result = m.wordlist_subtract(str(infile), str(outfile), str(remove1), str(remove2))
|
||||
assert result is True
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "rli.bin" in cmd[0]
|
||||
assert cmd[1] == str(infile)
|
||||
assert cmd[2] == str(outfile)
|
||||
assert str(remove1) in cmd
|
||||
assert str(remove2) in cmd
|
||||
|
||||
|
||||
class TestWordlistSubtractSingleWrapper:
|
||||
def test_calls_rli2_bin(self, tmp_path):
|
||||
m = _get_main()
|
||||
infile = tmp_path / "in.txt"
|
||||
infile.write_text("word1\n")
|
||||
removefile = tmp_path / "remove.txt"
|
||||
removefile.write_text("word1\n")
|
||||
outfile = tmp_path / "out.txt"
|
||||
with patch("hate_crack.main.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
result = m.wordlist_subtract_single(str(infile), str(removefile), str(outfile))
|
||||
assert result is True
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "rli2.bin" in cmd[0]
|
||||
assert cmd[1] == str(infile)
|
||||
assert cmd[2] == str(removefile)
|
||||
|
||||
|
||||
class TestWordlistGateWrapper:
|
||||
def test_calls_gate_bin(self, tmp_path):
|
||||
m = _get_main()
|
||||
infile = tmp_path / "in.txt"
|
||||
infile.write_text("word1\nword2\nword3\n")
|
||||
outfile = tmp_path / "shard.txt"
|
||||
with patch("hate_crack.main.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
result = m.wordlist_gate(str(infile), str(outfile), 3, 0)
|
||||
assert result is True
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "gate.bin" in cmd[0]
|
||||
assert cmd[1] == "3"
|
||||
assert cmd[2] == "0"
|
||||
Reference in New Issue
Block a user