Files
hate_crack/tests/test_hashcat_rules.py
Justin Bollinger 993bd51865 fix: ensure hashcat.induct exists before loopback test
Hashcat renames ~/.hashcat/sessions/hashcat.induct after each session.
When the directory is absent the loopback test fails with "No such file
or directory". Recreate the directory in the test setup so it always
exists when the loopback command runs.
2026-03-19 12:20:37 -04:00

186 lines
5.6 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")
# Hashcat renames hashcat.induct after each run; recreate so loopback can write.
(Path.home() / ".hashcat" / "sessions" / "hashcat.induct").mkdir(
parents=True, exist_ok=True
)
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,
)