feat(honeypot): threats dashboard — probe feed, KPIs, banned-IP management
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/v1-foundation
parent
c8e1e07a43
commit
4f9699d470
|
|
@ -156,6 +156,8 @@ class LoginProtection extends Component
|
||||||
'bans' => BannedIp::query()->where('banned_until', '>', now())->orderByDesc('banned_until')->limit(200)->get(),
|
'bans' => BannedIp::query()->where('banned_until', '>', now())->orderByDesc('banned_until')->limit(200)->get(),
|
||||||
'currentIp' => $ip,
|
'currentIp' => $ip,
|
||||||
'currentIpExempt' => $guard->isExempt($ip),
|
'currentIpExempt' => $guard->isExempt($ip),
|
||||||
|
// Read-only status of the env-driven honeypot deception layer (no toggle here).
|
||||||
|
'honeypotEnabled' => (bool) config('clusev.honeypot.enabled'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -87,4 +87,11 @@ return [
|
||||||
'lp_unban_all_body' => 'Alle aktuell gesperrten IP-Adressen werden wieder zugelassen.',
|
'lp_unban_all_body' => 'Alle aktuell gesperrten IP-Adressen werden wieder zugelassen.',
|
||||||
'lp_unban_all_notify' => 'Alle Sperren aufgehoben.',
|
'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.',
|
'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',
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ return [
|
||||||
'nav_terminal' => 'Terminal',
|
'nav_terminal' => 'Terminal',
|
||||||
'nav_settings' => 'Einstellungen',
|
'nav_settings' => 'Einstellungen',
|
||||||
'nav_system' => 'System',
|
'nav_system' => 'System',
|
||||||
|
'nav_threats' => 'Bedrohungen',
|
||||||
'nav_versions' => 'Version',
|
'nav_versions' => 'Version',
|
||||||
'nav_help' => 'Hilfe',
|
'nav_help' => 'Hilfe',
|
||||||
'update_available' => 'Update verfügbar',
|
'update_available' => 'Update verfügbar',
|
||||||
|
|
|
||||||
|
|
@ -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.',
|
||||||
|
];
|
||||||
|
|
@ -87,4 +87,11 @@ return [
|
||||||
'lp_unban_all_body' => 'All currently blocked IP addresses will be allowed again.',
|
'lp_unban_all_body' => 'All currently blocked IP addresses will be allowed again.',
|
||||||
'lp_unban_all_notify' => 'All blocks cleared.',
|
'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.',
|
'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',
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ return [
|
||||||
'nav_terminal' => 'Terminal',
|
'nav_terminal' => 'Terminal',
|
||||||
'nav_settings' => 'Settings',
|
'nav_settings' => 'Settings',
|
||||||
'nav_system' => 'System',
|
'nav_system' => 'System',
|
||||||
|
'nav_threats' => 'Threats',
|
||||||
'nav_versions' => 'Version',
|
'nav_versions' => 'Version',
|
||||||
'nav_help' => 'Help',
|
'nav_help' => 'Help',
|
||||||
'update_available' => 'Update available',
|
'update_available' => 'Update available',
|
||||||
|
|
|
||||||
|
|
@ -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.',
|
||||||
|
];
|
||||||
|
|
@ -25,6 +25,7 @@
|
||||||
'search' => '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
|
'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' => '<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-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"/>',
|
'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"/>',
|
'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"/>',
|
'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"/>',
|
||||||
|
|
|
||||||
|
|
@ -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>
|
<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="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>
|
<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>
|
<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'))
|
@if (config('clusev.release_controls'))
|
||||||
<x-nav-item icon="tag" href="/release" :active="request()->is('release*')">
|
<x-nav-item icon="tag" href="/release" :active="request()->is('release*')">
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,19 @@
|
||||||
@endif
|
@endif
|
||||||
</x-panel>
|
</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">
|
<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" />
|
<x-icon name="shield" class="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||||
<span>{{ __('settings.lp_lockout_note') }}</span>
|
<span>{{ __('settings.lp_lockout_note') }}</span>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -15,6 +15,7 @@ use App\Livewire\Services;
|
||||||
use App\Livewire\Settings;
|
use App\Livewire\Settings;
|
||||||
use App\Livewire\System;
|
use App\Livewire\System;
|
||||||
use App\Livewire\Terminal;
|
use App\Livewire\Terminal;
|
||||||
|
use App\Livewire\Threats;
|
||||||
use App\Livewire\Versions;
|
use App\Livewire\Versions;
|
||||||
use App\Livewire\Wireguard;
|
use App\Livewire\Wireguard;
|
||||||
use App\Models\HostCredential;
|
use App\Models\HostCredential;
|
||||||
|
|
@ -214,6 +215,10 @@ Route::middleware('auth')->group(function () {
|
||||||
Route::get('/files', Files\Index::class)->name('files.index');
|
Route::get('/files', Files\Index::class)->name('files.index');
|
||||||
Route::get('/audit', Audit\Index::class)->name('audit.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('/settings', Settings\Index::class)->name('settings');
|
||||||
Route::get('/versions', Versions\Index::class)->name('versions');
|
Route::get('/versions', Versions\Index::class)->name('versions');
|
||||||
Route::get('/system', System\Index::class)->name('system');
|
Route::get('/system', System\Index::class)->name('system');
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue