diff --git a/.github/workflows/ci-staging.yml b/.github/workflows/ci-staging.yml index 7eda38f..f0d466c 100644 --- a/.github/workflows/ci-staging.yml +++ b/.github/workflows/ci-staging.yml @@ -112,6 +112,11 @@ jobs: permissions: contents: read packages: write + # Route the tag through an env var instead of interpolating ${{ github.ref_name }} straight into + # run: shells (template-injection hardening / actionlint). The "Enforce a clean stable SemVer tag" + # step below still validates $GITHUB_REF_NAME before any step consumes it. + env: + TAG: ${{ github.ref_name }} steps: # Hard stop: only an EXACT stable SemVer tag may cause a public side-effect. The `if:` above # cannot regex, so `vfoo` / `v1.2` / `v2026` would slip through — fail here, before any publish. @@ -133,8 +138,8 @@ jobs: id: img run: | set -euo pipefail - appd=$(docker buildx imagetools inspect "ghcr.io/${{ vars.GHCR_OWNER }}/clusev:${{ github.ref_name }}" --format '{{.Manifest.Digest}}') - termd=$(docker buildx imagetools inspect "ghcr.io/${{ vars.GHCR_OWNER }}/clusev-terminal:${{ github.ref_name }}" --format '{{.Manifest.Digest}}') + appd=$(docker buildx imagetools inspect "ghcr.io/${{ vars.GHCR_OWNER }}/clusev:${TAG}" --format '{{.Manifest.Digest}}') + termd=$(docker buildx imagetools inspect "ghcr.io/${{ vars.GHCR_OWNER }}/clusev-terminal:${TAG}" --format '{{.Manifest.Digest}}') { echo "appd=$appd"; echo "termd=$termd"; } >> "$GITHUB_OUTPUT" - uses: actions/checkout@v5 with: @@ -152,14 +157,14 @@ jobs: git config --global user.name 'clusev-release' git config --global user.email 'release@clusev' chmod +x src/scripts/promote.sh - src/scripts/promote.sh "$PWD/src" "${{ github.ref_name }}" "$PWD/pub" "Release ${{ github.ref_name }}" + src/scripts/promote.sh "$PWD/src" "$TAG" "$PWD/pub" "Release $TAG" # release-images.lock — consumed by sub-project #2 (install/update pull). printf '{ "version": "%s", "app": "ghcr.io/%s/clusev@%s", "terminal": "ghcr.io/%s/clusev-terminal@%s" }\n' \ - "${{ github.ref_name }}" "${{ vars.PUBLIC_GHCR_OWNER }}" "${{ steps.img.outputs.appd }}" \ + "$TAG" "${{ vars.PUBLIC_GHCR_OWNER }}" "${{ steps.img.outputs.appd }}" \ "${{ vars.PUBLIC_GHCR_OWNER }}" "${{ steps.img.outputs.termd }}" > pub/release-images.lock git -C pub add -f release-images.lock git -C pub commit -q --amend --no-edit - git -C pub tag -f "${{ github.ref_name }}" + git -C pub tag -f "$TAG" # Publish AFTER every guard, and BEFORE the git push (publish-then-advertise): a public release # ref therefore ALWAYS has a pullable image. A push failure after this leaves only unreferenced, # content-addressed image tags — re-running the promote republishes them idempotently and retries @@ -167,8 +172,8 @@ jobs: - name: Publish images to public GHCR run: | set -euo pipefail - docker buildx imagetools create -t "ghcr.io/${{ vars.PUBLIC_GHCR_OWNER }}/clusev:${{ github.ref_name }}" "ghcr.io/${{ vars.GHCR_OWNER }}/clusev@${{ steps.img.outputs.appd }}" - docker buildx imagetools create -t "ghcr.io/${{ vars.PUBLIC_GHCR_OWNER }}/clusev-terminal:${{ github.ref_name }}" "ghcr.io/${{ vars.GHCR_OWNER }}/clusev-terminal@${{ steps.img.outputs.termd }}" + docker buildx imagetools create -t "ghcr.io/${{ vars.PUBLIC_GHCR_OWNER }}/clusev:${TAG}" "ghcr.io/${{ vars.GHCR_OWNER }}/clusev@${{ steps.img.outputs.appd }}" + docker buildx imagetools create -t "ghcr.io/${{ vars.PUBLIC_GHCR_OWNER }}/clusev-terminal:${TAG}" "ghcr.io/${{ vars.GHCR_OWNER }}/clusev-terminal@${{ steps.img.outputs.termd }}" # Atomic: main + tag land together, or neither does. - name: Push code + tag to public (atomic) - run: git -C pub push --atomic origin HEAD:main "refs/tags/${{ github.ref_name }}" + run: git -C pub push --atomic origin HEAD:main "refs/tags/${TAG}" diff --git a/app/Http/Controllers/HoneypotController.php b/app/Http/Controllers/HoneypotController.php index c8cc385..0be9440 100644 --- a/app/Http/Controllers/HoneypotController.php +++ b/app/Http/Controllers/HoneypotController.php @@ -6,6 +6,7 @@ use App\Models\AuditEvent; use App\Services\BruteforceGuard; use Illuminate\Http\Request; use Illuminate\Http\Response; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Str; /** @@ -18,6 +19,14 @@ use Illuminate\Support\Str; */ class HoneypotController extends Controller { + /** + * Per-IP cap on decoy audit rows written in a rolling hour. The decoy routes run WITHOUT + * BlockBannedIp (an already-banned prober still gets the bait), so a single source can loop + * over wildcard paths indefinitely — this bounds how far it can grow audit_events. The ban + + * the deception still happen past the cap; only the (redundant) extra audit rows are dropped. + */ + private const AUDIT_CAP_PER_HOUR = 60; + public function trap(Request $request): Response { // Feature off → behave exactly like an ordinary 404 for these paths (no ban, no audit). @@ -33,46 +42,89 @@ class HoneypotController extends Controller $ip = (string) $request->ip(); + // An active (non-safe) method is a real attack attempt; a safe read (GET/HEAD/OPTIONS) is merely + // viewing a decoy. Only an attempt bans, so simply opening a fake page never locks anyone out of + // the real panel (an operator testing the honeypot, or a mistaken click, stays free). + // + // Use getRealMethod() — the RAW REQUEST_METHOD — NOT isMethod()/getMethod(): those honor the + // `_method` body/query field and the X-HTTP-Method-Override header, so an attacker could send a + // real POST spoofed as `_method=PUT` to make isMethod('post') false and dodge the instant-ban + // entirely (the decoys are Route::any, so the override still routes here). Ban any non-safe verb. + $isAttempt = ! in_array($request->getRealMethod(), ['GET', 'HEAD', 'OPTIONS'], true); + + // BAN FIRST, and independently of the audit. The instant-ban IS the protection; it must never be + // skipped because a later step throws on hostile input. Guarded on its own so that even a ban + // failure (e.g. a momentary DB outage) still serves the deception below. + // + // We deliberately do NOT try to spare a "cross-site forged" POST by inspecting the Origin header: + // the header is attacker-controlled, so any Origin-based skip is a trivial UNIVERSAL ban-evasion + // (a scanner just sends a foreign Origin) — strictly worse than the narrow reflected-griefing it + // would prevent. Honeypot bans are time-limited and unban-able from the Threats page. + if ($isAttempt) { + try { + app(BruteforceGuard::class)->banNow($ip, 'honeypot'); + } catch (\Throwable $e) { + report($e); + } + } + + // Audit the probe (best-effort, rate-capped). Fully isolated: a logging failure must not affect + // the ban above or the response — an unhandled throw here would otherwise surface a Laravel 500 + // that UNMASKS the fake. + try { + $this->auditProbe($request, $ip, $isAttempt); + } catch (\Throwable $e) { + report($e); + } + + return $this->deceive($request); + } + + /** Record the probe as an audit event, rate-capped per source IP (see AUDIT_CAP_PER_HOUR). */ + private function auditProbe(Request $request, string $ip, bool $isAttempt): void + { + // Bound audit growth per source IP WITHOUT a check-then-act race: `add` seeds the counter with a + // 1-hour TTL on the first hit only, then the atomic `increment` both counts and returns the new + // total (Redis INCR keeps the TTL). Concurrent hits can at most overshoot by the concurrency + // count — fine for a DoS bound. Past the cap we drop the redundant row (the ban already happened). + $capKey = 'honeypot:audit:'.md5($ip); + Cache::add($capKey, 0, now()->addHour()); + if (Cache::increment($capKey) > self::AUDIT_CAP_PER_HOUR) { + return; + } + + // Attacker-controlled strings are scrubbed to valid UTF-8 before they reach the array-cast + // `meta` column: a raw invalid byte (e.g. a lone 0x80 in the password field or User-Agent) + // would make json_encode throw JsonEncodingException on the cast. mb_scrub replaces ill-formed + // sequences with the Unicode substitute char. + $clean = static fn (?string $v, int $n = 190): string => Str::limit(mb_scrub((string) $v), $n); + $meta = [ - 'ua' => Str::limit((string) $request->userAgent(), 190), + 'ua' => $clean($request->userAgent()), 'method' => $request->method(), - 'query' => Str::limit((string) $request->getQueryString(), 190), + 'query' => $clean($request->getQueryString()), ]; - // Capture the credentials a prober typed into the fake login — their guesses, which are - // threat intel (a legitimate user never reaches a decoy). The field names span the - // impersonated apps: WordPress (log/pwd), phpMyAdmin (pma_username/pma_password), and the - // generic form (username/password). This is the attacker's OWN submission, so logging + - // banning stays keyed to their IP — no reflected-victim risk. + // Capture the credentials a prober typed into the fake login — their guesses, which are threat + // intel (a legitimate user never reaches a decoy). The field names span the impersonated apps: + // WordPress (log/pwd), phpMyAdmin (pma_username/pma_password), and the generic form. $user = $request->input('log') ?? $request->input('pma_username') ?? $request->input('username'); $pass = $request->input('pwd') ?? $request->input('pma_password') ?? $request->input('password'); if (is_string($user) && $user !== '') { - $meta['tried_user'] = Str::limit($user, 120); + $meta['tried_user'] = $clean($user, 120); } if (is_string($pass) && $pass !== '') { - $meta['tried_pass'] = Str::limit($pass, 120); + $meta['tried_pass'] = $clean($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' => $isAttempt ? 'security.honeypot_login' : 'security.honeypot_hit', - 'target' => Str::limit($request->path(), 190), + 'target' => $clean($request->path()), 'ip' => $ip, 'meta' => $meta, ]); - - if ($isAttempt) { - app(BruteforceGuard::class)->banNow($ip, 'honeypot'); - } - - return $this->deceive($request); } /** Pick a convincing fake response for the probed path. */ diff --git a/app/Http/Middleware/DetectHoneytoken.php b/app/Http/Middleware/DetectHoneytoken.php index ad633a2..c375014 100644 --- a/app/Http/Middleware/DetectHoneytoken.php +++ b/app/Http/Middleware/DetectHoneytoken.php @@ -6,6 +6,8 @@ use App\Models\AuditEvent; use App\Services\BruteforceGuard; use Closure; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Str; use Symfony\Component\HttpFoundation\Response; /** @@ -26,6 +28,9 @@ class DetectHoneytoken /** Request BODY keys that plausibly carry a leaked secret. Kept tiny on purpose; NO `password`. */ private const CANDIDATE_KEYS = ['api_key', 'apikey', 'token', 'secret', 'key', 'access_key']; + /** Per-IP cap on honeytoken audit rows per rolling hour (bounds an exempt/rotating source). */ + private const AUDIT_CAP_PER_HOUR = 60; + public function __construct(private BruteforceGuard $guard) {} public function handle(Request $request, Closure $next): Response @@ -81,16 +86,40 @@ class DetectHoneytoken { $ip = (string) $request->ip(); - $this->guard->banNow($ip, 'honeytoken'); + // Ban first, then audit — each guarded independently (mirrors HoneypotController::trap). A write + // failure (DB hiccup, un-run migration) must never turn a honeytoken trip into a 500 that leaks + // this is a real app, nor let a failing audit skip the ban. No per-IP audit cap is needed here: + // BlockBannedIp runs BEFORE this middleware, so a banned IP is 403'd upstream and never reaches + // trip() again — the honeytoken audit cannot be looped. + try { + $this->guard->banNow($ip, 'honeytoken'); + } catch (\Throwable $e) { + report($e); + } - AuditEvent::create([ - 'user_id' => null, - 'actor' => 'system', - 'action' => 'security.honeytoken_used', - 'target' => $canaryName, - 'ip' => $ip, - 'meta' => ['path' => $request->path()], - ]); + try { + // Bound audit growth per source IP WITHOUT a race (add seeds a 1h TTL once, atomic increment + // counts + returns the new total). An EXEMPT IP is never banned (banNow no-ops), so + // BlockBannedIp cannot throttle its repeat trips; and a briefly-stale ban-check cache can let + // a few through before the ban takes hold. Past the cap we drop the row — the ban + 403 still + // happen. Mirrors HoneypotController::auditProbe. + $capKey = 'honeytoken:audit:'.md5($ip); + Cache::add($capKey, 0, now()->addHour()); + if (Cache::increment($capKey) <= self::AUDIT_CAP_PER_HOUR) { + AuditEvent::create([ + 'user_id' => null, + 'actor' => 'system', + 'action' => 'security.honeytoken_used', + 'target' => $canaryName, + 'ip' => $ip, + // path() is derived from the (attacker-controlled) URL — scrub to valid UTF-8 so a + // percent-decoded invalid byte can't make the array-cast meta column's json_encode throw. + 'meta' => ['path' => Str::limit(mb_scrub($request->path()), 190)], + ]); + } + } catch (\Throwable $e) { + report($e); + } return response()->view('errors.blocked', [], 403); } diff --git a/app/Livewire/Threats/Index.php b/app/Livewire/Threats/Index.php index c1ca2dc..173303a 100644 --- a/app/Livewire/Threats/Index.php +++ b/app/Livewire/Threats/Index.php @@ -137,7 +137,7 @@ class Index extends Component // Most active source across all honeypot/token events (null when there is no traffic yet). $topIp = AuditEvent::query() - ->whereIn('action', ['security.honeypot_hit', 'security.honeytoken_used']) + ->whereIn('action', ['security.honeypot_hit', 'security.honeypot_login', 'security.honeytoken_used']) ->whereNotNull('ip') ->selectRaw('ip, COUNT(*) as hits') ->groupBy('ip') diff --git a/app/Services/BruteforceGuard.php b/app/Services/BruteforceGuard.php index a31bd5f..de2a539 100644 --- a/app/Services/BruteforceGuard.php +++ b/app/Services/BruteforceGuard.php @@ -151,14 +151,23 @@ class BruteforceGuard ); Cache::forget('bruteforce:banned:'.$canonical); - AuditEvent::create([ - 'user_id' => null, - 'actor' => 'system', - 'action' => 'auth.ip_banned', - 'target' => $canonical, - 'ip' => $canonical, - 'meta' => ['reason' => $reason], - ]); + // Dedup the ban-audit: the honeypot calls banNow on EVERY decoy POST, so without this a prober + // looping the fake login would write one auth.ip_banned row per request. Emit the audit at most + // once per IP+reason per 60s (Cache::add is atomic); the upsert above stays idempotent. The key + // includes $reason so a DISTINCT-reason ban of the same IP (e.g. a honeytoken trip after a + // honeypot ban) is still audited. A short 60s TTL (matching record()'s ban dedup) collapses the + // rapid scanner flood WITHOUT masking a legitimate later re-ban — e.g. after an operator unban, + // which is minutes of human time later, well past this window (so it is not tied to unban()). + if (Cache::add('bruteforce:banaudit:'.$canonical.':'.$reason, 1, 60)) { + AuditEvent::create([ + 'user_id' => null, + 'actor' => 'system', + 'action' => 'auth.ip_banned', + 'target' => $canonical, + 'ip' => $canonical, + 'meta' => ['reason' => $reason], + ]); + } return true; } diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index a62dfa0..154f129 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -101,7 +101,9 @@ services: - clusev caddy: - image: caddy:2-alpine # pin by @sha256 digest before the first prod push + # Pinned by digest (supply-chain integrity): a mutable tag could silently swap the image under us. + # Bump: docker buildx imagetools inspect caddy:2-alpine --format '{{.Manifest.Digest}}' + image: caddy:2-alpine@sha256:5f5c8640aae01df9654968d946d8f1a56c497f1dd5c5cda4cf95ab7c14d58648 restart: unless-stopped ports: - "${APP_PORT:-80}:80" @@ -130,7 +132,8 @@ services: - clusev mariadb: - image: mariadb:11 + # Pinned by digest (see caddy). Bump: docker buildx imagetools inspect mariadb:11 --format '{{.Manifest.Digest}}' + image: mariadb:11@sha256:efb4959ef2c835cd735dbc388eb9ad6aab0c78dd64febcd51bc17481111890c4 restart: unless-stopped environment: MARIADB_DATABASE: "${DB_DATABASE:-clusev}" @@ -149,7 +152,8 @@ services: # no host ports in prod — DB is reachable only on the internal clusev network redis: - image: redis:7-alpine + # Pinned by digest (see caddy). Bump: docker buildx imagetools inspect redis:7-alpine --format '{{.Manifest.Digest}}' + image: redis:7-alpine@sha256:6ab0b6e7381779332f97b8ca76193e45b0756f38d4c0dcda72dbb3c32061ab99 restart: unless-stopped command: redis-server --appendonly yes --requirepass "${REDIS_PASSWORD:?set REDIS_PASSWORD in .env}" volumes: diff --git a/docker/release/clusev-release.sh b/docker/release/clusev-release.sh index 8b1df9b..b0d5553 100755 --- a/docker/release/clusev-release.sh +++ b/docker/release/clusev-release.sh @@ -73,7 +73,10 @@ _do_stage() { # target *) url="$origin" ;; # local/file remote (tests): push directly esac - if ! git -C "$PROJ" push "$url" "$BRANCH" >/dev/null 2>&1 || ! git -C "$PROJ" push "$url" "$newtag" >/dev/null 2>&1; then + # Atomic: the branch commit and its tag land together, or neither does. A non-atomic two-push (the + # old form) could advance origin/$BRANCH and then fail the tag push, leaving an UNTAGGED release + # commit on the remote that _rollback (reset --hard origin/$BRANCH) can no longer undo. + if ! git -C "$PROJ" push --atomic "$url" "$BRANCH" "$newtag" >/dev/null 2>&1; then echo "push-failed" >&2; _rollback "$newtag"; return 1 fi printf '%s' "$newtag" diff --git a/resources/views/livewire/settings/index.blade.php b/resources/views/livewire/settings/index.blade.php index 61a4c91..59138ff 100644 --- a/resources/views/livewire/settings/index.blade.php +++ b/resources/views/livewire/settings/index.blade.php @@ -38,7 +38,7 @@ class="inline-flex min-h-9 items-center gap-1.5 rounded-md border border-line px-3 font-mono text-[11px] text-ink-2 transition-colors hover:border-accent/40 hover:text-accent-text"> {{ __('onboarding.relaunch') }} - {{ __('settings.role_admin') }} + {{ $u->role?->label() ?? __('settings.role_admin') }} diff --git a/resources/views/livewire/threats/index.blade.php b/resources/views/livewire/threats/index.blade.php index 41fd7aa..3081bbd 100644 --- a/resources/views/livewire/threats/index.blade.php +++ b/resources/views/livewire/threats/index.blade.php @@ -5,8 +5,11 @@

{{ __('threats.eyebrow') }}

{{ __('threats.title') }}

- - {{ __('threats.probes_pill', ['count' => $probes_24h]) }} + {{-- Escalate on ANY hostile activity: red if someone tried the fake login, amber for mere + probes, green only when truly quiet. Count = all decoy hits so the pill never reads a + calm "0 probes" while login attempts are landing. --}} + + {{ __('threats.probes_pill', ['count' => $probes_24h + $login_attempts_24h]) }} diff --git a/resources/views/livewire/wireguard/index.blade.php b/resources/views/livewire/wireguard/index.blade.php index b31f388..d3ca331 100644 --- a/resources/views/livewire/wireguard/index.blade.php +++ b/resources/views/livewire/wireguard/index.blade.php @@ -289,7 +289,7 @@ {{ __('wireguard.down_up', ['rx' => $fmtBytes($peer['rx']), 'tx' => $fmtBytes($peer['tx'])]) }}
- + {{ __('wireguard.remove') }}
diff --git a/scripts/set-repository-url.sh b/scripts/set-repository-url.sh index e6e176d..33edcfe 100755 --- a/scripts/set-repository-url.sh +++ b/scripts/set-repository-url.sh @@ -9,6 +9,11 @@ proj="${1:?project dir required}" envfile="${2:?env file required}" url="$(git -C "$proj" remote get-url origin 2>/dev/null || true)" +# Strip any inline credentials (https://user:token@host → https://host). A clone origin may carry an +# embedded push token (the release flow uses https://oauth2:${token}@host); writing that verbatim would +# leak the secret into .env — and CLUSEV_REPOSITORY surfaces in the Versions/update UI. Keep it clean. +# [^/]* is greedy so it strips up to the LAST '@' before the path — handles an '@' inside the password. +url="$(printf '%s' "$url" | sed -E 's#^(https?://)[^/]*@#\1#')" url="${url%.git}" # normalise — the API host is the same with or without .git url="${url%/}" # drop a trailing slash [ -n "$url" ] || exit 0 # no origin (e.g. tarball install) → leave .env untouched diff --git a/tests/Feature/HoneypotSubmitTest.php b/tests/Feature/HoneypotSubmitTest.php index d7ee205..5b40e40 100644 --- a/tests/Feature/HoneypotSubmitTest.php +++ b/tests/Feature/HoneypotSubmitTest.php @@ -4,7 +4,10 @@ namespace Tests\Feature; use App\Models\AuditEvent; use App\Models\BannedIp; +use App\Services\BruteforceGuard; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Schema; use Tests\TestCase; class HoneypotSubmitTest extends TestCase @@ -68,4 +71,140 @@ class HoneypotSubmitTest extends TestCase // …but the ban still keeps them off the REAL login (BlockBannedIp -> 403 for a banned guest). $this->call('GET', '/login', [], [], [], ['REMOTE_ADDR' => '203.0.113.79'])->assertStatus(403); } + + public function test_probe_with_invalid_utf8_does_not_500_still_bans_and_audits(): void + { + config(['clusev.honeypot.enabled' => true]); + + // A lone 0x80/0xFF in the User-Agent (which bypasses TrimStrings) is invalid UTF-8. Un-scrubbed + // it makes the array-cast `meta` column's json_encode throw — an HTTP 500 that unmasks the fake + // AND aborts the ban, letting an attacker evade the trap by appending a bad byte. Must not happen. + $res = $this->call( + 'POST', '/wp-login.php', + ['log' => 'admin', 'pwd' => 'Hunter2!'], + [], [], + ['HTTP_USER_AGENT' => "scanner/\x80\xFF", 'REMOTE_ADDR' => '203.0.113.90'] + ); + + $res->assertStatus(200); + $res->assertSee('WordPress', false); + + $e = AuditEvent::where('action', 'security.honeypot_login')->where('ip', '203.0.113.90')->latest('id')->first(); + $this->assertNotNull($e); // create() did NOT throw + $this->assertNotFalse(json_encode($e->meta)); // stored meta is valid UTF-8 / re-encodable + $this->assertSame('admin', $e->meta['tried_user']); + $this->assertTrue(BannedIp::where('ip', '203.0.113.90')->exists()); + } + + public function test_a_forged_origin_header_does_not_evade_the_ban(): void + { + config(['clusev.honeypot.enabled' => true]); + + // The Origin header is attacker-controlled, so the ban MUST NOT depend on it. A scanner sending a + // foreign Origin (the obvious evasion attempt) is banned exactly like one that sends none — the + // POST is still a login attempt on a decoy. Regression guard for the removed Origin-based skip. + $this->call( + 'POST', '/wp-login.php', + ['log' => 'admin', 'pwd' => 'x'], + [], [], + ['HTTP_ORIGIN' => 'https://evil.example.test', 'REMOTE_ADDR' => '203.0.113.91'] + )->assertStatus(200); + + $this->assertTrue(AuditEvent::where('action', 'security.honeypot_login')->where('ip', '203.0.113.91')->exists()); + $this->assertTrue(BannedIp::where('ip', '203.0.113.91')->exists()); + } + + public function test_ban_still_lands_when_the_audit_write_fails(): void + { + config(['clusev.honeypot.enabled' => true]); + + // The instant-ban IS the protection and must NOT be gated by the audit. Drop audit_events so + // EVERY AuditEvent::create throws (the honeypot row AND banNow's own auth.ip_banned row), then + // confirm the POST is still banned and still gets the fake body — the ban write (banned_ips) runs + // before any audit and both are independently guarded, so a broken audit never disarms the trap. + Schema::drop('audit_events'); + + $this->call('POST', '/wp-login.php', ['log' => 'admin', 'pwd' => 'x'], [], [], ['REMOTE_ADDR' => '203.0.113.92']) + ->assertStatus(200); + + $this->assertTrue(BannedIp::where('ip', '203.0.113.92')->exists()); + } + + public function test_repeated_login_attempts_write_a_single_ban_audit(): void + { + config(['clusev.honeypot.enabled' => true]); + + // The honeypot bans on EVERY decoy POST; without the ban-audit dedup, a prober looping the fake + // login would flood auth.ip_banned. Expect exactly one ban-audit row for the source IP. + foreach (range(1, 3) as $ignored) { + $this->call('POST', '/wp-login.php', ['log' => 'a', 'pwd' => 'b'], [], [], ['REMOTE_ADDR' => '203.0.113.93']) + ->assertStatus(200); + } + + $this->assertSame(1, AuditEvent::where('action', 'auth.ip_banned')->where('ip', '203.0.113.93')->count()); + $this->assertTrue(BannedIp::where('ip', '203.0.113.93')->exists()); + } + + public function test_audit_cap_stops_new_rows_but_still_bans(): void + { + config(['clusev.honeypot.enabled' => true]); + $ip = '203.0.113.94'; + + // Simulate this IP having already blown past the per-hour audit cap (the decoys run without + // BlockBannedIp, so one source can loop wildcard paths forever). Past the cap: no NEW audit row, + // but the ban still lands — DoS-bounded logging without weakening protection or the deception. + Cache::put('honeypot:audit:'.md5($ip), 1000, now()->addHour()); + + $this->call('POST', '/wp-login.php', ['log' => 'a', 'pwd' => 'b'], [], [], ['REMOTE_ADDR' => $ip]) + ->assertStatus(200); + + // No NEW probe-audit row past the cap (the ban itself still writes ONE auth.ip_banned row, so we + // assert on the honeypot action specifically — not every event for this IP). + $this->assertSame(0, AuditEvent::where('action', 'security.honeypot_login')->where('ip', $ip)->count()); + $this->assertTrue(BannedIp::where('ip', $ip)->exists()); + } + + public function test_a_method_override_does_not_evade_the_ban(): void + { + config(['clusev.honeypot.enabled' => true]); + + // A real POST spoofed as PUT via the `_method` body field must NOT dodge the ban — the trap keys + // off the RAW wire method (getRealMethod), not getMethod()/isMethod() which honor the override. + $this->call('POST', '/wp-login.php', ['_method' => 'PUT', 'log' => 'admin', 'pwd' => 'x'], [], [], ['REMOTE_ADDR' => '203.0.113.95']) + ->assertStatus(200); + $this->assertTrue(BannedIp::where('ip', '203.0.113.95')->exists()); + $this->assertTrue(AuditEvent::where('action', 'security.honeypot_login')->where('ip', '203.0.113.95')->exists()); + + // Same via the X-HTTP-Method-Override header vector. + $this->call('POST', '/wp-login.php', ['log' => 'admin', 'pwd' => 'x'], [], [], ['HTTP_X_HTTP_METHOD_OVERRIDE' => 'PATCH', 'REMOTE_ADDR' => '203.0.113.96']) + ->assertStatus(200); + $this->assertTrue(BannedIp::where('ip', '203.0.113.96')->exists()); + } + + public function test_distinct_reason_bans_of_one_ip_are_each_audited(): void + { + $guard = app(BruteforceGuard::class); + + // Same reason within the 60s dedup window collapses to one audit; a DISTINCT reason is still + // audited (the dedup key is per IP+reason). Expect 2 rows: one honeypot, one honeytoken. + $guard->banNow('203.0.113.97', 'honeypot'); + $guard->banNow('203.0.113.97', 'honeypot'); + $guard->banNow('203.0.113.97', 'honeytoken'); + + $this->assertSame(2, AuditEvent::where('action', 'auth.ip_banned')->where('ip', '203.0.113.97')->count()); + } + + public function test_a_reban_after_the_dedup_window_is_audited(): void + { + $guard = app(BruteforceGuard::class); + $ip = '203.0.113.100'; + + // The ban-audit dedup is a short 60s window (not the full ban time), so a legitimate later re-ban + // — e.g. after an operator unban, minutes later — is audited again rather than silently swallowed. + $guard->banNow($ip, 'honeypot'); + $this->travel(61)->seconds(); + $guard->banNow($ip, 'honeypot'); + + $this->assertSame(2, AuditEvent::where('action', 'auth.ip_banned')->where('ip', $ip)->count()); + } } diff --git a/tests/Feature/HoneypotTest.php b/tests/Feature/HoneypotTest.php index eb7e89f..101e498 100644 --- a/tests/Feature/HoneypotTest.php +++ b/tests/Feature/HoneypotTest.php @@ -9,6 +9,7 @@ use App\Models\User; use App\Services\BruteforceGuard; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Schema; use Tests\TestCase; class HoneypotTest extends TestCase @@ -108,6 +109,37 @@ class HoneypotTest extends TestCase ); } + public function test_honeytoken_trip_still_bans_and_403s_when_the_audit_write_fails(): void + { + $canary = config('clusev.honeypot.canaries.api_key'); + + // The ban + 403 must survive a broken audit table (DB hiccup / un-run migration): dropping + // audit_events makes every AuditEvent::create throw (banNow's auth.ip_banned AND the honeytoken + // row), yet the trip must NOT become a 500 that leaks this is a real app, and the ban (a separate + // banned_ips write, run first + guarded) must still land. + Schema::drop('audit_events'); + + $this->fromIp('POST', '/broadcasting/auth', self::ATTACKER_IP, ['api_key' => $canary]) + ->assertStatus(403); + + $this->assertTrue(BannedIp::where('ip', self::ATTACKER_IP)->exists()); + } + + public function test_honeytoken_audit_is_capped_per_ip_but_still_bans(): void + { + $canary = config('clusev.honeypot.canaries.api_key'); + $ip = '203.0.113.98'; + + // An exempt/rotating source could otherwise loop honeytoken trips to grow audit_events. Over the + // per-hour cap: no NEW honeytoken audit row, but the ban + 403 still land. + Cache::put('honeytoken:audit:'.md5($ip), 1000, now()->addHour()); + + $this->fromIp('POST', '/broadcasting/auth', $ip, ['api_key' => $canary])->assertStatus(403); + + $this->assertSame(0, AuditEvent::where('action', 'security.honeytoken_used')->where('ip', $ip)->count()); + $this->assertTrue(BannedIp::where('ip', $ip)->exists()); + } + public function test_login_post_with_password_equal_to_canary_does_not_ban(): void { // `password` is no longer scanned — a login carrying a value that happens to equal a canary