diff --git a/app/Http/Controllers/HoneypotController.php b/app/Http/Controllers/HoneypotController.php
new file mode 100644
index 0000000..382568d
--- /dev/null
+++ b/app/Http/Controllers/HoneypotController.php
@@ -0,0 +1,185 @@
+ip();
+
+ AuditEvent::create([
+ 'user_id' => null,
+ 'actor' => 'system',
+ 'action' => 'security.honeypot_hit',
+ 'target' => Str::limit($request->path(), 190),
+ 'ip' => $ip,
+ 'meta' => [
+ 'ua' => Str::limit((string) $request->userAgent(), 190),
+ 'method' => $request->method(),
+ 'query' => Str::limit((string) $request->getQueryString(), 190),
+ ],
+ ]);
+
+ app(BruteforceGuard::class)->banNow($ip, 'honeypot');
+
+ return $this->deceive($request);
+ }
+
+ /** Pick a convincing fake response for the probed path. */
+ private function deceive(Request $request): Response
+ {
+ $path = strtolower($request->path());
+
+ if ($path === '.env') {
+ return response($this->fakeDotenv(), 200, ['Content-Type' => 'text/plain; charset=UTF-8']);
+ }
+
+ if (str_starts_with($path, 'wp-login.php') || str_starts_with($path, 'wp-admin')) {
+ return response($this->fakeWordPress(), 200, ['Content-Type' => 'text/html; charset=UTF-8']);
+ }
+
+ if (str_starts_with($path, 'phpmyadmin')) {
+ return response($this->fakePhpMyAdmin(), 200, ['Content-Type' => 'text/html; charset=UTF-8']);
+ }
+
+ return response($this->fakeGenericLogin(), 200, ['Content-Type' => 'text/html; charset=UTF-8']);
+ }
+
+ /** Plausible-looking dotenv seeded with the three config canaries (the bait). */
+ private function fakeDotenv(): string
+ {
+ $c = (array) config('clusev.honeypot.canaries');
+
+ return implode("\n", [
+ 'APP_NAME=Application',
+ 'APP_ENV=production',
+ 'APP_KEY='.($c['app_key'] ?? ''),
+ 'APP_DEBUG=false',
+ 'APP_URL=https://app.internal.example.com',
+ '',
+ 'DB_CONNECTION=mysql',
+ 'DB_HOST=10.20.0.14',
+ 'DB_PORT=3306',
+ 'DB_DATABASE=app_production',
+ 'DB_USERNAME=app_prod',
+ 'DB_PASSWORD='.($c['db_password'] ?? ''),
+ '',
+ 'AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE',
+ 'AWS_SECRET_ACCESS_KEY='.($c['aws_secret'] ?? ''),
+ 'AWS_DEFAULT_REGION=eu-central-1',
+ '',
+ ]);
+ }
+
+ private function fakeWordPress(): string
+ {
+ return <<<'HTML'
+
+
+
+
+
+Log In ‹ Blog — WordPress
+
+
+
+
+
+HTML;
+ }
+
+ private function fakePhpMyAdmin(): string
+ {
+ return <<<'HTML'
+
+
+
+
+
+phpMyAdmin
+
+
+
+
+
+HTML;
+ }
+
+ private function fakeGenericLogin(): string
+ {
+ return <<<'HTML'
+
+
+
+
+
+Sign in
+
+
+
+Sign in to continue
+
+
+
+
+HTML;
+ }
+}
diff --git a/app/Http/Middleware/DetectHoneytoken.php b/app/Http/Middleware/DetectHoneytoken.php
new file mode 100644
index 0000000..106661e
--- /dev/null
+++ b/app/Http/Middleware/DetectHoneytoken.php
@@ -0,0 +1,86 @@
+input($key);
+ if (is_string($value) && $value !== '') {
+ $candidates[] = $value;
+ }
+ }
+ if (($bearer = $request->bearerToken()) !== null && $bearer !== '') {
+ $candidates[] = $bearer;
+ }
+ if (($auth = $request->header('Authorization')) !== null && $auth !== '') {
+ $candidates[] = $auth;
+ }
+
+ foreach ($canaries as $name => $canaryValue) {
+ if (! is_string($canaryValue) || $canaryValue === '') {
+ continue;
+ }
+ foreach ($candidates as $candidate) {
+ if (hash_equals($canaryValue, $candidate)) {
+ return $this->trip($request, (string) $name);
+ }
+ }
+ }
+
+ return $next($request);
+ }
+
+ private function trip(Request $request, string $canaryName): Response
+ {
+ $ip = (string) $request->ip();
+
+ $this->guard->banNow($ip, 'honeytoken');
+
+ AuditEvent::create([
+ 'user_id' => null,
+ 'actor' => 'system',
+ 'action' => 'security.honeytoken_used',
+ 'target' => $canaryName,
+ 'ip' => $ip,
+ 'meta' => ['path' => $request->path()],
+ ]);
+
+ return response()->view('errors.blocked', [], 403);
+ }
+}
diff --git a/app/Models/AuditEvent.php b/app/Models/AuditEvent.php
index 2106188..532ecee 100644
--- a/app/Models/AuditEvent.php
+++ b/app/Models/AuditEvent.php
@@ -37,6 +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',
];
/**
diff --git a/app/Services/BruteforceGuard.php b/app/Services/BruteforceGuard.php
index e4d13fb..b24f404 100644
--- a/app/Services/BruteforceGuard.php
+++ b/app/Services/BruteforceGuard.php
@@ -118,6 +118,49 @@ class BruteforceGuard
]);
}
+ /**
+ * Ban an IP on the FIRST hit — no maxretry threshold, no RateLimiter window. Used by the
+ * honeypot deception layer: a request to a decoy path (or a leaked honeytoken) is proof of
+ * hostile intent, so there is nothing to count. Mirrors the ban write in record() minus the
+ * throttling. Returns true when a ban was written, false when it no-op'd (disabled / invalid
+ * / exempt IP) — the caller stays a cheap no-op for legitimate traffic. Reuses the same
+ * private helpers as record() (canonical/isExempt/enabled/bantime/maxretry).
+ */
+ public function banNow(?string $ip, string $reason): bool
+ {
+ if (! $this->enabled() || ! $ip || filter_var($ip, FILTER_VALIDATE_IP) === false || $this->isExempt($ip)) {
+ return false;
+ }
+
+ $canonical = $this->canonical($ip);
+
+ $now = now();
+ BannedIp::upsert(
+ [[
+ 'ip' => $canonical,
+ 'banned_until' => $now->copy()->addMinutes($this->bantime()),
+ 'reason' => $reason,
+ 'attempts' => $this->maxretry(),
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]],
+ ['ip'],
+ ['banned_until', 'reason', 'attempts', 'updated_at'],
+ );
+ Cache::forget('bruteforce:banned:'.$canonical);
+
+ AuditEvent::create([
+ 'user_id' => null,
+ 'actor' => 'system',
+ 'action' => 'auth.ip_banned',
+ 'target' => $canonical,
+ 'ip' => $canonical,
+ 'meta' => ['reason' => $reason],
+ ]);
+
+ return true;
+ }
+
/**
* Whether $ip has an active ban. Does NOT check enabled() — the BlockBannedIp middleware
* gates on enabled() before calling this, so disabling the feature stops enforcement while
diff --git a/bootstrap/app.php b/bootstrap/app.php
index 13debc7..f44a404 100644
--- a/bootstrap/app.php
+++ b/bootstrap/app.php
@@ -1,6 +1,7 @@
web(
prepend: [PanelScheme::class],
- append: [SetLocale::class, SecurityHeaders::class, AuthenticateSession::class, BlockBannedIp::class],
+ append: [SetLocale::class, SecurityHeaders::class, AuthenticateSession::class, BlockBannedIp::class, DetectHoneytoken::class],
);
// The terminal sidecar POSTs to the internal resolve endpoint from the private network with
diff --git a/config/clusev.php b/config/clusev.php
index f90d19e..a8a14d8 100644
--- a/config/clusev.php
+++ b/config/clusev.php
@@ -55,5 +55,18 @@ return [
// generates it alongside the other secrets; the host reads the SAME value from .env.
'update_hmac_key' => env('UPDATE_HMAC_KEY', ''),
+ 'honeypot' => [
+ // Deception layer for unauthenticated attacker probes. ON by default: legit users never hit
+ // decoy paths, so there are no false positives. Set CLUSEV_HONEYPOT=false to disable.
+ 'enabled' => (bool) env('CLUSEV_HONEYPOT', true),
+ // Canary secret VALUES embedded in the fake .env we serve. No real system secret equals these;
+ // if any reappears in a later request it PROVES an attacker took the bait -> instant ban.
+ 'canaries' => [
+ 'app_key' => 'base64:H0n3yP0tC4n4ryK3yD0N0tUs3AAAAAAAAAAAAAAAAA0=',
+ 'db_password' => 'Sup3rS3cr3t-Pr0d-DB-9f2a7c',
+ 'aws_secret' => 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
+ ],
+ ],
+
'license' => 'AGPL-3.0',
];
diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf
index 96fa7bd..8d0d685 100644
--- a/docker/nginx/nginx.conf
+++ b/docker/nginx/nginx.conf
@@ -80,6 +80,13 @@ http {
fastcgi_read_timeout 120;
}
+ # Honeypot dotfile decoys: route /.env and /.git/config to Laravel (HoneypotController)
+ # instead of letting the dotfile-deny below 403 them. Exact-match locations take priority
+ # over the regex deny in nginx; placed above for clarity. Same try_files → index.php path
+ # as the main `location /`. All OTHER dotfiles stay denied.
+ location = /.env { try_files $uri /index.php?$query_string; }
+ location = /.git/config { try_files $uri /index.php?$query_string; }
+
# deny dotfiles except .well-known
location ~ /\.(?!well-known).* {
deny all;
diff --git a/lang/de/audit.php b/lang/de/audit.php
index 8e33f62..4712e3d 100644
--- a/lang/de/audit.php
+++ b/lang/de/audit.php
@@ -60,6 +60,8 @@ return [
'password.change' => 'Passwort geändert',
'password.reset' => 'Passwort zurückgesetzt',
'profile.update' => 'Profil aktualisiert',
+ 'security.honeypot_hit' => 'Honeypot-Treffer',
+ 'security.honeytoken_used' => 'Honeytoken benutzt',
'server.create' => 'Server angelegt',
'server.credential_updated' => 'SSH-Zugangsdaten aktualisiert',
'ssh_key.add' => 'SSH-Schlüssel hinzugefügt',
diff --git a/lang/en/audit.php b/lang/en/audit.php
index 4705774..b10b8b8 100644
--- a/lang/en/audit.php
+++ b/lang/en/audit.php
@@ -60,6 +60,8 @@ return [
'password.change' => 'Password changed',
'password.reset' => 'Password reset',
'profile.update' => 'Profile updated',
+ 'security.honeypot_hit' => 'Honeypot hit',
+ 'security.honeytoken_used' => 'Honeytoken used',
'server.create' => 'Server added',
'server.credential_updated' => 'SSH credentials updated',
'ssh_key.add' => 'SSH key added',
diff --git a/routes/web.php b/routes/web.php
index 8ac1895..3de7104 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -1,5 +1,6 @@
middleware(VerifyTerminalSidecarSecret::class)->name('internal.terminal.resolve');
+// ── Honeypot decoy paths ────────────────────────────────────────────────────────────────────
+// Well-known scanner/exploit targets that NO legitimate client ever visits. Every hit is a hostile
+// probe → HoneypotController::trap logs it, instant-bans the source IP, and serves a convincing fake
+// body (BruteforceGuard::banNow no-ops for exempt/loopback IPs). Route::any so probes on any verb
+// (GET/POST/HEAD) are caught. These paths were checked against the real top-level routes above
+// (/locale, /_caddy/ask, /version.json, /update-status.json, /_internal/*) and the auth groups
+// below (/login, /dashboard, …) — none collide. The two dotfile decoys (/.env, /.git/config) are
+// additionally allowed through the nginx dotfile-deny guard (docker/nginx/nginx.conf).
+Route::any('/.env', [HoneypotController::class, 'trap'])->name('honeypot.env');
+Route::any('/.git/config', [HoneypotController::class, 'trap'])->name('honeypot.git');
+Route::any('/wp-login.php', [HoneypotController::class, 'trap'])->name('honeypot.wp-login');
+Route::any('/wp-admin', [HoneypotController::class, 'trap'])->name('honeypot.wp-admin');
+Route::any('/wp-admin/{path?}', [HoneypotController::class, 'trap'])->where('path', '.*')->name('honeypot.wp-admin.path');
+Route::any('/xmlrpc.php', [HoneypotController::class, 'trap'])->name('honeypot.xmlrpc');
+Route::any('/phpmyadmin', [HoneypotController::class, 'trap'])->name('honeypot.phpmyadmin');
+Route::any('/phpmyadmin/{path?}', [HoneypotController::class, 'trap'])->where('path', '.*')->name('honeypot.phpmyadmin.path');
+Route::any('/administrator', [HoneypotController::class, 'trap'])->name('honeypot.administrator');
+Route::any('/admin.php', [HoneypotController::class, 'trap'])->name('honeypot.admin-php');
+Route::any('/adminer.php', [HoneypotController::class, 'trap'])->name('honeypot.adminer');
+Route::any('/config.json', [HoneypotController::class, 'trap'])->name('honeypot.config-json');
+Route::any('/vendor/{path?}', [HoneypotController::class, 'trap'])->where('path', '.*')->name('honeypot.vendor');
+Route::any('/cgi-bin/{path?}', [HoneypotController::class, 'trap'])->where('path', '.*')->name('honeypot.cgi-bin');
+Route::any('/solr/{path?}', [HoneypotController::class, 'trap'])->where('path', '.*')->name('honeypot.solr');
+Route::any('/actuator/{path?}', [HoneypotController::class, 'trap'])->where('path', '.*')->name('honeypot.actuator');
+
Route::middleware('guest')->group(function () {
Route::get('/login', Auth\Login::class)->name('login');
Route::get('/forgot-password', Auth\ForgotPassword::class)->name('password.request');
diff --git a/tests/Feature/HoneypotTest.php b/tests/Feature/HoneypotTest.php
new file mode 100644
index 0000000..6b11199
--- /dev/null
+++ b/tests/Feature/HoneypotTest.php
@@ -0,0 +1,98 @@
+call($method, $uri, $data, [], [], ['REMOTE_ADDR' => $ip]);
+ }
+
+ public function test_wp_login_decoy_serves_fake_wordpress_and_bans(): void
+ {
+ $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->assertTrue(AuditEvent::where('action', 'security.honeypot_hit')->exists());
+ }
+
+ public function test_env_decoy_leaks_canary_and_bans(): void
+ {
+ $canary = config('clusev.honeypot.canaries.app_key');
+
+ $response = $this->fromIp('GET', '/.env', self::ATTACKER_IP);
+
+ $response->assertStatus(200);
+ $response->assertSee($canary, false);
+
+ $this->assertTrue(BannedIp::where('ip', self::ATTACKER_IP)->exists());
+ $this->assertTrue(AuditEvent::where('action', 'security.honeypot_hit')->exists());
+ }
+
+ public function test_honeytoken_replay_is_blocked_banned_and_audited(): void
+ {
+ $canary = config('clusev.honeypot.canaries.db_password');
+
+ // A normal POST web route carrying the leaked canary in a form field trips the middleware
+ // (broadcasting/auth is in the web group, so the appended DetectHoneytoken runs). The 403
+ // fires before the route's own handler, proving the tripwire, not the endpoint, responded.
+ $response = $this->fromIp('POST', '/broadcasting/auth', self::ATTACKER_IP, ['password' => $canary]);
+
+ $response->assertStatus(403);
+
+ $this->assertTrue(BannedIp::where('ip', self::ATTACKER_IP)->exists());
+ $this->assertTrue(
+ AuditEvent::where('action', 'security.honeytoken_used')
+ ->where('target', 'db_password')
+ ->where('ip', self::ATTACKER_IP)
+ ->exists()
+ );
+ }
+
+ public function test_disabled_honeypot_returns_404_without_ban_or_audit(): void
+ {
+ config(['clusev.honeypot.enabled' => false]);
+
+ $response = $this->fromIp('GET', '/wp-login.php', self::ATTACKER_IP);
+
+ $response->assertStatus(404);
+ $this->assertDatabaseCount('banned_ips', 0);
+ $this->assertFalse(AuditEvent::where('action', 'security.honeypot_hit')->exists());
+ }
+
+ public function test_exempt_ip_gets_deception_but_is_not_banned(): void
+ {
+ // Default test client IP is 127.0.0.1 (loopback → exempt): deception still fires, ban does not.
+ $response = $this->get('/wp-login.php');
+
+ $response->assertStatus(200);
+ $response->assertSee('wp-submit', false);
+
+ // The hit is still audited, but banNow no-ops for the exempt IP → no ban row.
+ $this->assertTrue(AuditEvent::where('action', 'security.honeypot_hit')->exists());
+ $this->assertDatabaseCount('banned_ips', 0);
+ }
+}