167 lines
5.7 KiB
PHP
167 lines
5.7 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.honeypot_login',
|
|
'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(),
|
|
// POST fake-login attempts in the last 24h — the "someone tried to log in" signal.
|
|
'login_attempts_24h' => AuditEvent::query()
|
|
->where('action', 'security.honeypot_login')
|
|
->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'));
|
|
}
|
|
}
|