feat(honeypot): surface login attempts in the dashboard (threats KPI, tried-credentials, sidebar badge)
parent
473fa8c0d0
commit
c63af35194
|
|
@ -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,
|
||||
]);
|
||||
|
||||
app(BruteforceGuard::class)->banNow($ip, 'honeypot');
|
||||
if ($isAttempt) {
|
||||
app(BruteforceGuard::class)->banNow($ip, 'honeypot');
|
||||
}
|
||||
|
||||
return $this->deceive($request);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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('<script>', false); // it is escaped instead
|
||||
}
|
||||
|
||||
public function test_top_ip_is_null_when_there_is_no_traffic(): void
|
||||
{
|
||||
Livewire::actingAs($this->admin())
|
||||
|
|
|
|||
Loading…
Reference in New Issue