mirror of
https://github.com/trustedsec/hate_crack.git
synced 2026-07-28 14:47:22 -07:00
lots of refactoring around the menues and building out test cases
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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..."
|
||||
|
||||
@@ -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 <FILENAME>`: Download a specific Weakpass torrent file.
|
||||
@@ -230,6 +232,70 @@ Common options:
|
||||
- `--bandrel-basewords <PATH>`: 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 <output_file>.out --hash-type 1000
|
||||
```
|
||||
|
||||
Upload a wordlist:
|
||||
```bash
|
||||
hate_crack.py --hashview upload-wordlist --file <wordlist>.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 <hash_type> is attained by running `hashcat --help`
|
||||
|
||||
Example Hashes: http://hashcat.net/wiki/doku.php?id=example_hashes
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+8
-1
@@ -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)
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
# hate_crack package
|
||||
# hate_crack package
|
||||
|
||||
+525
-244
File diff suppressed because it is too large
Load Diff
+69
-46
@@ -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:
|
||||
|
||||
+3
-1
@@ -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)
|
||||
|
||||
+925
-623
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
]
|
||||
|
||||
+21
-8
@@ -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
|
||||
assert "Downloaded" in captured.out or "Downloaded" in captured.err
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+37
-15
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
+355
-140
@@ -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"])
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, (
|
||||
|
||||
@@ -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)
|
||||
|
||||
+3
-6
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 == []
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user