Restore hate_crack package and add E2E install tests

This commit is contained in:
Justin Bollinger
2026-01-31 22:50:51 -05:00
parent c298008f8e
commit 203afb3d82
13 changed files with 406 additions and 2247 deletions
+18
View File
@@ -0,0 +1,18 @@
.git
.gitmodules
.gitignore
.DS_Store
.idea
.vscode
.venv
.pytest_cache
.ruff_cache
__pycache__
*.pyc
build
wordlists
hashcat.pot
left_*.txt
left_*.txt.out
*.out
*.pot
+14
View File
@@ -0,0 +1,14 @@
FROM python:3.13-slim
WORKDIR /workspace
RUN python -m pip install -q uv
COPY . /workspace
RUN uv tool install .
ENV PATH="/root/.local/bin:${PATH}"
ENV HATE_CRACK_SKIP_INIT=1
CMD ["bash", "-lc", "/root/.local/bin/hate_crack --help >/tmp/hc_help.txt && ./hate_crack.py --help >/tmp/hc_script_help.txt"]
+56 -4
View File
@@ -32,7 +32,7 @@ Core logic is now split into modules under `hate_crack/`:
- `hate_crack/api.py`: Hashview, Weakpass, and Hashmob integrations (downloads/menus/helpers).
- `hate_crack/attacks.py`: menu attack handlers.
- `hate_crack/hashmob_wordlist.py`: Hashmob wordlist utilities (thin wrapper; calls into api.py).
- `hate_crack/hate_crack.py`: module shim that loads the top-level script.
- `hate_crack/main.py`: main CLI implementation.
The top-level `hate_crack.py` remains the main entry point and orchestrates these modules.
@@ -47,12 +47,41 @@ This project depends on and is inspired by a number of external projects and ser
-------------------------------------------------------------------
## Usage
`$ uv run hate_crack`
You can run hate_crack as a tool, as a script, or via `uv run`:
```
usage: uv run hate_crack.py
uv run hate_crack.py
or
usage: uv run hate_crack.py <hash_file> <hash_type> [options]
uv run hate_crack.py <hash_file> <hash_type> [options]
```
### Run as a tool (recommended)
Install once from the repo root:
```
uv tool install .
hate_crack
```
If you run the tool outside the repo, set `HATE_CRACK_HOME` so assets like
`hashcat-utils` can be found:
```
HATE_CRACK_HOME=/path/to/hate_crack hate_crack
```
### Run as a script
The script uses a `uv` shebang. Make it executable and run:
```
chmod +x hate_crack.py
./hate_crack.py
```
You can also use Python directly:
```
python hate_crack.py
```
Common options:
@@ -106,6 +135,29 @@ uv run pytest -v
uv run pytest tests/test_hashview.py -v
```
### Live Hashview Tests
The live Hashview upload test is skipped by default. To run it, set the
environment variable and provide valid credentials in `config.json`:
```bash
HATE_CRACK_RUN_LIVE_TESTS=1 uv run pytest tests/test_upload_cracked_hashes.py -v
```
### End-to-End Install Tests (Local + Docker)
Local uv tool install + script execution (uses a temporary HOME):
```bash
HATE_CRACK_RUN_E2E=1 uv run pytest tests/test_e2e_local_install.py -v
```
Docker-based end-to-end install/run (cached via `Dockerfile.test`):
```bash
HATE_CRACK_RUN_DOCKER_TESTS=1 uv run pytest tests/test_docker_script_install.py -v
```
### Test Structure
- **tests/test_hashview.py**: Comprehensive test suite for HashviewAPI class with mocked API responses, including:
+59 -2187
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,11 +1,11 @@
#!/usr/bin/env python3
import sys
from .hate_crack import cli_main
from .main import main as _main
def main() -> int:
return cli_main() or 0
return _main() or 0
if __name__ == "__main__":
+65 -5
View File
@@ -1,4 +1,5 @@
import json
import sys
import os
import threading
import time
@@ -386,8 +387,19 @@ def weakpass_wordlist_menu(rank=-1):
continue
return sorted(i for i in indices if 1 <= i <= max_index)
def _safe_input(prompt):
try:
if not sys.stdin or not sys.stdin.isatty():
return "q"
except Exception:
return "q"
try:
return input(prompt)
except EOFError:
return "q"
try:
sel = input("\nEnter the number(s) to download (e.g. 1,3,5-7) or 'q' to cancel: ")
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
@@ -762,13 +774,36 @@ def download_hashes_from_hashview(
print_fn: Callable[..., None] = print,
) -> Tuple[str, str]:
"""Interactive Hashview download flow used by CLI."""
try:
if not sys.stdin or not sys.stdin.isatty():
print_fn("\nAvailable Customers:")
raise ValueError("non-interactive")
except ValueError:
raise
except Exception:
# 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()
if customers:
api_harness.display_customers_multicolumn(customers)
else:
print_fn("\nNo customers found with hashfiles.")
customer_id = int(input_fn("\nEnter customer ID: "))
def _safe_input(prompt):
try:
if not sys.stdin or not sys.stdin.isatty():
return "q"
except Exception:
return "q"
try:
return input_fn(prompt)
except EOFError:
return "q"
customer_raw = _safe_input("\nEnter customer ID: ").strip()
if customer_raw.lower() == "q":
raise ValueError("cancelled")
customer_id = int(customer_raw)
try:
customer_hashfiles = api_harness.get_customer_hashfiles(customer_id)
if customer_hashfiles:
@@ -790,7 +825,10 @@ def download_hashes_from_hashview(
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.")
hashfile_id = int(input_fn("\nEnter hashfile ID: "))
hashfile_raw = _safe_input("\nEnter hashfile ID: ").strip()
if hashfile_raw.lower() == "q":
raise ValueError("cancelled")
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)
@@ -1025,7 +1063,18 @@ def list_and_download_official_wordlists():
file_name = entry.get('file_name', name)
print(f"{idx+1}. {name} ({file_name})")
print("a. Download ALL files")
sel = input("Enter the number(s) to download (e.g. 1,3,5-7), or 'a' for all, or 'q' to quit: ")
def _safe_input(prompt):
try:
if not sys.stdin or not sys.stdin.isatty():
return "q"
except Exception:
return "q"
try:
return input(prompt)
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':
return
if sel.lower() == 'a':
@@ -1087,7 +1136,18 @@ def list_and_download_hashmob_rules():
if not rules:
return
print("a. Download ALL files")
sel = input("Enter the number(s) to download (e.g. 1,3,5-7), or 'a' for all, or 'q' to quit: ")
def _safe_input(prompt):
try:
if not sys.stdin or not sys.stdin.isatty():
return "q"
except Exception:
return "q"
try:
return input(prompt)
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':
return
rules_dir = get_rules_dir()
+24
View File
@@ -0,0 +1,24 @@
{
"hcatPath": "",
"hcatBin": "hashcat",
"hcatTuning": "--force --remove",
"hcatWordlists": "/Passwords/wordlists",
"hcatOptimizedWordlists": "/Passwords/optimized_wordlists",
"rules_directory": "/path/to/hashcat/rules",
"hcatDictionaryWordlist": ["rockyou.txt"],
"hcatCombinationWordlist": ["rockyou.txt","rockyou.txt"],
"hcatHybridlist": ["rockyou.txt"],
"hcatMiddleCombinatorMasks": ["2","4"," ","-","_","+",",",".","&"],
"hcatMiddleBaseList": "rockyou.txt",
"hcatThoroughCombinatorMasks": ["0","1","2","3","4","5","6","7","8","9"," ","-","_","+",",","!","#","$","\"","%","&","'","(",")","*",".","/",":",";","<","=",">","?","@","[","\\","]","^","`","{","|","}","~"],
"hcatThoroughBaseList": "rockyou.txt",
"hcatGoodMeasureBaseList": "rockyou.txt",
"hcatPrinceBaseList": "rockyou.txt",
"pipalPath": "/path/to/pipal",
"pipal_count" : 10,
"bandrelmaxruntime": 300,
"bandrel_common_basedwords": "welcome,password,p@ssword,p@$$word,changeme,letmein,summer,winter,spring,springtime,fall,autumn,monday,tuesday,wednesday,thursday,friday,saturday,sunday,january,february,march,april,may,june,july,august,september,october,november,december,christmas,easter,covid19",
"hashview_url": "http://localhost:8443",
"hashview_api_key": "",
"hashmob_api_key": ""
}
-42
View File
@@ -1,42 +0,0 @@
#!/usr/bin/env python3
import os
import sys
def _resolve_root():
return os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir, "hate_crack.py"))
def _load_root_module():
try:
from . import _root # type: ignore
return _root
except Exception:
pass
root_path = _resolve_root()
if not os.path.isfile(root_path):
raise FileNotFoundError(f"Root hate_crack.py not found at {root_path}")
import importlib.util
spec = importlib.util.spec_from_file_location("hate_crack_root", root_path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
_ROOT = _load_root_module()
for _name, _value in _ROOT.__dict__.items():
if _name.startswith("__") and _name not in {"__all__", "__doc__", "__name__", "__package__", "__loader__", "__spec__"}:
continue
globals().setdefault(_name, _value)
def cli_main():
if hasattr(_ROOT, "cli_main"):
return _ROOT.cli_main()
if hasattr(_ROOT, "main"):
return _ROOT.main()
raise AttributeError("Root hate_crack.py has no cli_main or main")
if __name__ == "__main__":
sys.exit(cli_main())
+28 -2
View File
@@ -66,14 +66,40 @@ if _root_dir not in sys.path:
sys.path.insert(0, _root_dir)
# Allow submodule imports (hate_crack.*) even when this file is imported as a module.
_pkg_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "hate_crack")
_pkg_dir = os.path.dirname(os.path.realpath(__file__))
if os.path.isdir(_pkg_dir):
__path__ = [_pkg_dir]
if "__spec__" in globals() and __spec__ is not None:
__spec__.submodule_search_locations = __path__
hate_path = os.path.dirname(os.path.realpath(__file__))
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"))
)
def _resolve_hate_path(package_path):
env_override = os.environ.get("HATE_CRACK_HOME") or os.environ.get("HATE_CRACK_ASSETS")
candidates = []
if env_override:
candidates.append(env_override)
cwd = os.getcwd()
candidates.extend([cwd, os.path.abspath(os.path.join(cwd, os.pardir))])
candidates.append(package_path)
for candidate in candidates:
if _has_hate_crack_assets(candidate):
return candidate
return package_path
hate_path = _resolve_hate_path(os.path.dirname(os.path.realpath(__file__)))
if not os.path.isfile(hate_path + '/config.json'):
print('Initializing config.json from config.json.example')
shutil.copy(hate_path + '/config.json.example', hate_path + '/config.json')
+4 -3
View File
@@ -3,16 +3,14 @@ requires = ["setuptools>=69"]
build-backend = "setuptools.build_meta"
[project]
name = "hate-crack"
name = "hate_crack"
version = "2.0"
description = "Menu driven Python wrapper for hashcat"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"pytest>=8.3.4",
"requests>=2.31.0",
"beautifulsoup4>=4.12.0",
"ruff>=0.9.4",
]
[project.scripts]
@@ -20,3 +18,6 @@ hate_crack = "hate_crack.__main__:main"
[tool.setuptools.packages.find]
include = ["hate_crack*"]
[tool.setuptools.package-data]
hate_crack = ["config.json.example"]
+48
View File
@@ -0,0 +1,48 @@
import os
import shutil
import subprocess
from pathlib import Path
import pytest
@pytest.mark.skipif(
os.environ.get("HATE_CRACK_RUN_DOCKER_TESTS") != "1",
reason="Set HATE_CRACK_RUN_DOCKER_TESTS=1 to run Docker-based tests.",
)
def test_docker_script_install_and_run():
if shutil.which("docker") is None:
pytest.skip("docker not available")
repo_root = Path(__file__).resolve().parents[1]
image_tag = "hate-crack-e2e"
try:
build = subprocess.run(
["docker", "build", "-f", "Dockerfile.test", "-t", image_tag, str(repo_root)],
capture_output=True,
text=True,
timeout=600,
)
except subprocess.TimeoutExpired as exc:
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}"
)
try:
run = subprocess.run(
["docker", "run", "--rm", image_tag],
capture_output=True,
text=True,
timeout=120,
)
except subprocess.TimeoutExpired as exc:
pytest.fail(f"Docker run timed out after {exc.timeout}s")
assert run.returncode == 0, (
"Docker script install/run failed. "
f"stdout={run.stdout} stderr={run.stderr}"
)
+84
View File
@@ -0,0 +1,84 @@
import os
import shutil
import subprocess
from pathlib import Path
import pytest
@pytest.mark.skipif(
os.environ.get("HATE_CRACK_RUN_E2E") != "1",
reason="Set HATE_CRACK_RUN_E2E=1 to run local end-to-end install tests.",
)
def test_local_uv_tool_install_and_help(tmp_path):
if shutil.which("uv") is None:
pytest.skip("uv not available")
repo_root = Path(__file__).resolve().parents[1]
home_dir = tmp_path / "home"
home_dir.mkdir()
env = os.environ.copy()
env.update(
{
"HOME": str(home_dir),
"PATH": f"{home_dir / '.local' / 'bin'}:{env.get('PATH', '')}",
"HATE_CRACK_SKIP_INIT": "1",
"HATE_CRACK_HOME": str(repo_root),
"XDG_CACHE_HOME": str(tmp_path / "cache"),
"XDG_CONFIG_HOME": str(tmp_path / "config"),
"XDG_DATA_HOME": str(tmp_path / "data"),
}
)
install = subprocess.run(
["uv", "tool", "install", "."],
cwd=repo_root,
env=env,
capture_output=True,
text=True,
)
assert install.returncode == 0, (
"uv tool install failed. "
f"stdout={install.stdout} stderr={install.stderr}"
)
tool_help = subprocess.run(
["hate_crack", "--help"],
cwd=repo_root,
env=env,
capture_output=True,
text=True,
)
assert tool_help.returncode == 0, (
"hate_crack --help failed. "
f"stdout={tool_help.stdout} stderr={tool_help.stderr}"
)
script_help = subprocess.run(
["./hate_crack.py", "--help"],
cwd=repo_root,
env=env,
capture_output=True,
text=True,
)
assert script_help.returncode == 0, (
"./hate_crack.py --help failed. "
f"stdout={script_help.stdout} stderr={script_help.stderr}"
)
output = tool_help.stdout + tool_help.stderr
expected_flags = [
"--download-hashview",
"--hashview",
"--download-torrent",
"--download-all-torrents",
"--weakpass",
"--rank",
"--hashmob",
"--rules",
"--cleanup",
"--debug",
]
for flag in expected_flags:
assert flag in output, f"Missing {flag} in help output"
+4 -2
View File
@@ -13,9 +13,11 @@ def get_hashview_config():
return hashview_url, hashview_api_key
RUN_LIVE = os.environ.get("HATE_CRACK_RUN_LIVE_TESTS") == "1"
@pytest.mark.skipif(
not get_hashview_config()[0] or not get_hashview_config()[1],
reason="Requires hashview_url and hashview_api_key in config.json."
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."
)
def test_upload_cracked_hashes_from_file():
hashview_url, hashview_api_key = get_hashview_config()