main
py 984 lines 36 KB
Raw
1 from datetime import datetime
2 from datetime import timezone
3 from typing import Optional
4
5 from fastapi import APIRouter
6 from fastapi import Depends
7 from fastapi import HTTPException
8 from fastapi import Path
9 from fastapi import Query
10 from fastapi import Security
11 from loguru import logger
12 from sqlalchemy import select
13 from sqlalchemy.ext.asyncio import AsyncSession
14
15 from app.auth.utils import AuthHandler
16 from app.db.db_session import get_db
17 from app.integrations.github_audit.model import GitHubAuditBaseline
18 from app.integrations.github_audit.model import GitHubAuditCheckExclusion
19 from app.integrations.github_audit.model import GitHubAuditConfig
20 from app.integrations.github_audit.model import GitHubAuditReport
21 from app.integrations.github_audit.schema.github_audit import AvailableChecksResponse
22 from app.integrations.github_audit.schema.github_audit import GitHubAuditBaselineCreate
23 from app.integrations.github_audit.schema.github_audit import (
24 GitHubAuditBaselineResponse,
25 )
26 from app.integrations.github_audit.schema.github_audit import GitHubAuditConfigCreate
27 from app.integrations.github_audit.schema.github_audit import GitHubAuditConfigResponse
28 from app.integrations.github_audit.schema.github_audit import GitHubAuditConfigUpdate
29 from app.integrations.github_audit.schema.github_audit import GitHubAuditExclusionCreate
30 from app.integrations.github_audit.schema.github_audit import (
31 GitHubAuditExclusionResponse,
32 )
33 from app.integrations.github_audit.schema.github_audit import GitHubAuditExclusionUpdate
34 from app.integrations.github_audit.schema.github_audit import (
35 GitHubAuditReportListResponse,
36 )
37 from app.integrations.github_audit.schema.github_audit import GitHubAuditReportResponse
38 from app.integrations.github_audit.schema.github_audit import GitHubAuditRequest
39 from app.integrations.github_audit.schema.github_audit import GitHubAuditResponse
40 from app.integrations.github_audit.schema.github_audit import GitHubAuditSummaryResponse
41 from app.integrations.github_audit.services.github_audit import run_github_audit
42 from app.integrations.github_audit.services.github_audit import run_github_audit_summary
43
44 github_audit_router = APIRouter()
45
46
47 # ==================== Configuration Routes ====================
48
49
50 @github_audit_router.post(
51 "/config",
52 response_model=GitHubAuditConfigResponse,
53 description="Create a new GitHub Audit configuration for a customer",
54 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
55 )
56 async def create_config(
57 config: GitHubAuditConfigCreate,
58 session: AsyncSession = Depends(get_db),
59 ) -> GitHubAuditConfigResponse:
60 """Create a new GitHub Audit configuration."""
61 logger.info(f"Creating GitHub Audit config for customer: {config.customer_code}")
62
63 # Check if config already exists for this customer + organization
64 existing = await session.execute(
65 select(GitHubAuditConfig).where(
66 GitHubAuditConfig.customer_code == config.customer_code,
67 GitHubAuditConfig.organization == config.organization,
68 ),
69 )
70 if existing.scalar_one_or_none():
71 raise HTTPException(
72 status_code=400,
73 detail=f"Configuration already exists for customer '{config.customer_code}' and organization '{config.organization}'",
74 )
75
76 db_config = GitHubAuditConfig(
77 customer_code=config.customer_code,
78 github_token=config.github_token, # TODO: Encrypt before storing
79 organization=config.organization,
80 token_type=config.token_type,
81 token_expires_at=config.token_expires_at,
82 enabled=config.enabled,
83 auto_audit_enabled=config.auto_audit_enabled,
84 audit_schedule_cron=config.audit_schedule_cron,
85 include_repos=config.include_repos,
86 include_workflows=config.include_workflows,
87 include_members=config.include_members,
88 include_archived_repos=config.include_archived_repos,
89 repo_filter_mode=config.repo_filter_mode,
90 repo_filter_list=config.repo_filter_list,
91 notify_on_critical=config.notify_on_critical,
92 notify_on_high=config.notify_on_high,
93 notification_webhook_url=config.notification_webhook_url,
94 notification_email=config.notification_email,
95 minimum_passing_score=config.minimum_passing_score,
96 created_by=config.created_by,
97 )
98
99 session.add(db_config)
100 await session.commit()
101 await session.refresh(db_config)
102
103 return GitHubAuditConfigResponse(
104 success=True,
105 message="GitHub Audit configuration created successfully",
106 config=db_config,
107 )
108
109
110 @github_audit_router.get(
111 "/config",
112 response_model=GitHubAuditConfigResponse,
113 description="Get GitHub Audit configurations",
114 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
115 )
116 async def get_configs(
117 customer_code: Optional[str] = Query(None, description="Filter by customer code"),
118 session: AsyncSession = Depends(get_db),
119 ) -> GitHubAuditConfigResponse:
120 """Get all GitHub Audit configurations, optionally filtered by customer."""
121 query = select(GitHubAuditConfig)
122
123 if customer_code:
124 query = query.where(GitHubAuditConfig.customer_code == customer_code)
125
126 result = await session.execute(query)
127 configs = result.scalars().all()
128
129 # Mask tokens in response
130 for config in configs:
131 if config.github_token:
132 config.github_token = "***" + config.github_token[-4:] if len(config.github_token) > 4 else "***"
133
134 return GitHubAuditConfigResponse(
135 success=True,
136 message=f"Found {len(configs)} configuration(s)",
137 configs=list(configs),
138 )
139
140
141 @github_audit_router.get(
142 "/config/{config_id}",
143 response_model=GitHubAuditConfigResponse,
144 description="Get a specific GitHub Audit configuration",
145 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
146 )
147 async def get_config(
148 config_id: int = Path(..., description="Configuration ID"),
149 session: AsyncSession = Depends(get_db),
150 ) -> GitHubAuditConfigResponse:
151 """Get a specific GitHub Audit configuration by ID."""
152 result = await session.execute(
153 select(GitHubAuditConfig).where(GitHubAuditConfig.id == config_id),
154 )
155 config = result.scalar_one_or_none()
156
157 if not config:
158 raise HTTPException(status_code=404, detail="Configuration not found")
159
160 # Mask token
161 if config.github_token:
162 config.github_token = "***" + config.github_token[-4:] if len(config.github_token) > 4 else "***"
163
164 return GitHubAuditConfigResponse(
165 success=True,
166 message="Configuration retrieved successfully",
167 config=config,
168 )
169
170
171 @github_audit_router.put(
172 "/config/{config_id}",
173 response_model=GitHubAuditConfigResponse,
174 description="Update a GitHub Audit configuration",
175 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
176 )
177 async def update_config(
178 config_update: GitHubAuditConfigUpdate,
179 config_id: int = Path(..., description="Configuration ID"),
180 session: AsyncSession = Depends(get_db),
181 ) -> GitHubAuditConfigResponse:
182 """Update an existing GitHub Audit configuration."""
183 result = await session.execute(
184 select(GitHubAuditConfig).where(GitHubAuditConfig.id == config_id),
185 )
186 config = result.scalar_one_or_none()
187
188 if not config:
189 raise HTTPException(status_code=404, detail="Configuration not found")
190
191 # Update fields that were provided
192 update_data = config_update.model_dump(exclude_unset=True)
193
194 for field, value in update_data.items():
195 if value is not None:
196 setattr(config, field, value)
197
198 config.updated_at = datetime.now(timezone.utc)
199
200 await session.commit()
201 await session.refresh(config)
202
203 # Mask token
204 if config.github_token:
205 config.github_token = "***" + config.github_token[-4:] if len(config.github_token) > 4 else "***"
206
207 return GitHubAuditConfigResponse(
208 success=True,
209 message="Configuration updated successfully",
210 config=config,
211 )
212
213
214 @github_audit_router.delete(
215 "/config/{config_id}",
216 description="Delete a GitHub Audit configuration",
217 dependencies=[Security(AuthHandler().require_any_scope("admin"))],
218 )
219 async def delete_config(
220 config_id: int = Path(..., description="Configuration ID"),
221 session: AsyncSession = Depends(get_db),
222 ):
223 """Delete a GitHub Audit configuration and all associated data."""
224 result = await session.execute(
225 select(GitHubAuditConfig).where(GitHubAuditConfig.id == config_id),
226 )
227 config = result.scalar_one_or_none()
228
229 if not config:
230 raise HTTPException(status_code=404, detail="Configuration not found")
231
232 # Delete associated records
233 await session.execute(
234 select(GitHubAuditReport).where(GitHubAuditReport.config_id == config_id),
235 )
236 await session.execute(
237 select(GitHubAuditCheckExclusion).where(GitHubAuditCheckExclusion.config_id == config_id),
238 )
239 await session.execute(
240 select(GitHubAuditBaseline).where(GitHubAuditBaseline.config_id == config_id),
241 )
242
243 await session.delete(config)
244 await session.commit()
245
246 return {"success": True, "message": "Configuration deleted successfully"}
247
248
249 # ==================== Audit Execution Routes ====================
250
251
252 @github_audit_router.post(
253 "/config/{config_id}/audit",
254 response_model=GitHubAuditResponse,
255 description="Run a GitHub audit using a saved configuration",
256 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
257 )
258 async def run_audit_from_config(
259 config_id: int = Path(..., description="Configuration ID"),
260 session: AsyncSession = Depends(get_db),
261 ) -> GitHubAuditResponse:
262 """Run a GitHub audit using a saved configuration."""
263 logger.info(f"Running GitHub audit from config ID: {config_id}")
264
265 # Get configuration
266 result = await session.execute(
267 select(GitHubAuditConfig).where(GitHubAuditConfig.id == config_id),
268 )
269 config = result.scalar_one_or_none()
270
271 if not config:
272 raise HTTPException(status_code=404, detail="Configuration not found")
273
274 if not config.enabled:
275 raise HTTPException(status_code=400, detail="This configuration is disabled")
276
277 # Get exclusions for this config
278 exclusions_result = await session.execute(
279 select(GitHubAuditCheckExclusion).where(
280 GitHubAuditCheckExclusion.config_id == config_id,
281 GitHubAuditCheckExclusion.enabled == True,
282 ),
283 )
284 exclusions = exclusions_result.scalars().all()
285
286 # Build request from config
287 repo_filter = None
288 if config.repo_filter_mode == "include" and config.repo_filter_list:
289 repo_filter = config.repo_filter_list
290
291 request = GitHubAuditRequest(
292 organization=config.organization,
293 include_repos=config.include_repos,
294 include_workflows=config.include_workflows,
295 include_members=config.include_members,
296 repo_filter=repo_filter,
297 )
298
299 # Create report record
300 report = GitHubAuditReport(
301 config_id=config.id,
302 customer_code=config.customer_code,
303 report_name=f"Audit-{config.organization}-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}",
304 organization=config.organization,
305 status="running",
306 triggered_by="manual",
307 )
308 session.add(report)
309 await session.commit()
310 await session.refresh(report)
311
312 try:
313 # Run the audit
314 start_time = datetime.now(timezone.utc)
315 audit_response = await run_github_audit(config.github_token, request)
316 end_time = datetime.now(timezone.utc)
317
318 # Apply exclusions to findings
319 if exclusions:
320 exclusion_set = {(e.check_id, e.resource_name) for e in exclusions}
321 audit_response.top_findings = [
322 f
323 for f in audit_response.top_findings
324 if (f.check_id, f.resource_name) not in exclusion_set and (f.check_id, None) not in exclusion_set
325 ]
326
327 # Update report with results
328 report.status = "completed" if audit_response.success else "failed"
329 report.audit_completed_at = end_time
330 report.audit_duration_seconds = (end_time - start_time).total_seconds()
331
332 if audit_response.summary:
333 report.total_repos_audited = audit_response.summary.total_repos_audited
334 report.total_checks = audit_response.summary.total_checks
335 report.passed_checks = audit_response.summary.passed_checks
336 report.failed_checks = audit_response.summary.failed_checks
337 report.warning_checks = audit_response.summary.warning_checks
338 report.critical_findings = audit_response.summary.critical_findings
339 report.high_findings = audit_response.summary.high_findings
340 report.medium_findings = audit_response.summary.medium_findings
341 report.low_findings = audit_response.summary.low_findings
342 report.score = audit_response.summary.score
343 report.grade = audit_response.summary.grade
344
345 report.full_report = audit_response.model_dump()
346 report.top_findings = [f.model_dump() for f in audit_response.top_findings[:20]]
347
348 # Update config with last audit info
349 config.last_audit_at = end_time
350 if audit_response.summary:
351 config.last_audit_score = audit_response.summary.score
352 config.last_audit_grade = audit_response.summary.grade
353
354 await session.commit()
355
356 return audit_response
357
358 except Exception as e:
359 logger.error(f"Audit failed: {e}")
360 report.status = "failed"
361 report.error_message = str(e)
362 report.audit_completed_at = datetime.now(timezone.utc)
363 await session.commit()
364 raise HTTPException(status_code=500, detail=f"Audit failed: {e}")
365
366
367 @github_audit_router.post(
368 "/audit",
369 response_model=GitHubAuditResponse,
370 description="Run a one-time GitHub audit (without saving config)",
371 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
372 )
373 async def run_audit_adhoc(
374 request: GitHubAuditRequest,
375 github_token: str = Query(..., description="GitHub Personal Access Token"),
376 ) -> GitHubAuditResponse:
377 """Run a one-time GitHub audit without saving configuration."""
378 logger.info(f"Running ad-hoc GitHub audit for organization: {request.organization}")
379
380 try:
381 return await run_github_audit(github_token, request)
382 except Exception as e:
383 logger.error(f"GitHub audit failed: {e}")
384 raise HTTPException(status_code=500, detail=f"Audit failed: {e}")
385
386
387 @github_audit_router.post(
388 "/config/{config_id}/audit/summary",
389 response_model=GitHubAuditSummaryResponse,
390 description="Run a GitHub audit and return summary only",
391 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
392 )
393 async def run_audit_summary_from_config(
394 config_id: int = Path(..., description="Configuration ID"),
395 session: AsyncSession = Depends(get_db),
396 ) -> GitHubAuditSummaryResponse:
397 """Run a GitHub audit using saved config and return only the summary."""
398 logger.info(f"Running GitHub audit summary from config ID: {config_id}")
399
400 result = await session.execute(
401 select(GitHubAuditConfig).where(GitHubAuditConfig.id == config_id),
402 )
403 config = result.scalar_one_or_none()
404
405 if not config:
406 raise HTTPException(status_code=404, detail="Configuration not found")
407
408 if not config.enabled:
409 raise HTTPException(status_code=400, detail="This configuration is disabled")
410
411 request = GitHubAuditRequest(
412 organization=config.organization,
413 include_repos=config.include_repos,
414 include_workflows=config.include_workflows,
415 include_members=config.include_members,
416 )
417
418 try:
419 return await run_github_audit_summary(config.github_token, request)
420 except Exception as e:
421 logger.error(f"GitHub audit summary failed: {e}")
422 raise HTTPException(status_code=500, detail=f"Audit failed: {e}")
423
424
425 # ==================== Report Routes ====================
426
427
428 @github_audit_router.get(
429 "/reports",
430 response_model=GitHubAuditReportListResponse,
431 description="Get list of GitHub audit reports",
432 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
433 )
434 async def get_reports(
435 customer_code: Optional[str] = Query(None, description="Filter by customer code"),
436 config_id: Optional[int] = Query(None, description="Filter by config ID"),
437 organization: Optional[str] = Query(None, description="Filter by organization"),
438 status: Optional[str] = Query(None, description="Filter by status"),
439 limit: int = Query(50, ge=1, le=200, description="Maximum number of reports"),
440 offset: int = Query(0, ge=0, description="Offset for pagination"),
441 session: AsyncSession = Depends(get_db),
442 ) -> GitHubAuditReportListResponse:
443 """Get list of GitHub audit reports with optional filters."""
444 from sqlalchemy import func
445
446 # Select only the columns we need for the list view (exclude large JSON columns)
447 query = select(
448 GitHubAuditReport.id,
449 GitHubAuditReport.config_id,
450 GitHubAuditReport.customer_code,
451 GitHubAuditReport.report_name,
452 GitHubAuditReport.organization,
453 GitHubAuditReport.audit_started_at,
454 GitHubAuditReport.audit_completed_at,
455 GitHubAuditReport.audit_duration_seconds,
456 GitHubAuditReport.total_repos_audited,
457 GitHubAuditReport.total_checks,
458 GitHubAuditReport.passed_checks,
459 GitHubAuditReport.failed_checks,
460 GitHubAuditReport.warning_checks,
461 GitHubAuditReport.critical_findings,
462 GitHubAuditReport.high_findings,
463 GitHubAuditReport.medium_findings,
464 GitHubAuditReport.low_findings,
465 GitHubAuditReport.score,
466 GitHubAuditReport.grade,
467 GitHubAuditReport.status,
468 GitHubAuditReport.triggered_by,
469 GitHubAuditReport.triggered_by_user,
470 ).order_by(GitHubAuditReport.audit_started_at.desc())
471
472 if customer_code:
473 query = query.where(GitHubAuditReport.customer_code == customer_code)
474 if config_id:
475 query = query.where(GitHubAuditReport.config_id == config_id)
476 if organization:
477 query = query.where(GitHubAuditReport.organization == organization)
478 if status:
479 query = query.where(GitHubAuditReport.status == status)
480
481 # Get total count using a separate count query
482 count_query = select(func.count(GitHubAuditReport.id))
483 if customer_code:
484 count_query = count_query.where(GitHubAuditReport.customer_code == customer_code)
485 if config_id:
486 count_query = count_query.where(GitHubAuditReport.config_id == config_id)
487 if organization:
488 count_query = count_query.where(GitHubAuditReport.organization == organization)
489 if status:
490 count_query = count_query.where(GitHubAuditReport.status == status)
491
492 count_result = await session.execute(count_query)
493 total = count_result.scalar() or 0
494
495 # Apply pagination
496 query = query.offset(offset).limit(limit)
497 result = await session.execute(query)
498 rows = result.all()
499
500 # Convert rows to dictionaries
501 report_summaries = []
502 for row in rows:
503 report_dict = {
504 "id": row.id,
505 "config_id": row.config_id,
506 "customer_code": row.customer_code,
507 "report_name": row.report_name,
508 "organization": row.organization,
509 "audit_started_at": row.audit_started_at,
510 "audit_completed_at": row.audit_completed_at,
511 "audit_duration_seconds": row.audit_duration_seconds,
512 "total_repos_audited": row.total_repos_audited,
513 "total_checks": row.total_checks,
514 "passed_checks": row.passed_checks,
515 "failed_checks": row.failed_checks,
516 "warning_checks": row.warning_checks,
517 "critical_findings": row.critical_findings,
518 "high_findings": row.high_findings,
519 "medium_findings": row.medium_findings,
520 "low_findings": row.low_findings,
521 "score": row.score,
522 "grade": row.grade,
523 "status": row.status,
524 "triggered_by": row.triggered_by,
525 "triggered_by_user": row.triggered_by_user,
526 }
527 report_summaries.append(report_dict)
528
529 return GitHubAuditReportListResponse(
530 success=True,
531 message=f"Found {total} report(s)",
532 reports=report_summaries,
533 total_count=total,
534 )
535
536
537 @github_audit_router.get(
538 "/reports/{report_id}",
539 response_model=GitHubAuditReportResponse,
540 description="Get a specific GitHub audit report with full details",
541 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
542 )
543 async def get_report(
544 report_id: int = Path(..., description="Report ID"),
545 session: AsyncSession = Depends(get_db),
546 ) -> GitHubAuditReportResponse:
547 """Get a specific GitHub audit report with full details."""
548 result = await session.execute(
549 select(GitHubAuditReport).where(GitHubAuditReport.id == report_id),
550 )
551 report = result.scalar_one_or_none()
552
553 if not report:
554 raise HTTPException(status_code=404, detail="Report not found")
555
556 return GitHubAuditReportResponse(
557 success=True,
558 message="Report retrieved successfully",
559 report=report,
560 )
561
562
563 @github_audit_router.delete(
564 "/reports/{report_id}",
565 description="Delete a GitHub audit report",
566 dependencies=[Security(AuthHandler().require_any_scope("admin"))],
567 )
568 async def delete_report(
569 report_id: int = Path(..., description="Report ID"),
570 session: AsyncSession = Depends(get_db),
571 ):
572 """Delete a specific GitHub audit report."""
573 result = await session.execute(
574 select(GitHubAuditReport).where(GitHubAuditReport.id == report_id),
575 )
576 report = result.scalar_one_or_none()
577
578 if not report:
579 raise HTTPException(status_code=404, detail="Report not found")
580
581 await session.delete(report)
582 await session.commit()
583
584 return {"success": True, "message": "Report deleted successfully"}
585
586
587 # ==================== Exclusion Routes ====================
588
589
590 @github_audit_router.post(
591 "/config/{config_id}/exclusions",
592 response_model=GitHubAuditExclusionResponse,
593 description="Add an exclusion rule for a specific audit check",
594 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
595 )
596 async def create_exclusion(
597 exclusion: GitHubAuditExclusionCreate,
598 config_id: int = Path(..., description="Configuration ID"),
599 session: AsyncSession = Depends(get_db),
600 ) -> GitHubAuditExclusionResponse:
601 """Create an exclusion rule for a specific check."""
602 # Verify config exists
603 config_result = await session.execute(
604 select(GitHubAuditConfig).where(GitHubAuditConfig.id == config_id),
605 )
606 config = config_result.scalar_one_or_none()
607
608 if not config:
609 raise HTTPException(status_code=404, detail="Configuration not found")
610
611 db_exclusion = GitHubAuditCheckExclusion(
612 config_id=config_id,
613 customer_code=config.customer_code,
614 check_id=exclusion.check_id,
615 resource_name=exclusion.resource_name,
616 resource_type=exclusion.resource_type,
617 reason=exclusion.reason,
618 approved_by=exclusion.approved_by,
619 approved_at=datetime.now(timezone.utc) if exclusion.approved_by else None,
620 expires_at=exclusion.expires_at,
621 created_by=exclusion.created_by,
622 )
623
624 session.add(db_exclusion)
625 await session.commit()
626 await session.refresh(db_exclusion)
627
628 return GitHubAuditExclusionResponse(
629 success=True,
630 message="Exclusion created successfully",
631 exclusion=db_exclusion,
632 )
633
634
635 @github_audit_router.get(
636 "/config/{config_id}/exclusions",
637 response_model=GitHubAuditExclusionResponse,
638 description="Get all exclusion rules for a configuration",
639 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
640 )
641 async def get_exclusions(
642 config_id: int = Path(..., description="Configuration ID"),
643 include_expired: bool = Query(False, description="Include expired exclusions"),
644 session: AsyncSession = Depends(get_db),
645 ) -> GitHubAuditExclusionResponse:
646 """Get all exclusion rules for a configuration."""
647 query = select(GitHubAuditCheckExclusion).where(
648 GitHubAuditCheckExclusion.config_id == config_id,
649 )
650
651 if not include_expired:
652 query = query.where(
653 (GitHubAuditCheckExclusion.expires_at.is_(None)) | (GitHubAuditCheckExclusion.expires_at > datetime.now(timezone.utc)),
654 )
655
656 result = await session.execute(query)
657 exclusions = result.scalars().all()
658
659 return GitHubAuditExclusionResponse(
660 success=True,
661 message=f"Found {len(exclusions)} exclusion(s)",
662 exclusions=list(exclusions),
663 )
664
665
666 @github_audit_router.put(
667 "/exclusions/{exclusion_id}",
668 response_model=GitHubAuditExclusionResponse,
669 description="Update an exclusion rule",
670 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
671 )
672 async def update_exclusion(
673 exclusion_update: GitHubAuditExclusionUpdate,
674 exclusion_id: int = Path(..., description="Exclusion ID"),
675 session: AsyncSession = Depends(get_db),
676 ) -> GitHubAuditExclusionResponse:
677 """Update an exclusion rule."""
678 result = await session.execute(
679 select(GitHubAuditCheckExclusion).where(GitHubAuditCheckExclusion.id == exclusion_id),
680 )
681 exclusion = result.scalar_one_or_none()
682
683 if not exclusion:
684 raise HTTPException(status_code=404, detail="Exclusion not found")
685
686 update_data = exclusion_update.model_dump(exclude_unset=True)
687 for field, value in update_data.items():
688 if value is not None:
689 setattr(exclusion, field, value)
690
691 await session.commit()
692 await session.refresh(exclusion)
693
694 return GitHubAuditExclusionResponse(
695 success=True,
696 message="Exclusion updated successfully",
697 exclusion=exclusion,
698 )
699
700
701 @github_audit_router.delete(
702 "/exclusions/{exclusion_id}",
703 description="Delete an exclusion rule",
704 dependencies=[Security(AuthHandler().require_any_scope("admin"))],
705 )
706 async def delete_exclusion(
707 exclusion_id: int = Path(..., description="Exclusion ID"),
708 session: AsyncSession = Depends(get_db),
709 ):
710 """Delete an exclusion rule."""
711 result = await session.execute(
712 select(GitHubAuditCheckExclusion).where(GitHubAuditCheckExclusion.id == exclusion_id),
713 )
714 exclusion = result.scalar_one_or_none()
715
716 if not exclusion:
717 raise HTTPException(status_code=404, detail="Exclusion not found")
718
719 await session.delete(exclusion)
720 await session.commit()
721
722 return {"success": True, "message": "Exclusion deleted successfully"}
723
724
725 # ==================== Baseline Routes ====================
726
727
728 @github_audit_router.post(
729 "/config/{config_id}/baselines",
730 response_model=GitHubAuditBaselineResponse,
731 description="Create a baseline from a previous audit report",
732 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
733 )
734 async def create_baseline(
735 baseline: GitHubAuditBaselineCreate,
736 config_id: int = Path(..., description="Configuration ID"),
737 session: AsyncSession = Depends(get_db),
738 ) -> GitHubAuditBaselineResponse:
739 """Create a baseline from a previous audit report."""
740 # Verify config exists
741 config_result = await session.execute(
742 select(GitHubAuditConfig).where(GitHubAuditConfig.id == config_id),
743 )
744 config = config_result.scalar_one_or_none()
745
746 if not config:
747 raise HTTPException(status_code=404, detail="Configuration not found")
748
749 # If baseline_report_id provided, extract expected checks from that report
750 expected_checks = baseline.expected_checks
751 if baseline.baseline_report_id:
752 report_result = await session.execute(
753 select(GitHubAuditReport).where(GitHubAuditReport.id == baseline.baseline_report_id),
754 )
755 report = report_result.scalar_one_or_none()
756
757 if not report:
758 raise HTTPException(status_code=404, detail="Baseline report not found")
759
760 if report.full_report:
761 # Extract check statuses from report
762 expected_checks = {}
763 full_report = report.full_report
764
765 # Organization checks
766 if "organization_results" in full_report and full_report["organization_results"]:
767 for check in full_report["organization_results"].get("checks", []):
768 expected_checks[check["check_id"]] = check["status"]
769
770 # Repository checks
771 for repo in full_report.get("repository_results", []):
772 for check in repo.get("checks", []):
773 key = f"{check['check_id']}:{repo['repo_name']}"
774 expected_checks[key] = check["status"]
775
776 # Deactivate other baselines for this config
777 if baseline.is_active:
778 await session.execute(
779 select(GitHubAuditBaseline).where(GitHubAuditBaseline.config_id == config_id).where(GitHubAuditBaseline.is_active == True),
780 )
781 existing = await session.execute(
782 select(GitHubAuditBaseline).where(
783 GitHubAuditBaseline.config_id == config_id,
784 GitHubAuditBaseline.is_active == True,
785 ),
786 )
787 for b in existing.scalars().all():
788 b.is_active = False
789
790 db_baseline = GitHubAuditBaseline(
791 config_id=config_id,
792 customer_code=config.customer_code,
793 name=baseline.name,
794 description=baseline.description,
795 expected_checks=expected_checks,
796 baseline_report_id=baseline.baseline_report_id,
797 is_active=baseline.is_active,
798 created_by=baseline.created_by,
799 )
800
801 session.add(db_baseline)
802 await session.commit()
803 await session.refresh(db_baseline)
804
805 return GitHubAuditBaselineResponse(
806 success=True,
807 message="Baseline created successfully",
808 baseline=db_baseline,
809 )
810
811
812 @github_audit_router.get(
813 "/config/{config_id}/baselines",
814 response_model=GitHubAuditBaselineResponse,
815 description="Get all baselines for a configuration",
816 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
817 )
818 async def get_baselines(
819 config_id: int = Path(..., description="Configuration ID"),
820 active_only: bool = Query(False, description="Only return active baseline"),
821 session: AsyncSession = Depends(get_db),
822 ) -> GitHubAuditBaselineResponse:
823 """Get all baselines for a configuration."""
824 query = select(GitHubAuditBaseline).where(GitHubAuditBaseline.config_id == config_id)
825
826 if active_only:
827 query = query.where(GitHubAuditBaseline.is_active == True)
828
829 result = await session.execute(query)
830 baselines = result.scalars().all()
831
832 return GitHubAuditBaselineResponse(
833 success=True,
834 message=f"Found {len(baselines)} baseline(s)",
835 baselines=list(baselines),
836 )
837
838
839 @github_audit_router.delete(
840 "/baselines/{baseline_id}",
841 description="Delete a baseline",
842 dependencies=[Security(AuthHandler().require_any_scope("admin"))],
843 )
844 async def delete_baseline(
845 baseline_id: int = Path(..., description="Baseline ID"),
846 session: AsyncSession = Depends(get_db),
847 ):
848 """Delete a baseline."""
849 result = await session.execute(
850 select(GitHubAuditBaseline).where(GitHubAuditBaseline.id == baseline_id),
851 )
852 baseline = result.scalar_one_or_none()
853
854 if not baseline:
855 raise HTTPException(status_code=404, detail="Baseline not found")
856
857 await session.delete(baseline)
858 await session.commit()
859
860 return {"success": True, "message": "Baseline deleted successfully"}
861
862
863 # ==================== Available Checks Route ====================
864
865
866 @github_audit_router.get(
867 "/checks",
868 response_model=AvailableChecksResponse,
869 description="Get list of all available audit checks",
870 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
871 )
872 async def get_available_checks() -> AvailableChecksResponse:
873 """Get list of all audit checks that will be performed."""
874 return AvailableChecksResponse(
875 success=True,
876 message="Available audit checks retrieved successfully",
877 checks=[
878 {
879 "id": "org-2fa-required",
880 "name": "Two-Factor Authentication Required",
881 "category": "organization",
882 "severity": "critical",
883 "description": "Checks if 2FA is required for all organization members",
884 },
885 {
886 "id": "org-default-permission",
887 "name": "Default Repository Permission",
888 "category": "organization",
889 "severity": "medium",
890 "description": "Checks the default permission level for new repositories",
891 },
892 {
893 "id": "org-member-repo-creation",
894 "name": "Member Repository Creation",
895 "category": "organization",
896 "severity": "low",
897 "description": "Checks if members can create repositories",
898 },
899 {
900 "id": "org-public-repo-creation",
901 "name": "Public Repository Creation",
902 "category": "organization",
903 "severity": "high",
904 "description": "Checks if members can create public repositories",
905 },
906 {
907 "id": "org-verified-domains",
908 "name": "Verified Domains",
909 "category": "organization",
910 "severity": "medium",
911 "description": "Checks if organization has verified domains",
912 },
913 {
914 "id": "org-sso-enforcement",
915 "name": "SAML SSO Enforcement",
916 "category": "organization",
917 "severity": "high",
918 "description": "Checks if SAML SSO is enforced",
919 },
920 {
921 "id": "repo-branch-protection",
922 "name": "Default Branch Protection",
923 "category": "repository",
924 "severity": "high",
925 "description": "Checks if default branch has protection rules",
926 },
927 {
928 "id": "repo-secret-scanning",
929 "name": "Secret Scanning",
930 "category": "repository",
931 "severity": "high",
932 "description": "Checks if secret scanning is enabled",
933 },
934 {
935 "id": "repo-dependabot-alerts",
936 "name": "Dependabot Alerts",
937 "category": "repository",
938 "severity": "high",
939 "description": "Checks if Dependabot alerts are enabled",
940 },
941 {
942 "id": "repo-code-scanning",
943 "name": "Code Scanning (GHAS)",
944 "category": "repository",
945 "severity": "medium",
946 "description": "Checks if code scanning is enabled",
947 },
948 {
949 "id": "repo-private-vuln-reporting",
950 "name": "Private Vulnerability Reporting",
951 "category": "repository",
952 "severity": "low",
953 "description": "Checks if private vulnerability reporting is enabled",
954 },
955 {
956 "id": "repo-license",
957 "name": "Repository License",
958 "category": "repository",
959 "severity": "low",
960 "description": "Checks if public repositories have a license",
961 },
962 {
963 "id": "repo-branch-deletion",
964 "name": "Default Branch Deletion Protection",
965 "category": "repository",
966 "severity": "high",
967 "description": "Checks if default branch is protected from deletion",
968 },
969 {
970 "id": "actions-allowed-all",
971 "name": "Actions Permission Policy",
972 "category": "workflow",
973 "severity": "medium",
974 "description": "Checks which GitHub Actions are allowed to run",
975 },
976 {
977 "id": "actions-default-token-perms",
978 "name": "Default Workflow Token Permissions",
979 "category": "workflow",
980 "severity": "medium",
981 "description": "Checks default GITHUB_TOKEN permissions in workflows",
982 },
983 ],
984 )