Compare commits

...

2 Commits

Author SHA1 Message Date
drjones
08b12e2ce2 Wire AI features to Claude (claude-sonnet-5); fix key detection + retired model
Some checks failed
Deploy / deploy (push) Has been cancelled
Deploy / docker-build (push) Has been cancelled
Test / backend-test (push) Has been cancelled
Test / frontend-test (push) Has been cancelled
Test / security-scan (push) Has been cancelled
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>
2026-07-07 17:20:31 +00:00
drjones
c44fa6fcda Fix cross-origin API access, bcrypt login break, and weak secret key
Some checks failed
Deploy / deploy (push) Has been cancelled
Deploy / docker-build (push) Has been cancelled
Test / backend-test (push) Has been cancelled
Test / frontend-test (push) Has been cancelled
Test / security-scan (push) Has been cancelled
The frontend only worked from localhost:3000 — the browser made absolute
cross-origin calls to localhost:8000, which (a) points at the visitor's own
machine when accessed via the LAN IP or Cloudflare tunnel, and (b) was blocked
by CORS (backend only allowed localhost:3000). Now all API calls are
same-origin and proxied to the backend:

- api.ts: default API base to same-origin ("") instead of localhost:8000
- docker-compose: NEXT_PUBLIC_API_URL="" (client uses relative /api)
- next.config.ts: server-side rewrite uses API_INTERNAL_URL (http://backend:8000
  inside Docker) so :3000 direct access proxies correctly
- main.py: broaden CORS via allow_origin_regex (localhost + private LAN) and an
  optional CORS_ORIGINS env var, as defense-in-depth for direct :8000 access

Login was returning 500: passlib 1.7.4 is incompatible with the bcrypt 5.0.0
actually installed in the image (requirements pin 4.1.2, but the image drifted).
- security.py: call bcrypt directly, truncating to 72 bytes; existing $2b$
  hashes verify unchanged, and it works under both bcrypt 4.x and 5.x

Security: SECRET_KEY was a known placeholder string while the app is exposed on
the LAN and via Cloudflare tunnel — anyone could forge admin JWTs. Regenerated
to a strong random value in backend/.env (gitignored; not committed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 15:13:23 +00:00
8 changed files with 124 additions and 46 deletions

View File

@@ -8,10 +8,12 @@ SYNC_DATABASE_URL=postgresql://trustos:trustos_dev@postgres:5432/trustos
SECRET_KEY=changeme-use-openssl-rand-hex-32-in-production
ACCESS_TOKEN_EXPIRE_MINUTES=480
# AI
OPENAI_API_KEY=sk-...
# AI — defaults to Anthropic/Claude (model: claude-sonnet-5).
# Leave the placeholders as-is to run in mock mode (all AI features return
# canned demo responses). Drop in a real key to enable live AI.
AI_PROVIDER=anthropic
ANTHROPIC_API_KEY=sk-ant-...
AI_PROVIDER=openai
OPENAI_API_KEY=sk-...
# External APIs
HIBP_API_KEY=

View File

@@ -13,9 +13,10 @@ class Settings(BaseSettings):
SECRET_KEY: str = "dev-secret-key-change-in-production"
ALGORITHM: str = "HS256"
CORS_ORIGINS: Optional[str] = None # comma-separated extra allowed origins
ACCESS_TOKEN_EXPIRE_MINUTES: int = 480
AI_PROVIDER: str = "openai"
AI_PROVIDER: str = "anthropic"
OPENAI_API_KEY: Optional[str] = None
ANTHROPIC_API_KEY: Optional[str] = None

View File

@@ -1,21 +1,34 @@
from datetime import datetime, timedelta
from typing import Optional, Any
import bcrypt
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")
# We call bcrypt directly rather than through passlib: passlib 1.7.x is
# incompatible with bcrypt >= 4.1 (its version shim raises on the modern
# library). bcrypt only uses the first 72 bytes of a password, so we truncate
# to that to avoid the ValueError bcrypt 5.x raises on longer inputs. Existing
# $2b$ hashes (created via passlib's bcrypt backend) verify unchanged.
_BCRYPT_MAX_BYTES = 72
def _to_bytes(password: str) -> bytes:
return password.encode("utf-8")[:_BCRYPT_MAX_BYTES]
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
try:
return bcrypt.checkpw(_to_bytes(plain), hashed.encode("utf-8"))
except (ValueError, TypeError):
return False
def hash_password(password: str) -> str:
return pwd_context.hash(password)
return bcrypt.hashpw(_to_bytes(password), bcrypt.gensalt()).decode("utf-8")
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:

View File

@@ -22,9 +22,16 @@ app = FastAPI(
lifespan=lifespan,
)
# The app is normally served same-origin (nginx proxies /api to the backend),
# so CORS is not exercised in the primary flow. This allowlist exists for direct
# browser access to :8000 during development and for any explicitly configured
# origins. Extra origins can be added via the CORS_ORIGINS env var (comma-separated).
_extra_origins = [o.strip() for o in (settings.CORS_ORIGINS or "").split(",") if o.strip()]
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000", "http://frontend:3000"],
allow_origins=["http://localhost:3000", "http://frontend:3000", *_extra_origins],
# Also allow localhost and private-network hosts on any port (dev convenience).
allow_origin_regex=r"^https?://(localhost|127\.0\.0\.1|10\.\d+\.\d+\.\d+|192\.168\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+)(:\d+)?$",
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],

View File

@@ -12,7 +12,51 @@ import logging
logger = logging.getLogger(__name__)
TRANSLATION_SYSTEM_PROMPT = """You are TrustOS, an AI cyber resilience advisor.
# 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.
@@ -37,11 +81,24 @@ 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 and not settings.OPENAI_API_KEY.startswith("sk-..."):
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=settings.OPENAI_API_KEY)
client = AsyncOpenAI(api_key=openai_key)
resp = await client.chat.completions.create(
model="gpt-4o-mini",
model=OPENAI_MODEL,
messages=[
{"role": "system", "content": TRANSLATION_SYSTEM_PROMPT},
{"role": "user", "content": prompt}
@@ -50,16 +107,6 @@ async def _call_llm(prompt: str) -> Optional[str]:
response_format={"type": "json_object"},
)
return resp.choices[0].message.content
elif settings.AI_PROVIDER == "anthropic" and settings.ANTHROPIC_API_KEY and not settings.ANTHROPIC_API_KEY.startswith("sk-ant-"):
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.info("No valid AI provider configured — using mock translation")
return _generate_mock_translation(prompt)
@@ -137,11 +184,24 @@ Answer in 2-4 sentences. Be specific to this finding. Use plain English."""
from app.core.config import settings
try:
if settings.AI_PROVIDER == "openai" and settings.OPENAI_API_KEY and not settings.OPENAI_API_KEY.startswith("sk-..."):
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=settings.OPENAI_API_KEY)
client = AsyncOpenAI(api_key=openai_key)
resp = await client.chat.completions.create(
model="gpt-4o-mini",
model=OPENAI_MODEL,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": prompt}
@@ -149,16 +209,6 @@ Answer in 2-4 sentences. Be specific to this finding. Use plain English."""
temperature=0.5,
)
return resp.choices[0].message.content
elif settings.AI_PROVIDER == "anthropic" and settings.ANTHROPIC_API_KEY and not settings.ANTHROPIC_API_KEY.startswith("sk-ant-"):
from anthropic import AsyncAnthropic
client = AsyncAnthropic(api_key=settings.ANTHROPIC_API_KEY)
resp = await client.messages.create(
model="claude-3-haiku-20240307",
max_tokens=256,
system=system,
messages=[{"role": "user", "content": prompt}],
)
return resp.content[0].text
except Exception as e:
logger.error(f"AI coach call failed: {e}")
@@ -185,13 +235,7 @@ async def generate_attack_path_narrative(finding_id: str):
if not finding:
return
from app.core.config import settings
use_mock = not (
(settings.AI_PROVIDER == "openai" and settings.OPENAI_API_KEY and not settings.OPENAI_API_KEY.startswith("sk-...")) or
(settings.AI_PROVIDER == "anthropic" and settings.ANTHROPIC_API_KEY and not settings.ANTHROPIC_API_KEY.startswith("sk-ant-"))
)
if use_mock:
if not _ai_enabled():
raw = _generate_mock_attack_path(finding)
else:
prompt = f"""Create an attack path for this vulnerability:

View File

@@ -5,7 +5,11 @@ const nextConfig: NextConfig = {
return [
{
source: "/api/:path*",
destination: `${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"}/api/:path*`,
// Server-side proxy for direct :3000 access. Uses the internal service
// hostname inside Docker (API_INTERNAL_URL=http://backend:8000); falls
// back to localhost for non-container dev. Not used when served via
// nginx, which proxies /api itself.
destination: `${process.env.API_INTERNAL_URL || "http://localhost:8000"}/api/:path*`,
},
];
},

View File

@@ -1,4 +1,8 @@
const BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
// Empty string = same-origin. API calls go to /api/... on whatever host served
// the page, and are proxied to the backend by nginx (port 80) or the Next.js
// rewrite in next.config.ts (port 3000). This keeps everything same-origin so
// it works via localhost, the LAN IP, and the Cloudflare tunnel with no CORS.
const BASE = process.env.NEXT_PUBLIC_API_URL ?? "";
let authToken: string | null = null;

View File

@@ -43,7 +43,10 @@ services:
dockerfile: ../infra/Dockerfile.frontend
container_name: trustos_frontend
environment:
NEXT_PUBLIC_API_URL: http://localhost:8000
# Empty = same-origin API calls (proxied to backend by nginx / Next rewrite).
# Works via localhost, LAN IP, and Cloudflare tunnel without CORS.
NEXT_PUBLIC_API_URL: ""
API_INTERNAL_URL: "http://backend:8000"
ports:
- "3000:3000"
volumes: