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">
{{ __('threats.eyebrow') }}