Files
hate_crack/tests/test_hashcat_rules.py
2026-03-10 12:32:42 -04:00

182 lines
5.4 KiB
Python

import os
import json
import shutil
import subprocess
import shlex
from pathlib import Path
import pytest
_TEST_HASH = "994a24ad0d9ac6f1fd7d4d75adffeda2"
def _format_hashcat_cmd(cmd: list[str]) -> str:
# Mirror hate_crack's debug printing: safe shell-style quoting.
return " ".join(shlex.quote(part) for part in cmd)
def _get_hcat_tuning_args(repo_root: Path) -> list[str]:
config_path = repo_root / "config.json"
if not config_path.is_file():
return []
try:
config = json.loads(config_path.read_text())
except Exception:
return []
tuning = (config.get("hcatTuning") or "").strip()
if not tuning:
return []
return shlex.split(tuning)
def _hashcat_sessions_writable() -> bool:
"""
Hashcat writes session files under ~/.hashcat/sessions on macOS/homebrew builds.
If that location is not writable (sandbox/MDM), running hashcat will emit stderr.
"""
sessions_dir = Path.home() / ".hashcat" / "sessions"
try:
sessions_dir.mkdir(parents=True, exist_ok=True)
probe = sessions_dir / f"pytest_write_probe_{os.getpid()}"
probe.write_text("probe")
probe.unlink(missing_ok=True)
return True
except Exception:
return False
def _run_hashcat(
cmd: list[str],
cwd: Path,
*,
timeout_s: int = 300,
capsys=None,
show_output: bool = False,
show_cmd: bool = False,
) -> subprocess.CompletedProcess:
"""
Run hashcat and skip (not fail) on common local-environment issues.
This repo's normal test suite is offline/mocked; this test is opt-in and
depends on a local hashcat installation that can write its session files.
"""
if show_cmd and capsys is not None:
with capsys.disabled():
print("\n[DEBUG] hashcat cmd: " + _format_hashcat_cmd(cmd))
try:
result = subprocess.run(
cmd,
cwd=str(cwd),
capture_output=True,
text=True,
timeout=timeout_s,
)
except FileNotFoundError:
pytest.skip("hashcat not available in PATH")
except subprocess.TimeoutExpired:
pytest.fail(f"hashcat timed out after {timeout_s}s: {cmd!r}")
combined = (result.stdout or "") + (result.stderr or "")
if show_output and capsys is not None:
with capsys.disabled():
print("\n[hashcat stdout]\n" + (result.stdout or ""))
print("\n[hashcat stderr]\n" + (result.stderr or ""))
# If hashcat crashed, subprocess uses a negative return code (signal).
if result.returncode < 0:
pytest.fail(
f"hashcat terminated by signal {-result.returncode}. stdout={result.stdout!r} stderr={result.stderr!r}"
)
stderr = (result.stderr or "").strip()
if stderr:
# OpenCL/device build failures are environment-specific, not code bugs.
opencl_noise = all(
"clCreateProgramWithBinary" in line
or "Kernel" in line and "build failed" in line
or line == ""
for line in stderr.splitlines()
)
if opencl_noise:
pytest.skip(f"hashcat OpenCL device error (environment issue): {stderr!r}")
pytest.fail(
f"hashcat wrote to stderr (treated as failure). cmd={_format_hashcat_cmd(cmd)!r} stderr={stderr!r}"
)
assert "Segmentation fault" not in combined
assert "core dumped" not in combined.lower()
return result
def test_toggle_rule_parses_with_and_without_loopback(tmp_path: Path, capsys):
"""
Execute the two hashcat command-lines requested (with an empty wordlist),
primarily to ensure hashcat does not crash while parsing/using the rule file.
"""
if shutil.which("hashcat") is None:
pytest.skip("hashcat not available in PATH")
if not _hashcat_sessions_writable():
pytest.skip("hashcat session directory (~/.hashcat/sessions) is not writable")
show_output = os.environ.get("HATE_CRACK_SHOW_HASHCAT_OUTPUT") == "1"
show_cmd = (
os.environ.get("HATE_CRACK_SHOW_HASHCAT_CMD") == "1"
or os.environ.get("HATE_CRACK_SHOW_HASHCAT_OUTPUT") == "1"
)
repo_root = Path(__file__).resolve().parents[1]
tuning_args = _get_hcat_tuning_args(repo_root)
src_rule = repo_root / "rules" / "toggles-lm-ntlm.rule"
if not src_rule.is_file():
pytest.skip("rules/toggles-lm-ntlm.rule not found")
# Mirror the requested relative `rules/...` path in a temp working dir.
(tmp_path / "rules").mkdir(parents=True, exist_ok=True)
rule_path = tmp_path / "rules" / "toggles-lm-ntlm.rule"
shutil.copy2(src_rule, rule_path)
# Equivalent to: `echo > empty.txt`
(tmp_path / "empty.txt").write_text("")
cmd_with_loopback = [
"hashcat",
*tuning_args,
"-m",
"1000",
_TEST_HASH,
"empty.txt",
"--loopback",
"-r",
"rules/toggles-lm-ntlm.rule",
]
cmd_without_loopback = [
"hashcat",
*tuning_args,
"-m",
"1000",
_TEST_HASH,
"empty.txt",
"-r",
"rules/toggles-lm-ntlm.rule",
]
_run_hashcat(
cmd_with_loopback,
cwd=tmp_path,
capsys=capsys,
show_output=show_output,
show_cmd=show_cmd,
)
_run_hashcat(
cmd_without_loopback,
cwd=tmp_path,
capsys=capsys,
show_output=show_output,
show_cmd=show_cmd,
)