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:
Heiko
2026-01-23 11:05:01 +01:00
parent 2b27138b2a
commit f60de7c2da
68 changed files with 7189 additions and 2835 deletions

View File

@@ -0,0 +1 @@
"""Reporter tests package."""

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

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

View 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

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