Community edition generated from private repo via sync pipeline. Includes 9 plugins (WordPress, WooCommerce, WP Advanced, Gitea, n8n, Supabase, OpenPanel, Appwrite, Directus) with ~587 tools. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
566 lines
18 KiB
Python
566 lines
18 KiB
Python
"""
|
|
Audit Logging System
|
|
|
|
Comprehensive audit logging for all MCP operations.
|
|
Tracks tool calls, authentication attempts, and system events.
|
|
|
|
Features:
|
|
- Structured JSON logging
|
|
- Log rotation support
|
|
- Query and filter capabilities
|
|
- Export to JSON/CSV
|
|
- GDPR-compliant (no sensitive data in logs)
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
from datetime import UTC, datetime
|
|
from enum import Enum
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
class LogLevel(Enum):
|
|
"""Log severity levels."""
|
|
|
|
INFO = "INFO"
|
|
WARNING = "WARNING"
|
|
ERROR = "ERROR"
|
|
CRITICAL = "CRITICAL"
|
|
|
|
class EventType(Enum):
|
|
"""Types of events to log."""
|
|
|
|
TOOL_CALL = "tool_call"
|
|
AUTHENTICATION = "authentication"
|
|
HEALTH_CHECK = "health_check"
|
|
ERROR = "error"
|
|
SYSTEM = "system"
|
|
|
|
class AuditLogger:
|
|
"""
|
|
Audit logging system for MCP operations.
|
|
|
|
Logs all important events to a structured JSON log file with
|
|
rotation support and query capabilities.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
log_dir: str = "logs",
|
|
log_file: str = "audit.log",
|
|
max_file_size_mb: int = 10,
|
|
backup_count: int = 5,
|
|
):
|
|
"""
|
|
Initialize audit logger.
|
|
|
|
Args:
|
|
log_dir: Directory for log files
|
|
log_file: Log file name
|
|
max_file_size_mb: Max size before rotation (MB)
|
|
backup_count: Number of backup files to keep
|
|
"""
|
|
# Setup Python logger for internal logging
|
|
self.logger = logging.getLogger("AuditLogger")
|
|
|
|
# Try to create log directory, fallback to /tmp if permission denied
|
|
self.log_dir = Path(log_dir)
|
|
|
|
try:
|
|
self.log_dir.mkdir(parents=True, exist_ok=True)
|
|
except PermissionError:
|
|
# Fallback to /tmp/logs for Docker containers
|
|
self.logger.warning(f"Permission denied for {log_dir}, using /tmp/logs instead")
|
|
self.log_dir = Path("/tmp/logs")
|
|
self.log_dir.mkdir(parents=True, exist_ok=True)
|
|
except Exception as e:
|
|
self.logger.error(f"Failed to create log directory: {e}, using /tmp/logs")
|
|
self.log_dir = Path("/tmp/logs")
|
|
try:
|
|
self.log_dir.mkdir(parents=True, exist_ok=True)
|
|
except Exception as e2:
|
|
self.logger.critical(f"Cannot create any log directory: {e2}, logging disabled")
|
|
# Set to None to disable file logging
|
|
self.log_dir = None
|
|
|
|
if self.log_dir:
|
|
self.log_file = self.log_dir / log_file
|
|
self.max_file_size = max_file_size_mb * 1024 * 1024 # Convert to bytes
|
|
self.backup_count = backup_count
|
|
self.logger.info(f"Audit logger initialized: {self.log_file}")
|
|
else:
|
|
self.log_file = None
|
|
self.max_file_size = 0
|
|
self.backup_count = 0
|
|
self.logger.warning("Audit logging to file is disabled due to permission errors")
|
|
|
|
def _rotate_logs_if_needed(self) -> None:
|
|
"""Rotate log files if size exceeds limit."""
|
|
if not self.log_file or not self.log_file.exists():
|
|
return
|
|
|
|
if self.log_file.stat().st_size >= self.max_file_size:
|
|
# Rotate existing backup files
|
|
for i in range(self.backup_count - 1, 0, -1):
|
|
old_backup = self.log_dir / f"{self.log_file.name}.{i}"
|
|
new_backup = self.log_dir / f"{self.log_file.name}.{i + 1}"
|
|
|
|
if old_backup.exists():
|
|
if new_backup.exists():
|
|
new_backup.unlink() # Delete oldest if at limit
|
|
old_backup.rename(new_backup)
|
|
|
|
# Move current log to .1
|
|
backup = self.log_dir / f"{self.log_file.name}.1"
|
|
if backup.exists():
|
|
backup.unlink()
|
|
self.log_file.rename(backup)
|
|
|
|
self.logger.info(f"Log rotated: {self.log_file}")
|
|
|
|
def _write_log_entry(self, entry: dict[str, Any]) -> None:
|
|
"""
|
|
Write a log entry to file.
|
|
|
|
Args:
|
|
entry: Log entry dictionary
|
|
"""
|
|
# Skip if logging is disabled
|
|
if not self.log_file:
|
|
return
|
|
|
|
self._rotate_logs_if_needed()
|
|
|
|
try:
|
|
with open(self.log_file, "a", encoding="utf-8") as f:
|
|
# Write as JSON line
|
|
json.dump(entry, f, ensure_ascii=False)
|
|
f.write("\n")
|
|
except Exception as e:
|
|
self.logger.error(f"Failed to write audit log: {e}", exc_info=True)
|
|
|
|
def log_tool_call(
|
|
self,
|
|
tool_name: str,
|
|
site: str | None = None,
|
|
project_id: str | None = None,
|
|
params: dict[str, Any] | None = None,
|
|
result_summary: str | None = None,
|
|
error: str | None = None,
|
|
duration_ms: int | None = None,
|
|
user_id: str | None = None,
|
|
) -> None:
|
|
"""
|
|
Log a tool call.
|
|
|
|
Args:
|
|
tool_name: Name of the tool called
|
|
site: Site ID or alias (for unified tools)
|
|
project_id: Full project ID (for per-site tools)
|
|
params: Tool parameters (sensitive data should be filtered)
|
|
result_summary: Brief summary of result (not full response)
|
|
error: Error message if failed
|
|
duration_ms: Execution duration in milliseconds
|
|
user_id: User identifier (if available)
|
|
"""
|
|
# Filter sensitive data from params
|
|
safe_params = self._filter_sensitive_data(params) if params else None
|
|
|
|
entry = {
|
|
"timestamp": datetime.now(UTC).isoformat(),
|
|
"event_type": EventType.TOOL_CALL.value,
|
|
"level": LogLevel.ERROR.value if error else LogLevel.INFO.value,
|
|
"tool_name": tool_name,
|
|
"site": site,
|
|
"project_id": project_id,
|
|
"params": safe_params,
|
|
"result_summary": result_summary,
|
|
"error": error,
|
|
"duration_ms": duration_ms,
|
|
"user_id": user_id,
|
|
"success": error is None,
|
|
}
|
|
|
|
self._write_log_entry(entry)
|
|
|
|
def log_authentication(
|
|
self,
|
|
success: bool,
|
|
project_id: str | None = None,
|
|
reason: str | None = None,
|
|
ip_address: str | None = None,
|
|
) -> None:
|
|
"""
|
|
Log an authentication attempt.
|
|
|
|
Args:
|
|
success: Whether authentication succeeded
|
|
project_id: Project being accessed (if known)
|
|
reason: Failure reason if unsuccessful
|
|
ip_address: Client IP address
|
|
"""
|
|
entry = {
|
|
"timestamp": datetime.now(UTC).isoformat(),
|
|
"event_type": EventType.AUTHENTICATION.value,
|
|
"level": LogLevel.WARNING.value if not success else LogLevel.INFO.value,
|
|
"success": success,
|
|
"project_id": project_id,
|
|
"reason": reason,
|
|
"ip_address": ip_address,
|
|
}
|
|
|
|
self._write_log_entry(entry)
|
|
|
|
def log_error(
|
|
self,
|
|
error_type: str,
|
|
error_message: str,
|
|
context: dict[str, Any] | None = None,
|
|
stack_trace: str | None = None,
|
|
) -> None:
|
|
"""
|
|
Log an error event.
|
|
|
|
Args:
|
|
error_type: Type of error (e.g., 'ValidationError', 'APIError')
|
|
error_message: Error message
|
|
context: Additional context
|
|
stack_trace: Stack trace if available
|
|
"""
|
|
entry = {
|
|
"timestamp": datetime.now(UTC).isoformat(),
|
|
"event_type": EventType.ERROR.value,
|
|
"level": LogLevel.ERROR.value,
|
|
"error_type": error_type,
|
|
"error_message": error_message,
|
|
"context": context,
|
|
"stack_trace": stack_trace,
|
|
}
|
|
|
|
self._write_log_entry(entry)
|
|
|
|
def log_system_event(
|
|
self, event: str, details: dict[str, Any] | None = None, level: LogLevel = LogLevel.INFO
|
|
) -> None:
|
|
"""
|
|
Log a system event.
|
|
|
|
Args:
|
|
event: Event description
|
|
details: Event details
|
|
level: Log level
|
|
"""
|
|
entry = {
|
|
"timestamp": datetime.now(UTC).isoformat(),
|
|
"event_type": EventType.SYSTEM.value,
|
|
"level": level.value,
|
|
"event": event,
|
|
"details": details,
|
|
}
|
|
|
|
self._write_log_entry(entry)
|
|
|
|
def _filter_sensitive_data(self, data: dict[str, Any]) -> dict[str, Any]:
|
|
"""
|
|
Filter sensitive data from logs (GDPR compliance).
|
|
|
|
Args:
|
|
data: Data to filter
|
|
|
|
Returns:
|
|
Filtered data with sensitive fields masked
|
|
"""
|
|
if not data:
|
|
return {}
|
|
|
|
sensitive_keys = {
|
|
"password",
|
|
"app_password",
|
|
"token",
|
|
"api_key",
|
|
"secret",
|
|
"credential",
|
|
"auth",
|
|
"private_key",
|
|
"access_token",
|
|
"refresh_token",
|
|
}
|
|
|
|
filtered = {}
|
|
for key, value in data.items():
|
|
# Check if key contains sensitive words
|
|
if any(sensitive in key.lower() for sensitive in sensitive_keys):
|
|
filtered[key] = "[REDACTED]"
|
|
elif isinstance(value, dict):
|
|
filtered[key] = self._filter_sensitive_data(value)
|
|
else:
|
|
filtered[key] = value
|
|
|
|
return filtered
|
|
|
|
def get_logs(
|
|
self,
|
|
event_type: EventType | None = None,
|
|
start_time: datetime | None = None,
|
|
end_time: datetime | None = None,
|
|
level: LogLevel | None = None,
|
|
project_id: str | None = None,
|
|
tool_name: str | None = None,
|
|
success_only: bool | None = None,
|
|
limit: int = 100,
|
|
) -> list[dict[str, Any]]:
|
|
"""
|
|
Query audit logs with filters.
|
|
|
|
Args:
|
|
event_type: Filter by event type
|
|
start_time: Start of time range
|
|
end_time: End of time range
|
|
level: Filter by log level
|
|
project_id: Filter by project
|
|
tool_name: Filter by tool name
|
|
success_only: Only successful operations
|
|
limit: Maximum number of entries to return
|
|
|
|
Returns:
|
|
List of log entries matching filters
|
|
"""
|
|
if not self.log_file or not self.log_file.exists():
|
|
return []
|
|
|
|
results = []
|
|
|
|
try:
|
|
with open(self.log_file, encoding="utf-8") as f:
|
|
for line in f:
|
|
if not line.strip():
|
|
continue
|
|
|
|
try:
|
|
entry = json.loads(line)
|
|
|
|
# Apply filters
|
|
if event_type and entry.get("event_type") != event_type.value:
|
|
continue
|
|
|
|
if level and entry.get("level") != level.value:
|
|
continue
|
|
|
|
if project_id and entry.get("project_id") != project_id:
|
|
continue
|
|
|
|
if tool_name and entry.get("tool_name") != tool_name:
|
|
continue
|
|
|
|
if success_only is not None:
|
|
if entry.get("success") != success_only:
|
|
continue
|
|
|
|
# Time range filter
|
|
if start_time or end_time:
|
|
entry_time = datetime.fromisoformat(entry.get("timestamp", ""))
|
|
|
|
if start_time and entry_time < start_time:
|
|
continue
|
|
|
|
if end_time and entry_time > end_time:
|
|
continue
|
|
|
|
results.append(entry)
|
|
|
|
if len(results) >= limit:
|
|
break
|
|
|
|
except json.JSONDecodeError:
|
|
self.logger.warning(f"Invalid JSON in log: {line[:50]}...")
|
|
continue
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error reading logs: {e}", exc_info=True)
|
|
|
|
return results
|
|
|
|
def export_logs(self, output_path: str, format: str = "json", **filter_kwargs) -> bool:
|
|
"""
|
|
Export logs to a file.
|
|
|
|
Args:
|
|
output_path: Output file path
|
|
format: Export format ('json' or 'csv')
|
|
**filter_kwargs: Filters to apply (same as get_logs)
|
|
|
|
Returns:
|
|
True if successful
|
|
"""
|
|
logs = self.get_logs(**filter_kwargs)
|
|
|
|
try:
|
|
if format == "json":
|
|
with open(output_path, "w", encoding="utf-8") as f:
|
|
json.dump(logs, f, indent=2, ensure_ascii=False)
|
|
|
|
elif format == "csv":
|
|
import csv
|
|
|
|
if not logs:
|
|
return False
|
|
|
|
# Get all unique keys from logs
|
|
keys = set()
|
|
for log in logs:
|
|
keys.update(log.keys())
|
|
|
|
with open(output_path, "w", newline="", encoding="utf-8") as f:
|
|
writer = csv.DictWriter(f, fieldnames=sorted(keys))
|
|
writer.writeheader()
|
|
writer.writerows(logs)
|
|
|
|
else:
|
|
raise ValueError(f"Unsupported format: {format}")
|
|
|
|
self.logger.info(f"Exported {len(logs)} logs to {output_path}")
|
|
return True
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error exporting logs: {e}", exc_info=True)
|
|
return False
|
|
|
|
def get_recent_entries(self, limit: int = 10) -> list[dict[str, Any]]:
|
|
"""
|
|
Get the most recent log entries.
|
|
|
|
Args:
|
|
limit: Maximum number of entries to return
|
|
|
|
Returns:
|
|
List of recent log entries (newest first)
|
|
"""
|
|
if not self.log_file or not self.log_file.exists():
|
|
return []
|
|
|
|
entries = []
|
|
|
|
try:
|
|
with open(self.log_file, encoding="utf-8") as f:
|
|
# Read all lines and get the last N
|
|
lines = f.readlines()
|
|
|
|
# Process lines in reverse order
|
|
for line in reversed(lines):
|
|
if not line.strip():
|
|
continue
|
|
|
|
try:
|
|
entry = json.loads(line)
|
|
|
|
# Format the entry for display
|
|
formatted_entry = {
|
|
"timestamp": entry.get("timestamp", ""),
|
|
"event_type": entry.get("event_type", "unknown"),
|
|
"level": entry.get("level", "INFO"),
|
|
"message": self._format_log_message(entry),
|
|
"metadata": {
|
|
"project_id": entry.get("project_id"),
|
|
"tool_name": entry.get("tool_name"),
|
|
"site": entry.get("site"),
|
|
"duration_ms": entry.get("duration_ms"),
|
|
"success": entry.get("success"),
|
|
},
|
|
}
|
|
|
|
entries.append(formatted_entry)
|
|
|
|
if len(entries) >= limit:
|
|
break
|
|
|
|
except json.JSONDecodeError:
|
|
continue
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error reading recent logs: {e}", exc_info=True)
|
|
|
|
return entries
|
|
|
|
def _format_log_message(self, entry: dict[str, Any]) -> str:
|
|
"""Format a log entry into a human-readable message."""
|
|
event_type = entry.get("event_type", "")
|
|
|
|
if event_type == EventType.TOOL_CALL.value:
|
|
tool_name = entry.get("tool_name", "unknown")
|
|
if entry.get("error"):
|
|
return f"{tool_name} failed: {entry.get('error', '')[:50]}"
|
|
return f"{tool_name}"
|
|
|
|
elif event_type == EventType.AUTHENTICATION.value:
|
|
if entry.get("success"):
|
|
return "Authentication successful"
|
|
return f"Authentication failed: {entry.get('reason', 'unknown')}"
|
|
|
|
elif event_type == EventType.ERROR.value:
|
|
return f"{entry.get('error_type', 'Error')}: {entry.get('error_message', '')[:50]}"
|
|
|
|
elif event_type == EventType.SYSTEM.value:
|
|
return entry.get("event", "System event")
|
|
|
|
return entry.get("event", entry.get("message", "Unknown event"))
|
|
|
|
def get_statistics(self) -> dict[str, Any]:
|
|
"""
|
|
Get statistics about audit logs.
|
|
|
|
Returns:
|
|
Dictionary with statistics
|
|
"""
|
|
all_logs = self.get_logs(limit=10000) # Get recent logs
|
|
|
|
if not all_logs:
|
|
return {"total_entries": 0, "by_type": {}, "by_level": {}, "success_rate": 0.0}
|
|
|
|
# Count by type
|
|
by_type = {}
|
|
by_level = {}
|
|
successful = 0
|
|
total_with_success_field = 0
|
|
|
|
for entry in all_logs:
|
|
# Count by type
|
|
event_type = entry.get("event_type", "unknown")
|
|
by_type[event_type] = by_type.get(event_type, 0) + 1
|
|
|
|
# Count by level
|
|
level = entry.get("level", "unknown")
|
|
by_level[level] = by_level.get(level, 0) + 1
|
|
|
|
# Success rate
|
|
if "success" in entry:
|
|
total_with_success_field += 1
|
|
if entry["success"]:
|
|
successful += 1
|
|
|
|
success_rate = (
|
|
(successful / total_with_success_field * 100) if total_with_success_field > 0 else 0.0
|
|
)
|
|
|
|
# Calculate log file size
|
|
log_file_size_mb = 0
|
|
if self.log_file and self.log_file.exists():
|
|
log_file_size_mb = round(self.log_file.stat().st_size / (1024 * 1024), 2)
|
|
|
|
return {
|
|
"total_entries": len(all_logs),
|
|
"by_type": by_type,
|
|
"by_level": by_level,
|
|
"success_rate": round(success_rate, 2),
|
|
"log_file_size_mb": log_file_size_mb,
|
|
}
|
|
|
|
# Global audit logger instance
|
|
_audit_logger: AuditLogger | None = None
|
|
|
|
def get_audit_logger() -> AuditLogger:
|
|
"""Get the global audit logger instance."""
|
|
global _audit_logger
|
|
if _audit_logger is None:
|
|
_audit_logger = AuditLogger()
|
|
return _audit_logger
|