feat: initial TrustOS platform scaffold

- FastAPI backend: auth, findings, dashboard, attack paths, footprint, AI translator, risk calculator, PDF report generator
- Next.js frontend: Vault dashboard, login, findings table, finding detail with AI coach, digital footprint, reports
- PostgreSQL data model: tenants, users, assets, findings, risk scores, audit reports, attack paths
- Docker Compose + Dockerfiles for all services
- Demo seed data: Acme Corp with 6 findings and 90-day risk score history
- AI Risk Translator (OpenAI/Anthropic) with plain-English business impact
- Role-based access: executive / it_admin / trustos_admin
- Scope-lock engine: authorization required before any assessment

Stage 1-8 complete: Phase 1 Vault Audit product ready
This commit is contained in:
drjones
2026-07-05 09:46:08 +00:00
commit 463dff883b
64 changed files with 11679 additions and 0 deletions

View File

View File

@@ -0,0 +1,189 @@
"""
AI Risk Translator — calls OpenAI/Anthropic to generate plain-English
business-impact explanations for security findings.
"""
from datetime import datetime
from typing import Optional
from app.db.session import AsyncSessionLocal
from app.models.models import Finding, AttackPath
from sqlalchemy import select
import json
import logging
logger = logging.getLogger(__name__)
TRANSLATION_SYSTEM_PROMPT = """You are TrustOS, an AI cyber resilience advisor.
Your role is to translate technical cybersecurity findings into clear, plain-English
business impact statements for executive and non-technical audiences.
Rules:
- Never use CVE IDs, CVSS scores, or technical jargon in the executive summary
- Always frame risk in terms of business impact: customers, revenue, operations, reputation
- Be direct and calm — not alarmist, not dismissive
- Always provide a clear recommended action
- Output must be valid JSON matching the schema provided
Output JSON schema:
{
"summary": "One sentence: what this is in plain English",
"business_impact": "1-2 sentences: what could happen to the business if exploited",
"impact_level": "Low|Medium|High|Critical",
"remediation_steps": "3-5 concrete steps to fix this, numbered",
"fix_priority": "urgent|soon|planned"
}"""
async def _call_llm(prompt: str) -> Optional[str]:
"""Call the configured LLM provider. Returns raw text response."""
from app.core.config import settings
try:
if settings.AI_PROVIDER == "openai" and settings.OPENAI_API_KEY:
from openai import AsyncOpenAI
client = AsyncOpenAI(api_key=settings.OPENAI_API_KEY)
resp = await client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": TRANSLATION_SYSTEM_PROMPT},
{"role": "user", "content": prompt}
],
temperature=0.3,
response_format={"type": "json_object"},
)
return resp.choices[0].message.content
elif settings.AI_PROVIDER == "anthropic" and settings.ANTHROPIC_API_KEY:
from anthropic import AsyncAnthropic
client = AsyncAnthropic(api_key=settings.ANTHROPIC_API_KEY)
resp = await client.messages.create(
model="claude-3-haiku-20240307",
max_tokens=1024,
system=TRANSLATION_SYSTEM_PROMPT,
messages=[{"role": "user", "content": prompt}],
)
return resp.content[0].text
else:
logger.warning("No AI provider configured — skipping translation")
return None
except Exception as e:
logger.error(f"LLM call failed: {e}")
return None
async def translate_finding_async(finding_id: str):
"""Background task: generate AI translation for a finding and persist it."""
async with AsyncSessionLocal() as db:
result = await db.execute(select(Finding).where(Finding.id == finding_id))
finding = result.scalar_one_or_none()
if not finding:
return
prompt = f"""Translate this cybersecurity finding:
Title: {finding.title}
Severity: {finding.severity.value}
Category: {finding.category.value}
CVE ID: {finding.cve_id or 'N/A'}
CVSS Score: {finding.cvss_score or 'N/A'}
Technical Description: {finding.technical_description or 'Not provided'}
Affected Component: {finding.affected_component or 'Unknown'}
Provide the JSON output as specified."""
raw = await _call_llm(prompt)
if not raw:
return
try:
data = json.loads(raw)
finding.ai_summary = data.get("summary")
finding.ai_business_impact = data.get("business_impact")
finding.ai_impact_level = data.get("impact_level")
finding.ai_remediation_steps = data.get("remediation_steps")
finding.ai_fix_priority = data.get("fix_priority")
finding.ai_generated_at = datetime.utcnow()
await db.commit()
logger.info(f"AI translation complete for finding {finding_id}")
except (json.JSONDecodeError, KeyError) as e:
logger.error(f"Failed to parse AI response for finding {finding_id}: {e}")
async def answer_finding_question(finding: Finding, question: str) -> str:
"""AI Security Coach: answer a specific question about a finding."""
prompt = f"""A security professional is asking about this finding:
Title: {finding.title}
Summary: {finding.ai_summary or finding.technical_description}
Business Impact: {finding.ai_business_impact or 'See technical description'}
Category: {finding.category.value}
Their question: {question}
Answer in 2-4 sentences. Be specific to this finding. Use plain English."""
system = "You are TrustOS AI Security Coach. Answer questions about specific security findings clearly and directly. Do not use CVE IDs or CVSS in your answers."
from app.core.config import settings
try:
if settings.AI_PROVIDER == "openai" and settings.OPENAI_API_KEY:
from openai import AsyncOpenAI
client = AsyncOpenAI(api_key=settings.OPENAI_API_KEY)
resp = await client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": system},
{"role": "user", "content": prompt}
],
temperature=0.5,
)
return resp.choices[0].message.content
except Exception as e:
logger.error(f"AI coach call failed: {e}")
return "AI explanation is not available. Please review the technical description and remediation steps."
async def generate_attack_path_narrative(finding_id: str):
"""Generate an AI-written attack path narrative for a finding."""
async with AsyncSessionLocal() as db:
result = await db.execute(select(Finding).where(Finding.id == finding_id))
finding = result.scalar_one_or_none()
if not finding:
return
prompt = f"""Create an attack path for this vulnerability:
Title: {finding.title}
Summary: {finding.ai_summary or finding.technical_description}
Category: {finding.category.value}
Severity: {finding.severity.value}
Provide:
1. A plain-English narrative (2-3 sentences): how an attacker could exploit this path from the internet to sensitive data
2. A JSON list of nodes: [{{"id": "1", "label": "Internet", "type": "attacker", "risk_level": "none"}}, ...]
- types: attacker, entry_point, pivot, target
- risk_level: none, low, medium, high, critical
3. A JSON list of edges: [{{"source": "1", "target": "2"}}, ...]
Output JSON:
{{
"narrative": "...",
"nodes": [...],
"edges": [...]
}}"""
raw = await _call_llm(prompt)
if not raw:
return
try:
data = json.loads(raw)
path = AttackPath(
finding_id=finding_id,
title=f"Attack path: {finding.title}",
ai_narrative=data.get("narrative"),
nodes_json=json.dumps(data.get("nodes", [])),
edges_json=json.dumps(data.get("edges", [])),
)
db.add(path)
await db.commit()
except Exception as e:
logger.error(f"Attack path generation failed for {finding_id}: {e}")

View File

@@ -0,0 +1,139 @@
"""
PDF report generator for Vault Audit Reports.
Uses Jinja2 + WeasyPrint to produce branded PDFs.
"""
import os
import json
import logging
from datetime import datetime
from pathlib import Path
from jinja2 import Environment, PackageLoader, select_autoescape, DictLoader
from app.db.session import AsyncSessionLocal
from app.models.models import AuditReport, Tenant, Finding, FindingStatus
from app.core.config import settings
from sqlalchemy import select, desc
logger = logging.getLogger(__name__)
REPORT_HTML_TEMPLATE = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<style>
body { font-family: 'Segoe UI', sans-serif; color: #1f2328; background: #ffffff; margin: 40px; }
.header { border-bottom: 3px solid #1a1f2e; padding-bottom: 20px; margin-bottom: 30px; }
.logo { font-size: 28px; font-weight: 800; color: #1a1f2e; letter-spacing: -1px; }
.logo span { color: #3b82d4; }
.report-title { font-size: 22px; font-weight: 600; margin-top: 10px; }
.meta { color: #57606a; font-size: 13px; margin-top: 6px; }
.score-block { background: #1a1f2e; color: white; padding: 24px 30px; border-radius: 8px; margin: 24px 0; display: inline-block; min-width: 200px; }
.score-value { font-size: 52px; font-weight: 800; color: #3b82d4; line-height: 1; }
.score-label { font-size: 13px; color: #94a3b8; margin-top: 4px; }
h2 { font-size: 18px; font-weight: 700; color: #1a1f2e; border-left: 4px solid #3b82d4; padding-left: 12px; margin-top: 32px; }
.finding { border: 1px solid #e5e7eb; border-radius: 6px; padding: 16px; margin: 12px 0; }
.finding.critical { border-left: 4px solid #dc2626; }
.finding.high { border-left: 4px solid #ea580c; }
.finding.medium { border-left: 4px solid #d97706; }
.finding.low { border-left: 4px solid #65a30d; }
.finding-title { font-weight: 600; font-size: 15px; }
.finding-summary { color: #374151; margin-top: 6px; font-size: 13px; }
.badge { display: inline-block; padding: 2px 10px; border-radius: 20px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; margin-left: 8px; }
.badge.critical { background: #fee2e2; color: #991b1b; }
.badge.high { background: #ffedd5; color: #9a3412; }
.badge.medium { background: #fef3c7; color: #92400e; }
.badge.low { background: #dcfce7; color: #166534; }
.exec-summary { background: #f7f8fa; border-left: 3px solid #3b82d4; padding: 16px 20px; margin: 20px 0; font-size: 14px; line-height: 1.6; }
.footer { margin-top: 60px; padding-top: 16px; border-top: 1px solid #e5e7eb; font-size: 11px; color: #57606a; text-align: center; }
.confidential { background: #fef3c7; border: 1px solid #fcd34d; padding: 8px 16px; font-size: 12px; color: #78350f; border-radius: 4px; margin-bottom: 20px; }
</style>
</head>
<body>
<div class="confidential">⚠ CONFIDENTIAL — This report contains sensitive security information. Do not distribute without authorization.</div>
<div class="header">
<div class="logo">Trust<span>OS</span></div>
<div class="report-title">{{ report.title }}</div>
<div class="meta">Vault Audit Report · {{ tenant.name }} · Generated {{ report.report_date.strftime('%B %d, %Y') }}</div>
</div>
<div class="score-block">
<div class="score-value">{{ report.baseline_score | int }}</div>
<div class="score-label">Cyber Health Score at Audit Date<br><small>100 = Optimal · 0 = Critical Risk</small></div>
</div>
{% if report.executive_summary %}
<h2>Executive Summary</h2>
<div class="exec-summary">{{ report.executive_summary }}</div>
{% endif %}
{% if report.scope_description %}
<h2>Scope</h2>
<p style="font-size:14px; line-height:1.6;">{{ report.scope_description }}</p>
{% endif %}
<h2>Key Findings</h2>
{% for f in findings %}
<div class="finding {{ f.severity }}">
<div class="finding-title">{{ f.title }} <span class="badge {{ f.severity }}">{{ f.severity | upper }}</span></div>
{% if f.ai_summary %}
<div class="finding-summary">{{ f.ai_summary }}</div>
{% endif %}
{% if f.ai_business_impact %}
<div class="finding-summary" style="margin-top:8px; color:#6b7280;"><strong>Business Impact:</strong> {{ f.ai_business_impact }}</div>
{% endif %}
</div>
{% endfor %}
<div class="footer">
TrustOS · The AI Operating System for Cyber Resilience · trustos.com<br>
This report is a point-in-time assessment. Continuous monitoring is required to maintain current accuracy.
</div>
</body>
</html>
"""
async def generate_pdf_for_report(report_id: str):
"""Generate a branded PDF for a Vault Audit Report and store the path."""
async with AsyncSessionLocal() as db:
try:
r_result = await db.execute(select(AuditReport).where(AuditReport.id == report_id))
report = r_result.scalar_one_or_none()
if not report:
return
t_result = await db.execute(select(Tenant).where(Tenant.id == report.tenant_id))
tenant = t_result.scalar_one_or_none()
# Get findings snapshot
f_result = await db.execute(
select(Finding)
.where(
Finding.tenant_id == report.tenant_id,
Finding.status.in_([FindingStatus.open, FindingStatus.in_progress])
)
.order_by(Finding.created_at)
.limit(20)
)
findings = f_result.scalars().all()
# Render HTML
env = Environment(loader=DictLoader({"report.html": REPORT_HTML_TEMPLATE}))
template = env.get_template("report.html")
html = template.render(report=report, tenant=tenant, findings=findings)
# Write PDF
storage = Path(settings.STORAGE_PATH) / "reports"
storage.mkdir(parents=True, exist_ok=True)
pdf_path = storage / f"vault-audit-{report_id}.pdf"
from weasyprint import HTML as WH
WH(string=html).write_pdf(str(pdf_path))
report.pdf_path = str(pdf_path)
await db.commit()
logger.info(f"PDF generated: {pdf_path}")
except Exception as e:
logger.error(f"PDF generation failed for report {report_id}: {e}")

View File

@@ -0,0 +1,104 @@
"""
Risk score calculator service.
Score is 0100, higher = safer (inverted from CVSS).
Scoring: Start at 100, deduct per open finding by severity.
"""
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from datetime import datetime
from typing import Optional
from app.db.session import AsyncSessionLocal
from app.models.models import Finding, RiskScore, FindingStatus, FindingCategory
SEVERITY_DEDUCTIONS = {
"critical": 12,
"high": 6,
"medium": 2,
"low": 0.5,
"info": 0,
}
CATEGORY_WEIGHTS = {
"external_exposure": 1.2,
"cloud_posture": 1.1,
"credential_exposure": 1.15,
"digital_footprint": 0.9,
"web_application": 1.1,
"network": 1.0,
"identity": 1.1,
"third_party": 0.8,
"compliance": 0.7,
"other": 0.6,
}
async def recalculate_risk_score(tenant_id: str, db: Optional[AsyncSession] = None) -> float:
"""Recalculate and persist the risk score for a tenant. Returns the new overall score."""
own_session = db is None
if own_session:
db = AsyncSessionLocal()
try:
# Fetch all open/in-progress findings
result = await db.execute(
select(Finding).where(
Finding.tenant_id == tenant_id,
Finding.status.in_([FindingStatus.open, FindingStatus.in_progress])
)
)
findings = result.scalars().all()
score = 100.0
category_scores: dict[str, float] = {}
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0}
for f in findings:
sev = f.severity.value
cat = f.category.value
deduction = SEVERITY_DEDUCTIONS.get(sev, 0) * CATEGORY_WEIGHTS.get(cat, 1.0)
score -= deduction
if sev in severity_counts:
severity_counts[sev] += 1
score = max(0.0, min(100.0, round(score, 1)))
# Category sub-scores
category_map = {
"credential_exposure": "score_credential",
"cloud_posture": "score_cloud",
"external_exposure": "score_network",
"web_application": "score_web",
"identity": "score_identity",
"digital_footprint": "score_digital_footprint",
"third_party": "score_third_party",
}
cat_deductions: dict[str, float] = {}
for f in findings:
col = category_map.get(f.category.value)
if col:
cat_deductions[col] = cat_deductions.get(col, 0) + SEVERITY_DEDUCTIONS.get(f.severity.value, 0)
snapshot = RiskScore(
tenant_id=tenant_id,
score_date=datetime.utcnow(),
overall_score=score,
score_identity=max(0, 100 - cat_deductions.get("score_identity", 0)),
score_cloud=max(0, 100 - cat_deductions.get("score_cloud", 0)),
score_network=max(0, 100 - cat_deductions.get("score_network", 0)),
score_web=max(0, 100 - cat_deductions.get("score_web", 0)),
score_credential=max(0, 100 - cat_deductions.get("score_credential", 0)),
score_digital_footprint=max(0, 100 - cat_deductions.get("score_digital_footprint", 0)),
score_third_party=max(0, 100 - cat_deductions.get("score_third_party", 0)),
critical_count=severity_counts["critical"],
high_count=severity_counts["high"],
medium_count=severity_counts["medium"],
low_count=severity_counts["low"],
)
db.add(snapshot)
await db.commit()
return score
finally:
if own_session:
await db.close()