Files
SneakyScan/templates/report_template.html
Phillip Tarrant d390c4b491 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>
2025-11-14 01:43:59 +00:00

950 lines
38 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>