mirror of
https://github.com/trustedsec/hate_crack.git
synced 2026-07-28 14:47:22 -07:00
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
201cc37e55
commit
144d1a5334
@@ -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 <wordlist>.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
|
||||
|
||||
@@ -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/<id>).
|
||||
|
||||
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"}
|
||||
|
||||
@@ -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_<id>.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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user