4ca9d3cdf1
Tests cover: - packet_capture: zstd compression, AES-256-GCM encryption, rotation pipeline, disk purge, file locking (15 tests) - credential_db: ingestion, deduplication at scale (1000+), hashcat export filtering, crack status management, bulk update, CSV/JSON export, query helpers (25 tests) - ja3_spoofer: builtin profile validation, cipher ID mapping, external DB loading, auto-selection, SSL context config, randomization validation (28 tests) Addresses DevTrack items #424, #425, #437.
347 lines
13 KiB
Python
347 lines
13 KiB
Python
"""Tests for modules/intel/credential_db — deduplication, bulk load, hashcat export.
|
|
|
|
Validates credential ingestion, deduplication logic, export formats,
|
|
and bulk operations without requiring network or bus events.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import sqlite3
|
|
import time
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from modules.base import BaseModule
|
|
from modules.intel.credential_db import CredentialDB, _TABLE_DDL, _INDEXES, _HASHCAT_MODES
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.fixture
|
|
def cred_db(mock_bus, mock_state, tmp_path):
|
|
"""Return a started CredentialDB instance backed by a temp SQLite file."""
|
|
config = {"data_dir": str(tmp_path)}
|
|
mod = CredentialDB(mock_bus, mock_state, config)
|
|
mod.start()
|
|
yield mod
|
|
mod.stop()
|
|
|
|
|
|
def _ingest(db, username="admin", service="smb", cred_type="ntlmv2",
|
|
cred_value="hash123", domain="CORP", source_module="test",
|
|
source_ip="10.0.0.5", target_ip="10.0.0.1", target_port=445,
|
|
notes=""):
|
|
"""Helper to call _ingest_credential with defaults."""
|
|
return db._ingest_credential(
|
|
source_module=source_module,
|
|
source_ip=source_ip,
|
|
target_ip=target_ip,
|
|
target_port=target_port,
|
|
service=service,
|
|
username=username,
|
|
domain=domain,
|
|
cred_type=cred_type,
|
|
cred_value=cred_value,
|
|
notes=notes,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Structure
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestCredentialDBStructure:
|
|
|
|
def test_is_base_module(self):
|
|
assert issubclass(CredentialDB, BaseModule)
|
|
|
|
def test_module_attributes(self):
|
|
assert CredentialDB.name == "credential_db"
|
|
assert CredentialDB.module_type == "intel"
|
|
assert CredentialDB.requires_root is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Ingestion + Deduplication
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestIngestion:
|
|
|
|
def test_ingest_new_credential(self, cred_db):
|
|
"""A new credential returns True and increments count."""
|
|
result = _ingest(cred_db)
|
|
assert result is True
|
|
assert cred_db._ingest_count == 1
|
|
|
|
def test_ingest_empty_value_rejected(self, cred_db):
|
|
"""Empty cred_value is rejected."""
|
|
result = _ingest(cred_db, cred_value="")
|
|
assert result is False
|
|
|
|
def test_duplicate_returns_false(self, cred_db):
|
|
"""Inserting the same (service, username, cred_type, cred_value) deduplicates."""
|
|
_ingest(cred_db, username="admin", service="smb", cred_type="ntlmv2", cred_value="AAA")
|
|
result = _ingest(cred_db, username="admin", service="smb", cred_type="ntlmv2", cred_value="AAA")
|
|
assert result is False
|
|
assert cred_db._dedup_count == 1
|
|
|
|
def test_duplicate_appends_notes(self, cred_db):
|
|
"""Duplicate credential appends new notes to existing record."""
|
|
_ingest(cred_db, username="user1", cred_value="hash1", notes="first seen")
|
|
_ingest(cred_db, username="user1", cred_value="hash1", notes="seen again")
|
|
|
|
creds = cred_db.get_credentials(username="user1")
|
|
assert len(creds) == 1
|
|
assert "first seen" in creds[0]["notes"]
|
|
assert "seen again" in creds[0]["notes"]
|
|
|
|
def test_duplicate_appends_source_module(self, cred_db):
|
|
"""Duplicate from a different module appends source_module."""
|
|
_ingest(cred_db, username="user1", cred_value="hash1", source_module="sniffer")
|
|
_ingest(cred_db, username="user1", cred_value="hash1", source_module="responder")
|
|
|
|
creds = cred_db.get_credentials(username="user1")
|
|
assert "sniffer" in creds[0]["source_module"]
|
|
assert "responder" in creds[0]["source_module"]
|
|
|
|
def test_different_cred_types_not_deduplicated(self, cred_db):
|
|
"""Same username+service but different cred_type are distinct entries."""
|
|
_ingest(cred_db, cred_type="ntlmv2", cred_value="hash_ntlm")
|
|
_ingest(cred_db, cred_type="kerberos_tgs", cred_value="hash_kerb")
|
|
|
|
creds = cred_db.get_credentials()
|
|
assert len(creds) == 2
|
|
|
|
def test_auto_hashcat_mode_resolution(self, cred_db):
|
|
"""hashcat_mode is auto-resolved from cred_type when not provided."""
|
|
_ingest(cred_db, cred_type="ntlmv2", cred_value="somehash")
|
|
|
|
creds = cred_db.get_credentials(cred_type="ntlmv2")
|
|
assert len(creds) == 1
|
|
assert creds[0]["hashcat_mode"] == 5600
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Bulk load (1000+ credentials)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestBulkLoad:
|
|
|
|
def test_ingest_1000_unique_credentials(self, cred_db):
|
|
"""Ingest 1000 unique credentials without errors."""
|
|
for i in range(1000):
|
|
result = _ingest(
|
|
cred_db,
|
|
username=f"user_{i}",
|
|
cred_value=f"hash_{i:04d}",
|
|
service="smb",
|
|
cred_type="ntlmv2",
|
|
)
|
|
assert result is True
|
|
|
|
assert cred_db._ingest_count == 1000
|
|
s = cred_db.summary()
|
|
assert s["total"] == 1000
|
|
assert s["unique_users"] == 1000
|
|
|
|
def test_bulk_deduplication_at_scale(self, cred_db):
|
|
"""Ingest 500 unique + 500 duplicates — exactly 500 stored."""
|
|
for i in range(500):
|
|
_ingest(cred_db, username=f"u{i}", cred_value=f"h{i}")
|
|
|
|
for i in range(500):
|
|
_ingest(cred_db, username=f"u{i}", cred_value=f"h{i}")
|
|
|
|
assert cred_db._dedup_count == 500
|
|
s = cred_db.summary()
|
|
assert s["total"] == 500
|
|
|
|
def test_mixed_services_bulk(self, cred_db):
|
|
"""1200 credentials across multiple services."""
|
|
services = ["smb", "ldap", "http", "rdp", "ssh", "ftp"]
|
|
for i in range(1200):
|
|
svc = services[i % len(services)]
|
|
_ingest(cred_db, username=f"user_{i}", cred_value=f"val_{i}", service=svc)
|
|
|
|
s = cred_db.summary()
|
|
assert s["total"] == 1200
|
|
assert len(s["by_service"]) == len(services)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Hashcat export
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestHashcatExport:
|
|
|
|
def test_to_hashcat_filters_by_mode(self, cred_db):
|
|
"""to_hashcat(mode) only returns credentials with matching hashcat_mode."""
|
|
_ingest(cred_db, username="u1", cred_type="ntlmv2", cred_value="ntlm_hash_1")
|
|
_ingest(cred_db, username="u2", cred_type="kerberos_tgs", cred_value="kerb_hash_1")
|
|
|
|
ntlm_output = cred_db.to_hashcat(mode=5600)
|
|
assert "ntlm_hash_1" in ntlm_output
|
|
assert "kerb_hash_1" not in ntlm_output
|
|
|
|
kerb_output = cred_db.to_hashcat(mode=13100)
|
|
assert "kerb_hash_1" in kerb_output
|
|
assert "ntlm_hash_1" not in kerb_output
|
|
|
|
def test_to_hashcat_all_hashable(self, cred_db):
|
|
"""to_hashcat(None) returns all credentials with a hashcat_mode."""
|
|
_ingest(cred_db, username="u1", cred_type="ntlmv2", cred_value="h1")
|
|
_ingest(cred_db, username="u2", cred_type="kerberos_tgs", cred_value="h2")
|
|
_ingest(cred_db, username="u3", cred_type="ftp", cred_value="plaintext") # no hashcat mode
|
|
|
|
output = cred_db.to_hashcat()
|
|
assert "h1" in output
|
|
assert "h2" in output
|
|
assert "plaintext" not in output
|
|
|
|
def test_to_hashcat_excludes_cracked(self, cred_db):
|
|
"""Already cracked credentials are excluded from hashcat export."""
|
|
_ingest(cred_db, username="u1", cred_type="ntlmv2", cred_value="hash_uncracked")
|
|
_ingest(cred_db, username="u2", cred_type="ntlmv2", cred_value="hash_cracked")
|
|
|
|
creds = cred_db.get_credentials(username="u2")
|
|
cred_db.update_crack_status(creds[0]["id"], "cracked", "password123")
|
|
|
|
output = cred_db.to_hashcat(mode=5600)
|
|
assert "hash_uncracked" in output
|
|
assert "hash_cracked" not in output
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Crack status management
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestCrackStatus:
|
|
|
|
def test_update_crack_status(self, cred_db):
|
|
"""update_crack_status changes status and stores cracked value."""
|
|
_ingest(cred_db, username="target", cred_value="ntlm_hash")
|
|
|
|
creds = cred_db.get_credentials(username="target")
|
|
cred_id = creds[0]["id"]
|
|
|
|
cred_db.update_crack_status(cred_id, "cracked", "P@ssw0rd!")
|
|
|
|
updated = cred_db.get_credentials(username="target")
|
|
assert updated[0]["crack_status"] == "cracked"
|
|
assert updated[0]["cracked_value"] == "P@ssw0rd!"
|
|
|
|
def test_invalid_status_ignored(self, cred_db):
|
|
"""Invalid status values are silently ignored."""
|
|
_ingest(cred_db, username="u1", cred_value="h1")
|
|
creds = cred_db.get_credentials(username="u1")
|
|
cred_db.update_crack_status(creds[0]["id"], "invalid_status")
|
|
|
|
updated = cred_db.get_credentials(username="u1")
|
|
assert updated[0]["crack_status"] == "uncracked"
|
|
|
|
def test_bulk_update_cracked(self, cred_db):
|
|
"""bulk_update_cracked updates multiple credentials from hashcat results."""
|
|
for i in range(10):
|
|
_ingest(cred_db, username=f"u{i}", cred_type="ntlmv2", cred_value=f"hash_{i}")
|
|
|
|
results = {f"hash_{i}": f"pass_{i}" for i in range(5)}
|
|
updated = cred_db.bulk_update_cracked(results)
|
|
|
|
assert updated == 5
|
|
cracked = cred_db.get_cracked()
|
|
assert len(cracked) == 5
|
|
|
|
def test_bulk_update_idempotent(self, cred_db):
|
|
"""Running bulk_update_cracked twice doesn't double-count."""
|
|
_ingest(cred_db, username="u1", cred_type="ntlmv2", cred_value="hash_x")
|
|
|
|
results = {"hash_x": "cracked_pass"}
|
|
first = cred_db.bulk_update_cracked(results)
|
|
second = cred_db.bulk_update_cracked(results)
|
|
|
|
assert first == 1
|
|
assert second == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Export formats
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestExports:
|
|
|
|
def test_to_csv(self, cred_db):
|
|
"""CSV export includes header and all rows."""
|
|
for i in range(5):
|
|
_ingest(cred_db, username=f"u{i}", cred_value=f"h{i}")
|
|
|
|
csv_output = cred_db.to_csv()
|
|
lines = csv_output.strip().split("\n")
|
|
assert len(lines) == 6 # header + 5 data rows
|
|
assert "username" in lines[0]
|
|
|
|
def test_to_json(self, cred_db):
|
|
"""JSON export is valid and contains all records."""
|
|
for i in range(3):
|
|
_ingest(cred_db, username=f"u{i}", cred_value=f"h{i}")
|
|
|
|
json_output = cred_db.to_json()
|
|
data = json.loads(json_output)
|
|
assert len(data) == 3
|
|
assert all("username" in entry for entry in data)
|
|
|
|
def test_summary(self, cred_db):
|
|
"""Summary returns correct counts by type and service."""
|
|
_ingest(cred_db, username="u1", service="smb", cred_type="ntlmv2", cred_value="h1")
|
|
_ingest(cred_db, username="u2", service="ldap", cred_type="ntlmv2", cred_value="h2")
|
|
_ingest(cred_db, username="u3", service="smb", cred_type="kerberos_tgs", cred_value="h3")
|
|
|
|
s = cred_db.summary()
|
|
assert s["total"] == 3
|
|
assert s["unique_users"] == 3
|
|
assert s["by_type"]["ntlmv2"] == 2
|
|
assert s["by_service"]["smb"] == 2
|
|
assert s["by_service"]["ldap"] == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Query helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestQueries:
|
|
|
|
def test_get_credentials_filter_by_service(self, cred_db):
|
|
_ingest(cred_db, username="u1", service="smb", cred_value="h1")
|
|
_ingest(cred_db, username="u2", service="ldap", cred_value="h2")
|
|
|
|
smb_creds = cred_db.get_credentials(service="smb")
|
|
assert len(smb_creds) == 1
|
|
assert smb_creds[0]["service"] == "smb"
|
|
|
|
def test_get_credentials_filter_by_cred_type(self, cred_db):
|
|
_ingest(cred_db, username="u1", cred_type="ntlmv2", cred_value="h1")
|
|
_ingest(cred_db, username="u2", cred_type="kerberos_tgs", cred_value="h2")
|
|
|
|
kerb = cred_db.get_credentials(cred_type="kerberos_tgs")
|
|
assert len(kerb) == 1
|
|
|
|
def test_get_credentials_limit(self, cred_db):
|
|
for i in range(20):
|
|
_ingest(cred_db, username=f"u{i}", cred_value=f"h{i}")
|
|
|
|
limited = cred_db.get_credentials(limit=5)
|
|
assert len(limited) == 5
|
|
|
|
def test_get_cracked_returns_only_cracked(self, cred_db):
|
|
_ingest(cred_db, username="u1", cred_value="h1")
|
|
_ingest(cred_db, username="u2", cred_value="h2")
|
|
|
|
creds = cred_db.get_credentials(username="u2")
|
|
cred_db.update_crack_status(creds[0]["id"], "cracked", "pw")
|
|
|
|
cracked = cred_db.get_cracked()
|
|
assert len(cracked) == 1
|
|
assert cracked[0]["username"] == "u2"
|