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

1""" 

2Reporting engine for Alprina CLI. 

3Generates structured reports in multiple formats (JSONL, HTML, PDF). 

4""" 

5 

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 

13 

14console = Console() 

15 

16ALPRINA_DIR = Path.home() / ".alprina" 

17OUTPUT_DIR = ALPRINA_DIR / "out" 

18EVENTS_FILE = OUTPUT_DIR / "events.jsonl" 

19 

20 

21def ensure_output_dir(): 

22 """Ensure output directory exists.""" 

23 OUTPUT_DIR.mkdir(parents=True, exist_ok=True) 

24 

25 

26def write_event(event: Dict[str, Any]): 

27 """ 

28 Write an event to the JSONL log file. 

29 

30 Args: 

31 event: Event dictionary to log 

32 """ 

33 ensure_output_dir() 

34 

35 # Add timestamp 

36 event["_timestamp"] = datetime.datetime.utcnow().isoformat() 

37 

38 # Append to JSONL file 

39 with open(EVENTS_FILE, "a") as f: 

40 f.write(json.dumps(event) + "\n") 

41 

42 

43def load_events() -> list: 

44 """Load all events from the JSONL log file.""" 

45 if not EVENTS_FILE.exists(): 

46 return [] 

47 

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 

55 

56 return events 

57 

58 

59def report_command(format: str = "html", output: Optional[Path] = None): 

60 """ 

61 Generate a security report from scan results. 

62 

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 )) 

71 

72 # Load events 

73 events = load_events() 

74 

75 if not events: 

76 console.print("[yellow]No scan events found. Run some scans first![/yellow]") 

77 return 

78 

79 # Filter scan events 

80 scan_events = [e for e in events if e.get("type") in ["scan", "recon"]] 

81 

82 if not scan_events: 

83 console.print("[yellow]No scan results found.[/yellow]") 

84 return 

85 

86 console.print(f"Found {len(scan_events)} scan events") 

87 

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 

98 

99 console.print(f"[green]✓ Report generated:[/green] {output_path}") 

100 

101 

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" 

106 

107 ensure_output_dir() 

108 

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 } 

116 

117 with open(output, "w") as f: 

118 json.dump(report, f, indent=2) 

119 

120 return output 

121 

122 

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" 

127 

128 ensure_output_dir() 

129 

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) 

136 

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 

142 

143 html_content = _create_html_template(events, all_findings, severity_counts) 

144 

145 with open(output, "w") as f: 

146 f.write(html_content) 

147 

148 return output 

149 

150 

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) 

155 

156 if output is None: 

157 output = OUTPUT_DIR / f"report_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf" 

158 

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 

167 

168 return output 

169 

170 

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 } 

180 

181 findings_html = "" 

182 for finding in findings: 

183 severity = finding.get("severity", "UNKNOWN") 

184 color = severity_colors.get(severity, "#6c757d") 

185 

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 """ 

194 

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 """ 

203 

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> 

265 

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> 

271 

272 <h3>Findings by Severity</h3> 

273 {severity_summary_html} 

274 </div> 

275 

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> 

290 

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 """