diff --git a/app/Livewire/Settings/LoginProtection.php b/app/Livewire/Settings/LoginProtection.php index c552122..4d24037 100644 --- a/app/Livewire/Settings/LoginProtection.php +++ b/app/Livewire/Settings/LoginProtection.php @@ -156,6 +156,8 @@ class LoginProtection extends Component 'bans' => BannedIp::query()->where('banned_until', '>', now())->orderByDesc('banned_until')->limit(200)->get(), 'currentIp' => $ip, 'currentIpExempt' => $guard->isExempt($ip), + // Read-only status of the env-driven honeypot deception layer (no toggle here). + 'honeypotEnabled' => (bool) config('clusev.honeypot.enabled'), ]); } } diff --git a/app/Livewire/Threats/Index.php b/app/Livewire/Threats/Index.php new file mode 100644 index 0000000..db07d53 --- /dev/null +++ b/app/Livewire/Threats/Index.php @@ -0,0 +1,160 @@ +can('manage-panel'), 403); + } + + /** A changed search must restart at page 1 — never land on an out-of-range page. */ + public function updatedQ(): void + { + $this->resetPage(); + } + + /** + * Honeypot / ban events, newest first, optionally filtered. The filter runs in SQL (so it spans + * the whole history, not just one page) and matches the source IP and the probed target path. + * Mirrors Audit\Index::eventsQuery() but scoped to the honeypot action set. + */ + protected function eventsQuery(): Builder + { + $query = AuditEvent::query()->whereIn('action', self::FEED_ACTIONS)->latest(); + + $needle = trim($this->q); + if ($needle === '') { + return $query; + } + + $like = '%'.mb_strtolower($needle).'%'; + + return $query->where(function (Builder $w) use ($like): void { + $w->whereRaw('LOWER(ip) LIKE ?', [$like]) + ->orWhereRaw('LOWER(target) LIKE ?', [$like]); + }); + } + + /** Currently active bans (not yet expired), newest first — a short list for the ban panel. */ + protected function bannedIps(): Collection + { + return BannedIp::active()->latest('banned_until')->limit(50)->get(); + } + + /** + * Page numbers to render, with "…" gaps for long histories (1 … 4 5 6 … 20). + * + * @return array + */ + protected function pageWindow(int $current, int $total): array + { + if ($total <= 7) { + return range(1, max(1, $total)); + } + + $pages = [1]; + $from = max(2, $current - 1); + $to = min($total - 1, $current + 1); + if ($from > 2) { + $pages[] = '…'; + } + for ($i = $from; $i <= $to; $i++) { + $pages[] = $i; + } + if ($to < $total - 1) { + $pages[] = '…'; + } + $pages[] = $total; + + return $pages; + } + + /** + * Lift an active ban. Reuses the exact unban path + audit code + toast convention from + * Settings\LoginProtection so the two management surfaces stay consistent. + */ + public function unban(string $ip, BruteforceGuard $guard): void + { + abort_unless(Auth::user()?->can('manage-panel'), 403); + + $guard->unban($ip); + + AuditEvent::create([ + 'user_id' => Auth::id(), + 'actor' => Auth::user()?->name ?? 'system', + 'action' => 'auth.ip_unbanned', + 'target' => $ip, + 'ip' => request()->ip(), + ]); + + $this->dispatch('notify', message: __('threats.unbanned_toast', ['ip' => $ip])); + } + + public function render() + { + /** @var LengthAwarePaginator $events */ + $events = $this->eventsQuery()->paginate(self::PER_PAGE); + + // Most active source across all honeypot/token events (null when there is no traffic yet). + $topIp = AuditEvent::query() + ->whereIn('action', ['security.honeypot_hit', 'security.honeytoken_used']) + ->whereNotNull('ip') + ->selectRaw('ip, COUNT(*) as hits') + ->groupBy('ip') + ->orderByDesc('hits') + ->limit(1) + ->value('ip'); + + return view('livewire.threats.index', [ + 'events' => $events, + 'pageWindow' => $this->pageWindow($events->currentPage(), $events->lastPage()), + 'bannedIps' => $this->bannedIps(), + 'probes_24h' => AuditEvent::query() + ->where('action', 'security.honeypot_hit') + ->where('created_at', '>=', now()->subDay()) + ->count(), + 'banned_total' => BannedIp::active()->count(), + 'honeytoken_trips' => AuditEvent::query()->where('action', 'security.honeytoken_used')->count(), + 'top_ip' => $topIp, + ])->title(__('threats.title')); + } +} diff --git a/lang/de/settings.php b/lang/de/settings.php index 1715975..9b44626 100644 --- a/lang/de/settings.php +++ b/lang/de/settings.php @@ -87,4 +87,11 @@ return [ 'lp_unban_all_body' => 'Alle aktuell gesperrten IP-Adressen werden wieder zugelassen.', 'lp_unban_all_notify' => 'Alle Sperren aufgehoben.', 'lp_lockout_note' => 'Eingeloggte Operatoren werden nie geblockt und können hier ihre eigene IP entsperren. Notfall: clusev unban auf der Host-Shell.', + + // Honeypot-Statusanzeige (per Umgebungsvariable gesteuert, kein Schalter) + 'hp_title' => 'Honeypot', + 'hp_subtitle' => 'Täuschungsebene gegen Angreifer-Sonden', + 'hp_note' => 'Köder-Pfade sperren scannende IP-Adressen sofort. Per CLUSEV_HONEYPOT gesteuert. Treffer erscheinen unter Bedrohungen.', + 'hp_on' => 'Aktiv', + 'hp_off' => 'Inaktiv', ]; diff --git a/lang/de/shell.php b/lang/de/shell.php index 4a6a7e8..f66c429 100644 --- a/lang/de/shell.php +++ b/lang/de/shell.php @@ -22,6 +22,7 @@ return [ 'nav_terminal' => 'Terminal', 'nav_settings' => 'Einstellungen', 'nav_system' => 'System', + 'nav_threats' => 'Bedrohungen', 'nav_versions' => 'Version', 'nav_help' => 'Hilfe', 'update_available' => 'Update verfügbar', diff --git a/lang/de/threats.php b/lang/de/threats.php new file mode 100644 index 0000000..a088bbd --- /dev/null +++ b/lang/de/threats.php @@ -0,0 +1,38 @@ + 'Sicherheit', + 'title' => 'Bedrohungen', + 'subtitle' => 'Honeypot-Aktivität und gesperrte IP-Adressen', + 'probes_pill' => ':count Sonden (24h)', + + // KPI-Kacheln + 'kpi_probes' => 'Sonden (24h)', + 'kpi_banned' => 'Gesperrte IPs', + 'kpi_honeytoken' => 'Honeytoken', + 'kpi_top_ip' => 'Top-Angreifer', + + // Bedrohungs-Feed + 'feed_title' => 'Honeypot-Ereignisse', + 'feed_subtitle' => 'neueste zuerst', + 'search_label' => 'Bedrohungen durchsuchen', + 'search_placeholder' => 'IP-Adresse, Ziel …', + 'pagination_nav' => 'Bedrohungs-Seiten', + 'pagination_prev' => 'Zurück', + 'pagination_next' => 'Weiter', + 'empty_title' => 'Keine Bedrohungen', + 'empty_feed' => 'Keine Bedrohungen erfasst.', + 'empty_filtered' => 'Für „:query“ wurden keine Bedrohungen gefunden.', + + // Gesperrte IPs + 'banned_title' => 'Gesperrte IPs', + 'banned_subtitle' => 'aktive Sperren', + 'banned_until' => 'gesperrt bis :time', + 'reason_unknown' => 'unbekannt', + 'unban' => 'Entsperren', + 'unbanned_toast' => ':ip entsperrt.', + 'empty_banned_title' => 'Keine Sperren', + 'empty_banned' => 'Derzeit sind keine IP-Adressen gesperrt.', +]; diff --git a/lang/en/settings.php b/lang/en/settings.php index 27790b8..fd408c6 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -87,4 +87,11 @@ return [ 'lp_unban_all_body' => 'All currently blocked IP addresses will be allowed again.', 'lp_unban_all_notify' => 'All blocks cleared.', 'lp_lockout_note' => 'Logged-in operators are never blocked and can unblock their own IP here. Emergency: clusev unban on the host shell.', + + // Honeypot status indicator (env-driven, no toggle) + 'hp_title' => 'Honeypot', + 'hp_subtitle' => 'Deception layer against attacker probes', + 'hp_note' => 'Decoy paths instantly ban scanning IP addresses. Controlled via CLUSEV_HONEYPOT. Hits appear under Threats.', + 'hp_on' => 'Enabled', + 'hp_off' => 'Disabled', ]; diff --git a/lang/en/shell.php b/lang/en/shell.php index 4b7e9f2..0a68808 100644 --- a/lang/en/shell.php +++ b/lang/en/shell.php @@ -22,6 +22,7 @@ return [ 'nav_terminal' => 'Terminal', 'nav_settings' => 'Settings', 'nav_system' => 'System', + 'nav_threats' => 'Threats', 'nav_versions' => 'Version', 'nav_help' => 'Help', 'update_available' => 'Update available', diff --git a/lang/en/threats.php b/lang/en/threats.php new file mode 100644 index 0000000..ae333db --- /dev/null +++ b/lang/en/threats.php @@ -0,0 +1,38 @@ + 'Security', + 'title' => 'Threats', + 'subtitle' => 'Honeypot activity and banned IP addresses', + 'probes_pill' => ':count probes (24h)', + + // KPI tiles + 'kpi_probes' => 'Probes (24h)', + 'kpi_banned' => 'Banned IPs', + 'kpi_honeytoken' => 'Honeytokens', + 'kpi_top_ip' => 'Top attacker', + + // Threat feed + 'feed_title' => 'Honeypot events', + 'feed_subtitle' => 'newest first', + 'search_label' => 'Search threats', + 'search_placeholder' => 'IP address, target …', + 'pagination_nav' => 'Threat pages', + 'pagination_prev' => 'Previous', + 'pagination_next' => 'Next', + 'empty_title' => 'No threats', + 'empty_feed' => 'No threats recorded.', + 'empty_filtered' => 'No threats found for “:query”.', + + // Banned IPs + 'banned_title' => 'Banned IPs', + 'banned_subtitle' => 'active blocks', + 'banned_until' => 'blocked until :time', + 'reason_unknown' => 'unknown', + 'unban' => 'Unblock', + 'unbanned_toast' => ':ip unblocked.', + 'empty_banned_title' => 'No blocks', + 'empty_banned' => 'No IP addresses are currently blocked.', +]; diff --git a/resources/views/components/icon.blade.php b/resources/views/components/icon.blade.php index e7634af..64dc772 100644 --- a/resources/views/components/icon.blade.php +++ b/resources/views/components/icon.blade.php @@ -25,6 +25,7 @@ 'search' => '', 'shield' => '', 'shield-check' => '', + 'shield-alert' => '', 'shield-off' => '', 'plus' => '', 'logout' => '', diff --git a/resources/views/components/sidebar.blade.php b/resources/views/components/sidebar.blade.php index 99d4c18..ce52cb7 100644 --- a/resources/views/components/sidebar.blade.php +++ b/resources/views/components/sidebar.blade.php @@ -39,6 +39,9 @@

{{ __('shell.group_account') }}

{{ __('shell.nav_settings') }} {{ __('shell.nav_system') }} + @can('manage-panel') + {{ __('shell.nav_threats') }} + @endcan {{ __('shell.nav_versions') }} @if (config('clusev.release_controls')) diff --git a/resources/views/livewire/settings/login-protection.blade.php b/resources/views/livewire/settings/login-protection.blade.php index 67d1876..e1f4760 100644 --- a/resources/views/livewire/settings/login-protection.blade.php +++ b/resources/views/livewire/settings/login-protection.blade.php @@ -72,6 +72,19 @@ @endif + {{-- Honeypot deception layer — read-only status (env-driven, no toggle here). --}} + +
+

+ + {{ __('settings.hp_note') }} +

+ + {{ $honeypotEnabled ? __('settings.hp_on') : __('settings.hp_off') }} + +
+
+

{{ __('settings.lp_lockout_note') }} diff --git a/resources/views/livewire/threats/index.blade.php b/resources/views/livewire/threats/index.blade.php new file mode 100644 index 0000000..c278fba --- /dev/null +++ b/resources/views/livewire/threats/index.blade.php @@ -0,0 +1,163 @@ +

+ {{-- Header --}} +
+
+

{{ __('threats.eyebrow') }}

+

{{ __('threats.title') }}

+
+ + {{ __('threats.probes_pill', ['count' => $probes_24h]) }} + +
+ + {{-- KPI strip: 4 → 2 → 1 --}} +
+ + + + +
+ + {{-- Threat feed --}} + + + + + +
+ @forelse ($events as $e) +
+ $e->is_error, + 'bg-raised text-ink-3' => ! $e->is_error, + ])> + + + +
+

+ $e->is_error, 'text-ink' => ! $e->is_error])>{{ $e->action_label }} +

+ @if ($e->ip) +

{{ $e->ip }}

+ @endif + @if ($e->target) +

{{ $e->target }}

+ @endif +
+ + + {{ $e->created_at->diffForHumans() }} + +
+ @empty +
+ + + +

{{ __('threats.empty_title') }}

+

+ @if (trim($q) !== '') + {{ __('threats.empty_filtered', ['query' => $q]) }} + @else + {{ __('threats.empty_feed') }} + @endif +

+
+ @endforelse +
+ + {{-- Pagination — page is deep-linked via ?page= (Livewire WithPagination). --}} + @if ($events->lastPage() > 1) + + @endif +
+ + {{-- Banned-IP management --}} + +
+ @forelse ($bannedIps as $ban) +
+
+

{{ $ban->ip }}

+

+ {{ $ban->reason ?? __('threats.reason_unknown') }} + {{ __('threats.banned_until', ['time' => $ban->banned_until->diffForHumans()]) }} +

+
+ @can('manage-panel') + + + {{ __('threats.unban') }} + + @endcan +
+ @empty +
+ + + +

{{ __('threats.empty_banned_title') }}

+

{{ __('threats.empty_banned') }}

+
+ @endforelse +
+
+
diff --git a/routes/web.php b/routes/web.php index 3de7104..fbe43ca 100644 --- a/routes/web.php +++ b/routes/web.php @@ -15,6 +15,7 @@ use App\Livewire\Services; use App\Livewire\Settings; use App\Livewire\System; use App\Livewire\Terminal; +use App\Livewire\Threats; use App\Livewire\Versions; use App\Livewire\Wireguard; use App\Models\HostCredential; @@ -214,6 +215,10 @@ Route::middleware('auth')->group(function () { Route::get('/files', Files\Index::class)->name('files.index'); Route::get('/audit', Audit\Index::class)->name('audit.index'); + // Honeypot intelligence dashboard — admin-only (defense-in-depth: the route middleware here + // AND Threats\Index::mount() abort_unless both gate on manage-panel). + Route::get('/threats', Threats\Index::class)->name('threats')->middleware('can:manage-panel'); + Route::get('/settings', Settings\Index::class)->name('settings'); Route::get('/versions', Versions\Index::class)->name('versions'); Route::get('/system', System\Index::class)->name('system'); diff --git a/tests/Feature/ThreatsPageTest.php b/tests/Feature/ThreatsPageTest.php new file mode 100644 index 0000000..35c3ed1 --- /dev/null +++ b/tests/Feature/ThreatsPageTest.php @@ -0,0 +1,182 @@ +create(['must_change_password' => false]); + } + + private function operator(): User + { + return User::factory()->operator()->create(['must_change_password' => false]); + } + + private function viewer(): User + { + return User::factory()->viewer()->create(['must_change_password' => false]); + } + + // ── Access control ────────────────────────────────────────────────────────── + + public function test_admin_can_load_the_threats_route(): void + { + $this->actingAs($this->admin()); + + $this->get('/threats')->assertOk(); + } + + public function test_operator_is_forbidden_from_the_threats_route(): void + { + $this->actingAs($this->operator()); + + $this->get('/threats')->assertForbidden(); + } + + public function test_viewer_is_forbidden_from_the_threats_route(): void + { + $this->actingAs($this->viewer()); + + $this->get('/threats')->assertForbidden(); + } + + public function test_admin_can_mount_the_component(): void + { + Livewire::actingAs($this->admin()) + ->test(Index::class) + ->assertOk(); + } + + public function test_operator_mount_aborts_403(): void + { + Livewire::actingAs($this->operator()) + ->test(Index::class) + ->assertForbidden(); + } + + public function test_viewer_mount_aborts_403(): void + { + Livewire::actingAs($this->viewer()) + ->test(Index::class) + ->assertForbidden(); + } + + // ── Feed + KPIs ───────────────────────────────────────────────────────────── + + public function test_page_shows_honeypot_events_and_correct_kpis(): void + { + // Two probes within 24h + one honeytoken trip + one stale probe (outside the 24h window). + AuditEvent::create(['actor' => 'system', 'action' => 'security.honeypot_hit', 'target' => '/wp-login.php', 'ip' => self::ATTACKER_IP]); + AuditEvent::create(['actor' => 'system', 'action' => 'security.honeypot_hit', 'target' => '/.env', 'ip' => self::ATTACKER_IP]); + AuditEvent::create(['actor' => 'system', 'action' => 'security.honeytoken_used', 'target' => 'db_password', 'ip' => self::ATTACKER_IP]); + AuditEvent::create(['actor' => 'system', 'action' => 'security.honeypot_hit', 'target' => '/old', 'ip' => '198.51.100.7', 'created_at' => now()->subDays(3)]); + + BannedIp::create(['ip' => self::ATTACKER_IP, 'banned_until' => now()->addHour(), 'reason' => 'honeypot', 'attempts' => 1]); + + Livewire::actingAs($this->admin()) + ->test(Index::class) + ->assertSee(self::ATTACKER_IP) + ->assertSee('/wp-login.php') + ->assertSee('Honeypot-Treffer') // localized action label (DE default) + ->assertSee('Honeytoken benutzt') + ->assertViewHas('probes_24h', 2) // 2 recent hits, the 3-day-old one excluded + ->assertViewHas('banned_total', 1) + ->assertViewHas('honeytoken_trips', 1) + ->assertViewHas('top_ip', self::ATTACKER_IP); + } + + public function test_top_ip_is_null_when_there_is_no_traffic(): void + { + Livewire::actingAs($this->admin()) + ->test(Index::class) + ->assertViewHas('top_ip', null) + ->assertViewHas('probes_24h', 0) + ->assertSee('—'); + } + + public function test_search_filters_the_feed_by_ip(): void + { + AuditEvent::create(['actor' => 'system', 'action' => 'security.honeypot_hit', 'target' => '/wp-login.php', 'ip' => self::ATTACKER_IP]); + AuditEvent::create(['actor' => 'system', 'action' => 'security.honeypot_hit', 'target' => '/xmlrpc.php', 'ip' => '198.51.100.7']); + + Livewire::actingAs($this->admin()) + ->test(Index::class) + ->set('q', self::ATTACKER_IP) + ->assertViewHas('events', fn ($e) => $e->total() === 1) + ->assertSee('/wp-login.php') + ->assertDontSee('/xmlrpc.php'); + } + + // ── Unban mutation ────────────────────────────────────────────────────────── + + public function test_admin_can_unban_and_it_is_audited(): void + { + BannedIp::create(['ip' => self::ATTACKER_IP, 'banned_until' => now()->addHour(), 'reason' => 'honeypot', 'attempts' => 1]); + + Livewire::actingAs($this->admin()) + ->test(Index::class) + ->call('unban', self::ATTACKER_IP) + ->assertDispatched('notify'); + + $this->assertDatabaseMissing('banned_ips', ['ip' => self::ATTACKER_IP]); + $this->assertTrue( + AuditEvent::where('action', 'auth.ip_unbanned') + ->where('target', self::ATTACKER_IP) + ->exists() + ); + } + + public function test_operator_cannot_unban(): void + { + BannedIp::create(['ip' => self::ATTACKER_IP, 'banned_until' => now()->addHour(), 'reason' => 'honeypot', 'attempts' => 1]); + + // An admin boots the component so the snapshot is valid, then we swap to an operator and + // call the mutation — the unban() abort_unless must still fire 403 (mid-session demotion or + // a hand-crafted /livewire/update). Mirrors RbacNetworkGateTest::test_operator_cannot_add… + $component = Livewire::actingAs($this->admin())->test(Index::class)->assertOk(); + + $this->actingAs($this->operator()); + + $component->call('unban', self::ATTACKER_IP)->assertForbidden(); + + $this->assertDatabaseHas('banned_ips', ['ip' => self::ATTACKER_IP]); + } + + public function test_viewer_cannot_unban(): void + { + BannedIp::create(['ip' => self::ATTACKER_IP, 'banned_until' => now()->addHour(), 'reason' => 'honeypot', 'attempts' => 1]); + + $component = Livewire::actingAs($this->admin())->test(Index::class)->assertOk(); + + $this->actingAs($this->viewer()); + + $component->call('unban', self::ATTACKER_IP)->assertForbidden(); + + $this->assertDatabaseHas('banned_ips', ['ip' => self::ATTACKER_IP]); + } +}