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:
25
backend/.env.example
Normal file
25
backend/.env.example
Normal file
@@ -0,0 +1,25 @@
|
||||
# Database
|
||||
DATABASE_URL=postgresql+asyncpg://trustos:trustos_dev@postgres:5432/trustos
|
||||
SYNC_DATABASE_URL=postgresql://trustos:trustos_dev@postgres:5432/trustos
|
||||
|
||||
# Auth
|
||||
SECRET_KEY=changeme-use-openssl-rand-hex-32-in-production
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=480
|
||||
|
||||
# AI
|
||||
OPENAI_API_KEY=sk-...
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
AI_PROVIDER=openai
|
||||
|
||||
# External APIs
|
||||
HIBP_API_KEY=
|
||||
NVD_API_KEY=
|
||||
|
||||
# Storage
|
||||
STORAGE_PATH=/app/storage
|
||||
|
||||
# Email (optional)
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASS=
|
||||
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
22
backend/requirements.txt
Normal file
22
backend/requirements.txt
Normal file
@@ -0,0 +1,22 @@
|
||||
fastapi==0.139.0
|
||||
uvicorn[standard]==0.50.0
|
||||
sqlalchemy==2.0.51
|
||||
alembic==1.18.5
|
||||
asyncpg==0.31.0
|
||||
psycopg2-binary==2.9.12
|
||||
pydantic==2.13.4
|
||||
pydantic-settings==2.14.2
|
||||
python-jose[cryptography]==3.5.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
python-multipart==0.0.32
|
||||
httpx==0.28.1
|
||||
openai==2.44.0
|
||||
anthropic==0.116.0
|
||||
celery==5.6.3
|
||||
redis==8.0.1
|
||||
apscheduler==3.11.3
|
||||
aiohttp==3.14.1
|
||||
aiofiles==25.1.0
|
||||
boto3==1.43.40
|
||||
jinja2==3.1.6
|
||||
weasyprint==69.0
|
||||
232
backend/seed.py
Normal file
232
backend/seed.py
Normal file
@@ -0,0 +1,232 @@
|
||||
"""
|
||||
Seed script — creates the demo tenant, users, assets, findings, and risk score history.
|
||||
Run with: python seed.py
|
||||
"""
|
||||
import asyncio
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from app.db.session import engine, AsyncSessionLocal
|
||||
from app.models.models import (
|
||||
Base, Tenant, User, UserRole, Asset, AssetType, Finding,
|
||||
FindingSeverity, FindingStatus, FindingCategory, RiskScore,
|
||||
AuthorizedAsset, Executive
|
||||
)
|
||||
from app.core.security import hash_password
|
||||
|
||||
|
||||
DEMO_FINDINGS = [
|
||||
{
|
||||
"title": "Internet-accessible admin panel with no authentication",
|
||||
"severity": FindingSeverity.critical,
|
||||
"category": FindingCategory.external_exposure,
|
||||
"technical_description": "The administrative interface at admin.acmecorp.io is exposed to the internet without any authentication requirement. An unauthenticated attacker can access full system configuration.",
|
||||
"affected_component": "admin.acmecorp.io:443",
|
||||
"ai_summary": "Your internal admin portal is accessible to anyone on the internet without a password.",
|
||||
"ai_business_impact": "An attacker could gain full control of your platform, access all customer data, and disrupt operations — potentially within minutes of discovery.",
|
||||
"ai_impact_level": "Critical",
|
||||
"ai_remediation_steps": "1. Immediately restrict admin portal access to VPN/office IPs only.\n2. Add multi-factor authentication.\n3. Audit access logs for any unauthorized access.\n4. Review what data the portal can access.",
|
||||
"ai_fix_priority": "urgent",
|
||||
"is_top_risk": True,
|
||||
"source": "manual",
|
||||
},
|
||||
{
|
||||
"title": "5 executive email accounts found in breach database",
|
||||
"severity": FindingSeverity.high,
|
||||
"category": FindingCategory.credential_exposure,
|
||||
"technical_description": "HaveIBeenPwned confirmed that 5 corporate email addresses belonging to C-suite executives (including CEO and CFO) appear in the Stealer Logs breach dataset with associated plaintext passwords.",
|
||||
"affected_component": "Corporate email / M365",
|
||||
"ai_summary": "Passwords for your senior leadership email accounts have been leaked and are available on the dark web.",
|
||||
"ai_business_impact": "Attackers could use these credentials to access email, cloud systems, and sensitive financial data — enabling targeted phishing, wire fraud, or full account takeover.",
|
||||
"ai_impact_level": "High",
|
||||
"ai_remediation_steps": "1. Force immediate password reset for all affected accounts.\n2. Enable MFA on all executive accounts if not already active.\n3. Review recent email forwarding rules and login activity.\n4. Brief executives on spear-phishing risk.",
|
||||
"ai_fix_priority": "urgent",
|
||||
"is_top_risk": True,
|
||||
"source": "hibp",
|
||||
},
|
||||
{
|
||||
"title": "S3 storage bucket publicly accessible — contains customer files",
|
||||
"severity": FindingSeverity.critical,
|
||||
"category": FindingCategory.cloud_posture,
|
||||
"technical_description": "AWS S3 bucket 'acme-customer-uploads-prod' has public read access enabled. The bucket contains customer-uploaded documents including contracts and personally identifiable information.",
|
||||
"cve_id": None,
|
||||
"affected_component": "AWS S3: acme-customer-uploads-prod",
|
||||
"ai_summary": "A cloud storage location containing your customer files is publicly accessible — anyone on the internet can read them.",
|
||||
"ai_business_impact": "This constitutes a data breach. Exposure of customer PII could trigger regulatory penalties, customer notification obligations, and severe damage to customer trust.",
|
||||
"ai_impact_level": "Critical",
|
||||
"ai_remediation_steps": "1. Immediately set bucket ACL to private.\n2. Audit which files were stored there and since when.\n3. Determine if any unauthorized access occurred via S3 access logs.\n4. Notify legal counsel — this may trigger breach notification requirements.\n5. Review all other S3 buckets for similar misconfigurations.",
|
||||
"ai_fix_priority": "urgent",
|
||||
"is_top_risk": True,
|
||||
"source": "cloud_scan",
|
||||
},
|
||||
{
|
||||
"title": "SSL/TLS certificate expiring in 12 days",
|
||||
"severity": FindingSeverity.medium,
|
||||
"category": FindingCategory.external_exposure,
|
||||
"technical_description": "The TLS certificate for api.acmecorp.io expires in 12 days. Expiration will cause browser security warnings and API failures for all customers.",
|
||||
"affected_component": "api.acmecorp.io",
|
||||
"ai_summary": "Your API security certificate is about to expire — this will break your application for customers in less than two weeks.",
|
||||
"ai_business_impact": "Customers will see security errors and be unable to use your product, resulting in direct revenue impact and support escalation.",
|
||||
"ai_impact_level": "Medium",
|
||||
"ai_remediation_steps": "1. Renew the certificate immediately via your certificate authority.\n2. Set up auto-renewal to prevent this in the future.\n3. Verify renewal across all sub-domains.",
|
||||
"ai_fix_priority": "soon",
|
||||
"is_top_risk": False,
|
||||
"source": "cert_monitor",
|
||||
},
|
||||
{
|
||||
"title": "CEO LinkedIn profile reveals unreported board membership and home city",
|
||||
"severity": FindingSeverity.medium,
|
||||
"category": FindingCategory.digital_footprint,
|
||||
"technical_description": "OSINT analysis of the CEO's public LinkedIn profile reveals home city, secondary board membership not disclosed in company materials, and personal email address listed publicly. This information could be used for targeted social engineering.",
|
||||
"affected_component": "Executive digital footprint",
|
||||
"ai_summary": "Publicly available information about your CEO can help attackers craft convincing impersonation or social engineering attacks.",
|
||||
"ai_business_impact": "Sophisticated attackers use personal information to craft targeted phishing attacks, impersonate executives in wire transfer fraud, or manipulate employees.",
|
||||
"ai_impact_level": "Medium",
|
||||
"ai_remediation_steps": "1. Review and reduce personal information on professional profiles.\n2. Brief CEO on executive-targeted social engineering tactics.\n3. Remove personal email from public profiles.\n4. Enable enhanced privacy settings on all platforms.",
|
||||
"ai_fix_priority": "soon",
|
||||
"is_top_risk": False,
|
||||
"source": "manual",
|
||||
},
|
||||
{
|
||||
"title": "Web application missing security headers (HSTS, CSP, X-Frame-Options)",
|
||||
"severity": FindingSeverity.medium,
|
||||
"category": FindingCategory.web_application,
|
||||
"technical_description": "The main web application at app.acmecorp.io is missing HTTP Strict Transport Security (HSTS), Content Security Policy (CSP), and X-Frame-Options headers. This increases susceptibility to clickjacking and protocol downgrade attacks.",
|
||||
"affected_component": "app.acmecorp.io",
|
||||
"ai_summary": "Your web application is missing basic browser security protections that prevent common attacks.",
|
||||
"ai_business_impact": "Users could be redirected to malicious sites or have their sessions hijacked through man-in-the-middle attacks, exposing customer data.",
|
||||
"ai_impact_level": "Medium",
|
||||
"ai_remediation_steps": "1. Add HSTS header with a minimum 1-year max-age.\n2. Implement a Content Security Policy.\n3. Add X-Frame-Options: DENY.\n4. Test changes in a staging environment first.",
|
||||
"ai_fix_priority": "soon",
|
||||
"is_top_risk": False,
|
||||
"source": "scanner",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def seed():
|
||||
# Ensure tables exist
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
# ── Demo Tenant ──────────────────────────────────────────────────
|
||||
tenant = Tenant(
|
||||
id="acme-corp-demo-001",
|
||||
name="Acme Corp",
|
||||
slug="acme-corp",
|
||||
industry="SaaS",
|
||||
size_range="100-300",
|
||||
contact_email="it@acmecorp.io",
|
||||
)
|
||||
db.add(tenant)
|
||||
|
||||
# ── Users ────────────────────────────────────────────────────────
|
||||
users = [
|
||||
User(
|
||||
tenant_id="acme-corp-demo-001",
|
||||
email="executive@acmecorp.io",
|
||||
hashed_password=hash_password("TrustOS2024!"),
|
||||
full_name="Sarah Chen (CEO)",
|
||||
role=UserRole.executive,
|
||||
),
|
||||
User(
|
||||
tenant_id="acme-corp-demo-001",
|
||||
email="it@acmecorp.io",
|
||||
hashed_password=hash_password("TrustOS2024!"),
|
||||
full_name="Marcus Johnson (IT Director)",
|
||||
role=UserRole.it_admin,
|
||||
),
|
||||
User(
|
||||
tenant_id="acme-corp-demo-001",
|
||||
email="admin@trustos.com",
|
||||
hashed_password=hash_password("TrustOS-Admin-2024!"),
|
||||
full_name="TrustOS Admin",
|
||||
role=UserRole.trustos_admin,
|
||||
tenant_id="acme-corp-demo-001",
|
||||
),
|
||||
]
|
||||
for u in users:
|
||||
db.add(u)
|
||||
|
||||
# ── Authorized Assets (scope-lock) ───────────────────────────────
|
||||
auth_assets = [
|
||||
AuthorizedAsset(
|
||||
tenant_id="acme-corp-demo-001",
|
||||
value="acmecorp.io",
|
||||
asset_type=AssetType.domain,
|
||||
description="Primary company domain",
|
||||
authorized_by="it@acmecorp.io",
|
||||
),
|
||||
AuthorizedAsset(
|
||||
tenant_id="acme-corp-demo-001",
|
||||
value="*.acmecorp.io",
|
||||
asset_type=AssetType.domain,
|
||||
description="All subdomains",
|
||||
authorized_by="it@acmecorp.io",
|
||||
),
|
||||
]
|
||||
for a in auth_assets:
|
||||
db.add(a)
|
||||
|
||||
# ── Assets ────────────────────────────────────────────────────────
|
||||
assets = [
|
||||
Asset(tenant_id="acme-corp-demo-001", name="Main Website", asset_type=AssetType.domain, value="acmecorp.io"),
|
||||
Asset(tenant_id="acme-corp-demo-001", name="Admin Portal", asset_type=AssetType.web_application, value="admin.acmecorp.io"),
|
||||
Asset(tenant_id="acme-corp-demo-001", name="Customer API", asset_type=AssetType.api_endpoint, value="api.acmecorp.io"),
|
||||
Asset(tenant_id="acme-corp-demo-001", name="Customer Upload Bucket", asset_type=AssetType.cloud_resource, value="s3://acme-customer-uploads-prod"),
|
||||
]
|
||||
for a in assets:
|
||||
db.add(a)
|
||||
|
||||
# ── Executives ───────────────────────────────────────────────────
|
||||
execs = [
|
||||
Executive(tenant_id="acme-corp-demo-001", full_name="Sarah Chen", title="CEO", corporate_email="sarah.chen@acmecorp.io"),
|
||||
Executive(tenant_id="acme-corp-demo-001", full_name="David Park", title="CFO", corporate_email="david.park@acmecorp.io"),
|
||||
]
|
||||
for e in execs:
|
||||
db.add(e)
|
||||
|
||||
await db.flush()
|
||||
|
||||
# ── Findings ──────────────────────────────────────────────────────
|
||||
for f_data in DEMO_FINDINGS:
|
||||
finding = Finding(tenant_id="acme-corp-demo-001", ai_generated_at=datetime.utcnow(), **f_data)
|
||||
db.add(finding)
|
||||
|
||||
await db.flush()
|
||||
|
||||
# ── Historical Risk Scores (90 days) ──────────────────────────────
|
||||
base_score = 58.0
|
||||
for i in range(90, 0, -1):
|
||||
delta = (90 - i) * 0.35
|
||||
score = min(100, round(base_score + delta + (i % 3 - 1) * 0.5, 1))
|
||||
rs = RiskScore(
|
||||
tenant_id="acme-corp-demo-001",
|
||||
score_date=datetime.utcnow() - timedelta(days=i),
|
||||
overall_score=score,
|
||||
score_identity=min(100, score + 5),
|
||||
score_cloud=max(0, score - 10),
|
||||
score_network=score,
|
||||
score_web=score + 2,
|
||||
score_credential=max(0, score - 8),
|
||||
score_digital_footprint=score + 3,
|
||||
score_third_party=score + 1,
|
||||
critical_count=3 if i > 30 else 2,
|
||||
high_count=2,
|
||||
medium_count=3,
|
||||
low_count=1,
|
||||
)
|
||||
db.add(rs)
|
||||
|
||||
await db.commit()
|
||||
print("✓ Seed complete — demo tenant 'Acme Corp' created")
|
||||
print(" executive@acmecorp.io / TrustOS2024!")
|
||||
print(" it@acmecorp.io / TrustOS2024!")
|
||||
print(" admin@trustos.com / TrustOS-Admin-2024!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(seed())
|
||||
Reference in New Issue
Block a user