From 5b814d631b3af5202c2b071de3b8a8896456f44e Mon Sep 17 00:00:00 2001 From: HolgerHatGarKeineNode Date: Wed, 4 Feb 2026 13:40:30 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20Security=20Monitoring=20Syste?= =?UTF-8?q?m=20with=20Command,=20Model,=20and=20Service?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 🛡️ Introduce `SecurityMonitor` service for tampering and malicious activity detection. - 🏷️ Add `SecurityAttempt` model and migration to log, categorize, and query security attempts. - 🖥️ Create `SecurityAttemptsCommand` for filtering, statistics, and top IP analysis. - ✅ Add extensive tests to ensure the reliability of security monitoring and logging. - 🔗 Integrate `SecurityMonitor` into the exception handling pipeline for real-time monitoring. --- .../Commands/SecurityAttemptsCommand.php | 166 ++++++++++++ app/Models/SecurityAttempt.php | 50 ++++ app/Services/SecurityMonitor.php | 237 ++++++++++++++++++ bootstrap/app.php | 5 + ..._123717_create_security_attempts_table.php | 40 +++ tests/Feature/SecurityMonitorTest.php | 166 ++++++++++++ 6 files changed, 664 insertions(+) create mode 100644 app/Console/Commands/SecurityAttemptsCommand.php create mode 100644 app/Models/SecurityAttempt.php create mode 100644 app/Services/SecurityMonitor.php create mode 100644 database/migrations/2026_02_04_123717_create_security_attempts_table.php create mode 100644 tests/Feature/SecurityMonitorTest.php diff --git a/app/Console/Commands/SecurityAttemptsCommand.php b/app/Console/Commands/SecurityAttemptsCommand.php new file mode 100644 index 0000000..e66d6aa --- /dev/null +++ b/app/Console/Commands/SecurityAttemptsCommand.php @@ -0,0 +1,166 @@ +option('hours'); + + if ($this->option('stats')) { + return $this->showStats($hours); + } + + if ($this->option('top-ips')) { + return $this->showTopIps($hours, (int) $this->option('top-ips')); + } + + return $this->showAttempts($hours); + } + + private function showAttempts(int $hours): int + { + $query = SecurityAttempt::query()->recent($hours); + + if ($ip = $this->option('ip')) { + $query->fromIp($ip); + } + + if ($severity = $this->option('severity')) { + $query->severity($severity); + } + + if ($component = $this->option('component')) { + $query->forComponent($component); + } + + $attempts = $query->latest()->limit(100)->get(); + + if ($attempts->isEmpty()) { + $this->info("No security attempts found in the last {$hours} hours."); + + return self::SUCCESS; + } + + $this->table( + ['ID', 'Time', 'IP', 'Severity', 'Component', 'Property', 'Exception'], + $attempts->map(fn (SecurityAttempt $a) => [ + $a->id, + $a->created_at->format('Y-m-d H:i:s'), + $a->ip_address, + $this->formatSeverity($a->severity), + $a->component_name ?? '-', + $a->target_property ?? '-', + class_basename($a->exception_class), + ]) + ); + + $this->newLine(); + $this->info("Showing {$attempts->count()} attempts (max 100). Use filters to narrow down."); + + return self::SUCCESS; + } + + private function showStats(int $hours): int + { + $total = SecurityAttempt::query()->recent($hours)->count(); + $bySeverity = SecurityAttempt::query() + ->recent($hours) + ->selectRaw('severity, COUNT(*) as count') + ->groupBy('severity') + ->pluck('count', 'severity') + ->toArray(); + + $uniqueIps = SecurityAttempt::query() + ->recent($hours) + ->distinct('ip_address') + ->count('ip_address'); + + $topComponents = SecurityAttempt::query() + ->recent($hours) + ->whereNotNull('component_name') + ->selectRaw('component_name, COUNT(*) as count') + ->groupBy('component_name') + ->orderByDesc('count') + ->limit(5) + ->pluck('count', 'component_name') + ->toArray(); + + $this->info("Security Attempts - Last {$hours} hours"); + $this->newLine(); + + $this->table(['Metric', 'Value'], [ + ['Total Attempts', $total], + ['Unique IPs', $uniqueIps], + ['High Severity', $bySeverity['high'] ?? 0], + ['Medium Severity', $bySeverity['medium'] ?? 0], + ['Low Severity', $bySeverity['low'] ?? 0], + ]); + + if (! empty($topComponents)) { + $this->newLine(); + $this->info('Top Targeted Components:'); + $this->table( + ['Component', 'Attempts'], + collect($topComponents)->map(fn ($count, $name) => [$name, $count])->toArray() + ); + } + + return self::SUCCESS; + } + + private function showTopIps(int $hours, int $limit): int + { + $ips = SecurityAttempt::query() + ->recent($hours) + ->selectRaw('ip_address, COUNT(*) as count, MAX(severity) as max_severity') + ->groupBy('ip_address') + ->orderByDesc('count') + ->limit($limit) + ->get(); + + if ($ips->isEmpty()) { + $this->info("No security attempts found in the last {$hours} hours."); + + return self::SUCCESS; + } + + $this->info("Top {$limit} Attacking IPs - Last {$hours} hours"); + $this->newLine(); + + $this->table( + ['IP Address', 'Attempts', 'Max Severity'], + $ips->map(fn ($row) => [ + $row->ip_address, + $row->count, + $this->formatSeverity($row->max_severity), + ]) + ); + + return self::SUCCESS; + } + + private function formatSeverity(string $severity): string + { + return match ($severity) { + 'high' => 'HIGH', + 'medium' => 'MEDIUM', + 'low' => 'LOW', + default => $severity, + }; + } +} diff --git a/app/Models/SecurityAttempt.php b/app/Models/SecurityAttempt.php new file mode 100644 index 0000000..1dc8592 --- /dev/null +++ b/app/Models/SecurityAttempt.php @@ -0,0 +1,50 @@ + 'array', + ]; + } + + public function scopeFromIp(Builder $query, string $ip): Builder + { + return $query->where('ip_address', $ip); + } + + public function scopeSeverity(Builder $query, string $severity): Builder + { + return $query->where('severity', $severity); + } + + public function scopeRecent(Builder $query, int $hours = 24): Builder + { + return $query->where('created_at', '>=', now()->subHours($hours)); + } + + public function scopeForComponent(Builder $query, string $component): Builder + { + return $query->where('component_name', $component); + } +} diff --git a/app/Services/SecurityMonitor.php b/app/Services/SecurityMonitor.php new file mode 100644 index 0000000..20a2b7e --- /dev/null +++ b/app/Services/SecurityMonitor.php @@ -0,0 +1,237 @@ + + */ + private const LIVEWIRE_SECURITY_EXCEPTIONS = [ + 'Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException', + 'Livewire\Exceptions\ComponentNotFoundException', + 'Livewire\Exceptions\MethodNotFoundException', + 'Livewire\Exceptions\MissingFileUploadsTraitException', + 'Livewire\Exceptions\PropertyNotFoundException', + 'Livewire\Exceptions\PublicPropertyNotFoundException', + 'Livewire\Exceptions\CannotBindToModelDataWithoutValidationRuleException', + 'Livewire\Exceptions\CorruptComponentPayloadException', + ]; + + /** + * Patterns in payloads that indicate injection attacks. + * + * @var array + */ + private const MALICIOUS_PATTERNS = [ + '__toString', + 'phpinfo', + 'system(', + 'exec(', + 'shell_exec', + 'passthru', + 'eval(', + 'base64_decode', + 'SerializableClosure', + 'BroadcastEvent', + 'FnStream', + 'PendingBroadcast', + 'dispatchNextJobInChain', + ]; + + public function recordFromException(Throwable $exception, ?Request $request = null): void + { + try { + $request ??= request(); + + if (! $this->shouldRecord($exception)) { + return; + } + + $componentName = $this->extractComponentName($request); + $targetProperty = $this->extractTargetProperty($exception); + $payload = $this->sanitizePayload($request); + $severity = $this->determineSeverity($exception, $payload); + + SecurityAttempt::query()->create([ + 'ip_address' => $this->getIpAddress($request), + 'user_agent' => $this->truncate($request->userAgent(), 500), + 'method' => $request->method(), + 'url' => $this->truncate($request->fullUrl(), 2000), + 'route_name' => $request->route()?->getName(), + 'exception_class' => get_class($exception), + 'exception_message' => $this->truncate($exception->getMessage(), 1000), + 'component_name' => $componentName, + 'target_property' => $targetProperty, + 'payload' => $payload, + 'severity' => $severity, + ]); + } catch (Throwable $e) { + // Never let monitoring fail the application + Log::warning('SecurityMonitor failed to record attempt', [ + 'error' => $e->getMessage(), + 'original_exception' => get_class($exception), + ]); + } + } + + public function shouldRecord(Throwable $exception): bool + { + $exceptionClass = get_class($exception); + + foreach (self::LIVEWIRE_SECURITY_EXCEPTIONS as $securityException) { + if ($exceptionClass === $securityException || is_subclass_of($exception, $securityException)) { + return true; + } + } + + return false; + } + + public function getAttemptsFromIp(string $ip, int $hours = 24): int + { + try { + return SecurityAttempt::query() + ->fromIp($ip) + ->recent($hours) + ->count(); + } catch (Throwable) { + return 0; + } + } + + public function isIpSuspicious(string $ip, int $threshold = 10, int $hours = 24): bool + { + return $this->getAttemptsFromIp($ip, $hours) >= $threshold; + } + + private function extractComponentName(Request $request): ?string + { + try { + $components = $request->input('components', []); + if (empty($components)) { + return null; + } + + $snapshot = json_decode($components[0]['snapshot'] ?? '{}', true); + + return $snapshot['memo']['name'] ?? null; + } catch (Throwable) { + return null; + } + } + + private function extractTargetProperty(Throwable $exception): ?string + { + $message = $exception->getMessage(); + + if (preg_match('/\[([^\]]+)\]/', $message, $matches)) { + return $matches[1]; + } + + return null; + } + + private function sanitizePayload(Request $request): ?array + { + try { + $payload = $request->input('components', []); + $jsonPayload = json_encode($payload); + + // Truncate if too large + if (strlen($jsonPayload) > self::MAX_PAYLOAD_SIZE) { + return [ + '_truncated' => true, + '_original_size' => strlen($jsonPayload), + 'summary' => $this->extractPayloadSummary($payload), + ]; + } + + return $payload; + } catch (Throwable) { + return ['_error' => 'Could not serialize payload']; + } + } + + private function extractPayloadSummary(array $payload): array + { + $summary = []; + + try { + foreach ($payload as $index => $component) { + $summary[$index] = [ + 'has_snapshot' => isset($component['snapshot']), + 'updates_count' => count($component['updates'] ?? []), + 'update_keys' => array_keys($component['updates'] ?? []), + 'calls_count' => count($component['calls'] ?? []), + ]; + } + } catch (Throwable) { + // Ignore + } + + return $summary; + } + + private function determineSeverity(Throwable $exception, ?array $payload): string + { + // Check for injection patterns in payload + $payloadJson = json_encode($payload) ?: ''; + + foreach (self::MALICIOUS_PATTERNS as $pattern) { + if (stripos($payloadJson, $pattern) !== false) { + return self::SEVERITY_HIGH; + } + } + + // Locked property tampering is medium severity + if (str_contains(get_class($exception), 'LockedProperty')) { + return self::SEVERITY_MEDIUM; + } + + return self::SEVERITY_LOW; + } + + private function getIpAddress(Request $request): string + { + // Handle proxied requests + $ip = $request->header('X-Forwarded-For'); + + if ($ip) { + // Take the first IP if there are multiple + $ips = explode(',', $ip); + + return trim($ips[0]); + } + + return $request->ip() ?? 'unknown'; + } + + private function truncate(?string $value, int $length): ?string + { + if ($value === null) { + return null; + } + + if (strlen($value) <= $length) { + return $value; + } + + return substr($value, 0, $length - 3).'...'; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 71d7be6..f3d90f2 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,5 +1,6 @@ withExceptions(function (Exceptions $exceptions) { Integration::handles($exceptions); + + $exceptions->report(function (Throwable $e) { + app(SecurityMonitor::class)->recordFromException($e); + }); })->create(); diff --git a/database/migrations/2026_02_04_123717_create_security_attempts_table.php b/database/migrations/2026_02_04_123717_create_security_attempts_table.php new file mode 100644 index 0000000..0ae6d63 --- /dev/null +++ b/database/migrations/2026_02_04_123717_create_security_attempts_table.php @@ -0,0 +1,40 @@ +id(); + $table->string('ip_address', 45)->index(); + $table->string('user_agent', 500)->nullable(); + $table->string('method', 10); + $table->string('url', 2000); + $table->string('route_name')->nullable(); + $table->string('exception_class'); + $table->string('exception_message', 1000); + $table->string('component_name')->nullable()->index(); + $table->string('target_property')->nullable(); + $table->json('payload')->nullable(); + $table->string('severity')->default('medium')->index(); + $table->timestamps(); + + $table->index('created_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('security_attempts'); + } +}; diff --git a/tests/Feature/SecurityMonitorTest.php b/tests/Feature/SecurityMonitorTest.php new file mode 100644 index 0000000..1a45ead --- /dev/null +++ b/tests/Feature/SecurityMonitorTest.php @@ -0,0 +1,166 @@ +monitor = app(SecurityMonitor::class); +}); + +it('records locked property exceptions', function () { + $exception = new CannotUpdateLockedPropertyException('isLoggedIn'); + + $request = Request::create('/livewire/update', 'POST', [ + 'components' => [ + [ + 'snapshot' => json_encode([ + 'memo' => ['name' => 'auth-button'], + ]), + 'updates' => ['isLoggedIn' => true], + ], + ], + ]); + + $request->setRouteResolver(fn () => null); + + app()->instance('request', $request); + + $this->monitor->recordFromException($exception, $request); + + expect(SecurityAttempt::count())->toBe(1); + + $attempt = SecurityAttempt::first(); + expect($attempt->exception_class)->toBe(CannotUpdateLockedPropertyException::class) + ->and($attempt->target_property)->toBe('isLoggedIn') + ->and($attempt->component_name)->toBe('auth-button') + ->and($attempt->severity)->toBe('medium'); +}); + +it('detects high severity injection attempts', function () { + $exception = new CannotUpdateLockedPropertyException('isLoggedIn'); + + $request = Request::create('/livewire/update', 'POST', [ + 'components' => [ + [ + 'snapshot' => json_encode([ + 'memo' => ['name' => 'auth-button'], + ]), + 'updates' => [ + 'isLoggedIn' => [ + '__toString' => 'phpinfo', + 'SerializableClosure' => [], + ], + ], + ], + ], + ]); + + $request->setRouteResolver(fn () => null); + + $this->monitor->recordFromException($exception, $request); + + $attempt = SecurityAttempt::first(); + expect($attempt->severity)->toBe('high'); +}); + +it('does not record non-security exceptions', function () { + $exception = new RuntimeException('Something went wrong'); + + $this->monitor->recordFromException($exception); + + expect(SecurityAttempt::count())->toBe(0); +}); + +it('never throws exceptions itself', function () { + $exception = new CannotUpdateLockedPropertyException('test'); + + // Create a request that might cause issues + $request = Request::create('/test', 'POST'); + $request->setRouteResolver(fn () => null); + + // This should not throw even if there are issues + $this->monitor->recordFromException($exception, $request); + + // If we get here without exception, the test passes + expect(true)->toBeTrue(); +}); + +it('tracks attempts from same IP', function () { + $exception = new CannotUpdateLockedPropertyException('test'); + + $request = Request::create('/livewire/update', 'POST', [ + 'components' => [ + ['snapshot' => '{}', 'updates' => []], + ], + ], server: ['REMOTE_ADDR' => '192.168.1.100']); + + $request->setRouteResolver(fn () => null); + + // Record multiple attempts + $this->monitor->recordFromException($exception, $request); + $this->monitor->recordFromException($exception, $request); + $this->monitor->recordFromException($exception, $request); + + expect($this->monitor->getAttemptsFromIp('192.168.1.100'))->toBe(3); +}); + +it('identifies suspicious IPs', function () { + $exception = new CannotUpdateLockedPropertyException('test'); + + $request = Request::create('/livewire/update', 'POST', [ + 'components' => [ + ['snapshot' => '{}', 'updates' => []], + ], + ], server: ['REMOTE_ADDR' => '10.0.0.1']); + + $request->setRouteResolver(fn () => null); + + // Record 10 attempts (threshold) + for ($i = 0; $i < 10; $i++) { + $this->monitor->recordFromException($exception, $request); + } + + expect($this->monitor->isIpSuspicious('10.0.0.1', threshold: 10))->toBeTrue() + ->and($this->monitor->isIpSuspicious('10.0.0.2', threshold: 10))->toBeFalse(); +}); + +it('truncates long values', function () { + $exception = new CannotUpdateLockedPropertyException('test'); + + $longUserAgent = str_repeat('a', 1000); + + $request = Request::create('/livewire/update', 'POST', [ + 'components' => [ + ['snapshot' => '{}', 'updates' => []], + ], + ], server: ['HTTP_USER_AGENT' => $longUserAgent]); + + $request->setRouteResolver(fn () => null); + + $this->monitor->recordFromException($exception, $request); + + $attempt = SecurityAttempt::first(); + expect(strlen($attempt->user_agent))->toBeLessThanOrEqual(500); +}); + +it('handles X-Forwarded-For header', function () { + $exception = new CannotUpdateLockedPropertyException('test'); + + $request = Request::create('/livewire/update', 'POST', [ + 'components' => [ + ['snapshot' => '{}', 'updates' => []], + ], + ], server: [ + 'REMOTE_ADDR' => '127.0.0.1', + 'HTTP_X_FORWARDED_FOR' => '203.0.113.50, 70.41.3.18', + ]); + + $request->setRouteResolver(fn () => null); + + $this->monitor->recordFromException($exception, $request); + + $attempt = SecurityAttempt::first(); + expect($attempt->ip_address)->toBe('203.0.113.50'); +});