From f7faf13db44fa417d6da1d75b5320d57cf2b5694 Mon Sep 17 00:00:00 2001 From: Romario Hornburg Date: Tue, 3 Mar 2026 13:58:15 +0000 Subject: [PATCH 1/2] feat(audit): implement audit logging functionality with middleware and configuration options - Added audit logging middleware to the FastAPI application, activated based on configuration. - Enhanced user and token handling in authentication to include audit actor information. - Updated webapp and worker command execution endpoints to capture and log execution details. - Introduced environment variables for audit logging configuration in Docker setup. --- api/app/audit/__init__.py | 1 + api/app/audit/core/__init__.py | 1 + api/app/audit/core/audit_config.py | 26 ++++ api/app/audit/core/audit_event.py | 82 +++++++++++ api/app/audit/core/audit_sender.py | 46 +++++++ api/app/main.py | 6 + api/app/shared/dependencies/auth.py | 5 +- api/app/shared/middleware/__init__.py | 1 + api/app/shared/middleware/audit_middleware.py | 70 ++++++++++ api/app/webapps/api/webapp_handlers.py | 15 +- api/app/workers/api/worker_handlers.py | 15 +- api/pyrightconfig.json | 5 + api/tests/unit/test_audit_config.py | 67 +++++++++ api/tests/unit/test_audit_event.py | 128 ++++++++++++++++++ api/tests/unit/test_audit_sender.py | 61 +++++++++ docker/.env.example | 18 +++ docker/docker-compose.yaml | 5 + 17 files changed, 549 insertions(+), 3 deletions(-) create mode 100644 api/app/audit/__init__.py create mode 100644 api/app/audit/core/__init__.py create mode 100644 api/app/audit/core/audit_config.py create mode 100644 api/app/audit/core/audit_event.py create mode 100644 api/app/audit/core/audit_sender.py create mode 100644 api/app/shared/middleware/__init__.py create mode 100644 api/app/shared/middleware/audit_middleware.py create mode 100644 api/pyrightconfig.json create mode 100644 api/tests/unit/test_audit_config.py create mode 100644 api/tests/unit/test_audit_event.py create mode 100644 api/tests/unit/test_audit_sender.py diff --git a/api/app/audit/__init__.py b/api/app/audit/__init__.py new file mode 100644 index 0000000..6c4684d --- /dev/null +++ b/api/app/audit/__init__.py @@ -0,0 +1 @@ +# Audit logging module - exports events to external SIEMs diff --git a/api/app/audit/core/__init__.py b/api/app/audit/core/__init__.py new file mode 100644 index 0000000..6d2bda9 --- /dev/null +++ b/api/app/audit/core/__init__.py @@ -0,0 +1 @@ +# Audit core - config, event schema, sender diff --git a/api/app/audit/core/audit_config.py b/api/app/audit/core/audit_config.py new file mode 100644 index 0000000..c8c44b1 --- /dev/null +++ b/api/app/audit/core/audit_config.py @@ -0,0 +1,26 @@ +"""Audit logging configuration loaded from environment variables.""" + +import os + + +def is_audit_enabled() -> bool: + """Return True if audit logging is enabled via AUDIT_LOG_ENABLED.""" + return os.getenv("AUDIT_LOG_ENABLED", "false").lower() == "true" + + +def get_siem_url() -> str | None: + """Return the SIEM endpoint URL or None if not configured.""" + return os.getenv("AUDIT_SIEM_URL") or None + + +def get_siem_token() -> str | None: + """Return the SIEM Bearer token or None if not configured.""" + return os.getenv("AUDIT_SIEM_TOKEN") or None + + +def get_siem_timeout() -> float: + """Return the SIEM HTTP timeout in seconds (default: 5).""" + try: + return float(os.getenv("AUDIT_SIEM_TIMEOUT", "5")) + except ValueError: + return 5.0 diff --git a/api/app/audit/core/audit_event.py b/api/app/audit/core/audit_event.py new file mode 100644 index 0000000..af6e89f --- /dev/null +++ b/api/app/audit/core/audit_event.py @@ -0,0 +1,82 @@ +"""Audit event schema and HTTP method to action mapping.""" + +from datetime import datetime, timezone + + +def map_method_to_action(method: str, path: str) -> str: + """ + Map HTTP method and path to audit action. + GET=READ, POST=CREATE/EXEC, PUT/PATCH=UPDATE, DELETE=DELETE. + For /exec endpoints, use EXEC. + """ + method_upper = method.upper() + path_lower = path.lower() + + if "/exec" in path_lower: + return "EXEC" + + mapping = { + "GET": "READ", + "HEAD": "READ", + "OPTIONS": "READ", + "POST": "CREATE", + "PUT": "UPDATE", + "PATCH": "UPDATE", + "DELETE": "DELETE", + } + return mapping.get(method_upper, "UNKNOWN") + + +def normalize_resource(path: str) -> str: + """ + Normalize request path to resource identifier. + Strips leading slash and collapses repeated slashes. + Keeps path structure (e.g., organizations/123/instances/456). + """ + if not path: + return "unknown" + # Remove leading slash, strip trailing slash + normalized = path.strip("/") + # Collapse multiple slashes + while "//" in normalized: + normalized = normalized.replace("//", "/") + return normalized or "unknown" + + +def _truncate_for_audit(value: str, max_length: int = 4096) -> str: + """Truncate string for audit logging to avoid huge payloads.""" + if not value or len(value) <= max_length: + return value + return value[:max_length] + f"... [truncated, total {len(value)} chars]" + + +def build_audit_event( + actor: str, + method: str, + path: str, + status_code: int, + source_ip: str, + exec_payload: dict | None = None, +) -> dict: + """Build an audit event dict from request/response data.""" + event = { + "timestamp": datetime.now(timezone.utc).isoformat(), + "actor": actor, + "action": map_method_to_action(method, path), + "resource": normalize_resource(path), + "status": f"{'success' if 200 <= status_code < 400 else 'failure'}:{status_code}", + "source_ip": source_ip, + } + if exec_payload: + event["exec_request"] = exec_payload.get("request") + response_data = exec_payload.get("response") + if response_data and isinstance(response_data, dict): + truncated = response_data.copy() + if "stdout" in truncated and isinstance(truncated["stdout"], str): + truncated["stdout"] = _truncate_for_audit(truncated["stdout"]) + if "stderr" in truncated and isinstance(truncated["stderr"], str): + truncated["stderr"] = _truncate_for_audit(truncated["stderr"]) + event["exec_response"] = truncated + else: + event["exec_response"] = response_data + return event diff --git a/api/app/audit/core/audit_sender.py b/api/app/audit/core/audit_sender.py new file mode 100644 index 0000000..09de243 --- /dev/null +++ b/api/app/audit/core/audit_sender.py @@ -0,0 +1,46 @@ +"""Asynchronous audit event sender with fail-open behavior.""" + +import logging +from typing import Any + +import httpx +from httpx import ConnectError, TimeoutException + +from app.audit.core.audit_config import get_siem_token, get_siem_timeout + +logger = logging.getLogger(__name__) + + +async def send_audit_event(siem_url: str, payload: dict[str, Any]) -> None: + """ + Send audit event to SIEM endpoint asynchronously. + Fail-open: logs errors but never propagates exceptions. + """ + timeout = get_siem_timeout() + headers: dict[str, str] = {"Content-Type": "application/json"} + token = get_siem_token() + if token: + headers["Authorization"] = f"Bearer {token}" + + try: + async with httpx.AsyncClient() as client: + response = await client.post( + siem_url, + json=payload, + headers=headers, + timeout=timeout, + ) + if response.status_code >= 400: + logger.warning( + "Audit SIEM returned %s for event: %s", + response.status_code, + payload.get("resource", "unknown"), + ) + except TimeoutException as e: + logger.warning("Audit SIEM request timed out: %s", e) + except ConnectError as e: + logger.warning("Audit SIEM request failed: %s", e) + except httpx.RequestError as e: + logger.warning("Audit SIEM request failed: %s", e) + except Exception as e: + logger.warning("Audit send unexpected error: %s", e) diff --git a/api/app/main.py b/api/app/main.py index dd339d6..0ca4253 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -4,7 +4,9 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.openapi.docs import get_redoc_html +from app.audit.core.audit_config import get_siem_url, is_audit_enabled from app.shared.database.database import Base, engine +from app.shared.middleware.audit_middleware import AuditMiddleware # Also import Base from old database to ensure compatibility from app.database import Base as OldBase @@ -89,6 +91,10 @@ ).split(",") CORS_ALLOW_HEADERS = [header.strip() for header in CORS_ALLOW_HEADERS if header.strip()] +# Audit logging - only add when enabled and SIEM URL is configured +if is_audit_enabled() and get_siem_url(): + app.add_middleware(AuditMiddleware) + app.add_middleware( CORSMiddleware, allow_origins=CORS_ORIGINS, diff --git a/api/app/shared/dependencies/auth.py b/api/app/shared/dependencies/auth.py index c77916e..1ec7acd 100644 --- a/api/app/shared/dependencies/auth.py +++ b/api/app/shared/dependencies/auth.py @@ -1,4 +1,4 @@ -from fastapi import Depends, HTTPException, status, Header +from fastapi import Depends, HTTPException, Request, status, Header from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from sqlalchemy.orm import Session from typing import Optional, Union @@ -15,6 +15,7 @@ async def get_current_user_or_token( + request: Request, x_tron_token: Optional[str] = Header(None, alias="x-tron-token"), credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), db: Session = Depends(get_db), @@ -46,6 +47,7 @@ async def get_current_user_or_token( status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expirado" ) + request.state.audit_actor = f"token:{token.name}" return token # Fallback para JWT @@ -82,6 +84,7 @@ async def get_current_user_or_token( status_code=status.HTTP_401_UNAUTHORIZED, detail="User inactive" ) + request.state.audit_actor = user.email return user diff --git a/api/app/shared/middleware/__init__.py b/api/app/shared/middleware/__init__.py new file mode 100644 index 0000000..81d4808 --- /dev/null +++ b/api/app/shared/middleware/__init__.py @@ -0,0 +1 @@ +# Shared middleware diff --git a/api/app/shared/middleware/audit_middleware.py b/api/app/shared/middleware/audit_middleware.py new file mode 100644 index 0000000..e91bde0 --- /dev/null +++ b/api/app/shared/middleware/audit_middleware.py @@ -0,0 +1,70 @@ +"""Audit logging middleware - captures request/response and sends to SIEM.""" + +import asyncio + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request + +from app.audit.core.audit_config import get_siem_url +from app.audit.core.audit_event import build_audit_event +from app.audit.core.audit_sender import send_audit_event + +SKIP_PATHS = {"/health", "/docs", "/redoc", "/openapi.json"} + + +def _get_client_ip(request: Request) -> str: + """Extract client IP, supporting X-Forwarded-For and X-Real-IP proxies.""" + forwarded = request.headers.get("X-Forwarded-For") + if forwarded: + return forwarded.split(",")[0].strip() + real_ip = request.headers.get("X-Real-IP") + if real_ip: + return real_ip.strip() + if request.client: + return request.client.host + return "unknown" + + +class AuditMiddleware(BaseHTTPMiddleware): + """Middleware that logs audit events to configured SIEM endpoint.""" + + async def dispatch(self, request: Request, call_next): + response = await call_next(request) + + path = request.url.path + if path in SKIP_PATHS: + return response + + siem_url = get_siem_url() + if not siem_url: + return response + + actor = getattr(request.state, "audit_actor", None) or "anonymous" + source_ip = _get_client_ip(request) + exec_payload = getattr(request.state, "audit_exec_payload", None) + + event = build_audit_event( + actor=actor, + method=request.method, + path=path, + status_code=response.status_code, + source_ip=source_ip, + exec_payload=exec_payload, + ) + + task = asyncio.create_task(send_audit_event(siem_url, event)) + + def _log_task_error(t): + if t.cancelled(): + return + exc = t.exception() + if exc: + import logging + + logging.getLogger(__name__).warning( + "Audit task failed: %s", exc, exc_info=True + ) + + task.add_done_callback(_log_task_error) + + return response diff --git a/api/app/webapps/api/webapp_handlers.py b/api/app/webapps/api/webapp_handlers.py index 430835b..6961ae6 100644 --- a/api/app/webapps/api/webapp_handlers.py +++ b/api/app/webapps/api/webapp_handlers.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from sqlalchemy.orm import Session from uuid import UUID @@ -494,6 +494,7 @@ def get_webapp_pod_describe( @router.post("/{uuid}/pods/{pod_name}/exec", response_model=PodCommandResponse) def exec_webapp_pod_command( + http_request: Request, uuid: UUID, pod_name: str, request: PodCommandRequest, @@ -502,6 +503,12 @@ def exec_webapp_pod_command( current_user: User = Depends(get_current_user), ): """Execute a command in a pod.""" + http_request.state.audit_exec_payload = { + "request": { + "command": request.command, + "container_name": request.container_name, + } + } repository = WebappRepository(database_session) webapp = repository.find_by_uuid(uuid, load_relations=True) @@ -533,8 +540,14 @@ def exec_webapp_pod_command( result = exec_webapp_pod_command_from_cluster( cluster, namespace, pod_name, request.command, request.container_name ) + http_request.state.audit_exec_payload["response"] = { + "stdout": result.get("stdout", ""), + "stderr": result.get("stderr", ""), + "return_code": result.get("return_code", -1), + } return result except Exception as e: + http_request.state.audit_exec_payload["response"] = {"error": str(e)} raise HTTPException( status_code=500, detail=f"Failed to execute command in pod {pod_name}: {str(e)}", diff --git a/api/app/workers/api/worker_handlers.py b/api/app/workers/api/worker_handlers.py index 8bbdcf0..f0d37ee 100644 --- a/api/app/workers/api/worker_handlers.py +++ b/api/app/workers/api/worker_handlers.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from sqlalchemy.orm import Session from uuid import UUID @@ -454,6 +454,7 @@ def get_worker_pod_describe( @router.post("/{uuid}/pods/{pod_name}/exec", response_model=PodCommandResponse) def exec_worker_pod_command( + http_request: Request, uuid: UUID, pod_name: str, request: PodCommandRequest, @@ -462,6 +463,12 @@ def exec_worker_pod_command( current_user: User = Depends(get_current_user), ): """Execute a command in a pod.""" + http_request.state.audit_exec_payload = { + "request": { + "command": request.command, + "container_name": request.container_name, + } + } repository = WorkerRepository(database_session) worker = repository.find_by_uuid(uuid, load_relations=True) @@ -496,8 +503,14 @@ def exec_worker_pod_command( result = exec_worker_pod_command_from_cluster( cluster, namespace, pod_name, request.command, request.container_name ) + http_request.state.audit_exec_payload["response"] = { + "stdout": result.get("stdout", ""), + "stderr": result.get("stderr", ""), + "return_code": result.get("return_code", -1), + } return result except Exception as e: + http_request.state.audit_exec_payload["response"] = {"error": str(e)} raise HTTPException( status_code=500, detail=f"Failed to execute command in pod {pod_name}: {str(e)}", diff --git a/api/pyrightconfig.json b/api/pyrightconfig.json new file mode 100644 index 0000000..bfe1b41 --- /dev/null +++ b/api/pyrightconfig.json @@ -0,0 +1,5 @@ +{ + "venvPath": ".", + "venv": ".venv", + "extraPaths": ["."] +} diff --git a/api/tests/unit/test_audit_config.py b/api/tests/unit/test_audit_config.py new file mode 100644 index 0000000..0b0b658 --- /dev/null +++ b/api/tests/unit/test_audit_config.py @@ -0,0 +1,67 @@ +"""Unit tests for audit configuration.""" + +import os +from unittest.mock import patch + +from app.audit.core.audit_config import ( + is_audit_enabled, + get_siem_url, + get_siem_token, + get_siem_timeout, +) + + +class TestIsAuditEnabled: + """Tests for is_audit_enabled.""" + + def test_default_is_false(self): + with patch.dict(os.environ, {}, clear=True): + assert is_audit_enabled() is False + + def test_true_when_enabled(self): + with patch.dict(os.environ, {"AUDIT_LOG_ENABLED": "true"}): + assert is_audit_enabled() is True + + def test_case_insensitive_true(self): + with patch.dict(os.environ, {"AUDIT_LOG_ENABLED": "TRUE"}): + assert is_audit_enabled() is True + + +class TestGetSiemUrl: + """Tests for get_siem_url.""" + + def test_returns_none_when_not_set(self): + with patch.dict(os.environ, {}, clear=True): + assert get_siem_url() is None + + def test_returns_url_when_set(self): + with patch.dict(os.environ, {"AUDIT_SIEM_URL": "https://logs.example.com/ingest"}): + assert get_siem_url() == "https://logs.example.com/ingest" + + +class TestGetSiemToken: + """Tests for get_siem_token.""" + + def test_returns_none_when_not_set(self): + with patch.dict(os.environ, {}, clear=True): + assert get_siem_token() is None + + def test_returns_token_when_set(self): + with patch.dict(os.environ, {"AUDIT_SIEM_TOKEN": "secret-token"}): + assert get_siem_token() == "secret-token" + + +class TestGetSiemTimeout: + """Tests for get_siem_timeout.""" + + def test_default_is_five(self): + with patch.dict(os.environ, {}, clear=True): + assert get_siem_timeout() == 5.0 + + def test_parses_custom_timeout(self): + with patch.dict(os.environ, {"AUDIT_SIEM_TIMEOUT": "10"}): + assert get_siem_timeout() == 10.0 + + def test_invalid_falls_back_to_five(self): + with patch.dict(os.environ, {"AUDIT_SIEM_TIMEOUT": "invalid"}): + assert get_siem_timeout() == 5.0 diff --git a/api/tests/unit/test_audit_event.py b/api/tests/unit/test_audit_event.py new file mode 100644 index 0000000..b9e5471 --- /dev/null +++ b/api/tests/unit/test_audit_event.py @@ -0,0 +1,128 @@ +"""Unit tests for audit event schema and mapping.""" + + +from app.audit.core.audit_event import ( + map_method_to_action, + normalize_resource, + build_audit_event, +) + + +class TestMapMethodToAction: + """Tests for HTTP method to audit action mapping.""" + + def test_get_maps_to_read(self): + assert map_method_to_action("GET", "/instances") == "READ" + + def test_head_maps_to_read(self): + assert map_method_to_action("HEAD", "/health") == "READ" + + def test_post_maps_to_create(self): + assert map_method_to_action("POST", "/instances") == "CREATE" + + def test_put_maps_to_update(self): + assert map_method_to_action("PUT", "/instances/123") == "UPDATE" + + def test_patch_maps_to_update(self): + assert map_method_to_action("PATCH", "/instances/123") == "UPDATE" + + def test_delete_maps_to_delete(self): + assert map_method_to_action("DELETE", "/instances/123") == "DELETE" + + def test_exec_path_maps_to_exec(self): + assert map_method_to_action("POST", "/workers/abc/pods/x/exec") == "EXEC" + + def test_exec_path_case_insensitive(self): + assert map_method_to_action("POST", "/webapps/abc/pods/x/EXEC") == "EXEC" + + def test_unknown_method_maps_to_unknown(self): + assert map_method_to_action("TRACE", "/instances") == "UNKNOWN" + + +class TestNormalizeResource: + """Tests for path to resource normalization.""" + + def test_strips_leading_slash(self): + assert normalize_resource("/instances") == "instances" + + def test_strips_trailing_slash(self): + assert normalize_resource("instances/") == "instances" + + def test_preserves_path_structure(self): + assert normalize_resource("/organizations/123/instances/456") == "organizations/123/instances/456" + + def test_empty_path_returns_unknown(self): + assert normalize_resource("") == "unknown" + + def test_slash_only_returns_unknown(self): + assert normalize_resource("/") == "unknown" + + def test_collapses_double_slashes(self): + assert normalize_resource("/orgs//instances") == "orgs/instances" + + +class TestBuildAuditEvent: + """Tests for build_audit_event.""" + + def test_builds_valid_event(self): + event = build_audit_event( + actor="user@example.com", + method="GET", + path="/instances", + status_code=200, + source_ip="192.168.1.1", + ) + assert isinstance(event, dict) + assert event["actor"] == "user@example.com" + assert event["action"] == "READ" + assert event["resource"] == "instances" + assert event["status"] == "success:200" + assert event["source_ip"] == "192.168.1.1" + assert "T" in event["timestamp"] # ISO format + + def test_failure_status(self): + event = build_audit_event( + actor="anonymous", + method="POST", + path="/auth/login", + status_code=401, + source_ip="10.0.0.1", + ) + assert event["status"] == "failure:401" + + def test_exec_payload_included(self): + event = build_audit_event( + actor="admin@example.com", + method="POST", + path="/workers/abc/pods/x/exec", + status_code=200, + source_ip="10.0.0.1", + exec_payload={ + "request": {"command": ["ls", "-la"], "container_name": "app"}, + "response": { + "stdout": "total 4\ndrwxr-xr-x", + "stderr": "", + "return_code": 0, + }, + }, + ) + assert event["action"] == "EXEC" + assert event["exec_request"] == {"command": ["ls", "-la"], "container_name": "app"} + assert event["exec_response"]["stdout"] == "total 4\ndrwxr-xr-x" + assert event["exec_response"]["return_code"] == 0 + + def test_exec_response_truncated_for_large_output(self): + long_stdout = "x" * 5000 + event = build_audit_event( + actor="user@example.com", + method="POST", + path="/webapps/abc/pods/x/exec", + status_code=200, + source_ip="10.0.0.1", + exec_payload={ + "request": {"command": ["cat", "huge.log"], "container_name": None}, + "response": {"stdout": long_stdout, "stderr": "", "return_code": 0}, + }, + ) + assert len(event["exec_response"]["stdout"]) < 5000 + assert "truncated" in event["exec_response"]["stdout"] diff --git a/api/tests/unit/test_audit_sender.py b/api/tests/unit/test_audit_sender.py new file mode 100644 index 0000000..b8b203e --- /dev/null +++ b/api/tests/unit/test_audit_sender.py @@ -0,0 +1,61 @@ +"""Unit tests for audit sender - fail-open behavior.""" + +import asyncio +from unittest.mock import AsyncMock, patch + +from app.audit.core.audit_sender import send_audit_event + + +def _run(coro): + """Run async coroutine in sync test.""" + return asyncio.run(coro) + + +def test_send_audit_event_success(): + """Should complete without raising when SIEM accepts the event.""" + with patch("app.audit.core.audit_sender.httpx") as mock_httpx: + mock_client = AsyncMock() + mock_response = AsyncMock() + mock_response.status_code = 200 + mock_client.post = AsyncMock(return_value=mock_response) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_httpx.AsyncClient.return_value = mock_client + + _run(send_audit_event("https://siem.example.com/ingest", {"actor": "test"})) + + mock_client.post.assert_called_once() + + +def test_send_audit_event_timeout_fail_open(): + """Should not raise when SIEM times out (fail-open).""" + from httpx import TimeoutException + + with patch("app.audit.core.audit_sender.httpx") as mock_httpx: + mock_client = AsyncMock() + mock_client.post = AsyncMock(side_effect=TimeoutException("timeout")) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_httpx.AsyncClient.return_value = mock_client + + _run(send_audit_event("https://siem.example.com/ingest", {"actor": "test"})) + + # No exception propagated + mock_client.post.assert_called_once() + + +def test_send_audit_event_connection_error_fail_open(): + """Should not raise when SIEM is unreachable (fail-open).""" + from httpx import ConnectError + + with patch("app.audit.core.audit_sender.httpx") as mock_httpx: + mock_client = AsyncMock() + mock_client.post = AsyncMock(side_effect=ConnectError("unreachable")) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_httpx.AsyncClient.return_value = mock_client + + _run(send_audit_event("https://siem.example.com/ingest", {"actor": "test"})) + + # No exception propagated + mock_client.post.assert_called_once() diff --git a/docker/.env.example b/docker/.env.example index c642381..75eeb30 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -53,6 +53,24 @@ SKIP_SETUP=false # IMPORTANT: Keep this key safe! Losing it means losing access to all encrypted secrets TRON_SECRETS_KEY= +# ============================================================================= +# Audit Logging (SIEM Export) +# ============================================================================= + +# Enable audit logging (default: false) +# When enabled, structured audit events are sent to the configured SIEM endpoint +AUDIT_LOG_ENABLED=false + +# SIEM endpoint URL for audit event ingestion +# Example: https://logs.example.com/ingest +AUDIT_SIEM_URL= + +# Optional Bearer token for SIEM authentication +AUDIT_SIEM_TOKEN= + +# HTTP timeout in seconds for SIEM POST requests (default: 5) +AUDIT_SIEM_TIMEOUT=5 + # ============================================================================= # Namespace Protection # ============================================================================= diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 3e34d37..f92ea73 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -25,6 +25,11 @@ x-env-api: &shared_env_api TRON_DEFAULT_ORG_PASSWORD: "admin" # Encryption key for secrets (development only - generate new key for production) TRON_SECRETS_KEY: "NfMA_cWx0cuHAGiwl-qWYfl7u1S6bXvADFt5T9WMfUM=" + # Audit logging - pass from .env (AUDIT_LOG_ENABLED=true, AUDIT_SIEM_URL=http://...) + AUDIT_LOG_ENABLED: "${AUDIT_LOG_ENABLED:-false}" + AUDIT_SIEM_URL: "${AUDIT_SIEM_URL:-}" + AUDIT_SIEM_TOKEN: "${AUDIT_SIEM_TOKEN:-}" + AUDIT_SIEM_TIMEOUT: "${AUDIT_SIEM_TIMEOUT:-5}" services: database: From 79a48ffd78ceab4d8a8acda8dd510552ff52d131 Mon Sep 17 00:00:00 2001 From: romariohornburg Date: Mon, 23 Mar 2026 11:53:01 -0300 Subject: [PATCH 2/2] chore(workflows): update permissions in labeler.yml to allow writing issues --- .github/workflows/labeler.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 7c99da8..291220c 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -7,6 +7,7 @@ on: permissions: contents: read pull-requests: write + issues: write jobs: label: