Files
geo/api/src/Services/Security/SecurityMonitor.php
Pierre c1f23c4345 fix: Correction AppConfig::get() inexistante dans EmailThrottler et ajout des services Security manquants
- Suppression de l'appel à AppConfig::get() qui n'existe pas
- Utilisation de la configuration par défaut dans EmailThrottler
- Ajout de tous les services Security au repository (non trackés auparavant)

🤖 Generated with Claude Code (https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-19 13:14:33 +02:00

440 lines
14 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Security;
require_once __DIR__ . '/AlertService.php';
require_once __DIR__ . '/IPBlocker.php';
use PDO;
use Database;
/**
* Service de monitoring de sécurité
* Détecte les patterns d'attaque et les comportements suspects
*/
class SecurityMonitor {
private static ?PDO $db = null;
// Patterns suspects dans les paramètres
const SQL_INJECTION_PATTERNS = [
'/\bunion\b.*\bselect\b/i',
'/\bselect\b.*\bfrom\b.*\bwhere\b/i',
'/\bdrop\b.*\btable\b/i',
'/\binsert\b.*\binto\b/i',
'/\bupdate\b.*\bset\b/i',
'/\bdelete\b.*\bfrom\b/i',
'/\bexec(\s|\()/i',
'/\bscript\b.*\b\/script\b/i',
'/\b(char|nchar|varchar|nvarchar)\s*\(/i',
'/\bconvert\s*\(/i',
'/\bcast\s*\(/i',
'/\bwaitfor\s+delay\b/i',
'/\bsleep\s*\(/i',
'/\bbenchmark\s*\(/i',
'/\;.*\-\-/i',
'/\bor\b.*\=.*\bor\b/i',
'/\b1\s*\=\s*1\b/i',
'/\b\'\s*or\s*\'\s*\=\s*\'/i'
];
// Patterns de scan/exploration
const SCAN_PATTERNS = [
'admin', 'administrator', 'wp-admin', 'phpmyadmin',
'.git', '.env', 'config.php', 'wp-config.php',
'backup', '.sql', '.bak', '.zip', '.tar',
'shell.php', 'c99.php', 'r57.php', 'eval.php'
];
/**
* Vérifier les tentatives de brute force
*/
public static function checkBruteForce(string $ip, string $username = null): bool {
$db = self::getDb();
// Compter les tentatives récentes depuis cette IP
$stmt = $db->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;
}
}