"""Tests for modules/passive/packet_capture — rotation, zstd compression, AES-256-GCM encryption. Validates the post-rotation pipeline without requiring tcpdump or root. """ import os import struct import tempfile import threading import time from pathlib import Path from unittest.mock import MagicMock, patch, PropertyMock import pytest from modules.base import BaseModule from modules.passive.packet_capture import PacketCapture # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture def pcap_module(mock_bus, mock_state, tmp_path): """Return a PacketCapture instance with temp dirs and mocked externals.""" config = { "data_dir": str(tmp_path), "interface": "eth0", "snap_length": 65535, "rotation_minutes": 60, "compression_level": 3, "disk_threshold": 85, "encryption_key": os.urandom(32), } mod = PacketCapture(mock_bus, mock_state, config) # Pre-create pcap dir (start() would do this, but we bypass tcpdump) pcap_dir = tmp_path / "pcaps" pcap_dir.mkdir(exist_ok=True) mod._pcap_dir = str(pcap_dir) mod._encryption_key = config["encryption_key"] mod._running = True return mod @pytest.fixture def fake_pcap(tmp_path): """Create a minimal valid-looking pcap file in the pcaps subdir.""" pcap_dir = tmp_path / "pcaps" pcap_dir.mkdir(exist_ok=True) # Pcap global header (24 bytes) + a small amount of fake packet data pcap_path = pcap_dir / "capture_20260410_010000.pcap" header = struct.pack(" 0 # Decompress and verify round-trip dctx = zstd.ZstdDecompressor() with open(str(dst), "rb") as f: reader = dctx.stream_reader(f) decompressed = reader.read() assert decompressed == data def test_compress_zstd_cli_fallback(self, tmp_path): """Test CLI fallback when zstandard library is not available.""" src = tmp_path / "input.pcap" dst = tmp_path / "output.pcap.zst" data = os.urandom(2048) src.write_bytes(data) with patch.dict("sys.modules", {"zstandard": None}): try: PacketCapture._compress_zstd(str(src), str(dst), level=3) assert dst.exists() except FileNotFoundError: pytest.skip("zstd CLI not installed") # --------------------------------------------------------------------------- # Encryption (AES-256-GCM) # --------------------------------------------------------------------------- class TestAES256GCMEncryption: def test_encrypt_file_produces_valid_output(self, pcap_module, tmp_path): """Encrypted file has BB01 magic header, 12-byte nonce, and ciphertext.""" src = tmp_path / "plain.bin" dst = tmp_path / "encrypted.bin" plaintext = os.urandom(1024) src.write_bytes(plaintext) pcap_module._encrypt_file(str(src), str(dst)) encrypted = dst.read_bytes() # Magic header assert encrypted[:4] == b"BB01" # Nonce is 12 bytes nonce = encrypted[4:16] assert len(nonce) == 12 # Ciphertext is longer than plaintext (GCM tag adds 16 bytes) ciphertext = encrypted[16:] assert len(ciphertext) == len(plaintext) + 16 def test_encrypt_decrypt_roundtrip(self, pcap_module, tmp_path): """Encrypt then decrypt returns original data.""" cryptography = pytest.importorskip("cryptography") from cryptography.hazmat.primitives.ciphers.aead import AESGCM src = tmp_path / "plain.bin" dst = tmp_path / "encrypted.bin" plaintext = os.urandom(2048) src.write_bytes(plaintext) pcap_module._encrypt_file(str(src), str(dst)) # Decrypt manually encrypted = dst.read_bytes() nonce = encrypted[4:16] ciphertext = encrypted[16:] key = pcap_module._encryption_key[:32].ljust(32, b"\x00") aesgcm = AESGCM(key) decrypted = aesgcm.decrypt(nonce, ciphertext, None) assert decrypted == plaintext def test_encrypt_with_wrong_key_fails(self, pcap_module, tmp_path): """Decrypting with wrong key raises an error.""" cryptography = pytest.importorskip("cryptography") from cryptography.hazmat.primitives.ciphers.aead import AESGCM src = tmp_path / "plain.bin" dst = tmp_path / "encrypted.bin" src.write_bytes(b"secret pcap data") pcap_module._encrypt_file(str(src), str(dst)) encrypted = dst.read_bytes() nonce = encrypted[4:16] ciphertext = encrypted[16:] wrong_key = os.urandom(32) aesgcm = AESGCM(wrong_key) with pytest.raises(Exception): aesgcm.decrypt(nonce, ciphertext, None) def test_encrypt_skips_when_no_cryptography(self, pcap_module, tmp_path): """When cryptography lib is missing, file is just renamed.""" src = tmp_path / "plain.bin" dst = tmp_path / "renamed.bin" data = b"plain data" src.write_bytes(data) with patch.dict("sys.modules", {"cryptography": None, "cryptography.hazmat.primitives.ciphers.aead": None}): # Force ImportError by patching at function level original = pcap_module._encrypt_file def patched_encrypt(s, d): try: raise ImportError("no cryptography") except ImportError: import logging logging.getLogger().warning("cryptography not available") os.rename(s, d) pcap_module._encrypt_file = patched_encrypt pcap_module._encrypt_file(str(src), str(dst)) assert dst.read_bytes() == data assert not src.exists() # --------------------------------------------------------------------------- # Rotation pipeline (compress + encrypt + remove original) # --------------------------------------------------------------------------- class TestRotationPipeline: def test_process_completed_pcaps_full_pipeline(self, pcap_module, fake_pcap): """A completed pcap is compressed, encrypted, and original removed.""" cryptography = pytest.importorskip("cryptography") zstd = pytest.importorskip("zstandard") # Ensure _is_file_locked returns False so the pcap is eligible with patch.object(PacketCapture, "_is_file_locked", return_value=False): pcap_module._process_completed_pcaps(compress_level=3) # Original pcap should be deleted assert not os.path.exists(fake_pcap) # Encrypted+compressed file should exist expected = fake_pcap + ".zst.enc" assert os.path.exists(expected) # Counter should be incremented assert pcap_module._pcap_count == 1 assert pcap_module._bytes_written > 0 # Bus should have emitted PCAP_ROTATED # (mock_bus captures events internally) def test_process_skips_locked_files(self, pcap_module, fake_pcap): """Files still being written by tcpdump are skipped.""" with patch.object(PacketCapture, "_is_file_locked", return_value=True): pcap_module._process_completed_pcaps(compress_level=3) # Original should still exist assert os.path.exists(fake_pcap) assert pcap_module._pcap_count == 0 def test_process_skips_tiny_files(self, pcap_module, tmp_path): """Files smaller than pcap global header (24 bytes) are skipped.""" pcap_dir = tmp_path / "pcaps" tiny = pcap_dir / "capture_20260410_020000.pcap" tiny.write_bytes(b"tiny") with patch.object(PacketCapture, "_is_file_locked", return_value=False): pcap_module._process_completed_pcaps(compress_level=3) assert tiny.exists() assert pcap_module._pcap_count == 0 def test_compress_only_when_no_encryption_key(self, pcap_module, fake_pcap): """Without encryption key, only compression happens.""" zstd = pytest.importorskip("zstandard") pcap_module._encryption_key = b"" with patch.object(PacketCapture, "_is_file_locked", return_value=False): pcap_module._process_completed_pcaps(compress_level=3) assert not os.path.exists(fake_pcap) compressed = fake_pcap + ".zst" assert os.path.exists(compressed) # No .enc file should exist assert not os.path.exists(fake_pcap + ".zst.enc") # --------------------------------------------------------------------------- # Disk usage purge # --------------------------------------------------------------------------- class TestDiskPurge: def test_no_purge_below_threshold(self, pcap_module, tmp_path): """No files deleted when disk usage is below threshold.""" pcap_dir = tmp_path / "pcaps" enc_file = pcap_dir / "capture_20260410_010000.pcap.zst.enc" enc_file.write_bytes(os.urandom(100)) # Mock disk_usage to return 50% usage mock_usage = MagicMock(used=50, total=100) with patch("shutil.disk_usage", return_value=mock_usage): pcap_module._check_disk_usage(threshold=85) assert enc_file.exists() def test_purge_above_threshold(self, pcap_module, tmp_path): """Oldest files are purged when disk exceeds threshold.""" pcap_dir = tmp_path / "pcaps" # Create 3 encrypted pcap files with different mtimes for i in range(3): f = pcap_dir / f"capture_20260410_0{i}0000.pcap.zst.enc" f.write_bytes(os.urandom(100)) os.utime(str(f), (1000 + i, 1000 + i)) # Mock: first call 90% (above threshold), after purge 75% (below threshold-5) call_count = [0] def mock_disk_usage(path): call_count[0] += 1 if call_count[0] <= 1: return MagicMock(used=90, total=100) return MagicMock(used=75, total=100) with patch("shutil.disk_usage", side_effect=mock_disk_usage): pcap_module._check_disk_usage(threshold=85) # At least one file should have been purged remaining = list(pcap_dir.glob("*.enc")) assert len(remaining) < 3 # --------------------------------------------------------------------------- # Status reporting # --------------------------------------------------------------------------- class TestStatus: def test_status_when_not_running(self, mock_bus, mock_state): mod = PacketCapture(mock_bus, mock_state, {}) s = mod.status() assert s["running"] is False assert s["pcap_count"] == 0 assert s["bytes_written"] == 0