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:
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()
|
||||
Reference in New Issue
Block a user