diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c08a608..d80ee89 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -25,13 +25,19 @@ jobs: - name: Install project dependencies run: | uv venv .venv - uv pip install --python .venv/bin/python pytest + uv pip install --python .venv/bin/python pytest pytest-cov ruff mypy uv pip install --python .venv/bin/python . + - name: Run ruff + run: .venv/bin/ruff check hate_crack + + - name: Run mypy + run: .venv/bin/mypy --ignore-missing-imports hate_crack + - name: Run tests env: HATE_CRACK_RUN_E2E: "0" HATE_CRACK_RUN_DOCKER_TESTS: "0" HATE_CRACK_RUN_LIVE_TESTS: "0" HATE_CRACK_SKIP_INIT: "1" - run: .venv/bin/python -m pytest -v + run: .venv/bin/python -m pytest diff --git a/Makefile b/Makefile index 71b9abb..a5a7d22 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ .DEFAULT_GOAL := submodules -.PHONY: install reinstall clean hashcat-utils submodules test +.PHONY: install reinstall clean hashcat-utils submodules test coverage hashcat-utils: submodules $(MAKE) -C hashcat-utils @@ -59,6 +59,9 @@ clean: test: uv run pytest -v +coverage: + uv run pytest --cov=hate_crack --cov-report=term-missing + uninstall: @echo "Detecting OS and uninstalling dependencies..." diff --git a/README.md b/README.md index 57dd85d..0b745ca 100644 --- a/README.md +++ b/README.md @@ -220,6 +220,8 @@ make test Common options: - `--download-hashview`: Download hashes from Hashview before cracking. +- `--hashview`: Interactive Hashview menu for managing hashes, wordlists, and jobs. +- `--hashview --help`: Show Hashview command-line options. - `--weakpass`: Download wordlists from Weakpass. - `--hashmob`: Download wordlists from Hashmob.net. - `--download-torrent `: Download a specific Weakpass torrent file. @@ -230,6 +232,70 @@ Common options: - `--bandrel-basewords `: Override bandrel basewords file. - `--debug`: Enable debug logging (writes `hate_crack.log` in repo root). +### Hashview Integration + +hate_crack integrates with Hashview for centralized hash management and distributed cracking. + +#### Interactive Menu + +Access the interactive Hashview menu: +```bash +hate_crack.py --hashview +``` + +Menu options: +- **(1) Upload Cracked Hashes** - Upload cracked results from current session to Hashview +- **(2) Upload Wordlist** - Upload a wordlist file to Hashview +- **(3) Download Wordlist** - Download a wordlist from Hashview +- **(4) Download Left Hashes** - Download remaining uncracked hashes (automatically merges with found hashes if available) +- **(5) Upload Hashfile and Create Job** - Upload new hashfile and create a cracking job +- **(99) Back to Main Menu** - Return to main menu + +#### Command-Line Interface + +Hashview operations can also be performed via command-line: + +Upload cracked hashes: +```bash +hate_crack.py --hashview upload-cracked --file .out --hash-type 1000 +``` + +Upload a wordlist: +```bash +hate_crack.py --hashview upload-wordlist --file .txt --name "My Wordlist" +``` + +Download left hashes (automatically merges with found hashes): +```bash +hate_crack.py --hashview download-left --customer-id 1 --hashfile-id 123 +``` + +Upload hashfile and create job: +```bash +hate_crack.py --hashview upload-hashfile-job --file hashes.txt --customer-id 1 \ + --hash-type 1000 --job-name "NTLM Crack Job" --hashfile-name "Domain Hashes" +``` + +#### Configuration + +Set Hashview credentials in `config.json`: +```json +{ + "hashview_url": "https://hashview.example.com", + "hashview_api_key": "your-api-key-here" +} +``` + +#### Automatic Found Hash Merging + +When downloading left hashes, hate_crack automatically: +1. Attempts to download any found (cracked) hashes from Hashview +2. Merges found hashes with local `.out` files (e.g., `left_1_123.txt.out` or `left_1_123.nt.txt.out` for pwdump format) +3. Removes duplicate entries +4. Deletes the temporary found file after merging + +This ensures your local cracking results stay synchronized with Hashview's centralized database. + The is attained by running `hashcat --help` Example Hashes: http://hashcat.net/wiki/doku.php?id=example_hashes diff --git a/TESTING.md b/TESTING.md index 590cd34..822ae99 100644 --- a/TESTING.md +++ b/TESTING.md @@ -136,6 +136,12 @@ uv run pytest -v # Or via Makefile make test +# Run tests with coverage +uv run pytest --cov=hate_crack --cov-report=term-missing + +# Or via Makefile +make coverage + # Run specific test uv run pytest tests/test_hashview.py -v diff --git a/hate_crack.py b/hate_crack.py index c9cd167..bbd3a42 100755 --- a/hate_crack.py +++ b/hate_crack.py @@ -5,7 +5,14 @@ from hate_crack import main as _main # Re-export symbols for tests and legacy imports. for _name, _value in _main.__dict__.items(): - if _name.startswith("__") and _name not in {"__all__", "__doc__", "__name__", "__package__", "__loader__", "__spec__"}: + if _name.startswith("__") and _name not in { + "__all__", + "__doc__", + "__name__", + "__package__", + "__loader__", + "__spec__", + }: continue globals().setdefault(_name, _value) diff --git a/hate_crack/__init__.py b/hate_crack/__init__.py index e0f187f..cb37f13 100644 --- a/hate_crack/__init__.py +++ b/hate_crack/__init__.py @@ -1 +1 @@ -# hate_crack package \ No newline at end of file +# hate_crack package diff --git a/hate_crack/api.py b/hate_crack/api.py index 6c748ad..3ba08f1 100644 --- a/hate_crack/api.py +++ b/hate_crack/api.py @@ -11,6 +11,7 @@ import requests from bs4 import BeautifulSoup from hate_crack.formatting import print_multicolumn_list + _TORRENT_CLEANUP_REGISTERED = False @@ -23,7 +24,7 @@ def _candidate_roots(): "/opt/hate_crack", "/usr/local/share/hate_crack", ] - for candidate_name in ['hate_crack', 'hate-crack', '.hate_crack']: + for candidate_name in ["hate_crack", "hate-crack", ".hate_crack"]: candidates.append(os.path.join(home, candidate_name)) return candidates @@ -35,9 +36,11 @@ def _resolve_config_path(): return config_path return None + def check_7z(): import shutil - if shutil.which('7z') or shutil.which('7za'): + + if shutil.which("7z") or shutil.which("7za"): return True print("\n[!] 7z (or 7za) is missing.") print("To install on macOS: brew install p7zip") @@ -45,9 +48,11 @@ def check_7z(): print("Please install 7z and try again.") return False + def check_transmission_cli(): import shutil - if shutil.which('transmission-cli'): + + if shutil.which("transmission-cli"): return True print("\n[!] transmission-cli is missing.") print("To install on macOS: brew install transmission-cli") @@ -62,7 +67,7 @@ def get_hcat_wordlists_dir(): try: with open(config_path) as f: config = json.load(f) - path = config.get('hcatWordlists') + path = config.get("hcatWordlists") if path: path = os.path.expanduser(path) if not os.path.isabs(path): @@ -71,17 +76,18 @@ def get_hcat_wordlists_dir(): return path except Exception: pass - default = os.path.join(os.getcwd(), 'wordlists') + default = os.path.join(os.getcwd(), "wordlists") os.makedirs(default, exist_ok=True) return default + def get_rules_dir(): config_path = _resolve_config_path() if config_path: try: with open(config_path) as f: config = json.load(f) - path = config.get('rules_directory') + path = config.get("rules_directory") if path: path = os.path.expanduser(path) if not os.path.isabs(path): @@ -90,10 +96,11 @@ def get_rules_dir(): return path except Exception: pass - default = os.path.join(os.getcwd(), 'rules') + default = os.path.join(os.getcwd(), "rules") os.makedirs(default, exist_ok=True) return default + def cleanup_torrent_files(directory=None): """Remove stray .torrent files from the wordlists directory on graceful exit.""" if directory is None: @@ -109,14 +116,17 @@ def cleanup_torrent_files(directory=None): except Exception as e: print(f"[!] Failed to cleanup torrent files in {directory}: {e}") + def register_torrent_cleanup(): global _TORRENT_CLEANUP_REGISTERED if _TORRENT_CLEANUP_REGISTERED: return import atexit + atexit.register(cleanup_torrent_files) _TORRENT_CLEANUP_REGISTERED = True + def fetch_all_weakpass_wordlists_multithreaded(total_pages=67, threads=10): wordlists = [] lock = threading.Lock() @@ -141,18 +151,20 @@ def fetch_all_weakpass_wordlists_multithreaded(total_pages=67, threads=10): data_page_val = str(data_page_val) data = json.loads(data_page_val) wordlists_data = data.get("props", {}).get("wordlists", {}) - if isinstance(wordlists_data, dict) and 'data' in wordlists_data: - wordlists_data = wordlists_data['data'] + if isinstance(wordlists_data, dict) and "data" in wordlists_data: + wordlists_data = wordlists_data["data"] with lock: for wl in wordlists_data: - wordlists.append({ - "id": wl.get("id", ""), - "name": wl.get("name", ""), - "size": wl.get("size", ""), - "rank": wl.get("rank", ""), - "downloads": wl.get("downloaded", ""), - "torrent_url": wl.get("torrent_link", "") - }) + wordlists.append( + { + "id": wl.get("id", ""), + "name": wl.get("name", ""), + "size": wl.get("size", ""), + "rank": wl.get("rank", ""), + "downloads": wl.get("downloaded", ""), + "torrent_url": wl.get("torrent_link", ""), + } + ) except Exception as e: print(f"Error fetching page {page}: {e}") q.task_done() @@ -176,12 +188,13 @@ def fetch_all_weakpass_wordlists_multithreaded(total_pages=67, threads=10): seen = set() unique_wordlists = [] for wl in wordlists: - if wl['name'] not in seen: + if wl["name"] not in seen: unique_wordlists.append(wl) - seen.add(wl['name']) + seen.add(wl["name"]) return unique_wordlists + def download_torrent_file(torrent_url, save_dir=None, wordlist_id=None): register_torrent_cleanup() @@ -190,7 +203,9 @@ def download_torrent_file(torrent_url, save_dir=None, wordlist_id=None): 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) + save_dir = os.path.join( + os.path.dirname(os.path.abspath(__file__)), save_dir + ) os.makedirs(save_dir, exist_ok=True) # Optionally include hashmob_api_key in headers if present headers = { @@ -200,12 +215,15 @@ def download_torrent_file(torrent_url, save_dir=None, wordlist_id=None): # Try to get hashmob_api_key from config pkg_dir = os.path.dirname(os.path.abspath(__file__)) project_root = os.path.abspath(os.path.join(pkg_dir, os.pardir)) - for cfg in (os.path.join(pkg_dir, 'config.json'), os.path.join(project_root, 'config.json')): + for cfg in ( + os.path.join(pkg_dir, "config.json"), + os.path.join(project_root, "config.json"), + ): if os.path.isfile(cfg): try: with open(cfg) as f: config = json.load(f) - key = config.get('hashmob_api_key') + key = config.get("hashmob_api_key") if key: hashmob_api_key = key break @@ -226,7 +244,9 @@ def download_torrent_file(torrent_url, save_dir=None, wordlist_id=None): elif wordlist_id: torrent_link = f"https://weakpass.com/download/{wordlist_id}/{torrent_url}" else: - wordlist_base = filename.replace('.torrent', '').replace('.7z', '').replace('.txt', '') + wordlist_base = ( + filename.replace(".torrent", "").replace(".7z", "").replace(".txt", "") + ) wordlist_uri = f"https://weakpass.com/wordlists/{wordlist_base}" print(f"[+] Fetching wordlist page: {wordlist_uri}") r = requests.get(wordlist_uri, headers=headers) @@ -242,31 +262,34 @@ def download_torrent_file(torrent_url, save_dir=None, wordlist_id=None): data_page_val = app_div["data-page"] if not isinstance(data_page_val, str): data_page_val = str(data_page_val) - data_page_val = data_page_val.replace('"', '"') + data_page_val = data_page_val.replace(""", '"') try: data = json.loads(data_page_val) - wordlist = data.get('props', {}).get('wordlist') + wordlist = data.get("props", {}).get("wordlist") resolved_id = None torrent_link_from_data = None if wordlist: - resolved_id = wordlist.get('id') - torrent_link_from_data = wordlist.get('torrent_link') + resolved_id = wordlist.get("id") + torrent_link_from_data = wordlist.get("torrent_link") else: - wordlists = data.get('props', {}).get('wordlists') - if isinstance(wordlists, dict) and 'data' in wordlists: - wordlists = wordlists['data'] + wordlists = data.get("props", {}).get("wordlists") + if isinstance(wordlists, dict) and "data" in wordlists: + wordlists = wordlists["data"] if isinstance(wordlists, list): for wl in wordlists: - if wl.get('torrent_link') == filename or wl.get('name') == filename: - resolved_id = wl.get('id') - torrent_link_from_data = wl.get('torrent_link') + if ( + wl.get("torrent_link") == filename + or wl.get("name") == filename + ): + resolved_id = wl.get("id") + torrent_link_from_data = wl.get("torrent_link") break - if wordlist_base in wl.get('name', ''): - resolved_id = wl.get('id') - torrent_link_from_data = wl.get('torrent_link') + if wordlist_base in wl.get("name", ""): + resolved_id = wl.get("id") + torrent_link_from_data = wl.get("torrent_link") break if torrent_link_from_data and resolved_id: - if not torrent_link_from_data.startswith('http'): + if not torrent_link_from_data.startswith("http"): torrent_link = f"https://weakpass.com/download/{resolved_id}/{torrent_link_from_data}" else: torrent_link = torrent_link_from_data @@ -279,9 +302,11 @@ def download_torrent_file(torrent_url, save_dir=None, wordlist_id=None): print(f"[+] Downloading .torrent file from: {torrent_link}") 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') + local_filename = os.path.join( + save_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: + with open(local_filename, "wb") as f: for chunk in r2.iter_content(chunk_size=8192): if chunk: f.write(chunk) @@ -299,18 +324,24 @@ def download_torrent_file(torrent_url, save_dir=None, wordlist_id=None): if shutil.which("transmission-cli") is None: print("[ERROR] transmission-cli is not installed or not in your PATH.") - print("Please install it with: brew install transmission-cli (on macOS) or your package manager.") - print(f"Torrent file saved at {local_filename}, but download will not start until transmission-cli is available.") + print( + "Please install it with: brew install transmission-cli (on macOS) or your package manager." + ) + print( + f"Torrent file saved at {local_filename}, but download will not start until transmission-cli is available." + ) return local_filename def run_transmission(torrent_file, output_dir): import subprocess - import glob + print(f"Starting transmission-cli for {torrent_file}...") try: pkg_dir = os.path.dirname(os.path.abspath(__file__)) project_root = os.path.abspath(os.path.join(pkg_dir, os.pardir)) - kill_script = os.path.join(project_root, "wordlists", "kill_transmission.sh") + kill_script = os.path.join( + project_root, "wordlists", "kill_transmission.sh" + ) cmd = ["transmission-cli", "-w", output_dir, torrent_file] if os.path.isfile(kill_script): cmd.extend(["-f", kill_script]) @@ -324,10 +355,12 @@ def download_torrent_file(torrent_url, save_dir=None, wordlist_id=None): ) if proc.stdout is not None: for line in proc.stdout: - print(line, end='') + print(line, end="") proc.wait() if proc.returncode != 0: - print(f"transmission-cli failed for {torrent_file} (exit {proc.returncode})") + print( + f"transmission-cli failed for {torrent_file} (exit {proc.returncode})" + ) return else: print(f"Download complete for {torrent_file}") @@ -337,6 +370,7 @@ def download_torrent_file(torrent_url, save_dir=None, wordlist_id=None): run_transmission(local_filename, save_dir) return local_filename + def weakpass_wordlist_menu(rank=-1): try: all_wordlists = fetch_all_weakpass_wordlists_multithreaded() @@ -346,17 +380,21 @@ def weakpass_wordlist_menu(rank=-1): if rank == 0: filtered_wordlists = all_wordlists elif rank > 0: - filtered_wordlists = [wl for wl in all_wordlists if str(wl.get('rank', '')) == str(rank)] + filtered_wordlists = [ + wl for wl in all_wordlists if str(wl.get("rank", "")) == str(rank) + ] else: # Default: show all with rank > 4 - filtered_wordlists = [wl for wl in all_wordlists if str(wl.get('rank', '')) > '4'] + filtered_wordlists = [ + wl for wl in all_wordlists if str(wl.get("rank", "")) > "4" + ] print("\nEach entry shows: [number]. [wordlist name] [effectiveness score] [rank]") entries = [] for idx, wl in enumerate(filtered_wordlists): - effectiveness = wl.get('effectiveness', wl.get('downloads', '')) - rank = wl.get('rank', '') - name = str(wl.get('name', ''))[:30] - entry = f"{idx+1:3d}. {name:<30} {effectiveness:<8} {rank:<2}" + effectiveness = wl.get("effectiveness", wl.get("downloads", "")) + rank = wl.get("rank", "") + name = str(wl.get("name", ""))[:30] + entry = f"{idx + 1:3d}. {name:<30} {effectiveness:<8} {rank:<2}" entries.append(entry) max_entry_len = max((len(e) for e in entries), default=36) print_multicolumn_list( @@ -365,15 +403,16 @@ def weakpass_wordlist_menu(rank=-1): min_col_width=max_entry_len, max_col_width=max_entry_len, ) + def parse_indices(selection, max_index): indices = set() - for part in selection.split(','): + for part in selection.split(","): part = part.strip() if not part: continue - if '-' in part: + if "-" in part: try: - start, end = map(int, part.split('-', 1)) + start, end = map(int, part.split("-", 1)) if start > end: start, end = end, start indices.update(range(start, end + 1)) @@ -398,8 +437,10 @@ def weakpass_wordlist_menu(rank=-1): return "q" try: - sel = _safe_input("\nEnter the number(s) to download (e.g. 1,3,5-7) or 'q' to cancel: ") - if sel.lower() == 'q': + sel = _safe_input( + "\nEnter the number(s) to download (e.g. 1,3,5-7) or 'q' to cancel: " + ) + if sel.lower() == "q": print("Returning to menu...") return indices = parse_indices(sel, len(filtered_wordlists)) @@ -408,27 +449,28 @@ def weakpass_wordlist_menu(rank=-1): return for idx in indices: entry = filtered_wordlists[idx - 1] - torrent_url = entry.get('torrent_url') + torrent_url = entry.get("torrent_url") if not torrent_url: print(f"[!] Missing torrent URL for selection {idx}") continue - download_torrent_file(torrent_url, wordlist_id=entry.get('id')) + download_torrent_file(torrent_url, wordlist_id=entry.get("id")) except KeyboardInterrupt: print("\nKeyboard interrupt: Returning to main menu...") return except Exception as e: print(f"Error: {e}") + # Hashview Integration - Real API implementation matching hate_crack.py class HashviewAPI: def upload_wordlist_file(self, wordlist_path, wordlist_name=None): """Directly upload a wordlist file to Hashview (non-interactive).""" if wordlist_name is None: wordlist_name = os.path.basename(wordlist_path) - with open(wordlist_path, 'rb') as f: + with open(wordlist_path, "rb") as f: file_content = f.read() url = f"{self.base_url}/v1/wordlists/add/{wordlist_name}" - headers = {'Content-Type': 'text/plain'} + headers = {"Content-Type": "text/plain"} resp = self.session.post(url, data=file_content, headers=headers) resp.raise_for_status() return resp.json() @@ -443,27 +485,29 @@ class HashviewAPI: except Exception: raise Exception(f"Invalid API response: {response.text}") # The API may return a list or a dict with a key - if isinstance(data, dict) and 'wordlists' in data: - wordlists = data['wordlists'] + if isinstance(data, dict) and "wordlists" in data: + wordlists = data["wordlists"] # If wordlists is a JSON string, decode it if isinstance(wordlists, str): import json + wordlists = json.loads(wordlists) return wordlists elif isinstance(data, list): return data else: return [] + def __init__(self, base_url, api_key, debug=False): - self.base_url = base_url.rstrip('/') + self.base_url = base_url.rstrip("/") self.api_key = api_key self.debug = debug self.session = requests.Session() - self.session.cookies.set('uuid', api_key) + self.session.cookies.set("uuid", api_key) self.session.verify = False import urllib3 + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) - def get_customer_hashfile_types(self): """ @@ -475,25 +519,25 @@ class HashviewAPI: } """ result = {} - customers = self.list_customers().get('customers', []) + customers = self.list_customers().get("customers", []) for customer in customers: - cid = int(customer.get('id', 0)) + cid = int(customer.get("id", 0)) hashfiles = self.get_customer_hashfiles(cid) hashfile_map = {} for hf in hashfiles: - hfid = hf.get('id') + hfid = hf.get("id") if hfid is None: continue hfid = int(hfid) # Try to get hashtype from hashfile dict, else fetch details - hashtype = hf.get('hash_type') or hf.get('hashtype') + hashtype = hf.get("hash_type") or hf.get("hashtype") if not hashtype: details = self.get_hashfile_details(hfid) - hashtype = details.get('hashtype') or details.get('hash_type') + hashtype = details.get("hashtype") or details.get("hash_type") hashfile_map[hfid] = hashtype result[cid] = hashfile_map return result - + def get_hashfiles_by_type(self, hash_type="1000"): """ Return all hashfiles of a given hash_type using the /v1/hashfiles/hash_type/ endpoint. @@ -526,16 +570,21 @@ class HashviewAPI: data = None hashtype = None if data: - hashtype = data.get('hashtype') or data.get('hash_type') - return {'hashfile_id': hashfile_id, 'hashtype': hashtype, 'details': data, 'raw': resp.content} + hashtype = data.get("hashtype") or data.get("hash_type") + return { + "hashfile_id": hashfile_id, + "hashtype": hashtype, + "details": data, + "raw": resp.content, + } FILE_FORMATS = { - 'pwdump': 0, - 'netntlm': 1, - 'kerberos': 2, - 'shadow': 3, - 'user:hash': 4, - 'hash_only': 5, + "pwdump": 0, + "netntlm": 1, + "kerberos": 2, + "shadow": 3, + "user:hash": 4, + "hash_only": 5, } def list_customers(self): @@ -543,9 +592,9 @@ class HashviewAPI: resp = self.session.get(url) resp.raise_for_status() data = resp.json() - if 'users' in data: - customers = json.loads(data['users']) - return {'customers': customers} + if "users" in data: + customers = json.loads(data["users"]) + return {"customers": customers} return data def list_hashfiles(self): @@ -553,17 +602,19 @@ class HashviewAPI: resp = self.session.get(url) resp.raise_for_status() data = resp.json() - if 'hashfiles' in data: - if isinstance(data['hashfiles'], str): - hashfiles = json.loads(data['hashfiles']) + if "hashfiles" in data: + if isinstance(data["hashfiles"], str): + hashfiles = json.loads(data["hashfiles"]) else: - hashfiles = data['hashfiles'] + hashfiles = data["hashfiles"] return hashfiles return [] def get_customer_hashfiles(self, customer_id): all_hashfiles = self.list_hashfiles() - return [hf for hf in all_hashfiles if int(hf.get('customer_id', 0)) == customer_id] + return [ + hf for hf in all_hashfiles if int(hf.get("customer_id", 0)) == customer_id + ] def get_customer_hashfiles_with_hashtype(self, customer_id, target_hashtype="1000"): """Return hashfiles for a customer that match the requested hashtype.""" @@ -573,69 +624,33 @@ class HashviewAPI: target_str = str(target_hashtype) filtered = [] for hf in customer_hashfiles: - hashtype = hf.get('hashtype') or hf.get('hash_type') + hashtype = hf.get("hashtype") or hf.get("hash_type") if hashtype is None: - hf_id = hf.get('id') + hf_id = hf.get("id") if hf_id is not None: try: details = self.get_hashfile_details(hf_id) - hashtype = details.get('hashtype') + hashtype = details.get("hashtype") except Exception: hashtype = None if hashtype is not None and str(hashtype) == target_str: filtered.append(hf) return filtered - def list_customers_with_hashfiles(self): - """Return customers that have at least one hashfile.""" - customers_result = self.list_customers() - customers = customers_result.get('customers', []) if isinstance(customers_result, dict) else customers_result - if not customers: - return [] - - try: - all_hashfiles = self.list_hashfiles() - except Exception: - all_hashfiles = [] - - hashfiles_by_customer = {} - for hf in all_hashfiles or []: - try: - cust_id = int(hf.get('customer_id', 0)) - except Exception: - continue - if cust_id <= 0: - continue - hashfiles_by_customer.setdefault(cust_id, []).append(hf) - - filtered_customers = [] - for customer in customers: - try: - cust_id = int(customer.get('id', 0)) - except Exception: - continue - if cust_id <= 0: - continue - customer_hashfiles = hashfiles_by_customer.get(cust_id, []) - if not customer_hashfiles: - continue - filtered_customers.append(customer) - return filtered_customers - def display_customers_multicolumn(self, customers): if not customers: print("\nNo customers found.") return try: terminal_width = os.get_terminal_size().columns - except: + except OSError: terminal_width = 120 - max_id_len = max(len(str(c.get('id', ''))) for c in customers) + max_id_len = max(len(str(c.get("id", ""))) for c in customers) col_width = max_id_len + 2 + 30 + 2 num_cols = max(1, terminal_width // col_width) - print("\n" + "="*terminal_width) + print("\n" + "=" * terminal_width) print("Available Customers:") - print("="*terminal_width) + print("=" * terminal_width) num_customers = len(customers) rows = (num_customers + num_cols - 1) // num_cols for row in range(rows): @@ -644,34 +659,38 @@ class HashviewAPI: idx = row + col * rows if idx < num_customers: customer = customers[idx] - cust_id = customer.get('id', 'N/A') - cust_name = customer.get('name', 'N/A') + cust_id = customer.get("id", "N/A") + cust_name = customer.get("name", "N/A") name_width = col_width - max_id_len - 2 - 2 if len(str(cust_name)) > name_width: - cust_name = str(cust_name)[:name_width-3] + "..." + cust_name = str(cust_name)[: name_width - 3] + "..." entry = f"{cust_id}: {cust_name}" line_parts.append(entry.ljust(col_width)) print("".join(line_parts).rstrip()) - print("="*terminal_width) + print("=" * terminal_width) print(f"Total: {len(customers)} customer(s)") - def upload_hashfile(self, file_path, customer_id, hash_type, file_format=5, hashfile_name=None): + def upload_hashfile( + self, file_path, customer_id, hash_type, file_format=5, hashfile_name=None + ): if hashfile_name is None: hashfile_name = os.path.basename(file_path) - with open(file_path, 'rb') as f: + with open(file_path, "rb") as f: file_content = f.read() url = ( f"{self.base_url}/v1/hashfiles/upload/" f"{customer_id}/{file_format}/{hash_type}/{hashfile_name}" ) - headers = {'Content-Type': 'text/plain'} + headers = {"Content-Type": "text/plain"} resp = self.session.post(url, data=file_content, headers=headers) resp.raise_for_status() return resp.json() - def create_job(self, name, hashfile_id, customer_id, limit_recovered=False, notify_email=True): + def create_job( + self, name, hashfile_id, customer_id, limit_recovered=False, notify_email=True + ): url = f"{self.base_url}/v1/jobs/add" - headers = {'Content-Type': 'application/json'} + headers = {"Content-Type": "application/json"} data = { "name": name, "hashfile_id": hashfile_id, @@ -722,118 +741,297 @@ class HashviewAPI: def download_left_hashes(self, customer_id, hashfile_id, output_file=None): import sys + import re + url = f"{self.base_url}/v1/hashfiles/{hashfile_id}/left" resp = self.session.get(url, stream=True) resp.raise_for_status() if output_file is None: output_file = f"left_{customer_id}_{hashfile_id}.txt" - total = int(resp.headers.get('content-length', 0)) + output_file = os.fspath(output_file) + output_abs = os.path.abspath(output_file) + total = int(resp.headers.get("content-length", 0)) downloaded = 0 chunk_size = 8192 - with open(output_file, 'wb') as f: + with open(output_file, "wb") as f: for chunk in resp.iter_content(chunk_size=chunk_size): if chunk: f.write(chunk) downloaded += len(chunk) if total > 0: done = int(50 * downloaded / total) - bar = '[' + '=' * done + ' ' * (50 - done) + ']' + bar = "[" + "=" * done + " " * (50 - done) + "]" percent = 100 * downloaded / total - sys.stdout.write(f"\rDownloading: {bar} {percent:5.1f}% ({downloaded}/{total} bytes)") + sys.stdout.write( + f"\rDownloading: {bar} {percent:5.1f}% ({downloaded}/{total} bytes)" + ) sys.stdout.flush() if total > 0: sys.stdout.write("\n") # If content-length is not provided, just print size at end if total == 0: print(f"Downloaded {downloaded} bytes.") - return {'output_file': output_file, 'size': downloaded} + + # Try to download found file and merge with corresponding .out file if it exists + combined_count = 0 + combined_file = None + out_dir = os.path.dirname(output_abs) or os.getcwd() + found_file = os.path.join(out_dir, f"found_{customer_id}_{hashfile_id}.txt") + + try: + # Try to download the found file + found_url = f"{self.base_url}/v1/hashfiles/{hashfile_id}/found" + found_resp = self.session.get(found_url, stream=True, timeout=30) + + # Only proceed if we successfully downloaded the found file (ignore 404s) + if found_resp.status_code == 404: + # No found file available, that's okay + pass + else: + found_resp.raise_for_status() + + # Write the found file temporarily + with open(found_file, "wb") as f: + for chunk in found_resp.iter_content(chunk_size=8192): + if chunk: + f.write(chunk) + + # Determine the .out file (either .txt.out for regular or .nt.txt.out for pwdump) + out_file = output_abs + ".out" + if not os.path.exists(out_file): + # Check for pwdump format .nt.txt.out file if caller used the .txt name. + if out_file.endswith(".txt.out") and not out_file.endswith(".nt.txt.out"): + out_file = out_file.replace(".txt.out", ".nt.txt.out") + + # Only merge if the .out file exists + if os.path.exists(out_file): + # Read existing hashes from .out file into a set + out_hashes = set() + with open(out_file, "r", encoding="utf-8", errors="ignore") as f: + for line in f: + line = line.strip() + if line: + out_hashes.add(line) + + original_count = len(out_hashes) + + # Read and add hashes from the found file + with open(found_file, "r", encoding="utf-8", errors="ignore") as f: + for line in f: + line = line.strip() + if line: + out_hashes.add(line) + + combined_count = len(out_hashes) - original_count + + # Write combined results back to the .out file + combined_file = out_file + with open(combined_file, "w", encoding="utf-8") as f: + for line in sorted(out_hashes): + f.write(line + "\n") + + print(f"Merged {combined_count} new hashes from {found_file}") + print(f"Total unique hashes in {combined_file}: {len(out_hashes)}") + + # Delete the found file after successful merge + try: + os.remove(found_file) + print(f"Deleted {found_file}") + except Exception as e: + print(f"Warning: Could not delete {found_file}: {e}") + + except Exception as e: + # If there's any error downloading found file, just skip it + print(f"Note: Could not download found hashes: {e}") + # Clean up found file if it was partially written + try: + if os.path.exists(found_file): + os.remove(found_file) + except Exception: + pass + + return { + "output_file": output_file, + "size": downloaded, + "combined_count": combined_count, + "combined_file": combined_file, + } def download_found_hashes(self, customer_id, hashfile_id, output_file=None): import sys + url = f"{self.base_url}/v1/hashfiles/{hashfile_id}/found" resp = self.session.get(url, stream=True) resp.raise_for_status() if output_file is None: output_file = f"found_{customer_id}_{hashfile_id}.txt" - total = int(resp.headers.get('content-length', 0)) + total = int(resp.headers.get("content-length", 0)) downloaded = 0 chunk_size = 8192 - with open(output_file, 'wb') as f: + with open(output_file, "wb") as f: for chunk in resp.iter_content(chunk_size=chunk_size): if chunk: f.write(chunk) downloaded += len(chunk) if total > 0: done = int(50 * downloaded / total) - bar = '[' + '=' * done + ' ' * (50 - done) + ']' + bar = "[" + "=" * done + " " * (50 - done) + "]" percent = 100 * downloaded / total - sys.stdout.write(f"\rDownloading: {bar} {percent:5.1f}% ({downloaded}/{total} bytes)") + sys.stdout.write( + f"\rDownloading: {bar} {percent:5.1f}% ({downloaded}/{total} bytes)" + ) sys.stdout.flush() if total > 0: sys.stdout.write("\n") # If content-length is not provided, just print size at end if total == 0: print(f"Downloaded {downloaded} bytes.") - return {'output_file': output_file, 'size': downloaded} - def upload_cracked_hashes(self, file_path, hash_type='1000'): + # Combine with corresponding left file output if it exists + # Only combine if the output file matches the expected naming pattern + combined_count = 0 + combined_file = None + + # Extract customer_id and hashfile_id from the output filename to ensure proper matching + import re + + output_basename = os.path.basename(output_file) + # Match pattern: found_{customer_id}_{hashfile_id}.txt + match = re.match(r"found_(\d+)_(\d+)\.txt$", output_basename) + + if match: + found_customer_id = match.group(1) + found_hashfile_id = match.group(2) + + # Only proceed if the IDs from filename match the actual download IDs + if ( + str(customer_id) == found_customer_id + and str(hashfile_id) == found_hashfile_id + ): + left_base = f"left_{customer_id}_{hashfile_id}.txt" + + # Check for regular format .out file + left_out = left_base + ".out" + if not os.path.exists(left_out): + # Check for pwdump format .nt.txt.out file + left_out = f"left_{customer_id}_{hashfile_id}.nt.txt.out" + + if os.path.exists(left_out): + # Read existing hashes from .out file into a set + found_hashes = set() + with open(left_out, "r", encoding="utf-8", errors="ignore") as f: + for line in f: + line = line.strip() + if line: + found_hashes.add(line) + + original_count = len(found_hashes) + + # Read and add hashes from the downloaded found file + with open(output_file, "r", encoding="utf-8", errors="ignore") as f: + for line in f: + line = line.strip() + if line: + found_hashes.add(line) + + combined_count = len(found_hashes) - original_count + + # Write combined results to the .out file + combined_file = left_out + with open(combined_file, "w", encoding="utf-8") as f: + for line in sorted(found_hashes): + f.write(line + "\n") + + print(f"Combined {combined_count} new hashes from {output_file}") + print( + f"Total unique hashes in {combined_file}: {len(found_hashes)}" + ) + + # Delete the found file after successful merge + try: + os.remove(output_file) + print(f"Deleted {output_file} after merge") + except Exception as e: + print(f"Warning: Could not delete {output_file}: {e}") + else: + print( + f"Skipping combine: customer_id/hashfile_id mismatch (expected {customer_id}/{hashfile_id}, filename has {found_customer_id}/{found_hashfile_id})" + ) + else: + print( + f"Skipping combine: output filename '{output_basename}' doesn't match expected pattern 'found__.txt'" + ) + + return { + "output_file": output_file, + "size": downloaded, + "combined_count": combined_count, + "combined_file": combined_file, + } + + def upload_cracked_hashes(self, file_path, hash_type="1000"): valid_lines = [] - with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + with open(file_path, "r", encoding="utf-8", errors="ignore") as f: for line in f: line = line.strip() - if '31d6cfe0d16ae931b73c59d7e0c089c0' in line: + if "31d6cfe0d16ae931b73c59d7e0c089c0" in line: continue - if not line or ':' not in line: + if not line or ":" not in line: continue - parts = line.split(':', 1) + parts = line.split(":", 1) if len(parts) != 2: break hash_value = parts[0].strip() plaintext = parts[1].strip() valid_lines.append(f"{hash_value}:{plaintext}") - converted_content = '\n'.join(valid_lines) + converted_content = "\n".join(valid_lines) url = f"{self.base_url}/v1/hashes/import/{hash_type}" - headers = {'Content-Type': 'text/plain'} + headers = {"Content-Type": "text/plain"} resp = self.session.post(url, data=converted_content, headers=headers) resp.raise_for_status() try: json_response = resp.json() - if 'type' in json_response and json_response['type'] == 'Error': - raise Exception(f"Hashview API Error: {json_response.get('msg', 'Unknown error')}") + if "type" in json_response and json_response["type"] == "Error": + raise Exception( + f"Hashview API Error: {json_response.get('msg', 'Unknown error')}" + ) return json_response except (json.JSONDecodeError, ValueError): raise Exception(f"Invalid API response: {resp.text[:200]}") def download_wordlist(self, wordlist_id, output_file=None): import sys + url = f"{self.base_url}/v1/wordlists/{wordlist_id}" resp = self.session.get(url, stream=True) resp.raise_for_status() if output_file is None: output_file = f"wordlist_{wordlist_id}.gz" - total = int(resp.headers.get('content-length', 0)) + total = int(resp.headers.get("content-length", 0)) downloaded = 0 chunk_size = 8192 - with open(output_file, 'wb') as f: + with open(output_file, "wb") as f: for chunk in resp.iter_content(chunk_size=chunk_size): if chunk: f.write(chunk) downloaded += len(chunk) if total > 0: done = int(50 * downloaded / total) - bar = '[' + '=' * done + ' ' * (50 - done) + ']' + bar = "[" + "=" * done + " " * (50 - done) + "]" percent = 100 * downloaded / total - sys.stdout.write(f"\rDownloading: {bar} {percent:5.1f}% ({downloaded}/{total} bytes)") + sys.stdout.write( + f"\rDownloading: {bar} {percent:5.1f}% ({downloaded}/{total} bytes)" + ) sys.stdout.flush() if total > 0: sys.stdout.write("\n") if total == 0: print(f"Downloaded {downloaded} bytes.") - return {'output_file': output_file, 'size': downloaded} + return {"output_file": output_file, "size": downloaded} def create_customer(self, name): url = f"{self.base_url}/v1/customers/add" - headers = {'Content-Type': 'application/json'} + headers = {"Content-Type": "application/json"} data = {"name": name} resp = self.session.post(url, json=data, headers=headers) resp.raise_for_status() @@ -864,12 +1062,14 @@ class HashviewAPI: return data elif isinstance(data, dict): # Try common keys - for key in ('file_ids', 'ids', 'hashfile_ids'): + for key in ("file_ids", "ids", "hashfile_ids"): if key in data and isinstance(data[key], list): return data[key] return [] except Exception: return [] + + def download_hashes_from_hashview( hashview_url: str, hashview_api_key: str, @@ -888,11 +1088,17 @@ def download_hashes_from_hashview( # If stdin status can't be determined, continue normally. pass api_harness = HashviewAPI(hashview_url, hashview_api_key, debug=debug_mode) - customers = api_harness.list_customers_with_hashfiles() + customers_result = api_harness.list_customers() + customers = ( + customers_result.get("customers", []) + if isinstance(customers_result, dict) + else customers_result + ) if customers: api_harness.display_customers_multicolumn(customers) else: - print_fn("\nNo customers found with hashfiles.") + print_fn("\nNo customers found.") + def _safe_input(prompt): try: if not sys.stdin or not sys.stdin.isatty(): @@ -904,10 +1110,30 @@ def download_hashes_from_hashview( except EOFError: return "q" - customer_raw = _safe_input("\nEnter customer ID: ").strip() + # Select or create customer + customer_raw = _safe_input("\nEnter customer ID or N to create new: ").strip() if customer_raw.lower() == "q": raise ValueError("cancelled") - customer_id = int(customer_raw) + + if customer_raw.lower() == "n": + customer_name = _safe_input("Enter customer name: ").strip() + if customer_name.lower() == "q": + raise ValueError("cancelled") + if customer_name: + try: + result = api_harness.create_customer(customer_name) + print_fn(f"\n✓ Success: {result.get('msg', 'Customer created')}") + customer_id = result.get("customer_id") or result.get("id") + if not customer_id: + raise ValueError("Customer ID not returned") + print_fn(f" Customer ID: {customer_id}") + except Exception as e: + print_fn(f"\n✗ Error creating customer: {str(e)}") + raise + else: + raise ValueError("Customer name cannot be empty") + else: + customer_id = int(customer_raw) try: customer_hashfiles = api_harness.get_customer_hashfiles(customer_id) if customer_hashfiles: @@ -917,8 +1143,8 @@ def download_hashes_from_hashview( print_fn(f"{'ID':<10} {'Name':<88}") print_fn("-" * 100) for hf in customer_hashfiles: - hf_id = hf.get('id', 'N/A') - hf_name = hf.get('name', 'N/A') + hf_id = hf.get("id", "N/A") + hf_name = hf.get("name", "N/A") if len(str(hf_name)) > 88: hf_name = str(hf_name)[:85] + "..." print_fn(f"{hf_id:<10} {hf_name:<88}") @@ -926,6 +1152,11 @@ def download_hashes_from_hashview( print_fn(f"Total: {len(customer_hashfiles)} hashfile(s)") else: print_fn(f"\nNo hashfiles found for customer ID {customer_id}") + print_fn("This customer needs to have hashfiles uploaded before downloading left hashes.") + print_fn("Please use the Hashview menu to upload a hashfile first.") + raise ValueError("No hashfiles available for download") + except ValueError: + raise except Exception as exc: print_fn(f"\nWarning: Could not list hashfiles: {exc}") print_fn("You may need to manually find the hashfile ID in the web interface.") @@ -935,10 +1166,12 @@ def download_hashes_from_hashview( hashfile_id = int(hashfile_raw) hcat_hash_type = "1000" output_file = f"left_{customer_id}_{hashfile_id}.txt" - download_result = api_harness.download_left_hashes(customer_id, hashfile_id, output_file) + download_result = api_harness.download_left_hashes( + customer_id, hashfile_id, output_file + ) print_fn(f"\n✓ Success: Downloaded {download_result['size']} bytes") print_fn(f" File: {download_result['output_file']}") - hcat_hash_file = download_result['output_file'] + hcat_hash_file = download_result["output_file"] print_fn("\nNow starting hate_crack with:") print_fn(f" Hash file: {hcat_hash_file}") print_fn(f" Hash type: {hcat_hash_type}") @@ -949,8 +1182,8 @@ def sanitize_filename(filename): """Sanitize a filename by replacing spaces and removing problematic characters.""" import re - filename = filename.replace(' ', '_') - filename = re.sub(r'[^A-Za-z0-9._-]', '', filename) + filename = filename.replace(" ", "_") + filename = re.sub(r"[^A-Za-z0-9._-]", "", filename) return filename @@ -958,12 +1191,15 @@ def get_hashmob_api_key(): """Return hashmob_api_key from config.json in package or project root.""" pkg_dir = os.path.dirname(os.path.abspath(__file__)) project_root = os.path.abspath(os.path.join(pkg_dir, os.pardir)) - for cfg in (os.path.join(pkg_dir, 'config.json'), os.path.join(project_root, 'config.json')): + for cfg in ( + os.path.join(pkg_dir, "config.json"), + os.path.join(project_root, "config.json"), + ): if os.path.isfile(cfg): try: with open(cfg) as f: config = json.load(f) - key = config.get('hashmob_api_key') + key = config.get("hashmob_api_key") if key: return key except Exception: @@ -980,15 +1216,15 @@ def download_hashmob_wordlist_list(): resp = requests.get(url, headers=headers, timeout=30) resp.raise_for_status() data = resp.json() - wordlists = [r for r in data if r.get('type') == 'wordlist'] + wordlists = [r for r in data if r.get("type") == "wordlist"] entries = [] for idx, wl in enumerate(wordlists): - name = wl.get('name', wl.get('file_name', '')) - info = wl.get('information', '') + name = wl.get("name", wl.get("file_name", "")) + info = wl.get("information", "") if info: - entry = f"{idx+1}. {name} - {info}" + entry = f"{idx + 1}. {name} - {info}" else: - entry = f"{idx+1}. {name}" + entry = f"{idx + 1}. {name}" entries.append(entry) max_entry_len = max((len(e) for e in entries), default=30) print_multicolumn_list( @@ -1008,13 +1244,11 @@ def download_hashmob_wordlist(file_name, out_path): url = f"https://hashmob.net/api/v2/downloads/research/wordlists/{file_name}" api_key = get_hashmob_api_key() headers = {"api-key": api_key} if api_key else {} - import time base_backoff = 256 max_backoff = 300 penalty_add = 2 penalty = base_backoff - import threading - lock = getattr(download_hashmob_wordlist, '_rate_lock', None) + lock = getattr(download_hashmob_wordlist, "_rate_lock", None) if lock is None: lock = threading.Lock() download_hashmob_wordlist._rate_lock = lock @@ -1022,51 +1256,61 @@ def download_hashmob_wordlist(file_name, out_path): with lock: time.sleep(15) try: - with requests.get(url, headers=headers, stream=True, timeout=60, allow_redirects=True) as r: + with requests.get( + url, headers=headers, stream=True, timeout=60, allow_redirects=True + ) as r: if r.status_code == 429: - print(f"[!] Rate limit hit (429). Backing off for {penalty} seconds...") + print( + f"[!] Rate limit hit (429). Backing off for {penalty} seconds..." + ) time.sleep(penalty) penalty = min(penalty + penalty_add, max_backoff) penalty_add *= 2 continue if r.status_code in (301, 302, 303, 307, 308): - redirect_url = r.headers.get('Location') + redirect_url = r.headers.get("Location") if redirect_url: print(f"Following redirect to: {redirect_url}") return download_hashmob_wordlist(redirect_url, out_path) print("Redirect with no Location header!") return False r.raise_for_status() - content_type = r.headers.get('Content-Type', '') - if 'text/plain' in content_type: - html = r.content.decode(errors='replace') + content_type = r.headers.get("Content-Type", "") + if "text/plain" in content_type: + html = r.content.decode(errors="replace") import re + match = re.search( r"]+http-equiv=['\"]refresh['\"][^>]+content=['\"]0;url=([^'\"]+)['\"]", html, - re.IGNORECASE + re.IGNORECASE, ) if match: real_url = match.group(1) print(f"Found meta refresh redirect to: {real_url}") with requests.get(real_url, stream=True, timeout=120) as r2: r2.raise_for_status() - with open(out_path, 'wb') as f: + with open(out_path, "wb") as f: for chunk in r2.iter_content(chunk_size=8192): if chunk: f.write(chunk) print(f"Downloaded {out_path}") return True - print("Error: Received HTML instead of file. Possible permission or quota issue.") + print( + "Error: Received HTML instead of file. Possible permission or quota issue." + ) return False - with open(out_path, 'wb') as f: + with open(out_path, "wb") as f: for chunk in r.iter_content(chunk_size=8192): if chunk: f.write(chunk) print(f"Downloaded {out_path}") return True except Exception as e: - if hasattr(e, 'response') and getattr(e.response, 'status_code', None) == 429: + if ( + hasattr(e, "response") + and getattr(e.response, "status_code", None) == 429 + ): print(f"[!] Rate limit hit (429). Backing off for {penalty} seconds...") time.sleep(penalty) penalty = min(penalty + penalty_add, max_backoff) @@ -1085,10 +1329,10 @@ def download_hashmob_rule_list(): resp = requests.get(url, headers=headers, timeout=30) resp.raise_for_status() data = resp.json() - rules = [r for r in data if r.get('type') in ('rule', 'official_rule')] + rules = [r for r in data if r.get("type") in ("rule", "official_rule")] entries = [] for idx, rule in enumerate(rules): - entries.append(f"{idx+1}. {rule.get('name', rule.get('file_name', ''))}") + entries.append(f"{idx + 1}. {rule.get('name', rule.get('file_name', ''))}") max_entry_len = max((len(e) for e in entries), default=30) print_multicolumn_list( "Available Hashmob Rules", @@ -1172,18 +1416,18 @@ def download_hashmob_rule(file_name, out_path): } url = hashmob_rule_urls.get(file_name) if not url: - print(f"[i] Hashmob rule not in pinned URL list, using public prefix: {file_name}") + print( + f"[i] Hashmob rule not in pinned URL list, using public prefix: {file_name}" + ) url = f"https://www.hashmob.net/api/v2/downloads/research/rules/{file_name}" alt_url = f"https://hashmob.net/api/v2/downloads/research/official/hashmob_rules/{file_name}" api_key = get_hashmob_api_key() headers = {"api-key": api_key} if api_key else {} - import time base_backoff = 256 max_backoff = 300 penalty_add = 2 penalty = base_backoff - import threading - lock = getattr(download_hashmob_rule, '_rate_lock', None) + lock = getattr(download_hashmob_rule, "_rate_lock", None) if lock is None: lock = threading.Lock() download_hashmob_rule._rate_lock = lock @@ -1191,31 +1435,45 @@ def download_hashmob_rule(file_name, out_path): with lock: time.sleep(15) try: - with requests.get(url, headers=headers, stream=True, timeout=60, allow_redirects=True) as r: + with requests.get( + url, headers=headers, stream=True, timeout=60, allow_redirects=True + ) as r: if r.status_code == 429: - print(f"[!] Rate limit hit (429). Backing off for {penalty} seconds...") + print( + f"[!] Rate limit hit (429). Backing off for {penalty} seconds..." + ) time.sleep(penalty) penalty = min(penalty + penalty_add, max_backoff) penalty_add *= 2 continue if r.status_code == 404 and alt_url: - print(f"[i] Hashmob rule not found at primary URL, trying fallback: {alt_url}") - with requests.get(alt_url, headers=headers, stream=True, timeout=60, allow_redirects=True) as r_alt: + print( + f"[i] Hashmob rule not found at primary URL, trying fallback: {alt_url}" + ) + with requests.get( + alt_url, + headers=headers, + stream=True, + timeout=60, + allow_redirects=True, + ) as r_alt: if r_alt.status_code == 429: - print(f"[!] Rate limit hit (429). Backing off for {penalty} seconds...") + print( + f"[!] Rate limit hit (429). Backing off for {penalty} seconds..." + ) time.sleep(penalty) penalty = min(penalty + penalty_add, max_backoff) penalty_add *= 2 continue r_alt.raise_for_status() - with open(out_path, 'wb') as f: + with open(out_path, "wb") as f: for chunk in r_alt.iter_content(chunk_size=8192): if chunk: f.write(chunk) print(f"Downloaded {out_path}") return True r.raise_for_status() - with open(out_path, 'wb') as f: + with open(out_path, "wb") as f: for chunk in r.iter_content(chunk_size=8192): if chunk: f.write(chunk) @@ -1223,7 +1481,10 @@ def download_hashmob_rule(file_name, out_path): return True except Exception as e: # If it's a 429 error, handle backoff, else fail - if hasattr(e, 'response') and getattr(e.response, 'status_code', None) == 429: + if ( + hasattr(e, "response") + and getattr(e.response, "status_code", None) == 429 + ): print(f"[!] Rate limit hit (429). Backing off for {penalty} seconds...") time.sleep(penalty) penalty = min(penalty + penalty_add, max_backoff) @@ -1243,7 +1504,7 @@ def list_official_wordlists(): resp.raise_for_status() try: data = resp.json() - entries = [f"{idx+1}. {entry}" for idx, entry in enumerate(data)] + entries = [f"{idx + 1}. {entry}" for idx, entry in enumerate(data)] max_entry_len = max((len(e) for e in entries), default=30) print_multicolumn_list( "Official Hashmob Wordlists (JSON)", @@ -1274,9 +1535,9 @@ def list_and_download_official_wordlists(): return entries = [] for idx, entry in enumerate(data): - name = entry.get('name', entry.get('file_name', str(entry))) - file_name = entry.get('file_name', name) - entries.append(f"{idx+1}. {name} ({file_name})") + name = entry.get("name", entry.get("file_name", str(entry))) + file_name = entry.get("file_name", name) + entries.append(f"{idx + 1}. {name} ({file_name})") max_entry_len = max((len(e) for e in entries), default=30) print_multicolumn_list( "Official Hashmob Wordlists", @@ -1285,6 +1546,7 @@ def list_and_download_official_wordlists(): max_col_width=max_entry_len, ) print("a. Download ALL files") + def _safe_input(prompt): try: if not sys.stdin or not sys.stdin.isatty(): @@ -1296,31 +1558,34 @@ def list_and_download_official_wordlists(): except EOFError: return "q" - sel = _safe_input("Enter the number(s) to download (e.g. 1,3,5-7), or 'a' for all, or 'q' to quit: ") - if sel.lower() == 'q': + sel = _safe_input( + "Enter the number(s) to download (e.g. 1,3,5-7), or 'a' for all, or 'q' to quit: " + ) + if sel.lower() == "q": return - if sel.lower() == 'a': + if sel.lower() == "a": try: for entry in data: - file_name = entry.get('file_name') + file_name = entry.get("file_name") if not file_name: print("No file_name found for an entry, skipping.") continue - out_path = entry.get('file_name', file_name) + out_path = entry.get("file_name", file_name) download_official_wordlist(file_name, out_path) except KeyboardInterrupt: print("\nKeyboard interrupt: Returning to download menu...") return return + def parse_indices(selection, max_index): indices = set() - for part in selection.split(','): + for part in selection.split(","): part = part.strip() if not part: continue - if '-' in part: + if "-" in part: try: - start, end = map(int, part.split('-', 1)) + start, end = map(int, part.split("-", 1)) if start > end: start, end = end, start indices.update(range(start, end + 1)) @@ -1340,11 +1605,11 @@ def list_and_download_official_wordlists(): return for idx in indices: entry = data[idx - 1] - file_name = entry.get('file_name') + file_name = entry.get("file_name") if not file_name: print("No file_name found for selection, skipping.") continue - out_path = entry.get('file_name', file_name) + out_path = entry.get("file_name", file_name) download_official_wordlist(file_name, out_path) except Exception as e: print(f"Error: {e}") @@ -1358,6 +1623,7 @@ def list_and_download_hashmob_rules(): if not rules: return print("a. Download ALL files") + def _safe_input(prompt): try: if not sys.stdin or not sys.stdin.isatty(): @@ -1369,20 +1635,22 @@ def list_and_download_hashmob_rules(): except EOFError: return "q" - sel = _safe_input("Enter the number(s) to download (e.g. 1,3,5-7), or 'a' for all, or 'q' to quit: ") - if sel.lower() == 'q': + sel = _safe_input( + "Enter the number(s) to download (e.g. 1,3,5-7), or 'a' for all, or 'q' to quit: " + ) + if sel.lower() == "q": return rules_dir = get_rules_dir() def parse_indices(selection, max_index): indices = set() - for part in selection.split(','): + for part in selection.split(","): part = part.strip() if not part: continue - if '-' in part: + if "-" in part: try: - start, end = map(int, part.split('-', 1)) + start, end = map(int, part.split("-", 1)) if start > end: start, end = end, start indices.update(range(start, end + 1)) @@ -1406,9 +1674,9 @@ def list_and_download_hashmob_rules(): sanitized = sanitize_filename(file_name) return sanitized in downloaded_rules - if sel.lower() == 'a': + if sel.lower() == "a": for entry in rules: - file_name = entry.get('file_name') + file_name = entry.get("file_name") if not file_name: print("No file_name found for an entry, skipping.") continue @@ -1425,7 +1693,7 @@ def list_and_download_hashmob_rules(): return for idx in indices: entry = rules[idx - 1] - file_name = entry.get('file_name') + file_name = entry.get("file_name") if not file_name: print("No file_name found for selection, skipping.") continue @@ -1446,17 +1714,21 @@ def download_official_wordlist(file_name, out_path): with requests.get(url, stream=True, timeout=120) as r: r.raise_for_status() try: - total = int(r.headers.get('content-length') or 0) + total = int(r.headers.get("content-length") or 0) except Exception: total = 0 downloaded = 0 chunk_size = 8192 out_path = sanitize_filename(file_name) dest_dir = get_hcat_wordlists_dir() - archive_path = os.path.join(dest_dir, out_path) if not os.path.isabs(out_path) else out_path + archive_path = ( + os.path.join(dest_dir, out_path) + if not os.path.isabs(out_path) + else out_path + ) temp_path = archive_path + ".part" os.makedirs(os.path.dirname(archive_path), exist_ok=True) - with open(temp_path, 'wb') as f: + with open(temp_path, "wb") as f: for chunk in r.iter_content(chunk_size=chunk_size): if chunk: f.write(chunk) @@ -1464,7 +1736,7 @@ def download_official_wordlist(file_name, out_path): if total: done = int(50 * downloaded / total) percent = 100 * downloaded / total - bar = '=' * done + ' ' * (50 - done) + bar = "=" * done + " " * (50 - done) sys.stdout.write( f"\r[{bar}] {percent:6.2f}% ({downloaded // 1024} KB/{total // 1024} KB)" ) @@ -1475,7 +1747,7 @@ def download_official_wordlist(file_name, out_path): sys.stdout.write("\n") os.replace(temp_path, archive_path) print(f"Downloaded {archive_path}") - if archive_path.endswith('.7z'): + if archive_path.endswith(".7z"): extract_with_7z(archive_path) return True except KeyboardInterrupt: @@ -1499,17 +1771,19 @@ def extract_with_7z(archive_path, output_dir=None, remove_archive=True): if output_dir is None: output_dir = os.path.dirname(archive_path) or "." - sevenz_bin = shutil.which('7z') or shutil.which('7za') + sevenz_bin = shutil.which("7z") or shutil.which("7za") if not sevenz_bin: - print("[!] 7z or 7za not found in PATH. Please install p7zip-full or 7-zip to extract archives.") + print( + "[!] 7z or 7za not found in PATH. Please install p7zip-full or 7-zip to extract archives." + ) return False try: print(f"Extracting {archive_path} to {output_dir} ...") result = subprocess.run( - [sevenz_bin, 'e', '-y', archive_path], + [sevenz_bin, "e", "-y", archive_path], capture_output=True, text=True, - cwd=output_dir + cwd=output_dir, ) print(result.stdout) if result.returncode == 0: @@ -1527,6 +1801,7 @@ def extract_with_7z(archive_path, output_dir=None, remove_archive=True): print(f"[!] Error extracting {archive_path}: {e}") return False + def download_hashmob_wordlists(print_fn=print) -> None: """Download official Hashmob wordlists.""" list_and_download_official_wordlists() @@ -1561,12 +1836,18 @@ def download_all_weakpass_torrents( except Exception as exc: print_fn(f"Failed to load local wordlist cache: {exc}") raise - if any('id' not in wl or wl.get('id') in ("", None) for wl in all_wordlists): - print_fn("[i] weakpass_wordlists.json missing wordlist IDs, refreshing cache...") + if any("id" not in wl or wl.get("id") in ("", None) for wl in all_wordlists): + print_fn( + "[i] weakpass_wordlists.json missing wordlist IDs, refreshing cache..." + ) fetch_all_wordlists() with open(cache_path, "r", encoding="utf-8") as f: all_wordlists = json.load(f) - torrents = [(wl.get('torrent_url'), wl.get('id')) for wl in all_wordlists if wl.get('torrent_url')] + torrents = [ + (wl.get("torrent_url"), wl.get("id")) + for wl in all_wordlists + if wl.get("torrent_url") + ] print_fn(f"[i] Downloading {len(torrents)} torrents...") for tfile, wordlist_id in torrents: print_fn(f"[i] Downloading: {tfile}") diff --git a/hate_crack/attacks.py b/hate_crack/attacks.py index 23b3804..9baf43d 100644 --- a/hate_crack/attacks.py +++ b/hate_crack/attacks.py @@ -6,8 +6,9 @@ from typing import Any from hate_crack.api import download_hashmob_rules from hate_crack.formatting import print_multicolumn_list + def _configure_readline(completer): - readline.set_completer_delims(' \t\n;') + readline.set_completer_delims(" \t\n;") try: readline.parse_and_bind("set completion-query-items -1") except Exception: @@ -31,7 +32,9 @@ def quick_crack(ctx: Any) -> None: wordlist_files = sorted( f for f in os.listdir(ctx.hcatWordlists) if f != ".DS_Store" ) - wordlist_entries = [f"{i}. {file}" for i, file in enumerate(wordlist_files, start=1)] + wordlist_entries = [ + 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( "Wordlists", @@ -42,14 +45,19 @@ def quick_crack(ctx: Any) -> None: def path_completer(text, state): if not text: - text = './' + text = "./" text = os.path.expanduser(text) - if text.startswith('/') or text.startswith('./') or text.startswith('../') or text.startswith('~'): - matches = glob.glob(text + '*') + if ( + text.startswith("/") + or text.startswith("./") + or text.startswith("../") + or text.startswith("~") + ): + matches = glob.glob(text + "*") else: - matches = glob.glob('./' + text + '*') - matches = [m[2:] if m.startswith('./') else m for m in matches] - matches = [m + '/' if os.path.isdir(m) else m for m in matches] + matches = glob.glob("./" + text + "*") + matches = [m[2:] if m.startswith("./") else m for m in matches] + matches = [m + "/" if os.path.isdir(m) else m for m in matches] try: return matches[state] except IndexError: @@ -64,10 +72,12 @@ def quick_crack(ctx: Any) -> None: f"Press Enter for default optimized wordlists [{ctx.hcatOptimizedWordlists}]: " ) raw_choice = raw_choice.strip() - if raw_choice == '': + if raw_choice == "": wordlist_choice = ctx.hcatOptimizedWordlists elif raw_choice.isdigit() and 1 <= int(raw_choice) <= len(wordlist_files): - chosen = os.path.join(ctx.hcatWordlists, wordlist_files[int(raw_choice) - 1]) + chosen = os.path.join( + ctx.hcatWordlists, wordlist_files[int(raw_choice) - 1] + ) if os.path.exists(chosen): wordlist_choice = chosen print(wordlist_choice) @@ -75,28 +85,32 @@ def quick_crack(ctx: Any) -> None: wordlist_choice = raw_choice else: wordlist_choice = None - print('Please enter a valid wordlist or wordlist directory.') + print("Please enter a valid wordlist or wordlist directory.") except ValueError: print("Please enter a valid number.") rule_files = sorted( - f for f in os.listdir(ctx.hcatPath + '/rules') if f != ".DS_Store" + f for f in os.listdir(ctx.rulesDirectory) if f != ".DS_Store" ) if not rule_files: - download_rules = input( - "\nNo rules found. Download rules from Hashmob now? (Y/n): " - ).strip().lower() + download_rules = ( + input("\nNo rules found. Download rules from Hashmob now? (Y/n): ") + .strip() + .lower() + ) if download_rules in ("", "y", "yes"): download_hashmob_rules(print_fn=print) - rule_files = sorted(os.listdir(ctx.hcatPath + '/rules')) + rule_files = sorted(os.listdir(ctx.rulesDirectory)) if not rule_files: print("No rules available. Proceeding without rules.") - rule_choice = ['0'] + rule_choice = ["0"] else: 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.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) @@ -109,49 +123,51 @@ def quick_crack(ctx: Any) -> None: example_line = "" if len(rule_files) >= 2: - example_line = ( - f'For example 1+1 will run {rule_files[0]} chained twice and 1,2 would run {rule_files[0]} and then {rule_files[1]} sequentially.\n' - ) + example_line = f"For example 1+1 will run {rule_files[0]} chained twice and 1,2 would run {rule_files[0]} and then {rule_files[1]} sequentially.\n" elif len(rule_files) == 1: - example_line = f'For example 1+1 will run {rule_files[0]} chained twice.\n' + example_line = f"For example 1+1 will run {rule_files[0]} chained twice.\n" while rule_choice is None: raw_choice = input( - 'Enter Comma separated list of rules you would like to run. To run rules chained use the + symbol.\n' - f'{example_line}' - 'Choose wisely: ' + "Enter Comma separated list of rules you would like to run. To run rules chained use the + symbol.\n" + f"{example_line}" + "Choose wisely: " ) - if raw_choice.strip() == '99': + if raw_choice.strip() == "99": return - if raw_choice != '': - rule_choice = raw_choice.split(',') + if raw_choice != "": + rule_choice = raw_choice.split(",") - if '99' in rule_choice: + if "99" in rule_choice: return - if '98' in rule_choice: + if "98" in rule_choice: for rule in rule_files: - selected_hcatRules.append(f"-r {ctx.hcatPath}/rules/{rule}") - elif '0' in rule_choice: - selected_hcatRules = [''] + selected_hcatRules.append(f"-r {ctx.rulesDirectory}/{rule}") + elif "0" in rule_choice: + selected_hcatRules = [""] else: for choice in rule_choice: - if '+' in choice: - combined_choice = '' - choices = choice.split('+') + if "+" in choice: + combined_choice = "" + choices = choice.split("+") for rule in choices: try: - combined_choice = f"{combined_choice} -r {ctx.hcatPath}/rules/{rule_files[int(rule) - 1]}" + combined_choice = f"{combined_choice} -r {ctx.rulesDirectory}/{rule_files[int(rule) - 1]}" except Exception: continue selected_hcatRules.append(combined_choice) else: try: - selected_hcatRules.append(f"-r {ctx.hcatPath}/rules/{rule_files[int(choice) - 1]}") + selected_hcatRules.append( + f"-r {ctx.rulesDirectory}/{rule_files[int(choice) - 1]}" + ) except IndexError: continue for chain in selected_hcatRules: - ctx.hcatQuickDictionary(ctx.hcatHashType, ctx.hcatHashFile, chain, wordlist_choice) + ctx.hcatQuickDictionary( + ctx.hcatHashType, ctx.hcatHashFile, chain, wordlist_choice + ) def extensive_crack(ctx: Any) -> None: @@ -173,13 +189,19 @@ def extensive_crack(ctx: Any) -> None: def brute_force_crack(ctx: Any) -> None: - hcatMinLen = int(input("\nEnter the minimum password length to brute force (1): ") or 1) - hcatMaxLen = int(input("\nEnter the maximum password length to brute force (7): ") or 7) + hcatMinLen = int( + input("\nEnter the minimum password length to brute force (1): ") or 1 + ) + hcatMaxLen = int( + input("\nEnter the maximum password length to brute force (7): ") or 7 + ) ctx.hcatBruteForce(ctx.hcatHashType, ctx.hcatHashFile, hcatMinLen, hcatMaxLen) def top_mask_crack(ctx: Any) -> None: - hcatTargetTime = int(input("\nEnter a target time for completion in hours (4): ") or 4) + hcatTargetTime = int( + input("\nEnter a target time for completion in hours (4): ") or 4 + ) hcatTargetTime = hcatTargetTime * 60 * 60 ctx.hcatTopMask(ctx.hcatHashType, ctx.hcatHashFile, hcatTargetTime) @@ -202,9 +224,11 @@ def hybrid_crack(ctx: Any) -> None: print(" - Mode 7: mask + wordlist (e.g., '123' + 'password')") print("=" * 60) - use_default = input("\nUse default hybrid wordlist from config? (Y/n): ").strip().lower() + use_default = ( + input("\nUse default hybrid wordlist from config? (Y/n): ").strip().lower() + ) - if use_default != 'n': + if use_default != "n": print("\nUsing default wordlist(s) from config:") if isinstance(ctx.hcatHybridlist, list): for wl in ctx.hcatHybridlist: @@ -221,8 +245,7 @@ def hybrid_crack(ctx: Any) -> None: print(" - Press TAB to autocomplete file paths") selection = ctx.select_file_with_autocomplete( - "Enter wordlist file(s) (comma-separated for multiple)", - allow_multiple=True + "Enter wordlist file(s) (comma-separated for multiple)", allow_multiple=True ) if not selection: diff --git a/hate_crack/cli.py b/hate_crack/cli.py index 43271e5..910be72 100644 --- a/hate_crack/cli.py +++ b/hate_crack/cli.py @@ -21,5 +21,7 @@ def setup_logging(logger: logging.Logger, hate_path: str, debug_mode: bool) -> N if not any(isinstance(h, logging.FileHandler) for h in logger.handlers): log_path = os.path.join(hate_path, "hate_crack.log") file_handler = logging.FileHandler(log_path) - file_handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s")) + file_handler.setFormatter( + logging.Formatter("%(asctime)s %(levelname)s %(message)s") + ) logger.addHandler(file_handler) diff --git a/hate_crack/main.py b/hate_crack/main.py index 5c6a32a..1df4b1b 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -1,8 +1,8 @@ # Methodology provided by Martin Bos (pure_hate) - https://www.trustedsec.com/team/martin-bos/ # Original script created by Larry Spohn (spoonman) - https://www.trustedsec.com/team/larry-spohn/ # Python refactoring and general fixing, Justin Bollinger (bandrel) - https://www.trustedsec.com/team/justin-bollinger/ -# Hashview integration by Justin Bollinger (bandrel) and Claude Sonnet 4.5 -# special thanks to hans for all his hard work on hashview and creating APIs for us to use +# Hashview integration by Justin Bollinger (bandrel) and Claude Sonnet 4.5 +# special thanks to hans for all his hard work on hashview and creating APIs for us to use # Load config before anything that needs hashview_url/hashview_api_key @@ -11,11 +11,21 @@ import os import json import shutil import logging +import binascii +import glob +import random +import re +import readline +import subprocess +import shlex +import argparse +from types import SimpleNamespace #!/usr/bin/env python3 try: import requests + REQUESTS_AVAILABLE = True except Exception: requests = None @@ -33,54 +43,35 @@ if os.path.isdir(_pkg_dir): if "__spec__" in globals() and __spec__ is not None: __spec__.submodule_search_locations = __path__ -class HashviewAPI: - def upload_wordlist(self): - """Interactive method to upload a custom wordlist to Hashview.""" - print("\n" + "="*60) - print("WORDLIST UPLOAD") - print("="*60) - print("Select a wordlist file to upload to Hashview.") - print("After upload, you'll need to:") - print(" 1. Create a task in Hashview using this wordlist") - print(" 2. Manually add the task to this job via web interface") - print("\nPress TAB to autocomplete file paths.") - print("="*60) +from hate_crack.api import ( + fetch_all_weakpass_wordlists_multithreaded, + download_torrent_file, + weakpass_wordlist_menu, +) # noqa: E402 +from hate_crack.api import HashviewAPI # noqa: E402 +from hate_crack.api import ( # noqa: E402 + download_all_weakpass_torrents, + download_hashes_from_hashview, + download_hashmob_wordlists, + download_hashmob_rules, + download_weakpass_torrent, + extract_with_7z, +) +from hate_crack.cli import ( # noqa: E402 + resolve_path, + setup_logging, +) +from hate_crack import attacks as _attacks # noqa: E402 - wordlist_path = select_file_with_autocomplete( - "Enter path to wordlist file", - base_dir=hcatWordlists, - ) - - uploaded_wordlist_id = None - wordlist_name = None - if wordlist_path and os.path.isfile(wordlist_path): - # Ask for wordlist name - default_name = os.path.basename(wordlist_path) - wordlist_name = input(f"\nEnter wordlist name (default: {default_name}): ").strip() or default_name - try: - # Upload the wordlist - upload_result = self.upload_wordlist(wordlist_path, wordlist_name) - print(f"\n✓ Success: {upload_result.get('msg', 'Wordlist uploaded')}") - if 'wordlist_id' in upload_result: - uploaded_wordlist_id = upload_result['wordlist_id'] - print(f" Wordlist ID: {uploaded_wordlist_id}") - print(f" Wordlist Name: {wordlist_name}") - except Exception as e: - print(f"\n✗ Error uploading wordlist: {str(e)}") - print("Continuing with job creation...") - else: - print("\n✗ No valid wordlist file selected.") - print("Continuing with job creation...") - return uploaded_wordlist_id, wordlist_name def _has_hate_crack_assets(path): if not path: return False - return ( - os.path.isfile(os.path.join(path, "config.json.example")) - and os.path.isdir(os.path.join(path, "hashcat-utils")) + return os.path.isfile(os.path.join(path, "config.json.example")) and os.path.isdir( + os.path.join(path, "hashcat-utils") ) + def _candidate_roots(): cwd = os.getcwd() home = os.path.expanduser("~") @@ -90,10 +81,11 @@ def _candidate_roots(): "/opt/hate_crack", "/usr/local/share/hate_crack", ] - for candidate_name in ['hate_crack', 'hate-crack', '.hate_crack']: + for candidate_name in ["hate_crack", "hate-crack", ".hate_crack"]: candidates.append(os.path.join(home, candidate_name)) return candidates + def _resolve_config_path(): for candidate in _candidate_roots(): config_path = os.path.join(candidate, "config.json") @@ -101,6 +93,7 @@ def _resolve_config_path(): return config_path return None + def _resolve_config_destination(): for candidate in _candidate_roots(): if _has_hate_crack_assets(candidate): @@ -110,8 +103,8 @@ def _resolve_config_destination(): def _resolve_hate_path(package_path, config_dict=None): # Try to use hcatPath from config.json if it's set and contains assets - if config_dict and config_dict.get('hcatPath'): - assets_path = config_dict.get('hcatPath') + if config_dict and config_dict.get("hcatPath"): + assets_path = config_dict.get("hcatPath") if _has_hate_crack_assets(assets_path): return assets_path @@ -126,10 +119,12 @@ def _resolve_hate_path(package_path, config_dict=None): return package_path # Last resort: return package_path, but this likely means assets are missing - if not config_dict or not config_dict.get('hcatPath'): - print("\nWarning: Could not find hate_crack assets (hashcat-utils, princeprocessor).") + if not config_dict or not config_dict.get("hcatPath"): + print( + "\nWarning: Could not find hate_crack assets (hashcat-utils, princeprocessor)." + ) print("Set 'hcatPath' in config.json to the installation directory:") - print(" \"hcatPath\": \"/path/to/hate_crack\"") + print(' "hcatPath": "/path/to/hate_crack"') print("Or run from the repository directory where these assets are located.\n") return package_path @@ -166,22 +161,24 @@ def _ensure_hashfile_in_cwd(hashfile_path): _initial_package_path = os.path.dirname(os.path.realpath(__file__)) _config_path = _resolve_config_path() if not _config_path: - print('Initializing config.json from config.json.example') - src_config = os.path.abspath(os.path.join(_initial_package_path, 'config.json.example')) + print("Initializing config.json from config.json.example") + src_config = os.path.abspath( + os.path.join(_initial_package_path, "config.json.example") + ) config_dir = _resolve_config_destination() - dst_config = os.path.abspath(os.path.join(config_dir, 'config.json')) + dst_config = os.path.abspath(os.path.join(config_dir, "config.json")) shutil.copy(src_config, dst_config) - print(f'Config source: {src_config}') - print(f'Config destination: {dst_config}') + print(f"Config source: {src_config}") + print(f"Config destination: {dst_config}") _config_path = dst_config with open(_config_path) as config: config_parser = json.load(config) config_dir = os.path.dirname(_config_path) -defaults_path = os.path.join(config_dir, 'config.json.example') +defaults_path = os.path.join(config_dir, "config.json.example") if not os.path.isfile(defaults_path): - defaults_path = os.path.join(_initial_package_path, 'config.json.example') + defaults_path = os.path.join(_initial_package_path, "config.json.example") with open(defaults_path) as defaults: default_config = json.load(defaults) @@ -190,24 +187,32 @@ hate_path = _resolve_hate_path(_initial_package_path, config_parser) # If hate_path differs from initial path, reload config from the new location if hate_path != _initial_package_path: - if os.path.isfile(hate_path + '/config.json'): - with open(hate_path + '/config.json') as config: + if os.path.isfile(hate_path + "/config.json"): + with open(hate_path + "/config.json") as config: config_parser = json.load(config) - if os.path.isfile(hate_path + '/config.json.example'): - with open(hate_path + '/config.json.example') as defaults: + if os.path.isfile(hate_path + "/config.json.example"): + with open(hate_path + "/config.json.example") as defaults: default_config = json.load(defaults) try: - hashview_url = config_parser['hashview_url'] + hashview_url = config_parser["hashview_url"] except KeyError as e: - print('{0} is not defined in config.json using defaults from config.json.example'.format(e)) - hashview_url = default_config.get('hashview_url', 'https://localhost:8443') + print( + "{0} is not defined in config.json using defaults from config.json.example".format( + e + ) + ) + hashview_url = default_config.get("hashview_url", "https://localhost:8443") try: - hashview_api_key = config_parser['hashview_api_key'] + hashview_api_key = config_parser["hashview_api_key"] except KeyError as e: - print('{0} is not defined in config.json using defaults from config.json.example'.format(e)) - hashview_api_key = default_config.get('hashview_api_key', '') + print( + "{0} is not defined in config.json using defaults from config.json.example".format( + e + ) + ) + hashview_api_key = default_config.get("hashview_api_key", "") SKIP_INIT = os.environ.get("HATE_CRACK_SKIP_INIT") == "1" @@ -216,80 +221,65 @@ if not logger.handlers: logger.addHandler(logging.NullHandler()) - - def ensure_binary(binary_path, build_dir=None, name=None): if not os.path.isfile(binary_path) or not os.access(binary_path, os.X_OK): if build_dir: if not os.path.isdir(build_dir): - print(f'Error: Build directory {build_dir} does not exist.') - print(f'Expected to find {name or "binary"} at {binary_path}.') - print('\nThe hate_crack assets (hashcat-utils, princeprocessor) could not be found.') - print('These are part of the hate_crack repository, not hashcat installation.') - print('\nPlease run hate_crack from the repository directory:') - print(' cd /path/to/hate_crack && hate_crack ') - print('\nOr set "hcatPath" in config.json to the hate_crack directory that contains hashcat-utils and princeprocessor:') + print(f"Error: Build directory {build_dir} does not exist.") + print(f"Expected to find {name or 'binary'} at {binary_path}.") + print( + "\nThe hate_crack assets (hashcat-utils, princeprocessor) could not be found." + ) + print( + "These are part of the hate_crack repository, not hashcat installation." + ) + print("\nPlease run hate_crack from the repository directory:") + print(" cd /path/to/hate_crack && hate_crack ") + print( + '\nOr set "hcatPath" in config.json to the hate_crack directory that contains hashcat-utils and princeprocessor:' + ) print(' "hcatPath": "/path/to/hate_crack"') quit(1) - + # Binary missing - need to build - print(f'Error: {name or "binary"} not found at {binary_path}.') - print(f'\nPlease build the utilities by running:') - print(f' cd {build_dir} && make') - print('\nEnsure build tools (gcc, make) are installed on your system.') + print(f"Error: {name or 'binary'} not found at {binary_path}.") + print("\nPlease build the utilities by running:") + print(f" cd {build_dir} && make") + print("\nEnsure build tools (gcc, make) are installed on your system.") quit(1) else: - print(f'Error: {name or binary_path} not found or not executable at {binary_path}.') + print( + f"Error: {name or binary_path} not found or not executable at {binary_path}." + ) quit(1) return binary_path - -import os -import random -import re -import json -import binascii -import shutil -import readline -import glob -import subprocess -import argparse -import shlex - -from hate_crack.api import fetch_all_weakpass_wordlists_multithreaded, download_torrent_file, weakpass_wordlist_menu -from hate_crack.api import HashviewAPI -from hate_crack.api import ( - download_all_weakpass_torrents, - download_hashes_from_hashview, - download_hashmob_wordlists, - download_hashmob_rules, - download_weakpass_torrent, - extract_with_7z, -) -from hate_crack.cli import ( - add_common_args, - resolve_path, - setup_logging, -) - # NOTE: hcatPath is for hashcat binary location, NOT for hate_crack assets # If empty in config, we fall back to hate_path as a convenience # But hashcat-utils and princeprocessor should ALWAYS use hate_path -hcatPath = config_parser.get('hcatPath', '') or hate_path -hcatBin = config_parser['hcatBin'] -hcatTuning = config_parser['hcatTuning'] -hcatWordlists = config_parser['hcatWordlists'] -hcatOptimizedWordlists = config_parser['hcatOptimizedWordlists'] +hcatPath = config_parser.get("hcatPath", "") or hate_path +hcatBin = config_parser["hcatBin"] +hcatTuning = config_parser["hcatTuning"] +hcatWordlists = config_parser["hcatWordlists"] +hcatOptimizedWordlists = config_parser["hcatOptimizedWordlists"] hcatRules = [] try: - rulesDirectory = config_parser['rules_directory'] + rulesDirectory = config_parser["rules_directory"] except KeyError as e: - print('{0} is not defined in config.json using defaults from config.json.example'.format(e)) - rulesDirectory = default_config.get('rules_directory') + print( + "{0} is not defined in config.json using defaults from config.json.example".format( + e + ) + ) + rulesDirectory = default_config.get("rules_directory") if not rulesDirectory: - rulesDirectory = os.path.join(hcatPath, "rules") if hcatPath else os.path.join(hate_path, "rules") + rulesDirectory = ( + os.path.join(hcatPath, "rules") + if hcatPath + else os.path.join(hate_path, "rules") + ) rulesDirectory = os.path.expanduser(rulesDirectory) if not os.path.isabs(rulesDirectory): rulesDirectory = os.path.join(hate_path, rulesDirectory) @@ -310,85 +300,139 @@ if not os.path.isdir(hcatWordlists): if not os.path.isdir(hcatOptimizedWordlists): fallback_optimized = os.path.join(hate_path, "optimized_wordlists") if os.path.isdir(fallback_optimized): - print(f"[!] hcatOptimizedWordlists directory not found: {hcatOptimizedWordlists}") + print( + f"[!] hcatOptimizedWordlists directory not found: {hcatOptimizedWordlists}" + ) print(f"[!] Falling back to {fallback_optimized}") hcatOptimizedWordlists = fallback_optimized try: - maxruntime = config_parser['bandrelmaxruntime'] + maxruntime = config_parser["bandrelmaxruntime"] except KeyError as e: - print('{0} is not defined in config.json using defaults from config.json.example'.format(e)) - maxruntime = default_config['bandrelmaxruntime'] + print( + "{0} is not defined in config.json using defaults from config.json.example".format( + e + ) + ) + maxruntime = default_config["bandrelmaxruntime"] try: - bandrelbasewords = config_parser['bandrel_common_basedwords'] + bandrelbasewords = config_parser["bandrel_common_basedwords"] except KeyError as e: - print('{0} is not defined in config.json using defaults from config.json.example'.format(e)) - bandrelbasewords = default_config['bandrel_common_basedwords'] + print( + "{0} is not defined in config.json using defaults from config.json.example".format( + e + ) + ) + bandrelbasewords = default_config["bandrel_common_basedwords"] try: - - pipal_count = config_parser['pipal_count'] + pipal_count = config_parser["pipal_count"] except KeyError as e: - print('{0} is not defined in config.json using defaults from config.json.example'.format(e)) - pipal_count = default_config['pipal_count'] + print( + "{0} is not defined in config.json using defaults from config.json.example".format( + e + ) + ) + pipal_count = default_config["pipal_count"] try: - pipalPath = config_parser['pipalPath'] + pipalPath = config_parser["pipalPath"] except KeyError as e: - print('{0} is not defined in config.json using defaults from config.json.example'.format(e)) - pipalPath = default_config['pipalPath'] + print( + "{0} is not defined in config.json using defaults from config.json.example".format( + e + ) + ) + pipalPath = default_config["pipalPath"] try: - hcatDictionaryWordlist = config_parser['hcatDictionaryWordlist'] + hcatDictionaryWordlist = config_parser["hcatDictionaryWordlist"] except KeyError as e: - print('{0} is not defined in config.json using defaults from config.json.example'.format(e)) - hcatDictionaryWordlist = default_config['hcatDictionaryWordlist'] + print( + "{0} is not defined in config.json using defaults from config.json.example".format( + e + ) + ) + hcatDictionaryWordlist = default_config["hcatDictionaryWordlist"] try: - hcatHybridlist = config_parser['hcatHybridlist'] + hcatHybridlist = config_parser["hcatHybridlist"] except KeyError as e: - print('{0} is not defined in config.json using defaults from config.json.example'.format(e)) + print( + "{0} is not defined in config.json using defaults from config.json.example".format( + e + ) + ) hcatHybridlist = default_config[e.args[0]] try: - hcatCombinationWordlist = config_parser['hcatCombinationWordlist'] + hcatCombinationWordlist = config_parser["hcatCombinationWordlist"] except KeyError as e: - print('{0} is not defined in config.json using defaults from config.json.example'.format(e)) + print( + "{0} is not defined in config.json using defaults from config.json.example".format( + e + ) + ) hcatCombinationWordlist = default_config[e.args[0]] try: - hcatMiddleCombinatorMasks = config_parser['hcatMiddleCombinatorMasks'] + hcatMiddleCombinatorMasks = config_parser["hcatMiddleCombinatorMasks"] except KeyError as e: - print('{0} is not defined in config.json using defaults from config.json.example'.format(e)) + print( + "{0} is not defined in config.json using defaults from config.json.example".format( + e + ) + ) hcatMiddleCombinatorMasks = default_config[e.args[0]] try: - hcatMiddleBaseList = config_parser['hcatMiddleBaseList'] + hcatMiddleBaseList = config_parser["hcatMiddleBaseList"] except KeyError as e: - print('{0} is not defined in config.json using defaults from config.json.example'.format(e)) + print( + "{0} is not defined in config.json using defaults from config.json.example".format( + e + ) + ) hcatMiddleBaseList = default_config[e.args[0]] try: - hcatThoroughCombinatorMasks = config_parser['hcatThoroughCombinatorMasks'] + hcatThoroughCombinatorMasks = config_parser["hcatThoroughCombinatorMasks"] except KeyError as e: - print('{0} is not defined in config.json using defaults from config.json.example'.format(e)) + print( + "{0} is not defined in config.json using defaults from config.json.example".format( + e + ) + ) hcatThoroughCombinatorMasks = default_config[e.args[0]] try: - hcatThoroughBaseList = config_parser['hcatThoroughBaseList'] + hcatThoroughBaseList = config_parser["hcatThoroughBaseList"] except KeyError as e: - print('{0} is not defined in config.json using defaults from config.json.example'.format(e)) + print( + "{0} is not defined in config.json using defaults from config.json.example".format( + e + ) + ) hcatThoroughBaseList = default_config[e.args[0]] try: - hcatPrinceBaseList = config_parser['hcatPrinceBaseList'] + hcatPrinceBaseList = config_parser["hcatPrinceBaseList"] except KeyError as e: - print('{0} is not defined in config.json using defaults from config.json.example'.format(e)) + print( + "{0} is not defined in config.json using defaults from config.json.example".format( + e + ) + ) hcatPrinceBaseList = default_config[e.args[0]] try: - hcatGoodMeasureBaseList = config_parser['hcatGoodMeasureBaseList'] + hcatGoodMeasureBaseList = config_parser["hcatGoodMeasureBaseList"] except KeyError as e: - print('{0} is not defined in config.json using defaults from config.json.example'.format(e)) + print( + "{0} is not defined in config.json using defaults from config.json.example".format( + e + ) + ) hcatGoodMeasureBaseList = default_config[e.args[0]] hcatExpanderBin = "expander.bin" hcatCombinatorBin = "combinator.bin" hcatPrinceBin = "pp64.bin" + def _resolve_wordlist_path(wordlist, base_dir): if not wordlist: return wordlist @@ -439,6 +483,7 @@ def _resolve_wordlists_dir(): wordlists_dir = os.path.join(hate_path, wordlists_dir) return wordlists_dir + def get_rule_path(rule_name, fallback_dir=None): candidates = [] if rulesDirectory: @@ -450,6 +495,7 @@ def get_rule_path(rule_name, fallback_dir=None): return candidate return candidates[0] if candidates else rule_name + def ensure_toggle_rule(): """Ensure toggles-lm-ntlm.rule exists in the configured rules directory.""" if not rulesDirectory: @@ -471,6 +517,7 @@ def ensure_toggle_rule(): print(f"[!] Failed to create toggles-lm-ntlm.rule: {e}") return target_path + def cleanup_wordlist_artifacts(): wordlists_dir = hcatWordlists or os.path.join(hate_path, "wordlists") if not os.path.isabs(wordlists_dir): @@ -503,13 +550,20 @@ def cleanup_wordlist_artifacts(): except Exception as e: print(f"[!] Failed to remove archive {path}: {e}") + wordlists_dir = _resolve_wordlists_dir() -hcatDictionaryWordlist = _normalize_wordlist_setting(hcatDictionaryWordlist, wordlists_dir) -hcatCombinationWordlist = _normalize_wordlist_setting(hcatCombinationWordlist, wordlists_dir) +hcatDictionaryWordlist = _normalize_wordlist_setting( + hcatDictionaryWordlist, wordlists_dir +) +hcatCombinationWordlist = _normalize_wordlist_setting( + hcatCombinationWordlist, wordlists_dir +) hcatHybridlist = _normalize_wordlist_setting(hcatHybridlist, wordlists_dir) hcatMiddleBaseList = _normalize_wordlist_setting(hcatMiddleBaseList, wordlists_dir) hcatThoroughBaseList = _normalize_wordlist_setting(hcatThoroughBaseList, wordlists_dir) -hcatGoodMeasureBaseList = _normalize_wordlist_setting(hcatGoodMeasureBaseList, wordlists_dir) +hcatGoodMeasureBaseList = _normalize_wordlist_setting( + hcatGoodMeasureBaseList, wordlists_dir +) hcatPrinceBaseList = _normalize_wordlist_setting(hcatPrinceBaseList, wordlists_dir) if not SKIP_INIT: @@ -519,32 +573,40 @@ if not SKIP_INIT: try: if os.path.isabs(hcatBin): if not os.path.isfile(hcatBin): - print(f'Hashcat binary not found at {hcatBin}. Please check configuration and try again.') + print( + f"Hashcat binary not found at {hcatBin}. Please check configuration and try again." + ) quit(1) else: # hcatBin should be in PATH if shutil.which(hcatBin) is None: - print(f'Hashcat binary "{hcatBin}" not found in PATH. Please check configuration and try again.') + print( + f'Hashcat binary "{hcatBin}" not found in PATH. Please check configuration and try again.' + ) quit(1) # Verify hashcat-utils binaries exist and work # Note: hashcat-utils is part of hate_crack repo, not hashcat installation - hashcat_utils_path = hate_path + '/hashcat-utils/bin' + hashcat_utils_path = hate_path + "/hashcat-utils/bin" required_binaries = [ - (hcatExpanderBin, 'expander'), - (hcatCombinatorBin, 'combinator'), + (hcatExpanderBin, "expander"), + (hcatCombinatorBin, "combinator"), ] for binary, name in required_binaries: - binary_path = hashcat_utils_path + '/' + binary - ensure_binary(binary_path, build_dir=os.path.join(hate_path, 'hashcat-utils'), name=name) + binary_path = hashcat_utils_path + "/" + binary + ensure_binary( + binary_path, + build_dir=os.path.join(hate_path, "hashcat-utils"), + name=name, + ) # Test binary execution try: test_result = subprocess.run( - [binary_path], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - timeout=2 + [binary_path], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=2, ) # Binary should show usage and exit with error code (that's expected) # If we get here without exception, the binary is executable @@ -552,28 +614,31 @@ if not SKIP_INIT: # Timeout is fine - means binary is running pass except Exception as e: - print(f'Error: {name} binary at {binary_path} failed to execute: {e}') - print('The binary may be compiled for the wrong architecture.') - print('Try recompiling hashcat-utils for your system.') + print(f"Error: {name} binary at {binary_path} failed to execute: {e}") + print("The binary may be compiled for the wrong architecture.") + print("Try recompiling hashcat-utils for your system.") quit(1) # Verify princeprocessor binary # Note: princeprocessor is part of hate_crack repo, not hashcat installation - prince_path = hate_path + '/princeprocessor/' + hcatPrinceBin + prince_path = hate_path + "/princeprocessor/" + hcatPrinceBin try: - ensure_binary(prince_path, build_dir=os.path.join(hate_path, 'princeprocessor'), name='PRINCE') + ensure_binary( + prince_path, + build_dir=os.path.join(hate_path, "princeprocessor"), + name="PRINCE", + ) except SystemExit: - print('PRINCE attacks will not be available.') + print("PRINCE 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'): - print('Warning: Cannot find hashcat in PATH. Install it to use hate_crack.') + print(f"Module initialization error: {e}") + if not shutil.which("hashcat") and not os.path.exists("/usr/bin/hashcat"): + print("Warning: Cannot find hashcat in PATH. Install it to use hate_crack.") # Allow module to load even if initialization fails pass - hcatHashCount = 0 hcatHashCracked = 0 hcatBruteCount = 0 @@ -591,7 +656,7 @@ debug_mode = False # Sanitize filename for use as hashcat session name def generate_session_id(): """Sanitize the hashfile name for use as a hashcat session name - + Hashcat session names can only contain alphanumeric characters, hyphens, and underscores. This function removes the file extension and replaces problematic characters. """ @@ -600,14 +665,18 @@ def generate_session_id(): # Remove extension name_without_ext = os.path.splitext(filename)[0] # Replace any non-alphanumeric chars (except - and _) with underscore - sanitized = re.sub(r'[^a-zA-Z0-9_-]', '_', name_without_ext) + sanitized = re.sub(r"[^a-zA-Z0-9_-]", "_", name_without_ext) return sanitized # Help def usage(): print("usage: python hate_crack.py ") - print("\nThe is attained by running \"{hcatBin} --help\"\n".format(hcatBin=hcatBin)) + print( + '\nThe is attained by running "{hcatBin} --help"\n'.format( + hcatBin=hcatBin + ) + ) print("Example Hashes: http://hashcat.net/wiki/doku.php?id=example_hashes\n") @@ -625,64 +694,72 @@ def ascii_art(): # File selector with tab autocomplete -def select_file_with_autocomplete(prompt, default=None, allow_multiple=False, base_dir=None): +def select_file_with_autocomplete( + prompt, default=None, allow_multiple=False, base_dir=None +): """ Interactive file selector with tab autocomplete functionality. - + Args: prompt: The prompt to display to the user default: Optional default value if user presses Enter allow_multiple: If True, allows comma-separated file list - + Returns: String path or list of paths (if allow_multiple=True) """ + def path_completer(text, state): """Tab completion function for file paths""" if not text: - text = './' - + text = "./" + # Expand ~ to home directory text = os.path.expanduser(text) - + # Handle both absolute and relative paths - if text.startswith('/') or text.startswith('./') or text.startswith('../') or text.startswith('~'): - matches = glob.glob(text + '*') + if ( + text.startswith("/") + or text.startswith("./") + or text.startswith("../") + or text.startswith("~") + ): + matches = glob.glob(text + "*") else: - matches = glob.glob('./' + text + '*') - matches = [m[2:] if m.startswith('./') else m for m in matches] - + matches = glob.glob("./" + text + "*") + matches = [m[2:] if m.startswith("./") else m for m in matches] + # Add trailing slash for directories - matches = [m + '/' if os.path.isdir(m) else m for m in matches] - + matches = [m + "/" if os.path.isdir(m) else m for m in matches] + try: return matches[state] except IndexError: return None - + # Configure readline for tab completion - readline.set_completer_delims(' \t\n;') + readline.set_completer_delims(" \t\n;") # Disable the "Display all X possibilities?" prompt try: readline.parse_and_bind("set completion-query-items -1") - except: + except Exception: pass try: readline.parse_and_bind("tab: complete") - except: + except Exception: pass try: readline.parse_and_bind("bind ^I rl_complete") - except: + except Exception: pass readline.set_completer(path_completer) - + # Build prompt full_prompt = f"\n{prompt}" if default: full_prompt += f" (default: {default})" full_prompt += ": " - + result = input(full_prompt).strip() if not result and base_dir: result = base_dir @@ -690,12 +767,12 @@ def select_file_with_autocomplete(prompt, default=None, allow_multiple=False, ba # Handle default if not result and default: return default - + # Handle multiple files - if allow_multiple and ',' in result: - files = [f.strip() for f in result.split(',')] + if allow_multiple and "," in result: + files = [f.strip() for f in result.split(",")] return [os.path.expanduser(f) for f in files if f] - + return os.path.expanduser(result) if result else None @@ -707,12 +784,16 @@ def lineCount(file): for line in outFile: count = count + 1 return count - except: + except Exception: return 0 + def _write_delimited_field(input_path, output_path, field_index, delimiter=":"): try: - with open(input_path, "r", errors="replace") as src, open(output_path, "w") as dst: + with ( + open(input_path, "r", errors="replace") as src, + open(output_path, "w") as dst, + ): for line in src: line = line.rstrip("\n") parts = line.split(delimiter, field_index) @@ -725,8 +806,13 @@ def _write_delimited_field(input_path, output_path, field_index, delimiter=":"): def _write_field_sorted_unique(input_path, output_path, field_index, delimiter=":"): try: - with open(input_path, "r", errors="replace") as src, open(output_path, "w") as dst: - sort_proc = subprocess.Popen(["sort", "-u"], stdin=subprocess.PIPE, stdout=dst, text=True) + with ( + open(input_path, "r", errors="replace") as src, + open(output_path, "w") as dst, + ): + sort_proc = subprocess.Popen( + ["sort", "-u"], stdin=subprocess.PIPE, stdout=dst, text=True + ) for line in src: line = line.rstrip("\n") parts = line.split(delimiter, field_index) @@ -754,6 +840,7 @@ def _run_hashcat_show(hash_type, hash_file, output_path): check=False, ) + # Brute Force Attack def hcatBruteForce(hcatHashType, hcatHashFile, hcatMinLen, hcatMaxLen): global hcatBruteCount @@ -780,7 +867,7 @@ def hcatBruteForce(hcatHashType, hcatHashFile, hcatMinLen, hcatMaxLen): try: hcatProcess.wait() except KeyboardInterrupt: - print('Killing PID {0}...'.format(str(hcatProcess.pid))) + print("Killing PID {0}...".format(str(hcatProcess.pid))) hcatProcess.kill() hcatBruteCount = lineCount(hcatHashFile + ".out") @@ -812,10 +899,9 @@ def hcatDictionary(hcatHashType, hcatHashFile): try: hcatProcess.wait() except KeyboardInterrupt: - print('Killing PID {0}...'.format(str(hcatProcess.pid))) + print("Killing PID {0}...".format(str(hcatProcess.pid))) hcatProcess.kill() - for wordlist in hcatDictionaryWordlist: rule_d3ad0ne = get_rule_path("d3ad0ne.rule") cmd = [ @@ -837,7 +923,7 @@ def hcatDictionary(hcatHashType, hcatHashFile): try: hcatProcess.wait() except KeyboardInterrupt: - print('Killing PID {0}...'.format(str(hcatProcess.pid))) + print("Killing PID {0}...".format(str(hcatProcess.pid))) hcatProcess.kill() rule_toxic = get_rule_path("T0XlC.rule") @@ -860,7 +946,7 @@ def hcatDictionary(hcatHashType, hcatHashFile): try: hcatProcess.wait() except KeyboardInterrupt: - print('Killing PID {0}...'.format(str(hcatProcess.pid))) + print("Killing PID {0}...".format(str(hcatProcess.pid))) hcatProcess.kill() hcatDictionaryCount = lineCount(hcatHashFile + ".out") - hcatBruteCount @@ -891,11 +977,10 @@ def hcatQuickDictionary(hcatHashType, hcatHashFile, hcatChains, wordlists): try: hcatProcess.wait() except KeyboardInterrupt: - print('Killing PID {0}...'.format(str(hcatProcess.pid))) + print("Killing PID {0}...".format(str(hcatProcess.pid))) hcatProcess.kill() - # Top Mask Attack def hcatTopMask(hcatHashType, hcatHashFile, hcatTargetTime): global hcatMaskCount @@ -913,7 +998,7 @@ def hcatTopMask(hcatHashType, hcatHashFile, hcatTargetTime): try: hcatProcess.wait() except KeyboardInterrupt: - print('Killing PID {0}...'.format(str(hcatProcess.pid))) + print("Killing PID {0}...".format(str(hcatProcess.pid))) hcatProcess.kill() hcatProcess = subprocess.Popen( @@ -935,7 +1020,7 @@ def hcatTopMask(hcatHashType, hcatHashFile, hcatTargetTime): try: hcatProcess.wait() except KeyboardInterrupt: - print('Killing PID {0}...'.format(str(hcatProcess.pid))) + print("Killing PID {0}...".format(str(hcatProcess.pid))) hcatProcess.kill() cmd = [ @@ -957,7 +1042,7 @@ def hcatTopMask(hcatHashType, hcatHashFile, hcatTargetTime): try: hcatProcess.wait() except KeyboardInterrupt: - print('Killing PID {0}...'.format(str(hcatProcess.pid))) + print("Killing PID {0}...".format(str(hcatProcess.pid))) hcatProcess.kill() hcatMaskCount = lineCount(hcatHashFile + ".out") - hcatHashCracked @@ -973,15 +1058,22 @@ def hcatFingerprint(hcatHashType, hcatHashFile): crackedBefore = lineCount(hcatHashFile + ".out") _write_delimited_field(f"{hcatHashFile}.out", f"{hcatHashFile}.working", 2) expander_path = os.path.join(hate_path, "hashcat-utils", "bin", hcatExpanderBin) - with open(f"{hcatHashFile}.working", "rb") as src, open(f"{hcatHashFile}.expanded", "wb") as dst: - expander_proc = subprocess.Popen([expander_path], stdin=src, stdout=subprocess.PIPE) - hcatProcess = subprocess.Popen(["sort", "-u"], stdin=expander_proc.stdout, stdout=dst) + with ( + open(f"{hcatHashFile}.working", "rb") as src, + open(f"{hcatHashFile}.expanded", "wb") as dst, + ): + expander_proc = subprocess.Popen( + [expander_path], stdin=src, stdout=subprocess.PIPE + ) + hcatProcess = subprocess.Popen( + ["sort", "-u"], stdin=expander_proc.stdout, stdout=dst + ) expander_proc.stdout.close() try: hcatProcess.wait() expander_proc.wait() except KeyboardInterrupt: - print('Killing PID {0}...'.format(str(hcatProcess.pid))) + print("Killing PID {0}...".format(str(hcatProcess.pid))) hcatProcess.kill() expander_proc.kill() hcatProcess = subprocess.Popen( @@ -1005,7 +1097,7 @@ def hcatFingerprint(hcatHashType, hcatHashFile): try: hcatProcess.wait() except KeyboardInterrupt: - print('Killing PID {0}...'.format(str(hcatProcess.pid))) + print("Killing PID {0}...".format(str(hcatProcess.pid))) hcatProcess.kill() crackedAfter = lineCount(hcatHashFile + ".out") hcatFingerprintCount = lineCount(hcatHashFile + ".out") - hcatHashCracked @@ -1035,7 +1127,7 @@ def hcatCombination(hcatHashType, hcatHashFile): try: hcatProcess.wait() except KeyboardInterrupt: - print('Killing PID {0}...'.format(str(hcatProcess.pid))) + print("Killing PID {0}...".format(str(hcatProcess.pid))) hcatProcess.kill() hcatCombinationCount = lineCount(hcatHashFile + ".out") - hcatHashCracked @@ -1045,11 +1137,11 @@ def hcatCombination(hcatHashType, hcatHashFile): def hcatHybrid(hcatHashType, hcatHashFile, wordlists=None): global hcatHybridCount global hcatProcess - + # Use provided wordlists or fall back to config default if wordlists is None: wordlists = hcatHybridlist - + # Ensure wordlists is a list if not isinstance(wordlists, list): wordlists = [wordlists] @@ -1064,7 +1156,7 @@ def hcatHybrid(hcatHashType, hcatHashFile, wordlists=None): if not resolved_wordlists: print("[!] No valid wordlists found. Aborting hybrid attack.") return - + for wordlist in resolved_wordlists: variants = [ ["-a", "6", "-1", "?s?d", wordlist, "?1?1"], @@ -1092,7 +1184,7 @@ def hcatHybrid(hcatHashType, hcatHashFile, wordlists=None): try: hcatProcess.wait() except KeyboardInterrupt: - print('Killing PID {0}...'.format(str(hcatProcess.pid))) + print("Killing PID {0}...".format(str(hcatProcess.pid))) hcatProcess.kill() hcatHybridCount = lineCount(hcatHashFile + ".out") - hcatHashCracked @@ -1127,29 +1219,32 @@ def hcatYoloCombination(hcatHashType, hcatHashFile): try: hcatProcess.wait() except KeyboardInterrupt: - print('Killing PID {0}...'.format(str(hcatProcess.pid))) + print("Killing PID {0}...".format(str(hcatProcess.pid))) hcatProcess.kill() raise except KeyboardInterrupt: pass + # Bandrel methodlogy def hcatBandrel(hcatHashType, hcatHashFile): global hcatProcess basewords = [] while True: - company_name = input('What is the company name (Enter multiples comma separated)? ') + company_name = input( + "What is the company name (Enter multiples comma separated)? " + ) if company_name: break - for name in company_name.split(','): + for name in company_name.split(","): basewords.append(name) - for word in bandrelbasewords.split(','): + for word in bandrelbasewords.split(","): basewords.append(word) for name in basewords: - mask1 = '-1{0}{1}'.format(name[0].lower(),name[0].upper()) - mask2 = ' ?1{0}'.format(name[1:]) + mask1 = "-1{0}{1}".format(name[0].lower(), name[0].upper()) + mask2 = " ?1{0}".format(name[1:]) for x in range(6): - mask2 += '?a' + mask2 += "?a" cmd = [ hcatBin, "-m", @@ -1173,22 +1268,26 @@ def hcatBandrel(hcatHashType, hcatHashFile): try: hcatProcess.wait() except KeyboardInterrupt: - print('Killing PID {0}...'.format(str(hcatProcess.pid))) + print("Killing PID {0}...".format(str(hcatProcess.pid))) hcatProcess.kill() - print('Checking passwords against pipal for top {0} passwords and basewords'.format(pipal_count)) + print( + "Checking passwords against pipal for top {0} passwords and basewords".format( + pipal_count + ) + ) pipal_basewords = pipal() if pipal_basewords: for word in pipal_basewords: if word: - mask1 = '-1={0}{1}'.format(word[0].lower(),word[0].upper()) - mask2 = ' ?1{0}'.format(word[1:]) + mask1 = "-1={0}{1}".format(word[0].lower(), word[0].upper()) + mask2 = " ?1{0}".format(word[1:]) # ...existing code using mask1, mask2... else: continue else: pass for x in range(6): - mask2 += '?a' + mask2 += "?a" cmd = [ hcatBin, "-m", @@ -1212,9 +1311,10 @@ def hcatBandrel(hcatHashType, hcatHashFile): try: hcatProcess.wait() except KeyboardInterrupt: - print('Killing PID {0}...'.format(str(hcatProcess.pid))) + print("Killing PID {0}...".format(str(hcatProcess.pid))) hcatProcess.kill() + # Middle fast Combinator Attack def hcatMiddleCombinator(hcatHashType, hcatHashFile): global hcatProcess @@ -1226,9 +1326,9 @@ def hcatMiddleCombinator(hcatHashType, hcatHashFile): if len(mask) > 1: for character in mask: tmp.append(character) - new_masks.append('$' + '$'.join(tmp)) + new_masks.append("$" + "$".join(tmp)) else: - new_masks.append('$'+mask) + new_masks.append("$" + mask) masks = new_masks try: @@ -1254,12 +1354,13 @@ def hcatMiddleCombinator(hcatHashType, hcatHashFile): try: hcatProcess.wait() except KeyboardInterrupt: - print('Killing PID {0}...'.format(str(hcatProcess.pid))) + print("Killing PID {0}...".format(str(hcatProcess.pid))) hcatProcess.kill() raise except KeyboardInterrupt: pass + # Middle thorough Combinator Attack def hcatThoroughCombinator(hcatHashType, hcatHashFile): global hcatProcess @@ -1271,9 +1372,9 @@ def hcatThoroughCombinator(hcatHashType, hcatHashFile): if len(mask) > 1: for character in mask: tmp.append(character) - new_masks.append('$' + '$'.join(tmp)) + new_masks.append("$" + "$".join(tmp)) else: - new_masks.append('$'+mask) + new_masks.append("$" + mask) masks = new_masks cmd = [ @@ -1296,7 +1397,7 @@ def hcatThoroughCombinator(hcatHashType, hcatHashFile): try: hcatProcess.wait() except KeyboardInterrupt: - print('Killing PID {0}...'.format(str(hcatProcess.pid))) + print("Killing PID {0}...".format(str(hcatProcess.pid))) hcatProcess.kill() try: @@ -1322,7 +1423,7 @@ def hcatThoroughCombinator(hcatHashType, hcatHashFile): try: hcatProcess.wait() except KeyboardInterrupt: - print('Killing PID {0}...'.format(str(hcatProcess.pid))) + print("Killing PID {0}...".format(str(hcatProcess.pid))) hcatProcess.kill() raise except KeyboardInterrupt: @@ -1351,7 +1452,7 @@ def hcatThoroughCombinator(hcatHashType, hcatHashFile): try: hcatProcess.wait() except KeyboardInterrupt: - print('Killing PID {0}...'.format(str(hcatProcess.pid))) + print("Killing PID {0}...".format(str(hcatProcess.pid))) hcatProcess.kill() raise except KeyboardInterrupt: @@ -1381,9 +1482,10 @@ def hcatThoroughCombinator(hcatHashType, hcatHashFile): hcatProcess = subprocess.Popen(cmd) hcatProcess.wait() except KeyboardInterrupt: - print('Killing PID {0}...'.format(str(hcatProcess.pid))) + print("Killing PID {0}...".format(str(hcatProcess.pid))) hcatProcess.kill() + # Pathwell Mask Brute Force Attack def hcatPathwellBruteForce(hcatHashType, hcatHashFile): global hcatProcess @@ -1406,17 +1508,20 @@ def hcatPathwellBruteForce(hcatHashType, hcatHashFile): try: hcatProcess.wait() except KeyboardInterrupt: - print('Killing PID {0}...'.format(str(hcatProcess.pid))) + print("Killing PID {0}...".format(str(hcatProcess.pid))) hcatProcess.kill() # PRINCE Attack def hcatPrince(hcatHashType, hcatHashFile): global hcatProcess - hcatHashCracked = lineCount(hcatHashFile + ".out") prince_rules_dir = os.path.join(hate_path, "princeprocessor", "rules") prince_rule = get_rule_path("prince_optimized.rule", fallback_dir=prince_rules_dir) - prince_base = hcatPrinceBaseList[0] if isinstance(hcatPrinceBaseList, list) else hcatPrinceBaseList + prince_base = ( + hcatPrinceBaseList[0] + if isinstance(hcatPrinceBaseList, list) + else hcatPrinceBaseList + ) if not prince_base or not os.path.isfile(prince_base): print(f"Prince base list not found: {prince_base}") return @@ -1449,10 +1554,11 @@ def hcatPrince(hcatHashType, hcatHashFile): hcatProcess.wait() prince_proc.wait() except KeyboardInterrupt: - print('Killing PID {0}...'.format(str(hcatProcess.pid))) + print("Killing PID {0}...".format(str(hcatProcess.pid))) hcatProcess.kill() prince_proc.kill() + # Extra - Good Measure def hcatGoodMeasure(hcatHashType, hcatHashFile): global hcatExtraCount @@ -1480,7 +1586,7 @@ def hcatGoodMeasure(hcatHashType, hcatHashFile): try: hcatProcess.wait() except KeyboardInterrupt: - print('Killing PID {0}...'.format(str(hcatProcess.pid))) + print("Killing PID {0}...".format(str(hcatProcess.pid))) hcatProcess.kill() hcatExtraCount = lineCount(hcatHashFile + ".out") - hcatHashCracked @@ -1517,21 +1623,25 @@ def hcatLMtoNT(): _write_delimited_field(f"{hcatHashFile}.lm.cracked", f"{hcatHashFile}.working", 2) converted = convert_hex("{hash_file}.working".format(hash_file=hcatHashFile)) - with open("{hash_file}.working".format(hash_file=hcatHashFile),mode='w') as working: - working.writelines('\n'.join(converted)) + with open( + "{hash_file}.working".format(hash_file=hcatHashFile), mode="w" + ) as working: + working.writelines("\n".join(converted)) combine_path = os.path.join(hate_path, "hashcat-utils", "bin", hcatCombinatorBin) with open(f"{hcatHashFile}.combined", "wb") as combined_out: combine_proc = subprocess.Popen( [combine_path, f"{hcatHashFile}.working", f"{hcatHashFile}.working"], stdout=subprocess.PIPE, ) - hcatProcess = subprocess.Popen(["sort", "-u"], stdin=combine_proc.stdout, stdout=combined_out) + hcatProcess = subprocess.Popen( + ["sort", "-u"], stdin=combine_proc.stdout, stdout=combined_out + ) combine_proc.stdout.close() try: hcatProcess.wait() combine_proc.wait() except KeyboardInterrupt: - print('Killing PID {0}...'.format(str(hcatProcess.pid))) + print("Killing PID {0}...".format(str(hcatProcess.pid))) hcatProcess.kill() combine_proc.kill() @@ -1548,7 +1658,10 @@ def hcatLMtoNT(): f"{hcatHashFile}.nt.out", f"{hcatHashFile}.combined", "-r", - ensure_toggle_rule() or get_rule_path("toggles-lm-ntlm.rule", fallback_dir=os.path.join(hate_path, "rules")), + ensure_toggle_rule() + or get_rule_path( + "toggles-lm-ntlm.rule", fallback_dir=os.path.join(hate_path, "rules") + ), ] cmd.extend(shlex.split(hcatTuning)) cmd.append(f"--potfile-path={hate_path}/hashcat.pot") @@ -1556,7 +1669,7 @@ def hcatLMtoNT(): try: hcatProcess.wait() except KeyboardInterrupt: - print('Killing PID {0}...'.format(str(hcatProcess.pid))) + print("Killing PID {0}...".format(str(hcatProcess.pid))) hcatProcess.kill() # toggle-lm-ntlm.rule by Didier Stevens https://blog.didierstevens.com/2016/07/16/tool-to-generate-hashcat-toggle-rules/ @@ -1565,14 +1678,14 @@ def hcatLMtoNT(): # Recycle Cracked Passwords def hcatRecycle(hcatHashType, hcatHashFile, hcatNewPasswords): global hcatProcess - working_file = hcatHashFile + '.working' + working_file = hcatHashFile + ".working" if hcatNewPasswords > 0: _write_delimited_field(f"{hcatHashFile}.out", working_file, 2) converted = convert_hex(working_file) # Overwrite working file with updated converted words - with open(working_file, 'w') as f: + with open(working_file, "w") as f: f.write("\n".join(converted)) for rule in hcatRules: rule_path = get_rule_path(rule) @@ -1597,15 +1710,20 @@ def hcatRecycle(hcatHashType, hcatHashFile, hcatNewPasswords): except KeyboardInterrupt: hcatProcess.kill() + def check_potfile(): print("Checking POT file for already cracked hashes...") _run_hashcat_show(hcatHashType, hcatHashFile, f"{hcatHashFile}.out") hcatHashCracked = lineCount(hcatHashFile + ".out") if hcatHashCracked > 0: - print("Found %d hashes already cracked.\nCopied hashes to %s.out" % (hcatHashCracked, hcatHashFile)) + print( + "Found %d hashes already cracked.\nCopied hashes to %s.out" + % (hcatHashCracked, hcatHashFile) + ) else: print("No hashes found in POT file.") + # creating the combined output for pwdformat + cleartext def combine_ntlm_output(): hashes = {} @@ -1615,7 +1733,7 @@ def combine_ntlm_output(): return with open(hcatHashFile + ".out", "r") as hcatCrackedFile: for crackedLine in hcatCrackedFile: - parts = crackedLine.split(':', 1) + parts = crackedLine.split(":", 1) if len(parts) != 2: continue hash, password = parts @@ -1626,13 +1744,14 @@ def combine_ntlm_output(): with open(hcatHashFileOrig + ".out", "w+") as hcatCombinedHashes: with open(hcatHashFileOrig, "r") as hcatOrigFile: for origLine in hcatOrigFile: - orig_parts = origLine.split(':') + orig_parts = origLine.split(":") if len(orig_parts) < 4: continue ntlm_hash = orig_parts[3] if ntlm_hash in hashes: password = hashes[ntlm_hash] - hcatCombinedHashes.write(origLine.strip()+password+'\n') + hcatCombinedHashes.write(origLine.strip() + password + "\n") + # Cleanup Temp Files def cleanup(): @@ -1642,8 +1761,11 @@ def cleanup(): if hcatHashType == "1000" and pwdump_format: print("\nComparing cracked hashes to original file...") combine_ntlm_output() - print("\nCracked passwords combined with original hashes in %s" % (hcatHashFileOrig + ".out")) - print('\nCleaning up temporary files...') + print( + "\nCracked passwords combined with original hashes in %s" + % (hcatHashFileOrig + ".out") + ) + print("\nCleaning up temporary files...") if os.path.exists(hcatHashFile + ".masks"): os.remove(hcatHashFile + ".masks") if os.path.exists(hcatHashFile + ".working"): @@ -1661,80 +1783,77 @@ def cleanup(): if os.path.exists(hcatHashFileOrig + ".passwords"): os.remove(hcatHashFileOrig + ".passwords") except KeyboardInterrupt: - #incase someone mashes the Control+C it will still cleanup + # incase someone mashes the Control+C it will still cleanup cleanup() - - def hashview_api(): """Download/Upload data to Hashview API""" global hcatHashFile, hcatHashType - + if not REQUESTS_AVAILABLE: print("\nError: 'requests' module not found.") print("Install it with: pip install requests") return - - - print("\n" + "="*60) + + print("\n" + "=" * 60) print("Hashview Integration") - print("="*60) - + print("=" * 60) + # Get Hashview connection details from config if not hashview_api_key: print("\nError: Hashview API key not configured.") print("Please set 'hashview_api_key' in config.json") return - + print(f"\nConnecting to Hashview at: {hashview_url}") - + try: api_harness = HashviewAPI(hashview_url, hashview_api_key, debug=debug_mode) - + while True: - print("\n" + "="*60) + print("\n" + "=" * 60) print("What would you like to do?") - print("="*60) + print("=" * 60) print("\t(1) Upload Cracked Hashes from current session") print("\t(2) Upload Wordlist") print("\t(3) Download Wordlist") - print("\t(4) Download Left Hashes") - print("\t(5) Download Found Hashes") - print("\t(6) Upload Hashfile and Create Job") + print("\t(4) Download Left Hashes (with automatic merge if found)") + if hcatHashFile: + print("\t(5) Upload Hashfile and Create Job") print("\t(99) Back to Main Menu") - + choice = input("\nSelect an option: ") - - if choice == '1': + + if choice == "1": # Upload cracked hashes - print("\n" + "-"*60) + print("\n" + "-" * 60) print("Upload Cracked Hashes") - print("-"*60) - + print("-" * 60) + # Check if we're in an active session cracked_file = None session_file = None try: - if 'hcatHashFile' in globals() and hcatHashFile: + if "hcatHashFile" in globals() and hcatHashFile: potential_file = hcatHashFile + ".out" if os.path.exists(potential_file): session_file = potential_file print(f"Found session file: {session_file}") - elif 'hcatHashFile' in globals() and hcatHashFile: + elif "hcatHashFile" in globals() and hcatHashFile: potential_file = hcatHashFile + "nt.out" if os.path.exists(potential_file): session_file = potential_file print(f"Found session file: {session_file}") - except: + except Exception: pass - + # Prompt for file if session_file: use_session = input("Use this file? (Y/n): ").strip().lower() - if use_session != 'n': + if use_session != "n": cracked_file = session_file - + if not cracked_file: cracked_file = select_file_with_autocomplete( f"Enter path to cracked hashes file (.out format) [hash type: {hcatHashType}] (TAB to autocomplete)" @@ -1748,12 +1867,16 @@ def hashview_api(): if isinstance(cracked_file, str): cracked_file = cracked_file.strip() # Validate file exists - if not cracked_file or not isinstance(cracked_file, str) or not os.path.exists(cracked_file): + if ( + not cracked_file + or not isinstance(cracked_file, str) + or not os.path.exists(cracked_file) + ): print(f"✗ Error: File not found: {cracked_file}") continue # Show file info file_size = os.path.getsize(cracked_file) - with open(cracked_file, 'r') as f: + with open(cracked_file, "r") as f: line_count = sum(1 for _ in f) print(f"File: {cracked_file}") print(f"Size: {file_size} bytes") @@ -1771,19 +1894,22 @@ def hashview_api(): print(f"\nUploading to Hashview (hash type: {hash_type})...") try: result = api_harness.upload_cracked_hashes(cracked_file, hash_type) - print(f"\n✓ Success: {result.get('msg', 'Cracked hashes uploaded')}") - if 'count' in result: + print( + f"\n✓ Success: {result.get('msg', 'Cracked hashes uploaded')}" + ) + if "count" in result: print(f" Imported: {result['count']} hashes") except Exception as e: print(f"\n✗ Error: {str(e)}") import traceback + print("\nFull error details:") traceback.print_exc() - - elif choice == '2': - print("\n" + "-"*60) + + elif choice == "2": + print("\n" + "-" * 60) print("Upload Wordlist") - print("-"*60) + print("-" * 60) wordlist_path = select_file_with_autocomplete( "Enter path to wordlist file (TAB to autocomplete)", base_dir=hcatWordlists, @@ -1796,34 +1922,39 @@ def hashview_api(): print(f"✗ Error: File not found: {wordlist_path}") continue default_name = os.path.basename(wordlist_path) - wordlist_name = input(f"Enter wordlist name (default: {default_name}): ").strip() or default_name + wordlist_name = ( + input(f"Enter wordlist name (default: {default_name}): ").strip() + or default_name + ) try: - result = api_harness.upload_wordlist_file(wordlist_path, wordlist_name) + result = api_harness.upload_wordlist_file( + wordlist_path, wordlist_name + ) print(f"\n✓ Success: {result.get('msg', 'Wordlist uploaded')}") - if 'wordlist_id' in result: + if "wordlist_id" in result: print(f" Wordlist ID: {result['wordlist_id']}") except Exception as e: print(f"\n✗ Error uploading wordlist: {str(e)}") - - elif choice == '3': + + elif choice == "3": # Download wordlist try: wordlists = api_harness.list_wordlists() if wordlists: - print("\n" + "="*100) + print("\n" + "=" * 100) print("Available Wordlists:") - print("="*100) + print("=" * 100) print(f"{'ID':<10} {'Name':<60} {'Size':>12}") print("-" * 100) for wl in wordlists: - wl_id = wl.get('id', 'N/A') - wl_name = wl.get('name', 'N/A') - wl_size = wl.get('size', 'N/A') + wl_id = wl.get("id", "N/A") + wl_name = wl.get("name", "N/A") + wl_size = wl.get("size", "N/A") name = str(wl_name) if len(name) > 60: name = name[:57] + "..." print(f"{wl_id:<10} {name:<60} {wl_size:>12}") - print("="*100) + print("=" * 100) else: print("\nNo wordlists found.") except Exception as e: @@ -1836,90 +1967,54 @@ def hashview_api(): print("\n✗ Error: Invalid ID entered. Please enter a numeric ID.") continue - output_file = input("Enter output file name (default: wordlist_.gz): ").strip() or None + output_file = ( + input( + "Enter output file name (default: wordlist_.gz): " + ).strip() + or None + ) try: - download_result = api_harness.download_wordlist(wordlist_id, output_file) + download_result = api_harness.download_wordlist( + wordlist_id, output_file + ) print(f"\n✓ Success: Downloaded {download_result['size']} bytes") print(f" File: {download_result['output_file']}") except Exception as e: print(f"\n✗ Error downloading wordlist: {str(e)}") - elif choice == '5': - # Download found hashes - try: - # First, list customers to help user select - customers = api_harness.list_customers_with_hashfiles() - if customers: - api_harness.display_customers_multicolumn(customers) - else: - print("\nNo customers found with hashfiles.") - - # Get customer ID and hashfile ID directly - customer_id = int(input("\nEnter customer ID: ")) - - # List hashfiles for the customer - try: - customer_hashfiles = api_harness.get_customer_hashfiles(customer_id) - - if customer_hashfiles: - print("\n" + "="*100) - print(f"Hashfiles for Customer ID {customer_id}:") - print("="*100) - print(f"{'ID':<10} {'Name':<88}") - print("-" * 100) - for hf in customer_hashfiles: - hf_id = hf.get('id', 'N/A') - hf_name = hf.get('name', 'N/A') - # Truncate long names to fit within 100 columns - if len(str(hf_name)) > 88: - hf_name = str(hf_name)[:85] + "..." - print(f"{hf_id:<10} {hf_name:<88}") - print("="*100) - print(f"Total: {len(customer_hashfiles)} hashfile(s)") - else: - print(f"\nNo hashfiles found for customer ID {customer_id}") - except Exception as e: - print(f"\nWarning: Could not list hashfiles: {e}") - print("You may need to manually find the hashfile ID in the web interface.") - - hashfile_id = int(input("\nEnter hashfile ID: ")) - - # Set output filename automatically - output_file = f"found_{customer_id}_{hashfile_id}.txt" - - # Download the found hashes - download_result = api_harness.download_found_hashes( - customer_id, hashfile_id, output_file - ) - print(f"\n✓ Success: Downloaded {download_result['size']} bytes") - print(f" File: {download_result['output_file']}") - - except ValueError: - print("\n✗ Error: Invalid ID entered. Please enter a numeric ID.") - except Exception as e: - print(f"\n✗ Error downloading hashes: {str(e)}") - - elif choice == '6': + elif choice == "5": # Upload hashfile and create job + if not hcatHashFile: + print("\n✗ Error: No hashfile is currently set.") + continue # First, list customers to help user select try: - customers = api_harness.list_customers_with_hashfiles() + customers_result = api_harness.list_customers() + customers = ( + customers_result.get("customers", []) + if isinstance(customers_result, dict) + else customers_result + ) if customers: api_harness.display_customers_multicolumn(customers) else: - print("\nNo customers found with hashfiles.") + print("\nNo customers found.") except Exception as e: print(f"\n✗ Error fetching customers: {str(e)}") # Select or create customer - customer_input = input("\nEnter customer ID or N to create new: ").strip() - if customer_input.lower() == 'n': + customer_input = input( + "\nEnter customer ID or N to create new: " + ).strip() + if customer_input.lower() == "n": customer_name = input("Enter customer name: ").strip() if customer_name: try: result = api_harness.create_customer(customer_name) - print(f"\n✓ Success: {result.get('msg', 'Customer created')}") - customer_id = result.get('customer_id') or result.get('id') + print( + f"\n✓ Success: {result.get('msg', 'Customer created')}" + ) + customer_id = result.get("customer_id") or result.get("id") if not customer_id: print("\n✗ Error: Customer ID not returned.") continue @@ -1934,121 +2029,209 @@ def hashview_api(): try: customer_id = int(customer_input) except ValueError: - print("\n✗ Error: Invalid ID entered. Please enter a numeric ID or N.") + print( + "\n✗ Error: Invalid ID entered. Please enter a numeric ID or N." + ) continue - hashfile_path = hcatHashFile + # Use hashfile from original command if available + hashfile_path = hcatHashFileOrig # Use original path, not the modified one if not hashfile_path or not os.path.exists(hashfile_path): hashfile_path = select_file_with_autocomplete( "Enter path to hashfile (TAB to autocomplete)" ) + # Handle list return from autocomplete + if isinstance(hashfile_path, list): + hashfile_path = hashfile_path[0] if hashfile_path else None + if isinstance(hashfile_path, str): + hashfile_path = hashfile_path.strip() + if not hashfile_path or not os.path.exists(hashfile_path): print(f"Error: File not found: {hashfile_path}") continue - hash_type = int(input(f"Enter hash type (default: {hcatHashType}): ") or hcatHashType) - print("\nFile formats:") - print(" 0 = pwdump, 1 = NetNTLM, 2 = kerberos") - print(" 3 = shadow, 4 = user:hash, 5 = hash_only") - file_format = int(input("Enter file format (default: 5): ") or 5) - - hashfile_name = select_file_with_autocomplete( - f"Enter hashfile name (default: {hashfile_path}) (TAB to autocomplete)" - ) - if isinstance(hashfile_name, list): - hashfile_name = hashfile_name[0] if hashfile_name else None - if isinstance(hashfile_name, str): - hashfile_name = hashfile_name.strip() - if isinstance(hashfile_name, str) and hashfile_name: - # Hashview expects a name, not a path. - hashfile_name = os.path.basename(hashfile_name) - if not hashfile_name: - hashfile_name = None + # Use hash type from original command if available, otherwise prompt + if hcatHashType and str(hcatHashType).isdigit(): + hash_type = int(hcatHashType) + print(f"Using hash type: {hash_type}") + else: + hash_type = int(input("Enter hash type (e.g., 1000 for NTLM): ")) + + # Auto-detect file format based on content + file_format = 5 # Default to hash_only + try: + with open(hashfile_path, 'r', encoding='utf-8', errors='ignore') as f: + first_line = f.readline().strip() + if first_line: + # Check for pwdump format (username:hash or username:rid:lmhash:nthash) + parts = first_line.split(':') + if len(parts) >= 4: + # Likely pwdump format (username:rid:lmhash:nthash) + file_format = 0 + elif len(parts) == 2 and not all(c in '0123456789abcdefABCDEF' for c in parts[0]): + # Likely user:hash format (first part is not all hex) + file_format = 4 + # Otherwise default to 5 (hash_only) + except Exception: + file_format = 5 # Default if detection fails + print(f"\nAuto-detected file format: {file_format} ", end="") + format_names = {0: "pwdump", 1: "NetNTLM", 2: "kerberos", 3: "shadow", 4: "user:hash", 5: "hash_only"} + print(f"({format_names.get(file_format, 'unknown')})") + + # Default hashfile name to the basename of the file + hashfile_name = os.path.basename(hashfile_path) + print(f"Using hashfile name: {hashfile_name}") + try: result = api_harness.upload_hashfile( - hashfile_path, customer_id, hash_type, file_format, hashfile_name + hashfile_path, + customer_id, + hash_type, + file_format, + hashfile_name, ) print(f"\n✓ Success: {result.get('msg', 'Hashfile uploaded')}") - if 'hashfile_id' in result: + if "hashfile_id" in result: print(f" Hashfile ID: {result['hashfile_id']}") # Hash count is not returned by the upload API, so we don't display it - if 'hash_count' in result: + if "hash_count" in result: print(f" Hash count: {result['hash_count']}") - if 'instacracked' in result: + if "instacracked" in result: print(f" Insta-cracked: {result['instacracked']}") - + # Offer to create a job - create_job = input("\nWould you like to create a job for this hashfile? (Y/n): ") or "Y" - if create_job.upper() == 'Y': + create_job = ( + input( + "\nWould you like to create a job for this hashfile? (Y/n): " + ) + or "Y" + ) + if create_job.upper() == "Y": job_name = input("Enter job name: ") limit_recovered = False notify_email = True - uploaded_wordlist_id = None - try: job_result = api_harness.create_job( - job_name, result['hashfile_id'], customer_id, - limit_recovered, notify_email + job_name, + result["hashfile_id"], + customer_id, + limit_recovered, + notify_email, ) - print(f"\n✓ Success: {job_result.get('msg', 'Job created')}") - if 'job_id' in job_result: + print( + f"\n✓ Success: {job_result.get('msg', 'Job created')}" + ) + if "job_id" in job_result: print(f" Job ID: {job_result['job_id']}") - print(f"\nNote: Job created with automatically assigned tasks based on") - print(f" historical effectiveness for hash type {hash_type}.") - + print( + "\nNote: Job created with automatically assigned tasks based on" + ) + print( + f" historical effectiveness for hash type {hash_type}." + ) + # Offer to start the job - start_now = input("\nStart the job now? (Y/n): ") or "Y" - if start_now.upper() == 'Y': - stop_after_one = input("Stop after a single result? (y/N): ").strip().upper() == 'Y' + start_now = ( + input("\nStart the job now? (Y/n): ") or "Y" + ) + if start_now.upper() == "Y": + stop_after_one = ( + input("Stop after a single result? (y/N): ") + .strip() + .upper() + == "Y" + ) start_result = api_harness.start_job( - job_result['job_id'], + job_result["job_id"], limit_recovered=stop_after_one, ) - print(f"\n✓ Success: {start_result.get('msg', 'Job started')}") + print( + f"\n✓ Success: {start_result.get('msg', 'Job started')}" + ) except Exception as e: print(f"\n✗ Error creating job: {str(e)}") except Exception as e: print(f"\n✗ Error uploading hashfile: {str(e)}") - - elif choice == '4': + + elif choice == "4": # Download left hashes try: # First, list customers to help user select - customers = api_harness.list_customers_with_hashfiles() + customers_result = api_harness.list_customers() + customers = ( + customers_result.get("customers", []) + if isinstance(customers_result, dict) + else customers_result + ) if customers: api_harness.display_customers_multicolumn(customers) - - # Get customer ID and hashfile ID directly - customer_id = int(input("\nEnter customer ID: ")) - + else: + print("\nNo customers found.") + + # Select or create customer + customer_input = input( + "\nEnter customer ID or N to create new: " + ).strip() + if customer_input.lower() == "n": + customer_name = input("Enter customer name: ").strip() + if customer_name: + try: + result = api_harness.create_customer(customer_name) + print( + f"\n✓ Success: {result.get('msg', 'Customer created')}" + ) + customer_id = result.get("customer_id") or result.get("id") + if not customer_id: + print("\n✗ Error: Customer ID not returned.") + continue + print(f" Customer ID: {customer_id}") + except Exception as e: + print(f"\n✗ Error creating customer: {str(e)}") + continue + else: + print("\n✗ Error: Customer name cannot be empty.") + continue + else: + try: + customer_id = int(customer_input) + except ValueError: + print( + "\n✗ Error: Invalid ID entered. Please enter a numeric ID or N." + ) + continue + # List hashfiles for the customer try: - customer_hashfiles = api_harness.get_customer_hashfiles(customer_id) - + customer_hashfiles = api_harness.get_customer_hashfiles( + customer_id + ) + if customer_hashfiles: - print("\n" + "="*100) + print("\n" + "=" * 100) print(f"Hashfiles for Customer ID {customer_id}:") - print("="*100) + print("=" * 100) print(f"{'ID':<10} {'Name':<88}") print("-" * 100) for hf in customer_hashfiles: - hf_id = hf.get('id', 'N/A') - hf_name = hf.get('name', 'N/A') + hf_id = hf.get("id", "N/A") + hf_name = hf.get("name", "N/A") # Truncate long names to fit within 100 columns if len(str(hf_name)) > 88: hf_name = str(hf_name)[:85] + "..." print(f"{hf_id:<10} {hf_name:<88}") - print("="*100) + print("=" * 100) print(f"Total: {len(customer_hashfiles)} hashfile(s)") else: print(f"\nNo hashfiles found for customer ID {customer_id}") except Exception as e: print(f"\nWarning: Could not list hashfiles: {e}") - print("You may need to manually find the hashfile ID in the web interface.") - + print( + "You may need to manually find the hashfile ID in the web interface." + ) + hashfile_id = int(input("\nEnter hashfile ID: ")) - + # Set output filename automatically output_file = f"left_{customer_id}_{hashfile_id}.txt" @@ -2058,37 +2241,36 @@ def hashview_api(): ) print(f"\n✓ Success: Downloaded {download_result['size']} bytes") print(f" File: {download_result['output_file']}") - - # Ask if user wants to switch to this hashfile - switch = input("\nSwitch to this hashfile for cracking? (Y/n): ").strip().lower() - if switch != 'n': - hcatHashFile = download_result['output_file'] + # Ask if user wants to switch to this hashfile + switch = ( + input("\nSwitch to this hashfile for cracking? (Y/n): ") + .strip() + .lower() + ) + if switch != "n": + hcatHashFile = download_result["output_file"] + hcatHashType = "1000" # Default to NTLM for Hashview downloads print(f"✓ Switched to hashfile: {hcatHashFile}") print("\nReturning to main menu to start cracking...") return # Exit hashview menu and return to main menu - + except ValueError: print("\n✗ Error: Invalid ID entered. Please enter a numeric ID.") except Exception as e: print(f"\n✗ Error downloading hashes: {str(e)}") - - elif choice == '99': + + elif choice == "99": break else: print("Invalid option. Please try again.") - + except KeyboardInterrupt: print("\n\nHashview upload canceled.") except Exception as e: print(f"\nError connecting to Hashview: {str(e)}") -# Attack menu handlers live in hate_crack.attacks -from hate_crack import attacks as _attacks -from types import SimpleNamespace - - def _attack_ctx(): ctx = sys.modules.get(__name__) if ctx is None: @@ -2151,20 +2333,23 @@ def bandrel_method(): # convert hex words for recycling def convert_hex(working_file): processed_words = [] - regex = r'^\$HEX\[(\S+)\]' - with open(working_file, 'r') as f: + regex = r"^\$HEX\[(\S+)\]" + with open(working_file, "r") as f: for line in f: - match = re.search(regex, line.rstrip('\n')) + match = re.search(regex, line.rstrip("\n")) if match: try: - processed_words.append(binascii.unhexlify(match.group(1)).decode('iso-8859-9')) + processed_words.append( + binascii.unhexlify(match.group(1)).decode("iso-8859-9") + ) except UnicodeDecodeError: pass else: - processed_words.append(line.rstrip('\n')) + processed_words.append(line.rstrip("\n")) return processed_words + # Display Cracked Hashes def show_results(): if os.path.isfile(hcatHashFile + ".out"): @@ -2174,6 +2359,7 @@ def show_results(): else: print("No hashes were cracked :(") + # Analyze Hashes with Pipal def pipal(): hcatHashFilePipal = hcatHashFile @@ -2183,14 +2369,16 @@ def pipal(): if os.path.isfile(pipalPath): if os.path.isfile(hcatHashFilePipal + ".out"): - pipalFile = open(hcatHashFilePipal + ".passwords", 'w') + pipalFile = open(hcatHashFilePipal + ".passwords", "w") with open(hcatHashFilePipal + ".out") as hcatOutput: for cracked_hash in hcatOutput: - password = cracked_hash.split(':') + password = cracked_hash.split(":") clearTextPass = password[-1] - match = re.search(r'^\$HEX\[(\S+)\]', clearTextPass) + match = re.search(r"^\$HEX\[(\S+)\]", clearTextPass) if match: - clearTextPass = binascii.unhexlify(match.group(1)).decode('iso-8859-9') + clearTextPass = binascii.unhexlify(match.group(1)).decode( + "iso-8859-9" + ) pipalFile.write(clearTextPass) pipalFile.close() @@ -2199,33 +2387,40 @@ def pipal(): pipal_path=pipalPath, pipal_file=hcatHashFilePipal + ".passwords", pipal_out=hcatHashFilePipal + ".pipal", - pipal_count=pipal_count), - shell=True) + pipal_count=pipal_count, + ), + shell=True, + ) try: pipalProcess.wait() except KeyboardInterrupt: - print('Killing PID {0}...'.format(str(pipalProcess.pid))) + print("Killing PID {0}...".format(str(pipalProcess.pid))) pipalProcess.kill() print("Pipal file is at " + hcatHashFilePipal + ".pipal\n") import sys + if not sys.stdin.isatty(): - view_choice = 'y' + view_choice = "y" else: - view_choice = input("Would you like to view (cat) the pipal output? (Y/n): ").strip().lower() - if view_choice in ('', 'y', 'yes'): + view_choice = ( + input("Would you like to view (cat) the pipal output? (Y/n): ") + .strip() + .lower() + ) + if view_choice in ("", "y", "yes"): print("\n--- Pipal Output Start ---\n") with open(hcatHashFilePipal + ".pipal") as pipalfile: 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' + 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' + raw_regex += r"(\S+).*\n" basewords_re = re.compile(raw_regex) - results = re.search(basewords_re,raw_pipal) + results = re.search(basewords_re, raw_pipal) top_basewords = [] if results: if results.lastindex is not None: @@ -2245,30 +2440,33 @@ def pipal(): return - # Exports output to excel file def export_excel(): - # Check for openyxl dependancy for export try: import openpyxl except ImportError: - sys.stderr.write("You must install openpyxl first using 'pip install openpyxl' or 'pip3 install openpyxl'\n") + sys.stderr.write( + "You must install openpyxl first using 'pip install openpyxl' or 'pip3 install openpyxl'\n" + ) return if hcatHashType == "1000": combine_ntlm_output() output = openpyxl.Workbook() - current_ws = output.create_sheet(title='hate_crack output', index=0) + current_ws = output.create_sheet(title="hate_crack output", index=0) current_row = 2 - current_ws['A1'] = 'Username' - current_ws['B1'] = 'SID' - current_ws['C1'] = 'LM Hash' - current_ws['D1'] = 'NTLM Hash' - current_ws['E1'] = 'Clear-Text Password' - with open(hcatHashFileOrig+'.out') as input_file: + current_ws["A1"] = "Username" + current_ws["B1"] = "SID" + current_ws["C1"] = "LM Hash" + current_ws["D1"] = "NTLM Hash" + current_ws["E1"] = "Clear-Text Password" + with open(hcatHashFileOrig + ".out") as input_file: for line in input_file: - matches = re.match(r'(^[^:]+):([0-9]+):([a-z0-9A-Z]{32}):([a-z0-9A-Z]{32}):::(.*)',line.rstrip('\r\n')) + matches = re.match( + r"(^[^:]+):([0-9]+):([a-z0-9A-Z]{32}):([a-z0-9A-Z]{32}):::(.*)", + line.rstrip("\r\n"), + ) if not matches: continue username = matches.group(1) @@ -2277,21 +2475,23 @@ def export_excel(): ntlm = matches.group(4) try: clear_text = matches.group(5) - match = re.search(r'^\$HEX\[(\S+)\]', clear_text) + match = re.search(r"^\$HEX\[(\S+)\]", clear_text) if match: - clear_text = binascii.unhexlify(match.group(1)).decode('iso-8859-9') - except: - clear_text = '' - current_ws['A' + str(current_row)] = username - current_ws['B' + str(current_row)] = sid - current_ws['C' + str(current_row)] = lm - current_ws['D' + str(current_row)] = ntlm - current_ws['E' + str(current_row)] = clear_text + clear_text = binascii.unhexlify(match.group(1)).decode( + "iso-8859-9" + ) + except Exception: + clear_text = "" + current_ws["A" + str(current_row)] = username + current_ws["B" + str(current_row)] = sid + current_ws["C" + str(current_row)] = lm + current_ws["D" + str(current_row)] = ntlm + current_ws["E" + str(current_row)] = clear_text current_row += 1 - output.save(hcatHashFile+'.xlsx') - print("Output exported succesfully to {0}".format(hcatHashFile+'.xlsx')) + output.save(hcatHashFile + ".xlsx") + print("Output exported succesfully to {0}".format(hcatHashFile + ".xlsx")) else: - sys.stderr.write('Excel output only supported for pwdformat for NTLM hashes') + sys.stderr.write("Excel output only supported for pwdformat for NTLM hashes") return @@ -2335,6 +2535,7 @@ def get_main_menu_options(): "99": quit_hc, } + # The Main Guts def main(): global pwdump_format @@ -2347,88 +2548,178 @@ def main(): global hcatPath, hcatBin, hcatWordlists, hcatOptimizedWordlists, rulesDirectory global pipalPath, maxruntime, bandrelbasewords - - import argparse + # Initialize global variables + hcatHashFile = None + hcatHashType = None + hcatHashFileOrig = None def _build_parser(include_positional, include_subcommands): - parser = argparse.ArgumentParser(description="hate_crack - Hashcat automation and wordlist management tool") + parser = argparse.ArgumentParser( + description="hate_crack - Hashcat automation and wordlist management tool" + ) if include_positional: - parser.add_argument('hashfile', nargs='?', default=None, help='Path to hash file to crack (positional, optional)') - parser.add_argument('hashtype', nargs='?', default=None, help='Hashcat hash type (e.g., 1000 for NTLM) (positional, optional)') - parser.add_argument('--download-hashview', action='store_true', help='Download hashes from Hashview (legacy menu)') - parser.add_argument('--hashview', action='store_true', help='Jump directly to Hashview customer/hashfile menu') - parser.add_argument('--download-torrent', metavar='FILENAME', help='Download a specific Weakpass torrent file') - parser.add_argument('--download-all-torrents', action='store_true', help='Download all available Weakpass torrents from cache') - parser.add_argument('--weakpass', action='store_true', help='Download wordlists from Weakpass') - parser.add_argument('--rank', type=int, default=-1, help='Only show wordlists with this rank (use 0 to show all, default: >4)') - parser.add_argument('--hashmob', action='store_true', help='Download wordlists from Hashmob.net') - parser.add_argument('--rules', action='store_true', help='Download rules from Hashmob.net') - parser.add_argument('--cleanup', action='store_true', help='Cleanup .out files, torrents, and extract or remove .7z archives') - parser.add_argument('--debug', action='store_true', help='Enable debug mode') + parser.add_argument( + "hashfile", + nargs="?", + default=None, + help="Path to hash file to crack (positional, optional)", + ) + parser.add_argument( + "hashtype", + nargs="?", + default=None, + help="Hashcat hash type (e.g., 1000 for NTLM) (positional, optional)", + ) + parser.add_argument( + "--download-hashview", + action="store_true", + help="Download hashes from Hashview (legacy menu)", + ) + parser.add_argument( + "--hashview", + action="store_true", + help="Jump directly to Hashview customer/hashfile menu", + ) + parser.add_argument( + "--download-torrent", + metavar="FILENAME", + help="Download a specific Weakpass torrent file", + ) + parser.add_argument( + "--download-all-torrents", + action="store_true", + help="Download all available Weakpass torrents from cache", + ) + parser.add_argument( + "--weakpass", action="store_true", help="Download wordlists from Weakpass" + ) + parser.add_argument( + "--rank", + type=int, + default=-1, + help="Only show wordlists with this rank (use 0 to show all, default: >4)", + ) + parser.add_argument( + "--hashmob", action="store_true", help="Download wordlists from Hashmob.net" + ) + parser.add_argument( + "--rules", action="store_true", help="Download rules from Hashmob.net" + ) + parser.add_argument( + "--cleanup", + action="store_true", + help="Cleanup .out files, torrents, and extract or remove .7z archives", + ) + parser.add_argument("--debug", action="store_true", help="Enable debug mode") hashview_parser = None if not include_subcommands: return parser, hashview_parser - subparsers = parser.add_subparsers(dest='command') + subparsers = parser.add_subparsers(dest="command") - hashview_parser = subparsers.add_parser('hashview', help='Hashview menu actions') - hashview_subparsers = hashview_parser.add_subparsers(dest='hashview_command') + hashview_parser = subparsers.add_parser( + "hashview", help="Hashview menu actions" + ) + hashview_subparsers = hashview_parser.add_subparsers(dest="hashview_command") hv_upload_cracked = hashview_subparsers.add_parser( - 'upload-cracked', - help='Upload cracked hashes from a file', + "upload-cracked", + help="Upload cracked hashes from a file", + ) + hv_upload_cracked.add_argument( + "--file", required=True, help="Path to cracked hashes file (.out format)" + ) + hv_upload_cracked.add_argument( + "--hash-type", default="1000", help="Hash type (default: 1000)" ) - hv_upload_cracked.add_argument('--file', required=True, help='Path to cracked hashes file (.out format)') - hv_upload_cracked.add_argument('--hash-type', default='1000', help='Hash type (default: 1000)') hv_upload_wordlist = hashview_subparsers.add_parser( - 'upload-wordlist', - help='Upload a wordlist file', + "upload-wordlist", + help="Upload a wordlist file", + ) + hv_upload_wordlist.add_argument( + "--file", required=True, help="Path to wordlist file" + ) + hv_upload_wordlist.add_argument( + "--name", default=None, help="Wordlist name (default: filename)" ) - hv_upload_wordlist.add_argument('--file', required=True, help='Path to wordlist file') - hv_upload_wordlist.add_argument('--name', default=None, help='Wordlist name (default: filename)') hv_download_left = hashview_subparsers.add_parser( - 'download-left', - help='Download left hashes for a hashfile', + "download-left", + help="Download left hashes for a hashfile", ) - hv_download_left.add_argument('--customer-id', required=True, type=int, help='Customer ID') - hv_download_left.add_argument('--hashfile-id', required=True, type=int, help='Hashfile ID') - hv_download_left.add_argument('--out', default=None, help='Output file path') - - hv_download_found = hashview_subparsers.add_parser( - 'download-found', - help='Download found hashes for a hashfile', + hv_download_left.add_argument( + "--customer-id", required=True, type=int, help="Customer ID" + ) + hv_download_left.add_argument( + "--hashfile-id", required=True, type=int, help="Hashfile ID" ) - hv_download_found.add_argument('--customer-id', required=True, type=int, help='Customer ID') - hv_download_found.add_argument('--hashfile-id', required=True, type=int, help='Hashfile ID') - hv_download_found.add_argument('--out', default=None, help='Output file path') hv_upload_hashfile_job = hashview_subparsers.add_parser( - 'upload-hashfile-job', - help='Upload a hashfile and create a job', - ) - hv_upload_hashfile_job.add_argument('--file', required=True, help='Path to hashfile') - hv_upload_hashfile_job.add_argument('--customer-id', required=True, type=int, help='Customer ID') - hv_upload_hashfile_job.add_argument('--hash-type', required=True, type=int, help='Hash type (e.g., 1000)') - hv_upload_hashfile_job.add_argument('--file-format', default=5, type=int, help='File format (default: 5)') - hv_upload_hashfile_job.add_argument('--hashfile-name', default=None, help='Hashfile name (default: filename)') - hv_upload_hashfile_job.add_argument('--job-name', required=True, help='Job name') - hv_upload_hashfile_job.add_argument( - '--limit-recovered', - action='store_true', - help='Limit to recovered hashes only', + "upload-hashfile-job", + help="Upload a hashfile and create a job", ) hv_upload_hashfile_job.add_argument( - '--no-notify-email', - action='store_true', - help='Disable email notifications', + "--file", required=True, help="Path to hashfile" + ) + hv_upload_hashfile_job.add_argument( + "--customer-id", required=True, type=int, help="Customer ID" + ) + hv_upload_hashfile_job.add_argument( + "--hash-type", required=True, type=int, help="Hash type (e.g., 1000)" + ) + hv_upload_hashfile_job.add_argument( + "--file-format", default=5, type=int, help="File format (default: 5)" + ) + hv_upload_hashfile_job.add_argument( + "--hashfile-name", default=None, help="Hashfile name (default: filename)" + ) + hv_upload_hashfile_job.add_argument( + "--job-name", required=True, help="Job name" + ) + hv_upload_hashfile_job.add_argument( + "--limit-recovered", + action="store_true", + help="Limit to recovered hashes only", + ) + hv_upload_hashfile_job.add_argument( + "--no-notify-email", + action="store_true", + help="Disable email notifications", ) return parser, hashview_parser # Removed add_common_args(parser) since config items are now only set via config file argv = sys.argv[1:] - use_subcommand_parser = 'hashview' in argv + + hashview_subcommands = ["upload-cracked", "upload-wordlist", "download-left", "upload-hashfile-job"] + has_hashview_flag = "--hashview" in argv + has_hashview_subcommand = any(cmd in argv for cmd in hashview_subcommands) + + # Handle custom help for --hashview (without subcommand) + if has_hashview_flag and not has_hashview_subcommand and ("--help" in argv or "-h" in argv): + # Build the full parser to get hashview help + temp_parser, hashview_parser = _build_parser( + include_positional=False, + include_subcommands=True, + ) + if hashview_parser: + hashview_parser.print_help() + sys.exit(0) + + # If --hashview flag is used with a subcommand, convert to subcommand format for parser + if has_hashview_flag and has_hashview_subcommand: + # Remove --hashview flag and insert "hashview" as subcommand + argv_temp = [arg for arg in argv if arg != "--hashview"] + # Find the first hashview subcommand and insert "hashview" before it + for i, arg in enumerate(argv_temp): + if arg in hashview_subcommands: + argv = argv_temp[:i] + ["hashview"] + argv_temp[i:] + break + else: + argv = argv_temp # Fallback if subcommand not found + + use_subcommand_parser = "hashview" in argv parser, hashview_parser = _build_parser( include_positional=not use_subcommand_parser, include_subcommands=use_subcommand_parser, @@ -2454,7 +2745,7 @@ def main(): maxruntime=maxruntime, bandrelbasewords=bandrelbasewords, ) - + hashview_url = config.hashview_url hashview_api_key = config.hashview_api_key hcatPath = config.hcatPath @@ -2474,7 +2765,7 @@ def main(): ) sys.exit(0) - if getattr(args, 'command', None) == 'hashview': + if getattr(args, "command", None) == "hashview": if not hashview_api_key: print("\nError: Hashview API key not configured.") print("Please set 'hashview_api_key' in config.json") @@ -2482,49 +2773,40 @@ def main(): api_harness = HashviewAPI(hashview_url, hashview_api_key, debug=debug_mode) - if args.hashview_command == 'upload-cracked': + if args.hashview_command == "upload-cracked": cracked_file = resolve_path(args.file) if not cracked_file or not os.path.isfile(cracked_file): print(f"✗ Error: File not found: {args.file}") sys.exit(1) - result = api_harness.upload_cracked_hashes(cracked_file, hash_type=args.hash_type) + result = api_harness.upload_cracked_hashes( + cracked_file, hash_type=args.hash_type + ) print(f"\n✓ Success: {result.get('msg', 'Cracked hashes uploaded')}") - if 'count' in result: + if "count" in result: print(f" Imported: {result['count']} hashes") sys.exit(0) - if args.hashview_command == 'upload-wordlist': + if args.hashview_command == "upload-wordlist": wordlist_path = resolve_path(args.file) if not wordlist_path or not os.path.isfile(wordlist_path): print(f"✗ Error: File not found: {args.file}") sys.exit(1) result = api_harness.upload_wordlist_file(wordlist_path, args.name) print(f"\n✓ Success: {result.get('msg', 'Wordlist uploaded')}") - if 'wordlist_id' in result: + if "wordlist_id" in result: print(f" Wordlist ID: {result['wordlist_id']}") sys.exit(0) - if args.hashview_command == 'download-left': + if args.hashview_command == "download-left": download_result = api_harness.download_left_hashes( args.customer_id, args.hashfile_id, - output_file=args.out, ) print(f"\n✓ Success: Downloaded {download_result['size']} bytes") print(f" File: {download_result['output_file']}") sys.exit(0) - if args.hashview_command == 'download-found': - download_result = api_harness.download_found_hashes( - args.customer_id, - args.hashfile_id, - output_file=args.out, - ) - print(f"\n✓ Success: Downloaded {download_result['size']} bytes") - print(f" File: {download_result['output_file']}") - sys.exit(0) - - if args.hashview_command == 'upload-hashfile-job': + if args.hashview_command == "upload-hashfile-job": hashfile_path = resolve_path(args.file) if not hashfile_path or not os.path.isfile(hashfile_path): print(f"✗ Error: File not found: {args.file}") @@ -2537,18 +2819,18 @@ def main(): args.hashfile_name, ) print(f"\n✓ Success: {upload_result.get('msg', 'Hashfile uploaded')}") - if 'hashfile_id' not in upload_result: + if "hashfile_id" not in upload_result: print("✗ Error: Hashfile upload did not return a hashfile_id.") sys.exit(1) job_result = api_harness.create_job( args.job_name, - upload_result['hashfile_id'], + upload_result["hashfile_id"], args.customer_id, limit_recovered=args.limit_recovered, notify_email=not args.no_notify_email, ) print(f"\n✓ Success: {job_result.get('msg', 'Job created')}") - if 'job_id' in job_result: + if "job_id" in job_result: print(f" Job ID: {job_result['job_id']}") sys.exit(0) @@ -2571,8 +2853,6 @@ def main(): sys.exit(1) sys.exit(0) - - if args.hashview: if not hashview_api_key: print("Available Customers:") @@ -2608,6 +2888,7 @@ def main(): if args.hashfile and args.hashtype: hcatHashFile = resolve_path(args.hashfile) + hcatHashFileOrig = hcatHashFile # Store original before modification hcatHashFile = _ensure_hashfile_in_cwd(hcatHashFile) hcatHashType = args.hashtype if not hcatHashFile or not os.path.isfile(hcatHashFile): @@ -2618,50 +2899,58 @@ def main(): sys.exit(1) else: ascii_art() - print("\n" + "="*60) - print("No hash file provided. What would you like to do?") - print("="*60) - print("\t(1) Download hashes from Hashview") - print("\t(2) Download wordlists from Weakpass") - print("\t(3) Download wordlists from Hashmob.net") - print("\t(4) Download rules from Hashmob.net") - print("\t(5) Exit") - choice = input("\nSelect an option: ") - if choice == '1' or args.download_hashview: - if not hashview_api_key: - print("\nError: Hashview API key not configured.") - print("Please set 'hashview_api_key' in config.json") - sys.exit(1) - try: - hcatHashFile, hcatHashType = download_hashes_from_hashview( - hashview_url, - hashview_api_key, - debug_mode, - input_fn=input, - print_fn=print, - ) - except ValueError: - print("\n✗ Error: Invalid ID entered. Please enter a numeric ID.") - sys.exit(1) - except Exception as e: - print(f"\n✗ Error downloading hashes: {str(e)}") - sys.exit(1) - elif choice == '2' or args.weakpass: - weakpass_wordlist_menu(rank=args.rank) - sys.exit(0) - elif choice == '3' or args.hashmob: - download_hashmob_wordlists(print_fn=print) - sys.exit(0) - elif choice == '4' or args.rules: - download_hashmob_rules(print_fn=print) - sys.exit(0) - else: - sys.exit(0) + menu_loop = True + while menu_loop: + print("\n" + "=" * 60) + print("No hash file provided. What would you like to do?") + print("=" * 60) + print("\t(1) Hashview API") + print("\t(2) Download wordlists from Weakpass") + print("\t(3) Download wordlists from Hashmob.net") + print("\t(4) Download rules from Hashmob.net") + print("\t(5) Exit") + choice = input("\nSelect an option: ") + if choice == "1" or args.download_hashview: + hashview_api() + # Check if hashfile was set by hashview_api + if not hcatHashFile: + if args.download_hashview: + # Exit if called from command line + sys.exit(0) + # Otherwise continue the menu loop + else: + menu_loop = False + elif choice == "2" or args.weakpass: + weakpass_wordlist_menu(rank=args.rank) + if args.weakpass: + sys.exit(0) + # Otherwise continue the menu loop + elif choice == "3" or args.hashmob: + download_hashmob_wordlists(print_fn=print) + if args.hashmob: + sys.exit(0) + # Otherwise continue the menu loop + elif choice == "4" or args.rules: + download_hashmob_rules(print_fn=print) + if args.rules: + sys.exit(0) + # Otherwise continue the menu loop + elif choice == "5": + sys.exit(0) + else: + if args.download_hashview or args.weakpass or args.hashmob or args.rules: + sys.exit(0) - hcatHashFileOrig = hcatHashFile + # At this point, a hashfile must be loaded + if not hcatHashFile: + print("\n✗ Error: No hashfile loaded. Exiting.") + sys.exit(1) + + # Store original hashfile path if not already set (e.g., when downloaded from Hashview) + if not hcatHashFileOrig: + hcatHashFileOrig = hcatHashFile ascii_art() # Get Initial Input Hash Count - hcatHashCount = lineCount(hcatHashFile) # If LM or NT Mode Selected and pwdump Format Detected, Prompt For LM to NT Attack if hcatHashType == "1000": @@ -2675,12 +2964,21 @@ def main(): _write_field_sorted_unique(hcatHashFile, f"{hcatHashFile}.nt", 4) print("Parsing LM hashes...") _write_field_sorted_unique(hcatHashFile, f"{hcatHashFile}.lm", 3) - if ((lineCount(hcatHashFile + ".lm") == 1) and ( - hcatHashFileLine.split(":")[2].lower() != "aad3b435b51404eeaad3b435b51404ee")) or ( - lineCount(hcatHashFile + ".lm") > 1): + if ( + (lineCount(hcatHashFile + ".lm") == 1) + and ( + hcatHashFileLine.split(":")[2].lower() + != "aad3b435b51404eeaad3b435b51404ee" + ) + ) or (lineCount(hcatHashFile + ".lm") > 1): lmHashesFound = True - lmChoice = input("LM hashes identified. Would you like to brute force the LM hashes first? (Y) ") or "Y" - if lmChoice.upper() == 'Y': + lmChoice = ( + input( + "LM hashes identified. Would you like to brute force the LM hashes first? (Y) " + ) + or "Y" + ) + if lmChoice.upper() == "Y": hcatLMtoNT() hcatHashFileOrig = hcatHashFile hcatHashFile = hcatHashFile + ".nt" @@ -2706,7 +3004,10 @@ def main(): _run_hashcat_show(hcatHashType, hcatHashFile, f"{hcatHashFile}.out") hcatHashCracked = lineCount(hcatHashFile + ".out") if hcatHashCracked > 0: - print("Found %d hashes already cracked.\nCopied hashes to %s.out" % (hcatHashCracked, hcatHashFile)) + print( + "Found %d hashes already cracked.\nCopied hashes to %s.out" + % (hcatHashCracked, hcatHashFile) + ) else: print("No hashes found in POT file.") @@ -2745,6 +3046,7 @@ def main(): except KeyboardInterrupt: quit_hc() + # Boilerplate -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/pyproject.toml b/pyproject.toml index 0f041ef..0fc8ace 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,3 +25,18 @@ hate_crack = [ "hashcat-utils/**", "princeprocessor/**", ] + +[tool.ruff] +exclude = [ + "build", + "dist", + "PACK", + "hashcat-utils", + "princeprocessor", + "wordlists", +] + +[tool.pytest.ini_options] +testpaths = [ + "tests", +] diff --git a/tests/test_api.py b/tests/test_api.py index fdd44d8..2a44c28 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,21 +1,22 @@ -import os -import sys -import types import pytest from unittest import mock import hate_crack.api as api + @pytest.fixture def fake_file(tmp_path): return tmp_path / "testfile.txt" + @pytest.fixture def patch_dependencies(tmp_path): # Patch sanitize_filename to just return the filename patch1 = mock.patch("hate_crack.api.sanitize_filename", side_effect=lambda x: x) # Patch get_hcat_wordlists_dir to return tmp_path - patch2 = mock.patch("hate_crack.api.get_hcat_wordlists_dir", return_value=str(tmp_path)) + patch2 = mock.patch( + "hate_crack.api.get_hcat_wordlists_dir", return_value=str(tmp_path) + ) # Patch extract_with_7z to just return True patch3 = mock.patch("hate_crack.api.extract_with_7z", return_value=True) # Patch os.replace to do nothing @@ -29,16 +30,18 @@ def patch_dependencies(tmp_path): for p in patches: p.stop() -def make_mock_response(content=b"abc", total=3, status_code=200, endswith='.txt'): + +def make_mock_response(content=b"abc", total=3, status_code=200, endswith=".txt"): mock_resp = mock.MagicMock() mock_resp.__enter__.return_value = mock_resp mock_resp.__exit__.return_value = False mock_resp.iter_content = lambda chunk_size: [content] - mock_resp.headers = {'content-length': str(total)} + mock_resp.headers = {"content-length": str(total)} mock_resp.status_code = status_code mock_resp.raise_for_status = mock.Mock() return mock_resp + def test_download_success(tmp_path, patch_dependencies): file_name = "wordlist.txt" out_path = str(tmp_path / file_name) @@ -49,6 +52,7 @@ def test_download_success(tmp_path, patch_dependencies): assert result is True m_open.assert_called() # File was opened for writing + def test_download_7z_triggers_extract(tmp_path, patch_dependencies): file_name = "archive.7z" out_path = str(tmp_path / file_name) @@ -61,26 +65,35 @@ def test_download_7z_triggers_extract(tmp_path, patch_dependencies): assert result is True m_extract.assert_called_once() + def test_download_keyboard_interrupt(tmp_path, patch_dependencies): file_name = "wordlist.txt" out_path = str(tmp_path / file_name) + # Simulate KeyboardInterrupt in requests.get context manager def raise_keyboard_interrupt(*a, **kw): raise KeyboardInterrupt() - with mock.patch("hate_crack.api.requests.get", side_effect=raise_keyboard_interrupt): + + with mock.patch( + "hate_crack.api.requests.get", side_effect=raise_keyboard_interrupt + ): result = api.download_official_wordlist(file_name, out_path) assert result is False + def test_download_exception(tmp_path, patch_dependencies): file_name = "wordlist.txt" out_path = str(tmp_path / file_name) + # Simulate generic Exception in requests.get context manager def raise_exception(*a, **kw): raise Exception("fail") + with mock.patch("hate_crack.api.requests.get", side_effect=raise_exception): result = api.download_official_wordlist(file_name, out_path) assert result is False + def test_progress_bar_prints(tmp_path, patch_dependencies, capsys): file_name = "wordlist.txt" out_path = str(tmp_path / file_name) @@ -92,4 +105,4 @@ def test_progress_bar_prints(tmp_path, patch_dependencies, capsys): result = api.download_official_wordlist(file_name, out_path) assert result is True captured = capsys.readouterr() - assert "Downloaded" in captured.out or "Downloaded" in captured.err \ No newline at end of file + assert "Downloaded" in captured.out or "Downloaded" in captured.err diff --git a/tests/test_asset_path_separation.py b/tests/test_asset_path_separation.py index 3f976f1..76da93a 100644 --- a/tests/test_asset_path_separation.py +++ b/tests/test_asset_path_separation.py @@ -4,35 +4,35 @@ not from hcatPath (which should point to hashcat binary location). This prevents regression where hcatPath was incorrectly used for utilities. """ + import os import json import tempfile -import pytest def test_hashcat_utils_uses_hate_path_not_hcat_path(tmp_path, monkeypatch): """ Verify that hashcat-utils is loaded from hate_crack repo, not hcatPath. - + This test ensures that even when hcatPath points to a different directory (like /opt/hashcat), the code correctly uses hate_path for utilities. """ # Set HATE_CRACK_SKIP_INIT to prevent initialization checks monkeypatch.setenv("HATE_CRACK_SKIP_INIT", "1") - + # Import after setting env var from hate_crack import main - + # The hate_path should be the hate_crack repository assert main.hate_path is not None assert os.path.isdir(main.hate_path) assert os.path.isdir(os.path.join(main.hate_path, "hashcat-utils")) - + # The hcatPath might be different (fallback to hate_path if empty) # But utilities should ALWAYS use hate_path assert main.hcatPath is not None - - # Key assertion: even if hcatPath != hate_path, + + # Key assertion: even if hcatPath != hate_path, # the code should look for utilities in hate_path # This is verified by checking the actual code paths used @@ -43,11 +43,9 @@ def test_config_with_explicit_hashcat_path(): the code still finds utilities in the hate_crack repository. """ import os - import tempfile - import json - + # Create a temporary config with hcatPath set to a different location - with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: test_config = { "hcatPath": "/opt/hashcat", # Different from hate_crack repo "hcatBin": "hashcat", @@ -64,42 +62,38 @@ def test_config_with_explicit_hashcat_path(): "hcatThoroughBaseList": ["rockyou.txt"], "hcatThoroughCombList": ["rockyou.txt"], "hashview_url": "https://localhost:8443", - "hashview_api_key": "" + "hashview_api_key": "", } json.dump(test_config, f) config_path = f.name - + try: # Load the config with open(config_path) as f: config = json.load(f) - + # Verify that hcatPath is set to /opt/hashcat - assert config['hcatPath'] == '/opt/hashcat' - + assert config["hcatPath"] == "/opt/hashcat" + # This documents the expected behavior: # - hcatPath = /opt/hashcat (for hashcat binary) # - hashcat-utils should be found in hate_crack repo, not /opt/hashcat - + finally: os.unlink(config_path) def test_readme_documents_correct_usage(): """Verify README correctly explains hcatPath vs asset locations.""" - readme_path = os.path.join( - os.path.dirname(os.path.dirname(__file__)), - 'README.md' - ) - + readme_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "README.md") + with open(readme_path) as f: readme = f.read() - + # Check that README mentions the correct relationship - assert 'hcatPath' in readme - assert 'repository directory' in readme - assert 'hashcat-utils' in readme - + assert "hcatPath" in readme + assert "repository directory" in readme + assert "hashcat-utils" in readme + # Should NOT suggest putting hashcat-utils in hashcat directory # (This is a documentation test to prevent confusing users) - diff --git a/tests/test_cli_menus.py b/tests/test_cli_menus.py index e95cb0b..90ff38d 100644 --- a/tests/test_cli_menus.py +++ b/tests/test_cli_menus.py @@ -3,40 +3,62 @@ import sys import os import pytest -HATE_CRACK_SCRIPT = os.path.join(os.path.dirname(__file__), '..', 'hate_crack.py') +HATE_CRACK_SCRIPT = os.path.join(os.path.dirname(__file__), "..", "hate_crack.py") -@pytest.mark.parametrize("flag,menu_text,alt_text", [ - ("--hashview", "Available Customers", None), - ("--weakpass", "Available Wordlists", None), - ("--hashmob", "Official Hashmob Wordlists", None), -]) + +@pytest.mark.parametrize( + "flag,menu_text,alt_text", + [ + ("--hashview", "Available Customers", None), + ("--weakpass", "Available Wordlists", None), + ("--hashmob", "Official Hashmob Wordlists", None), + ], +) def test_direct_menu_flags(monkeypatch, flag, menu_text, alt_text): # Only check external services if explicitly enabled - if flag == "--hashmob" and not os.environ.get('HASHMOB_TEST_REAL', '').lower() in ('1', 'true', 'yes'): + if flag == "--hashmob" and os.environ.get("HASHMOB_TEST_REAL", "").lower() not in ( + "1", + "true", + "yes", + ): pytest.skip("Skipping --hashmob test unless HASHMOB_TEST_REAL is set.") - if flag == "--hashview" and not os.environ.get('HASHVIEW_TEST_REAL', '').lower() in ('1', 'true', 'yes'): + if flag == "--hashview" and os.environ.get( + "HASHVIEW_TEST_REAL", "" + ).lower() not in ("1", "true", "yes"): pytest.skip("Skipping --hashview test unless HASHVIEW_TEST_REAL is set.") - if flag == "--weakpass" and not os.environ.get('WEAKPASS_TEST_REAL', '').lower() in ('1', 'true', 'yes'): + if flag == "--weakpass" and os.environ.get( + "WEAKPASS_TEST_REAL", "" + ).lower() not in ("1", "true", "yes"): pytest.skip("Skipping --weakpass test unless WEAKPASS_TEST_REAL is set.") cli_cmd = [sys.executable, HATE_CRACK_SCRIPT, flag] + def fake_input(prompt): - return 'q' - monkeypatch.setattr('builtins.input', fake_input) + return "q" + + monkeypatch.setattr("builtins.input", fake_input) result = subprocess.run( cli_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, - env={**os.environ, 'PYTHONUNBUFFERED': '1'} + env={**os.environ, "PYTHONUNBUFFERED": "1"}, ) output = result.stdout + result.stderr # If Hashmob is down, skip the test for --hashmob - if flag == "--hashmob" and ("523" in output or "Server Error" in output or "Error listing official wordlists" in output): + if flag == "--hashmob" and ( + "523" in output + or "Server Error" in output + or "Error listing official wordlists" in output + ): pytest.skip("Hashmob is down or unreachable (error 523 or server error)") if alt_text: - assert menu_text in output or alt_text in output, f"Expected '{menu_text}' or '{alt_text}' in output for flag {flag}" + assert menu_text in output or alt_text in output, ( + f"Expected '{menu_text}' or '{alt_text}' in output for flag {flag}" + ) else: - assert menu_text in output, f"Menu text '{menu_text}' not found in output for flag {flag}" + assert menu_text in output, ( + f"Menu text '{menu_text}' not found in output for flag {flag}" + ) # Accept returncode 1 for --hashview, since 'q' is not a valid customer ID and triggers an error exit if flag == "--hashview": assert result.returncode in (0, 1, 130) diff --git a/tests/test_cli_weakpass.py b/tests/test_cli_weakpass.py index 3df2409..516ad20 100644 --- a/tests/test_cli_weakpass.py +++ b/tests/test_cli_weakpass.py @@ -5,7 +5,11 @@ import pytest def test_cli_weakpass_exits(hc_module, monkeypatch, capsys): hc = hc_module - monkeypatch.setattr(hc, "weakpass_wordlist_menu", lambda **kwargs: print("weakpass_wordlist_menu called")) + monkeypatch.setattr( + hc, + "weakpass_wordlist_menu", + lambda **kwargs: print("weakpass_wordlist_menu called"), + ) monkeypatch.setattr(sys, "argv", ["hate_crack.py", "--weakpass"]) with pytest.raises(SystemExit) as excinfo: hc.main() diff --git a/tests/test_dependencies.py b/tests/test_dependencies.py index 3eba532..be8f689 100644 --- a/tests/test_dependencies.py +++ b/tests/test_dependencies.py @@ -8,7 +8,11 @@ import pytest def _require_executable(name): if shutil.which(name) is None: warnings.warn(f"Missing required dependency: {name}", RuntimeWarning) - if os.environ.get("HATE_CRACK_REQUIRE_DEPS", "").lower() in ("1", "true", "yes"): + if os.environ.get("HATE_CRACK_REQUIRE_DEPS", "").lower() in ( + "1", + "true", + "yes", + ): pytest.fail(f"Required dependency not installed: {name}") pytest.skip(f"Missing required dependency: {name}") diff --git a/tests/test_docker_script_install.py b/tests/test_docker_script_install.py index 76e652f..1df1df7 100644 --- a/tests/test_docker_script_install.py +++ b/tests/test_docker_script_install.py @@ -34,12 +34,11 @@ def docker_image(): pytest.fail(f"Docker build timed out after {exc.timeout}s") assert build.returncode == 0, ( - "Docker build failed. " - f"stdout={build.stdout} stderr={build.stderr}" + f"Docker build failed. stdout={build.stdout} stderr={build.stderr}" ) - + yield image_tag - + # Cleanup: remove the Docker image after tests complete try: result = subprocess.run( @@ -52,11 +51,14 @@ def docker_image(): print( f"Warning: Failed to remove Docker image {image_tag}. " f"stderr={result.stderr}", - file=sys.stderr + file=sys.stderr, ) except Exception as e: # Don't fail the test if cleanup fails, but log the issue - print(f"Warning: Exception while removing Docker image {image_tag}: {e}", file=sys.stderr) + print( + f"Warning: Exception while removing Docker image {image_tag}: {e}", + file=sys.stderr, + ) def _run_container(image_tag, command, timeout=180): @@ -79,8 +81,7 @@ def test_docker_script_install_and_run(docker_image): timeout=120, ) assert run.returncode == 0, ( - "Docker script install/run failed. " - f"stdout={run.stdout} stderr={run.stderr}" + f"Docker script install/run failed. stdout={run.stdout} stderr={run.stderr}" ) @@ -94,6 +95,5 @@ def test_docker_hashcat_cracks_simple_password(docker_image): ) run = _run_container(docker_image, command, timeout=180) assert run.returncode == 0, ( - "Docker hashcat crack failed. " - f"stdout={run.stdout} stderr={run.stderr}" + f"Docker hashcat crack failed. stdout={run.stdout} stderr={run.stderr}" ) diff --git a/tests/test_e2e_local_install.py b/tests/test_e2e_local_install.py index 388ca8d..5955e26 100644 --- a/tests/test_e2e_local_install.py +++ b/tests/test_e2e_local_install.py @@ -38,8 +38,7 @@ def test_local_uv_tool_install_and_help(tmp_path): text=True, ) assert install.returncode == 0, ( - "uv tool install failed. " - f"stdout={install.stdout} stderr={install.stderr}" + f"uv tool install failed. stdout={install.stdout} stderr={install.stderr}" ) tool_help = subprocess.run( @@ -50,8 +49,7 @@ def test_local_uv_tool_install_and_help(tmp_path): text=True, ) assert tool_help.returncode == 0, ( - "hate_crack --help failed. " - f"stdout={tool_help.stdout} stderr={tool_help.stderr}" + f"hate_crack --help failed. stdout={tool_help.stdout} stderr={tool_help.stderr}" ) script_help = subprocess.run( diff --git a/tests/test_hashmob_connectivity.py b/tests/test_hashmob_connectivity.py index 0a397d8..cb7e250 100644 --- a/tests/test_hashmob_connectivity.py +++ b/tests/test_hashmob_connectivity.py @@ -1,4 +1,3 @@ - import os import re import pytest @@ -6,30 +5,30 @@ from hate_crack.api import download_hashmob_wordlist_list def test_hashmob_connectivity_real(capsys): - if not os.environ.get('HASHMOB_TEST_REAL', '').lower() in ('1', 'true', 'yes'): + if os.environ.get("HASHMOB_TEST_REAL", "").lower() not in ("1", "true", "yes"): # Mocked response result = [ - {'name': 'mock_wordlist_1', 'information': 'Mock info 1'}, - {'name': 'mock_wordlist_2', 'information': 'Mock info 2'} + {"name": "mock_wordlist_1", "information": "Mock info 1"}, + {"name": "mock_wordlist_2", "information": "Mock info 2"}, ] print("Available Hashmob Wordlists:") for idx, wl in enumerate(result): - print(f"{idx+1}. {wl['name']} - {wl['information']}") + print(f"{idx + 1}. {wl['name']} - {wl['information']}") captured = capsys.readouterr() else: try: result = download_hashmob_wordlist_list() except Exception as e: - if '523' in str(e) or 'HTTP ERROR 523' in str(e): + if "523" in str(e) or "HTTP ERROR 523" in str(e): pytest.skip("Hashmob returned HTTP ERROR 523 (Origin is unreachable)") pytest.skip(f"Network or API unavailable: {e}") captured = capsys.readouterr() - if 'HTTP ERROR 523' in captured.out or '523' in captured.out: + if "HTTP ERROR 523" in captured.out or "523" in captured.out: pytest.skip("Hashmob returned HTTP ERROR 523 (Origin is unreachable)") assert isinstance(result, list) - assert any('name' in wl for wl in result) + assert any("name" in wl for wl in result) # Check for at least one wordlist name in output using regex - names = [wl['name'] for wl in result if 'name' in wl] + names = [wl["name"] for wl in result if "name" in wl] found = False for name in names: if re.search(re.escape(name), captured.out): diff --git a/tests/test_hashview.py b/tests/test_hashview.py index f628239..4ad19bb 100644 --- a/tests/test_hashview.py +++ b/tests/test_hashview.py @@ -1,6 +1,7 @@ """ Tests for Hashview integration - Mocked API calls for CI/CD """ + import pytest import sys import os @@ -11,47 +12,44 @@ from unittest.mock import Mock, patch, MagicMock # Add the parent directory to the path to import hate_crack -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) from hate_crack.api import HashviewAPI # Test configuration - these are mock values, not real credentials -HASHVIEW_URL = 'https://hashview.example.com' -HASHVIEW_API_KEY = 'test-api-key-123' +HASHVIEW_URL = "https://hashview.example.com" +HASHVIEW_API_KEY = "test-api-key-123" class TestHashviewAPI: """Test suite for HashviewAPI class with mocked API calls""" def _get_hashview_config(self): - env_url = os.environ.get('HASHVIEW_URL') - env_key = os.environ.get('HASHVIEW_API_KEY') + env_url = os.environ.get("HASHVIEW_URL") + env_key = os.environ.get("HASHVIEW_API_KEY") if env_url and env_key: return env_url, env_key - config_path = os.path.join(os.path.dirname(__file__), '..', 'config.json') + config_path = os.path.join(os.path.dirname(__file__), "..", "config.json") try: with open(config_path) as f: config = json.load(f) - url = config.get('hashview_url') - key = config.get('hashview_api_key') + url = config.get("hashview_url") + key = config.get("hashview_api_key") if url and key: return url, key except Exception: pass return env_url, env_key - + @pytest.fixture def api(self): """Create a HashviewAPI instance with mocked session""" - with patch('requests.Session') as mock_session_class: - api = HashviewAPI( - base_url=HASHVIEW_URL, - api_key=HASHVIEW_API_KEY - ) + with patch("requests.Session") as mock_session_class: + api = HashviewAPI(base_url=HASHVIEW_URL, api_key=HASHVIEW_API_KEY) # Replace the session with a mock api.session = MagicMock() yield api - + @pytest.fixture def test_hashfile(self): """Create a temporary test hashfile with NTLM hashes""" @@ -60,14 +58,14 @@ class TestHashviewAPI: "e19ccf75ee54e06b06a5907af13cef42", # 123456 (NTLM) "5835048ce94ad0564e29a924a03510ef", # 12345678 (NTLM) ] - - with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f: + + with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f: hashfile_path = f.name for hash_val in test_hashes: - f.write(hash_val + '\n') - + f.write(hash_val + "\n") + yield hashfile_path - + # Cleanup def test_list_hashfiles_success(self, api): @@ -79,21 +77,23 @@ class TestHashviewAPI: assert isinstance(result, list) # If there are no hashfiles, that's valid, but if present, check structure if result: - assert 'name' in result[0] + assert "name" in result[0] else: mock_response = Mock() mock_response.json.return_value = { - 'hashfiles': json.dumps([ - {'id': 1, 'customer_id': 1, 'name': 'hashfile1.txt'}, - {'id': 2, 'customer_id': 2, 'name': 'hashfile2.txt'} - ]) + "hashfiles": json.dumps( + [ + {"id": 1, "customer_id": 1, "name": "hashfile1.txt"}, + {"id": 2, "customer_id": 2, "name": "hashfile2.txt"}, + ] + ) } mock_response.raise_for_status = Mock() api.session.get.return_value = mock_response result = api.list_hashfiles() assert isinstance(result, list) assert len(result) == 2 - assert result[0]['name'] == 'hashfile1.txt' + assert result[0]["name"] == "hashfile1.txt" def test_list_hashfiles_empty(self, api): """Test hashfile listing returns empty list if no hashfiles (real API if possible).""" @@ -117,23 +117,25 @@ class TestHashviewAPI: def test_get_customer_hashfiles(self, api): """Test filtering hashfiles by customer_id (real API if possible).""" hashview_url, hashview_api_key = self._get_hashview_config() - customer_id = os.environ.get('HASHVIEW_CUSTOMER_ID') + customer_id = os.environ.get("HASHVIEW_CUSTOMER_ID") if hashview_url and hashview_api_key and customer_id: real_api = HashviewAPI(hashview_url, hashview_api_key) result = real_api.get_customer_hashfiles(int(customer_id)) assert isinstance(result, list) # If there are hashfiles, all should match customer_id if result: - assert all(hf['customer_id'] == int(customer_id) for hf in result) + assert all(hf["customer_id"] == int(customer_id) for hf in result) else: - api.list_hashfiles = Mock(return_value=[ - {'id': 1, 'customer_id': 1, 'name': 'hashfile1.txt'}, - {'id': 2, 'customer_id': 2, 'name': 'hashfile2.txt'}, - {'id': 3, 'customer_id': 1, 'name': 'hashfile3.txt'} - ]) + api.list_hashfiles = Mock( + return_value=[ + {"id": 1, "customer_id": 1, "name": "hashfile1.txt"}, + {"id": 2, "customer_id": 2, "name": "hashfile2.txt"}, + {"id": 3, "customer_id": 1, "name": "hashfile3.txt"}, + ] + ) result = api.get_customer_hashfiles(1) assert len(result) == 2 - assert all(hf['customer_id'] == 1 for hf in result) + assert all(hf["customer_id"] == 1 for hf in result) def test_display_customers_multicolumn_empty(self, api, capsys): """Test display_customers_multicolumn with no customers (mock only, as real API not needed).""" @@ -144,42 +146,48 @@ class TestHashviewAPI: def test_upload_cracked_hashes_success(self, api, tmp_path): """Test uploading cracked hashes with valid lines (real API if possible).""" hashview_url, hashview_api_key = self._get_hashview_config() - hash_type = os.environ.get('HASHVIEW_HASH_TYPE', '1000') + hash_type = os.environ.get("HASHVIEW_HASH_TYPE", "1000") if hashview_url and hashview_api_key: real_api = HashviewAPI(hashview_url, hashview_api_key) cracked_file = tmp_path / "cracked.txt" - cracked_file.write_text("8846f7eaee8fb117ad06bdd830b7586c:password\n" - "e19ccf75ee54e06b06a5907af13cef42:123456\n") + cracked_file.write_text( + "8846f7eaee8fb117ad06bdd830b7586c:password\n" + "e19ccf75ee54e06b06a5907af13cef42:123456\n" + ) try: - result = real_api.upload_cracked_hashes(str(cracked_file), hash_type=hash_type) - assert 'imported' in result + result = real_api.upload_cracked_hashes( + str(cracked_file), hash_type=hash_type + ) + assert "imported" in result except Exception as e: # If the API does not allow upload, skip pytest.skip(f"Real API upload_cracked_hashes not allowed: {e}") else: cracked_file = tmp_path / "cracked.txt" - cracked_file.write_text("8846f7eaee8fb117ad06bdd830b7586c:password\n" - "e19ccf75ee54e06b06a5907af13cef42:123456\n" - "31d6cfe0d16ae931b73c59d7e0c089c0:should_skip\n" - "invalidline\n") + cracked_file.write_text( + "8846f7eaee8fb117ad06bdd830b7586c:password\n" + "e19ccf75ee54e06b06a5907af13cef42:123456\n" + "31d6cfe0d16ae931b73c59d7e0c089c0:should_skip\n" + "invalidline\n" + ) mock_response = Mock() - mock_response.json.return_value = {'imported': 2} + mock_response.json.return_value = {"imported": 2} mock_response.raise_for_status = Mock() api.session.post.return_value = mock_response - result = api.upload_cracked_hashes(str(cracked_file), hash_type='1000') - assert 'imported' in result - assert result['imported'] == 2 + result = api.upload_cracked_hashes(str(cracked_file), hash_type="1000") + assert "imported" in result + assert result["imported"] == 2 def test_upload_cracked_hashes_api_error(self, api, tmp_path): """Test uploading cracked hashes with API error response (mock only).""" cracked_file = tmp_path / "cracked.txt" cracked_file.write_text("8846f7eaee8fb117ad06bdd830b7586c:password\n") mock_response = Mock() - mock_response.json.return_value = {'type': 'Error', 'msg': 'Some error'} + mock_response.json.return_value = {"type": "Error", "msg": "Some error"} mock_response.raise_for_status = Mock() api.session.post.return_value = mock_response with pytest.raises(Exception) as excinfo: - api.upload_cracked_hashes(str(cracked_file), hash_type='1000') + api.upload_cracked_hashes(str(cracked_file), hash_type="1000") assert "Hashview API Error" in str(excinfo.value) def test_upload_cracked_hashes_invalid_json(self, api, tmp_path): @@ -192,7 +200,7 @@ class TestHashviewAPI: mock_response.raise_for_status = Mock() api.session.post.return_value = mock_response with pytest.raises(Exception) as excinfo: - api.upload_cracked_hashes(str(cracked_file), hash_type='1000') + api.upload_cracked_hashes(str(cracked_file), hash_type="1000") assert "Invalid API response" in str(excinfo.value) def test_create_customer_success(self, api): @@ -202,74 +210,80 @@ class TestHashviewAPI: real_api = HashviewAPI(hashview_url, hashview_api_key) try: result = real_api.create_customer("New Customer Test") - assert 'id' in result - assert 'name' in result + assert "id" in result + assert "name" in result except Exception as e: pytest.skip(f"Real API create_customer not allowed: {e}") else: mock_response = Mock() - mock_response.json.return_value = {'id': 10, 'name': 'New Customer'} + mock_response.json.return_value = {"id": 10, "name": "New Customer"} mock_response.raise_for_status = Mock() api.session.post.return_value = mock_response result = api.create_customer("New Customer") - assert result['id'] == 10 - assert result['name'] == "New Customer" + assert result["id"] == 10 + assert result["name"] == "New Customer" def test_download_left_hashes(self, api, tmp_path): """Test downloading left hashes: real API if possible, else mock.""" hashview_url, hashview_api_key = self._get_hashview_config() - customer_id = os.environ.get('HASHVIEW_CUSTOMER_ID') - hashfile_id = os.environ.get('HASHVIEW_HASHFILE_ID') + customer_id = os.environ.get("HASHVIEW_CUSTOMER_ID") + hashfile_id = os.environ.get("HASHVIEW_HASHFILE_ID") if all([hashview_url, hashview_api_key, customer_id, hashfile_id]): # Real API test real_api = HashviewAPI(hashview_url, hashview_api_key) output_file = tmp_path / f"left_{customer_id}_{hashfile_id}.txt" - result = real_api.download_left_hashes(int(customer_id), int(hashfile_id), output_file=str(output_file)) - assert os.path.exists(result['output_file']) - with open(result['output_file'], 'rb') as f: + result = real_api.download_left_hashes( + int(customer_id), int(hashfile_id), output_file=str(output_file) + ) + assert os.path.exists(result["output_file"]) + with open(result["output_file"], "rb") as f: content = f.read() print(f"[DEBUG] Downloaded {len(content)} bytes to {result['output_file']}") - assert result['size'] == len(content) + assert result["size"] == len(content) else: # Mock test mock_response = Mock() mock_response.content = b"hash1\nhash2\n" mock_response.raise_for_status = Mock() - mock_response.headers = {'content-length': '0'} + mock_response.headers = {"content-length": "0"} + def iter_content(chunk_size=8192): yield mock_response.content + mock_response.iter_content = iter_content api.session.get.return_value = mock_response output_file = tmp_path / "left_1_2.txt" result = api.download_left_hashes(1, 2, output_file=str(output_file)) - assert os.path.exists(result['output_file']) - with open(result['output_file'], 'rb') as f: + assert os.path.exists(result["output_file"]) + with open(result["output_file"], "rb") as f: content = f.read() assert content == b"hash1\nhash2\n" - assert result['size'] == len(content) + assert result["size"] == len(content) def test_download_found_hashes(self, api, tmp_path): """Test downloading found hashes: real API if possible, else mock.""" hashview_url, hashview_api_key = self._get_hashview_config() - customer_id = os.environ.get('HASHVIEW_CUSTOMER_ID') - hashfile_id = os.environ.get('HASHVIEW_HASHFILE_ID') + customer_id = os.environ.get("HASHVIEW_CUSTOMER_ID") + hashfile_id = os.environ.get("HASHVIEW_HASHFILE_ID") if all([hashview_url, hashview_api_key, customer_id, hashfile_id]): # Real API test real_api = HashviewAPI(hashview_url, hashview_api_key) output_file = tmp_path / f"found_{customer_id}_{hashfile_id}.txt" - result = real_api.download_found_hashes(int(customer_id), int(hashfile_id), output_file=str(output_file)) - assert os.path.exists(result['output_file']) - with open(result['output_file'], 'rb') as f: + result = real_api.download_found_hashes( + int(customer_id), int(hashfile_id), output_file=str(output_file) + ) + assert os.path.exists(result["output_file"]) + with open(result["output_file"], "rb") as f: content = f.read() print(f"[DEBUG] Downloaded {len(content)} bytes to {result['output_file']}") - assert result['size'] == len(content) + assert result["size"] == len(content) else: # Mock test mock_response = Mock() mock_response.content = b"hash1:pass1\nhash2:pass2\n" mock_response.raise_for_status = Mock() - mock_response.headers = {'content-length': '0'} + mock_response.headers = {"content-length": "0"} def iter_content(chunk_size=8192): yield mock_response.content @@ -279,30 +293,32 @@ class TestHashviewAPI: output_file = tmp_path / "found_1_2.txt" result = api.download_found_hashes(1, 2, output_file=str(output_file)) - assert os.path.exists(result['output_file']) - with open(result['output_file'], 'rb') as f: + assert os.path.exists(result["output_file"]) + with open(result["output_file"], "rb") as f: content = f.read() assert content == b"hash1:pass1\nhash2:pass2\n" - assert result['size'] == len(content) + assert result["size"] == len(content) def test_download_wordlist(self, api, tmp_path): """Test downloading a wordlist: real API if possible, else mock.""" hashview_url, hashview_api_key = self._get_hashview_config() - wordlist_id = os.environ.get('HASHVIEW_WORDLIST_ID') + wordlist_id = os.environ.get("HASHVIEW_WORDLIST_ID") if all([hashview_url, hashview_api_key, wordlist_id]): real_api = HashviewAPI(hashview_url, hashview_api_key) output_file = tmp_path / f"wordlist_{wordlist_id}.gz" - result = real_api.download_wordlist(int(wordlist_id), output_file=str(output_file)) - assert os.path.exists(result['output_file']) - with open(result['output_file'], 'rb') as f: + result = real_api.download_wordlist( + int(wordlist_id), output_file=str(output_file) + ) + assert os.path.exists(result["output_file"]) + with open(result["output_file"], "rb") as f: content = f.read() print(f"[DEBUG] Downloaded {len(content)} bytes to {result['output_file']}") - assert result['size'] == len(content) + assert result["size"] == len(content) else: mock_response = Mock() mock_response.content = b"gzipdata" mock_response.raise_for_status = Mock() - mock_response.headers = {'content-length': '0'} + mock_response.headers = {"content-length": "0"} def iter_content(chunk_size=8192): yield mock_response.content @@ -312,97 +328,90 @@ class TestHashviewAPI: output_file = tmp_path / "wordlist_1.gz" result = api.download_wordlist(1, output_file=str(output_file)) - assert os.path.exists(result['output_file']) - with open(result['output_file'], 'rb') as f: + assert os.path.exists(result["output_file"]) + with open(result["output_file"], "rb") as f: content = f.read() assert content == b"gzipdata" - assert result['size'] == len(content) - + assert result["size"] == len(content) + def test_create_job_workflow(self, api, test_hashfile): """Test creating a job in Hashview (option 2 complete workflow)""" - print("\n" + "="*60) + print("\n" + "=" * 60) print("Testing Option 2: Create Job Workflow") - print("="*60) - + print("=" * 60) + # Mock responses for different endpoints - API returns 'users' as a JSON string mock_customers_response = Mock() mock_customers_response.json.return_value = { - 'users': json.dumps([{'id': 1, 'name': 'Test Customer'}]) + "users": json.dumps([{"id": 1, "name": "Test Customer"}]) } mock_customers_response.raise_for_status = Mock() - + mock_upload_response = Mock() mock_upload_response.json.return_value = { - 'hashfile_id': 4567, - 'msg': 'Hashfile added' + "hashfile_id": 4567, + "msg": "Hashfile added", } mock_upload_response.raise_for_status = Mock() - + mock_job_response = Mock() - mock_job_response.json.return_value = { - 'job_id': 789, - 'msg': 'Job added' - } + mock_job_response.json.return_value = {"job_id": 789, "msg": "Job added"} mock_job_response.raise_for_status = Mock() - + # Configure session mock api.session.get.return_value = mock_customers_response api.session.post.side_effect = [mock_upload_response, mock_job_response] - + # Step 1: Get test customer print("\n[Step 1] Getting test customer...") customers_result = api.list_customers() - test_customer = customers_result['customers'][0] - customer_id = test_customer['id'] + test_customer = customers_result["customers"][0] + customer_id = test_customer["id"] print(f" ✓ Using customer ID: {customer_id} ({test_customer['name']})") - + # Step 2: Upload hashfile print("\n[Step 2] Uploading hashfile...") hash_type = 1000 # NTLM file_format = 5 # hash_only hashfile_name = "test_hashfile_automated" - + upload_result = api.upload_hashfile( - test_hashfile, - customer_id, - hash_type, - file_format, - hashfile_name + test_hashfile, customer_id, hash_type, file_format, hashfile_name ) - - hashfile_id = upload_result['hashfile_id'] + + hashfile_id = upload_result["hashfile_id"] print(f" ✓ Hashfile ID: {hashfile_id}") - + # Step 3: Create job print("\n[Step 3] Creating job...") job_name = "test_job_automated" - + job_result = api.create_job( - name=job_name, - hashfile_id=hashfile_id, - customer_id=customer_id + name=job_name, hashfile_id=hashfile_id, customer_id=customer_id ) - + assert job_result is not None, "No job result returned" print(" ✓ Job created successfully") - - if 'job_id' in job_result: + + if "job_id" in job_result: print(f" ✓ Job ID: {job_result['job_id']}") - - print("\n" + "="*60) + + print("\n" + "=" * 60) print("✓ Option 2 (Create Job) is READY and WORKING!") - print("="*60) + print("=" * 60) def test_create_job_with_new_customer(self, api, test_hashfile): """Test creating a new customer and then creating a job (real API if possible).""" hashview_url, hashview_api_key = self._get_hashview_config() - hash_type = os.environ.get('HASHVIEW_HASH_TYPE', '1000') + hash_type = os.environ.get("HASHVIEW_HASH_TYPE", "1000") if hashview_url and hashview_api_key: real_api = HashviewAPI(hashview_url, hashview_api_key) customer_name = f"Example Customer {uuid.uuid4().hex[:8]}" try: customer_result = real_api.create_customer(customer_name) - customer_id = customer_result.get('customer_id') or customer_result.get('id') + customer_id = customer_result.get("customer_id") or customer_result.get( + "id" + ) if not customer_id: pytest.skip("Create customer did not return a customer_id.") upload_result = real_api.upload_hashfile( @@ -412,7 +421,7 @@ class TestHashviewAPI: 5, "test_hashfile_new_customer", ) - hashfile_id = upload_result.get('hashfile_id') + hashfile_id = upload_result.get("hashfile_id") if not hashfile_id: pytest.skip("Upload hashfile did not return a hashfile_id.") job_result = real_api.create_job( @@ -426,8 +435,8 @@ class TestHashviewAPI: pytest.xfail(f"Hashview rejected job creation: {msg}") assert job_result is not None if isinstance(job_result, dict): - assert 'job_id' in job_result - job_id = job_result.get('job_id') + assert "job_id" in job_result + job_id = job_result.get("job_id") try: real_api.start_job(job_id) except Exception: @@ -444,31 +453,237 @@ class TestHashviewAPI: pytest.skip(f"Real API create_job with new customer not allowed: {e}") else: mock_create_customer = Mock() - mock_create_customer.json.return_value = {'customer_id': 101, 'name': 'Example Customer'} + mock_create_customer.json.return_value = { + "customer_id": 101, + "name": "Example Customer", + } mock_create_customer.raise_for_status = Mock() mock_upload_hashfile = Mock() mock_upload_hashfile.json.return_value = { - 'hashfile_id': 202, - 'msg': 'Hashfile added' + "hashfile_id": 202, + "msg": "Hashfile added", } mock_upload_hashfile.raise_for_status = Mock() mock_create_job = Mock() - mock_create_job.json.return_value = { - 'job_id': 303, - 'msg': 'Job added' - } + mock_create_job.json.return_value = {"job_id": 303, "msg": "Job added"} mock_create_job.raise_for_status = Mock() - api.session.post.side_effect = [mock_create_customer, mock_upload_hashfile, mock_create_job] + api.session.post.side_effect = [ + mock_create_customer, + mock_upload_hashfile, + mock_create_job, + ] customer_result = api.create_customer("Example Customer") - assert customer_result.get('customer_id') == 101 - upload_result = api.upload_hashfile(test_hashfile, 101, 1000, 5, "test_hashfile_new_customer") - assert upload_result.get('hashfile_id') == 202 + assert customer_result.get("customer_id") == 101 + upload_result = api.upload_hashfile( + test_hashfile, 101, 1000, 5, "test_hashfile_new_customer" + ) + assert upload_result.get("hashfile_id") == 202 job_result = api.create_job("test_job_new_customer", 202, 101) - assert job_result.get('job_id') == 303 + assert job_result.get("job_id") == 303 -if __name__ == '__main__': - pytest.main([__file__, '-v']) + def test_file_format_detection(self, tmp_path): + """Test auto-detection of hashfile formats""" + # Test pwdump format (4+ colons) + pwdump_file = tmp_path / "pwdump.txt" + pwdump_file.write_text( + "Administrator:500:aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0:::\n" + ) + + # Test user:hash format (2 parts, non-hex username) + userhash_file = tmp_path / "userhash.txt" + userhash_file.write_text("user123:5f4dcc3b5aa765d61d8327deb882cf99\n") + + # Test hash_only format (default) + hashonly_file = tmp_path / "hashonly.txt" + hashonly_file.write_text("5f4dcc3b5aa765d61d8327deb882cf99\n") + + # Test hex:hash format (should be hash_only since first part is all hex) + hexhash_file = tmp_path / "hexhash.txt" + hexhash_file.write_text("abcdef123456:5f4dcc3b5aa765d61d8327deb882cf99\n") + + # Detection logic (same as in main.py) + def detect_format(filepath): + file_format = 5 # Default to hash_only + try: + with open(filepath, 'r', encoding='utf-8', errors='ignore') as f: + first_line = f.readline().strip() + if first_line: + parts = first_line.split(':') + if len(parts) >= 4: + file_format = 0 # pwdump + elif len(parts) == 2 and not all(c in '0123456789abcdefABCDEF' for c in parts[0]): + file_format = 4 # user:hash + except Exception: + file_format = 5 + return file_format + + # Verify detection + assert detect_format(pwdump_file) == 0, "Should detect pwdump format" + assert detect_format(userhash_file) == 4, "Should detect user:hash format" + assert detect_format(hashonly_file) == 5, "Should detect hash_only format" + assert detect_format(hexhash_file) == 5, "hex:hash should default to hash_only" + + def test_download_left_with_auto_merge(self, api, tmp_path, monkeypatch): + """Test that download_left automatically downloads and merges found hashes""" + # Use a different CWD than the output directory to ensure merging uses + # output_file's directory (not os.getcwd()). + other_cwd = tmp_path / "other_cwd" + other_cwd.mkdir() + monkeypatch.chdir(other_cwd) + + # Create existing .out file + out_file = tmp_path / "left_1_2.txt.out" + out_file.write_text("previously_cracked_hash1:password1\npreviously_cracked_hash2:password2\n") + + # Mock left hashes download + mock_left_response = Mock() + mock_left_response.content = b"uncracked_hash1\nuncracked_hash2\n" + mock_left_response.raise_for_status = Mock() + mock_left_response.headers = {"content-length": "0"} + + def iter_content_left(chunk_size=8192): + yield mock_left_response.content + + mock_left_response.iter_content = iter_content_left + + # Mock found hashes download + mock_found_response = Mock() + mock_found_response.content = b"found_hash1:found_password1\nfound_hash2:found_password2\n" + mock_found_response.raise_for_status = Mock() + mock_found_response.headers = {"content-length": "0"} + + def iter_content_found(chunk_size=8192): + yield mock_found_response.content + + mock_found_response.iter_content = iter_content_found + + # Set up session.get to return different responses + api.session.get.side_effect = [mock_left_response, mock_found_response] + + # Download left hashes (should auto-download and merge found) + left_file = tmp_path / "left_1_2.txt" + result = api.download_left_hashes(1, 2, output_file=str(left_file)) + + # Verify left file was created + assert os.path.exists(result["output_file"]) + + # Verify found file was downloaded, merged, and deleted + found_file = tmp_path / "found_1_2.txt" + assert not os.path.exists(found_file), "Found file should be deleted after merge" + assert not (other_cwd / "found_1_2.txt").exists() + + # Verify merged content in .out file + if os.path.exists(str(out_file)): + with open(str(out_file), 'r') as f: + merged_content = f.read() + # Should contain both previous and new found hashes + assert "previously_cracked_hash1:password1" in merged_content + assert "found_hash1:found_password1" in merged_content or "found_password1" in merged_content + + def test_download_left_id_matching(self, api, tmp_path): + """Test that found hashes only merge when customer_id and hashfile_id match""" + # Create .out file with specific IDs + out_file = tmp_path / "left_1_2.txt.out" + out_file.write_text("existing_hash:password\n") + + # Mock left hashes download for different IDs + mock_response = Mock() + mock_response.content = b"hash1\nhash2\n" + mock_response.raise_for_status = Mock() + mock_response.headers = {"content-length": "0"} + + def iter_content(chunk_size=8192): + yield mock_response.content + + mock_response.iter_content = iter_content + api.session.get.return_value = mock_response + + # Download left hashes with different IDs (3_4 instead of 1_2) + left_file = tmp_path / "left_3_4.txt" + result = api.download_left_hashes(3, 4, output_file=str(left_file)) + + # Verify the different IDs' .out file wasn't affected + with open(str(out_file), 'r') as f: + content = f.read() + assert content == "existing_hash:password\n", "Different ID's .out file should be unchanged" + + def test_download_left_tolerates_missing_found(self, api, tmp_path): + """Test that 404 on found hash download doesn't fail the workflow""" + # Mock successful left download + mock_left_response = Mock() + mock_left_response.content = b"hash1\nhash2\n" + mock_left_response.raise_for_status = Mock() + mock_left_response.headers = {"content-length": "0"} + + def iter_content(chunk_size=8192): + yield mock_left_response.content + + mock_left_response.iter_content = iter_content + + # Mock 404 response for found download + from requests.exceptions import HTTPError + mock_found_response = Mock() + mock_found_response.status_code = 404 + + def raise_404(): + response = Mock() + response.status_code = 404 + raise HTTPError("404 Not Found", response=response) + + mock_found_response.raise_for_status = raise_404 + + # Set up session.get to return different responses + api.session.get.side_effect = [mock_left_response, mock_found_response] + + # Download left hashes (should complete despite 404 on found) + left_file = tmp_path / "left_1_2.txt" + result = api.download_left_hashes(1, 2, output_file=str(left_file)) + + # Verify left file was created successfully + assert os.path.exists(result["output_file"]) + with open(result["output_file"], 'rb') as f: + content = f.read() + assert content == b"hash1\nhash2\n" + + def test_hashfile_orig_path_preservation(self, tmp_path): + """Test that original hashfile path is preserved before _ensure_hashfile_in_cwd""" + import sys + import os + sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + from hate_crack.main import _ensure_hashfile_in_cwd + + # Create a test hashfile in a different directory + test_dir = tmp_path / "subdir" + test_dir.mkdir() + test_file = test_dir / "test.txt" + test_file.write_text("hash1\nhash2\n") + + original_path = str(test_file) + + # Save current directory + orig_cwd = os.getcwd() + try: + # Change to tmp_path + os.chdir(str(tmp_path)) + + # Call _ensure_hashfile_in_cwd + result_path = _ensure_hashfile_in_cwd(original_path) + + # The result should be different from original (in cwd now) + # But original_path should still exist and be unchanged + assert os.path.exists(original_path), "Original file should still exist" + assert os.path.exists(result_path), "Result file should exist" + + # If they're different, result should be in cwd + if result_path != original_path: + assert os.path.dirname(result_path) == str(tmp_path), "Result should be in cwd" + finally: + os.chdir(orig_cwd) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_hashview_cli_subcommands.py b/tests/test_hashview_cli_subcommands.py index a1b01ca..7831855 100644 --- a/tests/test_hashview_cli_subcommands.py +++ b/tests/test_hashview_cli_subcommands.py @@ -1,4 +1,3 @@ -import os import sys import pytest @@ -12,7 +11,7 @@ class DummyHashviewAPI: self.debug = debug self.calls = [] - def upload_cracked_hashes(self, file_path, hash_type='1000'): + def upload_cracked_hashes(self, file_path, hash_type="1000"): self.calls.append(("upload_cracked_hashes", file_path, hash_type)) return {"msg": "Cracked hashes uploaded", "count": 2} @@ -21,19 +20,51 @@ class DummyHashviewAPI: return {"msg": "Wordlist uploaded", "wordlist_id": 123} def download_left_hashes(self, customer_id, hashfile_id, output_file=None): - self.calls.append(("download_left_hashes", customer_id, hashfile_id, output_file)) - return {"output_file": output_file or f"left_{customer_id}_{hashfile_id}.txt", "size": 10} + self.calls.append( + ("download_left_hashes", customer_id, hashfile_id, output_file) + ) + return { + "output_file": output_file or f"left_{customer_id}_{hashfile_id}.txt", + "size": 10, + } def download_found_hashes(self, customer_id, hashfile_id, output_file=None): - self.calls.append(("download_found_hashes", customer_id, hashfile_id, output_file)) - return {"output_file": output_file or f"found_{customer_id}_{hashfile_id}.txt", "size": 12} + self.calls.append( + ("download_found_hashes", customer_id, hashfile_id, output_file) + ) + return { + "output_file": output_file or f"found_{customer_id}_{hashfile_id}.txt", + "size": 12, + } - def upload_hashfile(self, file_path, customer_id, hash_type, file_format=5, hashfile_name=None): - self.calls.append(("upload_hashfile", file_path, customer_id, hash_type, file_format, hashfile_name)) + def upload_hashfile( + self, file_path, customer_id, hash_type, file_format=5, hashfile_name=None + ): + self.calls.append( + ( + "upload_hashfile", + file_path, + customer_id, + hash_type, + file_format, + hashfile_name, + ) + ) return {"msg": "Hashfile uploaded", "hashfile_id": 456} - def create_job(self, name, hashfile_id, customer_id, limit_recovered=False, notify_email=True): - self.calls.append(("create_job", name, hashfile_id, customer_id, limit_recovered, notify_email)) + def create_job( + self, name, hashfile_id, customer_id, limit_recovered=False, notify_email=True + ): + self.calls.append( + ( + "create_job", + name, + hashfile_id, + customer_id, + limit_recovered, + notify_email, + ) + ) return {"msg": "Job created", "job_id": 789} @@ -89,28 +120,9 @@ def test_hashview_cli_upload_wordlist(_patch_hashview, monkeypatch, tmp_path, ca assert "Wordlist uploaded" in captured.out -def test_hashview_cli_download_found(_patch_hashview, monkeypatch, tmp_path, capsys): - out_file = tmp_path / "found_1_2.txt" - code = _run_main_with_args( - monkeypatch, - [ - "hashview", - "download-found", - "--customer-id", - "1", - "--hashfile-id", - "2", - "--out", - str(out_file), - ], - ) - captured = capsys.readouterr() - assert code == 0 - assert "Downloaded" in captured.out - assert str(out_file) in captured.out - - -def test_hashview_cli_upload_hashfile_job(_patch_hashview, monkeypatch, tmp_path, capsys): +def test_hashview_cli_upload_hashfile_job( + _patch_hashview, monkeypatch, tmp_path, capsys +): hashfile = tmp_path / "hashes.txt" hashfile.write_text("hash1\n") code = _run_main_with_args( diff --git a/tests/test_hashview_cli_subcommands_subprocess.py b/tests/test_hashview_cli_subcommands_subprocess.py index 6b2ee16..eb5fded 100644 --- a/tests/test_hashview_cli_subcommands_subprocess.py +++ b/tests/test_hashview_cli_subcommands_subprocess.py @@ -51,7 +51,11 @@ def _ensure_customer_one(): customers_result = api.list_customers() except Exception as exc: pytest.skip(f"Unable to list customers from HASHVIEW_URL: {exc}") - customers = customers_result.get("customers", []) if isinstance(customers_result, dict) else customers_result + customers = ( + customers_result.get("customers", []) + if isinstance(customers_result, dict) + else customers_result + ) if not any(int(cust.get("id", 0)) == 1 for cust in customers or []): api.create_customer("Example Customer") return 1 @@ -61,7 +65,14 @@ def _ensure_customer_one(): "args", [ ["hashview", "upload-cracked", "--file", "dummy.out", "--hash-type", "1000"], - ["hashview", "upload-wordlist", "--file", "dummy.txt", "--name", "TestWordlist"], + [ + "hashview", + "upload-wordlist", + "--file", + "dummy.txt", + "--name", + "TestWordlist", + ], ["hashview", "download-left", "--customer-id", "1", "--hashfile-id", "2"], ["hashview", "download-found", "--customer-id", "1", "--hashfile-id", "2"], [ @@ -80,7 +91,9 @@ def _ensure_customer_one(): ) def test_hashview_subcommands_require_api_key(tmp_path, args): if _config_has_hashview_key(): - pytest.skip("config.json has hashview_api_key set; skip API-key missing checks.") + pytest.skip( + "config.json has hashview_api_key set; skip API-key missing checks." + ) # Ensure any dummy files referenced exist to avoid confusion if the code path changes. for idx, arg in enumerate(args): @@ -116,7 +129,12 @@ def test_hashview_subcommands_live_downloads(): url, key = _get_hashview_config() if not url or not key: pytest.skip("Missing hashview_url/hashview_api_key in config.json or env.") - env = {**os.environ, "PYTHONUNBUFFERED": "1", "HASHVIEW_URL": url, "HASHVIEW_API_KEY": key} + env = { + **os.environ, + "PYTHONUNBUFFERED": "1", + "HASHVIEW_URL": url, + "HASHVIEW_API_KEY": key, + } base_cmd = [sys.executable, HATE_CRACK_SCRIPT, "hashview"] customer_id = _ensure_customer_one() @@ -174,7 +192,12 @@ def test_hashview_subcommands_live_upload_hashfile_job(tmp_path): url, key = _get_hashview_config() if not url or not key: pytest.skip("Missing hashview_url/hashview_api_key in config.json or env.") - env = {**os.environ, "PYTHONUNBUFFERED": "1", "HASHVIEW_URL": url, "HASHVIEW_API_KEY": key} + env = { + **os.environ, + "PYTHONUNBUFFERED": "1", + "HASHVIEW_URL": url, + "HASHVIEW_API_KEY": key, + } base_cmd = [sys.executable, HATE_CRACK_SCRIPT, "hashview"] customer_id = _ensure_customer_one() @@ -218,6 +241,7 @@ def test_hashview_subcommands_live_upload_hashfile_job(tmp_path): if job_id: try: from hate_crack.api import HashviewAPI + url, key = _get_hashview_config() if not url or not key: return @@ -251,7 +275,12 @@ def test_hashview_subcommands_live_upload_hashfile_job_pwdump(tmp_path): url, key = _get_hashview_config() if not url or not key: pytest.skip("Missing hashview_url/hashview_api_key in config.json or env.") - env = {**os.environ, "PYTHONUNBUFFERED": "1", "HASHVIEW_URL": url, "HASHVIEW_API_KEY": key} + env = { + **os.environ, + "PYTHONUNBUFFERED": "1", + "HASHVIEW_URL": url, + "HASHVIEW_API_KEY": key, + } base_cmd = [sys.executable, HATE_CRACK_SCRIPT, "hashview"] customer_id = _ensure_customer_one() @@ -298,6 +327,7 @@ def test_hashview_subcommands_live_upload_hashfile_job_pwdump(tmp_path): if job_id: try: from hate_crack.api import HashviewAPI + url, key = _get_hashview_config() if not url or not key: return @@ -331,7 +361,12 @@ def test_hashview_subcommands_live_upload_hashfile_job_hashonly(tmp_path): url, key = _get_hashview_config() if not url or not key: pytest.skip("Missing hashview_url/hashview_api_key in config.json or env.") - env = {**os.environ, "PYTHONUNBUFFERED": "1", "HASHVIEW_URL": url, "HASHVIEW_API_KEY": key} + env = { + **os.environ, + "PYTHONUNBUFFERED": "1", + "HASHVIEW_URL": url, + "HASHVIEW_API_KEY": key, + } base_cmd = [sys.executable, HATE_CRACK_SCRIPT, "hashview"] customer_id = _ensure_customer_one() @@ -376,6 +411,7 @@ def test_hashview_subcommands_live_upload_hashfile_job_hashonly(tmp_path): if job_id: try: from hate_crack.api import HashviewAPI + url, key = _get_hashview_config() if not url or not key: return diff --git a/tests/test_installed_tool_execution.py b/tests/test_installed_tool_execution.py index 91b5d52..f676c7e 100644 --- a/tests/test_installed_tool_execution.py +++ b/tests/test_installed_tool_execution.py @@ -2,6 +2,7 @@ Tests for hate_crack execution when installed as a uv tool. Verifies that the tool can find assets from any working directory. """ + import subprocess import os import tempfile @@ -11,7 +12,7 @@ import pytest @pytest.mark.skipif( not shutil.which("hate_crack"), - reason="hate_crack not installed as a tool (run 'make install' first)" + reason="hate_crack not installed as a tool (run 'make install' first)", ) class TestInstalledToolExecution: """Test suite for execution of installed hate_crack tool.""" @@ -24,7 +25,7 @@ class TestInstalledToolExecution: cwd=home_dir, capture_output=True, text=True, - timeout=5 + timeout=5, ) assert result.returncode == 0 assert "usage: hate_crack" in result.stdout @@ -37,7 +38,7 @@ class TestInstalledToolExecution: cwd="/tmp", capture_output=True, text=True, - timeout=5 + timeout=5, ) assert result.returncode == 0 assert "usage: hate_crack" in result.stdout @@ -50,7 +51,7 @@ class TestInstalledToolExecution: cwd=tmpdir, capture_output=True, text=True, - timeout=5 + timeout=5, ) assert result.returncode == 0 assert "usage: hate_crack" in result.stdout @@ -58,11 +59,7 @@ class TestInstalledToolExecution: def test_help_from_root_directory(self): """Test that --help works when run from root directory.""" result = subprocess.run( - ["hate_crack", "--help"], - cwd="/", - capture_output=True, - text=True, - timeout=5 + ["hate_crack", "--help"], cwd="/", capture_output=True, text=True, timeout=5 ) assert result.returncode == 0 assert "usage: hate_crack" in result.stdout @@ -75,7 +72,7 @@ class TestInstalledToolExecution: cwd=home_dir, capture_output=True, text=True, - timeout=5 + timeout=5, ) assert result.returncode == 0 assert "usage: hate_crack" in result.stdout @@ -88,12 +85,14 @@ class TestInstalledToolExecution: cwd=home_dir, capture_output=True, text=True, - timeout=5 + timeout=5, ) assert result.returncode == 0 # Check that there are no error-related messages assert "Error" not in result.stderr - assert "error" not in result.stdout.lower() or "error" in "usage" # "usage" might contain substring + assert ( + "error" not in result.stdout.lower() or "error" in "usage" + ) # "usage" might contain substring assert "not found" not in result.stderr.lower() assert "No such file" not in result.stderr @@ -106,7 +105,7 @@ class TestInstalledToolExecution: cwd=tmpdir, capture_output=True, text=True, - timeout=5 + timeout=5, ) assert result.returncode == 0 # Should successfully show help without errors @@ -119,14 +118,14 @@ class TestInstalledToolExecution: "/tmp", "/", ] - + for directory in directories: result = subprocess.run( ["hate_crack", "--help"], cwd=directory, capture_output=True, text=True, - timeout=5 + timeout=5, ) assert result.returncode == 0, f"Failed when running from {directory}" assert "usage: hate_crack" in result.stdout @@ -135,20 +134,22 @@ class TestInstalledToolExecution: """Test that assets are correctly resolved from various working directories.""" directories = [ os.path.expanduser("~"), - os.path.expanduser("~/Desktop") if os.path.exists(os.path.expanduser("~/Desktop")) else "/tmp", + os.path.expanduser("~/Desktop") + if os.path.exists(os.path.expanduser("~/Desktop")) + else "/tmp", "/tmp", ] - + for directory in directories: if not os.path.exists(directory): continue - + result = subprocess.run( ["hate_crack", "--help"], cwd=directory, capture_output=True, text=True, - timeout=5 + timeout=5, ) # Should succeed from any directory assert result.returncode == 0, ( diff --git a/tests/test_invalid_hcatpath.py b/tests/test_invalid_hcatpath.py index af03870..9d0012a 100644 --- a/tests/test_invalid_hcatpath.py +++ b/tests/test_invalid_hcatpath.py @@ -1,29 +1,29 @@ """ Test error handling when hcatPath is misconfigured. """ + import os import sys import tempfile -import json def test_ensure_binary_error_message(monkeypatch, capsys): """Test that ensure_binary provides helpful error when build_dir doesn't exist.""" # Import the function from hate_crack.main import ensure_binary - + # Test with non-existent build directory fake_binary = "/nonexistent/path/to/binary" with tempfile.TemporaryDirectory() as tmpdir: fake_build_dir = os.path.join(tmpdir, "hashcat-utils") - + # Expect SystemExit when binary and build dir don't exist try: ensure_binary(fake_binary, build_dir=fake_build_dir, name="expander") assert False, "Should have exited with error" except SystemExit as e: assert e.code == 1 - + # Check that the error message mentions the correct issue captured = capsys.readouterr() assert "Build directory" in captured.out or "does not exist" in captured.out @@ -34,7 +34,7 @@ def test_ensure_binary_error_message(monkeypatch, capsys): def test_ensure_binary_with_existing_binary(): """Test that ensure_binary succeeds when binary exists.""" from hate_crack.main import ensure_binary - + # Use a system binary that definitely exists python_path = sys.executable result = ensure_binary(python_path) diff --git a/tests/test_pipal.py b/tests/test_pipal.py index 08da3dd..407c95a 100644 --- a/tests/test_pipal.py +++ b/tests/test_pipal.py @@ -13,10 +13,7 @@ def test_pipal_runs_and_parses_basewords(hc_module, tmp_path, capsys): hc.hcatHashFile = str(tmp_path / "hashes") out_path = tmp_path / "hashes.out" - out_path.write_text( - "hash1:password123\n" - "hash2:$HEX[70617373313233]\n" - ) + out_path.write_text("hash1:password123\nhash2:$HEX[70617373313233]\n") pipal_stub = tmp_path / "pipal_stub.py" _write_executable( @@ -32,7 +29,7 @@ def test_pipal_runs_and_parses_basewords(hc_module, tmp_path, capsys): " f.write('Top 3 base words\\n')\n" " f.write('pass123 10\\n')\n" " f.write('letmein 5\\n')\n" - " f.write('welcome 3\\n')\n" + " f.write('welcome 3\\n')\n", ) hc.pipalPath = str(pipal_stub) @@ -69,7 +66,7 @@ def test_pipal_missing_out_returns_empty(hc_module, tmp_path, capsys): " f.write('Top 3 base words\\n')\n" " f.write('pass123 10\\n')\n" " f.write('letmein 5\\n')\n" - " f.write('welcome 3\\n')\n" + " f.write('welcome 3\\n')\n", ) hc.pipalPath = str(pipal_stub) diff --git a/tests/test_pipal_integration.py b/tests/test_pipal_integration.py index 6017a4f..61cf0de 100644 --- a/tests/test_pipal_integration.py +++ b/tests/test_pipal_integration.py @@ -4,14 +4,16 @@ import subprocess import json + def test_pipal_executable_and_runs(tmp_path): # Read pipalPath from config.json - config_path = os.path.join(os.path.dirname(__file__), '..', 'config.json') - with open(config_path, 'r') as f: + config_path = os.path.join(os.path.dirname(__file__), "..", "config.json") + with open(config_path, "r") as f: config = json.load(f) - pipal_path = config.get('pipalPath') + pipal_path = config.get("pipalPath") if not pipal_path or not os.path.isfile(pipal_path): import pytest + pytest.skip("pipalPath not configured or file missing") if not os.access(pipal_path, os.X_OK): @@ -37,7 +39,9 @@ def test_pipal_executable_and_runs(tmp_path): ) if not output_file.exists(): - raise AssertionError("pipal did not produce an output file; it may need to be compiled.") + raise AssertionError( + "pipal did not produce an output file; it may need to be compiled." + ) content = output_file.read_text() assert "Top 3 base words" in content diff --git a/tests/test_submodule_hashcat_utils.py b/tests/test_submodule_hashcat_utils.py index cc75535..a49837d 100644 --- a/tests/test_submodule_hashcat_utils.py +++ b/tests/test_submodule_hashcat_utils.py @@ -32,6 +32,5 @@ def test_hashcat_utils_submodule_initialized(): ) assert not _is_hashcat_utils_empty(submodule_path), ( - "hashcat-utils submodule is empty. " - "Run: git submodule update --init --recursive" + "hashcat-utils submodule is empty. Run: git submodule update --init --recursive" ) diff --git a/tests/test_ui_menu_options.py b/tests/test_ui_menu_options.py index b2c82ba..92d3399 100644 --- a/tests/test_ui_menu_options.py +++ b/tests/test_ui_menu_options.py @@ -4,7 +4,9 @@ from pathlib import Path 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_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) @@ -39,7 +41,9 @@ MENU_OPTION_TEST_CASES = [ ("option_key", "target_module", "target_attr", "expected_prefix"), MENU_OPTION_TEST_CASES, ) -def test_main_menu_option_returns_expected(monkeypatch, option_key, target_module, target_attr, expected_prefix): +def test_main_menu_option_returns_expected( + monkeypatch, option_key, target_module, target_attr, expected_prefix +): sentinel = f"{expected_prefix}-{option_key}" monkeypatch.setattr( target_module, diff --git a/tests/test_upload_cracked_hashes.py b/tests/test_upload_cracked_hashes.py index 511e71a..02fa44b 100644 --- a/tests/test_upload_cracked_hashes.py +++ b/tests/test_upload_cracked_hashes.py @@ -5,28 +5,29 @@ from hate_crack.api import HashviewAPI def get_hashview_config(): - config_path = os.path.join(os.path.dirname(__file__), '..', 'config.json') - with open(config_path, 'r') as f: + config_path = os.path.join(os.path.dirname(__file__), "..", "config.json") + with open(config_path, "r") as f: config = json.load(f) - hashview_url = config.get('hashview_url') - hashview_api_key = config.get('hashview_api_key') + hashview_url = config.get("hashview_url") + hashview_api_key = config.get("hashview_api_key") return hashview_url, hashview_api_key RUN_LIVE = os.environ.get("HATE_CRACK_RUN_LIVE_TESTS") == "1" + @pytest.mark.skipif( not RUN_LIVE or not get_hashview_config()[0] or not get_hashview_config()[1], - reason="Requires HATE_CRACK_RUN_LIVE_TESTS=1 and hashview_url/hashview_api_key in config.json." + reason="Requires HATE_CRACK_RUN_LIVE_TESTS=1 and hashview_url/hashview_api_key in config.json.", ) def test_upload_cracked_hashes_from_file(): hashview_url, hashview_api_key = get_hashview_config() api = HashviewAPI(hashview_url, hashview_api_key) - file_path = os.path.join(os.path.dirname(__file__), '..', '1.out') + file_path = os.path.join(os.path.dirname(__file__), "..", "1.out") if not os.path.isfile(file_path): pytest.skip("1.out not found in repo root.") - result = api.upload_cracked_hashes(file_path, hash_type='1000') + result = api.upload_cracked_hashes(file_path, hash_type="1000") assert result is not None - assert result.get('type') != 'Error' + assert result.get("type") != "Error" diff --git a/tests/test_upload_wordlist.py b/tests/test_upload_wordlist.py index bb3a079..c77fb9f 100644 --- a/tests/test_upload_wordlist.py +++ b/tests/test_upload_wordlist.py @@ -4,22 +4,24 @@ import tempfile import pytest from hate_crack.api import HashviewAPI + def get_hashview_config(): - config_path = os.path.join(os.path.dirname(__file__), '..', 'config.json') - with open(config_path, 'r') as f: + config_path = os.path.join(os.path.dirname(__file__), "..", "config.json") + with open(config_path, "r") as f: config = json.load(f) - hashview_url = config.get('hashview_url') - hashview_api_key = config.get('hashview_api_key') + hashview_url = config.get("hashview_url") + hashview_api_key = config.get("hashview_api_key") return hashview_url, hashview_api_key + def test_upload_wordlist_api_mocked(monkeypatch): """Test direct API upload of a wordlist file using a mocked API call.""" hashview_url, hashview_api_key = get_hashview_config() api = HashviewAPI(hashview_url or "http://example.com", hashview_api_key or "dummy") # Create a temp wordlist file - with tempfile.NamedTemporaryFile(mode='w', delete=False) as f: - f.write('password1\npassword2\n') + with tempfile.NamedTemporaryFile(mode="w", delete=False) as f: + f.write("password1\npassword2\n") wordlist_path = f.name wordlist_name = os.path.basename(wordlist_path) @@ -28,7 +30,12 @@ def test_upload_wordlist_api_mocked(monkeypatch): return None def json(self): - return {"status": 200, "type": "message", "msg": "Wordlist added", "wordlist_id": 123} + return { + "status": 200, + "type": "message", + "msg": "Wordlist added", + "wordlist_id": 123, + } def fake_post(url, data=None, headers=None): assert url.endswith(f"/v1/wordlists/add/{wordlist_name}") @@ -40,9 +47,9 @@ def test_upload_wordlist_api_mocked(monkeypatch): upload_result = api.upload_wordlist_file(wordlist_path, wordlist_name) assert upload_result is not None - assert 'wordlist_id' in upload_result - msg = upload_result.get('msg', '').lower() - assert 'uploaded' in msg or 'added' in msg + assert "wordlist_id" in upload_result + msg = upload_result.get("msg", "").lower() + assert "uploaded" in msg or "added" in msg os.remove(wordlist_path) @@ -58,16 +65,16 @@ def test_upload_wordlist_api_live(): pytest.skip("Requires hashview_url and hashview_api_key in config.json.") api = HashviewAPI(hashview_url, hashview_api_key) - with tempfile.NamedTemporaryFile(mode='w', delete=False) as f: - f.write('password1\npassword2\n') + with tempfile.NamedTemporaryFile(mode="w", delete=False) as f: + f.write("password1\npassword2\n") wordlist_path = f.name wordlist_name = os.path.basename(wordlist_path) try: upload_result = api.upload_wordlist_file(wordlist_path, wordlist_name) assert upload_result is not None - assert 'wordlist_id' in upload_result - msg = upload_result.get('msg', '').lower() - assert 'uploaded' in msg or 'added' in msg + assert "wordlist_id" in upload_result + msg = upload_result.get("msg", "").lower() + assert "uploaded" in msg or "added" in msg finally: os.remove(wordlist_path) diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..a213f0b --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,199 @@ +import logging +import os +import importlib + + +from hate_crack import api +from hate_crack import cli +from hate_crack import formatting + + +def test_resolve_path_none_and_expand(): + assert cli.resolve_path("") is None + resolved = cli.resolve_path("~") + assert resolved is not None + assert os.path.isabs(resolved) + + +def test_setup_logging_adds_single_filehandler(tmp_path): + logger = logging.getLogger("hate_crack_test") + logger.handlers.clear() + cli.setup_logging(logger, str(tmp_path), debug_mode=True) + cli.setup_logging(logger, str(tmp_path), debug_mode=True) + + file_handlers = [h for h in logger.handlers if isinstance(h, logging.FileHandler)] + assert len(file_handlers) == 1 + assert os.path.basename(file_handlers[0].baseFilename) == "hate_crack.log" + + logger.handlers.clear() + + +def test_print_multicolumn_list_truncates(capsys, monkeypatch): + # Avoid patching os.get_terminal_size (pytest uses it internally). + monkeypatch.setattr(formatting, "_terminal_width", lambda default=120: 10) + formatting.print_multicolumn_list( + "Title", + ["abcdefghijk"], + min_col_width=1, + max_col_width=10, + ) + captured = capsys.readouterr() + assert "..." in captured.out + + +def test_print_multicolumn_list_empty_entries(capsys): + formatting.print_multicolumn_list("Empty", []) + captured = capsys.readouterr() + assert "(none)" in captured.out + + +def test_get_hcat_wordlists_dir_from_config(tmp_path, monkeypatch): + config_path = tmp_path / "config.json" + config_path.write_text('{"hcatWordlists": "wordlists"}') + + monkeypatch.setattr(api, "_resolve_config_path", lambda: str(config_path)) + result = api.get_hcat_wordlists_dir() + + assert result == str(tmp_path / "wordlists") + assert os.path.isdir(result) + + +def test_get_hcat_wordlists_dir_fallback_cwd(tmp_path, monkeypatch): + monkeypatch.setattr(api, "_resolve_config_path", lambda: None) + monkeypatch.chdir(tmp_path) + + result = api.get_hcat_wordlists_dir() + + assert result == str(tmp_path / "wordlists") + assert os.path.isdir(result) + + +def test_get_rules_dir_from_config(tmp_path, monkeypatch): + config_path = tmp_path / "config.json" + config_path.write_text('{"rules_directory": "rules"}') + + monkeypatch.setattr(api, "_resolve_config_path", lambda: str(config_path)) + result = api.get_rules_dir() + + assert result == str(tmp_path / "rules") + assert os.path.isdir(result) + + +def test_get_rules_dir_fallback_cwd(tmp_path, monkeypatch): + monkeypatch.setattr(api, "_resolve_config_path", lambda: None) + monkeypatch.chdir(tmp_path) + + result = api.get_rules_dir() + + assert result == str(tmp_path / "rules") + assert os.path.isdir(result) + + +def test_cleanup_torrent_files_removes_only_torrents(tmp_path): + torrent = tmp_path / "a.torrent" + keep = tmp_path / "b.txt" + torrent.write_text("data") + keep.write_text("data") + + api.cleanup_torrent_files(directory=str(tmp_path)) + + assert not torrent.exists() + assert keep.exists() + + +def test_cleanup_torrent_files_missing_dir(capsys, tmp_path): + missing = tmp_path / "missing" + api.cleanup_torrent_files(directory=str(missing)) + captured = capsys.readouterr() + assert "Failed to cleanup torrent files" in captured.out + + +def test_register_torrent_cleanup_idempotent(monkeypatch): + calls = [] + + def fake_register(fn): + calls.append(fn) + + monkeypatch.setattr(api, "_TORRENT_CLEANUP_REGISTERED", False) + monkeypatch.setattr("atexit.register", fake_register) + + api.register_torrent_cleanup() + api.register_torrent_cleanup() + + assert len(calls) == 1 + + +def test_line_count_and_write_helpers(tmp_path, monkeypatch): + monkeypatch.setenv("HATE_CRACK_SKIP_INIT", "1") + from hate_crack import main as main_module + + importlib.reload(main_module) + + input_path = tmp_path / "input.txt" + input_path.write_text("a:b:c\nno-delim\n1:2:3\n") + out_delimited = tmp_path / "out_delimited.txt" + out_unique = tmp_path / "out_unique.txt" + + assert main_module.lineCount(str(input_path)) == 3 + assert main_module.lineCount(str(tmp_path / "missing.txt")) == 0 + + assert ( + main_module._write_delimited_field(str(input_path), str(out_delimited), 2) + is True + ) + assert out_delimited.read_text().splitlines() == ["b", "2"] + assert ( + main_module._write_delimited_field( + str(tmp_path / "missing.txt"), str(out_delimited), 2 + ) + is False + ) + + class FakePopen: + def __init__(self, args, stdin=None, stdout=None, text=None): + self.stdin = FakeStdin(self) + self._stdout = stdout + self._data = None + + def wait(self): + for line in sorted(set(self._data)): + self._stdout.write(line + "\n") + return 0 + + class FakeStdin: + def __init__(self, popen): + self._popen = popen + self._lines = [] + + def write(self, data): + self._lines.append(data.rstrip("\n")) + + def close(self): + self._popen._data = self._lines + + monkeypatch.setattr(main_module.subprocess, "Popen", FakePopen) + + assert ( + main_module._write_field_sorted_unique(str(input_path), str(out_unique), 2) + is True + ) + assert out_unique.read_text().splitlines() == ["2", "b"] + + +def test_get_customer_hashfiles_with_hashtype_filters(monkeypatch): + hv = api.HashviewAPI("https://example", "key") + monkeypatch.setattr( + hv, + "get_customer_hashfiles", + lambda customer_id: [ + {"customer_id": customer_id, "hashtype": "1000"}, + {"customer_id": customer_id, "hash_type": "0"}, + ], + ) + + matches = hv.get_customer_hashfiles_with_hashtype(1, target_hashtype="1000") + assert len(matches) == 1 + assert matches[0]["hashtype"] == "1000" + + none = hv.get_customer_hashfiles_with_hashtype(1, target_hashtype="999") + assert none == [] diff --git a/tests/test_uv_tool_install_dryrun.py b/tests/test_uv_tool_install_dryrun.py index e87fcaf..8f290af 100644 --- a/tests/test_uv_tool_install_dryrun.py +++ b/tests/test_uv_tool_install_dryrun.py @@ -14,7 +14,9 @@ def test_uv_tool_install_dryrun_metadata(): scripts = project.get("scripts", {}) assert scripts.get("hate_crack") == "hate_crack.__main__:main" - setuptools_find = data.get("tool", {}).get("setuptools", {}).get("packages", {}).get("find", {}) + setuptools_find = ( + data.get("tool", {}).get("setuptools", {}).get("packages", {}).get("find", {}) + ) assert "hate_crack*" in setuptools_find.get("include", []) entrypoint = repo_root / "hate_crack" / "__main__.py"