Merge pull request #105 from trustedsec/fix/output-path-symlinks

Fix output file path and remove symlink creation
This commit is contained in:
Justin Bollinger
2026-04-08 13:11:49 -04:00
committed by GitHub
4 changed files with 228 additions and 63 deletions
+12 -25
View File
@@ -196,27 +196,13 @@ def _resolve_config_destination():
def _ensure_hashfile_in_cwd(hashfile_path):
"""Ensure hashfile path points to cwd to keep output files in execution dir."""
if not hashfile_path:
return hashfile_path
cwd = orig_cwd()
if not os.path.isabs(hashfile_path):
return hashfile_path
if os.path.dirname(hashfile_path) == cwd:
return hashfile_path
basename = os.path.basename(hashfile_path)
local_path = os.path.join(cwd, basename)
if os.path.exists(local_path):
return local_path
try:
os.symlink(hashfile_path, local_path)
return local_path
except Exception:
try:
shutil.copy2(hashfile_path, local_path)
return local_path
except Exception:
return hashfile_path
"""Return hashfile path as-is.
Output files (.out, .nt, etc.) are written next to the hashfile.
``resolve_path()`` already resolves relative paths against
``HATE_CRACK_ORIG_CWD`` so no relocation is needed.
"""
return hashfile_path
# hate_path is where hate_crack assets live (hashcat-utils, princeprocessor, etc.)
@@ -3002,10 +2988,11 @@ def cleanup():
if hcatHashType == "1000" and pwdump_format:
print("\nComparing cracked hashes to original file...")
combine_ntlm_output()
print(
"\nCracked passwords combined with original hashes in %s"
% (hcatHashFileOrig + ".out")
)
out_path = hcatHashFileOrig + ".out"
if os.path.isfile(out_path):
print(f"\nCracked passwords combined with original hashes in {out_path}")
else:
print(f"\nNo cracked hashes to combine. Raw output (if any): {hcatHashFile}.out")
print("\nCleaning up temporary files...")
if os.path.exists(hcatHashFile + ".masks"):
os.remove(hcatHashFile + ".masks")
+193
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")
+2 -10
View File
@@ -775,7 +775,7 @@ class TestHashviewAPI:
assert content == b"hash1\nhash2\n"
def test_hashfile_orig_path_preservation(self, tmp_path, monkeypatch):
"""Test that original hashfile path is preserved before _ensure_hashfile_in_cwd"""
"""Test that _ensure_hashfile_in_cwd is a pass-through returning the input path."""
from hate_crack.main import _ensure_hashfile_in_cwd
# Create a test hashfile in a different directory
@@ -792,16 +792,8 @@ class TestHashviewAPI:
# 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 result_path == original_path, "Pass-through should return the input path"
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"
)
if __name__ == "__main__":
+21 -28
View File
@@ -100,25 +100,8 @@ class TestEnsureHashfileInCwd:
result = main_module._ensure_hashfile_in_cwd(str(target))
assert result == str(target)
def test_different_dir_existing_file_in_cwd(self, main_module, tmp_path, monkeypatch):
# File exists in a different directory
other_dir = tmp_path / "other"
other_dir.mkdir()
source_file = other_dir / "hashes.txt"
source_file.write_text("hashes")
# A file with the same name already exists in cwd
cwd_dir = tmp_path / "cwd"
cwd_dir.mkdir()
cwd_copy = cwd_dir / "hashes.txt"
cwd_copy.write_text("cwd version")
monkeypatch.setenv("HATE_CRACK_ORIG_CWD", str(cwd_dir))
result = main_module._ensure_hashfile_in_cwd(str(source_file))
assert result == str(cwd_copy)
def test_different_dir_creates_symlink(self, main_module, tmp_path, monkeypatch):
# Source file in a different directory, nothing in cwd
def test_different_dir_returns_original_path(self, main_module, tmp_path, monkeypatch):
"""File in different dir is returned as-is (no symlink/copy)."""
other_dir = tmp_path / "other"
other_dir.mkdir()
source_file = other_dir / "hashes.txt"
@@ -129,14 +112,25 @@ class TestEnsureHashfileInCwd:
monkeypatch.setenv("HATE_CRACK_ORIG_CWD", str(cwd_dir))
result = main_module._ensure_hashfile_in_cwd(str(source_file))
assert result == str(source_file)
expected = str(cwd_dir / "hashes.txt")
assert result == expected
assert os.path.exists(expected)
def test_different_dir_no_symlink(self, main_module, tmp_path, monkeypatch):
"""File in different dir does NOT create a symlink in cwd."""
other_dir = tmp_path / "other"
other_dir.mkdir()
source_file = other_dir / "hashes.txt"
source_file.write_text("hashes")
cwd_dir = tmp_path / "cwd"
cwd_dir.mkdir()
monkeypatch.setenv("HATE_CRACK_ORIG_CWD", str(cwd_dir))
result = main_module._ensure_hashfile_in_cwd(str(source_file))
assert result == str(source_file)
assert not (cwd_dir / "hashes.txt").exists()
def test_uses_orig_cwd_not_process_cwd(self, main_module, tmp_path, monkeypatch):
"""Output files go to HATE_CRACK_ORIG_CWD, not the process CWD."""
# Simulate: process CWD is the install dir, orig CWD is the user's dir
"""Returns original path as-is; no files created in any cwd."""
install_dir = tmp_path / "install"
install_dir.mkdir()
user_dir = tmp_path / "user"
@@ -150,10 +144,9 @@ class TestEnsureHashfileInCwd:
monkeypatch.setenv("HATE_CRACK_ORIG_CWD", str(user_dir))
result = main_module._ensure_hashfile_in_cwd(str(source_file))
# Should land in user_dir, NOT install_dir
assert result == str(user_dir / "hashes.txt")
assert os.path.exists(user_dir / "hashes.txt")
assert not os.path.exists(install_dir / "hashes.txt")
assert result == str(source_file)
assert not (user_dir / "hashes.txt").exists()
assert not (install_dir / "hashes.txt").exists()
class TestRunHashcatShow: