| """ |
| Report Generator for Shield Agents. |
| |
| Generates HTML reports with interactive visualizations, severity breakdowns, |
| and detailed findings. Also supports JSON and plain text output. |
| """ |
|
|
| import json |
| import logging |
| import os |
| from datetime import datetime |
| from pathlib import Path |
| from typing import Any, Dict, List, Optional |
|
|
| from .sarif import SARIFGenerator |
| from ..utils.helpers import calculate_risk_score |
|
|
| logger = logging.getLogger("shield_agents.report") |
|
|
|
|
| class ReportGenerator: |
| """Generate security scan reports in multiple formats. |
| |
| Supported formats: |
| - HTML: Interactive web report with charts and filtering |
| - SARIF: For GitHub Security tab integration |
| - JSON: Machine-readable structured data |
| """ |
|
|
| def __init__(self, config=None): |
| self.config = config |
| self.sarif_generator = SARIFGenerator(config) |
|
|
| def generate_report( |
| self, |
| findings: List[Dict[str, Any]], |
| target_path: str = ".", |
| output_dir: str = "./shield-reports", |
| formats: Optional[List[str]] = None, |
| scan_stats: Optional[Dict[str, Any]] = None, |
| ) -> Dict[str, str]: |
| """Generate reports in specified formats. |
| |
| Args: |
| findings: List of finding dictionaries. |
| target_path: Path that was scanned. |
| output_dir: Directory to save reports. |
| formats: List of formats to generate (html, sarif, json). |
| scan_stats: Optional scan statistics. |
| |
| Returns: |
| Dictionary of format -> file path. |
| """ |
| if formats is None: |
| formats = ["html", "sarif", "json"] |
|
|
| |
| Path(output_dir).mkdir(parents=True, exist_ok=True) |
|
|
| |
| risk_score = calculate_risk_score(findings) |
| summary = self._build_summary(findings, risk_score, scan_stats) |
|
|
| output_files = {} |
|
|
| for fmt in formats: |
| try: |
| if fmt == "html": |
| path = self._generate_html(findings, summary, target_path, output_dir) |
| output_files["html"] = path |
| elif fmt == "sarif": |
| path = self._generate_sarif(findings, target_path, output_dir) |
| output_files["sarif"] = path |
| elif fmt == "json": |
| path = self._generate_json(findings, summary, target_path, output_dir) |
| output_files["json"] = path |
| else: |
| logger.warning(f"Unknown report format: {fmt}") |
| except Exception as e: |
| logger.error(f"Failed to generate {fmt} report: {e}") |
|
|
| logger.info(f"Generated reports: {list(output_files.keys())}") |
| return output_files |
|
|
| def _build_summary( |
| self, |
| findings: List[Dict[str, Any]], |
| risk_score: int, |
| scan_stats: Optional[Dict[str, Any]] = None, |
| ) -> Dict[str, Any]: |
| """Build summary statistics from findings.""" |
| severity_counts = {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0, "INFO": 0} |
| category_counts: Dict[str, int] = {} |
| source_counts: Dict[str, int] = {} |
| file_counts: Dict[str, int] = {} |
|
|
| for f in findings: |
| sev = f.get("severity", "MEDIUM").upper() |
| severity_counts[sev] = severity_counts.get(sev, 0) + 1 |
|
|
| cat = f.get("category", "unknown") |
| category_counts[cat] = category_counts.get(cat, 0) + 1 |
|
|
| source = f.get("source", f.get("agent", "unknown")) |
| source_counts[source] = source_counts.get(source, 0) + 1 |
|
|
| file = f.get("file", "unknown") |
| file_counts[file] = file_counts.get(file, 0) + 1 |
|
|
| summary = { |
| "total_findings": len(findings), |
| "risk_score": risk_score, |
| "severity_counts": severity_counts, |
| "category_counts": category_counts, |
| "source_counts": source_counts, |
| "most_affected_files": sorted(file_counts.items(), key=lambda x: x[1], reverse=True)[:10], |
| "generated_at": datetime.now().isoformat(), |
| } |
|
|
| if scan_stats: |
| summary["scan_stats"] = scan_stats |
|
|
| return summary |
|
|
| def _generate_sarif(self, findings: List[Dict[str, Any]], target_path: str, output_dir: str) -> str: |
| """Generate SARIF report.""" |
| output_path = os.path.join(output_dir, "results.sarif") |
| return self.sarif_generator.save(findings, output_path, target_path) |
|
|
| def _generate_json( |
| self, |
| findings: List[Dict[str, Any]], |
| summary: Dict[str, Any], |
| target_path: str, |
| output_dir: str, |
| ) -> str: |
| """Generate JSON report.""" |
| output_path = os.path.join(output_dir, "results.json") |
| report = { |
| "shield_agents_version": "2.0.0", |
| "target_path": target_path, |
| "summary": summary, |
| "findings": findings, |
| } |
| with open(output_path, "w") as f: |
| json.dump(report, f, indent=2) |
| return output_path |
|
|
| def _generate_html( |
| self, |
| findings: List[Dict[str, Any]], |
| summary: Dict[str, Any], |
| target_path: str, |
| output_dir: str, |
| ) -> str: |
| """Generate HTML report with interactive visualizations.""" |
| output_path = os.path.join(output_dir, "report.html") |
|
|
| severity_colors = { |
| "CRITICAL": "#dc3545", |
| "HIGH": "#fd7e14", |
| "MEDIUM": "#ffc107", |
| "LOW": "#0dcaf0", |
| "INFO": "#6c757d", |
| } |
|
|
| |
| if summary["risk_score"] >= 75: |
| risk_level = "CRITICAL" |
| risk_color = "#dc3545" |
| elif summary["risk_score"] >= 50: |
| risk_level = "HIGH" |
| risk_color = "#fd7e14" |
| elif summary["risk_score"] >= 25: |
| risk_level = "MEDIUM" |
| risk_color = "#ffc107" |
| else: |
| risk_level = "LOW" |
| risk_color = "#0dcaf0" |
|
|
| |
| total = max(summary["total_findings"], 1) |
| severity_bars = "" |
| for sev in ["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"]: |
| count = summary["severity_counts"].get(sev, 0) |
| pct = (count / total) * 100 |
| color = severity_colors.get(sev, "#6c757d") |
| severity_bars += f""" |
| <div class="severity-row"> |
| <span class="severity-label">{sev}</span> |
| <div class="severity-bar-container"> |
| <div class="severity-bar" style="width: {pct}%; background-color: {color};"></div> |
| </div> |
| <span class="severity-count">{count}</span> |
| </div>""" |
|
|
| |
| findings_rows = "" |
| for i, f in enumerate(findings[:200]): |
| sev = f.get("severity", "MEDIUM").upper() |
| color = severity_colors.get(sev, "#6c757d") |
| title = f.get("title", "Unknown").replace("<", "<").replace(">", ">") |
| desc = f.get("description", "").replace("<", "<").replace(">", ">")[:150] |
| file = f.get("file", "N/A").replace("<", "<").replace(">", ">") |
| line = f.get("line", "N/A") |
| source = f.get("source", f.get("agent", "unknown")) |
| remediation = f.get("remediation", "").replace("<", "<").replace(">", ">")[:200] |
| sources = ", ".join(f.get("sources", [source])) |
| cwe = f.get("cwe", "") |
|
|
| findings_rows += f""" |
| <tr> |
| <td><span class="badge" style="background-color: {color}">{sev}</span></td> |
| <td>{title}</td> |
| <td>{desc}...</td> |
| <td class="mono">{file}</td> |
| <td>{line}</td> |
| <td>{sources}</td> |
| <td>{cwe}</td> |
| <td>{remediation}...</td> |
| </tr>""" |
|
|
| |
| category_items = "" |
| for cat, count in sorted(summary["category_counts"].items(), key=lambda x: x[1], reverse=True): |
| category_items += f'<li><strong>{cat}</strong>: {count}</li>' |
|
|
| |
| file_items = "" |
| for filepath, count in summary["most_affected_files"][:10]: |
| file_items += f'<li><code>{filepath}</code>: {count} findings</li>' |
|
|
| html = f"""<!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>Shield Agents - Security Report</title> |
| <style> |
| * {{ margin: 0; padding: 0; box-sizing: border-box; }} |
| body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0d1117; color: #c9d1d9; line-height: 1.6; }} |
| .container {{ max-width: 1400px; margin: 0 auto; padding: 20px; }} |
| .header {{ background: linear-gradient(135deg, #1a1b26 0%, #24283b 100%); border-radius: 12px; padding: 30px; margin-bottom: 24px; border: 1px solid #30363d; }} |
| .header h1 {{ font-size: 28px; color: #58a6ff; margin-bottom: 8px; }} |
| .header p {{ color: #8b949e; }} |
| .grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 20px; margin-bottom: 24px; }} |
| .card {{ background: #161b22; border-radius: 12px; padding: 24px; border: 1px solid #30363d; }} |
| .card h2 {{ font-size: 18px; color: #58a6ff; margin-bottom: 16px; }} |
| .risk-score {{ font-size: 64px; font-weight: bold; text-align: center; color: {risk_color}; }} |
| .risk-label {{ text-align: center; font-size: 18px; color: {risk_color}; margin-top: 8px; }} |
| .severity-row {{ display: flex; align-items: center; margin-bottom: 8px; }} |
| .severity-label {{ width: 80px; font-weight: 600; font-size: 13px; }} |
| .severity-bar-container {{ flex: 1; height: 20px; background: #21262d; border-radius: 4px; overflow: hidden; }} |
| .severity-bar {{ height: 100%; border-radius: 4px; transition: width 0.5s; }} |
| .severity-count {{ width: 40px; text-align: right; font-weight: 600; margin-left: 8px; }} |
| table {{ width: 100%; border-collapse: collapse; font-size: 13px; }} |
| th {{ background: #21262d; color: #8b949e; padding: 12px 8px; text-align: left; position: sticky; top: 0; }} |
| td {{ padding: 10px 8px; border-bottom: 1px solid #21262d; }} |
| tr:hover {{ background: #1c2128; }} |
| .badge {{ padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: 600; color: white; }} |
| .mono {{ font-family: 'SF Mono', Monaco, monospace; font-size: 12px; }} |
| .stats {{ display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-top: 16px; }} |
| .stat-item {{ text-align: center; }} |
| .stat-value {{ font-size: 24px; font-weight: bold; color: #58a6ff; }} |
| .stat-label {{ font-size: 12px; color: #8b949e; }} |
| .table-container {{ overflow-x: auto; max-height: 600px; overflow-y: auto; }} |
| .footer {{ text-align: center; padding: 20px; color: #484f58; font-size: 12px; }} |
| </style> |
| </head> |
| <body> |
| <div class="container"> |
| <div class="header"> |
| <h1>🛡 Shield Agents Security Report</h1> |
| <p>Target: <code>{target_path}</code> | Generated: {summary['generated_at']}</p> |
| </div> |
| |
| <div class="grid"> |
| <div class="card"> |
| <h2>Risk Score</h2> |
| <div class="risk-score">{summary['risk_score']}</div> |
| <div class="risk-label">{risk_level} RISK</div> |
| <div class="stats"> |
| <div class="stat-item"> |
| <div class="stat-value">{summary['total_findings']}</div> |
| <div class="stat-label">Findings</div> |
| </div> |
| <div class="stat-item"> |
| <div class="stat-value">{summary['severity_counts'].get('CRITICAL', 0)}</div> |
| <div class="stat-label">Critical</div> |
| </div> |
| <div class="stat-item"> |
| <div class="stat-value">{summary['severity_counts'].get('HIGH', 0)}</div> |
| <div class="stat-label">High</div> |
| </div> |
| <div class="stat-item"> |
| <div class="stat-value">{len(summary['category_counts'])}</div> |
| <div class="stat-label">Categories</div> |
| </div> |
| </div> |
| </div> |
| |
| <div class="card"> |
| <h2>Severity Distribution</h2> |
| {severity_bars} |
| </div> |
| |
| <div class="card"> |
| <h2>Categories</h2> |
| <ul style="list-style: none; padding: 0;"> |
| {category_items} |
| </ul> |
| <h2 style="margin-top: 16px;">Most Affected Files</h2> |
| <ul style="list-style: none; padding: 0; font-size: 13px;"> |
| {file_items} |
| </ul> |
| </div> |
| </div> |
| |
| <div class="card"> |
| <h2>Findings Detail ({summary['total_findings']} total)</h2> |
| <div class="table-container"> |
| <table> |
| <thead> |
| <tr> |
| <th>Severity</th> |
| <th>Title</th> |
| <th>Description</th> |
| <th>File</th> |
| <th>Line</th> |
| <th>Source</th> |
| <th>CWE</th> |
| <th>Remediation</th> |
| </tr> |
| </thead> |
| <tbody> |
| {findings_rows} |
| </tbody> |
| </table> |
| </div> |
| </div> |
| |
| <div class="footer"> |
| Generated by Shield Agents v2.0.0 |
| </div> |
| </div> |
| </body> |
| </html>""" |
|
|
| with open(output_path, "w", encoding="utf-8") as f: |
| f.write(html) |
|
|
| return output_path |
|
|