feat: CI/CD pipeline setup complete - Ansible playbooks updated, secrets configured, workflow ready

This commit is contained in:
2025-10-31 01:39:24 +01:00
parent 55c04e4fd0
commit e26eb2aa12
601 changed files with 44184 additions and 32477 deletions

View File

@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging\Handlers;
use App\Framework\Logging\LogLevel;
use App\Framework\Logging\ValueObjects\LogContext;
use App\Framework\Logging\ValueObjects\LogEntry;
/**
* In-memory log handler for testing
*
* Stores all log entries in memory for inspection and assertions in tests
*/
final class InMemoryHandler implements LogHandler
{
/** @var LogEntry[] */
private array $entries = [];
public function handle(LogEntry $entry): void
{
$this->entries[] = $entry;
}
/**
* Get all logged entries
*
* @return LogEntry[]
*/
public function getEntries(): array
{
return $this->entries;
}
/**
* Get entries by log level
*
* @return LogEntry[]
*/
public function getEntriesByLevel(LogLevel $level): array
{
return array_filter(
$this->entries,
fn(LogEntry $entry) => $entry->level === $level
);
}
/**
* Check if a message was logged
*/
public function hasMessage(string $message): bool
{
foreach ($this->entries as $entry) {
if (str_contains($entry->message, $message)) {
return true;
}
}
return false;
}
/**
* Check if a message was logged at specific level
*/
public function hasMessageAtLevel(string $message, LogLevel $level): bool
{
foreach ($this->getEntriesByLevel($level) as $entry) {
if (str_contains($entry->message, $message)) {
return true;
}
}
return false;
}
/**
* Get number of logged entries
*/
public function count(): int
{
return count($this->entries);
}
/**
* Get number of entries by level
*/
public function countByLevel(LogLevel $level): int
{
return count($this->getEntriesByLevel($level));
}
/**
* Clear all logged entries
*/
public function clear(): void
{
$this->entries = [];
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging\Handlers;
use App\Framework\Logging\LogHandler;
use App\Framework\Logging\LogRecord;
/**
* NullHandler discards all log entries without any output
*
* Use Cases:
* - MCP Server mode (prevents log interference with JSON-RPC)
* - Testing environments where logging should be suppressed
* - Performance-critical paths where logging overhead is unacceptable
*/
final readonly class NullHandler implements LogHandler
{
public function isHandling(LogRecord $record): bool
{
// Accept all levels but discard them
return true;
}
public function handle(LogRecord $record): void
{
// Discard all log entries - no output
}
}

View File

@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging;
use App\Framework\Logging\ValueObjects\LogContext;
use DateTimeImmutable;
/**
* In-memory logger for testing
*
* Stores all log records in memory for inspection and assertions in tests.
* Implements the Logger interface without any actual I/O operations.
*/
final class InMemoryLogger implements Logger
{
/** @var LogRecord[] */
private array $records = [];
public function debug(string $message, ?LogContext $context = null): void
{
$this->log(LogLevel::DEBUG, $message, $context);
}
public function info(string $message, ?LogContext $context = null): void
{
$this->log(LogLevel::INFO, $message, $context);
}
public function notice(string $message, ?LogContext $context = null): void
{
$this->log(LogLevel::NOTICE, $message, $context);
}
public function warning(string $message, ?LogContext $context = null): void
{
$this->log(LogLevel::WARNING, $message, $context);
}
public function error(string $message, ?LogContext $context = null): void
{
$this->log(LogLevel::ERROR, $message, $context);
}
public function critical(string $message, ?LogContext $context = null): void
{
$this->log(LogLevel::CRITICAL, $message, $context);
}
public function alert(string $message, ?LogContext $context = null): void
{
$this->log(LogLevel::ALERT, $message, $context);
}
public function emergency(string $message, ?LogContext $context = null): void
{
$this->log(LogLevel::EMERGENCY, $message, $context);
}
public function log(LogLevel $level, string $message, ?LogContext $context = null): void
{
$context = $context ?? LogContext::empty();
$record = new LogRecord(
message: $message,
context: $context,
level: $level,
timestamp: new DateTimeImmutable()
);
$this->records[] = $record;
}
/**
* Get all logged records
*
* @return LogRecord[]
*/
public function getRecords(): array
{
return $this->records;
}
/**
* Get records by log level
*
* @return LogRecord[]
*/
public function getRecordsByLevel(LogLevel $level): array
{
return array_filter(
$this->records,
fn(LogRecord $record) => $record->level === $level
);
}
/**
* Check if a message was logged
*/
public function hasMessage(string $message): bool
{
foreach ($this->records as $record) {
if (str_contains($record->message, $message)) {
return true;
}
}
return false;
}
/**
* Check if a message was logged at specific level
*/
public function hasMessageAtLevel(string $message, LogLevel $level): bool
{
foreach ($this->getRecordsByLevel($level) as $record) {
if (str_contains($record->message, $message)) {
return true;
}
}
return false;
}
/**
* Get number of logged records
*/
public function count(): int
{
return count($this->records);
}
/**
* Get number of records by level
*/
public function countByLevel(LogLevel $level): int
{
return count($this->getRecordsByLevel($level));
}
/**
* Clear all logged records
*/
public function clear(): void
{
$this->records = [];
}
}

View File

@@ -14,6 +14,7 @@ use App\Framework\Logging\Handlers\DockerJsonHandler;
use App\Framework\Logging\Handlers\FileHandler;
use App\Framework\Logging\Handlers\JsonFileHandler;
use App\Framework\Logging\Handlers\MultiFileHandler;
use App\Framework\Logging\Handlers\NullHandler;
use App\Framework\Logging\Handlers\QueuedLogHandler;
use App\Framework\Logging\Handlers\WebHandler;
use App\Framework\Queue\FileQueue;
@@ -31,6 +32,20 @@ final readonly class LoggerInitializer
Container $container,
Environment $env
): Logger {
// MCP Server Mode: Use NullHandler to suppress all output
// This prevents log interference with JSON-RPC communication
if ($env->get(EnvKey::MCP_SERVER_MODE) === '1') {
$contextManager = new LogContextManager();
$processorManager = new ProcessorManager();
return new DefaultLogger(
minLevel: LogLevel::DEBUG,
handlers: [new NullHandler()],
processorManager: $processorManager,
contextManager: $contextManager
);
}
// LogContextManager als Singleton im Container registrieren
if (! $container->has(LogContextManager::class)) {
$contextManager = new LogContextManager();

View File

@@ -6,10 +6,20 @@ namespace App\Framework\Logging\Processors;
use App\Framework\Logging\LogProcessor;
use App\Framework\Logging\LogRecord;
use App\Framework\Logging\ValueObjects\ExceptionContext;
use Throwable;
/**
* Enriches log records with detailed exception information
* Enriches log records with detailed exception information via ExceptionContext
*
* Nutzt das moderne ExceptionContext Value Object System für strukturierte
* Exception-Daten statt Legacy-Array-Ansatz.
*
* Features:
* - ExceptionContext mit StackFrame Value Objects
* - Exception Hash für Pattern-Erkennung
* - Exception Severity Categorization
* - Short Stack Trace für Quick Overview
*/
final readonly class ExceptionEnrichmentProcessor implements LogProcessor
{
@@ -21,27 +31,41 @@ final readonly class ExceptionEnrichmentProcessor implements LogProcessor
public function processRecord(LogRecord $record): LogRecord
{
$context = $record->getContext();
// Prüfe ob bereits ExceptionContext vorhanden
if ($record->context->hasException()) {
$exceptionContext = $record->context->getException();
// Check for exception in context
$exception = $this->findExceptionInContext($context);
// Füge Enrichment-Daten zu Extra hinzu
return $record->addExtras([
'exception_hash' => $this->generateExceptionHash($exceptionContext),
'exception_severity' => $this->categorizeExceptionSeverity($exceptionContext->class),
'stack_trace_short' => $this->getShortStackTrace($exceptionContext),
'chain_length' => $exceptionContext->getChainLength(),
]);
}
// Suche nach Exception in Context (für Legacy-Support)
$exception = $this->findExceptionInContext($record->context->structured);
if ($exception === null) {
return $record;
}
// Enrich record with exception details
return $record->addExtras([
'exception_class' => get_class($exception),
'exception_message' => $exception->getMessage(),
'exception_code' => $exception->getCode(),
'exception_file' => $exception->getFile(),
'exception_line' => $exception->getLine(),
'stack_trace' => $this->formatStackTrace($exception),
'stack_trace_short' => $this->getShortStackTrace($exception),
'previous_exceptions' => $this->getPreviousExceptions($exception),
'exception_hash' => $this->generateExceptionHash($exception),
'exception_severity' => $this->categorizeExceptionSeverity($exception),
]);
// Erstelle ExceptionContext und füge zu Record hinzu
$exceptionContext = ExceptionContext::fromThrowable($exception);
return new LogRecord(
level: $record->level,
message: $record->message,
channel: $record->channel,
context: $record->context->withExceptionContext($exceptionContext),
timestamp: $record->timestamp,
extra: array_merge($record->extra, [
'exception_hash' => $this->generateExceptionHash($exceptionContext),
'exception_severity' => $this->categorizeExceptionSeverity($exceptionContext->class),
'stack_trace_short' => $this->getShortStackTrace($exceptionContext),
'chain_length' => $exceptionContext->getChainLength(),
])
);
}
public function getPriority(): int
@@ -55,7 +79,7 @@ final readonly class ExceptionEnrichmentProcessor implements LogProcessor
}
/**
* Find exception in context data
* Find exception in context data (Legacy-Support)
*/
private function findExceptionInContext(array $context): ?Throwable
{
@@ -75,40 +99,22 @@ final readonly class ExceptionEnrichmentProcessor implements LogProcessor
}
/**
* Format complete stack trace
* Get short stack trace from ExceptionContext (top 5 frames)
*/
private function formatStackTrace(Throwable $exception): array
private function getShortStackTrace(ExceptionContext $exceptionContext): string
{
$trace = $exception->getTrace();
$formatted = [];
foreach (array_slice($trace, 0, $this->maxStackTraceDepth) as $index => $frame) {
$formatted[] = [
'index' => $index,
'file' => $frame['file'] ?? '[internal]',
'line' => $frame['line'] ?? null,
'function' => $this->formatFunction($frame),
'class' => $frame['class'] ?? null,
'type' => $frame['type'] ?? null,
'args' => $this->includeArgs ? $this->formatArgs($frame['args'] ?? []) : null,
];
}
return $formatted;
}
/**
* Get short stack trace (top 5 frames)
*/
private function getShortStackTrace(Throwable $exception): string
{
$trace = $exception->getTrace();
$frames = $exceptionContext->getTopFrames(5);
$short = [];
foreach (array_slice($trace, 0, 5) as $frame) {
$file = basename($frame['file'] ?? '[internal]');
$line = $frame['line'] ?? '?';
$function = $this->formatFunction($frame);
foreach ($frames as $frame) {
$file = basename($frame->file);
$line = $frame->line;
$function = $frame->function ?? 'unknown';
if ($frame->class !== null) {
$class = $this->shortenClassName($frame->class);
$function = "{$class}{$frame->type}{$function}";
}
$short[] = "{$file}:{$line} {$function}()";
}
@@ -117,78 +123,34 @@ final readonly class ExceptionEnrichmentProcessor implements LogProcessor
}
/**
* Get all previous exceptions in chain
* Generate unique hash for exception pattern from ExceptionContext
*/
private function getPreviousExceptions(Throwable $exception): array
private function generateExceptionHash(ExceptionContext $exceptionContext): string
{
$previous = [];
$current = $exception->getPrevious();
while ($current !== null) {
$previous[] = [
'class' => get_class($current),
'message' => $current->getMessage(),
'code' => $current->getCode(),
'file' => $current->getFile(),
'line' => $current->getLine(),
];
$current = $current->getPrevious();
// Prevent infinite loops
if (count($previous) > 10) {
break;
}
}
return $previous;
}
/**
* Generate unique hash for exception pattern
*/
private function generateExceptionHash(Throwable $exception): string
{
$signature = get_class($exception) .
$exception->getFile() .
$exception->getLine() .
$exception->getMessage();
$signature = $exceptionContext->class .
$exceptionContext->file .
$exceptionContext->line .
$exceptionContext->message;
return substr(md5($signature), 0, 8);
}
/**
* Categorize exception severity
* Categorize exception severity by class name
*/
private function categorizeExceptionSeverity(Throwable $exception): string
private function categorizeExceptionSeverity(string $exceptionClass): string
{
return match(true) {
$exception instanceof \Error => 'fatal',
$exception instanceof \ParseError => 'fatal',
$exception instanceof \TypeError => 'high',
$exception instanceof \InvalidArgumentException => 'medium',
$exception instanceof \LogicException => 'high',
$exception instanceof \RuntimeException => 'medium',
str_contains($exceptionClass, 'Error') && !str_contains($exceptionClass, 'Exception') => 'fatal',
str_contains($exceptionClass, 'ParseError') => 'fatal',
str_contains($exceptionClass, 'TypeError') => 'high',
str_contains($exceptionClass, 'InvalidArgumentException') => 'medium',
str_contains($exceptionClass, 'LogicException') => 'high',
str_contains($exceptionClass, 'RuntimeException') => 'medium',
default => 'unknown'
};
}
/**
* Format function name with class context
*/
private function formatFunction(array $frame): string
{
if (isset($frame['class'])) {
$class = $this->shortenClassName($frame['class']);
$type = $frame['type'] ?? '::';
$function = $frame['function'] ?? 'unknown';
return "{$class}{$type}{$function}";
}
return $frame['function'] ?? 'unknown';
}
/**
* Shorten class name for readability
*/
@@ -203,22 +165,4 @@ final readonly class ExceptionEnrichmentProcessor implements LogProcessor
// Keep first and last part: App\...\Controller
return $parts[0] . '\\...\\' . end($parts);
}
/**
* Format function arguments safely
*/
private function formatArgs(array $args): array
{
return array_map(function ($arg) {
return match(true) {
is_object($arg) => get_class($arg) . '{}',
is_array($arg) => 'Array[' . count($arg) . ']',
is_string($arg) => '"' . substr($arg, 0, 50) . (strlen($arg) > 50 ? '...' : '') . '"',
is_numeric($arg) => (string) $arg,
is_bool($arg) => $arg ? 'true' : 'false',
is_null($arg) => 'null',
default => gettype($arg)
};
}, $args);
}
}

View File

@@ -1,244 +0,0 @@
<?php
<?php
declare(strict_types=1);
namespace App\Framework\Logging\Processors;
use App\Framework\Logging\LogProcessor;
use App\Framework\Logging\LogRecord;
use App\Framework\Logging\ValueObjects\ExceptionContext;
/**
* Exception Processor für automatisches Exception-Enrichment
*
* Erkennt Exceptions in strukturierten Daten und konvertiert sie
* zu strukturiertem ExceptionContext.
*
* Features:
* - Automatische Exception-Erkennung im Context
* - Strukturierte Exception-Daten
* - Stack Trace Parsing
* - Previous Exception Chain
*/
final class ExceptionProcessor implements LogProcessor
{
/**
* @var int Priorität des Processors (höher = früher)
*/
private const int PRIORITY = 15;
public function __construct(
private readonly bool $includeStackTrace = true,
private readonly bool $includePreviousExceptions = true
) {
}
public function process(LogRecord $record): LogRecord
{
// Bereits ExceptionContext vorhanden - nichts zu tun
if ($record->context->hasException()) {
return $record;
}
// Suche nach Exception in strukturierten Daten
$exception = $this->findException($record->context->structured);
if ($exception === null) {
return $record;
}
// Konvertiere zu ExceptionContext und füge hinzu
$exceptionContext = ExceptionContext::fromThrowable($exception);
return new LogRecord(
level: $record->level,
message: $record->message,
channel: $record->channel,
context: $record->context->withExceptionContext($exceptionContext),
timestamp: $record->timestamp,
extra: array_merge($record->extra, [
'exception_class' => $exceptionContext->getShortClass(),
'exception_message' => $exceptionContext->message,
'exception_location' => sprintf(
'%s:%d',
$exceptionContext->getShortFile(),
$exceptionContext->line
),
])
);
}
/**
* Sucht nach Throwable in strukturierten Daten
*
* @param array<string, mixed> $data
*/
private function findException(array $data): ?\Throwable
{
// Suche nach 'exception' Key
if (isset($data['exception']) && $data['exception'] instanceof \Throwable) {
return $data['exception'];
}
// Suche nach 'error' Key
if (isset($data['error']) && $data['error'] instanceof \Throwable) {
return $data['error'];
}
// Suche nach 'throwable' Key
if (isset($data['throwable']) && $data['throwable'] instanceof \Throwable) {
return $data['throwable'];
}
// Durchsuche alle Values
foreach ($data as $value) {
if ($value instanceof \Throwable) {
return $value;
}
}
return null;
}
public function getPriority(): int
{
return self::PRIORITY;
}
public function getName(): string
{
return 'exception';
}
}
declare(strict_types=1);
namespace App\Framework\Logging\Processors;
use App\Framework\Logging\LogProcessor;
use App\Framework\Logging\LogRecord;
/**
* Verarbeitet Exceptions im Kontext und fügt detaillierte Informationen hinzu.
*/
final class ExceptionProcessor implements LogProcessor
{
/**
* @var int Priorität des Processors (15 = früh, nach Interpolation)
*/
private const int PRIORITY = 15;
/**
* @var bool Ob Stack-Traces hinzugefügt werden sollen
*/
private bool $includeStackTraces;
/**
* @var int Maximale Tiefe für Stack-Traces
*/
private int $traceDepth;
/**
* Erstellt einen neuen ExceptionProcessor
*/
public function __construct(
bool $includeStackTraces = true,
int $traceDepth = 10
) {
$this->includeStackTraces = $includeStackTraces;
$this->traceDepth = $traceDepth;
}
/**
* Verarbeitet einen Log-Record und extrahiert Exception-Informationen
*/
public function processRecord(LogRecord $record): LogRecord
{
$context = $record->getContext();
// Prüfen, ob eine Exception im Kontext vorhanden ist
if (isset($context['exception']) && $context['exception'] instanceof \Throwable) {
$exception = $context['exception'];
$exceptionData = $this->formatException($exception);
// Exception-Daten zu Extra-Daten hinzufügen
$record = $record->withExtra('exception', $exceptionData);
}
return $record;
}
/**
* Formatiert eine Exception für das Logging
*/
private function formatException(\Throwable $exception): array
{
$result = [
'class' => get_class($exception),
'message' => $exception->getMessage(),
'code' => $exception->getCode(),
'file' => $exception->getFile(),
'line' => $exception->getLine(),
];
// Stack-Trace hinzufügen, wenn aktiviert
if ($this->includeStackTraces) {
$result['trace'] = $this->formatTrace($exception);
}
// Bei verschachtelten Exceptions auch die vorherige Exception formatieren
if ($exception->getPrevious()) {
$result['previous'] = $this->formatException($exception->getPrevious());
}
return $result;
}
/**
* Formatiert den Stack-Trace einer Exception
*/
private function formatTrace(\Throwable $exception): array
{
$trace = $exception->getTrace();
$result = [];
// Tiefe begrenzen
$trace = array_slice($trace, 0, $this->traceDepth);
foreach ($trace as $frame) {
$entry = [
'file' => $frame['file'] ?? 'unknown',
'line' => $frame['line'] ?? 0,
];
if (isset($frame['function'])) {
$function = '';
if (isset($frame['class'])) {
$function .= $frame['class'] . $frame['type'];
}
$function .= $frame['function'];
$entry['function'] = $function;
}
$result[] = $entry;
}
return $result;
}
/**
* Gibt die Priorität des Processors zurück
*/
public function getPriority(): int
{
return self::PRIORITY;
}
/**
* Gibt einen eindeutigen Namen für den Processor zurück
*/
public function getName(): string
{
return 'exception';
}
}

View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging\Processors;
use App\Framework\Logging\LogLevel;
use App\Framework\Logging\ValueObjects\LogContext;
/**
* Security Event Processor für OWASP-konforme Security Event Logs
*
* Verarbeitet LogContext mit SecurityContext und generiert OWASP-format logs.
*
* OWASP Logging Cheat Sheet:
* https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html
*/
final readonly class SecurityEventProcessor
{
/**
* Verarbeitet LogContext und generiert OWASP-format wenn SecurityContext vorhanden
*
* @param string $level Framework LogLevel
* @param string $message Log message
* @param LogContext $context LogContext mit potentiellem SecurityContext
* @return array Enhanced log data mit OWASP format
*/
public function process(string $level, string $message, LogContext $context): array
{
// Wenn kein SecurityContext vorhanden, return original data
if (!$context->hasSecurity()) {
return $context->toArray();
}
$security = $context->getSecurity();
// OWASP-Format generieren
$owaspData = [
'event_id' => $security->eventId,
'event_category' => $security->category ?? 'general',
'event_description' => $security->description,
'security_level' => $security->level->value,
'requires_alert' => $security->requiresAlert,
];
// User-Informationen hinzufügen
if ($security->userId !== null) {
$owaspData['user_id'] = $security->userId;
}
if ($security->username !== null) {
$owaspData['username'] = $security->username;
}
// Request-Informationen hinzufügen
if ($security->sourceIp !== null) {
$owaspData['source_ip'] = $security->sourceIp;
}
if ($security->userAgent !== null) {
$owaspData['user_agent'] = $security->userAgent;
}
// Event-spezifische Daten hinzufügen
if (!empty($security->eventData)) {
$owaspData['event_data'] = $security->eventData;
}
// Standard LogContext data mergen
$contextData = $context->toArray();
// OWASP data als top-level field hinzufügen
$contextData['owasp_security_event'] = $owaspData;
return $contextData;
}
/**
* Mapped SecurityLogLevel zu Framework LogLevel
*
* @param \App\Framework\Exception\SecurityLogLevel $securityLevel
* @return LogLevel
*/
public function mapSecurityLevelToLogLevel(\App\Framework\Exception\SecurityLogLevel $securityLevel): LogLevel
{
return match ($securityLevel) {
\App\Framework\Exception\SecurityLogLevel::DEBUG => LogLevel::DEBUG,
\App\Framework\Exception\SecurityLogLevel::INFO => LogLevel::INFO,
\App\Framework\Exception\SecurityLogLevel::WARN => LogLevel::WARNING,
\App\Framework\Exception\SecurityLogLevel::ERROR => LogLevel::ERROR,
\App\Framework\Exception\SecurityLogLevel::FATAL => LogLevel::CRITICAL,
};
}
/**
* Prüft, ob ein Alert für dieses Security Event gesendet werden soll
*
* @param LogContext $context
* @return bool
*/
public function shouldAlert(LogContext $context): bool
{
if (!$context->hasSecurity()) {
return false;
}
return $context->getSecurity()->requiresAlert;
}
}

View File

@@ -24,6 +24,7 @@ final readonly class LogContext
* @param UserContext|null $user Benutzer-Kontext für Audit-Logs
* @param RequestContext|null $request HTTP-Request-Kontext
* @param ExceptionContext|null $exception Exception-Kontext für strukturiertes Exception-Logging
* @param SecurityContext|null $security Security-Kontext für OWASP-konforme Security-Event-Logs
* @param array<string, mixed> $metadata Zusätzliche Metadaten
*/
public function __construct(
@@ -34,6 +35,7 @@ final readonly class LogContext
public ?UserContext $user = null,
public ?RequestContext $request = null,
public ?ExceptionContext $exception = null,
public ?SecurityContext $security = null,
public array $metadata = []
) {
}
@@ -75,6 +77,7 @@ final readonly class LogContext
user: $this->user,
request: $this->request,
exception: $this->exception,
security: $this->security,
metadata: $this->metadata
);
}
@@ -92,6 +95,7 @@ final readonly class LogContext
user: $this->user,
request: $this->request,
exception: $this->exception,
security: $this->security,
metadata: $this->metadata
);
}
@@ -109,6 +113,7 @@ final readonly class LogContext
user: $this->user,
request: $this->request,
exception: $this->exception,
security: $this->security,
metadata: $this->metadata
);
}
@@ -195,6 +200,7 @@ final readonly class LogContext
user: $this->user,
request: $this->request,
exception: $exception,
security: $this->security,
metadata: $this->metadata
);
}
@@ -236,6 +242,7 @@ final readonly class LogContext
user: $this->user,
request: $this->request,
exception: $this->exception,
security: $this->security,
metadata: $this->metadata
);
}
@@ -269,6 +276,7 @@ final readonly class LogContext
user: $this->user,
request: $this->request,
exception: $this->exception,
security: $this->security,
metadata: $this->metadata
);
}
@@ -286,6 +294,7 @@ final readonly class LogContext
user: $user,
request: $this->request,
exception: $this->exception,
security: $this->security,
metadata: $this->metadata
);
}
@@ -303,10 +312,53 @@ final readonly class LogContext
user: $this->user,
request: $request,
exception: $this->exception,
security: $this->security,
metadata: $this->metadata
);
}
/**
* Setzt Security-Kontext
*/
public function withSecurityContext(SecurityContext $security): self
{
return new self(
structured: $this->structured,
tags: $this->tags,
correlationId: $this->correlationId,
trace: $this->trace,
user: $this->user,
request: $this->request,
exception: $this->exception,
security: $security,
metadata: $this->metadata
);
}
/**
* Setzt Security-Kontext (Alias für withSecurityContext)
*/
public function withSecurity(SecurityContext $security): self
{
return $this->withSecurityContext($security);
}
/**
* Holt Security-Kontext
*/
public function getSecurity(): ?SecurityContext
{
return $this->security;
}
/**
* Prüft, ob Security-Kontext vorhanden ist
*/
public function hasSecurity(): bool
{
return $this->security !== null;
}
/**
* Fügt Metadaten hinzu
*/
@@ -320,6 +372,7 @@ final readonly class LogContext
user: $this->user,
request: $this->request,
exception: $this->exception,
security: $this->security,
metadata: array_merge($this->metadata, [$key => $value])
);
}
@@ -337,6 +390,7 @@ final readonly class LogContext
user: $other->user ?? $this->user,
request: $other->request ?? $this->request,
exception: $other->exception ?? $this->exception,
security: $other->security ?? $this->security,
metadata: array_merge($this->metadata, $other->metadata)
);
}
@@ -403,6 +457,10 @@ final readonly class LogContext
$result['exception'] = $this->exception->toArray();
}
if ($this->security !== null) {
$result['security'] = $this->security->toArray();
}
if (! empty($this->metadata)) {
$result['metadata'] = $this->metadata;
}

View File

@@ -0,0 +1,244 @@
<?php
declare(strict_types=1);
namespace App\Framework\Logging\ValueObjects;
use App\Framework\Exception\SecurityLogLevel;
/**
* Security Context Value Object für strukturiertes Security-Event-Logging
*
* Kapselt alle Security-relevanten Informationen für OWASP-konforme Logs.
* Nutzt Framework's SecurityLogLevel aus Exception-Modul.
*
* OWASP Logging Cheat Sheet:
* https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html
*/
final readonly class SecurityContext
{
/**
* @param string $eventId OWASP Event Identifier (z.B. "AUTHN_login_failure")
* @param SecurityLogLevel $level Security-spezifisches Log Level
* @param string $description Human-readable Event-Beschreibung
* @param string|null $category Event-Kategorie (z.B. "authentication", "authorization")
* @param bool $requiresAlert Ob Event ein Security-Alert triggern soll
* @param string|null $userId Betroffene User-ID (falls zutreffend)
* @param string|null $username Betroffener Username (falls zutreffend)
* @param string|null $sourceIp Source IP-Adresse
* @param string|null $userAgent User-Agent String
* @param array<string, mixed> $eventData Zusätzliche Event-spezifische Daten
*/
public function __construct(
public string $eventId,
public SecurityLogLevel $level,
public string $description,
public ?string $category = null,
public bool $requiresAlert = false,
public ?string $userId = null,
public ?string $username = null,
public ?string $sourceIp = null,
public ?string $userAgent = null,
public array $eventData = []
) {
}
/**
* Factory: Erstellt SecurityContext für Authentication-Events
*/
public static function forAuthentication(
string $eventId,
string $description,
SecurityLogLevel $level = SecurityLogLevel::WARN,
bool $requiresAlert = false,
array $eventData = []
): self {
return new self(
eventId: $eventId,
level: $level,
description: $description,
category: 'authentication',
requiresAlert: $requiresAlert,
eventData: $eventData
);
}
/**
* Factory: Erstellt SecurityContext für Authorization-Events
*/
public static function forAuthorization(
string $eventId,
string $description,
SecurityLogLevel $level = SecurityLogLevel::WARN,
bool $requiresAlert = false,
array $eventData = []
): self {
return new self(
eventId: $eventId,
level: $level,
description: $description,
category: 'authorization',
requiresAlert: $requiresAlert,
eventData: $eventData
);
}
/**
* Factory: Erstellt SecurityContext für Input-Validation-Events
*/
public static function forInputValidation(
string $eventId,
string $description,
SecurityLogLevel $level = SecurityLogLevel::WARN,
bool $requiresAlert = false,
array $eventData = []
): self {
return new self(
eventId: $eventId,
level: $level,
description: $description,
category: 'input_validation',
requiresAlert: $requiresAlert,
eventData: $eventData
);
}
/**
* Factory: Erstellt SecurityContext für Session-Events
*/
public static function forSession(
string $eventId,
string $description,
SecurityLogLevel $level = SecurityLogLevel::INFO,
bool $requiresAlert = false,
array $eventData = []
): self {
return new self(
eventId: $eventId,
level: $level,
description: $description,
category: 'session',
requiresAlert: $requiresAlert,
eventData: $eventData
);
}
/**
* Factory: Erstellt SecurityContext für Intrusion-Detection-Events
*/
public static function forIntrusion(
string $eventId,
string $description,
SecurityLogLevel $level = SecurityLogLevel::FATAL,
bool $requiresAlert = true,
array $eventData = []
): self {
return new self(
eventId: $eventId,
level: $level,
description: $description,
category: 'intrusion_detection',
requiresAlert: $requiresAlert,
eventData: $eventData
);
}
/**
* Setzt User-Informationen
*/
public function withUser(?string $userId, ?string $username = null): self
{
return new self(
eventId: $this->eventId,
level: $this->level,
description: $this->description,
category: $this->category,
requiresAlert: $this->requiresAlert,
userId: $userId,
username: $username,
sourceIp: $this->sourceIp,
userAgent: $this->userAgent,
eventData: $this->eventData
);
}
/**
* Setzt Request-Informationen
*/
public function withRequestInfo(?string $sourceIp, ?string $userAgent = null): self
{
return new self(
eventId: $this->eventId,
level: $this->level,
description: $this->description,
category: $this->category,
requiresAlert: $this->requiresAlert,
userId: $this->userId,
username: $this->username,
sourceIp: $sourceIp,
userAgent: $userAgent,
eventData: $this->eventData
);
}
/**
* Fügt Event-Daten hinzu
*/
public function withEventData(array $eventData): self
{
return new self(
eventId: $this->eventId,
level: $this->level,
description: $this->description,
category: $this->category,
requiresAlert: $this->requiresAlert,
userId: $this->userId,
username: $this->username,
sourceIp: $this->sourceIp,
userAgent: $this->userAgent,
eventData: array_merge($this->eventData, $eventData)
);
}
/**
* Konvertiert zu Array für Serialisierung
*/
public function toArray(): array
{
$result = [
'event_id' => $this->eventId,
'level' => $this->level->value,
'description' => $this->description,
];
if ($this->category !== null) {
$result['category'] = $this->category;
}
if ($this->requiresAlert) {
$result['requires_alert'] = true;
}
if ($this->userId !== null) {
$result['user_id'] = $this->userId;
}
if ($this->username !== null) {
$result['username'] = $this->username;
}
if ($this->sourceIp !== null) {
$result['source_ip'] = $this->sourceIp;
}
if ($this->userAgent !== null) {
$result['user_agent'] = $this->userAgent;
}
if (!empty($this->eventData)) {
$result['event_data'] = $this->eventData;
}
return $result;
}
}