diff --git a/app/Http/Controllers/HoneypotController.php b/app/Http/Controllers/HoneypotController.php index 488e418..c8cc385 100644 --- a/app/Http/Controllers/HoneypotController.php +++ b/app/Http/Controllers/HoneypotController.php @@ -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); } diff --git a/app/Livewire/Threats/Index.php b/app/Livewire/Threats/Index.php index db07d53..c1ca2dc 100644 --- a/app/Livewire/Threats/Index.php +++ b/app/Livewire/Threats/Index.php @@ -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, diff --git a/app/Models/AuditEvent.php b/app/Models/AuditEvent.php index 532ecee..184f766 100644 --- a/app/Models/AuditEvent.php +++ b/app/Models/AuditEvent.php @@ -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', ]; /** diff --git a/lang/de/audit.php b/lang/de/audit.php index 4712e3d..65e2e21 100644 --- a/lang/de/audit.php +++ b/lang/de/audit.php @@ -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', diff --git a/lang/de/threats.php b/lang/de/threats.php index a088bbd..25aac6f 100644 --- a/lang/de/threats.php +++ b/lang/de/threats.php @@ -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', diff --git a/lang/en/audit.php b/lang/en/audit.php index b10b8b8..6b8261d 100644 --- a/lang/en/audit.php +++ b/lang/en/audit.php @@ -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', diff --git a/lang/en/threats.php b/lang/en/threats.php index ae333db..cebed41 100644 --- a/lang/en/threats.php +++ b/lang/en/threats.php @@ -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', diff --git a/resources/views/components/icon.blade.php b/resources/views/components/icon.blade.php index 64dc772..8a4a5ca 100644 --- a/resources/views/components/icon.blade.php +++ b/resources/views/components/icon.blade.php @@ -39,6 +39,7 @@ 'eye-off' => '', 'user-plus' => '', 'mail' => '', + 'log-in' => '', ]; @endphp merge(['class' => $class]) }} viewBox="0 0 24 24" fill="none" stroke="currentColor" diff --git a/resources/views/components/sidebar.blade.php b/resources/views/components/sidebar.blade.php index 198a09e..42256a2 100644 --- a/resources/views/components/sidebar.blade.php +++ b/resources/views/components/sidebar.blade.php @@ -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). --}}
+ target)

{{ $e->target }}

@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)) +

+ {{ __('threats.tried_creds') }}: {{ $triedUser ?? '—' }} / {{ $triedPass ?? '—' }} +

+ @endif
diff --git a/tests/Feature/HoneypotSubmitTest.php b/tests/Feature/HoneypotSubmitTest.php index 4b35562..d7ee205 100644 --- a/tests/Feature/HoneypotSubmitTest.php +++ b/tests/Feature/HoneypotSubmitTest.php @@ -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 diff --git a/tests/Feature/HoneypotTest.php b/tests/Feature/HoneypotTest.php index 6abea76..eb7e89f 100644 --- a/tests/Feature/HoneypotTest.php +++ b/tests/Feature/HoneypotTest.php @@ -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()); } diff --git a/tests/Feature/ThreatsPageTest.php b/tests/Feature/ThreatsPageTest.php index 35c3ed1..d379ded 100644 --- a/tests/Feature/ThreatsPageTest.php +++ b/tests/Feature/ThreatsPageTest.php @@ -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' => '', 'tried_pass' => 'x'], + ]); + + Livewire::actingAs($this->admin()) + ->test(Index::class) + ->assertDontSee('', 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())