diff --git a/api/src/Services/Security/AlertService.php b/api/src/Services/Security/AlertService.php new file mode 100644 index 00000000..6399e554 --- /dev/null +++ b/api/src/Services/Security/AlertService.php @@ -0,0 +1,553 @@ + ['email' => false, 'log' => true, 'throttle' => 0], + 'WARNING' => ['email' => true, 'log' => true, 'throttle' => 3600], // 1h + 'ERROR' => ['email' => true, 'log' => true, 'throttle' => 900], // 15min + 'CRITICAL' => ['email' => true, 'log' => true, 'throttle' => 300], // 5min + 'SECURITY' => ['email' => true, 'log' => true, 'throttle' => 0, 'priority' => true] + ]; + + // Types d'alertes avec leur niveau par défaut + const ALERT_TYPES = [ + 'BRUTE_FORCE' => 'SECURITY', + 'UNAUTHORIZED_ACCESS' => 'CRITICAL', + 'SQL_INJECTION' => 'SECURITY', + 'SQL_ERROR' => 'ERROR', + 'DB_CONNECTION' => 'CRITICAL', + 'DB_DEADLOCK' => 'ERROR', + 'PERFORMANCE_SLOW' => 'WARNING', + 'PERFORMANCE_CRITICAL' => 'ERROR', + 'HTTP_500' => 'ERROR', + 'HTTP_404_PATTERN' => 'WARNING', + 'SUSPICIOUS_PATTERN' => 'WARNING', + 'MEMORY_HIGH' => 'WARNING', + 'DISK_SPACE' => 'CRITICAL' + ]; + + private static ?PDO $db = null; + private static ?EmailThrottler $throttler = null; + + /** + * Déclencher une alerte + */ + public static function trigger(string $type, array $data = [], ?string $level = null): void { + try { + // Déterminer le niveau si non fourni + if ($level === null) { + $level = self::ALERT_TYPES[$type] ?? 'WARNING'; + } + + // Enrichir le contexte + $context = self::enrichContext($type, $level, $data); + + // Enregistrer en base de données + $alertId = self::saveAlert($type, $level, $context); + + // Logger + self::logAlert($type, $level, $context); + + // Envoyer email si nécessaire et non throttled + if (self::shouldSendEmail($type, $level)) { + self::sendAlertEmail($type, $level, $context, $alertId); + } + + // Actions automatiques selon le type + self::executeAutomaticActions($type, $context); + + } catch (\Exception $e) { + error_log("AlertService Error: " . $e->getMessage()); + } + } + + /** + * Enrichir le contexte avec des informations système + */ + private static function enrichContext(string $type, string $level, array $data): array { + $context = $data; + + // Ajouter les informations de base + $context['timestamp'] = date('Y-m-d H:i:s'); + $context['environment'] = AppConfig::getInstance()->getEnvironment(); + $context['server'] = $_SERVER['SERVER_NAME'] ?? 'unknown'; + + // Ajouter les informations de requête + if (isset($_SERVER['REQUEST_URI'])) { + $context['request'] = [ + 'uri' => $_SERVER['REQUEST_URI'], + 'method' => $_SERVER['REQUEST_METHOD'] ?? 'unknown', + 'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown', + 'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown' + ]; + } + + // Ajouter les informations de session + if (session_status() === PHP_SESSION_ACTIVE) { + $context['session'] = [ + 'user_id' => $_SESSION['user_id'] ?? null, + 'entity_id' => $_SESSION['entity_id'] ?? null, + 'session_id' => substr(session_id(), 0, 8) . '...' // Tronquer pour sécurité + ]; + } + + // Ajouter les métriques système + $context['system'] = [ + 'memory_usage_mb' => round(memory_get_usage(true) / 1024 / 1024, 2), + 'memory_peak_mb' => round(memory_get_peak_usage(true) / 1024 / 1024, 2), + 'load_average' => sys_getloadavg() + ]; + + return $context; + } + + /** + * Sauvegarder l'alerte en base de données + */ + private static function saveAlert(string $type, string $level, array $context): int { + $db = self::getDb(); + + // Vérifier si une alerte similaire existe déjà (dans les 5 dernières minutes) + $checkStmt = $db->prepare(' + SELECT id, occurrences + FROM sec_alerts + WHERE alert_type = :type + AND alert_level = :level + AND ip_address = :ip + AND endpoint = :endpoint + AND resolved = 0 + AND last_seen >= DATE_SUB(NOW(), INTERVAL 5 MINUTE) + LIMIT 1 + '); + + $ip = $context['request']['ip'] ?? null; + $endpoint = $context['request']['uri'] ?? null; + + $checkStmt->execute([ + 'type' => $type, + 'level' => $level, + 'ip' => $ip, + 'endpoint' => $endpoint + ]); + + $existing = $checkStmt->fetch(PDO::FETCH_ASSOC); + + if ($existing) { + // Mettre à jour l'alerte existante + $updateStmt = $db->prepare(' + UPDATE sec_alerts + SET occurrences = occurrences + 1, + last_seen = NOW(), + details = :details + WHERE id = :id + '); + + $updateStmt->execute([ + 'id' => $existing['id'], + 'details' => json_encode($context) + ]); + + return (int)$existing['id']; + } else { + // Créer une nouvelle alerte + $insertStmt = $db->prepare(' + INSERT INTO sec_alerts ( + alert_type, alert_level, ip_address, user_id, username, + endpoint, method, details, first_seen, last_seen + ) VALUES ( + :type, :level, :ip, :user_id, :username, + :endpoint, :method, :details, NOW(), NOW() + ) + '); + + $insertStmt->execute([ + 'type' => $type, + 'level' => $level, + 'ip' => $ip, + 'user_id' => $context['session']['user_id'] ?? null, + 'username' => $context['username'] ?? null, + 'endpoint' => $endpoint, + 'method' => $context['request']['method'] ?? null, + 'details' => json_encode($context) + ]); + + return (int)$db->lastInsertId(); + } + } + + /** + * Logger l'alerte + */ + private static function logAlert(string $type, string $level, array $context): void { + $message = sprintf( + "[ALERT] %s - %s: %s", + $level, + $type, + $context['message'] ?? 'No message provided' + ); + + // Utiliser LogService existant + if (class_exists('LogService')) { + \LogService::log($message, [ + 'level' => strtolower($level), + 'alert_type' => $type, + 'context' => $context + ]); + } else { + error_log($message); + } + } + + /** + * Vérifier si un email doit être envoyé + */ + private static function shouldSendEmail(string $type, string $level): bool { + $config = self::ALERT_LEVELS[$level] ?? []; + + if (!($config['email'] ?? false)) { + return false; + } + + // Vérifier l'environnement + $env = AppConfig::getInstance()->getEnvironment(); + if ($env === 'development' && $level !== 'SECURITY') { + return false; // Pas d'emails en dev sauf sécurité + } + + // Vérifier le throttling + $throttler = self::getThrottler(); + $throttleTime = $config['throttle'] ?? 0; + + if ($throttleTime > 0 && !$throttler->canSend($type, $throttleTime)) { + return false; + } + + return true; + } + + /** + * Envoyer l'email d'alerte + */ + private static function sendAlertEmail(string $type, string $level, array $context, int $alertId): void { + try { + $throttler = self::getThrottler(); + + // Préparer le contenu de l'email + $subject = sprintf( + "[%s] GeoSector %s - %s", + $level, + strtoupper($context['environment'] ?? 'PROD'), + str_replace('_', ' ', $type) + ); + + $body = self::formatEmailBody($type, $level, $context, $alertId); + + // Destinataire principal + $to = 'support@unikoffice.com'; + + // CC pour les alertes critiques + $cc = []; + if (in_array($level, ['CRITICAL', 'SECURITY'])) { + $cc[] = 'admin@geosector.fr'; + } + + // Envoyer l'email + $emailSent = ApiService::sendEmail( + $to, + 'GeoSector Security', + 'security_alert', + [ + 'subject' => $subject, + 'body' => $body, + 'type' => $type, + 'level' => $level, + 'context' => $context + ] + ); + + if ($emailSent) { + // Marquer l'alerte comme email envoyé + $db = self::getDb(); + $updateStmt = $db->prepare(' + UPDATE sec_alerts + SET email_sent = 1, email_sent_at = NOW() + WHERE id = :id + '); + $updateStmt->execute(['id' => $alertId]); + + // Enregistrer l'envoi pour throttling + $throttler->recordSent($type); + } + + } catch (\Exception $e) { + error_log("Failed to send alert email: " . $e->getMessage()); + } + } + + /** + * Formater le corps de l'email + */ + private static function formatEmailBody(string $type, string $level, array $context, int $alertId): string { + $env = strtoupper($context['environment'] ?? 'PRODUCTION'); + $timestamp = $context['timestamp'] ?? date('Y-m-d H:i:s'); + + $body = " +======================================== +ALERTE DE SÉCURITÉ - ACTION REQUISE +======================================== + +Environnement: $env +Date/Heure: $timestamp +Type: $type +Niveau: $level +ID Alerte: #$alertId + +RÉSUMÉ +------ +" . ($context['message'] ?? 'Alerte détectée sans message spécifique') . " + +DÉTAILS TECHNIQUES +------------------ +"; + + if (isset($context['request'])) { + $body .= " +Requête: +- Endpoint: " . $context['request']['uri'] . " +- Méthode: " . $context['request']['method'] . " +- IP: " . $context['request']['ip'] . " +- User Agent: " . substr($context['request']['user_agent'] ?? 'N/A', 0, 100) . " +"; + } + + if (isset($context['session']) && $context['session']['user_id']) { + $body .= " +Session: +- User ID: " . $context['session']['user_id'] . " +- Entity ID: " . ($context['session']['entity_id'] ?? 'N/A') . " +"; + } + + if (isset($context['system'])) { + $body .= " +Système: +- Mémoire: " . $context['system']['memory_usage_mb'] . " MB / " . $context['system']['memory_peak_mb'] . " MB (peak) +- Load Average: " . implode(', ', $context['system']['load_average'] ?? []) . " +"; + } + + // Ajouter les données spécifiques selon le type + $body .= self::getTypeSpecificDetails($type, $context); + + // Actions recommandées + $body .= " + +ACTIONS RECOMMANDÉES +-------------------- +" . self::getRecommendedActions($type, $level, $context) . " + +LIENS UTILES +------------ +- Logs: https://dapp.geosector.fr/admin/logs +- Dashboard: https://dapp.geosector.fr/admin/security +- Bloquer IP: https://dapp.geosector.fr/admin/block-ip/" . ($context['request']['ip'] ?? '') . " + +-- +Email automatique généré par GeoSector Security +Ne pas répondre à cet email +"; + + return $body; + } + + /** + * Détails spécifiques selon le type d'alerte + */ + private static function getTypeSpecificDetails(string $type, array $context): string { + $details = "\nDÉTAILS SPÉCIFIQUES\n-------------------\n"; + + switch ($type) { + case 'BRUTE_FORCE': + $details .= "- Tentatives: " . ($context['attempts'] ?? 'N/A') . "\n"; + $details .= "- Username ciblé: " . ($context['username'] ?? 'N/A') . "\n"; + $details .= "- Période: " . ($context['timeframe'] ?? 'N/A') . "\n"; + break; + + case 'SQL_ERROR': + $details .= "- Erreur SQL: " . ($context['sql_error'] ?? 'N/A') . "\n"; + $details .= "- Code: " . ($context['sql_code'] ?? 'N/A') . "\n"; + break; + + case 'PERFORMANCE_SLOW': + case 'PERFORMANCE_CRITICAL': + $details .= "- Temps réponse: " . ($context['response_time_ms'] ?? 'N/A') . " ms\n"; + $details .= "- Temps DB: " . ($context['db_time_ms'] ?? 'N/A') . " ms\n"; + $details .= "- Seuil dépassé: " . ($context['threshold_ms'] ?? 'N/A') . " ms\n"; + break; + + case 'HTTP_500': + $details .= "- Message d'erreur: " . ($context['error_message'] ?? 'N/A') . "\n"; + $details .= "- Stack trace: " . substr($context['stack_trace'] ?? 'N/A', 0, 500) . "\n"; + break; + } + + return $details; + } + + /** + * Actions recommandées selon le type + */ + private static function getRecommendedActions(string $type, string $level, array $context): string { + $actions = []; + + switch ($type) { + case 'BRUTE_FORCE': + $actions[] = "1. Vérifier les logs de connexion"; + $actions[] = "2. Bloquer l'IP si nécessaire"; + $actions[] = "3. Vérifier l'intégrité du compte ciblé"; + $actions[] = "4. Considérer l'activation de 2FA"; + break; + + case 'SQL_INJECTION': + $actions[] = "1. BLOQUER L'IP IMMÉDIATEMENT"; + $actions[] = "2. Vérifier l'intégrité de la base de données"; + $actions[] = "3. Analyser les logs pour d'autres tentatives"; + $actions[] = "4. Patcher la vulnérabilité identifiée"; + break; + + case 'DB_CONNECTION': + $actions[] = "1. Vérifier le statut du serveur MySQL"; + $actions[] = "2. Vérifier les connexions actives"; + $actions[] = "3. Redémarrer le service si nécessaire"; + $actions[] = "4. Vérifier l'espace disque"; + break; + + case 'PERFORMANCE_CRITICAL': + $actions[] = "1. Identifier les requêtes lentes"; + $actions[] = "2. Vérifier la charge serveur"; + $actions[] = "3. Optimiser les requêtes problématiques"; + $actions[] = "4. Considérer un scale-up si récurrent"; + break; + + default: + $actions[] = "1. Examiner les logs détaillés"; + $actions[] = "2. Évaluer l'impact sur les utilisateurs"; + $actions[] = "3. Prendre action selon la criticité"; + } + + return implode("\n", $actions); + } + + /** + * Exécuter des actions automatiques + */ + private static function executeAutomaticActions(string $type, array $context): void { + switch ($type) { + case 'BRUTE_FORCE': + // Bloquer automatiquement l'IP + if (isset($context['request']['ip'])) { + require_once __DIR__ . '/IPBlocker.php'; + IPBlocker::block( + $context['request']['ip'], + 3600, // 1 heure + 'Brute force detected: ' . ($context['attempts'] ?? 0) . ' attempts' + ); + } + break; + + case 'SQL_INJECTION': + // Bloquer immédiatement et définitivement + if (isset($context['request']['ip'])) { + require_once __DIR__ . '/IPBlocker.php'; + IPBlocker::blockPermanent( + $context['request']['ip'], + 'SQL Injection attempt detected' + ); + } + break; + } + } + + /** + * Obtenir l'instance de base de données + */ + private static function getDb(): PDO { + if (self::$db === null) { + self::$db = Database::getInstance(); + } + return self::$db; + } + + /** + * Obtenir l'instance du throttler + */ + private static function getThrottler(): EmailThrottler { + if (self::$throttler === null) { + self::$throttler = new EmailThrottler(); + } + return self::$throttler; + } + + /** + * Résoudre une alerte + */ + public static function resolve(int $alertId, int $userId, string $notes = ''): bool { + try { + $db = self::getDb(); + $stmt = $db->prepare(' + UPDATE sec_alerts + SET resolved = 1, + resolved_at = NOW(), + resolved_by = :user_id, + notes = :notes + WHERE id = :id + '); + + return $stmt->execute([ + 'id' => $alertId, + 'user_id' => $userId, + 'notes' => $notes + ]); + } catch (\Exception $e) { + error_log("Failed to resolve alert: " . $e->getMessage()); + return false; + } + } + + /** + * Obtenir les alertes actives + */ + public static function getActiveAlerts(int $limit = 50): array { + try { + $db = self::getDb(); + $stmt = $db->prepare(' + SELECT * FROM sec_active_alerts + LIMIT :limit + '); + $stmt->bindValue('limit', $limit, PDO::PARAM_INT); + $stmt->execute(); + + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } catch (\Exception $e) { + error_log("Failed to get active alerts: " . $e->getMessage()); + return []; + } + } +} \ No newline at end of file diff --git a/api/src/Services/Security/EmailThrottler.php b/api/src/Services/Security/EmailThrottler.php new file mode 100644 index 00000000..07138be9 --- /dev/null +++ b/api/src/Services/Security/EmailThrottler.php @@ -0,0 +1,294 @@ + 10, + 'max_per_day' => 50, + 'digest_after' => 5, // Grouper après 5 alertes similaires + 'cooldown_minutes' => 60 + ]; + + /** + * Vérifier si on peut envoyer un email pour ce type d'alerte + */ + public function canSend(string $alertType, int $throttleSeconds = 0): bool { + // Vérifier le cache en mémoire d'abord + if (isset(self::$cache[$alertType])) { + $lastSent = self::$cache[$alertType]; + if (time() - $lastSent < $throttleSeconds) { + return false; + } + } + + // Vérifier en base de données + $db = self::getDb(); + + // Vérifier le throttle spécifique au type + if ($throttleSeconds > 0) { + $stmt = $db->prepare(' + SELECT COUNT(*) as count + FROM sec_alerts + WHERE alert_type = :type + AND email_sent = 1 + AND email_sent_at >= DATE_SUB(NOW(), INTERVAL :seconds SECOND) + '); + + $stmt->execute([ + 'type' => $alertType, + 'seconds' => $throttleSeconds + ]); + + $result = $stmt->fetch(PDO::FETCH_ASSOC); + if ($result['count'] > 0) { + return false; + } + } + + // Vérifier les limites globales + return $this->checkGlobalLimits(); + } + + /** + * Vérifier les limites globales (par heure et par jour) + */ + private function checkGlobalLimits(): bool { + $db = self::getDb(); + $config = self::getConfig(); + + // Vérifier limite horaire + $hourlyStmt = $db->prepare(' + SELECT COUNT(*) as count + FROM sec_alerts + WHERE email_sent = 1 + AND email_sent_at >= DATE_SUB(NOW(), INTERVAL 1 HOUR) + '); + $hourlyStmt->execute(); + $hourlyCount = $hourlyStmt->fetch(PDO::FETCH_ASSOC)['count']; + + if ($hourlyCount >= $config['max_per_hour']) { + error_log("Email throttler: Hourly limit reached ({$hourlyCount}/{$config['max_per_hour']})"); + return false; + } + + // Vérifier limite quotidienne + $dailyStmt = $db->prepare(' + SELECT COUNT(*) as count + FROM sec_alerts + WHERE email_sent = 1 + AND email_sent_at >= DATE_SUB(NOW(), INTERVAL 1 DAY) + '); + $dailyStmt->execute(); + $dailyCount = $dailyStmt->fetch(PDO::FETCH_ASSOC)['count']; + + if ($dailyCount >= $config['max_per_day']) { + error_log("Email throttler: Daily limit reached ({$dailyCount}/{$config['max_per_day']})"); + return false; + } + + return true; + } + + /** + * Enregistrer l'envoi d'un email + */ + public function recordSent(string $alertType): void { + // Mettre à jour le cache + self::$cache[$alertType] = time(); + + // Nettoyer le cache périodiquement + if (count(self::$cache) > 100) { + $cutoff = time() - 3600; // Garder seulement la dernière heure + self::$cache = array_filter(self::$cache, function($time) use ($cutoff) { + return $time > $cutoff; + }); + } + } + + /** + * Vérifier si on doit envoyer un digest plutôt que des alertes individuelles + */ + public function shouldSendDigest(string $alertType): bool { + $db = self::getDb(); + $config = self::getConfig(); + + // Compter les alertes non envoyées du même type + $stmt = $db->prepare(' + SELECT COUNT(*) as count + FROM sec_alerts + WHERE alert_type = :type + AND email_sent = 0 + AND resolved = 0 + AND created_at >= DATE_SUB(NOW(), INTERVAL 1 HOUR) + '); + + $stmt->execute(['type' => $alertType]); + $result = $stmt->fetch(PDO::FETCH_ASSOC); + + return $result['count'] >= $config['digest_after']; + } + + /** + * Obtenir les alertes pour un digest + */ + public function getDigestAlerts(string $alertType, int $limit = 50): array { + $db = self::getDb(); + + $stmt = $db->prepare(' + SELECT * + FROM sec_alerts + WHERE alert_type = :type + AND email_sent = 0 + AND resolved = 0 + AND created_at >= DATE_SUB(NOW(), INTERVAL 24 HOUR) + ORDER BY alert_level DESC, last_seen DESC + LIMIT :limit + '); + + $stmt->bindValue('type', $alertType, PDO::PARAM_STR); + $stmt->bindValue('limit', $limit, PDO::PARAM_INT); + $stmt->execute(); + + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } + + /** + * Marquer les alertes comme envoyées dans un digest + */ + public function markDigestSent(array $alertIds): void { + if (empty($alertIds)) { + return; + } + + $db = self::getDb(); + $placeholders = implode(',', array_fill(0, count($alertIds), '?')); + + $stmt = $db->prepare(" + UPDATE sec_alerts + SET email_sent = 1, + email_sent_at = NOW(), + notes = CONCAT(IFNULL(notes, ''), '\nInclu dans digest email') + WHERE id IN ({$placeholders}) + "); + + $stmt->execute($alertIds); + } + + /** + * Obtenir les statistiques de throttling + */ + public function getStats(): array { + $db = self::getDb(); + $config = self::getConfig(); + + // Emails envoyés dans l'heure + $hourlyStmt = $db->prepare(' + SELECT COUNT(*) as count + FROM sec_alerts + WHERE email_sent = 1 + AND email_sent_at >= DATE_SUB(NOW(), INTERVAL 1 HOUR) + '); + $hourlyStmt->execute(); + $hourlyCount = $hourlyStmt->fetch(PDO::FETCH_ASSOC)['count']; + + // Emails envoyés dans la journée + $dailyStmt = $db->prepare(' + SELECT COUNT(*) as count + FROM sec_alerts + WHERE email_sent = 1 + AND email_sent_at >= DATE_SUB(NOW(), INTERVAL 1 DAY) + '); + $dailyStmt->execute(); + $dailyCount = $dailyStmt->fetch(PDO::FETCH_ASSOC)['count']; + + // Alertes en attente + $pendingStmt = $db->prepare(' + SELECT + alert_type, + COUNT(*) as count, + MIN(first_seen) as oldest + FROM sec_alerts + WHERE email_sent = 0 + AND resolved = 0 + GROUP BY alert_type + '); + $pendingStmt->execute(); + $pending = $pendingStmt->fetchAll(PDO::FETCH_ASSOC); + + return [ + 'hourly' => [ + 'sent' => $hourlyCount, + 'limit' => $config['max_per_hour'], + 'remaining' => max(0, $config['max_per_hour'] - $hourlyCount) + ], + 'daily' => [ + 'sent' => $dailyCount, + 'limit' => $config['max_per_day'], + 'remaining' => max(0, $config['max_per_day'] - $dailyCount) + ], + 'pending_alerts' => $pending, + 'cache_size' => count(self::$cache), + 'config' => $config + ]; + } + + /** + * Réinitialiser les compteurs (utile pour les tests) + */ + public function reset(): void { + self::$cache = []; + + // Optionnel : réinitialiser les flags en DB + $db = self::getDb(); + $stmt = $db->prepare(' + UPDATE sec_alerts + SET email_sent = 0, email_sent_at = NULL + WHERE email_sent_at < DATE_SUB(NOW(), INTERVAL 1 DAY) + '); + $stmt->execute(); + } + + /** + * Obtenir la configuration + */ + private static function getConfig(): array { + // Essayer de charger depuis AppConfig si disponible + try { + if (class_exists('AppConfig')) { + $appConfig = \AppConfig::getInstance(); + // AppConfig n'a pas de méthode get() générique + // On pourrait ajouter une configuration dans getCurrentConfig() si nécessaire + // Pour l'instant, utiliser la configuration par défaut + } + } catch (\Exception $e) { + // Utiliser config par défaut + } + + return self::DEFAULT_CONFIG; + } + + /** + * Obtenir l'instance de base de données + */ + private static function getDb(): PDO { + if (self::$db === null) { + self::$db = Database::getInstance(); + } + return self::$db; + } +} \ No newline at end of file diff --git a/api/src/Services/Security/IPBlocker.php b/api/src/Services/Security/IPBlocker.php new file mode 100644 index 00000000..4e027c63 --- /dev/null +++ b/api/src/Services/Security/IPBlocker.php @@ -0,0 +1,510 @@ + time()) { + return true; + } else { + // Expirée, retirer du cache + unset(self::$cache[$ip]); + } + } + + // Vérifier en base de données + $db = self::getDb(); + + $stmt = $db->prepare(' + SELECT ip_address, blocked_until, permanent, reason + FROM sec_blocked_ips + WHERE ip_address = :ip + AND (permanent = 1 OR blocked_until > NOW()) + AND unblocked_at IS NULL + LIMIT 1 + '); + + $stmt->execute(['ip' => $ip]); + $result = $stmt->fetch(PDO::FETCH_ASSOC); + + if ($result) { + // Mettre en cache + self::$cache[$ip] = [ + 'blocked_until' => strtotime($result['blocked_until']), + 'permanent' => (bool)$result['permanent'], + 'reason' => $result['reason'] + ]; + + // Nettoyer le cache périodiquement + self::cleanCacheIfNeeded(); + + return true; + } + + return false; + } + + /** + * Bloquer une IP temporairement + */ + public static function block( + string $ip, + int $durationSeconds = 3600, + string $reason = 'Suspicious activity', + string $blockedBy = 'system' + ): bool { + // Ne pas bloquer les IPs en whitelist + if (in_array($ip, self::WHITELIST)) { + error_log("Attempted to block whitelisted IP: $ip"); + return false; + } + + $db = self::getDb(); + + try { + // Vérifier si l'IP est déjà bloquée + $checkStmt = $db->prepare(' + SELECT ip_address, block_count + FROM sec_blocked_ips + WHERE ip_address = :ip + LIMIT 1 + '); + $checkStmt->execute(['ip' => $ip]); + $existing = $checkStmt->fetch(PDO::FETCH_ASSOC); + + $blockedUntil = date('Y-m-d H:i:s', time() + $durationSeconds); + + if ($existing) { + // Mettre à jour le blocage existant + $updateStmt = $db->prepare(' + UPDATE sec_blocked_ips + SET blocked_until = :until, + reason = :reason, + blocked_at = NOW(), + blocked_by = :by, + block_count = block_count + 1, + unblocked_at = NULL, + permanent = 0 + WHERE ip_address = :ip + '); + + $updateStmt->execute([ + 'ip' => $ip, + 'until' => $blockedUntil, + 'reason' => $reason, + 'by' => $blockedBy + ]); + + $blockCount = $existing['block_count'] + 1; + + // Si bloquée plus de 3 fois, envisager un blocage permanent + if ($blockCount >= 3) { + AlertService::trigger('REPEAT_OFFENDER', [ + 'ip' => $ip, + 'block_count' => $blockCount, + 'reason' => $reason, + 'message' => "IP bloquée pour la {$blockCount}e fois, considérer blocage permanent" + ], 'WARNING'); + } + + } else { + // Créer un nouveau blocage + $insertStmt = $db->prepare(' + INSERT INTO sec_blocked_ips ( + ip_address, reason, blocked_until, blocked_by, + permanent, details + ) VALUES ( + :ip, :reason, :until, :by, 0, :details + ) + '); + + $details = json_encode([ + 'timestamp' => date('c'), + 'duration_seconds' => $durationSeconds, + 'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? null, + 'endpoint' => $_SERVER['REQUEST_URI'] ?? null + ]); + + $insertStmt->execute([ + 'ip' => $ip, + 'reason' => $reason, + 'until' => $blockedUntil, + 'by' => $blockedBy, + 'details' => $details + ]); + } + + // Mettre à jour le cache + self::$cache[$ip] = [ + 'blocked_until' => time() + $durationSeconds, + 'permanent' => false, + 'reason' => $reason + ]; + + // Logger l'action + error_log("IP blocked: $ip for {$durationSeconds}s - Reason: $reason"); + + return true; + + } catch (\Exception $e) { + error_log("Failed to block IP: " . $e->getMessage()); + return false; + } + } + + /** + * Bloquer une IP définitivement + */ + public static function blockPermanent( + string $ip, + string $reason = 'Security threat', + string $blockedBy = 'system' + ): bool { + // Ne pas bloquer les IPs en whitelist + if (in_array($ip, self::WHITELIST)) { + error_log("Attempted to permanently block whitelisted IP: $ip"); + return false; + } + + $db = self::getDb(); + + try { + // Vérifier si l'IP existe déjà + $checkStmt = $db->prepare(' + SELECT ip_address FROM sec_blocked_ips + WHERE ip_address = :ip + LIMIT 1 + '); + $checkStmt->execute(['ip' => $ip]); + $existing = $checkStmt->fetch(PDO::FETCH_ASSOC); + + if ($existing) { + // Mettre à jour en permanent + $updateStmt = $db->prepare(' + UPDATE sec_blocked_ips + SET permanent = 1, + blocked_until = "2099-12-31 23:59:59", + reason = :reason, + blocked_at = NOW(), + blocked_by = :by, + unblocked_at = NULL + WHERE ip_address = :ip + '); + + $updateStmt->execute([ + 'ip' => $ip, + 'reason' => $reason, + 'by' => $blockedBy + ]); + } else { + // Créer un blocage permanent + $insertStmt = $db->prepare(' + INSERT INTO sec_blocked_ips ( + ip_address, reason, blocked_until, blocked_by, + permanent, details + ) VALUES ( + :ip, :reason, "2099-12-31 23:59:59", :by, 1, :details + ) + '); + + $details = json_encode([ + 'timestamp' => date('c'), + 'permanent' => true, + 'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? null, + 'endpoint' => $_SERVER['REQUEST_URI'] ?? null + ]); + + $insertStmt->execute([ + 'ip' => $ip, + 'reason' => $reason, + 'by' => $blockedBy, + 'details' => $details + ]); + } + + // Mettre à jour le cache + self::$cache[$ip] = [ + 'blocked_until' => PHP_INT_MAX, + 'permanent' => true, + 'reason' => $reason + ]; + + // Alerter pour blocage permanent + AlertService::trigger('IP_BLOCKED_PERMANENT', [ + 'ip' => $ip, + 'reason' => $reason, + 'blocked_by' => $blockedBy, + 'message' => "IP bloquée définitivement : $ip" + ], 'SECURITY'); + + return true; + + } catch (\Exception $e) { + error_log("Failed to permanently block IP: " . $e->getMessage()); + return false; + } + } + + /** + * Débloquer une IP + */ + public static function unblock(string $ip, int $unblockedBy = null): bool { + $db = self::getDb(); + + try { + $stmt = $db->prepare(' + UPDATE sec_blocked_ips + SET unblocked_at = NOW(), + unblocked_by = :by + WHERE ip_address = :ip + AND unblocked_at IS NULL + '); + + $stmt->execute([ + 'ip' => $ip, + 'by' => $unblockedBy + ]); + + // Retirer du cache + unset(self::$cache[$ip]); + + $affected = $stmt->rowCount(); + + if ($affected > 0) { + error_log("IP unblocked: $ip by user $unblockedBy"); + + // Logger l'action + AlertService::trigger('IP_UNBLOCKED', [ + 'ip' => $ip, + 'unblocked_by' => $unblockedBy, + 'message' => "IP débloquée manuellement : $ip" + ], 'INFO'); + } + + return $affected > 0; + + } catch (\Exception $e) { + error_log("Failed to unblock IP: " . $e->getMessage()); + return false; + } + } + + /** + * Obtenir la liste des IPs bloquées + */ + public static function getBlockedIPs(bool $activeOnly = true): array { + $db = self::getDb(); + + $whereClause = $activeOnly + ? 'WHERE unblocked_at IS NULL AND (permanent = 1 OR blocked_until > NOW())' + : ''; + + $stmt = $db->prepare(" + SELECT + ip_address, + reason, + blocked_at, + blocked_until, + blocked_by, + permanent, + unblocked_at, + unblocked_by, + block_count, + details + FROM sec_blocked_ips + $whereClause + ORDER BY blocked_at DESC + "); + + $stmt->execute(); + + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } + + /** + * Vérifier si une IP est dans un range CIDR + */ + public static function isInRange(string $ip, string $cidr): bool { + list($subnet, $bits) = explode('/', $cidr); + + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) && + filter_var($subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + // IPv4 + $ip = ip2long($ip); + $subnet = ip2long($subnet); + $mask = -1 << (32 - $bits); + $subnet &= $mask; + + return ($ip & $mask) == $subnet; + } elseif (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) && + filter_var($subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + // IPv6 + $ipBin = inet_pton($ip); + $subnetBin = inet_pton($subnet); + + $byteCount = (int)($bits / 8); + $bitCount = $bits % 8; + + for ($i = 0; $i < $byteCount; $i++) { + if ($ipBin[$i] !== $subnetBin[$i]) { + return false; + } + } + + if ($bitCount > 0) { + $mask = 0xFF << (8 - $bitCount); + return (ord($ipBin[$byteCount]) & $mask) === (ord($subnetBin[$byteCount]) & $mask); + } + + return true; + } + + return false; + } + + /** + * Nettoyer les blocages expirés + */ + public static function cleanupExpired(): int { + $db = self::getDb(); + + try { + $stmt = $db->prepare(' + UPDATE sec_blocked_ips + SET unblocked_at = NOW() + WHERE blocked_until <= NOW() + AND permanent = 0 + AND unblocked_at IS NULL + '); + + $stmt->execute(); + + $cleaned = $stmt->rowCount(); + + if ($cleaned > 0) { + error_log("Cleaned up $cleaned expired IP blocks"); + } + + return $cleaned; + + } catch (\Exception $e) { + error_log("Failed to cleanup expired blocks: " . $e->getMessage()); + return 0; + } + } + + /** + * Obtenir les statistiques de blocage + */ + public static function getStats(): array { + $db = self::getDb(); + + // Total des IPs bloquées + $totalStmt = $db->prepare(' + SELECT + COUNT(*) as total, + SUM(CASE WHEN permanent = 1 THEN 1 ELSE 0 END) as permanent, + SUM(CASE WHEN permanent = 0 AND blocked_until > NOW() THEN 1 ELSE 0 END) as temporary, + SUM(CASE WHEN unblocked_at IS NOT NULL THEN 1 ELSE 0 END) as unblocked + FROM sec_blocked_ips + '); + $totalStmt->execute(); + $totals = $totalStmt->fetch(PDO::FETCH_ASSOC); + + // Blocages récents (24h) + $recentStmt = $db->prepare(' + SELECT COUNT(*) as count + FROM sec_blocked_ips + WHERE blocked_at >= DATE_SUB(NOW(), INTERVAL 24 HOUR) + '); + $recentStmt->execute(); + $recent = $recentStmt->fetch(PDO::FETCH_ASSOC); + + // Top raisons de blocage + $reasonsStmt = $db->prepare(' + SELECT + reason, + COUNT(*) as count + FROM sec_blocked_ips + WHERE unblocked_at IS NULL + GROUP BY reason + ORDER BY count DESC + LIMIT 10 + '); + $reasonsStmt->execute(); + $reasons = $reasonsStmt->fetchAll(PDO::FETCH_ASSOC); + + return [ + 'totals' => $totals, + 'recent_24h' => $recent['count'], + 'top_reasons' => $reasons, + 'cache_size' => count(self::$cache), + 'whitelist' => self::WHITELIST + ]; + } + + /** + * Nettoyer le cache si nécessaire + */ + private static function cleanCacheIfNeeded(): void { + $now = time(); + + // Nettoyer toutes les 5 minutes + if (self::$lastCacheClean === null || $now - self::$lastCacheClean > 300) { + self::$cache = array_filter(self::$cache, function($item) use ($now) { + return $item['permanent'] || $item['blocked_until'] > $now; + }); + + self::$lastCacheClean = $now; + } + + // Limiter la taille du cache + if (count(self::$cache) > 1000) { + self::$cache = array_slice(self::$cache, -500, null, true); + } + } + + /** + * Obtenir l'instance de base de données + */ + private static function getDb(): PDO { + if (self::$db === null) { + self::$db = Database::getInstance(); + } + return self::$db; + } +} \ No newline at end of file diff --git a/api/src/Services/Security/PerformanceMonitor.php b/api/src/Services/Security/PerformanceMonitor.php new file mode 100644 index 00000000..ab7bd1af --- /dev/null +++ b/api/src/Services/Security/PerformanceMonitor.php @@ -0,0 +1,452 @@ + 1000, // 1 seconde + 'response_time_critical' => 3000, // 3 secondes + 'db_time_warning' => 500, // 500ms + 'db_time_critical' => 1000, // 1 seconde + 'memory_warning' => 64, // 64 MB + 'memory_critical' => 128, // 128 MB + 'db_queries_warning' => 20, // 20 requêtes + 'db_queries_critical' => 50 // 50 requêtes + ]; + + // Seuils spécifiques par endpoint + const ENDPOINT_THRESHOLDS = [ + '/api/operations/export' => [ + 'response_time_warning' => 5000, + 'response_time_critical' => 10000, + 'memory_warning' => 256, + 'memory_critical' => 512 + ], + '/api/chat/rooms' => [ + 'response_time_warning' => 500, + 'response_time_critical' => 1000 + ], + '/api/logs' => [ + 'response_time_warning' => 200, + 'response_time_critical' => 500 + ] + ]; + + /** + * Démarrer le monitoring d'une requête + */ + public static function startRequest(): void { + self::$requestStartTime = microtime(true); + self::$requestStartMemory = memory_get_usage(true); + self::$dbQueries = []; + self::$dbTotalTime = 0; + self::$dbQueryCount = 0; + } + + /** + * Démarrer le monitoring d'une requête DB + */ + public static function startDbQuery(string $query): void { + self::$dbQueries[] = [ + 'query' => $query, + 'start' => microtime(true), + 'duration' => 0 + ]; + } + + /** + * Terminer le monitoring d'une requête DB + */ + public static function endDbQuery(): void { + if (!empty(self::$dbQueries)) { + $lastIndex = count(self::$dbQueries) - 1; + $duration = (microtime(true) - self::$dbQueries[$lastIndex]['start']) * 1000; + self::$dbQueries[$lastIndex]['duration'] = $duration; + self::$dbTotalTime += $duration; + self::$dbQueryCount++; + + // Alerter si requête très lente + if ($duration > 1000) { // Plus d'1 seconde + AlertService::trigger('SLOW_QUERY', [ + 'query' => substr(self::$dbQueries[$lastIndex]['query'], 0, 500), + 'duration_ms' => $duration, + 'message' => "Requête SQL lente détectée : {$duration}ms" + ], 'WARNING'); + } + } + } + + /** + * Terminer le monitoring d'une requête HTTP + */ + public static function endRequest(string $endpoint, string $method, int $httpStatus): void { + if (self::$requestStartTime === null) { + return; // Monitoring non démarré + } + + // Calculer les métriques + $responseTime = (microtime(true) - self::$requestStartTime) * 1000; + $memoryPeak = memory_get_peak_usage(true) / 1024 / 1024; // En MB + $memoryStart = self::$requestStartMemory / 1024 / 1024; + $memoryUsed = $memoryPeak - $memoryStart; + + // Enrichir avec les infos de requête + $ip = $_SERVER['REMOTE_ADDR'] ?? null; + $userAgent = $_SERVER['HTTP_USER_AGENT'] ?? null; + $requestSize = strlen(file_get_contents('php://input')); + + // ID utilisateur si connecté + $userId = null; + if (session_status() === PHP_SESSION_ACTIVE && isset($_SESSION['user_id'])) { + $userId = $_SESSION['user_id']; + } + + // Enregistrer les métriques + self::saveMetrics([ + 'endpoint' => $endpoint, + 'method' => $method, + 'response_time_ms' => (int)$responseTime, + 'db_time_ms' => (int)self::$dbTotalTime, + 'db_queries_count' => self::$dbQueryCount, + 'memory_peak_mb' => $memoryPeak, + 'memory_start_mb' => $memoryStart, + 'http_status' => $httpStatus, + 'user_id' => $userId, + 'ip_address' => $ip, + 'user_agent' => $userAgent, + 'request_size' => $requestSize, + 'response_size' => ob_get_length() ?: 0 + ]); + + // Vérifier les seuils et alerter si nécessaire + self::checkThresholds($endpoint, $responseTime, self::$dbTotalTime, $memoryPeak); + + // Vérifier la dégradation des performances + self::checkPerformanceDegradation($endpoint, $responseTime); + + // Réinitialiser pour la prochaine requête + self::reset(); + } + + /** + * Sauvegarder les métriques en base + */ + private static function saveMetrics(array $metrics): void { + try { + $db = self::getDb(); + + $stmt = $db->prepare(' + INSERT INTO sec_performance_metrics ( + endpoint, method, response_time_ms, db_time_ms, db_queries_count, + memory_peak_mb, memory_start_mb, http_status, user_id, + ip_address, user_agent, request_size, response_size + ) VALUES ( + :endpoint, :method, :response_time, :db_time, :db_queries, + :memory_peak, :memory_start, :status, :user_id, + :ip, :agent, :req_size, :resp_size + ) + '); + + $stmt->execute([ + 'endpoint' => $metrics['endpoint'], + 'method' => $metrics['method'], + 'response_time' => $metrics['response_time_ms'], + 'db_time' => $metrics['db_time_ms'], + 'db_queries' => $metrics['db_queries_count'], + 'memory_peak' => $metrics['memory_peak_mb'], + 'memory_start' => $metrics['memory_start_mb'], + 'status' => $metrics['http_status'], + 'user_id' => $metrics['user_id'], + 'ip' => $metrics['ip_address'], + 'agent' => $metrics['user_agent'], + 'req_size' => $metrics['request_size'], + 'resp_size' => $metrics['response_size'] + ]); + + } catch (\Exception $e) { + error_log("Failed to save performance metrics: " . $e->getMessage()); + } + } + + /** + * Vérifier les seuils de performance + */ + private static function checkThresholds( + string $endpoint, + float $responseTime, + float $dbTime, + float $memoryPeak + ): void { + // Obtenir les seuils pour cet endpoint + $thresholds = self::getThresholdsForEndpoint($endpoint); + + // Vérifier temps de réponse + if ($responseTime > $thresholds['response_time_critical']) { + AlertService::trigger('PERFORMANCE_CRITICAL', [ + 'endpoint' => $endpoint, + 'response_time_ms' => $responseTime, + 'threshold_ms' => $thresholds['response_time_critical'], + 'db_time_ms' => $dbTime, + 'message' => "Performance critique sur $endpoint : {$responseTime}ms" + ], 'ERROR'); + } elseif ($responseTime > $thresholds['response_time_warning']) { + AlertService::trigger('PERFORMANCE_SLOW', [ + 'endpoint' => $endpoint, + 'response_time_ms' => $responseTime, + 'threshold_ms' => $thresholds['response_time_warning'], + 'db_time_ms' => $dbTime, + 'message' => "Performance lente sur $endpoint : {$responseTime}ms" + ], 'WARNING'); + } + + // Vérifier temps DB + if ($dbTime > $thresholds['db_time_critical']) { + AlertService::trigger('DB_PERFORMANCE_CRITICAL', [ + 'endpoint' => $endpoint, + 'db_time_ms' => $dbTime, + 'threshold_ms' => $thresholds['db_time_critical'], + 'queries_count' => self::$dbQueryCount, + 'message' => "Temps DB critique : {$dbTime}ms pour " . self::$dbQueryCount . " requêtes" + ], 'ERROR'); + } elseif ($dbTime > $thresholds['db_time_warning']) { + AlertService::trigger('DB_PERFORMANCE_SLOW', [ + 'endpoint' => $endpoint, + 'db_time_ms' => $dbTime, + 'threshold_ms' => $thresholds['db_time_warning'], + 'queries_count' => self::$dbQueryCount, + 'message' => "Temps DB lent : {$dbTime}ms" + ], 'WARNING'); + } + + // Vérifier mémoire + if ($memoryPeak > $thresholds['memory_critical']) { + AlertService::trigger('MEMORY_CRITICAL', [ + 'endpoint' => $endpoint, + 'memory_mb' => $memoryPeak, + 'threshold_mb' => $thresholds['memory_critical'], + 'message' => "Utilisation mémoire critique : {$memoryPeak}MB" + ], 'ERROR'); + } elseif ($memoryPeak > $thresholds['memory_warning']) { + AlertService::trigger('MEMORY_HIGH', [ + 'endpoint' => $endpoint, + 'memory_mb' => $memoryPeak, + 'threshold_mb' => $thresholds['memory_warning'], + 'message' => "Utilisation mémoire élevée : {$memoryPeak}MB" + ], 'WARNING'); + } + + // Vérifier nombre de requêtes DB + if (self::$dbQueryCount > $thresholds['db_queries_critical']) { + AlertService::trigger('DB_QUERIES_EXCESSIVE', [ + 'endpoint' => $endpoint, + 'queries_count' => self::$dbQueryCount, + 'threshold' => $thresholds['db_queries_critical'], + 'message' => "Trop de requêtes DB : " . self::$dbQueryCount + ], 'ERROR'); + } + } + + /** + * Vérifier la dégradation des performances + */ + private static function checkPerformanceDegradation(string $endpoint, float $currentTime): void { + try { + $db = self::getDb(); + + // Calculer la moyenne sur les 100 dernières requêtes + $stmt = $db->prepare(' + SELECT AVG(response_time_ms) as avg_time, + STDDEV(response_time_ms) as stddev_time, + COUNT(*) as sample_size + FROM ( + SELECT response_time_ms + FROM sec_performance_metrics + WHERE endpoint = :endpoint + AND http_status < 500 + AND created_at >= DATE_SUB(NOW(), INTERVAL 1 HOUR) + ORDER BY created_at DESC + LIMIT 100 + ) as recent + '); + + $stmt->execute(['endpoint' => $endpoint]); + $stats = $stmt->fetch(PDO::FETCH_ASSOC); + + if ($stats && $stats['sample_size'] >= 10) { + $avgTime = (float)$stats['avg_time']; + $stdDev = (float)$stats['stddev_time']; + + // Alerter si 2x plus lent que la moyenne + if ($currentTime > ($avgTime * 2) && $avgTime > 100) { + AlertService::trigger('PERFORMANCE_DEGRADATION', [ + 'endpoint' => $endpoint, + 'current_time_ms' => $currentTime, + 'average_time_ms' => $avgTime, + 'stddev_ms' => $stdDev, + 'factor' => round($currentTime / $avgTime, 2), + 'sample_size' => $stats['sample_size'], + 'message' => "Performance dégradée : " . round($currentTime / $avgTime, 1) . "x plus lent que la moyenne" + ], 'WARNING'); + } + } + + } catch (\Exception $e) { + error_log("Failed to check performance degradation: " . $e->getMessage()); + } + } + + /** + * Obtenir les seuils pour un endpoint + */ + private static function getThresholdsForEndpoint(string $endpoint): array { + // Chercher des seuils spécifiques + foreach (self::ENDPOINT_THRESHOLDS as $pattern => $thresholds) { + if (strpos($endpoint, $pattern) === 0) { + return array_merge(self::DEFAULT_THRESHOLDS, $thresholds); + } + } + + // Retourner les seuils par défaut + return self::DEFAULT_THRESHOLDS; + } + + /** + * Obtenir les statistiques de performance + */ + public static function getStats(string $endpoint = null, int $hours = 24): array { + $db = self::getDb(); + + $whereClause = 'WHERE created_at >= DATE_SUB(NOW(), INTERVAL :hours HOUR)'; + $params = ['hours' => $hours]; + + if ($endpoint) { + $whereClause .= ' AND endpoint = :endpoint'; + $params['endpoint'] = $endpoint; + } + + // Statistiques globales + $globalStmt = $db->prepare(" + SELECT + COUNT(*) as total_requests, + AVG(response_time_ms) as avg_response_time, + MIN(response_time_ms) as min_response_time, + MAX(response_time_ms) as max_response_time, + AVG(db_time_ms) as avg_db_time, + AVG(memory_peak_mb) as avg_memory, + SUM(CASE WHEN http_status >= 500 THEN 1 ELSE 0 END) as errors_5xx, + SUM(CASE WHEN http_status >= 400 AND http_status < 500 THEN 1 ELSE 0 END) as errors_4xx + FROM sec_performance_metrics + $whereClause + "); + + $globalStmt->execute($params); + $globalStats = $globalStmt->fetch(PDO::FETCH_ASSOC); + + // Top endpoints lents + $slowStmt = $db->prepare(" + SELECT + endpoint, + method, + COUNT(*) as requests, + AVG(response_time_ms) as avg_time, + MAX(response_time_ms) as max_time + FROM sec_performance_metrics + $whereClause + GROUP BY endpoint, method + ORDER BY avg_time DESC + LIMIT 10 + "); + + $slowStmt->execute($params); + $slowEndpoints = $slowStmt->fetchAll(PDO::FETCH_ASSOC); + + // Requêtes DB lentes + $slowDbStmt = $db->prepare(" + SELECT + endpoint, + MAX(db_time_ms) as max_db_time, + AVG(db_time_ms) as avg_db_time, + AVG(db_queries_count) as avg_queries + FROM sec_performance_metrics + WHERE db_time_ms > 500 + AND created_at >= DATE_SUB(NOW(), INTERVAL :hours HOUR) + GROUP BY endpoint + ORDER BY max_db_time DESC + LIMIT 10 + "); + + $slowDbStmt->execute(['hours' => $hours]); + $slowDbQueries = $slowDbStmt->fetchAll(PDO::FETCH_ASSOC); + + return [ + 'timeframe_hours' => $hours, + 'global' => $globalStats, + 'slow_endpoints' => $slowEndpoints, + 'slow_db_operations' => $slowDbQueries, + 'current_memory_mb' => round(memory_get_usage(true) / 1024 / 1024, 2), + 'current_load' => sys_getloadavg() + ]; + } + + /** + * Nettoyer les anciennes métriques + */ + public static function cleanup(int $daysToKeep = 30): int { + try { + $db = self::getDb(); + + $stmt = $db->prepare(' + DELETE FROM sec_performance_metrics + WHERE created_at < DATE_SUB(NOW(), INTERVAL :days DAY) + '); + + $stmt->execute(['days' => $daysToKeep]); + + return $stmt->rowCount(); + } catch (\Exception $e) { + error_log("Failed to cleanup performance metrics: " . $e->getMessage()); + return 0; + } + } + + /** + * Réinitialiser le monitoring + */ + private static function reset(): void { + self::$requestStartTime = null; + self::$requestStartMemory = null; + self::$dbQueries = []; + self::$dbTotalTime = 0; + self::$dbQueryCount = 0; + } + + /** + * Obtenir l'instance de base de données + */ + private static function getDb(): PDO { + if (self::$db === null) { + self::$db = Database::getInstance(); + } + return self::$db; + } +} \ No newline at end of file diff --git a/api/src/Services/Security/SecurityMonitor.php b/api/src/Services/Security/SecurityMonitor.php new file mode 100644 index 00000000..a59dd2e3 --- /dev/null +++ b/api/src/Services/Security/SecurityMonitor.php @@ -0,0 +1,440 @@ +prepare(' + SELECT COUNT(*) as attempts, + COUNT(DISTINCT username) as unique_users, + MIN(attempt_time) as first_attempt, + MAX(attempt_time) as last_attempt + FROM sec_failed_login_attempts + WHERE ip_address = :ip + AND attempt_time >= DATE_SUB(NOW(), INTERVAL 5 MINUTE) + '); + + $stmt->execute(['ip' => $ip]); + $result = $stmt->fetch(PDO::FETCH_ASSOC); + + $attempts = (int)$result['attempts']; + $uniqueUsers = (int)$result['unique_users']; + + // Critères de détection + $isBruteForce = false; + $reason = ''; + + if ($attempts >= 5) { + $isBruteForce = true; + $reason = "Plus de 5 tentatives en 5 minutes"; + } elseif ($uniqueUsers >= 3) { + $isBruteForce = true; + $reason = "Tentatives sur 3 usernames différents"; + } + + if ($isBruteForce) { + // Déclencher l'alerte + AlertService::trigger('BRUTE_FORCE', [ + 'ip' => $ip, + 'username' => $username, + 'attempts' => $attempts, + 'unique_usernames' => $uniqueUsers, + 'timeframe' => '5 minutes', + 'reason' => $reason, + 'message' => "Attaque brute force détectée depuis $ip : $reason" + ], 'SECURITY'); + + return false; // Bloquer + } + + return true; // Autoriser + } + + /** + * Enregistrer une tentative de login échouée + */ + public static function recordFailedLogin( + string $ip, + string $username = null, + string $errorType = 'invalid_credentials', + string $userAgent = null + ): void { + $db = self::getDb(); + + try { + // Chercher si le username existe (pour stocker la version chiffrée) + $encryptedUsername = null; + if ($username) { + $userStmt = $db->prepare(' + SELECT encrypted_user_name + FROM users + WHERE username = :username + LIMIT 1 + '); + $userStmt->execute(['username' => $username]); + $user = $userStmt->fetch(PDO::FETCH_ASSOC); + if ($user) { + $encryptedUsername = $user['encrypted_user_name']; + } + } + + // Enregistrer la tentative + $stmt = $db->prepare(' + INSERT INTO sec_failed_login_attempts ( + username, encrypted_username, ip_address, + user_agent, error_type + ) VALUES ( + :username, :encrypted, :ip, :agent, :error + ) + '); + + $stmt->execute([ + 'username' => $username, + 'encrypted' => $encryptedUsername, + 'ip' => $ip, + 'agent' => $userAgent, + 'error' => $errorType + ]); + + // Vérifier si on doit bloquer l'IP + if (!self::checkBruteForce($ip, $username)) { + // L'IP sera bloquée par AlertService via les actions automatiques + } + + } catch (\Exception $e) { + error_log("Failed to record login attempt: " . $e->getMessage()); + } + } + + /** + * Vérifier les patterns d'injection SQL + */ + public static function checkSQLInjection(string $value): bool { + // Nettoyer la valeur pour l'analyse + $decoded = urldecode($value); + + foreach (self::SQL_INJECTION_PATTERNS as $pattern) { + if (preg_match($pattern, $decoded)) { + // Injection SQL détectée ! + $context = [ + 'pattern_matched' => $pattern, + 'value' => substr($value, 0, 500), // Limiter la taille + 'decoded_value' => substr($decoded, 0, 500), + 'message' => 'Tentative d\'injection SQL détectée' + ]; + + // Enrichir avec le contexte de requête + if (isset($_SERVER['REQUEST_URI'])) { + $context['endpoint'] = $_SERVER['REQUEST_URI']; + $context['method'] = $_SERVER['REQUEST_METHOD'] ?? 'unknown'; + $context['ip'] = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; + } + + AlertService::trigger('SQL_INJECTION', $context, 'SECURITY'); + + return false; // Bloquer la requête + } + } + + return true; // Pas d'injection détectée + } + + /** + * Vérifier tous les paramètres d'une requête + */ + public static function checkRequestParameters(array $params): bool { + $suspicious = []; + + foreach ($params as $key => $value) { + if (is_string($value)) { + if (!self::checkSQLInjection($value)) { + $suspicious[$key] = $value; + } + } elseif (is_array($value)) { + // Vérifier récursivement + if (!self::checkRequestParameters($value)) { + $suspicious[$key] = $value; + } + } + } + + if (!empty($suspicious)) { + // Paramètres suspects détectés + return false; + } + + return true; + } + + /** + * Détecter les patterns de scan/exploration + */ + public static function checkScanPattern(string $uri): bool { + $lowerUri = strtolower($uri); + + foreach (self::SCAN_PATTERNS as $pattern) { + if (strpos($lowerUri, $pattern) !== false) { + // Pattern de scan détecté + AlertService::trigger('SUSPICIOUS_PATTERN', [ + 'pattern' => $pattern, + 'uri' => $uri, + 'message' => "Tentative d'accès à un fichier sensible : $pattern" + ], 'WARNING'); + + return false; + } + } + + return true; + } + + /** + * Vérifier les accès non autorisés + */ + public static function checkUnauthorizedAccess(string $endpoint, bool $hasAuth): bool { + // Endpoints publics qui n'ont pas besoin d'auth + $publicEndpoints = [ + '/api/login', + '/api/users/check-username', + '/api/logs', + '/api/health', + '/api/status' + ]; + + // Vérifier si l'endpoint nécessite une auth + $isPublic = false; + foreach ($publicEndpoints as $public) { + if (strpos($endpoint, $public) === 0) { + $isPublic = true; + break; + } + } + + if (!$isPublic && !$hasAuth) { + // Accès non autorisé + AlertService::trigger('UNAUTHORIZED_ACCESS', [ + 'endpoint' => $endpoint, + 'message' => "Tentative d'accès sans authentification à : $endpoint" + ], 'WARNING'); + + return false; + } + + return true; + } + + /** + * Analyser les patterns 404 + */ + public static function check404Pattern(string $ip): void { + $db = self::getDb(); + + // Compter les 404 récents de cette IP + $stmt = $db->prepare(' + SELECT COUNT(*) as count, + COUNT(DISTINCT endpoint) as unique_endpoints + FROM sec_performance_metrics + WHERE ip_address = :ip + AND http_status = 404 + AND created_at >= DATE_SUB(NOW(), INTERVAL 10 MINUTE) + '); + + $stmt->execute(['ip' => $ip]); + $result = $stmt->fetch(PDO::FETCH_ASSOC); + + $count404 = (int)$result['count']; + $uniqueEndpoints = (int)$result['unique_endpoints']; + + // Si trop de 404, c'est suspect + if ($count404 >= 10 || $uniqueEndpoints >= 5) { + AlertService::trigger('HTTP_404_PATTERN', [ + 'ip' => $ip, + 'count_404' => $count404, + 'unique_endpoints' => $uniqueEndpoints, + 'timeframe' => '10 minutes', + 'message' => "Pattern de scan détecté : $count404 erreurs 404 sur $uniqueEndpoints endpoints" + ], 'WARNING'); + } + } + + /** + * Vérifier la vitesse des requêtes (rate limiting) + */ + public static function checkRateLimit(string $ip): bool { + $db = self::getDb(); + + // Compter les requêtes de la dernière minute + $stmt = $db->prepare(' + SELECT COUNT(*) as count + FROM sec_performance_metrics + WHERE ip_address = :ip + AND created_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) + '); + + $stmt->execute(['ip' => $ip]); + $result = $stmt->fetch(PDO::FETCH_ASSOC); + + $requestsPerMinute = (int)$result['count']; + + // Limite : 60 requêtes par minute + if ($requestsPerMinute > 60) { + AlertService::trigger('RATE_LIMIT_EXCEEDED', [ + 'ip' => $ip, + 'requests_per_minute' => $requestsPerMinute, + 'limit' => 60, + 'message' => "Limite de taux dépassée : $requestsPerMinute requêtes/minute" + ], 'WARNING'); + + return false; + } + + return true; + } + + /** + * Analyser une erreur SQL pour détecter les problèmes + */ + public static function analyzeSQLError(\PDOException $e, string $query = null): void { + $message = $e->getMessage(); + $code = $e->getCode(); + + // Classifier l'erreur + $level = 'ERROR'; + $type = 'SQL_ERROR'; + + if (strpos($message, 'Unknown column') !== false) { + $type = 'SQL_ERROR'; + $level = 'ERROR'; + } elseif (strpos($message, 'Table') !== false && strpos($message, 'doesn\'t exist') !== false) { + $type = 'SQL_ERROR'; + $level = 'CRITICAL'; + } elseif (strpos($message, 'Connection refused') !== false || strpos($message, 'Can\'t connect') !== false) { + $type = 'DB_CONNECTION'; + $level = 'CRITICAL'; + } elseif (strpos($message, 'Too many connections') !== false) { + $type = 'DB_CONNECTION'; + $level = 'CRITICAL'; + } elseif (strpos($message, 'Deadlock') !== false) { + $type = 'DB_DEADLOCK'; + $level = 'ERROR'; + } + + // Créer l'alerte + AlertService::trigger($type, [ + 'sql_error' => $message, + 'sql_code' => $code, + 'query' => $query ? substr($query, 0, 500) : null, + 'message' => "Erreur SQL : $message" + ], $level); + } + + /** + * Obtenir les statistiques de sécurité + */ + public static function getSecurityStats(): array { + $db = self::getDb(); + + // Statistiques des dernières 24h + $stats = []; + + // Tentatives de login + $loginStmt = $db->prepare(' + SELECT + COUNT(*) as total_attempts, + COUNT(DISTINCT ip_address) as unique_ips, + COUNT(DISTINCT username) as unique_usernames + FROM sec_failed_login_attempts + WHERE attempt_time >= DATE_SUB(NOW(), INTERVAL 24 HOUR) + '); + $loginStmt->execute(); + $stats['failed_logins'] = $loginStmt->fetch(PDO::FETCH_ASSOC); + + // IPs bloquées + $blockedStmt = $db->prepare(' + SELECT COUNT(*) as total, + SUM(CASE WHEN permanent = 1 THEN 1 ELSE 0 END) as permanent, + SUM(CASE WHEN blocked_until > NOW() THEN 1 ELSE 0 END) as active + FROM sec_blocked_ips + '); + $blockedStmt->execute(); + $stats['blocked_ips'] = $blockedStmt->fetch(PDO::FETCH_ASSOC); + + // Alertes par type + $alertsStmt = $db->prepare(' + SELECT + alert_type, + alert_level, + COUNT(*) as count, + MAX(last_seen) as last_seen + FROM sec_alerts + WHERE last_seen >= DATE_SUB(NOW(), INTERVAL 24 HOUR) + GROUP BY alert_type, alert_level + ORDER BY count DESC + '); + $alertsStmt->execute(); + $stats['alerts_by_type'] = $alertsStmt->fetchAll(PDO::FETCH_ASSOC); + + return $stats; + } + + /** + * Obtenir l'instance de base de données + */ + private static function getDb(): PDO { + if (self::$db === null) { + self::$db = Database::getInstance(); + } + return self::$db; + } +} \ No newline at end of file