- 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>
440 lines
14 KiB
PHP
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;
|
|
}
|
|
} |