v1.7.14: LLM/SIEM domain allowlists, SRI hashes, auth misconfig warning, Azure Key Vault integration
This commit is contained in:
@@ -1,4 +1,10 @@
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from secrets_manager import load_key_vault_secrets
|
||||
|
||||
# Pre-load Azure Key Vault secrets into os.environ before pydantic-settings reads them.
|
||||
# This is a no-op if AZURE_KEY_VAULT_NAME is not set.
|
||||
load_key_vault_secrets()
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict # noqa: E402
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
@@ -80,6 +86,15 @@ class Settings(BaseSettings):
|
||||
DOCS_ENABLED: bool = False
|
||||
METRICS_ALLOWED_IPS: str = "127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
|
||||
|
||||
# LLM endpoint restriction (comma-separated domains, e.g. "api.openai.com,*.openai.azure.com")
|
||||
LLM_ALLOWED_DOMAINS: str = ""
|
||||
|
||||
# SIEM webhook restriction (comma-separated domains)
|
||||
SIEM_ALLOWED_DOMAINS: str = ""
|
||||
|
||||
# Optional Azure Key Vault integration for secrets
|
||||
AZURE_KEY_VAULT_NAME: str = ""
|
||||
|
||||
|
||||
_settings = Settings()
|
||||
|
||||
@@ -134,3 +149,8 @@ RATE_LIMIT_WINDOW_SECONDS = _settings.RATE_LIMIT_WINDOW_SECONDS
|
||||
|
||||
DOCS_ENABLED = _settings.DOCS_ENABLED
|
||||
METRICS_ALLOWED_IPS = _settings.METRICS_ALLOWED_IPS
|
||||
|
||||
LLM_ALLOWED_DOMAINS = [d.strip().lower() for d in _settings.LLM_ALLOWED_DOMAINS.split(",") if d.strip()]
|
||||
SIEM_ALLOWED_DOMAINS = [d.strip().lower() for d in _settings.SIEM_ALLOWED_DOMAINS.split(",") if d.strip()]
|
||||
|
||||
AZURE_KEY_VAULT_NAME = _settings.AZURE_KEY_VAULT_NAME
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Admin Operations Center</title>
|
||||
<link rel="stylesheet" href="/style.css?v=15" />
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
<script src="https://alcdn.msauth.net/browser/2.37.0/js/msal-browser.min.js" crossorigin="anonymous"></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" integrity="sha384-WPtu0YHhJ3arcykfnv1JgUffWDSKRnqnDeTpJUbOc2os2moEmLkIdaeR0trPN4be" crossorigin="anonymous"></script>
|
||||
<script src="https://alcdn.msauth.net/browser/2.37.0/js/msal-browser.min.js" integrity="sha384-DUSOaqAzlZRiZxkDi8hL7hXJDZ+X39ZOAYV9ZDx44gUv9pozmcunJH02tjSFLPnW" crossorigin="anonymous"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page" x-data="aocApp()" x-init="initApp()">
|
||||
|
||||
@@ -10,6 +10,8 @@ import structlog
|
||||
from audit_trail import log_action
|
||||
from config import (
|
||||
AI_FEATURES_ENABLED,
|
||||
AUTH_ALLOWED_GROUPS,
|
||||
AUTH_ALLOWED_ROLES,
|
||||
AUTH_ENABLED,
|
||||
CORS_ORIGINS,
|
||||
DOCS_ENABLED,
|
||||
@@ -275,6 +277,13 @@ async def start_periodic_fetch():
|
||||
auth_enabled=AUTH_ENABLED,
|
||||
ai_enabled=AI_FEATURES_ENABLED,
|
||||
)
|
||||
# Warn when auth is enabled but no role/group restrictions are configured
|
||||
if AUTH_ENABLED and not AUTH_ALLOWED_ROLES and not AUTH_ALLOWED_GROUPS:
|
||||
logger.warning(
|
||||
"AUTH_ENABLED is true but no AUTH_ALLOWED_ROLES or AUTH_ALLOWED_GROUPS are configured. "
|
||||
"Any Entra user in the tenant can authenticate and access AOC. "
|
||||
"Set AUTH_ALLOWED_ROLES or AUTH_ALLOWED_GROUPS to restrict access."
|
||||
)
|
||||
if ENABLE_PERIODIC_FETCH:
|
||||
app.state.fetch_task = asyncio.create_task(_periodic_fetch())
|
||||
|
||||
|
||||
@@ -16,3 +16,8 @@ gunicorn
|
||||
mcp
|
||||
redis
|
||||
arq
|
||||
|
||||
# Optional: Azure Key Vault integration for secrets storage
|
||||
# Uncomment if using AZURE_KEY_VAULT_NAME
|
||||
# azure-identity
|
||||
# azure-keyvault-secrets
|
||||
|
||||
@@ -7,6 +7,7 @@ import httpx
|
||||
import structlog
|
||||
from auth import require_auth, user_can_access_privacy_services
|
||||
from config import (
|
||||
LLM_ALLOWED_DOMAINS,
|
||||
LLM_API_KEY,
|
||||
LLM_API_VERSION,
|
||||
LLM_BASE_URL,
|
||||
@@ -398,7 +399,7 @@ def _format_events_for_llm(
|
||||
|
||||
|
||||
def _validate_llm_url(url: str):
|
||||
"""Prevent SSRF by rejecting internal/reserved addresses."""
|
||||
"""Prevent SSRF by rejecting internal/reserved addresses and enforcing domain allowlist."""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(url)
|
||||
@@ -420,6 +421,12 @@ def _validate_llm_url(url: str):
|
||||
except ValueError:
|
||||
pass # hostname is not an IP, which is fine
|
||||
|
||||
# Enforce domain allowlist if configured
|
||||
if LLM_ALLOWED_DOMAINS:
|
||||
allowed = any(hostname == d or (d.startswith("*.") and hostname.endswith(d[1:])) for d in LLM_ALLOWED_DOMAINS)
|
||||
if not allowed:
|
||||
raise RuntimeError(f"LLM_BASE_URL domain '{hostname}' is not in LLM_ALLOWED_DOMAINS")
|
||||
|
||||
|
||||
def _build_chat_url(base_url: str, api_version: str) -> str:
|
||||
base = base_url.rstrip("/")
|
||||
|
||||
76
backend/secrets_manager.py
Normal file
76
backend/secrets_manager.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""Optional Azure Key Vault integration for secrets storage.
|
||||
|
||||
If AZURE_KEY_VAULT_NAME is configured, sensitive secrets are fetched from
|
||||
Azure Key Vault at startup and injected into the environment so that
|
||||
pydantic-settings can read them. Falls back to .env / environment variables
|
||||
when Key Vault is not configured.
|
||||
|
||||
Secret naming convention in Key Vault:
|
||||
aoc-client-secret → CLIENT_SECRET
|
||||
aoc-llm-api-key → LLM_API_KEY
|
||||
aoc-mongo-uri → MONGO_URI
|
||||
aoc-webhook-client-secret → WEBHOOK_CLIENT_SECRET
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger("aoc.secrets")
|
||||
|
||||
_KEY_VAULT_SECRET_MAP = {
|
||||
"aoc-client-secret": "CLIENT_SECRET",
|
||||
"aoc-llm-api-key": "LLM_API_KEY",
|
||||
"aoc-mongo-uri": "MONGO_URI",
|
||||
"aoc-webhook-client-secret": "WEBHOOK_CLIENT_SECRET",
|
||||
}
|
||||
|
||||
|
||||
def _load_from_key_vault(vault_name: str) -> dict[str, str]:
|
||||
"""Fetch secrets from Azure Key Vault and return as {env_name: value}."""
|
||||
try:
|
||||
from azure.identity import DefaultAzureCredential
|
||||
from azure.keyvault.secrets import SecretClient
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"Azure Key Vault libraries are not installed. Run: pip install azure-identity azure-keyvault-secrets"
|
||||
) from exc
|
||||
|
||||
vault_url = f"https://{vault_name}.vault.azure.net/"
|
||||
credential = DefaultAzureCredential()
|
||||
client = SecretClient(vault_url=vault_url, credential=credential)
|
||||
|
||||
loaded = {}
|
||||
for kv_name, env_name in _KEY_VAULT_SECRET_MAP.items():
|
||||
try:
|
||||
secret = client.get_secret(kv_name)
|
||||
if secret.value:
|
||||
loaded[env_name] = secret.value
|
||||
logger.info("Loaded secret from Key Vault", secret_name=kv_name)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to load secret from Key Vault",
|
||||
secret_name=kv_name,
|
||||
error=str(exc),
|
||||
)
|
||||
return loaded
|
||||
|
||||
|
||||
def load_key_vault_secrets(vault_name: str | None = None):
|
||||
"""Load secrets from Azure Key Vault into os.environ if configured.
|
||||
|
||||
This should be called BEFORE pydantic-settings parses configuration.
|
||||
"""
|
||||
vault = vault_name or os.environ.get("AZURE_KEY_VAULT_NAME", "")
|
||||
if not vault:
|
||||
return
|
||||
|
||||
logger.info("Loading secrets from Azure Key Vault", vault_name=vault)
|
||||
secrets = _load_from_key_vault(vault)
|
||||
for env_name, value in secrets.items():
|
||||
os.environ[env_name] = value
|
||||
logger.info(
|
||||
"Key Vault secrets loaded",
|
||||
count=len(secrets),
|
||||
keys=list(secrets.keys()),
|
||||
)
|
||||
@@ -1,15 +1,43 @@
|
||||
import ipaddress
|
||||
|
||||
import requests
|
||||
import structlog
|
||||
from config import SIEM_ENABLED, SIEM_WEBHOOK_URL
|
||||
from config import SIEM_ALLOWED_DOMAINS, SIEM_ENABLED, SIEM_WEBHOOK_URL
|
||||
|
||||
logger = structlog.get_logger("aoc.siem")
|
||||
|
||||
|
||||
def _validate_siem_url(url: str):
|
||||
"""Prevent SSRF by rejecting internal/reserved addresses and enforcing domain allowlist."""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme != "https":
|
||||
raise RuntimeError("SIEM_WEBHOOK_URL must use HTTPS")
|
||||
hostname = (parsed.hostname or "").lower()
|
||||
if not hostname:
|
||||
raise RuntimeError("SIEM_WEBHOOK_URL must have a valid hostname")
|
||||
blocked = {"localhost", "127.0.0.1", "0.0.0.0", "::1", "169.254.169.254"}
|
||||
if hostname in blocked:
|
||||
raise RuntimeError(f"SIEM_WEBHOOK_URL hostname '{hostname}' is not allowed")
|
||||
try:
|
||||
ip = ipaddress.ip_address(hostname)
|
||||
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
|
||||
raise RuntimeError(f"SIEM_WEBHOOK_URL IP '{hostname}' is not allowed")
|
||||
except ValueError:
|
||||
pass
|
||||
if SIEM_ALLOWED_DOMAINS:
|
||||
allowed = any(hostname == d or (d.startswith("*.") and hostname.endswith(d[1:])) for d in SIEM_ALLOWED_DOMAINS)
|
||||
if not allowed:
|
||||
raise RuntimeError(f"SIEM_WEBHOOK_URL domain '{hostname}' is not in SIEM_ALLOWED_DOMAINS")
|
||||
|
||||
|
||||
def forward_event(event: dict):
|
||||
"""Forward a normalized event to the configured SIEM webhook."""
|
||||
if not SIEM_ENABLED or not SIEM_WEBHOOK_URL:
|
||||
return
|
||||
try:
|
||||
_validate_siem_url(SIEM_WEBHOOK_URL)
|
||||
res = requests.post(SIEM_WEBHOOK_URL, json=event, timeout=10)
|
||||
res.raise_for_status()
|
||||
logger.debug("Event forwarded to SIEM", event_id=event.get("id"))
|
||||
|
||||
Reference in New Issue
Block a user