From f038d6a3fca92b9f74ba72fdd70c504f5450e3a5 Mon Sep 17 00:00:00 2001 From: Heiko Date: Thu, 18 Dec 2025 19:16:04 +0100 Subject: [PATCH] feat: initial release --- .gitignore | 45 + README.md | 63 ++ docs/detailed-guide.md | 454 ++++++++++ docs/schema.sql | 506 +++++++++++ pyproject.toml | 45 + src/sslysze_scan/__init__.py | 15 + src/sslysze_scan/__main__.py | 29 + src/sslysze_scan/cli.py | 268 ++++++ src/sslysze_scan/commands/__init__.py | 6 + src/sslysze_scan/commands/report.py | 102 +++ src/sslysze_scan/commands/scan.py | 188 ++++ src/sslysze_scan/data/crypto_standards.db | Bin 0 -> 552960 bytes src/sslysze_scan/data/iana_parse.json | 61 ++ src/sslysze_scan/data/protocols.csv | 11 + src/sslysze_scan/db/__init__.py | 12 + src/sslysze_scan/db/compliance.py | 463 ++++++++++ src/sslysze_scan/db/schema.py | 71 ++ src/sslysze_scan/db/writer.py | 893 +++++++++++++++++++ src/sslysze_scan/output.py | 216 +++++ src/sslysze_scan/protocol_loader.py | 75 ++ src/sslysze_scan/reporter/__init__.py | 50 ++ src/sslysze_scan/reporter/csv_export.py | 536 +++++++++++ src/sslysze_scan/reporter/markdown_export.py | 37 + src/sslysze_scan/reporter/query.py | 534 +++++++++++ src/sslysze_scan/reporter/rst_export.py | 39 + src/sslysze_scan/reporter/template_utils.py | 168 ++++ src/sslysze_scan/scan_iana.py | 401 +++++++++ src/sslysze_scan/scanner.py | 203 +++++ src/sslysze_scan/templates/report.md.j2 | 181 ++++ src/sslysze_scan/templates/report.reST.j2 | 219 +++++ tests/__init__.py | 0 tests/conftest.py | 410 +++++++++ tests/fixtures/__init__.py | 1 + tests/fixtures/test_scan.db | Bin 0 -> 249856 bytes tests/test_cli.py | 26 + tests/test_compliance.py | 73 ++ tests/test_csv_export.py | 297 ++++++ tests/test_template_utils.py | 67 ++ 38 files changed, 6765 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 docs/detailed-guide.md create mode 100644 docs/schema.sql create mode 100644 pyproject.toml create mode 100644 src/sslysze_scan/__init__.py create mode 100644 src/sslysze_scan/__main__.py create mode 100644 src/sslysze_scan/cli.py create mode 100644 src/sslysze_scan/commands/__init__.py create mode 100644 src/sslysze_scan/commands/report.py create mode 100644 src/sslysze_scan/commands/scan.py create mode 100644 src/sslysze_scan/data/crypto_standards.db create mode 100644 src/sslysze_scan/data/iana_parse.json create mode 100644 src/sslysze_scan/data/protocols.csv create mode 100644 src/sslysze_scan/db/__init__.py create mode 100644 src/sslysze_scan/db/compliance.py create mode 100644 src/sslysze_scan/db/schema.py create mode 100644 src/sslysze_scan/db/writer.py create mode 100644 src/sslysze_scan/output.py create mode 100644 src/sslysze_scan/protocol_loader.py create mode 100644 src/sslysze_scan/reporter/__init__.py create mode 100644 src/sslysze_scan/reporter/csv_export.py create mode 100644 src/sslysze_scan/reporter/markdown_export.py create mode 100644 src/sslysze_scan/reporter/query.py create mode 100644 src/sslysze_scan/reporter/rst_export.py create mode 100644 src/sslysze_scan/reporter/template_utils.py create mode 100644 src/sslysze_scan/scan_iana.py create mode 100644 src/sslysze_scan/scanner.py create mode 100644 src/sslysze_scan/templates/report.md.j2 create mode 100644 src/sslysze_scan/templates/report.reST.j2 create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/fixtures/__init__.py create mode 100644 tests/fixtures/test_scan.db create mode 100644 tests/test_cli.py create mode 100644 tests/test_compliance.py create mode 100644 tests/test_csv_export.py create mode 100644 tests/test_template_utils.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f50c2f3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +proto/ +# Virtual environments +.venv/ + +# Poetry dependencies +poetry.lock + +# IDE / Editor files +*.swp +*.swo +*.pyc +__pycache__/ + +# OS files +.DS_Store +Thumbs.db + +# Build artifacts +dist/ +build/ +*.egg-info/ + +# Logs and databases +logs/ +*.log + +# Environment variables (optional, wenn nicht in .env.example) +.env.local +.env.*.local + +# Coverage reports +htmlcov/ +.tox/ +.cache/ +nosetests.xml +coverage.xml + + +# PyInstaller +build/ +dist/ + + +# pytest cache +.pytest_cache/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..fe6aae1 --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +# compliance-scan + +SSL/TLS configuration analysis with automated IANA/BSI compliance checking. + +## Quick Start + +```bash +# Scan +poetry run compliance-scan scan example.com:443,636 + +# Report +poetry run compliance-scan report -t md -o report.md +``` + +## Installation + +```bash +poetry install +``` + +## Features + +- Multi-port TLS/SSL scanning +- BSI TR-02102-1/2 compliance validation +- IANA recommendations checking +- Vulnerability detection (Heartbleed, ROBOT, CCS Injection) +- Certificate validation +- Multiple report formats (CSV, Markdown, reStructuredText) + +## Commands + +```bash +# Scan with ports +compliance-scan scan :, [--print] [-db ] + +# Generate report +compliance-scan report [scan_id] -t [-o ] + +# List scans +compliance-scan report --list +``` + +## Supported Protocols + +Opportunistic TLS: SMTP, LDAP, IMAP, POP3, FTP, XMPP, RDP, PostgreSQL +Direct TLS: HTTPS, LDAPS, SMTPS, IMAPS, POP3S + +## Documentation + +**[Detailed Guide](docs/detailed-guide.md)** - Complete reference with CLI commands, database schema, compliance rules, and development guide. + +## Requirements + +- Python 3.13+ +- SSLyze 6.0.0+ +- Poetry + +## Planned Features + +- 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 diff --git a/docs/detailed-guide.md b/docs/detailed-guide.md new file mode 100644 index 0000000..dcdeadf --- /dev/null +++ b/docs/detailed-guide.md @@ -0,0 +1,454 @@ +# compliance-scan - Detailed Guide + +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 | + +## Installation + +```bash +poetry install +``` + +## Quick Reference + +```bash +# Scan server on multiple ports +poetry run compliance-scan scan example.com:443,636 + +# Generate Markdown report +poetry run compliance-scan report -t md -o report.md + +# Generate CSV reports +poetry run compliance-scan report -t csv --output-dir ./reports + +# List all scans +poetry run compliance-scan report --list +``` + +## CLI Commands + +### Scan Command + +``` +compliance-scan scan :, [options] +``` + +| Argument | Required | Description | +| -------------------- | -------- | ---------------------------------------------------------------- | +| `:` | Yes | Target with comma-separated ports. IPv6: `[2001:db8::1]:443,636` | +| `--print` | No | Display summary in console | +| `-db ` | No | Database file path (default: compliance_status.db) | + +Examples: + +```bash +compliance-scan scan example.com:443,636 --print +compliance-scan scan [2001:db8::1]:443,636 -db custom.db +``` + +### Report Command + +``` +compliance-scan report [scan_id] -t [options] +``` + +| Argument | Required | Description | +| -------------------- | -------- | ------------------------------ | +| `scan_id` | No | Scan ID (default: latest scan) | +| `-t ` | Yes | Report type: csv, md, rest | +| `-o ` | No | Output file (md/rest only) | +| `--output-dir ` | No | Output directory | +| `--list` | No | List all available scans | + +Examples: + +```bash +compliance-scan report -t md -o report.md +compliance-scan report 5 -t csv --output-dir ./reports +compliance-scan report -t rest --output-dir ./docs +``` + +## Report Formats + +### CSV + +Generates granular files per port and category. + +| File Pattern | Content | +| --------------------------------------------- | -------------------------------------- | +| `summary.csv` | Scan statistics and compliance summary | +| `_cipher_suites__accepted.csv` | Accepted cipher suites per TLS version | +| `_cipher_suites__rejected.csv` | Rejected cipher suites per TLS version | +| `_supported_groups.csv` | Elliptic curves and DH groups | +| `_missing_groups_bsi.csv` | BSI-approved groups not offered | +| `_missing_groups_iana.csv` | IANA-recommended groups not offered | +| `_certificates.csv` | Certificate chain with compliance | +| `_vulnerabilities.csv` | Vulnerability scan results | +| `_protocol_features.csv` | TLS protocol features | +| `_session_features.csv` | Session handling features | +| `_http_headers.csv` | HTTP security headers | +| `_compliance_status.csv` | Aggregated compliance per check type | + +Behavior: Ports without TLS support generate no files. Empty sections are omitted. + +### Markdown + +Single comprehensive report with: + +1. Metadata: Scan ID, hostname, IPs, timestamp, duration, ports +2. Summary: Statistics table +3. Per-port sections (TLS-enabled ports only): + - TLS configuration + - Cipher suites (accepted/rejected by version) + - Supported groups with compliance + - Missing groups (collapsible details) + - Certificates with key size and compliance + - Vulnerabilities + - Protocol features + - Session features + - HTTP security headers + +### reStructuredText + +Identical structure to Markdown but uses `.. csv-table::` directives for Sphinx integration. + +Use case: Generate documentation that references CSV files for tabular data. + +## Database Structure + +File: `compliance_status.db` (SQLite, Schema Version 5) + +Template: `src/sslysze_scan/data/crypto_standards.db` + +Full schema: [schema.sql](schema.sql) + +### Scan Result Tables + +| Table | Content | +| ------------------------ | ------------------------------------------------------------ | +| `scans` | Scan metadata: scan_id, hostname, ports, timestamp, duration | +| `scanned_hosts` | Resolved FQDN with IPv4/IPv6 addresses | +| `scan_cipher_suites` | Cipher suites per port and TLS version (accepted/rejected) | +| `scan_supported_groups` | Elliptic curves and DH groups per port | +| `scan_certificates` | Certificate chain with key type, size, validity | +| `scan_vulnerabilities` | Vulnerability test results per port | +| `scan_protocol_features` | TLS protocol features (compression, early data, etc.) | +| `scan_session_features` | Session renegotiation and resumption | +| `scan_http_headers` | HTTP security headers per port | +| `scan_compliance_status` | Compliance evaluation per item and port | + +### Database Views (Schema v5) + +Six optimized views eliminate complex JOINs and improve query performance: + +| View | Purpose | +| ------------------------------------ | --------------------------------------------- | +| `v_cipher_suites_with_compliance` | Cipher suites with BSI/IANA compliance flags | +| `v_supported_groups_with_compliance` | Groups with compliance status | +| `v_certificates_with_compliance` | Certificates with key size compliance | +| `v_port_compliance_summary` | Aggregated compliance statistics per port | +| `v_missing_bsi_groups` | BSI-approved groups not offered by server | +| `v_missing_iana_groups` | IANA-recommended groups not offered by server | + +### Reference Data Tables + +IANA TLS: + +- `iana_tls_cipher_suites`: Cipher suite registry with recommendations +- `iana_tls_signature_schemes`: Signature algorithm registry +- `iana_tls_supported_groups`: Named groups registry + +BSI TR-02102-1 (Certificates): + +- `bsi_tr_02102_1_key_requirements`: Key length requirements +- `bsi_tr_02102_1_hash_requirements`: Hash algorithm requirements + +BSI TR-02102-2 (TLS): + +- `bsi_tr_02102_2_tls`: TLS cipher suites and groups with validity periods + +BSI TR-02102-3 (IPsec/IKEv2): + +- Encryption, integrity, DH groups + +BSI TR-02102-4 (SSH): + +- Key exchange, encryption, MAC + +CSV Export Metadata: + +- `csv_export_metadata`: Stores CSV headers as JSON for all export types + +## Compliance Validation + +### BSI TR-02102-1 (Certificates) + +Key length requirements: + +| Algorithm | Minimum Bits | Status | +| --------- | ------------ | ----------------------------- | +| RSA | 3000 | Required | +| ECDSA | 250 | Required | +| DSA | 3072 | Deprecated (valid until 2029) | + +Hash algorithms: + +- Allowed: SHA-256, SHA-384, SHA-512 +- Deprecated: SHA-1, MD5 + +### BSI TR-02102-2 (TLS) + +Validates: + +- Cipher suites against BSI-approved lists +- Supported groups against BSI requirements +- Validity periods (time-based expiration) + +### IANA + +Validates: + +- Cipher suite recommendations (Y/N/D flags) +- Supported group recommendations (Y/N/D flags) + +## Project Structure + +``` +src/sslysze_scan/ +├── __main__.py # CLI entry point +├── cli.py # Argument parsing +├── scanner.py # SSLyze integration +├── protocol_loader.py # Port-protocol mapping +├── output.py # Console output +├── commands/ +│ ├── scan.py # Scan command handler +│ └── report.py # Report command handler +├── db/ +│ ├── schema.py # Schema version management +│ ├── writer.py # Scan result storage +│ ├── compliance.py # Compliance validation +│ └── writers/ # Specialized writers +├── reporter/ +│ ├── query.py # Database queries (uses views) +│ ├── csv_export.py # CSV generation +│ ├── markdown_export.py # Markdown generation +│ ├── rst_export.py # reST generation +│ └── template_utils.py # Shared utilities +├── templates/ +│ ├── report.md.j2 # Markdown template +│ └── report.reST.j2 # reST template +└── data/ + ├── crypto_standards.db # Template DB (IANA/BSI + schema) + └── protocols.csv # Port-protocol mapping +``` + +## Key Functions + +### CLI and Parsing + +| Function | Module | Purpose | +| -------------------------- | -------- | ----------------------------------- | +| `parse_host_ports(target)` | `cli.py` | Parse `hostname:port1,port2` format | +| `parse_arguments()` | `cli.py` | Parse CLI arguments | + +### Scanning + +| Function | Module | Purpose | +| ------------------------------------------------------------ | ------------ | -------------------------------- | +| `perform_scan(hostname, port, start_time)` | `scanner.py` | Execute SSLyze scan for one port | +| `create_scan_request(hostname, port, use_opportunistic_tls)` | `scanner.py` | Create SSLyze scan request | + +### Database Writing + +| Function | Module | Purpose | +| ---------------------------------------------------------------------------- | ------------------ | --------------------------------------- | +| `save_scan_results(db_path, hostname, ports, results, start_time, duration)` | `db/writer.py` | Store all scan results, returns scan_id | +| `check_compliance(db_path, scan_id)` | `db/compliance.py` | Validate compliance, returns statistics | +| `check_schema_version(db_path)` | `db/schema.py` | Verify schema compatibility | +| `get_schema_version(db_path)` | `db/schema.py` | Get current schema version | + +### Database Querying + +| Function | Module | Purpose | +| ------------------------------------- | ------------------- | ---------------------------------- | +| `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 | + +### Report Generation + +| Function | Module | Purpose | +| ------------------------------------------------------------ | ----------------------------- | ------------------------------------ | +| `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) | + +## SQL Query Examples + +All queries use optimized views for performance. + +### Cipher Suites with Compliance + +```sql +SELECT cipher_suite_name, iana_recommended_final, bsi_approved_final, compliant +FROM v_cipher_suites_with_compliance +WHERE scan_id = ? AND port = ? AND accepted = 1; +``` + +### Port Compliance Summary + +```sql +SELECT check_type, total, passed, percentage +FROM v_port_compliance_summary +WHERE scan_id = ? AND port = ?; +``` + +### Missing BSI Groups + +```sql +SELECT group_name, tls_version, valid_until +FROM v_missing_bsi_groups +WHERE scan_id = ?; +``` + +### Non-Compliant Certificates + +```sql +SELECT port, key_type, key_bits, compliant, compliance_details +FROM v_certificates_with_compliance +WHERE scan_id = ? AND compliant = 0; +``` + +### Vulnerabilities + +```sql +SELECT port, vuln_type, vulnerable, details +FROM scan_vulnerabilities +WHERE scan_id = ? AND vulnerable = 1; +``` + +## Supported Protocols + +### Opportunistic TLS (STARTTLS) + +| Protocol | Ports | +| ---------- | ---------- | +| SMTP | 25, 587 | +| LDAP | 389 | +| IMAP | 143 | +| POP3 | 110 | +| FTP | 21 | +| XMPP | 5222, 5269 | +| RDP | 3389 | +| PostgreSQL | 5432 | + +### Direct TLS + +| Protocol | Port | +| -------- | ---- | +| HTTPS | 443 | +| LDAPS | 636 | +| SMTPS | 465 | +| IMAPS | 993 | +| POP3S | 995 | + +### Not Supported + +MySQL (proprietary TLS protocol) + +Fallback behavior: Automatic retry with direct TLS if STARTTLS fails. + +## Testing + +```bash +poetry run pytest tests/ -v +``` + +**Test structure:** + +- `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_cli.py`: 3 CLI parsing tests + +**Total:** 19 tests + +**Test database setup:** + +- Loads `crypto_standards.db` (reference data + schema) +- Loads `test_scan.db` (scan data only) +- Creates views dynamically +- In-memory for speed + +## Code Quality + +**Linter:** Ruff + +```bash +poetry run ruff check src/ tests/ +poetry run ruff format src/ tests/ +``` + +**Configuration:** `pyproject.toml` + +- Line length: 90 characters +- Target: Python 3.13 +- Rules: PEP 8, pyflakes, isort, naming, upgrades + +## Requirements + +- Python 3.13+ +- SSLyze 6.0.0+ +- Poetry (dependency management) +- Jinja2 3.1+ +- pytest 9.0+ (development) +- ruff (development) + +## Container Usage + +```bash +./container-build.sh +podman run --rm compliance-scan:latest scan example.com:443 +``` + +## Database Workflow + +1. **First scan:** Copies `crypto_standards.db` → `compliance_status.db` +2. **Schema check:** Validates schema version (must be 5) +3. **Scan execution:** SSLyze performs TLS analysis +4. **Data storage:** Results written to scan tables +5. **Compliance check:** Validation against BSI/IANA via views +6. **Report generation:** Queries use views for optimized performance + +## Architecture Notes + +**Design principles:** + +- Single database file contains everything (reference data + results) +- Views optimize complex queries (no N+1 queries) +- CSV headers in database (easy to modify) +- Template-based reports (Jinja2) +- Port-agnostic (one scan_id, multiple ports) + +**Key decisions:** + +- SQLite for simplicity and portability +- Views introduced in schema v5 for performance +- CSV export metadata centralized +- Test fixtures use real scan data +- Ruff for modern Python linting diff --git a/docs/schema.sql b/docs/schema.sql new file mode 100644 index 0000000..8e45ffc --- /dev/null +++ b/docs/schema.sql @@ -0,0 +1,506 @@ +CREATE TABLE iana_tls_cipher_suites ( + value TEXT PRIMARY KEY, + description TEXT, + dtls TEXT, + recommended TEXT, + rfc_draft TEXT +); +CREATE TABLE iana_tls_signature_schemes ( + value TEXT PRIMARY KEY, + description TEXT, + dtls TEXT, + recommended TEXT, + rfc_draft TEXT +); +CREATE TABLE iana_tls_supported_groups ( + value TEXT PRIMARY KEY, + description TEXT, + dtls TEXT, + recommended TEXT, + rfc_draft TEXT +); +CREATE TABLE iana_tls_alerts ( + value TEXT PRIMARY KEY, + description TEXT, + dtls TEXT, + recommended TEXT, + rfc_draft TEXT +); +CREATE TABLE iana_tls_content_types ( + value TEXT PRIMARY KEY, + description TEXT, + dtls TEXT, + recommended TEXT, + rfc_draft TEXT +); +CREATE TABLE iana_ikev2_encryption_algorithms ( + value TEXT PRIMARY KEY, + description TEXT, + esp TEXT, + ikev2 TEXT, + rfc_draft TEXT +); +CREATE TABLE iana_ikev2_prf_algorithms ( + value TEXT PRIMARY KEY, + description TEXT, + status TEXT, + rfc_draft TEXT +); +CREATE TABLE iana_ikev2_integrity_algorithms ( + value TEXT PRIMARY KEY, + description TEXT, + status TEXT, + rfc_draft TEXT +); +CREATE TABLE iana_ikev2_dh_groups ( + value TEXT PRIMARY KEY, + description TEXT, + status TEXT, + rfc_draft TEXT +); +CREATE TABLE iana_ikev2_authentication_methods ( + value TEXT PRIMARY KEY, + description TEXT, + status TEXT, + rfc_draft TEXT +); +CREATE TABLE bsi_tr_02102_2_tls ( + name TEXT, + iana_number TEXT, + category TEXT, + tls_version TEXT, + valid_until INTEGER, + reference TEXT, + notes TEXT, + PRIMARY KEY (name, tls_version, iana_number) +); +CREATE TABLE bsi_tr_02102_3_ikev2_encryption ( + verfahren TEXT PRIMARY KEY, + iana_nr TEXT, + spezifikation TEXT, + laenge TEXT, + verwendung TEXT +); +CREATE TABLE bsi_tr_02102_3_ikev2_prf ( + verfahren TEXT PRIMARY KEY, + iana_nr TEXT, + spezifikation TEXT, + verwendung TEXT +); +CREATE TABLE bsi_tr_02102_3_ikev2_integrity ( + verfahren TEXT PRIMARY KEY, + iana_nr TEXT, + spezifikation TEXT, + verwendung TEXT +); +CREATE TABLE bsi_tr_02102_3_ikev2_dh_groups ( + verfahren TEXT PRIMARY KEY, + iana_nr TEXT, + spezifikation TEXT, + verwendung TEXT +); +CREATE TABLE bsi_tr_02102_3_ikev2_auth ( + verfahren TEXT, + bit_laenge TEXT, + hash_funktion TEXT, + iana_nr TEXT, + spezifikation TEXT, + verwendung TEXT, + PRIMARY KEY (verfahren, hash_funktion) +); +CREATE TABLE bsi_tr_02102_3_esp_encryption ( + verfahren TEXT PRIMARY KEY, + iana_nr TEXT, + spezifikation TEXT, + aes_schluessellaenge TEXT, + verwendung TEXT +); +CREATE TABLE bsi_tr_02102_3_esp_integrity ( + verfahren TEXT PRIMARY KEY, + iana_nr TEXT, + spezifikation TEXT, + verwendung_bis TEXT +); +CREATE TABLE bsi_tr_02102_3_ah_integrity ( + verfahren TEXT PRIMARY KEY, + iana_nr TEXT, + spezifikation TEXT, + verwendung_bis TEXT +); +CREATE TABLE bsi_tr_02102_4_ssh_kex ( + key_exchange_method TEXT PRIMARY KEY, + spezifikation TEXT, + verwendung TEXT, + bemerkung TEXT +); +CREATE TABLE bsi_tr_02102_4_ssh_encryption ( + verschluesselungsverfahren TEXT PRIMARY KEY, + spezifikation TEXT, + verwendung TEXT, + bemerkung TEXT +); +CREATE TABLE bsi_tr_02102_4_ssh_mac ( + mac_verfahren TEXT PRIMARY KEY, + spezifikation TEXT, + verwendung TEXT +); +CREATE TABLE bsi_tr_02102_4_ssh_auth ( + signaturverfahren TEXT PRIMARY KEY, + spezifikation TEXT, + verwendung TEXT, + bemerkung TEXT +); +CREATE INDEX idx_bsi_tls_category ON bsi_tr_02102_2_tls(category); +CREATE INDEX idx_bsi_tls_valid_until ON bsi_tr_02102_2_tls(valid_until); +CREATE INDEX idx_iana_cipher_recommended ON iana_tls_cipher_suites(recommended); +CREATE INDEX idx_iana_groups_recommended ON iana_tls_supported_groups(recommended); +CREATE TABLE bsi_tr_02102_1_key_requirements ( + algorithm_type TEXT NOT NULL, + usage_context TEXT NOT NULL, + min_key_length INTEGER, + recommended_key_length INTEGER, + valid_from INTEGER NOT NULL, + valid_until INTEGER, + notes TEXT, + reference_section TEXT, + PRIMARY KEY (algorithm_type, usage_context, valid_from) +); +CREATE INDEX idx_bsi_key_req_algo ON bsi_tr_02102_1_key_requirements(algorithm_type); +CREATE INDEX idx_bsi_key_req_context ON bsi_tr_02102_1_key_requirements(usage_context); +CREATE TABLE bsi_tr_02102_1_hash_requirements ( + algorithm TEXT PRIMARY KEY, + min_output_bits INTEGER, + recommended_for TEXT, + valid_from INTEGER NOT NULL, + deprecated INTEGER DEFAULT 0, + notes TEXT, + reference_section TEXT +); +CREATE TABLE bsi_tr_02102_1_symmetric_requirements ( + algorithm TEXT NOT NULL, + mode TEXT, + min_key_bits INTEGER, + recommended_key_bits INTEGER, + block_size_bits INTEGER, + valid_from INTEGER NOT NULL, + deprecated INTEGER DEFAULT 0, + notes TEXT, + reference_section TEXT, + PRIMARY KEY (algorithm, mode, valid_from) +); +CREATE INDEX idx_bsi_sym_algo ON bsi_tr_02102_1_symmetric_requirements(algorithm); +CREATE INDEX idx_bsi_sym_mode ON bsi_tr_02102_1_symmetric_requirements(mode); +CREATE TABLE bsi_tr_02102_1_mac_requirements ( + algorithm TEXT PRIMARY KEY, + min_key_bits INTEGER, + min_tag_bits INTEGER, + valid_from INTEGER NOT NULL, + notes TEXT, + reference_section TEXT +); +CREATE TABLE bsi_tr_02102_1_pqc_requirements ( + algorithm TEXT NOT NULL, + parameter_set TEXT, + usage_context TEXT NOT NULL, + valid_from INTEGER NOT NULL, + notes TEXT, + reference_section TEXT, + PRIMARY KEY (algorithm, parameter_set, usage_context) +); +CREATE INDEX idx_bsi_pqc_algo ON bsi_tr_02102_1_pqc_requirements(algorithm); +CREATE INDEX idx_bsi_pqc_context ON bsi_tr_02102_1_pqc_requirements(usage_context); +CREATE TABLE bsi_tr_02102_1_auth_requirements ( + method TEXT PRIMARY KEY, + min_length INTEGER, + min_entropy_bits INTEGER, + max_attempts INTEGER, + valid_from INTEGER NOT NULL, + notes TEXT, + reference_section TEXT +); +CREATE TABLE bsi_tr_02102_1_rng_requirements ( + class TEXT PRIMARY KEY, + min_seed_entropy_bits INTEGER, + valid_from INTEGER NOT NULL, + deprecated INTEGER DEFAULT 0, + notes TEXT, + reference_section TEXT +); +CREATE TABLE bsi_tr_02102_1_metadata ( + key TEXT PRIMARY KEY, + value TEXT +); +CREATE TABLE schema_version ( + version INTEGER PRIMARY KEY, + applied_at TEXT NOT NULL, + description TEXT +); +CREATE TABLE scans ( + scan_id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL, + hostname TEXT NOT NULL, + ports TEXT NOT NULL, + scan_duration_seconds REAL +); +CREATE TABLE sqlite_sequence(name,seq); +CREATE TABLE scanned_hosts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + scan_id INTEGER NOT NULL, + fqdn TEXT NOT NULL, + ipv4 TEXT, + ipv6 TEXT, + FOREIGN KEY (scan_id) REFERENCES scans(scan_id) ON DELETE CASCADE +); +CREATE TABLE scan_cipher_suites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + scan_id INTEGER NOT NULL, + port INTEGER NOT NULL, + tls_version TEXT NOT NULL, + cipher_suite_name TEXT NOT NULL, + accepted BOOLEAN NOT NULL, + iana_value TEXT, + key_size INTEGER, + is_anonymous BOOLEAN, + FOREIGN KEY (scan_id) REFERENCES scans(scan_id) ON DELETE CASCADE +); +CREATE TABLE scan_supported_groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + scan_id INTEGER NOT NULL, + port INTEGER NOT NULL, + group_name TEXT NOT NULL, + iana_value INTEGER, + openssl_nid INTEGER, + FOREIGN KEY (scan_id) REFERENCES scans(scan_id) ON DELETE CASCADE +); +CREATE TABLE scan_certificates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + scan_id INTEGER NOT NULL, + port INTEGER NOT NULL, + position INTEGER NOT NULL, + subject TEXT, + issuer TEXT, + serial_number TEXT, + not_before TEXT, + not_after TEXT, + key_type TEXT, + key_bits INTEGER, + signature_algorithm TEXT, + fingerprint_sha256 TEXT, + FOREIGN KEY (scan_id) REFERENCES scans(scan_id) ON DELETE CASCADE +); +CREATE TABLE scan_vulnerabilities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + scan_id INTEGER NOT NULL, + port INTEGER NOT NULL, + vuln_type TEXT NOT NULL, + vulnerable BOOLEAN NOT NULL, + details TEXT, + FOREIGN KEY (scan_id) REFERENCES scans(scan_id) ON DELETE CASCADE +); +CREATE TABLE scan_compliance_status ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + scan_id INTEGER NOT NULL, + port INTEGER NOT NULL, + timestamp TEXT NOT NULL, + check_type TEXT NOT NULL, + item_name TEXT NOT NULL, + iana_value TEXT, + iana_recommended TEXT, + bsi_approved BOOLEAN, + bsi_valid_until INTEGER, + passed BOOLEAN NOT NULL, + severity TEXT, + details TEXT, + FOREIGN KEY (scan_id) REFERENCES scans(scan_id) ON DELETE CASCADE +); +CREATE TABLE scan_protocol_features ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + scan_id INTEGER NOT NULL, + port INTEGER NOT NULL, + feature_type TEXT NOT NULL, + supported BOOLEAN NOT NULL, + details TEXT, + FOREIGN KEY (scan_id) REFERENCES scans(scan_id) ON DELETE CASCADE +); +CREATE TABLE scan_session_features ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + scan_id INTEGER NOT NULL, + port INTEGER NOT NULL, + feature_type TEXT NOT NULL, + client_initiated BOOLEAN, + secure BOOLEAN, + session_id_supported BOOLEAN, + ticket_supported BOOLEAN, + attempted_resumptions INTEGER, + successful_resumptions INTEGER, + details TEXT, + FOREIGN KEY (scan_id) REFERENCES scans(scan_id) ON DELETE CASCADE +); +CREATE TABLE scan_http_headers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + scan_id INTEGER NOT NULL, + port INTEGER NOT NULL, + header_name TEXT NOT NULL, + header_value TEXT, + is_present BOOLEAN NOT NULL, + FOREIGN KEY (scan_id) REFERENCES scans(scan_id) ON DELETE CASCADE +); +CREATE INDEX idx_scans_hostname ON scans(hostname); +CREATE INDEX idx_scans_timestamp ON scans(timestamp); +CREATE INDEX idx_scanned_hosts_scan ON scanned_hosts(scan_id); +CREATE INDEX idx_scanned_hosts_fqdn ON scanned_hosts(fqdn); +CREATE INDEX idx_cipher_suites_scan ON scan_cipher_suites(scan_id, port); +CREATE INDEX idx_cipher_suites_name ON scan_cipher_suites(cipher_suite_name); +CREATE INDEX idx_supported_groups_scan ON scan_supported_groups(scan_id); +CREATE INDEX idx_certificates_scan ON scan_certificates(scan_id); +CREATE INDEX idx_vulnerabilities_scan ON scan_vulnerabilities(scan_id); +CREATE INDEX idx_compliance_scan ON scan_compliance_status(scan_id); +CREATE INDEX idx_compliance_passed ON scan_compliance_status(passed); +CREATE INDEX idx_protocol_features_scan ON scan_protocol_features(scan_id); +CREATE INDEX idx_session_features_scan ON scan_session_features(scan_id); +CREATE INDEX idx_http_headers_scan ON scan_http_headers(scan_id); +CREATE VIEW v_cipher_suites_with_compliance AS +SELECT + scs.scan_id, + scs.port, + scs.tls_version, + scs.cipher_suite_name, + scs.accepted, + scs.iana_value, + scs.key_size, + scs.is_anonymous, + sc.iana_recommended, + sc.bsi_approved, + sc.bsi_valid_until, + sc.passed as compliant, + CASE + WHEN scs.accepted = 1 THEN sc.iana_recommended + ELSE iana.recommended + END as iana_recommended_final, + CASE + WHEN scs.accepted = 1 THEN sc.bsi_approved + ELSE (bsi.name IS NOT NULL) + END as bsi_approved_final, + CASE + WHEN scs.accepted = 1 THEN sc.bsi_valid_until + ELSE bsi.valid_until + END as bsi_valid_until_final +FROM scan_cipher_suites scs +LEFT JOIN scan_compliance_status sc + ON scs.scan_id = sc.scan_id + AND scs.port = sc.port + AND sc.check_type = 'cipher_suite' + AND scs.cipher_suite_name = sc.item_name +LEFT JOIN iana_tls_cipher_suites iana + ON scs.cipher_suite_name = iana.description +LEFT JOIN bsi_tr_02102_2_tls bsi + ON scs.cipher_suite_name = bsi.name + AND scs.tls_version = bsi.tls_version + AND bsi.category = 'cipher_suite' +/* v_cipher_suites_with_compliance(scan_id,port,tls_version,cipher_suite_name,accepted,iana_value,key_size,is_anonymous,iana_recommended,bsi_approved,bsi_valid_until,compliant,iana_recommended_final,bsi_approved_final,bsi_valid_until_final) */; +CREATE VIEW v_supported_groups_with_compliance AS +SELECT + ssg.scan_id, + ssg.port, + ssg.group_name, + ssg.iana_value, + ssg.openssl_nid, + sc.iana_recommended, + sc.bsi_approved, + sc.bsi_valid_until, + sc.passed as compliant +FROM scan_supported_groups ssg +LEFT JOIN scan_compliance_status sc + ON ssg.scan_id = sc.scan_id + AND ssg.port = sc.port + AND sc.check_type = 'supported_group' + AND ssg.group_name = sc.item_name +/* v_supported_groups_with_compliance(scan_id,port,group_name,iana_value,openssl_nid,iana_recommended,bsi_approved,bsi_valid_until,compliant) */; +CREATE VIEW v_certificates_with_compliance AS +SELECT + c.scan_id, + c.port, + c.position, + c.subject, + c.issuer, + c.serial_number, + c.not_before, + c.not_after, + c.key_type, + c.key_bits, + c.signature_algorithm, + c.fingerprint_sha256, + MAX(cs.passed) as compliant, + MAX(cs.details) as compliance_details +FROM scan_certificates c +LEFT JOIN scan_compliance_status cs + ON c.scan_id = cs.scan_id + AND c.port = cs.port + AND cs.check_type = 'certificate' + AND cs.item_name = (c.key_type || ' ' || c.key_bits || ' Bit') +GROUP BY c.scan_id, c.port, c.position, c.subject, c.issuer, c.serial_number, + c.not_before, c.not_after, c.key_type, c.key_bits, + c.signature_algorithm, c.fingerprint_sha256 +/* v_certificates_with_compliance(scan_id,port,position,subject,issuer,serial_number,not_before,not_after,key_type,key_bits,signature_algorithm,fingerprint_sha256,compliant,compliance_details) */; +CREATE VIEW v_port_compliance_summary AS +SELECT + scan_id, + port, + check_type, + COUNT(*) as total, + SUM(CASE WHEN passed = 1 THEN 1 ELSE 0 END) as passed, + ROUND(CAST(SUM(CASE WHEN passed = 1 THEN 1 ELSE 0 END) AS REAL) / COUNT(*) * 100, 1) as percentage +FROM scan_compliance_status +GROUP BY scan_id, port, check_type +/* v_port_compliance_summary(scan_id,port,check_type,total,passed,percentage) */; +CREATE VIEW v_missing_bsi_groups AS +SELECT + s.scan_id, + s.ports, + bsi.name as group_name, + bsi.tls_version, + bsi.valid_until +FROM scans s +CROSS JOIN ( + SELECT DISTINCT name, tls_version, valid_until + FROM bsi_tr_02102_2_tls + WHERE category = 'dh_group' +) bsi +WHERE NOT EXISTS ( + SELECT 1 + FROM scan_supported_groups ssg + WHERE ssg.scan_id = s.scan_id + AND LOWER(ssg.group_name) = LOWER(bsi.name) +) +/* v_missing_bsi_groups(scan_id,ports,group_name,tls_version,valid_until) */; +CREATE VIEW v_missing_iana_groups AS +SELECT + s.scan_id, + s.ports, + iana.description as group_name, + iana.value as iana_value +FROM scans s +CROSS JOIN ( + SELECT description, value + FROM iana_tls_supported_groups + WHERE recommended = 'Y' +) iana +WHERE NOT EXISTS ( + SELECT 1 + FROM scan_supported_groups ssg + WHERE ssg.scan_id = s.scan_id + AND LOWER(ssg.group_name) = LOWER(iana.description) +) +AND NOT EXISTS ( + SELECT 1 + FROM bsi_tr_02102_2_tls bsi + WHERE LOWER(bsi.name) = LOWER(iana.description) + AND bsi.category = 'dh_group' +) +/* v_missing_iana_groups(scan_id,ports,group_name,iana_value) */; +CREATE TABLE csv_export_metadata ( + id INTEGER PRIMARY KEY, + export_type TEXT UNIQUE NOT NULL, + headers TEXT NOT NULL, + description TEXT +); diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..16be31a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,45 @@ +[project] +name = "compliance-scan" +version = "0.1.0" +description = "" +authors = [ + {name = "Heiko Haase",email = "heiko.haase.extern@univention.de"} +] +readme = "README.md" +requires-python = ">=3.13" +dependencies = [ + "sslyze>=6.0.0", + "jinja2 (>=3.1.6,<4.0.0)", +] + +[project.scripts] +compliance-scan = "sslysze_scan.__main__:main" + +[tool.poetry] +packages = [{include = "sslysze_scan", from = "src"}] +include = ["src/sslysze_scan/data/*.csv"] + +[build-system] +requires = ["poetry-core>=2.0.0,<3.0.0"] +build-backend = "poetry.core.masonry.api" + +[dependency-groups] +dev = [ + "pytest (>=9.0.2,<10.0.0)", + "ruff (>=0.14.9,<0.15.0)" +] + +[tool.ruff] +line-length = 90 +target-version = "py313" + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "N", "UP"] +ignore = ["TRY003", "EM102", "EM101", "C901", "PLR0912", "PLR0915"] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" + +[tool.ruff.lint.extend-per-file-ignores] +"*" = ["E501"] diff --git a/src/sslysze_scan/__init__.py b/src/sslysze_scan/__init__.py new file mode 100644 index 0000000..c2df4e6 --- /dev/null +++ b/src/sslysze_scan/__init__.py @@ -0,0 +1,15 @@ +"""compliance-scan package for scanning SSL/TLS configurations.""" + +import logging + +from .__main__ import main +from .scanner import perform_scan + +__version__ = "0.1.0" +__all__ = ["main", "perform_scan"] + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) diff --git a/src/sslysze_scan/__main__.py b/src/sslysze_scan/__main__.py new file mode 100644 index 0000000..7bea1f6 --- /dev/null +++ b/src/sslysze_scan/__main__.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +"""Main entry point for compliance-scan.""" + +import sys + +from .cli import parse_arguments +from .commands import handle_report_command, handle_scan_command +from .output import print_error + + +def main() -> int: + """Main entry point for compliance-scan. + + Returns: + Exit code (0 for success, 1 for error). + + """ + args = parse_arguments() + + if args.command == "scan": + return handle_scan_command(args) + if args.command == "report": + return handle_report_command(args) + print_error(f"Unknown command: {args.command}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/sslysze_scan/cli.py b/src/sslysze_scan/cli.py new file mode 100644 index 0000000..c030d84 --- /dev/null +++ b/src/sslysze_scan/cli.py @@ -0,0 +1,268 @@ +"""Command-line interface for compliance-scan.""" + +import argparse +import sys + +# Port constants +MIN_PORT_NUMBER = 1 +MAX_PORT_NUMBER = 65535 +MIN_PARTS_COUNT = 2 + +# Error messages +ERR_INVALID_FORMAT = "Invalid format" +ERR_EXPECTED_FORMAT = "Expected format: hostname:port or hostname:port1,port2,..." +ERR_IPV6_MISSING_BRACKET = "Invalid IPv6 format" +ERR_MISSING_CLOSING_BRACKET = "Missing closing bracket." +ERR_IPV6_MISSING_COLON = "Expected colon after IPv6 address." +ERR_HOSTNAME_EMPTY = "Hostname cannot be empty" +ERR_INVALID_PORT_NUMBER = "Invalid port number" +ERR_PORT_MUST_BE_INTEGER = "Must be an integer." +ERR_PORT_OUT_OF_RANGE = "Must be between" +ERR_AT_LEAST_ONE_PORT = "At least one port must be specified" + + +def _parse_ipv6_target(target: str) -> tuple[str, str]: + """Parse IPv6 target in format [ipv6]:ports. + + Args: + target: String in format "[ipv6]:port1,port2,..." + + Returns: + Tuple of (hostname, port_str) + + Raises: + ValueError: If format is invalid + + """ + bracket_end = target.find("]") + if bracket_end == -1: + msg = f"{ERR_IPV6_MISSING_BRACKET} '{target}'. {ERR_MISSING_CLOSING_BRACKET}" + raise ValueError(msg) + + hostname = target[1:bracket_end] + rest = target[bracket_end + 1 :] + + if not rest.startswith(":"): + msg = f"{ERR_INVALID_FORMAT} '{target}'. {ERR_IPV6_MISSING_COLON}" + raise ValueError(msg) + + return hostname, rest[1:] + + +def _parse_regular_target(target: str) -> tuple[str, str]: + """Parse regular hostname or IPv4 target. + + Args: + target: String in format "hostname:port1,port2,..." + + Returns: + Tuple of (hostname, port_str) + + Raises: + ValueError: If format is invalid + + """ + parts = target.rsplit(":", 1) + if len(parts) != MIN_PARTS_COUNT: + msg = f"{ERR_INVALID_FORMAT} '{target}'. {ERR_EXPECTED_FORMAT}" + raise ValueError(msg) + + return parts[0].strip(), parts[1].strip() + + +def _parse_port_list(port_str: str) -> list[int]: + """Parse comma-separated port list. + + Args: + port_str: String with comma-separated ports + + Returns: + List of port numbers + + Raises: + ValueError: If port is invalid or out of range + + """ + port_list = [] + for port_item in port_str.split(","): + port_item = port_item.strip() + if not port_item: + continue + + try: + port = int(port_item) + except ValueError as e: + msg = f"{ERR_INVALID_PORT_NUMBER} '{port_item}'. {ERR_PORT_MUST_BE_INTEGER}" + raise ValueError(msg) from e + + if port < MIN_PORT_NUMBER or port > MAX_PORT_NUMBER: + msg = f"{ERR_INVALID_PORT_NUMBER} {port}. {ERR_PORT_OUT_OF_RANGE} {MIN_PORT_NUMBER} and {MAX_PORT_NUMBER}." + raise ValueError(msg) + + port_list.append(port) + + if not port_list: + raise ValueError(ERR_AT_LEAST_ONE_PORT) + + return port_list + + +def parse_host_ports(target: str) -> tuple[str, list[int]]: + """Parse host:port1,port2,... string. + + Args: + target: String in format "hostname:port1,port2,..." or "[ipv6]:port1,port2,...". + + Returns: + Tuple of (hostname, list of ports). + + Raises: + ValueError: If format is invalid or port is out of range. + + """ + if ":" not in target: + msg = f"{ERR_INVALID_FORMAT} '{target}'. {ERR_EXPECTED_FORMAT}" + raise ValueError(msg) + + if target.startswith("["): + hostname, port_str = _parse_ipv6_target(target) + else: + hostname, port_str = _parse_regular_target(target) + + if not hostname: + raise ValueError(ERR_HOSTNAME_EMPTY) + + port_list = _parse_port_list(port_str) + return hostname, port_list + + +def parse_arguments() -> argparse.Namespace: + """Parse command-line arguments. + + Returns: + Parsed arguments namespace. + + """ + parser = argparse.ArgumentParser( + prog="compliance-scan", + description="SSL/TLS configuration analysis with SSLyze and compliance checking.", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + + # Create subcommands + subparsers = parser.add_subparsers(dest="command", help="Available commands") + + # Scan subcommand + scan_parser = subparsers.add_parser( + "scan", + help="Perform SSL/TLS scan", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + compliance-scan scan example.com:443 + compliance-scan scan example.com:443,636,993 + 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 + """, + ) + + scan_parser.add_argument( + "target", + type=str, + help="Target to scan in format hostname:port1,port2,... (e.g., example.com:443,636)", + ) + + scan_parser.add_argument( + "-db", + "--database", + type=str, + help="SQLite database file path (default: compliance_status.db in current directory)", + default="compliance_status.db", + ) + + scan_parser.add_argument( + "--print", + action="store_true", + help="Print scan results to console", + default=False, + ) + + # Report subcommand + report_parser = subparsers.add_parser( + "report", + help="Generate report from scan results", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + compliance-scan report -t csv + compliance-scan report 5 -t md -o report.md + compliance-scan report -t rest --output-dir ./rest-reports + compliance-scan report --list + compliance-scan report -t csv --output-dir ./reports + """, + ) + + report_parser.add_argument( + "scan_id", + type=int, + nargs="?", + help="Scan ID to generate report for (default: latest scan)", + ) + + report_parser.add_argument( + "-t", + "--type", + type=str, + choices=["csv", "md", "markdown", "rest", "rst"], + help="Report type (csv, markdown, or rest/rst)", + ) + + report_parser.add_argument( + "-db", + "--database", + type=str, + help="SQLite database file path (default: compliance_status.db in current directory)", + default="compliance_status.db", + ) + + report_parser.add_argument( + "-o", + "--output", + type=str, + help="Output file for markdown report (auto-generated if not specified)", + ) + + report_parser.add_argument( + "--output-dir", + type=str, + default=".", + help="Output directory for CSV/reST reports (default: current directory)", + ) + + report_parser.add_argument( + "--list", + action="store_true", + help="List all available scans", + default=False, + ) + + args = parser.parse_args() + + # Check if no command was provided + if args.command is None: + parser.print_help() + sys.exit(1) + + return args + + +def main() -> int: + """Main entry point for CLI. + + Returns: + Exit code (0 for success, 1 for error). + + """ + parse_arguments() + return 0 diff --git a/src/sslysze_scan/commands/__init__.py b/src/sslysze_scan/commands/__init__.py new file mode 100644 index 0000000..fe6291c --- /dev/null +++ b/src/sslysze_scan/commands/__init__.py @@ -0,0 +1,6 @@ +"""Command handlers for compliance-scan CLI.""" + +from .report import handle_report_command +from .scan import handle_scan_command + +__all__ = ["handle_report_command", "handle_scan_command"] diff --git a/src/sslysze_scan/commands/report.py b/src/sslysze_scan/commands/report.py new file mode 100644 index 0000000..d3b2208 --- /dev/null +++ b/src/sslysze_scan/commands/report.py @@ -0,0 +1,102 @@ +"""Report command handler.""" + +import argparse +import sqlite3 +from pathlib import Path + +from ..output import print_error, print_success +from ..reporter import generate_report, list_scans + + +def handle_report_command(args: argparse.Namespace) -> int: + """Handle the report subcommand. + + Args: + args: Parsed arguments + + Returns: + Exit code (0 for success, 1 for error) + + """ + db_path = args.database + + # Check if database exists + if not Path(db_path).exists(): + print_error(f"Database not found: {db_path}") + return 1 + + # Handle --list option + if args.list: + try: + scans = list_scans(db_path) + if not scans: + print("No scans found in database.") + return 0 + + print("Available scans:") + print("-" * 80) + print(f"{'ID':<5} {'Timestamp':<25} {'Hostname':<20} {'Ports':<20}") + print("-" * 80) + for scan in scans: + print( + f"{scan['scan_id']:<5} {scan['timestamp']:<25} {scan['hostname']:<20} {scan['ports']:<20}", + ) + return 0 + except (sqlite3.Error, OSError) as e: + print_error(f"Error listing scans: {e}") + return 1 + + # Check if report type is specified + if not args.type: + print_error("Report type must be specified with -t/--type (csv, md, or rest)") + return 1 + + # Determine scan_id + scan_id = args.scan_id + if scan_id is None: + # Get latest scan + try: + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + cursor.execute("SELECT MAX(scan_id) FROM scans") + result = cursor.fetchone() + conn.close() + + if result and result[0]: + scan_id = result[0] + else: + print_error("No scans found in database.") + return 1 + except (sqlite3.Error, OSError) as e: + print_error(f"Error determining latest scan ID: {e}") + return 1 + + # Generate report + try: + # Map report type aliases + if args.type in ["md", "markdown"]: + report_type = "markdown" + elif args.type in ["rest", "rst"]: + report_type = "rest" + else: + report_type = "csv" + + output = args.output if hasattr(args, "output") else None + output_dir = args.output_dir if hasattr(args, "output_dir") else "." + + files = generate_report( + db_path, + scan_id, + report_type, + output=output, + output_dir=output_dir, + ) + + print_success("Report successfully created:") + for file in files: + print(f" - {file}") + return 0 + + except (sqlite3.Error, OSError, ValueError) as e: + print_error(f"Error creating report: {e}") + return 1 diff --git a/src/sslysze_scan/commands/scan.py b/src/sslysze_scan/commands/scan.py new file mode 100644 index 0000000..024ea7d --- /dev/null +++ b/src/sslysze_scan/commands/scan.py @@ -0,0 +1,188 @@ +"""Scan command handler.""" + +import argparse +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from ..cli import parse_host_ports +from ..db import check_compliance, check_schema_version, save_scan_results +from ..output import print_error +from ..scanner import perform_scan + + +def handle_scan_command(args: argparse.Namespace) -> int: + """Handle the scan subcommand. + + Args: + args: Parsed arguments + + Returns: + Exit code (0 for success, 1 for error) + + """ + # Parse target + try: + hostname, ports = parse_host_ports(args.target) + except ValueError as e: + print_error(str(e)) + return 1 + + # Database path + db_path = args.database + + # Initialize database by copying template if needed + try: + db_file = Path(db_path) + + if not db_file.exists(): + # Get template database path + script_dir = Path(__file__).parent.parent + template_db = script_dir / "data" / "crypto_standards.db" + + if not template_db.exists(): + print_error(f"Template database not found: {template_db}") + return 1 + + # Copy template + import shutil + + print(f"Creating database from template: {template_db}") + shutil.copy2(template_db, db_path) + + # Check schema version + check_schema_version(db_path) + + except (OSError, sqlite3.Error, ValueError) as e: + print_error(f"Error initializing database: {e}") + return 1 + + # Single timestamp for all scans (program start time) + program_start_time = datetime.now(timezone.utc) + + # Scan results storage + scan_results_dict: dict[int, Any] = {} + failed_ports: list[int] = [] + total_scans = len(ports) + + # Perform scans for all ports sequentially + for port in ports: + try: + scan_result, scan_duration = perform_scan(hostname, port, program_start_time) + scan_results_dict[port] = scan_result + except (OSError, ValueError, RuntimeError) as e: + print_error(f"Error scanning {hostname}:{port}: {e}") + failed_ports.append(port) + continue + + # Calculate total scan duration + scan_end_time = datetime.now(timezone.utc) + total_scan_duration = (scan_end_time - program_start_time).total_seconds() + + # Save all results to database with single scan_id + if scan_results_dict: + try: + scan_id = save_scan_results( + db_path, + hostname, + list(scan_results_dict.keys()), + scan_results_dict, + program_start_time, + total_scan_duration, + ) + print(f"\n=> Scan results saved to database (Scan-ID: {scan_id})") + except (sqlite3.Error, OSError) as e: + print_error(f"Error saving to database: {e}") + return 1 + + # Run compliance checks + try: + compliance_stats = check_compliance(db_path, scan_id) + print("=> Compliance check completed") + except (sqlite3.Error, ValueError) as e: + print_error(f"Error during compliance check: {e}") + return 1 + + # Print summary if requested + if args.print: + import sqlite3 + + print("\n" + "=" * 70) + print("SCAN SUMMARY") + print("=" * 70) + print(f"Scan-ID: {scan_id}") + print(f"Hostname: {hostname}") + print(f"Ports: {', '.join(str(p) for p in scan_results_dict.keys())}") + print(f"Timestamp: {program_start_time.isoformat()}") + print(f"Duration: {total_scan_duration:.2f}s") + print("-" * 70) + + for port, scan_res in scan_results_dict.items(): + print(f"\nPort {port}:") + + from sslyze import ServerScanStatusEnum + + if scan_res.scan_status == ServerScanStatusEnum.COMPLETED: + print(" Status: COMPLETED") + if scan_res.connectivity_result: + print( + f" Highest TLS: {scan_res.connectivity_result.highest_tls_version_supported}", + ) + + # Query supported TLS versions from database + try: + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + cursor.execute( + """ + SELECT DISTINCT tls_version + FROM scan_cipher_suites + WHERE scan_id = ? AND port = ? AND accepted = 1 + ORDER BY + CASE tls_version + WHEN 'ssl_3.0' THEN 1 + WHEN '1.0' THEN 2 + WHEN '1.1' THEN 3 + WHEN '1.2' THEN 4 + WHEN '1.3' THEN 5 + ELSE 6 + END + """, + (scan_id, port), + ) + supported_versions = [row[0] for row in cursor.fetchall()] + conn.close() + + if supported_versions: + version_map = { + "ssl_3.0": "SSL 3.0", + "1.0": "TLS 1.0", + "1.1": "TLS 1.1", + "1.2": "TLS 1.2", + "1.3": "TLS 1.3", + } + formatted_versions = [ + version_map.get(v, v) for v in supported_versions + ] + print(f" Supported: {', '.join(formatted_versions)}") + except (sqlite3.Error, OSError): + pass # Silently ignore DB query errors in summary + else: + print(f" Status: {scan_res.scan_status}") + + print("\n" + "-" * 70) + print( + f"Compliance: Cipher Suites {compliance_stats['cipher_suites_passed']}/{compliance_stats['cipher_suites_checked']}, " + f"Groups {compliance_stats['supported_groups_passed']}/{compliance_stats['supported_groups_checked']}", + ) + + # Final summary + print("\n" + "=" * 70) + successful_scans = total_scans - len(failed_ports) + print(f"Completed: {successful_scans}/{total_scans} scans successful") + if failed_ports: + print(f"Failed: {', '.join(str(p) for p in failed_ports)}") + print(f"Database: {db_path}") + print("=" * 70) + + return 0 if len(failed_ports) == 0 else 1 diff --git a/src/sslysze_scan/data/crypto_standards.db b/src/sslysze_scan/data/crypto_standards.db new file mode 100644 index 0000000000000000000000000000000000000000..2f267c759a0ade62d86a3ddd715483a6e2409e3f GIT binary patch literal 552960 zcmeEv31AyX)&I(pR_E@-x!g2|l{j~zWOuc?a?6tJxQ?B~cAB`Cs@Srv#+F@4&e0R4 zKq<8J;AkndP-sgjy||%;a+Dhiv_QG}poI@wj&c& z;TzchZ%2>eE=9oq|2y<7dKx{2zK6a+Gyk3Y%5CQea0EC490861M}Q;15#R`L1ULd5 z0gk|bG6J11#jobKT@it_{R;g1jFb3x>q-1OTEM?M3Xll9c3}U119}`YyWz>7{J;$V z;|OpBI0762jsQo1Bft^h2yg^A0vrL307u|OjzD~_c?~%E!!bqoV$#-T8V8eOG+PfT z3SqIT&rDT8*QFvJvSXoEB;^`Vt$DO!k- zbcVDA*^x>9ll)srkv!-r`RDSFH_pR>Nx?k$fyDxB`?>^JrCb!7*(PQWf=u`hWM~3%=Bft^h2yg^A z0vrL307rl$@Zv=PnOjT&zZUX&1Af2HKj1Jo5nel7`lTjIzm)T-w4&)Ctp&pg!t0t} z1zyp7=MhQZm2*jnqWZ$#fUfD`b4UsBp|f#`KcHwqZvgnUu(yb(r|VYY7Rh zAtbn(kiaTJ{3{93TL|&3AVgbEi0UCkX~skgFC!$hl#t*OLIR5k@t;nJzK9UtX@qD^ zgs2M%Q5ImLhUXI!nny@*E+K(NLi}?G(Gej&nGj7PM0FFQxU6Pm3OjKca^N&*$7#TZ zQ@@B)-HKD61*e)Br)q=6j2wytlZqxv0{RfT9Sx&>s0p6tZ;}ggNM0`eSbCe3me#nR zcE8g-=2l&QaedNtjq8AGt*g=b1?PL6C!AsD3dhrq#~pV%u5}!A?6Uva{-pg5`=H%v zd(w7~ZJ%wG_+#-N@ipQhaWyo;|2P610geDi;KhkRlO`@+E=iKAk?ZDxOfr{C4`)(? ziYm@qg`|k8si}dHL^gHdWICG~>>tb~hw@u`+M?ccDnI1SkLJ9oTqX?{-LN9g+lr*^ zs-|Sd6Um8*(eywvpB~R7CbHxC@qzKt{_N1e7WfzRDe6{FkqPN~uxWGA3N_?a)o{g% z;3je28YFe8YH&D}NoA9xiBvW_o~>mD=mTAW!|9w?_3iMA^ZcY@U?7#vr-!gDscE}m zsK0I_so$ci{>jXd%=j_rMeaxUHA0&7_t#naQRG#)mWMi&BG$OmfU1cWA9RZ!fW2Wv!mfj33S?)0yPq(dTRT&Nbq^ zAc0?*%;Y8~CdRXQFd%h2pUUK5NX&Trwyq{!=u(yNSQ>_8X805;H?1lTCq;!D+E8UU zMOTXRc4D&>JVdiFqK3!wX*x*D2WX~avY zv9_O`pWs;6yA&DDtZk@RTW4!$Q%A>-%mS)iGCBNRx{sZ~7)>);ACknz4Y==G)!aQ} z&0X$d+bT>`Op}$X$V}(j7FY3@DI>|uU~VLN1Wz65(aCIT#?}`&>G=Sw4nF35Xm^N> z9nc*g8LDFwaIWEVx1@IVwk@zz{hH6KG5SL`3YVtgOICI;F%GlC(CGNF84Zq#Vq*j> z(@G~_$t<|!%z73GTIp=5Dfnm=O&CiK%-H$@3+;{y7aOKKTg_sl3P!1L1%wfs8o(F8 zF~BT2Jo6!#*C5U#w`E>NW85Snm@CGWfzk0?Dv^Op*U-$fF1gY2!d+7`pWFg>UOw#q z*W@wm|Nkxex%{;JJ^AbCN%>LvQ}RdT_sRFlcgVNMuamEW6@e4-82S$S8hR8xf2DhNsqifMCPytOyU38}g$~#OFT`xlx1sXZhDJ&YNCt6-R(0 zz!BgGa0EC490861M}Q;15#R{C;0Rzhe$ls}VK+Uv6Noz>oCFFfqbvTzr4Nhz#HFvQ z0pik!wfBvrj;e2<)VH2eZ5^fRT1u5Qgeu|HL?>PLT}AVhl{n0SEY=e5epp$7Gsto) zaqx!%9+H6J0XS%fplx*>7)`>C_vPo4dK z>g?C4vtOsqew{k|b?WTbsk2|F&VHRb`*rH<*Qv8#S6sLevKZsU-hZFsz!_xq#EvsG z%|?hS5~5gfFvGKCpy@+H^sPZ_z)Lz=0z(I-V9T78hYB5ky35eqTl3;?u>(#o)Ns0;wbU z#F11vS|t<;D(k^zj^he6|G+r);elI+6QiP zaK;U5J{ zNq7a@=VItV=@^9}KspW%_BeJk28#0dP^FF!fZKPNiQbvXU%VAO?ZjJY@U(*ib~Y}? zC^*`&|5eo?@&)Q7MpC1rhVnW$HCW5V9<&tJU6dBB{`#V~lde*nxa{;7BU(Sqq_95KuDc8On~2c~*3%j`dQ3K2pA7 zU1`MY5bZScgkgv+H2S~c6_Xx|h}z-Y_fah97@rD8X+H7kvuIH_6W4rKY4j1*GlHTw z&{}Ho)SX?e9S{ZpGkBL$I+i3HY4%4E`~R1sVDbI`(!2k~_x`VdH~uey1C@a{1I|bN zXeW%=9)PR-g+Tt!{VXr_T#O^Y5#R`L1ULd50geDifFr;W;0SO8{yh=EK6F^NT}r9G z1aCG#S6=DM{{i~)zn{MRuhWP@_^haYw{xL7t$lrUDDN3M%oMe_sxOb z0Y2`&!9DKY?{0Se#`P^w#{W1190861M}Q;15#R`L1ULd5ffoXSwX4PD+wl%9boV^i zYp*9oeB9tI9nX69Wz$C?EW>jkmnv<0;S0d77a9`~vSB4eJ4h{(&mZrO_9VI@?Ol;X zS9=%yiY7YydgAf!@w^8%mKZ+<8^5%OjqO-iC&HZv&g$-uM|XF0)~&GwcE0d~g+5=E zdZK#yamq`iREbD*cLe^|l*GQhd-`FoJAb01<4nfF)y)tofW;Le z9*OrR+M`_wHBcqD3iiKHK@N7u!`;SmQIHE&i7i_S5iww(7OLI)QrPywFChrCq2-aT z_?|r-5uyts)tADq7Nj^(qgeAUfnXdTu1&Fzvb96r(;*sXGcKiIS4#VOyo(_EW;3>n z!dfY7jjn|ln@(I!GmM3fcs%aerv}up=TKj-7dq@+53w^_F$NUc?gM-FBq9g;b`vyU z7(^Ldn;ro$p$P(2h!ToZ z>1e2dCI~$t1r(ujfwmCBN{Ds}PN^U?A0kCo;Z`XyB)_j`K16_!TsqE3ZfkT21ave* z|J6$WnI1Pn;KowGQB_JfdOZ-Nu?9jwXjfw}W;)LFX$QJ{_r;?fZ5{ELcL@Y%EN_t{ z+Gk%!EFNKs=R@$t8ZeFymexIc54Lsm?w$kj7tN4c8Y8`Zb0C%i=g>}LCCDnGrI15= z+Y|NAfj|W;Lc8e$TM&d9U=i9y$nZb}0k)R*j#?Yg8FRdIAX)%-vC>2A|6eUXSK9yo zxvB^N*!jP*>p$M{A9aKOzr3%1X-|J-Mo*#dp>ObA_+N-gp6lQUa0EC490861M}Q;1 z5#R`L1ULd5ffoh={C$JU0vg#Rptwr*-?{(4TRtS9AH$x0Z-Qw5vrqudm47LJQNCTi z8VdLyM}Q;15#R`L1ULd50geDifFr;W;0SO8ra?e+TOm*dHbEd?d7LIrrTAujPzwhZ zx~%x~L#k30v9j7}MM2mpVP8*MVmFRb>BUhht$W)OepM^h5BoxXkHZR|nr{hVIq>+a zS|||(@uGwtQ2i_HRgXbGc5hp!Z_uo0PnQN|b2OM0-3NM!s*8TgzbBjd3W zYjnn%L($f-rW(k*n*}i z`kDqSTD81IEthx2{FT~_;3AV1-2N?PuYa)!V~+j*Zge;G|9=SG4c>nKcKh%oj46Sa2mAZG+@K2 zU&N_y#i`GNQ_YN1wX*+zqtq|JQ~&+wGT7xWf*R$g ze;fgh07rl$z!BgGa0EC490861M}Q+x3jwY1c{sr5PzSib$^pI>Sz%Lx7F8!N>r)qa z(Q%zHI-S?bRMJ-^3GVBJ8ZJ4@v739XL?yK->wy+kEpHWoW$<sJQ@Q02V+DK=lF zVJ*Y<_n~Fh#ulmH7aSY)9!ZViQk{P4oTT0($+3xKCV3>C^A;u2M^Z;MuM!F_wZcB; z{k}jkKNtwjvf>h2(O;|xZ!OQRV#{LURQ2iYdwctQx~e2JMP=Vt#&12hoKA&&Mxlyd z7z>@g$O?O#xA?T^WcFxE*GvB0km6I9oQBP7@u{Q?zG|k1iw;6uy1dB>8?v|fl*8F% zIx{goKDrOTbzOQ7IS}^i%NG(6EnE@7uS*+57EqB;MFbmY5SdR!f{ch})Utk_6@R)w z3uy3G`Qdcl)04~$!Z+gM(S4rw?D*tF(Qd!0>g(rHS?G;6lWcpVwec9})ip&A5#g@A zv7$=Pv2=dK1IChk_`t$g%Ck2+n96#3Cl3!-HQGLhD)QA=RJ42sMf<_ye{6Y+2CsP+ zD^yzq-tM{#gQxm6mDJ2uz%%qCtoYVLZ~&H7;5g1G3*iAE3osWknnv#` zh!TL0HK?Ib>8Mn?RXlb+zZO{Kq+-EGq0h}%HRSo8w8pU!P53myA0icV1)en0L;xSsYvBehayrpnfdG*q_23Qpk z6#yz~9eYN~3!wlI1);(;RMpOu!;kHn#{cg@3#k8p0qo!ZdGrYE1o%F5ANc>@j^2Xa zfNnsqLN7&^qN8XWji8^PBAfu)4_mk^>aCd79-A=)BB)YAx2 znlRDA3keA=AS5`Skia}b{BsG>8wv3dx4)(lx4)(mx4))Pw||(r{X;G+sRo^d1RR9; z?S$wyyLo{LuThY<|NT~*d7cRt8*0D*FG-K!{r^9Lu7YR#R`C8mCf_6Aby^x*EPiD(?buCzO&HK6! z?AhaqOy);YnS6R6nNN>rN=*dR@J?{e4}+s$YfoiT+4O*?H#Go?Jo})rW8>Mus+yjz zRDNW9u&5xQ2ZFGLUE@K#=|DUh>y6ML4A}nO3$Z7j@r(zW_3ZOPB!dUk9xWND2mN{w z-1xYnQdJQm87dVqQ(Z9tFaATta8*5sYN*u1Om#ij0AL;j1nAyS1?P8rz4Y34QJE&6Q1Ze1fC2HY=M8+ zr@tCP06@3e0|-s0dxri0C3GA0|9=fViaw1#iav<$1@HfD@J_%F(RW@PgPdEz5#R`L z1ULd50geDifFr;W;0SO8I077j|7Zko@BzH_gO_$Br+JZyuG-KL0G);a_-F`#MneEp z8Umn@-T%WP_WZ-zM7{n0tpJ`0{TV~Jmy-WzG3B90861M}Q;15#R`L1ULd50geDifFr;W__sk| zpKd)kZ;`2QPj3QVVb~o{z*_-{gB^Xl6A}3AAiTMdz#lS%cPQZH2X&LC9FHnm$S0vM z2%>zWfNp`^{dD<{@@M56f#rW30geDifFr;W;0SO8I0762jsQo1Bft^(S0J$2CT>`? zba7L>JK95@U!&2kgbEL>ilVgjv_(CBEu^VhXfr(5!dD8^4V%SU6#}?|s?@3wwn7D! zX`#K`g6b<(*y_Q*LYf|A+X`E1Ri$m|(^uVS7FQYj)z?Eh6(jY+KKQ;zrBfRls-;__ zL^_J4@eTm2G#nTIDIXgr`mG?Mf`Fbx1@t=D|NkAZ58!W4Sq-ni5#R`L1ULd50geDi zfFr;W;0SO8I0762j=*#X%rkE=RjqQ^rsf4iLmpkLuujbjSmu~lG5Y#?%*Kq?D317- z$`(oJaltD8U4ZBQm!Jb^z5I7z_#a1rBft^h2yg^A0vrL307rl$z!BgGa0LFd5LhQ# zH!oV+w6CWvvAZim!sE3Bd}Dxk*@M{2u7?645@E2;T0<0K>4|8-C~B!83Xuv#)Nc@7 zW41z|12F)?^ue8pqfmuf|SLNX)gDU?&SZP6R0(t`7iH@Q+wDHu;=F%JijsQo1 zBft^h2yg^A0vrL307rl$z!CWGL12^B8U`^xTJoS9_CyE~{bWagvi#ZxtG8qi-u(|h_b0+k`~luOfJ6&m|Gx!Y zBcSi2N6=m98t(o7?-{kcryK!}07rl$z!BgGa0EC490861M}Q;n{|SN9&0$mRH3Vyo zqT+IarAAS4Wxzb&?4=5sMSv>*Kl(pmktr_7-$(D19+0ncUn!mIy3W1ZdDInksLloU zCHC)&Puo5s2CToZ&a*sVzQuf~;YfoUsyrv$2>-pne}&j0ThsRKFR<2svbv(trjv{F z$-|?m!?|=KpG_#5s%QycB7G!vR7*@`hw9~@9_@)o`r@9xNb8=sr=FB&tC9m2c<#c{bUu|xPUgo6pQve9Dl?EhF@Y5))ENX86)tG9 zHFb8HPIlHusiJQNZKutQSw+_i4&7)nl^ISk7*D}6x=?I7sLl#YEs-D1Rl!H{noiDH zU~5uU(-k!kBN7IXNY29gxRCN96q)20Yd#$`lVgWd?7$gF=2OGt*%M4Zv8B-0T-8WQ zj;03_lbL*a)YH-37jKXE&>@yh4W(eH4zO)y#`CEhqmmiHp0(JnjUJ|zjUL9fbwaS$e*~z#U0}MbHU6I>%D^k?}!*c}9^1g@f~KO?&s6PA(C(^S3^{mVY*q)n_CZvvcx~H?}hz{&OVE(O-^TEm}k@Z z6N%&~4EJ2T@Jw z=Z#bUkpJ7MpTivw*Fpkp}c?N=St>9JZ!#*mQDp1w=gQ4~)aPn8_#d zCni$#9GsHtoC??!ndc4DAgE$-NezsTjln%qYLLyXnpHcUVpAeKEm~77EMhbz-&OG2 zY)uit76RT2S_jGfqFX`oh3dZNIH>rrFjMiI`3XjpHMi9?rnk zKbuPA21Zh2%-J)g#8L;UY7PHdta>eC({Nm`{lNG+fw2s;&N-oy4=!hv6x?NMh%-xH#z^{`~%j&|NeUrC`hWU zDH=8vHjp)e^x*LXnURZQD_gldknD(F%QUiX z@9ARG!1j8RhT-X$S%;~5)@Di;U#i%ewyLJW;&T6p68zL#dB!#9B~R79DgCNZ4fknk z)wPV`b@|QkSOv|aDb1Ij#iuIJ>b6~~Q>Wol@w%=}VpDSKtWeYwx!ed|6dI`lu~MKG zI=ukmR?|r@xkMQ(u87siRt(c_9iig9Te=p^v?Mmm1u(i~aTqS1*&~%jvCfUt&AK%V z97_(=#0K&=7nBX;dQ`Z$9yB0TTj!>kfPtDtT(AW%5GKxb{p-c1vG!9K9LycRaY>LK z`%1C5FtpCr)ZSimL9kl51}GIx8)w3%B3im+!y4AW2rRJVa;Z@`*tr)7uR3FB?J16- zBdOyx#}MRiD4el|y{D-G3`o_^T|W~}P_qcok<^Jq>iED&64p73i)Z8!XzE(jKXi5- zSS>cCqq7=azC>~)QCX)jjxA&1y2AcdaGi>pPOh7Jo*6|AKpBhH)@A3b@8e~b~&B62I?VJ(NzPoO5w_xFsq2Z;GiW_@OWLG184SV?DtIf zFk98l${PF>EGtDVC+9blr~WJG!)#q0bqiKZhhL?kn%EVm>U2IarSBt2xXBrs%p9rm zn30>>=i^(WZ5ccr_jyQ&Be8+($?p#%^(bi<`nn;w-T%G=d;dRzM$sV@LkeW^KaK!LfFr;W;0SO8I0762 zjsQo1Bft^h2>jO~fH%~yTE%b}^T>jQ-Izz#CTvU0+fDVBAw*nQZ^gli3+pW|Senc} zQ;l^5GtRHEWKjM7|Az#p`w;=2`a^I}p=&A(hpAo5r3NP85j?9_je}N#PSCGYoXT$cPP0Lo zv3pn<w!C%-BV&405t+Rp-KWl))L;{sNqZPV{5JDW4{wAbJSd(Dm{+ z(6D@w+##PP-vdSbk0Zbl;0SO8I0762jsQo1Bft^h2yg`c!x2y{2Ns)Vv!H?>eBoi* z#a1vrCRbKq$is5+N!h7d4whRmJ_=VVkOyM&Yz@C0>n+QprphV^R)j0oSo^SVw)A4< zQx^a#y20qITD0EA$j`=jk&&O)_M(l}b(Z$p_LLVSik4UyNuwp@)dnmHMP^3QXhV4^ zq1Zb1_%E(AUM2JqL6k2Q;LCsKB9Hu({D6EZu>6lBz!BgGa0EC490861M}Q;15#R`L z1ULdO90ICM+^}eAb5p!K+LOSasEbCs;EVPo2!g!SuKScQeBmA*@!<)VglMQ@ttupL zLM1QFPpz`m3RP65mG*KgDtReiSCs(u`-e0=Xl!q*rB-Fy9@zt6y2_i(;woeR`g%y` z$W{ivKqz2rY*RzEd~1}*lN}9w0r;Yv0Sq`2(SPu0IMHtf5fudVBr2fSp~uiW&_^JP z|8WF30vrL307rl$z!BgGa0EC490861M}Q;nLLz`S3aDEDuuaVih=x46f?=JS7qHAR zuVVD|^_Yzrt_*eV?KP`x^YXxMN)6%CUhkK9fT44DfM}Q;15#R`L1ULd5 z0geDifFtnFN1(99CaO!DP6ks0gCpMDNK*4=(z!hTW;uQ(*dHivgC0w#JQqz4r$!!m ze>Odw^6XCLMusLcNAl_Mj3+sn^9-i4zvFUO?ie>V~^ba$eTerH!zY(=kuPxCT)|76_gACnPZRKJ~Ep3 z97|;fQ<+VLEn-dMA$qw~hrC(+A~oJx(|Cxl{x>k*V-eezHWkJO(?dh)ly@XGIy#oj zc!#s&lM`wH40iwC$fIg!q%Mm@gLq1AOY?8%Lfj(R3MsdOgg=}iu$ zJo~cgv6LqV8J@vp&NGrqKl0|hXADNrglD@)yKsZ*$)z)cQ1RIK2(FqO&A~t%81Y<4 z>TW75F%uJ0HBI!_G7*oHz1hLcWM&vfX3i7OUX+3{pUruOsexnAVk(zUWpW-(^D7?c zE(}>>_qcV`SO})bW9l zB%C1RjHCmCz*JGHZ_-Mi5C|&eQNE{gT44Y$N)l)YxQy*SMNvF(5_%4&^4a8YD&sj- z>tSNr^86+zCwdz8{|^f2W9U|N2}+@E6a<$4aRfL590861M}Q;15#R`L1ULd50geDi zfFmFX3(cx&>Jpu;Mm8*di8ZoeS;@NG+-|D58d6%YvDB3)t(TbBnh(^}QN3h=-}-N= zapWZQQP}_gJpvj+lKiavnEW1K_#a1rBft^h2yg^A0vrL307rl$z!BgGa0LGUBd|oY zE??RtB~v+gi1rTTvpB9n_vv)A{3X^>DQwd}wbW8B)k0HB55kuG;ij`A@klIzx6#9U z>%ph?X#9sBGzP;ECqL}j@9j+wjHI$8NMSMy0S{2a+ciE2K{%;Qek3^x@d*cE2Y*&E zd=a3oV)axM!_kQRLr27ko`n7XUnQVN(Ff5x(9P&o!0qO7O^ZyqF^gZ+iApDObz!BgGa0EC4 z90861M}Q;15#R`L1ULd5ffqLdjpj8b!!m-|m;=iNI2fSO&e%jS!#+^f8Qe8Ag&58 zC@h*7p77? z%z3}_R_8U&OPoiXXE{5ZA?F(BLZ{X7oZ~6S(lMUF5#R`L1ULd50geDi;6;Rh+q~2?IFcYA=RTih68N5NAUy%w zNhflX@LBHjsx!{5&N!z!Mo)Fd{_2c< z)fs!MGrFrYx~enwRA+QnXPi-;(NUeTyE>!2I-{*RBVL^mtImj4XS7ylM5;4(RcGw1 z&e&0%vAsHDTXn|P>WnSb8R6=TP<2MIIwMe>;jhlnt22Dn8CrFQTAiU(XKb#{*i@b2 zt)X866Cx2C){FQa`Tk7Pm zsFT0EPQIs3esi7tWp(nG*2!N|Cx3CB{L|T~a1qO##xhMTvyf#Lunat}R%Y3GEHf7~ z6S*UavGKuFBg@Po0rSb+NMdL*GmuY@XV8=!c}k8{r$6pG`K~(oPS!{V%h*}Q#xf$y zSXsuxGG>-(aG94n29o&{jN=pJ`CmFDpm(EIv=!;-6>>;c<@IulyhL6g%d$-tq(4f( zk$wix+~1R)kiINkEajy!X$YR)KZM>XUnU=uC*-tzSUy|sle^?Ld8c%TbgOiube(jS zbW%!4htMh&32RDRdq>i1wilc>7>AYDTA_M&v|h z`7iSC&hbWOLktlH!@(ULsPdh2+pu|qdvo=aZO01MvC^1vg zAe&npN0X!J!Ng=HpB{zhf78b-SHt`Naz8x3@0HJx;UF?Z0dx>vfFxlaS1|94zp zcYW6N3D<{R_q*QVdYkL@uIpT{bY1E?1`z;*uJc^`U7fDDE9BB#8(qz=MXq@+(Iq(l zvTQ%V2e2E^;Fg3G$z$uepEb{*m)# z&dZ!Togv2=4v%AzW3KfP>xa>w(eKc+=xJDW_#XNOdJJ|p{1o~qtX8}S-HYCiZbNTG zuY(nctI!qbQgjSmh>pN2L<04r-AIw2gH?y0%1_GQm7kEmB7Yv%9UhiHD8F022O<;R z0xJ)%m0vBtLOv;9A|Hiyj|=2M`8;{29F~2uS6(A8mlwmT36dSMS^7U%HTk*pztRt+ zZ%bc-wTe$jAC}%PJs{mJ-45#`H%T{0S4%IKULsut>nRy&L^>?(lg^N0(heykY0^e% zwd9dbmu!;Y{-gUh?(eyufXIx`x%g5!^l-#C8e_>tp#jwc*nc6=5hI39Go z$8nG24#%yI8y(jZMOw%n{8`t%WbFI=GojftN1tZ58|)Hr^P45?}%R$9~D0>epLLR z_%88o@onOp#Mg`keI_*8jEs zkM*0@$E=^X-fz9jdYkoT>uao6TVG}!weGO4vo=`1Yk9Iqa=Npq#q&a zheb1L=r%n>mxc+-*i7=?xRxo(!6^p zc_%5lo22g`>0MOp?X>((O75VUw^QzIl)ROa+eo#yko3(Yy_HJeLYOy^^o=CFnHIc( zlA9>Gk&@R_@;XXhOUY|UwHq)KGhI)~b+qVON?uLLHI!UU$*U-NB_&r;@(N0>q~zt4 zyo{2UV$yHAf|8Rovp~s9D7l=H%P6^&l1nh@H(gB0MKtpSCC4c_M#)i1CMn5NlA|O` z$%T|mP%=(QhLSN#MkzT$$pw_8DH)+;n35q%Qj`o*GC;{;N|Kbots5S1aO;N2d6dAd z8|KcT1a93hcNQi6lpLbuASGu~a)6RPO5oNF*XyBVKPCGp*-J?`C0&&4p`??NGbrhx zWH%-4l(bP2rzA#6l#*6T;MNVd4!3TYz^xl5J1Bu$H_UCL1a93hw}p~0B_T?Jlmsa8 zQ=(G>w{EzeMu|#^Ldj-IHc{fGWFsXTC|OU*I!e}3vWAk?l&qp;B_%DCte^yL-Eix0 z>xK#3x?!@661a84+!9LQ)(vx~Q?iJX(=dsdnkZRF$pTt7pAxur!}aD;(n!f1OkyTP zIhhg(lYWz%5*N*MQsSVQc1mnCQ>4U7iG>m~B@L9AC=mz|{!Yo?DETWTf1%{hl>CX3 zKT`64l>C8`-&68CN}i+Sx0L*bl3!EuD@vZFMlgkMndbDH@xN}i$QX-a-d$xkTx zUrK&V$&WCJ2~Sb-B+dLGB|o6#e<=ArCEvpnpYUCheut#rrUl<3%r{B;4U#@V3%*X6 zuaWetBz>F~JVuzWko3zW{SqyBlrUc;=@&@)d0Ox}!hDvbpCRd|X~Cxm^GT9ELefvr zf{zo1T+sw_MH3#T1>}+@e3%qJM6<{xO(2&v;e({$1GIo#(gboz6W&V--a`w>B~2ig zG~oeKa6c`$k1+R=^d6GFlNQ`f$vY^y3zL4~?UdX}$sLs3PO80)q;DnZZMdLYAQv=& zT+oDDX%@Ml32&mz8!5S&k~dIt6D2oN@_I^MN6Bj`c?~5uP;xyb*HLmUC9kIB8cMFF zU~grpacG)>YGk`9w}h@>f! z4w7_$q=!kGB2jat2O1aIzaG?WDMkq;ZnQNE#(+D@h|H-9^%!B;7&M?Ihhs z(yb)jLeelvLnIB7G(b{6Np+I?NUD)kC8r0Yq#j-+czx`w2y zNxF)pD{2i{KNPaW|22TAQDwUJaLsg{H}?MzNS_nXUm(){ z``~N;g!}<`%Dq;8seD4t$OCeZ+%9i}h=A4bWIIQ;N`I1`g-C$^foIy!q2Hrlfp7mu z==M&2!RLDd(nO9E_56Xq4UvMu(tqxnpcd^^YWj; z+yAWmwEUF(Rd^5JGxCS!cggRR?~vaN&)Zi)j6qHwk_z_ljr|#^8DXTp8vba^M5z<{NGKU|GUZae>Zvl?ns5DlLdf& zW&t2Z764*o0U$;e0AgeTAVwAdVq^gzHtPj|7+C;_kp+MlSpbNU1%Mb?0Em$VfEZZ- zh&7nal0$;CAS_%efRFqp@PaRa6@b^vXUfgswZ2;#m%Q%h-0uhP@>bVhT@Sim;)*!` z=KLi1+(V9EJMII|uiySF`;GRqZO_?mwnf0ldaD?-{?K}vwb62)rPKU<^Ht^^^Wugd zHr(DY+_28{Gt<4MoGBpuQFsWN6ohSIvAGMO7Uj62Y*dc>!hL&s6S3X#L{D!daj>Ir zcOnw+O~7UbiD+vy(Yrg+-<=&A*aH9jnjY8|GS&{2YR7teXRf_9C^mO8Z3k)X*)_d2 zV5}S{R-RSMA-~uhXIl2-%CqaSZtSsMY&Mx0&s2J>YN6VA`HZ!Fw00HRb!%(J+FGf$ zq0jYdZ&i(esYS~T-L6|%F;-TJm1os*XtM!rtqoV6RkQw0V)M?*anjPI%%@E|XkN?Kd7*3O<*eTt}`}4CX6ZFiNU0SCyeLS z#F4dCkdbL+N*hJUU{3mnAX{VX51E8&v{i--&w-~1*=l1GWU89d1OwTrH*si{*c@RF zJekl)6J!qWh{Yr1c)<8NMdMQ|ZTJ+dj9)V2RaE1nfM<<=aO7@j5t}=hGo8$gl~#Nh zop>x>?nLP-gin%#E6C|yK5xmqSgBhA_SE}+Xt@zpGG$g2mr*_S(riFgB{OfO6$7fc69!b58BkTp+*_$zLiN=99$hM~>X)R_Er%NQs4Eqn()jk` z6+tcDH8HlA4nIDrN{wT)4VQz?)5PZe2$j{8Zl4GQtR=;Xl2wMyFeuyJWW=+0JH_0Y zOpoV6Bc5dbG2BMh#dCoX&*BXgZJ6yveLT0%H`-plt191}OmF)Uu{l~9j0!owVHlTd*Ry;V5}OY&70Jn6tT;=< zWrN{L@g9qGom_LKH(W9rPHwfJ{qhm3Yq;BJcKJ>p@P*xb%^ot*k*z^1iYcZ$v1 znX2U6FITN+@^*(|cq-&{$F*h$wBK&HnN`SXUT)NI4Ju6)c%Ir}BeO$kmQ~0JUEOL= zw0v8Gua2eqcy@@0q_3sfMKL}il_n^BeJa(OGC@VGMiu1LE?Ztw!A?t4RJ2>j1*~iZ zIkl(uqI~aMvSrHrrkcsUeHjjNl9z3n@)Us$Q5%d^$k}bI!h(jY_)KE+R%U>ZvzaI- zrv&+hlQPn{5`O+~K%D|S{dfLnEr{{na0EC490861M}Q;15#R`L1ULd50geDi;GcrP z8CUEQS8bQ1F6DSz8@RI-#oN|aURxd-&wAkFen(;7JOF<8_X+98LDe?60!NY=5=A-Yt<_FCa4UaXvykSejV$PRl^2=fYAW(hf@P+aXkZ6gBCDAGZ+A_ZEAqp3MEDZHkYcZ z*Z>eP8UXHTY5*;+XMoqB1q@IP27ueSXn+nSMgumKtNOqIC1fz5qX7P1@L2bPX{4B} z4MyVa9lh8mM;zl2y3yZFhdN2U=`6UZ2Zpl4-ehVRT-3=_a?q=Ub{D|0>t}S41w*O} zhkw9IpI_BxrK+&F03KXeC@qYa3?r+DIFf_}7(}}gA*_(Gt%d`=cp7f?OlH$tM)LWI z+?LIo2a|9fWs?I(QrS(|;l61+JG^;td|)$t!*ep9%6Ze7d@7qB^JLo_5!$k!wA77K~W>?lB{t>)B0zcA-W3S*@ZZgvL>=T8QmR$|1~ACZ||Od z)u;GNU7b;1djZ_ItYu`CvJ`;R4zPh`@WGM(S!h~m)I=69OMNVA!tl?iX;A?@v&G>_ z#+Xt$K^GOk^GeH#0%-L^N;8|^)_JaPGhdSFRmNx5kp z8Wn|eFJ9^Uh61<{H&oVoqvYJ{?a~ube4M&`<#lmbjSp=ofZLFjCTACxCdZ;mI#vJ= z;kk_NqC1W85Jn?i@jZJwB5Y_DIh{&A(y&j96~IHtsxkQ1$cpsr)PQf0RWsGUHXAk3 z0{95eW;#{$vsIi`v(p0pLRL%B=T`9?%|^?v0=Ntr|5ubO)R))xXQLuk0JkB7N^8mY zR`EcW)sRV#hR;nsM&Lta)f7E(jN^57YQT@kswsy&l|5>+Q4=kILy z(a2UUd+nxFH5^i?y0ZWtMu@^H`I=hG9=meY(v)6|4ybN?X8|0ItcJ4l&Y+6b!Qg97rDI>W7SILj46sSCa0!&YXN+dOdM>4%p_&S zGa$CL0B%aQHkm*SwWmRAX8}Bx4BinkZ{V}tpaD8XFI4pQgFR7p76hXPGG$EBfNfxI zI}92wH!3L37EH8e2?F*GsC1pfC){O51;t>VDg{**^fL;!UTTDpOcS#OVCyBu%4DK2 zRIX~-(D#dtrjx0{5QkKw?_~vAD75n;qXIHhOi@tJ0#LBygdt8KQo7YBUpHnjV8?M| z{o-s^y2zP@XXi3Z}7O$E2|br8|o9HE$*a^2Yj8LU?9C z06+il5$+Y>E&flTThZlc0Qt~-`TyWOfOo?#`^V%%@b3Ry>G#q%VCTB)rAgS;Y_l{6 z-U|4-`#$%z?wq^N?RCrWWx%hx?r~k?y3lpNb-MG*u&>-1PP^l7$5#6v?C*s=-a@u# zZTGLf8@4XBe8+OLrQfp1{B`s7@TLH2_+rB~4QDjiOdmI04DSj2 z8VqCqA}3+8qVaE0d{?ZI5gU&hV!cWoE?(XYmkBKtZaoQ8RzJBwQXOQ_VjT%c>b!Sg zxJ(fxFmtg=$iS_o1Rwf3mPAj&7_rR`p#4n9vvtXvAr$`jj)WwhEZiSQX@gR#IW;1cPSf<_&PzR)9&3>4ZwIY9$rr zPSmR&DZre@jB%A*(n{6Kov0rw1|~!bFt4#X$W^VRquh&Wb!;!d)JB6`$+4hOswEwy z8`IX`Q7F%Bv_846kyE7dkW%kxhICEaQ7BJsQ~|lR)lg6iNvvRJAy=B5se= z?E}`(UdWarz^I1GTv6#mDU=LOl*&^#R5nDo1`8LKVh*XM%7jtTRJuJZ4<`!4L}`Yn zHJMqWd?csGaonhYnIH@b>f@L(;z;HOI>VF|OpD_Mg)tiru*Y%yv=d}*s8YcKS-OeE z2pE8z`Bu5VFiN0?dvMwonMA7EDno5-sP39u@qL=@g(HTOg2ud(F&WuXiYR6w-!cNdsSv^`|`&ZG{mMfC@2<1RRCTE=C3HaVXvGO*syM!Z3L~0iH9d zA~MAo6_r6|wd^Vk8D?08+%V&c@o6r=hErl!A!Srio?51;s0U=EFld+|X#xfFuT{Q&Mq7#hpsfPX;AHy*2nRXDU|1w8vz3;XLv{R36{uxr$Z69uanI8fKbM z;oQ;w1xUaXkZK1}$Jfz+gbMWPraFN{j}yma6)(0U%&BAW|}b z7S}TXyzz7>6EQHrZ!lnI*?=IF7!BA_t{MOX6xCpWx3FDo-b|h#{iK-OV6;W{>}ice zJB_}apkE0tDr_q?rMG(heq&QxOHJvxuOMLT>y}bqX>q;2h7DSvuezbHp^^dMM%N8} z#ryv~VSc@Uev5t#d*y!)Jp_B?zZqQ*d*e@{Vb~MD6KzME(Q-5oS>->#4t?K&{rNr$ zyYszGew}=8I3p9Q<}?T{6Dg*;ytVb}X-r0>Fh_aBqq4g1``UU~)WZ$B!X2YcJM zN;>Rmf12cU|Hb`F_Yd5UyFUqg3*6kqE~g|7~L z#`PiBJ6*T9u5}e$IadmHvF~tgb**zpT@dFPPx z5Uf3Hb8d2aoO7KP$L}3Kb$r|LMaRRA2OMv8yw>q@$8pC6j&mH{j$IDTvC^^7VYmOu z{xkb`?T^9^_4nJ~V!z&g(w?&q!jAN9_Mm;OeUaU1`!no7|2^B6Y#+5fV7twB1ME1T zx20@n!annU+iKf<*!keM;*Z3y!=CaV5bqM-Aih$(SR5745bXfmj{i*c{ z>u0R*v)*ZaE$kMbvkqE&tWm3OU16PPHCcXR`GMsz%SSB_K&-^ImP;)e%Q==V%T~)q z%jp)E`H$vj%-=MB&in!MUFO%DuQX4Zhs=HExH({6V?NF7X!ujZ&l(s9Z;cPZR~P27g5OI3oZW{9)l^MFDzM5I$NI zC|?MKhl>Jq?iW5n+2YhLe3;5tW^>`8q5y7Dj|dMk0??u!7CuA;igUB@K`KxQdlEiS z6u=sM5#jxe0BG=qh4)c`Vi1$?UMf(DTN2(=6u=s^i12Pk05oV};ayaq7=zTcooYAgjcexU$}~8b>S5( z>l3bISxtC3%c{c5SXL2U%CM0(;R=?G3ny7NCKOmUD!fE8Hvs^66a?$fyt~J4O zk@HwCd@jp{&SAOW*(?`0i{KE|v@KVN4CcRWZ=XWcXuVf7=;sM%e3*cCZ<684T@a zGW4KVZ)<0o&;xBO=Z~|T9%DIQl;yNmmQy1vr|e=lU)xTWi|=5$*mjnSZezLDtt=PW z!gAp-%Y{NL7YwpoAi#2dKg;Pl%lUjPr)eywsw}4{45zhiX1Vw#mWz2=F1nHBS~sv< zWIfA;*Rfn^Ez1Shuv}m@%lTKaoW7Ffd@U@etzbEIIm;;?hEv;`SuVbe8BS!+m8EQP z>k^iWEM~dz=`0so#B#yYST4}Sa{h%Zr!QbR-+Y$S=CPbQm*td3hEv++uv{FmTuf%U zsKj!uZkCIEd%Nu_+Zi@HJo#QB?y~+B;{MOEuCV;na+~F_Wu^Il&2KiJZ(h;xqlSCoi8O5b zo9PkLD@>gxS$Gs0t@_(p_ScbFHe3v9WTFvEzLi?BI5-{!+;vPmQFv-NBUXTypP6W& zax4xg*a}`cCY&i;4oE7yZR-Ui;mY8sV=I?qk;;{;S}rXa`=N3eoOH~_AmLKfV#Pby z8;{4PINo(NpoPIb$5t&zAeCKG^{b8V=7S7Q6*BHyQ#T z8e6jzb~JOFe(;_#JMx9gk%pQpj55623-q=w}Houtxen- zQ)<`pY2e!1!A-_&6%{5f4MXj^{tR3_4$d;bsLLKXGPZUx#;B@xT?7UU2!zUKbs7EX5dg0jgFvW!PM53C3V{%~x0pc}B4>74 zMQL8C4hg|PhQO`G)+DF3p=Q0Fhryl2oR}eUPM2#=+v~ye=sPT>JI)X}smpcQE|>g# zQx5vfuJQf@;os+1C|^tE#VUb$RkGO zg^x?mwBbQm*id=`A!l&uYKC9>i1bGHcJGe#?uH}P-)(qnX*^9{R8o~q0YwXK?(XRA z+tj;nQwXp8_#nK3`i4nby^m<|HKQ{&=4a?`%}=Jv>suHm0P> zNIVk5=U?%ysTaEA{otyEQ+S{Vdw|S7hK8Sa$9MJ^ZdL+hMuH1y<%(Z5E0F_R3LJ*5 zn|6XPl)2jpkcnxE0%mfWqF|pXQho#nT5X%Fz@cIj2rdizP9`dhk7 z%06Mz3VDXFOb){xU_>K%RhSmzC;FL;@Ra_XvwvCG3r=NtvVnJUX*+nKV48M@+l_XS zb%bf;>1LbH!>&l1;R>RcrvJ*RRGz(=V_A+VBG)nSYBLuhy*U3f=fcb?Vn!9@0${kA zo>9dv@QO26A)VYUvNzQ;YiKoUAUBMlp?pTns9`&}*O@z3om^?k=fjNEckVKrQhNDj zv1|c8rhCbtFWxX#5SXFBZYPGxZPW-X>g!mcw7W+HIo5fd&mxyEHdGP)9U7{+k5a)>& z>+j&3>))_`*7_mq-PSk4x7IJUW~>S8K5N9PSzE00tyatLEkCh*)ABjkY2clfH(9Q+ zTxJ=!oNw80X|?z)D=iD)L%M%3|J3{~^XJVEneQ>*Vt%#xa`S{a3GW<4&ANG&d7;_X z@P7?YH+;L{3k@HJwTfFCu5EZp!-Wlp8+sdJ4gQAJ4NVPp(;rRGpkJdW(O1zY(fiPy z=tlGkbOK#~&PID6N@5dgMswgRiob!c7=BIul>C1A?eb0V^}>tfBl0(Q;R}jimp(1MSGoftB(9W>OKIsWSZxVQ>mfeE;r@gBXYOygKkt6XeUJMV_p9BP zLv+M>?!E3^Zq>cQJuau0xZdNs&2_&Fdb%DzbVPGy6JqD^_dbZtC`MYS=Dqd%POXG7&h2uI-6zVrn6W!X6k3zsOb>P zwweyIY{YaX%Z5z{STOsy>IH$_-hH|=6s zpJ^w{YNj15tD3g6tYX^6u>LmFR+f#Mwy6lU3|Da5j^rXb5kOaYb+oBS*rGU+TE zH2GLIVA5FDZ&F!SHz_RZGi_#B&9sSSRg;%x71KtB)!R%PST=52&$2PoI+l%^*0OA? zX${LpOsiQoY+A*#A=65h4Vqe5Heg!8vVPNYmeoxjmi3vMSynSGV_DU-lx3Cw$KIE~ z$8l77ch6`}%}mEp97VAmk7FmcW6PfV@DY11*^VRIv1}(!;wZ8-mV{+VnUUi-9KoXi z2{#MjUe0je=Ms)^gk_gw7g$)XT_C{1VwP|$8(=w>?^V5;H`P6dY|?x`mM!lRO}~2e z{?)Hu*Lz*{s#;$vUB(CWCGurSzf`^~>V5KMLBB-4%2<;#qIk$jof zd*sWMexZDs)ECK@3H<{3GOio)WlRsrmr*?^UqPmNAYaC`|B)}F+VADdi1s__ zGCH9BR=zB0zmYGC+JDQJ1?|`JWnTN0e3{dJDPLx_N9D_m_FwX4TKk23nbQ7KzD#OA zmoF3A&*aOv_EY&Xru{^|jB1a_ml5s9(q&{o`w#iDr2R;~ENW-v%Yybp`7*ElyL_3` zejs0FweQQ98SUTX%e3}A`7)(_SH4VY-;pm9+Qaf?T>G|s8PmQcUq-cW%9j!CU-44p zxNHA{NivPJcI_L2fo0v~bK2J>2AG)2XSJ_E?6Rd1zntGMl-4+hOaGXl&{@UE^2K`M4 zT_^x_Zh%3*Q7~{yNq>WUS=3)IUl#P&$(MQke)%$|zgE7?>aUS6Gy1C~Xi!Tot-q?< z*#{|+|wuO0UV6N&Sp`nb4msU&i&j6I`KT*Dn=ueO?vjh6$<;#+Or+it| zPs^7D{SNsuub+}HbNcP_WmZ2aUuN_<`7*6n<;#>_kuQ__ZSrM8pOr7;`iy)T)2HRj zsD47ejObHr{r?*6uQlTVW7b$5`dR1!IRCE@@&~^dd_nN~U^s9V_W4%>n*uuI06eXG zXSWaX?Vsi!@Vj7MVBQz?e%t#o?@8}Q&soTezr}O0`%CWkx?k)*?cVQ>yDxS9*7Yt| z8TR>q9dg=tIRD^$*!fAwU>|da;Z6HN$DNLw9NQdWc(4A3{-FL!{SN)GzE!_W`xT7+ zABILPou`c$jZwNW@7XsGQ#H87Lk!uCvZeD|>gy>3>>=jkccXaKJj}H$GuX^A7Pr}0 zF~7+qg%1S{DSh+VdIQ9W-zZx;pQ%@E4F8SdRrBe31F*PV15zd_(14P>vtvG4Z-8(Z z4TPohiF(zB_h=Nanvd5TfW_?^5Hm@E1{6&Vh*AU4-!u@G&PS*!IiE&xZhm8ZzO;y* zh%M^ienwj8q`5jgevZw3)$=vrD|fNJ`3(fmWh5H%3-kT;gAq_Z?Ivusa1HVsi%4RT zg(2kT*PB~O8wxE_plS825_VdI+{)biIi7W3IsHs?rP(Dp>ZdiqK_8%dVtuQbaMt69zA0@Y#^=OigNzpAcuyfgFads`XiyIN_!D_Y>9S=qL7V}(`LF}Iv+Nf20Yribi*_*o?q_nLyKcE zQ=?$%{AE;+0;opus`+L0frW=pg?0lAop9KQvKmtQ=9iF7W60GgTRML!HGtx;M)CIf zzWN$S0fR9%uiLjafbdaf0es1=;YRD@90~}U1qcUa7GUvkW&r`S0N$acf?0sY zy_p4cn*`u7bWMjFuyCNRhzzY`rDMtH)$?#_AubzoCFZyzYbl_v2(y4K^S=5z8VcYZ ztfc_a0u%n0c{sQbm*KFwk+ich)oN@LnnF!c@7g<+>wf zjm^wE%vHrozZ7Q38kVaj=JopODx&g{pe176Fd1TE9uDc+ibE9}Uvm6$H&qjNN&U&Y zq;{9NOTf zbR@kFH=Z2cDYeI&I;BJ2HVj}C8DmvAZVQtu`JHyi*tS+p)J~f^rcJIkt7huY8XT`B z^=G!t3caP;Kn+gR8flmT=4a#RK*m715!`g~$X)s5xD-Sd12s5SOBTeR)pmk-7dM~X z%LEl`C+mmqqU!i_YmVGo9Mf!V4}1U5XdlpE-~TBi9{R7)`$Cn_#^8^GZwfvxxGwO+ z!0TXN{_^f`bl=x~jsFk+_xX?eFZX@j_dMS&pWFM8_X*x4ob2}|&r#13*!}(-_fEGC zXa65}t?2q{*R#7y&fh!V=d3t4I)3PQwc|!dkNySyS^9wX2LODG|B5v@9qfqIA$;^{ zefu4?ST&1j9*fSUSBo{+c_6o@d0Zkf9a^)!2Kx`B)9140F^WWVu&?bk*o7dAFppOx zqC+c+HQ0+F-wYqSTC0||MB0o&PZ~GSX|otUinSKQS|h#qn#I5#1-UhRJY$Zn^>f#@ zHLzbn7DIVS&0;#VW_t}bFG%XeZ1!dm9rUuj2D=z!5j;|97SW*I@(c#-4C)Pdf#p-sY9D~)?g=ucRfNCM7LtBcqBj#J8k%v+l&!sWN8f=k}HA5p$vzQL8Db#K< z-BY?LFY;U(Q>ejC2}v{QdUoV_Jzd2b?3R#jDNVC|&0;zXKgAksnUKZs;fD?F&0;!e zW_t||G?NC@v^j6`T-36?cE~J(k2~kpic;;MX)H}QMx16jOerD>)EbbLY6r}68skl~ z9Hx~P$)p%)4w;71v^3f@3+m9Iff{U7kjCV+G}bf=>d>McHQ2BqjihNl(y&3RI1mrk$t`t;*M6UxReK zbXv^En4+HCe)9)dOokMTz(Lorxj_~p=46_Mv}*|n*iyA14_j)m zOF^0;O3^G$qk0GQ?KRk;AdPakSEhAOw6ajHM%bYsi)emlqMm3qs&EmsLxI0_rkdZE z$R66Zg0?82h~{^tg<7$t2744_Rhr+E7HYwk8thS!tDE1E7DCU}V21+k6~P;zF{9zP zs0nS1AywUm`yEpFnndfbu$9Yz} zABI!!hTK8O(w}#2>-ufihv5vnO^|i}K4;Y#aXjL9i(}GprT#tr<@!P0&^`^2^eR_DKw2Ny92V2Hse*X26+KeV3TY! zm%PA;QLJUmV(9hEf8-w^$ zljQLch@dEVp+;=0CCn1|@YN)tLmRf&Am>5W1dU$j+=%V9m{|mkUS<&;TCuGLITN^T z5;akO<1*WNyTBPn;cc~uSwj6~%PgTo8@AUV`9ju&c+ZkT?}fT!dku0gWD(*e%POKn zE4I`iMMD~hlY9g--$VyBY^gzRhFqPG8|LaA&~r6P%i#TxXp9(U8SNrb=!skn(lVrR zJkc00%raOz+W6Fb4bnHH;V&VL7G@zG8j`QAF$8$=rY5`3g+JBGG# z@P!)Wg2+;65~t-D+D0qI8f1b{Vx9b2*7EP}=k z^AOmv6_=Ss(AZ%X(V-RFYLI3kjiR_u6}*Aj+B-C2TWzUXf|!`GNa)xGNK%p7Kzmiq zchRv8mzpKi=VQzgI<#R+4YFONLD0Hibpfl~QiDtvxjG*=%sT9Vesc{HTwtlPo(+L} zR@sOl&Cl0&uCh1OdTIoG_70AyS?H?-H`gHfMHWv(Nel6y=JOPPxVG5zsgSrd#?WhF zz4hlQJ6D5D8ObxmrGbZJ(`sz1{6*Df?AM#QD*8Lu1waaolvxp{X&b2zXmuEG#3W!_4Kilrr-Wu}Bmr$t!(mN$T+J|lKPFDET#|@Z zhvKl>rjw8swz=&!$fl77*?6m0Gm(eV;_VI&^MY9u$fuD-(3_bwwltqaR$umFB62mz zsFAzAS z@(g*_xPK2{_Irc-9(Tm`Q`g&FCtMlWmH)S0Pw6UkUFrO>^WDxnoQIvu9N%$#!tppq z#^Kh#tiKQP{Woj>1K^M4-{z~`ef`1UZrFyCjV7v76Jynp;p)UlY&e>Y9g~kXs+7mZ zPWR7ERr`^vKN5wr9vYS7mde$IDsPc0$4r%DnFWzIOO>OR$_pVc+vHx2nYocHY(C<* zF6DzqvhKbm!Qc)?kEBjafkrZE`DWO+>$xI$S$`xF$w)PloSI5@u922%BzTQPqGOFi zDfjB3U=Ti653Pb@wb`FIod$DkrV>6nGJ5>fNM$TMI&)%nWNz~4fUi_BxfwYFbW_6J2e9mjpiClq4tOF%It$sZo8nU4$obV>7Hc#sTI9`&ONptO zsJm~kXjZgP9%WX0v@$X|Jv%cqwJ#ZsRia?2(Ks_@c6DrY6Z|iejwdn^sb=0<6NVY7 zCcBbqZj@@~tTkb*l4`Q6M$Kg#8aERu@SBNByFjk)clSlHYcr921#V@wTIK)4)-TkB zSFe}q=PdQ%9u};>PO6`^)Q3A+sDAV+cVBP4{h9JuB9U3BN_;K1|3PL}k#u=1mP|&o z3su^-#@!dM?ROc#?%O8nFAe$c^k5OT(EO&qHooOt9htPJBmLBo2}?)9uzt=R>1(tOJ2GKUM|!Cv6LLpF)377Y!DH|BP)8>0=}0$q zWWv&s=g^!(E>cxYsLGa(EOc?F2NRJH71Rf>i4oyK+7Eg(I|SRml6#$`tC%gi+Tn2b ztqlgTRc50%UrciWW zW%BlsxpMemwY*TIx9skX^*7UjB6nB=MfuJ2(A_XMg7aEX&PHbVW5>I~vb*~UxPZU~ zGw^x>E+BBhCg61hoJZii8Tcv!&LeQ%Cg8OMoI~K88F&o==MXq&6Yy#R&LVKu416U4 z>ub!*q%`!6t*irpe1gcf+)ZFfN>K8OYdl$14dqjlib)mP2=6PQYnYwLNdZ z%kEx5z$pYynN=Mo;1mMGyQt~m{UfJ|?_N&8Nd!)sfiELqc(q{9F}yq30=$fX69}9z z11}}u1Oh{>Zwv4e0*)ha+zfmv0ml(IZWC}H0b`6m5HkZ`LckEkGF3y!Zp$6Ryp4FA zqIkzqGw{U(97W)$O~5cEBY-0a95Dl5M8Fun4@7JN?x_Qp1~_mDd|l)Gi$ix`SOcE?NAsmw?e9m+bP#9s)*xT(bCM zHvywRF4^UeVRk~?G5X_@#UFPOF#6+?UH;ff!03-l7Juv@VD!f&d;Bqb|KF}X{HJ~Y z|NE{tcJ1xj==_TF{mvuKt?)I#Pdbh}cI*GC|C4?i&ivD$0e_-@%W5!jf<2nTcq3(w zH)>j4u~5ziNcdyRICbVO0>*eFWsWz_Jc)oY-bmRLZ=87|0b{(8GRGTdoqh8!4OOjWeeS7~_qUIo>#P2LWTe0gEs8^zkVI#&`o3Vq_;b-w zfHB@kn&XW#vjmLsM$)Eu*eFX^uC} z93xj@bBF|4PXywf29Mt_{N%O4*k zVD!gHi$6X5li;fuWrFWB6ka@O1=?{y1Ur$NLBv{c*xB zf4rA~(H|!){&)`oqd!jA<&Osm82xd=;*YN-VD!fcyZrHP0!DwFu=wL^2pIiw!Y+Tj zi-6G|CoKMWCjp~BPT1v-cMvf8R|&*M}e zVD!fci$Bg2F#6+!J^q-z|L3%iX~x5_{yz@u|388C|C6x(|2tU!pNH?!`CKM06VSz`JBDoy*vC-L?il{q0~{q_j3?rjcmfvn z1u(`Fal7IPTHlXjJQ26V6STe$<@}EAiYI7&KaTN4+!9aF`aYC%V7uZ8THlXjJQ26V z6STe$L?jK9qA{yW$C2-;ZNF5x2w>w7w7J9N3f~{-#bE$1#gPruBU&=fHOP zV_M&jp+AmU{4uTXLpcYw%OBJFehmF_%;Jw}eILp>uwDL`*7sxRk7E{pOzZnl&VlXn z$F#m5Lw_8z_+wh%hjI>Vmp`WU{TTY=n8hE{`aYC%V7vS=t?$RsAIB{InAZ29oCDkC zk7@lohWg1LgHhwci^g(gF{goZ*pL#fcJP+uq%at41Ld<4!B z_-gQT!4C%C5xhV668O&j6N0w|hl7K`d@vncAG|bZ1iJ$N9r#J$dx5V7{vq&*z~8|4 z?q3~vVc_mSC2(tCPhd;ns=y@yclUpFf4BP!-4AxZwfm*r_jKRhJ=VRydwchW?xo#+ z|F8W&@PFC=3I99cbi!x)@AOaj5BZD!m_O_f`F`j7k?(81Pr;c5ukk(C_axtx??&HF zU&^=2ccD-7{>1yQ-p_bH;C;RK1>Q5>Y41(mo!+GPatb z+;y+(cGswDpKGh@Dp#+otLx`o-|G5o*9W>@*Y#IjcXds59qtVyDybGsia_pK<&R%#}RP@g&HYxWQ3!#2m{V0r+C# z5A-kTf8?CWA2-vYYW*oSaG`TGe4G|0^;mGh=Ki#Yh{T2iHr=OvLXa90UE0Tq#JM1r z);=akjaez}qeS9d5cp~j3Q}VVN&5(qI2S~z+J^U1jArj|;U`>0!AT_2vwD%E-b3tUKy%$N+9EJ8CA~7yDEup;#oQv7lJ%ZGTz_h0kiE}agda58b!X@n~MB-e`zU~&JMr@F|2 zBM{Nf5Q%d!`+D*s=V~1^jP@0SxA?n=!kNIlo+Kz}T`_p!ej-sg4;a@I1O;s?2Jgm? zCkp2Q)4Eeo(6VCia&?+0oCgf+4naY?ioqM_DWY&5Fss`I1+6LuFNY_I!g;``=8zJ_ z_c#zUP$dfEfr5BNP|%{H@XB=?Q8*76)U2SOJw@T|Xoe`92h3?&P|%v9@FH`9D4Yk3 zX-ZJgmZI>^ahxce2TbX4f`XP5h1Y{gqHrECqzOSmJBmUiKTZ_R17>tgP|%8^5OkM` z!g;`m#smdzC<;Dslqj4BOz5bfpan%ClpP@o=K%w{RZ!4=qF_J6MBzMOKeq@9+D{bh z=Lk_a57^Jmf`axF1^c;)D4YlE=SD$6`-y`693~3q0sFZ@P|$v&(DBz3h4X;@91;|? zpD3GLAPVOJ`#FG=2-**@3=9#4@jyX*zo4M~M8JNoBMRpM``ITbXg?9KpS?ukJYYY2 z1O@FU0`@aV6wU+obFHAD{Y1cib`yp3fc;z}C}=+su%BH-;XGhJI|U_+$0ULM?C?AL zU<7~zy$S>LGhd>gxg!0{7U*XtPe0Q+`kC5JKa<<&XX0x58Q)4jV_WEFbaTB`bimf8 zdSN7$rJuKD#(EVx zk7+}_Fp}@5pSkt)GrNv{X0D>2>9zDTwT6BsSJThLm4w7^3!c;~1f_9O&nm$Vhx4qY zpQX#`XK@AnEQIN2emVWjT}D5%%jjojDg8_@p`WQs>1VQ!ekLvvt!kXu(kluf-S^^p zArG+GfhmjYg^~D0^fT5&Kcg3lDvg6f7KuVgSGu5Hi1HxM$fy@a;vxDO3)0VMKvZd* zU(qcJVY$*@FGP8;&giQbM&e%j8S~K3s9RLQu_pp^%Pvs}gH9Lyj5+CN)Nz4xwGSRF zoTFq%2>3%8?alhzw9ply3xj`v@BV!~_^IG~g0BibD|lycJa{0O3vLK533>yM2EH5k zeBi@@H^KY)d|(z%0Q{i$4gY)Job|T_js&g=qywu0i@N`(`#-wB*8R8L?}iMAXLg^4 za}I`Jexbkn(r%Cczx?0v|D*py{x|yX^Vj?{{+s=~{3-uR{{_C^!|cLWeV_Eb%l8W3 zGkkaW%D(-+ZNBxsKKL@?FT4*MkDPCf1>Y|*R?48$%7D|-w-Sjxn@|$hw+Irttqiy= zeKV0b7x*oGlOUn-WWaIhSt4;RFd{u8Na(sU;JWlQkvJFlEi8;QiZz=!D@1PPs3 z8l0HkPbAI-UQAyvNa)7W;KuZIMB-fF$MmZN2_0D)9GSkBNSq5inZ8DlFceCIE7MmK ziF1Kd(61CE42IGW4Cz-8iF3gitFICy42RO7FnuMFI2Xi2`sIRz0Z|$PB7FssI2S}j zdRUM!BuYa_q%S8D=Yp6>zf6$OzS7LTmJx|_G5cC7NN8VaW?xH)#JQM#T`EXuUukAv zeMI71%)TxWB($$Iv#(wvaV}F7r2-KeKC`VKBAfbJ+9Shp`iNv|UzO;W6B(yKKy+HdOkvJEd68Nqlp?$Hv0NQtm z#JSj%z{7%s_QewSwQm!Nb20n+mLQ>hu`GM-n@Gx{3bX7HJwYT^5lZ6vlpvuBv+NN) zP9)C79?{1M5~?uE9??l6aW3|VP6!gJFv}j%aUyXp_J|%6BvfIRJ)&hIaW3|VjtLT~ zFv}j%Q6h0J_J|%8BvfIRJ)$E-;#}+zy;YD?=nk&c*C&yC9)`Wte?! zBNFFg_H{LqV22AMv5hX;zX}o>6WgMq{R@#e7aQ-tAxLOUY(I(ibs}*tHr{_tkkFXe z<`3{>w;`wpD0fA`;_bJ1Mk(5+pPxwg*D{B9S;3 z8}Gj$NN7xK!-DpCB5^J@-v6T@p)s-D1=>FliF2{>{&Rwa#>BP)XrCn#=VIgiX9Nk2 ziRJBUpC%ILV&nba3lbU=OS0Ggj!2w~jrX4tBs3&_Pyfa5|DUJ*0Rx5VOxM{e?S2T^8yU!GBE8AfN~BDBfBlYv_GJL z{-FT=K?bJ%0Z`6?!CBY>O#1^0=yD3+a%5oI9{}YX82pbdz_dRAQqfq-Kmoj%3{3k2 zpqvAPOS1)-_6OuK$^!pm0;c@|P|kqik3GP&KOm33HV?j5zGK=S0OcGQ9Iq{RO#1`! z=)UvdzGYzA9{}YX7zS2bfN6g~9wUN0L z&VeD~umza*2jnqs$wS;C1JnKhDCfWs#MlB%`vdYA3gsabl7VS|0F-lJh>L6iru_kV zjIQz!UCF?-KLE-(FvRV)0Mq_}JVyU{i2h|@+8+Sr92i~&YyqbI0eSStd5b@${Q*$U zf$j3gv_Bw^{y1;($Fx5H$~mxI{+RX$1M=vP^A>+h`vahy1KZ_~X@3BWh|C}7E&iDH z2S7Oow#y&W{(v0%V;C__&oS)}fN};5f9!dVX@5Wt{c+CXk7<7ZlyhLa{4wnh$e};Z zS^P2W4}fwGY?nW#{Q)`j$2p5Xru_j>&VlXn$Fx5nhyFNc@yE130LnSAUH+K%2jtKn z=Pdr1_6I;Y2e!)})Bb=Q`s19%AJhH-DCfX-`D5B2kVAi*v-o4$9{}YX*e-ue`vY?5 zk8>7(O#1_%oCDkAkN@)D|7rh24&#lSCEn2L`--8QE7qoXqjpz)Lvs$}jhs2&s6B~* zG2Q?+TSn&E6A2jOjhs2&s6ByzG2Y186mQfXPrw*&*eFXH&dUJ4wJ8Z{*DJMs1FOG2Vdj*&gGn z)`7DaZ@|DT#~Zau9T>{l;{<=~0ltlZG2Y0UA=}Z%VZj1dQkP20i!?8*!2Ei8zf-#$1L4L2ELYn z(I02*^2fUg82vHJAd!KuAz<{!8N2-PE&@h>%#u=M;GG1F{y1ZoKi)yW=#N>RiwrzK z!03-NcKPEH0i!==sWUQgk$}-3XYBIF1p;nlo3**w7KyA z`*#cIFEt)9zG3{G@m}NA#{={{WWn02nqr_79M3ya( zI2VK$y2!FEVPH^#z(5ySwm{-s5DVxc%eI7GyaZlc7g@GI;#}akb&+LTLYG?tm#d2` zTOe^R@UptdvIS?r=EqCmA9ays3nb13E>Ra*wncoogpqwf7g@GIVqEYiu8S<&B6^-8 zcphD3*#e1kfwR#?mTeK;L=lAPBFh#?oQtKvh%DP8j_E}h({+($3nb13!?-T8Y>PMm z7hwR_MV2j)I2Vk$y2!FE;%Hff(NY&#wm{-sFhJ@e%eII^TM>pfU1Zq;iF3hdri(1w zB922v7>9I`WeX(E1w)T6vTTbuXcSorpUAQW66b<(LKj)KMU1$M5OM1w%N9tS3+zi5 zS++&AuOhQAk!1@c&c*CYWZ4$czKYDgM3ya(I2W@ok=0j3`zkW~5?Osf;#|zWL{?uB z?W@S_OJwx{iE}ag5?Oskw67wwFOk&;B+kX`OJwyy&ocWeGW!x)eL&(|%)UfcUjgk4 zI(tADS$#laT<|BZi>$r^+E;n|e`=VJEtQb9udVyoQxONhj|n0>uikkG!^lC=IJB5^KeUoR9S zv@f=Ptlvi@&c*EO1xS(>d-dlNiE**@UHz{F3GIum%<9i066a#}^;|(h`(n$e`g4fH zxtM)DTaeJc*qW#QEFy6(W?#<~B(yKKK&d~2NSuq=*V6?F?Tf7@>h}_fb20n6N089I z*wUZ=G$L^>W?xSgB(yKKPNzSGNSuq=*WH4I_Qe*}^m!t2E@odfK|=dtD@6JkB5^Ke zUr!b!v@f=tqu)g&&c*EONrHs-#nxW*ClZNsG5dOgAfbJ+g%bVoMB-e`zU~wxv@f=* zp`Rua=VJDChajPSu_Xxo6p=U=v#;9)3GIum7w9L6#JQM#%^^ve+}Eo_Vq9!KU#|!f z+83LN*KZ>d=VJCX3juA9IjGf;OZqOaBO*v>h3w80xNV}EJ$dD17L-Y#YEyKv3>9|mk z&bjtc|{?TdXZ(qRyZb20l02@={D`?8}WNF>h1>?0n0_hjvqNb>!>;o=)cpysXs+OriZkj zLL=zkp)<5952qHg8-PEyOk2{TymY*fG|fV5@=(lS;aRd{wv?9SWB5R_6N@BRT9Joh z4hs*b9k8?@AHye{9fBmm(t11;b69xV?SQ4__!tfu>|7-YmR94Tn8U*GVFxTN#>a3B zVn;Peu(TEr#T*vKBRgPeDIV^Iy?V0KpCnjXiHBki3j>@Tu(S{_9VjKi(mFg8Gg$az z3oI?eM{(F?XH-eBv#_WKl z#rG)sLUx3h1WRl0P|RV$FWLc1OYc#1uIyAY36@sgp_s#hqqPH;7T%-it=U0m5-hE| zLotU1&us@RExSk2{j;;vBv@K?hhh$ElfR`!_bA4z>^L?Fme$;%n8VuSZ)wRrivE_J z@Fv01iaQi@SeyJUEx1R~-?BsFBv@K+hhh$ElfR|q_9*&Wb`G5cORMcr%wcWvx3t(E zMSshVwv%9KtsROvtWExwmfArq^SAu8yy=l@b#i>VJa&jy+M$@k+T?F(p#%VXhVGnMeskPfiR;MP$sw2bIiIL;w;aD<)o$ZLdbvd`Tw;XC01bI!wxW!9j@a6p|nlWP;DInV>rYP#gu`s zB4CV%(l*6IwY3C{0TDZAQwCl`z!(vwZHkC$s|gsVAlT8JGVqlIjB^lao8};DR}e5x zLa@_7W#Cl=jI$7Fn`R+uD+w5ED+n0Lhsqw{o;t8}@U0AdVI3ICnLlQy;M(Gk7ZEV}V|JFV4156rqd!jB<&O;lMt{tX z=aqp&1dRSTWtTq=5-|E>cH*xL93Wuy$0@t~aW?^@KW2vr%fNmDMt_{L%OCp)82vFj zmske&5-|GXlwJPVL%`^d*-^(bu$zFd|@1HITCR zZ2_ia18EO{3{2Su(uM$AfGOQT+EyR~Q@(+;$G{d~N;r^qD#*Z;aUgA5umzY>4y4Tw zGBD*FNIM~H0j8t_aIV~{&GE)OWgS2{bNlee7Pmi7X$R644f&2K??BqGVapv;;(@g5 zLk6bI18ED0Ex?p|AZ;X(fhqSu+Ero;FeM*IdsAd!%07@bs@MWd=?Btw7a5rH52U>> zwg6KCg0zE12Br)IX*-QAz?6a@ZOV~>DF;E?p<@d$B_T-rd}LtCLXbB7*aA#x2-4Oe z8JO}AqQnVrc4BBYmzO%l!_p2aFT&37eU(XWD77QBS?FuWMIlhkTy`+ z0!--$(l#s^nDP;%Jz2H@Q$m8Yvr7i1j09<$mo31Qk|1pslYuEGLE1TH3os=mz&j`N z$87(Z3`|)GP|o}@{ISO$Q(A(wrA-E=yaZ`~n=N-ti3!p!IT@HT6QnJ3wg6LVg0%5Y z2BzEuX&0U?z?7UI?d6k!DLX;h*k=ncr6)++17%>!PmuNs+5$`o3et{48JIE@r0s{c z08@&Bw24s$9;m+!OFJ5E0WQ_w7NvcWGH{WA@%|1a40 z{(rOf1);cEM}k{}%L4xrcuC;;z~b&Nbw96r zZ}-M-!~awN=lyT;&-;(~FY|rJ_kQ>S-mSiZ?@FJ~`(y9tz3=wk=e^x~i+8Jcspq$z zZ+IT?yvj4}xytb~{`jc2!*KU5mPY*7fDCcX!>_RqZ;|mFVhqJ_@@8 zp6p!Z^f(A1U>wEO|x}iM+4Y&MTaW8s9 zD@?VneuJnU%|?^`j4vrwzg|>_!bI!php>8rt@89IfiEsqKZw;?VXSra1EP97m5uf@ zzNl1vNK}WyNbBnRv3fk4OlSKO@l-}u-E~-<6}C~`K2bfHNTvH3pRBsQqB<0|QQaO< zJsM9Y`{TeTt8P$Khr%|hyB4d*(y>ImKMs7d>ULvwR@g>$*NE!LL^j*c_$1ZcxXax; zWEdkcD9j9%NB2PHa%J zd&XFN5nHlD+z_ZD+Rvn?BwH8|`hdbVwot5NGVm<_< zGOaD4Z>xJbtDH!}Gn7hYBs(Z>aWC#Sj3ZDK89fP!okN4M!E1My2EhvUnEL$m$n-ex z?1|GyE0bgW)f2J)6H~{_C;H)Y$D7@YqhL*mWG2guDV|AGqFhWg5=)$m?bIa|H@O$D zMM)VnoqB7|N6L<@d+|n;k|yK+V+8eOP;;?l8b+UFMl!hNY1BFtwYIlQQto9;>q!_k z;pvi$t&nv0EH{kf$!MaDl168yr)Q?7FcA@TbG&@Ie`LH;E}tk*&-F)=`GmU%dL4ws zTi@Ba$cWcvV5cXeF|(S_K}ICzUa~b9T$zkyV0e$#hhd%{yst8O`^a25d=PSm(IKU? z(QI$jy@V+ymWuW#Bhpi}Ji_&|5;n#iI|d_41`;I6m9d)3HwuPimNTTH39v3n^zsdL zh74y&M6xL}Lw}ti%^Bj6v;|^)ogu{;V6iu2l5p`lcOQS4V$iG7Gra67_agQbCZhG{ zXivRmnQS(*WUYHqA1jXRD1e#^Br_Y$WS6YL!g!V!=FNqxMPY^)=FEjxio*1c0vK7* zTzG{jOz}bs<|l2{Q~QMT&Ea}Dl!(Q) zUdC^2JeIwKyQcH3zG|6!(K@*KSfIuQgg`12ItK>@4}nSk?efg1&|E}V;ISJTmScjzONbIGCpoR z!a(cLY$L!SP=;Yk+=)_1RHq7U#tERcc^hl#(v_-|) z%H)Z1tBQ+nb@#GaafTRatjZ;Z-7DGRKq?9AK+wuqI=jFE0h`fWa*MdPL@WslR!~=3 z?Ft{kwK7--Ptx0e<0fFiJE%RXCADX0#)ph&8N)_2^!w1KL-&QoL)nl!_@&@0g44mP zgQ39p0`CnxHE=`Vs_vh6f1vxW?(4c&>Rp~2{h!vJ;dDA);)v*fr{CiHrSD$fQt$oV z^`7^7dfZ=j-{;P|e(HL;YggCryWZ7xYnS1C(0Rh~dB>9-QT@C6^YtQtKT75_vCJFq zi}yH|-Bf`SCuXbFVfb5SM^C^!ESyFG8#34tFC%j&E9K#lsqvZ0nTEqw%9XjvW0Rw7Wrb>c_G<>gYT6B#diyzWjsqVbtwtND ziAa>{nNiOYYAcNJ%=Fah@yoqY^DVL4f;)m)aDGSmHnPw6j>cEC0fH%R4wSPCDBK39 z-bY!RfF_o=vxd6vQg$I-)()wzyrfM?dz0Sl`>p^B?>TVo&~T|x+*umlKa?B3e%FDW z!@1HBJMCk5M`3VyXlIUN!v?a*=;Z7~xiVZmIXPF3Ziu0=!^n}{H!!p}v4Fs6EK?WA zJE#ze4RjP3UqB#hVIg0TS{Tc86c}4TAZwuzSZ`svlfY}D-a)GlL6cT377BxKmVYEy zY$+=ZVxB4(&4cZmhLP(Yx1eSWsk({>`^+WnE^Wf%hUf-ncHBzX`D$hfk{8%|m9;v%Ct34QJ=VVZ zT5ENymZ5sIZFT>s=4H<1h9xb%3XUns$Kq9PU*p}2UIlWAz*RODL>nv1l9P!RIu2j3 z@_deN^Y+#4aN(xJiZ;U)!)COzb2|^*E8F2hze>2=G9sC1XJ2>jxW}&mL*M}k>KWFc za7nqLTsWJcqjJwZCON*U8H}s7p;0)Gz%HXAsx~2G7DVDcbrI{k&mm z%f}6a1xVyG7wThvewwd@OWkBFbZ+kEQ=`S zZm!cXyL{_9uI4%o_s za@oq2%X_CsPLwA{rbmXSPo99ZV)n8NZ^x%08EhEdgR5Y*w~tIsjt!rjo|~Mil#i7w z<>}G#^vqnj%GUqaIUdklH@kve4|dITg`Hn?)|_$U1IDbeB=jlxn%&air-Qc#R|dWi zxGS)s`+-(GJLX;N`IP6BXQTVy-7j|Ua{b2j7RN6^ ztoo-k@Tbwh=3B+HvW|~GEd3Qm2RQXderovu$Ht%PWO5ehYkFZMx4>Gr zsiQdSn;nR2dV4pC%UZ=1hlbkEe?%g=rWZva4t1C5D6Uy|Im>IGr7O}M#kJ5~u1!}Y zQcQQLj^dhimuQ;oG3o9WSsa@;+BdYjqwbm}KX{;%Ok~nfbMQzP$G+AL1cXLN^ zlI}#Dly5Q=len8Yifh(gK4t9+S=_*l-jey5ZPHzR#;B=Tybn^aY5-LR4tJEpnfajpiQ!4uM`i|mS^ko~v72*zc6xX6J+dQR0+`*3GTJ&X` zrxeBQJfz}QIftCp+ zit`e@h1V@0(Ap(1aolEFHUSgIaLfA^5NPcb5ZLr!@LPt_gbUp3y^cR%eQe6ExU=)T zHrR91{o>;q%VhSu(4l2cHRiDj+|@~73mrBYq4kz`b`sb^hfT4M zMc{QiyayY0$d*_-TO!zX)1zF!=`8m1bpxG6TJ;Db+ZuOWWT~@ANt4`L!m>@%fNgDM zv9n04E-NbpDn9`MwAJSNL!CJ=^ya=MmVc{|o0D$AiAZ-k-w%ebD^^_Zs&tuK#pB=sM(j zy6bX1rhQKPgK>NCN5=KRPZ^ouYv8nj&vw6}`$YGaz>@AqyFYI%58i9IgR{oNp~InS z=oi6#!AS6ez*7RFfn4B`z-Iz)F#ayI-FSOwwQ*l46nY<=RdDa0=JnE6sq0*&o<1EN zV~a=+Ivi#d`Eq6Y#tyuqQ|r8`!!g%TC%KI$lUHFuFw)#wn( z)bAs?yM2w;_mQ}!eT~-lk=fP0M(g`1?eu<7f1|CVffDv7(SUj2Y~@wCr4?X0Zd(Ul zZU!_UXG>R`$Fe55mJx={_HFG@rKKfjOItcrX>H2+%^j+=wB=NKlex+{Je2Wlhbk@a zB?noQCb!gt(#ZCy4==fNhbk@aB@s#aRzc$8`Tnzx$foXWaX&0yD$%{=Pl04n10bbi z8(o7YC)gs~mV$+1bOC;@I#uNS#l1Br48eLZd?yfchSr(6SQF$MMy}XZW-jafAy;&*rgeStd<{_X zD-9?OC=L9%XuuB*Nqa{$qk%65 z-W_;$;PHWDfn=Zy&H;Q|_tUyZx_81^fEW9J@4eGI;{T@qA^+R_FZJK)ztO+Re+ldc z__FU^Ff(x6x8Ilbg?%3HPrU!^{iyd%-sk_h+`Vdt(ty%{(ty%{(ty&ypF{(fxR%3f z8RclHyI)-yvfyh6G0SB`I-Ff z4<>Q57c~{IoG~cqY7~I@O0-@h)6-OdH8Rdd0YEouq%UkLz#3`C<*qo~7vEwi?QMhv zbwtZVQp;Pj@H>W=PS^pNysR|~zkOs$EL-7AIJrpKUP_}xaEm08^{H_HdaPldqE!yZVkc;nP_26>k7OH z{5(lhJ2KJy>edx_Yp4SCCG5w!@*-Dny-D@nj0doTAxFJVigmAaC4sNiRtRh@?1t#Q z%(bqWM~H|PJXR5n+o+3(Tx=D=dFmqaZmWpKjR_IafyGu4oTn}#=W5JnvKdCxS{87c z=A8KOkSpG@K$XU(OM#M4-`t^!bGVwseTMDnSd;;w_B@cNF`1(&2?5i7Q1 zp*ohkfeE}^sZ*U6w*xrQfraW=?gwzP#iZ-7d_OJbSmh}}P#n}M6&9}5d{3!d=UOfA zDpt%dT&wxMlG{7ZZMU#GP+%m<^8fvzfM$H!c#d(;p}|l;5Y<1W0i^+@ z0i^+@0i^+@0i^+@0i^+@0i}VzNDVCa`+8S6G>4}36tCS?8iGyYmD98A(1Y#|c+SQ) z#4q#t7F{72cEUGy;E+5x9dCNPEXucgeLc&e+%eb-2QtwK0s+l)*7L}=k7&Zyo>IZo1noDTv&SC#a3y@(`f$Gf zDV6@+>@!T$(6o%(x0pALRVff6y?OPwlFH40Pao=^I zfTWJP*EaV;O==nInSDb;8;e8h!^g{$)8T!)3qvcTw?{W58DNyH|ND(T&4?O(#!lm; z@loU1#u4N7##f9V|3wub3*1>bO>tYv7G%!ihE5GNgbFm9%5p>fGg6FKGhqcis&klIbvi1K9 z^$%%|M?*(~{}UV!e75^3-K%^R$kxBj^RW99?opSw>rGwDozKueq~E9=^Z&s20pB}i zMbYnCy5Q*@9MtDW=0=WAm5){@hvzE8kg^kr5s&&|p9VVUdpJ5ga&m5BxKh6DPmT1rRm7#^9ME1#IPmf#_l$0{=? z>NUc9_8tiDIe6{0D43sU$s2>$bkyZAcL zvjX{PzVO+J^61P7_;UDId5n`;@?iDk?CeZst~`cE{#KJhTf6oYOE-i&NC@xU6K*SI zjRdv!%%Z%fH@;hL73+oB+eU}s)8cdGJLW8%!b(g%+|(vZeb$x*WUM(^9T|tOmLTBT zGoGBMw;$THk~CG9$-cm`fUuFa4Puqgr8V@KkF`g(yL)fjt!um;)!U}vYY5E7XZZE- z0u33CF2HbW?S^fh-rc+PyH__2UhuFqX<8cFS`-g=dX%iP#FIpKiVu_7uxCdqEQytU z2BJJC8MvvlT2-{^k-^7FdcebLsJ-jMrq-u4PUeQOcQZC2 z!RE1t^_n!~oB*3`ZOxJ|Yr}DLYG(BKaCP#|@*iXJW93z7a7{G=z?%wL)IT{B)%w6`%O?J`5aYL<;#>n|}E0bvAw^&0{`SqO) zp^3Y-4YVA^W=_t{o}6pZcXHPso2f|V(oiIIn%TVU>Qxx(`cj_W%@C_(O(P34R_Kp< zNMli+D5WU*P|KmkBBR9!W0hl#qb^E%dXvz$WXra-82PLM4|Z%=n{m%7q~W!P=T6U- z*B(inhikS9Ycgyr!_a&<&Rz3U(yi>;%DzC)?C)gNR#{e~=C|7_Kr#g@tr+U#U8@%v z+3Z;hTJbFS03ZWDn!o;t*fpB*yS4;mmaz@j|L@m~|2BSV{JZfj1p?3gD{ZkrH8c-Tg8c-Tg8c-Tg8c-Tg8c-Tg8c-T|>>5b; zyvwv@4sW_QR~es~j!lluj#R6sW-4Qzvjgm$zkPT)*S~Y5Ix#stp5Fi;K3L`T_P|+v zzN9f&OlnW|oZT}yIxz>^kS8i(*fD%;W&(2X6IXh?y~s7RGuI#0Ue$*0a<_MxW3|Jp z8Ne6shtHQdw28^_3ASDMWMy*hwC8N%a+iq*J{6%q*~+uD3wV~095~-l*T&1!<;utu zNJu)pOBhO$p|k|y_d78&Q4SBm?UXAM<;l53(jixCh1;@PzYf))Em4kUZpt9rd{Uh& zPglcG5Y7V%TmSbNFVc(`!MgujjYo~M#y5>G7!Mimf9zeXBq$9i4JZvL4JZvL4JZvL z4JZvL4JZvL4JZvL4Rq8%z`0DX&x`n2@;*#=@Z^0yq2Xc0^(hNCUBDcHi!R^=zl-e@ z5OWUB`fp%z_?=69j1D|({ofnftr?$(8Gy$b%R`TZJ`t({LH$!2P#RDgP#RDgP#RDg zP#RDgP#RDgP#RDgXsv-Oz5bpa4Zi;8Ti25-4V4P{#sWR9&BG_>20g&%fPdcG7~c&C zhO{T^8s|M(3By5n>*7k2(WxfEP1-#G&lM}42n3sSL0ya1bgYY3hx&#jR z0^lN6!3Pz@!~lHH5E|_@KF-en|2gFTf5-SboCNS`AgF&z14;u*14;u*14;u*14;u* z14;u*14;u*14;vbJ{n+~1K@?;EB<=KUw-00&|TuXOZ;_;zx-nYP>%2a4`{}(jh`7m zG#)m-W_-^0r18L?&kd@UC=DnLC=DnLC=DnLC=DnLC=DnLC=DnLC=L7{)&SeXUth(C zDFBu(z_$2{OaT7655FDkbssVd`1Ane5b$Y%LSc|k0~GQF7u)~uGE$oHAI8g#yNxOM zss1SqC=DnLC=DnLC=DnLC=DnLC=DnLC=DnLC=GB8BwU`w4&AZ0XUAX;&JeiOb2eX| z48w5%)$kT^1VFetH&Qts9yvM=p8j~R}PQCHTz;h z|FsX@KMhA89EWcy+{xj4blzyKr%-P+V6gRnm+_Bm{r?}0pBmpYzN)%dX+UW}X+UW} zX+UW}X+UW}X+UW}X+UW}X+UY<&szgt=VF~rNw`=lfS6_AUjbmhVP?U}7}#tA?&tr_n&MvWdg`+qq2+u)0WTLa$T$R zKgPalUY#x9Ia!{Xf=_+Js@zCrk}b`}Hzan2Jd3%c8wQ6Sqo{n)vt;pd$6)W!wLANZ zLpfM!JSH`6XL)SAJhnc(^YqcmK|wI&V%s&?Ey+U8fs7HyPb#8E_E# z`M1O0B;&l>af8p(w`jTJuHM0GnL0A*g>U4<pNe$V8<)9ovvN>RtdNmC9ruBgTl!)4#UUeI!zmOg8mcZewqk zXW3!~F;JNqo7r6&jK&j*$0Bf#)3dBc7MM*xrUt??ejjfj*dJ?NB=(q^cdhQ}C|H~0=E8dgf7sGoZ%0B$v9=aEObHWL!s9i4;l}LPK53Wr9=BeE7ef1G@vw~G@vw~ zG@vw~G@vw~G@vw~G@vx_m!<(YQeIoG%hOp#j|*m|bkn?4=u#Ib4d&YWaIzFnyypP2Tuqx-|9TO}Sv|L_hC|lg`!J&4sv=E|}nGi`Hg(!?n@{ za~68X8H=vVT`&znJgp`noGV-~v%pl-ZeGE$+y#>bzV=fE{Qck9%=Z6(#rU}KUgHh$ z6~Gr5_ZoK@bH;>mv$4-8Lb3X%G@vw~G@vw~G@vw~G@vw~G@vw~G@vw~H1K~_11y)G z&5DFrvb~rJ2}-{M((i8Rx8LvV6YuN}pR-TAoC_i(sQ4N``LTU48Dj*T%V;K`OTKVk zhu_Wu9R#z&1e8P7MKXp9?&jorppW23Rc=rLTOU;SV0Vs+n2 z14;u*14;u*14;u*14;u*14;u*14;w`%QX;mF4t+ZhGDu2nXZDStAOdM+jQkOUHME` z-o?(1dh?!ymQ;63s;ecnt0mRhlIpm~xzX3u20Z`&O3nBuN3Lhzu03w2=WHr`BcPTm)9b@0M()@UjZRc!==gT>d4%kcb+Pb9xpe(-jLbR<>}QO-p3nzKtvRl+_{YY*@@HD z$>Sqa_}K^eYzLFvKUSVAS58b$Pgdu|_aJtZr^}U*xtU5P&E8|wb%d8!p2o2lm?Urlm?Urlm?Urlm?Urlm?Urlm?Ur zlmePm#)yWM< zPfm|*7%Oiani#2+$BHwf)s4lOQ`1v3BV+KZv@t)lYvaC?N2ef7U~Y0|x;(w{KzVcm zasyyVeLqVVfUW+MjJiJ(i$-FMzyHAgetxlGcI?>o&aS>QtE)37E2Cv73)jo~qv73^ z)3b9k((c(ty%{(ty%{(ty%{(ty%{(ty%{(ty%{(!gJ^2H4(znAhlH zUlGJ<2B+a%sY^2h4!?7WPfP=_^?%*?kOu#+{wWP84JZvL4JZvL4JZvL4JZvL4JZvL z4JZvL4gC3Opy2A#;*Dkg%>Qfee16+VvO7*%wkXrmXp)ITWM>0Z3=AaB*s^8E>v#u) z2`#le98;uCQns~@x6^8tq_)ZKR##KDW@oZ1+cSejkVBA5jzMzC56mTd$tB1?kX&=f zA;`awLjvp}Kweemx9C>fdwoxnJb+|9SEsPS)o4=Fh$LeC}6s*6c>{o6{SGz3F#Pd`#l+ z?dREpW}$Tbdj7?o(Aw&9VOy^02VUseUf0~_R@e`?IHq)^X;j(~KNWjjC*-Ep4?Xp4(yzOi zVPL9XO);0HVAl-zUf&7muV=cUFqdCdqw}J8N4f9oiv#!BUeE73mTPlUgyg`;eIup8 zsQX6hygB!UPLGqdJ^v`*(S4fz_*l;p)Sl^Dy(8UD94vF+>h>dPm_!#AdeoE5v*#`d(2v4nw<3l%x32R*}d7^ygts z!Fk7|i9`QZ>w&rDgkso7k@L0#ua}f({M6w-1qJ=;)?l?-HP$K{wKlsH_2POV*O!QZ z)4%J=)lB2I{B<;CU9n8dwK4XR{j7DnP`Y|G|DrmW+xz{l{QI?A4lN~4yfA0V_J+u+ zbt_Zj&CP1r@v-wZv8v18Rp|;34LiBl{6$n~}tgh+yds{>06xQYz-}VB1k(7njHvQ#gO~GS+ zFmPuyC1la)TocZYtIiIl)!p%E@!RcbYPKEvg~`AV95*z@u66n9k2K+H*mj?7)C;9+ z*M??Jy8oLyf!FsVKW7WQQD0}Pyg7d>JHO4w7RN*656|bW5ai^+Au1BQ2CmQFTQ8KB zm-AotwYlT?^tY@{(Wh-Vo;-SqJ4)2nR9NOZFB$lGvtwn z4LL!C^BGU3ZR8C{n3n6g2R*MZlI8EU`RUo|TA{SCkbgcn)Vs7b?s_6rE=nJIBi>0x zuh}u%d!1-=%wTu?{omInKRv$Ywcz%x>Cy)uREQ@8#Z_o&Bd-YqoIaFVCDWep4);{+rWFGvCZC7yhxZF#T84@8`X|mCMQh z<7W&4zx)HcaQ4=%nLmHL@9-!4rtSELC@=pMB9>T8gL@f<8Ei5bZJXCrseyA zx1TAbew>ckcSEO}YVj>0c!ybnB}n7(tyEfu8n$F{?-Qf0fFvVeAF*ZDj!H)?Nt+q9 zmcgW*OPZZZYSdR{pV={(+m36+#(f6^^brqtzDS}a=>%47v3jG;>KnCMSvNs%J4W{! z+hGf&0a;-@dfBxmih3`uH5=>1ngUOcBq8w^YsOleecGr>XUGcaEHFiAg?(8`_1=iQ z6^Ac5OV~*)D|vS~anxOH{N+>S(6+)T3sy za<-f@dYdQ)bfa}?qEW?AhYLG!{E%jwrd2`=1M|}5e(RP-u^(kn77hQ^{z zXBvg9qLrjIWw~vIe8&q8M%aW~h0?W)Cl2QvnvzeQ&Qo(Y41IH#TOC>_Oz9Bi6umU} zB3`7b^;P3OJF*c|9wnFS4HskomJd%_h0@2@P8|Mj+O6P1E=%S%r??1c*-X36N^;Lf zy3bZojZ+h{(t#N>e_SY4uAeykz0?d^g+kBvx<}YCBFa4*F=V99uq7kn)RZqw@&j3N zHnl{XE76*#;Ay4N49Xpj7@^gPSIfj1nj~ty7*!~L_R~V?3U&X^QQfDW7-^pS#mhRE z?NTR8X*2i&Vjr( z2W%dM&bA|GjTVS{>7R^rtxJdd)g@`Chf@#a6v*pSpzq67jd#qQ!0Y>3&mtrFVx;$M zjbRJ2(t#EEpA<^hm-C13rSSFTOsMrN3OxNx^Z7{sqpb#>*IO>?6_xh#7n_j@gS9}f zqfr%CJxIDZiJ|EQ!foN-2JFjN+uyKyOk~es-r_C|$gme{mPJx#wM$5wCQ9*8rF?^ zJ2_w~a%uJC1Qtov=_AoJEQ&VFX!~y)_vzdgpWULCv6v1|ea@sjDyDO?PkSAALw2Ll zs2P=drrlbjX;g34mAUDpWldwvXd3kuqosx{1`=B6S1BW)1cFYZE0tBFSbo0OqUG&c z{_FgJX?90cT(gDYDBEn6H^({f2mnIc;j9XKe% zNSDXq8lhu9=HYQtE2JZ%OpZ+QV4ptG=|v=g6L;IE{7H!Ie)sq~9UfW^odE|y@fv(y zl*#PdV`Q{w3IZSi0w4eaAOHd&00JNY0w4eaATaR+kpEA-i(nuKfB*=900@8p2!H?x zfB*=900@jt0Qvve2%!K3KmY_l00ck)1V8`;KmY_l00btU0P_EdcM%K(0T2KI5C8!X z009sH0T2KI5CDO(2_XL;8zB^c00@8p2!H?xfB*=900@8p2!O!E6F~ky@h*aaAOHd& z00JNY0w4eaAOHd&00JN|HUZ@SVP^8c|BLIDVX00@8p2!H?xfB*=9 z00@8p2uwTyjFHtWxQL~8$gY}J#k;CW}KaQ5oenXkNkhd>#FDTr*K|7P<44~MLJ>VsM@z2NPQIYFFzkZKjkn zT`AL*No$otIsPy(FI`@~blJRYQUj&#-X})WV73+V9WOXwAF+kbZp3M!SZ36y7}cmZ z+RV65owkPUSXNq6lTFe0B@5n(VKX6iw2tU0K}UV17*&l*eU;T3_l)MEtTjMarkYWC zg0@^NKR>fwIQ#L(Ghdb?H_J{<+w1vV$8v2>cJz8yaIkM4BN%r0F_p4j*L^OVMEZ__{R3U2GB#o=GaeMK*XsNUqg>|jWF0$07_u2BLOXt~g)CUi2?uOP59|YYH#Lx=+ zqIi?~zs+uJs-ToX&(z@AVBlX}dc?^B5&9{2%T+5hLtd%IN`3Qb>DoMbl5L1 ztC?|*oy1k_lqJnU^jSDzI^n2lf9nyq6aOvI64B>D(opcgvAU+)?`;hVT`x4Z__i0s zPPUh1iM1ULsyyZgS`^qz@_EZimIdn6*>Qsg{)&L8D^ zW=E?;qs~&k6tiSfy#A#V*H*kEnIu0Dh^0ZCkbFV&>4IL%33*Q~l~idlq9^AR`}}jZ zK>wrv43I{JH=J;xJc!QJ!t+!!j?jVU$cvo@s?r%S z_vMLSP4+1Z)`7qffx}OoLg~tt6VI-P)>fC>bjs#W<=ilPv^RCE&^n?h_f@=DwJSGj zbP6496dyL74y)GN#!aKiZa1szmF6b9Wo$+ZLfnnoE852WHruFI?`#-Mo>=P}wOUkb zms=e=bTd_BSk&Rd4jf;e50toAUVAiM+Prci=g=DURP1$WVwzSz^whI?gqh~@5rw(0 ziO`Is^upgQoV|8!=9g2^BA?_WYB|g%C0gO|Qhr3M?CPCt%kfgFR_-`0k!qLpp&pT* zs5)vHmuFXHh*2ztOh9D2Ny2yC%-`ka z|84%KZ~fm}fBn|l-2cw?=6*Q)ue0shw@82=5C8!X009sH0T2KI5CDNUPr#l%mAkmN zlH6paSFaDwt;8>7&YfrHR^&Sd-ccXpmmHydxSihMpL@8HzWreF+YjdRK;6}^o;j6! zuX=4a41F`sgdd#yM5V+^OTY!a2aq4{%DnB|!%y1n+e~n~PuJ83EN-%2oX%Z1yl_Eq zdZp&M<~CQ?+2X;uwdj*Fuj`O1R(0vZod%#xZb?O4N?TD=R$Y}(?OKbq9U7SGdNsPl zKKC%Gl<2RYCPb}kbUPS&w%0wn-&XSCNh(fN$sUq+EYa7PIhFgMQQ7ZzT^?9l4(0a_ zr%|-&x>>fq8w2u!AF#wC`MKqMrtC@F9FoTexB2uk#&X>H^r_sH4{s$m z1<}=lDHF&C=WfcEpQ`^#y~1KYuSai3S(VBz=m~h#^e3lI<(40=Wb{$msi~8Q??&%4 zMRphu6a6<@)eFh}ZuC}EY@R%oyU@Roy%5keOQWPlYMfO}r5cCS_pxeo>QwH!bKQ3Q zT^`Vt1Kr;Ux^|YYe0YZ@mMUj0S)Kqnrk{t+xD|_((ae&{dMBoH*AJ&MJy^EQ{qNet z@JK4Lis9~-^QUs}URjQ3^@DTkJPaH=W+JC+D|*|>!`}wfD*o2=$sbHz&mVn#F*Pbm?j24Pr`|msUlRO3k^s}h literal 0 HcmV?d00001 diff --git a/src/sslysze_scan/data/iana_parse.json b/src/sslysze_scan/data/iana_parse.json new file mode 100644 index 0000000..7fb5510 --- /dev/null +++ b/src/sslysze_scan/data/iana_parse.json @@ -0,0 +1,61 @@ +{ + "proto/assignments/tls-parameters/tls-parameters.xml": [ + [ + "tls-parameters-4", + "tls_cipher_suites.csv", + ["Value", "Description", "DTLS", "Recommended", "RFC/Draft"] + ], + [ + "tls-signaturescheme", + "tls_signature_schemes.csv", + ["Value", "Description", "DTLS", "Recommended", "RFC/Draft"] + ], + [ + "tls-parameters-8", + "tls_supported_groups.csv", + ["Value", "Description", "DTLS", "Recommended", "RFC/Draft"] + ], + [ + "tls-parameters-6", + "tls_alerts.csv", + ["Value", "Description", "DTLS", "Recommended", "RFC/Draft"] + ], + [ + "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"] + ] + ], + "proto/assignments/ikev2-parameters/ikev2-parameters.xml": [ + [ + "ikev2-parameters-5", + "ikev2_encryption_algorithms.csv", + ["Value", "Description", "ESP", "IKEv2", "RFC/Draft"] + ], + [ + "ikev2-parameters-6", + "ikev2_prf_algorithms.csv", + ["Value", "Description", "Status", "RFC/Draft"] + ], + [ + "ikev2-parameters-7", + "ikev2_integrity_algorithms.csv", + ["Value", "Description", "Status", "RFC/Draft"] + ], + [ + "ikev2-parameters-8", + "ikev2_dh_groups.csv", + ["Value", "Description", "Status", "RFC/Draft"] + ], + [ + "ikev2-parameters-12", + "ikev2_authentication_methods.csv", + ["Value", "Description", "Status", "RFC/Draft"] + ] + ] +} diff --git a/src/sslysze_scan/data/protocols.csv b/src/sslysze_scan/data/protocols.csv new file mode 100644 index 0000000..29308c3 --- /dev/null +++ b/src/sslysze_scan/data/protocols.csv @@ -0,0 +1,11 @@ +protocol,port +SMTP,25 +SMTP,587 +LDAP,389 +IMAP,143 +POP3,110 +FTP,21 +XMPP,5222 +XMPP,5269 +RDP,3389 +POSTGRES,5432 diff --git a/src/sslysze_scan/db/__init__.py b/src/sslysze_scan/db/__init__.py new file mode 100644 index 0000000..558d4bb --- /dev/null +++ b/src/sslysze_scan/db/__init__.py @@ -0,0 +1,12 @@ +"""Database module for compliance-scan results storage.""" + +from .compliance import check_compliance +from .schema import check_schema_version, get_schema_version +from .writer import save_scan_results + +__all__ = [ + "check_compliance", + "check_schema_version", + "get_schema_version", + "save_scan_results", +] diff --git a/src/sslysze_scan/db/compliance.py b/src/sslysze_scan/db/compliance.py new file mode 100644 index 0000000..c86a2e1 --- /dev/null +++ b/src/sslysze_scan/db/compliance.py @@ -0,0 +1,463 @@ +"""Compliance checking module for IANA and BSI standards.""" + +import sqlite3 +from datetime import datetime, timezone +from typing import Any + +# Error messages +ERR_COMPLIANCE_CHECK = "Error during compliance check" + + +def check_compliance(db_path: str, scan_id: int) -> dict[str, Any]: + """Check compliance of scan results against IANA and BSI standards. + + Args: + db_path: Path to database file + scan_id: ID of scan to check + + Returns: + Dictionary with compliance statistics + + Raises: + sqlite3.Error: If database operations fail + + """ + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + try: + timestamp = datetime.now(timezone.utc).isoformat() + stats = { + "cipher_suites_checked": 0, + "cipher_suites_passed": 0, + "supported_groups_checked": 0, + "supported_groups_passed": 0, + "certificates_checked": 0, + "certificates_passed": 0, + } + + # Check cipher suites + stats["cipher_suites_checked"], stats["cipher_suites_passed"] = ( + _check_cipher_suite_compliance(cursor, scan_id, timestamp) + ) + + # Check supported groups + stats["supported_groups_checked"], stats["supported_groups_passed"] = ( + _check_supported_group_compliance(cursor, scan_id, timestamp) + ) + + # Check certificates + stats["certificates_checked"], stats["certificates_passed"] = ( + check_certificate_compliance(cursor, scan_id, timestamp) + ) + + conn.commit() + return stats + + except Exception as e: + conn.rollback() + raise sqlite3.Error(f"{ERR_COMPLIANCE_CHECK}: {e}") from e + finally: + conn.close() + + +def check_certificate_compliance( + cursor: sqlite3.Cursor, + scan_id: int, + timestamp: str, +) -> tuple[int, int]: + """Check certificate compliance against BSI TR-02102-1 standards. + + Returns: + Tuple of (total_checked, passed_count) + + """ + # Get certificates from scan + cursor.execute( + """ + SELECT id, port, key_type, key_bits, signature_algorithm + FROM scan_certificates + WHERE scan_id = ? + """, + (scan_id,), + ) + + certificates = cursor.fetchall() + total_checked = 0 + passed_count = 0 + + for cert_id, port, key_type, key_bits, signature_algorithm in certificates: + total_checked += 1 + + # Determine algorithm type from key_type string + # key_type examples: "RSA", "ECC", "DSA" + algo_type = None + if key_type: + key_type_upper = key_type.upper() + if "RSA" in key_type_upper: + algo_type = "RSA" + elif ( + "EC" in key_type_upper + or "ECDSA" in key_type_upper + or "ECC" in key_type_upper + ): + algo_type = "ECDSA" + elif "DSA" in key_type_upper and "EC" not in key_type_upper: + algo_type = "DSA" + + # Look up in BSI TR-02102-1 key requirements + cursor.execute( + """ + SELECT min_key_length, valid_until, notes + FROM bsi_tr_02102_1_key_requirements + WHERE algorithm_type = ? AND usage_context = 'signature' + """, + (algo_type,), + ) + bsi_result = cursor.fetchone() + + passed = False + severity = "critical" + details = [] + + if bsi_result and algo_type: + min_key_length, valid_until, notes = bsi_result + current_year = datetime.now(timezone.utc).year + + # Check key length + if key_bits and key_bits >= min_key_length: + if valid_until is None or valid_until >= current_year: + passed = True + severity = "info" + details.append( + f"BSI TR-02102-1: Compliant ({algo_type} {key_bits} ≥ {min_key_length} Bit)", + ) + else: + passed = False + severity = "critical" + details.append( + f"BSI TR-02102-1: Algorithm deprecated (valid until {valid_until})", + ) + else: + passed = False + severity = "critical" + details.append( + f"BSI TR-02102-1: Non-compliant ({algo_type} {key_bits} < {min_key_length} Bit required)", + ) + else: + details.append(f"BSI TR-02102-1: Unknown algorithm type ({key_type})") + severity = "warning" + + # Check signature hash algorithm + # Extract hash from signature_algorithm (e.g., "sha256WithRSAEncryption" -> "SHA-256") + sig_hash = None + if signature_algorithm: + sig_lower = signature_algorithm.lower() + if "sha256" in sig_lower: + sig_hash = "SHA-256" + elif "sha384" in sig_lower: + sig_hash = "SHA-384" + elif "sha512" in sig_lower: + sig_hash = "SHA-512" + elif "sha1" in sig_lower: + sig_hash = "SHA-1" + elif "md5" in sig_lower: + sig_hash = "MD5" + + if sig_hash: + cursor.execute( + """ + SELECT deprecated, min_output_bits + FROM bsi_tr_02102_1_hash_requirements + WHERE algorithm = ? + """, + (sig_hash,), + ) + hash_result = cursor.fetchone() + + if hash_result: + deprecated, min_bits = hash_result + if deprecated == 1: + details.append(f"Hash: {sig_hash} deprecated") + if passed: + passed = False + severity = "critical" + else: + details.append(f"Hash: {sig_hash} compliant") + else: + details.append(f"Hash: {sig_hash} unknown") + + if passed: + passed_count += 1 + + # Insert compliance record + # Use key_type as-is for matching in reports + cursor.execute( + """ + INSERT INTO scan_compliance_status ( + scan_id, port, timestamp, check_type, item_name, + iana_value, iana_recommended, bsi_approved, bsi_valid_until, + passed, severity, details + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + scan_id, + port, + timestamp, + "certificate", + f"{key_type} {key_bits} Bit" if key_type and key_bits else "Unknown", + None, + None, + passed, + None, + passed, + severity, + "; ".join(details), + ), + ) + + return total_checked, passed_count + + +def _check_cipher_suite_compliance( + cursor: sqlite3.Cursor, + scan_id: int, + timestamp: str, +) -> tuple[int, int]: + """Check cipher suite compliance against IANA and BSI standards. + + Returns: + Tuple of (total_checked, passed_count) + + """ + # Get accepted cipher suites from scan + cursor.execute( + """ + SELECT id, port, cipher_suite_name, tls_version + FROM scan_cipher_suites + WHERE scan_id = ? AND accepted = 1 + """, + (scan_id,), + ) + + cipher_suites = cursor.fetchall() + total_checked = 0 + passed_count = 0 + + for cs_id, port, cipher_name, tls_version in cipher_suites: + total_checked += 1 + + # Look up in IANA + cursor.execute( + """ + SELECT value, recommended + FROM iana_tls_cipher_suites + WHERE description = ? COLLATE NOCASE + """, + (cipher_name,), + ) + iana_result = cursor.fetchone() + + iana_value = None + iana_recommended = None + if iana_result: + iana_value = iana_result[0] + iana_recommended = iana_result[1] + + # Look up in BSI TR-02102-2 + cursor.execute( + """ + SELECT valid_until + FROM bsi_tr_02102_2_tls + WHERE name = ? COLLATE NOCASE AND tls_version = ? AND category = 'cipher_suite' + """, + (cipher_name, tls_version), + ) + bsi_result = cursor.fetchone() + + bsi_approved = bsi_result is not None + bsi_valid_until = bsi_result[0] if bsi_result else None + + # Determine if passed + passed = False + severity = "warning" + details = [] + + # BSI check (sole compliance criterion) + if bsi_approved: + current_year = datetime.now(timezone.utc).year + if bsi_valid_until and bsi_valid_until >= current_year: + details.append(f"BSI: Approved until {bsi_valid_until}") + passed = True + severity = "info" + else: + details.append(f"BSI: Expired (valid until {bsi_valid_until})") + passed = False + severity = "critical" + else: + details.append("BSI: Not in approved list") + passed = False + severity = "critical" + + # IANA check (informational only, does not affect passed status) + if iana_recommended == "Y": + details.append("IANA: Recommended") + elif iana_recommended == "D": + details.append("IANA: Deprecated/Transitioning") + elif iana_recommended == "N": + details.append("IANA: Not Recommended") + else: + details.append("IANA: Unknown") + + if passed: + passed_count += 1 + + # Insert compliance record + cursor.execute( + """ + INSERT INTO scan_compliance_status ( + scan_id, port, timestamp, check_type, item_name, + iana_value, iana_recommended, bsi_approved, bsi_valid_until, + passed, severity, details + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + scan_id, + port, + timestamp, + "cipher_suite", + cipher_name, + iana_value, + iana_recommended, + bsi_approved, + bsi_valid_until, + passed, + severity, + "; ".join(details), + ), + ) + + return total_checked, passed_count + + +def _check_supported_group_compliance( + cursor: sqlite3.Cursor, + scan_id: int, + timestamp: str, +) -> tuple[int, int]: + """Check supported groups compliance against IANA and BSI standards. + + Returns: + Tuple of (total_checked, passed_count) + + """ + # Get supported groups from scan + cursor.execute( + """ + SELECT id, port, group_name + FROM scan_supported_groups + WHERE scan_id = ? + """, + (scan_id,), + ) + + groups = cursor.fetchall() + total_checked = 0 + passed_count = 0 + + for group_id, port, group_name in groups: + total_checked += 1 + + # Look up in IANA + cursor.execute( + """ + SELECT value, recommended + FROM iana_tls_supported_groups + WHERE description = ? COLLATE NOCASE + """, + (group_name,), + ) + iana_result = cursor.fetchone() + + iana_value = None + iana_recommended = None + if iana_result: + iana_value = iana_result[0] + iana_recommended = iana_result[1] + + # Look up in BSI TR-02102-2 (DH groups for TLS 1.2 and 1.3) + cursor.execute( + """ + SELECT valid_until + FROM bsi_tr_02102_2_tls + WHERE name = ? COLLATE NOCASE AND category = 'dh_group' + ORDER BY valid_until DESC + LIMIT 1 + """, + (group_name,), + ) + bsi_result = cursor.fetchone() + + bsi_approved = bsi_result is not None + bsi_valid_until = bsi_result[0] if bsi_result else None + + # Determine if passed + passed = False + severity = "warning" + details = [] + + # BSI check (sole compliance criterion) + if bsi_approved: + current_year = datetime.now(timezone.utc).year + if bsi_valid_until and bsi_valid_until >= current_year: + details.append(f"BSI: Approved until {bsi_valid_until}") + passed = True + severity = "info" + else: + details.append(f"BSI: Expired (valid until {bsi_valid_until})") + passed = False + severity = "critical" + else: + details.append("BSI: Not in approved list") + passed = False + severity = "critical" + + # IANA check (informational only, does not affect passed status) + if iana_recommended == "Y": + details.append("IANA: Recommended") + elif iana_recommended == "D": + details.append("IANA: Deprecated/Transitioning") + elif iana_recommended == "N": + details.append("IANA: Not Recommended") + else: + details.append("IANA: Unknown") + + if passed: + passed_count += 1 + + # Insert compliance record + cursor.execute( + """ + INSERT INTO scan_compliance_status ( + scan_id, port, timestamp, check_type, item_name, + iana_value, iana_recommended, bsi_approved, bsi_valid_until, + passed, severity, details + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + scan_id, + port, + timestamp, + "supported_group", + group_name, + iana_value, + iana_recommended, + bsi_approved, + bsi_valid_until, + passed, + severity, + "; ".join(details), + ), + ) + + return total_checked, passed_count diff --git a/src/sslysze_scan/db/schema.py b/src/sslysze_scan/db/schema.py new file mode 100644 index 0000000..b864b84 --- /dev/null +++ b/src/sslysze_scan/db/schema.py @@ -0,0 +1,71 @@ +"""Database schema version management.""" + +import sqlite3 + +SCHEMA_VERSION = 5 + +# Error messages +ERR_SCHEMA_READ = "Error reading schema version" + + +def get_schema_version(db_path: str) -> int | None: + """Get current schema version from database. + + Args: + db_path: Path to database file + + Returns: + Schema version number or None if not found + + Raises: + sqlite3.Error: If database access fails + + """ + try: + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + cursor.execute( + """ + SELECT name FROM sqlite_master + WHERE type='table' AND name='schema_version' + """, + ) + if not cursor.fetchone(): + conn.close() + return None + + cursor.execute("SELECT MAX(version) FROM schema_version") + result = cursor.fetchone() + conn.close() + + return result[0] if result and result[0] is not None else None + except sqlite3.Error as e: + raise sqlite3.Error(f"{ERR_SCHEMA_READ}: {e}") from e + + +def check_schema_version(db_path: str) -> bool: + """Check if database schema version is compatible. + + Args: + db_path: Path to database file + + Returns: + True if schema version matches + + Raises: + ValueError: If schema version is incompatible + + """ + current_version = get_schema_version(db_path) + + if current_version is None: + raise ValueError(f"No schema version found in database: {db_path}") + + if current_version != SCHEMA_VERSION: + raise ValueError( + f"Schema version mismatch: database has version {current_version}, " + f"expected version {SCHEMA_VERSION}", + ) + + return True diff --git a/src/sslysze_scan/db/writer.py b/src/sslysze_scan/db/writer.py new file mode 100644 index 0000000..c2fadd8 --- /dev/null +++ b/src/sslysze_scan/db/writer.py @@ -0,0 +1,893 @@ +"""Database writer for scan results.""" + +import socket +import sqlite3 +from datetime import datetime +from typing import Any + +from sslyze.scanner.models import ServerScanResult + +# OpenSSL constants +OPENSSL_EVP_PKEY_DH = 28 + + +def save_scan_results( + db_path: str, + hostname: str, + ports: list[int], + scan_results: dict[int, Any], + scan_start_time: datetime, + scan_duration: float, +) -> int: + """Save scan results to database. + + Args: + db_path: Path to database file + hostname: Scanned hostname + ports: List of scanned ports + scan_results: Dictionary mapping port to SSLyze ServerScanResult object + scan_start_time: When scan started + scan_duration: Scan duration in seconds + + Returns: + scan_id of inserted record + + Raises: + sqlite3.Error: If database operations fail + + """ + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + try: + # Insert main scan record + scan_id = _insert_scan_record( + cursor, + hostname, + ports, + scan_start_time, + scan_duration, + ) + + # Save results for each port + for port, scan_result in scan_results.items(): + # Save cipher suites (all TLS versions) + _save_cipher_suites(cursor, scan_id, port, scan_result, "ssl_3.0") + _save_cipher_suites(cursor, scan_id, port, scan_result, "1.0") + _save_cipher_suites(cursor, scan_id, port, scan_result, "1.1") + _save_cipher_suites(cursor, scan_id, port, scan_result, "1.2") + _save_cipher_suites(cursor, scan_id, port, scan_result, "1.3") + + # Save supported groups (elliptic curves) + _save_supported_groups(cursor, scan_id, port, scan_result) + + # Extract and save DHE groups from cipher suites + _save_dhe_groups_from_cipher_suites(cursor, scan_id, port, scan_result) + + # Save certificate information + _save_certificates(cursor, scan_id, port, scan_result) + + # Save vulnerability checks + _save_vulnerabilities(cursor, scan_id, port, scan_result) + + # Save protocol features + _save_protocol_features(cursor, scan_id, port, scan_result) + + # Save session features + _save_session_features(cursor, scan_id, port, scan_result) + + # Save HTTP headers + _save_http_headers(cursor, scan_id, port, scan_result) + + conn.commit() + return scan_id + + except (sqlite3.Error, OSError, ValueError) as e: + conn.rollback() + raise sqlite3.Error(f"Error saving scan results: {e}") from e + finally: + conn.close() + + +def _insert_scan_record( + cursor: sqlite3.Cursor, + hostname: str, + ports: list[int], + scan_start_time: datetime, + scan_duration: float, +) -> int: + """Insert main scan record and return scan_id.""" + ports_str = ",".join(str(p) for p in ports) + + cursor.execute( + """ + INSERT INTO scans ( + timestamp, hostname, ports, scan_duration_seconds + ) VALUES (?, ?, ?, ?) + """, + ( + scan_start_time.isoformat(), + hostname, + ports_str, + scan_duration, + ), + ) + + scan_id = cursor.lastrowid + + # Resolve and store host information + _save_host_info(cursor, scan_id, hostname) + + return scan_id + + +def _resolve_hostname(hostname: str) -> tuple[str | None, str | None]: + """Resolve hostname to IPv4 and IPv6 addresses. + + Args: + hostname: Hostname to resolve + + Returns: + Tuple of (ipv4, ipv6) addresses or (None, None) if resolution fails + + """ + ipv4 = None + ipv6 = None + + try: + # Get all address info for the hostname + addr_info = socket.getaddrinfo(hostname, None) + + for info in addr_info: + family = info[0] + addr = info[4][0] + + if family == socket.AF_INET and ipv4 is None: + ipv4 = addr + elif family == socket.AF_INET6 and ipv6 is None: + ipv6 = addr + + # Stop if we have both + if ipv4 and ipv6: + break + + except (OSError, socket.gaierror): + pass + + return ipv4, ipv6 + + +def _save_host_info(cursor: sqlite3.Cursor, scan_id: int, hostname: str) -> None: + """Save host information with resolved IP addresses. + + Args: + cursor: Database cursor + scan_id: Scan ID + hostname: Hostname to resolve and store + + """ + ipv4, ipv6 = _resolve_hostname(hostname) + + cursor.execute( + """ + INSERT INTO scanned_hosts ( + scan_id, fqdn, ipv4, ipv6 + ) VALUES (?, ?, ?, ?) + """, + (scan_id, hostname, ipv4, ipv6), + ) + + +def _get_ffdhe_group_name(dh_size: int) -> str | None: + """Map DH key size to ffdhe group name. + + Args: + dh_size: DH key size in bits + + Returns: + ffdhe group name or None if not a standard size + + """ + ffdhe_map = { + 2048: "ffdhe2048", + 3072: "ffdhe3072", + 4096: "ffdhe4096", + 6144: "ffdhe6144", + 8192: "ffdhe8192", + } + return ffdhe_map.get(dh_size) + + +def _get_ffdhe_iana_value(group_name: str) -> int | None: + """Get IANA value for ffdhe group name. + + Args: + group_name: ffdhe group name (e.g., "ffdhe2048") + + Returns: + IANA value or None if unknown + + """ + iana_map = { + "ffdhe2048": 256, + "ffdhe3072": 257, + "ffdhe4096": 258, + "ffdhe6144": 259, + "ffdhe8192": 260, + } + return iana_map.get(group_name) + + +def _save_cipher_suites( + cursor: sqlite3.Cursor, + scan_id: int, + port: int, + scan_result: ServerScanResult, + tls_version: str, +) -> None: + """Save cipher suites for specific TLS version.""" + from sslyze import ScanCommandAttemptStatusEnum + + # Map version to result attribute + version_map = { + "ssl_3.0": "ssl_3_0_cipher_suites", + "1.0": "tls_1_0_cipher_suites", + "1.1": "tls_1_1_cipher_suites", + "1.2": "tls_1_2_cipher_suites", + "1.3": "tls_1_3_cipher_suites", + } + + if tls_version not in version_map: + return + + if not scan_result.scan_result: + return + + cipher_attempt = getattr(scan_result.scan_result, version_map[tls_version]) + + if cipher_attempt.status != ScanCommandAttemptStatusEnum.COMPLETED: + return + + cipher_result = cipher_attempt.result + if not cipher_result: + return + + # Insert accepted cipher suites + for accepted_cipher in cipher_result.accepted_cipher_suites: + cursor.execute( + """ + INSERT INTO scan_cipher_suites ( + scan_id, port, tls_version, cipher_suite_name, accepted, + iana_value, key_size, is_anonymous + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + scan_id, + port, + tls_version, + accepted_cipher.cipher_suite.name, + True, + None, # IANA value mapping would go here + accepted_cipher.cipher_suite.key_size, + accepted_cipher.cipher_suite.is_anonymous, + ), + ) + + # Insert rejected cipher suites (if available) + if hasattr(cipher_result, "rejected_cipher_suites"): + for rejected_cipher in cipher_result.rejected_cipher_suites: + cursor.execute( + """ + INSERT INTO scan_cipher_suites ( + scan_id, port, tls_version, cipher_suite_name, accepted, + iana_value, key_size, is_anonymous + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + scan_id, + port, + tls_version, + rejected_cipher.cipher_suite.name, + False, + None, + rejected_cipher.cipher_suite.key_size, + rejected_cipher.cipher_suite.is_anonymous, + ), + ) + + +def _save_supported_groups( + cursor: sqlite3.Cursor, + scan_id: int, + port: int, + scan_result: ServerScanResult, +) -> None: + """Save supported elliptic curves / DH groups.""" + from sslyze import ScanCommandAttemptStatusEnum + + if not scan_result.scan_result: + return + + ec_attempt = scan_result.scan_result.elliptic_curves + + if ec_attempt.status != ScanCommandAttemptStatusEnum.COMPLETED: + return + + ec_result = ec_attempt.result + if not ec_result: + return + + for curve in ec_result.supported_curves: + cursor.execute( + """ + INSERT INTO scan_supported_groups ( + scan_id, port, group_name, iana_value, openssl_nid + ) VALUES (?, ?, ?, ?, ?) + """, + ( + scan_id, + port, + curve.name, + None, # IANA value mapping would go here + curve.openssl_nid, + ), + ) + + +def _is_dhe_key_exchange(ephemeral_key: Any) -> bool: + """Check if ephemeral key is DHE (Finite Field DH). + + Args: + ephemeral_key: Ephemeral key object from cipher suite + + Returns: + True if DHE key exchange + + """ + if hasattr(ephemeral_key, "type_name"): + return ephemeral_key.type_name == "DH" + if hasattr(ephemeral_key, "type"): + return ephemeral_key.type == OPENSSL_EVP_PKEY_DH + return False + + +def _process_dhe_from_cipher_result( + cursor: sqlite3.Cursor, + scan_id: int, + port: int, + cipher_result: Any, + discovered_groups: set[str], +) -> None: + """Process cipher result to extract and save DHE groups. + + Args: + cursor: Database cursor + scan_id: Scan ID + port: Port number + cipher_result: Cipher suite scan result + discovered_groups: Set of already discovered groups + + """ + if not cipher_result: + return + + for accepted_cipher in cipher_result.accepted_cipher_suites: + ephemeral_key = accepted_cipher.ephemeral_key + + if not ephemeral_key: + continue + + if not _is_dhe_key_exchange(ephemeral_key): + continue + + # Get DH key size and map to ffdhe group name + dh_size = ephemeral_key.size + group_name = _get_ffdhe_group_name(dh_size) + + if not group_name or group_name in discovered_groups: + continue + + # Get IANA value and insert into database + iana_value = _get_ffdhe_iana_value(group_name) + cursor.execute( + """ + INSERT INTO scan_supported_groups ( + scan_id, port, group_name, iana_value, openssl_nid + ) VALUES (?, ?, ?, ?, ?) + """, + ( + scan_id, + port, + group_name, + iana_value, + None, + ), + ) + discovered_groups.add(group_name) + + +def _save_dhe_groups_from_cipher_suites( + cursor: sqlite3.Cursor, + scan_id: int, + port: int, + scan_result: Any, # ServerScanResult with dynamic ephemeral_key attributes +) -> None: + """Extract and save DHE groups from cipher suite ephemeral keys. + + Analyzes accepted cipher suites to find DHE key exchanges and extracts + the ffdhe group size (e.g., ffdhe2048, ffdhe3072). + + Args: + cursor: Database cursor + scan_id: Scan ID + port: Port number + scan_result: SSLyze ServerScanResult. Uses Any because ephemeral_key + has dynamic attributes (type_name, type, size) that vary by implementation. + + """ + from sslyze import ScanCommandAttemptStatusEnum + + if not scan_result.scan_result: + return + + discovered_groups = set() + + tls_versions = [ + ("ssl_3.0", "ssl_3_0_cipher_suites"), + ("1.0", "tls_1_0_cipher_suites"), + ("1.1", "tls_1_1_cipher_suites"), + ("1.2", "tls_1_2_cipher_suites"), + ("1.3", "tls_1_3_cipher_suites"), + ] + + for tls_version, attr_name in tls_versions: + cipher_attempt = getattr(scan_result.scan_result, attr_name) + + if cipher_attempt.status != ScanCommandAttemptStatusEnum.COMPLETED: + continue + + _process_dhe_from_cipher_result( + cursor, scan_id, port, cipher_attempt.result, discovered_groups + ) + + +def _save_certificates( + cursor: sqlite3.Cursor, + scan_id: int, + port: int, + scan_result: ServerScanResult, +) -> None: + """Save certificate information.""" + from sslyze import ScanCommandAttemptStatusEnum + + if not scan_result.scan_result: + return + + cert_attempt = scan_result.scan_result.certificate_info + + if cert_attempt.status != ScanCommandAttemptStatusEnum.COMPLETED: + return + + cert_result = cert_attempt.result + if not cert_result: + return + + for cert_deployment in cert_result.certificate_deployments: + for position, cert in enumerate(cert_deployment.received_certificate_chain): + # Get public key info + public_key = cert.public_key() + key_type = public_key.__class__.__name__ + key_bits = None + if hasattr(public_key, "key_size"): + key_bits = public_key.key_size + + # Get signature algorithm + sig_alg = None + if hasattr(cert, "signature_hash_algorithm"): + sig_alg = cert.signature_hash_algorithm.name + + cursor.execute( + """ + INSERT INTO scan_certificates ( + scan_id, port, position, subject, issuer, serial_number, + not_before, not_after, key_type, key_bits, + signature_algorithm, fingerprint_sha256 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + scan_id, + port, + position, + cert.subject.rfc4514_string(), + cert.issuer.rfc4514_string() if hasattr(cert, "issuer") else None, + str(cert.serial_number), + cert.not_valid_before_utc.isoformat() + if hasattr(cert, "not_valid_before_utc") + else None, + cert.not_valid_after_utc.isoformat() + if hasattr(cert, "not_valid_after_utc") + else None, + key_type, + key_bits, + sig_alg, + cert.fingerprint_sha256 + if hasattr(cert, "fingerprint_sha256") + else None, + ), + ) + + +def _save_vulnerabilities( + cursor: sqlite3.Cursor, + scan_id: int, + port: int, + scan_result: ServerScanResult, +) -> None: + """Save vulnerability scan results.""" + from sslyze import ScanCommandAttemptStatusEnum + + if not scan_result.scan_result: + return + + # Heartbleed + heartbleed_attempt = scan_result.scan_result.heartbleed + if heartbleed_attempt.status == ScanCommandAttemptStatusEnum.COMPLETED: + heartbleed_result = heartbleed_attempt.result + if heartbleed_result: + cursor.execute( + """ + INSERT INTO scan_vulnerabilities ( + scan_id, port, vuln_type, vulnerable, details + ) VALUES (?, ?, ?, ?, ?) + """, + ( + scan_id, + port, + "heartbleed", + heartbleed_result.is_vulnerable_to_heartbleed, + None, + ), + ) + + # ROBOT + robot_attempt = scan_result.scan_result.robot + if robot_attempt.status == ScanCommandAttemptStatusEnum.COMPLETED: + robot_result = robot_attempt.result + if robot_result: + # Check if robot_result has the attribute + vulnerable = False + details = None + if hasattr(robot_result, "robot_result_enum"): + vulnerable = ( + robot_result.robot_result_enum.name != "NOT_VULNERABLE_NO_ORACLE" + ) + details = robot_result.robot_result_enum.name + + cursor.execute( + """ + INSERT INTO scan_vulnerabilities ( + scan_id, port, vuln_type, vulnerable, details + ) VALUES (?, ?, ?, ?, ?) + """, + ( + scan_id, + port, + "robot", + vulnerable, + details, + ), + ) + + # OpenSSL CCS Injection + ccs_attempt = scan_result.scan_result.openssl_ccs_injection + if ccs_attempt.status == ScanCommandAttemptStatusEnum.COMPLETED: + ccs_result = ccs_attempt.result + if ccs_result: + cursor.execute( + """ + INSERT INTO scan_vulnerabilities ( + scan_id, port, vuln_type, vulnerable, details + ) VALUES (?, ?, ?, ?, ?) + """, + ( + scan_id, + port, + "openssl_ccs_injection", + ccs_result.is_vulnerable_to_ccs_injection, + None, + ), + ) + + +def _insert_protocol_feature( + cursor: sqlite3.Cursor, + scan_id: int, + port: int, + feature_type: str, + supported: bool, + details: str | None = None, +) -> None: + """Insert protocol feature into database. + + Args: + cursor: Database cursor + scan_id: Scan ID + port: Port number + feature_type: Feature type identifier + supported: Whether feature is supported + details: Optional details string + + """ + cursor.execute( + """ + INSERT INTO scan_protocol_features ( + scan_id, port, feature_type, supported, details + ) VALUES (?, ?, ?, ?, ?) + """, + (scan_id, port, feature_type, supported, details), + ) + + +def _save_protocol_features( + cursor: sqlite3.Cursor, + scan_id: int, + port: int, + scan_result: ServerScanResult, +) -> None: + """Save protocol features (compression, early data, fallback SCSV, extended master secret).""" + from sslyze import ScanCommandAttemptStatusEnum + + if not scan_result.scan_result: + return + + # TLS Compression + compression_attempt = scan_result.scan_result.tls_compression + if compression_attempt.status == ScanCommandAttemptStatusEnum.COMPLETED: + compression_result = compression_attempt.result + if compression_result: + supported = ( + hasattr(compression_result, "supports_compression") + and compression_result.supports_compression + ) + _insert_protocol_feature( + cursor, + scan_id, + port, + "tls_compression", + supported, + "TLS compression is deprecated and should not be used", + ) + + # TLS 1.3 Early Data + early_data_attempt = scan_result.scan_result.tls_1_3_early_data + if early_data_attempt.status == ScanCommandAttemptStatusEnum.COMPLETED: + early_data_result = early_data_attempt.result + if early_data_result: + supported = ( + hasattr(early_data_result, "supports_early_data") + and early_data_result.supports_early_data + ) + details = None + if supported and hasattr(early_data_result, "max_early_data_size"): + details = f"max_early_data_size: {early_data_result.max_early_data_size}" + _insert_protocol_feature( + cursor, scan_id, port, "tls_1_3_early_data", supported, details + ) + + # TLS Fallback SCSV + fallback_attempt = scan_result.scan_result.tls_fallback_scsv + if fallback_attempt.status == ScanCommandAttemptStatusEnum.COMPLETED: + fallback_result = fallback_attempt.result + if fallback_result: + supported = ( + hasattr(fallback_result, "supports_fallback_scsv") + and fallback_result.supports_fallback_scsv + ) + _insert_protocol_feature( + cursor, + scan_id, + port, + "tls_fallback_scsv", + supported, + "Prevents downgrade attacks", + ) + + # Extended Master Secret + ems_attempt = scan_result.scan_result.tls_extended_master_secret + if ems_attempt.status == ScanCommandAttemptStatusEnum.COMPLETED: + ems_result = ems_attempt.result + if ems_result: + supported = ( + hasattr(ems_result, "supports_extended_master_secret") + and ems_result.supports_extended_master_secret + ) + _insert_protocol_feature( + cursor, + scan_id, + port, + "tls_extended_master_secret", + supported, + "RFC 7627 - Mitigates certain TLS attacks", + ) + + +def _save_session_features( + cursor: sqlite3.Cursor, + scan_id: int, + port: int, + scan_result: ServerScanResult, +) -> None: + """Save session features (renegotiation and resumption).""" + from sslyze import ScanCommandAttemptStatusEnum + + if not scan_result.scan_result: + return + + # Session Renegotiation + renegotiation_attempt = scan_result.scan_result.session_renegotiation + if renegotiation_attempt.status == ScanCommandAttemptStatusEnum.COMPLETED: + renegotiation_result = renegotiation_attempt.result + if renegotiation_result: + client_initiated = ( + hasattr(renegotiation_result, "is_client_renegotiation_supported") + and renegotiation_result.is_client_renegotiation_supported + ) + secure = ( + hasattr(renegotiation_result, "supports_secure_renegotiation") + and renegotiation_result.supports_secure_renegotiation + ) + cursor.execute( + """ + INSERT INTO scan_session_features ( + scan_id, port, feature_type, client_initiated, secure, + session_id_supported, ticket_supported, + attempted_resumptions, successful_resumptions, details + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + scan_id, + port, + "session_renegotiation", + client_initiated, + secure, + None, + None, + None, + None, + None, + ), + ) + + # Session Resumption + resumption_attempt = scan_result.scan_result.session_resumption + if resumption_attempt.status == ScanCommandAttemptStatusEnum.COMPLETED: + resumption_result = resumption_attempt.result + if resumption_result: + session_id_supported = False + ticket_supported = False + attempted = 0 + successful = 0 + + if hasattr(resumption_result, "session_id_resumption_result"): + session_id_resumption = resumption_result.session_id_resumption_result + if session_id_resumption: + session_id_supported = ( + hasattr( + session_id_resumption, + "is_session_id_resumption_supported", + ) + and session_id_resumption.is_session_id_resumption_supported + ) + if hasattr(session_id_resumption, "attempted_resumptions_count"): + attempted += session_id_resumption.attempted_resumptions_count + if hasattr(session_id_resumption, "successful_resumptions_count"): + successful += session_id_resumption.successful_resumptions_count + + if hasattr(resumption_result, "tls_ticket_resumption_result"): + ticket_resumption = resumption_result.tls_ticket_resumption_result + if ticket_resumption: + ticket_supported = ( + hasattr(ticket_resumption, "is_tls_ticket_resumption_supported") + and ticket_resumption.is_tls_ticket_resumption_supported + ) + if hasattr(ticket_resumption, "attempted_resumptions_count"): + attempted += ticket_resumption.attempted_resumptions_count + if hasattr(ticket_resumption, "successful_resumptions_count"): + successful += ticket_resumption.successful_resumptions_count + + cursor.execute( + """ + INSERT INTO scan_session_features ( + scan_id, port, feature_type, client_initiated, secure, + session_id_supported, ticket_supported, + attempted_resumptions, successful_resumptions, details + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + scan_id, + port, + "session_resumption", + None, + None, + session_id_supported, + ticket_supported, + attempted, + successful, + None, + ), + ) + + +def _save_http_headers( + cursor: sqlite3.Cursor, + scan_id: int, + port: int, + scan_result: ServerScanResult, +) -> None: + """Save HTTP security headers.""" + from sslyze import ScanCommandAttemptStatusEnum + + if not scan_result.scan_result: + return + + http_headers_attempt = scan_result.scan_result.http_headers + if http_headers_attempt.status != ScanCommandAttemptStatusEnum.COMPLETED: + return + + http_headers_result = http_headers_attempt.result + if not http_headers_result: + return + + # Strict-Transport-Security + if hasattr(http_headers_result, "strict_transport_security_header"): + hsts = http_headers_result.strict_transport_security_header + cursor.execute( + """ + INSERT INTO scan_http_headers ( + scan_id, port, header_name, header_value, is_present + ) VALUES (?, ?, ?, ?, ?) + """, + ( + scan_id, + port, + "Strict-Transport-Security", + str(hsts) if hsts else None, + hsts is not None, + ), + ) + + # Public-Key-Pins + if hasattr(http_headers_result, "public_key_pins_header"): + hpkp = http_headers_result.public_key_pins_header + cursor.execute( + """ + INSERT INTO scan_http_headers ( + scan_id, port, header_name, header_value, is_present + ) VALUES (?, ?, ?, ?, ?) + """, + ( + scan_id, + port, + "Public-Key-Pins", + str(hpkp) if hpkp else None, + hpkp is not None, + ), + ) + + # Expect-CT + if hasattr(http_headers_result, "expect_ct_header"): + expect_ct = http_headers_result.expect_ct_header + cursor.execute( + """ + INSERT INTO scan_http_headers ( + scan_id, port, header_name, header_value, is_present + ) VALUES (?, ?, ?, ?, ?) + """, + ( + scan_id, + port, + "Expect-CT", + str(expect_ct) if expect_ct else None, + expect_ct is not None, + ), + ) diff --git a/src/sslysze_scan/output.py b/src/sslysze_scan/output.py new file mode 100644 index 0000000..16a3c42 --- /dev/null +++ b/src/sslysze_scan/output.py @@ -0,0 +1,216 @@ +"""Console output module for scan results.""" + +from typing import Any + +from sslyze.scanner.models import ServerScanResult + + +def print_scan_results( + scan_result: ServerScanResult, compliance_stats: dict[str, Any] +) -> None: + """Print scan results to console. + + Args: + scan_result: SSLyze ServerScanResult object + compliance_stats: Compliance check statistics + + """ + print("\n" + "=" * 70) + print( + f"Scan-Ergebnisse für {scan_result.server_location.hostname}:{scan_result.server_location.port}", + ) + print("=" * 70) + + # Connectivity status + print(f"\nVerbindungsstatus: {scan_result.scan_status.name}") + + if scan_result.connectivity_result: + print( + f"Höchste TLS-Version: {scan_result.connectivity_result.highest_tls_version_supported}", + ) + print(f"Cipher Suite: {scan_result.connectivity_result.cipher_suite_supported}") + + if not scan_result.scan_result: + print("\nKeine Scan-Ergebnisse verfügbar (Verbindungsfehler)") + return + + # TLS 1.2 Cipher Suites + _print_cipher_suites(scan_result, "1.2") + + # TLS 1.3 Cipher Suites + _print_cipher_suites(scan_result, "1.3") + + # Supported Groups + _print_supported_groups(scan_result) + + # Certificates + _print_certificates(scan_result) + + # Vulnerabilities + _print_vulnerabilities(scan_result) + + # Compliance Summary + print("\n" + "-" * 70) + print("Compliance-Zusammenfassung:") + print("-" * 70) + print( + f"Cipher Suites: {compliance_stats['cipher_suites_passed']}/{compliance_stats['cipher_suites_checked']} konform", + ) + print( + f"Supported Groups: {compliance_stats['supported_groups_passed']}/{compliance_stats['supported_groups_checked']} konform", + ) + print("=" * 70 + "\n") + + +def _print_cipher_suites(scan_result: ServerScanResult, tls_version: str) -> None: + """Print cipher suites for specific TLS version.""" + from sslyze import ScanCommandAttemptStatusEnum + + version_map = { + "1.2": "tls_1_2_cipher_suites", + "1.3": "tls_1_3_cipher_suites", + } + + if tls_version not in version_map: + return + + cipher_attempt = getattr(scan_result.scan_result, version_map[tls_version]) + + if cipher_attempt.status != ScanCommandAttemptStatusEnum.COMPLETED: + print(f"\nTLS {tls_version} Cipher Suites: Nicht verfügbar") + return + + cipher_result = cipher_attempt.result + if not cipher_result or not cipher_result.accepted_cipher_suites: + print(f"\nTLS {tls_version} Cipher Suites: Keine akzeptiert") + return + + print( + f"\nTLS {tls_version} Cipher Suites ({len(cipher_result.accepted_cipher_suites)} akzeptiert):", + ) + for cs in cipher_result.accepted_cipher_suites: + print(f" • {cs.cipher_suite.name}") + + +def _print_supported_groups(scan_result: ServerScanResult) -> None: + """Print supported elliptic curves / DH groups.""" + from sslyze import ScanCommandAttemptStatusEnum + + ec_attempt = scan_result.scan_result.elliptic_curves + + if ec_attempt.status != ScanCommandAttemptStatusEnum.COMPLETED: + print("\nUnterstützte Gruppen: Nicht verfügbar") + return + + ec_result = ec_attempt.result + if not ec_result or not ec_result.supported_curves: + print("\nUnterstützte Gruppen: Keine gefunden") + return + + print(f"\nUnterstützte Gruppen ({len(ec_result.supported_curves)}):") + for curve in ec_result.supported_curves: + print(f" • {curve.name}") + + +def _print_certificates(scan_result: ServerScanResult) -> None: + """Print certificate information.""" + from sslyze import ScanCommandAttemptStatusEnum + + cert_attempt = scan_result.scan_result.certificate_info + + if cert_attempt.status != ScanCommandAttemptStatusEnum.COMPLETED: + print("\nZertifikate: Nicht verfügbar") + return + + cert_result = cert_attempt.result + if not cert_result: + return + + print("\nZertifikate:") + for cert_deployment in cert_result.certificate_deployments: + for i, cert in enumerate(cert_deployment.received_certificate_chain): + print(f"\n Zertifikat #{i}:") + print(f" Subject: {cert.subject.rfc4514_string()}") + print(f" Serial: {cert.serial_number}") + + if hasattr(cert, "not_valid_before_utc") and hasattr( + cert, + "not_valid_after_utc", + ): + print( + f" Gültig von: {cert.not_valid_before_utc.strftime('%Y-%m-%d %H:%M:%S UTC')}", + ) + print( + f" Gültig bis: {cert.not_valid_after_utc.strftime('%Y-%m-%d %H:%M:%S UTC')}", + ) + + public_key = cert.public_key() + key_type = public_key.__class__.__name__ + key_bits = ( + public_key.key_size if hasattr(public_key, "key_size") else "unknown" + ) + print(f" Key: {key_type} ({key_bits} bits)") + + +def _print_vulnerabilities(scan_result: ServerScanResult) -> None: + """Print vulnerability scan results.""" + from sslyze import ScanCommandAttemptStatusEnum + + print("\nSicherheitsprüfungen:") + + # Heartbleed + heartbleed_attempt = scan_result.scan_result.heartbleed + if heartbleed_attempt.status == ScanCommandAttemptStatusEnum.COMPLETED: + heartbleed_result = heartbleed_attempt.result + if heartbleed_result: + status = ( + "VERWUNDBAR ⚠️" + if heartbleed_result.is_vulnerable_to_heartbleed + else "OK ✓" + ) + print(f" • Heartbleed: {status}") + + # ROBOT + robot_attempt = scan_result.scan_result.robot + if robot_attempt.status == ScanCommandAttemptStatusEnum.COMPLETED: + robot_result = robot_attempt.result + if robot_result: + vulnerable = False + if hasattr(robot_result, "robot_result_enum"): + vulnerable = ( + robot_result.robot_result_enum.name != "NOT_VULNERABLE_NO_ORACLE" + ) + elif hasattr(robot_result, "robot_result"): + vulnerable = str(robot_result.robot_result) != "NOT_VULNERABLE_NO_ORACLE" + status = "VERWUNDBAR ⚠️" if vulnerable else "OK ✓" + print(f" • ROBOT: {status}") + + # OpenSSL CCS Injection + ccs_attempt = scan_result.scan_result.openssl_ccs_injection + 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 ✓" + ) + print(f" • OpenSSL CCS Injection: {status}") + + +def print_error(message: str) -> None: + """Print error message to console. + + Args: + message: Error message + + """ + print(f"\n✗ Fehler: {message}\n") + + +def print_success(message: str) -> None: + """Print success message to console. + + Args: + message: Success message + + """ + print(f"\n✓ {message}\n") diff --git a/src/sslysze_scan/protocol_loader.py b/src/sslysze_scan/protocol_loader.py new file mode 100644 index 0000000..53c31b2 --- /dev/null +++ b/src/sslysze_scan/protocol_loader.py @@ -0,0 +1,75 @@ +"""Module for loading protocol-port mappings from CSV file.""" + +import csv +from pathlib import Path + +# Port constants +MIN_PORT_NUMBER = 1 +MAX_PORT_NUMBER = 65535 + + +def load_protocol_mappings() -> dict[int, str]: + """Load protocol-port mappings from CSV file. + + Returns: + Dictionary mapping port numbers to protocol names. + + Raises: + FileNotFoundError: If CSV file does not exist. + ValueError: If CSV file is malformed. + + """ + # Get path to CSV file relative to this module + csv_path = Path(__file__).parent / "data" / "protocols.csv" + + if not csv_path.exists(): + raise FileNotFoundError(f"Protocol mappings file not found: {csv_path}") + + mappings: dict[int, str] = {} + + try: + with csv_path.open(encoding="utf-8") as f: + reader = csv.DictReader(f) + + for row_num, row in enumerate( + reader, + start=2, + ): # start=2 because header is line 1 + try: + protocol = row["protocol"].strip() + port = int(row["port"].strip()) + + if port < MIN_PORT_NUMBER or port > MAX_PORT_NUMBER: + raise ValueError( + f"Invalid port number {port} on line {row_num}", + ) + + mappings[port] = protocol + + except KeyError as e: + raise ValueError( + f"Missing column {e} in CSV file on line {row_num}", + ) from e + except ValueError as e: + raise ValueError( + f"Invalid data in CSV file on line {row_num}: {e}" + ) from e + + except (OSError, csv.Error) as e: + raise ValueError(f"Error reading CSV file: {e}") from e + + return mappings + + +def get_protocol_for_port(port: int) -> str | None: + """Get the protocol name for a given port number. + + Args: + port: Port number to check. + + Returns: + Protocol name if found, None otherwise. + + """ + mappings = load_protocol_mappings() + return mappings.get(port) diff --git a/src/sslysze_scan/reporter/__init__.py b/src/sslysze_scan/reporter/__init__.py new file mode 100644 index 0000000..7e5a3c4 --- /dev/null +++ b/src/sslysze_scan/reporter/__init__.py @@ -0,0 +1,50 @@ +"""Report generation module for scan results.""" + +from .csv_export import generate_csv_reports +from .markdown_export import generate_markdown_report +from .query import get_scan_data, get_scan_metadata, list_scans +from .rst_export import generate_rest_report + +__all__ = [ + "generate_csv_reports", + "generate_markdown_report", + "generate_report", + "generate_rest_report", + "get_scan_data", + "get_scan_metadata", + "list_scans", +] + + +def generate_report( + db_path: str, + scan_id: int, + report_type: str, + output: str = None, + output_dir: str = ".", +) -> list[str]: + """Generate report for scan. + + Args: + db_path: Path to database file + scan_id: Scan ID + report_type: Report type ('csv', 'markdown', or 'rest') + output: Output file for markdown/rest (auto-generated if None) + output_dir: Output directory for CSV/reST files + + Returns: + List of generated file paths + + Raises: + ValueError: If report type is unknown + + """ + if report_type == "markdown": + file_path = generate_markdown_report(db_path, scan_id, output) + return [file_path] + if report_type == "csv": + return generate_csv_reports(db_path, scan_id, output_dir) + if report_type in ("rest", "rst"): + file_path = generate_rest_report(db_path, scan_id, output, output_dir) + return [file_path] + raise ValueError(f"Unknown report type: {report_type}") diff --git a/src/sslysze_scan/reporter/csv_export.py b/src/sslysze_scan/reporter/csv_export.py new file mode 100644 index 0000000..17c825d --- /dev/null +++ b/src/sslysze_scan/reporter/csv_export.py @@ -0,0 +1,536 @@ +"""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) + + +def _export_summary( + output_dir: Path, + summary: dict[str, Any], + db_path: str, +) -> list[str]: + """Export summary statistics to CSV. + + Args: + output_dir: Output directory path + 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)], + ["Cipher Suites Checked", summary.get("total_cipher_suites", 0)], + [ + "Cipher Suites Compliant", + ( + f"{summary.get('compliant_cipher_suites', 0)} " + f"({summary.get('cipher_suite_percentage', 0)}%)" + ), + ], + ["Supported Groups Checked", summary.get("total_groups", 0)], + [ + "Supported Groups Compliant", + ( + f"{summary.get('compliant_groups', 0)} " + f"({summary.get('group_percentage', 0)}%)" + ), + ], + [ + "Critical Vulnerabilities", + summary.get("critical_vulnerabilities", 0), + ], + ] + headers = _get_headers(db_path, "summary") + _write_csv(summary_file, headers, rows) + return [str(summary_file)] + + +def _export_cipher_suites( + output_dir: Path, + 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 + port: Port number + cipher_suites: Cipher suites data per TLS version + + Returns: + List of generated file paths + + """ + generated = [] + + 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")), + suite.get("bsi_valid_until", "-"), + _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)) + + 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")), + 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)) + + return generated + + +def _export_supported_groups( + output_dir: Path, + port: int, + groups: list[dict[str, Any]], + db_path: str, +) -> list[str]: + """Export supported groups to CSV. + + Args: + output_dir: Output directory path + port: Port number + groups: List of supported groups + + Returns: + 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")), + group.get("bsi_valid_until", "-"), + _format_bool(group.get("compliant")), + ] + for group in groups + ] + headers = _get_headers(db_path, "supported_groups") + _write_csv(filepath, headers, rows) + return [str(filepath)] + + +def _export_missing_groups( + output_dir: Path, + 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 + port: Port number + missing: Dictionary with bsi_approved and iana_recommended groups + + Returns: + List of generated file paths + + """ + generated = [] + + if missing.get("bsi_approved"): + filepath = output_dir / f"{port}_missing_groups_bsi.csv" + rows = [ + [ + group["name"], + ", ".join(group.get("tls_versions", [])), + group.get("valid_until", "-"), + ] + for group in missing["bsi_approved"] + ] + headers = _get_headers(db_path, "missing_groups_bsi") + _write_csv(filepath, headers, rows) + generated.append(str(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)) + + return generated + + +def _export_certificates( + output_dir: Path, + port: int, + certificates: list[dict[str, Any]], + db_path: str, +) -> list[str]: + """Export certificates to CSV. + + Args: + output_dir: Output directory path + port: Port number + certificates: List of certificate data + + Returns: + List of generated file paths + + """ + filepath = output_dir / f"{port}_certificates.csv" + rows = [ + [ + cert["position"], + cert["subject"], + cert["issuer"], + cert["not_before"], + cert["not_after"], + cert["key_type"], + cert["key_bits"], + _format_bool(cert.get("compliant")), + ] + for cert in certificates + ] + headers = _get_headers(db_path, "certificates") + _write_csv(filepath, headers, rows) + return [str(filepath)] + + +def _export_vulnerabilities( + output_dir: Path, + port: int, + vulnerabilities: list[dict[str, Any]], + db_path: str, +) -> list[str]: + """Export vulnerabilities to CSV. + + Args: + output_dir: Output directory path + port: Port number + vulnerabilities: List of vulnerability data + + Returns: + List of generated file paths + + """ + filepath = output_dir / f"{port}_vulnerabilities.csv" + rows = [ + [ + vuln["type"], + _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)] + + +def _export_protocol_features( + output_dir: Path, + port: int, + features: list[dict[str, Any]], + db_path: str, +) -> list[str]: + """Export protocol features to CSV. + + Args: + output_dir: Output directory path + port: Port number + features: List of protocol feature data + + Returns: + List of generated file paths + + """ + filepath = output_dir / f"{port}_protocol_features.csv" + rows = [ + [ + feature["name"], + _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)] + + +def _export_session_features( + output_dir: Path, + port: int, + features: list[dict[str, Any]], + db_path: str, +) -> list[str]: + """Export session features to CSV. + + Args: + output_dir: Output directory path + port: Port number + features: List of session feature data + + Returns: + 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")), + feature.get("details", "-"), + ] + for feature in features + ] + headers = _get_headers(db_path, "session_features") + _write_csv(filepath, headers, rows) + return [str(filepath)] + + +def _export_http_headers( + output_dir: Path, + port: int, + headers: list[dict[str, Any]], + db_path: str, +) -> list[str]: + """Export HTTP headers to CSV. + + Args: + output_dir: Output directory path + port: Port number + headers: List of HTTP header data + + Returns: + List of generated file paths + + """ + filepath = output_dir / f"{port}_http_headers.csv" + rows = [ + [ + header["name"], + _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)] + + +def _export_compliance_status( + output_dir: Path, + port: int, + compliance: dict[str, Any], + db_path: str, +) -> list[str]: + """Export compliance status to CSV. + + Args: + output_dir: Output directory path + port: Port number + compliance: Compliance data dictionary + + Returns: + List of generated file paths + + """ + filepath = output_dir / f"{port}_compliance_status.csv" + rows = [] + + if "cipher_suites_checked" in compliance: + rows.append( + [ + "Cipher Suites", + compliance["cipher_suites_checked"], + compliance["cipher_suites_passed"], + f"{compliance['cipher_suite_percentage']}%", + ], + ) + + if "groups_checked" in compliance: + rows.append( + [ + "Supported Groups", + compliance["groups_checked"], + compliance["groups_passed"], + f"{compliance['group_percentage']}%", + ], + ) + + if rows: + headers = _get_headers(db_path, "compliance_status") + _write_csv(filepath, headers, rows) + return [str(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), + ("missing_recommended_groups", _export_missing_groups), + ("certificates", _export_certificates), + ("vulnerabilities", _export_vulnerabilities), + ("protocol_features", _export_protocol_features), + ("session_features", _export_session_features), + ("http_headers", _export_http_headers), + ("compliance", _export_compliance_status), +) + + +def generate_csv_reports( + db_path: str, + scan_id: int, + output_dir: str = ".", +) -> list[str]: + """Generate granular CSV reports for scan. + + Args: + db_path: Path to database file + scan_id: Scan ID + output_dir: Output directory for CSV files + + Returns: + List of generated file paths + + """ + data = get_scan_data(db_path, scan_id) + output_dir_path = Path(output_dir) + output_dir_path.mkdir(parents=True, exist_ok=True) + + generated_files = [] + + generated_files.extend( + _export_summary(output_dir_path, data.get("summary", {}), db_path), + ) + + for port_data in data["ports_data"].values(): + 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), + ) + + return generated_files diff --git a/src/sslysze_scan/reporter/markdown_export.py b/src/sslysze_scan/reporter/markdown_export.py new file mode 100644 index 0000000..76e0405 --- /dev/null +++ b/src/sslysze_scan/reporter/markdown_export.py @@ -0,0 +1,37 @@ +"""Markdown report generation using shared template utilities.""" + + +from .query import _generate_recommendations, get_scan_data +from .template_utils import ( + build_template_context, + generate_report_id, + prepare_output_path, + render_template_to_file, +) + + +def generate_markdown_report( + db_path: str, scan_id: int, output_file: str | None = None, +) -> str: + """Generate markdown report for scan. + + Args: + db_path: Path to database file + scan_id: Scan ID + output_file: Optional output file path (auto-generated if None) + + Returns: + Path to generated report file + + """ + data = get_scan_data(db_path, scan_id) + metadata = data["metadata"] + report_id = generate_report_id(metadata) + + context = build_template_context(data) + context["recommendations"] = _generate_recommendations(data) + + default_filename = f"compliance_report_{report_id}.md" + output_path = prepare_output_path(output_file, ".", default_filename) + + return render_template_to_file("report.md.j2", context, output_path) diff --git a/src/sslysze_scan/reporter/query.py b/src/sslysze_scan/reporter/query.py new file mode 100644 index 0000000..9be2ca3 --- /dev/null +++ b/src/sslysze_scan/reporter/query.py @@ -0,0 +1,534 @@ +"""Report generation module for scan results.""" + +import sqlite3 +from typing import Any + +# Compliance thresholds +COMPLIANCE_WARNING_THRESHOLD = 50.0 + + +def list_scans(db_path: str) -> list[dict[str, Any]]: + """List all available scans in the database. + + Args: + db_path: Path to database file + + Returns: + List of scan dictionaries with metadata + + """ + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + cursor.execute( + """ + SELECT scan_id, timestamp, hostname, ports, scan_duration_seconds + FROM scans + ORDER BY scan_id DESC + """, + ) + + scans = [] + for row in cursor.fetchall(): + scans.append( + { + "scan_id": row[0], + "timestamp": row[1], + "hostname": row[2], + "ports": row[3], + "duration": row[4], + }, + ) + + conn.close() + return scans + + +def get_scan_metadata(db_path: str, scan_id: int) -> dict[str, Any] | None: + """Get metadata for a specific scan. + + Args: + db_path: Path to database file + scan_id: Scan ID + + Returns: + Dictionary with scan metadata or None if not found + + """ + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + cursor.execute( + """ + SELECT s.scan_id, s.timestamp, s.hostname, s.ports, s.scan_duration_seconds, + h.fqdn, h.ipv4, h.ipv6 + FROM scans s + LEFT JOIN scanned_hosts h ON s.scan_id = h.scan_id + WHERE s.scan_id = ? + """, + (scan_id,), + ) + + row = cursor.fetchone() + conn.close() + + if not row: + return None + + return { + "scan_id": row[0], + "timestamp": row[1], + "hostname": row[2], + "ports": row[3].split(",") if row[3] else [], + "duration": row[4], + "fqdn": row[5] or row[2], + "ipv4": row[6], + "ipv6": row[7], + } + + +def get_scan_data(db_path: str, scan_id: int) -> dict[str, Any]: + """Get all scan data for report generation. + + Args: + db_path: Path to database file + scan_id: Scan ID + + Returns: + Dictionary with all scan data + + """ + metadata = get_scan_metadata(db_path, scan_id) + if not metadata: + raise ValueError(f"Scan ID {scan_id} not found") + + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + data = { + "metadata": metadata, + "ports_data": {}, + } + + # Get data for each port + for port in metadata["ports"]: + port_num = int(port) + port_data = { + "port": port_num, + "status": "completed", + "tls_version": None, + "cipher_suites": {}, + "supported_groups": [], + "certificates": [], + "vulnerabilities": [], + "protocol_features": [], + "session_features": [], + "http_headers": [], + "compliance": {}, + } + + # Cipher suites using view + cursor.execute( + """ + SELECT tls_version, cipher_suite_name, accepted, iana_value, key_size, is_anonymous, + iana_recommended_final, bsi_approved_final, bsi_valid_until_final, compliant + FROM v_cipher_suites_with_compliance + WHERE scan_id = ? AND port = ? + ORDER BY tls_version, accepted DESC, cipher_suite_name + """, + (scan_id, port_num), + ) + + rejected_counts = {} + for row in cursor.fetchall(): + tls_version = row[0] + if tls_version not in port_data["cipher_suites"]: + port_data["cipher_suites"][tls_version] = { + "accepted": [], + "rejected": [], + } + rejected_counts[tls_version] = 0 + + suite = { + "name": row[1], + "accepted": row[2], + "iana_value": row[3], + "key_size": row[4], + "is_anonymous": row[5], + } + + if row[2]: # accepted + suite["iana_recommended"] = row[6] + suite["bsi_approved"] = row[7] + suite["bsi_valid_until"] = row[8] + suite["compliant"] = row[9] + port_data["cipher_suites"][tls_version]["accepted"].append(suite) + else: # rejected + rejected_counts[tls_version] += 1 + # Only include rejected if BSI-approved OR IANA-recommended + if row[7] or row[6] == "Y": + suite["iana_recommended"] = row[6] + suite["bsi_approved"] = row[7] + suite["bsi_valid_until"] = row[8] + suite["compliant"] = False + port_data["cipher_suites"][tls_version]["rejected"].append(suite) + + # Store rejected counts + for tls_version in port_data["cipher_suites"]: + port_data["cipher_suites"][tls_version]["rejected_total"] = ( + rejected_counts.get(tls_version, 0) + ) + + # Determine highest TLS version + if port_data["cipher_suites"]: + tls_versions = list(port_data["cipher_suites"].keys()) + version_order = ["ssl_3.0", "1.0", "1.1", "1.2", "1.3"] + for version in reversed(version_order): + if version in tls_versions: + port_data["tls_version"] = version + break + + # Supported groups using view + cursor.execute( + """ + SELECT group_name, iana_value, openssl_nid, + iana_recommended, bsi_approved, bsi_valid_until, compliant + FROM v_supported_groups_with_compliance + WHERE scan_id = ? AND port = ? + ORDER BY group_name + """, + (scan_id, port_num), + ) + + for row in cursor.fetchall(): + port_data["supported_groups"].append( + { + "name": row[0], + "iana_value": row[1], + "openssl_nid": row[2], + "iana_recommended": row[3], + "bsi_approved": row[4], + "bsi_valid_until": row[5], + "compliant": row[6], + }, + ) + + # Certificates using view + cursor.execute( + """ + SELECT position, subject, issuer, serial_number, not_before, not_after, + key_type, key_bits, signature_algorithm, fingerprint_sha256, + compliant, compliance_details + FROM v_certificates_with_compliance + WHERE scan_id = ? AND port = ? + ORDER BY position + """, + (scan_id, port_num), + ) + + for row in cursor.fetchall(): + port_data["certificates"].append( + { + "position": row[0], + "subject": row[1], + "issuer": row[2], + "serial_number": row[3], + "not_before": row[4], + "not_after": row[5], + "key_type": row[6], + "key_bits": row[7], + "signature_algorithm": row[8], + "fingerprint_sha256": row[9], + "compliant": row[10] if row[10] is not None else None, + "compliance_details": row[11] if row[11] else None, + }, + ) + + # Vulnerabilities + cursor.execute( + """ + SELECT vuln_type, vulnerable, details + FROM scan_vulnerabilities + WHERE scan_id = ? AND port = ? + ORDER BY vuln_type + """, + (scan_id, port_num), + ) + + for row in cursor.fetchall(): + port_data["vulnerabilities"].append( + { + "type": row[0], + "vulnerable": row[1], + "details": row[2], + }, + ) + + # Protocol features + cursor.execute( + """ + SELECT feature_type, supported, details + FROM scan_protocol_features + WHERE scan_id = ? AND port = ? + ORDER BY feature_type + """, + (scan_id, port_num), + ) + + for row in cursor.fetchall(): + port_data["protocol_features"].append( + { + "name": row[0], + "supported": row[1], + "details": row[2], + }, + ) + + # Session features + cursor.execute( + """ + SELECT feature_type, client_initiated, secure, session_id_supported, + ticket_supported, attempted_resumptions, successful_resumptions, details + FROM scan_session_features + WHERE scan_id = ? AND port = ? + ORDER BY feature_type + """, + (scan_id, port_num), + ) + + for row in cursor.fetchall(): + port_data["session_features"].append( + { + "type": row[0], + "client_initiated": row[1], + "secure": row[2], + "session_id_supported": row[3], + "ticket_supported": row[4], + "attempted_resumptions": row[5], + "successful_resumptions": row[6], + "details": row[7], + }, + ) + + # HTTP headers + cursor.execute( + """ + SELECT header_name, header_value, is_present + FROM scan_http_headers + WHERE scan_id = ? AND port = ? + ORDER BY header_name + """, + (scan_id, port_num), + ) + + for row in cursor.fetchall(): + port_data["http_headers"].append( + { + "name": row[0], + "value": row[1], + "is_present": row[2], + }, + ) + + # Compliance summary using view + cursor.execute( + """ + SELECT check_type, total, passed, percentage + FROM v_port_compliance_summary + WHERE scan_id = ? AND port = ? + """, + (scan_id, port_num), + ) + + for row in cursor.fetchall(): + check_type = row[0] + total = row[1] + passed = row[2] + percentage = row[3] + + if check_type == "cipher_suite": + port_data["compliance"]["cipher_suites_checked"] = total + port_data["compliance"]["cipher_suites_passed"] = passed + port_data["compliance"]["cipher_suite_percentage"] = f"{percentage:.1f}" + elif check_type == "supported_group": + port_data["compliance"]["groups_checked"] = total + port_data["compliance"]["groups_passed"] = passed + port_data["compliance"]["group_percentage"] = f"{percentage:.1f}" + + # Get missing recommended groups for this port + port_data["missing_recommended_groups"] = _get_missing_recommended_groups( + cursor, + scan_id, + port_num, + ) + + data["ports_data"][port_num] = port_data + + conn.close() + + # Calculate overall summary + data["summary"] = _calculate_summary(data) + + return data + + +def _get_missing_recommended_groups( + cursor: sqlite3.Cursor, + scan_id: int, + port: int, +) -> dict[str, list[dict[str, Any]]]: + """Get recommended groups that are not offered by the server using views. + + Args: + cursor: Database cursor + scan_id: Scan ID + port: Port number + + Returns: + Dictionary with 'bsi_approved' and 'iana_recommended' lists + + """ + missing = {"bsi_approved": [], "iana_recommended": []} + + # Get missing BSI-approved groups using view + cursor.execute( + """ + SELECT group_name, tls_version, valid_until + FROM v_missing_bsi_groups + WHERE scan_id = ? + ORDER BY group_name, tls_version + """, + (scan_id,), + ) + + bsi_groups = {} + for row in cursor.fetchall(): + group_name = row[0] + tls_version = row[1] + valid_until = row[2] + + if group_name not in bsi_groups: + bsi_groups[group_name] = { + "name": group_name, + "tls_versions": [], + "valid_until": valid_until, + } + bsi_groups[group_name]["tls_versions"].append(tls_version) + + missing["bsi_approved"] = list(bsi_groups.values()) + + # Get missing IANA-recommended groups using view + cursor.execute( + """ + SELECT group_name, iana_value + FROM v_missing_iana_groups + WHERE scan_id = ? + ORDER BY CAST(iana_value AS INTEGER) + """, + (scan_id,), + ) + + for row in cursor.fetchall(): + missing["iana_recommended"].append( + { + "name": row[0], + "iana_value": row[1], + }, + ) + + return missing + + +def _calculate_summary(data: dict[str, Any]) -> dict[str, Any]: + """Calculate overall summary statistics.""" + total_cipher_suites = 0 + compliant_cipher_suites = 0 + total_groups = 0 + compliant_groups = 0 + critical_vulnerabilities = 0 + ports_with_tls = 0 + ports_without_tls = 0 + + for port_data in data["ports_data"].values(): + # Check if port has TLS support + 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: + ports_with_tls += 1 + compliance = port_data.get("compliance", {}) + total_cipher_suites += compliance.get("cipher_suites_checked", 0) + compliant_cipher_suites += compliance.get("cipher_suites_passed", 0) + total_groups += compliance.get("groups_checked", 0) + compliant_groups += compliance.get("groups_passed", 0) + + for vuln in port_data.get("vulnerabilities", []): + if vuln.get("vulnerable"): + critical_vulnerabilities += 1 + else: + ports_without_tls += 1 + + cipher_suite_percentage = ( + (compliant_cipher_suites / total_cipher_suites * 100) + if total_cipher_suites > 0 + else 0 + ) + group_percentage = (compliant_groups / total_groups * 100) if total_groups > 0 else 0 + + return { + "total_ports": len(data["ports_data"]), + "successful_ports": ports_with_tls, + "ports_without_tls": ports_without_tls, + "total_cipher_suites": total_cipher_suites, + "compliant_cipher_suites": compliant_cipher_suites, + "cipher_suite_percentage": f"{cipher_suite_percentage:.1f}", + "total_groups": total_groups, + "compliant_groups": compliant_groups, + "group_percentage": f"{group_percentage:.1f}", + "critical_vulnerabilities": critical_vulnerabilities, + } + + +def _generate_recommendations(data: dict[str, Any]) -> list[dict[str, str]]: + """Generate recommendations based on scan results.""" + recommendations = [] + + # Check for vulnerabilities + for port_data in data["ports_data"].values(): + for vuln in port_data.get("vulnerabilities", []): + if vuln.get("vulnerable"): + recommendations.append( + { + "severity": "CRITICAL", + "message": f"Port {port_data['port']}: {vuln['type']} vulnerability found. Immediate update required.", + }, + ) + + # Check for low compliance + summary = data.get("summary", {}) + cipher_percentage = float(summary.get("cipher_suite_percentage", 0)) + if cipher_percentage < COMPLIANCE_WARNING_THRESHOLD: + recommendations.append( + { + "severity": "WARNING", + "message": f"Only {cipher_percentage:.1f}% of cipher suites are compliant. Disable insecure cipher suites.", + }, + ) + + # Check for deprecated TLS versions + for port_data in data["ports_data"].values(): + for tls_version in port_data.get("cipher_suites", {}).keys(): + if tls_version in ["ssl_3.0", "1.0", "1.1"]: + if port_data["cipher_suites"][tls_version]["accepted"]: + recommendations.append( + { + "severity": "WARNING", + "message": f"Port {port_data['port']}: Deprecated TLS version {tls_version} is supported. Disable TLS 1.0 and 1.1.", + }, + ) + + return recommendations diff --git a/src/sslysze_scan/reporter/rst_export.py b/src/sslysze_scan/reporter/rst_export.py new file mode 100644 index 0000000..3352e6a --- /dev/null +++ b/src/sslysze_scan/reporter/rst_export.py @@ -0,0 +1,39 @@ +"""reStructuredText report generation with CSV includes using shared utilities.""" + +from .csv_export import generate_csv_reports +from .query import get_scan_data +from .template_utils import ( + build_template_context, + prepare_output_path, + render_template_to_file, +) + + +def generate_rest_report( + db_path: str, scan_id: int, output_file: str | None = None, output_dir: str = ".", +) -> str: + """Generate reStructuredText report with CSV includes. + + Args: + db_path: Path to database file + scan_id: Scan ID + output_file: Output file path (optional) + output_dir: Output directory for report and CSV files + + Returns: + Path to generated report file + + """ + data = get_scan_data(db_path, scan_id) + + # Generate CSV files first + generate_csv_reports(db_path, scan_id, output_dir) + + # Build template context + context = build_template_context(data) + + # Prepare output path - always use fixed filename + default_filename = "compliance_report.rst" + output_path = prepare_output_path(output_file, output_dir, default_filename) + + return render_template_to_file("report.reST.j2", context, output_path) diff --git a/src/sslysze_scan/reporter/template_utils.py b/src/sslysze_scan/reporter/template_utils.py new file mode 100644 index 0000000..1527326 --- /dev/null +++ b/src/sslysze_scan/reporter/template_utils.py @@ -0,0 +1,168 @@ +"""Shared utilities for report template rendering.""" + +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from jinja2 import Environment, FileSystemLoader, select_autoescape + + +def format_tls_version(version: str) -> str: + """Format TLS version string for display. + + Args: + version: TLS version identifier (e.g., "1.2", "ssl_3.0") + + Returns: + Formatted version string (e.g., "TLS 1.2", "SSL 3.0") + + """ + version_map = { + "ssl_3.0": "SSL 3.0", + "1.0": "TLS 1.0", + "1.1": "TLS 1.1", + "1.2": "TLS 1.2", + "1.3": "TLS 1.3", + } + return version_map.get(version, version) + + +def create_jinja_env() -> Environment: + """Create Jinja2 environment with standard configuration. + + Returns: + Configured Jinja2 Environment with custom filters + + """ + template_dir = Path(__file__).parent.parent / "templates" + env = Environment( + loader=FileSystemLoader(str(template_dir)), + autoescape=select_autoescape(["html", "xml"]), + trim_blocks=True, + lstrip_blocks=True, + ) + env.filters["format_tls_version"] = format_tls_version + return env + + +def generate_report_id(metadata: dict[str, Any]) -> str: + """Generate report ID from scan metadata. + + Args: + metadata: Scan metadata dictionary containing timestamp + + Returns: + Report ID in format YYYYMMDD_ + + """ + try: + 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") + + return f"{date_str}_{metadata['scan_id']}" + + +def build_template_context(data: dict[str, Any]) -> dict[str, Any]: + """Build template context from scan data. + + Args: + data: Scan data dictionary from get_scan_data() + + Returns: + Dictionary with template context variables + + """ + metadata = data["metadata"] + + duration = metadata.get("duration") + if duration is not None: + duration_str = ( + f"{duration:.2f}" if isinstance(duration, (int, float)) else str(duration) + ) + else: + duration_str = "N/A" + + # Format timestamp to minute precision (DD.MM.YYYY HH:MM) + timestamp_str = metadata["timestamp"] + try: + dt = datetime.fromisoformat(timestamp_str) + timestamp_str = dt.strftime("%d.%m.%Y %H:%M") + except (ValueError, KeyError): + pass + + # 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: + ports_with_tls.append(port_data) + + return { + "scan_id": metadata["scan_id"], + "hostname": metadata["hostname"], + "fqdn": metadata["fqdn"], + "ipv4": metadata["ipv4"], + "ipv6": metadata["ipv6"], + "timestamp": timestamp_str, + "duration": duration_str, + "ports": ", ".join(metadata["ports"]), + "ports_without_tls": data.get("summary", {}).get("ports_without_tls", 0), + "summary": data.get("summary", {}), + "ports_data": sorted(ports_with_tls, key=lambda x: x["port"]), + } + + +def prepare_output_path( + output_file: str | None, + output_dir: str, + default_filename: str, +) -> Path: + """Prepare output file path and ensure parent directory exists. + + Args: + output_file: Explicit output file path (optional) + output_dir: Output directory for auto-generated files + default_filename: Default filename if output_file is None + + Returns: + Path object for output file + + """ + if output_file: + output_path = Path(output_file) + else: + output_path = Path(output_dir) / default_filename + + output_path.parent.mkdir(parents=True, exist_ok=True) + return output_path + + +def render_template_to_file( + template_name: str, + context: dict[str, Any], + output_path: Path, +) -> str: + """Render Jinja2 template and write to file. + + Args: + template_name: Name of template file + context: Template context variables + output_path: Output file path + + Returns: + String path of generated file + + """ + env = create_jinja_env() + template = env.get_template(template_name) + content = template.render(**context) + + output_path.write_text(content, encoding="utf-8") + return str(output_path) diff --git a/src/sslysze_scan/scan_iana.py b/src/sslysze_scan/scan_iana.py new file mode 100644 index 0000000..e68e0bc --- /dev/null +++ b/src/sslysze_scan/scan_iana.py @@ -0,0 +1,401 @@ +#!/usr/bin/env python3 +"""IANA XML Registry to SQLite Converter + +Parses IANA XML registry files and exports specified registries directly to SQLite database +based on configuration from iana_parse.json. +""" + +"""Script to fetch and parse IANA TLS registries into SQLite database.""" + +import json +import sqlite3 +import xml.etree.ElementTree as ET +from pathlib import Path + + +def load_config(config_path: str) -> dict: + """Load configuration from JSON file. + + Args: + config_path: Path to iana_parse.json + + Returns: + Dictionary with XML paths as keys and registry definitions as values + + Raises: + FileNotFoundError: If config file does not exist + json.JSONDecodeError: If config file is invalid JSON + + """ + config_path_obj = Path(config_path) + if not config_path_obj.is_file(): + raise FileNotFoundError(f"Konfigurationsdatei nicht gefunden: {config_path}") + + with config_path_obj.open(encoding="utf-8") as f: + return json.load(f) + + +def parse_xml_with_namespace_support( + xml_path: str, +) -> tuple[ET.Element, dict | None]: + """Parse XML file and detect if it uses IANA namespace. + + Args: + xml_path: Path to XML file + + Returns: + Tuple of (root element, namespace dict or None) + + Raises: + FileNotFoundError: If XML file does not exist + ET.ParseError: If XML is malformed + + """ + xml_path_obj = Path(xml_path) + if not xml_path_obj.is_file(): + raise FileNotFoundError(f"XML-Datei nicht gefunden: {xml_path}") + + try: + tree = ET.parse(xml_path) + root = tree.getroot() + + # Check if IANA namespace is used + if root.tag.startswith("{http://www.iana.org/assignments}"): + ns = {"iana": "http://www.iana.org/assignments"} + return root, ns + return root, None + + except ET.ParseError as e: + raise ET.ParseError(f"Fehler beim Parsen von {xml_path}: {e}") from e + + +def find_registry(root: ET.Element, registry_id: str, ns: dict | None) -> ET.Element: + """Find registry element by ID in XML tree. + + Args: + root: Root element of XML tree + registry_id: ID of registry to find + ns: Namespace dictionary or None + + Returns: + Registry element + + Raises: + ValueError: If registry not found + + """ + if ns: + registry = root.find(f'.//iana:registry[@id="{registry_id}"]', ns) + else: + registry = root.find(f'.//registry[@id="{registry_id}"]') + + if registry is None: + raise ValueError(f"Registry mit ID '{registry_id}' nicht gefunden") + + return registry + + +def get_element_text(record: ET.Element, tag: str, ns: dict | None) -> str: + """Get text content of element, supporting both namespaced and non-namespaced XML. + + Args: + record: Record element + tag: Tag name to find + ns: Namespace dictionary or None + + Returns: + Element text or empty string if not found + + """ + if ns: + elem = record.find(f"iana:{tag}", ns) + else: + elem = record.find(tag) + + if elem is not None and elem.text: + return elem.text.strip() + return "" + + +def process_xref_elements(record: ET.Element, ns: dict | None) -> str: + """Process all xref elements and combine them into a single string. + + Args: + record: Record element + ns: Namespace dictionary or None + + Returns: + Semicolon-separated string of xref references + + """ + xrefs = [] + + if ns: + xref_elements = record.findall("iana:xref", ns) + else: + xref_elements = record.findall("xref") + + for xref in xref_elements: + xref_type = xref.get("type", "") + xref_data = xref.get("data", "") + if xref_type and xref_data: + xrefs.append(f"{xref_type}:{xref_data}") + + return "; ".join(xrefs) + + +def map_header_to_element(header: str) -> str: + """Map CSV header name to XML element name. + + Implements implicit mapping with special cases: + - "Recommended" -> "rec" + - Most others: lowercase of header name + + Args: + header: CSV header name + + Returns: + XML element name + + """ + # Special mappings + special_mappings = { + "Recommended": "rec", + "RFC/Draft": "xref", # Special handling needed + "ESP": "esp", + "IKEv2": "ikev2", + "Status": "status", + } + + if header in special_mappings: + return special_mappings[header] + + # Default: lowercase + return header.lower() + + +def extract_field_value(record: ET.Element, header: str, ns: dict | None) -> str: + """Extract field value from record based on header name. + + Args: + record: XML record element + header: CSV header name + ns: Namespace dictionary or None + + Returns: + Field value as string + + """ + # Special handling for RFC/Draft (xref elements) + if header == "RFC/Draft": + return process_xref_elements(record, ns) + + # Get XML element name for this header + element_name = map_header_to_element(header) + + # Extract text + return get_element_text(record, element_name, ns) + + +def get_table_name_from_filename(filename: str) -> str: + """Convert CSV filename to database table name. + + Args: + filename: CSV filename (e.g., "tls_cipher_suites.csv") + + Returns: + Table name with iana_ prefix (e.g., "iana_tls_cipher_suites") + + """ + table_name = filename.replace(".csv", "") + if not table_name.startswith("iana_"): + table_name = f"iana_{table_name}" + return table_name + + +def write_registry_to_db( + root: ET.Element, + registry_id: str, + table_name: str, + headers: list[str], + ns: dict | None, + db_conn: sqlite3.Connection, +) -> int: + """Write registry data directly to SQLite database. + + Args: + root: Root element of XML tree + registry_id: ID of registry to export + table_name: Database table name + headers: List of column names + ns: Namespace dictionary or None + db_conn: SQLite database connection + + Returns: + Number of rows inserted + + Raises: + ValueError: If registry not found + sqlite3.Error: If database operation fails + + """ + # Find registry + registry = find_registry(root, registry_id, ns) + + # Process all records + if ns: + records = registry.findall("iana:record", ns) + else: + records = registry.findall("record") + + # Prepare data + rows = [] + for record in records: + row = [] + for header in headers: + value = extract_field_value(record, header, ns) + row.append(value) + rows.append(tuple(row)) + + if not rows: + return 0 + + # Insert into database + cursor = db_conn.cursor() + placeholders = ",".join(["?"] * len(headers)) + + # Delete existing data for this table + cursor.execute(f"DELETE FROM {table_name}") + + # Insert new data + cursor.executemany(f"INSERT INTO {table_name} VALUES ({placeholders})", rows) + + db_conn.commit() + + return len(rows) + + +def process_xml_file( + xml_path: str, + registries: list[tuple[str, str, list[str]]], + db_conn: sqlite3.Connection, + repo_root: str, +) -> int: + """Process single XML file and export all specified registries to database. + + Args: + xml_path: Relative path to XML file from repo root + registries: List of (registry_id, output_filename, headers) tuples + db_conn: SQLite database connection + repo_root: Repository root directory + + Returns: + Total number of rows inserted + + Raises: + Various exceptions from helper functions + + """ + # Construct absolute path to XML file + full_xml_path = repo_root / xml_path + + print(f"\nVerarbeite XML: {xml_path}") + + # Parse XML + try: + root, ns = parse_xml_with_namespace_support(str(full_xml_path)) + except (FileNotFoundError, ET.ParseError, OSError) as e: + raise RuntimeError(f"Fehler beim Laden von {xml_path}: {e}") from e + + # Process each registry + total_rows = 0 + for registry_id, output_filename, headers in registries: + table_name = get_table_name_from_filename(output_filename) + + try: + row_count = write_registry_to_db( + root, + registry_id, + table_name, + headers, + ns, + db_conn, + ) + total_rows += row_count + print(f"Tabelle aktualisiert: {table_name} ({row_count} Eintraege)") + except (ValueError, sqlite3.Error) as e: + print(f"Fehler bei Tabelle {table_name}: {e}") + raise RuntimeError( + f"Fehler beim Exportieren von Registry '{registry_id}' " + f"aus {xml_path} in Tabelle {table_name}: {e}", + ) 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/scanner.py b/src/sslysze_scan/scanner.py new file mode 100644 index 0000000..d0f2d4c --- /dev/null +++ b/src/sslysze_scan/scanner.py @@ -0,0 +1,203 @@ +"""Module for performing SSL/TLS scans with SSLyze.""" + +import logging +from datetime import datetime, timezone +from typing import Any + +logger = logging.getLogger(__name__) + +from sslyze import ( + ProtocolWithOpportunisticTlsEnum, + Scanner, + ServerConnectivityStatusEnum, + ServerHostnameCouldNotBeResolved, + ServerNetworkConfiguration, + ServerNetworkLocation, + ServerScanRequest, + ServerScanStatusEnum, +) + +from .protocol_loader import get_protocol_for_port + + +def create_scan_request( + hostname: str, + port: int, + use_opportunistic_tls: bool = True, +) -> tuple[ServerScanRequest, bool]: + """Create a scan request for the given hostname and port. + + Checks if the port requires opportunistic TLS and configures accordingly. + + Args: + hostname: Server hostname to scan. + port: Port number to scan. + use_opportunistic_tls: Whether to use opportunistic TLS if available. + + Returns: + Tuple of (ServerScanRequest, is_opportunistic_tls). + + Raises: + ServerHostnameCouldNotBeResolved: If hostname cannot be resolved. + + """ + # Check if port requires opportunistic TLS + protocol = get_protocol_for_port(port) + + if protocol and use_opportunistic_tls: + # Port requires opportunistic TLS + logger.info( + "Port %s detected as %s - using opportunistic TLS scan", port, protocol + ) + + # Get the protocol enum + protocol_enum = getattr(ProtocolWithOpportunisticTlsEnum, protocol) + + return ( + ServerScanRequest( + server_location=ServerNetworkLocation(hostname=hostname, port=port), + network_configuration=ServerNetworkConfiguration( + tls_server_name_indication=hostname, + tls_opportunistic_encryption=protocol_enum, + ), + ), + True, + ) + # Direct TLS connection + if protocol and not use_opportunistic_tls: + logger.info("Port %s - falling back to direct TLS scan", port) + else: + logger.info("Port %s - using direct TLS scan", port) + + return ( + ServerScanRequest( + server_location=ServerNetworkLocation(hostname=hostname, port=port), + ), + False, + ) + + +def perform_scan( + hostname: str, + port: int, + scan_start_time: datetime, +) -> tuple[Any, float]: + """Perform SSL/TLS scan on the given hostname and port. + + Args: + hostname: Server hostname to scan. + port: Port number to scan. + scan_start_time: Timestamp to use for this scan. + + Returns: + Tuple of (ServerScanResult, duration_seconds) + + Raises: + ServerHostnameCouldNotBeResolved: If hostname cannot be resolved. + Exception: For other scan errors. + + """ + logger.info("Starting scan for %s:%s", hostname, port) + + # Create scan request + try: + scan_request, is_opportunistic = create_scan_request(hostname, port) + except ServerHostnameCouldNotBeResolved as e: + raise RuntimeError(f"Error: Could not resolve hostname '{hostname}'") from e + + # Queue the scan + scanner = Scanner() + scanner.queue_scans([scan_request]) + + # Process results + all_server_scan_results = [] + for server_scan_result in scanner.get_results(): + all_server_scan_results.append(server_scan_result) + logger.info( + "Results for %s:%s", + server_scan_result.server_location.hostname, + server_scan_result.server_location.port, + ) + + # Check connectivity + if server_scan_result.scan_status == ServerScanStatusEnum.ERROR_NO_CONNECTIVITY: + # If opportunistic TLS failed, try fallback to direct TLS + if ( + is_opportunistic + and server_scan_result.connectivity_status + == ServerConnectivityStatusEnum.ERROR + ): + logger.warning( + "Opportunistic TLS connection failed for %s:%s", + server_scan_result.server_location.hostname, + server_scan_result.server_location.port, + ) + logger.info("Retrying with direct TLS connection...") + + # Create new scan request without opportunistic TLS + try: + fallback_request, _ = create_scan_request( + hostname, + port, + use_opportunistic_tls=False, + ) + except ServerHostnameCouldNotBeResolved as e: + raise RuntimeError( + f"Error: Could not resolve hostname '{hostname}'" + ) from e + + # Queue and execute fallback scan + fallback_scanner = Scanner() + fallback_scanner.queue_scans([fallback_request]) + + # Process fallback results + for fallback_result in fallback_scanner.get_results(): + all_server_scan_results[-1] = fallback_result + server_scan_result = fallback_result + logger.info( + "Fallback Results for %s:%s", + server_scan_result.server_location.hostname, + server_scan_result.server_location.port, + ) + + # Check connectivity again + if ( + server_scan_result.scan_status + == ServerScanStatusEnum.ERROR_NO_CONNECTIVITY + ): + logger.error( + "Could not connect to %s:%s", + server_scan_result.server_location.hostname, + server_scan_result.server_location.port, + ) + if server_scan_result.connectivity_error_trace: + logger.error( + "Details: %s", + server_scan_result.connectivity_error_trace, + ) + continue + break + else: + logger.error( + "Could not connect to %s:%s", + server_scan_result.server_location.hostname, + server_scan_result.server_location.port, + ) + if server_scan_result.connectivity_error_trace: + logger.error( + "Details: %s", server_scan_result.connectivity_error_trace + ) + continue + + # Skip further processing if still no connectivity + if server_scan_result.scan_status == ServerScanStatusEnum.ERROR_NO_CONNECTIVITY: + continue + + # Calculate scan duration + scan_end_time = datetime.now(timezone.utc) + scan_duration = (scan_end_time - scan_start_time).total_seconds() + + # Return first result (we only scan one host) + if all_server_scan_results: + return all_server_scan_results[0], scan_duration + raise RuntimeError("No scan results obtained") diff --git a/src/sslysze_scan/templates/report.md.j2 b/src/sslysze_scan/templates/report.md.j2 new file mode 100644 index 0000000..febb21c --- /dev/null +++ b/src/sslysze_scan/templates/report.md.j2 @@ -0,0 +1,181 @@ +# Compliance Report: {{ hostname }} + +**Scan-ID:** {{ scan_id }} +**FQDN:** {{ fqdn }} +**IPv4:** {{ ipv4 or 'N/A' }} +**IPv6:** {{ ipv6 or 'N/A' }} +**Timestamp:** {{ timestamp }} +**Duration:** {{ duration }}s +**Ports:** {{ ports }} +**Ports without TLS Support:** {{ ports_without_tls }} + +--- + +## Summary + +| Metric | Value | +|--------|-------| +| Scanned Ports | {{ summary.total_ports }} | +| Ports with TLS Support | {{ summary.successful_ports }} | +| Cipher Suites Checked | {{ summary.total_cipher_suites }} | +| Cipher Suites Compliant | {{ summary.compliant_cipher_suites }} ({{ summary.cipher_suite_percentage }}%) | +| Supported Groups Checked | {{ summary.total_groups }} | +| Supported Groups Compliant | {{ summary.compliant_groups }} ({{ summary.group_percentage }}%) | +| Critical Vulnerabilities | {{ summary.critical_vulnerabilities }} | + +--- + +{% for port_data in ports_data -%} +{% if port_data.cipher_suites or port_data.supported_groups or port_data.certificates or port_data.tls_version -%} +## Port {{ port_data.port }} + +### TLS Configuration + +**Status:** {{ port_data.status }} + +{% if port_data.tls_version -%} +**Highest TLS Version:** {{ port_data.tls_version }} +{% endif -%} + +{% if port_data.cipher_suites -%} +### Cipher Suites + +{% for tls_version, suites in port_data.cipher_suites.items() -%} +#### {{ tls_version | format_tls_version }} + +**Offered by Server:** {{ suites.accepted|length }} + +{% if suites.accepted -%} +| Cipher Suite | IANA | BSI | Valid Until | Compliant | +|--------------|------|-----|-------------|-----------| +{% for suite in suites.accepted -%} +| {{ suite.name }} | {{ suite.iana_recommended or '-' }} | {{ 'Yes' if suite.bsi_approved else '-' }} | {{ suite.bsi_valid_until or '-' }} | {{ 'Yes' if suite.compliant else 'No' }} | +{% endfor -%} +{% endif -%} + +{% if suites.rejected -%} +**Not Offered by Server:** {{ suites.rejected|length }} (of {{ suites.rejected_total }} tested) + +
+Show recommended cipher suites not offered + +| Cipher Suite | IANA | BSI | Valid Until | +|--------------|------|-----|-------------| +{% for suite in suites.rejected -%} +| {{ suite.name }} | {{ suite.iana_recommended or '-' }} | {{ 'Yes' if suite.bsi_approved else '-' }} | {{ suite.bsi_valid_until or '-' }} | +{% endfor -%} + +
+{% endif -%} + +{% endfor -%} +{% endif -%} + +{% if port_data.supported_groups -%} +### Supported Groups (Elliptic Curves / DH) + +| Group | IANA | BSI | Valid Until | Compliant | +|-------|------|-----|-------------|-----------| +{% for group in port_data.supported_groups -%} +| {{ group.name }} | {{ group.iana_recommended or '-' }} | {{ 'Yes' if group.bsi_approved else '-' }} | {{ group.bsi_valid_until or '-' }} | {{ 'Yes' if group.compliant else 'No' }} | +{% endfor -%} +{% endif -%} + +{% if port_data.missing_recommended_groups -%} +{% if port_data.missing_recommended_groups.bsi_approved or port_data.missing_recommended_groups.iana_recommended -%} +### Recommended Groups Not Offered + +
+Show recommended groups not offered + +{% if port_data.missing_recommended_groups.bsi_approved -%} +**BSI TR-02102-2 Approved (missing):** + +| Group | TLS Versions | Valid Until | +|-------|--------------|-------------| +{% for group in port_data.missing_recommended_groups.bsi_approved -%} +| {{ group.name }} | {{ group.tls_versions|join(', ') }} | {{ group.valid_until }} | +{% endfor -%} + +{% endif -%} +{% if port_data.missing_recommended_groups.iana_recommended -%} +**IANA Recommended (missing):** + +| Group | IANA Value | +|-------|------------| +{% for group in port_data.missing_recommended_groups.iana_recommended -%} +| {{ group.name }} | {{ group.iana_value }} | +{% endfor -%} + +{% endif -%} + +
+{% endif -%} +{% endif -%} + +{% if port_data.certificates -%} +### Certificates + +| Position | Subject | Issuer | Valid From | Valid Until | Key Type | Key Size | Compliant | +|----------|---------|--------|------------|-------------|----------|----------|-----------| +{% for cert in port_data.certificates -%} +| {{ cert.position }} | {{ cert.subject }} | {{ cert.issuer }} | {{ cert.not_before }} | {{ cert.not_after }} | {{ cert.key_type }} | {{ cert.key_bits }} | {{ 'Yes' if cert.compliant else 'No' if cert.compliant is not none else '-' }} | +{% endfor -%} +{% endif -%} + +{% if port_data.vulnerabilities -%} +### Vulnerabilities + +| Type | Vulnerable | Details | +|------|------------|---------| +{% for vuln in port_data.vulnerabilities -%} +| {{ vuln.type }} | {{ 'Yes' if vuln.vulnerable else 'No' }} | {{ vuln.details or '-' }} | +{% endfor -%} +{% endif -%} + +{% if port_data.protocol_features -%} +### Protocol Features + +| Feature | Supported | Details | +|---------|-----------|---------| +{% for feature in port_data.protocol_features -%} +| {{ feature.name }} | {{ 'Yes' if feature.supported else 'No' }} | {{ feature.details or '-' }} | +{% endfor -%} +{% endif -%} + +{% if port_data.session_features -%} +### Session Features + +| Feature | Client Initiated | Secure | Session ID | TLS Ticket | Details | +|---------|------------------|--------|------------|------------|---------| +{% for feature in port_data.session_features -%} +| {{ feature.type }} | {{ 'Yes' if feature.client_initiated else 'No' if feature.client_initiated is not none else '-' }} | {{ 'Yes' if feature.secure else 'No' if feature.secure is not none else '-' }} | {{ 'Yes' if feature.session_id_supported else 'No' if feature.session_id_supported is not none else '-' }} | {{ 'Yes' if feature.ticket_supported else 'No' if feature.ticket_supported is not none else '-' }} | {{ feature.details or '-' }} | +{% endfor -%} +{% endif -%} + +{% if port_data.http_headers -%} +### HTTP Security Headers + +| Header | Present | Value | +|--------|---------|-------| +{% for header in port_data.http_headers -%} +| {{ header.name }} | {{ 'Yes' if header.is_present else 'No' }} | {{ header.value or '-' }} | +{% endfor -%} +{% endif -%} + +### Compliance Status Port {{ port_data.port }} + +| Category | Checked | Compliant | Percentage | +|----------|---------|-----------|------------| +| Cipher Suites | {{ port_data.compliance.cipher_suites_checked }} | {{ port_data.compliance.cipher_suites_passed }} | {{ port_data.compliance.cipher_suite_percentage }}% | +| Supported Groups | {{ port_data.compliance.groups_checked }} | {{ port_data.compliance.groups_passed }} | {{ port_data.compliance.group_percentage }}% | + +{% else -%} +## Port {{ port_data.port }} - Kein TLS Support + +{% endif -%} +{% endfor -%} + +--- + +*Generated with compliance-scan* diff --git a/src/sslysze_scan/templates/report.reST.j2 b/src/sslysze_scan/templates/report.reST.j2 new file mode 100644 index 0000000..aba8fd3 --- /dev/null +++ b/src/sslysze_scan/templates/report.reST.j2 @@ -0,0 +1,219 @@ +{{ '#' * 27 }} +TLS Compliance Report |UCS| +{{ '#' * 27 }} + +- **Scan-ID:** {{ scan_id }} +- **FQDN:** {{ fqdn }} +- **IPv4:** {{ ipv4 or 'N/A' }} +- **IPv6:** {{ ipv6 or 'N/A' }} +- **Timestamp:** {{ timestamp }} +- **Duration:** {{ duration }} Seconds +- **Ports:** {{ ports }} +- **Ports without TLS Support:** {{ ports_without_tls }} + +---- + +******* +Summary +******* + +.. csv-table:: + :file: summary.csv + :header-rows: 1 + :widths: auto + +---- + +{% for port_data in ports_data -%} +{% if port_data.cipher_suites or port_data.supported_groups or port_data.certificates or port_data.tls_version -%} +{{ '*' * (5 + port_data.port|string|length) }} +Port {{ port_data.port }} +{{ '*' * (5 + port_data.port|string|length) }} + +TLS Configuration +================= + +**Status:** {{ port_data.status }} + +{% if port_data.tls_version -%} +**Highest TLS Version:** {{ port_data.tls_version }} + +{% endif -%} + +{% if port_data.cipher_suites -%} +Cipher Suites +============= + +{% for tls_version, suites in port_data.cipher_suites.items() -%} +{{ tls_version | format_tls_version }} +{{ '-' * (tls_version | format_tls_version | length) }} + +**Offered by Server:** {{ suites.accepted|length }} + +{% if suites.accepted -%} +.. csv-table:: + :file: {{ port_data.port }}_cipher_suites_{{ tls_version }}_accepted.csv + :header-rows: 1 + :widths: auto + +{% else -%} +No cipher suites accepted by server. + +{% endif -%} +{% if suites.rejected -%} +**Not Offered by Server:** {{ suites.rejected|length }} (of {{ suites.rejected_total }} tested) + +.. raw:: html + +
+ +.. raw:: html + + + +Show recommended cipher suites not offered + +.. raw:: html + + + +.. csv-table:: + :file: {{ port_data.port }}_cipher_suites_{{ tls_version }}_rejected.csv + :header-rows: 1 + :widths: auto + +.. raw:: html + +
+ +{% endif -%} +{% endfor -%} +{% endif -%} + +{% if port_data.supported_groups -%} +Supported Groups (Elliptic Curves / DH) +======================================= + +.. csv-table:: + :file: {{ port_data.port }}_supported_groups.csv + :header-rows: 1 + :widths: auto + +{% endif -%} +{% if port_data.missing_recommended_groups -%} +{% if port_data.missing_recommended_groups.bsi_approved or port_data.missing_recommended_groups.iana_recommended -%} +Recommended Groups Not Offered +============================== + +.. raw:: html + +
+ +.. raw:: html + + + +Show recommended groups not offered + +.. raw:: html + + + +{% if port_data.missing_recommended_groups.bsi_approved -%} +**BSI TR-02102-2 Approved (missing):** + +.. csv-table:: + :file: {{ port_data.port }}_missing_groups_bsi.csv + :header-rows: 1 + :widths: auto + +{% else -%} +No BSI-approved groups missing. + +{% endif -%} +{% if port_data.missing_recommended_groups.iana_recommended -%} +**IANA Recommended (missing):** + +.. csv-table:: + :file: {{ port_data.port }}_missing_groups_iana.csv + :header-rows: 1 + :widths: auto + +{% else -%} +No IANA-recommended groups missing. + +{% endif -%} +.. raw:: html + +
+ +{% endif -%} +{% endif -%} + +{% if port_data.certificates -%} +Certificates +============ + +.. csv-table:: + :file: {{ port_data.port }}_certificates.csv + :header-rows: 1 + :widths: auto + +{% endif -%} +{% if port_data.vulnerabilities -%} +Vulnerabilities +=============== + +.. csv-table:: + :file: {{ port_data.port }}_vulnerabilities.csv + :header-rows: 1 + :widths: auto + +{% endif -%} +{% if port_data.protocol_features -%} +Protocol Features +================= + +.. csv-table:: + :file: {{ port_data.port }}_protocol_features.csv + :header-rows: 1 + :widths: auto + +{% endif -%} +{% if port_data.session_features -%} +Session Features +================ + +.. csv-table:: + :file: {{ port_data.port }}_session_features.csv + :header-rows: 1 + :widths: auto + +{% endif -%} +{% if port_data.http_headers -%} +HTTP Security Headers +===================== + +.. csv-table:: + :file: {{ port_data.port }}_http_headers.csv + :header-rows: 1 + :widths: auto + +{% endif -%} +Compliance Status Port {{ port_data.port }} +{{ '-' * (23 + port_data.port|string|length) }} + +.. csv-table:: + :file: {{ port_data.port }}_compliance_status.csv + :header-rows: 1 + :widths: auto + +{% else -%} +{{ '*' * (28 + port_data.port|string|length) }} +Port {{ port_data.port }} - Kein TLS Support +{{ '*' * (28 + port_data.port|string|length) }} + +{% endif -%} +{% endfor -%} + +*Generated with compliance-scan* diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..9ea87ca --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,410 @@ +"""Pytest configuration and shared fixtures.""" + +import sqlite3 +from pathlib import Path +from typing import Any + +import pytest + + +@pytest.fixture +def mock_scan_metadata() -> dict[str, Any]: + """Provide mock scan metadata.""" + return { + "scan_id": 5, + "hostname": "example.com", + "fqdn": "example.com", + "ipv4": "192.168.1.1", + "ipv6": "2001:db8::1", + "timestamp": "2025-01-08T10:30:00.123456", + "duration": 12.34, + "ports": ["443", "636"], + } + + +@pytest.fixture +def mock_scan_data(mock_scan_metadata: dict[str, Any]) -> dict[str, Any]: + """Provide complete mock scan data structure.""" + return { + "metadata": mock_scan_metadata, + "summary": { + "total_ports": 2, + "successful_ports": 2, + "total_cipher_suites": 50, + "compliant_cipher_suites": 45, + "cipher_suite_percentage": 90, + "total_groups": 10, + "compliant_groups": 8, + "group_percentage": 80, + "critical_vulnerabilities": 0, + }, + "ports_data": { + 443: { + "port": 443, + "status": "completed", + "tls_version": "1.3", + "cipher_suites": { + "1.2": { + "accepted": [ + { + "name": "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", + "iana_recommended": "Y", + "bsi_approved": True, + "bsi_valid_until": "2029", + "compliant": True, + }, + ], + "rejected": [], + "rejected_total": 0, + }, + "1.3": { + "accepted": [ + { + "name": "TLS_AES_128_GCM_SHA256", + "iana_recommended": "Y", + "bsi_approved": True, + "bsi_valid_until": "2031", + "compliant": True, + }, + ], + "rejected": [ + { + "name": "TLS_AES_128_CCM_SHA256", + "iana_recommended": "Y", + "bsi_approved": True, + "bsi_valid_until": "2031", + }, + ], + "rejected_total": 1, + }, + }, + "supported_groups": [ + { + "name": "x25519", + "iana_recommended": "Y", + "bsi_approved": False, + "bsi_valid_until": None, + "compliant": True, + }, + ], + "missing_recommended_groups": { + "bsi_approved": [ + { + "name": "brainpoolP256r1", + "tls_versions": ["1.2"], + "valid_until": "2031", + }, + ], + "iana_recommended": [], + }, + "certificates": [ + { + "position": 0, + "subject": "CN=example.com", + "issuer": "CN=Test CA", + "not_before": "2024-01-01", + "not_after": "2025-12-31", + "key_type": "RSA", + "key_bits": 2048, + }, + ], + "vulnerabilities": [ + { + "type": "Heartbleed", + "vulnerable": False, + "details": "Not vulnerable", + }, + ], + "protocol_features": [ + { + "name": "TLS Compression", + "supported": False, + "details": "Disabled", + }, + ], + "session_features": [ + { + "type": "Session Resumption", + "client_initiated": True, + "secure": True, + "session_id_supported": True, + "ticket_supported": True, + "details": "Supported", + }, + ], + "http_headers": [ + { + "name": "Strict-Transport-Security", + "is_present": True, + "value": "max-age=31536000", + }, + ], + "compliance": { + "cipher_suites_checked": 45, + "cipher_suites_passed": 40, + "cipher_suite_percentage": 88.89, + "groups_checked": 5, + "groups_passed": 4, + "group_percentage": 80.0, + }, + }, + 636: { + "port": 636, + "status": "completed", + "tls_version": "1.2", + "cipher_suites": { + "1.2": { + "accepted": [ + { + "name": "TLS_DHE_RSA_WITH_AES_256_GCM_SHA384", + "iana_recommended": "Y", + "bsi_approved": True, + "bsi_valid_until": "2029", + "compliant": True, + }, + ], + "rejected": [], + "rejected_total": 0, + }, + }, + "supported_groups": [], + "missing_recommended_groups": { + "bsi_approved": [], + "iana_recommended": [], + }, + "certificates": [], + "vulnerabilities": [], + "protocol_features": [], + "session_features": [], + "http_headers": [], + "compliance": { + "cipher_suites_checked": 5, + "cipher_suites_passed": 5, + "cipher_suite_percentage": 100.0, + "groups_checked": 0, + "groups_passed": 0, + "group_percentage": 0.0, + }, + }, + }, + } + + +@pytest.fixture +def temp_output_dir(tmp_path: Path) -> Path: + """Provide temporary output directory.""" + output_dir = tmp_path / "output" + output_dir.mkdir() + return output_dir + + +# SQL for database views +VIEWS_SQL = """ +-- View: Cipher suites with compliance information +CREATE VIEW IF NOT EXISTS v_cipher_suites_with_compliance AS +SELECT + scs.scan_id, + scs.port, + scs.tls_version, + scs.cipher_suite_name, + scs.accepted, + scs.iana_value, + scs.key_size, + scs.is_anonymous, + sc.iana_recommended, + sc.bsi_approved, + sc.bsi_valid_until, + sc.passed as compliant, + CASE + WHEN scs.accepted = 1 THEN sc.iana_recommended + ELSE iana.recommended + END as iana_recommended_final, + CASE + WHEN scs.accepted = 1 THEN sc.bsi_approved + ELSE (bsi.name IS NOT NULL) + END as bsi_approved_final, + CASE + WHEN scs.accepted = 1 THEN sc.bsi_valid_until + ELSE bsi.valid_until + END as bsi_valid_until_final +FROM scan_cipher_suites scs +LEFT JOIN scan_compliance_status sc + ON scs.scan_id = sc.scan_id + AND scs.port = sc.port + AND sc.check_type = 'cipher_suite' + AND scs.cipher_suite_name = sc.item_name +LEFT JOIN iana_tls_cipher_suites iana + ON scs.cipher_suite_name = iana.description +LEFT JOIN bsi_tr_02102_2_tls bsi + ON scs.cipher_suite_name = bsi.name + AND scs.tls_version = bsi.tls_version + AND bsi.category = 'cipher_suite'; + +-- View: Supported groups with compliance information +CREATE VIEW IF NOT EXISTS v_supported_groups_with_compliance AS +SELECT + ssg.scan_id, + ssg.port, + ssg.group_name, + ssg.iana_value, + ssg.openssl_nid, + sc.iana_recommended, + sc.bsi_approved, + sc.bsi_valid_until, + sc.passed as compliant +FROM scan_supported_groups ssg +LEFT JOIN scan_compliance_status sc + ON ssg.scan_id = sc.scan_id + AND ssg.port = sc.port + AND sc.check_type = 'supported_group' + AND ssg.group_name = sc.item_name; + +-- View: Certificates with compliance information +CREATE VIEW IF NOT EXISTS v_certificates_with_compliance AS +SELECT + c.scan_id, + c.port, + c.position, + c.subject, + c.issuer, + c.serial_number, + c.not_before, + c.not_after, + c.key_type, + c.key_bits, + c.signature_algorithm, + c.fingerprint_sha256, + MAX(cs.passed) as compliant, + MAX(cs.details) as compliance_details +FROM scan_certificates c +LEFT JOIN scan_compliance_status cs + ON c.scan_id = cs.scan_id + AND c.port = cs.port + AND cs.check_type = 'certificate' + AND cs.item_name = (c.key_type || ' ' || c.key_bits || ' Bit') +GROUP BY c.scan_id, c.port, c.position, c.subject, c.issuer, c.serial_number, + c.not_before, c.not_after, c.key_type, c.key_bits, + c.signature_algorithm, c.fingerprint_sha256; + +-- View: Port compliance summary +CREATE VIEW IF NOT EXISTS v_port_compliance_summary AS +SELECT + scan_id, + port, + check_type, + COUNT(*) as total, + SUM(CASE WHEN passed = 1 THEN 1 ELSE 0 END) as passed, + ROUND(CAST(SUM(CASE WHEN passed = 1 THEN 1 ELSE 0 END) AS REAL) / COUNT(*) * 100, 1) as percentage +FROM scan_compliance_status +GROUP BY scan_id, port, check_type; + +-- View: Missing BSI-approved groups +CREATE VIEW IF NOT EXISTS v_missing_bsi_groups AS +SELECT + s.scan_id, + s.ports, + bsi.name as group_name, + bsi.tls_version, + bsi.valid_until +FROM scans s +CROSS JOIN ( + SELECT DISTINCT name, tls_version, valid_until + FROM bsi_tr_02102_2_tls + WHERE category = 'dh_group' +) bsi +WHERE NOT EXISTS ( + SELECT 1 + FROM scan_supported_groups ssg + WHERE ssg.scan_id = s.scan_id + AND LOWER(ssg.group_name) = LOWER(bsi.name) +); + +-- View: Missing IANA-recommended groups +CREATE VIEW IF NOT EXISTS v_missing_iana_groups AS +SELECT + s.scan_id, + s.ports, + iana.description as group_name, + iana.value as iana_value +FROM scans s +CROSS JOIN ( + SELECT description, value + FROM iana_tls_supported_groups + WHERE recommended = 'Y' +) iana +WHERE NOT EXISTS ( + SELECT 1 + FROM scan_supported_groups ssg + WHERE ssg.scan_id = s.scan_id + AND LOWER(ssg.group_name) = LOWER(iana.description) +) +AND NOT EXISTS ( + SELECT 1 + FROM bsi_tr_02102_2_tls bsi + WHERE LOWER(bsi.name) = LOWER(iana.description) + AND bsi.category = 'dh_group' +); +""" + + +@pytest.fixture +def test_db() -> sqlite3.Connection: + """Provide in-memory test database with crypto standards and scan data.""" + conn = sqlite3.connect(":memory:") + + # 1. Copy crypto_standards.db to memory + standards_path = ( + Path(__file__).parent.parent / "src/sslysze_scan/data/crypto_standards.db" + ) + if standards_path.exists(): + with sqlite3.connect(str(standards_path)) as src_conn: + for line in src_conn.iterdump(): + conn.execute(line) + + # 2. Copy test_scan.db data to memory (skip CREATE and csv_export_metadata) + fixtures_dir = Path(__file__).parent / "fixtures" + test_scan_path = fixtures_dir / "test_scan.db" + if test_scan_path.exists(): + with sqlite3.connect(str(test_scan_path)) as src_conn: + for line in src_conn.iterdump(): + if not line.startswith("CREATE ") and "csv_export_metadata" not in line: + conn.execute(line) + + # 3. Create views + conn.executescript(VIEWS_SQL) + + conn.commit() + yield conn + conn.close() + + +@pytest.fixture +def test_db_path(tmp_path: Path) -> str: + """Provide test database as file path for functions expecting a path.""" + db_path = tmp_path / "test.db" + conn = sqlite3.connect(str(db_path)) + + # 1. Copy crypto_standards.db to file + standards_path = ( + Path(__file__).parent.parent / "src/sslysze_scan/data/crypto_standards.db" + ) + if standards_path.exists(): + with sqlite3.connect(str(standards_path)) as src_conn: + for line in src_conn.iterdump(): + conn.execute(line) + + # 2. Copy test_scan.db data to file (skip CREATE and csv_export_metadata) + fixtures_dir = Path(__file__).parent / "fixtures" + test_scan_path = fixtures_dir / "test_scan.db" + if test_scan_path.exists(): + with sqlite3.connect(str(test_scan_path)) as src_conn: + for line in src_conn.iterdump(): + if not line.startswith("CREATE ") and "csv_export_metadata" not in line: + conn.execute(line) + + # 3. Create views + conn.executescript(VIEWS_SQL) + + conn.commit() + conn.close() + return str(db_path) diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py new file mode 100644 index 0000000..d1b3395 --- /dev/null +++ b/tests/fixtures/__init__.py @@ -0,0 +1 @@ +"""Test fixtures package.""" diff --git a/tests/fixtures/test_scan.db b/tests/fixtures/test_scan.db new file mode 100644 index 0000000000000000000000000000000000000000..4dab0eaf4b785f6dc7cce77282f9d01598c0610d GIT binary patch literal 249856 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 None: + """Test parsing hostname with multiple ports.""" + hostname, ports = parse_host_ports("example.com:443,636,993") + assert hostname == "example.com" + assert ports == [443, 636, 993] + + def test_parse_host_ports_ipv6_multiple(self) -> None: + """Test parsing IPv6 address with multiple ports.""" + hostname, ports = parse_host_ports("[2001:db8::1]:443,636") + assert hostname == "2001:db8::1" + assert ports == [443, 636] + + def test_parse_host_ports_invalid_port_range(self) -> None: + """Test error when port number out of range.""" + with pytest.raises(ValueError, match="Invalid port number.*Must be between"): + parse_host_ports("example.com:99999") diff --git a/tests/test_compliance.py b/tests/test_compliance.py new file mode 100644 index 0000000..251855b --- /dev/null +++ b/tests/test_compliance.py @@ -0,0 +1,73 @@ +"""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_csv_export.py b/tests/test_csv_export.py new file mode 100644 index 0000000..ca60092 --- /dev/null +++ b/tests/test_csv_export.py @@ -0,0 +1,297 @@ +"""Tests for CSV export functionality.""" + +import csv +from pathlib import Path + +from sslysze_scan.reporter.csv_export import generate_csv_reports + + +class TestCsvExport: + """Tests for CSV file generation.""" + + def test_export_summary(self, test_db_path: str, tmp_path: Path) -> None: + """Test summary CSV export with aggregated statistics.""" + output_dir = tmp_path / "output" + output_dir.mkdir() + + files = generate_csv_reports(test_db_path, 1, str(output_dir)) + + summary_file = output_dir / "summary.csv" + assert summary_file.exists() + assert str(summary_file) in files + + with open(summary_file, newline="", encoding="utf-8") as f: + reader = csv.reader(f) + rows = list(reader) + + assert rows[0] == ["Metric", "Value"] + assert len(rows) >= 7 + + metrics = {row[0]: row[1] for row in rows[1:]} + assert "Scanned Ports" in metrics + assert "Ports with TLS Support" in metrics + assert "Cipher Suites Checked" in metrics + + def test_export_cipher_suites_port_443( + self, test_db_path: str, tmp_path: Path + ) -> None: + """Test cipher suites export for port 443.""" + output_dir = tmp_path / "output" + output_dir.mkdir() + + files = generate_csv_reports(test_db_path, 1, str(output_dir)) + + accepted_files = [ + f for f in files if "443_cipher_suites" in f and "accepted" in f + ] + assert len(accepted_files) > 0 + + accepted_file = Path(accepted_files[0]) + assert accepted_file.exists() + + with open(accepted_file, newline="", encoding="utf-8") as f: + reader = csv.reader(f) + rows = list(reader) + + assert rows[0] == ["Cipher Suite", "IANA", "BSI", "Valid Until", "Compliant"] + assert len(rows) > 1 + + for row in rows[1:]: + assert len(row) == 5 + assert row[4] in ["Yes", "No", "-"] + + def test_export_supported_groups_port_636( + self, test_db_path: str, tmp_path: Path + ) -> None: + """Test supported groups export for port 636.""" + output_dir = tmp_path / "output" + output_dir.mkdir() + + files = generate_csv_reports(test_db_path, 1, str(output_dir)) + + groups_files = [f for f in files if "636_supported_groups.csv" in f] + + if groups_files: + groups_file = Path(groups_files[0]) + assert groups_file.exists() + + with open(groups_file, newline="", encoding="utf-8") as f: + reader = csv.reader(f) + rows = list(reader) + + assert rows[0] == ["Group", "IANA", "BSI", "Valid Until", "Compliant"] + + for row in rows[1:]: + assert len(row) == 5 + assert row[4] in ["Yes", "No", "-"] + + def test_export_missing_groups_port_443( + self, test_db_path: str, tmp_path: Path + ) -> None: + """Test missing groups export for port 443.""" + output_dir = tmp_path / "output" + output_dir.mkdir() + + files = generate_csv_reports(test_db_path, 1, str(output_dir)) + + bsi_files = [f for f in files if "443_missing_groups_bsi.csv" in f] + + if bsi_files: + bsi_file = Path(bsi_files[0]) + assert bsi_file.exists() + + with open(bsi_file, newline="", encoding="utf-8") as f: + reader = csv.reader(f) + rows = list(reader) + + assert rows[0] == ["Group", "TLS Versions", "Valid Until"] + + for row in rows[1:]: + assert len(row) == 3 + + def test_export_certificates_port_636( + self, test_db_path: str, tmp_path: Path + ) -> None: + """Test certificates export for port 636.""" + output_dir = tmp_path / "output" + output_dir.mkdir() + + files = generate_csv_reports(test_db_path, 1, str(output_dir)) + + cert_files = [f for f in files if "636_certificates.csv" in f] + + if cert_files: + cert_file = Path(cert_files[0]) + assert cert_file.exists() + + with open(cert_file, newline="", encoding="utf-8") as f: + reader = csv.reader(f) + rows = list(reader) + + expected_headers = [ + "Position", + "Subject", + "Issuer", + "Valid From", + "Valid Until", + "Key Type", + "Key Size", + "Compliant", + ] + assert rows[0] == expected_headers + + for row in rows[1:]: + assert len(row) == 8 + assert row[7] in ["Yes", "No", "-"] + + def test_export_vulnerabilities_port_443( + self, test_db_path: str, tmp_path: Path + ) -> None: + """Test vulnerabilities export for port 443.""" + output_dir = tmp_path / "output" + output_dir.mkdir() + + files = generate_csv_reports(test_db_path, 1, str(output_dir)) + + vuln_files = [f for f in files if "443_vulnerabilities.csv" in f] + + if vuln_files: + vuln_file = Path(vuln_files[0]) + assert vuln_file.exists() + + with open(vuln_file, newline="", encoding="utf-8") as f: + reader = csv.reader(f) + rows = list(reader) + + assert rows[0] == ["Type", "Vulnerable", "Details"] + + for row in rows[1:]: + assert len(row) == 3 + assert row[1] in ["Yes", "No", "-"] + + def test_export_protocol_features_port_636( + self, test_db_path: str, tmp_path: Path + ) -> None: + """Test protocol features export for port 636.""" + output_dir = tmp_path / "output" + output_dir.mkdir() + + files = generate_csv_reports(test_db_path, 1, str(output_dir)) + + protocol_files = [f for f in files if "636_protocol_features.csv" in f] + + if protocol_files: + protocol_file = Path(protocol_files[0]) + assert protocol_file.exists() + + with open(protocol_file, newline="", encoding="utf-8") as f: + reader = csv.reader(f) + rows = list(reader) + + assert rows[0] == ["Feature", "Supported", "Details"] + + for row in rows[1:]: + assert len(row) == 3 + assert row[1] in ["Yes", "No", "-"] + + def test_export_session_features_port_443( + self, test_db_path: str, tmp_path: Path + ) -> None: + """Test session features export for port 443.""" + output_dir = tmp_path / "output" + output_dir.mkdir() + + files = generate_csv_reports(test_db_path, 1, str(output_dir)) + + session_files = [f for f in files if "443_session_features.csv" in f] + + if session_files: + session_file = Path(session_files[0]) + assert session_file.exists() + + with open(session_file, newline="", encoding="utf-8") as f: + reader = csv.reader(f) + rows = list(reader) + + expected_headers = [ + "Feature", + "Client Initiated", + "Secure", + "Session ID", + "TLS Ticket", + "Details", + ] + assert rows[0] == expected_headers + + for row in rows[1:]: + assert len(row) == 6 + for i in range(1, 5): + assert row[i] in ["Yes", "No", "-"] + + def test_export_http_headers_port_636( + self, test_db_path: str, tmp_path: Path + ) -> None: + """Test HTTP headers export for port 636.""" + output_dir = tmp_path / "output" + output_dir.mkdir() + + files = generate_csv_reports(test_db_path, 1, str(output_dir)) + + header_files = [f for f in files if "636_http_headers.csv" in f] + + if header_files: + header_file = Path(header_files[0]) + assert header_file.exists() + + with open(header_file, newline="", encoding="utf-8") as f: + reader = csv.reader(f) + rows = list(reader) + + assert rows[0] == ["Header", "Present", "Value"] + + for row in rows[1:]: + assert len(row) == 3 + assert row[1] in ["Yes", "No", "-"] + + def test_export_compliance_status_port_443( + self, test_db_path: str, tmp_path: Path + ) -> None: + """Test compliance status export for port 443.""" + output_dir = tmp_path / "output" + output_dir.mkdir() + + files = generate_csv_reports(test_db_path, 1, str(output_dir)) + + compliance_files = [f for f in files if "443_compliance_status.csv" in f] + + if compliance_files: + compliance_file = Path(compliance_files[0]) + assert compliance_file.exists() + + with open(compliance_file, newline="", encoding="utf-8") as f: + reader = csv.reader(f) + rows = list(reader) + + assert rows[0] == ["Category", "Checked", "Compliant", "Percentage"] + + for row in rows[1:]: + assert len(row) == 4 + assert "%" in row[3] + + def test_generate_csv_reports_all_files( + self, test_db_path: str, tmp_path: Path + ) -> None: + """Test that generate_csv_reports creates expected files.""" + output_dir = tmp_path / "output" + output_dir.mkdir() + + files = generate_csv_reports(test_db_path, 1, str(output_dir)) + + assert len(files) > 0 + assert any("summary.csv" in f for f in files) + assert any("443_" in f for f in files) + assert any("636_" in f for f in files) + + for file_path in files: + assert Path(file_path).exists() + assert Path(file_path).suffix == ".csv" diff --git a/tests/test_template_utils.py b/tests/test_template_utils.py new file mode 100644 index 0000000..52ea641 --- /dev/null +++ b/tests/test_template_utils.py @@ -0,0 +1,67 @@ +"""Tests for template utilities.""" + +from datetime import datetime +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.""" + + def test_generate_report_id_valid_and_invalid(self) -> None: + """Test report ID generation with valid and invalid timestamps.""" + # Valid timestamp + metadata = {"timestamp": "2025-01-08T10:30:00.123456", "scan_id": 5} + result = generate_report_id(metadata) + assert result == "20250108_5" + + # Invalid timestamp falls back to current date + metadata = {"timestamp": "invalid", "scan_id": 5} + result = generate_report_id(metadata) + today = datetime.now().strftime("%Y%m%d") + assert result == f"{today}_5" + + +class TestBuildTemplateContext: + """Tests for build_template_context function.""" + + def test_build_template_context_complete_and_partial( + self, mock_scan_data: dict[str, Any] + ) -> None: + """Test context building with complete and partial data.""" + # Complete data + context = build_template_context(mock_scan_data) + assert context["scan_id"] == 5 + assert context["hostname"] == "example.com" + assert context["fqdn"] == "example.com" + assert context["ipv4"] == "192.168.1.1" + assert context["ipv6"] == "2001:db8::1" + assert context["timestamp"] == "08.01.2025 10:30" + assert context["duration"] == "12.34" + assert context["ports"] == "443, 636" + assert "summary" in context + assert "ports_data" in context + + # Verify ports_data sorted by port + ports = [p["port"] for p in context["ports_data"]] + assert ports == sorted(ports) + + # Missing duration + mock_scan_data["metadata"]["duration"] = None + context = build_template_context(mock_scan_data) + assert context["duration"] == "N/A"