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

28
.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
# Python
__pycache__/
*.py[cod]
venv/
.venv/
*.env
!.env.example
# Node / Next.js
node_modules/
.next/
out/
next-env.d.ts
*.tsbuildinfo
# Storage
backend/storage/
# OS
.DS_Store
Thumbs.db
# IDE
.vscode/
.idea/
# Logs
*.log

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

41
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

5
frontend/AGENTS.md Normal file
View File

@@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->

1
frontend/CLAUDE.md Normal file
View File

@@ -0,0 +1 @@
@AGENTS.md

36
frontend/README.md Normal file
View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

17
frontend/next.config.ts Normal file
View File

@@ -0,0 +1,17 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
async rewrites() {
return [
{
source: "/api/:path*",
destination: `${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"}/api/:path*`,
},
];
},
eslint: {
ignoreDuringBuilds: true,
},
};
export default nextConfig;

7888
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
frontend/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.18",
"@radix-ui/react-dropdown-menu": "^2.1.19",
"@radix-ui/react-tabs": "^1.1.16",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.23.0",
"next": "16.2.10",
"react": "19.2.4",
"react-dom": "19.2.4",
"recharts": "^3.9.2",
"tailwind-merge": "^3.6.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.10",
"tailwindcss": "^4",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
frontend/public/file.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
frontend/public/next.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -0,0 +1,164 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { api, type DashboardData } from "@/lib/api";
import Sidebar from "@/components/Sidebar";
import RiskDial from "@/components/RiskDial";
import TopRiskCard from "@/components/TopRiskCard";
import ScoreTrend from "@/components/ScoreTrend";
import { TrendingUp, TrendingDown, Minus, AlertCircle, AlertTriangle, Activity, Calendar } from "lucide-react";
export default function DashboardPage() {
const { tenantId, role, ready } = useAuth();
const router = useRouter();
const [data, setData] = useState<DashboardData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
if (!ready) return;
if (!tenantId) { router.replace("/login"); return; }
api.dashboard(tenantId)
.then(setData)
.catch(e => setError(e.message))
.finally(() => setLoading(false));
}, [ready, tenantId]);
const DeltaIcon = !data?.score_delta ? Minus : data.score_delta > 0 ? TrendingUp : TrendingDown;
const deltaColor = !data?.score_delta ? "text-vault-muted" : data.score_delta > 0 ? "text-emerald-400" : "text-red-400";
return (
<div className="flex min-h-screen bg-vault-black">
<Sidebar />
<main className="ml-64 flex-1 p-8">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between">
<div>
<p className="text-vault-muted text-sm mb-1">
{data?.tenant_name ?? "Loading..."} · Vault Dashboard
</p>
<h1 className="text-2xl font-bold text-vault-text">Cyber Resilience Overview</h1>
</div>
{data?.baseline_date && (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-vault-surface border border-vault-border text-xs text-vault-muted">
<Calendar className="w-3.5 h-3.5" />
Audit baseline: {new Date(data.baseline_date).toLocaleDateString()}
</div>
)}
</div>
</div>
{loading && (
<div className="flex items-center justify-center h-64">
<div className="animate-spin w-8 h-8 border-2 border-vault-sapphire border-t-transparent rounded-full" />
</div>
)}
{error && (
<div className="vault-card border-vault-crimson/40 bg-vault-crimsonDim/30 text-red-300 text-sm">
{error}
</div>
)}
{data && !loading && (
<>
{/* Top row: score + stats */}
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6 mb-8">
{/* Risk Dial */}
<div className="vault-card flex flex-col items-center justify-center lg:col-span-1">
<RiskDial score={data.current_score} size={180} />
{data.score_delta !== null && (
<div className={`flex items-center gap-1.5 mt-3 text-sm font-semibold ${deltaColor}`}>
<DeltaIcon className="w-4 h-4" />
{data.score_delta > 0 ? "+" : ""}{data.score_delta?.toFixed(1)} pts this month
</div>
)}
</div>
{/* Stat cards */}
<div className="lg:col-span-3 grid grid-cols-2 sm:grid-cols-4 gap-4">
{[
{
label: "Critical Issues",
value: data.open_critical,
icon: AlertCircle,
color: data.open_critical > 0 ? "text-red-400" : "text-emerald-400",
bg: data.open_critical > 0 ? "bg-vault-crimsonDim/40 border-vault-crimson/30" : "bg-vault-emeraldDim/40 border-green-800/30",
},
{
label: "High Issues",
value: data.open_high,
icon: AlertTriangle,
color: data.open_high > 0 ? "text-orange-400" : "text-emerald-400",
bg: "bg-vault-surface",
},
{
label: "Medium Issues",
value: data.open_medium,
icon: AlertTriangle,
color: "text-amber-400",
bg: "bg-vault-surface",
},
{
label: "Total Open",
value: data.total_open,
icon: Activity,
color: "text-vault-text",
bg: "bg-vault-surface",
},
].map(({ label, value, icon: Icon, color, bg }) => (
<div key={label} className={`vault-card ${bg} flex flex-col`}>
<div className="flex items-center justify-between mb-3">
<p className="text-vault-muted text-xs font-medium">{label}</p>
<Icon className={`w-4 h-4 ${color}`} />
</div>
<p className={`text-3xl font-bold ${color}`}>{value}</p>
</div>
))}
</div>
</div>
{/* Score trend */}
<div className="vault-card mb-8">
<div className="flex items-center justify-between mb-4">
<h2 className="text-vault-text font-semibold">Risk Score 90 Day Trend</h2>
<span className="text-xs text-vault-muted">Higher is safer · 100 = optimal</span>
</div>
{data.score_trend.length > 0 ? (
<ScoreTrend data={data.score_trend} />
) : (
<p className="text-vault-muted text-sm text-center py-8">No trend data yet</p>
)}
</div>
{/* Top 3 Risks */}
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-vault-text font-semibold">
{role === "executive" ? "Top Risks Requiring Your Attention" : "Top Risks"}
</h2>
<a href="/findings" className="text-vault-sapphireLight text-xs hover:underline">
View all findings
</a>
</div>
{data.top_risks.length > 0 ? (
<div className="grid grid-cols-1 xl:grid-cols-3 gap-5">
{data.top_risks.map((risk, i) => (
<TopRiskCard key={risk.id} risk={risk} index={i} />
))}
</div>
) : (
<div className="vault-card text-center py-12 border-vault-emerald/20 bg-vault-emeraldDim/20">
<p className="text-emerald-400 font-semibold"> No active top risks flagged</p>
<p className="text-vault-muted text-sm mt-1">Continue monitoring to stay ahead of emerging threats</p>
</div>
)}
</div>
</>
)}
</main>
</div>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,245 @@
"use client";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { api, type Finding, type AttackPath } from "@/lib/api";
import Sidebar from "@/components/Sidebar";
import { ArrowLeft, MessageSquare, Send, GitBranch } from "lucide-react";
import Link from "next/link";
export default function FindingDetailPage() {
const { id } = useParams<{ id: string }>();
const { role, ready } = useAuth();
const [finding, setFinding] = useState<Finding | null>(null);
const [loading, setLoading] = useState(true);
const [attackPaths, setAttackPaths] = useState<AttackPath[]>([]);
const [question, setQuestion] = useState("");
const [answer, setAnswer] = useState("");
const [asking, setAsking] = useState(false);
const [resolveNote, setResolveNote] = useState("");
const [updating, setUpdating] = useState(false);
useEffect(() => {
if (!ready || !id) return;
api.finding(id).then(f => {
setFinding(f);
return api.attackPaths(id).then(setAttackPaths).catch(() => {});
}).finally(() => setLoading(false));
}, [ready, id]);
async function askCoach() {
if (!question.trim() || !id) return;
setAsking(true);
try {
const r = await api.aiExplain(id, question);
setAnswer(r.answer);
} catch {
setAnswer("AI coach is unavailable. Please check your API key configuration.");
} finally {
setAsking(false);
}
}
async function markResolved() {
if (!finding || !resolveNote.trim()) return;
setUpdating(true);
try {
const updated = await api.updateFindingStatus(finding.id, { status: "resolved", resolution_note: resolveNote });
setFinding(updated);
} finally {
setUpdating(false);
}
}
if (loading) return (
<div className="flex min-h-screen bg-vault-black">
<Sidebar />
<main className="ml-64 flex-1 flex items-center justify-center">
<div className="animate-spin w-8 h-8 border-2 border-vault-sapphire border-t-transparent rounded-full" />
</main>
</div>
);
if (!finding) return null;
const isExecutive = role === "executive";
return (
<div className="flex min-h-screen bg-vault-black">
<Sidebar />
<main className="ml-64 flex-1 p-8 max-w-4xl">
<Link href="/findings" className="inline-flex items-center gap-1.5 text-vault-muted text-sm hover:text-vault-text mb-6">
<ArrowLeft className="w-4 h-4" /> Back to Findings
</Link>
{/* Header */}
<div className="vault-card mb-6">
<div className="flex items-start gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 flex-wrap mb-2">
<span className={`vault-badge-${finding.severity}`}>{finding.severity.toUpperCase()}</span>
<span className="vault-badge-info">{finding.status.replace("_", " ")}</span>
{finding.is_top_risk && (
<span className="vault-badge-critical"> Top Risk</span>
)}
</div>
<h1 className="text-xl font-bold text-vault-text leading-tight">{finding.title}</h1>
{finding.affected_component && (
<p className="text-vault-muted text-sm mt-1">📍 {finding.affected_component}</p>
)}
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
{/* Executive (AI) view */}
<div className="vault-card">
<h2 className="text-vault-sapphireLight text-sm font-semibold uppercase tracking-wider mb-4">
Business Impact
</h2>
{finding.ai_summary && (
<div className="mb-4">
<p className="text-vault-text leading-relaxed">{finding.ai_summary}</p>
</div>
)}
{finding.ai_business_impact && (
<div className="bg-vault-dark rounded-lg p-3 mb-4">
<p className="text-xs text-vault-muted font-medium mb-1">Why it matters</p>
<p className="text-vault-subtle text-sm leading-relaxed">{finding.ai_business_impact}</p>
</div>
)}
{finding.ai_remediation_steps && (
<div>
<p className="text-xs text-vault-muted font-medium mb-2">Remediation Steps</p>
<pre className="text-vault-subtle text-xs leading-relaxed whitespace-pre-wrap font-sans">
{finding.ai_remediation_steps}
</pre>
</div>
)}
</div>
{/* Technical view (IT only) */}
{!isExecutive && (
<div className="vault-card">
<h2 className="text-vault-muted text-sm font-semibold uppercase tracking-wider mb-4">
Technical Details
</h2>
{finding.cve_id && (
<div className="flex items-center gap-2 mb-3">
<span className="text-xs text-vault-muted">CVE:</span>
<code className="text-vault-sapphireLight text-xs bg-vault-sapphireDim px-2 py-0.5 rounded">
{finding.cve_id}
</code>
{finding.cvss_score && (
<span className="text-vault-muted text-xs">CVSS {finding.cvss_score}</span>
)}
</div>
)}
{finding.technical_description && (
<p className="text-vault-subtle text-sm leading-relaxed mb-3">{finding.technical_description}</p>
)}
{finding.source && (
<p className="text-vault-muted text-xs">Source: <span className="text-vault-subtle">{finding.source}</span></p>
)}
</div>
)}
</div>
{/* Attack Path */}
{attackPaths.length > 0 && (
<div className="vault-card mb-6">
<div className="flex items-center gap-2 mb-3">
<GitBranch className="w-4 h-4 text-vault-sapphire" />
<h2 className="text-vault-text font-semibold">Attack Path</h2>
</div>
{attackPaths[0].ai_narrative && (
<p className="text-vault-subtle text-sm leading-relaxed mb-4">{attackPaths[0].ai_narrative}</p>
)}
{attackPaths[0].nodes_json && (() => {
try {
const nodes = JSON.parse(attackPaths[0].nodes_json);
const nodeColors: Record<string, string> = {
attacker: "bg-red-900/40 text-red-300 border-red-800/50",
entry_point: "bg-orange-900/40 text-orange-300 border-orange-800/50",
pivot: "bg-amber-900/40 text-amber-300 border-amber-800/50",
target: "bg-blue-900/40 text-blue-300 border-blue-800/50",
};
return (
<div className="flex items-center gap-2 flex-wrap">
{nodes.map((n: any, i: number) => (
<div key={n.id} className="flex items-center gap-2">
<div className={`px-3 py-1.5 rounded-lg border text-xs font-medium ${nodeColors[n.type] ?? "bg-vault-dark border-vault-border text-vault-muted"}`}>
{n.label}
</div>
{i < nodes.length - 1 && <span className="text-vault-muted"></span>}
</div>
))}
</div>
);
} catch { return null; }
})()}
</div>
)}
{/* AI Security Coach */}
<div className="vault-card mb-6">
<div className="flex items-center gap-2 mb-4">
<MessageSquare className="w-4 h-4 text-vault-sapphire" />
<h2 className="text-vault-text font-semibold">AI Security Coach</h2>
</div>
<div className="flex gap-2 mb-4">
<input
type="text"
value={question}
onChange={e => setQuestion(e.target.value)}
onKeyDown={e => e.key === "Enter" && askCoach()}
placeholder="Ask a question… e.g. 'Can ransomware use this?' or 'What is the estimated cost?'"
className="flex-1 px-3.5 py-2 rounded-lg bg-vault-dark border border-vault-border text-vault-text placeholder-vault-muted text-sm focus:outline-none focus:border-vault-sapphire transition-colors"
/>
<button onClick={askCoach} disabled={asking || !question.trim()} className="btn-primary px-3">
{asking ? <div className="animate-spin w-4 h-4 border-2 border-white border-t-transparent rounded-full" /> : <Send className="w-4 h-4" />}
</button>
</div>
{answer && (
<div className="bg-vault-dark rounded-lg p-4 border border-vault-sapphireDim">
<p className="text-xs text-vault-sapphireLight font-medium mb-2">TrustOS AI</p>
<p className="text-vault-subtle text-sm leading-relaxed">{answer}</p>
</div>
)}
<div className="flex gap-2 flex-wrap mt-3">
{["Why does this matter to our business?", "Can ransomware use this?", "How would an attacker exploit this?", "What is the estimated cost if exploited?"].map(q => (
<button key={q} onClick={() => { setQuestion(q); }} className="text-xs px-2.5 py-1 rounded-full border border-vault-border text-vault-muted hover:text-vault-text hover:border-vault-sapphire transition-colors">
{q}
</button>
))}
</div>
</div>
{/* Remediation — IT only */}
{!isExecutive && finding.status === "open" && (
<div className="vault-card">
<h2 className="text-vault-text font-semibold mb-4">Mark as Resolved</h2>
<textarea
value={resolveNote}
onChange={e => setResolveNote(e.target.value)}
placeholder="Describe how this was resolved (required)…"
rows={3}
className="w-full px-3.5 py-2.5 rounded-lg bg-vault-dark border border-vault-border text-vault-text placeholder-vault-muted text-sm focus:outline-none focus:border-vault-sapphire resize-none transition-colors mb-3"
/>
<button onClick={markResolved} disabled={!resolveNote.trim() || updating} className="btn-primary">
{updating ? "Saving..." : "Mark Resolved"}
</button>
</div>
)}
{finding.status === "resolved" && (
<div className="vault-card border-green-800/30 bg-vault-emeraldDim/20">
<p className="text-emerald-400 text-sm font-semibold"> Marked as resolved</p>
{finding.resolution_note && (
<p className="text-vault-subtle text-sm mt-1">{finding.resolution_note}</p>
)}
</div>
)}
</main>
</div>
);
}

View File

@@ -0,0 +1,168 @@
"use client";
import { useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { api, type Finding } from "@/lib/api";
import Sidebar from "@/components/Sidebar";
import Link from "next/link";
import { Shield, Filter, ArrowUpDown, CheckCircle2, Clock, AlertCircle } from "lucide-react";
const SEVERITY_ORDER = ["critical", "high", "medium", "low", "info"];
const STATUS_COLORS: Record<string, string> = {
open: "vault-badge-critical",
in_progress: "vault-badge-medium",
resolved: "vault-badge-low",
verified: "vault-badge-info",
accepted_risk: "vault-badge-info",
};
const CATEGORY_LABELS: Record<string, string> = {
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",
};
export default function FindingsPage() {
const { tenantId, role, ready } = useAuth();
const [findings, setFindings] = useState<Finding[]>([]);
const [loading, setLoading] = useState(true);
const [filterSeverity, setFilterSeverity] = useState("all");
const [filterStatus, setFilterStatus] = useState("open");
useEffect(() => {
if (!ready || !tenantId) return;
const params = [
filterSeverity !== "all" ? `severity=${filterSeverity}` : "",
filterStatus !== "all" ? `status=${filterStatus}` : "",
].filter(Boolean).join("&");
api.findings(tenantId, params)
.then(setFindings)
.finally(() => setLoading(false));
}, [ready, tenantId, filterSeverity, filterStatus]);
return (
<div className="flex min-h-screen bg-vault-black">
<Sidebar />
<main className="ml-64 flex-1 p-8">
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-vault-text flex items-center gap-2">
<Shield className="w-6 h-6 text-vault-sapphire" />
Findings
</h1>
<p className="text-vault-muted text-sm mt-0.5">All security findings across your environment</p>
</div>
<span className="text-vault-muted text-sm">{findings.length} results</span>
</div>
{/* Filters */}
<div className="flex gap-3 mb-6 flex-wrap">
<div className="flex items-center gap-2">
<Filter className="w-4 h-4 text-vault-muted" />
<span className="text-vault-muted text-sm">Filter:</span>
</div>
{["all", "critical", "high", "medium", "low"].map(s => (
<button
key={s}
onClick={() => { setFilterSeverity(s); setLoading(true); }}
className={`px-3 py-1 rounded-full text-xs font-semibold border transition-colors ${
filterSeverity === s
? "bg-vault-sapphire text-white border-vault-sapphire"
: "bg-vault-surface border-vault-border text-vault-muted hover:text-vault-text"
}`}
>
{s === "all" ? "All Severities" : s.toUpperCase()}
</button>
))}
<div className="border-l border-vault-border mx-1" />
{["all", "open", "in_progress", "resolved", "verified"].map(s => (
<button
key={s}
onClick={() => { setFilterStatus(s); setLoading(true); }}
className={`px-3 py-1 rounded-full text-xs font-semibold border transition-colors ${
filterStatus === s
? "bg-vault-sapphire text-white border-vault-sapphire"
: "bg-vault-surface border-vault-border text-vault-muted hover:text-vault-text"
}`}
>
{s === "all" ? "All Statuses" : s.replace("_", " ")}
</button>
))}
</div>
{/* Findings table */}
<div className="vault-card overflow-hidden p-0">
{loading ? (
<div className="flex items-center justify-center h-40">
<div className="animate-spin w-6 h-6 border-2 border-vault-sapphire border-t-transparent rounded-full" />
</div>
) : findings.length === 0 ? (
<div className="text-center py-16">
<CheckCircle2 className="w-10 h-10 text-emerald-400 mx-auto mb-3" />
<p className="text-vault-text font-semibold">No findings match these filters</p>
</div>
) : (
<table className="w-full">
<thead>
<tr className="border-b border-vault-border">
{["Severity", "Title", "Category", "Status", "Priority", ""].map(h => (
<th key={h} className="px-5 py-3 text-left text-xs font-semibold text-vault-muted uppercase tracking-wider">
{h}
</th>
))}
</tr>
</thead>
<tbody>
{findings.map(f => (
<tr key={f.id} className="border-b border-vault-border/50 hover:bg-vault-titanium/30 transition-colors">
<td className="px-5 py-4">
<span className={`vault-badge-${f.severity}`}>{f.severity.toUpperCase()}</span>
</td>
<td className="px-5 py-4">
<p className="text-vault-text text-sm font-medium leading-snug max-w-md">{f.title}</p>
{f.ai_summary && (
<p className="text-vault-muted text-xs mt-0.5 line-clamp-1">{f.ai_summary}</p>
)}
</td>
<td className="px-5 py-4 text-vault-muted text-xs whitespace-nowrap">
{CATEGORY_LABELS[f.category] ?? f.category}
</td>
<td className="px-5 py-4">
<span className={STATUS_COLORS[f.status] ?? "vault-badge-info"}>
{f.status.replace("_", " ")}
</span>
</td>
<td className="px-5 py-4">
{f.ai_fix_priority && (
<span className={
f.ai_fix_priority === "urgent" ? "text-red-400 text-xs font-semibold" :
f.ai_fix_priority === "soon" ? "text-amber-400 text-xs font-semibold" :
"text-vault-muted text-xs"
}>
{f.ai_fix_priority}
</span>
)}
</td>
<td className="px-5 py-4">
<Link
href={`/findings/${f.id}`}
className="text-vault-sapphireLight text-xs hover:underline whitespace-nowrap"
>
View
</Link>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,103 @@
"use client";
import { useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { api, type FootprintData } from "@/lib/api";
import Sidebar from "@/components/Sidebar";
import { Search, User, Shield, AlertTriangle } from "lucide-react";
export default function FootprintPage() {
const { tenantId, ready } = useAuth();
const [data, setData] = useState<FootprintData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!ready || !tenantId) return;
api.footprint(tenantId).then(setData).finally(() => setLoading(false));
}, [ready, tenantId]);
return (
<div className="flex min-h-screen bg-vault-black">
<Sidebar />
<main className="ml-64 flex-1 p-8">
<div className="mb-6">
<h1 className="text-2xl font-bold text-vault-text flex items-center gap-2">
<Search className="w-6 h-6 text-vault-sapphire" />
Digital Footprint Center
</h1>
<p className="text-vault-muted text-sm mt-1">
Publicly available organizational exposure authorized scope only
</p>
</div>
{loading ? (
<div className="flex items-center justify-center h-40">
<div className="animate-spin w-6 h-6 border-2 border-vault-sapphire border-t-transparent rounded-full" />
</div>
) : data && (
<>
{/* Executive Exposure */}
<div className="mb-8">
<h2 className="text-vault-text font-semibold mb-4 flex items-center gap-2">
<User className="w-4 h-4 text-vault-sapphire" />
Executive Exposure
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{data.executives.map(exec => (
<div key={exec.id} className="vault-card hover:bg-vault-titanium/50 transition-colors">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 rounded-full bg-vault-sapphire/20 border border-vault-sapphire/30 flex items-center justify-center">
<span className="text-vault-sapphireLight font-bold text-sm">
{exec.name.charAt(0)}
</span>
</div>
<div>
<p className="text-vault-text font-medium text-sm">{exec.name}</p>
<p className="text-vault-muted text-xs">{exec.title}</p>
</div>
</div>
<div className="text-xs text-vault-muted space-y-1">
<p>📧 {exec.email || "No corporate email enrolled"}</p>
</div>
</div>
))}
{data.executives.length === 0 && (
<p className="text-vault-muted text-sm col-span-3">No executives enrolled. Contact your TrustOS administrator.</p>
)}
</div>
</div>
{/* Footprint Findings */}
<div>
<h2 className="text-vault-text font-semibold mb-4 flex items-center gap-2">
<Shield className="w-4 h-4 text-vault-sapphire" />
Exposure Findings
<span className="ml-2 vault-badge-high">{data.total_exposures}</span>
</h2>
{data.footprint_findings.length === 0 ? (
<div className="vault-card text-center py-10">
<p className="text-vault-muted text-sm">No digital footprint findings recorded yet.</p>
<p className="text-vault-muted text-xs mt-1">Findings will appear here after a Vault Audit is completed.</p>
</div>
) : (
<div className="space-y-3">
{data.footprint_findings.map((f: any) => (
<div key={f.id} className="vault-card flex items-start gap-4">
<span className={`vault-badge-${f.severity} flex-shrink-0 mt-0.5`}>{f.severity.toUpperCase()}</span>
<div className="flex-1 min-w-0">
<p className="text-vault-text text-sm font-medium">{f.title}</p>
{f.ai_summary && (
<p className="text-vault-muted text-xs mt-1">{f.ai_summary}</p>
)}
</div>
<span className="vault-badge-info text-xs flex-shrink-0">{f.status.replace("_", " ")}</span>
</div>
))}
</div>
)}
</div>
</>
)}
</main>
</div>
);
}

View File

@@ -0,0 +1,52 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--font-inter: 'Inter', system-ui, sans-serif;
}
* {
box-sizing: border-box;
}
html, body {
background: #0a0d14;
color: #e2e8f0;
font-family: var(--font-inter);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: #1a1f2e; }
::-webkit-scrollbar-thumb { background: #2d3447; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #3b82d4; }
@layer components {
.vault-card {
@apply bg-vault-surface border border-vault-border rounded-xl p-6;
}
.vault-badge-critical {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-red-900/40 text-red-300 border border-red-800/50;
}
.vault-badge-high {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-orange-900/40 text-orange-300 border border-orange-800/50;
}
.vault-badge-medium {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-amber-900/40 text-amber-300 border border-amber-800/50;
}
.vault-badge-low {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-green-900/40 text-green-300 border border-green-800/50;
}
.vault-badge-info {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-blue-900/40 text-blue-300 border border-blue-800/50;
}
.btn-primary {
@apply inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-vault-sapphire text-white text-sm font-medium hover:bg-blue-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed;
}
.btn-ghost {
@apply inline-flex items-center gap-2 px-4 py-2 rounded-lg text-vault-subtle text-sm font-medium hover:bg-vault-titanium hover:text-vault-text transition-colors;
}
}

View File

@@ -0,0 +1,21 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "TrustOS — The AI Operating System for Cyber Resilience",
description: "Understand, reduce, and prove cyber risk. Continuous AI-powered resilience for growing companies.",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" className="dark">
<body className="bg-vault-black min-h-screen">
{children}
</body>
</html>
);
}

View File

@@ -0,0 +1,131 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { api, setAuthToken } from "@/lib/api";
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function handleLogin(e: React.FormEvent) {
e.preventDefault();
setError("");
setLoading(true);
try {
const data = await api.login(email, password);
setAuthToken(data.access_token);
localStorage.setItem("trustos_token", data.access_token);
localStorage.setItem("trustos_role", data.role);
localStorage.setItem("trustos_tenant_id", data.tenant_id);
localStorage.setItem("trustos_name", data.full_name);
router.push("/dashboard");
} catch (err: any) {
setError(err.message || "Invalid credentials. Please try again.");
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen bg-vault-black flex items-center justify-center px-4">
{/* Background texture */}
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,_#1a1f2e_0%,_#0a0d14_70%)] pointer-events-none" />
<div className="relative w-full max-w-md">
{/* Logo */}
<div className="text-center mb-10">
<div className="inline-flex items-center gap-2 mb-3">
<div className="w-10 h-10 rounded-xl bg-vault-sapphire/20 border border-vault-sapphire/40 flex items-center justify-center">
<svg className="w-5 h-5 text-vault-sapphire" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
</svg>
</div>
<span className="text-2xl font-bold tracking-tight text-vault-text">
Trust<span className="text-vault-sapphire">OS</span>
</span>
</div>
<p className="text-vault-muted text-sm">The AI Operating System for Cyber Resilience</p>
</div>
{/* Login Card */}
<div className="vault-card shadow-2xl">
<h1 className="text-xl font-semibold text-vault-text mb-6">Sign in to your Vault</h1>
<form onSubmit={handleLogin} className="space-y-5">
<div>
<label className="block text-sm text-vault-subtle mb-1.5">Email address</label>
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
required
placeholder="you@company.com"
className="w-full px-3.5 py-2.5 rounded-lg bg-vault-dark border border-vault-border text-vault-text placeholder-vault-muted text-sm focus:outline-none focus:border-vault-sapphire focus:ring-1 focus:ring-vault-sapphire transition-colors"
/>
</div>
<div>
<label className="block text-sm text-vault-subtle mb-1.5">Password</label>
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
required
placeholder="••••••••"
className="w-full px-3.5 py-2.5 rounded-lg bg-vault-dark border border-vault-border text-vault-text placeholder-vault-muted text-sm focus:outline-none focus:border-vault-sapphire focus:ring-1 focus:ring-vault-sapphire transition-colors"
/>
</div>
{error && (
<div className="flex items-center gap-2 p-3 rounded-lg bg-vault-crimsonDim border border-vault-crimson/40 text-red-300 text-sm">
<svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg>
{error}
</div>
)}
<button type="submit" disabled={loading} className="w-full btn-primary justify-center py-2.5 text-base">
{loading ? (
<span className="flex items-center gap-2">
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Signing in...
</span>
) : "Sign In"}
</button>
</form>
{/* Demo credentials */}
<div className="mt-6 pt-5 border-t border-vault-border">
<p className="text-xs text-vault-muted mb-3 font-medium uppercase tracking-wider">Demo Access</p>
<div className="space-y-2">
{[
{ label: "Executive (CEO)", email: "executive@acmecorp.io", pwd: "TrustOS2024!" },
{ label: "IT Admin", email: "it@acmecorp.io", pwd: "TrustOS2024!" },
{ label: "TrustOS Admin", email: "admin@trustos.com", pwd: "TrustOS-Admin-2024!" },
].map(({ label, email: e, pwd }) => (
<button
key={e}
onClick={() => { setEmail(e); setPassword(pwd); }}
className="w-full text-left px-3 py-2 rounded-lg hover:bg-vault-titanium transition-colors text-xs"
>
<span className="text-vault-sapphireLight font-medium">{label}</span>
<span className="text-vault-muted ml-2">{e}</span>
</button>
))}
</div>
</div>
</div>
<p className="text-center text-vault-muted text-xs mt-6">
Authorization required for all assessments · Privacy by design
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function Home() {
redirect("/login");
}

View File

@@ -0,0 +1,104 @@
"use client";
import { useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { api } from "@/lib/api";
import Sidebar from "@/components/Sidebar";
import { FileText, Download, CheckCircle } from "lucide-react";
interface Report {
id: string;
tenant_id: string;
title: string;
report_date: string;
baseline_score: number | null;
executive_summary: string | null;
pdf_path: string | null;
is_baseline: boolean;
generated_by: string | null;
created_at: string;
}
export default function ReportsPage() {
const { tenantId, role, ready } = useAuth();
const [reports, setReports] = useState<Report[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!ready || !tenantId || role !== "trustos_admin") {
setLoading(false);
return;
}
// Only admins see this — public endpoint for tenants to view their own would come later
fetch(`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"}/api/v1/audit-reports?tenant_id=${tenantId}`, {
headers: { Authorization: `Bearer ${localStorage.getItem("trustos_token")}` }
})
.then(r => r.json())
.then(setReports)
.catch(() => {})
.finally(() => setLoading(false));
}, [ready, tenantId, role]);
return (
<div className="flex min-h-screen bg-vault-black">
<Sidebar />
<main className="ml-64 flex-1 p-8">
<div className="mb-6">
<h1 className="text-2xl font-bold text-vault-text flex items-center gap-2">
<FileText className="w-6 h-6 text-vault-sapphire" />
Vault Audit Reports
</h1>
<p className="text-vault-muted text-sm mt-1">Point-in-time baseline reports and audit deliverables</p>
</div>
{loading ? (
<div className="flex items-center justify-center h-40">
<div className="animate-spin w-6 h-6 border-2 border-vault-sapphire border-t-transparent rounded-full" />
</div>
) : role !== "trustos_admin" ? (
<div className="vault-card">
<p className="text-vault-muted text-sm">Audit reports are managed by your TrustOS administrator.</p>
</div>
) : reports.length === 0 ? (
<div className="vault-card text-center py-12">
<FileText className="w-8 h-8 text-vault-muted mx-auto mb-3" />
<p className="text-vault-text font-semibold">No audit reports yet</p>
<p className="text-vault-muted text-sm mt-1">Generate the first Vault Audit from the admin panel.</p>
</div>
) : (
<div className="space-y-4">
{reports.map(r => (
<div key={r.id} className="vault-card">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
{r.is_baseline && (
<span className="vault-badge-info text-xs">Baseline</span>
)}
<p className="text-vault-text font-semibold">{r.title}</p>
</div>
<p className="text-vault-muted text-xs">
{new Date(r.report_date).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })}
{r.baseline_score && ` · Score at audit: ${Math.round(r.baseline_score)}`}
</p>
{r.executive_summary && (
<p className="text-vault-subtle text-sm mt-2 leading-relaxed line-clamp-2">{r.executive_summary}</p>
)}
</div>
<div className="flex items-center gap-2">
{r.pdf_path ? (
<span className="flex items-center gap-1.5 text-emerald-400 text-xs">
<CheckCircle className="w-3.5 h-3.5" /> PDF ready
</span>
) : (
<span className="text-vault-muted text-xs">PDF generating</span>
)}
</div>
</div>
</div>
))}
</div>
)}
</main>
</div>
);
}

View File

@@ -0,0 +1,86 @@
"use client";
import { useEffect, useRef } from "react";
interface RiskDialProps {
score: number;
size?: number;
}
export default function RiskDial({ score, size = 200 }: RiskDialProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
canvas.width = size * dpr;
canvas.height = size * dpr;
ctx.scale(dpr, dpr);
const cx = size / 2;
const cy = size / 2;
const radius = size * 0.38;
const startAngle = Math.PI * 0.75; // 135°
const endAngle = Math.PI * 2.25; // 405° (full 270° arc)
const valueAngle = startAngle + (endAngle - startAngle) * (score / 100);
// Background track
ctx.beginPath();
ctx.arc(cx, cy, radius, startAngle, endAngle);
ctx.strokeStyle = "#2d3447";
ctx.lineWidth = 12;
ctx.lineCap = "round";
ctx.stroke();
// Score color
const getColor = (s: number) => {
if (s >= 75) return "#3b82d4"; // sapphire — healthy
if (s >= 50) return "#d97706"; // amber — warning
return "#dc2626"; // crimson — critical
};
// Score arc
if (score > 0) {
ctx.beginPath();
ctx.arc(cx, cy, radius, startAngle, valueAngle);
ctx.strokeStyle = getColor(score);
ctx.lineWidth = 12;
ctx.lineCap = "round";
ctx.stroke();
// Glow
ctx.beginPath();
ctx.arc(cx, cy, radius, startAngle, valueAngle);
ctx.strokeStyle = getColor(score) + "40";
ctx.lineWidth = 20;
ctx.lineCap = "round";
ctx.stroke();
}
// Score number
ctx.fillStyle = "#e2e8f0";
ctx.font = `bold ${size * 0.22}px system-ui, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(Math.round(score).toString(), cx, cy - 4);
// Label
ctx.fillStyle = "#64748b";
ctx.font = `${size * 0.07}px system-ui, sans-serif`;
ctx.fillText("/ 100", cx, cy + size * 0.14);
}, [score, size]);
return (
<div className="flex flex-col items-center">
<canvas
ref={canvasRef}
style={{ width: size, height: size }}
className="drop-shadow-lg"
/>
<p className="text-vault-muted text-xs mt-1">Cyber Health Score</p>
</div>
);
}

View File

@@ -0,0 +1,70 @@
"use client";
import {
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
ResponsiveContainer, ReferenceLine
} from "recharts";
interface ScoreTrendProps {
data: { date: string; score: number }[];
}
const CustomTooltip = ({ active, payload, label }: any) => {
if (active && payload?.length) {
return (
<div className="bg-vault-dark border border-vault-border rounded-lg px-3 py-2 shadow-xl">
<p className="text-vault-muted text-xs mb-1">{label}</p>
<p className="text-vault-sapphireLight font-bold text-sm">Score: {payload[0].value}</p>
</div>
);
}
return null;
};
export default function ScoreTrend({ data }: ScoreTrendProps) {
// Thin the data to ~30 points for readability
const step = Math.ceil(data.length / 30);
const thinned = data.filter((_, i) => i % step === 0 || i === data.length - 1);
// Format dates to short labels
const formatted = thinned.map(d => ({
...d,
shortDate: d.date.slice(5), // MM-DD
}));
return (
<ResponsiveContainer width="100%" height={180}>
<LineChart data={formatted} margin={{ top: 5, right: 10, left: -20, bottom: 0 }}>
<defs>
<linearGradient id="scoreGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82d4" stopOpacity={0.3} />
<stop offset="95%" stopColor="#3b82d4" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#2d3447" />
<XAxis
dataKey="shortDate"
tick={{ fill: "#64748b", fontSize: 10 }}
axisLine={{ stroke: "#2d3447" }}
tickLine={false}
interval="preserveStartEnd"
/>
<YAxis
domain={[0, 100]}
tick={{ fill: "#64748b", fontSize: 10 }}
axisLine={false}
tickLine={false}
/>
<Tooltip content={<CustomTooltip />} />
<ReferenceLine y={75} stroke="#3b82d430" strokeDasharray="4 4" />
<Line
type="monotone"
dataKey="score"
stroke="#3b82d4"
strokeWidth={2}
dot={false}
activeDot={{ r: 5, fill: "#3b82d4", stroke: "#0a0d14", strokeWidth: 2 }}
/>
</LineChart>
</ResponsiveContainer>
);
}

View File

@@ -0,0 +1,107 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import {
LayoutDashboard, Shield, Search, FileText, Settings, LogOut, ChevronRight
} from "lucide-react";
const NAV = [
{ href: "/dashboard", icon: LayoutDashboard, label: "Vault Dashboard" },
{ href: "/findings", icon: Shield, label: "Findings" },
{ href: "/footprint", icon: Search, label: "Digital Footprint" },
{ href: "/reports", icon: FileText, label: "Audit Reports" },
];
const ADMIN_NAV = [
{ href: "/admin", icon: Settings, label: "Admin Panel" },
];
export default function Sidebar() {
const pathname = usePathname();
const { name, role, logout } = useAuth();
return (
<aside className="fixed left-0 top-0 h-screen w-64 bg-vault-dark border-r border-vault-border flex flex-col z-40">
{/* Logo */}
<div className="px-6 py-5 border-b border-vault-border">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg bg-vault-sapphire/20 border border-vault-sapphire/40 flex items-center justify-center flex-shrink-0">
<svg className="w-4 h-4 text-vault-sapphire" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
</svg>
</div>
<span className="text-lg font-bold text-vault-text tracking-tight">
Trust<span className="text-vault-sapphire">OS</span>
</span>
</div>
</div>
{/* Nav */}
<nav className="flex-1 px-3 py-4 overflow-y-auto">
<div className="space-y-0.5">
{NAV.map(({ href, icon: Icon, label }) => {
const active = pathname === href || pathname.startsWith(href + "/");
return (
<Link
key={href}
href={href}
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors ${
active
? "bg-vault-sapphireDim text-vault-sapphireLight border border-vault-sapphire/20"
: "text-vault-muted hover:text-vault-text hover:bg-vault-titanium"
}`}
>
<Icon className="w-4 h-4 flex-shrink-0" />
{label}
</Link>
);
})}
</div>
{role === "trustos_admin" && (
<div className="mt-6">
<p className="px-3 text-xs font-semibold text-vault-muted uppercase tracking-wider mb-1">Administration</p>
<div className="space-y-0.5">
{ADMIN_NAV.map(({ href, icon: Icon, label }) => {
const active = pathname === href || pathname.startsWith(href + "/");
return (
<Link
key={href}
href={href}
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors ${
active
? "bg-vault-sapphireDim text-vault-sapphireLight border border-vault-sapphire/20"
: "text-vault-muted hover:text-vault-text hover:bg-vault-titanium"
}`}
>
<Icon className="w-4 h-4 flex-shrink-0" />
{label}
</Link>
);
})}
</div>
</div>
)}
</nav>
{/* User Footer */}
<div className="px-3 py-4 border-t border-vault-border">
<div className="flex items-center gap-3 px-3 py-2 rounded-lg">
<div className="w-8 h-8 rounded-full bg-vault-sapphire/20 border border-vault-sapphire/30 flex items-center justify-center flex-shrink-0">
<span className="text-vault-sapphireLight text-xs font-bold">
{name?.charAt(0) ?? "U"}
</span>
</div>
<div className="flex-1 min-w-0">
<p className="text-vault-text text-xs font-medium truncate">{name ?? "User"}</p>
<p className="text-vault-muted text-xs capitalize">{role?.replace("_", " ")}</p>
</div>
<button onClick={logout} className="text-vault-muted hover:text-red-400 transition-colors" title="Sign out">
<LogOut className="w-4 h-4" />
</button>
</div>
</div>
</aside>
);
}

View File

@@ -0,0 +1,67 @@
"use client";
import Link from "next/link";
import { ArrowRight, AlertTriangle, AlertCircle, Info } from "lucide-react";
import type { RiskCard } from "@/lib/api";
const severityConfig: Record<string, { badge: string; icon: React.ElementType; border: string }> = {
critical: { badge: "vault-badge-critical", icon: AlertCircle, border: "border-l-4 border-vault-crimson" },
high: { badge: "vault-badge-high", icon: AlertTriangle, border: "border-l-4 border-orange-500" },
medium: { badge: "vault-badge-medium", icon: AlertTriangle, border: "border-l-4 border-vault-amber" },
low: { badge: "vault-badge-low", icon: Info, border: "border-l-4 border-green-500" },
};
const priorityLabel: Record<string, { label: string; color: string }> = {
urgent: { label: "Fix within 24h", color: "text-red-400" },
soon: { label: "Fix this week", color: "text-amber-400" },
planned: { label: "Schedule fix", color: "text-blue-400" },
};
interface TopRiskCardProps {
risk: RiskCard;
index: number;
}
export default function TopRiskCard({ risk, index }: TopRiskCardProps) {
const config = severityConfig[risk.severity] ?? severityConfig.medium;
const Icon = config.icon;
const priority = priorityLabel[risk.ai_fix_priority ?? ""] ?? null;
return (
<div className={`vault-card ${config.border} hover:bg-vault-titanium/50 transition-colors`}>
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex items-start gap-3 flex-1 min-w-0">
<div className="flex items-center justify-center w-7 h-7 rounded-full bg-vault-dark border border-vault-border flex-shrink-0 mt-0.5">
<span className="text-xs font-bold text-vault-muted">{index + 1}</span>
</div>
<div className="flex-1 min-w-0">
<p className="text-vault-text font-semibold text-sm leading-tight line-clamp-2">{risk.title}</p>
</div>
</div>
<span className={config.badge}>{risk.severity.toUpperCase()}</span>
</div>
{risk.ai_summary && (
<p className="text-vault-subtle text-sm leading-relaxed mb-3">{risk.ai_summary}</p>
)}
{risk.ai_business_impact && (
<div className="bg-vault-dark/60 rounded-lg px-3 py-2.5 mb-3">
<p className="text-xs text-vault-muted mb-1 font-medium uppercase tracking-wider">Business Impact</p>
<p className="text-vault-subtle text-sm leading-relaxed">{risk.ai_business_impact}</p>
</div>
)}
<div className="flex items-center justify-between">
{priority && (
<span className={`text-xs font-semibold ${priority.color}`}> {priority.label}</span>
)}
<Link
href={`/findings/${risk.id}`}
className="ml-auto inline-flex items-center gap-1 text-vault-sapphireLight text-xs font-medium hover:underline"
>
View details <ArrowRight className="w-3 h-3" />
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,39 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter, usePathname } from "next/navigation";
import { setAuthToken } from "@/lib/api";
export function useAuth() {
const router = useRouter();
const pathname = usePathname();
const [token, setToken] = useState<string | null>(null);
const [role, setRole] = useState<string | null>(null);
const [tenantId, setTenantId] = useState<string | null>(null);
const [name, setName] = useState<string | null>(null);
const [ready, setReady] = useState(false);
useEffect(() => {
const t = localStorage.getItem("trustos_token");
const r = localStorage.getItem("trustos_role");
const tid = localStorage.getItem("trustos_tenant_id");
const n = localStorage.getItem("trustos_name");
if (t) {
setToken(t);
setRole(r);
setTenantId(tid);
setName(n);
setAuthToken(t);
} else if (pathname !== "/login") {
router.replace("/login");
}
setReady(true);
}, []);
function logout() {
localStorage.clear();
setAuthToken(null);
router.replace("/login");
}
return { token, role, tenantId, name, ready, logout };
}

127
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,127 @@
const BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
let authToken: string | null = null;
export function setAuthToken(token: string | null) {
authToken = token;
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(options.headers as Record<string, string>),
};
if (authToken) {
headers["Authorization"] = `Bearer ${authToken}`;
}
const res = await fetch(`${BASE}${path}`, { ...options, headers });
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || `HTTP ${res.status}`);
}
return res.json();
}
export const api = {
login: (email: string, password: string) =>
request<{ access_token: string; role: string; tenant_id: string; full_name: string }>(
"/api/v1/auth/login",
{ method: "POST", body: JSON.stringify({ email, password }) }
),
me: () => request<{ id: string; email: string; full_name: string; role: string; tenant_id: string }>(
"/api/v1/auth/me"
),
dashboard: (tenantId: string) =>
request<DashboardData>(`/api/v1/dashboard/${tenantId}`),
findings: (tenantId: string, params?: string) =>
request<Finding[]>(`/api/v1/findings?tenant_id=${tenantId}${params ? "&" + params : ""}`),
finding: (id: string) =>
request<Finding>(`/api/v1/findings/${id}`),
updateFindingStatus: (id: string, body: { status: string; resolution_note?: string }) =>
request<Finding>(`/api/v1/findings/${id}/status`, {
method: "PATCH",
body: JSON.stringify(body),
}),
attackPaths: (findingId: string) =>
request<AttackPath[]>(`/api/v1/attack-paths/${findingId}`),
footprint: (tenantId: string) =>
request<FootprintData>(`/api/v1/footprint/${tenantId}`),
aiExplain: (findingId: string, question: string) =>
request<{ question: string; answer: string }>(`/api/v1/ai/explain/${findingId}?question=${encodeURIComponent(question)}`),
};
// ─── Types ────────────────────────────────────────────────────────────────────
export interface DashboardData {
tenant_name: string;
current_score: number;
previous_score: number | null;
score_delta: number | null;
score_trend: { date: string; score: number }[];
top_risks: RiskCard[];
open_critical: number;
open_high: number;
open_medium: number;
total_open: number;
baseline_score: number | null;
baseline_date: string | null;
}
export interface RiskCard {
id: string;
title: string;
ai_summary: string | null;
ai_business_impact: string | null;
ai_impact_level: string | null;
ai_fix_priority: string | null;
severity: string;
category: string;
}
export interface Finding {
id: string;
tenant_id: string;
title: string;
severity: string;
status: string;
category: string;
technical_description: string | null;
cve_id: string | null;
cvss_score: number | null;
affected_component: string | null;
ai_summary: string | null;
ai_business_impact: string | null;
ai_impact_level: string | null;
ai_remediation_steps: string | null;
ai_fix_priority: string | null;
assignee_email: string | null;
due_date: string | null;
is_top_risk: boolean;
source: string | null;
created_at: string;
updated_at: string;
}
export interface AttackPath {
id: string;
finding_id: string;
title: string;
ai_narrative: string | null;
nodes_json: string | null;
edges_json: string | null;
}
export interface FootprintData {
tenant_id: string;
executives: { id: string; name: string; title: string; email: string }[];
footprint_findings: any[];
total_exposures: number;
}

View File

@@ -0,0 +1,45 @@
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: "class",
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
// TrustOS brand palette
vault: {
black: "#0a0d14",
dark: "#1a1f2e",
titanium: "#2d3447",
surface: "#1e2336",
border: "#2d3447",
sapphire: "#3b82d4",
sapphireLight: "#60a5fa",
sapphireDim: "#1e3a5f",
crimson: "#dc2626",
crimsonDim: "#450a0a",
amber: "#d97706",
amberDim: "#451a03",
emerald: "#059669",
emeraldDim: "#052e16",
text: "#e2e8f0",
muted: "#64748b",
subtle: "#94a3b8",
},
},
fontFamily: {
sans: ["var(--font-inter)", "system-ui", "sans-serif"],
},
backgroundImage: {
"vault-gradient": "linear-gradient(135deg, #0a0d14 0%, #1a1f2e 100%)",
},
},
},
plugins: [],
};
export default config;

34
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}

16
infra/Dockerfile.backend Normal file
View File

@@ -0,0 +1,16 @@
FROM python:3.10-slim
WORKDIR /app
RUN apt-get update && apt-get install -y \
libpango-1.0-0 libpangoft2-1.0-0 libpangocairo-1.0-0 \
libcairo2 libglib2.0-0 libharfbuzz0b libfontconfig1 \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

11
infra/Dockerfile.frontend Normal file
View File

@@ -0,0 +1,11 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
EXPOSE 3000
CMD ["npm", "run", "dev"]

59
infra/docker-compose.yml Normal file
View File

@@ -0,0 +1,59 @@
services:
postgres:
image: postgres:16-alpine
container_name: trustos_postgres
environment:
POSTGRES_USER: trustos
POSTGRES_PASSWORD: trustos_dev
POSTGRES_DB: trustos
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U trustos -d trustos"]
interval: 5s
timeout: 5s
retries: 10
backend:
build:
context: ../backend
dockerfile: ../infra/Dockerfile.backend
container_name: trustos_backend
env_file:
- ../backend/.env
environment:
DATABASE_URL: postgresql+asyncpg://trustos:trustos_dev@postgres:5432/trustos
SYNC_DATABASE_URL: postgresql://trustos:trustos_dev@postgres:5432/trustos
STORAGE_PATH: /app/storage
ports:
- "8000:8000"
volumes:
- ../backend:/app
- storage:/app/storage
depends_on:
postgres:
condition: service_healthy
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
frontend:
build:
context: ../frontend
dockerfile: ../infra/Dockerfile.frontend
container_name: trustos_frontend
environment:
NEXT_PUBLIC_API_URL: http://localhost:8000
ports:
- "3000:3000"
volumes:
- ../frontend:/app
- /app/node_modules
- /app/.next
depends_on:
- backend
command: npm run dev
volumes:
pgdata:
storage: