remove infra.md.example, infra.md is the source of truth
This commit is contained in:
535
ayn-antivirus/ayn_antivirus/reports/generator.py
Normal file
535
ayn-antivirus/ayn_antivirus/reports/generator.py
Normal file
@@ -0,0 +1,535 @@
|
||||
"""Report generator for AYN Antivirus.
|
||||
|
||||
Produces scan reports in plain-text, JSON, and HTML formats from
|
||||
:class:`ScanResult` / :class:`FullScanResult` dataclasses.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html as html_mod
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
from ayn_antivirus import __version__
|
||||
from ayn_antivirus.core.engine import (
|
||||
FullScanResult,
|
||||
ScanResult,
|
||||
ThreatInfo,
|
||||
)
|
||||
from ayn_antivirus.utils.helpers import format_duration, format_size, get_system_info
|
||||
|
||||
# Type alias for either result kind.
|
||||
AnyResult = Union[ScanResult, FullScanResult]
|
||||
|
||||
|
||||
class ReportGenerator:
|
||||
"""Create scan reports in multiple output formats."""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Plain text
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def generate_text(result: AnyResult) -> str:
|
||||
"""Render a human-readable plain-text report."""
|
||||
threats, meta = _extract(result)
|
||||
lines: List[str] = []
|
||||
|
||||
lines.append("=" * 72)
|
||||
lines.append(" AYN ANTIVIRUS — SCAN REPORT")
|
||||
lines.append("=" * 72)
|
||||
lines.append("")
|
||||
lines.append(f" Generated : {datetime.utcnow().isoformat()}")
|
||||
lines.append(f" Version : {__version__}")
|
||||
lines.append(f" Scan ID : {meta.get('scan_id', 'N/A')}")
|
||||
lines.append(f" Scan Type : {meta.get('scan_type', 'N/A')}")
|
||||
lines.append(f" Duration : {format_duration(meta.get('duration', 0))}")
|
||||
lines.append("")
|
||||
|
||||
# Summary.
|
||||
sev_counts = _severity_counts(threats)
|
||||
lines.append("-" * 72)
|
||||
lines.append(" SUMMARY")
|
||||
lines.append("-" * 72)
|
||||
lines.append(f" Files scanned : {meta.get('files_scanned', 0)}")
|
||||
lines.append(f" Files skipped : {meta.get('files_skipped', 0)}")
|
||||
lines.append(f" Threats found : {len(threats)}")
|
||||
lines.append(f" CRITICAL : {sev_counts.get('CRITICAL', 0)}")
|
||||
lines.append(f" HIGH : {sev_counts.get('HIGH', 0)}")
|
||||
lines.append(f" MEDIUM : {sev_counts.get('MEDIUM', 0)}")
|
||||
lines.append(f" LOW : {sev_counts.get('LOW', 0)}")
|
||||
lines.append("")
|
||||
|
||||
# Threat table.
|
||||
if threats:
|
||||
lines.append("-" * 72)
|
||||
lines.append(" THREATS")
|
||||
lines.append("-" * 72)
|
||||
hdr = f" {'#':>3} {'Severity':<10} {'Threat Name':<30} {'File'}"
|
||||
lines.append(hdr)
|
||||
lines.append(" " + "-" * 68)
|
||||
for idx, t in enumerate(threats, 1):
|
||||
sev = _sev_str(t)
|
||||
name = t.threat_name[:30]
|
||||
fpath = t.path[:60]
|
||||
lines.append(f" {idx:>3} {sev:<10} {name:<30} {fpath}")
|
||||
lines.append("")
|
||||
|
||||
# System info.
|
||||
try:
|
||||
info = get_system_info()
|
||||
lines.append("-" * 72)
|
||||
lines.append(" SYSTEM INFORMATION")
|
||||
lines.append("-" * 72)
|
||||
lines.append(f" Hostname : {info['hostname']}")
|
||||
lines.append(f" OS : {info['os_pretty']}")
|
||||
lines.append(f" CPUs : {info['cpu_count']}")
|
||||
lines.append(f" Memory : {info['memory_total_human']}")
|
||||
lines.append(f" Uptime : {info['uptime_human']}")
|
||||
lines.append("")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
lines.append("=" * 72)
|
||||
lines.append(f" Report generated by AYN Antivirus v{__version__}")
|
||||
lines.append("=" * 72)
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# JSON
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def generate_json(result: AnyResult) -> str:
|
||||
"""Render a machine-readable JSON report."""
|
||||
threats, meta = _extract(result)
|
||||
sev_counts = _severity_counts(threats)
|
||||
|
||||
try:
|
||||
sys_info = get_system_info()
|
||||
except Exception:
|
||||
sys_info = {}
|
||||
|
||||
report: Dict[str, Any] = {
|
||||
"generator": f"ayn-antivirus v{__version__}",
|
||||
"generated_at": datetime.utcnow().isoformat(),
|
||||
"scan": {
|
||||
"scan_id": meta.get("scan_id"),
|
||||
"scan_type": meta.get("scan_type"),
|
||||
"start_time": meta.get("start_time"),
|
||||
"end_time": meta.get("end_time"),
|
||||
"duration_seconds": meta.get("duration"),
|
||||
"files_scanned": meta.get("files_scanned", 0),
|
||||
"files_skipped": meta.get("files_skipped", 0),
|
||||
},
|
||||
"summary": {
|
||||
"total_threats": len(threats),
|
||||
"by_severity": sev_counts,
|
||||
},
|
||||
"threats": [
|
||||
{
|
||||
"path": t.path,
|
||||
"threat_name": t.threat_name,
|
||||
"threat_type": t.threat_type.name if hasattr(t.threat_type, "name") else str(t.threat_type),
|
||||
"severity": _sev_str(t),
|
||||
"detector": t.detector_name,
|
||||
"details": t.details,
|
||||
"file_hash": t.file_hash,
|
||||
"timestamp": t.timestamp.isoformat() if hasattr(t.timestamp, "isoformat") else str(t.timestamp),
|
||||
}
|
||||
for t in threats
|
||||
],
|
||||
"system": sys_info,
|
||||
}
|
||||
return json.dumps(report, indent=2, default=str)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# HTML
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def generate_html(result: AnyResult) -> str:
|
||||
"""Render a professional HTML report with dark-theme CSS."""
|
||||
threats, meta = _extract(result)
|
||||
sev_counts = _severity_counts(threats)
|
||||
now = datetime.utcnow()
|
||||
esc = html_mod.escape
|
||||
|
||||
try:
|
||||
sys_info = get_system_info()
|
||||
except Exception:
|
||||
sys_info = {}
|
||||
|
||||
total_threats = len(threats)
|
||||
status_class = "clean" if total_threats == 0 else "infected"
|
||||
|
||||
# --- Build threat table rows ---
|
||||
threat_rows = []
|
||||
for idx, t in enumerate(threats, 1):
|
||||
sev = _sev_str(t)
|
||||
sev_lower = sev.lower()
|
||||
ttype = t.threat_type.name if hasattr(t.threat_type, "name") else str(t.threat_type)
|
||||
threat_rows.append(
|
||||
f"<tr>"
|
||||
f'<td class="idx">{idx}</td>'
|
||||
f"<td>{esc(t.path)}</td>"
|
||||
f"<td>{esc(t.threat_name)}</td>"
|
||||
f"<td>{esc(ttype)}</td>"
|
||||
f'<td><span class="badge badge-{sev_lower}">{sev}</span></td>'
|
||||
f"<td>{esc(t.detector_name)}</td>"
|
||||
f'<td class="hash">{esc(t.file_hash[:16])}{"…" if len(t.file_hash) > 16 else ""}</td>'
|
||||
f"</tr>"
|
||||
)
|
||||
|
||||
threat_table = "\n".join(threat_rows) if threat_rows else (
|
||||
'<tr><td colspan="7" class="empty">No threats detected ✅</td></tr>'
|
||||
)
|
||||
|
||||
# --- System info rows ---
|
||||
sys_rows = ""
|
||||
if sys_info:
|
||||
sys_rows = (
|
||||
f"<tr><td>Hostname</td><td>{esc(str(sys_info.get('hostname', '')))}</td></tr>"
|
||||
f"<tr><td>Operating System</td><td>{esc(str(sys_info.get('os_pretty', '')))}</td></tr>"
|
||||
f"<tr><td>Architecture</td><td>{esc(str(sys_info.get('architecture', '')))}</td></tr>"
|
||||
f"<tr><td>CPUs</td><td>{sys_info.get('cpu_count', '?')}</td></tr>"
|
||||
f"<tr><td>Memory</td><td>{esc(str(sys_info.get('memory_total_human', '')))}"
|
||||
f" ({sys_info.get('memory_percent', '?')}% used)</td></tr>"
|
||||
f"<tr><td>Uptime</td><td>{esc(str(sys_info.get('uptime_human', '')))}</td></tr>"
|
||||
)
|
||||
|
||||
html = f"""\
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AYN Antivirus — Scan Report</title>
|
||||
<style>
|
||||
{_CSS}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Header -->
|
||||
<header>
|
||||
<div class="logo">⚔️ AYN ANTIVIRUS</div>
|
||||
<div class="subtitle">Scan Report — {esc(now.strftime("%Y-%m-%d %H:%M:%S"))}</div>
|
||||
</header>
|
||||
|
||||
<!-- Summary cards -->
|
||||
<section class="cards">
|
||||
<div class="card">
|
||||
<div class="card-value">{meta.get("files_scanned", 0)}</div>
|
||||
<div class="card-label">Files Scanned</div>
|
||||
</div>
|
||||
<div class="card card-{status_class}">
|
||||
<div class="card-value">{total_threats}</div>
|
||||
<div class="card-label">Threats Found</div>
|
||||
</div>
|
||||
<div class="card card-critical">
|
||||
<div class="card-value">{sev_counts.get("CRITICAL", 0)}</div>
|
||||
<div class="card-label">Critical</div>
|
||||
</div>
|
||||
<div class="card card-high">
|
||||
<div class="card-value">{sev_counts.get("HIGH", 0)}</div>
|
||||
<div class="card-label">High</div>
|
||||
</div>
|
||||
<div class="card card-medium">
|
||||
<div class="card-value">{sev_counts.get("MEDIUM", 0)}</div>
|
||||
<div class="card-label">Medium</div>
|
||||
</div>
|
||||
<div class="card card-low">
|
||||
<div class="card-value">{sev_counts.get("LOW", 0)}</div>
|
||||
<div class="card-label">Low</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Scan details -->
|
||||
<section class="details">
|
||||
<h2>Scan Details</h2>
|
||||
<table class="info-table">
|
||||
<tr><td>Scan ID</td><td>{esc(str(meta.get("scan_id", "N/A")))}</td></tr>
|
||||
<tr><td>Scan Type</td><td>{esc(str(meta.get("scan_type", "N/A")))}</td></tr>
|
||||
<tr><td>Duration</td><td>{esc(format_duration(meta.get("duration", 0)))}</td></tr>
|
||||
<tr><td>Files Scanned</td><td>{meta.get("files_scanned", 0)}</td></tr>
|
||||
<tr><td>Files Skipped</td><td>{meta.get("files_skipped", 0)}</td></tr>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<!-- Threat table -->
|
||||
<section class="threats">
|
||||
<h2>Threat Details</h2>
|
||||
<table class="threat-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>File Path</th>
|
||||
<th>Threat Name</th>
|
||||
<th>Type</th>
|
||||
<th>Severity</th>
|
||||
<th>Detector</th>
|
||||
<th>Hash</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{threat_table}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<!-- System info -->
|
||||
<section class="system">
|
||||
<h2>System Information</h2>
|
||||
<table class="info-table">
|
||||
{sys_rows}
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer>
|
||||
Generated by AYN Antivirus v{__version__} — {esc(now.isoformat())}
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
return html
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# File output
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def save_report(content: str, filepath: str | Path) -> None:
|
||||
"""Write *content* to *filepath*, creating parent dirs if needed."""
|
||||
fp = Path(filepath)
|
||||
fp.parent.mkdir(parents=True, exist_ok=True)
|
||||
fp.write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _extract(result: AnyResult) -> tuple:
|
||||
"""Return ``(threats_list, meta_dict)`` from either result type."""
|
||||
if isinstance(result, FullScanResult):
|
||||
sr = result.file_scan
|
||||
threats = list(sr.threats)
|
||||
elif isinstance(result, ScanResult):
|
||||
sr = result
|
||||
threats = list(sr.threats)
|
||||
else:
|
||||
sr = result
|
||||
threats = []
|
||||
|
||||
meta: Dict[str, Any] = {
|
||||
"scan_id": getattr(sr, "scan_id", None),
|
||||
"scan_type": sr.scan_type.value if hasattr(sr, "scan_type") else None,
|
||||
"start_time": sr.start_time.isoformat() if hasattr(sr, "start_time") and sr.start_time else None,
|
||||
"end_time": sr.end_time.isoformat() if hasattr(sr, "end_time") and sr.end_time else None,
|
||||
"duration": sr.duration_seconds if hasattr(sr, "duration_seconds") else 0,
|
||||
"files_scanned": getattr(sr, "files_scanned", 0),
|
||||
"files_skipped": getattr(sr, "files_skipped", 0),
|
||||
}
|
||||
return threats, meta
|
||||
|
||||
|
||||
def _sev_str(threat: ThreatInfo) -> str:
|
||||
"""Return the severity as an uppercase string."""
|
||||
sev = threat.severity
|
||||
if hasattr(sev, "name"):
|
||||
return sev.name
|
||||
return str(sev).upper()
|
||||
|
||||
|
||||
def _severity_counts(threats: List[ThreatInfo]) -> Dict[str, int]:
|
||||
counts: Dict[str, int] = {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0}
|
||||
for t in threats:
|
||||
key = _sev_str(t)
|
||||
counts[key] = counts.get(key, 0) + 1
|
||||
return counts
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Embedded CSS (dark theme)
|
||||
# ---------------------------------------------------------------------------
|
||||
_CSS = """\
|
||||
:root {
|
||||
--bg: #0f1117;
|
||||
--surface: #1a1d27;
|
||||
--border: #2a2d3a;
|
||||
--text: #e0e0e0;
|
||||
--text-dim: #8b8fa3;
|
||||
--accent: #00bcd4;
|
||||
--critical: #ff1744;
|
||||
--high: #ff9100;
|
||||
--medium: #ffea00;
|
||||
--low: #00e676;
|
||||
--clean: #00e676;
|
||||
--infected: #ff1744;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', 'Inter', system-ui, -apple-system, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
header {
|
||||
background: linear-gradient(135deg, #1a1d27 0%, #0d1117 100%);
|
||||
border-bottom: 2px solid var(--accent);
|
||||
text-align: center;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
header .logo {
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
header .subtitle {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.95rem;
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
section {
|
||||
max-width: 1200px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: var(--accent);
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
/* Summary cards */
|
||||
.cards {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
max-width: 1200px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
flex: 1 1 140px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 1.2rem 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.card-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.card-label {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.card-clean .card-value { color: var(--clean); }
|
||||
.card-infected .card-value { color: var(--infected); }
|
||||
.card-critical .card-value { color: var(--critical); }
|
||||
.card-high .card-value { color: var(--high); }
|
||||
.card-medium .card-value { color: var(--medium); }
|
||||
.card-low .card-value { color: var(--low); }
|
||||
|
||||
/* Tables */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.info-table td {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.info-table td:first-child {
|
||||
color: var(--text-dim);
|
||||
width: 180px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.threat-table {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.threat-table thead th {
|
||||
background: #12141c;
|
||||
color: var(--accent);
|
||||
padding: 0.7rem 0.75rem;
|
||||
text-align: left;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.threat-table tbody td {
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.9rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.threat-table tbody tr:hover {
|
||||
background: rgba(0, 188, 212, 0.06);
|
||||
}
|
||||
|
||||
.threat-table .idx { color: var(--text-dim); width: 40px; }
|
||||
.threat-table .hash { font-family: monospace; color: var(--text-dim); font-size: 0.8rem; }
|
||||
.threat-table .empty { text-align: center; color: var(--clean); padding: 2rem; font-size: 1.1rem; }
|
||||
|
||||
/* Severity badges */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.badge-critical { background: rgba(255,23,68,0.15); color: var(--critical); border: 1px solid var(--critical); }
|
||||
.badge-high { background: rgba(255,145,0,0.15); color: var(--high); border: 1px solid var(--high); }
|
||||
.badge-medium { background: rgba(255,234,0,0.12); color: var(--medium); border: 1px solid var(--medium); }
|
||||
.badge-low { background: rgba(0,230,118,0.12); color: var(--low); border: 1px solid var(--low); }
|
||||
|
||||
/* Footer */
|
||||
footer {
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.8rem;
|
||||
padding: 2rem 1rem;
|
||||
border-top: 1px solid var(--border);
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
/* System info */
|
||||
.system { margin-bottom: 2rem; }
|
||||
"""
|
||||
Reference in New Issue
Block a user