Add HTML report generation with dark theme

Implements comprehensive HTML report generation from JSON scan data with Jinja2 templates. Reports feature a dark slate theme with summary dashboard, drift alerts, security warnings, and expandable service details.

Features:
- Dark theme HTML reports with slate/grey color scheme
- Summary dashboard: scan statistics, drift alerts, security warnings
- Site-by-site breakdown with IP grouping and status badges
- Expandable service details and SSL/TLS certificate information
- Visual badges: green (expected), red (unexpected), yellow (missing)
- UDP port handling: shows expected, unexpected, and missing UDP ports
- Screenshot links with relative paths for portability
- Optimized hover effects for table rows
- Standalone HTML output (no external dependencies)

Technical changes:
- Added src/report_generator.py: HTMLReportGenerator class with summary calculations
- Added templates/report_template.html: Jinja2 template for dynamic reports
- Added templates/report_mockup.html: Static mockup for design testing
- Updated requirements.txt: Added Jinja2==3.1.2
- Updated README.md: Added HTML report generation section with usage and features
- Updated CLAUDE.md: Added implementation details, usage guide, and troubleshooting

Usage:
  python3 src/report_generator.py output/scan_report.json

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-14 01:43:59 +00:00
parent 61cc24f8d2
commit d390c4b491
6 changed files with 2933 additions and 58 deletions

199
CLAUDE.md
View File

@@ -61,11 +61,21 @@ python3 -c "import yaml; yaml.safe_load(open('configs/example-site.yaml'))"
- `_get_screenshot_dir()`: Creates screenshots subdirectory
- `_generate_filename()`: Generates filename for screenshot (IP_PORT.png)
3. **configs/** - YAML configuration files
3. **src/report_generator.py** - HTML report generator
- `HTMLReportGenerator` class: Generates comprehensive HTML reports from JSON scan data
- `generate_report()`: Main method to generate HTML report with summary dashboard and service details
- `_load_json_report()`: Loads and parses JSON scan report
- `_calculate_summary_stats()`: Calculates scan statistics for dashboard (IPs, ports, services, screenshots)
- `_identify_drift_alerts()`: Identifies unexpected/missing ports and services
- `_identify_security_warnings()`: Identifies security issues (expiring certs, weak TLS, self-signed certs)
- `_format_date()`: Helper to format ISO date strings
- `_format_duration()`: Helper to format scan duration
4. **configs/** - YAML configuration files
- Define scan title, sites, IPs, and expected network behavior
- Each IP includes expected ping response and TCP/UDP ports
4. **output/** - JSON scan reports and screenshots
5. **output/** - JSON scan reports and screenshots
- Timestamped JSON files: `scan_report_YYYYMMDD_HHMMSS.json`
- Screenshot directory: `scan_report_YYYYMMDD_HHMMSS_screenshots/`
- Contains actual vs. expected comparison for each IP
@@ -266,6 +276,41 @@ JSON structure defined in src/scanner.py:365+. To modify:
3. Update README.md output format documentation
4. Update example output in both README.md and CLAUDE.md
### Generating HTML Reports
**Basic usage:**
```bash
# Generate HTML report from most recent JSON scan
python3 src/report_generator.py output/scan_report_20251113_175235.json
```
**Custom output location:**
```bash
# Specify output filename
python3 src/report_generator.py output/scan.json reports/my_report.html
```
**Programmatic usage:**
```python
from src.report_generator import HTMLReportGenerator
generator = HTMLReportGenerator('output/scan_report.json')
html_path = generator.generate_report('custom_output.html')
print(f"Report generated: {html_path}")
```
**Customizing the HTML template:**
- Edit `templates/report_template.html` to modify layout, colors, or content
- Template uses Jinja2 syntax with variables like `{{ title }}`, `{% for site in sites %}`
- CSS is embedded in the template for portability (single file output)
- Test design changes with `templates/report_mockup.html` first
**Output:**
- Standalone HTML file (no external dependencies)
- Can be opened directly in any browser
- Dark theme optimized for readability
- Screenshot links are relative paths (keep output directory structure intact)
### Customizing Screenshot Capture
**Change viewport size** (src/screenshot_capture.py:35):
@@ -339,53 +384,69 @@ Optimization strategies:
- Adjust screenshot timeout (currently 15 seconds in src/screenshot_capture.py:34)
- Disable screenshot capture for faster scans (set screenshot_capture to None)
## HTML Report Generation (✅ Implemented)
SneakyScanner now includes comprehensive HTML report generation from JSON scan data.
**Usage:**
```bash
# Generate HTML report from JSON scan output
python3 src/report_generator.py output/scan_report_20251113_175235.json
# Specify custom output path
python3 src/report_generator.py output/scan.json custom_report.html
```
**Implemented Features:**
- ✅ Dark theme with slate/grey color scheme for easy reading
- ✅ Summary dashboard with scan statistics, drift alerts, and security warnings
- ✅ Site-by-site breakdown with IP grouping
- ✅ Service details with expandable cards (click to expand)
- ✅ Visual badges for expected vs. unexpected services (green/red/yellow)
- ✅ SSL/TLS certificate details with expandable sections
- ✅ TLS version support display with cipher suites
- ✅ Certificate expiration warnings (highlighted for <30 days)
- ✅ Weak TLS version detection (TLS 1.0/1.1)
- ✅ Self-signed certificate identification
- ✅ Screenshot links (referenced, not embedded)
- ✅ Missing expected services clearly marked
- ✅ UDP port handling:
- Expected UDP ports shown with green "Expected" badge
- Unexpected UDP ports shown with red "Unexpected" badge
- Missing expected UDP ports shown with yellow "Missing" badge
- Note displayed that service detection not available for UDP
- ✅ Responsive layout for different screen sizes
- ✅ No JavaScript dependencies - pure HTML/CSS with minimal vanilla JS
- ✅ Optimized hover effects for table rows (lighter background + blue left border)
**Template Architecture:**
- `templates/report_template.html` - Jinja2 template for dynamic report generation
- `templates/report_mockup.html` - Static mockup for design testing
- Custom CSS with dark slate theme (#0f172a background, #60a5fa accents)
- Expandable service details and SSL/TLS sections
- Color-coded status badges (expected=green, unexpected=red, missing=yellow)
**HTMLReportGenerator Class** (`src/report_generator.py`):
- Loads JSON scan reports
- Calculates summary statistics (IPs, ports, services, screenshots)
- Identifies drift alerts (unexpected/missing ports and services)
- Identifies security warnings (expiring certs, weak TLS, self-signed certs, high ports)
- Renders Jinja2 template with context data
- Outputs standalone HTML file (no server required)
**Future Enhancements for HTML Reports:**
- Sortable/filterable tables with JavaScript
- Timeline view of scan history
- Scan comparison (diff between two reports)
- Export to PDF capability
- Interactive charts/graphs for trends
- Embedded screenshot thumbnails (currently links only)
## Planned Features (Future Development)
The following features are planned for future implementation:
### 1. HTML Report Generation
Build comprehensive HTML reports from JSON scan data with interactive visualizations.
**Report Features:**
- Service details and SSL/TLS information tables
- Visual comparison of expected vs. actual results (red/green highlighting)
- Certificate expiration warnings with countdown timers
- TLS version compliance reports (highlight weak configurations)
- Embedded webpage screenshots
- Sortable/filterable tables
- Timeline view of scan history
- Export to PDF capability
**Implementation Considerations:**
- Template engine: Jinja2 or similar
- CSS framework: Bootstrap or Tailwind for responsive design
- Charts/graphs: Chart.js or Plotly for visualizations
- Store templates in `templates/` directory
- Generate static HTML that can be opened without server
**Architecture:**
```python
class HTMLReportGenerator:
def __init__(self, json_report_path, template_dir='templates'):
pass
def generate_report(self, output_path):
# Parse JSON
# Render template with data
# Include screenshots
# Write HTML file
pass
def _compare_expected_actual(self, expected, actual):
# Generate diff/comparison data
pass
def _generate_cert_warnings(self, services):
# Identify expiring certs, weak TLS, etc.
pass
```
### 2. Comparison Reports (Scan Diffs)
### 1. Comparison Reports (Scan Diffs)
Generate reports showing changes between scans over time.
**Features:**
@@ -409,18 +470,21 @@ Generate reports showing changes between scans over time.
- python-libnmap==0.7.3 (nmap XML parsing)
- sslyze==6.0.0 (SSL/TLS analysis)
- playwright==1.40.0 (webpage screenshot capture)
- Built-in: socket, ssl, subprocess, xml.etree.ElementTree, logging
- Jinja2==3.1.2 (HTML report template engine)
- Built-in: socket, ssl, subprocess, xml.etree.ElementTree, logging, json, pathlib, datetime
- System: chromium, chromium-driver (installed via Dockerfile)
### For HTML Reports, Will Need:
- Jinja2 (template engine)
- Optional: weasyprint or pdfkit for PDF export
### For Future Enhancements, May Need:
- weasyprint or pdfkit for PDF export
- Chart.js or Plotly for interactive visualizations
### Key Files to Modify for New Features:
1. **src/scanner.py** - Core scanning logic (add new phases/methods)
2. **src/screenshot_capture.py** - ✅ Implemented: Webpage screenshot capture module
3. **src/report_generator.py** - New file for HTML report generation (planned)
4. **templates/** - New directory for HTML templates (planned)
3. **src/report_generator.py** - ✅ Implemented: HTML report generation with Jinja2 templates
4. **templates/** - ✅ Implemented: Jinja2 templates for HTML reports
- `report_template.html` - Main report template with dark theme
- `report_mockup.html` - Static mockup for design testing
5. **requirements.txt** - Add new dependencies
6. **Dockerfile** - Install additional system dependencies (browsers, etc.)
@@ -475,6 +539,39 @@ Generate reports showing changes between scans over time.
- **Check**: System certificates up to date in container
- **Solution**: This should not happen; file an issue if it does
### HTML Report Generation Issues
**Problem**: "Template not found" error
- **Check**: Verify `templates/report_template.html` exists
- **Check**: Running script from correct directory (project root)
- **Solution**: Ensure template directory structure is intact: `templates/report_template.html`
**Problem**: Report generated but looks broken/unstyled
- **Check**: Browser opens HTML file correctly (not viewing source)
- **Check**: CSS is embedded in template (should be in `<style>` tags)
- **Solution**: Try different browser (Chrome, Firefox, Edge)
**Problem**: UDP ports not showing in report
- **Check**: JSON report contains UDP port data in `actual.udp_ports`
- **Solution**: Regenerate report with updated template (template was fixed to show UDP ports)
- **Note**: Expected behavior - UDP ports show with note that service detection unavailable
**Problem**: Screenshot links broken in HTML report
- **Check**: Screenshot directory exists in same parent directory as HTML file
- **Check**: Screenshot paths in JSON are relative (not absolute)
- **Solution**: Keep HTML report and screenshot directory together (don't move HTML file alone)
- **Example**: If HTML is at `output/scan_report.html`, screenshots should be at `output/scan_report_screenshots/`
**Problem**: "Invalid JSON" error when generating report
- **Check**: JSON report file is valid: `python3 -m json.tool output/scan_report.json`
- **Check**: JSON file not truncated or corrupted
- **Solution**: Re-run scan to generate fresh JSON report
**Problem**: Expandable sections not working (click doesn't expand)
- **Check**: JavaScript is enabled in browser
- **Check**: Browser console for JavaScript errors (F12 → Console)
- **Solution**: This indicates template corruption; re-generate from `templates/report_template.html`
### Nmap/Masscan Issues
**Problem**: No ports discovered

View File

@@ -41,6 +41,13 @@ A dockerized network scanning tool that uses masscan for fast port discovery, nm
### Reporting & Output
- **Machine-readable JSON output** format for easy post-processing
- **HTML report generation**:
- Comprehensive HTML reports with dark theme for easy reading
- Summary dashboard with scan statistics, drift alerts, and security warnings
- Site-by-site breakdown with expandable service details
- Visual badges for expected vs. unexpected services
- SSL/TLS certificate details with expiration warnings
- One-click generation from JSON scan data
- **Dockerized** for consistent execution environment and root privilege isolation
- **Expected vs. Actual comparison** to identify infrastructure drift
- Timestamped reports with complete scan duration metrics
@@ -269,17 +276,87 @@ Screenshots are captured on a best-effort basis:
- Failed screenshots are logged but don't stop the scan
- Services without screenshots simply omit the `screenshot` field in JSON output
## HTML Report Generation
SneakyScanner can generate comprehensive HTML reports from JSON scan data, providing an easy-to-read visual interface for analyzing scan results.
### Generating Reports
After completing a scan, generate an HTML report from the JSON output:
```bash
# Generate HTML report (creates report in same directory as JSON)
python3 src/report_generator.py output/scan_report_20251113_175235.json
# Specify custom output path
python3 src/report_generator.py output/scan_report.json /path/to/custom_report.html
```
### Report Features
The generated HTML report includes:
**Summary Dashboard**:
- **Scan Statistics**: Total IPs scanned, TCP/UDP ports found, services identified, web services, screenshots captured
- **Drift Alerts**: Unexpected TCP/UDP ports, missing expected services, new services detected
- **Security Warnings**: Expiring certificates (<30 days), weak TLS versions (1.0/1.1), self-signed certificates, high port services (>10000)
**Site-by-Site Breakdown**:
- Organized by logical site grouping from configuration
- Per-IP sections with status badges (ping, port drift summary)
- Service tables with expandable details (click any row to expand)
- Visual badges: green (expected), red (unexpected), yellow (missing/warning)
**Service Details** (click to expand):
- Product name, version, extra information, OS type
- HTTP/HTTPS protocol detection
- Screenshot links for web services
- SSL/TLS certificate details (expandable):
- Subject, issuer, validity dates, serial number
- Days until expiration with color-coded warnings
- Subject Alternative Names (SANs)
- TLS version support (1.0, 1.1, 1.2, 1.3) with cipher suites
- Weak TLS and self-signed certificate warnings
**UDP Port Handling**:
- Expected UDP ports shown with green "Expected" badge
- Unexpected UDP ports shown with red "Unexpected" badge
- Missing expected UDP ports shown with yellow "Missing" badge
- Note: Service detection not available for UDP (nmap limitation)
**Design**:
- Dark theme with slate/grey color scheme for comfortable reading
- Responsive layout works on different screen sizes
- No external dependencies - single HTML file
- Minimal JavaScript for expand/collapse functionality
- Optimized hover effects for table rows
### Report Output
The HTML report is a standalone file that can be:
- Opened directly in any web browser (Chrome, Firefox, Safari, Edge)
- Shared via email or file transfer
- Archived for compliance or historical comparison
- Viewed without an internet connection or web server
Screenshot links in the report are relative paths, so keep the report and screenshot directory together.
## Project Structure
```
SneakyScanner/
├── src/
│ ├── scanner.py # Main scanner application
── screenshot_capture.py # Webpage screenshot capture module
── screenshot_capture.py # Webpage screenshot capture module
│ └── report_generator.py # HTML report generation module
├── templates/
│ ├── report_template.html # Jinja2 template for HTML reports
│ └── report_mockup.html # Static mockup for design testing
├── configs/
│ └── example-site.yaml # Example configuration
├── output/ # Scan results
│ ├── scan_report_*.json # JSON reports with timestamps
│ ├── scan_report_*.html # HTML reports (generated from JSON)
│ └── scan_report_*_screenshots/ # Screenshot directories
├── Dockerfile
├── docker-compose.yml
@@ -298,12 +375,12 @@ Only use this tool on networks you own or have explicit authorization to scan. U
## Future Enhancements
- **HTML Report Generation**: Build comprehensive HTML reports from JSON output with:
- Service details and SSL/TLS information
- Visual comparison of expected vs. actual results
- Certificate expiration warnings
- TLS version compliance reports
- Embedded webpage screenshots
- **Enhanced HTML Reports**:
- Sortable/filterable service tables with JavaScript
- Interactive charts and graphs for trends
- Timeline view of scan history
- Embedded screenshot thumbnails (currently links only)
- Export to PDF capability
- **Comparison Reports**: Generate diff reports showing changes between scans
- **Email Notifications**: Alert on unexpected changes or certificate expirations
- **Scheduled Scanning**: Automated periodic scans with cron integration

View File

@@ -2,3 +2,4 @@ PyYAML==6.0.1
python-libnmap==0.7.3
sslyze==6.0.0
playwright==1.40.0
Jinja2==3.1.2

327
src/report_generator.py Executable file
View File

@@ -0,0 +1,327 @@
#!/usr/bin/env python3
"""
HTML Report Generator for SneakyScanner
Generates comprehensive HTML reports from JSON scan results with:
- Summary dashboard (statistics, drift alerts, security warnings)
- Site-by-site breakdown with service details
- SSL/TLS certificate and cipher suite information
- Visual badges for expected vs. unexpected services
"""
import json
import logging
import sys
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Any, Optional
from jinja2 import Environment, FileSystemLoader, select_autoescape
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
class HTMLReportGenerator:
"""Generates HTML reports from SneakyScanner JSON output."""
def __init__(self, json_report_path: str, template_dir: str = 'templates'):
"""
Initialize the HTML report generator.
Args:
json_report_path: Path to the JSON scan report
template_dir: Directory containing Jinja2 templates
"""
self.json_report_path = Path(json_report_path)
self.template_dir = Path(template_dir)
self.report_data = None
# Initialize Jinja2 environment
self.jinja_env = Environment(
loader=FileSystemLoader(self.template_dir),
autoescape=select_autoescape(['html', 'xml'])
)
# Register custom filters
self.jinja_env.filters['format_date'] = self._format_date
self.jinja_env.filters['format_duration'] = self._format_duration
def generate_report(self, output_path: Optional[str] = None) -> str:
"""
Generate HTML report from JSON scan data.
Args:
output_path: Path for output HTML file. If None, derives from JSON filename.
Returns:
Path to generated HTML report
"""
logger.info(f"Loading JSON report from {self.json_report_path}")
self._load_json_report()
logger.info("Calculating summary statistics")
summary_stats = self._calculate_summary_stats()
logger.info("Identifying drift alerts")
drift_alerts = self._identify_drift_alerts()
logger.info("Identifying security warnings")
security_warnings = self._identify_security_warnings()
# Prepare template context
context = {
'title': self.report_data.get('title', 'SneakyScanner Report'),
'scan_time': self.report_data.get('scan_time'),
'scan_duration': self.report_data.get('scan_duration'),
'config_file': self.report_data.get('config_file'),
'sites': self.report_data.get('sites', []),
'summary_stats': summary_stats,
'drift_alerts': drift_alerts,
'security_warnings': security_warnings,
}
# Determine output path
if output_path is None:
output_path = self.json_report_path.with_suffix('.html')
else:
output_path = Path(output_path)
logger.info("Rendering HTML template")
template = self.jinja_env.get_template('report_template.html')
html_content = template.render(**context)
logger.info(f"Writing HTML report to {output_path}")
output_path.write_text(html_content, encoding='utf-8')
logger.info(f"Successfully generated HTML report: {output_path}")
return str(output_path)
def _load_json_report(self) -> None:
"""Load and parse JSON scan report."""
if not self.json_report_path.exists():
raise FileNotFoundError(f"JSON report not found: {self.json_report_path}")
with open(self.json_report_path, 'r') as f:
self.report_data = json.load(f)
def _calculate_summary_stats(self) -> Dict[str, int]:
"""
Calculate summary statistics for the dashboard.
Returns:
Dictionary with stat counts
"""
stats = {
'total_ips': 0,
'tcp_ports': 0,
'udp_ports': 0,
'services': 0,
'web_services': 0,
'screenshots': 0,
}
for site in self.report_data.get('sites', []):
for ip_data in site.get('ips', []):
stats['total_ips'] += 1
actual = ip_data.get('actual', {})
stats['tcp_ports'] += len(actual.get('tcp_ports', []))
stats['udp_ports'] += len(actual.get('udp_ports', []))
services = actual.get('services', [])
stats['services'] += len(services)
# Count web services (HTTP/HTTPS)
for service in services:
if service.get('http_info'):
stats['web_services'] += 1
if service['http_info'].get('screenshot'):
stats['screenshots'] += 1
return stats
def _identify_drift_alerts(self) -> Dict[str, int]:
"""
Identify infrastructure drift (unexpected/missing items).
Returns:
Dictionary with drift alert counts
"""
alerts = {
'unexpected_tcp': 0,
'unexpected_udp': 0,
'missing_tcp': 0,
'missing_udp': 0,
'new_services': 0,
}
for site in self.report_data.get('sites', []):
for ip_data in site.get('ips', []):
expected = ip_data.get('expected', {})
actual = ip_data.get('actual', {})
expected_tcp = set(expected.get('tcp_ports', []))
actual_tcp = set(actual.get('tcp_ports', []))
expected_udp = set(expected.get('udp_ports', []))
actual_udp = set(actual.get('udp_ports', []))
# Count unexpected ports
alerts['unexpected_tcp'] += len(actual_tcp - expected_tcp)
alerts['unexpected_udp'] += len(actual_udp - expected_udp)
# Count missing ports
alerts['missing_tcp'] += len(expected_tcp - actual_tcp)
alerts['missing_udp'] += len(expected_udp - actual_udp)
# Count new services (any service on unexpected port)
unexpected_ports = (actual_tcp - expected_tcp) | (actual_udp - expected_udp)
for service in actual.get('services', []):
if service.get('port') in unexpected_ports:
alerts['new_services'] += 1
return alerts
def _identify_security_warnings(self) -> Dict[str, Any]:
"""
Identify security issues (cert expiry, weak TLS, etc.).
Returns:
Dictionary with security warning counts and details
"""
warnings = {
'expiring_certs': 0,
'weak_tls': 0,
'self_signed': 0,
'high_ports': 0,
'expiring_cert_details': [], # List of IPs with expiring certs
}
for site in self.report_data.get('sites', []):
for ip_data in site.get('ips', []):
actual = ip_data.get('actual', {})
for service in actual.get('services', []):
port = service.get('port')
# Check for high ports (>10000)
if port and port > 10000:
warnings['high_ports'] += 1
# Check SSL/TLS if present
http_info = service.get('http_info', {})
ssl_tls = http_info.get('ssl_tls', {})
if ssl_tls:
# Check certificate expiry
cert = ssl_tls.get('certificate', {})
days_until_expiry = cert.get('days_until_expiry')
if days_until_expiry is not None and days_until_expiry < 30:
warnings['expiring_certs'] += 1
warnings['expiring_cert_details'].append({
'ip': ip_data.get('address'),
'port': port,
'days': days_until_expiry,
'subject': cert.get('subject'),
})
# Check for self-signed
issuer = cert.get('issuer', '')
subject = cert.get('subject', '')
if issuer and subject and issuer == subject:
warnings['self_signed'] += 1
# Check for weak TLS versions
tls_versions = ssl_tls.get('tls_versions', {})
if tls_versions.get('TLS 1.0', {}).get('supported'):
warnings['weak_tls'] += 1
elif tls_versions.get('TLS 1.1', {}).get('supported'):
warnings['weak_tls'] += 1
return warnings
@staticmethod
def _format_date(date_str: Optional[str]) -> str:
"""
Format ISO date string for display.
Args:
date_str: ISO format date string
Returns:
Formatted date string
"""
if not date_str:
return 'N/A'
try:
dt = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
return dt.strftime('%Y-%m-%d %H:%M:%S UTC')
except (ValueError, AttributeError):
return str(date_str)
@staticmethod
def _format_duration(duration: Optional[float]) -> str:
"""
Format scan duration for display.
Args:
duration: Duration in seconds
Returns:
Formatted duration string
"""
if duration is None:
return 'N/A'
if duration < 60:
return f"{duration:.1f} seconds"
elif duration < 3600:
minutes = duration / 60
return f"{minutes:.1f} minutes"
else:
hours = duration / 3600
return f"{hours:.2f} hours"
def main():
"""Command-line entry point for standalone usage."""
if len(sys.argv) < 2:
print("Usage: python report_generator.py <json_report_path> [output_html_path]")
print("\nExample:")
print(" python report_generator.py output/scan_report_20251114_103000.json")
print(" python report_generator.py output/scan_report.json custom_report.html")
sys.exit(1)
json_path = sys.argv[1]
output_path = sys.argv[2] if len(sys.argv) > 2 else None
try:
# Determine template directory relative to script location
script_dir = Path(__file__).parent.parent
template_dir = script_dir / 'templates'
generator = HTMLReportGenerator(json_path, template_dir=str(template_dir))
result_path = generator.generate_report(output_path)
print(f"\n✓ Successfully generated HTML report:")
print(f" {result_path}")
except FileNotFoundError as e:
logger.error(f"File not found: {e}")
sys.exit(1)
except json.JSONDecodeError as e:
logger.error(f"Invalid JSON in report file: {e}")
sys.exit(1)
except Exception as e:
logger.error(f"Error generating report: {e}", exc_info=True)
sys.exit(1)
if __name__ == '__main__':
main()

1424
templates/report_mockup.html Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,949 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SneakyScanner Report - {{ title }}</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #0f172a;
color: #e2e8f0;
line-height: 1.6;
padding: 20px;
}
.container {
max-width: 1400px;
margin: 0 auto;
}
/* Header */
.header {
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
padding: 30px;
border-radius: 12px;
margin-bottom: 30px;
border: 1px solid #475569;
}
.header h1 {
font-size: 2rem;
margin-bottom: 10px;
color: #60a5fa;
}
.header-meta {
display: flex;
gap: 30px;
color: #94a3b8;
font-size: 0.95rem;
}
.header-meta span {
display: flex;
align-items: center;
gap: 8px;
}
/* Summary Dashboard */
.dashboard {
background-color: #1e293b;
padding: 25px;
border-radius: 12px;
margin-bottom: 30px;
border: 1px solid #334155;
}
.dashboard h2 {
font-size: 1.5rem;
margin-bottom: 20px;
color: #60a5fa;
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
}
.dashboard-card {
background-color: #0f172a;
padding: 20px;
border-radius: 8px;
border: 1px solid #334155;
}
.dashboard-card h3 {
font-size: 1rem;
color: #94a3b8;
margin-bottom: 15px;
text-transform: uppercase;
font-weight: 600;
letter-spacing: 0.5px;
}
.stat-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.stat-item {
display: flex;
justify-content: space-between;
padding: 8px 0;
}
.stat-label {
color: #94a3b8;
}
.stat-value {
color: #e2e8f0;
font-weight: 600;
}
.alert-grid {
display: flex;
flex-direction: column;
gap: 10px;
}
.alert-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px;
background-color: #1e293b;
border-radius: 6px;
border-left: 3px solid;
}
.alert-item.critical {
border-color: #ef4444;
}
.alert-item.warning {
border-color: #f59e0b;
}
.alert-item.info {
border-color: #3b82f6;
}
/* Badges */
.badge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.badge.expected {
background-color: #065f46;
color: #6ee7b7;
}
.badge.unexpected {
background-color: #7f1d1d;
color: #fca5a5;
}
.badge.missing {
background-color: #78350f;
color: #fcd34d;
}
.badge.critical {
background-color: #7f1d1d;
color: #fca5a5;
}
.badge.warning {
background-color: #78350f;
color: #fcd34d;
}
.badge.good {
background-color: #065f46;
color: #6ee7b7;
}
.badge.info {
background-color: #1e3a8a;
color: #93c5fd;
}
.badge-count {
background-color: #334155;
color: #e2e8f0;
padding: 2px 8px;
border-radius: 10px;
font-size: 0.85rem;
font-weight: 600;
}
/* Site Section */
.site-section {
background-color: #1e293b;
padding: 25px;
border-radius: 12px;
margin-bottom: 25px;
border: 1px solid #334155;
}
.site-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 2px solid #334155;
}
.site-header h2 {
font-size: 1.75rem;
color: #60a5fa;
}
.site-stats {
display: flex;
gap: 15px;
color: #94a3b8;
font-size: 0.9rem;
}
/* IP Section */
.ip-section {
background-color: #0f172a;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
border: 1px solid #334155;
}
.ip-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.ip-header h3 {
font-size: 1.25rem;
color: #e2e8f0;
font-family: 'Courier New', monospace;
}
.ip-badges {
display: flex;
gap: 8px;
}
/* Service Table */
.service-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 15px;
background-color: #1e293b;
border-radius: 6px;
overflow: hidden;
}
.service-table thead {
background-color: #334155;
}
.service-table th {
padding: 12px;
text-align: left;
font-weight: 600;
color: #94a3b8;
text-transform: uppercase;
font-size: 0.8rem;
letter-spacing: 0.5px;
}
.service-table td {
padding: 12px;
border-top: 1px solid #334155;
}
.service-table tbody tr {
cursor: pointer;
transition: all 0.2s ease;
border-left: 3px solid transparent;
}
.service-table tbody tr:hover {
background-color: #334155;
border-left-color: #60a5fa;
}
.service-row-clickable {
position: relative;
}
.service-row-clickable::after {
content: '▼';
position: absolute;
right: 12px;
color: #64748b;
font-size: 0.7rem;
transition: transform 0.2s;
}
.service-row-clickable.expanded::after {
transform: rotate(-180deg);
}
/* Service Details Card */
.service-details {
display: none;
background-color: #0f172a;
padding: 20px;
margin: 10px 0;
border-radius: 6px;
border: 1px solid #334155;
border-left: 3px solid #60a5fa;
}
.service-details.show {
display: block;
animation: slideDown 0.3s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.details-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 15px;
margin-bottom: 15px;
}
.detail-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.detail-label {
color: #94a3b8;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.detail-value {
color: #e2e8f0;
font-weight: 500;
font-family: 'Courier New', monospace;
}
/* SSL/TLS Section */
.ssl-section {
background-color: #1e293b;
padding: 15px;
border-radius: 6px;
margin-top: 15px;
border: 1px solid #334155;
}
.ssl-header {
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
padding: 5px 0;
}
.ssl-header h4 {
color: #60a5fa;
font-size: 1rem;
}
.ssl-toggle {
color: #64748b;
font-size: 0.8rem;
}
.ssl-content {
display: none;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #334155;
}
.ssl-content.show {
display: block;
}
.cert-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 15px;
margin-bottom: 20px;
}
.tls-versions {
margin-top: 15px;
}
.tls-version-item {
background-color: #0f172a;
padding: 12px;
border-radius: 4px;
margin-bottom: 10px;
border-left: 3px solid;
}
.tls-version-item.supported {
border-color: #10b981;
}
.tls-version-item.unsupported {
border-color: #64748b;
}
.tls-version-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.cipher-list {
color: #94a3b8;
font-size: 0.85rem;
font-family: 'Courier New', monospace;
margin-left: 15px;
}
.cipher-list li {
margin: 4px 0;
}
/* Screenshot Link */
.screenshot-link {
display: inline-flex;
align-items: center;
gap: 6px;
color: #60a5fa;
text-decoration: none;
font-size: 0.9rem;
margin-top: 10px;
transition: color 0.2s;
}
.screenshot-link:hover {
color: #93c5fd;
text-decoration: underline;
}
/* Utilities */
.mono {
font-family: 'Courier New', monospace;
}
.text-muted {
color: #94a3b8;
}
.text-success {
color: #10b981;
}
.text-warning {
color: #f59e0b;
}
.text-danger {
color: #ef4444;
}
</style>
</head>
<body>
<div class="container">
<!-- Header -->
<div class="header">
<h1>{{ title }}</h1>
<div class="header-meta">
<span>📅 <strong>Scan Time:</strong> {{ scan_time | format_date }}</span>
<span>⏱️ <strong>Duration:</strong> {{ scan_duration | format_duration }}</span>
{% if config_file %}
<span>📄 <strong>Config:</strong> {{ config_file }}</span>
{% endif %}
</div>
</div>
<!-- Summary Dashboard -->
<div class="dashboard">
<h2>Scan Summary</h2>
<div class="dashboard-grid">
<!-- Statistics Card -->
<div class="dashboard-card">
<h3>Scan Statistics</h3>
<div class="stat-grid">
<div class="stat-item">
<span class="stat-label">Total IPs Scanned</span>
<span class="stat-value">{{ summary_stats.total_ips }}</span>
</div>
<div class="stat-item">
<span class="stat-label">TCP Ports Found</span>
<span class="stat-value">{{ summary_stats.tcp_ports }}</span>
</div>
<div class="stat-item">
<span class="stat-label">UDP Ports Found</span>
<span class="stat-value">{{ summary_stats.udp_ports }}</span>
</div>
<div class="stat-item">
<span class="stat-label">Services Identified</span>
<span class="stat-value">{{ summary_stats.services }}</span>
</div>
<div class="stat-item">
<span class="stat-label">Web Services</span>
<span class="stat-value">{{ summary_stats.web_services }}</span>
</div>
<div class="stat-item">
<span class="stat-label">Screenshots Captured</span>
<span class="stat-value">{{ summary_stats.screenshots }}</span>
</div>
</div>
</div>
<!-- Drift Alerts Card -->
<div class="dashboard-card">
<h3>Drift Alerts</h3>
<div class="alert-grid">
{% if drift_alerts.unexpected_tcp > 0 %}
<div class="alert-item warning">
<span>Unexpected TCP Ports</span>
<span class="badge-count">{{ drift_alerts.unexpected_tcp }}</span>
</div>
{% endif %}
{% if drift_alerts.unexpected_udp > 0 %}
<div class="alert-item warning">
<span>Unexpected UDP Ports</span>
<span class="badge-count">{{ drift_alerts.unexpected_udp }}</span>
</div>
{% endif %}
{% if drift_alerts.missing_tcp > 0 or drift_alerts.missing_udp > 0 %}
<div class="alert-item critical">
<span>Missing Expected Services</span>
<span class="badge-count">{{ drift_alerts.missing_tcp + drift_alerts.missing_udp }}</span>
</div>
{% endif %}
{% if drift_alerts.new_services > 0 %}
<div class="alert-item info">
<span>New Services Detected</span>
<span class="badge-count">{{ drift_alerts.new_services }}</span>
</div>
{% endif %}
{% if drift_alerts.unexpected_tcp == 0 and drift_alerts.unexpected_udp == 0 and drift_alerts.missing_tcp == 0 and drift_alerts.missing_udp == 0 %}
<div class="alert-item info">
<span>No drift detected - all services match expectations</span>
<span class="badge good"></span>
</div>
{% endif %}
</div>
</div>
<!-- Security Warnings Card -->
<div class="dashboard-card">
<h3>Security Warnings</h3>
<div class="alert-grid">
{% if security_warnings.expiring_certs > 0 %}
<div class="alert-item critical">
<span>Certificates Expiring Soon (&lt;30 days)</span>
<span class="badge-count">{{ security_warnings.expiring_certs }}</span>
</div>
{% endif %}
{% if security_warnings.weak_tls > 0 %}
<div class="alert-item warning">
<span>Weak TLS Versions (1.0/1.1)</span>
<span class="badge-count">{{ security_warnings.weak_tls }}</span>
</div>
{% endif %}
{% if security_warnings.self_signed > 0 %}
<div class="alert-item warning">
<span>Self-Signed Certificates</span>
<span class="badge-count">{{ security_warnings.self_signed }}</span>
</div>
{% endif %}
{% if security_warnings.high_ports > 0 %}
<div class="alert-item info">
<span>High Port Services (&gt;10000)</span>
<span class="badge-count">{{ security_warnings.high_ports }}</span>
</div>
{% endif %}
{% if security_warnings.expiring_certs == 0 and security_warnings.weak_tls == 0 and security_warnings.self_signed == 0 %}
<div class="alert-item info">
<span>No critical security warnings detected</span>
<span class="badge good"></span>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Sites -->
{% for site in sites %}
<div class="site-section">
<div class="site-header">
<h2>{{ site.name }}</h2>
<div class="site-stats">
<span>{{ site.ips | length }} IP{{ 's' if site.ips | length != 1 else '' }}</span>
</div>
</div>
<!-- IPs -->
{% for ip_data in site.ips %}
{% set expected = ip_data.expected %}
{% set actual = ip_data.actual %}
{% set expected_tcp = expected.tcp_ports | default([]) | list %}
{% set actual_tcp = actual.tcp_ports | default([]) | list %}
{% set expected_udp = expected.udp_ports | default([]) | list %}
{% set actual_udp = actual.udp_ports | default([]) | list %}
{% set unexpected_tcp = actual_tcp | reject('in', expected_tcp) | list %}
{% set unexpected_udp = actual_udp | reject('in', expected_udp) | list %}
{% set missing_tcp = expected_tcp | reject('in', actual_tcp) | list %}
{% set missing_udp = expected_udp | reject('in', actual_udp) | list %}
<div class="ip-section">
<div class="ip-header">
<h3>{{ ip_data.address }}</h3>
<div class="ip-badges">
{% if expected.ping %}
{% if actual.ping %}
<span class="badge expected">Ping: Expected</span>
{% else %}
<span class="badge missing">Ping: Missing</span>
{% endif %}
{% endif %}
{% if (unexpected_tcp | length) > 0 or (unexpected_udp | length) > 0 %}
<span class="badge warning">{{ (unexpected_tcp | length) + (unexpected_udp | length) }} Unexpected Port{{ 's' if ((unexpected_tcp | length) + (unexpected_udp | length)) > 1 else '' }}</span>
{% elif (missing_tcp | length) > 0 or (missing_udp | length) > 0 %}
<span class="badge critical">{{ (missing_tcp | length) + (missing_udp | length) }} Missing Service{{ 's' if ((missing_tcp | length) + (missing_udp | length)) > 1 else '' }}</span>
{% else %}
<span class="badge good">All Ports Expected</span>
{% endif %}
</div>
</div>
<table class="service-table">
<thead>
<tr>
<th>Port</th>
<th>Protocol</th>
<th>Service</th>
<th>Product</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for service in actual.services | default([]) %}
{% set service_id = 'service_' ~ loop.index ~ '_' ~ ip_data.address | replace('.', '_') %}
{% set is_expected = service.port in expected_tcp or service.port in expected_udp %}
<tr class="service-row-clickable" onclick="toggleDetails('{{ service_id }}')">
<td class="mono">{{ service.port }}</td>
<td>{{ service.protocol | upper }}</td>
<td>{{ service.service | default('unknown') }}</td>
<td>{{ service.product | default('') }} {% if service.version %}{{ service.version }}{% endif %}</td>
<td>
{% if is_expected %}
<span class="badge expected">Expected</span>
{% else %}
<span class="badge unexpected">Unexpected</span>
{% endif %}
</td>
</tr>
<tr>
<td colspan="5">
<div id="{{ service_id }}" class="service-details">
<div class="details-grid">
{% if service.product %}
<div class="detail-item">
<span class="detail-label">Product</span>
<span class="detail-value">{{ service.product }}</span>
</div>
{% endif %}
{% if service.version %}
<div class="detail-item">
<span class="detail-label">Version</span>
<span class="detail-value">{{ service.version }}</span>
</div>
{% endif %}
{% if service.extrainfo %}
<div class="detail-item">
<span class="detail-label">Extra Info</span>
<span class="detail-value">{{ service.extrainfo }}</span>
</div>
{% endif %}
{% if service.ostype %}
<div class="detail-item">
<span class="detail-label">OS Type</span>
<span class="detail-value">{{ service.ostype }}</span>
</div>
{% endif %}
{% if service.http_info %}
<div class="detail-item">
<span class="detail-label">Protocol</span>
<span class="detail-value">{{ service.http_info.protocol | upper }}</span>
</div>
{% endif %}
{% if not is_expected %}
<div class="detail-item">
<span class="detail-label">⚠️ Status</span>
<span class="detail-value text-warning">Not in expected ports list</span>
</div>
{% endif %}
</div>
{% if service.http_info and service.http_info.screenshot %}
<a href="{{ service.http_info.screenshot }}" class="screenshot-link" target="_blank">
🖼️ View Screenshot
</a>
{% endif %}
{% if service.http_info and service.http_info.ssl_tls %}
{% set ssl_id = 'ssl_' ~ loop.index ~ '_' ~ ip_data.address | replace('.', '_') %}
{% set ssl = service.http_info.ssl_tls %}
{% set cert = ssl.certificate %}
<div class="ssl-section">
<div class="ssl-header" onclick="toggleSSL('{{ ssl_id }}')">
<h4>🔒 SSL/TLS Details
{% if cert.days_until_expiry is defined and cert.days_until_expiry < 30 %}
<span class="badge critical" style="margin-left: 10px;">Certificate Expiring Soon</span>
{% elif cert.issuer == cert.subject %}
<span class="badge warning" style="margin-left: 10px;">Self-Signed Certificate</span>
{% endif %}
</h4>
<span class="ssl-toggle">Click to expand ▼</span>
</div>
<div id="{{ ssl_id }}" class="ssl-content">
<h5 style="color: #94a3b8; margin-bottom: 10px;">Certificate Information</h5>
<div class="cert-grid">
{% if cert.subject %}
<div class="detail-item">
<span class="detail-label">Subject</span>
<span class="detail-value">{{ cert.subject }}</span>
</div>
{% endif %}
{% if cert.issuer %}
<div class="detail-item">
<span class="detail-label">Issuer</span>
<span class="detail-value {% if cert.issuer == cert.subject %}text-warning{% endif %}">
{{ cert.issuer }}{% if cert.issuer == cert.subject %} (Self-Signed){% endif %}
</span>
</div>
{% endif %}
{% if cert.not_valid_before %}
<div class="detail-item">
<span class="detail-label">Valid From</span>
<span class="detail-value">{{ cert.not_valid_before | format_date }}</span>
</div>
{% endif %}
{% if cert.not_valid_after %}
<div class="detail-item">
<span class="detail-label">Valid Until</span>
<span class="detail-value {% if cert.days_until_expiry is defined and cert.days_until_expiry < 30 %}text-danger{% else %}text-success{% endif %}">
{{ cert.not_valid_after | format_date }}
</span>
</div>
{% endif %}
{% if cert.days_until_expiry is defined %}
<div class="detail-item">
<span class="detail-label">Days Until Expiry</span>
<span class="detail-value {% if cert.days_until_expiry < 30 %}text-danger{% else %}text-success{% endif %}">
{{ cert.days_until_expiry }} days{% if cert.days_until_expiry < 30 %} {% endif %}
</span>
</div>
{% endif %}
{% if cert.serial_number %}
<div class="detail-item">
<span class="detail-label">Serial Number</span>
<span class="detail-value">{{ cert.serial_number }}</span>
</div>
{% endif %}
</div>
{% if cert.sans %}
<div class="detail-item" style="margin-bottom: 15px;">
<span class="detail-label">Subject Alternative Names (SANs)</span>
<span class="detail-value">{{ cert.sans | join(', ') }}</span>
</div>
{% endif %}
{% if ssl.tls_versions %}
<div class="tls-versions">
<h5 style="color: #94a3b8; margin-bottom: 10px;">TLS Version Support</h5>
{% for version_name in ['TLS 1.0', 'TLS 1.1', 'TLS 1.2', 'TLS 1.3'] %}
{% set tls_version = ssl.tls_versions.get(version_name, {}) %}
{% if tls_version.supported %}
<div class="tls-version-item supported">
<div class="tls-version-header">
<strong>{{ version_name }}</strong>
{% if version_name in ['TLS 1.0', 'TLS 1.1'] %}
<span class="badge warning">Supported (Weak) ⚠️</span>
{% else %}
<span class="badge good">Supported</span>
{% endif %}
</div>
{% if tls_version.cipher_suites %}
<ul class="cipher-list">
{% for cipher in tls_version.cipher_suites %}
<li>{{ cipher }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% elif tls_version.supported is defined %}
<div class="tls-version-item unsupported">
<div class="tls-version-header">
<strong>{{ version_name }}</strong>
<span class="badge info">Not Supported</span>
</div>
</div>
{% endif %}
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
{# Show expected UDP ports (UDP ports found and expected, with no service details) #}
{% for port in actual_udp %}
{% if port in expected_udp %}
<tr class="service-row-clickable" onclick="toggleDetails('udp_{{ port }}_{{ ip_data.address | replace('.', '_') }}')">
<td class="mono">{{ port }}</td>
<td>UDP</td>
<td colspan="2" class="text-muted">No service detection available</td>
<td><span class="badge expected">Expected</span></td>
</tr>
<tr>
<td colspan="5">
<div id="udp_{{ port }}_{{ ip_data.address | replace('.', '_') }}" class="service-details">
<div class="details-grid">
<div class="detail-item">
<span class="detail-label">Protocol</span>
<span class="detail-value">UDP</span>
</div>
<div class="detail-item">
<span class="detail-label">Note</span>
<span class="detail-value text-muted">Service detection not available for UDP ports</span>
</div>
</div>
</div>
</td>
</tr>
{% endif %}
{% endfor %}
{# Show unexpected UDP ports (UDP ports found but not expected, with no service details) #}
{% for port in unexpected_udp %}
<tr class="service-row-clickable" onclick="toggleDetails('udp_{{ port }}_{{ ip_data.address | replace('.', '_') }}')">
<td class="mono">{{ port }}</td>
<td>UDP</td>
<td colspan="2" class="text-muted">No service detection available</td>
<td><span class="badge unexpected">Unexpected</span></td>
</tr>
<tr>
<td colspan="5">
<div id="udp_{{ port }}_{{ ip_data.address | replace('.', '_') }}" class="service-details">
<div class="details-grid">
<div class="detail-item">
<span class="detail-label">⚠️ Status</span>
<span class="detail-value text-warning">UDP port discovered but not in expected ports list. Service detection not available for UDP.</span>
</div>
</div>
</div>
</td>
</tr>
{% endfor %}
{# Show missing expected services #}
{% for port in missing_tcp %}
<tr style="background-color: rgba(127, 29, 29, 0.2);">
<td class="mono">{{ port }}</td>
<td>TCP</td>
<td colspan="2" class="text-danger">❌ Expected but not found</td>
<td><span class="badge missing">Missing</span></td>
</tr>
{% endfor %}
{% for port in missing_udp %}
<tr style="background-color: rgba(127, 29, 29, 0.2);">
<td class="mono">{{ port }}</td>
<td>UDP</td>
<td colspan="2" class="text-danger">❌ Expected but not found</td>
<td><span class="badge missing">Missing</span></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endfor %}
</div>
{% endfor %}
</div>
<script>
function toggleDetails(id) {
const details = document.getElementById(id);
const row = event.currentTarget;
if (details.classList.contains('show')) {
details.classList.remove('show');
row.classList.remove('expanded');
} else {
details.classList.add('show');
row.classList.add('expanded');
}
}
function toggleSSL(id) {
const sslContent = document.getElementById(id);
const header = event.currentTarget;
const toggle = header.querySelector('.ssl-toggle');
if (sslContent.classList.contains('show')) {
sslContent.classList.remove('show');
toggle.textContent = 'Click to expand ▼';
} else {
sslContent.classList.add('show');
toggle.textContent = 'Click to collapse ▲';
}
event.stopPropagation();
}
</script>
</body>
</html>