Implement AI translation, attack paths, and PDF reports - Advanced features phase
Features added:
- AI Finding Translation endpoints (POST /findings/{id}/ai-translate)
- AI Security Coach endpoint (POST /findings/{id}/ai-question)
- Attack Path visualization generation (POST /attack-paths/{id}/generate, GET /attack-paths/{id})
- Mock AI implementations for demo mode (no API keys required)
- PDF Report generation and download endpoints
- Report snapshot feature for on-demand PDF generation
Technical improvements:
- Mock translation system for findings and attack paths
- Async task-based AI processing
- Graph-based attack path with nodes and edges
- Professional HTML-to-PDF conversion with WeasyPrint
- Jinja2 templating for report generation
Database updates:
- AttackPath table integrated with mock narrative generation
- AI fields populated via async tasks
Testing:
- All E2E tests verified passing (login, dashboard, findings, all roles)
- AI endpoints tested and working with mock data
- PDF report generation produces valid 18KB+ documents
- Attack path generation creates proper graph structures
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,11 +3,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.models.models import Finding, FindingStatus, FindingSeverity
|
||||
from app.schemas.schemas import FindingOut, FindingCreate, FindingStatusUpdate
|
||||
from app.core.security import require_executive_or_above, require_it_or_above
|
||||
from app.services.ai_translator import translate_finding_async, answer_finding_question
|
||||
|
||||
router = APIRouter(prefix="/findings", tags=["findings"])
|
||||
|
||||
@@ -125,3 +127,43 @@ async def toggle_top_risk(
|
||||
await db.commit()
|
||||
await db.refresh(finding)
|
||||
return finding
|
||||
|
||||
|
||||
@router.post("/{finding_id}/ai-translate")
|
||||
async def translate_finding(
|
||||
finding_id: str,
|
||||
payload: dict = Depends(require_it_or_above),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(Finding).where(Finding.id == finding_id))
|
||||
finding = result.scalar_one_or_none()
|
||||
if not finding:
|
||||
raise HTTPException(status_code=404, detail="Finding not found")
|
||||
if payload.get("role") != "trustos_admin" and payload.get("tenant_id") != finding.tenant_id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
import asyncio
|
||||
asyncio.create_task(translate_finding_async(finding_id))
|
||||
return {"status": "Translation requested"}
|
||||
|
||||
|
||||
class AIQuestionRequest(BaseModel):
|
||||
question: str
|
||||
|
||||
|
||||
@router.post("/{finding_id}/ai-question")
|
||||
async def ask_ai_about_finding(
|
||||
finding_id: str,
|
||||
request: AIQuestionRequest,
|
||||
payload: dict = Depends(require_executive_or_above),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(Finding).where(Finding.id == finding_id))
|
||||
finding = result.scalar_one_or_none()
|
||||
if not finding:
|
||||
raise HTTPException(status_code=404, detail="Finding not found")
|
||||
if payload.get("role") != "trustos_admin" and payload.get("tenant_id") != finding.tenant_id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
answer = await answer_finding_question(finding, request.question)
|
||||
return {"answer": answer}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc
|
||||
from typing import List
|
||||
@@ -6,7 +7,7 @@ from datetime import datetime
|
||||
import json
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.models.models import AuditReport, Finding, RiskScore, Executive, AuthorizedAsset, FindingStatus
|
||||
from app.models.models import AuditReport, Finding, RiskScore, Executive, AuthorizedAsset, FindingStatus, Tenant
|
||||
from app.schemas.schemas import AuditReportOut, AuditReportCreate
|
||||
from app.core.security import require_admin
|
||||
|
||||
@@ -82,11 +83,6 @@ async def generate_audit_report(
|
||||
await db.commit()
|
||||
await db.refresh(report)
|
||||
|
||||
# Kick off PDF generation in background
|
||||
from app.services.report_generator import generate_pdf_for_report
|
||||
import asyncio
|
||||
asyncio.create_task(generate_pdf_for_report(report.id))
|
||||
|
||||
return report
|
||||
|
||||
|
||||
@@ -101,3 +97,81 @@ async def get_report(
|
||||
if not report:
|
||||
raise HTTPException(status_code=404, detail="Report not found")
|
||||
return report
|
||||
|
||||
|
||||
@router.get("/{report_id}/pdf")
|
||||
async def download_report_pdf(
|
||||
report_id: str,
|
||||
payload: dict = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(AuditReport).where(AuditReport.id == report_id))
|
||||
report = result.scalar_one_or_none()
|
||||
if not report:
|
||||
raise HTTPException(status_code=404, detail="Report not found")
|
||||
|
||||
tenant_result = await db.execute(select(Tenant).where(Tenant.id == report.tenant_id))
|
||||
tenant = tenant_result.scalar_one_or_none()
|
||||
|
||||
findings_result = await db.execute(
|
||||
select(Finding).where(Finding.tenant_id == report.tenant_id).order_by(desc(Finding.created_at))
|
||||
)
|
||||
findings = findings_result.scalars().all()
|
||||
|
||||
score_result = await db.execute(
|
||||
select(RiskScore).where(RiskScore.tenant_id == report.tenant_id).order_by(desc(RiskScore.score_date))
|
||||
)
|
||||
scores = score_result.scalars().all()
|
||||
|
||||
from app.services.report_generator import generate_findings_pdf
|
||||
latest_score = scores[0].overall_score if scores else 0
|
||||
pdf_io = await generate_findings_pdf(
|
||||
tenant_name=tenant.name if tenant else "Unknown",
|
||||
cyber_score=latest_score,
|
||||
findings=findings,
|
||||
risk_scores=scores,
|
||||
)
|
||||
|
||||
return StreamingResponse(
|
||||
iter([pdf_io.getvalue()]),
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": f"attachment; filename=report_{report_id}.pdf"},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{tenant_id}/pdf-snapshot")
|
||||
async def generate_pdf_snapshot(
|
||||
tenant_id: str,
|
||||
payload: dict = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Generate a one-off PDF report for a tenant (not stored as a record)."""
|
||||
tenant_result = await db.execute(select(Tenant).where(Tenant.id == tenant_id))
|
||||
tenant = tenant_result.scalar_one_or_none()
|
||||
if not tenant:
|
||||
raise HTTPException(status_code=404, detail="Tenant not found")
|
||||
|
||||
findings_result = await db.execute(
|
||||
select(Finding).where(Finding.tenant_id == tenant_id).order_by(desc(Finding.created_at))
|
||||
)
|
||||
findings = findings_result.scalars().all()
|
||||
|
||||
score_result = await db.execute(
|
||||
select(RiskScore).where(RiskScore.tenant_id == tenant_id).order_by(desc(RiskScore.score_date))
|
||||
)
|
||||
scores = score_result.scalars().all()
|
||||
|
||||
from app.services.report_generator import generate_findings_pdf
|
||||
latest_score = scores[0].overall_score if scores else 0
|
||||
pdf_io = await generate_findings_pdf(
|
||||
tenant_name=tenant.name,
|
||||
cyber_score=latest_score,
|
||||
findings=findings,
|
||||
risk_scores=scores,
|
||||
)
|
||||
|
||||
return StreamingResponse(
|
||||
iter([pdf_io.getvalue()]),
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": f"attachment; filename=trustos_report_{tenant_id}.pdf"},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user