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:
199
CLAUDE.md
199
CLAUDE.md
@@ -61,11 +61,21 @@ python3 -c "import yaml; yaml.safe_load(open('configs/example-site.yaml'))"
|
|||||||
- `_get_screenshot_dir()`: Creates screenshots subdirectory
|
- `_get_screenshot_dir()`: Creates screenshots subdirectory
|
||||||
- `_generate_filename()`: Generates filename for screenshot (IP_PORT.png)
|
- `_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
|
- Define scan title, sites, IPs, and expected network behavior
|
||||||
- Each IP includes expected ping response and TCP/UDP ports
|
- 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`
|
- Timestamped JSON files: `scan_report_YYYYMMDD_HHMMSS.json`
|
||||||
- Screenshot directory: `scan_report_YYYYMMDD_HHMMSS_screenshots/`
|
- Screenshot directory: `scan_report_YYYYMMDD_HHMMSS_screenshots/`
|
||||||
- Contains actual vs. expected comparison for each IP
|
- 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
|
3. Update README.md output format documentation
|
||||||
4. Update example output in both README.md and CLAUDE.md
|
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
|
### Customizing Screenshot Capture
|
||||||
|
|
||||||
**Change viewport size** (src/screenshot_capture.py:35):
|
**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)
|
- Adjust screenshot timeout (currently 15 seconds in src/screenshot_capture.py:34)
|
||||||
- Disable screenshot capture for faster scans (set screenshot_capture to None)
|
- 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)
|
## Planned Features (Future Development)
|
||||||
|
|
||||||
The following features are planned for future implementation:
|
The following features are planned for future implementation:
|
||||||
|
|
||||||
### 1. HTML Report Generation
|
### 1. Comparison Reports (Scan Diffs)
|
||||||
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)
|
|
||||||
Generate reports showing changes between scans over time.
|
Generate reports showing changes between scans over time.
|
||||||
|
|
||||||
**Features:**
|
**Features:**
|
||||||
@@ -409,18 +470,21 @@ Generate reports showing changes between scans over time.
|
|||||||
- python-libnmap==0.7.3 (nmap XML parsing)
|
- python-libnmap==0.7.3 (nmap XML parsing)
|
||||||
- sslyze==6.0.0 (SSL/TLS analysis)
|
- sslyze==6.0.0 (SSL/TLS analysis)
|
||||||
- playwright==1.40.0 (webpage screenshot capture)
|
- 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)
|
- System: chromium, chromium-driver (installed via Dockerfile)
|
||||||
|
|
||||||
### For HTML Reports, Will Need:
|
### For Future Enhancements, May Need:
|
||||||
- Jinja2 (template engine)
|
- weasyprint or pdfkit for PDF export
|
||||||
- Optional: weasyprint or pdfkit for PDF export
|
- Chart.js or Plotly for interactive visualizations
|
||||||
|
|
||||||
### Key Files to Modify for New Features:
|
### Key Files to Modify for New Features:
|
||||||
1. **src/scanner.py** - Core scanning logic (add new phases/methods)
|
1. **src/scanner.py** - Core scanning logic (add new phases/methods)
|
||||||
2. **src/screenshot_capture.py** - ✅ Implemented: Webpage screenshot capture module
|
2. **src/screenshot_capture.py** - ✅ Implemented: Webpage screenshot capture module
|
||||||
3. **src/report_generator.py** - New file for HTML report generation (planned)
|
3. **src/report_generator.py** - ✅ Implemented: HTML report generation with Jinja2 templates
|
||||||
4. **templates/** - New directory for HTML templates (planned)
|
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
|
5. **requirements.txt** - Add new dependencies
|
||||||
6. **Dockerfile** - Install additional system dependencies (browsers, etc.)
|
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
|
- **Check**: System certificates up to date in container
|
||||||
- **Solution**: This should not happen; file an issue if it does
|
- **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
|
### Nmap/Masscan Issues
|
||||||
|
|
||||||
**Problem**: No ports discovered
|
**Problem**: No ports discovered
|
||||||
|
|||||||
91
README.md
91
README.md
@@ -41,6 +41,13 @@ A dockerized network scanning tool that uses masscan for fast port discovery, nm
|
|||||||
|
|
||||||
### Reporting & Output
|
### Reporting & Output
|
||||||
- **Machine-readable JSON output** format for easy post-processing
|
- **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
|
- **Dockerized** for consistent execution environment and root privilege isolation
|
||||||
- **Expected vs. Actual comparison** to identify infrastructure drift
|
- **Expected vs. Actual comparison** to identify infrastructure drift
|
||||||
- Timestamped reports with complete scan duration metrics
|
- 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
|
- Failed screenshots are logged but don't stop the scan
|
||||||
- Services without screenshots simply omit the `screenshot` field in JSON output
|
- 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
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
SneakyScanner/
|
SneakyScanner/
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── scanner.py # Main scanner application
|
│ ├── 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/
|
├── configs/
|
||||||
│ └── example-site.yaml # Example configuration
|
│ └── example-site.yaml # Example configuration
|
||||||
├── output/ # Scan results
|
├── output/ # Scan results
|
||||||
│ ├── scan_report_*.json # JSON reports with timestamps
|
│ ├── scan_report_*.json # JSON reports with timestamps
|
||||||
|
│ ├── scan_report_*.html # HTML reports (generated from JSON)
|
||||||
│ └── scan_report_*_screenshots/ # Screenshot directories
|
│ └── scan_report_*_screenshots/ # Screenshot directories
|
||||||
├── Dockerfile
|
├── Dockerfile
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml
|
||||||
@@ -298,12 +375,12 @@ Only use this tool on networks you own or have explicit authorization to scan. U
|
|||||||
|
|
||||||
## Future Enhancements
|
## Future Enhancements
|
||||||
|
|
||||||
- **HTML Report Generation**: Build comprehensive HTML reports from JSON output with:
|
- **Enhanced HTML Reports**:
|
||||||
- Service details and SSL/TLS information
|
- Sortable/filterable service tables with JavaScript
|
||||||
- Visual comparison of expected vs. actual results
|
- Interactive charts and graphs for trends
|
||||||
- Certificate expiration warnings
|
- Timeline view of scan history
|
||||||
- TLS version compliance reports
|
- Embedded screenshot thumbnails (currently links only)
|
||||||
- Embedded webpage screenshots
|
- Export to PDF capability
|
||||||
- **Comparison Reports**: Generate diff reports showing changes between scans
|
- **Comparison Reports**: Generate diff reports showing changes between scans
|
||||||
- **Email Notifications**: Alert on unexpected changes or certificate expirations
|
- **Email Notifications**: Alert on unexpected changes or certificate expirations
|
||||||
- **Scheduled Scanning**: Automated periodic scans with cron integration
|
- **Scheduled Scanning**: Automated periodic scans with cron integration
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ PyYAML==6.0.1
|
|||||||
python-libnmap==0.7.3
|
python-libnmap==0.7.3
|
||||||
sslyze==6.0.0
|
sslyze==6.0.0
|
||||||
playwright==1.40.0
|
playwright==1.40.0
|
||||||
|
Jinja2==3.1.2
|
||||||
|
|||||||
327
src/report_generator.py
Executable file
327
src/report_generator.py
Executable 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
1424
templates/report_mockup.html
Normal file
File diff suppressed because it is too large
Load Diff
949
templates/report_template.html
Normal file
949
templates/report_template.html
Normal 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 (<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 (>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>
|
||||||
Reference in New Issue
Block a user