Enable Discovery debug logging for production troubleshooting

- Add DISCOVERY_LOG_LEVEL=debug
- Add DISCOVERY_SHOW_PROGRESS=true
- Temporary changes for debugging InitializerProcessor fixes on production
This commit is contained in:
2025-08-11 20:13:26 +02:00
parent 59fd3dd3b1
commit 55a330b223
3683 changed files with 2956207 additions and 16948 deletions

View File

@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
namespace App\Framework\Exception\Authentication;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\ExceptionContext;
use App\Framework\Exception\FrameworkException;
/**
* Ausnahme für gesperrte Benutzerkonten
*
* Verwendet OWASP-konforme Nachrichten für Account-Sperrungen
*/
final class AccountLockedException extends FrameworkException
{
/**
* @param string $identifier Benutzer-Identifikator
* @param int $failedAttempts Anzahl der Fehlversuche die zur Sperrung führten
* @param int $lockDurationMinutes Dauer der Sperrung in Minuten
* @param \Throwable|null $previous Vorherige Ausnahme
*/
public function __construct(
public readonly string $identifier,
public readonly int $failedAttempts,
public readonly int $lockDurationMinutes = 15,
?\Throwable $previous = null
) {
// OWASP-konforme Nachricht mit Platzhaltern
$message = "User {$this->identifier} account locked after {$this->failedAttempts} failed attempts";
$unlockTime = new \DateTimeImmutable("+{$this->lockDurationMinutes} minutes");
$context = ExceptionContext::forOperation('authentication.account_lock', 'Auth')
->withData([
'user_identifier' => $this->identifier,
'failed_attempts' => $this->failedAttempts,
'lock_duration_minutes' => $this->lockDurationMinutes,
'unlock_time' => $unlockTime->format('Y-m-d H:i:s'),
'event_identifier' => "authn_account_locked:{$this->identifier},{$this->failedAttempts}",
'category' => 'authentication',
'requires_alert' => true, // Account-Sperrungen sind immer alert-würdig
])
->withMetadata([
'security_event' => true,
'owasp_compliant' => true,
'log_level' => 'WARN',
'critical_security_event' => true,
]);
parent::__construct(
message: $message,
context: $context,
code: 423, // Locked
previous: $previous,
errorCode: ErrorCode::AUTH_ACCOUNT_LOCKED,
retryAfter: $this->lockDurationMinutes * 60 // Retry nach Sperrzeit in Sekunden
);
}
// === Factory Methods für verschiedene Sperr-Szenarien ===
public static function tooManyFailedAttempts(string $identifier, int $attempts = 5): self
{
return new self($identifier, $attempts, 15); // 15 Minuten Standard-Sperrzeit
}
public static function suspiciousActivity(string $identifier, int $lockDurationMinutes = 60): self
{
return new self($identifier, 0, $lockDurationMinutes);
}
public static function administrativeLock(string $identifier): self
{
return new self($identifier, 0, 0); // 0 = permanente Sperrung
}
public static function bruteForceDetected(string $identifier, int $attempts): self
{
return new self($identifier, $attempts, 120); // 2 Stunden bei Brute Force
}
/**
* Gibt OWASP-konforme Event-Daten zurück
*/
public function getSecurityEventData(): array
{
return [
'event_identifier' => "authn_account_locked:{$this->identifier},{$this->failedAttempts}",
'description' => "User {$this->identifier} account locked after {$this->failedAttempts} failed attempts",
'category' => 'authentication',
'log_level' => 'WARN',
'requires_alert' => true,
'user_identifier' => $this->identifier,
'failed_attempts' => $this->failedAttempts,
'lock_duration_minutes' => $this->lockDurationMinutes,
];
}
/**
* Prüft ob die Sperrung permanent ist
*/
public function isPermanentLock(): bool
{
return $this->lockDurationMinutes === 0;
}
/**
* Berechnet die Entsperrzeit
*/
public function getUnlockTime(): ?\DateTimeImmutable
{
if ($this->isPermanentLock()) {
return null; // Permanente Sperrung
}
return new \DateTimeImmutable("+{$this->lockDurationMinutes} minutes");
}
/**
* Gibt verbleibende Sperrzeit in Sekunden zurück
*/
public function getRemainingLockTimeSeconds(): ?int
{
$unlockTime = $this->getUnlockTime();
if ($unlockTime === null) {
return null; // Permanente Sperrung
}
$now = new \DateTimeImmutable();
$diff = $unlockTime->getTimestamp() - $now->getTimestamp();
return max(0, $diff);
}
/**
* Account-Sperrungen erfordern immer Alerts
*/
public function requiresAlert(): bool
{
return true;
}
}

View File

@@ -0,0 +1,200 @@
<?php
declare(strict_types=1);
namespace App\Framework\Exception\Authentication;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\ExceptionContext;
use App\Framework\Exception\FrameworkException;
/**
* Ausnahme für unzureichende Berechtigungen
*
* Verwendet OWASP-konforme Nachrichten für Authorization-Fehler
*/
final class InsufficientPrivilegesException extends FrameworkException
{
/**
* @param string $userId Benutzer-Identifikator
* @param string $resource Geschützte Ressource
* @param string $action Versuchte Aktion
* @param array $requiredRoles Erforderliche Rollen/Berechtigungen
* @param array $userRoles Aktuelle Benutzer-Rollen
* @param \Throwable|null $previous Vorherige Ausnahme
*/
public function __construct(
public readonly string $userId,
public readonly string $resource,
public readonly string $action,
public readonly array $requiredRoles = [],
public readonly array $userRoles = [],
?\Throwable $previous = null
) {
// OWASP-konforme Nachricht mit Platzhaltern
$message = "User {$this->userId} access denied to {$this->resource}";
$context = ExceptionContext::forOperation('authorization.access_check', 'Auth')
->withData([
'user_identifier' => $this->userId,
'resource' => $this->resource,
'action' => $this->action,
'required_roles' => $this->requiredRoles,
'user_roles' => $this->userRoles,
'missing_roles' => $this->getMissingRoles(),
'event_identifier' => "authz_access_denied:{$this->userId},{$this->resource}",
'category' => 'authorization',
'requires_alert' => $this->requiresAlert(),
])
->withMetadata([
'security_event' => true,
'owasp_compliant' => true,
'log_level' => 'WARN',
'authorization_failure' => true,
]);
parent::__construct(
message: $message,
context: $context,
code: 403, // Forbidden
previous: $previous,
errorCode: ErrorCode::AUTH_INSUFFICIENT_PRIVILEGES
);
}
// === Factory Methods für verschiedene Authorization-Szenarien ===
public static function resourceAccess(string $userId, string $resource, array $requiredRoles = []): self
{
return new self($userId, $resource, 'access', $requiredRoles);
}
public static function adminAction(string $userId, string $action): self
{
return new self($userId, 'admin_panel', $action, ['admin', 'super_admin']);
}
public static function apiEndpoint(string $userId, string $endpoint, array $requiredScopes = []): self
{
return new self($userId, $endpoint, 'api_call', $requiredScopes);
}
public static function fileAccess(string $userId, string $filePath, string $action = 'read'): self
{
return new self($userId, $filePath, $action, ['file_access']);
}
public static function dataAccess(string $userId, string $dataType, string $action = 'read'): self
{
return new self($userId, $dataType, $action, ['data_access']);
}
public static function privilegeEscalation(string $userId, string $targetRole): self
{
return new self($userId, $targetRole, 'privilege_escalation', ['admin']);
}
/**
* Gibt OWASP-konforme Event-Daten zurück
*/
public function getSecurityEventData(): array
{
return [
'event_identifier' => "authz_access_denied:{$this->userId},{$this->resource}",
'description' => "User {$this->userId} access denied to {$this->resource}",
'category' => 'authorization',
'log_level' => 'WARN',
'requires_alert' => $this->requiresAlert(),
'user_identifier' => $this->userId,
'resource' => $this->resource,
'action' => $this->action,
'required_roles' => $this->requiredRoles,
'user_roles' => $this->userRoles,
'missing_roles' => $this->getMissingRoles(),
];
}
/**
* Berechnet fehlende Rollen/Berechtigungen
*/
public function getMissingRoles(): array
{
return array_diff($this->requiredRoles, $this->userRoles);
}
/**
* Prüft ob Benutzer alle erforderlichen Rollen hat
*/
public function hasRequiredRoles(): bool
{
return empty($this->getMissingRoles());
}
/**
* Prüft ob der Access-Denial verdächtig ist und ein Alert erfordert
*/
public function requiresAlert(): bool
{
// Alert bei Admin-Aktionen oder Privilege-Escalation-Versuchen
if ($this->action === 'privilege_escalation') {
return true;
}
if (str_contains($this->resource, 'admin') || str_contains($this->action, 'admin')) {
return true;
}
// Alert bei sensiblen Ressourcen
$sensitiveResources = ['user_data', 'financial_data', 'system_config', 'security_settings'];
foreach ($sensitiveResources as $sensitive) {
if (str_contains($this->resource, $sensitive)) {
return true;
}
}
return false;
}
/**
* Gibt benutzerfreundliche Fehlermeldung zurück
*/
public function getUserMessage(): string
{
if (empty($this->requiredRoles)) {
return "You don't have permission to access this resource.";
}
$missingRoles = $this->getMissingRoles();
if (count($missingRoles) === 1) {
return "You need the '{$missingRoles[0]}' role to access this resource.";
} elseif (count($missingRoles) > 1) {
$roleList = implode(', ', $missingRoles);
return "You need one of these roles to access this resource: {$roleList}";
}
return "Access denied. Please contact your administrator for the required permissions.";
}
/**
* Gibt Empfehlung für die nächsten Schritte zurück
*/
public function getRecommendation(): string
{
if ($this->requiresAlert()) {
return "Contact your system administrator for access to this resource.";
}
return "Please request the necessary permissions from your team lead or administrator.";
}
/**
* Prüft ob es ein potentieller Privilege-Escalation-Versuch ist
*/
public function isPrivilegeEscalationAttempt(): bool
{
return $this->action === 'privilege_escalation' ||
str_contains($this->action, 'escalat') ||
(in_array('admin', $this->requiredRoles) && ! in_array('admin', $this->userRoles));
}
}

View File

@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace App\Framework\Exception\Authentication;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\ExceptionContext;
use App\Framework\Exception\FrameworkException;
/**
* Ausnahme für ungültige Anmeldedaten
*
* Verwendet OWASP-konforme Nachrichten für Authentifizierungsfehler
*/
final class InvalidCredentialsException extends FrameworkException
{
/**
* @param string $identifier Benutzer-Identifikator (E-Mail, Username, etc.)
* @param string $reason Grund für den Fehler (invalid_credentials, password_expired, etc.)
* @param int $attemptCount Anzahl der Fehlversuche
* @param \Throwable|null $previous Vorherige Ausnahme
*/
public function __construct(
public readonly string $identifier,
public readonly string $reason = 'invalid_credentials',
public readonly int $attemptCount = 1,
?\Throwable $previous = null
) {
// OWASP-konforme Nachricht mit Platzhaltern
$message = "User {$this->identifier} authentication failure";
$context = ExceptionContext::forOperation('authentication.login', 'Auth')
->withData([
'user_identifier' => $this->identifier,
'failure_reason' => $this->reason,
'attempt_count' => $this->attemptCount,
'event_identifier' => "authn_login_fail:{$this->identifier}",
'category' => 'authentication',
'requires_alert' => $this->attemptCount >= 3, // Alert ab 3 Versuchen
])
->withMetadata([
'security_event' => true,
'owasp_compliant' => true,
'log_level' => 'WARN',
]);
parent::__construct(
message: $message,
context: $context,
code: 401, // Unauthorized
previous: $previous,
errorCode: ErrorCode::AUTH_CREDENTIALS_INVALID
);
}
// === Factory Methods für verschiedene Authentifizierungsfehler ===
public static function invalidPassword(string $identifier, int $attemptCount = 1): self
{
return new self($identifier, 'invalid_password', $attemptCount);
}
public static function invalidUsername(string $identifier, int $attemptCount = 1): self
{
return new self($identifier, 'invalid_username', $attemptCount);
}
public static function passwordExpired(string $identifier): self
{
return new self($identifier, 'password_expired', 1);
}
public static function twoFactorRequired(string $identifier): self
{
return new self($identifier, 'two_factor_required', 1);
}
public static function accountNotVerified(string $identifier): self
{
return new self($identifier, 'account_not_verified', 1);
}
/**
* Gibt OWASP-konforme Event-Daten zurück
*/
public function getSecurityEventData(): array
{
return [
'event_identifier' => "authn_login_fail:{$this->identifier}",
'description' => "User {$this->identifier} login failure",
'category' => 'authentication',
'log_level' => 'WARN',
'requires_alert' => $this->attemptCount >= 3,
'user_identifier' => $this->identifier,
'failure_reason' => $this->reason,
'attempt_count' => $this->attemptCount,
];
}
/**
* Prüft ob Alert erforderlich ist (basierend auf Anzahl Versuche)
*/
public function requiresAlert(): bool
{
return $this->attemptCount >= 3;
}
/**
* Gibt sicheren Identifier für Logs zurück (ohne sensible Daten)
*/
public function getSafeIdentifier(): string
{
// Wenn E-Mail, zeige nur ersten Teil
if (str_contains($this->identifier, '@')) {
$parts = explode('@', $this->identifier);
return substr($parts[0], 0, 2) . '***@' . $parts[1];
}
// Bei anderen Identifiern zeige nur ersten Teil
return substr($this->identifier, 0, 3) . '***';
}
}

View File

@@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
namespace App\Framework\Exception\Authentication;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\ExceptionContext;
use App\Framework\Exception\FrameworkException;
/**
* Ausnahme für Session-Timeouts
*
* Verwendet OWASP-konforme Nachrichten für Session-Management
*/
final class SessionTimeoutException extends FrameworkException
{
/**
* @param string $sessionId Session-Identifikator
* @param string $userId Benutzer-Identifikator
* @param int $timeoutMinutes Timeout-Dauer in Minuten
* @param string $reason Grund für Timeout (inactivity, absolute, etc.)
* @param \Throwable|null $previous Vorherige Ausnahme
*/
public function __construct(
public readonly string $sessionId,
public readonly string $userId,
public readonly int $timeoutMinutes,
public readonly string $reason = 'inactivity',
?\Throwable $previous = null
) {
// OWASP-konforme Nachricht mit Platzhaltern
$message = "User {$this->userId} session timeout after {$this->timeoutMinutes} minutes of {$this->reason}";
$context = ExceptionContext::forOperation('authentication.session_timeout', 'Auth')
->withData([
'session_id' => $this->sessionId,
'user_identifier' => $this->userId,
'timeout_minutes' => $this->timeoutMinutes,
'timeout_reason' => $this->reason,
'event_identifier' => "session_timeout:{$this->userId},{$this->reason}",
'category' => 'session_management',
'requires_alert' => false, // Session-Timeouts sind normal
])
->withMetadata([
'security_event' => true,
'owasp_compliant' => true,
'log_level' => 'INFO',
'session_management' => true,
]);
parent::__construct(
message: $message,
context: $context,
code: 401, // Unauthorized
previous: $previous,
errorCode: ErrorCode::AUTH_SESSION_EXPIRED
);
}
// === Factory Methods für verschiedene Timeout-Szenarien ===
public static function inactivityTimeout(string $sessionId, string $userId, int $timeoutMinutes = 30): self
{
return new self($sessionId, $userId, $timeoutMinutes, 'inactivity');
}
public static function absoluteTimeout(string $sessionId, string $userId, int $timeoutMinutes = 480): self
{
return new self($sessionId, $userId, $timeoutMinutes, 'absolute_timeout');
}
public static function concurrentSessionLimit(string $sessionId, string $userId): self
{
return new self($sessionId, $userId, 0, 'concurrent_session_limit');
}
public static function securityPolicyTimeout(string $sessionId, string $userId, int $timeoutMinutes): self
{
return new self($sessionId, $userId, $timeoutMinutes, 'security_policy');
}
public static function administrativeTermination(string $sessionId, string $userId): self
{
return new self($sessionId, $userId, 0, 'administrative_termination');
}
/**
* Gibt OWASP-konforme Event-Daten zurück
*/
public function getSecurityEventData(): array
{
return [
'event_identifier' => "session_timeout:{$this->userId},{$this->reason}",
'description' => "User {$this->userId} session timeout after {$this->timeoutMinutes} minutes of {$this->reason}",
'category' => 'session_management',
'log_level' => 'INFO',
'requires_alert' => $this->requiresAlert(),
'session_id' => $this->getSessionIdHash(), // Gehashte Session-ID für Security
'user_identifier' => $this->userId,
'timeout_minutes' => $this->timeoutMinutes,
'timeout_reason' => $this->reason,
];
}
/**
* Gibt gehashte Session-ID für sicheres Logging zurück
*/
public function getSessionIdHash(): string
{
return substr(hash('sha256', $this->sessionId), 0, 12);
}
/**
* Prüft ob der Timeout verdächtig ist und ein Alert erfordert
*/
public function requiresAlert(): bool
{
return match ($this->reason) {
'concurrent_session_limit' => true,
'security_policy' => true,
'administrative_termination' => true,
default => false,
};
}
/**
* Prüft ob die Session automatisch verlängert werden kann
*/
public function isRenewable(): bool
{
return match ($this->reason) {
'inactivity' => true,
'absolute_timeout' => false,
'concurrent_session_limit' => false,
'security_policy' => false,
'administrative_termination' => false,
default => false,
};
}
/**
* Gibt empfohlene Aktion für den Benutzer zurück
*/
public function getRecommendedAction(): string
{
return match ($this->reason) {
'inactivity' => 'Please log in again to continue',
'absolute_timeout' => 'Session has reached maximum duration. Please log in again',
'concurrent_session_limit' => 'Maximum concurrent sessions reached. Please close other sessions or log in again',
'security_policy' => 'Session terminated due to security policy. Please log in again',
'administrative_termination' => 'Session was terminated by administrator. Please contact support if needed',
default => 'Please log in again to continue',
};
}
/**
* Gibt benutzerfreundliche Timeout-Nachricht zurück
*/
public function getUserMessage(): string
{
if ($this->timeoutMinutes === 0) {
return $this->getRecommendedAction();
}
if ($this->timeoutMinutes < 60) {
return "Your session timed out after {$this->timeoutMinutes} minutes of inactivity. " . $this->getRecommendedAction();
} else {
$hours = floor($this->timeoutMinutes / 60);
$minutes = $this->timeoutMinutes % 60;
$timeString = $hours . 'h' . ($minutes > 0 ? " {$minutes}m" : '');
return "Your session timed out after {$timeString}. " . $this->getRecommendedAction();
}
}
}

View File

@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace App\Framework\Exception\Authentication;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\ExceptionContext;
use App\Framework\Exception\FrameworkException;
/**
* Ausnahme für abgelaufene Authentication-Token
*
* Verwendet OWASP-konforme Nachrichten für Token-Expiration
*/
final class TokenExpiredException extends FrameworkException
{
/**
* @param string $tokenType Art des Tokens (session, jwt, api_key, etc.)
* @param string $identifier Token-Identifikator oder Benutzer-ID
* @param \DateTimeImmutable $expiredAt Zeitpunkt der Ablaufzeit
* @param \Throwable|null $previous Vorherige Ausnahme
*/
public function __construct(
public readonly string $tokenType,
public readonly string $identifier,
public readonly \DateTimeImmutable $expiredAt,
?\Throwable $previous = null
) {
// OWASP-konforme Nachricht mit Platzhaltern
$message = "User {$this->identifier} {$this->tokenType} token expired";
$context = ExceptionContext::forOperation('authentication.token_validation', 'Auth')
->withData([
'token_type' => $this->tokenType,
'user_identifier' => $this->identifier,
'expired_at' => $this->expiredAt->format('Y-m-d H:i:s'),
'expired_since_seconds' => $this->getExpiredSinceSeconds(),
'event_identifier' => "authn_token_expired:{$this->identifier},{$this->tokenType}",
'category' => 'authentication',
'requires_alert' => false, // Token-Ablauf ist normal
])
->withMetadata([
'security_event' => true,
'owasp_compliant' => true,
'log_level' => 'INFO',
'token_expired' => true,
]);
parent::__construct(
message: $message,
context: $context,
code: 401, // Unauthorized
previous: $previous,
errorCode: ErrorCode::AUTH_TOKEN_EXPIRED
);
}
// === Factory Methods für verschiedene Token-Typen ===
public static function sessionToken(string $sessionId, \DateTimeImmutable $expiredAt): self
{
return new self('session', $sessionId, $expiredAt);
}
public static function jwtToken(string $userId, \DateTimeImmutable $expiredAt): self
{
return new self('jwt', $userId, $expiredAt);
}
public static function apiKey(string $keyId, \DateTimeImmutable $expiredAt): self
{
return new self('api_key', $keyId, $expiredAt);
}
public static function refreshToken(string $userId, \DateTimeImmutable $expiredAt): self
{
return new self('refresh_token', $userId, $expiredAt);
}
public static function passwordResetToken(string $userId, \DateTimeImmutable $expiredAt): self
{
return new self('password_reset', $userId, $expiredAt);
}
public static function twoFactorToken(string $userId, \DateTimeImmutable $expiredAt): self
{
return new self('two_factor', $userId, $expiredAt);
}
/**
* Gibt OWASP-konforme Event-Daten zurück
*/
public function getSecurityEventData(): array
{
return [
'event_identifier' => "authn_token_expired:{$this->identifier},{$this->tokenType}",
'description' => "User {$this->identifier} {$this->tokenType} token expired",
'category' => 'authentication',
'log_level' => 'INFO',
'requires_alert' => false,
'token_type' => $this->tokenType,
'user_identifier' => $this->identifier,
'expired_at' => $this->expiredAt->format('Y-m-d H:i:s'),
'expired_since_seconds' => $this->getExpiredSinceSeconds(),
];
}
/**
* Berechnet wie lange der Token schon abgelaufen ist (in Sekunden)
*/
public function getExpiredSinceSeconds(): int
{
$now = new \DateTimeImmutable();
return max(0, $now->getTimestamp() - $this->expiredAt->getTimestamp());
}
/**
* Prüft ob der Token kürzlich abgelaufen ist (< 5 Minuten)
*/
public function isRecentlyExpired(): bool
{
return $this->getExpiredSinceSeconds() < 300; // 5 Minuten
}
/**
* Prüft ob der Token schon länger abgelaufen ist (> 1 Stunde)
*/
public function isLongExpired(): bool
{
return $this->getExpiredSinceSeconds() > 3600; // 1 Stunde
}
/**
* Gibt eine benutzerfreundliche Nachricht über die Ablaufzeit zurück
*/
public function getExpirationMessage(): string
{
$expiredSince = $this->getExpiredSinceSeconds();
if ($expiredSince < 60) {
return "Token expired {$expiredSince} seconds ago";
} elseif ($expiredSince < 3600) {
$minutes = floor($expiredSince / 60);
return "Token expired {$minutes} minutes ago";
} else {
$hours = floor($expiredSince / 3600);
return "Token expired {$hours} hours ago";
}
}
/**
* Token-Ablauf erfordert normalerweise keine Alerts
*/
public function requiresAlert(): bool
{
return false;
}
}

View File

@@ -13,7 +13,7 @@ final class ConsoleException extends FrameworkException
'command' => $command,
'exit_code' => $exitCode,
'output' => $output,
'working_directory' => getcwd()
'working_directory' => getcwd(),
]);
return new self("Console command failed: {$command}", $exitCode, null, $context);
@@ -23,7 +23,7 @@ final class ConsoleException extends FrameworkException
{
$context = ExceptionContext::forOperation('console.command_lookup', 'ConsoleRunner')
->withData([
'command' => $command
'command' => $command,
]);
return new self("Console command not found: {$command}", 404, null, $context);
@@ -35,7 +35,7 @@ final class ConsoleException extends FrameworkException
->withData([
'command' => $command,
'arguments' => $arguments,
'validation_errors' => $errors
'validation_errors' => $errors,
]);
return new self("Invalid arguments for command: {$command}", 400, null, $context);

View File

@@ -4,58 +4,143 @@ declare(strict_types=1);
namespace App\Framework\Exception;
final class DatabaseException extends FrameworkException
/**
* Database-spezifische Exception mit vorgefertigten Error Codes
*/
class DatabaseException extends FrameworkException
{
public static function connectionFailed(string $dsn, \Throwable $previous): self
{
$context = ExceptionContext::forOperation('database.connect', 'PDO')
// === Factory Methods für häufige Database Errors ===
public static function connectionFailed(
?string $details = null,
?\Throwable $previous = null
): static {
$context = ExceptionContext::forOperation('database.connect', 'Database')
->withData(['details' => $details]);
$message = $details ? "Database connection failed: $details" : null;
return static::create(
ErrorCode::DB_CONNECTION_FAILED,
$message,
$context,
$previous
);
}
public static function queryFailed(
string $sql,
?string $error = null,
?\Throwable $previous = null
): static {
$context = ExceptionContext::forOperation('database.query', 'Database')
->withData(['sql' => $sql, 'error' => $error])
->withDebug(['query_length' => strlen($sql)]);
return static::create(
ErrorCode::DB_QUERY_FAILED,
"Query execution failed: $error",
$context,
$previous
);
}
public static function constraintViolation(
string $constraint,
?string $table = null,
?array $data = null,
?\Throwable $previous = null
): static {
$context = ExceptionContext::forOperation('database.constraint', 'Database')
->withData([
'dsn' => self::sanitizeDsn($dsn),
'driver' => explode(':', $dsn)[0] ?? 'unknown'
'constraint' => $constraint,
'table' => $table,
'violation_data' => $data,
]);
return new self('Database connection failed', 0, $previous, $context);
return static::create(
ErrorCode::DB_CONSTRAINT_VIOLATION,
"Database constraint violation: $constraint" . ($table ? " on table $table" : ''),
$context,
$previous
);
}
public static function queryFailed(string $query, array $params, \Throwable $previous): self
{
$context = ExceptionContext::forOperation('database.query', 'PDO')
public static function transactionFailed(
?string $details = null,
?array $operations = null,
?\Throwable $previous = null
): static {
$context = ExceptionContext::forOperation('database.transaction', 'Database')
->withData(['details' => $details, 'operations' => $operations]);
return static::create(
ErrorCode::DB_TRANSACTION_FAILED,
"Transaction failed: $details",
$context,
$previous
);
}
public static function poolExhausted(
int $maxConnections,
int $currentConnections,
?\Throwable $previous = null
): static {
$context = ExceptionContext::forOperation('database.pool', 'Database')
->withData([
'query' => $query,
'parameters' => $params,
'query_type' => self::detectQueryType($query)
'max_connections' => $maxConnections,
'current_connections' => $currentConnections,
'usage_percentage' => round(($currentConnections / $maxConnections) * 100, 2),
]);
return new self('Database query failed', 0, $previous, $context);
return static::create(
ErrorCode::DB_POOL_EXHAUSTED,
"Connection pool exhausted ($currentConnections/$maxConnections)",
$context,
$previous
);
}
public static function transactionFailed(string $operation, \Throwable $previous): self
{
$context = ExceptionContext::forOperation('database.transaction', 'PDO')
public static function timeout(
float $timeoutSeconds,
string $operation = 'query',
?string $details = null,
?\Throwable $previous = null
): static {
$context = ExceptionContext::forOperation("database.$operation", 'Database')
->withData([
'transaction_operation' => $operation
'timeout_seconds' => $timeoutSeconds,
'operation' => $operation,
'details' => $details,
]);
return new self("Database transaction failed: {$operation}", 0, $previous, $context);
return static::create(
ErrorCode::DB_TIMEOUT,
"Database operation '$operation' timed out after {$timeoutSeconds}s",
$context,
$previous
);
}
private static function sanitizeDsn(string $dsn): string
{
return preg_replace('/password=[^;]+/', 'password=[REDACTED]', $dsn);
}
public static function migrationFailed(
string $migrationName,
?string $error = null,
?string $version = null,
?\Throwable $previous = null
): static {
$context = ExceptionContext::forOperation('database.migration', 'Database')
->withData([
'migration' => $migrationName,
'version' => $version,
'error' => $error,
]);
private static function detectQueryType(string $query): string
{
$query = trim(strtoupper($query));
return match (true) {
str_starts_with($query, 'SELECT') => 'SELECT',
str_starts_with($query, 'INSERT') => 'INSERT',
str_starts_with($query, 'UPDATE') => 'UPDATE',
str_starts_with($query, 'DELETE') => 'DELETE',
str_starts_with($query, 'CREATE') => 'CREATE',
str_starts_with($query, 'DROP') => 'DROP',
str_starts_with($query, 'ALTER') => 'ALTER',
default => 'UNKNOWN'
};
return static::create(
ErrorCode::DB_MIGRATION_FAILED,
"Migration '$migrationName' failed: $error",
$context,
$previous
);
}
}

View File

@@ -17,7 +17,7 @@ final class DirectoryCreateException extends FrameworkException
'permissions' => is_dir(dirname($directory)) ? decoct(fileperms(dirname($directory))) : 'unknown',
'parent_exists' => is_dir(dirname($directory)),
'parent_writable' => is_writable(dirname($directory)),
'disk_free_space' => disk_free_space(dirname($directory))
'disk_free_space' => disk_free_space(dirname($directory)),
]);
parent::__construct(

View File

@@ -17,7 +17,7 @@ final class DirectoryListException extends FrameworkException
'exists' => is_dir($directory),
'readable' => is_readable($directory),
'permissions' => is_dir($directory) ? decoct(fileperms($directory)) : 'unknown',
'file_count' => is_readable($directory) ? count(scandir($directory)) - 2 : 'unknown'
'file_count' => is_readable($directory) ? count(scandir($directory)) - 2 : 'unknown',
]);
parent::__construct(

View File

@@ -0,0 +1,290 @@
<?php
declare(strict_types=1);
namespace App\Framework\Exception;
/**
* Systematische Error Codes für bessere Fehlerklassifizierung
*/
enum ErrorCode: string
{
// System-Level Errors (SYS)
case SYSTEM_CONFIG_MISSING = 'SYS001';
case SYSTEM_CONFIG_INVALID = 'SYS002';
case SYSTEM_DEPENDENCY_MISSING = 'SYS003';
case SYSTEM_RESOURCE_EXHAUSTED = 'SYS004';
case SYSTEM_INITIALIZATION_FAILED = 'SYS005';
// Database Errors (DB)
case DB_CONNECTION_FAILED = 'DB001';
case DB_QUERY_FAILED = 'DB002';
case DB_CONSTRAINT_VIOLATION = 'DB003';
case DB_TRANSACTION_FAILED = 'DB004';
case DB_MIGRATION_FAILED = 'DB005';
case DB_POOL_EXHAUSTED = 'DB006';
case DB_TIMEOUT = 'DB007';
// Authentication Errors (AUTH)
case AUTH_CREDENTIALS_INVALID = 'AUTH001';
case AUTH_TOKEN_EXPIRED = 'AUTH002';
case AUTH_TOKEN_INVALID = 'AUTH003';
case AUTH_USER_LOCKED = 'AUTH004';
case AUTH_SESSION_EXPIRED = 'AUTH005';
case AUTH_INSUFFICIENT_PRIVILEGES = 'AUTH006';
// Validation Errors (VAL)
case VAL_REQUIRED_FIELD_MISSING = 'VAL001';
case VAL_INVALID_FORMAT = 'VAL002';
case VAL_OUT_OF_RANGE = 'VAL003';
case VAL_DUPLICATE_VALUE = 'VAL004';
case VAL_BUSINESS_RULE_VIOLATION = 'VAL005';
// HTTP Errors (HTTP)
case HTTP_NOT_FOUND = 'HTTP001';
case HTTP_METHOD_NOT_ALLOWED = 'HTTP002';
case HTTP_RATE_LIMIT_EXCEEDED = 'HTTP003';
case HTTP_PAYLOAD_TOO_LARGE = 'HTTP004';
case HTTP_UNSUPPORTED_MEDIA_TYPE = 'HTTP005';
// Security Errors (SEC)
case SEC_XSS_ATTEMPT = 'SEC001';
case SEC_SQL_INJECTION_ATTEMPT = 'SEC002';
case SEC_PATH_TRAVERSAL_ATTEMPT = 'SEC003';
case SEC_CSRF_TOKEN_MISMATCH = 'SEC004';
case SEC_UNAUTHORIZED_ACCESS = 'SEC005';
case SEC_SUSPICIOUS_ACTIVITY = 'SEC006';
// Cache Errors (CACHE)
case CACHE_CONNECTION_FAILED = 'CACHE001';
case CACHE_KEY_NOT_FOUND = 'CACHE002';
case CACHE_SERIALIZATION_FAILED = 'CACHE003';
case CACHE_EVICTION_FAILED = 'CACHE004';
// File System Errors (FS)
case FS_FILE_NOT_FOUND = 'FS001';
case FS_PERMISSION_DENIED = 'FS002';
case FS_DISK_FULL = 'FS003';
case FS_DIRECTORY_NOT_WRITABLE = 'FS004';
case FS_UPLOAD_FAILED = 'FS005';
// External API Errors (API)
case API_SERVICE_UNAVAILABLE = 'API001';
case API_RATE_LIMIT_EXCEEDED = 'API002';
case API_AUTHENTICATION_FAILED = 'API003';
case API_INVALID_RESPONSE = 'API004';
case API_TIMEOUT = 'API005';
// Service Errors (SVC)
case SERVICE_CIRCUIT_OPEN = 'SVC001';
case SERVICE_CIRCUIT_HALF_OPEN = 'SVC002';
case SERVICE_HEALTH_CHECK_FAILED = 'SVC003';
case SERVICE_DEGRADED = 'SVC004';
// Business Logic Errors (BIZ)
case BIZ_WORKFLOW_VIOLATION = 'BIZ001';
case BIZ_INSUFFICIENT_BALANCE = 'BIZ002';
case BIZ_OPERATION_NOT_ALLOWED = 'BIZ003';
case BIZ_QUOTA_EXCEEDED = 'BIZ004';
// Search Errors (SEARCH)
case SEARCH_INDEX_FAILED = 'SEARCH001';
case SEARCH_UPDATE_FAILED = 'SEARCH002';
case SEARCH_DELETE_FAILED = 'SEARCH003';
case SEARCH_CONFIG_INVALID = 'SEARCH004';
case SEARCH_ENGINE_UNAVAILABLE = 'SEARCH005';
public function getCategory(): string
{
return substr($this->value, 0, strpos($this->value, '0') ?: 3);
}
public function getNumericCode(): int
{
return (int) substr($this->value, -3);
}
public function getDescription(): string
{
return match($this) {
// System Errors
self::SYSTEM_CONFIG_MISSING => 'Required configuration is missing',
self::SYSTEM_CONFIG_INVALID => 'Configuration contains invalid values',
self::SYSTEM_DEPENDENCY_MISSING => 'Required system dependency is not available',
self::SYSTEM_RESOURCE_EXHAUSTED => 'System resources are exhausted',
self::SYSTEM_INITIALIZATION_FAILED => 'System initialization failed',
// Database Errors
self::DB_CONNECTION_FAILED => 'Database connection could not be established',
self::DB_QUERY_FAILED => 'Database query execution failed',
self::DB_CONSTRAINT_VIOLATION => 'Database constraint violation occurred',
self::DB_TRANSACTION_FAILED => 'Database transaction failed',
self::DB_MIGRATION_FAILED => 'Database migration failed',
self::DB_POOL_EXHAUSTED => 'Database connection pool exhausted',
self::DB_TIMEOUT => 'Database operation timed out',
// Authentication Errors
self::AUTH_CREDENTIALS_INVALID => 'Provided credentials are invalid',
self::AUTH_TOKEN_EXPIRED => 'Authentication token has expired',
self::AUTH_TOKEN_INVALID => 'Authentication token is invalid',
self::AUTH_USER_LOCKED => 'User account is locked',
self::AUTH_SESSION_EXPIRED => 'User session has expired',
self::AUTH_INSUFFICIENT_PRIVILEGES => 'Insufficient privileges for this operation',
// Validation Errors
self::VAL_REQUIRED_FIELD_MISSING => 'Required field is missing',
self::VAL_INVALID_FORMAT => 'Field has invalid format',
self::VAL_OUT_OF_RANGE => 'Value is out of allowed range',
self::VAL_DUPLICATE_VALUE => 'Value already exists',
self::VAL_BUSINESS_RULE_VIOLATION => 'Business rule violation',
// HTTP Errors
self::HTTP_NOT_FOUND => 'Requested resource not found',
self::HTTP_METHOD_NOT_ALLOWED => 'HTTP method not allowed',
self::HTTP_RATE_LIMIT_EXCEEDED => 'Rate limit exceeded',
self::HTTP_PAYLOAD_TOO_LARGE => 'Request payload too large',
self::HTTP_UNSUPPORTED_MEDIA_TYPE => 'Unsupported media type',
// Security Errors
self::SEC_XSS_ATTEMPT => 'Cross-site scripting attempt detected',
self::SEC_SQL_INJECTION_ATTEMPT => 'SQL injection attempt detected',
self::SEC_PATH_TRAVERSAL_ATTEMPT => 'Path traversal attempt detected',
self::SEC_CSRF_TOKEN_MISMATCH => 'CSRF token mismatch',
self::SEC_UNAUTHORIZED_ACCESS => 'Unauthorized access attempt',
self::SEC_SUSPICIOUS_ACTIVITY => 'Suspicious activity detected',
// Cache Errors
self::CACHE_CONNECTION_FAILED => 'Cache connection failed',
self::CACHE_KEY_NOT_FOUND => 'Cache key not found',
self::CACHE_SERIALIZATION_FAILED => 'Cache serialization failed',
self::CACHE_EVICTION_FAILED => 'Cache eviction failed',
// File System Errors
self::FS_FILE_NOT_FOUND => 'File not found',
self::FS_PERMISSION_DENIED => 'File permission denied',
self::FS_DISK_FULL => 'Disk space exhausted',
self::FS_DIRECTORY_NOT_WRITABLE => 'Directory is not writable',
self::FS_UPLOAD_FAILED => 'File upload failed',
// External API Errors
self::API_SERVICE_UNAVAILABLE => 'External service unavailable',
self::API_RATE_LIMIT_EXCEEDED => 'API rate limit exceeded',
self::API_AUTHENTICATION_FAILED => 'API authentication failed',
self::API_INVALID_RESPONSE => 'Invalid API response received',
self::API_TIMEOUT => 'API request timed out',
// Business Logic Errors
self::BIZ_WORKFLOW_VIOLATION => 'Business workflow violation',
self::BIZ_INSUFFICIENT_BALANCE => 'Insufficient balance for operation',
self::BIZ_OPERATION_NOT_ALLOWED => 'Operation not allowed in current state',
self::BIZ_QUOTA_EXCEEDED => 'Usage quota exceeded',
// Service Errors
self::SERVICE_CIRCUIT_OPEN => 'Service circuit breaker is open',
self::SERVICE_CIRCUIT_HALF_OPEN => 'Service circuit breaker is half-open',
self::SERVICE_HEALTH_CHECK_FAILED => 'Service health check failed',
self::SERVICE_DEGRADED => 'Service is in degraded state',
// Search Errors
self::SEARCH_INDEX_FAILED => 'Failed to index document in search engine',
self::SEARCH_UPDATE_FAILED => 'Failed to update document in search engine',
self::SEARCH_DELETE_FAILED => 'Failed to delete document from search engine',
self::SEARCH_CONFIG_INVALID => 'Invalid search configuration',
self::SEARCH_ENGINE_UNAVAILABLE => 'Search engine is unavailable',
};
}
public function getRecoveryHint(): string
{
return match($this) {
// System Errors
self::SYSTEM_CONFIG_MISSING => 'Check configuration files and environment variables',
self::SYSTEM_CONFIG_INVALID => 'Review configuration values and fix invalid entries',
self::SYSTEM_DEPENDENCY_MISSING => 'Install missing dependencies or check system requirements',
self::SYSTEM_RESOURCE_EXHAUSTED => 'Free up system resources or increase limits',
self::SYSTEM_INITIALIZATION_FAILED => 'Check system startup logs and fix initialization issues',
// Database Errors
self::DB_CONNECTION_FAILED => 'Check database server status and connection settings',
self::DB_QUERY_FAILED => 'Review query syntax and database schema',
self::DB_CONSTRAINT_VIOLATION => 'Check data integrity and constraint definitions',
self::DB_TRANSACTION_FAILED => 'Retry transaction or check for deadlocks',
self::DB_MIGRATION_FAILED => 'Review migration scripts and database state',
self::DB_POOL_EXHAUSTED => 'Increase connection pool size or optimize queries',
self::DB_TIMEOUT => 'Optimize query performance or increase timeout limits',
// Authentication Errors
self::AUTH_CREDENTIALS_INVALID => 'Verify username and password',
self::AUTH_TOKEN_EXPIRED => 'Refresh authentication token',
self::AUTH_TOKEN_INVALID => 'Obtain new authentication token',
self::AUTH_USER_LOCKED => 'Contact administrator to unlock account',
self::AUTH_SESSION_EXPIRED => 'Log in again to create new session',
self::AUTH_INSUFFICIENT_PRIVILEGES => 'Request appropriate permissions from administrator',
// Validation Errors
self::VAL_REQUIRED_FIELD_MISSING => 'Provide value for required field',
self::VAL_INVALID_FORMAT => 'Correct field format according to requirements',
self::VAL_OUT_OF_RANGE => 'Provide value within allowed range',
self::VAL_DUPLICATE_VALUE => 'Use unique value that does not already exist',
self::VAL_BUSINESS_RULE_VIOLATION => 'Follow business rules and constraints',
// HTTP Errors
self::HTTP_NOT_FOUND => 'Check URL and ensure resource exists',
self::HTTP_METHOD_NOT_ALLOWED => 'Use correct HTTP method for this endpoint',
self::HTTP_RATE_LIMIT_EXCEEDED => 'Reduce request frequency or wait before retrying',
self::HTTP_PAYLOAD_TOO_LARGE => 'Reduce request payload size',
self::HTTP_UNSUPPORTED_MEDIA_TYPE => 'Use supported content type',
// Security Errors
self::SEC_XSS_ATTEMPT => 'Review input validation and output encoding',
self::SEC_SQL_INJECTION_ATTEMPT => 'Use parameterized queries and input validation',
self::SEC_PATH_TRAVERSAL_ATTEMPT => 'Validate and sanitize file paths',
self::SEC_CSRF_TOKEN_MISMATCH => 'Include valid CSRF token in request',
self::SEC_UNAUTHORIZED_ACCESS => 'Authenticate and ensure proper authorization',
self::SEC_SUSPICIOUS_ACTIVITY => 'Review security logs and investigate activity',
// Search Errors
self::SEARCH_INDEX_FAILED => 'Check search engine connectivity and document format',
self::SEARCH_UPDATE_FAILED => 'Verify document exists and search engine is operational',
self::SEARCH_DELETE_FAILED => 'Ensure document exists in search index',
self::SEARCH_CONFIG_INVALID => 'Review search configuration and field mappings',
self::SEARCH_ENGINE_UNAVAILABLE => 'Wait for search engine to become available or use fallback',
// Other errors have generic recovery hint
default => 'Check logs for more details and contact support if needed',
};
}
public function isRecoverable(): bool
{
return match($this) {
// Non-recoverable errors
self::SYSTEM_CONFIG_MISSING,
self::SYSTEM_CONFIG_INVALID,
self::SYSTEM_DEPENDENCY_MISSING,
self::FS_PERMISSION_DENIED,
self::AUTH_USER_LOCKED,
self::BIZ_WORKFLOW_VIOLATION => false,
// Recoverable errors (can be retried or handled gracefully)
default => true,
};
}
public function getRetryAfterSeconds(): ?int
{
return match($this) {
self::DB_CONNECTION_FAILED => 30,
self::DB_TIMEOUT => 60,
self::API_SERVICE_UNAVAILABLE => 300,
self::API_RATE_LIMIT_EXCEEDED => 60,
self::API_TIMEOUT => 30,
self::CACHE_CONNECTION_FAILED => 10,
self::HTTP_RATE_LIMIT_EXCEEDED => 60,
self::SEARCH_ENGINE_UNAVAILABLE => 60,
self::SEARCH_INDEX_FAILED => 5,
self::SEARCH_UPDATE_FAILED => 5,
default => null,
};
}
}

View File

@@ -16,18 +16,20 @@ final readonly class ErrorHandlerContext
public RequestContext $request,
public SystemContext $system,
public array $metadata = []
) {}
) {
}
public static function create(
ExceptionContext $exceptionContext,
?RequestContext $requestContext = null,
?SystemContext $systemContext = null,
array $metadata = []
array $metadata = [],
?\App\Framework\Performance\MemoryMonitor $memoryMonitor = null
): self {
return new self(
exception: $exceptionContext,
request: $requestContext ?? RequestContext::fromGlobals(),
system: $systemContext ?? SystemContext::current(),
system: $systemContext ?? SystemContext::current($memoryMonitor),
metadata: $metadata
);
}
@@ -79,7 +81,7 @@ final readonly class ErrorHandlerContext
'exception' => $this->exception->toArray(),
'request' => $this->request->toArray(),
'system' => $this->system->toArray(),
'metadata' => $this->metadata
'metadata' => $this->metadata,
];
}
@@ -91,12 +93,12 @@ final readonly class ErrorHandlerContext
$contexts = [
'exception' => $this->exception->toArray(),
'request' => $this->request->toArray(),
'system' => $this->system->toArray()
'system' => $this->system->toArray(),
];
$flattened = [];
foreach ($contexts as $prefix => $data) {
array_walk($data, function($value, $key) use (&$flattened, $prefix) {
array_walk($data, function ($value, $key) use (&$flattened, $prefix) {
$flattened[$prefix . '_' . $key] = $value;
});
}
@@ -118,7 +120,7 @@ final readonly class ErrorHandlerContext
'memory_usage' => $this->system->memoryUsage,
'execution_time' => $this->system->executionTime,
'data' => $this->exception->data,
'metadata' => $this->metadata
'metadata' => $this->metadata,
];
}
@@ -133,8 +135,8 @@ final readonly class ErrorHandlerContext
$securityLog = [
'datetime' => date('c'),
'appid' => $appId,
'level' => 'INFO',
'description' => 'No description',
#'level' => 'INFO',
#'description' => 'No description',
'useragent' => $this->request->userAgent,
'source_ip' => $this->request->clientIp,
'host_ip' => $this->request->hostIp,
@@ -144,7 +146,7 @@ final readonly class ErrorHandlerContext
'request_uri' => $this->request->requestUri,
'request_method' => $this->request->requestMethod,
'region' => $_ENV['AWS_REGION'] ?? 'unknown',
'geo' => $_ENV['GEO_LOCATION'] ?? 'unknown'
'geo' => $_ENV['GEO_LOCATION'] ?? 'unknown',
];
// Security-Event-spezifische Daten falls verfügbar

View File

@@ -15,7 +15,8 @@ final readonly class ExceptionContext
public array $data = [],
public array $debug = [],
public array $metadata = []
) {}
) {
}
public static function empty(): self
{
@@ -78,7 +79,7 @@ final readonly class ExceptionContext
'component' => $this->component,
'data' => $this->sanitizeData($this->data),
'debug' => $this->debug,
'metadata' => $this->metadata
'metadata' => $this->metadata,
];
}

View File

@@ -8,14 +8,22 @@ class FrameworkException extends \RuntimeException
{
protected ExceptionContext $context;
protected ?ErrorCode $errorCode;
protected ?int $retryAfter;
public function __construct(
string $message,
ExceptionContext $context,
int $code = 0,
?\Throwable $previous = null,
?ExceptionContext $context = null
?ErrorCode $errorCode = null,
?int $retryAfter = null
) {
parent::__construct($message, $code, $previous);
$this->context = $context ?? ExceptionContext::empty();
$this->context = $context;
$this->errorCode = $errorCode;
$this->retryAfter = $retryAfter ?? $errorCode?->getRetryAfterSeconds();
}
public function getContext(): ExceptionContext
@@ -27,6 +35,7 @@ class FrameworkException extends \RuntimeException
{
$new = clone $this;
$new->context = $context;
return $new;
}
@@ -60,7 +69,7 @@ class FrameworkException extends \RuntimeException
public function toArray(): array
{
return [
$array = [
'class' => static::class,
'message' => $this->getMessage(),
'code' => $this->getCode(),
@@ -69,5 +78,179 @@ class FrameworkException extends \RuntimeException
'context' => $this->context->toArray(),
'trace' => $this->getTraceAsString(),
];
// ErrorCode-spezifische Daten hinzufügen wenn vorhanden
if ($this->errorCode) {
$array['error_code'] = $this->errorCode->value;
$array['error_category'] = $this->errorCode->getCategory();
$array['description'] = $this->errorCode->getDescription();
$array['recovery_hint'] = $this->errorCode->getRecoveryHint();
$array['is_recoverable'] = $this->errorCode->isRecoverable();
$array['retry_after'] = $this->retryAfter;
}
return $array;
}
// === Factory Methods ===
/**
* Einfache Exception ohne ErrorCode - für schnelle Verwendung
*/
public static function simple(
string $message,
?\Throwable $previous = null,
int $code = 0
): static {
return new static(
message: $message,
context: ExceptionContext::empty(),
code: $code,
previous: $previous
);
}
/**
* Exception mit ErrorCode und automatischer Beschreibung
*/
public static function create(
ErrorCode $errorCode,
?string $message = null,
?ExceptionContext $context = null,
?\Throwable $previous = null,
int $code = 0
): static {
$finalMessage = $message ?? $errorCode->getDescription();
$finalContext = $context ?? ExceptionContext::empty();
return new static(
message: $finalMessage,
context: $finalContext,
code: $code,
previous: $previous,
errorCode: $errorCode
);
}
/**
* Exception mit Operation Context
*/
public static function forOperation(
string $operation,
?string $component = null,
?string $message = null,
?ErrorCode $errorCode = null,
?\Throwable $previous = null,
int $code = 0
): static {
$context = ExceptionContext::forOperation($operation, $component);
$finalMessage = $message ?? $errorCode?->getDescription() ?? "Operation failed: $operation";
return new static(
message: $finalMessage,
context: $context,
code: $code,
previous: $previous,
errorCode: $errorCode
);
}
/**
* Exception mit vollständigem Context und Daten
*/
public static function fromContext(
string $message,
ExceptionContext $context,
?ErrorCode $errorCode = null,
?\Throwable $previous = null,
int $code = 0
): static {
return new static(
message: $message,
context: $context,
code: $code,
previous: $previous,
errorCode: $errorCode
);
}
// === ErrorCode Getter Methods ===
public function getErrorCode(): ?ErrorCode
{
return $this->errorCode;
}
public function getRetryAfter(): ?int
{
return $this->retryAfter;
}
public function isRecoverable(): bool
{
return $this->errorCode?->isRecoverable() ?? false;
}
public function getRecoveryHint(): ?string
{
return $this->errorCode?->getRecoveryHint();
}
// === ErrorCode Utility Methods ===
/**
* Prüft ob Exception von bestimmtem Error Code Typ ist
*/
public function isErrorCode(ErrorCode $errorCode): bool
{
return $this->errorCode === $errorCode;
}
/**
* Prüft ob Exception zu bestimmter Kategorie gehört
*/
public function isCategory(string $category): bool
{
return $this->errorCode?->getCategory() === strtoupper($category);
}
/**
* Setzt ErrorCode nachträglich
*/
public function withErrorCode(ErrorCode $errorCode): self
{
$new = clone $this;
$new->errorCode = $errorCode;
$new->retryAfter = $errorCode->getRetryAfterSeconds();
return $new;
}
/**
* Setzt Custom Retry-Zeit
*/
public function withRetryAfter(int $seconds): self
{
$new = clone $this;
$new->retryAfter = $seconds;
return $new;
}
/**
* String-Representation für Logging
*/
public function __toString(): string
{
$errorCodePart = $this->errorCode ? '[' . $this->errorCode->value . ']' : '';
return sprintf(
'%s %s: %s in %s:%d',
static::class,
$errorCodePart,
$this->getMessage(),
$this->getFile(),
$this->getLine()
);
}
}

View File

@@ -0,0 +1,314 @@
<?php
declare(strict_types=1);
namespace App\Framework\Exception\Http;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\ExceptionContext;
use App\Framework\Exception\FrameworkException;
/**
* Ausnahme für ungültige Content-Types
*
* Verwendet OWASP-konforme Nachrichten für Content-Type-Validierung
*/
final class InvalidContentTypeException extends FrameworkException
{
/**
* @param string $clientIp Client-IP für Security-Tracking
* @param string $providedContentType Gesendeter Content-Type
* @param array $allowedContentTypes Erlaubte Content-Types
* @param string $endpoint Betroffener Endpoint
* @param \Throwable|null $previous Vorherige Ausnahme
*/
public function __construct(
public readonly string $clientIp,
public readonly string $providedContentType,
public readonly array $allowedContentTypes,
public readonly string $endpoint = '',
?\Throwable $previous = null
) {
$allowedList = implode(', ', $this->allowedContentTypes);
// OWASP-konforme Nachricht mit Platzhaltern
$message = "Client {$this->clientIp} sent invalid content type '{$this->providedContentType}' to {$this->endpoint}";
$context = ExceptionContext::forOperation('http.content_type_validation', 'Http')
->withData([
'client_ip' => $this->clientIp,
'provided_content_type' => $this->providedContentType,
'allowed_content_types' => $this->allowedContentTypes,
'endpoint' => $this->endpoint,
'allowed_types_count' => count($this->allowedContentTypes),
'event_identifier' => "http_invalid_content_type:{$this->clientIp},{$this->endpoint}",
'category' => 'http_validation',
'requires_alert' => $this->requiresAlert(),
])
->withMetadata([
'security_event' => true,
'owasp_compliant' => true,
'log_level' => 'INFO',
'http_error' => true,
]);
parent::__construct(
message: $message,
context: $context,
code: 415, // Unsupported Media Type
previous: $previous,
errorCode: ErrorCode::HTTP_UNSUPPORTED_MEDIA_TYPE
);
}
// === Factory Methods für verschiedene Content-Type-Szenarien ===
public static function jsonRequired(string $clientIp, string $providedType, string $endpoint = ''): self
{
return new self($clientIp, $providedType, ['application/json'], $endpoint);
}
public static function xmlRequired(string $clientIp, string $providedType, string $endpoint = ''): self
{
return new self($clientIp, $providedType, ['application/xml', 'text/xml'], $endpoint);
}
public static function formDataRequired(string $clientIp, string $providedType, string $endpoint = ''): self
{
return new self($clientIp, $providedType, ['application/x-www-form-urlencoded'], $endpoint);
}
public static function multipartRequired(string $clientIp, string $providedType, string $endpoint = ''): self
{
return new self($clientIp, $providedType, ['multipart/form-data'], $endpoint);
}
public static function textRequired(string $clientIp, string $providedType, string $endpoint = ''): self
{
return new self($clientIp, $providedType, ['text/plain'], $endpoint);
}
public static function apiEndpoint(string $clientIp, string $providedType, string $endpoint = ''): self
{
return new self($clientIp, $providedType, [
'application/json',
'application/xml',
'application/x-www-form-urlencoded',
], $endpoint);
}
public static function fileUpload(string $clientIp, string $providedType, array $allowedTypes, string $endpoint = ''): self
{
return new self($clientIp, $providedType, $allowedTypes, $endpoint);
}
/**
* Gibt OWASP-konforme Event-Daten zurück
*/
public function getSecurityEventData(): array
{
return [
'event_identifier' => "http_invalid_content_type:{$this->clientIp},{$this->endpoint}",
'description' => "Client {$this->clientIp} sent invalid content type '{$this->providedContentType}' to {$this->endpoint}",
'category' => 'http_validation',
'log_level' => 'INFO',
'requires_alert' => $this->requiresAlert(),
'client_ip' => $this->clientIp,
'provided_content_type' => $this->providedContentType,
'allowed_content_types' => $this->allowedContentTypes,
'endpoint' => $this->endpoint,
];
}
/**
* Prüft ob ein Alert erforderlich ist (bei verdächtigen Content-Types)
*/
public function requiresAlert(): bool
{
// Alert bei potentiell gefährlichen Content-Types
$dangerousTypes = [
'application/x-shockwave-flash',
'application/x-executable',
'application/octet-stream',
'text/html', // Wenn nicht erwartet
'text/javascript',
'application/javascript',
];
foreach ($dangerousTypes as $dangerous) {
if (str_contains(strtolower($this->providedContentType), $dangerous)) {
return true;
}
}
// Alert bei API-Endpoints und ungewöhnlichen Types
if (str_contains($this->endpoint, '/api/') && ! $this->isCommonApiContentType()) {
return true;
}
return false;
}
/**
* Prüft ob es ein häufiger API-Content-Type ist
*/
private function isCommonApiContentType(): bool
{
$commonApiTypes = [
'application/json',
'application/xml',
'text/xml',
'application/x-www-form-urlencoded',
'multipart/form-data',
'text/plain',
];
foreach ($commonApiTypes as $common) {
if (str_contains(strtolower($this->providedContentType), $common)) {
return true;
}
}
return false;
}
/**
* Gibt benutzerfreundliche Fehlermeldung zurück
*/
public function getUserMessage(): string
{
$allowedList = $this->formatContentTypeList($this->allowedContentTypes);
if (empty($this->endpoint)) {
return "Unsupported content type '{$this->providedContentType}'. Supported types: {$allowedList}";
}
return "Endpoint '{$this->endpoint}' does not support content type '{$this->providedContentType}'. Supported types: {$allowedList}";
}
/**
* Formatiert Content-Type-Liste für Benutzer
*/
private function formatContentTypeList(array $contentTypes): string
{
if (count($contentTypes) === 1) {
return $contentTypes[0];
}
if (count($contentTypes) === 2) {
return $contentTypes[0] . ' and ' . $contentTypes[1];
}
$last = array_pop($contentTypes);
return implode(', ', $contentTypes) . ', and ' . $last;
}
/**
* Schlägt passenden Content-Type vor
*/
public function getSuggestedContentType(): ?string
{
if (empty($this->allowedContentTypes)) {
return null;
}
// Bei API-Endpoints JSON bevorzugen
if (str_contains($this->endpoint, '/api/') && in_array('application/json', $this->allowedContentTypes)) {
return 'application/json';
}
// Ersten erlaubten Type zurückgeben
return $this->allowedContentTypes[0];
}
/**
* Gibt HTTP-Header für korrekte Content-Type-Information zurück
*/
public function getResponseHeaders(): array
{
$headers = [
'Accept' => implode(', ', $this->allowedContentTypes),
];
$suggested = $this->getSuggestedContentType();
if ($suggested) {
$headers['X-Suggested-Content-Type'] = $suggested;
}
return $headers;
}
/**
* Analysiert den gesendeten Content-Type
*/
public function analyzeProvidedContentType(): array
{
$analysis = [
'main_type' => '',
'sub_type' => '',
'charset' => '',
'boundary' => '',
'is_multipart' => false,
'is_text' => false,
'is_binary' => false,
];
if (empty($this->providedContentType)) {
return $analysis;
}
// Parse Content-Type
$parts = explode(';', $this->providedContentType);
$mainType = trim($parts[0]);
if (str_contains($mainType, '/')) {
[$type, $subtype] = explode('/', $mainType, 2);
$analysis['main_type'] = trim($type);
$analysis['sub_type'] = trim($subtype);
}
// Parse Parameter
for ($i = 1; $i < count($parts); $i++) {
$param = trim($parts[$i]);
if (str_contains($param, '=')) {
[$key, $value] = explode('=', $param, 2);
$key = trim($key);
$value = trim($value, ' "');
switch ($key) {
case 'charset':
$analysis['charset'] = $value;
break;
case 'boundary':
$analysis['boundary'] = $value;
break;
}
}
}
// Typ-Analyse
$analysis['is_multipart'] = str_starts_with($mainType, 'multipart/');
$analysis['is_text'] = str_starts_with($mainType, 'text/') ||
in_array($mainType, ['application/json', 'application/xml']);
$analysis['is_binary'] = ! $analysis['is_text'] && ! $analysis['is_multipart'];
return $analysis;
}
/**
* Gibt Empfehlung für Client-Implementierung zurück
*/
public function getClientRecommendation(): string
{
$suggested = $this->getSuggestedContentType();
if ($suggested) {
return "Set the Content-Type header to '{$suggested}' for this endpoint.";
}
return "Check the API documentation for supported content types for this endpoint.";
}
}

View File

@@ -0,0 +1,194 @@
<?php
declare(strict_types=1);
namespace App\Framework\Exception\Http;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\ExceptionContext;
use App\Framework\Exception\FrameworkException;
/**
* Ausnahme für fehlerhaft formatiertes JSON
*
* Verwendet OWASP-konforme Nachrichten für JSON-Parsing-Fehler
*/
final class MalformedJsonException extends FrameworkException
{
/**
* @param string $clientIp Client-IP für Security-Tracking
* @param string $jsonError JSON-Parser-Fehlermeldung
* @param int $jsonErrorCode JSON-Error-Code
* @param string $jsonData Ursprüngliche JSON-Daten (gekürzt)
* @param \Throwable|null $previous Vorherige Ausnahme
*/
public function __construct(
public readonly string $clientIp,
public readonly string $jsonError,
public readonly int $jsonErrorCode = JSON_ERROR_SYNTAX,
public readonly string $jsonData = '',
?\Throwable $previous = null
) {
// OWASP-konforme Nachricht mit Platzhaltern
$message = "Client {$this->clientIp} sent malformed JSON: {$this->jsonError}";
$context = ExceptionContext::forOperation('http.json_parsing', 'Http')
->withData([
'client_ip' => $this->clientIp,
'json_error' => $this->jsonError,
'json_error_code' => $this->jsonErrorCode,
'json_data_length' => strlen($this->jsonData),
'event_identifier' => "http_malformed_json:{$this->clientIp}",
'category' => 'http_validation',
'requires_alert' => false,
])
->withDebug([
'json_data_sample' => substr($this->jsonData, 0, 100), // Erste 100 Zeichen für Debug
])
->withMetadata([
'security_event' => true,
'owasp_compliant' => true,
'log_level' => 'INFO',
'http_error' => true,
]);
parent::__construct(
message: $message,
context: $context,
code: 400, // Bad Request
previous: $previous,
errorCode: ErrorCode::HTTP_MALFORMED_REQUEST
);
}
// === Factory Methods für verschiedene JSON-Fehler ===
public static function syntaxError(string $clientIp, string $jsonData = ''): self
{
return new self($clientIp, 'Syntax error', JSON_ERROR_SYNTAX, $jsonData);
}
public static function depthExceeded(string $clientIp, string $jsonData = ''): self
{
return new self($clientIp, 'Maximum stack depth exceeded', JSON_ERROR_DEPTH, $jsonData);
}
public static function ctrlCharError(string $clientIp, string $jsonData = ''): self
{
return new self($clientIp, 'Unexpected control character found', JSON_ERROR_CTRL_CHAR, $jsonData);
}
public static function utf8Error(string $clientIp, string $jsonData = ''): self
{
return new self($clientIp, 'Malformed UTF-8 characters', JSON_ERROR_UTF8, $jsonData);
}
public static function fromJsonLastError(string $clientIp, string $jsonData = ''): self
{
$error = json_last_error_msg();
$code = json_last_error();
return new self($clientIp, $error, $code, $jsonData);
}
/**
* Gibt OWASP-konforme Event-Daten zurück
*/
public function getSecurityEventData(): array
{
return [
'event_identifier' => "http_malformed_json:{$this->clientIp}",
'description' => "Client {$this->clientIp} sent malformed JSON: {$this->jsonError}",
'category' => 'http_validation',
'log_level' => 'INFO',
'requires_alert' => false,
'client_ip' => $this->clientIp,
'json_error' => $this->jsonError,
'json_error_code' => $this->jsonErrorCode,
];
}
/**
* Gibt benutzerfreundliche Fehlermeldung zurück
*/
public function getUserMessage(): string
{
return match ($this->jsonErrorCode) {
JSON_ERROR_SYNTAX => 'Invalid JSON format. Please check your JSON syntax.',
JSON_ERROR_DEPTH => 'JSON structure too complex. Please reduce nesting depth.',
JSON_ERROR_CTRL_CHAR => 'Invalid characters in JSON. Please check for control characters.',
JSON_ERROR_UTF8 => 'Invalid UTF-8 encoding in JSON. Please check character encoding.',
default => 'Invalid JSON format. Please check your request body.',
};
}
/**
* Gibt detaillierte Fehlerbeschreibung für Entwickler zurück
*/
public function getDeveloperMessage(): string
{
$position = $this->findErrorPosition();
$positionInfo = $position ? " at position {$position}" : '';
return "JSON parsing failed: {$this->jsonError}{$positionInfo}";
}
/**
* Versucht die Position des JSON-Fehlers zu finden
*/
private function findErrorPosition(): ?int
{
if (empty($this->jsonData)) {
return null;
}
// Vereinfachte Positionsbestimmung
// In einer echten Implementierung könnte hier ein detaillierterer Parser verwendet werden
$testData = $this->jsonData;
$position = 0;
while ($position < strlen($testData)) {
$substr = substr($testData, 0, $position + 1);
json_decode($substr);
if (json_last_error() !== JSON_ERROR_NONE) {
return $position;
}
$position++;
}
return null;
}
/**
* Gibt JSON-Error-Code als String zurück
*/
public function getJsonErrorName(): string
{
return match ($this->jsonErrorCode) {
JSON_ERROR_NONE => 'JSON_ERROR_NONE',
JSON_ERROR_DEPTH => 'JSON_ERROR_DEPTH',
JSON_ERROR_STATE_MISMATCH => 'JSON_ERROR_STATE_MISMATCH',
JSON_ERROR_CTRL_CHAR => 'JSON_ERROR_CTRL_CHAR',
JSON_ERROR_SYNTAX => 'JSON_ERROR_SYNTAX',
JSON_ERROR_UTF8 => 'JSON_ERROR_UTF8',
JSON_ERROR_RECURSION => 'JSON_ERROR_RECURSION',
JSON_ERROR_INF_OR_NAN => 'JSON_ERROR_INF_OR_NAN',
JSON_ERROR_UNSUPPORTED_TYPE => 'JSON_ERROR_UNSUPPORTED_TYPE',
default => 'UNKNOWN_JSON_ERROR',
};
}
/**
* Prüft ob der Fehler ein häufiger Entwicklerfehler ist
*/
public function isCommonDeveloperError(): bool
{
return in_array($this->jsonErrorCode, [
JSON_ERROR_SYNTAX,
JSON_ERROR_CTRL_CHAR,
JSON_ERROR_UTF8,
]);
}
}

View File

@@ -0,0 +1,241 @@
<?php
declare(strict_types=1);
namespace App\Framework\Exception\Http;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\ExceptionContext;
use App\Framework\Exception\FrameworkException;
/**
* Ausnahme für zu große HTTP-Requests
*
* Verwendet OWASP-konforme Nachrichten für Request-Size-Violations
*/
final class OversizedRequestException extends FrameworkException
{
/**
* @param string $clientIp Client-IP für Security-Tracking
* @param int $requestSize Größe des Requests in Bytes
* @param int $maxSize Maximale erlaubte Größe in Bytes
* @param string $requestType Art des Requests (body, header, file, etc.)
* @param \Throwable|null $previous Vorherige Ausnahme
*/
public function __construct(
public readonly string $clientIp,
public readonly int $requestSize,
public readonly int $maxSize,
public readonly string $requestType = 'body',
?\Throwable $previous = null
) {
// OWASP-konforme Nachricht mit Platzhaltern
$message = "Client {$this->clientIp} sent oversized {$this->requestType}: {$this->requestSize} bytes (max: {$this->maxSize} bytes)";
$context = ExceptionContext::forOperation('http.size_validation', 'Http')
->withData([
'client_ip' => $this->clientIp,
'request_size' => $this->requestSize,
'max_size' => $this->maxSize,
'request_type' => $this->requestType,
'size_ratio' => round($this->requestSize / $this->maxSize, 2),
'excess_bytes' => $this->requestSize - $this->maxSize,
'event_identifier' => "http_oversized_request:{$this->clientIp},{$this->requestType}",
'category' => 'http_validation',
'requires_alert' => $this->requiresAlert(),
])
->withMetadata([
'security_event' => true,
'owasp_compliant' => true,
'log_level' => 'WARN',
'http_error' => true,
]);
parent::__construct(
message: $message,
context: $context,
code: 413, // Payload Too Large
previous: $previous,
errorCode: ErrorCode::HTTP_REQUEST_TOO_LARGE
);
}
// === Factory Methods für verschiedene Size-Violations ===
public static function requestBody(string $clientIp, int $size, int $maxSize = 10485760): self // 10MB default
{
return new self($clientIp, $size, $maxSize, 'body');
}
public static function requestHeader(string $clientIp, int $size, int $maxSize = 8192): self // 8KB default
{
return new self($clientIp, $size, $maxSize, 'header');
}
public static function uploadedFile(string $clientIp, int $size, int $maxSize = 52428800): self // 50MB default
{
return new self($clientIp, $size, $maxSize, 'file');
}
public static function multipartForm(string $clientIp, int $size, int $maxSize = 20971520): self // 20MB default
{
return new self($clientIp, $size, $maxSize, 'multipart');
}
public static function queryString(string $clientIp, int $size, int $maxSize = 2048): self // 2KB default
{
return new self($clientIp, $size, $maxSize, 'query');
}
public static function jsonPayload(string $clientIp, int $size, int $maxSize = 5242880): self // 5MB default
{
return new self($clientIp, $size, $maxSize, 'json');
}
/**
* Gibt OWASP-konforme Event-Daten zurück
*/
public function getSecurityEventData(): array
{
return [
'event_identifier' => "http_oversized_request:{$this->clientIp},{$this->requestType}",
'description' => "Client {$this->clientIp} sent oversized {$this->requestType}: {$this->requestSize} bytes (max: {$this->maxSize} bytes)",
'category' => 'http_validation',
'log_level' => 'WARN',
'requires_alert' => $this->requiresAlert(),
'client_ip' => $this->clientIp,
'request_type' => $this->requestType,
'request_size' => $this->requestSize,
'max_size' => $this->maxSize,
'size_ratio' => $this->getSizeRatio(),
];
}
/**
* Prüft ob ein Alert erforderlich ist (bei extremen Größenüberschreitungen)
*/
public function requiresAlert(): bool
{
$ratio = $this->getSizeRatio();
// Alert bei extremen Überschreitungen (>10x des Limits)
if ($ratio > 10) {
return true;
}
// Alert bei sehr großen absoluten Größen (>100MB)
if ($this->requestSize > 104857600) {
return true;
}
// Alert bei Header-Größenproblemen (potentielle Header-Injection)
if ($this->requestType === 'header' && $ratio > 5) {
return true;
}
return false;
}
/**
* Berechnet Verhältnis von Request-Größe zu Maximum
*/
public function getSizeRatio(): float
{
return round($this->requestSize / $this->maxSize, 2);
}
/**
* Gibt Überschuss in Bytes zurück
*/
public function getExcessBytes(): int
{
return max(0, $this->requestSize - $this->maxSize);
}
/**
* Prüft ob es ein potentieller DoS-Angriff ist
*/
public function isPotentialDoS(): bool
{
return $this->getSizeRatio() > 10 || $this->requestSize > 104857600; // >100MB
}
/**
* Gibt benutzerfreundliche Fehlermeldung zurück
*/
public function getUserMessage(): string
{
$requestSizeFormatted = $this->formatBytes($this->requestSize);
$maxSizeFormatted = $this->formatBytes($this->maxSize);
return match ($this->requestType) {
'body' => "Request body too large ({$requestSizeFormatted}). Maximum allowed size is {$maxSizeFormatted}.",
'header' => "Request headers too large ({$requestSizeFormatted}). Maximum allowed size is {$maxSizeFormatted}.",
'file' => "Uploaded file too large ({$requestSizeFormatted}). Maximum allowed size is {$maxSizeFormatted}.",
'multipart' => "Form data too large ({$requestSizeFormatted}). Maximum allowed size is {$maxSizeFormatted}.",
'query' => "Query string too long ({$requestSizeFormatted}). Maximum allowed size is {$maxSizeFormatted}.",
'json' => "JSON payload too large ({$requestSizeFormatted}). Maximum allowed size is {$maxSizeFormatted}.",
default => "Request too large ({$requestSizeFormatted}). Maximum allowed size is {$maxSizeFormatted}.",
};
}
/**
* Formatiert Bytes in menschenlesbare Einheiten
*/
public function formatBytes(int $bytes): string
{
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
$bytes /= pow(1024, $pow);
return round($bytes, 2) . ' ' . $units[$pow];
}
/**
* Gibt Empfehlung für Client-Implementierung zurück
*/
public function getClientRecommendation(): string
{
return match ($this->requestType) {
'body' => 'Split large requests into smaller chunks or compress the data before sending.',
'header' => 'Reduce header size by removing unnecessary headers or using shorter values.',
'file' => 'Compress the file or split it into smaller parts for upload.',
'multipart' => 'Reduce form data size or upload files separately.',
'query' => 'Use POST request with body instead of long query strings.',
'json' => 'Reduce JSON payload size or implement pagination for large datasets.',
default => 'Reduce request size or contact support for higher limits.',
};
}
/**
* Gibt technische Details für Entwickler zurück
*/
public function getTechnicalDetails(): array
{
return [
'request_type' => $this->requestType,
'actual_size' => $this->requestSize,
'max_allowed' => $this->maxSize,
'excess_bytes' => $this->getExcessBytes(),
'size_ratio' => $this->getSizeRatio(),
'formatted_actual' => $this->formatBytes($this->requestSize),
'formatted_max' => $this->formatBytes($this->maxSize),
'formatted_excess' => $this->formatBytes($this->getExcessBytes()),
];
}
/**
* Schlägt alternative HTTP-Codes vor
*/
public function getAlternativeHttpCode(): int
{
return match ($this->requestType) {
'header' => 431, // Request Header Fields Too Large
'query' => 414, // URI Too Long
default => 413, // Payload Too Large
};
}
}

View File

@@ -0,0 +1,236 @@
<?php
declare(strict_types=1);
namespace App\Framework\Exception\Http;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\ExceptionContext;
use App\Framework\Exception\FrameworkException;
/**
* Ausnahme für Rate-Limit-Überschreitungen
*
* Verwendet OWASP-konforme Nachrichten für Rate-Limiting-Violations
*/
final class RateLimitExceededException extends FrameworkException
{
/**
* @param string $clientIp Client-IP für Security-Tracking
* @param int $currentRequests Aktuelle Anzahl Requests
* @param int $maxRequests Maximale erlaubte Requests
* @param int $windowSeconds Zeitfenster in Sekunden
* @param int $retryAfterSeconds Warten bis nächster Request erlaubt
* @param string $limitType Art des Limits (ip, user, api_key, etc.)
* @param \Throwable|null $previous Vorherige Ausnahme
*/
public function __construct(
public readonly string $clientIp,
public readonly int $currentRequests,
public readonly int $maxRequests,
public readonly int $windowSeconds,
public readonly int $retryAfterSeconds,
public readonly string $limitType = 'ip',
?\Throwable $previous = null
) {
// OWASP-konforme Nachricht mit Platzhaltern
$message = "Client {$this->clientIp} exceeded rate limit: {$this->currentRequests}/{$this->maxRequests} requests in {$this->windowSeconds}s";
$context = ExceptionContext::forOperation('http.rate_limiting', 'Http')
->withData([
'client_ip' => $this->clientIp,
'current_requests' => $this->currentRequests,
'max_requests' => $this->maxRequests,
'window_seconds' => $this->windowSeconds,
'retry_after_seconds' => $this->retryAfterSeconds,
'limit_type' => $this->limitType,
'usage_percentage' => round(($this->currentRequests / $this->maxRequests) * 100, 2),
'event_identifier' => "http_rate_limit_exceeded:{$this->clientIp},{$this->limitType}",
'category' => 'rate_limiting',
'requires_alert' => $this->requiresAlert(),
])
->withMetadata([
'security_event' => true,
'owasp_compliant' => true,
'log_level' => 'WARN',
'http_error' => true,
]);
parent::__construct(
message: $message,
context: $context,
code: 429, // Too Many Requests
previous: $previous,
errorCode: ErrorCode::HTTP_RATE_LIMIT_EXCEEDED,
retryAfter: $this->retryAfterSeconds
);
}
// === Factory Methods für verschiedene Rate-Limit-Szenarien ===
public static function ipLimit(string $clientIp, int $current, int $max, int $windowSeconds = 3600): self
{
$retryAfter = min(3600, $windowSeconds); // Maximal 1 Stunde
return new self($clientIp, $current, $max, $windowSeconds, $retryAfter, 'ip');
}
public static function userLimit(string $clientIp, string $userId, int $current, int $max, int $windowSeconds = 3600): self
{
$retryAfter = min(1800, $windowSeconds); // Maximal 30 Minuten für User
return new self($clientIp, $current, $max, $windowSeconds, $retryAfter, 'user');
}
public static function apiKeyLimit(string $clientIp, string $apiKey, int $current, int $max, int $windowSeconds = 3600): self
{
$retryAfter = min(900, $windowSeconds); // Maximal 15 Minuten für API Keys
return new self($clientIp, $current, $max, $windowSeconds, $retryAfter, 'api_key');
}
public static function endpointLimit(string $clientIp, string $endpoint, int $current, int $max, int $windowSeconds = 300): self
{
$retryAfter = min(300, $windowSeconds); // Maximal 5 Minuten für Endpoints
return new self($clientIp, $current, $max, $windowSeconds, $retryAfter, 'endpoint');
}
public static function globalLimit(string $clientIp, int $current, int $max, int $windowSeconds = 60): self
{
$retryAfter = min(60, $windowSeconds); // Maximal 1 Minute für Global
return new self($clientIp, $current, $max, $windowSeconds, $retryAfter, 'global');
}
/**
* Gibt OWASP-konforme Event-Daten zurück
*/
public function getSecurityEventData(): array
{
return [
'event_identifier' => "http_rate_limit_exceeded:{$this->clientIp},{$this->limitType}",
'description' => "Client {$this->clientIp} exceeded rate limit: {$this->currentRequests}/{$this->maxRequests} requests in {$this->windowSeconds}s",
'category' => 'rate_limiting',
'log_level' => 'WARN',
'requires_alert' => $this->requiresAlert(),
'client_ip' => $this->clientIp,
'limit_type' => $this->limitType,
'current_requests' => $this->currentRequests,
'max_requests' => $this->maxRequests,
'usage_percentage' => $this->getUsagePercentage(),
];
}
/**
* Prüft ob ein Alert erforderlich ist (bei extremen Überschreitungen)
*/
public function requiresAlert(): bool
{
$usagePercentage = $this->getUsagePercentage();
// Alert bei extremen Überschreitungen (>200% des Limits)
if ($usagePercentage > 200) {
return true;
}
// Alert bei sehr hohen Request-Zahlen (potentielle DDoS)
if ($this->currentRequests > 1000) {
return true;
}
// Alert bei API-Key-Limits (könnte kompromittiert sein)
if ($this->limitType === 'api_key' && $usagePercentage > 150) {
return true;
}
return false;
}
/**
* Berechnet Nutzungsprozentsatz
*/
public function getUsagePercentage(): float
{
return round(($this->currentRequests / $this->maxRequests) * 100, 2);
}
/**
* Prüft ob es ein potentieller DDoS-Angriff ist
*/
public function isPotentialDDoS(): bool
{
return $this->currentRequests > 1000 || $this->getUsagePercentage() > 500;
}
/**
* Gibt benutzerfreundliche Fehlermeldung zurück
*/
public function getUserMessage(): string
{
$waitTime = $this->getWaitTimeMessage();
return match ($this->limitType) {
'ip' => "Too many requests from your IP address. Please wait {$waitTime} before trying again.",
'user' => "You have exceeded your request limit. Please wait {$waitTime} before trying again.",
'api_key' => "API key rate limit exceeded. Please wait {$waitTime} before making more requests.",
'endpoint' => "Too many requests to this endpoint. Please wait {$waitTime} before trying again.",
'global' => "System is busy. Please wait {$waitTime} and try again.",
default => "Rate limit exceeded. Please wait {$waitTime} before trying again.",
};
}
/**
* Gibt Wartezeit als benutzerfreundlichen String zurück
*/
public function getWaitTimeMessage(): string
{
if ($this->retryAfterSeconds < 60) {
return "{$this->retryAfterSeconds} seconds";
} elseif ($this->retryAfterSeconds < 3600) {
$minutes = ceil($this->retryAfterSeconds / 60);
return "{$minutes} minutes";
} else {
$hours = ceil($this->retryAfterSeconds / 3600);
return "{$hours} hours";
}
}
/**
* Gibt HTTP-Header für Rate-Limiting zurück
*/
public function getRateLimitHeaders(): array
{
return [
'X-RateLimit-Limit' => (string) $this->maxRequests,
'X-RateLimit-Remaining' => (string) max(0, $this->maxRequests - $this->currentRequests),
'X-RateLimit-Reset' => (string) (time() + $this->retryAfterSeconds),
'X-RateLimit-Window' => (string) $this->windowSeconds,
'Retry-After' => (string) $this->retryAfterSeconds,
];
}
/**
* Berechnet wann der nächste Request erlaubt ist
*/
public function getNextAllowedTime(): \DateTimeImmutable
{
return new \DateTimeImmutable("+{$this->retryAfterSeconds} seconds");
}
/**
* Gibt Empfehlung für Client-Implementierung zurück
*/
public function getClientRecommendation(): string
{
return match ($this->limitType) {
'ip', 'global' => 'Implement exponential backoff and respect Retry-After headers.',
'user' => 'Consider caching responses and batching requests to reduce API calls.',
'api_key' => 'Review your API usage patterns and consider upgrading your plan if needed.',
'endpoint' => 'This endpoint has specific rate limits. Consider alternative endpoints or batch operations.',
default => 'Implement proper rate limiting in your client application.',
};
}
}

View File

@@ -0,0 +1,240 @@
<?php
declare(strict_types=1);
namespace App\Framework\Exception\Http;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\ExceptionContext;
use App\Framework\Exception\FrameworkException;
/**
* Ausnahme für nicht gefundene Routen
*
* Verwendet OWASP-konforme Nachrichten für HTTP-Routing-Fehler
*/
final class RouteNotFoundException extends FrameworkException
{
/**
* @param string $path Angeforderte Route/Pfad
* @param string $method HTTP-Methode
* @param string $clientIp Client-IP für Security-Tracking
* @param array $availableRoutes Verfügbare Routen (für Debugging)
* @param \Throwable|null $previous Vorherige Ausnahme
*/
public function __construct(
public readonly string $path,
public readonly string $method,
public readonly string $clientIp = 'unknown',
public readonly array $availableRoutes = [],
?\Throwable $previous = null
) {
// OWASP-konforme Nachricht mit Platzhaltern
$message = "Client {$this->clientIp} requested non-existent route {$this->method} {$this->path}";
$context = ExceptionContext::forOperation('http.routing', 'Router')
->withData([
'path' => $this->path,
'method' => $this->method,
'client_ip' => $this->clientIp,
'available_routes_count' => count($this->availableRoutes),
'event_identifier' => "http_route_not_found:{$this->clientIp},{$this->path}",
'category' => 'http_routing',
'requires_alert' => $this->requiresAlert(),
])
->withDebug([
'available_routes' => $this->availableRoutes, // Nur im Debug-Modus
])
->withMetadata([
'security_event' => true,
'owasp_compliant' => true,
'log_level' => 'INFO',
'http_error' => true,
]);
parent::__construct(
message: $message,
context: $context,
code: 404, // Not Found
previous: $previous,
errorCode: ErrorCode::HTTP_ROUTE_NOT_FOUND
);
}
// === Factory Methods für verschiedene 404-Szenarien ===
public static function path(string $path, string $method = 'GET', string $clientIp = 'unknown'): self
{
return new self($path, $method, $clientIp);
}
public static function api(string $apiPath, string $method, string $clientIp = 'unknown'): self
{
return new self("/api{$apiPath}", $method, $clientIp);
}
public static function admin(string $adminPath, string $method = 'GET', string $clientIp = 'unknown'): self
{
return new self("/admin{$adminPath}", $method, $clientIp);
}
public static function asset(string $assetPath, string $clientIp = 'unknown'): self
{
return new self($assetPath, 'GET', $clientIp);
}
/**
* Gibt OWASP-konforme Event-Daten zurück
*/
public function getSecurityEventData(): array
{
return [
'event_identifier' => "http_route_not_found:{$this->clientIp},{$this->path}",
'description' => "Client {$this->clientIp} requested non-existent route {$this->method} {$this->path}",
'category' => 'http_routing',
'log_level' => 'INFO',
'requires_alert' => $this->requiresAlert(),
'path' => $this->path,
'method' => $this->method,
'client_ip' => $this->clientIp,
];
}
/**
* Prüft ob der 404-Fehler verdächtig ist (Scanning, Brute Force, etc.)
*/
public function requiresAlert(): bool
{
// Alert bei verdächtigen Pfaden
$suspiciousPaths = [
'/admin', '/wp-admin', '/phpmyadmin', '/manager', '/console',
'/.env', '/config', '/backup', '/test', '/debug',
'/shell', '/cmd', '/execute', '/eval', '/system',
'/../', '/..\\', '/etc/', '/var/', '/usr/', '/root/',
'.php', '.asp', '.jsp', '.cgi', '.pl',
];
foreach ($suspiciousPaths as $suspicious) {
if (str_contains(strtolower($this->path), $suspicious)) {
return true;
}
}
// Alert bei ungewöhnlichen HTTP-Methoden auf normalen Pfaden
if (in_array($this->method, ['TRACE', 'TRACK', 'CONNECT', 'OPTIONS']) && ! str_starts_with($this->path, '/api/')) {
return true;
}
return false;
}
/**
* Prüft ob es ein potentieller Scanner/Bot ist
*/
public function isScannerActivity(): bool
{
$scannerPaths = [
'/robots.txt', '/sitemap.xml', '/.well-known/',
'/favicon.ico', '/apple-touch-icon', '/browserconfig.xml',
'/crossdomain.xml', '/clientaccesspolicy.xml',
];
foreach ($scannerPaths as $scanner) {
if (str_starts_with($this->path, $scanner)) {
return true;
}
}
return false;
}
/**
* Schlägt ähnliche verfügbare Routen vor
*/
public function getSimilarRoutes(): array
{
if (empty($this->availableRoutes)) {
return [];
}
$similar = [];
$requestedPath = strtolower($this->path);
foreach ($this->availableRoutes as $route) {
$routePath = strtolower($route);
// Exakte Teilstring-Matches
if (str_contains($routePath, $requestedPath) || str_contains($requestedPath, $routePath)) {
$similar[] = $route;
continue;
}
// Ähnlichkeit basierend auf Levenshtein-Distanz
$distance = levenshtein($requestedPath, $routePath);
if ($distance <= 3 && strlen($requestedPath) > 3) {
$similar[] = $route;
}
}
return array_slice($similar, 0, 5); // Maximal 5 Vorschläge
}
/**
* Gibt benutzerfreundliche Fehlermeldung zurück
*/
public function getUserMessage(): string
{
if ($this->isScannerActivity()) {
return "The requested resource was not found.";
}
$similar = $this->getSimilarRoutes();
if (! empty($similar)) {
$suggestions = implode(', ', $similar);
return "The requested page was not found. Did you mean: {$suggestions}?";
}
return "The requested page was not found. Please check the URL and try again.";
}
/**
* Generiert sichere Log-Nachricht (ohne sensible Daten)
*/
public function getLogMessage(): string
{
// IP-Adresse anonymisieren für Logs
$anonymizedIp = $this->anonymizeIp($this->clientIp);
return "Route not found: {$this->method} {$this->path} (Client: {$anonymizedIp})";
}
/**
* Anonymisiert IP-Adresse für Logs
*/
private function anonymizeIp(string $ip): string
{
if ($ip === 'unknown') {
return $ip;
}
// IPv4
if (str_contains($ip, '.')) {
$parts = explode('.', $ip);
if (count($parts) === 4) {
return $parts[0] . '.' . $parts[1] . '.XXX.XXX';
}
}
// IPv6
if (str_contains($ip, ':')) {
$parts = explode(':', $ip);
if (count($parts) >= 3) {
return $parts[0] . ':' . $parts[1] . ':XXXX::';
}
}
return 'XXX.XXX.XXX.XXX';
}
}

View File

@@ -4,6 +4,9 @@ declare(strict_types=1);
namespace App\Framework\Exception;
use App\Framework\Http\RequestId;
use App\Framework\UserAgent\UserAgent;
final readonly class RequestContext
{
public function __construct(
@@ -13,10 +16,40 @@ final readonly class RequestContext
public ?string $port = null,
public ?string $requestUri = null,
public ?string $requestMethod = null,
public ?string $userAgent = null,
public ?UserAgent $userAgent = null,
public ?string $clientIp = null,
public ?RequestId $requestId = null,
public array $headers = []
) {}
) {
}
// TODO: Get Requestdata from Request Object in Container
public static function create(
?string $clientIp = null,
?UserAgent $userAgent = null,
?string $requestMethod = null,
?string $requestUri = null,
?string $hostIp = null,
?string $hostname = null,
?string $protocol = null,
?string $port = null,
?RequestId $requestId = null,
array $headers = []
): self {
return new self(
hostIp: $hostIp,
hostname: $hostname,
protocol: $protocol,
port: $port,
requestUri: $requestUri,
requestMethod: $requestMethod,
userAgent: $userAgent,
clientIp: $clientIp,
requestId: $requestId,
headers: $headers
);
}
public static function fromGlobals(): self
{
@@ -27,7 +60,7 @@ final readonly class RequestContext
port: $_SERVER['SERVER_PORT'] ?? null,
requestUri: $_SERVER['REQUEST_URI'] ?? null,
requestMethod: $_SERVER['REQUEST_METHOD'] ?? null,
userAgent: $_SERVER['HTTP_USER_AGENT'] ?? null,
userAgent: isset($_SERVER['HTTP_USER_AGENT']) ? UserAgent::fromString($_SERVER['HTTP_USER_AGENT']) : null,
clientIp: self::getClientIp(),
headers: self::getHeaders()
);
@@ -47,9 +80,10 @@ final readonly class RequestContext
'port' => $this->port,
'request_uri' => $this->requestUri,
'request_method' => $this->requestMethod,
'user_agent' => $this->userAgent,
'user_agent' => $this->userAgent?->value,
'client_ip' => $this->clientIp,
'headers' => $this->headers
'request_id' => $this->requestId?->toString(),
'headers' => $this->headers,
];
}
@@ -62,11 +96,11 @@ final readonly class RequestContext
'HTTP_X_CLUSTER_CLIENT_IP', // Cluster
'HTTP_FORWARDED_FOR', // Proxy
'HTTP_FORWARDED', // Proxy
'REMOTE_ADDR' // Standard
'REMOTE_ADDR', // Standard
];
foreach ($ipKeys as $key) {
if (!empty($_SERVER[$key])) {
if (! empty($_SERVER[$key])) {
$ips = explode(',', $_SERVER[$key]);
$ip = trim($ips[0]);
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
@@ -81,9 +115,24 @@ final readonly class RequestContext
private static function getHeaders(): array
{
$headers = [];
$maxHeaders = 20; // EMERGENCY: Limit to 20 headers max
$maxHeaderSize = 1024; // EMERGENCY: Max 1KB per header
$count = 0;
foreach ($_SERVER as $key => $value) {
if (str_starts_with($key, 'HTTP_')) {
// EMERGENCY: Prevent memory exhaustion from massive headers
if (++$count > $maxHeaders) {
$headers['X-Headers-Truncated'] = 'true';
break;
}
// EMERGENCY: Truncate massive header values
if (strlen($value) > $maxHeaderSize) {
$value = substr($value, 0, $maxHeaderSize) . '...[TRUNCATED]';
}
$header = str_replace('_', '-', substr($key, 5));
$header = ucwords(strtolower($header), '-');
$headers[$header] = $value;

View File

@@ -0,0 +1,342 @@
<?php
declare(strict_types=1);
namespace App\Framework\Exception\Security;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\ExceptionContext;
use App\Framework\Exception\FrameworkException;
/**
* Ausnahme für Path-Traversal-Angriffs-Versuche
*
* Verwendet OWASP-konforme Nachrichten für Path-Traversal-Detection
*/
final class PathTraversalAttemptException extends FrameworkException
{
/**
* @param string $clientIp Client-IP für Security-Tracking
* @param string $requestedPath Angeforderte Pfad
* @param string $pattern Erkanntes Path-Traversal-Pattern
* @param string $attackContext Kontext (file_access, url_parameter, etc.)
* @param \Throwable|null $previous Vorherige Ausnahme
*/
public function __construct(
public readonly string $clientIp,
public readonly string $requestedPath,
public readonly string $pattern,
public readonly string $attackContext = 'file_access',
?\Throwable $previous = null
) {
// OWASP-konforme Nachricht mit Platzhaltern
$message = "Client {$this->clientIp} attempted path traversal: {$this->requestedPath}";
$context = ExceptionContext::forOperation('security.path_traversal_detection', 'Security')
->withData([
'client_ip' => $this->clientIp,
'requested_path' => $this->requestedPath,
'pattern' => $this->pattern,
'context' => $this->attackContext,
'path_depth' => $this->calculateTraversalDepth(),
'event_identifier' => "security_path_traversal:{$this->clientIp}",
'category' => 'file_access',
'requires_alert' => true, // Path-Traversal-Versuche erfordern immer Alerts
])
->withMetadata([
'security_event' => true,
'owasp_compliant' => true,
'log_level' => 'ERROR',
'attack_type' => 'path_traversal',
'critical_security_event' => true,
]);
parent::__construct(
message: $message,
context: $context,
code: 400, // Bad Request
previous: $previous,
errorCode: ErrorCode::SECURITY_PATH_TRAVERSAL
);
}
// === Factory Methods für verschiedene Path-Traversal-Patterns ===
public static function dotDotSlash(string $clientIp, string $path): self
{
return new self($clientIp, $path, '../ pattern', 'file_access');
}
public static function dotDotBackslash(string $clientIp, string $path): self
{
return new self($clientIp, $path, '..\ pattern', 'file_access');
}
public static function encodedTraversal(string $clientIp, string $path): self
{
return new self($clientIp, $path, 'URL-encoded traversal', 'file_access');
}
public static function unicodeTraversal(string $clientIp, string $path): self
{
return new self($clientIp, $path, 'Unicode-encoded traversal', 'file_access');
}
public static function absolutePath(string $clientIp, string $path): self
{
return new self($clientIp, $path, 'Absolute path access', 'file_access');
}
public static function systemPath(string $clientIp, string $path): self
{
return new self($clientIp, $path, 'System directory access', 'system_access');
}
public static function configFileAccess(string $clientIp, string $path): self
{
return new self($clientIp, $path, 'Configuration file access', 'config_access');
}
/**
* Gibt OWASP-konforme Event-Daten zurück
*/
public function getSecurityEventData(): array
{
return [
'event_identifier' => "security_path_traversal:{$this->clientIp}",
'description' => "Client {$this->clientIp} attempted path traversal: {$this->requestedPath}",
'category' => 'file_access',
'log_level' => 'ERROR',
'requires_alert' => true,
'client_ip' => $this->clientIp,
'requested_path' => $this->requestedPath,
'pattern' => $this->pattern,
'context' => $this->attackContext,
'attack_severity' => $this->getAttackSeverity(),
];
}
/**
* Bestimmt Schweregrad des Path-Traversal-Angriffs
*/
public function getAttackSeverity(): string
{
// Systemverzeichnisse sind kritisch
$criticalPaths = ['/etc/', '/var/', '/usr/', '/sys/', '/proc/', 'C:\Windows', 'C:\System'];
foreach ($criticalPaths as $critical) {
if (str_contains($this->requestedPath, $critical)) {
return 'CRITICAL';
}
}
// Konfigurationsdateien sind hochriskant
$sensitiveFiles = ['.env', 'config', 'passwd', 'shadow', 'hosts', 'web.config'];
foreach ($sensitiveFiles as $sensitive) {
if (str_contains($this->requestedPath, $sensitive)) {
return 'HIGH';
}
}
// Deep traversal ist verdächtig
if ($this->calculateTraversalDepth() > 3) {
return 'HIGH';
}
return 'MEDIUM';
}
/**
* Berechnet die Tiefe des Traversal-Versuchs
*/
public function calculateTraversalDepth(): int
{
return substr_count($this->requestedPath, '../') + substr_count($this->requestedPath, '..\\');
}
/**
* Path-Traversal-Versuche erfordern immer Alerts
*/
public function requiresAlert(): bool
{
return true;
}
/**
* Prüft ob es ein automatisierter Angriff ist
*/
public function isAutomatedAttack(): bool
{
// Typische automatisierte Scanner-Patterns
$automatedPatterns = [
'../../../../../../../../etc/passwd',
'..\\..\\..\\..\\windows\\system32',
'%2e%2e%2f', // URL-encoded ../
'\x2e\x2e\x2f', // Hex-encoded
];
$lowerPath = strtolower($this->requestedPath);
foreach ($automatedPatterns as $pattern) {
if (str_contains($lowerPath, strtolower($pattern))) {
return true;
}
}
return false;
}
/**
* Analysiert das Path-Traversal-Pattern detailliert
*/
public function analyzePattern(): array
{
$analysis = [
'encoding_type' => 'none',
'target_os' => 'unknown',
'target_files' => [],
'traversal_depth' => $this->calculateTraversalDepth(),
'automation_detected' => $this->isAutomatedAttack(),
'evasion_techniques' => [],
];
// Encoding-Erkennung
if (str_contains($this->requestedPath, '%')) {
$analysis['encoding_type'] = 'url_encoded';
} elseif (str_contains($this->requestedPath, '\x')) {
$analysis['encoding_type'] = 'hex_encoded';
} elseif (preg_match('/\\u[0-9a-f]{4}/i', $this->requestedPath)) {
$analysis['encoding_type'] = 'unicode_encoded';
}
// OS-Erkennung
if (str_contains($this->requestedPath, '\\')) {
$analysis['target_os'] = 'windows';
} elseif (str_contains($this->requestedPath, '/')) {
$analysis['target_os'] = 'unix';
}
// Zieldateien identifizieren
$targetFiles = [
'passwd' => 'unix_password_file',
'shadow' => 'unix_shadow_file',
'hosts' => 'hosts_file',
'.env' => 'environment_file',
'config' => 'configuration_file',
'web.config' => 'iis_config',
'httpd.conf' => 'apache_config',
'nginx.conf' => 'nginx_config',
];
foreach ($targetFiles as $file => $description) {
if (str_contains(strtolower($this->requestedPath), $file)) {
$analysis['target_files'][] = $description;
}
}
// Evasion-Techniken
if (str_contains($this->requestedPath, './')) {
$analysis['evasion_techniques'][] = 'current_directory_reference';
}
if (preg_match('/\.{3,}/', $this->requestedPath)) {
$analysis['evasion_techniques'][] = 'multiple_dots';
}
if (str_contains($this->requestedPath, '//')) {
$analysis['evasion_techniques'][] = 'double_slash';
}
return $analysis;
}
/**
* Gibt benutzerfreundliche Fehlermeldung zurück (ohne Details zu verraten)
*/
public function getUserMessage(): string
{
return "Invalid file path. Access denied.";
}
/**
* Gibt spezifische Verteidigungsempfehlung zurück
*/
public function getDefenseRecommendation(): string
{
return match ($this->attackContext) {
'file_access' => 'Implement proper path validation, use allowlists, and restrict file access to specific directories.',
'system_access' => 'Block access to system directories, implement strict path validation, and use security contexts.',
'config_access' => 'Secure configuration files, implement access controls, and use environment variables.',
default => 'Implement comprehensive path validation and access controls.',
};
}
/**
* Generiert IOC (Indicator of Compromise) für Security-Teams
*/
public function generateIOC(): array
{
return [
'type' => 'path_traversal_attempt',
'source_ip' => $this->clientIp,
'requested_path' => $this->requestedPath,
'pattern' => $this->pattern,
'context' => $this->attackContext,
'severity' => $this->getAttackSeverity(),
'timestamp' => date('Y-m-d H:i:s'),
'automated' => $this->isAutomatedAttack(),
'analysis' => $this->analyzePattern(),
];
}
/**
* Prüft ob sofortige Gegenmaßnahmen erforderlich sind
*/
public function requiresImmediateAction(): bool
{
return in_array($this->getAttackSeverity(), ['HIGH', 'CRITICAL']) ||
$this->isAutomatedAttack() ||
$this->calculateTraversalDepth() > 5;
}
/**
* Gibt WAF-Regel-Vorschläge zurück
*/
public function getWafRuleSuggestions(): array
{
$rules = [
'Block requests containing ../ or ..\\ patterns',
'Block requests with URL-encoded path traversal sequences',
'Block access to sensitive file extensions (.env, .config, etc.)',
];
$analysis = $this->analyzePattern();
if ($analysis['encoding_type'] !== 'none') {
$rules[] = 'Implement URL decoding before path traversal detection';
}
if (! empty($analysis['target_files'])) {
$rules[] = 'Block access to system configuration files and password files';
}
if ($analysis['traversal_depth'] > 3) {
$rules[] = 'Limit maximum directory traversal depth';
}
return $rules;
}
/**
* Gibt sichere Alternative für File-Access vor
*/
public function getSecureAlternatives(): array
{
return [
'Use file IDs instead of file paths in URLs',
'Implement a file mapping table',
'Restrict file access to a specific directory',
'Use symbolic links instead of direct paths',
'Implement role-based file access controls',
'Validate file paths against an allowlist',
];
}
}

View File

@@ -0,0 +1,284 @@
<?php
declare(strict_types=1);
namespace App\Framework\Exception\Security;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\ExceptionContext;
use App\Framework\Exception\FrameworkException;
/**
* Ausnahme für SQL-Injection-Versuche
*
* Verwendet OWASP-konforme Nachrichten für SQL-Injection-Detection
*/
final class SqlInjectionAttemptException extends FrameworkException
{
/**
* @param string $clientIp Client-IP für Security-Tracking
* @param string $field Betroffenes Feld/Parameter
* @param string $pattern Erkanntes SQL-Injection-Pattern
* @param string $originalValue Ursprünglicher Wert (sanitized)
* @param string $detectionMethod Erkennungsmethode (regex, heuristic, etc.)
* @param \Throwable|null $previous Vorherige Ausnahme
*/
public function __construct(
public readonly string $clientIp,
public readonly string $field,
public readonly string $pattern,
public readonly string $originalValue,
public readonly string $detectionMethod = 'pattern_match',
?\Throwable $previous = null
) {
// OWASP-konforme Nachricht mit Platzhaltern
$message = "Client {$this->clientIp} attempted SQL injection in field {$this->field}";
$context = ExceptionContext::forOperation('security.sql_injection_detection', 'Security')
->withData([
'client_ip' => $this->clientIp,
'field' => $this->field,
'pattern' => $this->pattern,
'detection_method' => $this->detectionMethod,
'value_length' => strlen($this->originalValue),
'event_identifier' => "security_sql_injection:{$this->clientIp},{$this->field}",
'category' => 'input_validation',
'requires_alert' => true, // SQL-Injection-Versuche erfordern immer Alerts
])
->withDebug([
'sanitized_value' => $this->sanitizeValueForLog($this->originalValue),
])
->withMetadata([
'security_event' => true,
'owasp_compliant' => true,
'log_level' => 'ERROR',
'attack_type' => 'sql_injection',
'critical_security_event' => true,
]);
parent::__construct(
message: $message,
context: $context,
code: 400, // Bad Request
previous: $previous,
errorCode: ErrorCode::SECURITY_SQL_INJECTION
);
}
// === Factory Methods für verschiedene SQL-Injection-Patterns ===
public static function unionSelect(string $clientIp, string $field, string $value): self
{
return new self($clientIp, $field, 'UNION SELECT', $value, 'union_detection');
}
public static function commentInjection(string $clientIp, string $field, string $value): self
{
return new self($clientIp, $field, 'SQL Comment (-- or /*)', $value, 'comment_detection');
}
public static function blindSqlInjection(string $clientIp, string $field, string $value): self
{
return new self($clientIp, $field, 'Blind SQL Injection', $value, 'blind_detection');
}
public static function timeBasedInjection(string $clientIp, string $field, string $value): self
{
return new self($clientIp, $field, 'Time-based SQL Injection', $value, 'time_based_detection');
}
public static function errorBasedInjection(string $clientIp, string $field, string $value): self
{
return new self($clientIp, $field, 'Error-based SQL Injection', $value, 'error_based_detection');
}
public static function booleanBasedInjection(string $clientIp, string $field, string $value): self
{
return new self($clientIp, $field, 'Boolean-based SQL Injection', $value, 'boolean_detection');
}
public static function stackedQueries(string $clientIp, string $field, string $value): self
{
return new self($clientIp, $field, 'Stacked Queries', $value, 'stacked_queries_detection');
}
/**
* Gibt OWASP-konforme Event-Daten zurück
*/
public function getSecurityEventData(): array
{
return [
'event_identifier' => "security_sql_injection:{$this->clientIp},{$this->field}",
'description' => "Client {$this->clientIp} attempted SQL injection in field {$this->field}",
'category' => 'input_validation',
'log_level' => 'ERROR',
'requires_alert' => true,
'client_ip' => $this->clientIp,
'field' => $this->field,
'pattern' => $this->pattern,
'detection_method' => $this->detectionMethod,
'attack_severity' => $this->getAttackSeverity(),
];
}
/**
* Bestimmt Schweregrad des Angriffs
*/
public function getAttackSeverity(): string
{
return match ($this->detectionMethod) {
'union_detection' => 'HIGH',
'stacked_queries_detection' => 'CRITICAL',
'error_based_detection' => 'HIGH',
'time_based_detection' => 'MEDIUM',
'blind_detection' => 'MEDIUM',
'boolean_detection' => 'MEDIUM',
'comment_detection' => 'LOW',
default => 'MEDIUM',
};
}
/**
* SQL-Injection-Versuche erfordern immer Alerts
*/
public function requiresAlert(): bool
{
return true;
}
/**
* Prüft ob es ein automatisierter Angriff ist
*/
public function isAutomatedAttack(): bool
{
// Heuristiken für automatisierte Angriffe
$automatedPatterns = [
'sqlmap',
'havij',
'pangolin',
'union select',
'order by',
'group by',
];
$lowerValue = strtolower($this->originalValue);
foreach ($automatedPatterns as $pattern) {
if (str_contains($lowerValue, $pattern)) {
return true;
}
}
return false;
}
/**
* Analysiert das SQL-Injection-Pattern
*/
public function analyzePattern(): array
{
$analysis = [
'type' => 'unknown',
'technique' => 'unknown',
'payload_type' => 'unknown',
'database_fingerprint' => 'unknown',
'automation_detected' => $this->isAutomatedAttack(),
];
$lowerValue = strtolower($this->originalValue);
// Typ-Erkennung
if (str_contains($lowerValue, 'union')) {
$analysis['type'] = 'union_based';
} elseif (str_contains($lowerValue, 'sleep(') || str_contains($lowerValue, 'waitfor')) {
$analysis['type'] = 'time_based';
} elseif (str_contains($lowerValue, '1=1') || str_contains($lowerValue, '1=0')) {
$analysis['type'] = 'boolean_based';
} elseif (str_contains($lowerValue, 'error') || str_contains($lowerValue, 'convert(')) {
$analysis['type'] = 'error_based';
}
// Technik-Erkennung
if (str_contains($lowerValue, '/*') || str_contains($lowerValue, '--')) {
$analysis['technique'] = 'comment_bypassing';
} elseif (str_contains($lowerValue, 'char(') || str_contains($lowerValue, 'ascii(')) {
$analysis['technique'] = 'encoding_bypassing';
} elseif (str_contains($lowerValue, ';')) {
$analysis['technique'] = 'stacked_queries';
}
// Database-Fingerprinting
if (str_contains($lowerValue, 'mysql') || str_contains($lowerValue, 'information_schema')) {
$analysis['database_fingerprint'] = 'mysql';
} elseif (str_contains($lowerValue, 'postgres') || str_contains($lowerValue, 'pg_')) {
$analysis['database_fingerprint'] = 'postgresql';
} elseif (str_contains($lowerValue, 'mssql') || str_contains($lowerValue, 'sys.')) {
$analysis['database_fingerprint'] = 'mssql';
} elseif (str_contains($lowerValue, 'oracle') || str_contains($lowerValue, 'dual')) {
$analysis['database_fingerprint'] = 'oracle';
}
return $analysis;
}
/**
* Gibt benutzerfreundliche Fehlermeldung zurück (ohne Details zu verraten)
*/
public function getUserMessage(): string
{
return "Invalid input detected. Please check your data and try again.";
}
/**
* Bereinigt Wert für sicheres Logging
*/
private function sanitizeValueForLog(string $value): string
{
// Begrenzen auf 100 Zeichen und entferne gefährliche Zeichen
$sanitized = substr($value, 0, 100);
$sanitized = preg_replace('/[<>"\']/', '*', $sanitized);
$sanitized = preg_replace('/\s+/', ' ', $sanitized);
return trim($sanitized);
}
/**
* Gibt Empfehlung für Verteidigung zurück
*/
public function getDefenseRecommendation(): string
{
return match ($this->detectionMethod) {
'union_detection' => 'Implement parameterized queries and input validation.',
'stacked_queries_detection' => 'Disable multiple statements and use stored procedures.',
'error_based_detection' => 'Implement proper error handling and logging.',
'time_based_detection' => 'Add request timeouts and rate limiting.',
'blind_detection' => 'Implement comprehensive input validation and WAF rules.',
default => 'Use parameterized queries, input validation, and proper escaping.',
};
}
/**
* Generiert IOC (Indicator of Compromise) für Security-Teams
*/
public function generateIOC(): array
{
return [
'type' => 'sql_injection_attempt',
'source_ip' => $this->clientIp,
'target_field' => $this->field,
'pattern' => $this->pattern,
'detection_method' => $this->detectionMethod,
'severity' => $this->getAttackSeverity(),
'timestamp' => date('Y-m-d H:i:s'),
'automated' => $this->isAutomatedAttack(),
'analysis' => $this->analyzePattern(),
];
}
/**
* Prüft ob sofortige Gegenmaßnahmen erforderlich sind
*/
public function requiresImmediateAction(): bool
{
return in_array($this->getAttackSeverity(), ['HIGH', 'CRITICAL']) || $this->isAutomatedAttack();
}
}

View File

@@ -0,0 +1,365 @@
<?php
declare(strict_types=1);
namespace App\Framework\Exception\Security;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\ExceptionContext;
use App\Framework\Exception\FrameworkException;
/**
* Ausnahme für XSS-Angriffs-Versuche
*
* Verwendet OWASP-konforme Nachrichten für XSS-Detection
*/
final class XssAttemptException extends FrameworkException
{
/**
* @param string $clientIp Client-IP für Security-Tracking
* @param string $field Betroffenes Feld/Parameter
* @param string $pattern Erkanntes XSS-Pattern
* @param string $originalValue Ursprünglicher Wert (sanitized)
* @param string $xssType Art des XSS (reflected, stored, dom)
* @param \Throwable|null $previous Vorherige Ausnahme
*/
public function __construct(
public readonly string $clientIp,
public readonly string $field,
public readonly string $pattern,
public readonly string $originalValue,
public readonly string $xssType = 'reflected',
?\Throwable $previous = null
) {
// OWASP-konforme Nachricht mit Platzhaltern
$message = "Client {$this->clientIp} attempted XSS attack in field {$this->field}";
$context = ExceptionContext::forOperation('security.xss_detection', 'Security')
->withData([
'client_ip' => $this->clientIp,
'field' => $this->field,
'pattern' => $this->pattern,
'xss_type' => $this->xssType,
'value_length' => strlen($this->originalValue),
'event_identifier' => "security_xss_attempt:{$this->clientIp},{$this->field}",
'category' => 'input_validation',
'requires_alert' => true, // XSS-Versuche erfordern immer Alerts
])
->withDebug([
'sanitized_value' => $this->sanitizeValueForLog($this->originalValue),
])
->withMetadata([
'security_event' => true,
'owasp_compliant' => true,
'log_level' => 'ERROR',
'attack_type' => 'xss',
'critical_security_event' => true,
]);
parent::__construct(
message: $message,
context: $context,
code: 400, // Bad Request
previous: $previous,
errorCode: ErrorCode::SECURITY_XSS_ATTEMPT
);
}
// === Factory Methods für verschiedene XSS-Patterns ===
public static function scriptTag(string $clientIp, string $field, string $value): self
{
return new self($clientIp, $field, '<script> tag injection', $value, 'reflected');
}
public static function onEventHandler(string $clientIp, string $field, string $value): self
{
return new self($clientIp, $field, 'Event handler injection (onclick, onload, etc.)', $value, 'reflected');
}
public static function javascriptProtocol(string $clientIp, string $field, string $value): self
{
return new self($clientIp, $field, 'javascript: protocol injection', $value, 'reflected');
}
public static function htmlInjection(string $clientIp, string $field, string $value): self
{
return new self($clientIp, $field, 'HTML tag injection', $value, 'reflected');
}
public static function domBasedXss(string $clientIp, string $field, string $value): self
{
return new self($clientIp, $field, 'DOM-based XSS pattern', $value, 'dom');
}
public static function storedXss(string $clientIp, string $field, string $value): self
{
return new self($clientIp, $field, 'Stored XSS pattern', $value, 'stored');
}
public static function cssInjection(string $clientIp, string $field, string $value): self
{
return new self($clientIp, $field, 'CSS injection with expression()', $value, 'reflected');
}
public static function svgXss(string $clientIp, string $field, string $value): self
{
return new self($clientIp, $field, 'SVG-based XSS injection', $value, 'reflected');
}
/**
* Gibt OWASP-konforme Event-Daten zurück
*/
public function getSecurityEventData(): array
{
return [
'event_identifier' => "security_xss_attempt:{$this->clientIp},{$this->field}",
'description' => "Client {$this->clientIp} attempted XSS attack in field {$this->field}",
'category' => 'input_validation',
'log_level' => 'ERROR',
'requires_alert' => true,
'client_ip' => $this->clientIp,
'field' => $this->field,
'pattern' => $this->pattern,
'xss_type' => $this->xssType,
'attack_severity' => $this->getAttackSeverity(),
];
}
/**
* Bestimmt Schweregrad des XSS-Angriffs
*/
public function getAttackSeverity(): string
{
return match ($this->xssType) {
'stored' => 'CRITICAL', // Stored XSS ist am gefährlichsten
'dom' => 'HIGH', // DOM-based XSS ist schwer zu erkennen
'reflected' => 'MEDIUM', // Reflected XSS ist häufig aber weniger persistent
default => 'MEDIUM',
};
}
/**
* XSS-Versuche erfordern immer Alerts
*/
public function requiresAlert(): bool
{
return true;
}
/**
* Prüft ob es ein automatisierter Angriff ist
*/
public function isAutomatedAttack(): bool
{
$automatedPatterns = [
'alert(',
'prompt(',
'confirm(',
'document.cookie',
'xss',
'<svg',
'javascript:',
'onerror=',
'onload=',
];
$lowerValue = strtolower($this->originalValue);
foreach ($automatedPatterns as $pattern) {
if (str_contains($lowerValue, $pattern)) {
return true;
}
}
return false;
}
/**
* Analysiert das XSS-Pattern detailliert
*/
public function analyzePattern(): array
{
$analysis = [
'vector_type' => 'unknown',
'payload_complexity' => 'low',
'encoding_used' => false,
'obfuscation_detected' => false,
'automation_detected' => $this->isAutomatedAttack(),
'potential_impact' => 'low',
];
$lowerValue = strtolower($this->originalValue);
// Vector-Typ-Erkennung
if (str_contains($lowerValue, '<script')) {
$analysis['vector_type'] = 'script_tag';
} elseif (str_contains($lowerValue, 'on') && preg_match('/on\w+\s*=/', $lowerValue)) {
$analysis['vector_type'] = 'event_handler';
} elseif (str_contains($lowerValue, 'javascript:')) {
$analysis['vector_type'] = 'javascript_protocol';
} elseif (str_contains($lowerValue, '<svg') || str_contains($lowerValue, '<embed')) {
$analysis['vector_type'] = 'svg_embed';
} elseif (str_contains($lowerValue, 'expression(')) {
$analysis['vector_type'] = 'css_expression';
}
// Komplexität bewerten
if (strlen($this->originalValue) > 100) {
$analysis['payload_complexity'] = 'high';
} elseif (strlen($this->originalValue) > 50) {
$analysis['payload_complexity'] = 'medium';
}
// Encoding-Erkennung
if (str_contains($this->originalValue, '%') || str_contains($this->originalValue, '&#')) {
$analysis['encoding_used'] = true;
}
// Obfuskierung-Erkennung
if (preg_match('/String\.fromCharCode|eval\(|unescape\(/', $this->originalValue)) {
$analysis['obfuscation_detected'] = true;
$analysis['payload_complexity'] = 'high';
}
// Impact-Bewertung
if (str_contains($lowerValue, 'cookie') || str_contains($lowerValue, 'document')) {
$analysis['potential_impact'] = 'high';
} elseif (str_contains($lowerValue, 'alert') || str_contains($lowerValue, 'prompt')) {
$analysis['potential_impact'] = 'medium';
}
return $analysis;
}
/**
* Gibt benutzerfreundliche Fehlermeldung zurück (ohne Details zu verraten)
*/
public function getUserMessage(): string
{
return "Invalid input detected. Please check your data and try again.";
}
/**
* Bereinigt Wert für sicheres Logging
*/
private function sanitizeValueForLog(string $value): string
{
// HTML-Entities kodieren und auf 100 Zeichen begrenzen
$sanitized = htmlspecialchars(substr($value, 0, 100), ENT_QUOTES, 'UTF-8');
// Zusätzliche Bereinigung für Logs
$sanitized = preg_replace('/\s+/', ' ', $sanitized);
return trim($sanitized);
}
/**
* Gibt spezifische Verteidigungsempfehlung zurück
*/
public function getDefenseRecommendation(): string
{
return match ($this->xssType) {
'stored' => 'Implement output encoding, Content Security Policy, and input validation.',
'dom' => 'Use safe JavaScript APIs, validate DOM manipulation, implement CSP.',
'reflected' => 'Implement output encoding, input validation, and Content Security Policy.',
default => 'Use output encoding, input validation, and Content Security Policy.',
};
}
/**
* Generiert Content Security Policy Empfehlungen
*/
public function getCspRecommendations(): array
{
$baseCSP = [
"default-src 'self'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"connect-src 'self'",
"font-src 'self'",
"object-src 'none'",
"frame-src 'none'",
];
$analysis = $this->analyzePattern();
// Verschärfungen basierend auf Angriffsmuster
if ($analysis['vector_type'] === 'script_tag') {
$baseCSP[] = "script-src 'self' 'nonce-{random}'"; // Nonce-basierte Scripts
}
if ($analysis['vector_type'] === 'css_expression') {
$baseCSP[] = "style-src 'self'"; // Entferne unsafe-inline
}
return $baseCSP;
}
/**
* Generiert IOC (Indicator of Compromise) für Security-Teams
*/
public function generateIOC(): array
{
return [
'type' => 'xss_attempt',
'source_ip' => $this->clientIp,
'target_field' => $this->field,
'pattern' => $this->pattern,
'xss_type' => $this->xssType,
'severity' => $this->getAttackSeverity(),
'timestamp' => date('Y-m-d H:i:s'),
'automated' => $this->isAutomatedAttack(),
'analysis' => $this->analyzePattern(),
];
}
/**
* Prüft ob sofortige Gegenmaßnahmen erforderlich sind
*/
public function requiresImmediateAction(): bool
{
return $this->xssType === 'stored' ||
$this->getAttackSeverity() === 'CRITICAL' ||
$this->isAutomatedAttack();
}
/**
* Gibt WAF (Web Application Firewall) Regel-Vorschläge zurück
*/
public function getWafRuleSuggestions(): array
{
$rules = [];
$analysis = $this->analyzePattern();
switch ($analysis['vector_type']) {
case 'script_tag':
$rules[] = 'Block requests containing <script tags in input fields';
break;
case 'event_handler':
$rules[] = 'Block requests containing on* event handlers in input fields';
break;
case 'javascript_protocol':
$rules[] = 'Block requests containing javascript: protocol in input fields';
break;
case 'svg_embed':
$rules[] = 'Block or sanitize SVG/embed tags in user input';
break;
}
if ($analysis['encoding_used']) {
$rules[] = 'Implement URL decoding before XSS detection';
}
if ($analysis['obfuscation_detected']) {
$rules[] = 'Implement advanced obfuscation detection rules';
}
return $rules;
}
}

View File

@@ -14,7 +14,8 @@ final readonly class AuthenticationAccountLockedEvent implements SecurityEventIn
public function __construct(
public string $userId,
public int $attempts
) {}
) {
}
public function getEventIdentifier(): string
{
@@ -50,7 +51,7 @@ final readonly class AuthenticationAccountLockedEvent implements SecurityEventIn
'event_identifier' => $this->getEventIdentifier(),
'category' => $this->getCategory(),
'log_level' => $this->getLogLevel()->value,
'requires_alert' => $this->requiresAlert()
'requires_alert' => $this->requiresAlert(),
];
}
}

View File

@@ -14,7 +14,8 @@ final readonly class AuthenticationLoginFailedEvent implements SecurityEventInte
public function __construct(
public string $userId,
public string $reason = 'invalid_credentials'
) {}
) {
}
public function getEventIdentifier(): string
{
@@ -50,7 +51,7 @@ final readonly class AuthenticationLoginFailedEvent implements SecurityEventInte
'event_identifier' => $this->getEventIdentifier(),
'category' => $this->getCategory(),
'log_level' => $this->getLogLevel()->value,
'requires_alert' => $this->requiresAlert()
'requires_alert' => $this->requiresAlert(),
];
}
}

View File

@@ -14,7 +14,8 @@ final readonly class AuthenticationLoginSuccessAfterFailEvent implements Securit
public function __construct(
public string $userId,
public int $retries
) {}
) {
}
public function getEventIdentifier(): string
{
@@ -50,7 +51,7 @@ final readonly class AuthenticationLoginSuccessAfterFailEvent implements Securit
'event_identifier' => $this->getEventIdentifier(),
'category' => $this->getCategory(),
'log_level' => $this->getLogLevel()->value,
'requires_alert' => $this->requiresAlert()
'requires_alert' => $this->requiresAlert(),
];
}
}

View File

@@ -13,7 +13,8 @@ final readonly class AuthenticationLoginSuccessEvent implements SecurityEventInt
{
public function __construct(
public string $userId
) {}
) {
}
public function getEventIdentifier(): string
{
@@ -47,7 +48,7 @@ final readonly class AuthenticationLoginSuccessEvent implements SecurityEventInt
'event_identifier' => $this->getEventIdentifier(),
'category' => $this->getCategory(),
'log_level' => $this->getLogLevel()->value,
'requires_alert' => $this->requiresAlert()
'requires_alert' => $this->requiresAlert(),
];
}
}

View File

@@ -15,7 +15,8 @@ final readonly class AuthorizationAccessDeniedEvent implements SecurityEventInte
public string $userId,
public string $resource,
public string $action = 'access'
) {}
) {
}
public function getEventIdentifier(): string
{
@@ -51,7 +52,7 @@ final readonly class AuthorizationAccessDeniedEvent implements SecurityEventInte
'event_identifier' => $this->getEventIdentifier(),
'category' => $this->getCategory(),
'log_level' => $this->getLogLevel()->value,
'requires_alert' => $this->requiresAlert()
'requires_alert' => $this->requiresAlert(),
];
}
}

View File

@@ -15,7 +15,8 @@ final readonly class AuthorizationAdminActionEvent implements SecurityEventInter
public string $userId,
public string $resource,
public string $action = 'admin_action'
) {}
) {
}
public function getEventIdentifier(): string
{
@@ -52,7 +53,7 @@ final readonly class AuthorizationAdminActionEvent implements SecurityEventInter
'event_identifier' => $this->getEventIdentifier(),
'category' => $this->getCategory(),
'log_level' => $this->getLogLevel()->value,
'requires_alert' => $this->requiresAlert()
'requires_alert' => $this->requiresAlert(),
];
}
}

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Framework\Exception\SecurityEvent;
use App\Framework\Exception\SecurityException;
use App\Framework\Exception\SecurityLogLevel;
/**
@@ -15,7 +14,8 @@ final readonly class InputSqlInjectionAttemptEvent implements SecurityEventInter
public function __construct(
public string $field,
public string $detectedPattern = 'generic_sql_pattern'
) {}
) {
}
public function getEventIdentifier(): string
{
@@ -51,7 +51,7 @@ final readonly class InputSqlInjectionAttemptEvent implements SecurityEventInter
'event_identifier' => $this->getEventIdentifier(),
'category' => $this->getCategory(),
'log_level' => $this->getLogLevel()->value,
'requires_alert' => $this->requiresAlert()
'requires_alert' => $this->requiresAlert(),
];
}
}

View File

@@ -14,7 +14,8 @@ final readonly class InputXssAttemptEvent implements SecurityEventInterface
public function __construct(
public string $field,
public string $detectedPattern = 'generic_xss_pattern'
) {}
) {
}
public function getEventIdentifier(): string
{
@@ -49,7 +50,7 @@ final readonly class InputXssAttemptEvent implements SecurityEventInterface
'event_identifier' => $this->getEventIdentifier(),
'category' => $this->getCategory(),
'log_level' => $this->getLogLevel()->value,
'requires_alert' => $this->requiresAlert()
'requires_alert' => $this->requiresAlert(),
];
}
}

View File

@@ -15,7 +15,8 @@ final readonly class SystemExcessiveUseEvent implements SecurityEventInterface
public string $identifier,
public int $limit,
public int $currentUsage
) {}
) {
}
public function getEventIdentifier(): string
{
@@ -52,7 +53,7 @@ final readonly class SystemExcessiveUseEvent implements SecurityEventInterface
'event_identifier' => $this->getEventIdentifier(),
'category' => $this->getCategory(),
'log_level' => $this->getLogLevel()->value,
'requires_alert' => $this->requiresAlert()
'requires_alert' => $this->requiresAlert(),
];
}
}

View File

@@ -28,7 +28,7 @@ class SecurityException extends FrameworkException
// Verwende Event-Beschreibung als Message falls nicht gesetzt
$finalMessage = $message ?: $securityEvent->getDescription();
parent::__construct($finalMessage, $code, $previous, $context);
parent::__construct($finalMessage, $context, $code, $previous);
}
/**
@@ -57,13 +57,13 @@ class SecurityException extends FrameworkException
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown',
'request_uri' => $_SERVER['REQUEST_URI'] ?? null,
'request_method' => $_SERVER['REQUEST_METHOD'] ?? null,
'timestamp' => time()
'timestamp' => time(),
])->withMetadata([
'security_event' => $securityEvent->getEventIdentifier(),
'security_level' => $securityEvent->getLogLevel()->value,
'security_description' => $securityEvent->getDescription(),
'requires_alert' => $securityEvent->requiresAlert(),
'event_category' => $securityEvent->getCategory()
'event_category' => $securityEvent->getCategory(),
]);
// Merge mit zusätzlichem Context falls vorhanden

View File

@@ -15,20 +15,6 @@ enum SecurityLogLevel: string
case ERROR = 'ERROR';
case FATAL = 'FATAL';
/**
* Konvertiert zu Standard-PSR-Log-Level
*/
public function toPsrLevel(): string
{
return match ($this) {
self::DEBUG => 'debug',
self::INFO => 'info',
self::WARN => 'warning',
self::ERROR => 'error',
self::FATAL => 'critical',
};
}
/**
* Numerischer Wert für Vergleiche
*/

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Framework\Exception;
use App\Framework\Performance\MemoryMonitor;
final readonly class SystemContext
{
public function __construct(
@@ -12,12 +14,15 @@ final readonly class SystemContext
public ?string $phpVersion = null,
public ?string $frameworkVersion = null,
public array $environment = []
) {}
) {
}
public static function current(): self
public static function current(?MemoryMonitor $memoryMonitor = null): self
{
return new self(
memoryUsage: self::formatBytes(memory_get_usage(true)),
memoryUsage: $memoryMonitor
? $memoryMonitor->getCurrentMemory()->toHumanReadable()
: self::formatBytes(memory_get_usage(true)),
executionTime: isset($_SERVER['REQUEST_TIME_FLOAT'])
? microtime(true) - $_SERVER['REQUEST_TIME_FLOAT']
: null,
@@ -28,7 +33,7 @@ final readonly class SystemContext
'sapi' => PHP_SAPI,
'timezone' => date_default_timezone_get(),
'memory_limit' => ini_get('memory_limit'),
'max_execution_time' => ini_get('max_execution_time')
'max_execution_time' => ini_get('max_execution_time'),
]
);
}
@@ -45,7 +50,7 @@ final readonly class SystemContext
'execution_time' => $this->executionTime,
'php_version' => $this->phpVersion,
'framework_version' => $this->frameworkVersion,
'environment' => $this->environment
'environment' => $this->environment,
];
}

View File

@@ -1,123 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Framework\Exception;
final class ValidationException extends FrameworkException
{
public readonly ValidationResult $validationResult;
public readonly array $errors;
public readonly string $field;
/**
* @param ValidationResult $validationResult Das Validierungsergebnis mit allen Fehlern
* @param string|null $field Optionaler einzelner Feldname für Rückwärtskompatibilität
*/
public function __construct(
ValidationResult $validationResult,
?string $field = null
) {
$this->validationResult = $validationResult;
// Für Rückwärtskompatibilität: Wenn nur ein Feld angegeben wurde, verwende dessen Fehler
if ($field !== null && $validationResult->getFieldErrors($field)) {
$this->field = $field;
$this->errors = $validationResult->getFieldErrors($field);
} else {
// Andernfalls verwende das erste Feld oder einen Standard
$allErrors = $validationResult->getAll();
$firstField = array_key_first($allErrors);
$this->field = $firstField ?? 'unknown';
$this->errors = $firstField ? $allErrors[$firstField] : [];
}
// Erstelle eine aussagekräftige Fehlernachricht aus allen Fehlern
$message = $this->createErrorMessage();
// Erstelle Exception-Kontext
$context = ExceptionContext::forOperation('validation.validate', 'Validator')
->withData([
'failed_fields' => array_keys($validationResult->getAll()),
'error_count' => count($validationResult->getAllErrorMessages()),
'primary_field' => $this->field,
'validation_errors' => $validationResult->getAll()
]);
parent::__construct(message: $message, context: $context);
}
/**
* Erstellt eine strukturierte Fehlernachricht aus allen Validierungsfehlern
*/
private function createErrorMessage(): string
{
$allErrors = $this->validationResult->getAll();
if (empty($allErrors)) {
return 'Unbekannter Validierungsfehler.';
}
$messages = [];
foreach ($allErrors as $field => $fieldErrors) {
$fieldMessage = $field . ': ' . implode(', ', $fieldErrors);
$messages[] = $fieldMessage;
}
return implode('; ', $messages);
}
/**
* Gibt alle Fehlermeldungen für ein bestimmtes Feld zurück
*
* @param string $field Feldname
* @return array<string> Liste der Fehlermeldungen für das Feld
*/
public function getFieldErrors(string $field): array
{
return $this->validationResult->getFieldErrors($field);
}
/**
* Gibt alle Fehlermeldungen als Array zurück
*
* @return array<string, string[]> Alle Fehlermeldungen gruppiert nach Feldern
*/
public function getAllErrors(): array
{
return $this->validationResult->getAll();
}
/**
* Gibt alle Fehlermeldungen als flache Liste zurück
*
* @return array<string> Liste aller Fehlermeldungen
*/
public function getAllErrorMessages(): array
{
return $this->validationResult->getAllErrorMessages();
}
/**
* Prüft, ob ein bestimmtes Feld Fehler hat
*/
public function hasFieldErrors(string $field): bool
{
return !empty($this->validationResult->getFieldErrors($field));
}
/**
* Statische Factory-Methode für einfache Einzelfeld-Fehler (Rückwärtskompatibilität)
*
* @param array<string> $errors Liste der Fehlermeldungen
* @param string $field Feldname
* @return self
*/
public static function forField(array $errors, string $field): self
{
$validationResult = new ValidationResult();
$validationResult->addErrors($field, $errors);
return new self($validationResult, $field);
}
}