Files
compliance-scan/tests/conftest.py
2025-12-18 19:16:04 +01:00

411 lines
13 KiB
Python

"""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)