Release v3.1.4 - Mode terrain et génération PDF #10
553
api/src/Services/Security/AlertService.php
Normal file
553
api/src/Services/Security/AlertService.php
Normal file
@@ -0,0 +1,553 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Security;
|
||||
|
||||
require_once __DIR__ . '/../../Services/ApiService.php';
|
||||
require_once __DIR__ . '/EmailThrottler.php';
|
||||
|
||||
use PDO;
|
||||
use Database;
|
||||
use ApiService;
|
||||
use AppConfig;
|
||||
|
||||
/**
|
||||
* Service central de gestion des alertes de sécurité et monitoring
|
||||
*/
|
||||
class AlertService {
|
||||
|
||||
// Niveaux d'alerte avec leur configuration
|
||||
const ALERT_LEVELS = [
|
||||
'INFO' => ['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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
294
api/src/Services/Security/EmailThrottler.php
Normal file
294
api/src/Services/Security/EmailThrottler.php
Normal file
@@ -0,0 +1,294 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Security;
|
||||
|
||||
use PDO;
|
||||
use Database;
|
||||
|
||||
/**
|
||||
* Service de throttling pour les emails d'alerte
|
||||
* Évite le spam et groupe les alertes similaires
|
||||
*/
|
||||
class EmailThrottler {
|
||||
|
||||
private static ?PDO $db = null;
|
||||
private static array $cache = [];
|
||||
|
||||
// Configuration par défaut
|
||||
const DEFAULT_CONFIG = [
|
||||
'max_per_hour' => 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;
|
||||
}
|
||||
}
|
||||
510
api/src/Services/Security/IPBlocker.php
Normal file
510
api/src/Services/Security/IPBlocker.php
Normal file
@@ -0,0 +1,510 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Security;
|
||||
|
||||
require_once __DIR__ . '/AlertService.php';
|
||||
|
||||
use PDO;
|
||||
use Database;
|
||||
|
||||
/**
|
||||
* Service de gestion des blocages d'IP
|
||||
* Bloque temporairement ou définitivement des adresses IP suspectes
|
||||
*/
|
||||
class IPBlocker {
|
||||
|
||||
private static ?PDO $db = null;
|
||||
private static array $cache = [];
|
||||
private static ?int $lastCacheClean = null;
|
||||
|
||||
// IPs en whitelist (jamais bloquées)
|
||||
const WHITELIST = [
|
||||
'127.0.0.1',
|
||||
'::1',
|
||||
'localhost'
|
||||
];
|
||||
|
||||
/**
|
||||
* Vérifier si une IP est bloquée
|
||||
*/
|
||||
public static function isBlocked(string $ip): bool {
|
||||
// Vérifier la whitelist
|
||||
if (in_array($ip, self::WHITELIST)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Vérifier le cache en mémoire
|
||||
if (isset(self::$cache[$ip])) {
|
||||
$cached = self::$cache[$ip];
|
||||
if ($cached['blocked_until'] > 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;
|
||||
}
|
||||
}
|
||||
452
api/src/Services/Security/PerformanceMonitor.php
Normal file
452
api/src/Services/Security/PerformanceMonitor.php
Normal file
@@ -0,0 +1,452 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Security;
|
||||
|
||||
require_once __DIR__ . '/AlertService.php';
|
||||
|
||||
use PDO;
|
||||
use Database;
|
||||
|
||||
/**
|
||||
* Service de monitoring des performances
|
||||
* Mesure et alerte sur les temps de réponse, utilisation mémoire, etc.
|
||||
*/
|
||||
class PerformanceMonitor {
|
||||
|
||||
private static ?PDO $db = null;
|
||||
private static ?float $requestStartTime = null;
|
||||
private static ?float $requestStartMemory = null;
|
||||
private static array $dbQueries = [];
|
||||
private static float $dbTotalTime = 0;
|
||||
private static int $dbQueryCount = 0;
|
||||
|
||||
// Seuils par défaut (en millisecondes)
|
||||
const DEFAULT_THRESHOLDS = [
|
||||
'response_time_warning' => 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;
|
||||
}
|
||||
}
|
||||
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