From 753c582010fb7eb3c90bc4704e53c999f8028f27 Mon Sep 17 00:00:00 2001 From: Heiko Date: Fri, 19 Dec 2025 20:10:39 +0100 Subject: [PATCH] feature: IANA update --- README.md | 117 +++++-- docs/detailed-guide.md | 171 ++++++++++- pyproject.toml | 2 +- src/sslysze_scan/__init__.py | 8 +- src/sslysze_scan/__main__.py | 8 +- src/sslysze_scan/cli.py | 28 ++ src/sslysze_scan/commands/__init__.py | 7 +- src/sslysze_scan/commands/scan.py | 9 +- src/sslysze_scan/commands/update_iana.py | 248 +++++++++++++++ src/sslysze_scan/data/crypto_standards.db | Bin 552960 -> 552960 bytes src/sslysze_scan/data/iana_parse.json | 4 +- src/sslysze_scan/db/compliance.py | 10 +- .../{scan_iana.py => iana_parser.py} | 123 +++----- src/sslysze_scan/iana_validator.py | 227 ++++++++++++++ src/sslysze_scan/output.py | 10 +- src/sslysze_scan/reporter/csv_export.py | 260 +++++----------- src/sslysze_scan/reporter/csv_utils.py | 102 +++++++ src/sslysze_scan/reporter/query.py | 18 ++ src/sslysze_scan/reporter/template_utils.py | 14 +- src/sslysze_scan/scanner.py | 8 +- .../iana_xml/ikev2-parameters-minimal.xml | 69 +++++ .../iana_xml/tls-parameters-minimal.xml | 96 ++++++ tests/test_compliance.py | 73 ----- tests/test_iana_parse.py | 202 +++++++++++++ tests/test_iana_update.py | 285 ++++++++++++++++++ tests/test_iana_validator.py | 232 ++++++++++++++ tests/test_template_utils.py | 11 - 27 files changed, 1923 insertions(+), 419 deletions(-) create mode 100644 src/sslysze_scan/commands/update_iana.py rename src/sslysze_scan/{scan_iana.py => iana_parser.py} (77%) create mode 100644 src/sslysze_scan/iana_validator.py create mode 100644 src/sslysze_scan/reporter/csv_utils.py create mode 100644 tests/fixtures/iana_xml/ikev2-parameters-minimal.xml create mode 100644 tests/fixtures/iana_xml/tls-parameters-minimal.xml delete mode 100644 tests/test_compliance.py create mode 100644 tests/test_iana_parse.py create mode 100644 tests/test_iana_update.py create mode 100644 tests/test_iana_validator.py diff --git a/README.md b/README.md index fe6aae1..57f2528 100644 --- a/README.md +++ b/README.md @@ -1,63 +1,132 @@ # compliance-scan -SSL/TLS configuration analysis with automated IANA/BSI compliance checking. +SSL/TLS configuration analysis with automated BSI/IANA compliance checking. ## Quick Start ```bash -# Scan +# Install +poetry install + +# Scan server poetry run compliance-scan scan example.com:443,636 -# Report +# Generate report poetry run compliance-scan report -t md -o report.md + +# Update IANA registry data +poetry run compliance-scan update-iana ``` -## Installation - -```bash -poetry install -``` +Note: SSLyze outputs INFO-level log messages during scanning that cannot be suppressed. These messages are harmless and can be ignored. ## Features -- Multi-port TLS/SSL scanning +- Multi-port TLS/SSL scanning with SSLyze - BSI TR-02102-1/2 compliance validation - IANA recommendations checking - Vulnerability detection (Heartbleed, ROBOT, CCS Injection) -- Certificate validation +- Certificate validation with key size compliance - Multiple report formats (CSV, Markdown, reStructuredText) +- IANA registry updates from official sources ## Commands +### Scan + ```bash -# Scan with ports -compliance-scan scan :, [--print] [-db ] +compliance-scan scan :, [options] -# Generate report -compliance-scan report [scan_id] -t [-o ] +# Examples +compliance-scan scan example.com:443,636 --print +compliance-scan scan [2001:db8::1]:443 -db custom.db +``` -# List scans +Note: SSLyze outputs INFO-level log messages during scanning that cannot be suppressed. + +Options: + +- `--print` - Display scan summary in console +- `-db ` - Database file path (default: compliance_status.db) + +### Report + +```bash +compliance-scan report [scan_id] -t [options] + +# Examples +compliance-scan report -t md -o report.md +compliance-scan report 5 -t csv --output-dir ./reports compliance-scan report --list ``` +Options: + +- `-t ` - Report type: csv, md, markdown, rest, rst +- `-o ` - Output file for Markdown/reStructuredText +- `--output-dir ` - Output directory for CSV files +- `--list` - List all available scans +- `-db ` - Database file path + +### Update IANA Data + +```bash +compliance-scan update-iana [-db ] + +# Example +compliance-scan update-iana -db compliance_status.db +``` + +Updates IANA registry data from official sources. Default database contains IANA data as of 12/2024. + +## Report Formats + +**CSV**: Granular files per port and category for data analysis. + +**Markdown**: Single comprehensive report with all findings. + +**reStructuredText**: Sphinx-compatible report with CSV table includes. + ## Supported Protocols -Opportunistic TLS: SMTP, LDAP, IMAP, POP3, FTP, XMPP, RDP, PostgreSQL -Direct TLS: HTTPS, LDAPS, SMTPS, IMAPS, POP3S +**Opportunistic TLS**: SMTP, LDAP, IMAP, POP3, FTP, XMPP, RDP, PostgreSQL + +**Direct TLS**: HTTPS, LDAPS, SMTPS, IMAPS, POP3S + +## Compliance Standards + +- BSI TR-02102-1: Certificate requirements +- BSI TR-02102-2: TLS cipher suites and parameters +- IANA TLS Parameters: Cipher suites, signature schemes, supported groups ## Documentation -**[Detailed Guide](docs/detailed-guide.md)** - Complete reference with CLI commands, database schema, compliance rules, and development guide. +**[Detailed Guide](docs/detailed-guide.md)** - Complete reference with database schema, compliance rules, and development information. ## Requirements - Python 3.13+ -- SSLyze 6.0.0+ - Poetry +- SSLyze 6.0.0+ -## Planned Features +## Database -- CLI command for updating IANA reference data -- Automated IANA registry updates from web sources base on `src/sslysze_scan/scan_iana.py` - - TLS Parameters: https://www.iana.org/assignments/tls-parameters/tls-parameters.xml - - IKEv2 Parameters: https://www.iana.org/assignments/ikev2-parameters/ikev2-parameters.xml +Default location: `compliance_status.db` + +Template with reference data: `src/sslysze_scan/data/crypto_standards.db` + +Schema version: 5 (includes optimized views for reporting) + +## Development + +```bash +# Run tests +poetry run pytest + +# Update IANA reference data in template +python3 -m sslysze_scan.iana_parser +``` + +## Version Management + +Version is maintained in `pyproject.toml` and read dynamically at runtime. diff --git a/docs/detailed-guide.md b/docs/detailed-guide.md index dcdeadf..4b932fd 100644 --- a/docs/detailed-guide.md +++ b/docs/detailed-guide.md @@ -4,14 +4,17 @@ Complete reference for developers and advanced users. ## Core Entry Points -| Component | Path | Purpose | -| --------------- | ------------------------------------ | ------------------------------------- | -| CLI | `src/sslysze_scan/__main__.py` | Command-line interface entry | -| Scanner | `src/sslysze_scan/scanner.py` | SSLyze integration and scan execution | -| Database Writer | `src/sslysze_scan/db/writer.py` | Scan result persistence | -| Reporter | `src/sslysze_scan/reporter/` | Report generation (CSV/MD/reST) | -| Compliance | `src/sslysze_scan/db/compliance.py` | BSI/IANA validation logic | -| Query | `src/sslysze_scan/reporter/query.py` | Database queries using views | +| Component | Path | Purpose | +| --------------- | ------------------------------------------ | ------------------------------------- | +| CLI | `src/sslysze_scan/__main__.py` | Command-line interface entry | +| Scanner | `src/sslysze_scan/scanner.py` | SSLyze integration and scan execution | +| Database Writer | `src/sslysze_scan/db/writer.py` | Scan result persistence | +| Reporter | `src/sslysze_scan/reporter/` | Report generation (CSV/MD/reST) | +| Compliance | `src/sslysze_scan/db/compliance.py` | BSI/IANA validation logic | +| Query | `src/sslysze_scan/reporter/query.py` | Database queries using views | +| IANA Update | `src/sslysze_scan/commands/update_iana.py` | IANA registry updates from web | +| IANA Validator | `src/sslysze_scan/iana_validator.py` | IANA data validation | +| IANA Parser | `src/sslysze_scan/iana_parser.py` | IANA XML parsing utilities | ## Installation @@ -43,6 +46,8 @@ poetry run compliance-scan report --list compliance-scan scan :, [options] ``` +Note: SSLyze outputs INFO-level log messages during scanning that cannot be suppressed. These messages are harmless and can be ignored. + | Argument | Required | Description | | -------------------- | -------- | ---------------------------------------------------------------- | | `:` | Yes | Target with comma-separated ports. IPv6: `[2001:db8::1]:443,636` | @@ -78,6 +83,39 @@ compliance-scan report 5 -t csv --output-dir ./reports compliance-scan report -t rest --output-dir ./docs ``` +### Update IANA Command + +``` +compliance-scan update-iana [-db ] +``` + +| Argument | Required | Description | +| ------------ | -------- | ------------------------------------------------------- | +| `-db ` | No | Database file to update (default: compliance_status.db) | + +Updates IANA registry data from official sources: + +- TLS Parameters: https://www.iana.org/assignments/tls-parameters/tls-parameters.xml +- IKEv2 Parameters: https://www.iana.org/assignments/ikev2-parameters/ikev2-parameters.xml + +Default database contains IANA data as of 12/2024. + +Examples: + +```bash +compliance-scan update-iana +compliance-scan update-iana -db custom.db +``` + +Update process: + +1. Fetches XML from IANA URLs +2. Validates headers against database schema +3. Validates data integrity (value formats, minimum row counts) +4. Calculates diff (added/modified/deleted entries) +5. Updates database in transaction (rollback on error) +6. Logs all changes at INFO level + ## Report Formats ### CSV @@ -230,9 +268,12 @@ src/sslysze_scan/ ├── scanner.py # SSLyze integration ├── protocol_loader.py # Port-protocol mapping ├── output.py # Console output +├── iana_parser.py # IANA XML parsing utilities +├── iana_validator.py # IANA data validation ├── commands/ │ ├── scan.py # Scan command handler -│ └── report.py # Report command handler +│ ├── report.py # Report command handler +│ └── update_iana.py # IANA update command handler ├── db/ │ ├── schema.py # Schema version management │ ├── writer.py # Scan result storage @@ -241,6 +282,7 @@ src/sslysze_scan/ ├── reporter/ │ ├── query.py # Database queries (uses views) │ ├── csv_export.py # CSV generation +│ ├── csv_utils.py # CSV utilities (exporter class) │ ├── markdown_export.py # Markdown generation │ ├── rst_export.py # reST generation │ └── template_utils.py # Shared utilities @@ -249,7 +291,16 @@ src/sslysze_scan/ │ └── report.reST.j2 # reST template └── data/ ├── crypto_standards.db # Template DB (IANA/BSI + schema) + ├── iana_parse.json # IANA XML source URLs and registry config └── protocols.csv # Port-protocol mapping + +tests/ +├── fixtures/ +│ ├── iana_xml/ # Minimal XML test fixtures +│ └── test_scan.db # Test database +├── test_iana_validator.py # IANA validation tests (25 tests) +├── test_iana_parse.py # IANA XML parsing tests (20 tests) +└── test_iana_update.py # IANA update logic tests (13 tests) ``` ## Key Functions @@ -284,6 +335,7 @@ src/sslysze_scan/ | `get_scan_data(db_path, scan_id)` | `reporter/query.py` | Get complete scan data using views | | `get_scan_metadata(db_path, scan_id)` | `reporter/query.py` | Get scan metadata only | | `list_scans(db_path)` | `reporter/query.py` | List all scans in database | +| `has_tls_support(port_data)` | `reporter/query.py` | Check if port has TLS support | ### Report Generation @@ -292,10 +344,101 @@ src/sslysze_scan/ | `generate_csv_reports(db_path, scan_id, output_dir)` | `reporter/csv_export.py` | Generate all CSV files | | `generate_markdown_report(db_path, scan_id, output)` | `reporter/markdown_export.py` | Generate Markdown report | | `generate_rest_report(db_path, scan_id, output, output_dir)` | `reporter/rst_export.py` | Generate reStructuredText report | -| `_get_headers(db_path, export_type)` | `reporter/csv_export.py` | Load CSV headers from database | | `build_template_context(data)` | `reporter/template_utils.py` | Prepare Jinja2 template context | | `generate_report_id(metadata)` | `reporter/template_utils.py` | Generate report ID (YYYYMMDD_scanid) | +### IANA Update and Validation + +| Function | Module | Purpose | +| ------------------------------------------------ | ------------------------- | ---------------------------------------- | +| `handle_update_iana_command(args)` | `commands/update_iana.py` | Main update command handler | +| `fetch_xml_from_url(url)` | `commands/update_iana.py` | Fetch XML from IANA URL | +| `calculate_diff(old_rows, new_rows)` | `commands/update_iana.py` | Calculate added/modified/deleted entries | +| `process_registry_with_validation(...)` | `commands/update_iana.py` | Process and validate single registry | +| `validate_headers(table_name, headers, db_conn)` | `iana_validator.py` | Validate headers match database schema | +| `validate_registry_data(table_name, rows)` | `iana_validator.py` | Validate complete registry data | +| `validate_cipher_suite_row(row)` | `iana_validator.py` | Validate single cipher suite record | +| `validate_supported_groups_row(row)` | `iana_validator.py` | Validate single supported group record | +| `normalize_header(header)` | `iana_validator.py` | Normalize header to DB column format | +| `get_min_rows(table_name)` | `iana_validator.py` | Get minimum expected rows for table | +| `extract_updated_date(xml_content)` | `iana_parser.py` | Extract date from XML `` tag | +| `parse_xml_with_namespace_support(xml_path)` | `iana_parser.py` | Parse XML with IANA namespace detection | +| `find_registry(root, registry_id, ns)` | `iana_parser.py` | Find registry element by ID | +| `extract_field_value(record, header, ns)` | `iana_parser.py` | Extract field value from XML record | + +## IANA Data Update Process + +Configuration file: `src/sslysze_scan/data/iana_parse.json` + +Structure: + +```json +{ + "https://www.iana.org/assignments/tls-parameters/tls-parameters.xml": [ + ["registry_id", "output_filename.csv", ["Header1", "Header2", "..."]] + ] +} +``` + +Validation rules: + +1. Headers must match database schema (case-insensitive, `/` → `_`) +2. Minimum row counts per table (50 for cipher suites, 10 for groups, 5 for small tables) +3. Value format validation (0x prefix for hex values, numeric for groups) +4. Recommended field must be Y, N, or D + +Error handling: + +- Validation failure: Rollback transaction, display error with hint to open issue +- Network error: Abort with error message +- XML structure change: Validation catches and aborts + +Logging output: + +``` +INFO: Fetching https://www.iana.org/assignments/tls-parameters/tls-parameters.xml +INFO: XML data date: 2025-12-03 +INFO: iana_tls_cipher_suites: 448 rows (2 added, 1 modified, 0 deleted) +INFO: Successfully updated 11 registries (1310 total rows) +``` + +## Version Management + +Version is maintained in `pyproject.toml` only: + +```toml +[project] +version = "0.1.0" +``` + +Runtime access via `importlib.metadata`: + +```python +from sslysze_scan import __version__ +print(__version__) # "0.1.0" +``` + +## Development + +Run tests: + +```bash +poetry run pytest +poetry run pytest tests/test_iana_validator.py -v +``` + +Update IANA template database: + +```bash +python3 -m sslysze_scan.iana_parser +``` + +Code style: + +- PEP 8 compliant +- Max line length: 90 characters +- Ruff for linting and formatting + ## SQL Query Examples All queries use optimized views for performance. @@ -382,11 +525,13 @@ poetry run pytest tests/ -v - `tests/conftest.py`: Fixtures with test_db, test_db_path - `tests/fixtures/test_scan.db`: Real scan data (Scan 1: dc.validation.lan:443,636) - `tests/test_csv_export.py`: 11 CSV export tests -- `tests/test_template_utils.py`: 3 template utility tests -- `tests/test_compliance.py`: 2 compliance tests +- `tests/test_template_utils.py`: 2 template utility tests - `tests/test_cli.py`: 3 CLI parsing tests +- `tests/test_iana_validator.py`: 20 IANA validation tests +- `tests/test_iana_parse.py`: 14 IANA parsing tests +- `tests/test_iana_update.py`: 13 IANA update tests -**Total:** 19 tests +**Total:** 63 tests **Test database setup:** diff --git a/pyproject.toml b/pyproject.toml index 16be31a..14f0023 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "compliance-scan" -version = "0.1.0" +version = "1.0.0" description = "" authors = [ {name = "Heiko Haase",email = "heiko.haase.extern@univention.de"} diff --git a/src/sslysze_scan/__init__.py b/src/sslysze_scan/__init__.py index c2df4e6..b07bc0a 100644 --- a/src/sslysze_scan/__init__.py +++ b/src/sslysze_scan/__init__.py @@ -5,7 +5,13 @@ import logging from .__main__ import main from .scanner import perform_scan -__version__ = "0.1.0" +try: + from importlib.metadata import version + + __version__ = version("compliance-scan") +except Exception: + __version__ = "unknown" + __all__ = ["main", "perform_scan"] # Configure logging diff --git a/src/sslysze_scan/__main__.py b/src/sslysze_scan/__main__.py index 7bea1f6..7c81194 100644 --- a/src/sslysze_scan/__main__.py +++ b/src/sslysze_scan/__main__.py @@ -4,7 +4,11 @@ import sys from .cli import parse_arguments -from .commands import handle_report_command, handle_scan_command +from .commands import ( + handle_report_command, + handle_scan_command, + handle_update_iana_command, +) from .output import print_error @@ -21,6 +25,8 @@ def main() -> int: return handle_scan_command(args) if args.command == "report": return handle_report_command(args) + if args.command == "update-iana": + return handle_update_iana_command(args) print_error(f"Unknown command: {args.command}") return 1 diff --git a/src/sslysze_scan/cli.py b/src/sslysze_scan/cli.py index c030d84..ae377fb 100644 --- a/src/sslysze_scan/cli.py +++ b/src/sslysze_scan/cli.py @@ -164,6 +164,10 @@ Examples: compliance-scan scan example.com:443 --print compliance-scan scan example.com:443,636 -db /path/to/scans.db compliance-scan scan [2001:db8::1]:443,636 --print + +Note: + SSLyze outputs INFO-level log messages during scanning that cannot be suppressed. + These messages are harmless and can be ignored. """, ) @@ -247,6 +251,30 @@ Examples: default=False, ) + # Update-iana subcommand + update_parser = subparsers.add_parser( + "update-iana", + help="Update IANA registry data from official online sources", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + compliance-scan update-iana + compliance-scan update-iana -db /path/to/scans.db + +Note: + Default database contains IANA data as of 12/2024. + This command fetches current data from IANA and updates the database. + """, + ) + + update_parser.add_argument( + "-db", + "--database", + type=str, + help="Database file to update (default: compliance_status.db)", + default="compliance_status.db", + ) + args = parser.parse_args() # Check if no command was provided diff --git a/src/sslysze_scan/commands/__init__.py b/src/sslysze_scan/commands/__init__.py index fe6291c..9243291 100644 --- a/src/sslysze_scan/commands/__init__.py +++ b/src/sslysze_scan/commands/__init__.py @@ -2,5 +2,10 @@ from .report import handle_report_command from .scan import handle_scan_command +from .update_iana import handle_update_iana_command -__all__ = ["handle_report_command", "handle_scan_command"] +__all__ = [ + "handle_report_command", + "handle_scan_command", + "handle_update_iana_command", +] diff --git a/src/sslysze_scan/commands/scan.py b/src/sslysze_scan/commands/scan.py index 024ea7d..9871d15 100644 --- a/src/sslysze_scan/commands/scan.py +++ b/src/sslysze_scan/commands/scan.py @@ -1,7 +1,8 @@ """Scan command handler.""" import argparse -from datetime import datetime, timezone +import sqlite3 +from datetime import UTC, datetime from pathlib import Path from typing import Any @@ -58,7 +59,7 @@ def handle_scan_command(args: argparse.Namespace) -> int: return 1 # Single timestamp for all scans (program start time) - program_start_time = datetime.now(timezone.utc) + program_start_time = datetime.now(UTC) # Scan results storage scan_results_dict: dict[int, Any] = {} @@ -76,7 +77,7 @@ def handle_scan_command(args: argparse.Namespace) -> int: continue # Calculate total scan duration - scan_end_time = datetime.now(timezone.utc) + scan_end_time = datetime.now(UTC) total_scan_duration = (scan_end_time - program_start_time).total_seconds() # Save all results to database with single scan_id @@ -105,8 +106,6 @@ def handle_scan_command(args: argparse.Namespace) -> int: # Print summary if requested if args.print: - import sqlite3 - print("\n" + "=" * 70) print("SCAN SUMMARY") print("=" * 70) diff --git a/src/sslysze_scan/commands/update_iana.py b/src/sslysze_scan/commands/update_iana.py new file mode 100644 index 0000000..117cc1d --- /dev/null +++ b/src/sslysze_scan/commands/update_iana.py @@ -0,0 +1,248 @@ +"""Update IANA command handler.""" + +import argparse +import json +import logging +import sqlite3 +from pathlib import Path +from urllib.error import URLError +from urllib.request import urlopen + +from ..iana_parser import ( + extract_updated_date, + find_registry, + get_table_name_from_filename, + parse_xml_with_namespace_support, +) +from ..iana_validator import ( + ValidationError, + normalize_header, + validate_headers, + validate_registry_data, +) +from ..output import print_error + +logger = logging.getLogger(__name__) + + +def fetch_xml_from_url(url: str, timeout: int = 30) -> str: + """Fetch XML content from URL. + + Args: + url: URL to fetch + timeout: Timeout in seconds + + Returns: + XML content as string + + Raises: + URLError: If URL cannot be fetched + + """ + logger.info(f"Fetching {url}") + with urlopen(url, timeout=timeout) as response: + return response.read().decode("utf-8") + + +def calculate_diff( + old_rows: list[tuple], + new_rows: list[tuple], + pk_index: int = 0, +) -> dict[str, list]: + """Calculate diff between old and new data. + + Args: + old_rows: Existing rows from DB + new_rows: New rows from XML + pk_index: Index of primary key column + + Returns: + Dict with 'added', 'deleted', 'modified' lists of primary keys + + """ + old_dict = {row[pk_index]: row for row in old_rows} + new_dict = {row[pk_index]: row for row in new_rows} + + added = [k for k in new_dict if k not in old_dict] + deleted = [k for k in old_dict if k not in new_dict] + modified = [k for k in new_dict if k in old_dict and old_dict[k] != new_dict[k]] + + return {"added": added, "deleted": deleted, "modified": modified} + + +def process_registry_with_validation( + xml_content: str, + registry_id: str, + table_name: str, + headers: list[str], + db_conn: sqlite3.Connection, + skip_min_rows_check: bool = False, +) -> tuple[int, dict[str, list]]: + """Process registry with validation and diff calculation. + + Args: + xml_content: XML content as string + registry_id: Registry ID to extract + table_name: Database table name + headers: List of column headers + db_conn: Database connection + skip_min_rows_check: Skip minimum rows validation (for tests) + + Returns: + Tuple of (row_count, diff_dict) + + Raises: + ValidationError: If validation fails + ValueError: If registry not found + + """ + import tempfile + + with tempfile.NamedTemporaryFile( + mode="w", suffix=".xml", delete=False, encoding="utf-8" + ) as tmp_file: + tmp_file.write(xml_content) + tmp_path = tmp_file.name + + try: + root, ns = parse_xml_with_namespace_support(tmp_path) + finally: + Path(tmp_path).unlink() + + validate_headers(table_name, headers, db_conn) + + registry = find_registry(root, registry_id, ns) + + if ns: + records = registry.findall("iana:record", ns) + else: + records = registry.findall("record") + + from ..iana_parser import extract_field_value, is_unassigned + + rows_dict = [] + for record in records: + if is_unassigned(record, ns): + continue + row_dict = {} + for header in headers: + normalized_key = normalize_header(header) + row_dict[normalized_key] = extract_field_value(record, header, ns) + rows_dict.append(row_dict) + + validate_registry_data(table_name, rows_dict, skip_min_rows_check) + + rows = [tuple(row.values()) for row in rows_dict] + + cursor = db_conn.cursor() + old_rows = cursor.execute(f"SELECT * FROM {table_name}").fetchall() + + diff = calculate_diff(old_rows, rows) + + placeholders = ",".join(["?"] * len(headers)) + cursor.execute(f"DELETE FROM {table_name}") + cursor.executemany(f"INSERT INTO {table_name} VALUES ({placeholders})", rows) + + return len(rows), diff + + +def handle_update_iana_command(args: argparse.Namespace) -> int: + """Handle the update-iana subcommand. + + Args: + args: Parsed arguments + + Returns: + Exit code (0 for success, 1 for error) + + """ + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + + db_path = args.database + + if not Path(db_path).exists(): + print_error(f"Database not found: {db_path}") + return 1 + + script_dir = Path(__file__).parent.parent + config_path = script_dir / "data" / "iana_parse.json" + + logger.info(f"Loading configuration from {config_path}") + + try: + with config_path.open(encoding="utf-8") as f: + config = json.load(f) + except (FileNotFoundError, json.JSONDecodeError, OSError) as e: + print_error(f"Error loading configuration: {e}") + return 1 + + try: + conn = sqlite3.connect(str(db_path)) + except sqlite3.Error as e: + print_error(f"Error opening database: {e}") + return 1 + + logger.info("Starting IANA registry update") + + try: + conn.execute("BEGIN TRANSACTION") + + total_registries = 0 + total_rows = 0 + + for url, registries in config.items(): + try: + xml_content = fetch_xml_from_url(url) + except (URLError, OSError) as e: + print_error(f"Failed to fetch {url}: {e}") + conn.rollback() + conn.close() + return 1 + + xml_date = extract_updated_date(xml_content) + logger.info(f"XML data date: {xml_date}") + + for registry_id, output_filename, headers in registries: + table_name = get_table_name_from_filename(output_filename) + + try: + row_count, diff = process_registry_with_validation( + xml_content, registry_id, table_name, headers, conn + ) + + logger.info( + f"{table_name}: {row_count} rows " + f"({len(diff['added'])} added, " + f"{len(diff['modified'])} modified, " + f"{len(diff['deleted'])} deleted)" + ) + + total_registries += 1 + total_rows += row_count + + except (ValidationError, ValueError) as e: + print_error( + f"Validation failed for {table_name}: {e}\n" + f"IANA data structure may have changed. " + f"Please open an issue at the project repository." + ) + conn.rollback() + conn.close() + return 1 + + conn.commit() + logger.info( + f"Successfully updated {total_registries} registries " + f"({total_rows} total rows)" + ) + + except sqlite3.Error as e: + print_error(f"Database error: {e}") + conn.rollback() + conn.close() + return 1 + + finally: + conn.close() + + return 0 diff --git a/src/sslysze_scan/data/crypto_standards.db b/src/sslysze_scan/data/crypto_standards.db index 2f267c759a0ade62d86a3ddd715483a6e2409e3f..bca0fd0ca3e7f90f9e7c40249cefffba68532ef6 100644 GIT binary patch delta 16237 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 GT>F2s6ECCy delta 46530 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 buO4DRnx6J?FSw6;!E@XT-m6~lUHX3jb3^5; diff --git a/src/sslysze_scan/data/iana_parse.json b/src/sslysze_scan/data/iana_parse.json index 7fb5510..f98d7ca 100644 --- a/src/sslysze_scan/data/iana_parse.json +++ b/src/sslysze_scan/data/iana_parse.json @@ -1,5 +1,5 @@ { - "proto/assignments/tls-parameters/tls-parameters.xml": [ + "https://www.iana.org/assignments/tls-parameters/tls-parameters.xml": [ [ "tls-parameters-4", "tls_cipher_suites.csv", @@ -31,7 +31,7 @@ ["Value", "Description", "DTLS", "Recommended", "RFC/Draft"] ] ], - "proto/assignments/ikev2-parameters/ikev2-parameters.xml": [ + "https://www.iana.org/assignments/ikev2-parameters/ikev2-parameters.xml": [ [ "ikev2-parameters-5", "ikev2_encryption_algorithms.csv", diff --git a/src/sslysze_scan/db/compliance.py b/src/sslysze_scan/db/compliance.py index c86a2e1..f80f0a4 100644 --- a/src/sslysze_scan/db/compliance.py +++ b/src/sslysze_scan/db/compliance.py @@ -1,7 +1,7 @@ """Compliance checking module for IANA and BSI standards.""" import sqlite3 -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import Any # Error messages @@ -26,7 +26,7 @@ def check_compliance(db_path: str, scan_id: int) -> dict[str, Any]: cursor = conn.cursor() try: - timestamp = datetime.now(timezone.utc).isoformat() + timestamp = datetime.now(UTC).isoformat() stats = { "cipher_suites_checked": 0, "cipher_suites_passed": 0, @@ -122,7 +122,7 @@ def check_certificate_compliance( if bsi_result and algo_type: min_key_length, valid_until, notes = bsi_result - current_year = datetime.now(timezone.utc).year + current_year = datetime.now(UTC).year # Check key length if key_bits and key_bits >= min_key_length: @@ -285,7 +285,7 @@ def _check_cipher_suite_compliance( # BSI check (sole compliance criterion) if bsi_approved: - current_year = datetime.now(timezone.utc).year + current_year = datetime.now(UTC).year if bsi_valid_until and bsi_valid_until >= current_year: details.append(f"BSI: Approved until {bsi_valid_until}") passed = True @@ -408,7 +408,7 @@ def _check_supported_group_compliance( # BSI check (sole compliance criterion) if bsi_approved: - current_year = datetime.now(timezone.utc).year + current_year = datetime.now(UTC).year if bsi_valid_until and bsi_valid_until >= current_year: details.append(f"BSI: Approved until {bsi_valid_until}") passed = True diff --git a/src/sslysze_scan/scan_iana.py b/src/sslysze_scan/iana_parser.py similarity index 77% rename from src/sslysze_scan/scan_iana.py rename to src/sslysze_scan/iana_parser.py index e68e0bc..f6a5608 100644 --- a/src/sslysze_scan/scan_iana.py +++ b/src/sslysze_scan/iana_parser.py @@ -1,13 +1,11 @@ -#!/usr/bin/env python3 -"""IANA XML Registry to SQLite Converter +"""IANA XML parser utilities. -Parses IANA XML registry files and exports specified registries directly to SQLite database -based on configuration from iana_parse.json. +Provides functions for parsing IANA XML registry files and extracting +registry data. Used by update_iana command and tests. """ -"""Script to fetch and parse IANA TLS registries into SQLite database.""" - import json +import re import sqlite3 import xml.etree.ElementTree as ET from pathlib import Path @@ -213,6 +211,45 @@ def get_table_name_from_filename(filename: str) -> str: return table_name +def extract_updated_date(xml_content: str) -> str: + """Extract date from tag in XML content. + + Args: + xml_content: XML content as string + + Returns: + Date string in format YYYY-MM-DD or "unknown" + + """ + for line in xml_content.split("\n")[:10]: + if "" in line: + match = re.search(r"([\d-]+)", line) + if match: + return match.group(1) + return "unknown" + + +def is_unassigned(record: ET.Element, ns: dict | None) -> bool: + """Check if record is an unassigned or reserved range entry. + + Args: + record: XML record element + ns: Namespace dictionary or None + + Returns: + True if record should be skipped (unassigned/reserved ranges) + + """ + value = get_element_text(record, "value", ns) + + # Check for range notation (e.g., "42-255", "0x0000-0x0200") + # Range values indicate unassigned or reserved blocks + if re.search(r"\d+-\d+", value): + return True + + return False + + def write_registry_to_db( root: ET.Element, registry_id: str, @@ -248,9 +285,11 @@ def write_registry_to_db( else: records = registry.findall("record") - # Prepare data + # Prepare data (skip unassigned entries) rows = [] for record in records: + if is_unassigned(record, ns): + continue row = [] for header in headers: value = extract_field_value(record, header, ns) @@ -279,7 +318,7 @@ def process_xml_file( xml_path: str, registries: list[tuple[str, str, list[str]]], db_conn: sqlite3.Connection, - repo_root: str, + repo_root: Path, ) -> int: """Process single XML file and export all specified registries to database. @@ -331,71 +370,3 @@ def process_xml_file( ) from e return total_rows - - -def main() -> None: - """Main entry point.""" - # Determine paths - script_dir = Path(__file__).parent - repo_root = script_dir.parent.parent - config_path = script_dir / "data" / "iana_parse.json" - db_path = script_dir / "data" / "crypto_standards.db" - - print("IANA XML zu SQLite Konverter") - print("=" * 50) - print(f"Repository Root: {repo_root}") - print(f"Konfiguration: {config_path}") - print(f"Datenbank: {db_path}") - - # Check if database exists - if not db_path.exists(): - print(f"\n✗ Fehler: Datenbank {db_path} nicht gefunden", file=sys.stderr) - print("Bitte zuerst die Datenbank erstellen.", file=sys.stderr) - sys.exit(1) - - # Load configuration - try: - config = load_config(config_path) - except (FileNotFoundError, json.JSONDecodeError, OSError) as e: - print(f"\nFehler beim Laden der Konfiguration: {e}", file=sys.stderr) - sys.exit(1) - - print(f"\n{len(config)} XML-Datei(en) gefunden in Konfiguration") - - # Connect to database - try: - db_conn = sqlite3.connect(str(db_path)) - except sqlite3.Error as e: - print(f"\n✗ Fehler beim Öffnen der Datenbank: {e}", file=sys.stderr) - sys.exit(1) - - # Process each XML file - try: - success_count = 0 - total_count = 0 - total_rows = 0 - - for xml_path, registries in config.items(): - total_count += len(registries) - try: - rows = process_xml_file(xml_path, registries, db_conn, str(repo_root)) - success_count += len(registries) - total_rows += rows - except (RuntimeError, ValueError, sqlite3.Error) as e: - print(f"\nFehler: {e}", file=sys.stderr) - db_conn.close() - sys.exit(1) - - # Summary - print("\n" + "=" * 50) - print( - f"Erfolgreich abgeschlossen: {success_count}/{total_count} Registries " - f"({total_rows} Einträge) in Datenbank importiert", - ) - - finally: - db_conn.close() - - -if __name__ == "__main__": - main() diff --git a/src/sslysze_scan/iana_validator.py b/src/sslysze_scan/iana_validator.py new file mode 100644 index 0000000..b13d26a --- /dev/null +++ b/src/sslysze_scan/iana_validator.py @@ -0,0 +1,227 @@ +"""Validation functions for IANA registry data.""" + +import sqlite3 + + +class ValidationError(Exception): + """Raised when IANA data validation fails.""" + + pass + + +def normalize_header(header: str) -> str: + """Normalize header name to database column format. + + Args: + header: Header name from JSON config + + Returns: + Normalized column name (lowercase, / replaced with _) + + """ + return header.lower().replace("/", "_") + + +def validate_headers( + table_name: str, + headers: list[str], + db_conn: sqlite3.Connection, +) -> None: + """Validate that headers match database schema. + + Args: + table_name: Database table name + headers: Headers from JSON config + db_conn: Database connection + + Raises: + ValidationError: If headers don't match schema + + """ + cursor = db_conn.cursor() + cursor.execute(f"PRAGMA table_info({table_name})") + db_columns = [row[1] for row in cursor.fetchall()] + + normalized_headers = [normalize_header(h) for h in headers] + + if len(normalized_headers) != len(db_columns): + raise ValidationError( + f"Column count mismatch for {table_name}: " + f"expected {len(db_columns)}, got {len(normalized_headers)}" + ) + + for i, (expected, actual) in enumerate(zip(db_columns, normalized_headers)): + if expected != actual: + raise ValidationError( + f"Column {i} mismatch for {table_name}: " + f"expected '{expected}', got '{actual}' " + f"(from header '{headers[i]}')" + ) + + +def validate_cipher_suite_row(row: dict[str, str]) -> None: + """Validate single cipher suite record. + + Args: + row: Dictionary with column names as keys + + Raises: + ValidationError: If data is invalid + + """ + required_fields = ["value", "description"] + + for field in required_fields: + if field not in row or not row[field]: + raise ValidationError(f"Missing required field: {field}") + + value = row["value"] + if not value.startswith("0x"): + raise ValidationError(f"Invalid value format: {value}") + + rec = row.get("recommended", "") + if rec and rec not in ["Y", "N", "D"]: + raise ValidationError(f"Invalid Recommended value: {rec}") + + +def validate_supported_groups_row(row: dict[str, str]) -> None: + """Validate single supported groups record. + + Args: + row: Dictionary with column names as keys + + Raises: + ValidationError: If data is invalid + + """ + required_fields = ["value", "description"] + + for field in required_fields: + if field not in row or not row[field]: + raise ValidationError(f"Missing required field: {field}") + + try: + int(row["value"]) + except ValueError as e: + raise ValidationError(f"Value must be numeric: {row['value']}") from e + + rec = row.get("recommended", "") + if rec and rec not in ["Y", "N", "D"]: + raise ValidationError(f"Invalid Recommended value: {rec}") + + +def validate_signature_schemes_row(row: dict[str, str]) -> None: + """Validate single signature schemes record. + + Args: + row: Dictionary with column names as keys + + Raises: + ValidationError: If data is invalid + + """ + required_fields = ["value", "description"] + + for field in required_fields: + if field not in row or not row[field]: + raise ValidationError(f"Missing required field: {field}") + + value = row["value"] + if not value.startswith("0x"): + raise ValidationError(f"Invalid value format: {value}") + + +def validate_ikev2_row(row: dict[str, str]) -> None: + """Validate IKEv2 record (encryption, DH groups, auth methods). + + Args: + row: Dictionary with column names as keys + + Raises: + ValidationError: If data is invalid + + """ + required_fields = ["value", "description"] + + for field in required_fields: + if field not in row or not row[field]: + raise ValidationError(f"Missing required field: {field}") + + try: + int(row["value"]) + except ValueError as e: + raise ValidationError(f"Value must be numeric: {row['value']}") from e + + +VALIDATORS = { + "iana_tls_cipher_suites": validate_cipher_suite_row, + "iana_tls_supported_groups": validate_supported_groups_row, + "iana_tls_signature_schemes": validate_signature_schemes_row, + "iana_ikev2_encryption_algorithms": validate_ikev2_row, + "iana_ikev2_dh_groups": validate_ikev2_row, + "iana_ikev2_authentication_methods": validate_ikev2_row, + "iana_ikev2_prf_algorithms": validate_ikev2_row, + "iana_ikev2_integrity_algorithms": validate_ikev2_row, +} + +MIN_ROWS = { + "iana_tls_cipher_suites": 50, + "iana_tls_signature_schemes": 10, + "iana_tls_supported_groups": 10, + "iana_tls_alerts": 10, + "iana_tls_content_types": 5, + "iana_ikev2_encryption_algorithms": 10, + "iana_ikev2_prf_algorithms": 5, + "iana_ikev2_integrity_algorithms": 5, + "iana_ikev2_dh_groups": 10, + "iana_ikev2_authentication_methods": 5, +} + + +def get_min_rows(table_name: str) -> int: + """Get minimum expected rows for table. + + Args: + table_name: Database table name + + Returns: + Minimum number of rows expected + + """ + return MIN_ROWS.get(table_name, 5) + + +def validate_registry_data( + table_name: str, + rows: list[dict[str, str]], + skip_min_rows_check: bool = False, +) -> None: + """Validate complete registry data before DB write. + + Args: + table_name: Database table name + rows: List of row dictionaries + skip_min_rows_check: Skip minimum rows validation (for tests) + + Raises: + ValidationError: If validation fails + + """ + if not skip_min_rows_check: + min_rows = get_min_rows(table_name) + + if len(rows) < min_rows: + raise ValidationError( + f"Insufficient data for {table_name}: " + f"{len(rows)} rows (expected >= {min_rows})" + ) + + validator = VALIDATORS.get(table_name) + if not validator: + return + + for i, row in enumerate(rows, 1): + try: + validator(row) + except ValidationError as e: + raise ValidationError(f"Row {i} in {table_name}: {e}") from e diff --git a/src/sslysze_scan/output.py b/src/sslysze_scan/output.py index 16a3c42..eb79dbe 100644 --- a/src/sslysze_scan/output.py +++ b/src/sslysze_scan/output.py @@ -164,9 +164,7 @@ def _print_vulnerabilities(scan_result: ServerScanResult) -> None: heartbleed_result = heartbleed_attempt.result if heartbleed_result: status = ( - "VERWUNDBAR ⚠️" - if heartbleed_result.is_vulnerable_to_heartbleed - else "OK ✓" + "VERWUNDBAR" if heartbleed_result.is_vulnerable_to_heartbleed else "OK" ) print(f" • Heartbleed: {status}") @@ -182,7 +180,7 @@ def _print_vulnerabilities(scan_result: ServerScanResult) -> None: ) elif hasattr(robot_result, "robot_result"): vulnerable = str(robot_result.robot_result) != "NOT_VULNERABLE_NO_ORACLE" - status = "VERWUNDBAR ⚠️" if vulnerable else "OK ✓" + status = "VERWUNDBAR" if vulnerable else "OK" print(f" • ROBOT: {status}") # OpenSSL CCS Injection @@ -190,9 +188,7 @@ def _print_vulnerabilities(scan_result: ServerScanResult) -> None: if ccs_attempt.status == ScanCommandAttemptStatusEnum.COMPLETED: ccs_result = ccs_attempt.result if ccs_result: - status = ( - "VERWUNDBAR ⚠️" if ccs_result.is_vulnerable_to_ccs_injection else "OK ✓" - ) + status = "VERWUNDBAR" if ccs_result.is_vulnerable_to_ccs_injection else "OK" print(f" • OpenSSL CCS Injection: {status}") diff --git a/src/sslysze_scan/reporter/csv_export.py b/src/sslysze_scan/reporter/csv_export.py index 17c825d..42307b1 100644 --- a/src/sslysze_scan/reporter/csv_export.py +++ b/src/sslysze_scan/reporter/csv_export.py @@ -1,95 +1,26 @@ """CSV report generation with granular file structure for reST integration.""" -import csv -import json -import sqlite3 from pathlib import Path from typing import Any -from .query import get_scan_data - - -def _get_headers(db_path: str, export_type: str) -> list[str]: - """Get CSV headers from database. - - Args: - db_path: Path to database file - export_type: Type of export (e.g. 'cipher_suites_accepted') - - Returns: - List of column headers - - """ - conn = sqlite3.connect(db_path) - cursor = conn.cursor() - cursor.execute( - "SELECT headers FROM csv_export_metadata WHERE export_type = ?", - (export_type,), - ) - row = cursor.fetchone() - conn.close() - - if row: - return json.loads(row[0]) - raise ValueError(f"No headers found for export_type: {export_type}") - - -def _format_bool( - value: bool | None, - true_val: str = "Yes", - false_val: str = "No", - none_val: str = "-", -) -> str: - """Format boolean value to string representation. - - Args: - value: Boolean value to format - true_val: String representation for True - false_val: String representation for False - none_val: String representation for None - - Returns: - Formatted string - - """ - if value is True: - return true_val - if value is False: - return false_val - return none_val - - -def _write_csv(filepath: Path, headers: list[str], rows: list[list[Any]]) -> None: - """Write data to CSV file. - - Args: - filepath: Path to CSV file - headers: List of column headers - rows: List of data rows - - """ - with filepath.open("w", newline="", encoding="utf-8") as f: - writer = csv.writer(f) - writer.writerow(headers) - writer.writerows(rows) +from .csv_utils import CSVExporter, format_bool +from .query import get_scan_data, has_tls_support def _export_summary( - output_dir: Path, + exporter: CSVExporter, summary: dict[str, Any], - db_path: str, ) -> list[str]: """Export summary statistics to CSV. Args: - output_dir: Output directory path + exporter: CSVExporter instance summary: Summary data dictionary Returns: List of generated file paths """ - summary_file = output_dir / "summary.csv" rows = [ ["Scanned Ports", summary.get("total_ports", 0)], ["Ports with TLS Support", summary.get("successful_ports", 0)], @@ -114,21 +45,19 @@ def _export_summary( summary.get("critical_vulnerabilities", 0), ], ] - headers = _get_headers(db_path, "summary") - _write_csv(summary_file, headers, rows) - return [str(summary_file)] + filepath = exporter.write_csv("summary.csv", "summary", rows) + return [filepath] def _export_cipher_suites( - output_dir: Path, + exporter: CSVExporter, port: int, cipher_suites: dict[str, dict[str, list]], - db_path: str, ) -> list[str]: """Export cipher suites to CSV files. Args: - output_dir: Output directory path + exporter: CSVExporter instance port: Port number cipher_suites: Cipher suites data per TLS version @@ -140,49 +69,46 @@ def _export_cipher_suites( for tls_version, suites in cipher_suites.items(): if suites.get("accepted"): - filepath = output_dir / f"{port}_cipher_suites_{tls_version}_accepted.csv" rows = [ [ suite["name"], suite.get("iana_recommended", "-"), - _format_bool(suite.get("bsi_approved")), + format_bool(suite.get("bsi_approved")), suite.get("bsi_valid_until", "-"), - _format_bool(suite.get("compliant")), + format_bool(suite.get("compliant")), ] for suite in suites["accepted"] ] - headers = _get_headers(db_path, "cipher_suites_accepted") - _write_csv(filepath, headers, rows) - generated.append(str(filepath)) + filename = f"{port}_cipher_suites_{tls_version}_accepted.csv" + filepath = exporter.write_csv(filename, "cipher_suites_accepted", rows) + generated.append(filepath) if suites.get("rejected"): - filepath = output_dir / f"{port}_cipher_suites_{tls_version}_rejected.csv" rows = [ [ suite["name"], suite.get("iana_recommended", "-"), - _format_bool(suite.get("bsi_approved")), + format_bool(suite.get("bsi_approved")), suite.get("bsi_valid_until", "-"), ] for suite in suites["rejected"] ] - headers = _get_headers(db_path, "cipher_suites_rejected") - _write_csv(filepath, headers, rows) - generated.append(str(filepath)) + filename = f"{port}_cipher_suites_{tls_version}_rejected.csv" + filepath = exporter.write_csv(filename, "cipher_suites_rejected", rows) + generated.append(filepath) return generated def _export_supported_groups( - output_dir: Path, + exporter: CSVExporter, port: int, groups: list[dict[str, Any]], - db_path: str, ) -> list[str]: """Export supported groups to CSV. Args: - output_dir: Output directory path + exporter: CSVExporter instance port: Port number groups: List of supported groups @@ -190,32 +116,30 @@ def _export_supported_groups( List of generated file paths """ - filepath = output_dir / f"{port}_supported_groups.csv" rows = [ [ group["name"], group.get("iana_recommended", "-"), - _format_bool(group.get("bsi_approved")), + format_bool(group.get("bsi_approved")), group.get("bsi_valid_until", "-"), - _format_bool(group.get("compliant")), + format_bool(group.get("compliant")), ] for group in groups ] - headers = _get_headers(db_path, "supported_groups") - _write_csv(filepath, headers, rows) - return [str(filepath)] + filename = f"{port}_supported_groups.csv" + filepath = exporter.write_csv(filename, "supported_groups", rows) + return [filepath] def _export_missing_groups( - output_dir: Path, + exporter: CSVExporter, port: int, missing: dict[str, list[dict[str, Any]]], - db_path: str, ) -> list[str]: """Export missing recommended groups to CSV. Args: - output_dir: Output directory path + exporter: CSVExporter instance port: Port number missing: Dictionary with bsi_approved and iana_recommended groups @@ -226,7 +150,6 @@ def _export_missing_groups( generated = [] if missing.get("bsi_approved"): - filepath = output_dir / f"{port}_missing_groups_bsi.csv" rows = [ [ group["name"], @@ -235,33 +158,31 @@ def _export_missing_groups( ] for group in missing["bsi_approved"] ] - headers = _get_headers(db_path, "missing_groups_bsi") - _write_csv(filepath, headers, rows) - generated.append(str(filepath)) + filename = f"{port}_missing_groups_bsi.csv" + filepath = exporter.write_csv(filename, "missing_groups_bsi", rows) + generated.append(filepath) if missing.get("iana_recommended"): - filepath = output_dir / f"{port}_missing_groups_iana.csv" rows = [ [group["name"], group.get("iana_value", "-")] for group in missing["iana_recommended"] ] - headers = _get_headers(db_path, "missing_groups_iana") - _write_csv(filepath, headers, rows) - generated.append(str(filepath)) + filename = f"{port}_missing_groups_iana.csv" + filepath = exporter.write_csv(filename, "missing_groups_iana", rows) + generated.append(filepath) return generated def _export_certificates( - output_dir: Path, + exporter: CSVExporter, port: int, certificates: list[dict[str, Any]], - db_path: str, ) -> list[str]: """Export certificates to CSV. Args: - output_dir: Output directory path + exporter: CSVExporter instance port: Port number certificates: List of certificate data @@ -269,7 +190,6 @@ def _export_certificates( List of generated file paths """ - filepath = output_dir / f"{port}_certificates.csv" rows = [ [ cert["position"], @@ -279,25 +199,24 @@ def _export_certificates( cert["not_after"], cert["key_type"], cert["key_bits"], - _format_bool(cert.get("compliant")), + format_bool(cert.get("compliant")), ] for cert in certificates ] - headers = _get_headers(db_path, "certificates") - _write_csv(filepath, headers, rows) - return [str(filepath)] + filename = f"{port}_certificates.csv" + filepath = exporter.write_csv(filename, "certificates", rows) + return [filepath] def _export_vulnerabilities( - output_dir: Path, + exporter: CSVExporter, port: int, vulnerabilities: list[dict[str, Any]], - db_path: str, ) -> list[str]: """Export vulnerabilities to CSV. Args: - output_dir: Output directory path + exporter: CSVExporter instance port: Port number vulnerabilities: List of vulnerability data @@ -305,30 +224,28 @@ def _export_vulnerabilities( List of generated file paths """ - filepath = output_dir / f"{port}_vulnerabilities.csv" rows = [ [ vuln["type"], - _format_bool(vuln["vulnerable"]), + format_bool(vuln["vulnerable"]), vuln.get("details", "-"), ] for vuln in vulnerabilities ] - headers = _get_headers(db_path, "vulnerabilities") - _write_csv(filepath, headers, rows) - return [str(filepath)] + filename = f"{port}_vulnerabilities.csv" + filepath = exporter.write_csv(filename, "vulnerabilities", rows) + return [filepath] def _export_protocol_features( - output_dir: Path, + exporter: CSVExporter, port: int, features: list[dict[str, Any]], - db_path: str, ) -> list[str]: """Export protocol features to CSV. Args: - output_dir: Output directory path + exporter: CSVExporter instance port: Port number features: List of protocol feature data @@ -336,30 +253,28 @@ def _export_protocol_features( List of generated file paths """ - filepath = output_dir / f"{port}_protocol_features.csv" rows = [ [ feature["name"], - _format_bool(feature["supported"]), + format_bool(feature["supported"]), feature.get("details", "-"), ] for feature in features ] - headers = _get_headers(db_path, "protocol_features") - _write_csv(filepath, headers, rows) - return [str(filepath)] + filename = f"{port}_protocol_features.csv" + filepath = exporter.write_csv(filename, "protocol_features", rows) + return [filepath] def _export_session_features( - output_dir: Path, + exporter: CSVExporter, port: int, features: list[dict[str, Any]], - db_path: str, ) -> list[str]: """Export session features to CSV. Args: - output_dir: Output directory path + exporter: CSVExporter instance port: Port number features: List of session feature data @@ -367,33 +282,31 @@ def _export_session_features( List of generated file paths """ - filepath = output_dir / f"{port}_session_features.csv" rows = [ [ feature["type"], - _format_bool(feature.get("client_initiated")), - _format_bool(feature.get("secure")), - _format_bool(feature.get("session_id_supported")), - _format_bool(feature.get("ticket_supported")), + format_bool(feature.get("client_initiated")), + format_bool(feature.get("secure")), + format_bool(feature.get("session_id_supported")), + format_bool(feature.get("ticket_supported")), feature.get("details", "-"), ] for feature in features ] - headers = _get_headers(db_path, "session_features") - _write_csv(filepath, headers, rows) - return [str(filepath)] + filename = f"{port}_session_features.csv" + filepath = exporter.write_csv(filename, "session_features", rows) + return [filepath] def _export_http_headers( - output_dir: Path, + exporter: CSVExporter, port: int, headers: list[dict[str, Any]], - db_path: str, ) -> list[str]: """Export HTTP headers to CSV. Args: - output_dir: Output directory path + exporter: CSVExporter instance port: Port number headers: List of HTTP header data @@ -401,30 +314,28 @@ def _export_http_headers( List of generated file paths """ - filepath = output_dir / f"{port}_http_headers.csv" rows = [ [ header["name"], - _format_bool(header["is_present"]), + format_bool(header["is_present"]), header.get("value", "-"), ] for header in headers ] - csv_headers = _get_headers(db_path, "http_headers") - _write_csv(filepath, csv_headers, rows) - return [str(filepath)] + filename = f"{port}_http_headers.csv" + filepath = exporter.write_csv(filename, "http_headers", rows) + return [filepath] def _export_compliance_status( - output_dir: Path, + exporter: CSVExporter, port: int, compliance: dict[str, Any], - db_path: str, ) -> list[str]: """Export compliance status to CSV. Args: - output_dir: Output directory path + exporter: CSVExporter instance port: Port number compliance: Compliance data dictionary @@ -432,7 +343,6 @@ def _export_compliance_status( List of generated file paths """ - filepath = output_dir / f"{port}_compliance_status.csv" rows = [] if "cipher_suites_checked" in compliance: @@ -456,32 +366,13 @@ def _export_compliance_status( ) if rows: - headers = _get_headers(db_path, "compliance_status") - _write_csv(filepath, headers, rows) - return [str(filepath)] + filename = f"{port}_compliance_status.csv" + filepath = exporter.write_csv(filename, "compliance_status", rows) + return [filepath] return [] -def _has_tls_support(port_data: dict[str, Any]) -> bool: - """Check if port has TLS support. - - Args: - port_data: Port data dictionary - - Returns: - True if port has TLS support - - """ - return bool( - port_data.get("cipher_suites") - or port_data.get("supported_groups") - or port_data.get("certificates") - or port_data.get("tls_version"), - ) - - -# Export handlers mapping: (data_key, handler_function) EXPORT_HANDLERS = ( ("cipher_suites", _export_cipher_suites), ("supported_groups", _export_supported_groups), @@ -515,22 +406,19 @@ def generate_csv_reports( output_dir_path = Path(output_dir) output_dir_path.mkdir(parents=True, exist_ok=True) + exporter = CSVExporter(db_path, output_dir_path) generated_files = [] - generated_files.extend( - _export_summary(output_dir_path, data.get("summary", {}), db_path), - ) + generated_files.extend(_export_summary(exporter, data.get("summary", {}))) for port_data in data["ports_data"].values(): - if not _has_tls_support(port_data): + if not has_tls_support(port_data): continue port = port_data["port"] for data_key, handler_func in EXPORT_HANDLERS: if port_data.get(data_key): - generated_files.extend( - handler_func(output_dir_path, port, port_data[data_key], db_path), - ) + generated_files.extend(handler_func(exporter, port, port_data[data_key])) return generated_files diff --git a/src/sslysze_scan/reporter/csv_utils.py b/src/sslysze_scan/reporter/csv_utils.py new file mode 100644 index 0000000..2eca2f2 --- /dev/null +++ b/src/sslysze_scan/reporter/csv_utils.py @@ -0,0 +1,102 @@ +"""Utilities for CSV export with header caching and path management.""" + +import csv +import json +import sqlite3 +from pathlib import Path +from typing import Any + + +class CSVExporter: + """CSV export helper with header caching and path management.""" + + def __init__(self, db_path: str, output_dir: Path): + """Initialize CSV exporter. + + Args: + db_path: Path to database file + output_dir: Output directory for CSV files + + """ + self.db_path = db_path + self.output_dir = output_dir + self._headers_cache: dict[str, list[str]] = {} + + def get_headers(self, export_type: str) -> list[str]: + """Get CSV headers from database with caching. + + Args: + export_type: Type of export (e.g. 'cipher_suites_accepted') + + Returns: + List of column headers + + """ + if export_type not in self._headers_cache: + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + cursor.execute( + "SELECT headers FROM csv_export_metadata WHERE export_type = ?", + (export_type,), + ) + row = cursor.fetchone() + conn.close() + + if row: + self._headers_cache[export_type] = json.loads(row[0]) + else: + raise ValueError(f"No headers found for export_type: {export_type}") + + return self._headers_cache[export_type] + + def write_csv( + self, + filename: str, + export_type: str, + rows: list[list[Any]], + ) -> str: + """Write data to CSV file with headers from metadata. + + Args: + filename: CSV filename + export_type: Type of export for header lookup + rows: List of data rows + + Returns: + String path to created file + + """ + filepath = self.output_dir / filename + headers = self.get_headers(export_type) + + with filepath.open("w", newline="", encoding="utf-8") as f: + writer = csv.writer(f) + writer.writerow(headers) + writer.writerows(rows) + + return str(filepath) + + +def format_bool( + value: bool | None, + true_val: str = "Yes", + false_val: str = "No", + none_val: str = "-", +) -> str: + """Format boolean value to string representation. + + Args: + value: Boolean value to format + true_val: String representation for True + false_val: String representation for False + none_val: String representation for None + + Returns: + Formatted string + + """ + if value is True: + return true_val + if value is False: + return false_val + return none_val diff --git a/src/sslysze_scan/reporter/query.py b/src/sslysze_scan/reporter/query.py index 9be2ca3..43c1dc4 100644 --- a/src/sslysze_scan/reporter/query.py +++ b/src/sslysze_scan/reporter/query.py @@ -7,6 +7,24 @@ from typing import Any COMPLIANCE_WARNING_THRESHOLD = 50.0 +def has_tls_support(port_data: dict[str, Any]) -> bool: + """Check if port has TLS support based on data presence. + + Args: + port_data: Port data dictionary + + Returns: + True if port has TLS support + + """ + return bool( + port_data.get("cipher_suites") + or port_data.get("supported_groups") + or port_data.get("certificates") + or port_data.get("tls_version") + ) + + def list_scans(db_path: str) -> list[dict[str, Any]]: """List all available scans in the database. diff --git a/src/sslysze_scan/reporter/template_utils.py b/src/sslysze_scan/reporter/template_utils.py index 1527326..3046388 100644 --- a/src/sslysze_scan/reporter/template_utils.py +++ b/src/sslysze_scan/reporter/template_utils.py @@ -1,11 +1,13 @@ """Shared utilities for report template rendering.""" -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path from typing import Any from jinja2 import Environment, FileSystemLoader, select_autoescape +from .query import has_tls_support + def format_tls_version(version: str) -> str: """Format TLS version string for display. @@ -59,7 +61,7 @@ def generate_report_id(metadata: dict[str, Any]) -> str: dt = datetime.fromisoformat(metadata["timestamp"]) date_str = dt.strftime("%Y%m%d") except (ValueError, KeyError): - date_str = datetime.now(timezone.utc).strftime("%Y%m%d") + date_str = datetime.now(UTC).strftime("%Y%m%d") return f"{date_str}_{metadata['scan_id']}" @@ -95,13 +97,7 @@ def build_template_context(data: dict[str, Any]) -> dict[str, Any]: # Filter ports with TLS support for port sections ports_with_tls = [] for port_data in data["ports_data"].values(): - has_tls = ( - port_data.get("cipher_suites") - or port_data.get("supported_groups") - or port_data.get("certificates") - or port_data.get("tls_version") - ) - if has_tls: + if has_tls_support(port_data): ports_with_tls.append(port_data) return { diff --git a/src/sslysze_scan/scanner.py b/src/sslysze_scan/scanner.py index d0f2d4c..c7d09f2 100644 --- a/src/sslysze_scan/scanner.py +++ b/src/sslysze_scan/scanner.py @@ -1,11 +1,9 @@ """Module for performing SSL/TLS scans with SSLyze.""" import logging -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import Any -logger = logging.getLogger(__name__) - from sslyze import ( ProtocolWithOpportunisticTlsEnum, Scanner, @@ -19,6 +17,8 @@ from sslyze import ( from .protocol_loader import get_protocol_for_port +logger = logging.getLogger(__name__) + def create_scan_request( hostname: str, @@ -194,7 +194,7 @@ def perform_scan( continue # Calculate scan duration - scan_end_time = datetime.now(timezone.utc) + scan_end_time = datetime.now(UTC) scan_duration = (scan_end_time - scan_start_time).total_seconds() # Return first result (we only scan one host) diff --git a/tests/fixtures/iana_xml/ikev2-parameters-minimal.xml b/tests/fixtures/iana_xml/ikev2-parameters-minimal.xml new file mode 100644 index 0000000..d619a92 --- /dev/null +++ b/tests/fixtures/iana_xml/ikev2-parameters-minimal.xml @@ -0,0 +1,69 @@ + + + Internet Key Exchange Version 2 (IKEv2) Parameters + 2005-01-18 + 2025-12-03 + + + Transform Type 1 - Encryption Algorithm Transform IDs + + 12 + ENCR_AES_CBC + Y + Y + + + + 20 + ENCR_AES_GCM_16 + Y + Y + + + + 28 + ENCR_CHACHA20_POLY1305 + Y + Y + + + + + + Transform Type 4 - Diffie-Hellman Group Transform IDs + + 14 + 2048-bit MODP Group + RECOMMENDED + + + + 19 + 256-bit random ECP group + RECOMMENDED + + + + 31 + Curve25519 + RECOMMENDED + + + + + + IKEv2 Authentication Method + + 1 + RSA Digital Signature + DEPRECATED + + + + 14 + Digital Signature + RECOMMENDED + + + + diff --git a/tests/fixtures/iana_xml/tls-parameters-minimal.xml b/tests/fixtures/iana_xml/tls-parameters-minimal.xml new file mode 100644 index 0000000..17da16e --- /dev/null +++ b/tests/fixtures/iana_xml/tls-parameters-minimal.xml @@ -0,0 +1,96 @@ + + + Transport Layer Security (TLS) Parameters + Transport Layer Security (TLS) + 2005-08-23 + 2025-12-03 + + + TLS Cipher Suites + + 0x13,0x01 + TLS_AES_128_GCM_SHA256 + Y + Y + + + + 0x13,0x02 + TLS_AES_256_GCM_SHA384 + Y + Y + + + + 0x00,0x9C + TLS_RSA_WITH_AES_128_GCM_SHA256 + Y + N + + + + 0x00,0x2F + TLS_RSA_WITH_AES_128_CBC_SHA + Y + N + + + + 0x00,0x0A + TLS_RSA_WITH_3DES_EDE_CBC_SHA + Y + N + + + + + + TLS Supported Groups + + 23 + secp256r1 + Y + Y + + + + 24 + secp384r1 + Y + Y + + + + 29 + x25519 + Y + Y + + + + + + TLS SignatureScheme + + 0x0403 + ecdsa_secp256r1_sha256 + Y + Y + + + + 0x0804 + rsa_pss_rsae_sha256 + Y + Y + + + + 0x0401 + rsa_pkcs1_sha256 + Y + N + + + + diff --git a/tests/test_compliance.py b/tests/test_compliance.py deleted file mode 100644 index 251855b..0000000 --- a/tests/test_compliance.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Tests for compliance checking functionality.""" - -from datetime import datetime - - -class TestComplianceChecks: - """Tests for compliance validation logic.""" - - def test_check_bsi_validity(self) -> None: - """Test BSI cipher suite validity checking.""" - # Valid BSI-approved cipher suite (not expired) - cipher_suite_valid = { - "name": "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", - "iana_recommended": "N", - "bsi_approved": True, - "bsi_valid_until": "2029", - } - # Check that current year is before 2029 - current_year = datetime.now().year - assert current_year < 2029, "Test assumes current year < 2029" - # BSI-approved and valid should be compliant - assert cipher_suite_valid["bsi_approved"] is True - assert int(cipher_suite_valid["bsi_valid_until"]) > current_year - - # Expired BSI-approved cipher suite - cipher_suite_expired = { - "name": "TLS_OLD_CIPHER", - "iana_recommended": "N", - "bsi_approved": True, - "bsi_valid_until": "2020", - } - # BSI-approved but expired should not be compliant - assert cipher_suite_expired["bsi_approved"] is True - assert int(cipher_suite_expired["bsi_valid_until"]) < current_year - - # No BSI data - cipher_suite_no_bsi = { - "name": "TLS_CHACHA20_POLY1305_SHA256", - "iana_recommended": "Y", - "bsi_approved": False, - "bsi_valid_until": None, - } - # Without BSI approval, compliance depends on IANA - assert cipher_suite_no_bsi["bsi_approved"] is False - - def test_check_iana_recommendation(self) -> None: - """Test IANA recommendation checking.""" - # IANA recommended cipher suite - cipher_suite_recommended = { - "name": "TLS_AES_256_GCM_SHA384", - "iana_recommended": "Y", - "bsi_approved": True, - "bsi_valid_until": "2031", - } - assert cipher_suite_recommended["iana_recommended"] == "Y" - - # IANA not recommended cipher suite - cipher_suite_not_recommended = { - "name": "TLS_RSA_WITH_AES_128_CBC_SHA", - "iana_recommended": "N", - "bsi_approved": False, - "bsi_valid_until": None, - } - assert cipher_suite_not_recommended["iana_recommended"] == "N" - - # No IANA data (should default to non-compliant) - cipher_suite_no_iana = { - "name": "TLS_UNKNOWN_CIPHER", - "iana_recommended": None, - "bsi_approved": False, - "bsi_valid_until": None, - } - assert cipher_suite_no_iana["iana_recommended"] is None diff --git a/tests/test_iana_parse.py b/tests/test_iana_parse.py new file mode 100644 index 0000000..f05a1ba --- /dev/null +++ b/tests/test_iana_parse.py @@ -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 .* nicht gefunden"): + 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 = """ + + 0x13,0x01 + Test + + """ + 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 "" 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 = """ + + 42-255 + Unassigned + + """ + 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 = """ + + 0x0000-0x0200 + Reserved for backward compatibility + + """ + 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 diff --git a/tests/test_iana_update.py b/tests/test_iana_update.py new file mode 100644 index 0000000..f53c0f4 --- /dev/null +++ b/tests/test_iana_update.py @@ -0,0 +1,285 @@ +"""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 .* nicht gefunden"): + 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"] diff --git a/tests/test_iana_validator.py b/tests/test_iana_validator.py new file mode 100644 index 0000000..8bf6d58 --- /dev/null +++ b/tests/test_iana_validator.py @@ -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) diff --git a/tests/test_template_utils.py b/tests/test_template_utils.py index 52ea641..56e365b 100644 --- a/tests/test_template_utils.py +++ b/tests/test_template_utils.py @@ -5,21 +5,10 @@ from typing import Any from sslysze_scan.reporter.template_utils import ( build_template_context, - format_tls_version, generate_report_id, ) -class TestFormatTlsVersion: - """Tests for format_tls_version function.""" - - def test_format_tls_version_all_versions(self) -> None: - """Test formatting all known TLS versions.""" - versions = ["1.0", "1.1", "1.2", "1.3", "ssl_3.0", "unknown"] - expected = ["TLS 1.0", "TLS 1.1", "TLS 1.2", "TLS 1.3", "SSL 3.0", "unknown"] - assert [format_tls_version(v) for v in versions] == expected - - class TestGenerateReportId: """Tests for generate_report_id function."""