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:
2025-08-19 13:14:33 +02:00
parent 5e255ebf5e
commit c1f23c4345
5 changed files with 2249 additions and 0 deletions

View 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 [];
}
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}