feat(honeypot): surface login attempts in the dashboard (threats KPI, tried-credentials, sidebar badge)

feat/v1-foundation
boban 2026-07-05 10:49:38 +02:00
parent 473fa8c0d0
commit c63af35194
13 changed files with 115 additions and 17 deletions

View File

@ -53,16 +53,24 @@ class HoneypotController extends Controller
$meta['tried_pass'] = Str::limit($pass, 120);
}
// A POST is a SUBMITTED fake login — an actual attack attempt; a GET is merely viewing a decoy.
// Only an attempt bans the source IP, so merely opening a fake page never locks anyone out of
// the real panel (an operator testing the honeypot, or a mistaken click, stays free). The probe
// is still logged either way, so the dashboard sees it.
$isAttempt = $request->isMethod('post');
AuditEvent::create([
'user_id' => null,
'actor' => 'system',
'action' => 'security.honeypot_hit',
'action' => $isAttempt ? 'security.honeypot_login' : 'security.honeypot_hit',
'target' => Str::limit($request->path(), 190),
'ip' => $ip,
'meta' => $meta,
]);
if ($isAttempt) {
app(BruteforceGuard::class)->banNow($ip, 'honeypot');
}
return $this->deceive($request);
}

View File

@ -31,6 +31,7 @@ class Index extends Component
/** 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',
];
@ -152,6 +153,11 @@ class Index extends Component
->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,

View File

@ -37,7 +37,7 @@ class AuditEvent extends Model
'auth.login_failed', 'auth.2fa_failed', 'auth.ip_banned',
'fail2ban.ban', 'wg.action-failed', 'deploy.staging_release_failed',
'deploy.public_failed', 'deploy.stable_failed', 'deploy.yank_failed',
'security.honeypot_hit', 'security.honeytoken_used',
'security.honeypot_hit', 'security.honeypot_login', 'security.honeytoken_used',
];
/**

View File

@ -61,6 +61,7 @@ return [
'password.reset' => 'Passwort zurückgesetzt',
'profile.update' => 'Profil aktualisiert',
'security.honeypot_hit' => 'Honeypot-Treffer',
'security.honeypot_login' => 'Honeypot-Login-Versuch',
'security.honeytoken_used' => 'Honeytoken benutzt',
'server.create' => 'Server angelegt',
'server.credential_updated' => 'SSH-Zugangsdaten aktualisiert',

View File

@ -9,6 +9,7 @@ return [
'probes_pill' => ':count Sonden (24h)',
// KPI-Kacheln
'kpi_login_attempts' => 'Login-Versuche (24h)',
'kpi_probes' => 'Sonden (24h)',
'kpi_banned' => 'Gesperrte IPs',
'kpi_honeytoken' => 'Honeytoken',
@ -17,6 +18,7 @@ return [
// Bedrohungs-Feed
'feed_title' => 'Honeypot-Ereignisse',
'feed_subtitle' => 'neueste zuerst',
'tried_creds' => 'Versuchte Zugangsdaten',
'search_label' => 'Bedrohungen durchsuchen',
'search_placeholder' => 'IP-Adresse, Ziel …',
'pagination_nav' => 'Bedrohungs-Seiten',

View File

@ -61,6 +61,7 @@ return [
'password.reset' => 'Password reset',
'profile.update' => 'Profile updated',
'security.honeypot_hit' => 'Honeypot hit',
'security.honeypot_login' => 'Honeypot login attempt',
'security.honeytoken_used' => 'Honeytoken used',
'server.create' => 'Server added',
'server.credential_updated' => 'SSH credentials updated',

View File

@ -9,6 +9,7 @@ return [
'probes_pill' => ':count probes (24h)',
// KPI tiles
'kpi_login_attempts' => 'Login attempts (24h)',
'kpi_probes' => 'Probes (24h)',
'kpi_banned' => 'Banned IPs',
'kpi_honeytoken' => 'Honeytokens',
@ -17,6 +18,7 @@ return [
// Threat feed
'feed_title' => 'Honeypot events',
'feed_subtitle' => 'newest first',
'tried_creds' => 'Attempted credentials',
'search_label' => 'Search threats',
'search_placeholder' => 'IP address, target …',
'pagination_nav' => 'Threat pages',

View File

@ -39,6 +39,7 @@
'eye-off' => '<path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"/><path d="M14.084 14.158a3 3 0 0 1-4.242-4.242"/><path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"/><path d="m2 2 20 20"/>',
'user-plus' => '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" x2="19" y1="8" y2="14"/><line x1="22" x2="16" y1="11" y2="11"/>',
'mail' => '<rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>',
'log-in' => '<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" x2="3" y1="12" y2="12"/>',
];
@endphp
<svg {{ $attributes->merge(['class' => $class]) }} viewBox="0 0 24 24" fill="none" stroke="currentColor"

View File

@ -1,6 +1,15 @@
@php
// Global "update available" cue for the Version nav item — a cache read only (no network).
$updateAvailable = app(\App\Services\ReleaseChecker::class)->updateAvailable();
// Honeypot fake-login attempts in the last 24h → Threats nav badge. The sidebar renders on
// every page, so cache the count for 60s to avoid a per-render COUNT query.
$threatCount = \Illuminate\Support\Facades\Cache::remember(
'threats:login_attempts_24h',
60,
fn () => \App\Models\AuditEvent::where('action', 'security.honeypot_login')
->where('created_at', '>', now()->subDay())
->count(),
);
@endphp
{{-- Fixed on desktop, off-canvas drawer on mobile/tablet (toggled by `nav` in the layout). --}}
<aside class="fixed inset-y-0 left-0 z-40 flex w-[272px] -translate-x-full flex-col border-r border-line bg-surface transition-transform duration-200 lg:translate-x-0"
@ -42,7 +51,7 @@
<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>
<x-nav-item icon="shield-alert" href="/threats" :active="request()->is('threats*')" :badge="$threatCount > 0 ? $threatCount : null">{{ __('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'))

View File

@ -10,8 +10,13 @@
</x-status-pill>
</div>
{{-- KPI strip: 4 2 1 --}}
{{-- KPI strip: auto-fit login attempts first (the "someone tried to log in" signal). --}}
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
<x-kpi
:label="__('threats.kpi_login_attempts')"
:value="$login_attempts_24h"
:tone="$login_attempts_24h > 0 ? 'offline' : 'ink'"
icon="log-in" />
<x-kpi
:label="__('threats.kpi_probes')"
:value="$probes_24h"
@ -73,6 +78,17 @@
@if ($e->target)
<p class="truncate font-mono text-[11px] text-ink-3">{{ $e->target }}</p>
@endif
@php
// Attacker-guessed credentials captured on a fake-login POST. Values are
// ATTACKER-controlled — rendered via {{ }} so Blade auto-escapes them.
$triedUser = data_get($e->meta, 'tried_user');
$triedPass = data_get($e->meta, 'tried_pass');
@endphp
@if (! is_null($triedUser) || ! is_null($triedPass))
<p class="truncate font-mono text-[11px] text-ink-4">
{{ __('threats.tried_creds') }}: {{ $triedUser ?? '—' }} / {{ $triedPass ?? '—' }}
</p>
@endif
</div>
<span class="shrink-0 whitespace-nowrap font-mono text-[11px] text-ink-4">

View File

@ -11,7 +11,7 @@ class HoneypotSubmitTest extends TestCase
{
use RefreshDatabase;
public function test_posting_the_fake_login_is_trapped_and_captures_credentials(): void
public function test_posting_the_fake_login_is_trapped_bans_and_captures_credentials(): void
{
config(['clusev.honeypot.enabled' => true]);
@ -21,11 +21,23 @@ class HoneypotSubmitTest extends TestCase
$res->assertStatus(200);
$res->assertSee('WordPress', false);
// The attacker's guessed credentials are logged as threat intel.
$e = AuditEvent::where('action', 'security.honeypot_hit')->latest('id')->first();
// A POST is a login ATTEMPT → logged as such, IP banned, and the guessed credentials captured.
$e = AuditEvent::where('action', 'security.honeypot_login')->latest('id')->first();
$this->assertNotNull($e);
$this->assertSame('admin', $e->meta['tried_user']);
$this->assertSame('Hunter2!', $e->meta['tried_pass']);
$this->assertTrue(BannedIp::where('ip', '203.0.113.77')->exists());
}
public function test_viewing_a_decoy_logs_but_does_not_ban(): void
{
config(['clusev.honeypot.enabled' => true]);
// GET = merely viewing → logged as a probe, but the source IP is NOT banned (no self-lockout).
$this->call('GET', '/wp-login.php', [], [], [], ['REMOTE_ADDR' => '203.0.113.82'])->assertStatus(200);
$this->assertTrue(AuditEvent::where('action', 'security.honeypot_hit')->where('ip', '203.0.113.82')->exists());
$this->assertFalse(BannedIp::where('ip', '203.0.113.82')->exists());
}
public function test_phpmyadmin_and_generic_submits_are_trapped_not_405(): void
@ -34,19 +46,19 @@ class HoneypotSubmitTest extends TestCase
$this->call('POST', '/phpmyadmin', ['pma_username' => 'root', 'pma_password' => 'toor'], [], [], ['REMOTE_ADDR' => '203.0.113.80'])
->assertStatus(200);
$this->assertSame('root', AuditEvent::where('action', 'security.honeypot_hit')->latest('id')->first()->meta['tried_user']);
$this->assertSame('root', AuditEvent::where('action', 'security.honeypot_login')->latest('id')->first()->meta['tried_user']);
$this->call('POST', '/administrator', ['username' => 'sa', 'password' => 'pw'], [], [], ['REMOTE_ADDR' => '203.0.113.81'])
->assertStatus(200);
$this->assertSame('sa', AuditEvent::where('action', 'security.honeypot_hit')->latest('id')->first()->meta['tried_user']);
$this->assertSame('sa', AuditEvent::where('action', 'security.honeypot_login')->latest('id')->first()->meta['tried_user']);
}
public function test_banned_ip_stays_in_the_decoy_but_is_blocked_from_the_real_login(): void
{
config(['clusev.honeypot.enabled' => true]);
// First hit from a non-exempt IP instant-bans it.
$this->call('GET', '/wp-login.php', [], [], [], ['REMOTE_ADDR' => '203.0.113.79'])->assertStatus(200);
// A POST login attempt from a non-exempt IP bans it.
$this->call('POST', '/wp-login.php', ['log' => 'x', 'pwd' => 'y'], [], [], ['REMOTE_ADDR' => '203.0.113.79'])->assertStatus(200);
$this->assertTrue(BannedIp::where('ip', '203.0.113.79')->exists());
// The now-banned prober still gets the fake decoy (BlockBannedIp is off for decoys) — kept inside

View File

@ -46,15 +46,18 @@ class HoneypotTest extends TestCase
return $this->call($method, $uri, $data, [], [], ['REMOTE_ADDR' => $ip]);
}
public function test_wp_login_decoy_serves_fake_wordpress_and_bans(): void
public function test_wp_login_decoy_serves_fake_wordpress_without_banning_on_view(): void
{
// Merely VIEWING a decoy (GET) is logged but never bans — an operator testing the honeypot,
// or a stray click, must not lock themselves out of the real panel. The ban fires only on a
// POST login attempt (see HoneypotSubmitTest).
$response = $this->fromIp('GET', '/wp-login.php', self::ATTACKER_IP);
$response->assertStatus(200);
$response->assertSee('wp-submit', false);
$response->assertSee('WordPress', false);
$this->assertTrue(BannedIp::where('ip', self::ATTACKER_IP)->exists());
$this->assertDatabaseCount('banned_ips', 0);
$this->assertTrue(AuditEvent::where('action', 'security.honeypot_hit')->exists());
}
@ -67,11 +70,12 @@ class HoneypotTest extends TestCase
$response->assertStatus(200);
$response->assertSee($canary, false);
$this->assertTrue(BannedIp::where('ip', self::ATTACKER_IP)->exists());
// GET = view only → leaked + logged, but not banned (ban is reserved for a POST attempt).
$this->assertDatabaseCount('banned_ips', 0);
$this->assertTrue(AuditEvent::where('action', 'security.honeypot_hit')->exists());
}
public function test_git_config_decoy_serves_fake_git_config_and_bans(): void
public function test_git_config_decoy_serves_fake_git_config_without_banning_on_view(): void
{
$response = $this->fromIp('GET', '/.git/config', self::ATTACKER_IP);
@ -80,7 +84,7 @@ class HoneypotTest extends TestCase
$response->assertSee('[remote "origin"]', false);
$response->assertSee('[core]', false);
$this->assertTrue(BannedIp::where('ip', self::ATTACKER_IP)->exists());
$this->assertDatabaseCount('banned_ips', 0);
$this->assertTrue(AuditEvent::where('action', 'security.honeypot_hit')->exists());
}

View File

@ -110,6 +110,42 @@ class ThreatsPageTest extends TestCase
->assertViewHas('top_ip', self::ATTACKER_IP);
}
public function test_page_shows_honeypot_login_attempts_with_tried_credentials(): void
{
AuditEvent::create([
'actor' => 'system',
'action' => 'security.honeypot_login',
'target' => '/wp-login.php',
'ip' => self::ATTACKER_IP,
'meta' => ['tried_user' => 'admin', 'tried_pass' => 'hunter2', 'method' => 'POST'],
]);
Livewire::actingAs($this->admin())
->test(Index::class)
->assertSee(self::ATTACKER_IP)
->assertSee('Honeypot-Login-Versuch') // localized action label (DE default)
->assertSee('admin') // captured tried_user
->assertSee('hunter2') // captured tried_pass
->assertViewHas('login_attempts_24h', 1);
}
public function test_tried_credentials_are_html_escaped(): void
{
// Attacker-controlled meta must render escaped, never as live markup.
AuditEvent::create([
'actor' => 'system',
'action' => 'security.honeypot_login',
'target' => '/wp-login.php',
'ip' => self::ATTACKER_IP,
'meta' => ['tried_user' => '<script>alert(1)</script>', 'tried_pass' => 'x'],
]);
Livewire::actingAs($this->admin())
->test(Index::class)
->assertDontSee('<script>alert(1)</script>', false) // raw markup must NOT be present
->assertSee('&lt;script&gt;', false); // it is escaped instead
}
public function test_top_ip_is_null_when_there_is_no_traffic(): void
{
Livewire::actingAs($this->admin())