fix(analytics): hide container tooling user agents by default (#12335)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-10-26 19:27:22 +01:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 4559d21b4c
commit 0a9019f738
6 changed files with 148 additions and 6 deletions
+1 -1
View File
@@ -19,7 +19,7 @@
- SSH retries replay the full command batch. Only exit code 255 is an SSH transport failure; other exit codes come from the remote command, so do not retry them. Keep batches safe to run twice (for example `docker rm -f <name> || true` before `docker run --name <name>`).
- Do not put `pkill -f "playwright run-server"` in the same shell command as other work; the pattern matches that shell and kills it. Run it as its own command.
- Redirect browser test output to a file (`> /tmp/x.log 2>&1`); piping it (`| tail`) hangs because the Playwright server keeps the pipe open.
- Browser `click()`/`assertSee()` wait forever when nothing matches, so a bad selector hangs the run instead of failing it. `click('Text')` can match a hidden element first (use `button:visible:has-text("Text")`); `:text-is()` on an element with child elements and `assertSee()` text with double quotes never match. Prefer plain CSS selectors.
- Browser `click()`/`assertSee()` wait forever when nothing matches, so a bad selector hangs the run instead of failing it. `click('Text')` can match a hidden element first (use `button:visible:has-text("Text")`); `:text-is()` on an element with child elements and `assertSee()` text with double quotes never match. Prefer plain CSS selectors. `click()` also hung on a button far down the long `/analytics` page; click it with `$page->script("document.querySelector('...').click()")` there.
- A new worktree has no `vendor/` or `public/build/`. Run `composer install --ignore-platform-req=ext-zip` on the host and copy `public/build` from the main checkout before page-rendering tests; otherwise they fail with a Vite manifest error.
- Never run `php artisan test` inside a dev container (`./scripts/dev exec` or `docker exec coolify-dev-*`). The container sets `DB_CONNECTION=pgsql` as a real environment variable, which wins over `phpunit.xml`, so `RefreshDatabase` runs `migrate:fresh` on the dev database and wipes it. Run tests on the host only.
- The helper image runs `docker compose` for deployments and for the Coolify upgrade itself. Before you bump its Docker CLI or Compose, test them against the oldest supported Docker Engine (for example a `docker:<version>-dind` container); newer clients can reject data from older daemons.
+11
View File
@@ -5871,6 +5871,17 @@ function deviceLabel(?string $device): string
};
}
/**
* Whether a raw User-Agent comes from container tooling (Docker CLI and Engine,
* BuildKit, containerd, Podman, Skopeo) instead of a person or a bot. The
* analytics views hide these rows behind a toggle: image pulls and builds are
* normal, but the raw strings look like an attack or a leak of internal data.
*/
function isContainerToolingUserAgent(?string $userAgent): bool
{
return preg_match('#^(docker|docker-client|buildkit|containerd|podman|skopeo|cri-o)/#i', trim((string) $userAgent)) === 1;
}
/**
* Prepare aggregated `city` breakdown rows (`CC|subdivision|City`, from
* TrafficAnalyticsAggregator::breakdowns(), not raw Sentinel rows) for display,
+19 -3
View File
@@ -2,7 +2,8 @@
// its sort values as JSON in `data-row`. Rows are moved in the DOM, so keyboard
// and screen-reader order match the visual order. A Livewire morph puts the rows
// back in server order (they are keyed) and can add or remove rows, so the rows
// are sorted again after it.
// are sorted again after it. Rows marked `data-hidable` (container tooling user
// agents) are left out of the sort and the pages until `showHidden` is on.
function compareValues(a, b) {
if (typeof a === 'number' && typeof b === 'number') {
return a - b;
@@ -23,6 +24,8 @@ function trafficTable({ per = 10, key = 'requests', dir = 'desc' } = {}) {
key,
dir,
tick: 0,
showHidden: false,
hiddenCount: 0,
init() {
this.resort();
@@ -65,9 +68,19 @@ function trafficTable({ per = 10, key = 'requests', dir = 'desc' } = {}) {
this.resort();
},
toggleHidden() {
this.showHidden = !this.showHidden;
this.page = 0;
this.resort();
},
resort() {
const sign = this.dir === 'asc' ? 1 : -1;
const rows = [...this.$root.querySelectorAll('[data-row]')].map((el, index) => {
const all = [...this.$root.querySelectorAll('[data-row]')];
const hidable = all.filter((el) => 'hidable' in el.dataset);
hidable.forEach((el) => ranks.delete(el));
this.hiddenCount = hidable.length;
const rows = all.filter((el) => this.showHidden || !('hidable' in el.dataset)).map((el, index) => {
let values = {};
try {
values = JSON.parse(el.dataset.row) || {};
@@ -97,7 +110,10 @@ function trafficTable({ per = 10, key = 'requests', dir = 'desc' } = {}) {
shown(el) {
this.tick;
const rank = ranks.get(el) ?? 0;
const rank = ranks.get(el);
if (rank === undefined) {
return false;
}
return rank >= this.page * this.per && rank < (this.page + 1) * this.per;
},
@@ -2,7 +2,8 @@
One breakdown dimension rendered as a sortable, paginated settings-section. Handles the
per-dimension presentation: referrers get a DuckDuckGo favicon + an openable
external link (rel="noopener noreferrer nofollow", new tab), devices are mapped
to friendly capitalized labels, and Sentinel's `__other__` overflow bucket reads
to friendly capitalized labels, container tooling user agents (Docker, BuildKit,
…) are hidden behind a toggle, and Sentinel's `__other__` overflow bucket reads
as "Other". Expects `$dimension`, `$label`, `$rows` in scope; optional `$helper`.
@param string $dimension Sentinel dimension key (referer, device, agent, …)
@@ -32,6 +33,7 @@
@php
$value = (string) ($row['value'] ?? '');
$isOther = $value === '__other__';
$isHidable = $dimension === 'useragent' && isContainerToolingUserAgent($value);
$host = ! $isOther && $dimension === 'referer' ? refererHost($value) : null;
$display = $isOther
? 'Other'
@@ -46,7 +48,7 @@
$width = min(100, round(($requests / $maxRequests) * 100, 1));
@endphp
<div wire:key="analytics-{{ $dimension }}-{{ md5($value) }}"
data-row="{{ json_encode(['name' => $display, 'requests' => $requests, 'bytesOut' => $bytesOut]) }}" @if ($isOther) data-pinned @endif
data-row="{{ json_encode(['name' => $display, 'requests' => $requests, 'bytesOut' => $bytesOut]) }}" @if ($isOther) data-pinned @endif @if ($isHidable) data-hidable @endif
x-show="shown($el)"
class="flex min-h-11 items-center gap-3 border-t border-neutral-200 px-4 py-2 transition-colors hover:bg-neutral-50 dark:border-white/[0.07] dark:hover:bg-white/[0.03]">
@if ($dimension === 'referer' && $host)
@@ -66,6 +68,12 @@
</div>
@endforeach
</div>
<div x-show="hiddenCount > 0" x-cloak
class="flex items-center border-t border-neutral-200 px-4 py-2 dark:border-white/[0.07]">
<button type="button" @click="toggleHidden()"
class="text-[11px] font-medium text-neutral-500 transition-colors hover:text-black dark:text-fg-faint dark:hover:text-fg"
x-text="showHidden ? 'Hide container tooling' : `Show ${hiddenCount} container tooling ${hiddenCount === 1 ? 'agent' : 'agents'} (Docker, BuildKit, …)`"></button>
</div>
@include('livewire.traffic._pager')
</div>
@endif
@@ -73,3 +73,25 @@ it('distinguishes a missing response status from numeric values in other dimensi
'overflow status' => ['status', '__other__', 'Other'],
'zero in another dimension' => ['path', '0', '0'],
]);
it('detects container tooling user agents', function (?string $userAgent, bool $expected) {
expect(isContainerToolingUserAgent($userAgent))->toBe($expected);
})->with([
'buildkit' => ['buildkit/v0.33', true],
'docker with compose upstream' => ['docker/29.6.2 go/go1.26.5 git-commit/3d80467 kernel/6.1.0-51-amd64 os/linux arch/amd64 UpstreamClient(compose/v5.3.1)', true],
'docker-client' => ['docker-client/1.0', true],
'containerd' => ['containerd/2.3.3+unknown', true],
'podman' => ['podman/5.4.0', true],
'skopeo' => ['skopeo/1.18.0', true],
'cri-o' => ['cri-o/1.32.1', true],
'mixed case' => ['Docker/29.6.2', true],
'leading whitespace' => [' buildkit/v0.33', true],
'docker without slash' => ['docker', false],
'docker as a later token' => ['curl/8.5.0 docker/29.6.2', false],
'docker prefix of another name' => ['dockerd/29.6.2', false],
'firefox' => ['Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:156.0) Gecko/20100101 Firefox/156.0', false],
'curl' => ['curl/8.5.0', false],
'overflow bucket' => ['__other__', false],
'empty' => ['', false],
'null' => [null, false],
]);
@@ -0,0 +1,85 @@
<?php
use App\Services\SentinelTrafficClient;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
class FakeBrowserUserAgentTrafficClient extends SentinelTrafficClient
{
protected function raw(string $url): string
{
$responses = [
'/traffic/apps' => [],
'/traffic/overview' => [
'requests' => 900, 'bytes_in' => 5000, 'bytes_out' => 12000,
'status' => ['s2xx' => 900, 's3xx' => 0, 's4xx' => 0, 's5xx' => 0],
'latency' => ['p50' => 12.5, 'p95' => 45.2, 'p99' => 90.1],
'unique_visitors' => 320,
],
'/breakdown/useragent' => [
['value' => 'Mozilla/5.0 Firefox/156.0', 'requests' => 654, 'bytes_out' => 1000],
['value' => 'buildkit/v0.33', 'requests' => 138, 'bytes_out' => 0],
['value' => 'docker/29.6.2 go/go1.26.5 os/linux', 'requests' => 63, 'bytes_out' => 500],
],
];
foreach ($responses as $needle => $response) {
if (str_contains($url, $needle)) {
return json_encode($response);
}
}
return '{}';
}
}
beforeEach(function () {
config()->set('app.maintenance.store', 'array');
$this->stack = seedBrowserResourceStack();
$server = $this->stack['server'];
$server->settings->is_traffic_analytics_enabled = true;
$server->settings->save();
app()->bind(SentinelTrafficClient::class, fn () => new FakeBrowserUserAgentTrafficClient($server));
});
/**
* The names of the visible user agent rows.
*/
function visibleUserAgentNames(mixed $page): array
{
return $page->script(<<<'JS'
[...document.querySelectorAll('#analytics-useragent-section [data-row]')]
.filter((row) => getComputedStyle(row).display !== 'none')
.map((row) => JSON.parse(row.dataset.row).name)
JS);
}
it('hides container tooling user agents until the toggle is selected', function () {
loginAndSkipBoarding();
$page = visit('/analytics');
$toggle = '#analytics-useragent-section button[x-text]';
$page->assertSeeIn($toggle, 'Show 2 container tooling agents');
expect(visibleUserAgentNames($page))->toBe(['Mozilla/5.0 Firefox/156.0']);
$page->script("document.querySelector('{$toggle}').click()");
$page->assertSeeIn($toggle, 'Hide container tooling')
->wait(0.5);
expect(visibleUserAgentNames($page))->toBe([
'Mozilla/5.0 Firefox/156.0',
'buildkit/v0.33',
'docker/29.6.2 go/go1.26.5 os/linux',
]);
$page->script("document.querySelector('{$toggle}').click()");
$page->assertSeeIn($toggle, 'Show 2 container tooling agents')
->wait(0.5)
->assertNoJavaScriptErrors()
->screenshot(filename: 'analytics-useragent-tooling');
expect(visibleUserAgentNames($page))->toBe(['Mozilla/5.0 Firefox/156.0']);
});