Coverage for src/alprina_cli/api/routes/dashboard.py: 28%

134 statements  

« prev     ^ index     » next       coverage.py v7.11.3, created at 2025-11-14 11:27 +0100

1""" 

2Dashboard API Routes - Provides data for the dashboard overview 

3""" 

4from fastapi import APIRouter, HTTPException, Depends, Query, Body 

5from pydantic import BaseModel, Field 

6from typing import List, Dict, Any, Optional 

7from datetime import datetime, timedelta 

8from ..services.neon_service import neon_service 

9from ..middleware.auth import get_current_user 

10from ..services.ai_fix_service import ai_fix_service 

11 

12router = APIRouter(prefix="/dashboard", tags=["dashboard"]) 

13 

14 

15# Response Models 

16class VulnerabilityItem(BaseModel): 

17 """Individual vulnerability item""" 

18 id: str 

19 title: str 

20 severity: str # critical, high, medium, low 

21 cvss: Optional[float] = None 

22 cve: Optional[str] = None 

23 package: Optional[str] = None 

24 version: Optional[str] = None 

25 fixed_version: Optional[str] = None 

26 description: Optional[str] = None 

27 file_path: Optional[str] = None 

28 line_number: Optional[int] = None 

29 scan_id: str 

30 created_at: str 

31 

32 

33class VulnerabilitiesResponse(BaseModel): 

34 """Response for vulnerabilities endpoint""" 

35 vulnerabilities: List[VulnerabilityItem] 

36 total_count: int 

37 critical_count: int 

38 high_count: int 

39 medium_count: int 

40 low_count: int 

41 

42 

43class ScanActivityItem(BaseModel): 

44 """Recent scan activity item""" 

45 id: str 

46 type: str # 'scan' 

47 title: str 

48 timestamp: str 

49 status: str # completed, failed, running 

50 findings_count: int 

51 critical_count: int 

52 high_count: int 

53 scan_type: str 

54 workflow_mode: str 

55 

56 

57class RecentScansResponse(BaseModel): 

58 """Response for recent scans endpoint""" 

59 scans: List[ScanActivityItem] 

60 total_count: int 

61 

62 

63class TrendDataPoint(BaseModel): 

64 """Trend data point for a time period""" 

65 period: str 

66 critical: int 

67 high: int 

68 medium: int 

69 low: int 

70 total: int 

71 

72 

73class TrendsResponse(BaseModel): 

74 """Response for trends endpoint""" 

75 trends: List[TrendDataPoint] 

76 period_days: int 

77 

78 

79@router.get("/vulnerabilities", response_model=VulnerabilitiesResponse) 

80async def get_vulnerabilities( 

81 limit: int = Query(default=10, ge=1, le=100), 

82 severity: Optional[str] = Query(default=None, regex="^(critical|high|medium|low)$"), 

83 user: dict = Depends(get_current_user) 

84): 

85 """ 

86 Get vulnerabilities for the dashboard 

87  

88 - **limit**: Maximum number of vulnerabilities to return (1-100) 

89 - **severity**: Filter by severity (critical, high, medium, low) 

90  

91 Returns the most recent vulnerabilities sorted by severity and date. 

92 """ 

93 try: 

94 if not neon_service.is_enabled(): 

95 raise HTTPException(status_code=503, detail="Database not configured") 

96 

97 # Query to get vulnerabilities from scans 

98 # Since vulnerabilities are stored in the findings JSONB field, we need to extract them 

99 severity_filter = "" 

100 if severity: 

101 severity_filter = f"AND finding->>'severity' = '{severity.upper()}'" 

102 

103 query = f""" 

104 WITH vulnerability_findings AS ( 

105 SELECT  

106 s.id as scan_id, 

107 s.created_at, 

108 jsonb_array_elements(s.findings) as finding 

109 FROM scans s 

110 WHERE s.user_id = $1 

111 AND s.status = 'completed' 

112 AND s.findings IS NOT NULL 

113 AND jsonb_array_length(s.findings) > 0 

114 ), 

115 ranked_vulnerabilities AS ( 

116 SELECT  

117 scan_id, 

118 finding, 

119 created_at, 

120 CASE finding->>'severity' 

121 WHEN 'CRITICAL' THEN 1 

122 WHEN 'HIGH' THEN 2 

123 WHEN 'MEDIUM' THEN 3 

124 WHEN 'LOW' THEN 4 

125 ELSE 5 

126 END as severity_rank 

127 FROM vulnerability_findings 

128 WHERE finding->>'type' IS NOT NULL 

129 {severity_filter} 

130 ) 

131 SELECT  

132 scan_id::text, 

133 finding, 

134 created_at 

135 FROM ranked_vulnerabilities 

136 ORDER BY severity_rank ASC, created_at DESC 

137 LIMIT $2 

138 """ 

139 

140 result = await neon_service.execute(query, user["id"], limit) 

141 

142 vulnerabilities = [] 

143 counts = {"critical": 0, "high": 0, "medium": 0, "low": 0} 

144 

145 for row in result: 

146 finding = row["finding"] 

147 severity_val = finding.get("severity", "LOW").lower() 

148 

149 vuln = VulnerabilityItem( 

150 id=f"{row['scan_id']}-{len(vulnerabilities)}", 

151 title=finding.get("title", finding.get("type", "Unknown Vulnerability")), 

152 severity=severity_val, 

153 cvss=finding.get("cvss"), 

154 cve=finding.get("cve"), 

155 package=finding.get("package"), 

156 version=finding.get("version"), 

157 fixed_version=finding.get("fixed_version"), 

158 description=finding.get("description", finding.get("message")), 

159 file_path=finding.get("file"), 

160 line_number=finding.get("line"), 

161 scan_id=row["scan_id"], 

162 created_at=row["created_at"].isoformat() 

163 ) 

164 vulnerabilities.append(vuln) 

165 

166 if severity_val in counts: 

167 counts[severity_val] += 1 

168 

169 # Get total counts across all scans 

170 count_query = """ 

171 WITH all_findings AS ( 

172 SELECT jsonb_array_elements(findings) as finding 

173 FROM scans 

174 WHERE user_id = $1 

175 AND status = 'completed' 

176 AND findings IS NOT NULL 

177 ) 

178 SELECT  

179 COUNT(*) FILTER (WHERE finding->>'severity' = 'CRITICAL') as critical, 

180 COUNT(*) FILTER (WHERE finding->>'severity' = 'HIGH') as high, 

181 COUNT(*) FILTER (WHERE finding->>'severity' = 'MEDIUM') as medium, 

182 COUNT(*) FILTER (WHERE finding->>'severity' = 'LOW') as low, 

183 COUNT(*) as total 

184 FROM all_findings 

185 """ 

186 

187 count_result = await neon_service.execute(count_query, user["id"]) 

188 total_counts = count_result[0] if count_result else { 

189 "critical": 0, "high": 0, "medium": 0, "low": 0, "total": 0 

190 } 

191 

192 return VulnerabilitiesResponse( 

193 vulnerabilities=vulnerabilities, 

194 total_count=total_counts.get("total", 0), 

195 critical_count=total_counts.get("critical", 0), 

196 high_count=total_counts.get("high", 0), 

197 medium_count=total_counts.get("medium", 0), 

198 low_count=total_counts.get("low", 0) 

199 ) 

200 

201 except Exception as e: 

202 raise HTTPException( 

203 status_code=500, 

204 detail=f"Error fetching vulnerabilities: {str(e)}" 

205 ) 

206 

207 

208@router.get("/scans/recent", response_model=RecentScansResponse) 

209async def get_recent_scans( 

210 limit: int = Query(default=5, ge=1, le=50), 

211 user: dict = Depends(get_current_user) 

212): 

213 """ 

214 Get recent scan activity for the dashboard 

215  

216 - **limit**: Maximum number of scans to return (1-50) 

217  

218 Returns recent scans with their finding counts. 

219 """ 

220 try: 

221 if not neon_service.is_enabled(): 

222 raise HTTPException(status_code=503, detail="Database not configured") 

223 

224 query = """ 

225 SELECT  

226 id::text, 

227 scan_type, 

228 workflow_mode, 

229 status, 

230 findings_count, 

231 findings, 

232 created_at, 

233 completed_at 

234 FROM scans 

235 WHERE user_id = $1 

236 ORDER BY created_at DESC 

237 LIMIT $2 

238 """ 

239 

240 result = await neon_service.execute(query, user["id"], limit) 

241 

242 scans = [] 

243 for row in result: 

244 # Count severities from findings 

245 critical_count = 0 

246 high_count = 0 

247 

248 if row["findings"]: 

249 for finding in row["findings"]: 

250 severity = finding.get("severity", "").upper() 

251 if severity == "CRITICAL": 

252 critical_count += 1 

253 elif severity == "HIGH": 

254 high_count += 1 

255 

256 scan = ScanActivityItem( 

257 id=row["id"], 

258 type="scan", 

259 title=f"{row['scan_type'].title()} Scan", 

260 timestamp=row["created_at"].isoformat(), 

261 status=row["status"], 

262 findings_count=row["findings_count"] or 0, 

263 critical_count=critical_count, 

264 high_count=high_count, 

265 scan_type=row["scan_type"], 

266 workflow_mode=row["workflow_mode"] 

267 ) 

268 scans.append(scan) 

269 

270 return RecentScansResponse( 

271 scans=scans, 

272 total_count=len(scans) 

273 ) 

274 

275 except Exception as e: 

276 raise HTTPException( 

277 status_code=500, 

278 detail=f"Error fetching recent scans: {str(e)}" 

279 ) 

280 

281 

282@router.get("/analytics/trends", response_model=TrendsResponse) 

283async def get_vulnerability_trends( 

284 days: int = Query(default=30, ge=7, le=90), 

285 user: dict = Depends(get_current_user) 

286): 

287 """ 

288 Get vulnerability trends over time 

289  

290 - **days**: Number of days to include in trends (7-90) 

291  

292 Returns vulnerability counts grouped by week. 

293 """ 

294 try: 

295 if not neon_service.is_enabled(): 

296 raise HTTPException(status_code=503, detail="Database not configured") 

297 

298 # Calculate date range 

299 end_date = datetime.now() 

300 start_date = end_date - timedelta(days=days) 

301 

302 # Query to get weekly vulnerability trends 

303 query = """ 

304 WITH weekly_findings AS ( 

305 SELECT  

306 date_trunc('week', s.created_at) as week_start, 

307 jsonb_array_elements(s.findings) as finding 

308 FROM scans s 

309 WHERE s.user_id = $1 

310 AND s.status = 'completed' 

311 AND s.created_at >= $2 

312 AND s.created_at <= $3 

313 AND s.findings IS NOT NULL 

314 ) 

315 SELECT  

316 week_start, 

317 COUNT(*) FILTER (WHERE finding->>'severity' = 'CRITICAL') as critical, 

318 COUNT(*) FILTER (WHERE finding->>'severity' = 'HIGH') as high, 

319 COUNT(*) FILTER (WHERE finding->>'severity' = 'MEDIUM') as medium, 

320 COUNT(*) FILTER (WHERE finding->>'severity' = 'LOW') as low, 

321 COUNT(*) as total 

322 FROM weekly_findings 

323 GROUP BY week_start 

324 ORDER BY week_start ASC 

325 """ 

326 

327 result = await neon_service.execute(query, user["id"], start_date, end_date) 

328 

329 trends = [] 

330 for row in result: 

331 trend = TrendDataPoint( 

332 period=row["week_start"].strftime("Week of %b %d"), 

333 critical=row.get("critical", 0), 

334 high=row.get("high", 0), 

335 medium=row.get("medium", 0), 

336 low=row.get("low", 0), 

337 total=row.get("total", 0) 

338 ) 

339 trends.append(trend) 

340 

341 # If no data, return empty trends for the requested period 

342 if not trends: 

343 # Create empty data points for each week 

344 weeks = days // 7 

345 for i in range(weeks): 

346 week_start = start_date + timedelta(weeks=i) 

347 trends.append(TrendDataPoint( 

348 period=week_start.strftime("Week of %b %d"), 

349 critical=0, 

350 high=0, 

351 medium=0, 

352 low=0, 

353 total=0 

354 )) 

355 

356 return TrendsResponse( 

357 trends=trends, 

358 period_days=days 

359 ) 

360 

361 except Exception as e: 

362 raise HTTPException( 

363 status_code=500, 

364 detail=f"Error fetching trends: {str(e)}" 

365 ) 

366 

367 

368# AI Fix Endpoint 

369class AIFixRequest(BaseModel): 

370 """Request model for AI fix generation""" 

371 vulnerability_id: str = Field(..., description="Vulnerability ID (scan_id-index)") 

372 scan_id: str = Field(..., description="Scan ID containing the vulnerability") 

373 code_context: Optional[str] = Field(None, description="Code context (auto-fetched if not provided)") 

374 

375 

376class AIFixResponse(BaseModel): 

377 """Response model for AI fix generation""" 

378 fixed_code: str 

379 explanation: str 

380 diff: Optional[str] = None 

381 confidence: float 

382 provider: str # "kimi" or "openai" 

383 is_security_fix: bool 

384 security_principle: Optional[str] = None 

385 

386 

387@router.post("/ai-fix", response_model=AIFixResponse) 

388async def generate_ai_fix( 

389 request: AIFixRequest = Body(...), 

390 user: dict = Depends(get_current_user) 

391): 

392 """ 

393 Generate an AI-powered security fix for a vulnerability. 

394  

395 - **vulnerability_id**: ID of the vulnerability (format: scan_id-index) 

396 - **scan_id**: ID of the scan containing the vulnerability 

397 - **code_context**: Optional code context (will be fetched from scan if not provided) 

398  

399 **IMPORTANT**: This endpoint ONLY generates fixes for security vulnerabilities. 

400 It does NOT: 

401 - Generate new features 

402 - Refactor non-security code  

403 - Act as a general code assistant 

404  

405 Token limits are enforced to control costs. 

406 """ 

407 try: 

408 if not neon_service.is_enabled(): 

409 raise HTTPException(status_code=503, detail="Database not configured") 

410 

411 # Fetch vulnerability details from database 

412 query = """ 

413 SELECT  

414 s.findings, 

415 s.metadata 

416 FROM scans s 

417 WHERE s.id = $1 

418 AND s.user_id = $2 

419 """ 

420 

421 result = await neon_service.execute(query, request.scan_id, user["id"]) 

422 

423 if not result: 

424 raise HTTPException( 

425 status_code=404, 

426 detail="Scan not found or you don't have permission to access it" 

427 ) 

428 

429 # Extract the specific vulnerability 

430 findings = result[0].get("findings", []) 

431 

432 # Parse vulnerability_id to get index 

433 try: 

434 vuln_index = int(request.vulnerability_id.split("-")[-1]) 

435 except: 

436 raise HTTPException(status_code=400, detail="Invalid vulnerability_id format") 

437 

438 if vuln_index >= len(findings): 

439 raise HTTPException(status_code=404, detail="Vulnerability not found in scan") 

440 

441 vulnerability = findings[vuln_index] 

442 

443 # Get code context (use provided or fetch from vulnerability) 

444 code_context = request.code_context 

445 if not code_context: 

446 # Try to get context from vulnerability 

447 file_content = vulnerability.get("file_content") 

448 if file_content: 

449 code_context = file_content 

450 else: 

451 # Get snippet around the vulnerable line 

452 code_context = vulnerability.get("code", "") 

453 if not code_context: 

454 raise HTTPException( 

455 status_code=400, 

456 detail="No code context available. Please provide code_context in the request." 

457 ) 

458 

459 file_path = vulnerability.get("file", "unknown") 

460 

461 # Generate fix using AI service 

462 fix_result = await ai_fix_service.generate_security_fix( 

463 vulnerability=vulnerability, 

464 code_context=code_context, 

465 file_path=file_path 

466 ) 

467 

468 # Check if fix was successful 

469 if "error" in fix_result: 

470 raise HTTPException( 

471 status_code=400, 

472 detail=fix_result.get("message", fix_result["error"]) 

473 ) 

474 

475 if not fix_result.get("is_security_fix"): 

476 raise HTTPException( 

477 status_code=400, 

478 detail="This service only generates fixes for security vulnerabilities" 

479 ) 

480 

481 return AIFixResponse( 

482 fixed_code=fix_result["fixed_code"], 

483 explanation=fix_result.get("explanation", "No explanation provided"), 

484 diff=fix_result.get("diff"), 

485 confidence=fix_result.get("confidence", 0.0), 

486 provider=fix_result.get("provider", "unknown"), 

487 is_security_fix=True, 

488 security_principle=fix_result.get("security_principle") 

489 ) 

490 

491 except HTTPException: 

492 raise 

493 except Exception as e: 

494 raise HTTPException( 

495 status_code=500, 

496 detail=f"Error generating AI fix: {str(e)}" 

497 )