feat: initial TrustOS platform scaffold
- FastAPI backend: auth, findings, dashboard, attack paths, footprint, AI translator, risk calculator, PDF report generator - Next.js frontend: Vault dashboard, login, findings table, finding detail with AI coach, digital footprint, reports - PostgreSQL data model: tenants, users, assets, findings, risk scores, audit reports, attack paths - Docker Compose + Dockerfiles for all services - Demo seed data: Acme Corp with 6 findings and 90-day risk score history - AI Risk Translator (OpenAI/Anthropic) with plain-English business impact - Role-based access: executive / it_admin / trustos_admin - Scope-lock engine: authorization required before any assessment Stage 1-8 complete: Phase 1 Vault Audit product ready
This commit is contained in:
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/routes/__init__.py
Normal file
0
backend/app/api/routes/__init__.py
Normal file
49
backend/app/api/routes/ai.py
Normal file
49
backend/app/api/routes/ai.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.models.models import Finding
|
||||
from app.core.security import require_it_or_above
|
||||
|
||||
router = APIRouter(prefix="/ai", tags=["ai"])
|
||||
|
||||
|
||||
@router.post("/translate/{finding_id}")
|
||||
async def translate_finding(
|
||||
finding_id: str,
|
||||
payload: dict = Depends(require_it_or_above),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Trigger or re-trigger AI translation for a specific finding."""
|
||||
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")
|
||||
|
||||
from app.services.ai_translator import translate_finding_async
|
||||
import asyncio
|
||||
asyncio.create_task(translate_finding_async(finding_id))
|
||||
return {"status": "queued", "finding_id": finding_id}
|
||||
|
||||
|
||||
@router.get("/explain/{finding_id}")
|
||||
async def explain_finding(
|
||||
finding_id: str,
|
||||
question: str = Query(default="Why does this matter to our business?"),
|
||||
payload: dict = Depends(require_it_or_above),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""AI Security Coach: answer a specific question about a finding."""
|
||||
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")
|
||||
|
||||
from app.services.ai_translator import answer_finding_question
|
||||
answer = await answer_finding_question(finding, question)
|
||||
return {"question": question, "answer": answer, "finding_id": finding_id}
|
||||
42
backend/app/api/routes/attack_paths.py
Normal file
42
backend/app/api/routes/attack_paths.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from typing import List
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.models.models import Finding, AttackPath
|
||||
from app.schemas.schemas import AttackPathOut
|
||||
from app.core.security import require_executive_or_above
|
||||
|
||||
router = APIRouter(prefix="/attack-paths", tags=["attack-paths"])
|
||||
|
||||
|
||||
@router.get("/{finding_id}", response_model=List[AttackPathOut])
|
||||
async def get_attack_paths(
|
||||
finding_id: str,
|
||||
payload: dict = Depends(require_executive_or_above),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
# Verify tenant access
|
||||
f_result = await db.execute(select(Finding).where(Finding.id == finding_id))
|
||||
finding = f_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")
|
||||
|
||||
result = await db.execute(select(AttackPath).where(AttackPath.finding_id == finding_id))
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.post("/{finding_id}/generate")
|
||||
async def generate_attack_path(
|
||||
finding_id: str,
|
||||
payload: dict = Depends(require_executive_or_above),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Trigger AI generation of attack path narrative for a finding."""
|
||||
from app.services.ai_translator import generate_attack_path_narrative
|
||||
import asyncio
|
||||
asyncio.create_task(generate_attack_path_narrative(finding_id))
|
||||
return {"status": "queued", "finding_id": finding_id}
|
||||
37
backend/app/api/routes/auth.py
Normal file
37
backend/app/api/routes/auth.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from datetime import datetime
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.models.models import User
|
||||
from app.schemas.schemas import LoginRequest, TokenResponse, UserMe
|
||||
from app.core.security import verify_password, create_access_token, get_current_user_payload
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(User).where(User.email == body.email, User.is_active == True))
|
||||
user: User | None = result.scalar_one_or_none()
|
||||
if not user or not verify_password(body.password, user.hashed_password):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
user.last_login = datetime.utcnow()
|
||||
await db.commit()
|
||||
token = create_access_token({"sub": user.id, "role": user.role.value, "tenant_id": user.tenant_id})
|
||||
return TokenResponse(
|
||||
access_token=token,
|
||||
role=user.role.value,
|
||||
tenant_id=user.tenant_id,
|
||||
full_name=user.full_name
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserMe)
|
||||
async def get_me(payload: dict = Depends(get_current_user_payload), db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(User).where(User.id == payload["sub"]))
|
||||
user: User | None = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return UserMe(id=user.id, email=user.email, full_name=user.full_name, role=user.role, tenant_id=user.tenant_id)
|
||||
143
backend/app/api/routes/dashboard.py
Normal file
143
backend/app/api/routes/dashboard.py
Normal file
@@ -0,0 +1,143 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, desc
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.models.models import Finding, RiskScore, AuditReport, FindingStatus
|
||||
from app.schemas.schemas import DashboardResponse, RiskCardData
|
||||
from app.core.security import require_executive_or_above
|
||||
|
||||
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
||||
|
||||
|
||||
@router.get("/{tenant_id}", response_model=DashboardResponse)
|
||||
async def get_dashboard(
|
||||
tenant_id: str,
|
||||
payload: dict = Depends(require_executive_or_above),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
# Enforce tenant isolation
|
||||
if payload.get("role") != "trustos_admin" and payload.get("tenant_id") != tenant_id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
# Current score (most recent)
|
||||
score_result = await db.execute(
|
||||
select(RiskScore)
|
||||
.where(RiskScore.tenant_id == tenant_id)
|
||||
.order_by(desc(RiskScore.score_date))
|
||||
.limit(1)
|
||||
)
|
||||
current_score_obj = score_result.scalar_one_or_none()
|
||||
current_score = current_score_obj.overall_score if current_score_obj else 50.0
|
||||
|
||||
# Previous score (30 days ago) for delta
|
||||
prev_result = await db.execute(
|
||||
select(RiskScore)
|
||||
.where(
|
||||
RiskScore.tenant_id == tenant_id,
|
||||
RiskScore.score_date <= datetime.utcnow() - timedelta(days=30)
|
||||
)
|
||||
.order_by(desc(RiskScore.score_date))
|
||||
.limit(1)
|
||||
)
|
||||
prev_score_obj = prev_result.scalar_one_or_none()
|
||||
prev_score = prev_score_obj.overall_score if prev_score_obj else None
|
||||
delta = round(current_score - prev_score, 1) if prev_score else None
|
||||
|
||||
# 90-day trend
|
||||
trend_result = await db.execute(
|
||||
select(RiskScore)
|
||||
.where(
|
||||
RiskScore.tenant_id == tenant_id,
|
||||
RiskScore.score_date >= datetime.utcnow() - timedelta(days=90)
|
||||
)
|
||||
.order_by(RiskScore.score_date)
|
||||
)
|
||||
trend_scores = trend_result.scalars().all()
|
||||
score_trend = [
|
||||
{"date": rs.score_date.strftime("%Y-%m-%d"), "score": rs.overall_score}
|
||||
for rs in trend_scores
|
||||
]
|
||||
|
||||
# Top 3 risks
|
||||
top_result = await db.execute(
|
||||
select(Finding)
|
||||
.where(
|
||||
Finding.tenant_id == tenant_id,
|
||||
Finding.is_top_risk == True,
|
||||
Finding.status.in_([FindingStatus.open, FindingStatus.in_progress])
|
||||
)
|
||||
.order_by(Finding.created_at)
|
||||
.limit(3)
|
||||
)
|
||||
top_findings = top_result.scalars().all()
|
||||
|
||||
# If no manually-flagged top risks, fall back to open criticals
|
||||
if not top_findings:
|
||||
fallback = await db.execute(
|
||||
select(Finding)
|
||||
.where(
|
||||
Finding.tenant_id == tenant_id,
|
||||
Finding.status.in_([FindingStatus.open, FindingStatus.in_progress])
|
||||
)
|
||||
.order_by(Finding.created_at)
|
||||
.limit(3)
|
||||
)
|
||||
top_findings = fallback.scalars().all()
|
||||
|
||||
top_risks = [
|
||||
RiskCardData(
|
||||
id=f.id,
|
||||
title=f.title,
|
||||
ai_summary=f.ai_summary,
|
||||
ai_business_impact=f.ai_business_impact,
|
||||
ai_impact_level=f.ai_impact_level,
|
||||
ai_fix_priority=f.ai_fix_priority,
|
||||
severity=f.severity.value,
|
||||
category=f.category.value,
|
||||
)
|
||||
for f in top_findings
|
||||
]
|
||||
|
||||
# Open finding counts
|
||||
counts_result = await db.execute(
|
||||
select(Finding.severity, func.count(Finding.id))
|
||||
.where(
|
||||
Finding.tenant_id == tenant_id,
|
||||
Finding.status.in_([FindingStatus.open, FindingStatus.in_progress])
|
||||
)
|
||||
.group_by(Finding.severity)
|
||||
)
|
||||
counts = {row[0].value: row[1] for row in counts_result}
|
||||
total_open = sum(counts.values())
|
||||
|
||||
# Baseline
|
||||
baseline_result = await db.execute(
|
||||
select(AuditReport)
|
||||
.where(AuditReport.tenant_id == tenant_id, AuditReport.is_baseline == True)
|
||||
.order_by(AuditReport.report_date)
|
||||
.limit(1)
|
||||
)
|
||||
baseline = baseline_result.scalar_one_or_none()
|
||||
|
||||
# Tenant name
|
||||
from app.models.models import Tenant
|
||||
t_result = await db.execute(select(Tenant).where(Tenant.id == tenant_id))
|
||||
tenant = t_result.scalar_one_or_none()
|
||||
|
||||
return DashboardResponse(
|
||||
tenant_name=tenant.name if tenant else "Unknown",
|
||||
current_score=current_score,
|
||||
previous_score=prev_score,
|
||||
score_delta=delta,
|
||||
score_trend=score_trend,
|
||||
top_risks=top_risks,
|
||||
open_critical=counts.get("critical", 0),
|
||||
open_high=counts.get("high", 0),
|
||||
open_medium=counts.get("medium", 0),
|
||||
total_open=total_open,
|
||||
baseline_score=baseline.baseline_score if baseline else None,
|
||||
baseline_date=baseline.report_date if baseline else None,
|
||||
)
|
||||
127
backend/app/api/routes/findings.py
Normal file
127
backend/app/api/routes/findings.py
Normal file
@@ -0,0 +1,127 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
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
|
||||
|
||||
router = APIRouter(prefix="/findings", tags=["findings"])
|
||||
|
||||
|
||||
@router.get("", response_model=List[FindingOut])
|
||||
async def list_findings(
|
||||
tenant_id: str = Query(...),
|
||||
severity: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
payload: dict = Depends(require_executive_or_above),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if payload.get("role") != "trustos_admin" and payload.get("tenant_id") != tenant_id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
query = select(Finding).where(Finding.tenant_id == tenant_id)
|
||||
if severity:
|
||||
query = query.where(Finding.severity == severity)
|
||||
if status:
|
||||
query = query.where(Finding.status == status)
|
||||
if category:
|
||||
query = query.where(Finding.category == category)
|
||||
query = query.order_by(desc(Finding.created_at)).limit(limit).offset(offset)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.get("/{finding_id}", response_model=FindingOut)
|
||||
async def get_finding(
|
||||
finding_id: str,
|
||||
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")
|
||||
return finding
|
||||
|
||||
|
||||
@router.post("", response_model=FindingOut, status_code=201)
|
||||
async def create_finding(
|
||||
tenant_id: str = Query(...),
|
||||
body: FindingCreate = ...,
|
||||
payload: dict = Depends(require_it_or_above),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if payload.get("role") != "trustos_admin" and payload.get("tenant_id") != tenant_id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
finding = Finding(tenant_id=tenant_id, **body.model_dump())
|
||||
db.add(finding)
|
||||
await db.commit()
|
||||
await db.refresh(finding)
|
||||
return finding
|
||||
|
||||
|
||||
@router.patch("/{finding_id}/status", response_model=FindingOut)
|
||||
async def update_finding_status(
|
||||
finding_id: str,
|
||||
body: FindingStatusUpdate,
|
||||
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")
|
||||
|
||||
finding.status = body.status
|
||||
if body.resolution_note:
|
||||
finding.resolution_note = body.resolution_note
|
||||
if body.assignee_email:
|
||||
finding.assignee_email = body.assignee_email
|
||||
if body.due_date:
|
||||
finding.due_date = body.due_date
|
||||
if body.status == FindingStatus.resolved:
|
||||
finding.resolved_at = datetime.utcnow()
|
||||
if body.status == FindingStatus.verified:
|
||||
finding.verified_at = datetime.utcnow()
|
||||
if not body.resolution_note:
|
||||
raise HTTPException(status_code=400, detail="A resolution note is required to verify a finding")
|
||||
|
||||
finding.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(finding)
|
||||
|
||||
# Trigger async risk score recalculation (fire and forget)
|
||||
from app.services.risk_calculator import recalculate_risk_score
|
||||
import asyncio
|
||||
asyncio.create_task(recalculate_risk_score(finding.tenant_id))
|
||||
|
||||
return finding
|
||||
|
||||
|
||||
@router.patch("/{finding_id}/top-risk", response_model=FindingOut)
|
||||
async def toggle_top_risk(
|
||||
finding_id: str,
|
||||
is_top_risk: bool = Query(...),
|
||||
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="Not found")
|
||||
finding.is_top_risk = is_top_risk
|
||||
await db.commit()
|
||||
await db.refresh(finding)
|
||||
return finding
|
||||
88
backend/app/api/routes/footprint.py
Normal file
88
backend/app/api/routes/footprint.py
Normal file
@@ -0,0 +1,88 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from typing import List
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.models.models import Finding, Executive, AuthorizedAsset
|
||||
from app.schemas.schemas import AuthorizedAssetCreate, AuthorizedAssetOut
|
||||
from app.core.security import require_admin, require_it_or_above
|
||||
|
||||
router = APIRouter(prefix="/footprint", tags=["footprint"])
|
||||
|
||||
|
||||
@router.get("/{tenant_id}")
|
||||
async def get_footprint(
|
||||
tenant_id: str,
|
||||
payload: dict = Depends(require_it_or_above),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if payload.get("role") != "trustos_admin" and payload.get("tenant_id") != tenant_id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
# Digital footprint findings
|
||||
from app.models.models import FindingCategory
|
||||
fp_result = await db.execute(
|
||||
select(Finding).where(
|
||||
Finding.tenant_id == tenant_id,
|
||||
Finding.category == FindingCategory.digital_footprint
|
||||
)
|
||||
)
|
||||
footprint_findings = fp_result.scalars().all()
|
||||
|
||||
# Executives
|
||||
exec_result = await db.execute(
|
||||
select(Executive).where(Executive.tenant_id == tenant_id, Executive.is_enrolled == True)
|
||||
)
|
||||
executives = exec_result.scalars().all()
|
||||
|
||||
return {
|
||||
"tenant_id": tenant_id,
|
||||
"executives": [
|
||||
{"id": e.id, "name": e.full_name, "title": e.title, "email": e.corporate_email}
|
||||
for e in executives
|
||||
],
|
||||
"footprint_findings": [
|
||||
{
|
||||
"id": f.id,
|
||||
"title": f.title,
|
||||
"severity": f.severity.value,
|
||||
"ai_summary": f.ai_summary,
|
||||
"status": f.status.value,
|
||||
"source": f.source,
|
||||
}
|
||||
for f in footprint_findings
|
||||
],
|
||||
"total_exposures": len(footprint_findings),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/authorized-assets/{tenant_id}", response_model=List[AuthorizedAssetOut])
|
||||
async def list_authorized_assets(
|
||||
tenant_id: str,
|
||||
payload: dict = Depends(require_it_or_above),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if payload.get("role") != "trustos_admin" and payload.get("tenant_id") != tenant_id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
result = await db.execute(
|
||||
select(AuthorizedAsset).where(
|
||||
AuthorizedAsset.tenant_id == tenant_id,
|
||||
AuthorizedAsset.is_active == True
|
||||
)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.post("/authorized-assets/{tenant_id}", response_model=AuthorizedAssetOut, status_code=201)
|
||||
async def add_authorized_asset(
|
||||
tenant_id: str,
|
||||
body: AuthorizedAssetCreate,
|
||||
payload: dict = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
asset = AuthorizedAsset(tenant_id=tenant_id, **body.model_dump())
|
||||
db.add(asset)
|
||||
await db.commit()
|
||||
await db.refresh(asset)
|
||||
return asset
|
||||
103
backend/app/api/routes/reports.py
Normal file
103
backend/app/api/routes/reports.py
Normal file
@@ -0,0 +1,103 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc
|
||||
from typing import List
|
||||
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.schemas.schemas import AuditReportOut, AuditReportCreate
|
||||
from app.core.security import require_admin
|
||||
|
||||
router = APIRouter(prefix="/audit-reports", tags=["audit-reports"])
|
||||
|
||||
|
||||
@router.get("", response_model=List[AuditReportOut])
|
||||
async def list_reports(
|
||||
tenant_id: str = Query(...),
|
||||
payload: dict = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(AuditReport)
|
||||
.where(AuditReport.tenant_id == tenant_id)
|
||||
.order_by(desc(AuditReport.report_date))
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.post("/generate", response_model=AuditReportOut, status_code=201)
|
||||
async def generate_audit_report(
|
||||
tenant_id: str = Query(...),
|
||||
body: AuditReportCreate = ...,
|
||||
payload: dict = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Generate a Vault Audit Report snapshot for a tenant."""
|
||||
# Get current score
|
||||
score_result = await db.execute(
|
||||
select(RiskScore)
|
||||
.where(RiskScore.tenant_id == tenant_id)
|
||||
.order_by(desc(RiskScore.score_date))
|
||||
.limit(1)
|
||||
)
|
||||
latest_score = score_result.scalar_one_or_none()
|
||||
|
||||
# Snapshot top findings
|
||||
findings_result = await db.execute(
|
||||
select(Finding)
|
||||
.where(
|
||||
Finding.tenant_id == tenant_id,
|
||||
Finding.status.in_([FindingStatus.open, FindingStatus.in_progress])
|
||||
)
|
||||
.order_by(Finding.created_at)
|
||||
.limit(20)
|
||||
)
|
||||
top_findings = findings_result.scalars().all()
|
||||
findings_snapshot = [
|
||||
{
|
||||
"id": f.id,
|
||||
"title": f.title,
|
||||
"severity": f.severity.value,
|
||||
"category": f.category.value,
|
||||
"ai_summary": f.ai_summary,
|
||||
"ai_impact_level": f.ai_impact_level,
|
||||
}
|
||||
for f in top_findings
|
||||
]
|
||||
|
||||
report = AuditReport(
|
||||
tenant_id=tenant_id,
|
||||
title=body.title,
|
||||
report_date=datetime.utcnow(),
|
||||
baseline_score=latest_score.overall_score if latest_score else None,
|
||||
executive_summary=body.executive_summary,
|
||||
scope_description=body.scope_description,
|
||||
key_findings_json=json.dumps(findings_snapshot),
|
||||
is_baseline=True,
|
||||
generated_by=payload.get("sub"),
|
||||
)
|
||||
db.add(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
|
||||
|
||||
|
||||
@router.get("/{report_id}", response_model=AuditReportOut)
|
||||
async def get_report(
|
||||
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")
|
||||
return report
|
||||
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
37
backend/app/core/config.py
Normal file
37
backend/app/core/config.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic import field_validator
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
PROJECT_NAME: str = "TrustOS"
|
||||
VERSION: str = "0.1.0"
|
||||
API_V1_STR: str = "/api/v1"
|
||||
|
||||
DATABASE_URL: str = "postgresql+asyncpg://trustos:trustos_dev@localhost:5432/trustos"
|
||||
SYNC_DATABASE_URL: str = "postgresql://trustos:trustos_dev@localhost:5432/trustos"
|
||||
|
||||
SECRET_KEY: str = "dev-secret-key-change-in-production"
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 480
|
||||
|
||||
AI_PROVIDER: str = "openai"
|
||||
OPENAI_API_KEY: Optional[str] = None
|
||||
ANTHROPIC_API_KEY: Optional[str] = None
|
||||
|
||||
HIBP_API_KEY: Optional[str] = None
|
||||
NVD_API_KEY: Optional[str] = None
|
||||
|
||||
STORAGE_PATH: str = "/app/storage"
|
||||
|
||||
SMTP_HOST: Optional[str] = None
|
||||
SMTP_PORT: int = 587
|
||||
SMTP_USER: Optional[str] = None
|
||||
SMTP_PASS: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = True
|
||||
|
||||
|
||||
settings = Settings()
|
||||
57
backend/app/core/security.py
Normal file
57
backend/app/core/security.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Any
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from fastapi import HTTPException, status, Depends
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from app.core.config import settings
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.API_V1_STR}/auth/login")
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return pwd_context.verify(plain, hashed)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||
to_encode = data.copy()
|
||||
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES))
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict:
|
||||
try:
|
||||
return jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
except JWTError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
async def get_current_user_payload(token: str = Depends(oauth2_scheme)) -> dict:
|
||||
return decode_token(token)
|
||||
|
||||
|
||||
def require_roles(*roles: str):
|
||||
"""Dependency factory — require one of the specified roles."""
|
||||
async def role_checker(payload: dict = Depends(get_current_user_payload)):
|
||||
if payload.get("role") not in roles:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Insufficient permissions"
|
||||
)
|
||||
return payload
|
||||
return role_checker
|
||||
|
||||
|
||||
require_executive_or_above = require_roles("executive", "it_admin", "trustos_admin")
|
||||
require_it_or_above = require_roles("it_admin", "trustos_admin")
|
||||
require_admin = require_roles("trustos_admin")
|
||||
0
backend/app/db/__init__.py
Normal file
0
backend/app/db/__init__.py
Normal file
27
backend/app/db/session.py
Normal file
27
backend/app/db/session.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from app.core.config import settings
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=False,
|
||||
pool_pre_ping=True,
|
||||
pool_size=10,
|
||||
max_overflow=20,
|
||||
)
|
||||
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
engine, class_=AsyncSession, expire_on_commit=False
|
||||
)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
async def get_db() -> AsyncSession:
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
47
backend/app/main.py
Normal file
47
backend/app/main.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.session import engine
|
||||
from app.models.models import Base
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Create tables on startup (dev only — use Alembic in production)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.PROJECT_NAME,
|
||||
version=settings.VERSION,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:3000", "http://frontend:3000"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# ─── Routes ───────────────────────────────────────────────────────────────────
|
||||
from app.api.routes import auth, dashboard, findings, reports, attack_paths, footprint, ai as ai_routes
|
||||
|
||||
app.include_router(auth.router, prefix=settings.API_V1_STR)
|
||||
app.include_router(dashboard.router, prefix=settings.API_V1_STR)
|
||||
app.include_router(findings.router, prefix=settings.API_V1_STR)
|
||||
app.include_router(reports.router, prefix=settings.API_V1_STR)
|
||||
app.include_router(attack_paths.router, prefix=settings.API_V1_STR)
|
||||
app.include_router(footprint.router, prefix=settings.API_V1_STR)
|
||||
app.include_router(ai_routes.router, prefix=settings.API_V1_STR)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok", "service": settings.PROJECT_NAME, "version": settings.VERSION}
|
||||
0
backend/app/models/__init__.py
Normal file
0
backend/app/models/__init__.py
Normal file
275
backend/app/models/models.py
Normal file
275
backend/app/models/models.py
Normal file
@@ -0,0 +1,275 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, DateTime, Text, Float, Integer, Boolean, ForeignKey, Enum as SAEnum
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from app.db.session import Base
|
||||
import enum
|
||||
|
||||
|
||||
class UserRole(str, enum.Enum):
|
||||
executive = "executive"
|
||||
it_admin = "it_admin"
|
||||
trustos_admin = "trustos_admin"
|
||||
|
||||
|
||||
class FindingSeverity(str, enum.Enum):
|
||||
critical = "critical"
|
||||
high = "high"
|
||||
medium = "medium"
|
||||
low = "low"
|
||||
info = "info"
|
||||
|
||||
|
||||
class FindingStatus(str, enum.Enum):
|
||||
open = "open"
|
||||
in_progress = "in_progress"
|
||||
resolved = "resolved"
|
||||
verified = "verified"
|
||||
accepted_risk = "accepted_risk"
|
||||
|
||||
|
||||
class FindingCategory(str, enum.Enum):
|
||||
external_exposure = "external_exposure"
|
||||
cloud_posture = "cloud_posture"
|
||||
credential_exposure = "credential_exposure"
|
||||
digital_footprint = "digital_footprint"
|
||||
web_application = "web_application"
|
||||
network = "network"
|
||||
identity = "identity"
|
||||
third_party = "third_party"
|
||||
compliance = "compliance"
|
||||
other = "other"
|
||||
|
||||
|
||||
class AssetType(str, enum.Enum):
|
||||
domain = "domain"
|
||||
ip_address = "ip_address"
|
||||
cloud_resource = "cloud_resource"
|
||||
email_account = "email_account"
|
||||
executive = "executive"
|
||||
web_application = "web_application"
|
||||
api_endpoint = "api_endpoint"
|
||||
repository = "repository"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────
|
||||
# Tenant (client organization)
|
||||
# ─────────────────────────────────────────
|
||||
class Tenant(Base):
|
||||
__tablename__ = "tenants"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
|
||||
industry: Mapped[str | None] = mapped_column(String(100))
|
||||
size_range: Mapped[str | None] = mapped_column(String(50)) # e.g. "50-200"
|
||||
contact_email: Mapped[str | None] = mapped_column(String(255))
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
users: Mapped[list["User"]] = relationship("User", back_populates="tenant", lazy="select")
|
||||
assets: Mapped[list["Asset"]] = relationship("Asset", back_populates="tenant", lazy="select")
|
||||
findings: Mapped[list["Finding"]] = relationship("Finding", back_populates="tenant", lazy="select")
|
||||
risk_scores: Mapped[list["RiskScore"]] = relationship("RiskScore", back_populates="tenant", lazy="select")
|
||||
audit_reports: Mapped[list["AuditReport"]] = relationship("AuditReport", back_populates="tenant", lazy="select")
|
||||
executives: Mapped[list["Executive"]] = relationship("Executive", back_populates="tenant", lazy="select")
|
||||
authorized_assets: Mapped[list["AuthorizedAsset"]] = relationship("AuthorizedAsset", back_populates="tenant", lazy="select")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────
|
||||
# User
|
||||
# ─────────────────────────────────────────
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), ForeignKey("tenants.id"), nullable=False)
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
||||
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
full_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
role: Mapped[UserRole] = mapped_column(SAEnum(UserRole), default=UserRole.it_admin)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
last_login: Mapped[datetime | None] = mapped_column(DateTime)
|
||||
|
||||
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="users")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────
|
||||
# Authorized Asset Scope (scope-lock)
|
||||
# ─────────────────────────────────────────
|
||||
class AuthorizedAsset(Base):
|
||||
__tablename__ = "authorized_assets"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), ForeignKey("tenants.id"), nullable=False)
|
||||
value: Mapped[str] = mapped_column(String(500), nullable=False) # domain, CIDR, cloud account ID
|
||||
asset_type: Mapped[AssetType] = mapped_column(SAEnum(AssetType), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
scope_agreement_ref: Mapped[str | None] = mapped_column(String(255)) # file path or reference
|
||||
authorized_by: Mapped[str | None] = mapped_column(String(255)) # email of authorizing contact
|
||||
authorized_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="authorized_assets")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────
|
||||
# Asset (discovered asset record)
|
||||
# ─────────────────────────────────────────
|
||||
class Asset(Base):
|
||||
__tablename__ = "assets"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), ForeignKey("tenants.id"), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
asset_type: Mapped[AssetType] = mapped_column(SAEnum(AssetType), nullable=False)
|
||||
value: Mapped[str] = mapped_column(String(500), nullable=False) # FQDN, IP, ARN, etc.
|
||||
owner_name: Mapped[str | None] = mapped_column(String(255))
|
||||
owner_email: Mapped[str | None] = mapped_column(String(255))
|
||||
tags: Mapped[str | None] = mapped_column(Text) # JSON array of tags
|
||||
notes: Mapped[str | None] = mapped_column(Text)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
first_seen: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
last_seen: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="assets")
|
||||
findings: Mapped[list["Finding"]] = relationship("Finding", back_populates="asset", lazy="select")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────
|
||||
# Executive (for digital footprint tracking)
|
||||
# ─────────────────────────────────────────
|
||||
class Executive(Base):
|
||||
__tablename__ = "executives"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), ForeignKey("tenants.id"), nullable=False)
|
||||
full_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
title: Mapped[str | None] = mapped_column(String(255))
|
||||
corporate_email: Mapped[str | None] = mapped_column(String(255))
|
||||
is_enrolled: Mapped[bool] = mapped_column(Boolean, default=True) # explicit consent
|
||||
notes: Mapped[str | None] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="executives")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────
|
||||
# Finding
|
||||
# ─────────────────────────────────────────
|
||||
class Finding(Base):
|
||||
__tablename__ = "findings"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), ForeignKey("tenants.id"), nullable=False)
|
||||
asset_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("assets.id"))
|
||||
executive_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("executives.id"))
|
||||
|
||||
title: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
severity: Mapped[FindingSeverity] = mapped_column(SAEnum(FindingSeverity), nullable=False)
|
||||
status: Mapped[FindingStatus] = mapped_column(SAEnum(FindingStatus), default=FindingStatus.open)
|
||||
category: Mapped[FindingCategory] = mapped_column(SAEnum(FindingCategory), nullable=False)
|
||||
|
||||
# Technical fields (IT Admin view)
|
||||
technical_description: Mapped[str | None] = mapped_column(Text)
|
||||
cve_id: Mapped[str | None] = mapped_column(String(30))
|
||||
cvss_score: Mapped[float | None] = mapped_column(Float)
|
||||
affected_component: Mapped[str | None] = mapped_column(String(500))
|
||||
evidence: Mapped[str | None] = mapped_column(Text) # raw evidence
|
||||
|
||||
# AI-translated fields (Executive view)
|
||||
ai_summary: Mapped[str | None] = mapped_column(Text) # plain-English summary
|
||||
ai_business_impact: Mapped[str | None] = mapped_column(Text)
|
||||
ai_impact_level: Mapped[str | None] = mapped_column(String(20)) # Low/Medium/High/Critical
|
||||
ai_remediation_steps: Mapped[str | None] = mapped_column(Text)
|
||||
ai_fix_priority: Mapped[str | None] = mapped_column(String(20)) # urgent/soon/planned
|
||||
ai_generated_at: Mapped[datetime | None] = mapped_column(DateTime)
|
||||
|
||||
# Remediation tracking
|
||||
assignee_email: Mapped[str | None] = mapped_column(String(255))
|
||||
due_date: Mapped[datetime | None] = mapped_column(DateTime)
|
||||
resolution_note: Mapped[str | None] = mapped_column(Text)
|
||||
resolved_at: Mapped[datetime | None] = mapped_column(DateTime)
|
||||
verified_at: Mapped[datetime | None] = mapped_column(DateTime)
|
||||
|
||||
# Metadata
|
||||
source: Mapped[str | None] = mapped_column(String(100)) # "manual", "scanner", "hibp", "cve_monitor"
|
||||
is_top_risk: Mapped[bool] = mapped_column(Boolean, default=False) # flagged as a Top 3 risk
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="findings")
|
||||
asset: Mapped["Asset"] = relationship("Asset", back_populates="findings")
|
||||
attack_paths: Mapped[list["AttackPath"]] = relationship("AttackPath", back_populates="finding", lazy="select")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────
|
||||
# Risk Score (daily snapshot)
|
||||
# ─────────────────────────────────────────
|
||||
class RiskScore(Base):
|
||||
__tablename__ = "risk_scores"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), ForeignKey("tenants.id"), nullable=False)
|
||||
score_date: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
overall_score: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
|
||||
# Category scores
|
||||
score_identity: Mapped[float | None] = mapped_column(Float)
|
||||
score_cloud: Mapped[float | None] = mapped_column(Float)
|
||||
score_network: Mapped[float | None] = mapped_column(Float)
|
||||
score_web: Mapped[float | None] = mapped_column(Float)
|
||||
score_credential: Mapped[float | None] = mapped_column(Float)
|
||||
score_digital_footprint: Mapped[float | None] = mapped_column(Float)
|
||||
score_third_party: Mapped[float | None] = mapped_column(Float)
|
||||
|
||||
# Counts at time of snapshot
|
||||
critical_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
high_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
medium_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
low_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
notes: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="risk_scores")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────
|
||||
# Attack Path
|
||||
# ─────────────────────────────────────────
|
||||
class AttackPath(Base):
|
||||
__tablename__ = "attack_paths"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
finding_id: Mapped[str] = mapped_column(String(36), ForeignKey("findings.id"), nullable=False)
|
||||
title: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
ai_narrative: Mapped[str | None] = mapped_column(Text)
|
||||
nodes_json: Mapped[str | None] = mapped_column(Text) # JSON: [{id, label, type, risk_level}]
|
||||
edges_json: Mapped[str | None] = mapped_column(Text) # JSON: [{source, target}]
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
finding: Mapped["Finding"] = relationship("Finding", back_populates="attack_paths")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────
|
||||
# Audit Report (Phase 1 deliverable)
|
||||
# ─────────────────────────────────────────
|
||||
class AuditReport(Base):
|
||||
__tablename__ = "audit_reports"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), ForeignKey("tenants.id"), nullable=False)
|
||||
title: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
report_date: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
|
||||
baseline_score: Mapped[float | None] = mapped_column(Float)
|
||||
executive_summary: Mapped[str | None] = mapped_column(Text)
|
||||
scope_description: Mapped[str | None] = mapped_column(Text)
|
||||
key_findings_json: Mapped[str | None] = mapped_column(Text) # JSON snapshot of top findings
|
||||
pdf_path: Mapped[str | None] = mapped_column(String(500))
|
||||
is_baseline: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
generated_by: Mapped[str | None] = mapped_column(String(255)) # admin email
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="audit_reports")
|
||||
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
202
backend/app/schemas/schemas.py
Normal file
202
backend/app/schemas/schemas.py
Normal file
@@ -0,0 +1,202 @@
|
||||
from pydantic import BaseModel, EmailStr, field_validator
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from app.models.models import UserRole, FindingSeverity, FindingStatus, FindingCategory, AssetType
|
||||
|
||||
|
||||
# ─── Auth ────────────────────────────────────────────────────────────────────
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
role: str
|
||||
tenant_id: str
|
||||
full_name: str
|
||||
|
||||
|
||||
class UserMe(BaseModel):
|
||||
id: str
|
||||
email: str
|
||||
full_name: str
|
||||
role: UserRole
|
||||
tenant_id: str
|
||||
|
||||
|
||||
# ─── Tenant ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class TenantBase(BaseModel):
|
||||
name: str
|
||||
slug: str
|
||||
industry: Optional[str] = None
|
||||
size_range: Optional[str] = None
|
||||
contact_email: Optional[str] = None
|
||||
|
||||
|
||||
class TenantCreate(TenantBase):
|
||||
pass
|
||||
|
||||
|
||||
class TenantOut(TenantBase):
|
||||
id: str
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ─── Finding ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class FindingOut(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
asset_id: Optional[str] = None
|
||||
title: str
|
||||
severity: FindingSeverity
|
||||
status: FindingStatus
|
||||
category: FindingCategory
|
||||
technical_description: Optional[str] = None
|
||||
cve_id: Optional[str] = None
|
||||
cvss_score: Optional[float] = None
|
||||
affected_component: Optional[str] = None
|
||||
ai_summary: Optional[str] = None
|
||||
ai_business_impact: Optional[str] = None
|
||||
ai_impact_level: Optional[str] = None
|
||||
ai_remediation_steps: Optional[str] = None
|
||||
ai_fix_priority: Optional[str] = None
|
||||
assignee_email: Optional[str] = None
|
||||
due_date: Optional[datetime] = None
|
||||
is_top_risk: bool
|
||||
source: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class FindingCreate(BaseModel):
|
||||
title: str
|
||||
severity: FindingSeverity
|
||||
category: FindingCategory
|
||||
technical_description: Optional[str] = None
|
||||
cve_id: Optional[str] = None
|
||||
cvss_score: Optional[float] = None
|
||||
affected_component: Optional[str] = None
|
||||
asset_id: Optional[str] = None
|
||||
executive_id: Optional[str] = None
|
||||
source: str = "manual"
|
||||
|
||||
|
||||
class FindingStatusUpdate(BaseModel):
|
||||
status: FindingStatus
|
||||
resolution_note: Optional[str] = None
|
||||
assignee_email: Optional[str] = None
|
||||
due_date: Optional[datetime] = None
|
||||
|
||||
|
||||
# ─── Risk Score ───────────────────────────────────────────────────────────────
|
||||
|
||||
class RiskScoreOut(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
score_date: datetime
|
||||
overall_score: float
|
||||
score_identity: Optional[float] = None
|
||||
score_cloud: Optional[float] = None
|
||||
score_network: Optional[float] = None
|
||||
score_web: Optional[float] = None
|
||||
score_credential: Optional[float] = None
|
||||
score_digital_footprint: Optional[float] = None
|
||||
score_third_party: Optional[float] = None
|
||||
critical_count: int
|
||||
high_count: int
|
||||
medium_count: int
|
||||
low_count: int
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ─── Dashboard ────────────────────────────────────────────────────────────────
|
||||
|
||||
class RiskCardData(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
ai_summary: Optional[str]
|
||||
ai_business_impact: Optional[str]
|
||||
ai_impact_level: Optional[str]
|
||||
ai_fix_priority: Optional[str]
|
||||
severity: str
|
||||
category: str
|
||||
|
||||
|
||||
class DashboardResponse(BaseModel):
|
||||
tenant_name: str
|
||||
current_score: float
|
||||
previous_score: Optional[float]
|
||||
score_delta: Optional[float]
|
||||
score_trend: List[dict] # [{date, score}]
|
||||
top_risks: List[RiskCardData]
|
||||
open_critical: int
|
||||
open_high: int
|
||||
open_medium: int
|
||||
total_open: int
|
||||
baseline_score: Optional[float] = None
|
||||
baseline_date: Optional[datetime] = None
|
||||
|
||||
|
||||
# ─── Audit Report ─────────────────────────────────────────────────────────────
|
||||
|
||||
class AuditReportOut(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
title: str
|
||||
report_date: datetime
|
||||
baseline_score: Optional[float]
|
||||
executive_summary: Optional[str]
|
||||
scope_description: Optional[str]
|
||||
pdf_path: Optional[str]
|
||||
is_baseline: bool
|
||||
generated_by: Optional[str]
|
||||
created_at: datetime
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AuditReportCreate(BaseModel):
|
||||
title: str
|
||||
executive_summary: Optional[str] = None
|
||||
scope_description: Optional[str] = None
|
||||
|
||||
|
||||
# ─── Attack Path ──────────────────────────────────────────────────────────────
|
||||
|
||||
class AttackPathOut(BaseModel):
|
||||
id: str
|
||||
finding_id: str
|
||||
title: str
|
||||
ai_narrative: Optional[str]
|
||||
nodes_json: Optional[str]
|
||||
edges_json: Optional[str]
|
||||
created_at: datetime
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ─── Authorized Asset ─────────────────────────────────────────────────────────
|
||||
|
||||
class AuthorizedAssetCreate(BaseModel):
|
||||
value: str
|
||||
asset_type: AssetType
|
||||
description: Optional[str] = None
|
||||
authorized_by: str
|
||||
|
||||
|
||||
class AuthorizedAssetOut(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
value: str
|
||||
asset_type: AssetType
|
||||
description: Optional[str]
|
||||
authorized_by: Optional[str]
|
||||
authorized_at: datetime
|
||||
is_active: bool
|
||||
model_config = {"from_attributes": True}
|
||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
189
backend/app/services/ai_translator.py
Normal file
189
backend/app/services/ai_translator.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
AI Risk Translator — calls OpenAI/Anthropic to generate plain-English
|
||||
business-impact explanations for security findings.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.models import Finding, AttackPath
|
||||
from sqlalchemy import select
|
||||
import json
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TRANSLATION_SYSTEM_PROMPT = """You are TrustOS, an AI cyber resilience advisor.
|
||||
Your role is to translate technical cybersecurity findings into clear, plain-English
|
||||
business impact statements for executive and non-technical audiences.
|
||||
|
||||
Rules:
|
||||
- Never use CVE IDs, CVSS scores, or technical jargon in the executive summary
|
||||
- Always frame risk in terms of business impact: customers, revenue, operations, reputation
|
||||
- Be direct and calm — not alarmist, not dismissive
|
||||
- Always provide a clear recommended action
|
||||
- Output must be valid JSON matching the schema provided
|
||||
|
||||
Output JSON schema:
|
||||
{
|
||||
"summary": "One sentence: what this is in plain English",
|
||||
"business_impact": "1-2 sentences: what could happen to the business if exploited",
|
||||
"impact_level": "Low|Medium|High|Critical",
|
||||
"remediation_steps": "3-5 concrete steps to fix this, numbered",
|
||||
"fix_priority": "urgent|soon|planned"
|
||||
}"""
|
||||
|
||||
|
||||
async def _call_llm(prompt: str) -> Optional[str]:
|
||||
"""Call the configured LLM provider. Returns raw text response."""
|
||||
from app.core.config import settings
|
||||
try:
|
||||
if settings.AI_PROVIDER == "openai" and settings.OPENAI_API_KEY:
|
||||
from openai import AsyncOpenAI
|
||||
client = AsyncOpenAI(api_key=settings.OPENAI_API_KEY)
|
||||
resp = await client.chat.completions.create(
|
||||
model="gpt-4o-mini",
|
||||
messages=[
|
||||
{"role": "system", "content": TRANSLATION_SYSTEM_PROMPT},
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
temperature=0.3,
|
||||
response_format={"type": "json_object"},
|
||||
)
|
||||
return resp.choices[0].message.content
|
||||
elif settings.AI_PROVIDER == "anthropic" and settings.ANTHROPIC_API_KEY:
|
||||
from anthropic import AsyncAnthropic
|
||||
client = AsyncAnthropic(api_key=settings.ANTHROPIC_API_KEY)
|
||||
resp = await client.messages.create(
|
||||
model="claude-3-haiku-20240307",
|
||||
max_tokens=1024,
|
||||
system=TRANSLATION_SYSTEM_PROMPT,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
return resp.content[0].text
|
||||
else:
|
||||
logger.warning("No AI provider configured — skipping translation")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"LLM call failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def translate_finding_async(finding_id: str):
|
||||
"""Background task: generate AI translation for a finding and persist it."""
|
||||
async with AsyncSessionLocal() as db:
|
||||
result = await db.execute(select(Finding).where(Finding.id == finding_id))
|
||||
finding = result.scalar_one_or_none()
|
||||
if not finding:
|
||||
return
|
||||
|
||||
prompt = f"""Translate this cybersecurity finding:
|
||||
|
||||
Title: {finding.title}
|
||||
Severity: {finding.severity.value}
|
||||
Category: {finding.category.value}
|
||||
CVE ID: {finding.cve_id or 'N/A'}
|
||||
CVSS Score: {finding.cvss_score or 'N/A'}
|
||||
Technical Description: {finding.technical_description or 'Not provided'}
|
||||
Affected Component: {finding.affected_component or 'Unknown'}
|
||||
|
||||
Provide the JSON output as specified."""
|
||||
|
||||
raw = await _call_llm(prompt)
|
||||
if not raw:
|
||||
return
|
||||
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
finding.ai_summary = data.get("summary")
|
||||
finding.ai_business_impact = data.get("business_impact")
|
||||
finding.ai_impact_level = data.get("impact_level")
|
||||
finding.ai_remediation_steps = data.get("remediation_steps")
|
||||
finding.ai_fix_priority = data.get("fix_priority")
|
||||
finding.ai_generated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
logger.info(f"AI translation complete for finding {finding_id}")
|
||||
except (json.JSONDecodeError, KeyError) as e:
|
||||
logger.error(f"Failed to parse AI response for finding {finding_id}: {e}")
|
||||
|
||||
|
||||
async def answer_finding_question(finding: Finding, question: str) -> str:
|
||||
"""AI Security Coach: answer a specific question about a finding."""
|
||||
prompt = f"""A security professional is asking about this finding:
|
||||
|
||||
Title: {finding.title}
|
||||
Summary: {finding.ai_summary or finding.technical_description}
|
||||
Business Impact: {finding.ai_business_impact or 'See technical description'}
|
||||
Category: {finding.category.value}
|
||||
|
||||
Their question: {question}
|
||||
|
||||
Answer in 2-4 sentences. Be specific to this finding. Use plain English."""
|
||||
|
||||
system = "You are TrustOS AI Security Coach. Answer questions about specific security findings clearly and directly. Do not use CVE IDs or CVSS in your answers."
|
||||
|
||||
from app.core.config import settings
|
||||
try:
|
||||
if settings.AI_PROVIDER == "openai" and settings.OPENAI_API_KEY:
|
||||
from openai import AsyncOpenAI
|
||||
client = AsyncOpenAI(api_key=settings.OPENAI_API_KEY)
|
||||
resp = await client.chat.completions.create(
|
||||
model="gpt-4o-mini",
|
||||
messages=[
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
temperature=0.5,
|
||||
)
|
||||
return resp.choices[0].message.content
|
||||
except Exception as e:
|
||||
logger.error(f"AI coach call failed: {e}")
|
||||
|
||||
return "AI explanation is not available. Please review the technical description and remediation steps."
|
||||
|
||||
|
||||
async def generate_attack_path_narrative(finding_id: str):
|
||||
"""Generate an AI-written attack path narrative for a finding."""
|
||||
async with AsyncSessionLocal() as db:
|
||||
result = await db.execute(select(Finding).where(Finding.id == finding_id))
|
||||
finding = result.scalar_one_or_none()
|
||||
if not finding:
|
||||
return
|
||||
|
||||
prompt = f"""Create an attack path for this vulnerability:
|
||||
|
||||
Title: {finding.title}
|
||||
Summary: {finding.ai_summary or finding.technical_description}
|
||||
Category: {finding.category.value}
|
||||
Severity: {finding.severity.value}
|
||||
|
||||
Provide:
|
||||
1. A plain-English narrative (2-3 sentences): how an attacker could exploit this path from the internet to sensitive data
|
||||
2. A JSON list of nodes: [{{"id": "1", "label": "Internet", "type": "attacker", "risk_level": "none"}}, ...]
|
||||
- types: attacker, entry_point, pivot, target
|
||||
- risk_level: none, low, medium, high, critical
|
||||
3. A JSON list of edges: [{{"source": "1", "target": "2"}}, ...]
|
||||
|
||||
Output JSON:
|
||||
{{
|
||||
"narrative": "...",
|
||||
"nodes": [...],
|
||||
"edges": [...]
|
||||
}}"""
|
||||
|
||||
raw = await _call_llm(prompt)
|
||||
if not raw:
|
||||
return
|
||||
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
path = AttackPath(
|
||||
finding_id=finding_id,
|
||||
title=f"Attack path: {finding.title}",
|
||||
ai_narrative=data.get("narrative"),
|
||||
nodes_json=json.dumps(data.get("nodes", [])),
|
||||
edges_json=json.dumps(data.get("edges", [])),
|
||||
)
|
||||
db.add(path)
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"Attack path generation failed for {finding_id}: {e}")
|
||||
139
backend/app/services/report_generator.py
Normal file
139
backend/app/services/report_generator.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
PDF report generator for Vault Audit Reports.
|
||||
Uses Jinja2 + WeasyPrint to produce branded PDFs.
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from jinja2 import Environment, PackageLoader, select_autoescape, DictLoader
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.models import AuditReport, Tenant, Finding, FindingStatus
|
||||
from app.core.config import settings
|
||||
from sqlalchemy import select, desc
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
REPORT_HTML_TEMPLATE = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
body { font-family: 'Segoe UI', sans-serif; color: #1f2328; background: #ffffff; margin: 40px; }
|
||||
.header { border-bottom: 3px solid #1a1f2e; padding-bottom: 20px; margin-bottom: 30px; }
|
||||
.logo { font-size: 28px; font-weight: 800; color: #1a1f2e; letter-spacing: -1px; }
|
||||
.logo span { color: #3b82d4; }
|
||||
.report-title { font-size: 22px; font-weight: 600; margin-top: 10px; }
|
||||
.meta { color: #57606a; font-size: 13px; margin-top: 6px; }
|
||||
.score-block { background: #1a1f2e; color: white; padding: 24px 30px; border-radius: 8px; margin: 24px 0; display: inline-block; min-width: 200px; }
|
||||
.score-value { font-size: 52px; font-weight: 800; color: #3b82d4; line-height: 1; }
|
||||
.score-label { font-size: 13px; color: #94a3b8; margin-top: 4px; }
|
||||
h2 { font-size: 18px; font-weight: 700; color: #1a1f2e; border-left: 4px solid #3b82d4; padding-left: 12px; margin-top: 32px; }
|
||||
.finding { border: 1px solid #e5e7eb; border-radius: 6px; padding: 16px; margin: 12px 0; }
|
||||
.finding.critical { border-left: 4px solid #dc2626; }
|
||||
.finding.high { border-left: 4px solid #ea580c; }
|
||||
.finding.medium { border-left: 4px solid #d97706; }
|
||||
.finding.low { border-left: 4px solid #65a30d; }
|
||||
.finding-title { font-weight: 600; font-size: 15px; }
|
||||
.finding-summary { color: #374151; margin-top: 6px; font-size: 13px; }
|
||||
.badge { display: inline-block; padding: 2px 10px; border-radius: 20px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; margin-left: 8px; }
|
||||
.badge.critical { background: #fee2e2; color: #991b1b; }
|
||||
.badge.high { background: #ffedd5; color: #9a3412; }
|
||||
.badge.medium { background: #fef3c7; color: #92400e; }
|
||||
.badge.low { background: #dcfce7; color: #166534; }
|
||||
.exec-summary { background: #f7f8fa; border-left: 3px solid #3b82d4; padding: 16px 20px; margin: 20px 0; font-size: 14px; line-height: 1.6; }
|
||||
.footer { margin-top: 60px; padding-top: 16px; border-top: 1px solid #e5e7eb; font-size: 11px; color: #57606a; text-align: center; }
|
||||
.confidential { background: #fef3c7; border: 1px solid #fcd34d; padding: 8px 16px; font-size: 12px; color: #78350f; border-radius: 4px; margin-bottom: 20px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="confidential">⚠ CONFIDENTIAL — This report contains sensitive security information. Do not distribute without authorization.</div>
|
||||
<div class="header">
|
||||
<div class="logo">Trust<span>OS</span></div>
|
||||
<div class="report-title">{{ report.title }}</div>
|
||||
<div class="meta">Vault Audit Report · {{ tenant.name }} · Generated {{ report.report_date.strftime('%B %d, %Y') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="score-block">
|
||||
<div class="score-value">{{ report.baseline_score | int }}</div>
|
||||
<div class="score-label">Cyber Health Score at Audit Date<br><small>100 = Optimal · 0 = Critical Risk</small></div>
|
||||
</div>
|
||||
|
||||
{% if report.executive_summary %}
|
||||
<h2>Executive Summary</h2>
|
||||
<div class="exec-summary">{{ report.executive_summary }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if report.scope_description %}
|
||||
<h2>Scope</h2>
|
||||
<p style="font-size:14px; line-height:1.6;">{{ report.scope_description }}</p>
|
||||
{% endif %}
|
||||
|
||||
<h2>Key Findings</h2>
|
||||
{% for f in findings %}
|
||||
<div class="finding {{ f.severity }}">
|
||||
<div class="finding-title">{{ f.title }} <span class="badge {{ f.severity }}">{{ f.severity | upper }}</span></div>
|
||||
{% if f.ai_summary %}
|
||||
<div class="finding-summary">{{ f.ai_summary }}</div>
|
||||
{% endif %}
|
||||
{% if f.ai_business_impact %}
|
||||
<div class="finding-summary" style="margin-top:8px; color:#6b7280;"><strong>Business Impact:</strong> {{ f.ai_business_impact }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="footer">
|
||||
TrustOS · The AI Operating System for Cyber Resilience · trustos.com<br>
|
||||
This report is a point-in-time assessment. Continuous monitoring is required to maintain current accuracy.
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
async def generate_pdf_for_report(report_id: str):
|
||||
"""Generate a branded PDF for a Vault Audit Report and store the path."""
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
r_result = await db.execute(select(AuditReport).where(AuditReport.id == report_id))
|
||||
report = r_result.scalar_one_or_none()
|
||||
if not report:
|
||||
return
|
||||
|
||||
t_result = await db.execute(select(Tenant).where(Tenant.id == report.tenant_id))
|
||||
tenant = t_result.scalar_one_or_none()
|
||||
|
||||
# Get findings snapshot
|
||||
f_result = await db.execute(
|
||||
select(Finding)
|
||||
.where(
|
||||
Finding.tenant_id == report.tenant_id,
|
||||
Finding.status.in_([FindingStatus.open, FindingStatus.in_progress])
|
||||
)
|
||||
.order_by(Finding.created_at)
|
||||
.limit(20)
|
||||
)
|
||||
findings = f_result.scalars().all()
|
||||
|
||||
# Render HTML
|
||||
env = Environment(loader=DictLoader({"report.html": REPORT_HTML_TEMPLATE}))
|
||||
template = env.get_template("report.html")
|
||||
html = template.render(report=report, tenant=tenant, findings=findings)
|
||||
|
||||
# Write PDF
|
||||
storage = Path(settings.STORAGE_PATH) / "reports"
|
||||
storage.mkdir(parents=True, exist_ok=True)
|
||||
pdf_path = storage / f"vault-audit-{report_id}.pdf"
|
||||
|
||||
from weasyprint import HTML as WH
|
||||
WH(string=html).write_pdf(str(pdf_path))
|
||||
|
||||
report.pdf_path = str(pdf_path)
|
||||
await db.commit()
|
||||
logger.info(f"PDF generated: {pdf_path}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"PDF generation failed for report {report_id}: {e}")
|
||||
104
backend/app/services/risk_calculator.py
Normal file
104
backend/app/services/risk_calculator.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
Risk score calculator service.
|
||||
Score is 0–100, higher = safer (inverted from CVSS).
|
||||
Scoring: Start at 100, deduct per open finding by severity.
|
||||
"""
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.models import Finding, RiskScore, FindingStatus, FindingCategory
|
||||
|
||||
|
||||
SEVERITY_DEDUCTIONS = {
|
||||
"critical": 12,
|
||||
"high": 6,
|
||||
"medium": 2,
|
||||
"low": 0.5,
|
||||
"info": 0,
|
||||
}
|
||||
|
||||
CATEGORY_WEIGHTS = {
|
||||
"external_exposure": 1.2,
|
||||
"cloud_posture": 1.1,
|
||||
"credential_exposure": 1.15,
|
||||
"digital_footprint": 0.9,
|
||||
"web_application": 1.1,
|
||||
"network": 1.0,
|
||||
"identity": 1.1,
|
||||
"third_party": 0.8,
|
||||
"compliance": 0.7,
|
||||
"other": 0.6,
|
||||
}
|
||||
|
||||
|
||||
async def recalculate_risk_score(tenant_id: str, db: Optional[AsyncSession] = None) -> float:
|
||||
"""Recalculate and persist the risk score for a tenant. Returns the new overall score."""
|
||||
own_session = db is None
|
||||
if own_session:
|
||||
db = AsyncSessionLocal()
|
||||
|
||||
try:
|
||||
# Fetch all open/in-progress findings
|
||||
result = await db.execute(
|
||||
select(Finding).where(
|
||||
Finding.tenant_id == tenant_id,
|
||||
Finding.status.in_([FindingStatus.open, FindingStatus.in_progress])
|
||||
)
|
||||
)
|
||||
findings = result.scalars().all()
|
||||
|
||||
score = 100.0
|
||||
category_scores: dict[str, float] = {}
|
||||
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0}
|
||||
|
||||
for f in findings:
|
||||
sev = f.severity.value
|
||||
cat = f.category.value
|
||||
deduction = SEVERITY_DEDUCTIONS.get(sev, 0) * CATEGORY_WEIGHTS.get(cat, 1.0)
|
||||
score -= deduction
|
||||
if sev in severity_counts:
|
||||
severity_counts[sev] += 1
|
||||
|
||||
score = max(0.0, min(100.0, round(score, 1)))
|
||||
|
||||
# Category sub-scores
|
||||
category_map = {
|
||||
"credential_exposure": "score_credential",
|
||||
"cloud_posture": "score_cloud",
|
||||
"external_exposure": "score_network",
|
||||
"web_application": "score_web",
|
||||
"identity": "score_identity",
|
||||
"digital_footprint": "score_digital_footprint",
|
||||
"third_party": "score_third_party",
|
||||
}
|
||||
cat_deductions: dict[str, float] = {}
|
||||
for f in findings:
|
||||
col = category_map.get(f.category.value)
|
||||
if col:
|
||||
cat_deductions[col] = cat_deductions.get(col, 0) + SEVERITY_DEDUCTIONS.get(f.severity.value, 0)
|
||||
|
||||
snapshot = RiskScore(
|
||||
tenant_id=tenant_id,
|
||||
score_date=datetime.utcnow(),
|
||||
overall_score=score,
|
||||
score_identity=max(0, 100 - cat_deductions.get("score_identity", 0)),
|
||||
score_cloud=max(0, 100 - cat_deductions.get("score_cloud", 0)),
|
||||
score_network=max(0, 100 - cat_deductions.get("score_network", 0)),
|
||||
score_web=max(0, 100 - cat_deductions.get("score_web", 0)),
|
||||
score_credential=max(0, 100 - cat_deductions.get("score_credential", 0)),
|
||||
score_digital_footprint=max(0, 100 - cat_deductions.get("score_digital_footprint", 0)),
|
||||
score_third_party=max(0, 100 - cat_deductions.get("score_third_party", 0)),
|
||||
critical_count=severity_counts["critical"],
|
||||
high_count=severity_counts["high"],
|
||||
medium_count=severity_counts["medium"],
|
||||
low_count=severity_counts["low"],
|
||||
)
|
||||
db.add(snapshot)
|
||||
await db.commit()
|
||||
return score
|
||||
finally:
|
||||
if own_session:
|
||||
await db.close()
|
||||
0
backend/app/workers/__init__.py
Normal file
0
backend/app/workers/__init__.py
Normal file
Reference in New Issue
Block a user