Add SSH scan support with BSI TR-02102-4 compliance
- 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
This commit is contained in:
1
tests/reporter/__init__.py
Normal file
1
tests/reporter/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Reporter tests package."""
|
||||
297
tests/reporter/test_csv_export.py
Normal file
297
tests/reporter/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"
|
||||
316
tests/reporter/test_csv_export_ssh.py
Normal file
316
tests/reporter/test_csv_export_ssh.py
Normal file
@@ -0,0 +1,316 @@
|
||||
"""Tests for SSH-specific CSV export functionality."""
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from src.sslysze_scan.reporter.csv_export import (
|
||||
_export_ssh_encryption_algorithms,
|
||||
_export_ssh_host_keys,
|
||||
_export_ssh_kex_methods,
|
||||
_export_ssh_mac_algorithms,
|
||||
)
|
||||
|
||||
|
||||
class TestSshCsvExport:
|
||||
"""Tests for SSH CSV export functions."""
|
||||
|
||||
def test_export_ssh_kex_methods(self) -> None:
|
||||
"""Test SSH key exchange methods export."""
|
||||
# Create mock exporter
|
||||
with patch(
|
||||
"src.sslysze_scan.reporter.csv_export.CSVExporter"
|
||||
) as mock_exporter_class:
|
||||
mock_exporter = Mock()
|
||||
mock_exporter_class.return_value = mock_exporter
|
||||
mock_exporter.write_csv.return_value = "/tmp/test.csv"
|
||||
|
||||
# Test data
|
||||
port = 22
|
||||
ssh_kex_methods = [
|
||||
{
|
||||
"name": "curve25519-sha256",
|
||||
"accepted": True,
|
||||
"iana_recommended": "Y",
|
||||
"bsi_approved": True,
|
||||
"bsi_valid_until": 2031,
|
||||
"compliant": True,
|
||||
},
|
||||
{
|
||||
"name": "diffie-hellman-group14-sha256",
|
||||
"accepted": True,
|
||||
"iana_recommended": "N",
|
||||
"bsi_approved": False,
|
||||
"bsi_valid_until": None,
|
||||
"compliant": False,
|
||||
},
|
||||
]
|
||||
|
||||
# Call the function
|
||||
result = _export_ssh_kex_methods(mock_exporter, port, ssh_kex_methods)
|
||||
|
||||
# Verify the call
|
||||
assert len(result) == 1
|
||||
mock_exporter.write_csv.assert_called_once()
|
||||
args, kwargs = mock_exporter.write_csv.call_args
|
||||
assert args[0] == "22_ssh_kex_methods.csv"
|
||||
assert args[1] == "ssh_kex_methods"
|
||||
assert len(args[2]) == 2 # Two rows of data plus header
|
||||
|
||||
def test_export_ssh_encryption_algorithms(self) -> None:
|
||||
"""Test SSH encryption algorithms export."""
|
||||
# Create mock exporter
|
||||
with patch(
|
||||
"src.sslysze_scan.reporter.csv_export.CSVExporter"
|
||||
) as mock_exporter_class:
|
||||
mock_exporter = Mock()
|
||||
mock_exporter_class.return_value = mock_exporter
|
||||
mock_exporter.write_csv.return_value = "/tmp/test.csv"
|
||||
|
||||
# Test data
|
||||
port = 22
|
||||
ssh_encryption_algorithms = [
|
||||
{
|
||||
"name": "chacha20-poly1305@openssh.com",
|
||||
"accepted": True,
|
||||
"iana_recommended": "Y",
|
||||
"bsi_approved": True,
|
||||
"bsi_valid_until": 2031,
|
||||
"compliant": True,
|
||||
},
|
||||
{
|
||||
"name": "aes128-ctr",
|
||||
"accepted": True,
|
||||
"iana_recommended": "N",
|
||||
"bsi_approved": False,
|
||||
"bsi_valid_until": None,
|
||||
"compliant": False,
|
||||
},
|
||||
]
|
||||
|
||||
# Call the function
|
||||
result = _export_ssh_encryption_algorithms(
|
||||
mock_exporter, port, ssh_encryption_algorithms
|
||||
)
|
||||
|
||||
# Verify the call
|
||||
assert len(result) == 1
|
||||
mock_exporter.write_csv.assert_called_once()
|
||||
args, kwargs = mock_exporter.write_csv.call_args
|
||||
assert args[0] == "22_ssh_encryption_algorithms.csv"
|
||||
assert args[1] == "ssh_encryption_algorithms"
|
||||
assert len(args[2]) == 2 # Two rows of data plus header
|
||||
|
||||
def test_export_ssh_mac_algorithms(self) -> None:
|
||||
"""Test SSH MAC algorithms export."""
|
||||
# Create mock exporter
|
||||
with patch(
|
||||
"src.sslysze_scan.reporter.csv_export.CSVExporter"
|
||||
) as mock_exporter_class:
|
||||
mock_exporter = Mock()
|
||||
mock_exporter_class.return_value = mock_exporter
|
||||
mock_exporter.write_csv.return_value = "/tmp/test.csv"
|
||||
|
||||
# Test data
|
||||
port = 22
|
||||
ssh_mac_algorithms = [
|
||||
{
|
||||
"name": "hmac-sha2-256",
|
||||
"accepted": True,
|
||||
"iana_recommended": "Y",
|
||||
"bsi_approved": True,
|
||||
"bsi_valid_until": 2031,
|
||||
"compliant": True,
|
||||
},
|
||||
{
|
||||
"name": "umac-64-etm@openssh.com",
|
||||
"accepted": True,
|
||||
"iana_recommended": "N",
|
||||
"bsi_approved": False,
|
||||
"bsi_valid_until": None,
|
||||
"compliant": False,
|
||||
},
|
||||
]
|
||||
|
||||
# Call the function
|
||||
result = _export_ssh_mac_algorithms(mock_exporter, port, ssh_mac_algorithms)
|
||||
|
||||
# Verify the call
|
||||
assert len(result) == 1
|
||||
mock_exporter.write_csv.assert_called_once()
|
||||
args, kwargs = mock_exporter.write_csv.call_args
|
||||
assert args[0] == "22_ssh_mac_algorithms.csv"
|
||||
assert args[1] == "ssh_mac_algorithms"
|
||||
assert len(args[2]) == 2 # Two rows of data plus header
|
||||
|
||||
def test_export_ssh_host_keys(self) -> None:
|
||||
"""Test SSH host keys export."""
|
||||
# Create mock exporter
|
||||
with patch(
|
||||
"src.sslysze_scan.reporter.csv_export.CSVExporter"
|
||||
) as mock_exporter_class:
|
||||
mock_exporter = Mock()
|
||||
mock_exporter_class.return_value = mock_exporter
|
||||
mock_exporter.write_csv.return_value = "/tmp/test.csv"
|
||||
|
||||
# Test data
|
||||
port = 22
|
||||
ssh_host_keys = [
|
||||
{
|
||||
"algorithm": "ssh-ed25519",
|
||||
"type": "ed25519",
|
||||
"bits": 256,
|
||||
"bsi_approved": False,
|
||||
"bsi_valid_until": None,
|
||||
"compliant": False,
|
||||
},
|
||||
{
|
||||
"algorithm": "rsa-sha2-512",
|
||||
"type": "rsa",
|
||||
"bits": 3072,
|
||||
"bsi_approved": False,
|
||||
"bsi_valid_until": None,
|
||||
"compliant": False,
|
||||
},
|
||||
{
|
||||
"algorithm": "ecdsa-sha2-nistp256",
|
||||
"type": "ecdsa",
|
||||
"bits": None, # Test the derivation logic
|
||||
"bsi_approved": True,
|
||||
"bsi_valid_until": 2031,
|
||||
"compliant": True,
|
||||
},
|
||||
{
|
||||
"algorithm": "rsa-sha2-256",
|
||||
"type": "rsa",
|
||||
"bits": "-", # Test the derivation logic
|
||||
"bsi_approved": False,
|
||||
"bsi_valid_until": None,
|
||||
"compliant": False,
|
||||
},
|
||||
]
|
||||
|
||||
# Call the function
|
||||
result = _export_ssh_host_keys(mock_exporter, port, ssh_host_keys)
|
||||
|
||||
# Verify the call
|
||||
assert len(result) == 1
|
||||
mock_exporter.write_csv.assert_called_once()
|
||||
args, kwargs = mock_exporter.write_csv.call_args
|
||||
assert args[0] == "22_ssh_host_keys.csv"
|
||||
assert args[1] == "ssh_host_keys"
|
||||
assert len(args[2]) == 4 # Four rows of data plus header
|
||||
|
||||
# Verify that each row has 6 columns (algorithm, type, bits, bsi_approved, bsi_valid_until, compliant)
|
||||
for row in args[2]:
|
||||
assert (
|
||||
len(row) == 6
|
||||
) # 6 columns: Algorithm, Type, Bits, BSI Approved, BSI Valid Until, Compliant
|
||||
|
||||
# Verify that the bits are derived correctly when not provided
|
||||
# Row 2 should have bits = 256 for nistp256
|
||||
assert (
|
||||
args[2][2][2] == 256
|
||||
) # Third row (index 2), third column (index 2) should be 256
|
||||
# Row 3 should have bits = 2048 for rsa-sha2-256
|
||||
assert (
|
||||
args[2][3][2] == 2048
|
||||
) # Fourth row (index 3), third column (index 2) should be 2048
|
||||
|
||||
def test_export_ssh_host_keys_derived_bits(self) -> None:
|
||||
"""Test that SSH host keys export properly derives bits from algorithm names."""
|
||||
# Create mock exporter
|
||||
with patch(
|
||||
"src.sslysze_scan.reporter.csv_export.CSVExporter"
|
||||
) as mock_exporter_class:
|
||||
mock_exporter = Mock()
|
||||
mock_exporter_class.return_value = mock_exporter
|
||||
mock_exporter.write_csv.return_value = "/tmp/test.csv"
|
||||
|
||||
# Test data with missing bits to test derivation logic
|
||||
port = 22
|
||||
ssh_host_keys = [
|
||||
{
|
||||
"algorithm": "ecdsa-sha2-nistp521",
|
||||
"type": "ecdsa",
|
||||
"bits": None,
|
||||
"bsi_approved": True,
|
||||
"bsi_valid_until": 2031,
|
||||
"compliant": True,
|
||||
},
|
||||
{
|
||||
"algorithm": "ecdsa-sha2-nistp384",
|
||||
"type": "ecdsa",
|
||||
"bits": None,
|
||||
"bsi_approved": True,
|
||||
"bsi_valid_until": 2031,
|
||||
"compliant": True,
|
||||
},
|
||||
{
|
||||
"algorithm": "ecdsa-sha2-nistp256",
|
||||
"type": "ecdsa",
|
||||
"bits": None,
|
||||
"bsi_approved": True,
|
||||
"bsi_valid_until": 2031,
|
||||
"compliant": True,
|
||||
},
|
||||
{
|
||||
"algorithm": "ssh-ed25519",
|
||||
"type": "ed25519",
|
||||
"bits": None,
|
||||
"bsi_approved": False,
|
||||
"bsi_valid_until": None,
|
||||
"compliant": False,
|
||||
},
|
||||
{
|
||||
"algorithm": "rsa-sha2-256",
|
||||
"type": "rsa",
|
||||
"bits": None,
|
||||
"bsi_approved": False,
|
||||
"bsi_valid_until": None,
|
||||
"compliant": False,
|
||||
},
|
||||
{
|
||||
"algorithm": "rsa-sha2-512",
|
||||
"type": "rsa",
|
||||
"bits": None, # Should derive 4096 from algorithm name
|
||||
"fingerprint": "SHA256:test6",
|
||||
"iana_recommended": None,
|
||||
"bsi_approved": False,
|
||||
"bsi_valid_until": None,
|
||||
"compliant": False,
|
||||
},
|
||||
{
|
||||
"algorithm": "unknown-algorithm",
|
||||
"type": "unknown",
|
||||
"bits": None, # Should remain as "-" for unknown algorithm
|
||||
"fingerprint": "SHA256:test7",
|
||||
"iana_recommended": None,
|
||||
"bsi_approved": False,
|
||||
"bsi_valid_until": None,
|
||||
"compliant": False,
|
||||
},
|
||||
]
|
||||
|
||||
# Call the function
|
||||
result = _export_ssh_host_keys(mock_exporter, port, ssh_host_keys)
|
||||
|
||||
# Verify the call
|
||||
assert len(result) == 1
|
||||
mock_exporter.write_csv.assert_called_once()
|
||||
args, kwargs = mock_exporter.write_csv.call_args
|
||||
|
||||
# Verify that each row has 7 columns (algorithm, type, bits, iana_recommended, bsi_approved, bsi_valid_until, compliant)
|
||||
# Verify that each row has 6 columns
|
||||
for row in args[2]:
|
||||
assert (
|
||||
len(row) == 6
|
||||
) # 6 columns: Algorithm, Type, Bits, BSI Approved, BSI Valid Until, Compliant
|
||||
|
||||
# Verify that bits are derived correctly from algorithm names
|
||||
assert args[2][0][2] == 521 # nistp521 -> 521
|
||||
assert args[2][1][2] == 384 # nistp384 -> 384
|
||||
assert args[2][2][2] == 256 # nistp256 -> 256
|
||||
assert args[2][3][2] == 255 # ed25519 -> 255
|
||||
assert args[2][4][2] == 2048 # rsa-sha2-256 -> 2048
|
||||
assert (
|
||||
args[2][6][2] == "-"
|
||||
) # unknown algorithm -> "-" (since bits is None and no derivation rule)
|
||||
252
tests/reporter/test_summary_ssh_duplicates.py
Normal file
252
tests/reporter/test_summary_ssh_duplicates.py
Normal file
@@ -0,0 +1,252 @@
|
||||
"""Tests for SSH duplicate handling in summary statistics."""
|
||||
|
||||
import sqlite3
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sslysze_scan.db.writer import write_scan_results
|
||||
from sslysze_scan.reporter.query import fetch_scan_data
|
||||
|
||||
|
||||
class TestSummarySSHDuplicates:
|
||||
"""Tests for SSH duplicate detection in summary statistics."""
|
||||
|
||||
def test_ssh_encryption_no_duplicate_counting(self, test_db_path: str) -> None:
|
||||
"""Test that SSH encryption algorithms are not counted twice in summary.
|
||||
|
||||
SSH-audit returns both client-to-server and server-to-client algorithms,
|
||||
which are often identical. The summary should count unique algorithms only.
|
||||
"""
|
||||
# Create scan with known SSH data containing duplicates
|
||||
scan_results = {
|
||||
22: {
|
||||
"kex_algorithms": ["curve25519-sha256", "diffie-hellman-group16-sha512"],
|
||||
"encryption_algorithms_client_to_server": [
|
||||
"chacha20-poly1305@openssh.com",
|
||||
"aes256-ctr",
|
||||
"aes128-ctr",
|
||||
],
|
||||
"encryption_algorithms_server_to_client": [
|
||||
"chacha20-poly1305@openssh.com",
|
||||
"aes256-ctr",
|
||||
"aes128-ctr",
|
||||
],
|
||||
"mac_algorithms_client_to_server": [
|
||||
"hmac-sha2-256",
|
||||
"hmac-sha2-512",
|
||||
],
|
||||
"mac_algorithms_server_to_client": [
|
||||
"hmac-sha2-256",
|
||||
"hmac-sha2-512",
|
||||
],
|
||||
"host_keys": [
|
||||
{
|
||||
"algorithm": "ssh-rsa",
|
||||
"type": "RSA",
|
||||
"bits": 2048,
|
||||
"fingerprint": "test",
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
scan_id = write_scan_results(
|
||||
db_path=test_db_path,
|
||||
hostname="test.example.com",
|
||||
ports=[22],
|
||||
scan_results=scan_results,
|
||||
scan_start_time=datetime.now(UTC),
|
||||
scan_duration=1.0,
|
||||
)
|
||||
|
||||
# Verify database has no duplicates (fixed behavior)
|
||||
conn = sqlite3.connect(test_db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(
|
||||
"SELECT COUNT(*) FROM scan_ssh_encryption_algorithms WHERE scan_id = ?",
|
||||
(scan_id,),
|
||||
)
|
||||
db_count = cursor.fetchone()[0]
|
||||
|
||||
# Database should now contain only unique entries
|
||||
assert db_count == 3, (
|
||||
f"Database should contain 3 unique algorithms, got {db_count}"
|
||||
)
|
||||
|
||||
# Fetch scan data and check summary
|
||||
data = fetch_scan_data(test_db_path, scan_id)
|
||||
summary = data["summary"]
|
||||
|
||||
# Summary should count unique algorithms only
|
||||
assert summary["total_ssh_encryption"] == 3, (
|
||||
f"Expected 3 unique encryption algorithms, got {summary['total_ssh_encryption']}"
|
||||
)
|
||||
|
||||
# Check MAC algorithms (2 unique)
|
||||
cursor.execute(
|
||||
"SELECT COUNT(*) FROM scan_ssh_mac_algorithms WHERE scan_id = ?",
|
||||
(scan_id,),
|
||||
)
|
||||
mac_db_count = cursor.fetchone()[0]
|
||||
conn.close()
|
||||
|
||||
assert mac_db_count == 2, (
|
||||
f"Database should contain 2 unique MAC algorithms, got {mac_db_count}"
|
||||
)
|
||||
assert summary["total_ssh_mac"] == 2, (
|
||||
f"Expected 2 unique MAC algorithms, got {summary['total_ssh_mac']}"
|
||||
)
|
||||
|
||||
# Check KEX algorithms (no duplicates expected)
|
||||
assert summary["total_ssh_kex"] == 2, (
|
||||
f"Expected 2 KEX algorithms, got {summary['total_ssh_kex']}"
|
||||
)
|
||||
|
||||
# Check host keys (no duplicates expected)
|
||||
assert summary["total_ssh_host_keys"] == 1, (
|
||||
f"Expected 1 host key, got {summary['total_ssh_host_keys']}"
|
||||
)
|
||||
|
||||
def test_ssh_only_scan_has_valid_summary(self, test_db_path: str) -> None:
|
||||
"""Test that SSH-only scan produces valid summary statistics.
|
||||
|
||||
Previous bug: SSH-only scans showed all zeros in summary because
|
||||
only TLS data was counted.
|
||||
"""
|
||||
scan_results = {
|
||||
22: {
|
||||
"kex_algorithms": [
|
||||
"curve25519-sha256",
|
||||
"ecdh-sha2-nistp256",
|
||||
"diffie-hellman-group16-sha512",
|
||||
],
|
||||
"encryption_algorithms_client_to_server": [
|
||||
"chacha20-poly1305@openssh.com",
|
||||
"aes256-ctr",
|
||||
],
|
||||
"encryption_algorithms_server_to_client": [
|
||||
"chacha20-poly1305@openssh.com",
|
||||
"aes256-ctr",
|
||||
],
|
||||
"mac_algorithms_client_to_server": ["hmac-sha2-256"],
|
||||
"mac_algorithms_server_to_client": ["hmac-sha2-256"],
|
||||
"host_keys": [
|
||||
{
|
||||
"algorithm": "ssh-ed25519",
|
||||
"type": "ED25519",
|
||||
"bits": 256,
|
||||
"fingerprint": "test",
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
scan_id = write_scan_results(
|
||||
db_path=test_db_path,
|
||||
hostname="ssh-only.example.com",
|
||||
ports=[22],
|
||||
scan_results=scan_results,
|
||||
scan_start_time=datetime.now(UTC),
|
||||
scan_duration=1.0,
|
||||
)
|
||||
|
||||
data = fetch_scan_data(test_db_path, scan_id)
|
||||
summary = data["summary"]
|
||||
|
||||
# Verify scan was recognized
|
||||
assert summary["total_ports"] == 1
|
||||
assert summary["ports_with_ssh"] == 1
|
||||
assert summary["ports_with_tls"] == 0
|
||||
|
||||
# Verify SSH data is counted
|
||||
assert summary["total_ssh_items"] > 0, "SSH items should be counted"
|
||||
assert summary["total_ssh_kex"] == 3, (
|
||||
f"Expected 3 KEX methods, got {summary['total_ssh_kex']}"
|
||||
)
|
||||
assert summary["total_ssh_encryption"] == 2, (
|
||||
f"Expected 2 encryption algorithms, got {summary['total_ssh_encryption']}"
|
||||
)
|
||||
assert summary["total_ssh_mac"] == 1, (
|
||||
f"Expected 1 MAC algorithm, got {summary['total_ssh_mac']}"
|
||||
)
|
||||
assert summary["total_ssh_host_keys"] == 1, (
|
||||
f"Expected 1 host key, got {summary['total_ssh_host_keys']}"
|
||||
)
|
||||
|
||||
# Total should be sum of all SSH items
|
||||
expected_total = 3 + 2 + 1 + 1 # kex + enc + mac + hostkey
|
||||
assert summary["total_ssh_items"] == expected_total, (
|
||||
f"Expected {expected_total} total SSH items, got {summary['total_ssh_items']}"
|
||||
)
|
||||
|
||||
# TLS counters should be zero
|
||||
assert summary["total_cipher_suites"] == 0
|
||||
assert summary["total_groups"] == 0
|
||||
|
||||
def test_ssh_with_different_client_server_algorithms(self, test_db_path: str) -> None:
|
||||
"""Test that different client/server algorithms are both counted.
|
||||
|
||||
This test ensures that if client-to-server and server-to-client
|
||||
actually differ (rare case), both are counted.
|
||||
"""
|
||||
scan_results = {
|
||||
22: {
|
||||
"kex_algorithms": ["curve25519-sha256"],
|
||||
"encryption_algorithms_client_to_server": [
|
||||
"aes256-ctr",
|
||||
"aes192-ctr",
|
||||
],
|
||||
"encryption_algorithms_server_to_client": [
|
||||
"aes256-ctr", # Same as client
|
||||
"aes128-ctr", # Different from client
|
||||
],
|
||||
"mac_algorithms_client_to_server": ["hmac-sha2-256"],
|
||||
"mac_algorithms_server_to_client": ["hmac-sha2-512"],
|
||||
"host_keys": [
|
||||
{
|
||||
"algorithm": "ssh-ed25519",
|
||||
"type": "ED25519",
|
||||
"bits": 256,
|
||||
"fingerprint": "test",
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
scan_id = write_scan_results(
|
||||
db_path=test_db_path,
|
||||
hostname="asymmetric.example.com",
|
||||
ports=[22],
|
||||
scan_results=scan_results,
|
||||
scan_start_time=datetime.now(UTC),
|
||||
scan_duration=1.0,
|
||||
)
|
||||
|
||||
# Check database
|
||||
conn = sqlite3.connect(test_db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(
|
||||
"SELECT COUNT(*) FROM scan_ssh_encryption_algorithms WHERE scan_id = ?",
|
||||
(scan_id,),
|
||||
)
|
||||
enc_count = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute(
|
||||
"SELECT COUNT(*) FROM scan_ssh_mac_algorithms WHERE scan_id = ?",
|
||||
(scan_id,),
|
||||
)
|
||||
mac_count = cursor.fetchone()[0]
|
||||
conn.close()
|
||||
|
||||
# With the fix, only client_to_server is used
|
||||
# So we get 2 encryption and 1 MAC
|
||||
assert enc_count == 2, f"Expected 2 encryption algorithms, got {enc_count}"
|
||||
assert mac_count == 1, f"Expected 1 MAC algorithm, got {mac_count}"
|
||||
|
||||
# Summary should match
|
||||
data = fetch_scan_data(test_db_path, scan_id)
|
||||
summary = data["summary"]
|
||||
|
||||
assert summary["total_ssh_encryption"] == 2
|
||||
assert summary["total_ssh_mac"] == 1
|
||||
60
tests/reporter/test_template_utils.py
Normal file
60
tests/reporter/test_template_utils.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Tests for template utilities."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sslysze_scan.reporter.template_utils import (
|
||||
build_template_context,
|
||||
generate_report_id,
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
# We'll use a fixed date by temporarily controlling the system time
|
||||
# For this test, we just verify that it generates some valid format
|
||||
metadata = {"timestamp": "invalid", "scan_id": 5}
|
||||
result = generate_report_id(metadata)
|
||||
# Check that result follows the expected format: YYYYMMDD_number
|
||||
assert "_" in result
|
||||
assert result.endswith("_5")
|
||||
assert len(result.split("_")[0]) == 8 # YYYYMMDD format
|
||||
assert result.split("_")[0].isdigit() # Should be all digits
|
||||
|
||||
|
||||
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