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
« 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
12router = APIRouter(prefix="/dashboard", tags=["dashboard"])
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
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
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
57class RecentScansResponse(BaseModel):
58 """Response for recent scans endpoint"""
59 scans: List[ScanActivityItem]
60 total_count: int
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
73class TrendsResponse(BaseModel):
74 """Response for trends endpoint"""
75 trends: List[TrendDataPoint]
76 period_days: int
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
88 - **limit**: Maximum number of vulnerabilities to return (1-100)
89 - **severity**: Filter by severity (critical, high, medium, low)
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")
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()}'"
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 """
140 result = await neon_service.execute(query, user["id"], limit)
142 vulnerabilities = []
143 counts = {"critical": 0, "high": 0, "medium": 0, "low": 0}
145 for row in result:
146 finding = row["finding"]
147 severity_val = finding.get("severity", "LOW").lower()
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)
166 if severity_val in counts:
167 counts[severity_val] += 1
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 """
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 }
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 )
201 except Exception as e:
202 raise HTTPException(
203 status_code=500,
204 detail=f"Error fetching vulnerabilities: {str(e)}"
205 )
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
216 - **limit**: Maximum number of scans to return (1-50)
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")
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 """
240 result = await neon_service.execute(query, user["id"], limit)
242 scans = []
243 for row in result:
244 # Count severities from findings
245 critical_count = 0
246 high_count = 0
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
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)
270 return RecentScansResponse(
271 scans=scans,
272 total_count=len(scans)
273 )
275 except Exception as e:
276 raise HTTPException(
277 status_code=500,
278 detail=f"Error fetching recent scans: {str(e)}"
279 )
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
290 - **days**: Number of days to include in trends (7-90)
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")
298 # Calculate date range
299 end_date = datetime.now()
300 start_date = end_date - timedelta(days=days)
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 """
327 result = await neon_service.execute(query, user["id"], start_date, end_date)
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)
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 ))
356 return TrendsResponse(
357 trends=trends,
358 period_days=days
359 )
361 except Exception as e:
362 raise HTTPException(
363 status_code=500,
364 detail=f"Error fetching trends: {str(e)}"
365 )
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)")
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
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.
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)
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
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")
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 """
421 result = await neon_service.execute(query, request.scan_id, user["id"])
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 )
429 # Extract the specific vulnerability
430 findings = result[0].get("findings", [])
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")
438 if vuln_index >= len(findings):
439 raise HTTPException(status_code=404, detail="Vulnerability not found in scan")
441 vulnerability = findings[vuln_index]
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 )
459 file_path = vulnerability.get("file", "unknown")
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 )
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 )
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 )
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 )
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 )