diff --git a/.jules/sentinel.md b/.jules/sentinel.md index f07ab16..ef3b5ee 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -7,3 +7,8 @@ **Vulnerability:** Not a direct security vulnerability, but an environmental instability. The `requirements.txt` allowed `mistralai>=1.1.0`, which pulled in version 2.x. **Learning:** MistralAI 2.x introduces breaking changes in the client import structure (`from mistralai import Mistral` fails if not using the new client correctly or if expecting the old one). This caused the entire application (including security tests) to fail on startup. **Prevention:** Pin critical dependencies like `mistralai==1.1.0` in `requirements.txt` to ensure consistent behavior across development and CI environments, especially when using agents that rely on specific API structures. + +## 2025-05-15 - Information Exposure through Error Messages +**Vulnerability:** Raw exception details (`str(e)`) were being returned to the client in HTTP 500 responses across multiple endpoints, potentially leaking sensitive system state, file paths, or credentials. +**Learning:** Defaulting to returning exception strings in API responses is a common but dangerous practice. A centralized exception handler in FastAPI provides a robust safety net, but must be carefully implemented to avoid swallowing intentional `HTTPException` or `RequestValidationError` responses. +**Prevention:** Implement a global `@app.exception_handler(Exception)` that logs full tracebacks server-side but returns generic messages to clients. Avoid `detail=str(e)` in all production code. diff --git a/agents/mistral_agent/agent.py b/agents/mistral_agent/agent.py index e09878d..1ea204f 100644 --- a/agents/mistral_agent/agent.py +++ b/agents/mistral_agent/agent.py @@ -8,6 +8,7 @@ """ import json +import logging import os import uuid from datetime import UTC, datetime @@ -318,7 +319,8 @@ async def gap_analysis(req: GapAnalysisRequest, db: AsyncSession = Depends(get_d "model": MISTRAL_MODEL, } except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + logging.error(f"Mistral Gap Analysis Error: {str(e)}", exc_info=True) + raise HTTPException(status_code=500, detail="Internal server error") @router.post("/code-review", summary="DevSecOps code security analysis with Codestral") @@ -333,7 +335,8 @@ async def code_review(req: CodeReviewRequest, db: AsyncSession = Depends(get_db) ) return {"analysis": result, "model": MISTRAL_CODE_MODEL} except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + logging.error(f"Mistral Code Review Error: {str(e)}", exc_info=True) + raise HTTPException(status_code=500, detail="Internal server error") @router.post("/ask", summary="Ask a CMMC/ZT compliance question") @@ -343,4 +346,5 @@ async def ask_question(req: QuestionRequest): answer = await agent.answer_compliance_question(req.question, req.context) return {"question": req.question, "answer": answer, "model": MISTRAL_MODEL} except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + logging.error(f"Mistral Ask Error: {str(e)}", exc_info=True) + raise HTTPException(status_code=500, detail="Internal server error") diff --git a/backend/main.py b/backend/main.py index 7c2d83b..674cac2 100644 --- a/backend/main.py +++ b/backend/main.py @@ -8,12 +8,15 @@ """ import json +import logging import os from contextlib import asynccontextmanager from dotenv import load_dotenv -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException, Request +from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse from fastapi_mcp import FastApiMCP from agents.devsecops_agent import agent as devsecops @@ -34,6 +37,13 @@ async def lifespan(app: FastAPI): yield +# ─── Logging ─────────────────────────────────────────────────────────────────── + +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) + # ─── FastAPI Application ─────────────────────────────────────────────────────── app = FastAPI( @@ -64,6 +74,38 @@ async def lifespan(app: FastAPI): # Add Security Headers Middleware app.add_middleware(SecurityHeadersMiddleware) + +# ─── Exception Handlers ─────────────────────────────────────────────────────── + + +@app.exception_handler(Exception) +async def global_exception_handler(request: Request, exc: Exception): + """ + Global exception handler to prevent sensitive information leakage. + Logs the full error server-side and returns a generic 500 message. + """ + # Allow FastAPI's built-in exception handlers to handle their specific cases + if isinstance(exc, HTTPException): + return JSONResponse( + status_code=exc.status_code, + content={"detail": exc.detail}, + ) + if isinstance(exc, RequestValidationError): + return JSONResponse( + status_code=422, + content={"detail": exc.errors()}, + ) + + # Log the actual exception for debugging + logger.error(f"Unhandled error: {str(exc)}", exc_info=True) + + # Return a safe, generic error message to the client + return JSONResponse( + status_code=500, + content={"detail": "Internal server error"}, + ) + + # ─── Routers ────────────────────────────────────────────────────────────────── # Core Routers diff --git a/tests/test_sentinel_errors.py b/tests/test_sentinel_errors.py new file mode 100644 index 0000000..1e3ee8f --- /dev/null +++ b/tests/test_sentinel_errors.py @@ -0,0 +1,35 @@ +import pytest +from httpx import AsyncClient, ASGITransport +from backend.main import app + +@pytest.mark.asyncio +async def test_error_leakage(): + # Adding a temporary route to trigger an error + @app.get("/api/test-error-leakage") + async def trigger_error(): + raise ValueError("Sensitive database connection string: postgresql://user:password@localhost:5432/db") + + async with AsyncClient(transport=ASGITransport(app=app, raise_app_exceptions=False), base_url="http://test") as ac: + response = await ac.get("/api/test-error-leakage") + + assert response.status_code == 500 + assert "Sensitive database connection string" not in response.text + assert "password" not in response.text + assert "Internal server error" in response.text + +@pytest.mark.asyncio +async def test_http_exception_passthrough(): + async with AsyncClient(transport=ASGITransport(app=app, raise_app_exceptions=False), base_url="http://test") as ac: + response = await ac.get("/api/controls/NONEXISTENT") + + assert response.status_code == 404 + assert "Control NONEXISTENT not found" in response.text + +@pytest.mark.asyncio +async def test_validation_error_passthrough(): + async with AsyncClient(transport=ASGITransport(app=app, raise_app_exceptions=False), base_url="http://test") as ac: + # Sending invalid data to a POST endpoint + response = await ac.post("/api/agents/mistral/gap-analysis", json={"invalid": "data"}) + + assert response.status_code == 422 + assert "detail" in response.json()