"""Pytest configuration and shared fixtures.""" import sqlite3 from pathlib import Path from typing import Any import pytest @pytest.fixture def mock_scan_metadata() -> dict[str, Any]: """Provide mock scan metadata.""" return { "scan_id": 5, "hostname": "example.com", "fqdn": "example.com", "ipv4": "192.168.1.1", "ipv6": "2001:db8::1", "timestamp": "2025-01-08T10:30:00.123456", "duration": 12.34, "ports": ["443", "636"], } @pytest.fixture def mock_scan_data(mock_scan_metadata: dict[str, Any]) -> dict[str, Any]: """Provide complete mock scan data structure.""" return { "metadata": mock_scan_metadata, "summary": { "total_ports": 2, "successful_ports": 2, "total_cipher_suites": 50, "compliant_cipher_suites": 45, "cipher_suite_percentage": 90, "total_groups": 10, "compliant_groups": 8, "group_percentage": 80, "critical_vulnerabilities": 0, }, "ports_data": { 443: { "port": 443, "status": "completed", "tls_version": "1.3", "cipher_suites": { "1.2": { "accepted": [ { "name": "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", "iana_recommended": "Y", "bsi_approved": True, "bsi_valid_until": "2029", "compliant": True, }, ], "rejected": [], "rejected_total": 0, }, "1.3": { "accepted": [ { "name": "TLS_AES_128_GCM_SHA256", "iana_recommended": "Y", "bsi_approved": True, "bsi_valid_until": "2031", "compliant": True, }, ], "rejected": [ { "name": "TLS_AES_128_CCM_SHA256", "iana_recommended": "Y", "bsi_approved": True, "bsi_valid_until": "2031", }, ], "rejected_total": 1, }, }, "supported_groups": [ { "name": "x25519", "iana_recommended": "Y", "bsi_approved": False, "bsi_valid_until": None, "compliant": True, }, ], "missing_recommended_groups": { "bsi_approved": [ { "name": "brainpoolP256r1", "tls_versions": ["1.2"], "valid_until": "2031", }, ], "iana_recommended": [], }, "certificates": [ { "position": 0, "subject": "CN=example.com", "issuer": "CN=Test CA", "not_before": "2024-01-01", "not_after": "2025-12-31", "key_type": "RSA", "key_bits": 2048, }, ], "vulnerabilities": [ { "type": "Heartbleed", "vulnerable": False, "details": "Not vulnerable", }, ], "protocol_features": [ { "name": "TLS Compression", "supported": False, "details": "Disabled", }, ], "session_features": [ { "type": "Session Resumption", "client_initiated": True, "secure": True, "session_id_supported": True, "ticket_supported": True, "details": "Supported", }, ], "http_headers": [ { "name": "Strict-Transport-Security", "is_present": True, "value": "max-age=31536000", }, ], "compliance": { "cipher_suites_checked": 45, "cipher_suites_passed": 40, "cipher_suite_percentage": 88.89, "groups_checked": 5, "groups_passed": 4, "group_percentage": 80.0, }, }, 636: { "port": 636, "status": "completed", "tls_version": "1.2", "cipher_suites": { "1.2": { "accepted": [ { "name": "TLS_DHE_RSA_WITH_AES_256_GCM_SHA384", "iana_recommended": "Y", "bsi_approved": True, "bsi_valid_until": "2029", "compliant": True, }, ], "rejected": [], "rejected_total": 0, }, }, "supported_groups": [], "missing_recommended_groups": { "bsi_approved": [], "iana_recommended": [], }, "certificates": [], "vulnerabilities": [], "protocol_features": [], "session_features": [], "http_headers": [], "compliance": { "cipher_suites_checked": 5, "cipher_suites_passed": 5, "cipher_suite_percentage": 100.0, "groups_checked": 0, "groups_passed": 0, "group_percentage": 0.0, }, }, }, } @pytest.fixture def temp_output_dir(tmp_path: Path) -> Path: """Provide temporary output directory.""" output_dir = tmp_path / "output" output_dir.mkdir() return output_dir # SQL for database views VIEWS_SQL = """ -- View: Cipher suites with compliance information CREATE VIEW IF NOT EXISTS v_cipher_suites_with_compliance AS SELECT scs.scan_id, scs.port, scs.tls_version, scs.cipher_suite_name, scs.accepted, scs.iana_value, scs.key_size, scs.is_anonymous, sc.iana_recommended, sc.bsi_approved, sc.bsi_valid_until, sc.passed as compliant, CASE WHEN scs.accepted = 1 THEN sc.iana_recommended ELSE iana.recommended END as iana_recommended_final, CASE WHEN scs.accepted = 1 THEN sc.bsi_approved ELSE (bsi.name IS NOT NULL) END as bsi_approved_final, CASE WHEN scs.accepted = 1 THEN sc.bsi_valid_until ELSE bsi.valid_until END as bsi_valid_until_final FROM scan_cipher_suites scs LEFT JOIN scan_compliance_status sc ON scs.scan_id = sc.scan_id AND scs.port = sc.port AND sc.check_type = 'cipher_suite' AND scs.cipher_suite_name = sc.item_name LEFT JOIN iana_tls_cipher_suites iana ON scs.cipher_suite_name = iana.description LEFT JOIN bsi_tr_02102_2_tls bsi ON scs.cipher_suite_name = bsi.name AND scs.tls_version = bsi.tls_version AND bsi.category = 'cipher_suite'; -- View: Supported groups with compliance information CREATE VIEW IF NOT EXISTS v_supported_groups_with_compliance AS SELECT ssg.scan_id, ssg.port, ssg.group_name, ssg.iana_value, ssg.openssl_nid, sc.iana_recommended, sc.bsi_approved, sc.bsi_valid_until, sc.passed as compliant FROM scan_supported_groups ssg LEFT JOIN scan_compliance_status sc ON ssg.scan_id = sc.scan_id AND ssg.port = sc.port AND sc.check_type = 'supported_group' AND ssg.group_name = sc.item_name; -- View: Certificates with compliance information CREATE VIEW IF NOT EXISTS v_certificates_with_compliance AS SELECT c.scan_id, c.port, c.position, c.subject, c.issuer, c.serial_number, c.not_before, c.not_after, c.key_type, c.key_bits, c.signature_algorithm, c.fingerprint_sha256, MAX(cs.passed) as compliant, MAX(cs.details) as compliance_details FROM scan_certificates c LEFT JOIN scan_compliance_status cs ON c.scan_id = cs.scan_id AND c.port = cs.port AND cs.check_type = 'certificate' AND cs.item_name = (c.key_type || ' ' || c.key_bits || ' Bit') GROUP BY c.scan_id, c.port, c.position, c.subject, c.issuer, c.serial_number, c.not_before, c.not_after, c.key_type, c.key_bits, c.signature_algorithm, c.fingerprint_sha256; -- View: Port compliance summary CREATE VIEW IF NOT EXISTS v_port_compliance_summary AS SELECT scan_id, port, check_type, COUNT(*) as total, SUM(CASE WHEN passed = 1 THEN 1 ELSE 0 END) as passed, ROUND(CAST(SUM(CASE WHEN passed = 1 THEN 1 ELSE 0 END) AS REAL) / COUNT(*) * 100, 1) as percentage FROM scan_compliance_status GROUP BY scan_id, port, check_type; -- View: Missing BSI-approved groups CREATE VIEW IF NOT EXISTS v_missing_bsi_groups AS SELECT s.scan_id, s.ports, bsi.name as group_name, bsi.tls_version, bsi.valid_until FROM scans s CROSS JOIN ( SELECT DISTINCT name, tls_version, valid_until FROM bsi_tr_02102_2_tls WHERE category = 'dh_group' ) bsi WHERE NOT EXISTS ( SELECT 1 FROM scan_supported_groups ssg WHERE ssg.scan_id = s.scan_id AND LOWER(ssg.group_name) = LOWER(bsi.name) ); -- View: Missing IANA-recommended groups CREATE VIEW IF NOT EXISTS v_missing_iana_groups AS SELECT s.scan_id, s.ports, iana.description as group_name, iana.value as iana_value FROM scans s CROSS JOIN ( SELECT description, value FROM iana_tls_supported_groups WHERE recommended = 'Y' ) iana WHERE NOT EXISTS ( SELECT 1 FROM scan_supported_groups ssg WHERE ssg.scan_id = s.scan_id AND LOWER(ssg.group_name) = LOWER(iana.description) ) AND NOT EXISTS ( SELECT 1 FROM bsi_tr_02102_2_tls bsi WHERE LOWER(bsi.name) = LOWER(iana.description) AND bsi.category = 'dh_group' ); """ @pytest.fixture def test_db() -> sqlite3.Connection: """Provide in-memory test database with crypto standards and scan data.""" conn = sqlite3.connect(":memory:") # 1. Copy crypto_standards.db to memory standards_path = ( Path(__file__).parent.parent / "src/sslysze_scan/data/crypto_standards.db" ) if standards_path.exists(): with sqlite3.connect(str(standards_path)) as src_conn: for line in src_conn.iterdump(): conn.execute(line) # 2. Copy test_scan.db data to memory (skip CREATE and csv_export_metadata) fixtures_dir = Path(__file__).parent / "fixtures" test_scan_path = fixtures_dir / "test_scan.db" if test_scan_path.exists(): with sqlite3.connect(str(test_scan_path)) as src_conn: for line in src_conn.iterdump(): if not line.startswith("CREATE ") and "csv_export_metadata" not in line: conn.execute(line) # 3. Create views conn.executescript(VIEWS_SQL) conn.commit() yield conn conn.close() @pytest.fixture def test_db_path(tmp_path: Path) -> str: """Provide test database as file path for functions expecting a path.""" db_path = tmp_path / "test.db" conn = sqlite3.connect(str(db_path)) # 1. Copy crypto_standards.db to file standards_path = ( Path(__file__).parent.parent / "src/sslysze_scan/data/crypto_standards.db" ) if standards_path.exists(): with sqlite3.connect(str(standards_path)) as src_conn: for line in src_conn.iterdump(): conn.execute(line) # 2. Copy test_scan.db data to file (skip CREATE and csv_export_metadata) fixtures_dir = Path(__file__).parent / "fixtures" test_scan_path = fixtures_dir / "test_scan.db" if test_scan_path.exists(): with sqlite3.connect(str(test_scan_path)) as src_conn: for line in src_conn.iterdump(): if not line.startswith("CREATE ") and "csv_export_metadata" not in line: conn.execute(line) # 3. Create views conn.executescript(VIEWS_SQL) conn.commit() conn.close() return str(db_path)