clusev/app/Livewire/Threats/Index.php

161 lines
5.3 KiB
PHP

<?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'));
}
}