Coverage for src/alprina_cli/reporting.py: 19%
98 statements
« prev ^ index » next coverage.py v7.11.3, created at 2025-11-14 11:27 +0100
« prev ^ index » next coverage.py v7.11.3, created at 2025-11-14 11:27 +0100
1"""
2Reporting engine for Alprina CLI.
3Generates structured reports in multiple formats (JSONL, HTML, PDF).
4"""
6import json
7import os
8import datetime
9from pathlib import Path
10from typing import Dict, Any, Optional
11from rich.console import Console
12from rich.panel import Panel
14console = Console()
16ALPRINA_DIR = Path.home() / ".alprina"
17OUTPUT_DIR = ALPRINA_DIR / "out"
18EVENTS_FILE = OUTPUT_DIR / "events.jsonl"
21def ensure_output_dir():
22 """Ensure output directory exists."""
23 OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
26def write_event(event: Dict[str, Any]):
27 """
28 Write an event to the JSONL log file.
30 Args:
31 event: Event dictionary to log
32 """
33 ensure_output_dir()
35 # Add timestamp
36 event["_timestamp"] = datetime.datetime.utcnow().isoformat()
38 # Append to JSONL file
39 with open(EVENTS_FILE, "a") as f:
40 f.write(json.dumps(event) + "\n")
43def load_events() -> list:
44 """Load all events from the JSONL log file."""
45 if not EVENTS_FILE.exists():
46 return []
48 events = []
49 with open(EVENTS_FILE, "r") as f:
50 for line in f:
51 try:
52 events.append(json.loads(line.strip()))
53 except json.JSONDecodeError:
54 continue
56 return events
59def report_command(format: str = "html", output: Optional[Path] = None):
60 """
61 Generate a security report from scan results.
63 Args:
64 format: Report format (html, pdf, json)
65 output: Output file path
66 """
67 console.print(Panel(
68 f"📊 Generating report in [bold]{format.upper()}[/bold] format",
69 title="Report Generation"
70 ))
72 # Load events
73 events = load_events()
75 if not events:
76 console.print("[yellow]No scan events found. Run some scans first![/yellow]")
77 return
79 # Filter scan events
80 scan_events = [e for e in events if e.get("type") in ["scan", "recon"]]
82 if not scan_events:
83 console.print("[yellow]No scan results found.[/yellow]")
84 return
86 console.print(f"Found {len(scan_events)} scan events")
88 # Generate report based on format
89 if format == "json":
90 output_path = _generate_json_report(scan_events, output)
91 elif format == "html":
92 output_path = _generate_html_report(scan_events, output)
93 elif format == "pdf":
94 output_path = _generate_pdf_report(scan_events, output)
95 else:
96 console.print(f"[red]Unsupported format: {format}[/red]")
97 return
99 console.print(f"[green]✓ Report generated:[/green] {output_path}")
102def _generate_json_report(events: list, output: Optional[Path] = None) -> Path:
103 """Generate JSON report."""
104 if output is None:
105 output = OUTPUT_DIR / f"report_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
107 ensure_output_dir()
109 report = {
110 "generated_at": datetime.datetime.utcnow().isoformat(),
111 "tool": "Alprina CLI",
112 "version": "0.1.0",
113 "total_scans": len(events),
114 "events": events
115 }
117 with open(output, "w") as f:
118 json.dump(report, f, indent=2)
120 return output
123def _generate_html_report(events: list, output: Optional[Path] = None) -> Path:
124 """Generate HTML report."""
125 if output is None:
126 output = OUTPUT_DIR / f"report_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.html"
128 ensure_output_dir()
130 # Extract all findings
131 all_findings = []
132 for event in events:
133 findings = event.get("findings", [])
134 if isinstance(findings, list):
135 all_findings.extend(findings)
137 # Count by severity
138 severity_counts = {}
139 for finding in all_findings:
140 severity = finding.get("severity", "UNKNOWN")
141 severity_counts[severity] = severity_counts.get(severity, 0) + 1
143 html_content = _create_html_template(events, all_findings, severity_counts)
145 with open(output, "w") as f:
146 f.write(html_content)
148 return output
151def _generate_pdf_report(events: list, output: Optional[Path] = None) -> Path:
152 """Generate PDF report."""
153 # First generate HTML
154 html_path = _generate_html_report(events, None)
156 if output is None:
157 output = OUTPUT_DIR / f"report_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
159 # Convert HTML to PDF using weasyprint
160 try:
161 from weasyprint import HTML
162 HTML(filename=str(html_path)).write_pdf(output)
163 except ImportError:
164 console.print("[yellow]weasyprint not installed. Install with: pip install weasyprint[/yellow]")
165 console.print(f"[yellow]HTML report available at: {html_path}[/yellow]")
166 return html_path
168 return output
171def _create_html_template(events: list, findings: list, severity_counts: dict) -> str:
172 """Create HTML report template."""
173 severity_colors = {
174 "CRITICAL": "#dc3545",
175 "HIGH": "#fd7e14",
176 "MEDIUM": "#ffc107",
177 "LOW": "#0dcaf0",
178 "INFO": "#6c757d"
179 }
181 findings_html = ""
182 for finding in findings:
183 severity = finding.get("severity", "UNKNOWN")
184 color = severity_colors.get(severity, "#6c757d")
186 findings_html += f"""
187 <tr>
188 <td style="color: {color}; font-weight: bold;">{severity}</td>
189 <td>{finding.get('type', 'Unknown')}</td>
190 <td>{finding.get('description', 'N/A')}</td>
191 <td><code>{finding.get('location', 'N/A')}</code></td>
192 </tr>
193 """
195 severity_summary_html = ""
196 for severity, count in sorted(severity_counts.items()):
197 color = severity_colors.get(severity, "#6c757d")
198 severity_summary_html += f"""
199 <div style="margin: 10px 0;">
200 <span style="color: {color}; font-weight: bold;">{severity}:</span> {count}
201 </div>
202 """
204 return f"""
205 <!DOCTYPE html>
206 <html>
207 <head>
208 <meta charset="UTF-8">
209 <title>Alprina Security Report</title>
210 <style>
211 body {{
212 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
213 margin: 40px;
214 background: #f5f5f5;
215 }}
216 .container {{
217 max-width: 1200px;
218 margin: 0 auto;
219 background: white;
220 padding: 40px;
221 border-radius: 8px;
222 box-shadow: 0 2px 4px rgba(0,0,0,0.1);
223 }}
224 h1 {{
225 color: #333;
226 border-bottom: 3px solid #007bff;
227 padding-bottom: 10px;
228 }}
229 h2 {{
230 color: #555;
231 margin-top: 30px;
232 }}
233 table {{
234 width: 100%;
235 border-collapse: collapse;
236 margin: 20px 0;
237 }}
238 th, td {{
239 text-align: left;
240 padding: 12px;
241 border-bottom: 1px solid #ddd;
242 }}
243 th {{
244 background: #f8f9fa;
245 font-weight: 600;
246 }}
247 .summary {{
248 background: #f8f9fa;
249 padding: 20px;
250 border-radius: 4px;
251 margin: 20px 0;
252 }}
253 .footer {{
254 margin-top: 40px;
255 padding-top: 20px;
256 border-top: 1px solid #ddd;
257 color: #6c757d;
258 font-size: 14px;
259 }}
260 </style>
261 </head>
262 <body>
263 <div class="container">
264 <h1>🛡️ Alprina Security Report</h1>
266 <div class="summary">
267 <h2>Summary</h2>
268 <p><strong>Generated:</strong> {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>
269 <p><strong>Total Scans:</strong> {len(events)}</p>
270 <p><strong>Total Findings:</strong> {len(findings)}</p>
272 <h3>Findings by Severity</h3>
273 {severity_summary_html}
274 </div>
276 <h2>Detailed Findings</h2>
277 <table>
278 <thead>
279 <tr>
280 <th>Severity</th>
281 <th>Type</th>
282 <th>Description</th>
283 <th>Location</th>
284 </tr>
285 </thead>
286 <tbody>
287 {findings_html}
288 </tbody>
289 </table>
291 <div class="footer">
292 <p>Generated by Alprina CLI v0.1.0</p>
293 <p>© 2025 Alprina. All rights reserved.</p>
294 </div>
295 </div>
296 </body>
297 </html>
298 """