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:
drjones
2026-07-05 09:46:08 +00:00
commit 463dff883b
64 changed files with 11679 additions and 0 deletions

25
backend/.env.example Normal file
View 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
View File

View File

View File

View 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}

View 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}

View 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)

View 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,
)

View 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

View 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

View 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

View File

View 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()

View 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")

View File

27
backend/app/db/session.py Normal file
View 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
View 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}

View File

View 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")

View File

View 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}

View File

View 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}")

View 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}")

View File

@@ -0,0 +1,104 @@
"""
Risk score calculator service.
Score is 0100, 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()

View File

22
backend/requirements.txt Normal file
View 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
View 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())