diff --git a/tests/test_ntlm_preprocessing.py b/tests/test_ntlm_preprocessing.py index 8f7f2e5..938df80 100644 --- a/tests/test_ntlm_preprocessing.py +++ b/tests/test_ntlm_preprocessing.py @@ -1,6 +1,5 @@ """Tests for NTLM/NetNTLM hash preprocessing helpers (issues #27 and #28).""" -import os import sys import importlib @@ -219,3 +218,319 @@ class TestDedupNetntlmByUsername: assert total == 3 assert duplicates == 0 assert not output_file.exists() + + +class TestWriteFieldSortedUnique: + """Test _write_field_sorted_unique helper for extracting hash fields.""" + + def test_extracts_nt_hashes_field_4(self, tmp_path, main_module): + """Extract NT hashes (field 4) from pwdump format.""" + hash_file = tmp_path / "pwdump.txt" + hash_file.write_text( + "user1:500:aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0:::\n" + "user2:501:aad3b435b51404eeaad3b435b51404ee:8846f7eaee8fb117ad06bdd830b7586c:::\n" + "user3:502:aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0:::\n" + ) + output_file = tmp_path / "nt.txt" + result = main_module._write_field_sorted_unique( + str(hash_file), str(output_file), 4, ":" + ) + assert result is True + lines = output_file.read_text().strip().split("\n") + assert len(lines) == 2 # Two unique NT hashes + assert "31d6cfe0d16ae931b73c59d7e0c089c0" in lines + assert "8846f7eaee8fb117ad06bdd830b7586c" in lines + + def test_extracts_lm_hashes_field_3(self, tmp_path, main_module): + """Extract LM hashes (field 3) from pwdump format.""" + hash_file = tmp_path / "pwdump.txt" + hash_file.write_text( + "user1:500:e52cac67419a9a224a3b108f3fa6cb6d:31d6cfe0d16ae931b73c59d7e0c089c0:::\n" + "user2:501:aad3b435b51404eeaad3b435b51404ee:8846f7eaee8fb117ad06bdd830b7586c:::\n" + "user3:502:e52cac67419a9a224a3b108f3fa6cb6d:abc123def456:::\n" + ) + output_file = tmp_path / "lm.txt" + result = main_module._write_field_sorted_unique( + str(hash_file), str(output_file), 3, ":" + ) + assert result is True + lines = output_file.read_text().strip().split("\n") + assert len(lines) == 2 # Two unique LM hashes + assert "aad3b435b51404eeaad3b435b51404ee" in lines + assert "e52cac67419a9a224a3b108f3fa6cb6d" in lines + + def test_sorts_and_deduplicates(self, tmp_path, main_module): + """Verify sorting and deduplication.""" + hash_file = tmp_path / "pwdump.txt" + hash_file.write_text( + "user1:500:lm1:zzz:::\n" + "user2:501:lm2:aaa:::\n" + "user3:502:lm3:mmm:::\n" + "user4:503:lm4:aaa:::\n" # Duplicate NT hash + ) + output_file = tmp_path / "nt.txt" + main_module._write_field_sorted_unique(str(hash_file), str(output_file), 4, ":") + lines = output_file.read_text().strip().split("\n") + assert len(lines) == 3 + assert lines == ["aaa", "mmm", "zzz"] # Sorted alphabetically + + def test_handles_missing_fields(self, tmp_path, main_module): + """Lines with fewer fields than requested should be skipped.""" + hash_file = tmp_path / "pwdump.txt" + hash_file.write_text( + "user1:500:lm1:nt1:::\n" + "malformed:500\n" # Only 2 fields + "user2:501:lm2:nt2:::\n" + ) + output_file = tmp_path / "nt.txt" + main_module._write_field_sorted_unique(str(hash_file), str(output_file), 4, ":") + lines = output_file.read_text().strip().split("\n") + assert len(lines) == 2 + assert "nt1" in lines + assert "nt2" in lines + + def test_missing_input_file(self, tmp_path, main_module): + """Should return False when input file doesn't exist.""" + result = main_module._write_field_sorted_unique( + str(tmp_path / "nonexistent.txt"), str(tmp_path / "output.txt"), 4, ":" + ) + assert result is False + + def test_empty_file(self, tmp_path, main_module): + """Empty input should create empty output.""" + hash_file = tmp_path / "empty.txt" + hash_file.write_text("") + output_file = tmp_path / "out.txt" + result = main_module._write_field_sorted_unique( + str(hash_file), str(output_file), 4, ":" + ) + assert result is True + assert output_file.read_text().strip() == "" + + +class TestPwdumpFilterPipeline: + """Full pipeline tests: filter -> extract NT/LM -> verify output.""" + + def test_full_pipeline_with_filtering(self, tmp_path, main_module): + """Test complete flow: filter computer accounts, extract NT/LM hashes.""" + # Step 1: Create pwdump file with mixed accounts + pwdump_file = tmp_path / "dump.txt" + pwdump_file.write_text( + "Administrator:500:aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0:::\n" + "Guest:501:aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0:::\n" + "DESKTOP-ABC$:1001:aad3b435b51404eeaad3b435b51404ee:8846f7eaee8fb117ad06bdd830b7586c:::\n" + "john:1002:e52cac67419a9a224a3b108f3fa6cb6d:5f4dcc3b5aa765d61d8327deb882cf99:::\n" + "WORKSTATION$:1003:aad3b435b51404eeaad3b435b51404ee:deadbeefcafebabe1234567890abcdef:::\n" + "alice:1004:aad3b435b51404eeaad3b435b51404ee:6cb75f652a9b52798eb6cf2201057c73:::\n" + ) + + # Step 2: Count computer accounts + count = main_module._count_computer_accounts(str(pwdump_file)) + assert count == 2 + + # Step 3: Filter computer accounts + filtered_file = tmp_path / "dump.txt.filtered" + removed = main_module._filter_computer_accounts( + str(pwdump_file), str(filtered_file) + ) + assert removed == 2 + + # Step 4: Verify filtered file preserves complete pwdump format + filtered_lines = filtered_file.read_text().strip().split("\n") + assert len(filtered_lines) == 4 + for line in filtered_lines: + # Each line should have pwdump format: user:uid:LM:NT::: + parts = line.split(":") + assert len(parts) == 7 # 7 fields total (6 colons) + assert not parts[0].endswith("$") # No computer accounts + + # Step 5: Extract NT hashes from filtered file + nt_file = tmp_path / "dump.txt.filtered.nt" + result = main_module._write_field_sorted_unique( + str(filtered_file), str(nt_file), 4, ":" + ) + assert result is True + + # Step 6: Verify NT hashes are correct and don't include computer account hashes + nt_hashes = nt_file.read_text().strip().split("\n") + assert len(nt_hashes) == 3 # Three unique NT hashes from non-computer accounts + assert "31d6cfe0d16ae931b73c59d7e0c089c0" in nt_hashes # Admin/Guest empty hash + assert "5f4dcc3b5aa765d61d8327deb882cf99" in nt_hashes # john's hash + assert "6cb75f652a9b52798eb6cf2201057c73" in nt_hashes # alice's hash + # Computer account hashes should NOT be present + assert "8846f7eaee8fb117ad06bdd830b7586c" not in nt_hashes + assert "deadbeefcafebabe1234567890abcdef" not in nt_hashes + + # Step 7: Extract LM hashes from filtered file + lm_file = tmp_path / "dump.txt.filtered.lm" + result = main_module._write_field_sorted_unique( + str(filtered_file), str(lm_file), 3, ":" + ) + assert result is True + + # Step 8: Verify LM hashes are correct + lm_hashes = lm_file.read_text().strip().split("\n") + assert len(lm_hashes) == 2 # Two unique LM hashes + assert ( + "aad3b435b51404eeaad3b435b51404ee" in lm_hashes + ) # Empty LM (Admin/Guest/alice) + assert "e52cac67419a9a224a3b108f3fa6cb6d" in lm_hashes # john's LM + + def test_pipeline_without_filtering(self, tmp_path, main_module): + """Test pipeline when no computer accounts exist.""" + pwdump_file = tmp_path / "dump.txt" + pwdump_file.write_text( + "Administrator:500:aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0:::\n" + "john:1002:e52cac67419a9a224a3b108f3fa6cb6d:5f4dcc3b5aa765d61d8327deb882cf99:::\n" + "alice:1004:aad3b435b51404eeaad3b435b51404ee:6cb75f652a9b52798eb6cf2201057c73:::\n" + ) + + # Count should be zero + count = main_module._count_computer_accounts(str(pwdump_file)) + assert count == 0 + + # Extract NT directly from original file (simulate no filtering step) + nt_file = tmp_path / "dump.txt.nt" + main_module._write_field_sorted_unique(str(pwdump_file), str(nt_file), 4, ":") + + nt_hashes = nt_file.read_text().strip().split("\n") + assert len(nt_hashes) == 3 + assert "31d6cfe0d16ae931b73c59d7e0c089c0" in nt_hashes + assert "5f4dcc3b5aa765d61d8327deb882cf99" in nt_hashes + assert "6cb75f652a9b52798eb6cf2201057c73" in nt_hashes + + def test_pipeline_with_realistic_hashes(self, tmp_path, main_module): + """Test with realistic Active Directory pwdump data.""" + pwdump_file = tmp_path / "ad_dump.txt" + pwdump_file.write_text( + # Real format examples + "Administrator:500:aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0:::\n" + "krbtgt:502:aad3b435b51404eeaad3b435b51404ee:d3c02561bba6ee4ad6cfd024ec8fda5d:::\n" + "CORP-DC01$:1000:aad3b435b51404eeaad3b435b51404ee:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6:::\n" + "CORP-WKS01$:1001:aad3b435b51404eeaad3b435b51404ee:1234567890abcdef1234567890abcdef:::\n" + "jdoe:1102:e52cac67419a9a224a3b108f3fa6cb6d:8846f7eaee8fb117ad06bdd830b7586c:::\n" + "CORP-SRV01$:1003:aad3b435b51404eeaad3b435b51404ee:fedcba0987654321fedcba0987654321:::\n" + "asmith:1103:aad3b435b51404eeaad3b435b51404ee:2b2ac52d43a3d5c5c5b5b5f5e5d5c5a5:::\n" + ) + + # Count computer accounts (domain controllers, workstations, servers) + count = main_module._count_computer_accounts(str(pwdump_file)) + assert count == 3 + + # Filter them out + filtered_file = tmp_path / "ad_dump.txt.filtered" + removed = main_module._filter_computer_accounts( + str(pwdump_file), str(filtered_file) + ) + assert removed == 3 + + # Extract NT hashes + nt_file = tmp_path / "ad_dump.txt.filtered.nt" + main_module._write_field_sorted_unique(str(filtered_file), str(nt_file), 4, ":") + + nt_hashes = nt_file.read_text().strip().split("\n") + assert len(nt_hashes) == 4 # Four unique NT hashes from user accounts + # Verify no computer account hashes leaked through + assert "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6" not in nt_hashes + assert "1234567890abcdef1234567890abcdef" not in nt_hashes + assert "fedcba0987654321fedcba0987654321" not in nt_hashes + + def test_pipeline_with_bom(self, tmp_path, main_module): + """Test that BOM character doesn't break filtering.""" + pwdump_file = tmp_path / "bom_dump.txt" + # Note: The main.py code strips BOM at line 3672 but _filter_computer_accounts + # doesn't handle it. This tests if that causes issues. + pwdump_file.write_text( + "\ufeffAdministrator:500:aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0:::\n" + "COMPUTER$:1001:aad3b435b51404eeaad3b435b51404ee:8846f7eaee8fb117ad06bdd830b7586c:::\n" + "john:1002:e52cac67419a9a224a3b108f3fa6cb6d:5f4dcc3b5aa765d61d8327deb882cf99:::\n" + ) + + filtered_file = tmp_path / "bom_dump.txt.filtered" + removed = main_module._filter_computer_accounts( + str(pwdump_file), str(filtered_file) + ) + assert removed == 1 + + filtered_lines = filtered_file.read_text().strip().split("\n") + # BOM should be preserved in first line since filter doesn't strip it + assert len(filtered_lines) == 2 + # First username might have BOM attached + first_username = filtered_lines[0].split(":", 1)[0] + # BOM gets written through, so we check it's either with or without + assert "Administrator" in first_username + + def test_pipeline_preserves_all_fields(self, tmp_path, main_module): + """Verify filtered file maintains exact pwdump structure.""" + pwdump_file = tmp_path / "structure.txt" + pwdump_file.write_text( + "user1:500:LM1:NT1:extra1:extra2:extra3\n" + "COMP$:501:LM2:NT2:extra4:extra5:extra6\n" + "user2:502:LM3:NT3:extra7:extra8:extra9\n" + ) + + filtered_file = tmp_path / "structure.txt.filtered" + main_module._filter_computer_accounts(str(pwdump_file), str(filtered_file)) + + filtered_lines = filtered_file.read_text().strip().split("\n") + assert len(filtered_lines) == 2 + + # Each line should have exactly 7 fields + for line in filtered_lines: + assert line.count(":") == 6 + parts = line.split(":") + assert len(parts) == 7 + + def test_pipeline_empty_username_edge_case(self, tmp_path, main_module): + """Test handling of lines with empty username field.""" + pwdump_file = tmp_path / "empty_user.txt" + pwdump_file.write_text( + "user1:500:lm1:nt1:::\n" + ":501:lm2:nt2:::\n" # Empty username + "COMP$:502:lm3:nt3:::\n" + "user2:503:lm4:nt4:::\n" + ) + + count = main_module._count_computer_accounts(str(pwdump_file)) + assert count == 1 # Only COMP$ should count + + filtered_file = tmp_path / "empty_user.txt.filtered" + removed = main_module._filter_computer_accounts( + str(pwdump_file), str(filtered_file) + ) + assert removed == 1 + + filtered_lines = filtered_file.read_text().strip().split("\n") + assert len(filtered_lines) == 3 # Empty username line should be kept + + def test_pipeline_with_duplicate_hashes_across_accounts( + self, tmp_path, main_module + ): + """Verify deduplication works correctly when multiple users share hashes.""" + pwdump_file = tmp_path / "dup_hashes.txt" + pwdump_file.write_text( + "user1:500:aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0:::\n" + "user2:501:aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0:::\n" # Same hash + "COMP$:502:aad3b435b51404eeaad3b435b51404ee:8846f7eaee8fb117ad06bdd830b7586c:::\n" + "user3:503:e52cac67419a9a224a3b108f3fa6cb6d:31d6cfe0d16ae931b73c59d7e0c089c0:::\n" # Same NT, different LM + ) + + filtered_file = tmp_path / "dup_hashes.txt.filtered" + main_module._filter_computer_accounts(str(pwdump_file), str(filtered_file)) + + nt_file = tmp_path / "dup_hashes.txt.filtered.nt" + main_module._write_field_sorted_unique(str(filtered_file), str(nt_file), 4, ":") + + nt_hashes = nt_file.read_text().strip().split("\n") + # Should have only 1 unique NT hash (31d6cfe0d16ae931b73c59d7e0c089c0) + assert len(nt_hashes) == 1 + assert nt_hashes[0] == "31d6cfe0d16ae931b73c59d7e0c089c0" + + lm_file = tmp_path / "dup_hashes.txt.filtered.lm" + main_module._write_field_sorted_unique(str(filtered_file), str(lm_file), 3, ":") + + lm_hashes = lm_file.read_text().strip().split("\n") + # Should have 2 unique LM hashes + assert len(lm_hashes) == 2 + assert "aad3b435b51404eeaad3b435b51404ee" in lm_hashes + assert "e52cac67419a9a224a3b108f3fa6cb6d" in lm_hashes diff --git a/tests/test_ntlm_preprocessing_edge_cases.py b/tests/test_ntlm_preprocessing_edge_cases.py new file mode 100644 index 0000000..b93b7f2 --- /dev/null +++ b/tests/test_ntlm_preprocessing_edge_cases.py @@ -0,0 +1,325 @@ +"""Additional edge case tests for NTLM preprocessing (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 TestFilterComputerAccountsEdgeCases: + """Additional edge cases for computer account filtering.""" + + def test_unicode_in_usernames(self, tmp_path, main_module): + """Test handling of Unicode characters in usernames.""" + hash_file = tmp_path / "hashes.txt" + hash_file.write_text( + "user1:1001:aad3b435b51404ee:hash1:::\n" + "Müller$:1002:aad3b435b51404ee:hash2:::\n" + "用户:1003:aad3b435b51404ee:hash3:::\n", + encoding="utf-8", + ) + 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(encoding="utf-8").strip().split("\n") + assert len(lines) == 2 + assert "Müller$" not in output_file.read_text(encoding="utf-8") + + def test_dollar_in_middle_of_username(self, tmp_path, main_module): + """Test that $ only at end triggers filtering.""" + hash_file = tmp_path / "hashes.txt" + hash_file.write_text( + "user$name:1001:aad3b435b51404ee:hash1:::\n" + "COMP1$:1002:aad3b435b51404ee:hash2:::\n" + "$startdollar:1003:aad3b435b51404ee:hash3:::\n", + ) + output_file = tmp_path / "filtered.txt" + removed = main_module._filter_computer_accounts( + str(hash_file), str(output_file) + ) + # Only COMP1$ should be removed (ends with $) + assert removed == 1 + lines = output_file.read_text().strip().split("\n") + assert len(lines) == 2 + assert "user$name" in lines[0] + assert "$startdollar" in lines[1] + + def test_empty_username(self, tmp_path, main_module): + """Test handling of lines with empty username field.""" + hash_file = tmp_path / "hashes.txt" + hash_file.write_text( + ":1001:aad3b435b51404ee:hash1:::\nuser1: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_very_long_lines(self, tmp_path, main_module): + """Test handling of very long hash lines.""" + hash_file = tmp_path / "hashes.txt" + long_hash = "a" * 10000 + hash_file.write_text( + f"user1:1001:{long_hash}:hash1:::\nCOMP1$:1002:{long_hash}: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) == 1 + assert "user1" in lines[0] + + def test_output_file_exists_overwrites(self, tmp_path, main_module): + """Test that output file is overwritten if it exists.""" + hash_file = tmp_path / "hashes.txt" + hash_file.write_text( + "user1:1001:aad3b435b51404ee:hash1:::\n" + "COMP1$:1002:aad3b435b51404ee:hash2:::\n" + ) + output_file = tmp_path / "filtered.txt" + output_file.write_text("OLD CONTENT THAT SHOULD BE REPLACED\n") + + removed = main_module._filter_computer_accounts( + str(hash_file), str(output_file) + ) + assert removed == 1 + content = output_file.read_text() + assert "OLD CONTENT" not in content + assert "user1" in content + + def test_permission_denied_output(self, tmp_path, main_module): + """Test handling when output file cannot be written.""" + hash_file = tmp_path / "hashes.txt" + hash_file.write_text( + "user1:1001:aad3b435b51404ee:hash1:::\n" + "COMP1$:1002:aad3b435b51404ee:hash2:::\n" + ) + # Create a read-only directory for output + readonly_dir = tmp_path / "readonly" + readonly_dir.mkdir() + os.chmod(readonly_dir, 0o444) + + output_file = readonly_dir / "filtered.txt" + + # Should handle PermissionError gracefully + try: + removed = main_module._filter_computer_accounts( + str(hash_file), str(output_file) + ) + # If it doesn't raise, should return 0 + assert removed == 0 + except PermissionError: + # This is also acceptable behavior + pass + finally: + # Cleanup - restore permissions + os.chmod(readonly_dir, 0o755) + + def test_blank_lines_preserved_count(self, tmp_path, main_module): + """Test that blank lines don't affect removed count.""" + hash_file = tmp_path / "hashes.txt" + hash_file.write_text( + "user1:1001:aad3b435b51404ee:hash1:::\n" + "\n" + "COMP1$:1002:aad3b435b51404ee:hash2:::\n" + "\n\n" + "user2:1003:aad3b435b51404ee:hash3:::\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 TestDedupNetntlmEdgeCases: + """Additional edge cases for NetNTLM deduplication.""" + + def test_unicode_usernames(self, tmp_path, main_module): + """Test deduplication with Unicode usernames.""" + hash_file = tmp_path / "netntlm.txt" + hash_file.write_text( + "用户1::DOMAIN:challenge1:response1:blob1\n" + "用户1::DOMAIN:challenge2:response2:blob2\n" + "Müller::DOMAIN:challenge3:response3:blob3\n", + encoding="utf-8", + ) + 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 == 1 + lines = output_file.read_text(encoding="utf-8").strip().split("\n") + assert len(lines) == 2 + + def test_whitespace_in_usernames(self, tmp_path, main_module): + """Test handling of usernames with leading/trailing spaces.""" + 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) + ) + # These are NOT duplicates because split() doesn't strip whitespace + # This is actually correct behavior - the usernames differ + assert total == 3 + assert duplicates == 0 + + def test_case_variations(self, tmp_path, main_module): + """Test various case combinations are deduplicated.""" + 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" + "uSeR1::DOMAIN:challenge4:response4:blob4\n" + ) + output_file = tmp_path / "dedup.txt" + total, duplicates = main_module._dedup_netntlm_by_username( + str(hash_file), str(output_file) + ) + assert total == 4 + assert duplicates == 3 + lines = output_file.read_text().strip().split("\n") + assert len(lines) == 1 + + def test_very_long_username(self, tmp_path, main_module): + """Test handling of very long usernames.""" + hash_file = tmp_path / "netntlm.txt" + long_user = "a" * 10000 + hash_file.write_text( + f"{long_user}::DOMAIN:challenge1:response1:blob1\n" + f"{long_user}::DOMAIN:challenge2:response2:blob2\n" + "user2::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 == 1 + lines = output_file.read_text().strip().split("\n") + assert len(lines) == 2 + + def test_memory_efficiency_many_duplicates(self, tmp_path, main_module): + """Test memory handling with many duplicates.""" + hash_file = tmp_path / "netntlm.txt" + # Create 1000 lines, all duplicates of the same user + content = "" + for i in range(1000): + content += f"user1::DOMAIN:challenge{i}:response{i}:blob{i}\n" + hash_file.write_text(content) + + output_file = tmp_path / "dedup.txt" + total, duplicates = main_module._dedup_netntlm_by_username( + str(hash_file), str(output_file) + ) + assert total == 1000 + assert duplicates == 999 + lines = output_file.read_text().strip().split("\n") + assert len(lines) == 1 + + def test_special_characters_in_username(self, tmp_path, main_module): + """Test usernames with special characters.""" + hash_file = tmp_path / "netntlm.txt" + hash_file.write_text( + "user@domain::DOMAIN:challenge1:response1:blob1\n" + "user-name::DOMAIN:challenge2:response2:blob2\n" + "user.name::DOMAIN:challenge3:response3:blob3\n" + "user@domain::DOMAIN:challenge4:response4:blob4\n" + ) + output_file = tmp_path / "dedup.txt" + total, duplicates = main_module._dedup_netntlm_by_username( + str(hash_file), str(output_file) + ) + assert total == 4 + assert duplicates == 1 + lines = output_file.read_text().strip().split("\n") + assert len(lines) == 3 + + def test_only_colons(self, tmp_path, main_module): + """Test handling of malformed lines with only colons.""" + hash_file = tmp_path / "netntlm.txt" + hash_file.write_text( + "user1::DOMAIN:challenge1:response1:blob1\n" + ":::::\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) + ) + # Empty username from ::::: should be counted but not cause errors + assert total == 3 + + def test_preserved_line_order(self, tmp_path, main_module): + """Test that first occurrence is preserved, not last.""" + hash_file = tmp_path / "netntlm.txt" + hash_file.write_text( + "user1::DOMAIN:FIRST:response1:blob1\n" + "user2::DOMAIN:challenge2:response2:blob2\n" + "user1::DOMAIN:SECOND:response3:blob3\n" + "user3::DOMAIN:challenge4:response4:blob4\n" + "user1::DOMAIN:THIRD: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 + content = output_file.read_text() + # First occurrence should be kept + assert "FIRST" in content + assert "SECOND" not in content + assert "THIRD" not in content + + +class TestCountComputerAccountsEdgeCases: + """Additional edge cases for computer account counting.""" + + def test_mixed_delimiters(self, tmp_path, main_module): + """Test that only the specified delimiter is used.""" + hash_file = tmp_path / "hashes.txt" + hash_file.write_text( + "user1:1001:aad3b435b51404ee:hash1:::\n" + "COMP$;1002;aad3b435b51404ee;hash2\n" # Different delimiter + ) + # Default delimiter is ":" + assert main_module._count_computer_accounts(str(hash_file)) == 0 + # Custom delimiter ";" - COMP$ is first field with ";" delimiter + assert main_module._count_computer_accounts(str(hash_file), ";") == 1 + + def test_single_field_line(self, tmp_path, main_module): + """Test lines with only one field (no delimiter).""" + hash_file = tmp_path / "hashes.txt" + hash_file.write_text("COMP1$\nuser1:1001:aad3b435b51404ee:hash1:::\n") + # COMP1$ should still be counted (split returns single element) + assert main_module._count_computer_accounts(str(hash_file)) == 1