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/iana/__init__.py
Normal file
1
tests/iana/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""IANA tests package."""
|
||||
202
tests/iana/test_iana_parser.py
Normal file
202
tests/iana/test_iana_parser.py
Normal 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
|
||||
248
tests/iana/test_iana_ssh_import_issue.py
Normal file
248
tests/iana/test_iana_ssh_import_issue.py
Normal 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)
|
||||
324
tests/iana/test_iana_update.py
Normal file
324
tests/iana/test_iana_update.py
Normal 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()
|
||||
232
tests/iana/test_iana_validator.py
Normal file
232
tests/iana/test_iana_validator.py
Normal 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)
|
||||
Reference in New Issue
Block a user