The Anthropic path could never activate: it gated on
`not ANTHROPIC_API_KEY.startswith("sk-ant-")`, but real Anthropic keys start
with `sk-ant-`, so any real key was treated as a placeholder and every request
fell back to mock. It also targeted the retired `claude-3-haiku-20240307`.
- ai_translator.py: add `_real_key()` placeholder detection (rejects `sk-ant-...`,
`changeme`, `your-`, etc. — accepts real secrets), centralize provider gating
in `_ai_enabled()`, and point all three AI features (finding translation,
security coach, attack-path narrative) at `claude-sonnet-5` with thinking
disabled for fast structured output. OpenAI kept as a secondary provider.
- config.py / .env.example: default AI_PROVIDER to anthropic.
Mock mode still works with no key configured; dropping in a real
ANTHROPIC_API_KEY now actually enables live Claude.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
302 lines
12 KiB
Python
302 lines
12 KiB
Python
"""
|
|
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__)
|
|
|
|
# Current Claude model for all AI features. Sonnet 5 is a strong fit for this
|
|
# high-volume translation/classification work — near-Opus quality at lower cost.
|
|
CLAUDE_MODEL = "claude-sonnet-5"
|
|
OPENAI_MODEL = "gpt-4o-mini"
|
|
|
|
_PLACEHOLDER_MARKERS = ("...", "changeme", "your-", "replace")
|
|
|
|
|
|
def _real_key(value: Optional[str]) -> Optional[str]:
|
|
"""Return the key only if it looks like a real secret (not a placeholder).
|
|
|
|
The .env ships with placeholders like ``sk-ant-...`` and ``sk-...``; a real
|
|
key must be present and contain none of the placeholder markers. (The old
|
|
code checked ``startswith("sk-ant-")``, which matches *real* Anthropic keys
|
|
too, so it could never use one.)
|
|
"""
|
|
if not value:
|
|
return None
|
|
lowered = value.lower()
|
|
if any(marker in lowered for marker in _PLACEHOLDER_MARKERS):
|
|
return None
|
|
return value
|
|
|
|
|
|
def _anthropic_key():
|
|
from app.core.config import settings
|
|
return _real_key(settings.ANTHROPIC_API_KEY)
|
|
|
|
|
|
def _openai_key():
|
|
from app.core.config import settings
|
|
return _real_key(settings.OPENAI_API_KEY)
|
|
|
|
|
|
def _ai_enabled() -> bool:
|
|
"""True when a real API key is configured for the active provider."""
|
|
from app.core.config import settings
|
|
if settings.AI_PROVIDER == "anthropic":
|
|
return _anthropic_key() is not None
|
|
if settings.AI_PROVIDER == "openai":
|
|
return _openai_key() is not None
|
|
return False
|
|
|
|
|
|
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:
|
|
anthropic_key = _anthropic_key()
|
|
openai_key = _openai_key()
|
|
if settings.AI_PROVIDER == "anthropic" and anthropic_key:
|
|
from anthropic import AsyncAnthropic
|
|
client = AsyncAnthropic(api_key=anthropic_key)
|
|
resp = await client.messages.create(
|
|
model=CLAUDE_MODEL,
|
|
max_tokens=1024,
|
|
thinking={"type": "disabled"}, # fast, structured JSON output
|
|
system=TRANSLATION_SYSTEM_PROMPT,
|
|
messages=[{"role": "user", "content": prompt}],
|
|
)
|
|
return resp.content[0].text
|
|
elif settings.AI_PROVIDER == "openai" and openai_key:
|
|
from openai import AsyncOpenAI
|
|
client = AsyncOpenAI(api_key=openai_key)
|
|
resp = await client.chat.completions.create(
|
|
model=OPENAI_MODEL,
|
|
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
|
|
else:
|
|
logger.info("No valid AI provider configured — using mock translation")
|
|
return _generate_mock_translation(prompt)
|
|
except Exception as e:
|
|
logger.error(f"LLM call failed: {e}, using mock translation")
|
|
return _generate_mock_translation(prompt)
|
|
|
|
|
|
def _generate_mock_translation(prompt: str) -> str:
|
|
"""Generate a mock AI translation for demo purposes."""
|
|
return json.dumps({
|
|
"summary": "Security vulnerability detected in system component",
|
|
"business_impact": "Unauthorized access or data breach potential if exploited by attackers",
|
|
"impact_level": "High",
|
|
"remediation_steps": "1. Patch the affected component to latest version 2. Deploy patch during maintenance window 3. Verify patch application 4. Monitor logs for suspicious activity 5. Conduct security scan to confirm fix",
|
|
"fix_priority": "soon"
|
|
})
|
|
|
|
|
|
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)
|
|
if "summary" in data:
|
|
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}")
|
|
else:
|
|
logger.warning(f"Invalid AI response format 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:
|
|
anthropic_key = _anthropic_key()
|
|
openai_key = _openai_key()
|
|
if settings.AI_PROVIDER == "anthropic" and anthropic_key:
|
|
from anthropic import AsyncAnthropic
|
|
client = AsyncAnthropic(api_key=anthropic_key)
|
|
resp = await client.messages.create(
|
|
model=CLAUDE_MODEL,
|
|
max_tokens=256,
|
|
thinking={"type": "disabled"},
|
|
system=system,
|
|
messages=[{"role": "user", "content": prompt}],
|
|
)
|
|
return resp.content[0].text
|
|
elif settings.AI_PROVIDER == "openai" and openai_key:
|
|
from openai import AsyncOpenAI
|
|
client = AsyncOpenAI(api_key=openai_key)
|
|
resp = await client.chat.completions.create(
|
|
model=OPENAI_MODEL,
|
|
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 f"Based on this {finding.category.value} issue, {_generate_mock_question_answer(finding, question)}"
|
|
|
|
|
|
def _generate_mock_question_answer(finding: Finding, question: str) -> str:
|
|
"""Generate mock AI response to questions about findings."""
|
|
if "risk" in question.lower() or "impact" in question.lower():
|
|
return finding.ai_business_impact or "This finding could allow attackers to compromise system integrity."
|
|
elif "fix" in question.lower() or "remediate" in question.lower() or "resolve" in question.lower():
|
|
return finding.ai_remediation_steps or "Follow the listed remediation steps to address this issue."
|
|
elif "timeline" in question.lower() or "urgent" in question.lower() or "priority" in question.lower():
|
|
return f"This {finding.severity.value}-severity issue should be addressed as soon as possible."
|
|
else:
|
|
return "Review the finding details above for comprehensive information about this security issue."
|
|
|
|
|
|
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
|
|
|
|
if not _ai_enabled():
|
|
raw = _generate_mock_attack_path(finding)
|
|
else:
|
|
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:
|
|
raw = _generate_mock_attack_path(finding)
|
|
|
|
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()
|
|
logger.info(f"Attack path generated for finding {finding_id}")
|
|
except Exception as e:
|
|
logger.error(f"Attack path generation failed for {finding_id}: {e}")
|
|
|
|
|
|
def _generate_mock_attack_path(finding: Finding) -> str:
|
|
"""Generate a mock attack path for demo purposes."""
|
|
nodes = [
|
|
{"id": "1", "label": "Internet", "type": "attacker", "risk_level": "none"},
|
|
{"id": "2", "label": "Public Endpoint", "type": "entry_point", "risk_level": "critical"},
|
|
{"id": "3", "label": "Web Server", "type": "pivot", "risk_level": "high"},
|
|
{"id": "4", "label": "Database", "type": "target", "risk_level": "critical"},
|
|
]
|
|
edges = [
|
|
{"source": "1", "target": "2"},
|
|
{"source": "2", "target": "3"},
|
|
{"source": "3", "target": "4"},
|
|
]
|
|
|
|
narrative = f"An attacker from the internet discovers the exposed entry point in your {finding.category.value} infrastructure. They exploit the vulnerability to pivot through your web tier and ultimately access sensitive data in your backend database."
|
|
|
|
return json.dumps({
|
|
"narrative": narrative,
|
|
"nodes": nodes,
|
|
"edges": edges,
|
|
})
|