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

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>