GT AI OS Community Edition v2.0.33

Security hardening release addressing CodeQL and Dependabot alerts:

- Fix stack trace exposure in error responses
- Add SSRF protection with DNS resolution checking
- Implement proper URL hostname validation (replaces substring matching)
- Add centralized path sanitization to prevent path traversal
- Fix ReDoS vulnerability in email validation regex
- Improve HTML sanitization in validation utilities
- Fix capability wildcard matching in auth utilities
- Update glob dependency to address CVE
- Add CodeQL suppression comments for verified false positives

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
HackWeasel
2025-12-12 17:04:45 -05:00
commit b9dfb86260
746 changed files with 232071 additions and 0 deletions

View File

@@ -0,0 +1,128 @@
"""
GT 2.0 Control Panel Backend - CB-REST API Standards Integration
This module integrates the CB-REST standards into the Control Panel backend
"""
import os
import sys
from pathlib import Path
# Add the api-standards package to the path
api_standards_path = Path(__file__).parent.parent.parent.parent.parent / "packages" / "api-standards" / "src"
if api_standards_path.exists():
sys.path.insert(0, str(api_standards_path))
# Import CB-REST standards
try:
from response import StandardResponse, format_response, format_error
from capability import (
init_capability_verifier,
verify_capability,
require_capability,
Capability,
CapabilityToken
)
from errors import ErrorCode, APIError, raise_api_error
from middleware import (
RequestCorrelationMiddleware,
CapabilityMiddleware,
TenantIsolationMiddleware,
RateLimitMiddleware
)
except ImportError as e:
# Fallback for development - create minimal implementations
print(f"Warning: Could not import api-standards package: {e}")
# Create minimal implementations for development
class StandardResponse:
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
def format_response(data, capability_used, request_id=None):
return {
"data": data,
"error": None,
"capability_used": capability_used,
"request_id": request_id or "dev-mode"
}
def format_error(code, message, capability_used="none", **kwargs):
return {
"data": None,
"error": {
"code": code,
"message": message,
**kwargs
},
"capability_used": capability_used,
"request_id": kwargs.get("request_id", "dev-mode")
}
class ErrorCode:
CAPABILITY_INSUFFICIENT = "CAPABILITY_INSUFFICIENT"
RESOURCE_NOT_FOUND = "RESOURCE_NOT_FOUND"
INVALID_REQUEST = "INVALID_REQUEST"
SYSTEM_ERROR = "SYSTEM_ERROR"
class APIError(Exception):
def __init__(self, code, message, **kwargs):
self.code = code
self.message = message
self.kwargs = kwargs
super().__init__(message)
# Export all CB-REST components
__all__ = [
'StandardResponse',
'format_response',
'format_error',
'init_capability_verifier',
'verify_capability',
'require_capability',
'Capability',
'CapabilityToken',
'ErrorCode',
'APIError',
'raise_api_error',
'RequestCorrelationMiddleware',
'CapabilityMiddleware',
'TenantIsolationMiddleware',
'RateLimitMiddleware'
]
def setup_api_standards(app, secret_key: str):
"""
Setup CB-REST API standards for the application
Args:
app: FastAPI application instance
secret_key: Secret key for JWT signing
"""
# Initialize capability verifier
if 'init_capability_verifier' in globals():
init_capability_verifier(secret_key)
# Add middleware in correct order
if 'RequestCorrelationMiddleware' in globals():
app.add_middleware(RequestCorrelationMiddleware)
if 'RateLimitMiddleware' in globals():
app.add_middleware(
RateLimitMiddleware,
requests_per_minute=100 # Adjust based on your needs
)
if 'TenantIsolationMiddleware' in globals():
app.add_middleware(
TenantIsolationMiddleware,
enforce_isolation=True
)
if 'CapabilityMiddleware' in globals():
app.add_middleware(
CapabilityMiddleware,
exclude_paths=["/health", "/ready", "/metrics", "/docs", "/redoc", "/api/v1/auth/login"]
)

View File

@@ -0,0 +1,156 @@
"""
Authentication and authorization utilities
"""
import jwt
from datetime import datetime, timedelta, timezone
from typing import Optional, Dict, Any
from fastapi import HTTPException, Security, Depends, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.config import settings
from app.core.database import get_db
from app.models.user import User
security = HTTPBearer()
class JWTHandler:
"""JWT token handler"""
@staticmethod
def create_access_token(
user_id: int,
user_email: str,
user_type: str,
current_tenant: Optional[dict] = None,
available_tenants: Optional[list] = None,
capabilities: Optional[list] = None,
# For token refresh: preserve original login time and absolute expiry
original_iat: Optional[datetime] = None,
original_absolute_exp: Optional[float] = None,
# Server-side session token (Issue #264)
session_token: Optional[str] = None
) -> str:
"""Create a JWT access token with tenant context
NIST SP 800-63B AAL2 Compliant Session Management (Issues #242, #264):
- exp: 12 hours (matches absolute timeout) - serves as JWT-level backstop
- absolute_exp: Absolute timeout (12 hours) - NOT refreshable, forces re-login
- iat: Original login time - preserved across token refreshes
- session_id: Server-side session token for authoritative validation
The server-side session (via SessionService) enforces the 30-minute idle timeout
by tracking last_activity_at. JWT exp is set to 12 hours so it doesn't block
requests before the server-side session validation can check activity-based idle timeout.
"""
now = datetime.now(timezone.utc)
# Use original iat if refreshing, otherwise current time (new login)
iat = original_iat if original_iat else now
# Calculate absolute expiry: iat + absolute timeout hours (only set on initial login)
if original_absolute_exp is not None:
absolute_exp = original_absolute_exp
else:
absolute_exp = (iat + timedelta(hours=settings.JWT_ABSOLUTE_TIMEOUT_HOURS)).timestamp()
payload = {
"sub": str(user_id),
"email": user_email,
"user_type": user_type,
# Current tenant context (most important)
"current_tenant": current_tenant or {},
# Available tenants for switching
"available_tenants": available_tenants or [],
# Base capabilities (rarely used - tenant-specific capabilities are in current_tenant)
"capabilities": capabilities or [],
# NIST/OWASP Session Timeouts (Issues #242, #264)
# exp: Idle timeout - 4 hours from now (refreshable)
"exp": now + timedelta(minutes=settings.JWT_EXPIRES_MINUTES),
# iat: Original login time (preserved across refreshes)
"iat": iat,
# absolute_exp: Absolute timeout from original login (NOT refreshable)
"absolute_exp": absolute_exp,
# session_id: Server-side session token for authoritative validation (Issue #264)
# The server-side session is the source of truth - JWT expiry is secondary
"session_id": session_token
}
# Use HS256 with JWT_SECRET from settings (auto-generated by installer)
return jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
@staticmethod
def decode_token(token: str) -> Dict[str, Any]:
"""Decode and validate a JWT token"""
try:
# Use HS256 with JWT_SECRET from settings (auto-generated by installer)
payload = jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM])
return payload
except jwt.ExpiredSignatureError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token has expired"
)
except jwt.InvalidTokenError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token"
)
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Security(security),
db: AsyncSession = Depends(get_db)
) -> User:
"""Get the current authenticated user"""
token = credentials.credentials
payload = JWTHandler.decode_token(token)
user_id = int(payload["sub"])
# Get user from database
result = await db.execute(
select(User).where(User.id == user_id)
)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User account is inactive"
)
return user
async def require_admin(current_user: User = Depends(get_current_user)) -> User:
"""Require the current user to be a super admin (control panel access)"""
if current_user.user_type != "super_admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Super admin access required"
)
return current_user
async def require_super_admin(current_user: User = Depends(get_current_user)) -> User:
"""Require the current user to be a super admin"""
if current_user.user_type != "super_admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Super admin access required"
)
return current_user

View File

@@ -0,0 +1,145 @@
"""
Configuration settings for GT 2.0 Control Panel Backend
"""
import os
from typing import List, Optional
from pydantic_settings import BaseSettings
from pydantic import Field, validator
class Settings(BaseSettings):
"""Application settings"""
# Application
DEBUG: bool = Field(default=False, env="DEBUG")
ENVIRONMENT: str = Field(default="development", env="ENVIRONMENT")
SECRET_KEY: str = Field(default="PRODUCTION_SECRET_KEY_REQUIRED", env="SECRET_KEY")
ALLOWED_ORIGINS: List[str] = Field(
default=["http://localhost:3000", "http://localhost:3001"],
env="ALLOWED_ORIGINS"
)
# Database (PostgreSQL direct connection)
DATABASE_URL: str = Field(
default="postgresql+asyncpg://postgres:gt2_admin_dev_password@postgres:5432/gt2_admin",
env="DATABASE_URL"
)
# Redis removed - PostgreSQL handles all session and caching needs
# MinIO removed - PostgreSQL handles all file storage
# Kubernetes
KUBERNETES_IN_CLUSTER: bool = Field(default=False, env="KUBERNETES_IN_CLUSTER")
KUBECONFIG_PATH: Optional[str] = Field(default=None, env="KUBECONFIG_PATH")
# ChromaDB
CHROMADB_HOST: str = Field(default="localhost", env="CHROMADB_HOST")
CHROMADB_PORT: int = Field(default=8000, env="CHROMADB_PORT")
CHROMADB_AUTH_USER: str = Field(default="admin", env="CHROMADB_AUTH_USER")
CHROMADB_AUTH_PASSWORD: str = Field(default="dev_chroma_password", env="CHROMADB_AUTH_PASSWORD")
# Dremio SQL Federation
DREMIO_URL: Optional[str] = Field(default="http://dremio:9047", env="DREMIO_URL")
DREMIO_USERNAME: Optional[str] = Field(default="admin", env="DREMIO_USERNAME")
DREMIO_PASSWORD: Optional[str] = Field(default="admin123", env="DREMIO_PASSWORD")
# Service Authentication
SERVICE_AUTH_TOKEN: Optional[str] = Field(default="internal-service-token", env="SERVICE_AUTH_TOKEN")
# JWT - NIST/OWASP Compliant Session Timeouts (Issue #242)
JWT_SECRET: str = Field(default="dev-jwt-secret-change-in-production-32-chars-minimum", env="JWT_SECRET")
JWT_ALGORITHM: str = Field(default="HS256", env="JWT_ALGORITHM")
# JWT expiration: 12 hours (matches absolute timeout) - NIST SP 800-63B AAL2 compliant
# Server-side session enforces 30-minute idle timeout via last_activity_at tracking
# JWT exp serves as backstop - prevents tokens from being valid beyond absolute limit
JWT_EXPIRES_MINUTES: int = Field(default=720, env="JWT_EXPIRES_MINUTES")
# Absolute timeout: 12 hours - NIST SP 800-63B AAL2 maximum session duration
JWT_ABSOLUTE_TIMEOUT_HOURS: int = Field(default=12, env="JWT_ABSOLUTE_TIMEOUT_HOURS")
# Legacy support (deprecated - use JWT_EXPIRES_MINUTES instead)
JWT_EXPIRES_HOURS: int = Field(default=4, env="JWT_EXPIRES_HOURS")
# Aliases for compatibility
@property
def secret_key(self) -> str:
return self.JWT_SECRET
@property
def algorithm(self) -> str:
return self.JWT_ALGORITHM
# Encryption
MASTER_ENCRYPTION_KEY: str = Field(
default="dev-master-key-change-in-production-must-be-32-bytes-long",
env="MASTER_ENCRYPTION_KEY"
)
# Tenant Settings
TENANT_DATA_DIR: str = Field(default="/data", env="TENANT_DATA_DIR")
DEFAULT_TENANT_TEMPLATE: str = Field(default="basic", env="DEFAULT_TENANT_TEMPLATE")
# External AI Services
GROQ_API_KEY: Optional[str] = Field(default=None, env="GROQ_API_KEY")
GROQ_BASE_URL: str = Field(default="https://api.groq.com/openai/v1", env="GROQ_BASE_URL")
# Resource Cluster
RESOURCE_CLUSTER_URL: str = Field(default="http://localhost:8003", env="RESOURCE_CLUSTER_URL")
# Logging
LOG_LEVEL: str = Field(default="INFO", env="LOG_LEVEL")
# RabbitMQ (for message bus)
RABBITMQ_URL: str = Field(
default="amqp://admin:dev_rabbitmq_password@localhost:5672/gt2",
env="RABBITMQ_URL"
)
MESSAGE_BUS_SECRET_KEY: str = Field(
default="PRODUCTION_MESSAGE_BUS_SECRET_REQUIRED",
env="MESSAGE_BUS_SECRET_KEY"
)
# Celery (for background tasks) - Using PostgreSQL instead of Redis
CELERY_BROKER_URL: str = Field(
default="db+postgresql://gt2_admin:dev_password_change_in_prod@postgres:5432/gt2_control_panel",
env="CELERY_BROKER_URL"
)
CELERY_RESULT_BACKEND: str = Field(
default="db+postgresql://gt2_admin:dev_password_change_in_prod@postgres:5432/gt2_control_panel",
env="CELERY_RESULT_BACKEND"
)
@validator('ALLOWED_ORIGINS', pre=True)
def parse_cors_origins(cls, v):
if isinstance(v, str):
return [origin.strip() for origin in v.split(',')]
return v
@validator('MASTER_ENCRYPTION_KEY')
def validate_encryption_key_length(cls, v):
if len(v) < 32:
raise ValueError('Master encryption key must be at least 32 characters long')
return v
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
case_sensitive = True
# Global settings instance
settings = Settings()
def get_settings() -> Settings:
"""Get the global settings instance"""
return settings
# Environment-specific configurations
if settings.ENVIRONMENT == "production":
# Production settings
# Validation checks removed for flexibility
pass
else:
# Development/Test settings
import logging
logging.basicConfig(level=getattr(logging, settings.LOG_LEVEL.upper()))

View File

@@ -0,0 +1,136 @@
"""
Database configuration and utilities for GT 2.0 Control Panel
"""
import asyncio
from contextlib import asynccontextmanager, contextmanager
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker, Session
from sqlalchemy.pool import StaticPool
import structlog
from app.core.config import settings
logger = structlog.get_logger()
# Create async engine
engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.DEBUG,
future=True,
pool_pre_ping=True,
pool_size=10,
max_overflow=20
)
# Create sync engine for session management (Issue #264)
# Uses psycopg2 instead of asyncpg for sync operations
sync_database_url = settings.DATABASE_URL.replace("+asyncpg", "").replace("postgresql://", "postgresql+psycopg2://")
if "+psycopg2" not in sync_database_url:
sync_database_url = sync_database_url.replace("postgresql://", "postgresql+psycopg2://")
sync_engine = create_engine(
sync_database_url,
echo=settings.DEBUG,
pool_pre_ping=True,
pool_size=5,
max_overflow=10
)
# Create session makers
async_session_maker = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False
)
sync_session_maker = sessionmaker(
sync_engine,
class_=Session,
expire_on_commit=False
)
class Base(DeclarativeBase):
"""Base class for all database models"""
pass
@asynccontextmanager
async def get_db_session():
"""Get database session context manager"""
async with async_session_maker() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
async def get_db():
"""Dependency for getting async database session"""
async with get_db_session() as session:
yield session
@contextmanager
def get_sync_db_session():
"""Get synchronous database session context manager (for session management)"""
session = sync_session_maker()
try:
yield session
session.commit()
except Exception:
session.rollback()
raise
finally:
session.close()
def get_sync_db():
"""Dependency for getting synchronous database session (for session management)"""
with get_sync_db_session() as session:
yield session
async def init_db():
"""Initialize database tables"""
try:
# Import all models to ensure they're registered
from app.models import tenant, user, ai_resource, usage, audit, model_config, tenant_model_config
async with engine.begin() as conn:
# Create all tables
await conn.run_sync(Base.metadata.create_all)
logger.info("Database tables created successfully")
except Exception as e:
logger.error("Failed to initialize database", error=str(e))
raise
async def check_db_connection():
"""Check database connection health"""
try:
async with get_db_session() as session:
await session.execute("SELECT 1")
return True
except Exception as e:
logger.error("Database connection check failed", error=str(e))
return False
def create_database_url(
username: str,
password: str,
host: str,
port: int,
database: str,
driver: str = "postgresql+asyncpg"
) -> str:
"""Create database URL from components"""
return f"{driver}://{username}:{password}@{host}:{port}/{database}"

View File

@@ -0,0 +1,29 @@
"""
Email Service for GT 2.0
SMTP integration using Brevo (formerly Sendinblue) for transactional emails.
Supported email types:
- Budget alert emails (FR #257)
"""
import os
import smtplib
from email.mime.text import MIMEText
from typing import Optional, List
import structlog
logger = structlog.get_logger()
def get_smtp_config() -> dict:
"""Get SMTP configuration from environment"""
return {
'host': os.getenv('SMTP_HOST', 'smtp-relay.brevo.com'),
'port': int(os.getenv('SMTP_PORT', '587')),
'username': os.getenv('SMTP_USERNAME'), # Brevo SMTP username (usually your email)
'password': os.getenv('SMTP_PASSWORD'), # Brevo SMTP password (from SMTP settings)
'from_email': os.getenv('SMTP_FROM_EMAIL', 'noreply@gt2.com'),
'from_name': os.getenv('SMTP_FROM_NAME', 'GT 2.0 Platform'),
'use_tls': os.getenv('SMTP_USE_TLS', 'true').lower() == 'true'
}

View File

@@ -0,0 +1,189 @@
"""
Two-Factor Authentication utilities for GT 2.0
Handles TOTP generation, verification, QR code generation, and secret encryption.
"""
import os
import pyotp
import qrcode
import qrcode.image.pil
import io
import base64
from typing import Optional, Tuple
from cryptography.fernet import Fernet
import structlog
logger = structlog.get_logger()
# Get encryption key from environment
TFA_ENCRYPTION_KEY = os.getenv("TFA_ENCRYPTION_KEY")
TFA_ISSUER_NAME = os.getenv("TFA_ISSUER_NAME", "GT 2.0 Enterprise AI")
class TFAManager:
"""Manager for Two-Factor Authentication operations"""
def __init__(self):
if not TFA_ENCRYPTION_KEY:
raise ValueError("TFA_ENCRYPTION_KEY environment variable must be set")
# Initialize Fernet cipher for encryption
self.cipher = Fernet(TFA_ENCRYPTION_KEY.encode())
def generate_secret(self) -> str:
"""Generate a new TOTP secret (32-byte base32)"""
secret = pyotp.random_base32()
logger.info("Generated new TOTP secret")
return secret
def encrypt_secret(self, secret: str) -> str:
"""Encrypt TOTP secret using Fernet"""
try:
encrypted = self.cipher.encrypt(secret.encode())
return encrypted.decode()
except Exception as e:
logger.error("Failed to encrypt TFA secret", error=str(e))
raise
def decrypt_secret(self, encrypted_secret: str) -> str:
"""Decrypt TOTP secret using Fernet"""
try:
decrypted = self.cipher.decrypt(encrypted_secret.encode())
return decrypted.decode()
except Exception as e:
logger.error("Failed to decrypt TFA secret", error=str(e))
raise
def generate_qr_code_uri(self, secret: str, email: str, tenant_name: str) -> str:
"""
Generate otpauth:// URI for QR code scanning
Args:
secret: TOTP secret (unencrypted)
email: User's email address
tenant_name: Tenant name for issuer branding (required, no fallback)
Returns:
otpauth:// URI string
"""
issuer = f"{tenant_name} - GT AI OS"
totp = pyotp.TOTP(secret)
uri = totp.provisioning_uri(name=email, issuer_name=issuer)
logger.info("Generated QR code URI", email=email, issuer=issuer, tenant_name=tenant_name)
return uri
def generate_qr_code_image(self, uri: str) -> str:
"""
Generate base64-encoded QR code image from URI
Args:
uri: otpauth:// URI
Returns:
Base64-encoded PNG image data (data:image/png;base64,...)
"""
try:
# Create QR code with PIL image factory
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=10,
border=4,
image_factory=qrcode.image.pil.PilImage,
)
qr.add_data(uri)
qr.make(fit=True)
# Create image using PIL
img = qr.make_image(fill_color="black", back_color="white")
# Convert to base64
buffer = io.BytesIO()
img.save(buffer, format='PNG')
img_str = base64.b64encode(buffer.getvalue()).decode()
return f"data:image/png;base64,{img_str}"
except Exception as e:
logger.error("Failed to generate QR code image", error=str(e))
raise
def verify_totp(self, secret: str, code: str, window: int = 1) -> bool:
"""
Verify TOTP code with time window tolerance
Args:
secret: TOTP secret (unencrypted)
code: 6-digit code from user
window: Time window tolerance (±30 seconds per window, default=1)
Returns:
True if code is valid, False otherwise
"""
try:
totp = pyotp.TOTP(secret)
is_valid = totp.verify(code, valid_window=window)
if is_valid:
logger.info("TOTP verification successful")
else:
logger.warning("TOTP verification failed")
return is_valid
except Exception as e:
logger.error("TOTP verification error", error=str(e))
return False
def get_current_code(self, secret: str) -> str:
"""
Get current TOTP code (for testing/debugging only)
Args:
secret: TOTP secret (unencrypted)
Returns:
Current 6-digit TOTP code
"""
totp = pyotp.TOTP(secret)
return totp.now()
def setup_new_tfa(self, email: str, tenant_name: str) -> Tuple[str, str, str]:
"""
Complete setup for new TFA: generate secret, encrypt, create QR code
Args:
email: User's email address
tenant_name: Tenant name for QR code issuer (required, no fallback)
Returns:
Tuple of (encrypted_secret, qr_code_image, manual_entry_key)
"""
# Generate secret
secret = self.generate_secret()
# Encrypt for storage
encrypted_secret = self.encrypt_secret(secret)
# Generate QR code URI with tenant branding
qr_code_uri = self.generate_qr_code_uri(secret, email, tenant_name)
# Generate QR code image (base64-encoded PNG for display in <img> tag)
qr_code_image = self.generate_qr_code_image(qr_code_uri)
# Manual entry key (formatted for easier typing)
manual_entry_key = ' '.join([secret[i:i+4] for i in range(0, len(secret), 4)])
logger.info("TFA setup completed", email=email, tenant_name=tenant_name)
return encrypted_secret, qr_code_image, manual_entry_key
# Singleton instance
_tfa_manager: Optional[TFAManager] = None
def get_tfa_manager() -> TFAManager:
"""Get singleton TFAManager instance"""
global _tfa_manager
if _tfa_manager is None:
_tfa_manager = TFAManager()
return _tfa_manager