From 2f73289737780ef00f8cd3e83beb02eaff77da1f Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Wed, 8 Apr 2026 12:58:05 -0400 Subject: [PATCH 1/3] fix: remove symlink/copy from _ensure_hashfile_in_cwd Output files now land next to the original hashfile. resolve_path() already resolves relative paths against HATE_CRACK_ORIG_CWD, so relocating the hashfile into CWD was unnecessary and created confusing symlinks in the working directory. Co-Authored-By: Claude Opus 4.6 (1M context) --- hate_crack/main.py | 28 ++++++----------------- tests/test_hashview.py | 12 ++-------- tests/test_main_utils.py | 49 +++++++++++++++++----------------------- 3 files changed, 30 insertions(+), 59 deletions(-) diff --git a/hate_crack/main.py b/hate_crack/main.py index 4e8110e..9e299e1 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -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.) diff --git a/tests/test_hashview.py b/tests/test_hashview.py index b93a3dd..ed01f2a 100644 --- a/tests/test_hashview.py +++ b/tests/test_hashview.py @@ -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__": diff --git a/tests/test_main_utils.py b/tests/test_main_utils.py index 2569f44..7808149 100644 --- a/tests/test_main_utils.py +++ b/tests/test_main_utils.py @@ -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: From 0a9549b15ae43e4e5f83bce65a82e2d23c9d9743 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Wed, 8 Apr 2026 13:05:08 -0400 Subject: [PATCH 2/3] fix: only report combined output file when it exists cleanup() previously always printed 'Cracked passwords combined in X.out' even when combine_ntlm_output() returned early (no cracked hashes) or the hash type wasn't NTLM pwdump. Now checks file existence first and falls back to pointing at the raw .out file. Co-Authored-By: Claude Opus 4.6 (1M context) --- hate_crack/main.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/hate_crack/main.py b/hate_crack/main.py index 9e299e1..8614e03 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -2988,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") From abec43d5c4b75975e5db306d03bd6881514b5dda Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Wed, 8 Apr 2026 13:07:33 -0400 Subject: [PATCH 3/3] 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) --- tests/test_e2e_output_path.py | 193 ++++++++++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 tests/test_e2e_output_path.py diff --git a/tests/test_e2e_output_path.py b/tests/test_e2e_output_path.py new file mode 100644 index 0000000..ab25b6d --- /dev/null +++ b/tests/test_e2e_output_path.py @@ -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")