From 144d1a5334a1b6f968c2c6920d7ee4efdd432006 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Tue, 21 Jul 2026 18:06:19 -0400 Subject: [PATCH] feat(hashview): download rule files from /v1/rules Add HashviewAPI.list_rules and download_rules wrapping the Hashview rule endpoints. The server gzip-compresses plaintext rules on the fly, so download_rules decompresses before saving, yielding a file usable directly with hashcat -r. Wired into the interactive Hashview menu (Download Rule) and the CLI (hashview download-rules --rules-id/--output). Unknown rule ids surface the real HTTP 404. Co-Authored-By: Claude Opus 4.8 --- README.md | 6 ++ hate_crack/api.py | 51 ++++++++++++++++ hate_crack/main.py | 80 ++++++++++++++++++++++++++ tests/test_hashview.py | 54 +++++++++++++++++ tests/test_hashview_cli_subcommands.py | 24 ++++++++ 5 files changed, 215 insertions(+) diff --git a/README.md b/README.md index c7db24c..87a1373 100644 --- a/README.md +++ b/README.md @@ -402,6 +402,7 @@ 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 +- **Download Rule** - Download a rule file from Hashview (decompressed to plaintext, ready for `hashcat -r`) - **(4) Download Left Hashes** - Download remaining uncracked hashes (prompts to switch for cracking) - **(5) Download Found Hashes** - Download already-cracked hashes with cleartext passwords (for reference/analysis) - **(6) Upload Hashfile and Create Job** - Upload new hashfile and create a cracking job @@ -425,6 +426,11 @@ Upload a wordlist: hate_crack.py --hashview upload-wordlist --file .txt --name "My Wordlist" ``` +Download a rule file (saved decompressed, ready for `hashcat -r`): +```bash +hate_crack.py --hashview download-rules --rules-id 4 --output best64.rule +``` + Download left hashes (uncracked hashes for cracking): ```bash hate_crack.py --hashview download-left --customer-id 1 --hashfile-id 123 diff --git a/hate_crack/api.py b/hate_crack/api.py index 52b7345..c2fa2e7 100644 --- a/hate_crack/api.py +++ b/hate_crack/api.py @@ -1644,6 +1644,57 @@ class HashviewAPI: return {"output_file": output_file, "size": os.path.getsize(output_file)} return {"output_file": output_file, "size": 0} + def list_rules(self): + """List available rule files from the Hashview API (/v1/rules).""" + endpoint = f"{self.base_url}/v1/rules" + response = self.session.get(endpoint, headers=self._auth_headers()) + response.raise_for_status() + try: + data = response.json() + except Exception: + raise Exception(f"Invalid API response: {response.text}") + if isinstance(data, dict) and "rules" in data: + rules = data["rules"] + # Newer servers return a native JSON array (issue #229); tolerate a + # legacy double-encoded string as well. + if isinstance(rules, str): + rules = json.loads(rules) + return rules + elif isinstance(data, list): + return data + return [] + + def download_rules(self, rules_id, output_file=None): + """Download a rule file from the Hashview API (/v1/rules/). + + Rules are stored plaintext at rest and the server gzip-compresses them + on the fly, so the response body is gzip. We decompress before saving + so the file is directly usable with ``hashcat -r``. An unknown rule id + is a real HTTP 404, surfaced via ``raise_for_status``. + """ + import gzip + + url = f"{self.base_url}/v1/rules/{rules_id}" + resp = self.session.get(url, headers=self._auth_headers()) + resp.raise_for_status() + + content = resp.content + try: + content = gzip.decompress(content) + except (OSError, EOFError): + # Already plaintext (or a fork that serves uncompressed) — save as-is. + pass + + if output_file is None: + output_file = f"rule_{rules_id}.rule" + if not os.path.isabs(output_file): + output_file = os.path.join(get_rules_dir(), output_file) + os.makedirs(os.path.dirname(output_file) or ".", exist_ok=True) + + with open(output_file, "wb") as f: + f.write(content) + return {"output_file": output_file, "size": os.path.getsize(output_file)} + def create_customer(self, name): url = f"{self.base_url}/v1/customers/add" headers = {"Content-Type": "application/json"} diff --git a/hate_crack/main.py b/hate_crack/main.py index d1268c7..a523ca7 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -3328,6 +3328,7 @@ def hashview_api(): ) menu_options.append(("upload_wordlist", "Upload Wordlist")) menu_options.append(("download_wordlist", "Download Wordlist")) + menu_options.append(("download_rules", "Download Rule")) menu_options.append( ( "download_hashes", @@ -3548,6 +3549,62 @@ def hashview_api(): except Exception as e: print(f"\n✗ Error downloading wordlist: {str(e)}") + elif option_key == "download_rules": + # Download rule file + try: + rules = api_harness.list_rules() + rule_map = {} + if rules: + print("\n" + "=" * 100) + print("Available Rules:") + print("=" * 100) + print(f"{'ID':<10} {'Name':<60} {'Size':>12}") + print("-" * 100) + for rule in rules: + r_id = rule.get("id", "N/A") + r_name = rule.get("name", "N/A") + r_size = rule.get("size", "N/A") + name = str(r_name) + if len(name) > 60: + name = name[:57] + "..." + print(f"{r_id:<10} {name:<60} {r_size:>12}") + if r_id != "N/A": + try: + rule_map[int(r_id)] = str(r_name) + except ValueError: + pass + print("=" * 100) + else: + print("\nNo rules found.") + except Exception as e: + print(f"\n✗ Error fetching rules: {str(e)}") + continue + + try: + rules_id = int(input("\nEnter rule ID: ")) + except ValueError: + print("\n✗ Error: Invalid ID entered. Please enter a numeric ID.") + continue + + api_name = rule_map.get(rules_id) + prompt_suffix = ( + f" (API filename: {api_name})" if api_name else " (API filename)" + ) + output_file = ( + input( + f"Enter output file name{prompt_suffix} or press Enter to use API filename: " + ).strip() + or api_name + ) + try: + download_result = api_harness.download_rules( + rules_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 rule: {str(e)}") + elif option_key == "upload_hashfile_job": # Upload hashfile and create job if not hcatHashFile: @@ -4758,6 +4815,19 @@ def main(): help="Hash type for hashcat (e.g., 1000 for NTLM)", ) + hv_download_rules = hashview_subparsers.add_parser( + "download-rules", + help="Download a rule file", + ) + hv_download_rules.add_argument( + "--rules-id", required=True, type=int, help="Rule ID" + ) + hv_download_rules.add_argument( + "--output", + default=None, + help="Output file name (default: rule_.rule in the rules directory)", + ) + hv_upload_hashfile_job = hashview_subparsers.add_parser( "upload-hashfile-job", help="Upload a hashfile and create a job", @@ -4794,6 +4864,7 @@ def main(): "upload-cracked", "upload-wordlist", "download-hashes", + "download-rules", "upload-hashfile-job", ] has_hashview_flag = "--hashview" in argv @@ -4931,6 +5002,15 @@ def main(): print(f" File: {download_result['output_file']}") sys.exit(0) + if args.hashview_command == "download-rules": + download_result = api_harness.download_rules( + args.rules_id, + args.output, + ) + 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": hashfile_path = resolve_path(args.file) if not hashfile_path or not os.path.isfile(hashfile_path): diff --git a/tests/test_hashview.py b/tests/test_hashview.py index 5714413..0830b32 100644 --- a/tests/test_hashview.py +++ b/tests/test_hashview.py @@ -239,6 +239,60 @@ class TestHashviewAPI: assert api.get_hashfile_hash_type(1000) == [3, 9] + def test_list_rules_native_array(self, api): + """/v1/rules returns {rules: [...]} as a native JSON array.""" + mock_resp = Mock() + mock_resp.json.return_value = { + "status": 200, + "rules": [{"id": 4, "name": "best64.rule", "size": 77}], + } + mock_resp.raise_for_status = Mock() + api.session.get.return_value = mock_resp + + rules = api.list_rules() + assert rules == [{"id": 4, "name": "best64.rule", "size": 77}] + + def test_download_rules_gunzips_to_plaintext(self, api, tmp_path): + """Rule download arrives gzip-compressed; saved file must be plaintext.""" + import gzip + + plaintext = b":\nc\nu\nsa\n" + mock_resp = Mock() + mock_resp.content = gzip.compress(plaintext) + mock_resp.headers = {} + mock_resp.raise_for_status = Mock() + api.session.get.return_value = mock_resp + + out = os.path.join(str(tmp_path), "best64.rule") + result = api.download_rules(4, out) + assert result["output_file"] == out + with open(out, "rb") as f: + assert f.read() == plaintext + + def test_download_rules_passes_plaintext_through(self, api, tmp_path): + """If the body is already plaintext (not gzip), save it unchanged.""" + mock_resp = Mock() + mock_resp.content = b":\nc\nu\n" + mock_resp.headers = {} + mock_resp.raise_for_status = Mock() + api.session.get.return_value = mock_resp + + out = os.path.join(str(tmp_path), "plain.rule") + api.download_rules(7, out) + with open(out, "rb") as f: + assert f.read() == b":\nc\nu\n" + + def test_download_rules_raises_on_404(self, api, tmp_path): + """Unknown rule id is a real HTTP 404 -> raise_for_status propagates.""" + from requests.exceptions import HTTPError + + mock_resp = Mock() + mock_resp.raise_for_status = Mock(side_effect=HTTPError("404")) + api.session.get.return_value = mock_resp + + with pytest.raises(HTTPError): + api.download_rules(99999999, os.path.join(str(tmp_path), "x.rule")) + 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() diff --git a/tests/test_hashview_cli_subcommands.py b/tests/test_hashview_cli_subcommands.py index 4c59ff2..996d2a5 100644 --- a/tests/test_hashview_cli_subcommands.py +++ b/tests/test_hashview_cli_subcommands.py @@ -19,6 +19,13 @@ class DummyHashviewAPI: self.calls.append(("upload_wordlist_file", wordlist_path, wordlist_name)) return {"msg": "Wordlist uploaded", "wordlist_id": 123} + def download_rules(self, rules_id, output_file=None): + self.calls.append(("download_rules", rules_id, output_file)) + return { + "output_file": output_file or f"rule_{rules_id}.rule", + "size": 77, + } + def download_left_hashes(self, customer_id, hashfile_id, output_file=None): self.calls.append( ("download_left_hashes", customer_id, hashfile_id, output_file) @@ -111,6 +118,23 @@ def test_hashview_cli_upload_wordlist(_patch_hashview, monkeypatch, tmp_path, ca assert "Wordlist uploaded" in captured.out +def test_hashview_cli_download_rules(_patch_hashview, monkeypatch, tmp_path, capsys): + code = _run_main_with_args( + monkeypatch, + [ + "hashview", + "download-rules", + "--rules-id", + "4", + "--output", + str(tmp_path / "best64.rule"), + ], + ) + captured = capsys.readouterr() + assert code == 0 + assert "Downloaded 77 bytes" in captured.out + + def test_hashview_cli_upload_hashfile_job( _patch_hashview, monkeypatch, tmp_path, capsys ):