feat(honeypot): threats dashboard — probe feed, KPIs, banned-IP management

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-07-05 01:38:22 +02:00
parent c8e1e07a43
commit 4f9699d470
14 changed files with 621 additions and 0 deletions

View File

@ -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'),
]);
}
}

View File

@ -0,0 +1,160 @@
<?php
namespace App\Livewire\Threats;
use App\Models\AuditEvent;
use App\Models\BannedIp;
use App\Services\BruteforceGuard;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
/**
* THREATS dashboard the operator-facing frontend for the honeypot deception layer. Surfaces the
* hostile-probe feed (security.honeypot_hit / security.honeytoken_used / auth.ip_banned audit
* events), KPI counters, and the active IP bans with an unban control. Read-only intelligence plus
* one mutation (unban); the honeypot itself is env-driven and has no toggle here.
*/
#[Layout('layouts.app')]
class Index extends Component
{
use WithPagination;
/** Threat entries per page — page is deep-linked via ?page= (Livewire WithPagination). */
private const PER_PAGE = 25;
/** The honeypot / ban action codes that make up the threat feed. */
private const FEED_ACTIONS = [
'security.honeypot_hit',
'security.honeytoken_used',
'auth.ip_banned',
];
/** Freitext-Filter über IP / Ziel des Bedrohungs-Ereignisses. */
#[Url]
public string $q = '';
public function mount(): void
{
// Defense-in-depth: the route already carries can:manage-panel, but a persisted Livewire
// component could be re-hydrated after a role change — re-check on mount.
abort_unless(Auth::user()?->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<int, int|string>
*/
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'));
}
}

View File

@ -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 <ip> 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',
];

View File

@ -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',

38
lang/de/threats.php Normal file
View File

@ -0,0 +1,38 @@
<?php
// Bedrohungen (Honeypot-Dashboard): Sondenverlauf, KPIs, gesperrte IPs (R16).
return [
// Header
'eyebrow' => '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.',
];

View File

@ -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 <ip> 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',
];

View File

@ -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',

38
lang/en/threats.php Normal file
View File

@ -0,0 +1,38 @@
<?php
// Threats (honeypot dashboard): probe feed, KPIs, banned IPs (R16).
return [
// Header
'eyebrow' => '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.',
];

View File

@ -25,6 +25,7 @@
'search' => '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
'shield' => '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>',
'shield-check' => '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/>',
'shield-alert' => '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="M12 8v4"/><path d="M12 16h.01"/>',
'shield-off' => '<path d="M19.7 14a6.9 6.9 0 0 0 .3-2V5a1 1 0 0 0-1-1c-2 0-4.5-1.2-6.24-2.72a1.17 1.17 0 0 0-1.52 0c-.5.42-1.06.8-1.64 1.12"/><path d="M4.73 4.73 4 5v7c0 6 8 10 8 10a20.3 20.3 0 0 0 5.62-4.38"/><path d="m2 2 20 20"/>',
'plus' => '<path d="M5 12h14"/><path d="M12 5v14"/>',
'logout' => '<path d="m16 17 5-5-5-5"/><path d="M21 12H9"/><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>',

View File

@ -39,6 +39,9 @@
<p class="px-3 pb-1 pt-3 font-mono text-[10px] uppercase tracking-widest text-ink-4">{{ __('shell.group_account') }}</p>
<x-nav-item icon="settings" href="/settings" :active="request()->is('settings*')" data-tour="settings">{{ __('shell.nav_settings') }}</x-nav-item>
<x-nav-item icon="shield" href="/system" :active="request()->is('system*')">{{ __('shell.nav_system') }}</x-nav-item>
@can('manage-panel')
<x-nav-item icon="shield-alert" href="/threats" :active="request()->is('threats*')">{{ __('shell.nav_threats') }}</x-nav-item>
@endcan
<x-nav-item icon="tag" href="/versions" :active="request()->is('versions*')" :badge="$updateAvailable ? '1' : null">{{ __('shell.nav_versions') }}</x-nav-item>
@if (config('clusev.release_controls'))
<x-nav-item icon="tag" href="/release" :active="request()->is('release*')">

View File

@ -72,6 +72,19 @@
@endif
</x-panel>
{{-- Honeypot deception layer read-only status (env-driven, no toggle here). --}}
<x-panel :title="__('settings.hp_title')" :subtitle="__('settings.hp_subtitle')">
<div class="flex flex-wrap items-center justify-between gap-3">
<p class="flex items-start gap-2 font-mono text-[11px] text-ink-3">
<x-icon name="shield-alert" class="mt-0.5 h-3.5 w-3.5 shrink-0 text-ink-4" />
<span>{{ __('settings.hp_note') }}</span>
</p>
<x-status-pill :status="$honeypotEnabled ? 'online' : 'offline'" class="shrink-0">
{{ $honeypotEnabled ? __('settings.hp_on') : __('settings.hp_off') }}
</x-status-pill>
</div>
</x-panel>
<p class="flex items-start gap-2 px-1 font-mono text-[11px] text-ink-4">
<x-icon name="shield" class="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{{ __('settings.lp_lockout_note') }}</span>

View File

@ -0,0 +1,163 @@
<div class="space-y-4">
{{-- Header --}}
<div class="flex flex-wrap items-end justify-between gap-3">
<div>
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">{{ __('threats.eyebrow') }}</p>
<h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">{{ __('threats.title') }}</h2>
</div>
<x-status-pill :status="$probes_24h > 0 ? 'warning' : 'online'">
{{ __('threats.probes_pill', ['count' => $probes_24h]) }}
</x-status-pill>
</div>
{{-- KPI strip: 4 2 1 --}}
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
<x-kpi
:label="__('threats.kpi_probes')"
:value="$probes_24h"
:tone="$probes_24h > 0 ? 'warning' : 'ink'"
icon="shield-alert" />
<x-kpi
:label="__('threats.kpi_banned')"
:value="$banned_total"
:tone="$banned_total > 0 ? 'offline' : 'ink'"
icon="lock" />
<x-kpi
:label="__('threats.kpi_honeytoken')"
:value="$honeytoken_trips"
:tone="$honeytoken_trips > 0 ? 'offline' : 'ink'"
icon="alert" />
<x-kpi
:label="__('threats.kpi_top_ip')"
:value="$top_ip ?? '—'"
:tone="$top_ip ? 'warning' : 'ink'"
icon="activity" />
</div>
{{-- Threat feed --}}
<x-panel :title="__('threats.feed_title')" :subtitle="__('threats.feed_subtitle')" :padded="false">
<x-slot:actions>
<label class="relative block">
<span class="sr-only">{{ __('threats.search_label') }}</span>
<span class="pointer-events-none absolute inset-y-0 left-2.5 flex items-center text-ink-4">
<x-icon name="search" class="h-3.5 w-3.5" />
</span>
<input
type="search"
data-page-search
wire:model.live.debounce.300ms="q"
placeholder="{{ __('threats.search_placeholder') }}"
class="min-h-11 w-44 rounded-md border border-line bg-inset py-1.5 pl-8 pr-3 font-mono text-xs text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none sm:w-64"
/>
</label>
</x-slot:actions>
<div class="divide-y divide-line">
@forelse ($events as $e)
<div wire:key="threat-{{ $e->uuid }}" class="flex items-start gap-3 px-4 py-3 sm:px-5">
<span @class([
'mt-0.5 grid h-7 w-7 shrink-0 place-items-center rounded-sm',
'bg-offline/10 text-offline' => $e->is_error,
'bg-raised text-ink-3' => ! $e->is_error,
])>
<x-icon :name="$e->is_error ? 'shield-alert' : 'audit'" class="h-3.5 w-3.5" />
</span>
<div class="min-w-0 flex-1">
<p class="text-sm text-ink">
<span @class(['font-medium', 'text-offline' => $e->is_error, 'text-ink' => ! $e->is_error])>{{ $e->action_label }}</span>
</p>
@if ($e->ip)
<p class="truncate font-mono text-[11px] text-ink-2">{{ $e->ip }}</p>
@endif
@if ($e->target)
<p class="truncate font-mono text-[11px] text-ink-3">{{ $e->target }}</p>
@endif
</div>
<span class="shrink-0 whitespace-nowrap font-mono text-[11px] text-ink-4">
{{ $e->created_at->diffForHumans() }}
</span>
</div>
@empty
<div class="flex flex-col items-center gap-2 px-4 py-12 text-center sm:px-5">
<span class="grid h-10 w-10 place-items-center rounded-md bg-raised text-ink-4">
<x-icon name="shield-check" class="h-5 w-5" />
</span>
<p class="font-display text-sm font-semibold text-ink-2">{{ __('threats.empty_title') }}</p>
<p class="max-w-xs text-[11px] text-ink-4">
@if (trim($q) !== '')
{{ __('threats.empty_filtered', ['query' => $q]) }}
@else
{{ __('threats.empty_feed') }}
@endif
</p>
</div>
@endforelse
</div>
{{-- Pagination page is deep-linked via ?page= (Livewire WithPagination). --}}
@if ($events->lastPage() > 1)
<nav aria-label="{{ __('threats.pagination_nav') }}" class="flex items-center justify-between gap-3 border-t border-line px-4 py-3 sm:px-5">
<button type="button" wire:click="previousPage" @disabled($events->onFirstPage())
class="inline-flex items-center gap-1 rounded-md border border-line bg-raised px-2.5 py-3 font-mono text-[11px] text-ink-2 transition-colors hover:border-accent/30 hover:text-ink disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:border-line disabled:hover:text-ink-2 lg:py-1.5">
<x-icon name="chevron-left" class="h-3.5 w-3.5" />
<span class="hidden sm:inline">{{ __('threats.pagination_prev') }}</span>
</button>
<div class="flex items-center gap-1">
@foreach ($pageWindow as $p)
@if ($p === '…')
<span class="px-1 font-mono text-[11px] text-ink-4" aria-hidden="true"></span>
@else
<button type="button" wire:key="threat-page-{{ $p }}" wire:click="gotoPage({{ $p }})"
@class([
'grid h-11 min-w-11 place-items-center rounded-md border px-2 font-mono text-[11px] tabular-nums transition-colors lg:h-7 lg:min-w-[28px]',
'border-accent/40 bg-accent/15 text-accent-text' => $p === $events->currentPage(),
'border-line bg-raised text-ink-2 hover:border-accent/30 hover:text-ink' => $p !== $events->currentPage(),
])
@if ($p === $events->currentPage()) aria-current="page" @endif>{{ $p }}</button>
@endif
@endforeach
</div>
<button type="button" wire:click="nextPage" @disabled(! $events->hasMorePages())
class="inline-flex items-center gap-1 rounded-md border border-line bg-raised px-2.5 py-3 font-mono text-[11px] text-ink-2 transition-colors hover:border-accent/30 hover:text-ink disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:border-line disabled:hover:text-ink-2 lg:py-1.5">
<span class="hidden sm:inline">{{ __('threats.pagination_next') }}</span>
<x-icon name="chevron-right" class="h-3.5 w-3.5" />
</button>
</nav>
@endif
</x-panel>
{{-- Banned-IP management --}}
<x-panel :title="__('threats.banned_title')" :subtitle="__('threats.banned_subtitle')">
<div class="divide-y divide-line">
@forelse ($bannedIps as $ban)
<div wire:key="banned-{{ $ban->getKey() }}" class="flex flex-wrap items-center gap-3 py-3 first:pt-0 last:pb-0">
<div class="min-w-0 flex-1">
<p class="truncate font-mono text-sm text-ink">{{ $ban->ip }}</p>
<p class="mt-1 flex flex-wrap items-center gap-2 font-mono text-[11px] text-ink-3">
<x-badge tone="warning">{{ $ban->reason ?? __('threats.reason_unknown') }}</x-badge>
<span>{{ __('threats.banned_until', ['time' => $ban->banned_until->diffForHumans()]) }}</span>
</p>
</div>
@can('manage-panel')
<x-btn variant="secondary" class="shrink-0" wire:click="unban('{{ $ban->ip }}')" wire:loading.attr="disabled">
<x-icon name="shield-off" class="h-3.5 w-3.5" />
{{ __('threats.unban') }}
</x-btn>
@endcan
</div>
@empty
<div class="flex flex-col items-center gap-2 py-8 text-center">
<span class="grid h-10 w-10 place-items-center rounded-md bg-raised text-ink-4">
<x-icon name="shield-check" class="h-5 w-5" />
</span>
<p class="font-display text-sm font-semibold text-ink-2">{{ __('threats.empty_banned_title') }}</p>
<p class="max-w-xs text-[11px] text-ink-4">{{ __('threats.empty_banned') }}</p>
</div>
@endforelse
</div>
</x-panel>
</div>

View File

@ -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');

View File

@ -0,0 +1,182 @@
<?php
namespace Tests\Feature;
use App\Livewire\Threats\Index;
use App\Models\AuditEvent;
use App\Models\BannedIp;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Livewire\Livewire;
use Tests\TestCase;
/**
* The THREATS honeypot dashboard: admin-only access (route middleware + mount guard),
* the KPI counts + probe feed, and the unban mutation (reuses auth.ip_unbanned audit code).
*/
class ThreatsPageTest extends TestCase
{
use RefreshDatabase;
private const ATTACKER_IP = '203.0.113.99';
protected function setUp(): void
{
parent::setUp();
Cache::flush();
}
private function admin(): User
{
return User::factory()->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]);
}
}