224 lines
7.7 KiB
PHP
224 lines
7.7 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Models\AuditEvent;
|
|
use App\Services\BruteforceGuard;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Http\Response;
|
|
use Illuminate\Support\Str;
|
|
|
|
/**
|
|
* Honeypot deception layer. Handles requests to decoy paths (routes/web.php, above the guest
|
|
* group) that no legitimate client ever visits — /.env, /wp-login.php, /phpmyadmin, … A hit is
|
|
* proof of a hostile probe, so we: audit it, instant-ban the source IP (BruteforceGuard::banNow,
|
|
* exempt/loopback are skipped), and return a small, self-contained DECEPTIVE body so the attacker
|
|
* keeps chasing the bait instead of noticing a real panel behind it. Attacker-facing strings are
|
|
* intentionally NOT localized (they impersonate generic third-party software).
|
|
*/
|
|
class HoneypotController extends Controller
|
|
{
|
|
public function trap(Request $request): Response
|
|
{
|
|
// Feature off → behave exactly like an ordinary 404 for these paths (no ban, no audit).
|
|
if (! config('clusev.honeypot.enabled')) {
|
|
abort(404);
|
|
}
|
|
|
|
// An AUTHENTICATED user hitting a decoy is a stray request, NOT an attacker: behave like an
|
|
// ordinary 404 (no ban, no deception, no audit) so a logged-in operator/admin never self-bans.
|
|
if (auth()->check()) {
|
|
abort(404);
|
|
}
|
|
|
|
$ip = (string) $request->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 ($path === '.git/config') {
|
|
return response($this->fakeGitConfig(), 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 per-install canaries (the bait) when configured. When a
|
|
* canary is empty (unconfigured/dev), a plausible static value is served instead so the decoy still
|
|
* looks authentic — it is simply not registered as a detectable honeytoken.
|
|
*/
|
|
private function fakeDotenv(): string
|
|
{
|
|
$c = (array) config('clusev.honeypot.canaries');
|
|
|
|
$appKey = ($c['app_key'] ?? '') !== '' ? $c['app_key'] : 'base64:H0n3yP0tC4n4ryK3yD0N0tUs3AAAAAAAAAAAAAAAAA0=';
|
|
$dbPassword = ($c['db_password'] ?? '') !== '' ? $c['db_password'] : 'Sup3rS3cr3t-Pr0d-DB-9f2a7c';
|
|
$apiKey = ($c['api_key'] ?? '') !== '' ? $c['api_key'] : 'a7f3c9e1d5b04826bf1a3c7e9d2f6b84';
|
|
|
|
return implode("\n", [
|
|
'APP_NAME=Application',
|
|
'APP_ENV=production',
|
|
'APP_KEY='.$appKey,
|
|
'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='.$dbPassword,
|
|
'',
|
|
'API_KEY='.$apiKey,
|
|
'AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE',
|
|
'AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
|
|
'AWS_DEFAULT_REGION=eu-central-1',
|
|
'',
|
|
]);
|
|
}
|
|
|
|
/** Plausible fake git config (an [core]/[remote "origin"] INI) so the /.git/config decoy looks real. */
|
|
private function fakeGitConfig(): string
|
|
{
|
|
return implode("\n", [
|
|
'[core]',
|
|
"\trepositoryformatversion = 0",
|
|
"\tfilemode = true",
|
|
"\tbare = false",
|
|
"\tlogallrefupdates = true",
|
|
'[remote "origin"]',
|
|
"\turl = https://git.internal.example.com/app/backend.git",
|
|
"\tfetch = +refs/heads/*:refs/remotes/origin/*",
|
|
'[branch "main"]',
|
|
"\tremote = origin",
|
|
"\tmerge = refs/heads/main",
|
|
'',
|
|
]);
|
|
}
|
|
|
|
private function fakeWordPress(): string
|
|
{
|
|
return <<<'HTML'
|
|
<!DOCTYPE html>
|
|
<html lang="en-US">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Log In ‹ Blog — WordPress</title>
|
|
</head>
|
|
<body class="login">
|
|
<div id="login">
|
|
<h1><a href="https://wordpress.org/">Powered by WordPress</a></h1>
|
|
<form name="loginform" id="loginform" action="wp-login.php" method="post">
|
|
<p><label for="user_login">Username or Email Address</label>
|
|
<input type="text" name="log" id="user_login" class="input" value="" size="20"></p>
|
|
<p><label for="user_pass">Password</label>
|
|
<input type="password" name="pwd" id="user_pass" class="input" value="" size="20"></p>
|
|
<p class="forgetmenot"><label for="rememberme"><input name="rememberme" type="checkbox" id="rememberme" value="forever"> Remember Me</label></p>
|
|
<p class="submit"><input type="submit" name="wp-submit" id="wp-submit" class="button button-primary" value="Log In"></p>
|
|
</form>
|
|
<p id="nav"><a href="wp-login.php?action=lostpassword">Lost your password?</a></p>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
HTML;
|
|
}
|
|
|
|
private function fakePhpMyAdmin(): string
|
|
{
|
|
return <<<'HTML'
|
|
<!DOCTYPE html>
|
|
<html lang="en" dir="ltr">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>phpMyAdmin</title>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<a href="index.php" class="logo">phpMyAdmin</a>
|
|
<h1>Welcome to phpMyAdmin</h1>
|
|
<form method="post" action="index.php" name="login_form" class="login">
|
|
<fieldset>
|
|
<legend>Log in</legend>
|
|
<div class="item">
|
|
<label for="input_username">Username:</label>
|
|
<input type="text" name="pma_username" id="input_username" value="" size="24" class="textfield">
|
|
</div>
|
|
<div class="item">
|
|
<label for="input_password">Password:</label>
|
|
<input type="password" name="pma_password" id="input_password" value="" size="24" class="textfield">
|
|
</div>
|
|
<div class="item">
|
|
<label for="select_server">Server Choice:</label>
|
|
<select name="server" id="select_server"><option value="1">localhost</option></select>
|
|
</div>
|
|
<input type="submit" name="submit" value="Go" class="button">
|
|
</fieldset>
|
|
</form>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
HTML;
|
|
}
|
|
|
|
private function fakeGenericLogin(): string
|
|
{
|
|
return <<<'HTML'
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Sign in</title>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<h1>Sign in to continue</h1>
|
|
<form method="post" action="login">
|
|
<label for="username">Username</label>
|
|
<input type="text" name="username" id="username" autocomplete="username">
|
|
<label for="password">Password</label>
|
|
<input type="password" name="password" id="password" autocomplete="current-password">
|
|
<button type="submit">Sign in</button>
|
|
</form>
|
|
</main>
|
|
</body>
|
|
</html>
|
|
HTML;
|
|
}
|
|
}
|