feat: initial release
This commit is contained in:
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
410
tests/conftest.py
Normal file
410
tests/conftest.py
Normal file
@@ -0,0 +1,410 @@
|
||||
"""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)
|
||||
1
tests/fixtures/__init__.py
vendored
Normal file
1
tests/fixtures/__init__.py
vendored
Normal file
@@ -0,0 +1 @@
|
||||
"""Test fixtures package."""
|
||||
BIN
tests/fixtures/test_scan.db
vendored
Normal file
BIN
tests/fixtures/test_scan.db
vendored
Normal file
Binary file not shown.
26
tests/test_cli.py
Normal file
26
tests/test_cli.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""Tests for CLI argument parsing."""
|
||||
|
||||
import pytest
|
||||
|
||||
from sslysze_scan.cli import parse_host_ports
|
||||
|
||||
|
||||
class TestParseHostPorts:
|
||||
"""Tests for parse_host_ports function."""
|
||||
|
||||
def test_parse_host_ports_multiple_ports(self) -> None:
|
||||
"""Test parsing hostname with multiple ports."""
|
||||
hostname, ports = parse_host_ports("example.com:443,636,993")
|
||||
assert hostname == "example.com"
|
||||
assert ports == [443, 636, 993]
|
||||
|
||||
def test_parse_host_ports_ipv6_multiple(self) -> None:
|
||||
"""Test parsing IPv6 address with multiple ports."""
|
||||
hostname, ports = parse_host_ports("[2001:db8::1]:443,636")
|
||||
assert hostname == "2001:db8::1"
|
||||
assert ports == [443, 636]
|
||||
|
||||
def test_parse_host_ports_invalid_port_range(self) -> None:
|
||||
"""Test error when port number out of range."""
|
||||
with pytest.raises(ValueError, match="Invalid port number.*Must be between"):
|
||||
parse_host_ports("example.com:99999")
|
||||
73
tests/test_compliance.py
Normal file
73
tests/test_compliance.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""Tests for compliance checking functionality."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class TestComplianceChecks:
|
||||
"""Tests for compliance validation logic."""
|
||||
|
||||
def test_check_bsi_validity(self) -> None:
|
||||
"""Test BSI cipher suite validity checking."""
|
||||
# Valid BSI-approved cipher suite (not expired)
|
||||
cipher_suite_valid = {
|
||||
"name": "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
|
||||
"iana_recommended": "N",
|
||||
"bsi_approved": True,
|
||||
"bsi_valid_until": "2029",
|
||||
}
|
||||
# Check that current year is before 2029
|
||||
current_year = datetime.now().year
|
||||
assert current_year < 2029, "Test assumes current year < 2029"
|
||||
# BSI-approved and valid should be compliant
|
||||
assert cipher_suite_valid["bsi_approved"] is True
|
||||
assert int(cipher_suite_valid["bsi_valid_until"]) > current_year
|
||||
|
||||
# Expired BSI-approved cipher suite
|
||||
cipher_suite_expired = {
|
||||
"name": "TLS_OLD_CIPHER",
|
||||
"iana_recommended": "N",
|
||||
"bsi_approved": True,
|
||||
"bsi_valid_until": "2020",
|
||||
}
|
||||
# BSI-approved but expired should not be compliant
|
||||
assert cipher_suite_expired["bsi_approved"] is True
|
||||
assert int(cipher_suite_expired["bsi_valid_until"]) < current_year
|
||||
|
||||
# No BSI data
|
||||
cipher_suite_no_bsi = {
|
||||
"name": "TLS_CHACHA20_POLY1305_SHA256",
|
||||
"iana_recommended": "Y",
|
||||
"bsi_approved": False,
|
||||
"bsi_valid_until": None,
|
||||
}
|
||||
# Without BSI approval, compliance depends on IANA
|
||||
assert cipher_suite_no_bsi["bsi_approved"] is False
|
||||
|
||||
def test_check_iana_recommendation(self) -> None:
|
||||
"""Test IANA recommendation checking."""
|
||||
# IANA recommended cipher suite
|
||||
cipher_suite_recommended = {
|
||||
"name": "TLS_AES_256_GCM_SHA384",
|
||||
"iana_recommended": "Y",
|
||||
"bsi_approved": True,
|
||||
"bsi_valid_until": "2031",
|
||||
}
|
||||
assert cipher_suite_recommended["iana_recommended"] == "Y"
|
||||
|
||||
# IANA not recommended cipher suite
|
||||
cipher_suite_not_recommended = {
|
||||
"name": "TLS_RSA_WITH_AES_128_CBC_SHA",
|
||||
"iana_recommended": "N",
|
||||
"bsi_approved": False,
|
||||
"bsi_valid_until": None,
|
||||
}
|
||||
assert cipher_suite_not_recommended["iana_recommended"] == "N"
|
||||
|
||||
# No IANA data (should default to non-compliant)
|
||||
cipher_suite_no_iana = {
|
||||
"name": "TLS_UNKNOWN_CIPHER",
|
||||
"iana_recommended": None,
|
||||
"bsi_approved": False,
|
||||
"bsi_valid_until": None,
|
||||
}
|
||||
assert cipher_suite_no_iana["iana_recommended"] is None
|
||||
297
tests/test_csv_export.py
Normal file
297
tests/test_csv_export.py
Normal file
@@ -0,0 +1,297 @@
|
||||
"""Tests for CSV export functionality."""
|
||||
|
||||
import csv
|
||||
from pathlib import Path
|
||||
|
||||
from sslysze_scan.reporter.csv_export import generate_csv_reports
|
||||
|
||||
|
||||
class TestCsvExport:
|
||||
"""Tests for CSV file generation."""
|
||||
|
||||
def test_export_summary(self, test_db_path: str, tmp_path: Path) -> None:
|
||||
"""Test summary CSV export with aggregated statistics."""
|
||||
output_dir = tmp_path / "output"
|
||||
output_dir.mkdir()
|
||||
|
||||
files = generate_csv_reports(test_db_path, 1, str(output_dir))
|
||||
|
||||
summary_file = output_dir / "summary.csv"
|
||||
assert summary_file.exists()
|
||||
assert str(summary_file) in files
|
||||
|
||||
with open(summary_file, newline="", encoding="utf-8") as f:
|
||||
reader = csv.reader(f)
|
||||
rows = list(reader)
|
||||
|
||||
assert rows[0] == ["Metric", "Value"]
|
||||
assert len(rows) >= 7
|
||||
|
||||
metrics = {row[0]: row[1] for row in rows[1:]}
|
||||
assert "Scanned Ports" in metrics
|
||||
assert "Ports with TLS Support" in metrics
|
||||
assert "Cipher Suites Checked" in metrics
|
||||
|
||||
def test_export_cipher_suites_port_443(
|
||||
self, test_db_path: str, tmp_path: Path
|
||||
) -> None:
|
||||
"""Test cipher suites export for port 443."""
|
||||
output_dir = tmp_path / "output"
|
||||
output_dir.mkdir()
|
||||
|
||||
files = generate_csv_reports(test_db_path, 1, str(output_dir))
|
||||
|
||||
accepted_files = [
|
||||
f for f in files if "443_cipher_suites" in f and "accepted" in f
|
||||
]
|
||||
assert len(accepted_files) > 0
|
||||
|
||||
accepted_file = Path(accepted_files[0])
|
||||
assert accepted_file.exists()
|
||||
|
||||
with open(accepted_file, newline="", encoding="utf-8") as f:
|
||||
reader = csv.reader(f)
|
||||
rows = list(reader)
|
||||
|
||||
assert rows[0] == ["Cipher Suite", "IANA", "BSI", "Valid Until", "Compliant"]
|
||||
assert len(rows) > 1
|
||||
|
||||
for row in rows[1:]:
|
||||
assert len(row) == 5
|
||||
assert row[4] in ["Yes", "No", "-"]
|
||||
|
||||
def test_export_supported_groups_port_636(
|
||||
self, test_db_path: str, tmp_path: Path
|
||||
) -> None:
|
||||
"""Test supported groups export for port 636."""
|
||||
output_dir = tmp_path / "output"
|
||||
output_dir.mkdir()
|
||||
|
||||
files = generate_csv_reports(test_db_path, 1, str(output_dir))
|
||||
|
||||
groups_files = [f for f in files if "636_supported_groups.csv" in f]
|
||||
|
||||
if groups_files:
|
||||
groups_file = Path(groups_files[0])
|
||||
assert groups_file.exists()
|
||||
|
||||
with open(groups_file, newline="", encoding="utf-8") as f:
|
||||
reader = csv.reader(f)
|
||||
rows = list(reader)
|
||||
|
||||
assert rows[0] == ["Group", "IANA", "BSI", "Valid Until", "Compliant"]
|
||||
|
||||
for row in rows[1:]:
|
||||
assert len(row) == 5
|
||||
assert row[4] in ["Yes", "No", "-"]
|
||||
|
||||
def test_export_missing_groups_port_443(
|
||||
self, test_db_path: str, tmp_path: Path
|
||||
) -> None:
|
||||
"""Test missing groups export for port 443."""
|
||||
output_dir = tmp_path / "output"
|
||||
output_dir.mkdir()
|
||||
|
||||
files = generate_csv_reports(test_db_path, 1, str(output_dir))
|
||||
|
||||
bsi_files = [f for f in files if "443_missing_groups_bsi.csv" in f]
|
||||
|
||||
if bsi_files:
|
||||
bsi_file = Path(bsi_files[0])
|
||||
assert bsi_file.exists()
|
||||
|
||||
with open(bsi_file, newline="", encoding="utf-8") as f:
|
||||
reader = csv.reader(f)
|
||||
rows = list(reader)
|
||||
|
||||
assert rows[0] == ["Group", "TLS Versions", "Valid Until"]
|
||||
|
||||
for row in rows[1:]:
|
||||
assert len(row) == 3
|
||||
|
||||
def test_export_certificates_port_636(
|
||||
self, test_db_path: str, tmp_path: Path
|
||||
) -> None:
|
||||
"""Test certificates export for port 636."""
|
||||
output_dir = tmp_path / "output"
|
||||
output_dir.mkdir()
|
||||
|
||||
files = generate_csv_reports(test_db_path, 1, str(output_dir))
|
||||
|
||||
cert_files = [f for f in files if "636_certificates.csv" in f]
|
||||
|
||||
if cert_files:
|
||||
cert_file = Path(cert_files[0])
|
||||
assert cert_file.exists()
|
||||
|
||||
with open(cert_file, newline="", encoding="utf-8") as f:
|
||||
reader = csv.reader(f)
|
||||
rows = list(reader)
|
||||
|
||||
expected_headers = [
|
||||
"Position",
|
||||
"Subject",
|
||||
"Issuer",
|
||||
"Valid From",
|
||||
"Valid Until",
|
||||
"Key Type",
|
||||
"Key Size",
|
||||
"Compliant",
|
||||
]
|
||||
assert rows[0] == expected_headers
|
||||
|
||||
for row in rows[1:]:
|
||||
assert len(row) == 8
|
||||
assert row[7] in ["Yes", "No", "-"]
|
||||
|
||||
def test_export_vulnerabilities_port_443(
|
||||
self, test_db_path: str, tmp_path: Path
|
||||
) -> None:
|
||||
"""Test vulnerabilities export for port 443."""
|
||||
output_dir = tmp_path / "output"
|
||||
output_dir.mkdir()
|
||||
|
||||
files = generate_csv_reports(test_db_path, 1, str(output_dir))
|
||||
|
||||
vuln_files = [f for f in files if "443_vulnerabilities.csv" in f]
|
||||
|
||||
if vuln_files:
|
||||
vuln_file = Path(vuln_files[0])
|
||||
assert vuln_file.exists()
|
||||
|
||||
with open(vuln_file, newline="", encoding="utf-8") as f:
|
||||
reader = csv.reader(f)
|
||||
rows = list(reader)
|
||||
|
||||
assert rows[0] == ["Type", "Vulnerable", "Details"]
|
||||
|
||||
for row in rows[1:]:
|
||||
assert len(row) == 3
|
||||
assert row[1] in ["Yes", "No", "-"]
|
||||
|
||||
def test_export_protocol_features_port_636(
|
||||
self, test_db_path: str, tmp_path: Path
|
||||
) -> None:
|
||||
"""Test protocol features export for port 636."""
|
||||
output_dir = tmp_path / "output"
|
||||
output_dir.mkdir()
|
||||
|
||||
files = generate_csv_reports(test_db_path, 1, str(output_dir))
|
||||
|
||||
protocol_files = [f for f in files if "636_protocol_features.csv" in f]
|
||||
|
||||
if protocol_files:
|
||||
protocol_file = Path(protocol_files[0])
|
||||
assert protocol_file.exists()
|
||||
|
||||
with open(protocol_file, newline="", encoding="utf-8") as f:
|
||||
reader = csv.reader(f)
|
||||
rows = list(reader)
|
||||
|
||||
assert rows[0] == ["Feature", "Supported", "Details"]
|
||||
|
||||
for row in rows[1:]:
|
||||
assert len(row) == 3
|
||||
assert row[1] in ["Yes", "No", "-"]
|
||||
|
||||
def test_export_session_features_port_443(
|
||||
self, test_db_path: str, tmp_path: Path
|
||||
) -> None:
|
||||
"""Test session features export for port 443."""
|
||||
output_dir = tmp_path / "output"
|
||||
output_dir.mkdir()
|
||||
|
||||
files = generate_csv_reports(test_db_path, 1, str(output_dir))
|
||||
|
||||
session_files = [f for f in files if "443_session_features.csv" in f]
|
||||
|
||||
if session_files:
|
||||
session_file = Path(session_files[0])
|
||||
assert session_file.exists()
|
||||
|
||||
with open(session_file, newline="", encoding="utf-8") as f:
|
||||
reader = csv.reader(f)
|
||||
rows = list(reader)
|
||||
|
||||
expected_headers = [
|
||||
"Feature",
|
||||
"Client Initiated",
|
||||
"Secure",
|
||||
"Session ID",
|
||||
"TLS Ticket",
|
||||
"Details",
|
||||
]
|
||||
assert rows[0] == expected_headers
|
||||
|
||||
for row in rows[1:]:
|
||||
assert len(row) == 6
|
||||
for i in range(1, 5):
|
||||
assert row[i] in ["Yes", "No", "-"]
|
||||
|
||||
def test_export_http_headers_port_636(
|
||||
self, test_db_path: str, tmp_path: Path
|
||||
) -> None:
|
||||
"""Test HTTP headers export for port 636."""
|
||||
output_dir = tmp_path / "output"
|
||||
output_dir.mkdir()
|
||||
|
||||
files = generate_csv_reports(test_db_path, 1, str(output_dir))
|
||||
|
||||
header_files = [f for f in files if "636_http_headers.csv" in f]
|
||||
|
||||
if header_files:
|
||||
header_file = Path(header_files[0])
|
||||
assert header_file.exists()
|
||||
|
||||
with open(header_file, newline="", encoding="utf-8") as f:
|
||||
reader = csv.reader(f)
|
||||
rows = list(reader)
|
||||
|
||||
assert rows[0] == ["Header", "Present", "Value"]
|
||||
|
||||
for row in rows[1:]:
|
||||
assert len(row) == 3
|
||||
assert row[1] in ["Yes", "No", "-"]
|
||||
|
||||
def test_export_compliance_status_port_443(
|
||||
self, test_db_path: str, tmp_path: Path
|
||||
) -> None:
|
||||
"""Test compliance status export for port 443."""
|
||||
output_dir = tmp_path / "output"
|
||||
output_dir.mkdir()
|
||||
|
||||
files = generate_csv_reports(test_db_path, 1, str(output_dir))
|
||||
|
||||
compliance_files = [f for f in files if "443_compliance_status.csv" in f]
|
||||
|
||||
if compliance_files:
|
||||
compliance_file = Path(compliance_files[0])
|
||||
assert compliance_file.exists()
|
||||
|
||||
with open(compliance_file, newline="", encoding="utf-8") as f:
|
||||
reader = csv.reader(f)
|
||||
rows = list(reader)
|
||||
|
||||
assert rows[0] == ["Category", "Checked", "Compliant", "Percentage"]
|
||||
|
||||
for row in rows[1:]:
|
||||
assert len(row) == 4
|
||||
assert "%" in row[3]
|
||||
|
||||
def test_generate_csv_reports_all_files(
|
||||
self, test_db_path: str, tmp_path: Path
|
||||
) -> None:
|
||||
"""Test that generate_csv_reports creates expected files."""
|
||||
output_dir = tmp_path / "output"
|
||||
output_dir.mkdir()
|
||||
|
||||
files = generate_csv_reports(test_db_path, 1, str(output_dir))
|
||||
|
||||
assert len(files) > 0
|
||||
assert any("summary.csv" in f for f in files)
|
||||
assert any("443_" in f for f in files)
|
||||
assert any("636_" in f for f in files)
|
||||
|
||||
for file_path in files:
|
||||
assert Path(file_path).exists()
|
||||
assert Path(file_path).suffix == ".csv"
|
||||
67
tests/test_template_utils.py
Normal file
67
tests/test_template_utils.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""Tests for template utilities."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sslysze_scan.reporter.template_utils import (
|
||||
build_template_context,
|
||||
format_tls_version,
|
||||
generate_report_id,
|
||||
)
|
||||
|
||||
|
||||
class TestFormatTlsVersion:
|
||||
"""Tests for format_tls_version function."""
|
||||
|
||||
def test_format_tls_version_all_versions(self) -> None:
|
||||
"""Test formatting all known TLS versions."""
|
||||
versions = ["1.0", "1.1", "1.2", "1.3", "ssl_3.0", "unknown"]
|
||||
expected = ["TLS 1.0", "TLS 1.1", "TLS 1.2", "TLS 1.3", "SSL 3.0", "unknown"]
|
||||
assert [format_tls_version(v) for v in versions] == expected
|
||||
|
||||
|
||||
class TestGenerateReportId:
|
||||
"""Tests for generate_report_id function."""
|
||||
|
||||
def test_generate_report_id_valid_and_invalid(self) -> None:
|
||||
"""Test report ID generation with valid and invalid timestamps."""
|
||||
# Valid timestamp
|
||||
metadata = {"timestamp": "2025-01-08T10:30:00.123456", "scan_id": 5}
|
||||
result = generate_report_id(metadata)
|
||||
assert result == "20250108_5"
|
||||
|
||||
# Invalid timestamp falls back to current date
|
||||
metadata = {"timestamp": "invalid", "scan_id": 5}
|
||||
result = generate_report_id(metadata)
|
||||
today = datetime.now().strftime("%Y%m%d")
|
||||
assert result == f"{today}_5"
|
||||
|
||||
|
||||
class TestBuildTemplateContext:
|
||||
"""Tests for build_template_context function."""
|
||||
|
||||
def test_build_template_context_complete_and_partial(
|
||||
self, mock_scan_data: dict[str, Any]
|
||||
) -> None:
|
||||
"""Test context building with complete and partial data."""
|
||||
# Complete data
|
||||
context = build_template_context(mock_scan_data)
|
||||
assert context["scan_id"] == 5
|
||||
assert context["hostname"] == "example.com"
|
||||
assert context["fqdn"] == "example.com"
|
||||
assert context["ipv4"] == "192.168.1.1"
|
||||
assert context["ipv6"] == "2001:db8::1"
|
||||
assert context["timestamp"] == "08.01.2025 10:30"
|
||||
assert context["duration"] == "12.34"
|
||||
assert context["ports"] == "443, 636"
|
||||
assert "summary" in context
|
||||
assert "ports_data" in context
|
||||
|
||||
# Verify ports_data sorted by port
|
||||
ports = [p["port"] for p in context["ports_data"]]
|
||||
assert ports == sorted(ports)
|
||||
|
||||
# Missing duration
|
||||
mock_scan_data["metadata"]["duration"] = None
|
||||
context = build_template_context(mock_scan_data)
|
||||
assert context["duration"] == "N/A"
|
||||
Reference in New Issue
Block a user