test: add e2e test for output file path correctness

Verifies that a pwdump file at /tmp/test_hashes.ntds produces output
at /tmp/test_hashes.ntds.out using real hashcat. Confirms no files
leak into the project directory. Gated behind HATE_CRACK_RUN_E2E=1.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Justin Bollinger
2026-04-08 13:07:33 -04:00
parent 0a9549b15a
commit abec43d5c4

View File

@@ -0,0 +1,193 @@
"""E2E test: output files land next to the hashfile, not in the package directory.
Requires: hashcat installed, HATE_CRACK_RUN_E2E=1
Creates a dummy pwdump file at /tmp/test_hashes.ntds with known weak
NTLM hashes, runs a real hashcat dictionary attack, then verifies:
- /tmp/test_hashes.ntds.nt (NT hash extraction)
- /tmp/test_hashes.ntds.nt.out (hashcat cracked output)
- /tmp/test_hashes.ntds.out (combined pwdump + password)
- No symlinks or output files created in the project directory
"""
import os
import shutil
import subprocess
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
# Known NTLM hashes for weak passwords
PWDUMP_LINES = [
"alice:500:aad3b435b51404eeaad3b435b51404ee:8846f7eaee8fb117ad06bdd830b7586c:::\n",
"bob:501:aad3b435b51404eeaad3b435b51404ee:32ed87bdb5fdc5e9cba88547376818d4:::\n",
]
WORDLIST = ["password\n", "123456\n"]
EXPECTED_CRACKS = {
"8846f7eaee8fb117ad06bdd830b7586c": "password",
"32ed87bdb5fdc5e9cba88547376818d4": "123456",
}
HASH_BASE = Path("/tmp/test_hashes.ntds")
TEST_FILES = [
HASH_BASE,
Path(f"{HASH_BASE}.nt"),
Path(f"{HASH_BASE}.nt.out"),
Path(f"{HASH_BASE}.out"),
Path(f"{HASH_BASE}.lm"),
Path(f"{HASH_BASE}.lm.cracked"),
Path(f"{HASH_BASE}.working"),
Path(f"{HASH_BASE}.masks"),
Path(f"{HASH_BASE}.expanded"),
Path(f"{HASH_BASE}.combined"),
Path(f"{HASH_BASE}.passwords"),
Path("/tmp/test_wordlist.txt"),
Path("/tmp/test_potfile.pot"),
]
@pytest.fixture(autouse=True)
def clean_test_files():
"""Remove all test artifacts before and after each test."""
for f in TEST_FILES:
f.unlink(missing_ok=True)
yield
for f in TEST_FILES:
f.unlink(missing_ok=True)
def _hashcat_available() -> bool:
return shutil.which("hashcat") is not None
@pytest.mark.skipif(
os.environ.get("HATE_CRACK_RUN_E2E") != "1",
reason="Set HATE_CRACK_RUN_E2E=1 to run e2e tests.",
)
@pytest.mark.skipif(not _hashcat_available(), reason="hashcat not installed")
class TestOutputPathE2E:
"""Verify output files land next to the hashfile in /tmp/, not in the project dir."""
def _create_hash_file(self):
HASH_BASE.write_text("".join(PWDUMP_LINES))
def _create_wordlist(self):
Path("/tmp/test_wordlist.txt").write_text("".join(WORDLIST))
def _extract_nt_hashes(self):
"""Extract NT hashes (field 4) from pwdump, sorted unique — mirrors main.py preprocessing."""
nt_hashes = set()
with open(HASH_BASE) as f:
for line in f:
parts = line.strip().split(":")
if len(parts) >= 4:
nt_hashes.add(parts[3])
nt_path = Path(f"{HASH_BASE}.nt")
nt_path.write_text("\n".join(sorted(nt_hashes)) + "\n")
return str(nt_path)
def _run_hashcat_crack(self, nt_file: str):
"""Run a real hashcat dictionary attack against extracted NT hashes."""
potfile = "/tmp/test_potfile.pot"
out_file = f"{nt_file}.out"
cmd = [
"hashcat",
"-m", "1000",
nt_file,
"/tmp/test_wordlist.txt",
"-a", "0",
"-o", out_file,
f"--potfile-path={potfile}",
"--potfile-disable",
"--quiet",
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
# hashcat returns 0 (cracked) or 1 (exhausted) on success
assert result.returncode in (0, 1), (
f"hashcat failed (rc={result.returncode}): {result.stderr}"
)
return out_file
def _combine_ntlm_output(self, nt_out_file: str):
"""Combine cracked NT hashes back into pwdump format — mirrors combine_ntlm_output()."""
hashes = {}
if os.path.isfile(nt_out_file):
with open(nt_out_file) as f:
for line in f:
parts = line.strip().split(":", 1)
if len(parts) == 2:
hashes[parts[0]] = parts[1]
if not hashes:
return
combined_path = f"{HASH_BASE}.out"
with open(combined_path, "w") as out, open(HASH_BASE) as orig:
for line in orig:
parts = line.split(":")
if len(parts) >= 4 and parts[3] in hashes:
out.write(line.strip() + hashes[parts[3]] + "\n")
def test_output_files_land_in_tmp(self):
"""Full flow: pwdump in /tmp -> hashcat -> combined output in /tmp."""
self._create_hash_file()
self._create_wordlist()
# Step 1: Extract NT hashes (preprocessing)
nt_file = self._extract_nt_hashes()
assert os.path.isfile(nt_file), f"NT extraction failed: {nt_file}"
# Step 2: Crack with real hashcat
nt_out = self._run_hashcat_crack(nt_file)
assert os.path.isfile(nt_out), f"hashcat output missing: {nt_out}"
# Step 3: Combine back to pwdump format
self._combine_ntlm_output(nt_out)
# Verify: combined output exists at /tmp/test_hashes.ntds.out
combined = Path(f"{HASH_BASE}.out")
assert combined.exists(), f"Combined output missing: {combined}"
assert combined.stat().st_size > 0, "Combined output is empty"
# Verify: combined output has correct pwdump + password format
with open(combined) as f:
lines = f.readlines()
assert len(lines) == 2, f"Expected 2 cracked lines, got {len(lines)}"
for line in lines:
parts = line.strip().split(":")
assert len(parts) >= 4, f"Malformed combined line: {line}"
nt_hash = parts[3]
assert nt_hash in EXPECTED_CRACKS, f"Unexpected hash: {nt_hash}"
def test_no_files_created_in_project_dir(self):
"""Verify no symlinks or output files leak into the project directory."""
self._create_hash_file()
self._create_wordlist()
nt_file = self._extract_nt_hashes()
self._run_hashcat_crack(nt_file)
self._combine_ntlm_output(f"{nt_file}.out")
# Check project root for any test_hashes artifacts
for item in REPO_ROOT.iterdir():
assert "test_hashes" not in item.name, (
f"Test artifact leaked into project dir: {item}"
)
def test_nt_extraction_output_path(self):
"""NT hash extraction writes .nt file next to the original."""
self._create_hash_file()
nt_file = self._extract_nt_hashes()
assert nt_file == f"{HASH_BASE}.nt"
assert Path(nt_file).parent == Path("/tmp")
def test_hashcat_output_path(self):
"""hashcat -o writes .nt.out next to the .nt file."""
self._create_hash_file()
self._create_wordlist()
nt_file = self._extract_nt_hashes()
nt_out = self._run_hashcat_crack(nt_file)
assert nt_out == f"{HASH_BASE}.nt.out"
assert Path(nt_out).parent == Path("/tmp")