- SSH scanning via ssh-audit (KEX, encryption, MAC, host keys) - BSI TR-02102-4 and IANA compliance validation for SSH - CSV/Markdown/reST reports for SSH results - Unified compliance schema and database views - Code optimization: modular query/writer architecture
411 lines
13 KiB
Python
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_compliance_tls_cipher_suites 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_compliance_tls_supported_groups 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_compliance_tls_certificates 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_summary_port_compliance 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_summary_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_summary_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)
|