From 753c582010fb7eb3c90bc4704e53c999f8028f27 Mon Sep 17 00:00:00 2001 From: Heiko Date: Fri, 19 Dec 2025 20:10:39 +0100 Subject: [PATCH 1/3] 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.""" -- 2.49.1 From 0b09de7d1ebc085ec1ecee65abb3fb5757360153 Mon Sep 17 00:00:00 2001 From: Heiko Date: Fri, 19 Dec 2025 20:22:35 +0100 Subject: [PATCH 2/3] fix: remove duplicate IANA registry entry causing false update reports --- compliance_status.db | Bin 0 -> 606208 bytes docs/detailed-guide.md | 4 +-- src/sslysze_scan/data/iana_parse.json | 5 ---- tests/test_iana_update.py | 39 ++++++++++++++++++++++++++ 4 files changed, 41 insertions(+), 7 deletions(-) create mode 100644 compliance_status.db diff --git a/compliance_status.db b/compliance_status.db new file mode 100644 index 0000000000000000000000000000000000000000..2b4e47a66ebdfa179bd8509ee653bf967520a355 GIT binary patch literal 606208 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 literal 0 HcmV?d00001 diff --git a/docs/detailed-guide.md b/docs/detailed-guide.md index 4b932fd..74fac1f 100644 --- a/docs/detailed-guide.md +++ b/docs/detailed-guide.md @@ -529,9 +529,9 @@ poetry run pytest tests/ -v - `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 +- `tests/test_iana_update.py`: 14 IANA update tests -**Total:** 63 tests +**Total:** 64 tests **Test database setup:** diff --git a/src/sslysze_scan/data/iana_parse.json b/src/sslysze_scan/data/iana_parse.json index f98d7ca..479cf55 100644 --- a/src/sslysze_scan/data/iana_parse.json +++ b/src/sslysze_scan/data/iana_parse.json @@ -24,11 +24,6 @@ "tls-parameters-5", "tls_content_types.csv", ["Value", "Description", "DTLS", "Recommended", "RFC/Draft"] - ], - [ - "tls-parameters-7", - "tls_content_types.csv", - ["Value", "Description", "DTLS", "Recommended", "RFC/Draft"] ] ], "https://www.iana.org/assignments/ikev2-parameters/ikev2-parameters.xml": [ diff --git a/tests/test_iana_update.py b/tests/test_iana_update.py index f53c0f4..02a21e2 100644 --- a/tests/test_iana_update.py +++ b/tests/test_iana_update.py @@ -283,3 +283,42 @@ class TestDiffCalculationEdgeCases: 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() -- 2.49.1 From 99beb40ee04ab89ecdb70928ff536858e92ab47d Mon Sep 17 00:00:00 2001 From: Heiko Date: Fri, 19 Dec 2025 20:24:23 +0100 Subject: [PATCH 3/3] chore: bump version to 1.0.1 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 14f0023..2fde0a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "compliance-scan" -version = "1.0.0" +version = "1.0.1" description = "" authors = [ {name = "Heiko Haase",email = "heiko.haase.extern@univention.de"} -- 2.49.1