"""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"" f'{idx}' f"{esc(t.path)}" f"{esc(t.threat_name)}" f"{esc(ttype)}" f'{sev}' f"{esc(t.detector_name)}" f'{esc(t.file_hash[:16])}{"…" if len(t.file_hash) > 16 else ""}' f"" ) threat_table = "\n".join(threat_rows) if threat_rows else ( 'No threats detected ✅' ) # --- System info rows --- sys_rows = "" if sys_info: sys_rows = ( f"Hostname{esc(str(sys_info.get('hostname', '')))}" f"Operating System{esc(str(sys_info.get('os_pretty', '')))}" f"Architecture{esc(str(sys_info.get('architecture', '')))}" f"CPUs{sys_info.get('cpu_count', '?')}" f"Memory{esc(str(sys_info.get('memory_total_human', '')))}" f" ({sys_info.get('memory_percent', '?')}% used)" f"Uptime{esc(str(sys_info.get('uptime_human', '')))}" ) html = f"""\ AYN Antivirus — Scan Report
Scan Report — {esc(now.strftime("%Y-%m-%d %H:%M:%S"))}
{meta.get("files_scanned", 0)}
Files Scanned
{total_threats}
Threats Found
{sev_counts.get("CRITICAL", 0)}
Critical
{sev_counts.get("HIGH", 0)}
High
{sev_counts.get("MEDIUM", 0)}
Medium
{sev_counts.get("LOW", 0)}
Low

Scan Details

Scan ID{esc(str(meta.get("scan_id", "N/A")))}
Scan Type{esc(str(meta.get("scan_type", "N/A")))}
Duration{esc(format_duration(meta.get("duration", 0)))}
Files Scanned{meta.get("files_scanned", 0)}
Files Skipped{meta.get("files_skipped", 0)}

Threat Details

{threat_table}
# File Path Threat Name Type Severity Detector Hash

System Information

{sys_rows}
""" 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; } """