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

1
tests/iana/__init__.py Normal file
View File

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

View File

@@ -0,0 +1,202 @@
"""Tests for IANA XML parsing functionality."""
import xml.etree.ElementTree as ET
import pytest
from sslysze_scan.iana_parser import (
extract_field_value,
find_registry,
get_element_text,
is_unassigned,
parse_xml_with_namespace_support,
process_xref_elements,
)
class TestParseXmlWithNamespace:
"""Tests for XML parsing with namespace detection."""
def test_parse_tls_parameters_with_namespace(self) -> None:
"""Test parsing TLS parameters XML with IANA namespace."""
xml_path = "tests/fixtures/iana_xml/tls-parameters-minimal.xml"
root, ns = parse_xml_with_namespace_support(xml_path)
assert root is not None
assert ns is not None
assert "iana" in ns
assert ns["iana"] == "http://www.iana.org/assignments"
def test_parse_nonexistent_file(self) -> None:
"""Test that parsing nonexistent file raises FileNotFoundError."""
with pytest.raises(FileNotFoundError):
parse_xml_with_namespace_support("nonexistent.xml")
class TestFindRegistry:
"""Tests for finding registry by ID."""
def test_find_cipher_suites_registry(self) -> None:
"""Test finding TLS cipher suites registry."""
xml_path = "tests/fixtures/iana_xml/tls-parameters-minimal.xml"
root, ns = parse_xml_with_namespace_support(xml_path)
registry = find_registry(root, "tls-parameters-4", ns)
assert registry is not None
assert registry.get("id") == "tls-parameters-4"
def test_find_nonexistent_registry(self) -> None:
"""Test that finding nonexistent registry raises ValueError."""
xml_path = "tests/fixtures/iana_xml/tls-parameters-minimal.xml"
root, ns = parse_xml_with_namespace_support(xml_path)
with pytest.raises(ValueError, match="Registry with ID '.*' not found"):
find_registry(root, "nonexistent-registry", ns)
class TestGetElementText:
"""Tests for element text extraction."""
def test_get_element_text_with_namespace(self) -> None:
"""Test extracting text from element with namespace."""
xml_path = "tests/fixtures/iana_xml/tls-parameters-minimal.xml"
root, ns = parse_xml_with_namespace_support(xml_path)
registry = find_registry(root, "tls-parameters-4", ns)
records = registry.findall("iana:record", ns)
first_record = records[0]
value = get_element_text(first_record, "value", ns)
assert value == "0x13,0x01"
description = get_element_text(first_record, "description", ns)
assert description == "TLS_AES_128_GCM_SHA256"
def test_get_element_text_nonexistent(self) -> None:
"""Test that nonexistent element returns empty string."""
xml_path = "tests/fixtures/iana_xml/tls-parameters-minimal.xml"
root, ns = parse_xml_with_namespace_support(xml_path)
registry = find_registry(root, "tls-parameters-4", ns)
records = registry.findall("iana:record", ns)
first_record = records[0]
result = get_element_text(first_record, "nonexistent", ns)
assert result == ""
class TestProcessXrefElements:
"""Tests for xref element processing."""
def test_process_single_xref(self) -> None:
"""Test processing single xref element."""
xml_path = "tests/fixtures/iana_xml/tls-parameters-minimal.xml"
root, ns = parse_xml_with_namespace_support(xml_path)
registry = find_registry(root, "tls-parameters-4", ns)
records = registry.findall("iana:record", ns)
first_record = records[0]
xref_str = process_xref_elements(first_record, ns)
assert "rfc:rfc8446" in xref_str
def test_process_no_xref(self) -> None:
"""Test processing record without xref elements."""
xml_str = """
<record xmlns="http://www.iana.org/assignments">
<value>0x13,0x01</value>
<description>Test</description>
</record>
"""
record = ET.fromstring(xml_str)
ns = {"iana": "http://www.iana.org/assignments"}
xref_str = process_xref_elements(record, ns)
assert xref_str == ""
class TestExtractFieldValue:
"""Tests for field value extraction."""
def test_extract_recommended_field(self) -> None:
"""Test extracting Recommended field."""
xml_path = "tests/fixtures/iana_xml/tls-parameters-minimal.xml"
root, ns = parse_xml_with_namespace_support(xml_path)
registry = find_registry(root, "tls-parameters-4", ns)
records = registry.findall("iana:record", ns)
first_record = records[0]
rec = extract_field_value(first_record, "Recommended", ns)
assert rec == "Y"
def test_extract_rfc_draft_field(self) -> None:
"""Test extracting RFC/Draft field via xref processing."""
xml_path = "tests/fixtures/iana_xml/tls-parameters-minimal.xml"
root, ns = parse_xml_with_namespace_support(xml_path)
registry = find_registry(root, "tls-parameters-4", ns)
records = registry.findall("iana:record", ns)
first_record = records[0]
rfc_draft = extract_field_value(first_record, "RFC/Draft", ns)
assert "rfc:rfc8446" in rfc_draft
class TestExtractUpdatedDate:
"""Tests for extracting updated date from XML."""
def test_extract_updated_from_tls_xml(self) -> None:
"""Test extracting updated date from TLS parameters XML."""
xml_path = "tests/fixtures/iana_xml/tls-parameters-minimal.xml"
with open(xml_path, encoding="utf-8") as f:
xml_content = f.read()
lines = xml_content.split("\n")[:10]
updated_line = [line for line in lines if "<updated>" in line]
assert len(updated_line) == 1
assert "2025-12-03" in updated_line[0]
class TestIsUnassigned:
"""Tests for unassigned entry detection."""
def test_is_unassigned_numeric_range(self) -> None:
"""Test detection of numeric range values."""
xml_str = """
<record xmlns="http://www.iana.org/assignments">
<value>42-255</value>
<description>Unassigned</description>
</record>
"""
record = ET.fromstring(xml_str)
ns = {"iana": "http://www.iana.org/assignments"}
assert is_unassigned(record, ns) is True
def test_is_unassigned_hex_range(self) -> None:
"""Test detection of hex range values."""
xml_str = """
<record xmlns="http://www.iana.org/assignments">
<value>0x0000-0x0200</value>
<description>Reserved for backward compatibility</description>
</record>
"""
record = ET.fromstring(xml_str)
ns = {"iana": "http://www.iana.org/assignments"}
assert is_unassigned(record, ns) is True
def test_is_unassigned_false(self) -> None:
"""Test that assigned entries return False."""
xml_path = "tests/fixtures/iana_xml/tls-parameters-minimal.xml"
root, ns = parse_xml_with_namespace_support(xml_path)
registry = find_registry(root, "tls-parameters-4", ns)
records = registry.findall("iana:record", ns)
first_record = records[0]
assert is_unassigned(first_record, ns) is False

View File

@@ -0,0 +1,248 @@
"""Test to verify that IANA SSH tables remain empty due to import issues."""
import argparse
import os
import sqlite3
import tempfile
from pathlib import Path
from unittest.mock import patch
from src.sslysze_scan.commands.update_iana import handle_update_iana_command
def test_iana_ssh_tables_populated_after_successful_import():
"""Test that IANA SSH tables are populated after successful import.
This test verifies that the IANA SSH parameter import now succeeds
and populates the SSH tables with data using local XML fixtures.
"""
# Use the template database for this test
import shutil
template_db = (
Path(__file__).parent.parent.parent
/ "src"
/ "sslysze_scan"
/ "data"
/ "crypto_standards.db"
)
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as temp_db:
db_path = temp_db.name
# Copy the template database to use as our test database
shutil.copy2(template_db, db_path)
# Path to local XML fixtures
fixtures_dir = Path(__file__).parent.parent / "fixtures" / "iana_xml"
def mock_fetch_xml(url: str, timeout: int = 30) -> str:
"""Mock function that returns local XML files instead of downloading."""
if "tls-parameters" in url:
xml_file = fixtures_dir / "tls-parameters-minimal.xml"
elif "ikev2-parameters" in url:
xml_file = fixtures_dir / "ikev2-parameters-minimal.xml"
elif "ssh-parameters" in url:
xml_file = fixtures_dir / "ssh-parameters-minimal.xml"
else:
raise ValueError(f"Unknown URL: {url}")
return xml_file.read_text(encoding="utf-8")
try:
# Check initial state of SSH tables
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# Count initial entries in IANA SSH tables
ssh_tables = [
"iana_ssh_kex_methods",
"iana_ssh_encryption_algorithms",
"iana_ssh_mac_algorithms",
"iana_ssh_compression_algorithms",
]
initial_counts = {}
for table in ssh_tables:
cursor.execute(f"SELECT COUNT(*) FROM {table}")
initial_counts[table] = cursor.fetchone()[0]
conn.close()
# Run the IANA update command directly with mocked fetch and validation
with (
patch(
"src.sslysze_scan.commands.update_iana.fetch_xml_from_url",
side_effect=mock_fetch_xml,
),
patch(
"src.sslysze_scan.iana_validator.MIN_ROWS",
{
"iana_tls_cipher_suites": 1,
"iana_tls_signature_schemes": 1,
"iana_tls_supported_groups": 1,
"iana_tls_alerts": 1,
"iana_tls_content_types": 1,
"iana_ikev2_encryption_algorithms": 1,
"iana_ikev2_prf_algorithms": 1,
"iana_ikev2_integrity_algorithms": 1,
"iana_ikev2_dh_groups": 1,
"iana_ikev2_authentication_methods": 1,
"iana_ssh_kex_methods": 1,
"iana_ssh_encryption_algorithms": 1,
"iana_ssh_mac_algorithms": 1,
"iana_ssh_compression_algorithms": 1,
},
),
):
args = argparse.Namespace(database=db_path)
result = handle_update_iana_command(args)
# Verify that the command succeeded
assert result == 0, (
f"IANA update command should succeed, got return code: {result}"
)
# Connect to database again to check if tables are now populated
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# Check that SSH tables are now populated and get final counts
final_counts = {}
for table in ssh_tables:
cursor.execute(f"SELECT COUNT(*) FROM {table}")
final_count = cursor.fetchone()[0]
final_counts[table] = final_count
# The tables should now have data after successful import
# Note: Using minimal fixtures, so counts may be lower than full data
assert final_count > 0, (
f"Table {table} should be populated after successful import"
)
conn.close()
print(
"Test confirmed: IANA SSH tables are properly populated after "
"successful import using minimal fixtures"
)
print(f"Initial counts (from template DB): {initial_counts}")
print(f"Final counts (from minimal fixtures): {final_counts}")
finally:
# Clean up temporary database
if os.path.exists(db_path):
os.unlink(db_path)
def test_compliance_works_with_populated_iana_ssh_tables():
"""Test that compliance checking works appropriately when IANA SSH tables are populated."""
# Use the template database for this test
import shutil
template_db = (
Path(__file__).parent.parent.parent
/ "src"
/ "sslysze_scan"
/ "data"
/ "crypto_standards.db"
)
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as temp_db:
db_path = temp_db.name
# Copy the template database to use as our test database
shutil.copy2(template_db, db_path)
try:
# Connect to database to check SSH table status
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# Verify that IANA SSH tables are now populated
cursor.execute("SELECT COUNT(*) FROM iana_ssh_kex_methods")
kex_count = cursor.fetchone()[0]
cursor.execute("SELECT COUNT(*) FROM iana_ssh_encryption_algorithms")
enc_count = cursor.fetchone()[0]
cursor.execute("SELECT COUNT(*) FROM iana_ssh_mac_algorithms")
mac_count = cursor.fetchone()[0]
conn.close()
# Verify that the tables are populated (this is the corrected behavior)
assert kex_count > 0, (
f"IANA SSH KEX table should be populated but has {kex_count} entries"
)
assert enc_count > 0, (
f"IANA SSH encryption table should be populated but has {enc_count} entries"
)
assert mac_count > 0, (
f"IANA SSH MAC table should be populated but has {mac_count} entries"
)
print(
f"Confirmed populated SSH tables: KEX={kex_count}, ENC={enc_count}, MAC={mac_count}"
)
finally:
# Clean up temporary database
if os.path.exists(db_path):
os.unlink(db_path)
def test_iana_ssh_tables_should_not_be_empty_but_are():
"""Test that fails if IANA SSH tables are empty (demonstrating the issue).
This test expects SSH tables to have data but will fail because they are empty
due to the import column mismatch issue.
"""
# Use the template database for this test
import shutil
template_db = (
Path(__file__).parent.parent.parent
/ "src"
/ "sslysze_scan"
/ "data"
/ "crypto_standards.db"
)
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as temp_db:
db_path = temp_db.name
# Copy the template database to use as our test database
shutil.copy2(template_db, db_path)
try:
# Connect to database to check SSH table status
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# Check that IANA SSH tables are empty (this demonstrates the problem)
cursor.execute("SELECT COUNT(*) FROM iana_ssh_kex_methods")
kex_count = cursor.fetchone()[0]
cursor.execute("SELECT COUNT(*) FROM iana_ssh_encryption_algorithms")
enc_count = cursor.fetchone()[0]
cursor.execute("SELECT COUNT(*) FROM iana_ssh_mac_algorithms")
mac_count = cursor.fetchone()[0]
conn.close()
# This assertion will fail, demonstrating the issue
# The tables SHOULD have entries after a successful IANA import, but they don't
assert kex_count > 0, (
f"IANA SSH KEX table should have entries but has {kex_count} - this demonstrates the import issue"
)
assert enc_count > 0, (
f"IANA SSH encryption table should have entries but has {enc_count} - this demonstrates the import issue"
)
assert mac_count > 0, (
f"IANA SSH MAC table should have entries but has {mac_count} - this demonstrates the import issue"
)
print(
f"SSH tables have data as expected: KEX={kex_count}, ENC={enc_count}, MAC={mac_count}"
)
finally:
# Clean up temporary database
if os.path.exists(db_path):
os.unlink(db_path)

View File

@@ -0,0 +1,324 @@
"""Tests for IANA update functionality."""
import sqlite3
import pytest
from sslysze_scan.commands.update_iana import (
calculate_diff,
process_registry_with_validation,
)
from sslysze_scan.iana_validator import ValidationError
class TestCalculateDiff:
"""Tests for diff calculation between old and new data."""
def test_calculate_diff_no_changes(self) -> None:
"""Test diff calculation when data is unchanged."""
rows = [
("0x13,0x01", "TLS_AES_128_GCM_SHA256", "Y", "Y", "rfc8446"),
("0x13,0x02", "TLS_AES_256_GCM_SHA384", "Y", "Y", "rfc8446"),
]
diff = calculate_diff(rows, rows)
assert len(diff["added"]) == 0
assert len(diff["deleted"]) == 0
assert len(diff["modified"]) == 0
def test_calculate_diff_added_rows(self) -> None:
"""Test diff calculation with added rows."""
old_rows = [
("0x13,0x01", "TLS_AES_128_GCM_SHA256", "Y", "Y", "rfc8446"),
]
new_rows = [
("0x13,0x01", "TLS_AES_128_GCM_SHA256", "Y", "Y", "rfc8446"),
("0x13,0x02", "TLS_AES_256_GCM_SHA384", "Y", "Y", "rfc8446"),
]
diff = calculate_diff(old_rows, new_rows)
assert len(diff["added"]) == 1
assert "0x13,0x02" in diff["added"]
assert len(diff["deleted"]) == 0
assert len(diff["modified"]) == 0
def test_calculate_diff_deleted_rows(self) -> None:
"""Test diff calculation with deleted rows."""
old_rows = [
("0x13,0x01", "TLS_AES_128_GCM_SHA256", "Y", "Y", "rfc8446"),
("0x13,0x02", "TLS_AES_256_GCM_SHA384", "Y", "Y", "rfc8446"),
]
new_rows = [
("0x13,0x01", "TLS_AES_128_GCM_SHA256", "Y", "Y", "rfc8446"),
]
diff = calculate_diff(old_rows, new_rows)
assert len(diff["added"]) == 0
assert len(diff["deleted"]) == 1
assert "0x13,0x02" in diff["deleted"]
assert len(diff["modified"]) == 0
def test_calculate_diff_modified_rows(self) -> None:
"""Test diff calculation with modified rows."""
old_rows = [
("0x13,0x01", "TLS_AES_128_GCM_SHA256", "Y", "Y", "rfc8446"),
]
new_rows = [
("0x13,0x01", "TLS_AES_128_GCM_SHA256", "Y", "N", "rfc8446"),
]
diff = calculate_diff(old_rows, new_rows)
assert len(diff["added"]) == 0
assert len(diff["deleted"]) == 0
assert len(diff["modified"]) == 1
assert "0x13,0x01" in diff["modified"]
def test_calculate_diff_mixed_changes(self) -> None:
"""Test diff calculation with mixed changes."""
old_rows = [
("0x13,0x01", "TLS_AES_128_GCM_SHA256", "Y", "Y", "rfc8446"),
("0x13,0x02", "TLS_AES_256_GCM_SHA384", "Y", "Y", "rfc8446"),
("0x00,0x9C", "TLS_RSA_WITH_AES_128_GCM_SHA256", "Y", "N", "rfc5288"),
]
new_rows = [
("0x13,0x01", "TLS_AES_128_GCM_SHA256", "Y", "N", "rfc8446"),
("0x13,0x03", "TLS_CHACHA20_POLY1305_SHA256", "Y", "Y", "rfc8446"),
("0x00,0x9C", "TLS_RSA_WITH_AES_128_GCM_SHA256", "Y", "N", "rfc5288"),
]
diff = calculate_diff(old_rows, new_rows)
assert len(diff["added"]) == 1
assert "0x13,0x03" in diff["added"]
assert len(diff["deleted"]) == 1
assert "0x13,0x02" in diff["deleted"]
assert len(diff["modified"]) == 1
assert "0x13,0x01" in diff["modified"]
class TestProcessRegistryWithValidation:
"""Tests for registry processing with validation."""
def test_process_valid_registry(self, test_db_path: str) -> None:
"""Test processing valid registry data."""
xml_path = "tests/fixtures/iana_xml/tls-parameters-minimal.xml"
with open(xml_path, encoding="utf-8") as f:
xml_content = f.read()
conn = sqlite3.connect(test_db_path)
headers = ["Value", "Description", "DTLS", "Recommended", "RFC/Draft"]
row_count, diff = process_registry_with_validation(
xml_content,
"tls-parameters-4",
"iana_tls_cipher_suites",
headers,
conn,
skip_min_rows_check=True,
)
assert row_count == 5
assert isinstance(diff, dict)
assert "added" in diff
assert "deleted" in diff
assert "modified" in diff
conn.close()
def test_process_registry_invalid_headers(self, test_db_path: str) -> None:
"""Test that invalid headers raise ValidationError."""
xml_path = "tests/fixtures/iana_xml/tls-parameters-minimal.xml"
with open(xml_path, encoding="utf-8") as f:
xml_content = f.read()
conn = sqlite3.connect(test_db_path)
headers = ["Value", "Name"]
with pytest.raises(ValidationError, match="Column count mismatch"):
process_registry_with_validation(
xml_content,
"tls-parameters-4",
"iana_tls_cipher_suites",
headers,
conn,
skip_min_rows_check=True,
)
conn.close()
def test_process_registry_nonexistent_registry_id(self, test_db_path: str) -> None:
"""Test that nonexistent registry ID raises ValueError."""
xml_path = "tests/fixtures/iana_xml/tls-parameters-minimal.xml"
with open(xml_path, encoding="utf-8") as f:
xml_content = f.read()
conn = sqlite3.connect(test_db_path)
headers = ["Value", "Description", "DTLS", "Recommended", "RFC/Draft"]
with pytest.raises(ValueError, match="Registry .* not found"):
process_registry_with_validation(
xml_content,
"nonexistent-registry",
"iana_tls_cipher_suites",
headers,
conn,
skip_min_rows_check=True,
)
conn.close()
class TestTransactionHandling:
"""Tests for database transaction handling."""
def test_transaction_rollback_preserves_data(self, test_db_path: str) -> None:
"""Test that rollback preserves original data."""
conn = sqlite3.connect(test_db_path)
cursor = conn.cursor()
original_count = cursor.execute(
"SELECT COUNT(*) FROM iana_tls_cipher_suites"
).fetchone()[0]
conn.execute("BEGIN TRANSACTION")
cursor.execute("DELETE FROM iana_tls_cipher_suites")
after_delete = cursor.execute(
"SELECT COUNT(*) FROM iana_tls_cipher_suites"
).fetchone()[0]
assert after_delete == 0
conn.rollback()
after_rollback = cursor.execute(
"SELECT COUNT(*) FROM iana_tls_cipher_suites"
).fetchone()[0]
assert after_rollback == original_count
conn.close()
def test_transaction_commit_persists_changes(self, test_db_path: str) -> None:
"""Test that commit persists changes."""
conn = sqlite3.connect(test_db_path)
cursor = conn.cursor()
conn.execute("BEGIN TRANSACTION")
cursor.execute(
"""
INSERT INTO iana_tls_cipher_suites
VALUES ('0xFF,0xFF', 'TEST_CIPHER', 'Y', 'N', 'test')
"""
)
conn.commit()
result = cursor.execute(
"""
SELECT COUNT(*) FROM iana_tls_cipher_suites
WHERE value = '0xFF,0xFF'
"""
).fetchone()[0]
assert result == 1
cursor.execute("DELETE FROM iana_tls_cipher_suites WHERE value = '0xFF,0xFF'")
conn.commit()
conn.close()
class TestDiffCalculationEdgeCases:
"""Tests for edge cases in diff calculation."""
def test_calculate_diff_empty_old_rows(self) -> None:
"""Test diff with empty old rows (initial import)."""
old_rows = []
new_rows = [
("0x13,0x01", "TLS_AES_128_GCM_SHA256", "Y", "Y", "rfc8446"),
("0x13,0x02", "TLS_AES_256_GCM_SHA384", "Y", "Y", "rfc8446"),
]
diff = calculate_diff(old_rows, new_rows)
assert len(diff["added"]) == 2
assert len(diff["deleted"]) == 0
assert len(diff["modified"]) == 0
def test_calculate_diff_empty_new_rows(self) -> None:
"""Test diff with empty new rows (complete deletion)."""
old_rows = [
("0x13,0x01", "TLS_AES_128_GCM_SHA256", "Y", "Y", "rfc8446"),
]
new_rows = []
diff = calculate_diff(old_rows, new_rows)
assert len(diff["added"]) == 0
assert len(diff["deleted"]) == 1
assert len(diff["modified"]) == 0
def test_calculate_diff_different_pk_index(self) -> None:
"""Test diff calculation with different primary key index."""
old_rows = [
("desc1", "0x01", "Y"),
("desc2", "0x02", "Y"),
]
new_rows = [
("desc1", "0x01", "N"),
("desc3", "0x03", "Y"),
]
diff = calculate_diff(old_rows, new_rows, pk_index=1)
assert "0x03" in diff["added"]
assert "0x02" in diff["deleted"]
assert "0x01" in diff["modified"]
class TestConsecutiveUpdates:
"""Tests for consecutive IANA updates."""
def test_consecutive_updates_show_no_changes(self, test_db_path: str) -> None:
"""Test that second update with same data shows no changes."""
xml_path = "tests/fixtures/iana_xml/tls-parameters-minimal.xml"
with open(xml_path, encoding="utf-8") as f:
xml_content = f.read()
conn = sqlite3.connect(test_db_path)
headers = ["Value", "Description", "DTLS", "Recommended", "RFC/Draft"]
row_count_1, diff_1 = process_registry_with_validation(
xml_content,
"tls-parameters-4",
"iana_tls_cipher_suites",
headers,
conn,
skip_min_rows_check=True,
)
row_count_2, diff_2 = process_registry_with_validation(
xml_content,
"tls-parameters-4",
"iana_tls_cipher_suites",
headers,
conn,
skip_min_rows_check=True,
)
assert row_count_1 == row_count_2
assert len(diff_2["added"]) == 0
assert len(diff_2["deleted"]) == 0
assert len(diff_2["modified"]) == 0
conn.close()

View File

@@ -0,0 +1,232 @@
"""Tests for IANA data validators."""
import sqlite3
import pytest
from sslysze_scan.iana_validator import (
ValidationError,
get_min_rows,
normalize_header,
validate_cipher_suite_row,
validate_headers,
validate_ikev2_row,
validate_registry_data,
validate_signature_schemes_row,
validate_supported_groups_row,
)
class TestNormalizeHeader:
"""Tests for header normalization."""
def test_normalize_combined(self) -> None:
"""Test combined normalization."""
assert normalize_header("RFC/Draft") == "rfc_draft"
assert normalize_header("Recommended") == "recommended"
class TestValidateHeaders:
"""Tests for header validation against database schema."""
def test_validate_headers_matching(self, test_db_path: str) -> None:
"""Test that correct headers pass validation."""
conn = sqlite3.connect(test_db_path)
headers = ["Value", "Description", "DTLS", "Recommended", "RFC/Draft"]
validate_headers("iana_tls_cipher_suites", headers, conn)
conn.close()
def test_validate_headers_mismatch_count(self, test_db_path: str) -> None:
"""Test that wrong number of columns raises error."""
conn = sqlite3.connect(test_db_path)
headers = ["Value", "Description"]
with pytest.raises(ValidationError, match="Column count mismatch"):
validate_headers("iana_tls_cipher_suites", headers, conn)
conn.close()
def test_validate_headers_mismatch_name(self, test_db_path: str) -> None:
"""Test that wrong column name raises error."""
conn = sqlite3.connect(test_db_path)
headers = ["Value", "Name", "DTLS", "Recommended", "RFC/Draft"]
with pytest.raises(ValidationError, match="Column .* mismatch"):
validate_headers("iana_tls_cipher_suites", headers, conn)
conn.close()
class TestCipherSuiteValidation:
"""Tests for cipher suite data validation."""
def test_valid_cipher_suite(self) -> None:
"""Test that valid cipher suite passes."""
row = {
"value": "0x13,0x01",
"description": "TLS_AES_128_GCM_SHA256",
"dtls": "Y",
"recommended": "Y",
"rfc_draft": "rfc: rfc8446",
}
validate_cipher_suite_row(row)
def test_missing_required_field(self) -> None:
"""Test that missing value field raises error."""
row = {"description": "TLS_AES_128_GCM_SHA256"}
with pytest.raises(ValidationError, match="Missing required field"):
validate_cipher_suite_row(row)
def test_invalid_value_format(self) -> None:
"""Test that invalid value format raises error."""
row = {
"value": "1301",
"description": "TLS_AES_128_GCM_SHA256",
}
with pytest.raises(ValidationError, match="Invalid value format"):
validate_cipher_suite_row(row)
def test_invalid_recommended_value(self) -> None:
"""Test that invalid Recommended value raises error."""
row = {
"value": "0x13,0x01",
"description": "TLS_AES_128_GCM_SHA256",
"recommended": "X",
}
with pytest.raises(ValidationError, match="Invalid Recommended value"):
validate_cipher_suite_row(row)
def test_empty_recommended_valid(self) -> None:
"""Test that empty Recommended field is valid."""
row = {
"value": "0x13,0x01",
"description": "TLS_AES_128_GCM_SHA256",
"recommended": "",
}
validate_cipher_suite_row(row)
class TestSupportedGroupsValidation:
"""Tests for supported groups data validation."""
def test_valid_supported_group(self) -> None:
"""Test that valid supported group passes."""
row = {
"value": "23",
"description": "secp256r1",
"recommended": "Y",
}
validate_supported_groups_row(row)
def test_invalid_value_non_numeric(self) -> None:
"""Test that non-numeric value raises error."""
row = {"value": "0x17", "description": "secp256r1"}
with pytest.raises(ValidationError, match="Value must be numeric"):
validate_supported_groups_row(row)
class TestSignatureSchemesValidation:
"""Tests for signature schemes data validation."""
def test_valid_signature_scheme(self) -> None:
"""Test that valid signature scheme passes."""
row = {
"value": "0x0403",
"description": "ecdsa_secp256r1_sha256",
"recommended": "Y",
}
validate_signature_schemes_row(row)
def test_invalid_value_format(self) -> None:
"""Test that invalid value format raises error."""
row = {"value": "0403", "description": "ecdsa_secp256r1_sha256"}
with pytest.raises(ValidationError, match="Invalid value format"):
validate_signature_schemes_row(row)
class TestIKEv2Validation:
"""Tests for IKEv2 data validation."""
def test_valid_ikev2_row(self) -> None:
"""Test that valid IKEv2 row passes."""
row = {
"value": "12",
"description": "ENCR_AES_CBC",
"esp": "Y",
"ikev2": "Y",
}
validate_ikev2_row(row)
def test_invalid_value_non_numeric(self) -> None:
"""Test that non-numeric value raises error."""
row = {"value": "0x0C", "description": "ENCR_AES_CBC"}
with pytest.raises(ValidationError, match="Value must be numeric"):
validate_ikev2_row(row)
class TestGetMinRows:
"""Tests for minimum row count lookup."""
def test_get_min_rows_unknown_table(self) -> None:
"""Test that unknown tables return default minimum."""
assert get_min_rows("iana_unknown_table") == 5
class TestValidateRegistryData:
"""Tests for complete registry data validation."""
def test_validate_registry_sufficient_rows(self) -> None:
"""Test that sufficient rows pass validation."""
rows = [
{
"value": f"0x13,0x{i:02x}",
"description": f"Cipher_{i}",
"dtls": "Y",
"recommended": "Y",
"rfc_draft": "rfc: rfc8446",
}
for i in range(60)
]
validate_registry_data("iana_tls_cipher_suites", rows)
def test_validate_registry_insufficient_rows(self) -> None:
"""Test that insufficient rows raise error."""
rows = [
{
"value": "0x13,0x01",
"description": "Cipher_1",
"dtls": "Y",
"recommended": "Y",
"rfc_draft": "rfc: rfc8446",
}
]
with pytest.raises(ValidationError, match="Insufficient data"):
validate_registry_data("iana_tls_cipher_suites", rows)
def test_validate_registry_invalid_row(self) -> None:
"""Test that invalid row in dataset raises error."""
rows = [
{
"value": f"0x13,0x{i:02x}",
"description": f"Cipher_{i}",
"dtls": "Y",
"recommended": "Y",
"rfc_draft": "rfc: rfc8446",
}
for i in range(60)
]
rows[30]["value"] = "invalid"
with pytest.raises(ValidationError, match="Row 31"):
validate_registry_data("iana_tls_cipher_suites", rows)
def test_validate_registry_no_validator(self) -> None:
"""Test that tables without validator pass basic validation."""
rows = [{"value": "1", "description": f"Item_{i}"} for i in range(10)]
validate_registry_data("iana_tls_alerts", rows)