mirror of
https://github.com/trustedsec/hate_crack.git
synced 2026-07-28 22:51:14 -07:00
- Add type hints to _filter_computer_accounts and _dedup_netntlm_by_username - Fix unclosed file handle when reading hash file for format detection - Extract _count_computer_accounts helper to eliminate duplicate file reads - Stream _filter_computer_accounts output instead of collecting in memory - Only write .dedup file when duplicates actually exist - Add tests for _count_computer_accounts, malformed lines, and no-file-on-zero-dupes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
222 lines
8.0 KiB
Python
222 lines
8.0 KiB
Python
"""Tests for NTLM/NetNTLM hash preprocessing helpers (issues #27 and #28)."""
|
|
|
|
import os
|
|
import sys
|
|
import importlib
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture
|
|
def main_module(monkeypatch):
|
|
"""Load hate_crack.main with SKIP_INIT to access helper functions."""
|
|
monkeypatch.setenv("HATE_CRACK_SKIP_INIT", "1")
|
|
if "hate_crack.main" in sys.modules:
|
|
mod = sys.modules["hate_crack.main"]
|
|
importlib.reload(mod)
|
|
return mod
|
|
import hate_crack.main as mod
|
|
|
|
return mod
|
|
|
|
|
|
class TestCountComputerAccounts:
|
|
"""Issue #27 - count computer accounts (helper for detection)."""
|
|
|
|
def test_counts_computer_accounts(self, tmp_path, main_module):
|
|
hash_file = tmp_path / "hashes.txt"
|
|
hash_file.write_text(
|
|
"user1:1001:aad3b435b51404ee:hash1:::\n"
|
|
"COMPUTER1$:1002:aad3b435b51404ee:hash2:::\n"
|
|
"user2:1003:aad3b435b51404ee:hash3:::\n"
|
|
"WORKSTATION$:1004:aad3b435b51404ee:hash4:::\n"
|
|
)
|
|
assert main_module._count_computer_accounts(str(hash_file)) == 2
|
|
|
|
def test_no_computer_accounts(self, tmp_path, main_module):
|
|
hash_file = tmp_path / "hashes.txt"
|
|
hash_file.write_text(
|
|
"user1:1001:aad3b435b51404ee:hash1:::\n"
|
|
"user2:1002:aad3b435b51404ee:hash2:::\n"
|
|
)
|
|
assert main_module._count_computer_accounts(str(hash_file)) == 0
|
|
|
|
def test_missing_file(self, tmp_path, main_module):
|
|
assert main_module._count_computer_accounts(str(tmp_path / "nope.txt")) == 0
|
|
|
|
def test_empty_file(self, tmp_path, main_module):
|
|
hash_file = tmp_path / "hashes.txt"
|
|
hash_file.write_text("")
|
|
assert main_module._count_computer_accounts(str(hash_file)) == 0
|
|
|
|
|
|
class TestFilterComputerAccounts:
|
|
"""Issue #27 - filter accounts ending with $."""
|
|
|
|
def test_removes_computer_accounts(self, tmp_path, main_module):
|
|
hash_file = tmp_path / "hashes.txt"
|
|
hash_file.write_text(
|
|
"user1:1001:aad3b435b51404ee:hash1:::\n"
|
|
"COMPUTER1$:1002:aad3b435b51404ee:hash2:::\n"
|
|
"user2:1003:aad3b435b51404ee:hash3:::\n"
|
|
"WORKSTATION$:1004:aad3b435b51404ee:hash4:::\n"
|
|
)
|
|
output_file = tmp_path / "filtered.txt"
|
|
removed = main_module._filter_computer_accounts(
|
|
str(hash_file), str(output_file)
|
|
)
|
|
assert removed == 2
|
|
lines = output_file.read_text().strip().split("\n")
|
|
assert len(lines) == 2
|
|
assert all("$" not in line.split(":", 1)[0] for line in lines)
|
|
|
|
def test_no_computer_accounts(self, tmp_path, main_module):
|
|
hash_file = tmp_path / "hashes.txt"
|
|
hash_file.write_text(
|
|
"user1:1001:aad3b435b51404ee:hash1:::\n"
|
|
"user2:1002:aad3b435b51404ee:hash2:::\n"
|
|
)
|
|
output_file = tmp_path / "filtered.txt"
|
|
removed = main_module._filter_computer_accounts(
|
|
str(hash_file), str(output_file)
|
|
)
|
|
assert removed == 0
|
|
lines = output_file.read_text().strip().split("\n")
|
|
assert len(lines) == 2
|
|
|
|
def test_all_computer_accounts(self, tmp_path, main_module):
|
|
hash_file = tmp_path / "hashes.txt"
|
|
hash_file.write_text(
|
|
"COMP1$:1001:aad3b435b51404ee:hash1:::\n"
|
|
"COMP2$:1002:aad3b435b51404ee:hash2:::\n"
|
|
)
|
|
output_file = tmp_path / "filtered.txt"
|
|
removed = main_module._filter_computer_accounts(
|
|
str(hash_file), str(output_file)
|
|
)
|
|
assert removed == 2
|
|
content = output_file.read_text().strip()
|
|
assert content == ""
|
|
|
|
def test_missing_file(self, tmp_path, main_module):
|
|
removed = main_module._filter_computer_accounts(
|
|
str(tmp_path / "nonexistent.txt"),
|
|
str(tmp_path / "output.txt"),
|
|
)
|
|
assert removed == 0
|
|
|
|
def test_empty_file(self, tmp_path, main_module):
|
|
hash_file = tmp_path / "hashes.txt"
|
|
hash_file.write_text("")
|
|
output_file = tmp_path / "filtered.txt"
|
|
removed = main_module._filter_computer_accounts(
|
|
str(hash_file), str(output_file)
|
|
)
|
|
assert removed == 0
|
|
|
|
def test_malformed_lines(self, tmp_path, main_module):
|
|
hash_file = tmp_path / "hashes.txt"
|
|
hash_file.write_text(
|
|
"user1:1001:aad3b435b51404ee:hash1:::\n"
|
|
"malformed_line_without_dollar\n"
|
|
"COMP$:1002:aad3b435b51404ee:hash2:::\n"
|
|
)
|
|
output_file = tmp_path / "filtered.txt"
|
|
removed = main_module._filter_computer_accounts(
|
|
str(hash_file), str(output_file)
|
|
)
|
|
assert removed == 1
|
|
lines = output_file.read_text().strip().split("\n")
|
|
assert len(lines) == 2
|
|
|
|
|
|
class TestDedupNetntlmByUsername:
|
|
"""Issue #28 - deduplicate NetNTLM hashes by username."""
|
|
|
|
def test_removes_duplicates(self, tmp_path, main_module):
|
|
hash_file = tmp_path / "netntlm.txt"
|
|
hash_file.write_text(
|
|
"user1::DOMAIN:challenge1:response1:blob1\n"
|
|
"user2::DOMAIN:challenge2:response2:blob2\n"
|
|
"user1::DOMAIN:challenge3:response3:blob3\n"
|
|
"user3::DOMAIN:challenge4:response4:blob4\n"
|
|
"user2::DOMAIN:challenge5:response5:blob5\n"
|
|
)
|
|
output_file = tmp_path / "dedup.txt"
|
|
total, duplicates = main_module._dedup_netntlm_by_username(
|
|
str(hash_file), str(output_file)
|
|
)
|
|
assert total == 5
|
|
assert duplicates == 2
|
|
lines = output_file.read_text().strip().split("\n")
|
|
assert len(lines) == 3
|
|
# First occurrences should be kept
|
|
assert "challenge1" in lines[0]
|
|
assert "challenge2" in lines[1]
|
|
assert "challenge4" in lines[2]
|
|
|
|
def test_case_insensitive_dedup(self, tmp_path, main_module):
|
|
hash_file = tmp_path / "netntlm.txt"
|
|
hash_file.write_text(
|
|
"User1::DOMAIN:challenge1:response1:blob1\n"
|
|
"USER1::DOMAIN:challenge2:response2:blob2\n"
|
|
"user1::DOMAIN:challenge3:response3:blob3\n"
|
|
)
|
|
output_file = tmp_path / "dedup.txt"
|
|
total, duplicates = main_module._dedup_netntlm_by_username(
|
|
str(hash_file), str(output_file)
|
|
)
|
|
assert total == 3
|
|
assert duplicates == 2
|
|
lines = output_file.read_text().strip().split("\n")
|
|
assert len(lines) == 1
|
|
|
|
def test_no_duplicates(self, tmp_path, main_module):
|
|
hash_file = tmp_path / "netntlm.txt"
|
|
hash_file.write_text(
|
|
"user1::DOMAIN:challenge1:response1:blob1\n"
|
|
"user2::DOMAIN:challenge2:response2:blob2\n"
|
|
)
|
|
output_file = tmp_path / "dedup.txt"
|
|
total, duplicates = main_module._dedup_netntlm_by_username(
|
|
str(hash_file), str(output_file)
|
|
)
|
|
assert total == 2
|
|
assert duplicates == 0
|
|
# Output file should NOT be created when no duplicates exist
|
|
assert not output_file.exists()
|
|
|
|
def test_missing_file(self, tmp_path, main_module):
|
|
total, duplicates = main_module._dedup_netntlm_by_username(
|
|
str(tmp_path / "nonexistent.txt"),
|
|
str(tmp_path / "output.txt"),
|
|
)
|
|
assert total == 0
|
|
assert duplicates == 0
|
|
|
|
def test_empty_file(self, tmp_path, main_module):
|
|
hash_file = tmp_path / "netntlm.txt"
|
|
hash_file.write_text("")
|
|
output_file = tmp_path / "dedup.txt"
|
|
total, duplicates = main_module._dedup_netntlm_by_username(
|
|
str(hash_file), str(output_file)
|
|
)
|
|
assert total == 0
|
|
assert duplicates == 0
|
|
assert not output_file.exists()
|
|
|
|
def test_malformed_lines(self, tmp_path, main_module):
|
|
hash_file = tmp_path / "netntlm.txt"
|
|
hash_file.write_text(
|
|
"user1::DOMAIN:challenge1:response1:blob1\n"
|
|
"malformed_line_without_colons\n"
|
|
"user2::DOMAIN:challenge2:response2:blob2\n"
|
|
)
|
|
output_file = tmp_path / "dedup.txt"
|
|
total, duplicates = main_module._dedup_netntlm_by_username(
|
|
str(hash_file), str(output_file)
|
|
)
|
|
assert total == 3
|
|
assert duplicates == 0
|
|
assert not output_file.exists()
|