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:
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal 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
25
backend/.env.example
Normal file
@@ -0,0 +1,25 @@
|
||||
# Database
|
||||
DATABASE_URL=postgresql+asyncpg://trustos:trustos_dev@postgres:5432/trustos
|
||||
SYNC_DATABASE_URL=postgresql://trustos:trustos_dev@postgres:5432/trustos
|
||||
|
||||
# Auth
|
||||
SECRET_KEY=changeme-use-openssl-rand-hex-32-in-production
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=480
|
||||
|
||||
# AI
|
||||
OPENAI_API_KEY=sk-...
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
AI_PROVIDER=openai
|
||||
|
||||
# External APIs
|
||||
HIBP_API_KEY=
|
||||
NVD_API_KEY=
|
||||
|
||||
# Storage
|
||||
STORAGE_PATH=/app/storage
|
||||
|
||||
# Email (optional)
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASS=
|
||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/routes/__init__.py
Normal file
0
backend/app/api/routes/__init__.py
Normal file
49
backend/app/api/routes/ai.py
Normal file
49
backend/app/api/routes/ai.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.models.models import Finding
|
||||
from app.core.security import require_it_or_above
|
||||
|
||||
router = APIRouter(prefix="/ai", tags=["ai"])
|
||||
|
||||
|
||||
@router.post("/translate/{finding_id}")
|
||||
async def translate_finding(
|
||||
finding_id: str,
|
||||
payload: dict = Depends(require_it_or_above),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Trigger or re-trigger AI translation for a specific finding."""
|
||||
result = await db.execute(select(Finding).where(Finding.id == finding_id))
|
||||
finding = result.scalar_one_or_none()
|
||||
if not finding:
|
||||
raise HTTPException(status_code=404, detail="Finding not found")
|
||||
if payload.get("role") != "trustos_admin" and payload.get("tenant_id") != finding.tenant_id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
from app.services.ai_translator import translate_finding_async
|
||||
import asyncio
|
||||
asyncio.create_task(translate_finding_async(finding_id))
|
||||
return {"status": "queued", "finding_id": finding_id}
|
||||
|
||||
|
||||
@router.get("/explain/{finding_id}")
|
||||
async def explain_finding(
|
||||
finding_id: str,
|
||||
question: str = Query(default="Why does this matter to our business?"),
|
||||
payload: dict = Depends(require_it_or_above),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""AI Security Coach: answer a specific question about a finding."""
|
||||
result = await db.execute(select(Finding).where(Finding.id == finding_id))
|
||||
finding = result.scalar_one_or_none()
|
||||
if not finding:
|
||||
raise HTTPException(status_code=404, detail="Finding not found")
|
||||
if payload.get("role") != "trustos_admin" and payload.get("tenant_id") != finding.tenant_id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
from app.services.ai_translator import answer_finding_question
|
||||
answer = await answer_finding_question(finding, question)
|
||||
return {"question": question, "answer": answer, "finding_id": finding_id}
|
||||
42
backend/app/api/routes/attack_paths.py
Normal file
42
backend/app/api/routes/attack_paths.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from typing import List
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.models.models import Finding, AttackPath
|
||||
from app.schemas.schemas import AttackPathOut
|
||||
from app.core.security import require_executive_or_above
|
||||
|
||||
router = APIRouter(prefix="/attack-paths", tags=["attack-paths"])
|
||||
|
||||
|
||||
@router.get("/{finding_id}", response_model=List[AttackPathOut])
|
||||
async def get_attack_paths(
|
||||
finding_id: str,
|
||||
payload: dict = Depends(require_executive_or_above),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
# Verify tenant access
|
||||
f_result = await db.execute(select(Finding).where(Finding.id == finding_id))
|
||||
finding = f_result.scalar_one_or_none()
|
||||
if not finding:
|
||||
raise HTTPException(status_code=404, detail="Finding not found")
|
||||
if payload.get("role") != "trustos_admin" and payload.get("tenant_id") != finding.tenant_id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
result = await db.execute(select(AttackPath).where(AttackPath.finding_id == finding_id))
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.post("/{finding_id}/generate")
|
||||
async def generate_attack_path(
|
||||
finding_id: str,
|
||||
payload: dict = Depends(require_executive_or_above),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Trigger AI generation of attack path narrative for a finding."""
|
||||
from app.services.ai_translator import generate_attack_path_narrative
|
||||
import asyncio
|
||||
asyncio.create_task(generate_attack_path_narrative(finding_id))
|
||||
return {"status": "queued", "finding_id": finding_id}
|
||||
37
backend/app/api/routes/auth.py
Normal file
37
backend/app/api/routes/auth.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from datetime import datetime
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.models.models import User
|
||||
from app.schemas.schemas import LoginRequest, TokenResponse, UserMe
|
||||
from app.core.security import verify_password, create_access_token, get_current_user_payload
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(User).where(User.email == body.email, User.is_active == True))
|
||||
user: User | None = result.scalar_one_or_none()
|
||||
if not user or not verify_password(body.password, user.hashed_password):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
user.last_login = datetime.utcnow()
|
||||
await db.commit()
|
||||
token = create_access_token({"sub": user.id, "role": user.role.value, "tenant_id": user.tenant_id})
|
||||
return TokenResponse(
|
||||
access_token=token,
|
||||
role=user.role.value,
|
||||
tenant_id=user.tenant_id,
|
||||
full_name=user.full_name
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserMe)
|
||||
async def get_me(payload: dict = Depends(get_current_user_payload), db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(User).where(User.id == payload["sub"]))
|
||||
user: User | None = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return UserMe(id=user.id, email=user.email, full_name=user.full_name, role=user.role, tenant_id=user.tenant_id)
|
||||
143
backend/app/api/routes/dashboard.py
Normal file
143
backend/app/api/routes/dashboard.py
Normal file
@@ -0,0 +1,143 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, desc
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.models.models import Finding, RiskScore, AuditReport, FindingStatus
|
||||
from app.schemas.schemas import DashboardResponse, RiskCardData
|
||||
from app.core.security import require_executive_or_above
|
||||
|
||||
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
||||
|
||||
|
||||
@router.get("/{tenant_id}", response_model=DashboardResponse)
|
||||
async def get_dashboard(
|
||||
tenant_id: str,
|
||||
payload: dict = Depends(require_executive_or_above),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
# Enforce tenant isolation
|
||||
if payload.get("role") != "trustos_admin" and payload.get("tenant_id") != tenant_id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
# Current score (most recent)
|
||||
score_result = await db.execute(
|
||||
select(RiskScore)
|
||||
.where(RiskScore.tenant_id == tenant_id)
|
||||
.order_by(desc(RiskScore.score_date))
|
||||
.limit(1)
|
||||
)
|
||||
current_score_obj = score_result.scalar_one_or_none()
|
||||
current_score = current_score_obj.overall_score if current_score_obj else 50.0
|
||||
|
||||
# Previous score (30 days ago) for delta
|
||||
prev_result = await db.execute(
|
||||
select(RiskScore)
|
||||
.where(
|
||||
RiskScore.tenant_id == tenant_id,
|
||||
RiskScore.score_date <= datetime.utcnow() - timedelta(days=30)
|
||||
)
|
||||
.order_by(desc(RiskScore.score_date))
|
||||
.limit(1)
|
||||
)
|
||||
prev_score_obj = prev_result.scalar_one_or_none()
|
||||
prev_score = prev_score_obj.overall_score if prev_score_obj else None
|
||||
delta = round(current_score - prev_score, 1) if prev_score else None
|
||||
|
||||
# 90-day trend
|
||||
trend_result = await db.execute(
|
||||
select(RiskScore)
|
||||
.where(
|
||||
RiskScore.tenant_id == tenant_id,
|
||||
RiskScore.score_date >= datetime.utcnow() - timedelta(days=90)
|
||||
)
|
||||
.order_by(RiskScore.score_date)
|
||||
)
|
||||
trend_scores = trend_result.scalars().all()
|
||||
score_trend = [
|
||||
{"date": rs.score_date.strftime("%Y-%m-%d"), "score": rs.overall_score}
|
||||
for rs in trend_scores
|
||||
]
|
||||
|
||||
# Top 3 risks
|
||||
top_result = await db.execute(
|
||||
select(Finding)
|
||||
.where(
|
||||
Finding.tenant_id == tenant_id,
|
||||
Finding.is_top_risk == True,
|
||||
Finding.status.in_([FindingStatus.open, FindingStatus.in_progress])
|
||||
)
|
||||
.order_by(Finding.created_at)
|
||||
.limit(3)
|
||||
)
|
||||
top_findings = top_result.scalars().all()
|
||||
|
||||
# If no manually-flagged top risks, fall back to open criticals
|
||||
if not top_findings:
|
||||
fallback = await db.execute(
|
||||
select(Finding)
|
||||
.where(
|
||||
Finding.tenant_id == tenant_id,
|
||||
Finding.status.in_([FindingStatus.open, FindingStatus.in_progress])
|
||||
)
|
||||
.order_by(Finding.created_at)
|
||||
.limit(3)
|
||||
)
|
||||
top_findings = fallback.scalars().all()
|
||||
|
||||
top_risks = [
|
||||
RiskCardData(
|
||||
id=f.id,
|
||||
title=f.title,
|
||||
ai_summary=f.ai_summary,
|
||||
ai_business_impact=f.ai_business_impact,
|
||||
ai_impact_level=f.ai_impact_level,
|
||||
ai_fix_priority=f.ai_fix_priority,
|
||||
severity=f.severity.value,
|
||||
category=f.category.value,
|
||||
)
|
||||
for f in top_findings
|
||||
]
|
||||
|
||||
# Open finding counts
|
||||
counts_result = await db.execute(
|
||||
select(Finding.severity, func.count(Finding.id))
|
||||
.where(
|
||||
Finding.tenant_id == tenant_id,
|
||||
Finding.status.in_([FindingStatus.open, FindingStatus.in_progress])
|
||||
)
|
||||
.group_by(Finding.severity)
|
||||
)
|
||||
counts = {row[0].value: row[1] for row in counts_result}
|
||||
total_open = sum(counts.values())
|
||||
|
||||
# Baseline
|
||||
baseline_result = await db.execute(
|
||||
select(AuditReport)
|
||||
.where(AuditReport.tenant_id == tenant_id, AuditReport.is_baseline == True)
|
||||
.order_by(AuditReport.report_date)
|
||||
.limit(1)
|
||||
)
|
||||
baseline = baseline_result.scalar_one_or_none()
|
||||
|
||||
# Tenant name
|
||||
from app.models.models import Tenant
|
||||
t_result = await db.execute(select(Tenant).where(Tenant.id == tenant_id))
|
||||
tenant = t_result.scalar_one_or_none()
|
||||
|
||||
return DashboardResponse(
|
||||
tenant_name=tenant.name if tenant else "Unknown",
|
||||
current_score=current_score,
|
||||
previous_score=prev_score,
|
||||
score_delta=delta,
|
||||
score_trend=score_trend,
|
||||
top_risks=top_risks,
|
||||
open_critical=counts.get("critical", 0),
|
||||
open_high=counts.get("high", 0),
|
||||
open_medium=counts.get("medium", 0),
|
||||
total_open=total_open,
|
||||
baseline_score=baseline.baseline_score if baseline else None,
|
||||
baseline_date=baseline.report_date if baseline else None,
|
||||
)
|
||||
127
backend/app/api/routes/findings.py
Normal file
127
backend/app/api/routes/findings.py
Normal file
@@ -0,0 +1,127 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.models.models import Finding, FindingStatus, FindingSeverity
|
||||
from app.schemas.schemas import FindingOut, FindingCreate, FindingStatusUpdate
|
||||
from app.core.security import require_executive_or_above, require_it_or_above
|
||||
|
||||
router = APIRouter(prefix="/findings", tags=["findings"])
|
||||
|
||||
|
||||
@router.get("", response_model=List[FindingOut])
|
||||
async def list_findings(
|
||||
tenant_id: str = Query(...),
|
||||
severity: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
payload: dict = Depends(require_executive_or_above),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if payload.get("role") != "trustos_admin" and payload.get("tenant_id") != tenant_id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
query = select(Finding).where(Finding.tenant_id == tenant_id)
|
||||
if severity:
|
||||
query = query.where(Finding.severity == severity)
|
||||
if status:
|
||||
query = query.where(Finding.status == status)
|
||||
if category:
|
||||
query = query.where(Finding.category == category)
|
||||
query = query.order_by(desc(Finding.created_at)).limit(limit).offset(offset)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.get("/{finding_id}", response_model=FindingOut)
|
||||
async def get_finding(
|
||||
finding_id: str,
|
||||
payload: dict = Depends(require_executive_or_above),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(Finding).where(Finding.id == finding_id))
|
||||
finding = result.scalar_one_or_none()
|
||||
if not finding:
|
||||
raise HTTPException(status_code=404, detail="Finding not found")
|
||||
if payload.get("role") != "trustos_admin" and payload.get("tenant_id") != finding.tenant_id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
return finding
|
||||
|
||||
|
||||
@router.post("", response_model=FindingOut, status_code=201)
|
||||
async def create_finding(
|
||||
tenant_id: str = Query(...),
|
||||
body: FindingCreate = ...,
|
||||
payload: dict = Depends(require_it_or_above),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if payload.get("role") != "trustos_admin" and payload.get("tenant_id") != tenant_id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
finding = Finding(tenant_id=tenant_id, **body.model_dump())
|
||||
db.add(finding)
|
||||
await db.commit()
|
||||
await db.refresh(finding)
|
||||
return finding
|
||||
|
||||
|
||||
@router.patch("/{finding_id}/status", response_model=FindingOut)
|
||||
async def update_finding_status(
|
||||
finding_id: str,
|
||||
body: FindingStatusUpdate,
|
||||
payload: dict = Depends(require_it_or_above),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(Finding).where(Finding.id == finding_id))
|
||||
finding = result.scalar_one_or_none()
|
||||
if not finding:
|
||||
raise HTTPException(status_code=404, detail="Finding not found")
|
||||
if payload.get("role") != "trustos_admin" and payload.get("tenant_id") != finding.tenant_id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
finding.status = body.status
|
||||
if body.resolution_note:
|
||||
finding.resolution_note = body.resolution_note
|
||||
if body.assignee_email:
|
||||
finding.assignee_email = body.assignee_email
|
||||
if body.due_date:
|
||||
finding.due_date = body.due_date
|
||||
if body.status == FindingStatus.resolved:
|
||||
finding.resolved_at = datetime.utcnow()
|
||||
if body.status == FindingStatus.verified:
|
||||
finding.verified_at = datetime.utcnow()
|
||||
if not body.resolution_note:
|
||||
raise HTTPException(status_code=400, detail="A resolution note is required to verify a finding")
|
||||
|
||||
finding.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(finding)
|
||||
|
||||
# Trigger async risk score recalculation (fire and forget)
|
||||
from app.services.risk_calculator import recalculate_risk_score
|
||||
import asyncio
|
||||
asyncio.create_task(recalculate_risk_score(finding.tenant_id))
|
||||
|
||||
return finding
|
||||
|
||||
|
||||
@router.patch("/{finding_id}/top-risk", response_model=FindingOut)
|
||||
async def toggle_top_risk(
|
||||
finding_id: str,
|
||||
is_top_risk: bool = Query(...),
|
||||
payload: dict = Depends(require_it_or_above),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(Finding).where(Finding.id == finding_id))
|
||||
finding = result.scalar_one_or_none()
|
||||
if not finding:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
finding.is_top_risk = is_top_risk
|
||||
await db.commit()
|
||||
await db.refresh(finding)
|
||||
return finding
|
||||
88
backend/app/api/routes/footprint.py
Normal file
88
backend/app/api/routes/footprint.py
Normal file
@@ -0,0 +1,88 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from typing import List
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.models.models import Finding, Executive, AuthorizedAsset
|
||||
from app.schemas.schemas import AuthorizedAssetCreate, AuthorizedAssetOut
|
||||
from app.core.security import require_admin, require_it_or_above
|
||||
|
||||
router = APIRouter(prefix="/footprint", tags=["footprint"])
|
||||
|
||||
|
||||
@router.get("/{tenant_id}")
|
||||
async def get_footprint(
|
||||
tenant_id: str,
|
||||
payload: dict = Depends(require_it_or_above),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if payload.get("role") != "trustos_admin" and payload.get("tenant_id") != tenant_id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
# Digital footprint findings
|
||||
from app.models.models import FindingCategory
|
||||
fp_result = await db.execute(
|
||||
select(Finding).where(
|
||||
Finding.tenant_id == tenant_id,
|
||||
Finding.category == FindingCategory.digital_footprint
|
||||
)
|
||||
)
|
||||
footprint_findings = fp_result.scalars().all()
|
||||
|
||||
# Executives
|
||||
exec_result = await db.execute(
|
||||
select(Executive).where(Executive.tenant_id == tenant_id, Executive.is_enrolled == True)
|
||||
)
|
||||
executives = exec_result.scalars().all()
|
||||
|
||||
return {
|
||||
"tenant_id": tenant_id,
|
||||
"executives": [
|
||||
{"id": e.id, "name": e.full_name, "title": e.title, "email": e.corporate_email}
|
||||
for e in executives
|
||||
],
|
||||
"footprint_findings": [
|
||||
{
|
||||
"id": f.id,
|
||||
"title": f.title,
|
||||
"severity": f.severity.value,
|
||||
"ai_summary": f.ai_summary,
|
||||
"status": f.status.value,
|
||||
"source": f.source,
|
||||
}
|
||||
for f in footprint_findings
|
||||
],
|
||||
"total_exposures": len(footprint_findings),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/authorized-assets/{tenant_id}", response_model=List[AuthorizedAssetOut])
|
||||
async def list_authorized_assets(
|
||||
tenant_id: str,
|
||||
payload: dict = Depends(require_it_or_above),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if payload.get("role") != "trustos_admin" and payload.get("tenant_id") != tenant_id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
result = await db.execute(
|
||||
select(AuthorizedAsset).where(
|
||||
AuthorizedAsset.tenant_id == tenant_id,
|
||||
AuthorizedAsset.is_active == True
|
||||
)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.post("/authorized-assets/{tenant_id}", response_model=AuthorizedAssetOut, status_code=201)
|
||||
async def add_authorized_asset(
|
||||
tenant_id: str,
|
||||
body: AuthorizedAssetCreate,
|
||||
payload: dict = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
asset = AuthorizedAsset(tenant_id=tenant_id, **body.model_dump())
|
||||
db.add(asset)
|
||||
await db.commit()
|
||||
await db.refresh(asset)
|
||||
return asset
|
||||
103
backend/app/api/routes/reports.py
Normal file
103
backend/app/api/routes/reports.py
Normal file
@@ -0,0 +1,103 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc
|
||||
from typing import List
|
||||
from datetime import datetime
|
||||
import json
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.models.models import AuditReport, Finding, RiskScore, Executive, AuthorizedAsset, FindingStatus
|
||||
from app.schemas.schemas import AuditReportOut, AuditReportCreate
|
||||
from app.core.security import require_admin
|
||||
|
||||
router = APIRouter(prefix="/audit-reports", tags=["audit-reports"])
|
||||
|
||||
|
||||
@router.get("", response_model=List[AuditReportOut])
|
||||
async def list_reports(
|
||||
tenant_id: str = Query(...),
|
||||
payload: dict = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(AuditReport)
|
||||
.where(AuditReport.tenant_id == tenant_id)
|
||||
.order_by(desc(AuditReport.report_date))
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.post("/generate", response_model=AuditReportOut, status_code=201)
|
||||
async def generate_audit_report(
|
||||
tenant_id: str = Query(...),
|
||||
body: AuditReportCreate = ...,
|
||||
payload: dict = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Generate a Vault Audit Report snapshot for a tenant."""
|
||||
# Get current score
|
||||
score_result = await db.execute(
|
||||
select(RiskScore)
|
||||
.where(RiskScore.tenant_id == tenant_id)
|
||||
.order_by(desc(RiskScore.score_date))
|
||||
.limit(1)
|
||||
)
|
||||
latest_score = score_result.scalar_one_or_none()
|
||||
|
||||
# Snapshot top findings
|
||||
findings_result = await db.execute(
|
||||
select(Finding)
|
||||
.where(
|
||||
Finding.tenant_id == tenant_id,
|
||||
Finding.status.in_([FindingStatus.open, FindingStatus.in_progress])
|
||||
)
|
||||
.order_by(Finding.created_at)
|
||||
.limit(20)
|
||||
)
|
||||
top_findings = findings_result.scalars().all()
|
||||
findings_snapshot = [
|
||||
{
|
||||
"id": f.id,
|
||||
"title": f.title,
|
||||
"severity": f.severity.value,
|
||||
"category": f.category.value,
|
||||
"ai_summary": f.ai_summary,
|
||||
"ai_impact_level": f.ai_impact_level,
|
||||
}
|
||||
for f in top_findings
|
||||
]
|
||||
|
||||
report = AuditReport(
|
||||
tenant_id=tenant_id,
|
||||
title=body.title,
|
||||
report_date=datetime.utcnow(),
|
||||
baseline_score=latest_score.overall_score if latest_score else None,
|
||||
executive_summary=body.executive_summary,
|
||||
scope_description=body.scope_description,
|
||||
key_findings_json=json.dumps(findings_snapshot),
|
||||
is_baseline=True,
|
||||
generated_by=payload.get("sub"),
|
||||
)
|
||||
db.add(report)
|
||||
await db.commit()
|
||||
await db.refresh(report)
|
||||
|
||||
# Kick off PDF generation in background
|
||||
from app.services.report_generator import generate_pdf_for_report
|
||||
import asyncio
|
||||
asyncio.create_task(generate_pdf_for_report(report.id))
|
||||
|
||||
return report
|
||||
|
||||
|
||||
@router.get("/{report_id}", response_model=AuditReportOut)
|
||||
async def get_report(
|
||||
report_id: str,
|
||||
payload: dict = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(AuditReport).where(AuditReport.id == report_id))
|
||||
report = result.scalar_one_or_none()
|
||||
if not report:
|
||||
raise HTTPException(status_code=404, detail="Report not found")
|
||||
return report
|
||||
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
37
backend/app/core/config.py
Normal file
37
backend/app/core/config.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic import field_validator
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
PROJECT_NAME: str = "TrustOS"
|
||||
VERSION: str = "0.1.0"
|
||||
API_V1_STR: str = "/api/v1"
|
||||
|
||||
DATABASE_URL: str = "postgresql+asyncpg://trustos:trustos_dev@localhost:5432/trustos"
|
||||
SYNC_DATABASE_URL: str = "postgresql://trustos:trustos_dev@localhost:5432/trustos"
|
||||
|
||||
SECRET_KEY: str = "dev-secret-key-change-in-production"
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 480
|
||||
|
||||
AI_PROVIDER: str = "openai"
|
||||
OPENAI_API_KEY: Optional[str] = None
|
||||
ANTHROPIC_API_KEY: Optional[str] = None
|
||||
|
||||
HIBP_API_KEY: Optional[str] = None
|
||||
NVD_API_KEY: Optional[str] = None
|
||||
|
||||
STORAGE_PATH: str = "/app/storage"
|
||||
|
||||
SMTP_HOST: Optional[str] = None
|
||||
SMTP_PORT: int = 587
|
||||
SMTP_USER: Optional[str] = None
|
||||
SMTP_PASS: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = True
|
||||
|
||||
|
||||
settings = Settings()
|
||||
57
backend/app/core/security.py
Normal file
57
backend/app/core/security.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Any
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from fastapi import HTTPException, status, Depends
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from app.core.config import settings
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.API_V1_STR}/auth/login")
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return pwd_context.verify(plain, hashed)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||
to_encode = data.copy()
|
||||
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES))
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict:
|
||||
try:
|
||||
return jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
except JWTError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
async def get_current_user_payload(token: str = Depends(oauth2_scheme)) -> dict:
|
||||
return decode_token(token)
|
||||
|
||||
|
||||
def require_roles(*roles: str):
|
||||
"""Dependency factory — require one of the specified roles."""
|
||||
async def role_checker(payload: dict = Depends(get_current_user_payload)):
|
||||
if payload.get("role") not in roles:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Insufficient permissions"
|
||||
)
|
||||
return payload
|
||||
return role_checker
|
||||
|
||||
|
||||
require_executive_or_above = require_roles("executive", "it_admin", "trustos_admin")
|
||||
require_it_or_above = require_roles("it_admin", "trustos_admin")
|
||||
require_admin = require_roles("trustos_admin")
|
||||
0
backend/app/db/__init__.py
Normal file
0
backend/app/db/__init__.py
Normal file
27
backend/app/db/session.py
Normal file
27
backend/app/db/session.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from app.core.config import settings
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=False,
|
||||
pool_pre_ping=True,
|
||||
pool_size=10,
|
||||
max_overflow=20,
|
||||
)
|
||||
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
engine, class_=AsyncSession, expire_on_commit=False
|
||||
)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
async def get_db() -> AsyncSession:
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
47
backend/app/main.py
Normal file
47
backend/app/main.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.session import engine
|
||||
from app.models.models import Base
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Create tables on startup (dev only — use Alembic in production)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.PROJECT_NAME,
|
||||
version=settings.VERSION,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:3000", "http://frontend:3000"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# ─── Routes ───────────────────────────────────────────────────────────────────
|
||||
from app.api.routes import auth, dashboard, findings, reports, attack_paths, footprint, ai as ai_routes
|
||||
|
||||
app.include_router(auth.router, prefix=settings.API_V1_STR)
|
||||
app.include_router(dashboard.router, prefix=settings.API_V1_STR)
|
||||
app.include_router(findings.router, prefix=settings.API_V1_STR)
|
||||
app.include_router(reports.router, prefix=settings.API_V1_STR)
|
||||
app.include_router(attack_paths.router, prefix=settings.API_V1_STR)
|
||||
app.include_router(footprint.router, prefix=settings.API_V1_STR)
|
||||
app.include_router(ai_routes.router, prefix=settings.API_V1_STR)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok", "service": settings.PROJECT_NAME, "version": settings.VERSION}
|
||||
0
backend/app/models/__init__.py
Normal file
0
backend/app/models/__init__.py
Normal file
275
backend/app/models/models.py
Normal file
275
backend/app/models/models.py
Normal file
@@ -0,0 +1,275 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, DateTime, Text, Float, Integer, Boolean, ForeignKey, Enum as SAEnum
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from app.db.session import Base
|
||||
import enum
|
||||
|
||||
|
||||
class UserRole(str, enum.Enum):
|
||||
executive = "executive"
|
||||
it_admin = "it_admin"
|
||||
trustos_admin = "trustos_admin"
|
||||
|
||||
|
||||
class FindingSeverity(str, enum.Enum):
|
||||
critical = "critical"
|
||||
high = "high"
|
||||
medium = "medium"
|
||||
low = "low"
|
||||
info = "info"
|
||||
|
||||
|
||||
class FindingStatus(str, enum.Enum):
|
||||
open = "open"
|
||||
in_progress = "in_progress"
|
||||
resolved = "resolved"
|
||||
verified = "verified"
|
||||
accepted_risk = "accepted_risk"
|
||||
|
||||
|
||||
class FindingCategory(str, enum.Enum):
|
||||
external_exposure = "external_exposure"
|
||||
cloud_posture = "cloud_posture"
|
||||
credential_exposure = "credential_exposure"
|
||||
digital_footprint = "digital_footprint"
|
||||
web_application = "web_application"
|
||||
network = "network"
|
||||
identity = "identity"
|
||||
third_party = "third_party"
|
||||
compliance = "compliance"
|
||||
other = "other"
|
||||
|
||||
|
||||
class AssetType(str, enum.Enum):
|
||||
domain = "domain"
|
||||
ip_address = "ip_address"
|
||||
cloud_resource = "cloud_resource"
|
||||
email_account = "email_account"
|
||||
executive = "executive"
|
||||
web_application = "web_application"
|
||||
api_endpoint = "api_endpoint"
|
||||
repository = "repository"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────
|
||||
# Tenant (client organization)
|
||||
# ─────────────────────────────────────────
|
||||
class Tenant(Base):
|
||||
__tablename__ = "tenants"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
|
||||
industry: Mapped[str | None] = mapped_column(String(100))
|
||||
size_range: Mapped[str | None] = mapped_column(String(50)) # e.g. "50-200"
|
||||
contact_email: Mapped[str | None] = mapped_column(String(255))
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
users: Mapped[list["User"]] = relationship("User", back_populates="tenant", lazy="select")
|
||||
assets: Mapped[list["Asset"]] = relationship("Asset", back_populates="tenant", lazy="select")
|
||||
findings: Mapped[list["Finding"]] = relationship("Finding", back_populates="tenant", lazy="select")
|
||||
risk_scores: Mapped[list["RiskScore"]] = relationship("RiskScore", back_populates="tenant", lazy="select")
|
||||
audit_reports: Mapped[list["AuditReport"]] = relationship("AuditReport", back_populates="tenant", lazy="select")
|
||||
executives: Mapped[list["Executive"]] = relationship("Executive", back_populates="tenant", lazy="select")
|
||||
authorized_assets: Mapped[list["AuthorizedAsset"]] = relationship("AuthorizedAsset", back_populates="tenant", lazy="select")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────
|
||||
# User
|
||||
# ─────────────────────────────────────────
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), ForeignKey("tenants.id"), nullable=False)
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
||||
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
full_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
role: Mapped[UserRole] = mapped_column(SAEnum(UserRole), default=UserRole.it_admin)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
last_login: Mapped[datetime | None] = mapped_column(DateTime)
|
||||
|
||||
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="users")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────
|
||||
# Authorized Asset Scope (scope-lock)
|
||||
# ─────────────────────────────────────────
|
||||
class AuthorizedAsset(Base):
|
||||
__tablename__ = "authorized_assets"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), ForeignKey("tenants.id"), nullable=False)
|
||||
value: Mapped[str] = mapped_column(String(500), nullable=False) # domain, CIDR, cloud account ID
|
||||
asset_type: Mapped[AssetType] = mapped_column(SAEnum(AssetType), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
scope_agreement_ref: Mapped[str | None] = mapped_column(String(255)) # file path or reference
|
||||
authorized_by: Mapped[str | None] = mapped_column(String(255)) # email of authorizing contact
|
||||
authorized_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="authorized_assets")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────
|
||||
# Asset (discovered asset record)
|
||||
# ─────────────────────────────────────────
|
||||
class Asset(Base):
|
||||
__tablename__ = "assets"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), ForeignKey("tenants.id"), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
asset_type: Mapped[AssetType] = mapped_column(SAEnum(AssetType), nullable=False)
|
||||
value: Mapped[str] = mapped_column(String(500), nullable=False) # FQDN, IP, ARN, etc.
|
||||
owner_name: Mapped[str | None] = mapped_column(String(255))
|
||||
owner_email: Mapped[str | None] = mapped_column(String(255))
|
||||
tags: Mapped[str | None] = mapped_column(Text) # JSON array of tags
|
||||
notes: Mapped[str | None] = mapped_column(Text)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
first_seen: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
last_seen: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="assets")
|
||||
findings: Mapped[list["Finding"]] = relationship("Finding", back_populates="asset", lazy="select")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────
|
||||
# Executive (for digital footprint tracking)
|
||||
# ─────────────────────────────────────────
|
||||
class Executive(Base):
|
||||
__tablename__ = "executives"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), ForeignKey("tenants.id"), nullable=False)
|
||||
full_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
title: Mapped[str | None] = mapped_column(String(255))
|
||||
corporate_email: Mapped[str | None] = mapped_column(String(255))
|
||||
is_enrolled: Mapped[bool] = mapped_column(Boolean, default=True) # explicit consent
|
||||
notes: Mapped[str | None] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="executives")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────
|
||||
# Finding
|
||||
# ─────────────────────────────────────────
|
||||
class Finding(Base):
|
||||
__tablename__ = "findings"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), ForeignKey("tenants.id"), nullable=False)
|
||||
asset_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("assets.id"))
|
||||
executive_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("executives.id"))
|
||||
|
||||
title: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
severity: Mapped[FindingSeverity] = mapped_column(SAEnum(FindingSeverity), nullable=False)
|
||||
status: Mapped[FindingStatus] = mapped_column(SAEnum(FindingStatus), default=FindingStatus.open)
|
||||
category: Mapped[FindingCategory] = mapped_column(SAEnum(FindingCategory), nullable=False)
|
||||
|
||||
# Technical fields (IT Admin view)
|
||||
technical_description: Mapped[str | None] = mapped_column(Text)
|
||||
cve_id: Mapped[str | None] = mapped_column(String(30))
|
||||
cvss_score: Mapped[float | None] = mapped_column(Float)
|
||||
affected_component: Mapped[str | None] = mapped_column(String(500))
|
||||
evidence: Mapped[str | None] = mapped_column(Text) # raw evidence
|
||||
|
||||
# AI-translated fields (Executive view)
|
||||
ai_summary: Mapped[str | None] = mapped_column(Text) # plain-English summary
|
||||
ai_business_impact: Mapped[str | None] = mapped_column(Text)
|
||||
ai_impact_level: Mapped[str | None] = mapped_column(String(20)) # Low/Medium/High/Critical
|
||||
ai_remediation_steps: Mapped[str | None] = mapped_column(Text)
|
||||
ai_fix_priority: Mapped[str | None] = mapped_column(String(20)) # urgent/soon/planned
|
||||
ai_generated_at: Mapped[datetime | None] = mapped_column(DateTime)
|
||||
|
||||
# Remediation tracking
|
||||
assignee_email: Mapped[str | None] = mapped_column(String(255))
|
||||
due_date: Mapped[datetime | None] = mapped_column(DateTime)
|
||||
resolution_note: Mapped[str | None] = mapped_column(Text)
|
||||
resolved_at: Mapped[datetime | None] = mapped_column(DateTime)
|
||||
verified_at: Mapped[datetime | None] = mapped_column(DateTime)
|
||||
|
||||
# Metadata
|
||||
source: Mapped[str | None] = mapped_column(String(100)) # "manual", "scanner", "hibp", "cve_monitor"
|
||||
is_top_risk: Mapped[bool] = mapped_column(Boolean, default=False) # flagged as a Top 3 risk
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="findings")
|
||||
asset: Mapped["Asset"] = relationship("Asset", back_populates="findings")
|
||||
attack_paths: Mapped[list["AttackPath"]] = relationship("AttackPath", back_populates="finding", lazy="select")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────
|
||||
# Risk Score (daily snapshot)
|
||||
# ─────────────────────────────────────────
|
||||
class RiskScore(Base):
|
||||
__tablename__ = "risk_scores"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), ForeignKey("tenants.id"), nullable=False)
|
||||
score_date: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
overall_score: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
|
||||
# Category scores
|
||||
score_identity: Mapped[float | None] = mapped_column(Float)
|
||||
score_cloud: Mapped[float | None] = mapped_column(Float)
|
||||
score_network: Mapped[float | None] = mapped_column(Float)
|
||||
score_web: Mapped[float | None] = mapped_column(Float)
|
||||
score_credential: Mapped[float | None] = mapped_column(Float)
|
||||
score_digital_footprint: Mapped[float | None] = mapped_column(Float)
|
||||
score_third_party: Mapped[float | None] = mapped_column(Float)
|
||||
|
||||
# Counts at time of snapshot
|
||||
critical_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
high_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
medium_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
low_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
notes: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="risk_scores")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────
|
||||
# Attack Path
|
||||
# ─────────────────────────────────────────
|
||||
class AttackPath(Base):
|
||||
__tablename__ = "attack_paths"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
finding_id: Mapped[str] = mapped_column(String(36), ForeignKey("findings.id"), nullable=False)
|
||||
title: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
ai_narrative: Mapped[str | None] = mapped_column(Text)
|
||||
nodes_json: Mapped[str | None] = mapped_column(Text) # JSON: [{id, label, type, risk_level}]
|
||||
edges_json: Mapped[str | None] = mapped_column(Text) # JSON: [{source, target}]
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
finding: Mapped["Finding"] = relationship("Finding", back_populates="attack_paths")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────
|
||||
# Audit Report (Phase 1 deliverable)
|
||||
# ─────────────────────────────────────────
|
||||
class AuditReport(Base):
|
||||
__tablename__ = "audit_reports"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), ForeignKey("tenants.id"), nullable=False)
|
||||
title: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
report_date: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
|
||||
baseline_score: Mapped[float | None] = mapped_column(Float)
|
||||
executive_summary: Mapped[str | None] = mapped_column(Text)
|
||||
scope_description: Mapped[str | None] = mapped_column(Text)
|
||||
key_findings_json: Mapped[str | None] = mapped_column(Text) # JSON snapshot of top findings
|
||||
pdf_path: Mapped[str | None] = mapped_column(String(500))
|
||||
is_baseline: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
generated_by: Mapped[str | None] = mapped_column(String(255)) # admin email
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="audit_reports")
|
||||
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
202
backend/app/schemas/schemas.py
Normal file
202
backend/app/schemas/schemas.py
Normal file
@@ -0,0 +1,202 @@
|
||||
from pydantic import BaseModel, EmailStr, field_validator
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from app.models.models import UserRole, FindingSeverity, FindingStatus, FindingCategory, AssetType
|
||||
|
||||
|
||||
# ─── Auth ────────────────────────────────────────────────────────────────────
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
role: str
|
||||
tenant_id: str
|
||||
full_name: str
|
||||
|
||||
|
||||
class UserMe(BaseModel):
|
||||
id: str
|
||||
email: str
|
||||
full_name: str
|
||||
role: UserRole
|
||||
tenant_id: str
|
||||
|
||||
|
||||
# ─── Tenant ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class TenantBase(BaseModel):
|
||||
name: str
|
||||
slug: str
|
||||
industry: Optional[str] = None
|
||||
size_range: Optional[str] = None
|
||||
contact_email: Optional[str] = None
|
||||
|
||||
|
||||
class TenantCreate(TenantBase):
|
||||
pass
|
||||
|
||||
|
||||
class TenantOut(TenantBase):
|
||||
id: str
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ─── Finding ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class FindingOut(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
asset_id: Optional[str] = None
|
||||
title: str
|
||||
severity: FindingSeverity
|
||||
status: FindingStatus
|
||||
category: FindingCategory
|
||||
technical_description: Optional[str] = None
|
||||
cve_id: Optional[str] = None
|
||||
cvss_score: Optional[float] = None
|
||||
affected_component: Optional[str] = None
|
||||
ai_summary: Optional[str] = None
|
||||
ai_business_impact: Optional[str] = None
|
||||
ai_impact_level: Optional[str] = None
|
||||
ai_remediation_steps: Optional[str] = None
|
||||
ai_fix_priority: Optional[str] = None
|
||||
assignee_email: Optional[str] = None
|
||||
due_date: Optional[datetime] = None
|
||||
is_top_risk: bool
|
||||
source: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class FindingCreate(BaseModel):
|
||||
title: str
|
||||
severity: FindingSeverity
|
||||
category: FindingCategory
|
||||
technical_description: Optional[str] = None
|
||||
cve_id: Optional[str] = None
|
||||
cvss_score: Optional[float] = None
|
||||
affected_component: Optional[str] = None
|
||||
asset_id: Optional[str] = None
|
||||
executive_id: Optional[str] = None
|
||||
source: str = "manual"
|
||||
|
||||
|
||||
class FindingStatusUpdate(BaseModel):
|
||||
status: FindingStatus
|
||||
resolution_note: Optional[str] = None
|
||||
assignee_email: Optional[str] = None
|
||||
due_date: Optional[datetime] = None
|
||||
|
||||
|
||||
# ─── Risk Score ───────────────────────────────────────────────────────────────
|
||||
|
||||
class RiskScoreOut(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
score_date: datetime
|
||||
overall_score: float
|
||||
score_identity: Optional[float] = None
|
||||
score_cloud: Optional[float] = None
|
||||
score_network: Optional[float] = None
|
||||
score_web: Optional[float] = None
|
||||
score_credential: Optional[float] = None
|
||||
score_digital_footprint: Optional[float] = None
|
||||
score_third_party: Optional[float] = None
|
||||
critical_count: int
|
||||
high_count: int
|
||||
medium_count: int
|
||||
low_count: int
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ─── Dashboard ────────────────────────────────────────────────────────────────
|
||||
|
||||
class RiskCardData(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
ai_summary: Optional[str]
|
||||
ai_business_impact: Optional[str]
|
||||
ai_impact_level: Optional[str]
|
||||
ai_fix_priority: Optional[str]
|
||||
severity: str
|
||||
category: str
|
||||
|
||||
|
||||
class DashboardResponse(BaseModel):
|
||||
tenant_name: str
|
||||
current_score: float
|
||||
previous_score: Optional[float]
|
||||
score_delta: Optional[float]
|
||||
score_trend: List[dict] # [{date, score}]
|
||||
top_risks: List[RiskCardData]
|
||||
open_critical: int
|
||||
open_high: int
|
||||
open_medium: int
|
||||
total_open: int
|
||||
baseline_score: Optional[float] = None
|
||||
baseline_date: Optional[datetime] = None
|
||||
|
||||
|
||||
# ─── Audit Report ─────────────────────────────────────────────────────────────
|
||||
|
||||
class AuditReportOut(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
title: str
|
||||
report_date: datetime
|
||||
baseline_score: Optional[float]
|
||||
executive_summary: Optional[str]
|
||||
scope_description: Optional[str]
|
||||
pdf_path: Optional[str]
|
||||
is_baseline: bool
|
||||
generated_by: Optional[str]
|
||||
created_at: datetime
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AuditReportCreate(BaseModel):
|
||||
title: str
|
||||
executive_summary: Optional[str] = None
|
||||
scope_description: Optional[str] = None
|
||||
|
||||
|
||||
# ─── Attack Path ──────────────────────────────────────────────────────────────
|
||||
|
||||
class AttackPathOut(BaseModel):
|
||||
id: str
|
||||
finding_id: str
|
||||
title: str
|
||||
ai_narrative: Optional[str]
|
||||
nodes_json: Optional[str]
|
||||
edges_json: Optional[str]
|
||||
created_at: datetime
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ─── Authorized Asset ─────────────────────────────────────────────────────────
|
||||
|
||||
class AuthorizedAssetCreate(BaseModel):
|
||||
value: str
|
||||
asset_type: AssetType
|
||||
description: Optional[str] = None
|
||||
authorized_by: str
|
||||
|
||||
|
||||
class AuthorizedAssetOut(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
value: str
|
||||
asset_type: AssetType
|
||||
description: Optional[str]
|
||||
authorized_by: Optional[str]
|
||||
authorized_at: datetime
|
||||
is_active: bool
|
||||
model_config = {"from_attributes": True}
|
||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
189
backend/app/services/ai_translator.py
Normal file
189
backend/app/services/ai_translator.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
AI Risk Translator — calls OpenAI/Anthropic to generate plain-English
|
||||
business-impact explanations for security findings.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.models import Finding, AttackPath
|
||||
from sqlalchemy import select
|
||||
import json
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TRANSLATION_SYSTEM_PROMPT = """You are TrustOS, an AI cyber resilience advisor.
|
||||
Your role is to translate technical cybersecurity findings into clear, plain-English
|
||||
business impact statements for executive and non-technical audiences.
|
||||
|
||||
Rules:
|
||||
- Never use CVE IDs, CVSS scores, or technical jargon in the executive summary
|
||||
- Always frame risk in terms of business impact: customers, revenue, operations, reputation
|
||||
- Be direct and calm — not alarmist, not dismissive
|
||||
- Always provide a clear recommended action
|
||||
- Output must be valid JSON matching the schema provided
|
||||
|
||||
Output JSON schema:
|
||||
{
|
||||
"summary": "One sentence: what this is in plain English",
|
||||
"business_impact": "1-2 sentences: what could happen to the business if exploited",
|
||||
"impact_level": "Low|Medium|High|Critical",
|
||||
"remediation_steps": "3-5 concrete steps to fix this, numbered",
|
||||
"fix_priority": "urgent|soon|planned"
|
||||
}"""
|
||||
|
||||
|
||||
async def _call_llm(prompt: str) -> Optional[str]:
|
||||
"""Call the configured LLM provider. Returns raw text response."""
|
||||
from app.core.config import settings
|
||||
try:
|
||||
if settings.AI_PROVIDER == "openai" and settings.OPENAI_API_KEY:
|
||||
from openai import AsyncOpenAI
|
||||
client = AsyncOpenAI(api_key=settings.OPENAI_API_KEY)
|
||||
resp = await client.chat.completions.create(
|
||||
model="gpt-4o-mini",
|
||||
messages=[
|
||||
{"role": "system", "content": TRANSLATION_SYSTEM_PROMPT},
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
temperature=0.3,
|
||||
response_format={"type": "json_object"},
|
||||
)
|
||||
return resp.choices[0].message.content
|
||||
elif settings.AI_PROVIDER == "anthropic" and settings.ANTHROPIC_API_KEY:
|
||||
from anthropic import AsyncAnthropic
|
||||
client = AsyncAnthropic(api_key=settings.ANTHROPIC_API_KEY)
|
||||
resp = await client.messages.create(
|
||||
model="claude-3-haiku-20240307",
|
||||
max_tokens=1024,
|
||||
system=TRANSLATION_SYSTEM_PROMPT,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
return resp.content[0].text
|
||||
else:
|
||||
logger.warning("No AI provider configured — skipping translation")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"LLM call failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def translate_finding_async(finding_id: str):
|
||||
"""Background task: generate AI translation for a finding and persist it."""
|
||||
async with AsyncSessionLocal() as db:
|
||||
result = await db.execute(select(Finding).where(Finding.id == finding_id))
|
||||
finding = result.scalar_one_or_none()
|
||||
if not finding:
|
||||
return
|
||||
|
||||
prompt = f"""Translate this cybersecurity finding:
|
||||
|
||||
Title: {finding.title}
|
||||
Severity: {finding.severity.value}
|
||||
Category: {finding.category.value}
|
||||
CVE ID: {finding.cve_id or 'N/A'}
|
||||
CVSS Score: {finding.cvss_score or 'N/A'}
|
||||
Technical Description: {finding.technical_description or 'Not provided'}
|
||||
Affected Component: {finding.affected_component or 'Unknown'}
|
||||
|
||||
Provide the JSON output as specified."""
|
||||
|
||||
raw = await _call_llm(prompt)
|
||||
if not raw:
|
||||
return
|
||||
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
finding.ai_summary = data.get("summary")
|
||||
finding.ai_business_impact = data.get("business_impact")
|
||||
finding.ai_impact_level = data.get("impact_level")
|
||||
finding.ai_remediation_steps = data.get("remediation_steps")
|
||||
finding.ai_fix_priority = data.get("fix_priority")
|
||||
finding.ai_generated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
logger.info(f"AI translation complete for finding {finding_id}")
|
||||
except (json.JSONDecodeError, KeyError) as e:
|
||||
logger.error(f"Failed to parse AI response for finding {finding_id}: {e}")
|
||||
|
||||
|
||||
async def answer_finding_question(finding: Finding, question: str) -> str:
|
||||
"""AI Security Coach: answer a specific question about a finding."""
|
||||
prompt = f"""A security professional is asking about this finding:
|
||||
|
||||
Title: {finding.title}
|
||||
Summary: {finding.ai_summary or finding.technical_description}
|
||||
Business Impact: {finding.ai_business_impact or 'See technical description'}
|
||||
Category: {finding.category.value}
|
||||
|
||||
Their question: {question}
|
||||
|
||||
Answer in 2-4 sentences. Be specific to this finding. Use plain English."""
|
||||
|
||||
system = "You are TrustOS AI Security Coach. Answer questions about specific security findings clearly and directly. Do not use CVE IDs or CVSS in your answers."
|
||||
|
||||
from app.core.config import settings
|
||||
try:
|
||||
if settings.AI_PROVIDER == "openai" and settings.OPENAI_API_KEY:
|
||||
from openai import AsyncOpenAI
|
||||
client = AsyncOpenAI(api_key=settings.OPENAI_API_KEY)
|
||||
resp = await client.chat.completions.create(
|
||||
model="gpt-4o-mini",
|
||||
messages=[
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
temperature=0.5,
|
||||
)
|
||||
return resp.choices[0].message.content
|
||||
except Exception as e:
|
||||
logger.error(f"AI coach call failed: {e}")
|
||||
|
||||
return "AI explanation is not available. Please review the technical description and remediation steps."
|
||||
|
||||
|
||||
async def generate_attack_path_narrative(finding_id: str):
|
||||
"""Generate an AI-written attack path narrative for a finding."""
|
||||
async with AsyncSessionLocal() as db:
|
||||
result = await db.execute(select(Finding).where(Finding.id == finding_id))
|
||||
finding = result.scalar_one_or_none()
|
||||
if not finding:
|
||||
return
|
||||
|
||||
prompt = f"""Create an attack path for this vulnerability:
|
||||
|
||||
Title: {finding.title}
|
||||
Summary: {finding.ai_summary or finding.technical_description}
|
||||
Category: {finding.category.value}
|
||||
Severity: {finding.severity.value}
|
||||
|
||||
Provide:
|
||||
1. A plain-English narrative (2-3 sentences): how an attacker could exploit this path from the internet to sensitive data
|
||||
2. A JSON list of nodes: [{{"id": "1", "label": "Internet", "type": "attacker", "risk_level": "none"}}, ...]
|
||||
- types: attacker, entry_point, pivot, target
|
||||
- risk_level: none, low, medium, high, critical
|
||||
3. A JSON list of edges: [{{"source": "1", "target": "2"}}, ...]
|
||||
|
||||
Output JSON:
|
||||
{{
|
||||
"narrative": "...",
|
||||
"nodes": [...],
|
||||
"edges": [...]
|
||||
}}"""
|
||||
|
||||
raw = await _call_llm(prompt)
|
||||
if not raw:
|
||||
return
|
||||
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
path = AttackPath(
|
||||
finding_id=finding_id,
|
||||
title=f"Attack path: {finding.title}",
|
||||
ai_narrative=data.get("narrative"),
|
||||
nodes_json=json.dumps(data.get("nodes", [])),
|
||||
edges_json=json.dumps(data.get("edges", [])),
|
||||
)
|
||||
db.add(path)
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"Attack path generation failed for {finding_id}: {e}")
|
||||
139
backend/app/services/report_generator.py
Normal file
139
backend/app/services/report_generator.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
PDF report generator for Vault Audit Reports.
|
||||
Uses Jinja2 + WeasyPrint to produce branded PDFs.
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from jinja2 import Environment, PackageLoader, select_autoescape, DictLoader
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.models import AuditReport, Tenant, Finding, FindingStatus
|
||||
from app.core.config import settings
|
||||
from sqlalchemy import select, desc
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
REPORT_HTML_TEMPLATE = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
body { font-family: 'Segoe UI', sans-serif; color: #1f2328; background: #ffffff; margin: 40px; }
|
||||
.header { border-bottom: 3px solid #1a1f2e; padding-bottom: 20px; margin-bottom: 30px; }
|
||||
.logo { font-size: 28px; font-weight: 800; color: #1a1f2e; letter-spacing: -1px; }
|
||||
.logo span { color: #3b82d4; }
|
||||
.report-title { font-size: 22px; font-weight: 600; margin-top: 10px; }
|
||||
.meta { color: #57606a; font-size: 13px; margin-top: 6px; }
|
||||
.score-block { background: #1a1f2e; color: white; padding: 24px 30px; border-radius: 8px; margin: 24px 0; display: inline-block; min-width: 200px; }
|
||||
.score-value { font-size: 52px; font-weight: 800; color: #3b82d4; line-height: 1; }
|
||||
.score-label { font-size: 13px; color: #94a3b8; margin-top: 4px; }
|
||||
h2 { font-size: 18px; font-weight: 700; color: #1a1f2e; border-left: 4px solid #3b82d4; padding-left: 12px; margin-top: 32px; }
|
||||
.finding { border: 1px solid #e5e7eb; border-radius: 6px; padding: 16px; margin: 12px 0; }
|
||||
.finding.critical { border-left: 4px solid #dc2626; }
|
||||
.finding.high { border-left: 4px solid #ea580c; }
|
||||
.finding.medium { border-left: 4px solid #d97706; }
|
||||
.finding.low { border-left: 4px solid #65a30d; }
|
||||
.finding-title { font-weight: 600; font-size: 15px; }
|
||||
.finding-summary { color: #374151; margin-top: 6px; font-size: 13px; }
|
||||
.badge { display: inline-block; padding: 2px 10px; border-radius: 20px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; margin-left: 8px; }
|
||||
.badge.critical { background: #fee2e2; color: #991b1b; }
|
||||
.badge.high { background: #ffedd5; color: #9a3412; }
|
||||
.badge.medium { background: #fef3c7; color: #92400e; }
|
||||
.badge.low { background: #dcfce7; color: #166534; }
|
||||
.exec-summary { background: #f7f8fa; border-left: 3px solid #3b82d4; padding: 16px 20px; margin: 20px 0; font-size: 14px; line-height: 1.6; }
|
||||
.footer { margin-top: 60px; padding-top: 16px; border-top: 1px solid #e5e7eb; font-size: 11px; color: #57606a; text-align: center; }
|
||||
.confidential { background: #fef3c7; border: 1px solid #fcd34d; padding: 8px 16px; font-size: 12px; color: #78350f; border-radius: 4px; margin-bottom: 20px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="confidential">⚠ CONFIDENTIAL — This report contains sensitive security information. Do not distribute without authorization.</div>
|
||||
<div class="header">
|
||||
<div class="logo">Trust<span>OS</span></div>
|
||||
<div class="report-title">{{ report.title }}</div>
|
||||
<div class="meta">Vault Audit Report · {{ tenant.name }} · Generated {{ report.report_date.strftime('%B %d, %Y') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="score-block">
|
||||
<div class="score-value">{{ report.baseline_score | int }}</div>
|
||||
<div class="score-label">Cyber Health Score at Audit Date<br><small>100 = Optimal · 0 = Critical Risk</small></div>
|
||||
</div>
|
||||
|
||||
{% if report.executive_summary %}
|
||||
<h2>Executive Summary</h2>
|
||||
<div class="exec-summary">{{ report.executive_summary }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if report.scope_description %}
|
||||
<h2>Scope</h2>
|
||||
<p style="font-size:14px; line-height:1.6;">{{ report.scope_description }}</p>
|
||||
{% endif %}
|
||||
|
||||
<h2>Key Findings</h2>
|
||||
{% for f in findings %}
|
||||
<div class="finding {{ f.severity }}">
|
||||
<div class="finding-title">{{ f.title }} <span class="badge {{ f.severity }}">{{ f.severity | upper }}</span></div>
|
||||
{% if f.ai_summary %}
|
||||
<div class="finding-summary">{{ f.ai_summary }}</div>
|
||||
{% endif %}
|
||||
{% if f.ai_business_impact %}
|
||||
<div class="finding-summary" style="margin-top:8px; color:#6b7280;"><strong>Business Impact:</strong> {{ f.ai_business_impact }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="footer">
|
||||
TrustOS · The AI Operating System for Cyber Resilience · trustos.com<br>
|
||||
This report is a point-in-time assessment. Continuous monitoring is required to maintain current accuracy.
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
async def generate_pdf_for_report(report_id: str):
|
||||
"""Generate a branded PDF for a Vault Audit Report and store the path."""
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
r_result = await db.execute(select(AuditReport).where(AuditReport.id == report_id))
|
||||
report = r_result.scalar_one_or_none()
|
||||
if not report:
|
||||
return
|
||||
|
||||
t_result = await db.execute(select(Tenant).where(Tenant.id == report.tenant_id))
|
||||
tenant = t_result.scalar_one_or_none()
|
||||
|
||||
# Get findings snapshot
|
||||
f_result = await db.execute(
|
||||
select(Finding)
|
||||
.where(
|
||||
Finding.tenant_id == report.tenant_id,
|
||||
Finding.status.in_([FindingStatus.open, FindingStatus.in_progress])
|
||||
)
|
||||
.order_by(Finding.created_at)
|
||||
.limit(20)
|
||||
)
|
||||
findings = f_result.scalars().all()
|
||||
|
||||
# Render HTML
|
||||
env = Environment(loader=DictLoader({"report.html": REPORT_HTML_TEMPLATE}))
|
||||
template = env.get_template("report.html")
|
||||
html = template.render(report=report, tenant=tenant, findings=findings)
|
||||
|
||||
# Write PDF
|
||||
storage = Path(settings.STORAGE_PATH) / "reports"
|
||||
storage.mkdir(parents=True, exist_ok=True)
|
||||
pdf_path = storage / f"vault-audit-{report_id}.pdf"
|
||||
|
||||
from weasyprint import HTML as WH
|
||||
WH(string=html).write_pdf(str(pdf_path))
|
||||
|
||||
report.pdf_path = str(pdf_path)
|
||||
await db.commit()
|
||||
logger.info(f"PDF generated: {pdf_path}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"PDF generation failed for report {report_id}: {e}")
|
||||
104
backend/app/services/risk_calculator.py
Normal file
104
backend/app/services/risk_calculator.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
Risk score calculator service.
|
||||
Score is 0–100, higher = safer (inverted from CVSS).
|
||||
Scoring: Start at 100, deduct per open finding by severity.
|
||||
"""
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.models import Finding, RiskScore, FindingStatus, FindingCategory
|
||||
|
||||
|
||||
SEVERITY_DEDUCTIONS = {
|
||||
"critical": 12,
|
||||
"high": 6,
|
||||
"medium": 2,
|
||||
"low": 0.5,
|
||||
"info": 0,
|
||||
}
|
||||
|
||||
CATEGORY_WEIGHTS = {
|
||||
"external_exposure": 1.2,
|
||||
"cloud_posture": 1.1,
|
||||
"credential_exposure": 1.15,
|
||||
"digital_footprint": 0.9,
|
||||
"web_application": 1.1,
|
||||
"network": 1.0,
|
||||
"identity": 1.1,
|
||||
"third_party": 0.8,
|
||||
"compliance": 0.7,
|
||||
"other": 0.6,
|
||||
}
|
||||
|
||||
|
||||
async def recalculate_risk_score(tenant_id: str, db: Optional[AsyncSession] = None) -> float:
|
||||
"""Recalculate and persist the risk score for a tenant. Returns the new overall score."""
|
||||
own_session = db is None
|
||||
if own_session:
|
||||
db = AsyncSessionLocal()
|
||||
|
||||
try:
|
||||
# Fetch all open/in-progress findings
|
||||
result = await db.execute(
|
||||
select(Finding).where(
|
||||
Finding.tenant_id == tenant_id,
|
||||
Finding.status.in_([FindingStatus.open, FindingStatus.in_progress])
|
||||
)
|
||||
)
|
||||
findings = result.scalars().all()
|
||||
|
||||
score = 100.0
|
||||
category_scores: dict[str, float] = {}
|
||||
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0}
|
||||
|
||||
for f in findings:
|
||||
sev = f.severity.value
|
||||
cat = f.category.value
|
||||
deduction = SEVERITY_DEDUCTIONS.get(sev, 0) * CATEGORY_WEIGHTS.get(cat, 1.0)
|
||||
score -= deduction
|
||||
if sev in severity_counts:
|
||||
severity_counts[sev] += 1
|
||||
|
||||
score = max(0.0, min(100.0, round(score, 1)))
|
||||
|
||||
# Category sub-scores
|
||||
category_map = {
|
||||
"credential_exposure": "score_credential",
|
||||
"cloud_posture": "score_cloud",
|
||||
"external_exposure": "score_network",
|
||||
"web_application": "score_web",
|
||||
"identity": "score_identity",
|
||||
"digital_footprint": "score_digital_footprint",
|
||||
"third_party": "score_third_party",
|
||||
}
|
||||
cat_deductions: dict[str, float] = {}
|
||||
for f in findings:
|
||||
col = category_map.get(f.category.value)
|
||||
if col:
|
||||
cat_deductions[col] = cat_deductions.get(col, 0) + SEVERITY_DEDUCTIONS.get(f.severity.value, 0)
|
||||
|
||||
snapshot = RiskScore(
|
||||
tenant_id=tenant_id,
|
||||
score_date=datetime.utcnow(),
|
||||
overall_score=score,
|
||||
score_identity=max(0, 100 - cat_deductions.get("score_identity", 0)),
|
||||
score_cloud=max(0, 100 - cat_deductions.get("score_cloud", 0)),
|
||||
score_network=max(0, 100 - cat_deductions.get("score_network", 0)),
|
||||
score_web=max(0, 100 - cat_deductions.get("score_web", 0)),
|
||||
score_credential=max(0, 100 - cat_deductions.get("score_credential", 0)),
|
||||
score_digital_footprint=max(0, 100 - cat_deductions.get("score_digital_footprint", 0)),
|
||||
score_third_party=max(0, 100 - cat_deductions.get("score_third_party", 0)),
|
||||
critical_count=severity_counts["critical"],
|
||||
high_count=severity_counts["high"],
|
||||
medium_count=severity_counts["medium"],
|
||||
low_count=severity_counts["low"],
|
||||
)
|
||||
db.add(snapshot)
|
||||
await db.commit()
|
||||
return score
|
||||
finally:
|
||||
if own_session:
|
||||
await db.close()
|
||||
0
backend/app/workers/__init__.py
Normal file
0
backend/app/workers/__init__.py
Normal file
22
backend/requirements.txt
Normal file
22
backend/requirements.txt
Normal file
@@ -0,0 +1,22 @@
|
||||
fastapi==0.139.0
|
||||
uvicorn[standard]==0.50.0
|
||||
sqlalchemy==2.0.51
|
||||
alembic==1.18.5
|
||||
asyncpg==0.31.0
|
||||
psycopg2-binary==2.9.12
|
||||
pydantic==2.13.4
|
||||
pydantic-settings==2.14.2
|
||||
python-jose[cryptography]==3.5.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
python-multipart==0.0.32
|
||||
httpx==0.28.1
|
||||
openai==2.44.0
|
||||
anthropic==0.116.0
|
||||
celery==5.6.3
|
||||
redis==8.0.1
|
||||
apscheduler==3.11.3
|
||||
aiohttp==3.14.1
|
||||
aiofiles==25.1.0
|
||||
boto3==1.43.40
|
||||
jinja2==3.1.6
|
||||
weasyprint==69.0
|
||||
232
backend/seed.py
Normal file
232
backend/seed.py
Normal file
@@ -0,0 +1,232 @@
|
||||
"""
|
||||
Seed script — creates the demo tenant, users, assets, findings, and risk score history.
|
||||
Run with: python seed.py
|
||||
"""
|
||||
import asyncio
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from app.db.session import engine, AsyncSessionLocal
|
||||
from app.models.models import (
|
||||
Base, Tenant, User, UserRole, Asset, AssetType, Finding,
|
||||
FindingSeverity, FindingStatus, FindingCategory, RiskScore,
|
||||
AuthorizedAsset, Executive
|
||||
)
|
||||
from app.core.security import hash_password
|
||||
|
||||
|
||||
DEMO_FINDINGS = [
|
||||
{
|
||||
"title": "Internet-accessible admin panel with no authentication",
|
||||
"severity": FindingSeverity.critical,
|
||||
"category": FindingCategory.external_exposure,
|
||||
"technical_description": "The administrative interface at admin.acmecorp.io is exposed to the internet without any authentication requirement. An unauthenticated attacker can access full system configuration.",
|
||||
"affected_component": "admin.acmecorp.io:443",
|
||||
"ai_summary": "Your internal admin portal is accessible to anyone on the internet without a password.",
|
||||
"ai_business_impact": "An attacker could gain full control of your platform, access all customer data, and disrupt operations — potentially within minutes of discovery.",
|
||||
"ai_impact_level": "Critical",
|
||||
"ai_remediation_steps": "1. Immediately restrict admin portal access to VPN/office IPs only.\n2. Add multi-factor authentication.\n3. Audit access logs for any unauthorized access.\n4. Review what data the portal can access.",
|
||||
"ai_fix_priority": "urgent",
|
||||
"is_top_risk": True,
|
||||
"source": "manual",
|
||||
},
|
||||
{
|
||||
"title": "5 executive email accounts found in breach database",
|
||||
"severity": FindingSeverity.high,
|
||||
"category": FindingCategory.credential_exposure,
|
||||
"technical_description": "HaveIBeenPwned confirmed that 5 corporate email addresses belonging to C-suite executives (including CEO and CFO) appear in the Stealer Logs breach dataset with associated plaintext passwords.",
|
||||
"affected_component": "Corporate email / M365",
|
||||
"ai_summary": "Passwords for your senior leadership email accounts have been leaked and are available on the dark web.",
|
||||
"ai_business_impact": "Attackers could use these credentials to access email, cloud systems, and sensitive financial data — enabling targeted phishing, wire fraud, or full account takeover.",
|
||||
"ai_impact_level": "High",
|
||||
"ai_remediation_steps": "1. Force immediate password reset for all affected accounts.\n2. Enable MFA on all executive accounts if not already active.\n3. Review recent email forwarding rules and login activity.\n4. Brief executives on spear-phishing risk.",
|
||||
"ai_fix_priority": "urgent",
|
||||
"is_top_risk": True,
|
||||
"source": "hibp",
|
||||
},
|
||||
{
|
||||
"title": "S3 storage bucket publicly accessible — contains customer files",
|
||||
"severity": FindingSeverity.critical,
|
||||
"category": FindingCategory.cloud_posture,
|
||||
"technical_description": "AWS S3 bucket 'acme-customer-uploads-prod' has public read access enabled. The bucket contains customer-uploaded documents including contracts and personally identifiable information.",
|
||||
"cve_id": None,
|
||||
"affected_component": "AWS S3: acme-customer-uploads-prod",
|
||||
"ai_summary": "A cloud storage location containing your customer files is publicly accessible — anyone on the internet can read them.",
|
||||
"ai_business_impact": "This constitutes a data breach. Exposure of customer PII could trigger regulatory penalties, customer notification obligations, and severe damage to customer trust.",
|
||||
"ai_impact_level": "Critical",
|
||||
"ai_remediation_steps": "1. Immediately set bucket ACL to private.\n2. Audit which files were stored there and since when.\n3. Determine if any unauthorized access occurred via S3 access logs.\n4. Notify legal counsel — this may trigger breach notification requirements.\n5. Review all other S3 buckets for similar misconfigurations.",
|
||||
"ai_fix_priority": "urgent",
|
||||
"is_top_risk": True,
|
||||
"source": "cloud_scan",
|
||||
},
|
||||
{
|
||||
"title": "SSL/TLS certificate expiring in 12 days",
|
||||
"severity": FindingSeverity.medium,
|
||||
"category": FindingCategory.external_exposure,
|
||||
"technical_description": "The TLS certificate for api.acmecorp.io expires in 12 days. Expiration will cause browser security warnings and API failures for all customers.",
|
||||
"affected_component": "api.acmecorp.io",
|
||||
"ai_summary": "Your API security certificate is about to expire — this will break your application for customers in less than two weeks.",
|
||||
"ai_business_impact": "Customers will see security errors and be unable to use your product, resulting in direct revenue impact and support escalation.",
|
||||
"ai_impact_level": "Medium",
|
||||
"ai_remediation_steps": "1. Renew the certificate immediately via your certificate authority.\n2. Set up auto-renewal to prevent this in the future.\n3. Verify renewal across all sub-domains.",
|
||||
"ai_fix_priority": "soon",
|
||||
"is_top_risk": False,
|
||||
"source": "cert_monitor",
|
||||
},
|
||||
{
|
||||
"title": "CEO LinkedIn profile reveals unreported board membership and home city",
|
||||
"severity": FindingSeverity.medium,
|
||||
"category": FindingCategory.digital_footprint,
|
||||
"technical_description": "OSINT analysis of the CEO's public LinkedIn profile reveals home city, secondary board membership not disclosed in company materials, and personal email address listed publicly. This information could be used for targeted social engineering.",
|
||||
"affected_component": "Executive digital footprint",
|
||||
"ai_summary": "Publicly available information about your CEO can help attackers craft convincing impersonation or social engineering attacks.",
|
||||
"ai_business_impact": "Sophisticated attackers use personal information to craft targeted phishing attacks, impersonate executives in wire transfer fraud, or manipulate employees.",
|
||||
"ai_impact_level": "Medium",
|
||||
"ai_remediation_steps": "1. Review and reduce personal information on professional profiles.\n2. Brief CEO on executive-targeted social engineering tactics.\n3. Remove personal email from public profiles.\n4. Enable enhanced privacy settings on all platforms.",
|
||||
"ai_fix_priority": "soon",
|
||||
"is_top_risk": False,
|
||||
"source": "manual",
|
||||
},
|
||||
{
|
||||
"title": "Web application missing security headers (HSTS, CSP, X-Frame-Options)",
|
||||
"severity": FindingSeverity.medium,
|
||||
"category": FindingCategory.web_application,
|
||||
"technical_description": "The main web application at app.acmecorp.io is missing HTTP Strict Transport Security (HSTS), Content Security Policy (CSP), and X-Frame-Options headers. This increases susceptibility to clickjacking and protocol downgrade attacks.",
|
||||
"affected_component": "app.acmecorp.io",
|
||||
"ai_summary": "Your web application is missing basic browser security protections that prevent common attacks.",
|
||||
"ai_business_impact": "Users could be redirected to malicious sites or have their sessions hijacked through man-in-the-middle attacks, exposing customer data.",
|
||||
"ai_impact_level": "Medium",
|
||||
"ai_remediation_steps": "1. Add HSTS header with a minimum 1-year max-age.\n2. Implement a Content Security Policy.\n3. Add X-Frame-Options: DENY.\n4. Test changes in a staging environment first.",
|
||||
"ai_fix_priority": "soon",
|
||||
"is_top_risk": False,
|
||||
"source": "scanner",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def seed():
|
||||
# Ensure tables exist
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
# ── Demo Tenant ──────────────────────────────────────────────────
|
||||
tenant = Tenant(
|
||||
id="acme-corp-demo-001",
|
||||
name="Acme Corp",
|
||||
slug="acme-corp",
|
||||
industry="SaaS",
|
||||
size_range="100-300",
|
||||
contact_email="it@acmecorp.io",
|
||||
)
|
||||
db.add(tenant)
|
||||
|
||||
# ── Users ────────────────────────────────────────────────────────
|
||||
users = [
|
||||
User(
|
||||
tenant_id="acme-corp-demo-001",
|
||||
email="executive@acmecorp.io",
|
||||
hashed_password=hash_password("TrustOS2024!"),
|
||||
full_name="Sarah Chen (CEO)",
|
||||
role=UserRole.executive,
|
||||
),
|
||||
User(
|
||||
tenant_id="acme-corp-demo-001",
|
||||
email="it@acmecorp.io",
|
||||
hashed_password=hash_password("TrustOS2024!"),
|
||||
full_name="Marcus Johnson (IT Director)",
|
||||
role=UserRole.it_admin,
|
||||
),
|
||||
User(
|
||||
tenant_id="acme-corp-demo-001",
|
||||
email="admin@trustos.com",
|
||||
hashed_password=hash_password("TrustOS-Admin-2024!"),
|
||||
full_name="TrustOS Admin",
|
||||
role=UserRole.trustos_admin,
|
||||
tenant_id="acme-corp-demo-001",
|
||||
),
|
||||
]
|
||||
for u in users:
|
||||
db.add(u)
|
||||
|
||||
# ── Authorized Assets (scope-lock) ───────────────────────────────
|
||||
auth_assets = [
|
||||
AuthorizedAsset(
|
||||
tenant_id="acme-corp-demo-001",
|
||||
value="acmecorp.io",
|
||||
asset_type=AssetType.domain,
|
||||
description="Primary company domain",
|
||||
authorized_by="it@acmecorp.io",
|
||||
),
|
||||
AuthorizedAsset(
|
||||
tenant_id="acme-corp-demo-001",
|
||||
value="*.acmecorp.io",
|
||||
asset_type=AssetType.domain,
|
||||
description="All subdomains",
|
||||
authorized_by="it@acmecorp.io",
|
||||
),
|
||||
]
|
||||
for a in auth_assets:
|
||||
db.add(a)
|
||||
|
||||
# ── Assets ────────────────────────────────────────────────────────
|
||||
assets = [
|
||||
Asset(tenant_id="acme-corp-demo-001", name="Main Website", asset_type=AssetType.domain, value="acmecorp.io"),
|
||||
Asset(tenant_id="acme-corp-demo-001", name="Admin Portal", asset_type=AssetType.web_application, value="admin.acmecorp.io"),
|
||||
Asset(tenant_id="acme-corp-demo-001", name="Customer API", asset_type=AssetType.api_endpoint, value="api.acmecorp.io"),
|
||||
Asset(tenant_id="acme-corp-demo-001", name="Customer Upload Bucket", asset_type=AssetType.cloud_resource, value="s3://acme-customer-uploads-prod"),
|
||||
]
|
||||
for a in assets:
|
||||
db.add(a)
|
||||
|
||||
# ── Executives ───────────────────────────────────────────────────
|
||||
execs = [
|
||||
Executive(tenant_id="acme-corp-demo-001", full_name="Sarah Chen", title="CEO", corporate_email="sarah.chen@acmecorp.io"),
|
||||
Executive(tenant_id="acme-corp-demo-001", full_name="David Park", title="CFO", corporate_email="david.park@acmecorp.io"),
|
||||
]
|
||||
for e in execs:
|
||||
db.add(e)
|
||||
|
||||
await db.flush()
|
||||
|
||||
# ── Findings ──────────────────────────────────────────────────────
|
||||
for f_data in DEMO_FINDINGS:
|
||||
finding = Finding(tenant_id="acme-corp-demo-001", ai_generated_at=datetime.utcnow(), **f_data)
|
||||
db.add(finding)
|
||||
|
||||
await db.flush()
|
||||
|
||||
# ── Historical Risk Scores (90 days) ──────────────────────────────
|
||||
base_score = 58.0
|
||||
for i in range(90, 0, -1):
|
||||
delta = (90 - i) * 0.35
|
||||
score = min(100, round(base_score + delta + (i % 3 - 1) * 0.5, 1))
|
||||
rs = RiskScore(
|
||||
tenant_id="acme-corp-demo-001",
|
||||
score_date=datetime.utcnow() - timedelta(days=i),
|
||||
overall_score=score,
|
||||
score_identity=min(100, score + 5),
|
||||
score_cloud=max(0, score - 10),
|
||||
score_network=score,
|
||||
score_web=score + 2,
|
||||
score_credential=max(0, score - 8),
|
||||
score_digital_footprint=score + 3,
|
||||
score_third_party=score + 1,
|
||||
critical_count=3 if i > 30 else 2,
|
||||
high_count=2,
|
||||
medium_count=3,
|
||||
low_count=1,
|
||||
)
|
||||
db.add(rs)
|
||||
|
||||
await db.commit()
|
||||
print("✓ Seed complete — demo tenant 'Acme Corp' created")
|
||||
print(" executive@acmecorp.io / TrustOS2024!")
|
||||
print(" it@acmecorp.io / TrustOS2024!")
|
||||
print(" admin@trustos.com / TrustOS-Admin-2024!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(seed())
|
||||
41
frontend/.gitignore
vendored
Normal file
41
frontend/.gitignore
vendored
Normal 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
5
frontend/AGENTS.md
Normal 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
1
frontend/CLAUDE.md
Normal file
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
36
frontend/README.md
Normal file
36
frontend/README.md
Normal 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.
|
||||
18
frontend/eslint.config.mjs
Normal file
18
frontend/eslint.config.mjs
Normal 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
17
frontend/next.config.ts
Normal 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
7888
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
frontend/package.json
Normal file
34
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
7
frontend/postcss.config.mjs
Normal file
7
frontend/postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
frontend/public/file.svg
Normal file
1
frontend/public/file.svg
Normal 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 |
1
frontend/public/globe.svg
Normal file
1
frontend/public/globe.svg
Normal 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
1
frontend/public/next.svg
Normal 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 |
1
frontend/public/vercel.svg
Normal file
1
frontend/public/vercel.svg
Normal 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 |
1
frontend/public/window.svg
Normal file
1
frontend/public/window.svg
Normal 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 |
164
frontend/src/app/dashboard/page.tsx
Normal file
164
frontend/src/app/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
BIN
frontend/src/app/favicon.ico
Normal file
BIN
frontend/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
245
frontend/src/app/findings/[id]/page.tsx
Normal file
245
frontend/src/app/findings/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
168
frontend/src/app/findings/page.tsx
Normal file
168
frontend/src/app/findings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
frontend/src/app/footprint/page.tsx
Normal file
103
frontend/src/app/footprint/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
frontend/src/app/globals.css
Normal file
52
frontend/src/app/globals.css
Normal 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;
|
||||
}
|
||||
}
|
||||
21
frontend/src/app/layout.tsx
Normal file
21
frontend/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
131
frontend/src/app/login/page.tsx
Normal file
131
frontend/src/app/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
frontend/src/app/page.tsx
Normal file
5
frontend/src/app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function Home() {
|
||||
redirect("/login");
|
||||
}
|
||||
104
frontend/src/app/reports/page.tsx
Normal file
104
frontend/src/app/reports/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
frontend/src/components/RiskDial.tsx
Normal file
86
frontend/src/components/RiskDial.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
frontend/src/components/ScoreTrend.tsx
Normal file
70
frontend/src/components/ScoreTrend.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
107
frontend/src/components/Sidebar.tsx
Normal file
107
frontend/src/components/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
frontend/src/components/TopRiskCard.tsx
Normal file
67
frontend/src/components/TopRiskCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
frontend/src/hooks/useAuth.ts
Normal file
39
frontend/src/hooks/useAuth.ts
Normal 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
127
frontend/src/lib/api.ts
Normal 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;
|
||||
}
|
||||
45
frontend/tailwind.config.ts
Normal file
45
frontend/tailwind.config.ts
Normal 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
34
frontend/tsconfig.json
Normal 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
16
infra/Dockerfile.backend
Normal 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
11
infra/Dockerfile.frontend
Normal 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
59
infra/docker-compose.yml
Normal 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:
|
||||
Reference in New Issue
Block a user