99 lines
3.5 KiB
PHP
99 lines
3.5 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Models\AuditEvent;
|
|
use App\Models\BannedIp;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Tests\TestCase;
|
|
|
|
class HoneypotTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
/** A non-exempt (public) client IP — loopback/private ranges are exempt from banNow. */
|
|
private const ATTACKER_IP = '203.0.113.99';
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
Cache::flush();
|
|
}
|
|
|
|
/** Simulate a request from a specific client IP via REMOTE_ADDR (trustProxies is prod-gated). */
|
|
private function fromIp(string $method, string $uri, string $ip, array $data = [])
|
|
{
|
|
return $this->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);
|
|
}
|
|
}
|