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>
This commit is contained in:
440
api/src/Services/Security/SecurityMonitor.php
Normal file
440
api/src/Services/Security/SecurityMonitor.php
Normal file
@@ -0,0 +1,440 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user