|
| 1 | +""" |
| 2 | +Security Service |
| 3 | +
|
| 4 | +Provides security utilities including: |
| 5 | +- Prompt injection detection |
| 6 | +- Rate limiting |
| 7 | +- Audit logging |
| 8 | +- Content validation |
| 9 | +""" |
| 10 | + |
| 11 | +import re |
| 12 | +import time |
| 13 | +from collections import defaultdict |
| 14 | +from datetime import datetime, timedelta |
| 15 | +from typing import Dict, Optional, Tuple, List |
| 16 | +from pathlib import Path |
| 17 | +from fastmcp.utilities.logging import get_logger |
| 18 | + |
| 19 | +logger = get_logger("SecurityService") |
| 20 | + |
| 21 | + |
| 22 | +class SecurityService: |
| 23 | + """ |
| 24 | + Security service for detecting threats and enforcing rate limits. |
| 25 | + """ |
| 26 | + |
| 27 | + def __init__(self, data_dir: Path): |
| 28 | + """ |
| 29 | + Initialize security service. |
| 30 | + |
| 31 | + Args: |
| 32 | + data_dir: Directory for storing security logs |
| 33 | + """ |
| 34 | + self.logger = get_logger("SecurityService") |
| 35 | + self.data_dir = data_dir |
| 36 | + self.audit_log_dir = data_dir / "audit_logs" |
| 37 | + self.audit_log_dir.mkdir(parents=True, exist_ok=True) |
| 38 | + |
| 39 | + # Rate limiting: track requests per user_id |
| 40 | + self.rate_limit_store: Dict[str, List[float]] = defaultdict(list) |
| 41 | + |
| 42 | + # Rate limit configuration |
| 43 | + self.rate_limits = { |
| 44 | + 'read': {'max_requests': 100, 'window_seconds': 60}, # 100 reads per minute |
| 45 | + 'write': {'max_requests': 20, 'window_seconds': 60}, # 20 writes per minute |
| 46 | + 'general': {'max_requests': 200, 'window_seconds': 60} # 200 general requests per minute |
| 47 | + } |
| 48 | + |
| 49 | + # Prompt injection patterns (common attack patterns) |
| 50 | + self.injection_patterns = [ |
| 51 | + r'(?i)ignore\s+(all\s+)?previous\s+instructions?', |
| 52 | + r'(?i)forget\s+(all\s+)?previous\s+instructions?', |
| 53 | + r'(?i)disregard\s+(all\s+)?previous\s+instructions?', |
| 54 | + r'(?i)system\s*:\s*', |
| 55 | + r'(?i)assistant\s*:\s*', |
| 56 | + r'(?i)you\s+are\s+now\s+', |
| 57 | + r'(?i)new\s+instructions?\s*:', |
| 58 | + r'(?i)override\s+instructions?', |
| 59 | + r'(?i)new\s+system\s+prompt', |
| 60 | + r'(?i)act\s+as\s+if\s+you\s+are', |
| 61 | + r'(?i)pretend\s+to\s+be', |
| 62 | + r'(?i)roleplay\s+as', |
| 63 | + r'(?i)execute\s+this\s+command', |
| 64 | + r'(?i)run\s+this\s+code', |
| 65 | + r'(?i)send\s+(this\s+)?(data|information|content)\s+to', |
| 66 | + r'(?i)exfiltrate', |
| 67 | + r'(?i)leak\s+(this\s+)?(data|information)', |
| 68 | + r'(?i)reveal\s+(this\s+)?(data|information)', |
| 69 | + ] |
| 70 | + |
| 71 | + # Suspicious write patterns |
| 72 | + self.suspicious_write_patterns = [ |
| 73 | + r'(?i)delete\s+all', |
| 74 | + r'(?i)clear\s+everything', |
| 75 | + r'(?i)remove\s+all\s+content', |
| 76 | + r'(?i)overwrite\s+with\s+nothing', |
| 77 | + r'(?i)^\s*$', # Empty or whitespace-only content |
| 78 | + r'(?i)password|api[_\s]?key|secret|token|credential', |
| 79 | + ] |
| 80 | + |
| 81 | + def detect_prompt_injection(self, content: str) -> Tuple[bool, List[str]]: |
| 82 | + """ |
| 83 | + Detect potential prompt injection attacks in content. |
| 84 | + |
| 85 | + Args: |
| 86 | + content: Content to analyze |
| 87 | + |
| 88 | + Returns: |
| 89 | + Tuple of (is_suspicious, matched_patterns) |
| 90 | + """ |
| 91 | + if not content: |
| 92 | + return False, [] |
| 93 | + |
| 94 | + matched_patterns = [] |
| 95 | + content_lower = content.lower() |
| 96 | + |
| 97 | + for pattern in self.injection_patterns: |
| 98 | + if re.search(pattern, content): |
| 99 | + matched_patterns.append(pattern) |
| 100 | + |
| 101 | + is_suspicious = len(matched_patterns) > 0 |
| 102 | + |
| 103 | + if is_suspicious: |
| 104 | + self.logger.warning( |
| 105 | + f"Potential prompt injection detected. Matched {len(matched_patterns)} patterns." |
| 106 | + ) |
| 107 | + |
| 108 | + return is_suspicious, matched_patterns |
| 109 | + |
| 110 | + def validate_write_content(self, content: str, doc_id: str, user_id: str) -> Tuple[bool, Optional[str]]: |
| 111 | + """ |
| 112 | + Validate write content for suspicious patterns. |
| 113 | + |
| 114 | + Args: |
| 115 | + content: Content to be written |
| 116 | + doc_id: Document ID |
| 117 | + user_id: User ID |
| 118 | + |
| 119 | + Returns: |
| 120 | + Tuple of (is_valid, error_message) |
| 121 | + """ |
| 122 | + if not content: |
| 123 | + return False, "Content cannot be empty" |
| 124 | + |
| 125 | + # Check for suspicious patterns |
| 126 | + for pattern in self.suspicious_write_patterns: |
| 127 | + if re.search(pattern, content): |
| 128 | + self.log_audit_event( |
| 129 | + 'write_validation_failed', |
| 130 | + user_id, |
| 131 | + { |
| 132 | + 'reason': 'suspicious_pattern', |
| 133 | + 'pattern': pattern, |
| 134 | + 'doc_id': doc_id, |
| 135 | + 'content_preview': content[:100] |
| 136 | + } |
| 137 | + ) |
| 138 | + return False, f"Suspicious content pattern detected. Please review your content." |
| 139 | + |
| 140 | + # Check for prompt injection |
| 141 | + is_injection, patterns = self.detect_prompt_injection(content) |
| 142 | + if is_injection: |
| 143 | + self.log_audit_event( |
| 144 | + 'write_validation_failed', |
| 145 | + user_id, |
| 146 | + { |
| 147 | + 'reason': 'prompt_injection', |
| 148 | + 'patterns': patterns, |
| 149 | + 'doc_id': doc_id, |
| 150 | + 'content_preview': content[:100] |
| 151 | + } |
| 152 | + ) |
| 153 | + return False, "Potential prompt injection detected in content. Please review." |
| 154 | + |
| 155 | + # Check content size (already done in main code, but double-check) |
| 156 | + if len(content) > 50000: |
| 157 | + return False, "Content too large" |
| 158 | + |
| 159 | + return True, None |
| 160 | + |
| 161 | + def check_rate_limit(self, user_id: str, operation_type: str = 'general') -> Tuple[bool, Optional[str]]: |
| 162 | + """ |
| 163 | + Check if user has exceeded rate limits. |
| 164 | + |
| 165 | + Args: |
| 166 | + user_id: User identifier |
| 167 | + operation_type: Type of operation ('read', 'write', 'general') |
| 168 | + |
| 169 | + Returns: |
| 170 | + Tuple of (is_allowed, error_message) |
| 171 | + """ |
| 172 | + if operation_type not in self.rate_limits: |
| 173 | + operation_type = 'general' |
| 174 | + |
| 175 | + limit_config = self.rate_limits[operation_type] |
| 176 | + max_requests = limit_config['max_requests'] |
| 177 | + window_seconds = limit_config['window_seconds'] |
| 178 | + |
| 179 | + now = time.time() |
| 180 | + key = f"{user_id}:{operation_type}" |
| 181 | + |
| 182 | + # Clean old entries |
| 183 | + self.rate_limit_store[key] = [ |
| 184 | + timestamp for timestamp in self.rate_limit_store[key] |
| 185 | + if now - timestamp < window_seconds |
| 186 | + ] |
| 187 | + |
| 188 | + # Check limit |
| 189 | + if len(self.rate_limit_store[key]) >= max_requests: |
| 190 | + self.log_audit_event( |
| 191 | + 'rate_limit_exceeded', |
| 192 | + user_id, |
| 193 | + { |
| 194 | + 'operation_type': operation_type, |
| 195 | + 'requests': len(self.rate_limit_store[key]), |
| 196 | + 'limit': max_requests, |
| 197 | + 'window_seconds': window_seconds |
| 198 | + } |
| 199 | + ) |
| 200 | + return False, f"Rate limit exceeded. Maximum {max_requests} {operation_type} operations per {window_seconds} seconds." |
| 201 | + |
| 202 | + # Record this request |
| 203 | + self.rate_limit_store[key].append(now) |
| 204 | + |
| 205 | + return True, None |
| 206 | + |
| 207 | + def log_audit_event( |
| 208 | + self, |
| 209 | + event_type: str, |
| 210 | + user_id: str, |
| 211 | + details: Dict, |
| 212 | + severity: str = 'info' |
| 213 | + ): |
| 214 | + """ |
| 215 | + Log security audit event. |
| 216 | + |
| 217 | + Args: |
| 218 | + event_type: Type of event (e.g., 'rate_limit_exceeded', 'prompt_injection_detected') |
| 219 | + user_id: User identifier |
| 220 | + details: Additional event details |
| 221 | + severity: Event severity ('info', 'warning', 'error', 'critical') |
| 222 | + """ |
| 223 | + timestamp = datetime.utcnow().isoformat() |
| 224 | + |
| 225 | + log_entry = { |
| 226 | + 'timestamp': timestamp, |
| 227 | + 'event_type': event_type, |
| 228 | + 'user_id': user_id, |
| 229 | + 'severity': severity, |
| 230 | + 'details': details |
| 231 | + } |
| 232 | + |
| 233 | + # Log to console |
| 234 | + log_message = f"[AUDIT] {event_type} | user={user_id} | {details}" |
| 235 | + if severity == 'critical' or severity == 'error': |
| 236 | + self.logger.error(log_message) |
| 237 | + elif severity == 'warning': |
| 238 | + self.logger.warning(log_message) |
| 239 | + else: |
| 240 | + self.logger.info(log_message) |
| 241 | + |
| 242 | + # Write to audit log file (daily rotation) |
| 243 | + today = datetime.utcnow().date() |
| 244 | + log_file = self.audit_log_dir / f"audit_{today.isoformat()}.jsonl" |
| 245 | + |
| 246 | + try: |
| 247 | + import json |
| 248 | + with open(log_file, 'a') as f: |
| 249 | + f.write(json.dumps(log_entry) + '\n') |
| 250 | + except Exception as e: |
| 251 | + self.logger.error(f"Failed to write audit log: {e}") |
| 252 | + |
| 253 | + def sanitize_content_for_logging(self, content: str, max_length: int = 100) -> str: |
| 254 | + """ |
| 255 | + Sanitize content for safe logging (remove sensitive data). |
| 256 | + |
| 257 | + Args: |
| 258 | + content: Content to sanitize |
| 259 | + max_length: Maximum length to log |
| 260 | + |
| 261 | + Returns: |
| 262 | + Sanitized content preview |
| 263 | + """ |
| 264 | + if not content: |
| 265 | + return "" |
| 266 | + |
| 267 | + # Remove potential sensitive patterns |
| 268 | + sanitized = re.sub(r'(?i)(password|api[_\s]?key|secret|token|credential)\s*[:=]\s*\S+', |
| 269 | + r'\1=***REDACTED***', content) |
| 270 | + |
| 271 | + # Truncate |
| 272 | + if len(sanitized) > max_length: |
| 273 | + sanitized = sanitized[:max_length] + "..." |
| 274 | + |
| 275 | + return sanitized |
| 276 | + |
| 277 | + def get_security_warning_for_content(self, content: str) -> Optional[str]: |
| 278 | + """ |
| 279 | + Get a security warning message if content contains suspicious patterns. |
| 280 | + |
| 281 | + Args: |
| 282 | + content: Content to check |
| 283 | + |
| 284 | + Returns: |
| 285 | + Warning message or None |
| 286 | + """ |
| 287 | + is_injection, patterns = self.detect_prompt_injection(content) |
| 288 | + |
| 289 | + if is_injection: |
| 290 | + return ( |
| 291 | + "⚠️ SECURITY WARNING: This content contains patterns that may be " |
| 292 | + "attempting to inject instructions. Please review carefully before proceeding." |
| 293 | + ) |
| 294 | + |
| 295 | + return None |
| 296 | + |
0 commit comments